Your agent works in testing. Then you deploy it and things break in ways you didn't expect. Here are five failure modes I've seen repeatedly, with TypeScript code to prevent each one.
1. The Infinite Loop
What happens:
Your agent calls a tool, doesn't get the result it wants, so it tries again. And again. And again. Overnight, you've made 10,000 API calls.
Why it happens:
No termination condition. The agent keeps trying until it "succeeds," but success is poorly defined or impossible to achieve.
Real scenario:
// Agent tries to search for "the perfect answer"
// Each search doesn't satisfy it, so it keeps searching
// 1000 searches later, still going...
The fix: Max iterations + success criteria
class BudgetedAgent {
private callCount = 0;
private maxCalls = 10;
async run(task: string): Promise<string> {
this.callCount = 0;
while (this.callCount < this.maxCalls) {
const response = await this.executeStep(task);
this.callCount++;
// Check if we're done
if (this.isComplete(response)) {
return response;
}
// Check if we should stop trying
if (this.callCount >= this.maxCalls) {
throw new Error(
`Max iterations (${this.maxCalls}) reached without completion`
);
}
}
throw new Error('Task incomplete');
}
private isComplete(response: any): boolean {
// Define clear completion criteria
return (
response.status === 'success' ||
response.confidence > 0.8 ||
response.finalAnswer !== null
);
}
}
Better: Track why it's looping
class SmartAgent {
private attempts: Map<string, number> = new Map();
private maxRetries = 3;
async executeWithRetry(tool: string, args: any): Promise<any> {
const key = `${tool}:${JSON.stringify(args)}`;
const attemptCount = this.attempts.get(key) || 0;
if (attemptCount >= this.maxRetries) {
throw new Error(
`Tool ${tool} failed after ${this.maxRetries} attempts with same args`
);
}
this.attempts.set(key, attemptCount + 1);
try {
return await this.executeTool(tool, args);
} catch (error) {
// Log the failure reason
console.error(`Attempt ${attemptCount + 1} failed:`, error);
throw error;
}
}
}
Lesson: Always set hard limits. Don't trust the agent to know when to stop.
2. Context Window Overflow
What happens:
Your agent starts fast. After 20 turns, responses take 10 seconds and cost significantly more. Eventually, it crashes with "context length exceeded."
Why it happens:
Every API call sends the entire conversation history. As history grows, so does input token count. Eventually, you hit the model's context limit.
Real example:
Turn 1: 100 tokens input → 50 tokens output = 150 total
Turn 5: 500 tokens input → 200 tokens output = 700 total
Turn 10: 1200 tokens input → 400 tokens output = 1600 total
Turn 20: 4000 tokens input → context limit exceeded
The fix: Sliding window
interface Message {
role: 'user' | 'assistant';
content: string;
}
class ContextManager {
private messages: Message[] = [];
private maxMessages = 20;
private preserveSystemPrompt = true;
add(message: Message) {
this.messages.push(message);
// Keep within limit
if (this.messages.length > this.maxMessages) {
// Always keep the first message if it's system context
if (this.preserveSystemPrompt) {
const systemMessage = this.messages[0];
this.messages = [
systemMessage,
...this.messages.slice(-this.maxMessages + 1),
];
} else {
this.messages = this.messages.slice(-this.maxMessages);
}
}
}
getMessages(): Message[] {
return this.messages;
}
estimateTokens(): number {
// Rough estimate: ~4 characters per token
const totalChars = this.messages.reduce(
(sum, msg) => sum + msg.content.length,
0
);
return Math.ceil(totalChars / 4);
}
}
Better: Token-aware trimming
class TokenAwareContext {
private messages: Message[] = [];
private maxTokens = 100000;
add(message: Message) {
this.messages.push(message);
this.trim();
}
private trim() {
while (this.estimateTokens() > this.maxTokens && this.messages.length > 1) {
// Remove oldest message (keep at least 1)
this.messages.splice(1, 1);
}
}
private estimateTokens(): number {
return this.messages.reduce((sum, msg) => {
return sum + Math.ceil(msg.content.length / 4);
}, 0);
}
getMessages(): Message[] {
return this.messages;
}
}
Lesson: Context is not infinite. Manage it actively.
3. Hallucinated Tool Names
What happens:
The agent tries to call search_databse instead of search_database. Your code crashes because the tool doesn't exist. Or worse, it silently fails and the agent keeps trying.
Why it happens:
LLMs sometimes misspell tool names, especially if:
- The name is long or complex
- Similar tool names exist
- The model is tired (later in context)
Real examples I've seen:
-
search_database→searchDatabase(different casing) -
get_user_data→getUserData→get-user-data(inconsistent naming) -
calculate_sum→calculate_total(synonym hallucination)
The fix: Type-safe tool registry
const TOOLS = {
search_database: {
description: 'Search the database',
handler: async (args: any) => { /* ... */ },
},
send_email: {
description: 'Send an email',
handler: async (args: any) => { /* ... */ },
},
} as const;
type ToolName = keyof typeof TOOLS;
function invokeTool(name: string, args: any): Promise<any> {
// Check if tool exists
if (!(name in TOOLS)) {
throw new Error(`Tool "${name}" does not exist`);
}
return TOOLS[name as ToolName].handler(args);
}
Better: Fuzzy matching with suggestions
import { distance } from 'fastest-levenshtein';
class ToolRegistry {
private tools = new Map<string, Tool>();
register(name: string, tool: Tool) {
this.tools.set(name, tool);
}
async invoke(name: string, args: any): Promise<any> {
// Exact match
if (this.tools.has(name)) {
return await this.tools.get(name)!.execute(args);
}
// Find similar names
const similar = this.findSimilar(name, 2); // max edit distance of 2
if (similar.length === 1) {
console.warn(`Tool "${name}" not found. Using "${similar[0]}" instead.`);
return await this.tools.get(similar[0])!.execute(args);
}
if (similar.length > 1) {
throw new Error(
`Tool "${name}" not found. Did you mean: ${similar.join(', ')}?`
);
}
throw new Error(
`Tool "${name}" not found. Available tools: ${Array.from(this.tools.keys()).join(', ')}`
);
}
private findSimilar(name: string, maxDistance: number): string[] {
const candidates = Array.from(this.tools.keys())
.map(key => ({ key, distance: distance(key, name) }))
.filter(item => item.distance <= maxDistance)
.sort((a, b) => a.distance - b.distance)
.map(item => item.key);
return candidates;
}
}
Lesson: Don't trust the LLM to spell tool names correctly. Validate and suggest.
4. Unvalidated Tool Arguments
What happens:
The agent calls send_email with a string instead of an object. Or passes to: undefined. Your tool crashes with cryptic errors.
Why it happens:
LLMs don't guarantee type correctness. They might:
- Pass wrong types (
"5"instead of5) - Omit required fields
- Add unexpected fields
- Nest objects incorrectly
Real failure:
// Agent calls: send_email({ recipient: "user@example.com" })
// Tool expects: send_email({ to: string, subject: string, body: string })
// Result: TypeError: Cannot read property 'to' of undefined
The fix: Validate with Zod
import { z } from 'zod';
const sendEmailSchema = z.object({
to: z.string().email(),
subject: z.string().min(1).max(200),
body: z.string(),
cc: z.array(z.string().email()).optional(),
});
type SendEmailArgs = z.infer<typeof sendEmailSchema>;
async function sendEmail(args: unknown): Promise<string> {
try {
// Validate and parse
const validated = sendEmailSchema.parse(args);
// Now TypeScript knows the exact shape
await emailService.send({
to: validated.to,
subject: validated.subject,
body: validated.body,
cc: validated.cc,
});
return 'Email sent successfully';
} catch (error) {
if (error instanceof z.ZodError) {
// Return helpful error to the agent
const issues = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
return `Invalid arguments: ${issues.join(', ')}`;
}
throw error;
}
}
Better: Validate at registration
interface Tool {
name: string;
description: string;
schema: z.ZodSchema;
handler: (args: any) => Promise<string>;
}
function createTool<T extends z.ZodSchema>(
name: string,
description: string,
schema: T,
handler: (args: z.infer<T>) => Promise<string>
): Tool {
return {
name,
description,
schema,
handler: async (rawArgs: unknown) => {
const validated = schema.parse(rawArgs);
return handler(validated);
},
};
}
// Usage
const emailTool = createTool(
'send_email',
'Send an email',
sendEmailSchema,
async (args) => {
// args is fully typed!
return await emailService.send(args);
}
);
Lesson: Validate everything that crosses the LLM boundary.
5. The Silent Timeout
What happens:
Your agent calls a slow API. The call times out. The agent doesn't know what happened and can't recover.
Why it happens:
No timeout handling. External APIs can:
- Hang indefinitely
- Take longer than expected
- Fail without clear errors
Real scenario:
// Tool calls external API
const data = await fetch('https://slow-api.com/data');
// This might hang for minutes
// Agent has no idea what's happening
The fix: Timeout with retries
async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeoutMs: number = 5000
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeout);
return response;
} catch (error) {
clearTimeout(timeout);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw error;
}
}
Better: Retry with exponential backoff
async function fetchWithRetry(
url: string,
maxRetries: number = 3,
timeoutMs: number = 5000
): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fetchWithTimeout(url, {}, timeoutMs);
} catch (error) {
lastError = error as Error;
// Don't retry on last attempt
if (attempt < maxRetries - 1) {
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw new Error(
`Failed after ${maxRetries} attempts: ${lastError?.message}`
);
}
Best: Return partial results
async function searchWithTimeout(
query: string
): Promise<{ results: string[]; completed: boolean }> {
try {
const response = await fetchWithTimeout(
`https://api.search.com?q=${query}`,
{},
3000 // 3 second timeout
);
const data = await response.json();
return { results: data.results, completed: true };
} catch (error) {
// Return what we have, mark as incomplete
return {
results: ['Search timed out. Try a more specific query.'],
completed: false,
};
}
}
Lesson: External calls fail. Plan for it.
Putting It All Together
Here's a tool wrapper that prevents all five failures:
class RobustToolExecutor {
private attempts = new Map<string, number>();
private maxRetries = 3;
private timeout = 5000;
async execute(
toolName: string,
args: unknown,
schema: z.ZodSchema
): Promise<any> {
// Prevent #1: Infinite loops
const attemptKey = `${toolName}:${JSON.stringify(args)}`;
const attemptCount = this.attempts.get(attemptKey) || 0;
if (attemptCount >= this.maxRetries) {
throw new Error(`Tool ${toolName} failed after ${this.maxRetries} attempts`);
}
this.attempts.set(attemptKey, attemptCount + 1);
// Prevent #4: Unvalidated arguments
let validatedArgs;
try {
validatedArgs = schema.parse(args);
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid arguments: ${error.errors.map(e => e.message).join(', ')}`);
}
throw error;
}
// Prevent #5: Silent timeouts
try {
return await this.executeWithTimeout(toolName, validatedArgs);
} catch (error) {
// Clear attempt counter on different error types
if (!(error instanceof Error && error.message.includes('timeout'))) {
this.attempts.delete(attemptKey);
}
throw error;
}
}
private async executeWithTimeout(
toolName: string,
args: any
): Promise<any> {
return Promise.race([
this.executeTool(toolName, args),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error(`Tool ${toolName} timed out after ${this.timeout}ms`)),
this.timeout
)
),
]);
}
private async executeTool(toolName: string, args: any): Promise<any> {
// Your actual tool execution logic
return { success: true };
}
}
Common Theme
All five failures share a pattern: the agent assumes things will work, but they don't.
Prevention requires:
- Hard limits (iterations, tokens, time)
- Validation (types, schemas, existence)
- Graceful degradation (timeouts, partial results)
- Clear error messages (the agent needs to understand what failed)
These aren't bugs in your business logic. They're operational concerns that become visible under real usage.
Testing These Failures
You can't catch these in unit tests. They appear under load:
// Simulate failure scenarios
describe('Agent reliability', () => {
it('stops after max iterations', async () => {
const agent = new BudgetedAgent({ maxCalls: 5 });
await expect(agent.run(impossibleTask)).rejects.toThrow('Max iterations');
});
it('handles tool timeouts', async () => {
const slowTool = createTool('slow', 'A slow tool', z.object({}), async () => {
await new Promise(resolve => setTimeout(resolve, 10000));
return 'done';
});
await expect(executeWithTimeout(slowTool, {}, 1000)).rejects.toThrow('timed out');
});
});
Better: Monitor these in production with metrics.
Wrapping Up
These five failures happen to everyone. The difference is whether you build safeguards before or after they burn you in production.
Start with limits and validation. Add retries and timeouts as you find slow external calls. Log everything so you know what's actually failing.
Your agent will still have bugs. But these won't be among them.
Top comments (0)