DEV Community

Cover image for 5 Ways Your AI Agent Will Fail (And How to Prevent Them)
Raju Dandigam
Raju Dandigam

Posted on

5 Ways Your AI Agent Will Fail (And How to Prevent Them)

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...

Enter fullscreen mode Exit fullscreen mode

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
   );
 }
}

Enter fullscreen mode Exit fullscreen mode

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;
   }
 }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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);
 }
}

Enter fullscreen mode Exit fullscreen mode

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;
 }
}

Enter fullscreen mode Exit fullscreen mode

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_databasesearchDatabase (different casing)
  • get_user_datagetUserDataget-user-data (inconsistent naming)
  • calculate_sumcalculate_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);
}

Enter fullscreen mode Exit fullscreen mode

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;
 }
}

Enter fullscreen mode Exit fullscreen mode

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 of 5)
  • 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

Enter fullscreen mode Exit fullscreen mode

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;
 }
}

Enter fullscreen mode Exit fullscreen mode

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);
 }
);

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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;
 }
}

Enter fullscreen mode Exit fullscreen mode

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}`
 );
}

Enter fullscreen mode Exit fullscreen mode

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,
   };
 }
}

Enter fullscreen mode Exit fullscreen mode

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 };
 }
}

Enter fullscreen mode Exit fullscreen mode

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');
 });
});

Enter fullscreen mode Exit fullscreen mode

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)