Building AI Agents That Actually Work: Tool Use, Memory, and Reliability in Production
Move beyond demo agents to production-grade AI systems. Covers ReAct vs plan-and-execute architecture selection, tool schema design and validation, memory layers, reliability patterns, observability for agent chains, cost control, and when the agent abstraction is the wrong choice entirely.
Most agent failures are not model failures. The model is doing its job: reasoning from the context you gave it, calling the tools you described, using the memory you surfaced. The failures are engineering failures. Bad tool schemas that the model has to guess around. Memory layers that surface stale context. No human-in-the-loop gate before irreversible actions. Loops that run forever because there is no turn budget.
This article is a production guide, not a tutorial. It assumes you have already shipped a basic agent and are now trying to make it reliable enough to trust.
Choosing Your Agent Architecture: ReAct vs Plan-and-Execute
The architecture you choose determines everything downstream: how you handle failures, how you design tools, and how predictable the cost per session will be.
ReAct (Reasoning + Acting) is the default pattern. The model alternates between thinking and acting in a single loop: observe the state, reason about what to do next, execute a tool, observe the result, repeat. The loop terminates when the model produces a final answer.
interface AgentStep {
thought: string;
action: { tool: string; input: Record<string, unknown> } | null;
observation: string | null;
}
async function reactLoop(
goal: string,
tools: Tool[],
maxSteps = 15
): Promise<string> {
const messages: Message[] = [
{ role: "system", content: buildSystemPrompt(tools) },
{ role: "user", content: goal },
];
for (let step = 0; step < maxSteps; step++) {
const response = await anthropic.messages.create({
model: "claude-opus-4-5",
max_tokens: 1024,
messages,
tools: tools.map(toAnthropicTool),
});
if (response.stop_reason === "end_turn") {
return extractText(response.content);
}
if (response.stop_reason === "tool_use") {
messages.push({ role: "assistant", content: response.content });
const toolResults = await executeToolCalls(response.content, tools);
messages.push({ role: "user", content: toolResults });
}
}
throw new Error(`Agent exceeded max steps (${maxSteps})`);
}
ReAct works well when the task is exploratory: the model does not know upfront what steps are needed. It is flexible and handles ambiguity well. The downside is cost unpredictability. A session that should take four steps can balloon to fifteen if the model gets confused or the tools return ambiguous results.
Plan-and-execute separates the planning phase from the execution phase. In the first pass, the model produces a structured plan: a sequence of steps with expected inputs and outputs. A separate executor then runs each step, calling tools according to the plan rather than letting the model decide turn by turn.
interface ExecutionPlan {
steps: Array<{
id: string;
description: string;
tool: string;
expectedInput: Record<string, unknown>;
dependsOn: string[];
}>;
}
async function planAndExecute(goal: string, tools: Tool[]): Promise<string> {
// Phase 1: generate the plan
const planResponse = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "system", content: buildPlannerPrompt(tools) },
{ role: "user", content: goal },
],
response_format: { type: "json_object" },
});
const plan: ExecutionPlan = JSON.parse(planResponse.choices[0].message.content!);
// Phase 2: execute steps in dependency order
const results: Record<string, unknown> = {};
for (const step of topoSort(plan.steps)) {
const resolvedInput = resolveInputs(step.expectedInput, results);
results[step.id] = await executeTool(step.tool, resolvedInput, tools);
}
// Phase 3: synthesize final answer
return synthesize(goal, plan, results);
}
Plan-and-execute is better when the task is well-defined, the steps are largely predictable, and you want cost ceilings. You know upfront how many tool calls will happen. You can validate the plan before executing it, which is where human-in-the-loop fits naturally.
The tradeoff in practice:
| Dimension | ReAct | Plan-and-Execute |
|---|---|---|
| Task type | Exploratory, ambiguous | Structured, well-defined |
| Cost per run | Unpredictable (±5x) | Bounded, predictable |
| Failure recovery | Step-level | Plan-level replan |
| Human review point | Hard to inject cleanly | Natural gate between phases |
| Model requirement | Strong reasoning | Strong planning |
| Implementation complexity | Low | Medium |
A reasonable default: start with ReAct, add a maxSteps budget, and switch to plan-and-execute when you observe that most sessions follow similar step patterns and cost variance is a problem.
Tool Design: The Schema Is Your API
Tool schemas are the API between your code and the model. A bad schema forces the model to guess, which produces incorrect arguments. A good schema is self-documenting enough that the model rarely makes mistakes.
Three rules that actually matter in production:
Rule 1: Descriptions carry more weight than parameter names. The model reads descriptions, not just names. “Get user by ID” is worse than “Fetch a user record by their UUID. Returns 404 if not found. Use this when you have a userId from a prior step and need the full profile including subscription status.”
Rule 2: Be explicit about what the tool cannot do. Adding negative constraints (“this tool does not support pagination; use searchUsers instead for queries that might return multiple results”) prevents the model from using the wrong tool in contexts where it could plausibly make sense.
Rule 3: Validate inputs before execution, not after. Use Zod or another schema validator at the tool boundary, and return structured error messages the model can act on, not stack traces.
import { z } from "zod";
import zodToJsonSchema from "zod-to-json-schema";
function defineTool<TInput extends z.ZodTypeAny>(config: {
name: string;
description: string;
inputSchema: TInput;
execute: (input: z.infer<TInput>) => Promise<unknown>;
}): Tool {
return {
name: config.name,
description: config.description,
parameters: zodToJsonSchema(config.inputSchema),
execute: async (rawInput: unknown) => {
const parsed = config.inputSchema.safeParse(rawInput);
if (!parsed.success) {
// Return a structured error the model can reason about
return {
error: "invalid_input",
issues: parsed.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
};
}
return config.execute(parsed.data);
},
};
}
const getUserTool = defineTool({
name: "get_user",
description:
"Fetch a user record by UUID. Returns the full profile including subscription tier, " +
"account status, and creation date. Returns null if the user does not exist. " +
"Use this when you have a userId from a previous step. " +
"Do not use this to search by email — use search_users instead.",
inputSchema: z.object({
userId: z.string().uuid().describe("The user's UUID"),
}),
execute: async ({ userId }) => db.users.findById(userId),
});
One pattern worth calling out: tool result size. A tool that returns a full database row with 40 fields when the model only needs two wastes context budget and increases the chance the model gets confused by irrelevant data. Return projections, not raw records. Keep tool outputs under 500 tokens wherever possible.
Memory: Three Layers, Three Problems
Agent memory is not a single problem. There are three distinct layers, and they need different storage strategies.
Conversation memory is everything in the current session. The naive approach is to keep appending messages. This works until the context window fills and you start truncating important context from the beginning. A sliding window keeps recent messages but loses early context. Summarization compresses old turns into a dense summary that stays in the window.
async function buildContextWindow(
sessionId: string,
newMessage: Message,
maxTokens: number
): Promise<Message[]> {
const history = await db.sessions.getMessages(sessionId);
const allMessages = [...history, newMessage];
const tokenCount = estimateTokens(allMessages);
if (tokenCount <= maxTokens * 0.8) {
return allMessages;
}
// Summarize older messages, keep recent verbatim
const [old, recent] = splitAt(allMessages, Math.floor(allMessages.length * 0.6));
const summary = await summarize(old);
return [
{ role: "system", content: `Earlier conversation summary: ${summary}` },
...recent,
];
}
Episodic memory captures facts learned during a session that should persist across sessions. “The user prefers JSON responses.” “The account ID for Acme Corp is acc_8843.” “The last deployment failed due to a missing env var.” These are discrete facts that should be retrievable in a future session without replaying the entire history.
interface EpisodicMemory {
id: string;
sessionId: string;
userId: string;
content: string;
embedding: number[];
createdAt: Date;
importance: "low" | "medium" | "high";
}
async function recallRelevantMemories(
userId: string,
currentMessage: string,
k = 5
): Promise<EpisodicMemory[]> {
const queryEmbedding = await embed(currentMessage);
return vectorStore.query({
filter: { userId },
vector: queryEmbedding,
topK: k,
minScore: 0.75,
});
}
async function maybeStoreMemory(
userId: string,
sessionId: string,
text: string
): Promise<void> {
// Ask the model to classify whether this is worth storing
const classification = await classifyMemoryImportance(text);
if (classification.importance === "low") return;
const embedding = await embed(text);
await vectorStore.upsert({
id: crypto.randomUUID(),
sessionId,
userId,
content: text,
embedding,
importance: classification.importance,
createdAt: new Date(),
});
}
Semantic memory is structured knowledge: your product’s domain model, user account state, configuration. This is not something the agent builds up over time, it is data you surface from your existing systems. The agent should pull semantic memory through tools, not try to hold it in the conversation context.
The common mistake is mixing all three. When everything is “memory,” you end up with retrieval queries that return episodic facts when the model needs account data, or surface old conversation summaries when a fresh tool call would be more accurate.
Reliability: The Patterns That Actually Matter
Retries Are Not Simple
Not all agent failures are safe to retry. A tool that creates a record, sends an email, or charges a card is not idempotent. Retrying it naively duplicates side effects.
Classify your tools at definition time:
type ToolSafety = "read_only" | "idempotent" | "side_effect";
const getWeather = defineTool({
name: "get_weather",
safety: "read_only", // always safe to retry
// ...
});
const updateRecord = defineTool({
name: "update_record",
safety: "idempotent", // safe to retry with same inputs
// ...
});
const sendEmail = defineTool({
name: "send_email",
safety: "side_effect", // do not retry without explicit dedup logic
// ...
});
async function executeWithRetry(
tool: Tool,
input: unknown,
attempt = 1
): Promise<unknown> {
try {
return await tool.execute(input);
} catch (err) {
if (tool.safety === "side_effect") {
throw err; // never auto-retry side-effectful tools
}
if (attempt >= 3) throw err;
await sleep(exponentialBackoff(attempt));
return executeWithRetry(tool, input, attempt + 1);
}
}
Human-in-the-Loop as a First-Class Gate
The cleanest way to add human review is at the plan boundary in plan-and-execute, or before specific tool categories in ReAct. The gate is not a UI affordance bolted on later, it is a typed pause point in the execution model.
type AgentDecision =
| { type: "proceed"; result: unknown }
| { type: "needs_approval"; action: PendingAction; taskId: string };
async function executeToolWithGate(
tool: Tool,
input: unknown,
policy: ApprovalPolicy
): Promise<AgentDecision> {
const requiresApproval = policy.requiresApproval(tool.name, input);
if (!requiresApproval) {
return { type: "proceed", result: await tool.execute(input) };
}
const taskId = await approvalQueue.create({
tool: tool.name,
input,
requestedAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
return { type: "needs_approval", action: { tool: tool.name, input }, taskId };
}
The expiresAt field matters. A stale approval that executes days after the human reviewed the context is worse than no approval gate at all.
Fallbacks for Transient Provider Failures
Provider outages are not rare. Model the failure modes explicitly rather than letting them surface as unhandled exceptions.
async function callWithFallback<T>(
primary: () => Promise<T>,
fallback: () => Promise<T>,
options = { timeoutMs: 10_000 }
): Promise<T> {
try {
return await Promise.race([
primary(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), options.timeoutMs)
),
]);
} catch (err) {
if (isTransient(err)) {
return fallback();
}
throw err;
}
}
// Usage: primary on Anthropic, fallback to OpenAI
const response = await callWithFallback(
() => callAnthropic(messages),
() => callOpenAI(messages)
);
Observability: What You Actually Need to Trace
Agent chains are hard to debug because failures are often three steps downstream from their cause. A tool returned ambiguous data in step two; the model made an incorrect inference in step four; the final answer was wrong. Without a trace, you are guessing.
The minimum viable instrumentation for an agent:
interface AgentTrace {
traceId: string;
sessionId: string;
goal: string;
steps: Array<{
stepIndex: number;
type: "llm_call" | "tool_call" | "human_gate";
input: unknown;
output: unknown;
durationMs: number;
tokensIn?: number;
tokensOut?: number;
cost?: number;
error?: string;
}>;
totalDurationMs: number;
totalCost: number;
outcome: "success" | "failure" | "pending_approval" | "max_steps_exceeded";
}
function createTracer(traceId: string) {
const steps: AgentTrace["steps"] = [];
const startedAt = Date.now();
return {
recordStep(step: Omit<AgentTrace["steps"][0], "stepIndex">) {
steps.push({ stepIndex: steps.length, ...step });
},
complete(outcome: AgentTrace["outcome"], goal: string, sessionId: string) {
const trace: AgentTrace = {
traceId,
sessionId,
goal,
steps,
totalDurationMs: Date.now() - startedAt,
totalCost: steps.reduce((sum, s) => sum + (s.cost ?? 0), 0),
outcome,
};
telemetry.emit("agent.trace", trace);
return trace;
},
};
}
Two metrics that tell you the most: step count distribution (if p95 is 3x your median, something is causing the agent to loop on specific inputs) and cost per session by outcome (successful sessions that cost 10x more than the median usually indicate a poorly scoped task or a tool returning too much data).
Cost Control: Token Budgets Are Infrastructure
Cost is not an afterthought. An agent that costs $0.80 per session with a 10% error rate might be better than an agent that costs $0.08 per session with a 35% error rate, but you have to measure it to know. A few levers that matter:
Context budget management. Reserve a fixed percentage of the context window for system prompt, tool definitions, and the final response. The remaining budget is available for conversation history and tool results. When history exceeds the budget, summarize. This is more predictable than truncation.
Tool output size caps. Set a maximum token size for tool outputs and truncate with a clear notice: [truncated at 500 tokens — use get_full_record tool for complete data]. This prevents a single large tool result from consuming most of the context window.
Model routing by task complexity. Not every step in an agent loop needs the most capable model. Use a smaller, faster model for tool call parsing and result summarization. Reserve the powerful model for the steps that require deep reasoning.
function selectModel(step: AgentStepType): string {
switch (step) {
case "planning":
return "claude-opus-4-5"; // needs reasoning
case "tool_call_selection":
return "claude-haiku-3-5"; // pattern matching, not reasoning
case "result_synthesis":
return "claude-sonnet-4-5"; // balance
default:
return "claude-sonnet-4-5";
}
}
When Agents Are the Wrong Abstraction
Not every LLM-powered feature should be an agent. The agent abstraction adds cost, unpredictability, and operational complexity. It is justified when the task requires dynamic decision-making across multiple steps. It is not justified when:
The steps are known upfront. If the task always follows the same sequence (extract, validate, transform, write), a pipeline is simpler, cheaper, and more debuggable. You do not need an LLM to control the flow.
The task is a single call with structured output. A form parser, a classification endpoint, a document summarizer, these are not agents. Wrapping them in an agent loop adds latency and cost for no benefit.
Reliability requirements are very high. An agent with a 95% success rate sounds good until it is running 10,000 times per day and 500 sessions per day fail in unpredictable ways. For high-volume, high-reliability scenarios, a deterministic pipeline with LLM steps at specific points often outperforms a fully autonomous agent.
The task has strict audit requirements. Agents with non-deterministic paths are hard to audit after the fact. If you need to explain exactly why the system took a specific action, a pipeline with logged decision points is easier to reconstruct than a trace of an LLM reasoning loop.
A useful framing: before building an agent, write out the steps you expect it to take for a typical input. If those steps are always roughly the same, build a pipeline. If the steps genuinely vary based on what the agent discovers at runtime, build an agent.
The Compounding Problem
Individual reliability techniques (retries, fallbacks, human gates, cost caps) are each straightforward. The challenge in production is that they interact. A retry on a non-idempotent tool inside a context window that is filling up, during a provider fallback that uses a less capable model, can produce a session that technically completes but produces a wrong result. No individual system failed, but the compounding behavior was unexpected.
The only reliable mitigation is end-to-end eval runs. Build a suite of representative inputs with expected outputs or expected step patterns, run it on every code change, and track outcome quality over time alongside cost and latency. Agents that pass unit tests for individual components can still fail at the system level when the components interact under real load.
Production agents are engineering problems, not prompt engineering problems. The architecture, the tool schemas, the memory strategy, the reliability patterns, these are decisions that compound over the lifetime of the system. Getting them right early is cheaper than retrofitting them after the first production incident.
More in AI / ML
How Mixture of Experts Works: Sparse Gating, Expert Routing, and the Architecture Behind Efficient Large Language Models
A deep dive into MoE architecture: how the gating network routes tokens to experts, top-k selection, load balancing losses, capacity factor, token dropping, expert parallelism for serving, and the real production tradeoffs between dense transformers and sparse MoE models.
AI Agent Frameworks Compared: CrewAI, LangGraph, AutoGen, and Mastra for Production Systems
A practical comparison of CrewAI, LangGraph, AutoGen, and Mastra for building production AI agent systems. Covers architecture philosophy, state management, tool integration, observability, and deployment patterns with TypeScript code examples.
Google's Agent2Agent Protocol: How A2A Enables Cross-Framework Agent Communication in Production Systems
A deep dive into Google's Agent2Agent (A2A) protocol covering agent cards, task lifecycle, message parts, streaming via SSE, push notifications, and how A2A complements MCP. Includes TypeScript implementation examples, comparison with MCP and direct API integration, and production deployment patterns for multi-vendor agent ecosystems.
How Transformer Models Work: Self-Attention, Positional Encoding, and the Architecture Behind Modern LLMs
A technical deep dive into the Transformer architecture: tokenization, positional encoding, self-attention with Q/K/V matrices, multi-head attention, the encoder-decoder split, training dynamics, and what it all means for engineers building on top of LLMs.