Engineer sketching a system diagram on a whiteboard

Reliable AI agents in production: patterns that hold up in 2026

Most agent failures aren't model failures. They're missing budgets, retries, approvals and traces. Here is the set of patterns we put in place before an agent touches production.

By ForthClover EngineeringUpdated October 202615 min readAI Agents

Most agent demos do not survive contact with production traffic. The loop works on the ten examples someone tried by hand. Then real users arrive, a tool returns an unexpected shape, a provider has a bad afternoon, and the agent either loops until it hits a cost alarm or does something confidently wrong with a real customer record.

These are the patterns we put in place every time we move an agent from demo to production. Almost all of it is ordinary distributed-systems discipline, applied to a component that is non-deterministic and can be talked into things.

The one mental model that ties it together: treat every model call like an unreliable network call to a clever but untrusted service. It can time out, rate-limit you, return malformed output, or return well-formed output that is wrong. It can also be steered by whatever text ends up in its context. Every pattern below follows from taking that seriously.

Architecture

The control path between a model decision and a side effect

Every layer is plain code you own. The model proposes; the layers below decide whether, how and how often it happens. Checkpoints and traces run alongside all of them.

CHECKPOINTSOTEL TRACESRequestrun_id · user identity · taskBounded agent loopstep, token, cost and wall-clock budgetsValidationschema + business rules on model output and tool argsPolicy gateallow-listed actions · approval for irreversible onesTool wrappertimeout · retry with jitter · breaker · idempotency keyExternal systemsAPIs · MCP servers · databases (scoped credentials)results return tagged as untrustedafter each stepevery layer

What actually breaks

Before patterns, symptoms. These are the failures we see most often when we are brought in to stabilise an agent, and what each one looks like when you open the trace.

FailureHow it shows up in tracesFirst line of defence
Runaway loopDozens of steps; the same tool called with near-identical arguments; token count climbing with no new informationStep, token and cost budgets; repetition detector
Provider brownoutClusters of 429/5xx or timeouts across many runs at once; latency p99 jumps before errors doTimeouts, jittered retries at one layer, circuit breaker, fallback model
Duplicate side effectTwo execute_tool spans for the same write, usually either side of a retry or a worker restartIdempotency keys derived from run and step
Malformed outputParse errors after the model call; downstream code receiving null where it expected an IDStructured outputs plus your own validation
Plausible but wrong actionTrace looks clean; the tool arguments are valid but refer to the wrong order, account or amountBusiness-rule validation, approval for irreversible actions, outcome evals
Injected instructionA tool result (web page, email, ticket) contains imperative text; the next model step calls a tool the user never asked forAllow-listed actions, untrusted-content isolation, egress control
Lost progressA long run disappears after a deploy or pod eviction; the user retries and work is repeatedCheckpointing or a durable workflow engine

Bound the loop with budgets you enforce in code

The most common production failure is not a wrong answer. It is an agent that never finishes. Every agent loop we ship carries four budgets, checked before each model call: steps (model turns), tokens, cost in currency, and wall-clock time. When any one is exhausted, the agent stops and returns what it has, flagged as partial, rather than throwing. Frameworks increasingly provide the first of these; the OpenAI Agents SDK, for example, raises MaxTurnsExceeded when a run passes max_turns, and lets you register a handler that returns a controlled final output instead.1 The other three you usually build yourself.

@dataclass
class Budget:
    max_steps: int = 25
    max_tokens: int = 250_000
    max_cost_usd: float = 2.00
    deadline_s: float = 300.0
    steps: int = 0
    tokens: int = 0
    cost_usd: float = 0.0
    started: float = field(default_factory=time.monotonic)

    def charge(self, usage, model):
        self.steps += 1
        self.tokens += usage.input_tokens + usage.output_tokens
        self.cost_usd += price(model, usage)        # your own price table

    def remaining_s(self) -> float:
        return self.deadline_s - (time.monotonic() - self.started)

    def exhausted(self) -> str | None:
        if self.steps >= self.max_steps:     return "step_budget"
        if self.tokens >= self.max_tokens:   return "token_budget"
        if self.cost_usd >= self.max_cost_usd: return "cost_budget"
        if self.remaining_s() <= 0:          return "deadline"
        return None


def run_agent(task, tools, budget: Budget):
    messages = [system_prompt(), user_message(task)]
    seen = Counter()
    while True:
        if reason := budget.exhausted():
            return Partial(messages, reason)          # degrade, don't throw
        reply = call_model(messages, tools, timeout=min(60, budget.remaining_s()))
        budget.charge(reply.usage, reply.model)
        if not reply.tool_calls:
            return Final(reply)
        for call in reply.tool_calls:
            fingerprint = (call.name, canonical_json(call.args))
            seen[fingerprint] += 1
            if seen[fingerprint] > 2:
                return Partial(messages, "repeated_tool_call")
            messages.append(execute_tool(call, budget))

Set budgets per use case from real traces: put the ceiling a little above the tail of successful runs. Compare canonicalised arguments in the repetition detector, or key order hides the loop. And treat a budget stop as a product state, not an error: the user should see what was done and what was not.

Treat every model and tool call as an unreliable network call

Timeouts and a deadline that propagates

Every call gets a timeout, including the model call and including tools you consider fast. More important is a run-level deadline that shrinks as the run progresses, so the tenth step cannot be given a full sixty seconds when only ten remain. Streaming responses need two timeouts: time to first token and maximum gap between chunks.

Retries with backoff, jitter and a single owner

Retry only transient failures: timeouts, 429s, 5xx and connection resets. Never retry validation errors, auth failures or content refusals; they will fail again and cost money doing it. Use exponential backoff with full jitter so a fleet of workers does not retry in lockstep, and honour Retry-After when the provider sends it. Amazon’s guidance is worth internalising: retries are “selfish”, they should happen at a single point in the stack rather than at every layer, and APIs with side effects are not safe to retry unless they are idempotent.2 In agent stacks the classic mistake is three layers of retries (SDK, framework, your code) multiplying into dozens of attempts against a provider that is already struggling.

RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}

def call_with_retries(fn, *, budget, attempts=4, base_s=0.5, cap_s=8.0):
    for attempt in range(attempts):
        try:
            return breaker.call(fn, timeout=min(30, budget.remaining_s()))
        except ProviderError as e:
            transient = e.status in RETRYABLE_STATUS or e.is_timeout
            if not transient or attempt == attempts - 1:
                raise
            delay = e.retry_after_s or random.uniform(0, min(cap_s, base_s * 2 ** attempt))
            if delay >= budget.remaining_s():
                raise
            time.sleep(delay)

Circuit breakers and fallbacks

A circuit breaker is still the cheapest insurance you can buy. Track failures per dependency over a rolling window; past a threshold, fail fast for a cool-down period, then let a few probe requests through before closing again. Keep one breaker per model endpoint and per tool backend, not one global breaker, so a flaky CRM does not stop the agent answering questions that never touch the CRM.

State machine

The three states of a circuit breaker

A failing dependency moves the breaker from CLOSED to OPEN. A cool-down later, HALF-OPEN probes the upstream gently before reopening the circuit.

CLOSEDcalls flow throughOPENcalls rejected fastHALF-OPENprobing upstreamfailures hit thresholdcool-down elapsedsuccessfailure

An open breaker should route somewhere useful: a second provider or smaller model for the model call, keyword search for a retriever, and for a write tool, usually a queue for a human rather than a dropped action.

Make every side effect idempotent

Retries, worker restarts and resumed checkpoints all mean the same tool call can be executed more than once. For reads that is harmless. For “issue refund” or “send email” it is an incident. The fix is the same one payment APIs settled on years ago: an idempotency key. Stripe’s implementation is the reference most teams know: the client sends an Idempotency-Key header, the server stores the first result for that key and returns it on retries, and it rejects a reused key whose parameters differ from the original request.3

For agents, derive the key deterministically from the run and the step, never from a fresh random value at execution time, otherwise a retry after a crash generates a new key and defeats the point.

def execute_side_effect(run_id: str, step_id: str, call, store, client):
    key = f"{run_id}:{step_id}:{call.name}"
    args_hash = sha256_hex(canonical_json(call.args))

    prior = store.get(key)
    if prior is not None:
        if prior.args_hash != args_hash:
            raise IdempotencyConflict(key)        # same step, different args: stop
        return prior.result                       # replay: no second refund

    store.reserve(key, args_hash)                 # unique constraint in Postgres
    result = client.post(call.endpoint, json=call.args,
                         headers={"Idempotency-Key": key}, timeout=15)
    store.complete(key, result)
    return result

This matters even more with durable execution and human approval steps. In LangGraph, for example, when a run resumes after an interrupt(), the node restarts from the beginning, so any code before the interrupt runs again.4 If that code wrote to an external system, it writes twice unless it is idempotent.

Structured outputs, then validate anyway

By 2026 the major model APIs offer schema-constrained generation. Anthropic’s structured outputs, for example, use constrained decoding to guarantee that responses match a JSON schema, with a separate strict mode for tool inputs.5 Use it: it removes a whole class of parse-and-retry code.

It does not remove validation. The same documentation lists the edges you still have to handle: a refusal can produce output that does not match your schema, hitting max_tokens can truncate it, and several JSON Schema constraints (numeric ranges, string lengths) are not enforced by the API.5 More importantly, a schema guarantees shape, not truth. An order ID that matches the regex can still be the wrong order. We validate in two passes: shape with a schema library, then business rules against the real state.

class RefundDecision(BaseModel):
    order_id: str = Field(pattern=r"^ord_[a-z0-9]{12}$")
    action: Literal["refund", "deny", "escalate"]
    amount_cents: int = Field(ge=0)
    reason: str = Field(max_length=500)

def parse_decision(raw_json: str, order) -> RefundDecision:
    d = RefundDecision.model_validate_json(raw_json)       # pass 1: shape
    if d.order_id != order.id:                             # pass 2: facts
        raise RuleViolation("decision refers to a different order")
    if d.amount_cents > order.refundable_cents:
        raise RuleViolation("refund exceeds refundable amount")
    return d

When validation fails, send a short, specific error back to the model and allow one or two repair attempts, counted against the step budget. Beyond that, escalate. A model that cannot produce a valid decision in three tries is telling you the task or the context is wrong.

Design tools for the model, and give them least privilege

Many agent reliability problems we debug turn out to be tool problems: too many thin API wrappers to choose from, or results so large they crowd out the instructions. Anthropic’s guidance matches our experience: build fewer tools shaped around workflows rather than one per endpoint, namespace related tools, return high-signal fields instead of raw IDs, paginate or truncate large results by default, and write error messages that tell the agent what to do differently.6

Then treat each tool as a privilege. We sort tools into three tiers and attach controls to the tier, not to individual prompts:

TierExamplesControls
ReadSearch the knowledge base, look up an order, list invoicesScoped to the end user’s data; rate limits; result size caps
Reversible writeDraft a reply, add an internal note, create a ticketIdempotency key; audit log; easy undo path
Irreversible or externalIssue a refund, send an email, delete data, change permissionsPolicy check in code; human approval above a threshold; never available to agents that read untrusted content without a gate

Credentials follow the same logic. The agent should act with the end user’s permissions or a narrowly scoped service identity, never an admin key that happens to be in the environment. If you expose tools through MCP, read the specification’s security sections closely. Clients must treat tool annotations (such as hints that a tool is read-only or destructive) as untrusted unless they come from a trusted server, and the spec recommends confirmation prompts for sensitive operations and timeouts on tool calls.7 The MCP security guidance explicitly forbids “token passthrough”, where a server forwards a client’s token to a downstream API without checking it was issued for that server, and recommends a progressive, least-privilege scope model over requesting every scope up front.8 Managed platforms are starting to provide this layer too; Amazon Bedrock AgentCore, for instance, offers a Policy service that integrates with its Gateway to intercept tool calls before they execute.9

Put humans in front of irreversible actions

Human-in-the-loop is not a sign the agent is weak. It is how you get an agent into production at all when the actions have real consequences. Three rules make approvals work in practice:

  • Code decides when approval is needed, not the model. A policy function looks at the tool, the arguments and the context (amount, customer tier, whether untrusted content is in the context) and returns allow, deny or require approval.
  • Approve the exact action. Show the reviewer the literal tool name and arguments, not the model’s summary of them, and bind the approval to a hash of those arguments so nothing can change between approval and execution.
  • Approvals expire. An approval from yesterday for a refund on an order that has since been cancelled should not execute. Re-check preconditions at execution time.

Durable frameworks make the pause itself straightforward. In LangGraph, a node calls interrupt() with a payload, the run is checkpointed, and it resumes when the application invokes the graph again with Command(resume=...) on the same thread.4

from langgraph.types import interrupt, Command

def execute_refund(state):
    call = state["pending_call"]
    verdict = policy.check(call, state)               # pure function, safe to re-run
    if verdict == "deny":
        return {"result": "denied_by_policy"}
    if verdict == "require_approval":
        answer = interrupt({
            "tool": call.name,
            "args": call.args,                        # exactly what will run
            "args_hash": call.args_hash,
        })
        if not answer["approved"] or answer["args_hash"] != call.args_hash:
            return {"result": "rejected_by_reviewer"}
    return {"result": execute_side_effect(state["run_id"], state["step_id"],
                                          call, store, payments)}

# Later, from the review UI:
graph.invoke(Command(resume={"approved": True, "args_hash": h}),
             config={"configurable": {"thread_id": run_id}})

Assume tool output is hostile: prompt-injection defences

The moment an agent reads content it did not write (web pages, emails, support tickets, documents, other agents’ outputs), that content can contain instructions. OWASP ranks prompt injection as the top risk for LLM applications and describes the indirect form, where the payload arrives through external sources such as websites or files.10 There is no reliable way to make a model ignore injected instructions through prompting alone, so the defence has to be architectural.

Simon Willison’s “lethal trifecta” is the most useful threat model we know: an agent with access to private data, exposure to untrusted content and the ability to communicate externally can be tricked into exfiltrating that data.11 The practical rule is to never give one agent context all three without a deterministic gate in between. A June 2025 paper by Beurer-Kellner and colleagues formalises six design patterns for doing this, including action-selector, plan-then-execute, dual LLM, and context minimisation.12 In production we combine a few of them:

  • Plan before reading. Fix the sequence of tool calls from the trusted user request before any untrusted content enters the context, so injected text can influence data but not which actions run.
  • Quarantine untrusted content. A separate model call with no tools summarises or extracts from untrusted text into a strict schema; the privileged agent only sees the structured result.
  • Allow-list actions and egress. Each agent gets the smallest set of tools its job needs, and outbound requests go only to known hosts. An agent that can fetch arbitrary URLs can leak data in a query string.
  • Track taint. Once untrusted content is in the context, sensitive actions require approval for the rest of the run.
ALLOWED = {"search_kb", "get_order", "draft_reply"}          # no send, no open fetch
SENSITIVE = {"draft_reply"}
EGRESS_HOSTS = {"kb.internal.example", "orders.internal.example"}

def authorize(call, ctx) -> Verdict:
    if call.name not in ALLOWED:
        return Deny("tool not allow-listed for this agent")
    url = call.args.get("url")
    if url and urlparse(url).hostname not in EGRESS_HOSTS:
        return Deny("egress host not allowed")
    if ctx.tainted and call.name in SENSITIVE:
        return RequireApproval("untrusted content is in context")
    return Allow()

Durable execution for long-running agents

An agent that runs for thirty seconds can keep its state in memory. One that waits on a human for a day, or spans several deploys, cannot. Persist state at every step boundary so a crashed worker resumes instead of starting over.

There are two mature approaches. Checkpointed graphs save the agent’s state after each step. LangGraph checkpoints at every super-step, keyed by a thread_id, keeps the writes of nodes that succeeded when a sibling fails so they are not re-run on resume, and offers three durability modes that trade write latency against the risk of losing the last step.13 Workflow engines such as Temporal record the result of every activity and replay the workflow code on recovery. Temporal’s integration with the OpenAI Agents SDK runs agents inside durable workflows with model calls and tool executions as activities; the Python integration was announced generally available in March 2026.14

ApproachUse whenWatch out for
In-process loopRuns finish in seconds; losing one is cheap; no approvalsSilent loss on deploys; no replay for debugging
Checkpointed graphRuns of minutes to days; human pauses; you want to inspect and fork stateCode before an interrupt re-runs on resume; checkpoint storage growth on long threads
Workflow engineAgents embedded in business processes with timers, compensation and strict audit needsWorkflow code must stay deterministic; non-deterministic work (model calls, I/O) belongs in activities

Durability also changes how you deploy. A run that started on version N may resume on version N+1 with a different prompt or tool schema. Version your state, and drain or pin in-flight runs. Anthropic describes using rainbow deployments for exactly this reason, shifting traffic gradually while old and new versions run side by side.15

Trace everything, using the OpenTelemetry GenAI conventions

The trace view is the feature that makes every other feature debuggable. Every model call, tool call, validation failure, policy decision and budget stop should be a span in one tree per run. Rather than inventing a schema, use the OpenTelemetry GenAI semantic conventions. They define an invoke_agent span for agent invocations, an execute_tool span named execute_tool {gen_ai.tool.name}, and attributes such as gen_ai.operation.name, gen_ai.agent.name, gen_ai.conversation.id, gen_ai.tool.call.id and token usage counts.1617 The conventions are still marked Development, so pin the version your collector and backend expect and expect some attribute churn. Adopting them now still beats a home-grown schema: your traces line up with any tooling that follows the conventions, and migrating later is a rename rather than a redesign.

tracer = trace.get_tracer("support-agent")

def execute_tool_traced(call, run):
    with tracer.start_as_current_span(f"execute_tool {call.name}") as span:
        span.set_attribute("gen_ai.operation.name", "execute_tool")
        span.set_attribute("gen_ai.tool.name", call.name)
        span.set_attribute("gen_ai.tool.call.id", call.id)
        # Your own namespace for the things that explain incidents:
        span.set_attribute("app.run_id", run.id)
        span.set_attribute("app.budget.steps_used", run.budget.steps)
        span.set_attribute("app.policy.verdict", call.verdict)
        span.set_attribute("app.idempotency_key", call.idempotency_key or "")
        return execute_tool(call)

Two cautions. Prompt and tool content often contains personal data, so decide deliberately whether content goes on spans, into separate storage with its own retention, or nowhere. And alert on agent signals, not just HTTP errors: budget stops per hour, repair attempts per run, approval rejection rate, and cost per successful task.

Evals in CI: outcomes first, trajectories for guardrails

Prompts, tools and models all change, and any change can quietly break behaviour. Agent evals belong in CI like any other test suite. Anthropic’s eval guide gives a vocabulary we have adopted: tasks, trials, graders, transcripts and outcomes, where the outcome is the final state of the environment (does the refund exist in the database?) rather than what the agent claimed. It recommends grading what the agent produced rather than the exact path it took, and distinguishes pass@k (at least one of k trials succeeds) from pass^k (all k succeed), the stricter measure for customer-facing agents.18

In practice we run two kinds of checks. Outcome evals run the agent against a sandboxed copy of its tools and assert on the resulting state. Trajectory checks are used sparingly, for hard constraints only: never call a write tool without approval, never exceed the step budget, never touch another customer’s record.

@pytest.mark.parametrize("case", load_cases("evals/refunds/*.yaml"))
def test_refund_agent(case, sandbox_factory):
    trials = []
    for _ in range(case.trials):                       # e.g. 5
        env = sandbox_factory(case.fixtures)
        run = run_agent(case.request, env.tools, Budget(max_steps=case.max_steps))
        # Hard trajectory constraints: fail immediately.
        assert not run.called_without_approval("issue_refund")
        assert run.touched_customers() <= {case.customer_id}
        # Outcome: check the world, not the transcript.
        trials.append(env.refunds() == case.expected_refunds)
    assert all(trials), f"pass^k failed: {sum(trials)}/{len(trials)}"

The best eval cases come from production: every incident and every run a human had to fix becomes a fixture. Run a fast subset on each pull request and the full suite nightly, per model, so a provider upgrade is a measured decision.

Control cost and latency deliberately

Agent cost is dominated by repeatedly re-sending a growing context. The cheapest optimisations are structural:

  • Prompt caching. Keep the stable parts of the prompt (system instructions, tool definitions, reference material) at the front and identical across calls. Both Anthropic and OpenAI cache on exact prefix matches; Anthropic documents that changing tool definitions invalidates the cache for everything after them, and OpenAI recommends putting stable content first and variable details later.1920 Timestamps or user IDs at the top of a system prompt quietly defeat caching.
  • Model routing. Not every step needs the largest model. Classification, extraction and summarising tool output usually run well on a small model; planning and final decisions may not. Route by step type, and let your evals decide.
  • Context hygiene. Trim or summarise old tool results, cap result sizes at the tool, and do not keep a 30,000 token page in context after the three facts you needed have been extracted.
  • Parallel tool calls. Independent reads can run concurrently within one step; the wall-clock saving is often larger than any model change.

And when something does fail, degrade rather than throw. The bar is not “the agent never fails”. It is “when the agent struggles, the user still gets something useful and knows what happened”.

Production readiness checklist

This is the list we walk through when we review an agent before launch. Anything unchecked is a known risk you should be able to name.

  1. Step, token, cost and wall-clock budgets enforced in code, with a partial-result path.
  2. A repetition detector on canonicalised tool arguments.
  3. Timeouts on every model and tool call, and a run-level deadline that propagates.
  4. Retries for transient errors only, with jittered backoff, at exactly one layer.
  5. A circuit breaker per dependency, each with a defined fallback.
  6. Idempotency keys, derived from run and step, on every write tool.
  7. Structured outputs plus schema and business-rule validation, with a capped repair loop.
  8. Tools tiered by risk; credentials scoped to the user or a narrow service identity.
  9. Approval gates, decided by code, bound to exact arguments, for irreversible actions.
  10. No agent holds private data, untrusted content and external communication without a deterministic gate.
  11. State checkpointed at step boundaries; in-flight runs handled across deploys.
  12. One trace per run using OpenTelemetry GenAI conventions, with alerts on agent-level signals.
  13. Outcome evals and hard trajectory checks in CI, fed by production incidents.
  14. Cache-friendly prompt layout and model routing reviewed against cost per successful task.

None of this is glamorous. All of it is what separates an agent that earns its keep from one that quietly costs you money. If you want help hardening an agent for production, this is the work our AI agent engineering team does every week.

Sources

  1. OpenAI Agents SDK documentation, “Running agents” (max_turns, MaxTurnsExceeded, error handlers) — openai.github.io/openai-agents-python/running_agents/
  2. Amazon Builders’ Library, “Timeouts, retries, and backoff with jitter” — aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/
  3. Stripe API reference, “Idempotent requests” — docs.stripe.com/api/idempotent_requests
  4. LangGraph documentation, “Interrupts” — docs.langchain.com/oss/python/langgraph/interrupts
  5. Anthropic, Claude API documentation, “Structured outputs” — platform.claude.com/docs/en/build-with-claude/structured-outputs
  6. Anthropic Engineering, “Writing effective tools for AI agents — with agents,” 11 Sep 2025 — www.anthropic.com/engineering/writing-tools-for-agents
  7. Model Context Protocol specification 2026-07-28, “Tools” — modelcontextprotocol.io/specification/2026-07-28/server/tools
  8. Model Context Protocol, “Security Best Practices” (2026-07-28 docs) — modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices
  9. AWS, Amazon Bedrock AgentCore FAQs — aws.amazon.com/bedrock/agentcore/faqs/
  10. OWASP GenAI Security Project, “LLM01:2025 Prompt Injection” — genai.owasp.org/llmrisk/llm01-prompt-injection/
  11. Simon Willison, “The lethal trifecta for AI agents,” 16 Jun 2025 — simonwillison.net/2025/Jun/16/the-lethal-trifecta/
  12. Beurer-Kellner et al., “Design Patterns for Securing LLM Agents against Prompt Injections,” arXiv:2506.08837, Jun 2025 — arxiv.org/abs/2506.08837
  13. LangGraph documentation, “Checkpointers” (super-steps, pending writes, durability modes) — docs.langchain.com/oss/python/langgraph/checkpointers
  14. Temporal, “Production-ready agents with the OpenAI Agents SDK + Temporal,” 30 Jul 2025, updated 23 Mar 2026 (GA) — temporal.io/blog/announcing-openai-agents-sdk-integration
  15. Anthropic Engineering, “How we built our multi-agent research system,” 13 Jun 2025 — www.anthropic.com/engineering/multi-agent-research-system
  16. OpenTelemetry, Semantic conventions for GenAI agent and framework spans (status: Development) — github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-agent-spans.md
  17. OpenTelemetry, Semantic conventions for GenAI spans — “Execute tool span” (status: Development) — github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-spans.md
  18. Anthropic Engineering, “Demystifying evals for AI agents,” 9 Jan 2026 — www.anthropic.com/engineering/demystifying-evals-for-ai-agents
  19. Anthropic, Claude API documentation, “Prompt caching” — platform.claude.com/docs/en/build-with-claude/prompt-caching
  20. OpenAI API documentation, “Prompt caching” — developers.openai.com/api/docs/guides/prompt-caching

Framework, protocol and API details were checked against each primary source in October 2026. Code samples are illustrative sketches rather than drop-in implementations.