All writing

Agentic AI in Production: What I Learned Building Tool-Calling Workflows

Practical lessons from deploying AI agents with tool-calling. Hallucinated arguments, infinite loops, runaway costs, and the guardrails that actually prevent them.

At its core, an AI agent is a loop. You call the model, check whether it wants to use a tool, run the tool, feed the result back and repeat until the model says it’s done. Everything that goes wrong is in the details.

At EGS I build agentic workflows for Nooko.ai, an AI culinary platform. They connect models to application data and backend tools through tool calling, structured outputs and MCP, and run in production on Python and FastAPI. Among many others, there are workflows for creating recipes, analyzing recipes, building menu plans and a chatbot. The user talks to the chatbot, the chatbot calls agents, and those agents call other agents.

Getting a first demo working is quick. Making it production-ready takes much longer, and most of that time goes into things tutorials skip: what happens when the model invents tool arguments, when it loops without making progress, and when a run quietly uses far more tokens than it should.

I’ll cover the tool side, including strict validation, clear error messages, timeouts, idempotency and passing user tokens through, in a separate post. This one is about the loop around the tools and the chain of agents on top of it.

The agent loop

Every framework dresses it up differently, but the loop is always the same:

Python
import json
 
from openai import AsyncOpenAI
 
client = AsyncOpenAI()
MODEL = "gpt-4o"  # whichever model you use
 
 
async def run_agent(
    messages: list[dict],
    tools: list[dict],
    max_iterations: int = 10,
    token_budget: int = 50_000,
) -> str:
    total_tokens = 0
 
    for _ in range(max_iterations):
        response = await client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=tools,
            tool_choice="auto",
        )
        total_tokens += response.usage.total_tokens
        if total_tokens > token_budget:
            return f"Stopped: token budget exceeded ({total_tokens} tokens used)."
 
        msg = response.choices[0].message
        messages.append(msg.model_dump(exclude_none=True))
 
        if not msg.tool_calls:
            return msg.content or ""  # done
 
        for call in msg.tool_calls:
            result = await execute_tool(call.function.name, call.function.arguments)
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": json.dumps(result),
            })
 
    return "Stopped: iteration limit reached."

The two limits at the top are the most important lines in that function. Without an iteration limit, a model that keeps calling the same tool, getting the same error and trying again will do that for as long as you let it. Without a token budget, a run can grow quietly until it’s expensive. Every agent run I ship has both.

Validating tool arguments

The model knows which tools exist because you give it their schemas, but it will still invent arguments that look plausible and are wrong. An ID that doesn’t exist, a value outside the allowed set, a date in a format your API doesn’t accept. I validate every tool call with Pydantic before anything runs. Using simplified recipe and menu plan tools as examples:

Python
from datetime import date
 
from pydantic import BaseModel, Field, ValidationError
 
 
class AnalyzeRecipeArgs(BaseModel):
    recipe_id: str = Field(min_length=1)
 
 
class CreateMenuPlanArgs(BaseModel):
    recipe_ids: list[str] = Field(min_length=1)
    start_date: date
    days: int = Field(ge=1, le=31)
 
 
TOOL_SCHEMAS = {
    "analyze_recipe": AnalyzeRecipeArgs,
    "create_menu_plan": CreateMenuPlanArgs,
}
TOOL_HANDLERS = {
    "analyze_recipe": analyze_recipe,  # your async implementations
    "create_menu_plan": create_menu_plan,
}
 
 
async def execute_tool(name: str, raw_args: str) -> dict:
    schema = TOOL_SCHEMAS.get(name)
    if schema is None:
        return {"error": f"Unknown tool: {name}"}
 
    try:
        args = schema.model_validate_json(raw_args)
    except ValidationError as e:
        return {"error": f"Invalid arguments: {e}"}
 
    return await TOOL_HANDLERS[name](args)

The validation error goes back to the model as the tool result. Often it corrects itself on the next attempt. Sometimes it repeats the same mistake, and the iteration limit ends the run.

Pydantic checks the outputs of tool calls as well as the inputs, so the structure is always right. That doesn’t mean the content is right. A result can be perfectly valid and still be wrong or not good enough, and catching that is the job of the review step at the end of the chain.

When agents call agents

Once agents start calling other agents, a new set of problems shows up. I’ve run into all of these: an agent calling the wrong agent, data that doesn’t match between agents, failures that are hard to debug because they happen several steps down the chain, and reliability issues because the technology underneath changes so fast.

Wrong routing usually comes down to how agents are described. The calling model picks an agent based on its name and description, so vague or overlapping descriptions lead to wrong choices. It helps to give each agent one clear job and describe it in plain terms, including what it should not be used for.

Data mismatches are easier to prevent than to debug. I’d treat the data passed between agents as a contract, defined as strict Pydantic models on both sides, so a field that’s missing or in the wrong shape fails at the boundary where it happened instead of three agents later.

For debugging, the most useful thing is to trace each run end to end under one run ID. Every agent and tool call in the chain logs with that ID, so you can follow a single user request through all of it.

For reliability, pin model versions rather than following the latest alias, and re-test your workflows before you upgrade. A model change can shift behaviour in ways that only show up further down the chain.

Review, then confirm before saving

Reading data is low risk. The model can look things up all day. The moment it tries to change something, such as saving a recipe or a menu plan, you need a guardrail.

At the end of a chain, a review agent, which is a second model, checks the result against the quality we expect. When the result is incorrect or not good enough, it corrects it. If the result still fails the check, the user’s credits are refunded. If it passes, nothing is saved yet. The user sees the result as a preview and confirms it before it’s stored.

So nothing is written until the user has seen it and said yes. It adds a step, and that’s the point. A request can be read more broadly than anyone meant, and the preview is the chance to catch that before anything changes.

Keeping context under control

Each pass through the loop adds the model’s response and every tool result to the message history. A run with several steps and large tool results can reach the context limit faster than you’d expect, and cost grows with it.

The token budget in the loop is one half of the answer. The other is to keep tool results small. If a query returns hundreds of rows, the model rarely needs all of them. Return a count and a representative sample, or a summary, and let the model ask for more if it needs to.

Streaming

An agent run with several steps takes longer than a single model call, and a blank screen while it works feels broken. In our FastAPI services I stream model output to the React front end over server-sent events, so users see the response as it’s generated instead of waiting for the whole run to finish.

Most AI features aren’t agents

Most AI features in production don’t need an agent loop at all.

Classification, summarization and data extraction can each be a single call with a structured output. You define the schema, the model fills it in, and you’re done. There’s no loop, no tools and no budget to manage.

Agents make sense when the model has to decide what to do next based on intermediate results. Building a menu plan from a user’s request, where the model picks recipes and adjusts based on what it finds, is agent work. “Extract these five fields from this document” is not. When I find myself reaching for tool calling, I first ask whether a single structured output call would do. Often it would, and it avoids the extra latency, cost and failure modes.

Monitoring

I log agent runs as structured logs and metrics: iterations per run, token spend per run, tool-call success and completion time. With agents calling agents, those numbers are most useful when they’re tied to the run ID, so you can see them for the whole chain as well as for each agent. I also log each tool call with its arguments and validation result, which makes failures much easier to trace.

The most useful signal to alert on is average iterations per run going up. It usually means something changed underneath the agent. A system prompt drifted, or a tool started returning results or errors the model no longer understands, so it keeps retrying.

The model is the easy part. Limits, validation, clear contracts between agents, a review step with user confirmation, and monitoring are what make agents safe to run for real users, and they’re where most of the work goes.

Written by

Salih Yildirim

Senior Software Engineer

Senior software engineer across backend, cloud and front end, with AI where it helps. Currently building Nooko.ai, an AI culinary platform, at EGS.

Comments

Loading comments

All writing