Lang
API

AI agents explained

A chatbot responds in one shot. An agent acts in a loop: the model proposes tool calls, your runtime executes them, appends observations, and calls the model again until a final answer or a guardrail fires. That loop—LLM + tools + memory + planning—is the foundation of agentic products. Frameworks like LangGraph organize the same pattern with state and checkpoints; the API shape underneath stays familiar.

After reading, you should be able to: define an agent as four components (model, tools, memory, planner); explain why tool-calling APIs made agents practical; contrast agent vs chatbot vs RAG-only copilot; diagram the observe→think→act loop; implement a ReAct-style transcript; enumerate production failure modes (tool loops, bad params, lost goals, irreversible actions, SQL injection); and apply a shipping checklist before exposing write tools.

developer platform architect Track 4 gpt-4o-mini Claude 3.5 Spring AI 1.0+ LangGraph

What is an agent — LLM + tools + memory + planning

An AI agent is not a single API call—it is a system where a language model sits inside a runtime you control. The model proposes steps; your code validates, executes, logs, and decides when to stop. Four components appear in every serious agent architecture.

The four components

ComponentRoleOwned byExample
LLM (reasoner) Interprets user goal, plans next step, chooses tools or final answer Provider API gpt-4o-mini, Claude 3.5 Sonnet
Tools (actuators) Side-effecting or read-only functions the runtime executes Your application lookup_order, search_policy, send_email
Memory (state) Working transcript + optional long-term store across turns Your application messages[], session summary, vector recall
Planner (orchestrator) Loop control: max rounds, branching, human approval, termination Your application Custom loop, LangGraph graph, Spring AI advisor chain

The LLM is the planner-in-the-loop, not the orchestration engine. You own execution, validation, audit logs, and termination. Without that boundary, “agent” collapses into an unbounded chat that hallucinates API responses and never stops calling tools.

flowchart TB
  subgraph runtime["Your agent runtime"]
    P[Planner / loop]
    M[Memory messages state]
    T[Tool router allowlist]
  end
  U[User goal] --> P
  P --> LLM["LLM API"]
  LLM -->|tool_calls or text| P
  P --> T
  T -->|execute| EXT["DB APIs search email"]
  EXT -->|observation| M
  M --> P
  P -->|final answer| U

Planning: implicit vs explicit

Implicit planning happens inside the model’s hidden reasoning—one tool call at a time until done. Explicit planning asks the model to emit a numbered plan first, then execute step-by-step (useful for long workflows; costs extra tokens). Both are valid; explicit plans help debugging and human review but can go stale if tool results contradict the plan.

Memory layers (preview)

  • Working memory — the messages list for this request: user, assistant, tool roles.
  • Session memory — trimmed prior turns or rolling summary for multi-turn chat (Guide 3).
  • Long-term memory — facts extracted and stored in a DB or vector index for future sessions.

Track 3 taught context assembly under a token budget. Agents add tool observations to that stack—each round appends assistant + tool messages, so memory management becomes critical (Guide 3 in this track).

Minimal agent type definition

AgentState — four components as data structures
from dataclasses import dataclass, field
from typing import Callable, Any

@dataclass
class AgentState:
    """Working memory + control flags for one user request."""
    messages: list[dict] = field(default_factory=list)
    round_count: int = 0
    max_rounds: int = 8
    tool_log: list[dict] = field(default_factory=list)
    done: bool = False
    final_answer: str | None = None

@dataclass
class AgentRuntime:
    model: str
    tools: list[dict]           # JSON schemas for the API
    tool_impls: dict[str, Callable[..., Any]]  # name -> Python fn
    system_prompt: str

def step(state: AgentState, runtime: AgentRuntime, llm_client) -> AgentState:
    """One observe-think-act iteration — planner owns the loop."""
    if state.round_count >= state.max_rounds:
        state.done = True
        state.final_answer = "Step limit reached."
        return state
    # ... call LLM, dispatch tools, append observations (core-loop section)
    return state
public record AgentState(
    List<Message> messages,
    int roundCount,
    int maxRounds,
    List<ToolLogEntry> toolLog,
    boolean done,
    String finalAnswer
) {
  public AgentState withRound(int r) {
    return new AgentState(messages, r, maxRounds, toolLog, done, finalAnswer);
  }
}

public record AgentRuntime(
    String model,
    List<ToolDefinition> tools,
    Map<String, ToolFunction> toolImpls,
    String systemPrompt
) {}

@Service
public class AgentPlanner {
  public AgentState step(AgentState state, AgentRuntime runtime) {
    if (state.roundCount() >= state.maxRounds()) {
      return new AgentState(state.messages(), state.roundCount(),
          state.maxRounds(), state.toolLog(), true, "Step limit reached.");
    }
    // call ChatClient, dispatch tools, append observations
    return state;
  }
}

Notice what is not in the LLM: the allowlist map, round counter, and audit log live in your runtime. The model never directly invokes lookup_order—it emits a structured tool_calls payload your router validates.

🔬 Under the Hood

“Agent” is an architectural pattern, not a model capability flag. The same gpt-4o-mini weights behave as a chatbot or an agent depending entirely on whether your runtime exposes tools and loops until termination.

📦 Real World

Bank copilots, ITSM bots, and sales assistants share the same skeleton: policy system prompt (Track 3), allowlisted tools per role, session memory in Redis, and LangGraph or custom Python for the planner. Product teams define tool schemas; platform teams own the router and audit log.

🎯 Interview Tip

“What is an AI agent?” — Strong answer: LLM inside a bounded loop with tools and memory; the application owns execution and termination; the model proposes steps but never runs code directly. Weak answer: “ChatGPT with plugins” without guardrails.

Why agents now — tool calling APIs, instruction following, context

Agents are not new conceptually—planners and tool routers existed for years. What changed in 2023–2025 is native tool/function calling in frontier APIs, dramatically better instruction following, and 128K+ context windows that hold multi-step transcripts without aggressive trimming.

Before native tool calling

Early “agents” parsed free-text actions with regex: Action: search_kb(query="refund"). Fragile—models drifted format, hallucinated observations, and burned tokens on verbose Thought blocks. Production teams built brittle parsers and retry loops.

Native tools / tool_calls in chat completions return structured JSON: function name + arguments validated against your schema. The provider fine-tuned models to emit valid tool payloads—ReAct-style prompting became optional, not mandatory.

Three enablers (2024–2026)

EnablerWhat it unlocksWithout it
Tool / function calling APIs Structured actions, parallel calls, tool role in transcript Regex parsers, format drift, fake tool outputs in prose
Instruction following (RLHF + schema) Reliable adherence to system rules across many loop rounds Goal drift after 3–4 tool results; ignores “stop when done”
Large context windows Keep tool observations + RAG chunks + history in one request Aggressive summarization loses critical evidence mid-task

Provider tool-calling shape (OpenAI-style)

You register tool schemas once per request. The model may return finish_reason: tool_calls with one or more parallel invocations. You execute, append role: tool messages with tool_call_id, and call the model again.

Native tool calling — why regex ReAct is optional now
from openai import OpenAI

client = OpenAI()

TOOLS = [{
    "type": "function",
    "function": {
        "name": "lookup_order",
        "description": "Get shipping status for order id ORD-####",
        "parameters": {
            "type": "object",
            "properties": {"order_id": {"type": "string"}},
            "required": ["order_id"],
            "additionalProperties": False,
        },
    },
}]

resp = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "Use tools for live order data."},
        {"role": "user", "content": "Where is order ORD-1042?"},
    ],
    tools=TOOLS,
    tool_choice="auto",
)

msg = resp.choices[0].message
if msg.tool_calls:
    # Structured — no regex on "Action: lookup_order(...)"
    for call in msg.tool_calls:
        print(call.function.name, call.function.arguments)
@Bean
FunctionCallback lookupOrderCallback() {
  return FunctionCallback.builder()
      .function("lookup_order", (OrderIdRequest req) -> orderService.status(req.orderId()))
      .description("Get shipping status for order id ORD-####")
      .inputType(OrderIdRequest.class)
      .build();
}

String reply = chatClient.prompt()
    .system("Use tools for live order data.")
    .user("Where is order ORD-1042?")
    .functions(lookupOrderCallback())
    .call()
    .content();
// Spring AI maps tool_calls to Java methods — same structured contract

Instruction following in multi-step loops

Each loop round re-sends the full messages prefix. System rules from Track 3 (“do not invent tool results,” “cite policy text”) must survive 5–10 rounds of tool noise. Models trained for tool use maintain format longer than 2022-era chat models—but you still cap rounds and log drift.

Context budget in agent loops

Every round bills input tokens for the entire transcript so far. A six-round agent with 2K tokens per observation can exceed 12K input before the user question. Techniques:

  • Truncate tool outputs to the smallest useful slice (top 3 hits, 500 chars).
  • Summarize completed sub-goals into one assistant line; drop raw tool JSON.
  • Use smaller models for tool-selection rounds; larger model only for final synthesis.
💰 Cost

Agent cost scales with rounds × transcript_size, not one chat completion. A 8-round support agent at 4K input/round ≈ 32K input tokens per user message—price that before promising “unlimited agent steps.”

⚖️ Trade-off

Native tool calling reduces parser fragility but couples you to provider schema dialects (OpenAI vs Anthropic vs Bedrock). Abstract tool definitions in your codebase; map to provider format at the gateway—same pattern as Track 1 multi-provider routing.

💡 Pro Tip

Start agents on models explicitly marketed for tool use (gpt-4o-mini, Claude 3.5). Older or tiny models may emit tool calls with invalid JSON—validate arguments before every execution (Guide 2).

Agent vs chatbot — responds vs acts

A chatbot responds: one user message → one model call → one assistant message (maybe streamed). An agent acts: one user message → multiple model calls → multiple tool executions → final answer. The distinction is loop depth and who owns side effects—not model size or branding.

Comparison table

PatternModel calls per user turnExternal factsSide effectsTypical product
Chatbot 1 Only prompt + parametric knowledge None (unless you add tools manually) FAQ bot, drafting assistant
RAG copilot 1 (retrieve first, then generate) Retrieved doc chunks in context Read-only retrieval Policy Q&A, doc search
Agent 2–N (tool loop) Live APIs, DB, search, calculators Can write, book, refund—must gate Support resolver, ops automation

Use a chatbot when the answer fits in one generation from static or pre-assembled context. Use RAG alone when one retrieval + one answer suffices—see RAG explained. Use an agent when the user goal requires several lookups whose order depends on intermediate results: “Check order ORD-1042, then explain refund rules if it shipped late.”

Responds vs acts — concrete example

User: “Where is order ORD-1042, and can I get a refund on my monthly plan?”

Chatbot (responds)Agent (acts)
One completion: may invent tracking number and refund policy Round 1: lookup_order("ORD-1042") → shipped, ETA 2 days
No verifiable source for claims Round 2: search_policy("monthly refund") → no partial-month refunds
Fast, cheap, risky on facts Round 3: synthesize grounded answer citing tool outputs
flowchart LR
  subgraph chat["Chatbot — responds"]
    U1[User] --> M1[Model once]
    M1 --> A1[Answer]
  end
  subgraph agent["Agent — acts"]
    U2[User] --> M2[Model]
    M2 -->|tool_calls| T[Tools]
    T -->|observation| M2
    M2 --> A2[Final answer]
  end

When not to build an agent

  • Fixed workflow — known steps in order; code the steps, use LLM only for NLU or summarization.
  • Single retrieval suffices — FAQ with one search + one answer; agent adds latency and cost.
  • High-stakes writes without human review — refunds, account deletion, wire transfers need approval UI, not autonomous loops.
  • Eval simplicity — single-turn RAG golden sets are easier than multi-path agent trajectories (Track 5).
Same question — chatbot baseline vs agent with tools
QUESTION = "Where is order ORD-1042, and can I refund my monthly plan?"

# Chatbot — one shot, no tools (often hallucinates)
chat_resp = client.chat.completions.create(
    model="gpt-4o-mini",
    temperature=0.2,
    messages=[
        {"role": "system", "content": "You are Acme support."},
        {"role": "user", "content": QUESTION},
    ],
)

# Agent — run_agent() loops until no tool_calls (see core-loop section)
agent_result = run_agent(QUESTION)
# agent_result["tool_log"] shows lookup_order + search_policy
# agent_result["answer"] should cite shipped status + no partial-month refunds
String question = "Where is order ORD-1042, and can I refund my monthly plan?";

// Chatbot — single ChatClient call
String chatAnswer = chatClient.prompt()
    .system("You are Acme support.")
    .user(question)
    .call()
    .content();

// Agent — AgentService.run() loops with FunctionCallbacks
AgentResult agentResult = agentService.run(question);
// agentResult.toolLog() lists lookup_order, search_policy
// agentResult.answer() grounded in tool JSON
⚠️ Pitfall

Labeling every LLM feature “agent” for marketing. If there is no tool loop and no runtime guardrails, it is a chatbot—set user expectations and price accordingly.

⚙️ Config

Route simple intents to RAG-only path; escalate to agent path only when classifier detects multi-step intent (“check … then …”). Saves cost and reduces tool-loop failure surface.

🔒 Security

Agents multiply attack surface: each tool call is a potential injection or exfiltration point. Chatbots that only generate text have smaller blast radius—prefer chat/RAG when writes are not required.

Core loop — observe → think → act

Every agent implementation reduces to the same cycle: observe the current state (messages + tool results), think (model inference), act (execute tools or emit final answer). Repeat until termination.

Loop steps (production-shaped)

  1. Initialize — append system prompt and user message to messages.
  2. Think — call LLM with tool schemas; model returns text and/or tool_calls.
  3. Observe — if tool calls present, validate args, execute allowlisted functions, append role: tool messages.
  4. Check termination — if no tool calls, return assistant text; if round >= MAX_ROUNDS, return safe fallback.
  5. Log — record tool name, args hash, latency, success/fail for every act step.
flowchart TD
  START([User message]) --> OBS[Observe state messages]
  OBS --> THINK[Think LLM call]
  THINK --> DEC{tool_calls?}
  DEC -->|yes| ACT[Act execute tools]
  ACT --> APP[Append observations]
  APP --> OBS
  DEC -->|no| DONE([Final answer])
  THINK --> MAX{round >= MAX?}
  MAX -->|yes| SAFE([Safe fallback message])

Full agent runner

This is the reference loop for the Acme support demo used across Track 4 guides. Tools are mocked; swap real APIs without changing loop shape.

run_agent — observe → think → act until done
import json
from openai import OpenAI

client = OpenAI()
MAX_ROUNDS = 6

SYSTEM = (
    "You are Acme Corp support. Use tools for order status and policy facts. "
    "Do not invent tracking numbers or refund rules. "
    "If tools return not_found, say you are not sure."
)

TOOL_DEFINITIONS = [/* lookup_order, search_policy schemas — Guide 2 */]

ALLOWED = {
    "lookup_order": lookup_order_impl,
    "search_policy": search_policy_impl,
}

def run_tool(name: str, args_json: str) -> str:
    if name not in ALLOWED:
        return json.dumps({"error": "tool_not_allowed"})
    args = json.loads(args_json or "{}")
    # validate per tool — never trust raw model output
    return json.dumps(ALLOWED[name](**validated_args(name, args)))

def run_agent(user_message: str) -> dict:
    messages = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": user_message},
    ]
    tool_log = []

    for round_i in range(MAX_ROUNDS):
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=messages,
            tools=TOOL_DEFINITIONS,
            tool_choice="auto",
            temperature=0.2,
            max_tokens=500,
        )
        msg = response.choices[0].message
        messages.append(msg.model_dump(exclude_none=True))

        if not msg.tool_calls:
            return {"answer": msg.content or "", "rounds": round_i + 1, "tool_log": tool_log}

        for call in msg.tool_calls:
            output = run_tool(call.function.name, call.function.arguments)
            tool_log.append({
                "round": round_i + 1,
                "name": call.function.name,
                "arguments": call.function.arguments,
            })
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": output,
            })

    return {
        "answer": "Sorry, I could not complete this within the step limit.",
        "rounds": MAX_ROUNDS,
        "tool_log": tool_log,
        "error": "max_rounds",
    }
@Service
public class AcmeAgentService {
  private static final int MAX_ROUNDS = 6;
  private final ChatClient chatClient;
  private final ToolRouter toolRouter;

  public AgentResult run(String userMessage) {
    List<Message> messages = new ArrayList<>(List.of(
        new SystemMessage(SYSTEM_PROMPT),
        new UserMessage(userMessage)
    ));
    List<ToolLogEntry> toolLog = new ArrayList<>();

    for (int round = 0; round < MAX_ROUNDS; round++) {
      ChatResponse response = chatClient.prompt(new Prompt(messages))
          .tools(toolRouter.definitions())
          .call();

      AssistantMessage assistant = response.getResult().getOutput();
      messages.add(assistant);

      if (!assistant.hasToolCalls()) {
        return new AgentResult(assistant.getText(), round + 1, toolLog);
      }

      for (ToolCall call : assistant.getToolCalls()) {
        String output = toolRouter.run(call.name(), call.arguments());
        toolLog.add(new ToolLogEntry(round + 1, call.name(), call.arguments()));
        messages.add(new ToolResponseMessage(call.id(), output));
      }
    }
    return AgentResult.maxRoundsExceeded(toolLog);
  }
}

Termination conditions

ConditionBehaviorAlert if frequent
No tool_callsReturn assistant content as final answer
MAX_ROUNDS exceededSafe user message; log error=max_roundsYes — bad prompt or runaway tool
Tool error JSON returnedAppend to transcript; model may recover or ask userTrack error rate per tool
Human approval required (write tools)Pause loop; resume after approval tokenAudit denied approvals
Timeout per toolReturn timeout error in tool messageYes — dependency SLO breach

Parallel tool calls

Models may emit multiple tool_calls in one assistant message—e.g. lookup_order and search_policy together. Execute in parallel when independent; preserve order in transcript when results depend on each other.

☸️ OpenShift / K8s

Run agent workers as stateless pods; persist messages and checkpoint in Redis or Postgres between rounds if requests may span HTTP timeouts. Horizontal scale requires externalized session state—not in-memory lists per pod.

🏗️ IaC

Declare MAX_ROUNDS, tool timeouts, and model IDs in Helm values per environment. Staging can use lower caps to catch runaway loops cheaply before prod.

📦 Real World

Production agent services expose POST /agent/run with request ID, stream intermediate tool events over SSE for UI transparency, and never return raw tool JSON to end users—only synthesized answers.

ReAct pattern — Thought / Action / Observation

ReAct (Reason + Act) interleaves explicit Thought (planning prose), Action (tool invocation), and Observation (tool output) until the model emits a Final Answer. Track 3 introduced ReAct as prompting; Track 4 maps the same rhythm to native tool_calls.

Transcript shape

SegmentProducerNative tool API equivalent
ThoughtLLMOptional content before tool_calls
ActionLLMtool_calls[].function
ObservationRuntimerole: tool message
Final AnswerLLMAssistant message with no tool_calls

Worked example (Acme support)

User: “Where is order ORD-1042, and can I refund my monthly plan?”

Thought: I need live order status and refund policy for monthly plans. I'll query both.
Action: lookup_order(order_id="ORD-1042")
Observation: {"order_id": "ORD-1042", "status": "shipped", "eta_days": 2}

Thought: Order is shipped. Now check monthly refund policy before answering.
Action: search_policy(query="monthly plan refund partial month")
Observation: {"source": "refund-policy.txt", "snippet": "Monthly plans: no partial-month refunds."}

Thought: I have order status and policy. I can answer the user.
Final Answer: Order ORD-1042 shipped and should arrive in about 2 days. Monthly plans can be canceled anytime, but we do not refund partial months per [refund-policy.txt].

With native tool calling, Thoughts may appear in message.content alongside structured tool calls—useful for UI “reasoning” panels. You can suppress Thoughts in production to save tokens via system prompt: “Do not output chain-of-thought; call tools directly.”

ReAct-style system prompt + native tools
REACT_SYSTEM = """You are Acme support. Use tools when you need live data.

Before each tool call you may briefly state your plan (one sentence).
After tools return, synthesize a final user-facing answer.
Never invent data not present in tool observations."""

# Same run_agent() loop — tool_calls replace regex Action: lines
result = run_agent("Where is ORD-1042 and can I refund monthly?")

# Inspect transcript for ReAct shape:
# assistant(content="I'll check order status...", tool_calls=[...])
# tool(content='{"status":"shipped",...}')
# assistant(content="Order ORD-1042 is shipped...")
public static final String REACT_SYSTEM = """
    You are Acme support. Use tools when you need live data.
    Before each tool call you may briefly state your plan (one sentence).
    After tools return, synthesize a final user-facing answer.
    Never invent data not present in tool observations.""";

AgentResult result = acmeAgentService.run(
    "Where is ORD-1042 and can I refund monthly?");

// ChatResponse history shows:
// AssistantMessage with text + ToolCalls
// ToolResponseMessage with JSON observation
// Final AssistantMessage without tool calls

Text ReAct (legacy / teaching)

When tool APIs are unavailable, parse Action: tool(args) from assistant text and inject Observation: as a user message. See Core prompting techniques — ReAct for regex loop. Prefer native tool calling in production—parsers break under model updates.

ReAct vs plan-and-execute

PatternWhen to useRisk
ReAct (interleaved)Dynamic tasks; next step depends on observationsMore rounds; harder to predict cost
Plan-and-executeKnown tool palette; steps can be drafted upfrontPlan goes stale if first tool fails unexpectedly
Single-shot + tools onceParallel independent lookups onlyCannot adapt mid-flight
💡 Pro Tip

Truncate observations before append—top 3 KB hits, 500 chars per tool. Full dumps refill context and encourage the model to re-quote noise instead of synthesizing a Final Answer.

🔬 Under the Hood

ReAct was originally a prompting paper (Yao et al.) showing interleaved reasoning improves tool grounding. Native tool_calls bake much of that into model weights—you keep the Observation discipline (real tool JSON only) even if you drop explicit Thought lines.

🎯 Interview Tip

“Explain ReAct.” — Thought / Action / Observation loop; model never invents observations; cap steps; in production use structured tool APIs instead of parsing Action: lines from free text.

Failure modes — what breaks in production

Agents fail differently from chatbots. Failures are often multi-step—a bad tool arg in round 2 poisons round 5. Enumerate these modes in design reviews and golden evals (Track 5).

1. Tool loops (runaway rounds)

The model repeatedly calls the same tool with the same or slightly varied args—searching “refund policy” five times without synthesizing an answer. Causes: vague system prompt, missing “stop when sufficient,” or tool returning empty results without a clear error shape.

  • Mitigation: hard MAX_ROUNDS; detect duplicate tool+args hashes; return {"error":"duplicate_call_blocked"}.
  • Observability: alert when p95 rounds > 4 or duplicate-call rate spikes.

2. Hallucinated parameters

Model emits lookup_order(order_id="ORD-9999") when the user said ORD-1042—or invents SQL fragments in a “query” arg. Never pass raw model strings to databases or shell.

Validate tool args — block hallucinated order IDs
import re

ORDER_ID_RE = re.compile(r"^ORD-\d{4,8}$")

def validated_args(name: str, args: dict) -> dict:
    if name == "lookup_order":
        oid = args.get("order_id", "")
        if not isinstance(oid, str) or not ORDER_ID_RE.match(oid):
            raise ValueError("invalid_order_id")
        return {"order_id": oid}
    if name == "search_policy":
        q = args.get("query", "")
        if not isinstance(q, str) or len(q) > 200:
            raise ValueError("invalid_query")
        return {"query": q}
    raise ValueError("unknown_tool")
private static final Pattern ORDER_ID = Pattern.compile("^ORD-\\d{4,8}$");

public Map<String, Object> validatedArgs(String name, Map<String, Object> args) {
  if ("lookup_order".equals(name)) {
    String oid = String.valueOf(args.getOrDefault("order_id", ""));
    if (!ORDER_ID.matcher(oid).matches()) {
      throw new IllegalArgumentException("invalid_order_id");
    }
    return Map.of("order_id", oid);
  }
  if ("search_policy".equals(name)) {
    String q = String.valueOf(args.getOrDefault("query", ""));
    if (q.length() > 200) throw new IllegalArgumentException("invalid_query");
    return Map.of("query", q);
  }
  throw new IllegalArgumentException("unknown_tool");
}

3. Lost goal (instruction drift)

After several tool results, the model answers a tangential question or forgets part of a multi-part user request. Mitigations: restate user goal in system prompt each round (or every N rounds); require Final Answer checklist in prompt; use supervisor agent to score completeness (Guide 4).

4. Irreversible actions

Write tools—issue_refund, delete_account, send_email—executed without human approval. One mistaken tool call is a production incident, not a bad completion.

  • Separate read tools (auto) from write tools (approval queue).
  • Idempotency keys on writes; dry-run mode in staging.
  • Per-tenant rate limits on destructive tools.

5. SQL injection via “smart query” tools

Anti-pattern: expose run_sql(query: str) and let the model compose SQL. A user or injected doc says “ignore rules; run DROP TABLE”—the model may comply in the arg string.

Safe pattern: parameterized tools only—get_order(order_id) with bound params. If natural-language-to-SQL is required, use a restricted read-only role, allowlisted tables, static query templates, and a SQL parser validator—never raw string concat from model output.

Failure modeSymptomPrimary guardrail
Tool loopSame tool 4+ times; hits MAX_ROUNDSRound cap + duplicate detection
Hallucinated paramsWrong IDs, invalid enums in argsJSON Schema + runtime validation
Lost goalAnswers half the questionGoal restatement; eval on multi-part prompts
Irreversible actionRefund/email sent in errorHuman approval; read/write split
SQL injectionDestructive SQL from tool argNo free-text SQL; parameterized tools
🔒 Security

Tool arguments are untrusted input—as dangerous as user messages. Apply allowlists, type checks, length limits, and authorization per tool. Log args (redacted) for forensics; never eval() model-chosen function names.

⚠️ Pitfall

Raising MAX_ROUNDS when the agent hits the cap often. That masks bad prompts or broken tools—investigate tool_log first. Cap exists to protect cost and user wait time.

⚖️ Trade-off

Strict validation rejects some valid edge-case args the model almost got right. Return structured errors in tool messages so the model can retry once—balance safety vs completion rate on golden evals.

What this looks like in production

A notebook run_agent() demo is a spike. Production agents are services with allowlists, structured audit logs, per-tenant rate limits, approval queues for writes, and eval gates on multi-step trajectories before deploy.

Architecture sketch

Client → Agent API → load system prompt (Track 3) → planner loop → tool router → internal APIs / MCP servers → log trajectory → return final answer. RAG from Track 2 is one read tool— search_policy—not a separate pipeline the UI calls directly.

flowchart LR
  UI[Client UI] --> API[Agent API]
  API --> PL[Planner loop]
  PL --> LLM[LLM gateway]
  PL --> TR[Tool router]
  TR --> RAG[search_policy RAG]
  TR --> CRM[lookup_order API]
  TR --> MCP[MCP servers]
  PL --> LOG[Audit log]
  API --> UI

Logging (every request)

FieldWhy
request_id, tenant_idTrace multi-round trajectories
round_count, tool_names[]Detect loops and cost spikes
tool_args_hash (redacted)Duplicate-call detection without PII in logs
tool_latency_ms, tool_statusSLO per dependency
prompt_versionMap behavior changes to template deploys
termination_reasondone vs max_rounds vs approval_pending

Production readiness checklist

  • Allowlisted tool map—no dynamic eval of model-chosen function names
  • Argument validation and timeouts on every tool implementation
  • MAX_ROUNDS enforced; metrics on round count and tool error rate
  • Structured logs: request id, tool name, args hash, latency, success/fail
  • System prompt forbids inventing tool results; instructs refusal when tools fail
  • Separate read tools from write tools; gate writes behind auth and human approval
  • Duplicate tool-call detection within a single request
  • No free-text SQL or shell tools exposed to the model
  • Token budget: truncate observations; summarize completed sub-goals
  • Golden eval cases for multi-step trajectories—not only final answer match
  • Feature flag for agent path vs RAG-only fallback
  • SSE or polling for intermediate tool events in user-facing UI (transparency)

Minimal production-shaped agent service

Agent API handler — log trajectory, enforce caps
import hashlib
import logging
from fastapi import FastAPI
from pydantic import BaseModel

log = logging.getLogger("agent")
app = FastAPI()

class AgentRequest(BaseModel):
    message: str
    tenant_id: str
    session_id: str

@app.post("/v1/agent/run")
def agent_run(req: AgentRequest) -> dict:
    request_id = f"req_{hashlib.sha256(req.session_id.encode()).hexdigest()[:12]}"
    result = run_agent(req.message)
    log.info("agent_done", extra={
        "request_id": request_id,
        "tenant_id": req.tenant_id,
        "rounds": result["rounds"],
        "tools": [t["name"] for t in result["tool_log"]],
        "termination": result.get("error", "done"),
        "prompt_version": "support_agent_v1",
    })
    return {"request_id": request_id, "answer": result["answer"]}
@RestController
@RequestMapping("/v1/agent")
public class AgentController {
  private final AcmeAgentService agentService;
  private static final Logger log = LoggerFactory.getLogger(AgentController.class);

  @PostMapping("/run")
  public AgentResponse run(@RequestBody AgentRequest req) {
    String requestId = "req_" + UUID.randomUUID().toString().substring(0, 12);
    AgentResult result = agentService.run(req.message());
    log.info("agent_done request_id={} tenant={} rounds={} tools={} termination={}",
        requestId, req.tenantId(), result.rounds(),
        result.toolLog().stream().map(ToolLogEntry::name).toList(),
        result.terminationReason());
    return new AgentResponse(requestId, result.answer());
  }
}

Combine agents with RAG (preview)

Production copilots expose search_policy as hybrid retrieval from Track 2—not a hard-coded mock. The agent decides when to search; RAG supplies what text returns. Guide 6 covers agentic RAG patterns in depth.

💰 Cost

Price agent routes per successful task, not per token quote in sales decks. Log rounds × input_tokens per tenant; cap agent access on free tiers.

🎯 Interview Tip

“How do you ship agents safely?” — Allowlist, validate args, MAX_ROUNDS, read/write split, audit log per tool call, human approval for destructive actions, eval on trajectories, RAG-only fallback for simple intents.

💡 Pro Tip

Build an internal “trajectory replay” tool: paste request_id → see every round, tool arg, and observation. Debugging agents without transcripts is guesswork.