Skip to content

HITL, cost and cache

How approval, the cost ledger, the caches and the model ports fit together around one agent step. Each layer has a concept page with the details; this page shows the wiring.

flowchart LR
  subgraph step["one agent step"]
    M["InstrumentedModel<br/>(executor.model)"] --> X["Executor<br/>run_many"]
  end
  M -- "llm entry" --> L[("Ledger")]
  X -- "tool entry" --> L
  M -- "llm.*" --> E[("EventSink")]
  X -- "tool.* · approval.*" --> E
  M <--> RC[("LLM response cache")]
  X <--> TC[("tool-result cache")]
  X <--> A["ApprovalPort"]
  RC --- C[("CachePort<br/>Memory · Redis")]
  TC --- C

Approval (HITL)

A tool with confirm=True (or a rule) goes through the executor's ApprovalPort after the before_tool hooks; the decision may edit the arguments, which are validated again. Details: Approval.

sequenceDiagram
  participant Ex as Executor
  participant Port as ApprovalPort
  participant Hu as Human
  participant T as Tool
  Ex->>Ex: confirm rule says yes
  Ex->>Port: request(ApprovalRequest)
  alt approved
    Port-->>Ex: decision (edited_args?)
    Ex->>T: call
  else denied
    Port-->>Ex: approved=False → ApprovalDenied
  else deferred
    Port-->>Ex: ApprovalPending → result status "pending"
    Hu->>Port: store.decide(approval_id, decision)
    Ex->>Port: same run + call id again → decision, consumed once
  end
Outcome Executor Tool loop
approved runs with edited_args if given the result goes back to the model
denied ApprovalDenied (an error result in batches) the error goes back to the model
pending ApprovalPending / status="pending" stops with finish="pending"; resume with the same ctx

Approval records are bound to the tool and a digest of the original arguments, so an approval never covers other arguments. Framework bridges (LangGraph, ADK, Agents SDK, Pydantic AI, …) only add a gate: a call is approved by the executor's port or by a one-use token the adapter issued after that port approved (one-use rule). One opt-in exception: an MCP server built with client_approval=True makes the MCP client's user the approver (MCP approval).

Cost ledger

flowchart TB
  LLM["InstrumentedModel call"] --> U["usage normaliser<br/>OpenAI · Gemini · Anthropic"]
  U --> E["LedgerEntry kind=llm"]
  TOOL["executor tool call"] --> E2["LedgerEntry kind=tool"]
  API["paid API (Gemini cache storage)"] --> E3["LedgerEntry kind=api"]
  E --> LG["Ledger.record<br/>prices with Prices"]
  E2 --> LG
  E3 --> LG
  LG --> S["sinks: memory · callback · JSONL"]
  LG --> SUM["summary(run_id)"]
Who records kind Entry id Cost
InstrumentedModel llm {call or run id}:llm:{stage}:{n} or metadata["call_id"] tokens × the price row; 0 on a response-cache hit
Executor tool the tool call id 0
GeminiExplicitCache api gcache:{name} cache_storage_per_hour × token-hours

Entries are idempotent on (run_id, entry_id): a re-run with the same ids replaces them. BudgetHook reads ledger.total_usd(...) before each tool and model call. Details: Cost, Instrumented models.

Cache layers

flowchart TB
  REQ["model or tool call"] --> TC{"tool-result cache<br/>tool + args + cache_scope"}
  REQ --> LC{"LLM response cache<br/>provider + model + request + port fields"}
  LC -- miss --> PL["PromptLayout<br/>stable prefix first"]
  PL --> PC["prompt-cache plug-in<br/>caps from Prices.default()"]
  PC --> API["provider API"]
  TC -- hit --> OUT["result, cost 0"]
  LC -- hit --> OUT
Layer Where Saves
Tool-result cache executor, tools with cache_ttl repeated retrievals, paid reranks
LLM response cache InstrumentedModel with cache= (executor.model) regenerations, eval re-runs: a second identical loop costs 0
Prompt layout PromptLayout(static=..., dynamic=...) makes the provider's prefix cache hit
Provider plug-ins cache_plugin= on a port prompt_cache_key / prompt_cache_options (OpenAI), cachedContent (Gemini), cache_control (Anthropic)

Details: Cache.

Model ports and the tool loop

Every port implements LLMPort (generate, stream, cache_key_fields): OpenAIModel, GeminiModel, AnthropicModel, CallableModel(fn) for a host function, and FallbackModel([a, b]) for ordered fallback on transient errors. InstrumentedModel adds the layers above to any of them. run_tool_loop(model, registry, messages) is the minimal loop for apps without a framework; tool calls run through executor.run_many. There is no model gateway (no LiteLLM). Details: Model ports, Instrumented models and the tool loop.