rag-agent is the production agent runtime: an explicit state-machine loop
that iterates plan → act → observe → finalize, drives tools (retrieval
today, more later), enforces four budget dimensions, and checkpoints every
step so a run is resumable. It is the graduation of the Step 2.11
AgentLoopV0 spike (a fixed for loop) into a controller-driven, budgeted,
resumable runtime.
The package depends only on rag-core — it is pure orchestration. The
two concerns that touch the outside world (retrieval and final-answer egress
governance) are the gateway's job and are wired at the boundary, so the loop
itself makes no governed SPI calls.
The runtime is exposed over two gateway surfaces that serialise the same event stream frame-for-frame:
| Surface | Entry point | Streaming |
|---|---|---|
| REST | POST /v1/agent |
Server-Sent Events (text/event-stream) |
| gRPC | rag.gateway.v1.RagService/Converse |
server-streaming |
| CLI | ragctl agent "<goal>" |
consumes the SSE stream in-process |
Wire types (AgentRequest, AgentEvent, AgentRunResult, …) live in
rag_core.agent_types so SDKs, both surfaces, and the eval harness share one
definition without a dependency cycle — the same precedent as
rag_core.gateway_types (Step 3.1) and rag_core.openai_types (Step 3.4).
Post an AgentRequest; receive a stream of AgentEvent frames, each one
SSE data: line of JSON, closed by the canonical data: [DONE] sentinel.
curl -N http://127.0.0.1:8000/v1/agent \
-H 'content-type: application/json' \
-H 'x-tenant-id: acme' -H 'x-principal-id: alice' \
-d '{
"tenant_id": "acme",
"principal_id": "alice",
"goal": "What changed in the Q3 security policy?",
"top_k": 8,
"max_steps": 6,
"budget_tokens": 4000
}'data: {"kind":"run_started","run_id":"…", …}
data: {"kind":"phase_changed","phase":"planning","step_index":0, …}
data: {"kind":"tool_started","tool_call":{"tool":"retrieve", …}, …}
data: {"kind":"tool_completed","tool_result":{"ok":true, …}, …}
data: {"kind":"checkpoint_saved","step_index":1, …}
data: {"kind":"phase_changed","phase":"finalizing", …}
data: {"kind":"answer_delta","text":"Based on 8 retrieved passage(s), …"}
data: {"kind":"run_completed","result":{…}}
data: [DONE]
import grpc
from rag_gateway._grpc_gen import rag_pb2, rag_pb2_grpc
async with grpc.aio.insecure_channel("127.0.0.1:50051") as channel:
stub = rag_pb2_grpc.RagServiceStub(channel)
req = rag_pb2.ConverseRequest(
tenant_id="acme",
principal_id="alice",
goal="What changed in the Q3 security policy?",
top_k=8,
max_steps=6,
budget_tokens=4000,
)
async for event in stub.Converse(req):
kind = rag_pb2.AgentEventKind.Name(event.kind)
if kind == "AGENT_EVENT_KIND_ANSWER_DELTA":
print(event.text)
elif kind == "AGENT_EVENT_KIND_RUN_COMPLETED":
print(event.result.status, event.result.final_answer)uv run ragctl agent "What is RAG?" --tenant acme --top-k 8 --budget-tokens 4000Drives POST /v1/agent against an in-process gateway (build_app() with the
noop wiring) over FastAPI's TestClient, printing one line per event frame
plus the terminal result. Point the same client at a running gateway to drive
a real loop.
The POST /v1/agent body / ConverseRequest payload.
| Field | Type | Default | Notes |
|---|---|---|---|
tenant_id |
TenantId |
— | required |
principal_id |
PrincipalId |
— | required |
goal |
str |
— | the task/question (min length 1) |
corpus_ids |
list[CorpusId] |
[] |
restrict retrieval to these corpora |
top_k |
int |
8 |
1–100 |
max_steps |
int |
6 |
1–50; config-level hard ceiling (defence-in-depth) |
budget_tokens |
int | None |
None |
None = uncapped on that dimension |
budget_dollars |
float | None |
None |
|
budget_wall_ms |
int | None |
None |
|
budget_iter |
int | None |
None |
operator-set per-request iteration cap |
request_id |
RequestId | None |
None |
round-trips into the result |
resume_run_id |
str | None |
None |
resume a checkpointed run instead of starting fresh |
max_steps (config ceiling) and budget_iter (operator cap) are distinct on
purpose: max_steps is always present as a safety net, budget_iter is an
explicit per-request limit. Whichever bites first sets the StopReason.
A single flat model (not a discriminated union) keeps the SSE JSON and
the gRPC mapping mechanical: kind discriminates, the optional fields carry
whatever that kind needs.
| Field | Populated on |
|---|---|
kind |
every frame (AgentEventKind) |
run_id |
every frame |
step_index |
phase_changed, tool_started, tool_completed, checkpoint_saved |
phase |
phase_changed |
tool_call |
tool_started |
tool_result |
tool_completed |
text |
answer_delta |
result |
run_completed only (the full AgentRunResult) |
error_code, message |
run_failed only |
at |
every frame (UTC timestamp) |
AgentEventKind: run_started, phase_changed, tool_started,
tool_completed, answer_delta, checkpoint_saved, run_completed,
run_failed.
run_id, request_id, goal, status (StopReason), phase
(AgentPhase), final_answer, steps (list[AgentStep]), citations,
chunk_refs, budget_consumed (BudgetSpend), elapsed_ms.
One AgentStep per planning → acting → observing cycle: index, phase,
thought, optional tool_call / tool_result, elapsed_ms. An
AgentToolResult with ok=False is a recoverable tool failure — the
loop folds the error into the transcript so the controller can react; a
non-recoverable fault raises AgentLoopError instead.
final_answer, budget_tokens, budget_dollars, budget_wall,
budget_iter, max_iterations, diminishing_returns, caller_stop.
planning → acting → observing (tool step) or planning → finalizing (finish
step); terminal done / failed.
from rag_agent import (
AgentLoop, AgentConfig,
Controller, ScriptedController, HeuristicController, LLMController,
Tool, ToolRegistry, ToolSpec, RetrieveTool, Retriever,
CheckpointStore, InMemoryCheckpointStore,
AgentSnapshot,
)Pure orchestration with three injected collaborators:
loop = AgentLoop(
controller=HeuristicController(),
tools=ToolRegistry([RetrieveTool(retriever)]),
checkpoint_store=InMemoryCheckpointStore(),
config=AgentConfig(),
)
async for event in loop.stream(ctx, request): # one AgentEvent per transition
...
result = await loop.run(ctx, request) # drive to completion
result = await loop.resume(ctx, run_id) # reload a checkpoint + continuerun / resume re-raise AgentLoopError on a failed run (the stream emits a
run_failed frame first). A resume of an unknown run_id raises
AgentLoopError inside stream before the first frame — the gateway
catches it and surfaces an in-band run_failed frame rather than aborting the
transport.
| Field | Default | Purpose |
|---|---|---|
max_steps |
6 |
hard step ceiling |
per_step_token_estimate |
256 |
flat token delta charged per step when no measured cost is returned (keeps a token-capped run terminating against cost-free noop backends) |
per_step_dollar_estimate |
0.0 |
flat dollar delta per step |
checkpoint_each_step |
True |
persist a snapshot after every step |
A Protocol — decide(ctx, *, snapshot, tools) -> AgentAction returning
CallTool(tool, args, thought) or Finish(answer, thought). Three impls:
ScriptedController— replays a fixed action queue; the backbone of the unit suite (deterministic, no LLM). Finalises once the script is exhausted.HeuristicController— credential-free default: retrieve once for the goal, then finalise from what came back. Makes the surface curl-able with zero credentials (CI, smoke tests, the noop gateway profile). Not for answer quality.LLMController— production: renders snapshot + tool specs into a prompt, asks the wiredLLMfor a JSON action, parses it (tolerant of Markdown code fences). A parse failure degrades toFinish(raw_text)so the loop always produces an answer.
A Tool is a named, JSON-schema-described capability (spec + invoke).
Tools are the only way the loop touches the outside world. The built-in
RetrieveTool (name="retrieve", default_top_k=8, max_top_k=50) wraps a
structural Retriever Protocol:
async def retrieve(
self, ctx, *, query: str, top_k: int, corpus_ids: Sequence[CorpusId] | None,
) -> list[ChunkRef]: ...The gateway adapts its policed CorpusRouter / RetrievalRouter to this
shape (_GatewayRetriever), so read_chunk governance inside
HybridRetriever applies unchanged. A RetrievalError from the backend is
recoverable — RetrieveTool returns ok=False so the controller can
retry or finalise rather than abort the whole run.
AgentSnapshot is the full serialisable state of a run at a step boundary —
the single source of truth, so resume is just "load, then keep stepping". A
CheckpointStore is save / load / delete, last-write-wins per
run_id (a snapshot already encodes the full state). InMemoryCheckpointStore
is the process-local default, keyed by (tenant_id, run_id) so run state can't
leak across tenants; a Postgres/Redis-backed store is a drop-in swap.
The loop performs no policy enforcement itself:
- Retrieval governance happens inside the wired
Retriever— the gateway adapts a policed router whoseHybridRetrieveris theread_chunkPDP call site, identical to/v1/query. - Final-answer egress governance is the gateway's job at the boundary:
both surfaces consult
PolicyEnginewithegress_textover the final answer once (at theanswer_deltaframe), caching the decision. Adenywithholds the text (the denial notice goes on the wire instead); atransformsubstitutes a redacted answer. Because the check runs before theanswer_deltaframe is emitted, denied text never reaches the wire, and the cached decision is replayed onto the terminalrun_completedframe for a consistent answer + citation view.
LLMController calls llm.complete, so controller.py is allowlisted in
tests/policy/coverage.py (same posture as rag_query.rewriter and
rag_graphrag.summarizer). The loop names no governance-relevant SPI method,
so the coverage linter does not flag it and no allowlist entry is needed for
loop.py.
Spans:
agent.run— the full loop (REST + direct callers). Attributes:rag.tenant_id,rag.agent.run_id,rag.agent.max_steps,rag.agent.steps,rag.agent.stop_reason,rag.agent.final_chunks_n,rag.agent.budget_tokens_used.grpc.converse— the gRPCConverseflow (wrapsagent.run).
Structured-log events via rag-observability:
agent.step_complete— one per completed tool step.agent.run_failed— non-recoverable orchestration fault.gateway.agent_complete/grpc.converse_complete— one per successful run on the respective surface.gateway.agent.policy_denied—egress_textdeny over the final answer.
- Controller — implement the
ControllerProtocol for a custom planner; production swapsHeuristicControllerforLLMControllersourced fromrag.yaml. - Tools — subclass
Toolandregisterit; the controller can call any tool in the registry by name (web fetch, calculators, sub-agents are future steps). - Checkpoints — implement
CheckpointStore(Postgres/Redis) for durable, cross-restart resume; the loop is unchanged. - Retriever — supply any object satisfying the
RetrieverProtocol viaRetrieveTool; the gateway's_GatewayRetrieveris one such adapter.
- architecture/agent-loop.md — state machine, budget model, resumability, reviewer checklist
- guides/agent-quickstart.md — 5-minute walkthrough
- ADR-0015 — the design decisions
- reference/agent-loop.md — the Step 2.11
AgentLoopV0spike this supersedes - reference/grpc.md — the
ConverseRPC in the gRPC catalogue