DEV Community

Chen Yuan
Chen Yuan

Posted on • Originally published at Medium

I Built an AI Agent With Tools. The First 50 Calls Were a Disaster.

The Demo That Almost Lost Me the Project

I spent two weeks building the agent. It could look up customer data, calculate metrics, and suggest actions based on real-time API calls. On demo day, the VP asked one question: "What's our churn rate this quarter?"

The agent called the customer search API instead of the analytics API. It passed "churn rate" as a customer ID. When that failed with a 400, it tried multiplying three random metrics together -- average session length times number of users divided by page load time. Six API calls, twelve seconds, about fifty cents in compute. The answer it returned: "Churn rate is approximately purple."

I closed my laptop and said the internet was down.

This is the moment every AI agent builder faces. The model can reason, write code, and plan -- but give it real tools and everything falls apart. I've spent the last three months fixing exactly these failure modes. Here's what I found running over 10,000 tool calls across multiple production systems.

What 500 Tool Calls Taught Me

I started by instrumenting everything. Every tool call logged: which tool was selected, the arguments passed, whether execution succeeded, and what the model did next. After 500 calls through a naive implementation:

  • Wrong tool selection (23%): The model called get_customer when the question was about subscription data.
  • Malformed arguments (15%): Missing required fields, wrong types, hallucinated field names.
  • Failed execution with no recovery (12%): API returned 429, model retried same endpoint 3x without waiting.
  • Straight-up refusals (3%): Model decided to explain what it would do instead of calling any tool.
  • Success (47%): Got the right answer on the first try.

Less than half the calls worked. That's not production-ready.

Pattern 1: Validate Args Before They Touch an API

The simplest fix with the biggest impact: validate tool arguments before they reach any external system.

function validateToolArgs(name, args) {
  const schema = schemas[name]
  const errors = []
  for (const [key, def] of Object.entries(schema.properties)) {
    if (schema.required?.includes(key) && args[key] === undefined) {
      errors.push(`Missing required: ${key}`)
    }
    if (args[key] !== undefined && typeof args[key] !== def.type) {
      errors.push(`${key} must be ${def.type}, got ${typeof args[key]}`)
    }
  }
  return errors.length ? { valid: false, errors } : { valid: true }
}
Enter fullscreen mode Exit fullscreen mode

This caught 80% of argument errors immediately. Success rate went from 47% to 67%.

Pattern 2: Run Independent Tool Calls in Parallel

async function runToolCalls(toolCalls, timeoutMs = 10000) {
  const results = await Promise.allSettled(
    toolCalls.map(tc => {
      const check = validateToolArgs(tc.name, tc.args)
      if (!check.valid) return { error: check.errors.join('; ') }
      return callTool(tc.name, tc.args)
    })
  )
  return results.map((r, i) => ({
    id: toolCalls[i].id,
    status: r.status === 'fulfilled' ? 'ok' : 'failed',
    data: r.status === 'fulfilled' ? r.value : r.reason?.message
  }))
}
Enter fullscreen mode Exit fullscreen mode

Parallel calls plus validation pushed success rate from 67% to 83%.

Pattern 3: Exponential Backoff, Handled by the Host

I gave the model a "retry" tool initially. It was a disaster. The model called retry instantly (no delay), called retry on calls that succeeded, and once got stuck in a loop calling retry on retry.

async function robustCall(name, args, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await callTool(name, args)
    } catch (err) {
      if (attempt === maxRetries)
        return { error: `Failed after ${maxRetries}: ${err.message}` }
      const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500
      await new Promise(r => setTimeout(r, delay))
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The critical insight: the model should never handle retry logic. Your code does it.

With retry plus backoff, success rate reached 91%.

Pattern 4: Be Verbose With Tool Descriptions

The remaining failures were almost all wrong tool selection. Not "Get customer subscription" but:

"Use this when you need to know what plan a customer is subscribed to, their billing cycle, payment method on file, or subscription status (active/cancelled/past_due). Include a customer_id."

I also started merging conceptually related tools. Instead of three separate functions for "get transactions," "get refunds," and "get invoices," I created getCustomerFinancialData(scope, customerId) with three scopes.

This pushed success rate to about 95%.

Pattern 5: Log Everything

This is the meta-pattern that made the other four possible. Every tool call went into a structured log. After three weeks of this, I had a dataset of failure modes from 10,000+ logged calls. Without the logs, I'd be guessing. With them, I could prioritize the patterns that would have the biggest impact.

The tool you need most in an AI agent isn't a search tool or a calculator. It's observability.

What I Still Don't Know

  • Tool drift at scale. With 30+ tools, accuracy drops off a cliff.
  • Cost of parallelism. More parallel calls means more API spend per query.
  • Cross-model consistency. GPT-4o, Claude, and Gemini all handle tool calling differently.

If you've figured any of these out, I'd genuinely love to hear how. Tool calling is the biggest bottleneck in production AI agents right now.

Top comments (0)