Agent Execution — the Reasoning Loop
Audience: an engineer who needs to understand what physically happens when an agent processes one message — the graph nodes, where policy is enforced, and what is real vs stubbed vs dev-only.
Status legend used throughout: 🟢 LIVE (merged, in the prod code path) · 🟡 STUBBED (placeholder, works but not final) · 🔵 DEV-ONLY (only wired for beta-dev/docker or behind a default-off flag) · 🟠 PENDING/PARKED (designed and/or coded but gated off / not yet wired).
Changelog since 2026-07-06 (imported from Knowledge-Base Google Doc)
The legacy KB tab described the process/threading model well but was stale on the loop mechanics. Corrected against code:
- Context assembly is no longer stubbed on LangGraph. The old tab (and prior scaffolds) said the worker answered "knowledge-blind" with an empty-context stub. Since #2030/#2031 the
ContextEngineClientspeaks the real Context Engine over Connect-over-HTTP;assemble_context,validate_prompt, andingest_resultall hit the live service. (services/agent-worker/src/agent_worker/context/client.py:1) - There are two policy-enforcement points inside the loop, not one. A pre-LLM deterministic governance check gates a whole turn (
call_llm), and a per-tool check gates each individual tool call (execute_tools) — both fail-closed, both distinct from the MCP gateway edge. (nodes.py:309, nodes.py:678) validate_promptfails OPEN by design (advisory tier of the two-guardrail split) — it is not the fail-closed control the tab implied. The fail-closed deterministic tier isToolPolicyServiceat the gateway edge. (context/client.py:26)- In-step interrupts (T4-7) are a real terminal branch. A
requires_approvalverdict mid-batch suspends the cycle at the gated call without runningingest_result, preserving executed-sibling results for a clean resume. (nodes.py:678, edges.py:69) - The
RuntimeService.ExecuteStepininternal/runtime/server/runtime_server.gois a Wave-1 stub (synthetic completion). The live relay isLifecycleServiceinlifecycle_server.go:832. Don't read the former as the hot path. - MCP real tool execution is dev-only / flag-gated.
mcp_stub_modedefaults TRUE andMCP_GATEWAY_ENABLEDdefaults false; the stub path runs in prod today.
0. TL;DR — the mental model
- Agent creation = DB rows (
agents+agent_configurations). Session creation = a DB row (agent_sessions) + an in-memory entry. A message/turn = one async task on the worker's event loop. No process or container is ever created in the hot path. - One turn = one
ExecuteStep. The Go orchestrator (lifecycle_server.go) relays it over gRPC to the agent-worker (a single long-lived Python asyncio process, shared across all tenants). The worker runs a LangGraph think-act-observe graph and streams events back. 🟢 - The graph is a fixed loop: assemble_context → validate_prompt → call_llm → parse_response → execute_tools → ingest_result → (loop), with three terminals: final answer, loop-limit, and in-step interrupt.
- Two PEPs live inside the loop: a pre-LLM governance check (gate a whole turn) and a per-tool governance check (gate each tool call). Both are deterministic, fail-closed, and complementary to the un-bypassable MCP gateway edge PEP (
dispatch.go) — defense-in-depth, not duplication. - Per-agent runtime selection (#1631): the session snapshot's
runtimepicks the executor. Default is the LangGraph executor (this doc); aclaude_sdkagent runs the alternativeClaudeSDKExecutor(§7).
1. The loop, numbered and colored
Colour key: 🔵 blue = gRPC entry/relay · 🟢 green = LangGraph node/edge · 🟡 amber = in-loop PEP · ⚪ grey = terminal · 🟣 purple = external service (Context Engine, team gateway).
Walkthrough (numbers match the diagram)
- Relay + token mint. The Go orchestrator selects a worker, fetches provider keys from the vault, rehydrates the session snapshot (the #1622 lazy-init fix), and calls the worker's
ExecuteStep. Per step it mints two short-TTL, agent-scoped bearers:AgentGatewayToken(MCP; empty ⇒ MCP disabled for the agent) andContextEngineToken(CE scope). (lifecycle_server.go:810, mint at:826/:830). 🟢 - ExecuteStep entry. The worker validates
session_id/tenant_id/size, selects the per-session executor bysnapshot.runtime(#1631), and streams graph events out over the server-streaming RPC. (server.py:246,:328). 🟢 - assemble_context calls the real Context Engine
AssembleContext(system prompt + immutable L1–L4 layers + RAG + history), then folds any prior-looptool_resultsinto the message list. Fail-safe: a CE error degrades to empty context + WARN, never a crash. (nodes.py:148). 🟢 (was 🟡 stub pre-#2030) - validate_prompt calls
ValidatePromptagainst the snapshot'sguardrail_hashes. Advisory / fail-OPEN: an unreachable CE emits anintegrity_check_skippedaudit log and proceeds; a real violation setsstatus=errorand theafter_validateedge routes to END. (nodes.py:233, edges.py:96). 🟢 (advisory tier) - PEP #1 — pre-LLM governance (
_governance_block_llm). When a governance client is wired (default:governance_check_enabled=true+ orchestrator address), the embedded PDP is consulted before the model is called, so an entire turn can be gated deterministically. Fail-closed on deny / requires_approval / engine error →status=error→ END. (nodes.py:309, nodes.py:1285). 🟢 - call_llm builds the tool surface, then streams the provider response (tokens, tool-call deltas, usage). Tool discovery is dual-path: when MCP is live for the agent (
mcp_stub_mode=false, anMCPClient, a gateway token, and a survivingmcp:<server_id>freeze), it callstools/liston/mcp/team/{unit}and intersects by server prefix (LLD #1855 §5). A hard discovery failure raisesMCP_GATEWAY_UNAVAILABLE— the old stub-fabrication fallback is deleted so the model never hallucinates against phantom tools. Otherwise the flat stub definitions are used. (nodes.py:290, nodes.py:1004). MCP path 🔵 dev-only; stub path 🟢 prod-default. - parse_response decides the branch: tool calls present ⇒
status=acting; elsestatus=completewith the final answer. (nodes.py:479). 🟢 - should_continue (conditional edge): error ⇒ complete;
loop_count ≥ max_loops⇒ loop_limit; pending tool calls ⇒ execute_tools; else complete. (edges.py:18). 🟢 - loop_limit_handler — forced termination (AR-F06); emits
LOOP_LIMIT_REACHEDand returns the last LLM response as the partial final. (agent_graph.py:49). 🟢 - Final answer — no tool calls, no error → END with
final_response. - PEP #2 — per-tool authorization + governance (inside
execute_tools). For each tool call, in order: (a) blacklist check_is_tool_denied(AR-F53 default-allow); (b) skip-done resume enforcement so a re-delivered batch never double-fires a side-effecting tool (T4-8); (c) per-call_governance_decide_tool— but skipped for MCP-namespaced calls ({server}__{tool}/mcp:<id>), because ADR-0024 D2 makes the gateway edge (dispatch.go) the sole PEP for those. Verdicts: allow → execute; deny/engine-error → fail-closed errorToolResult+ continue; requires_approval → in-step interrupt. (nodes.py:531/615/669/678). 🟢 - execute_tools (allow path) runs the tool: MCP live ⇒
mcp_client.call_toolto/mcp/team/{unit}carrying{server}__{tool}verbatim + the deterministicX-Idempotency-Key(sha256(session:loop:tool_call_id)); else_execute_tool_stub. An optional fire-and-forgetpost_tool_callhook is emitted when a hook client is wired (default none → 🟠). Then the cleanafter_execute_toolsedge routes toingest_result. (nodes.py:725, edges.py:69). 🟢 (MCP call path 🔵) - In-step interrupt (T4-7). A
requires_approvalverdict sets aninterruptdescriptor; theafter_execute_toolsedge routes tointerrupt_handler→ END withoutingest_result.tool_resultsandloop_countstay frozen; the gated + not-yet-reached calls remain incurrent_tool_callsso a post-approval resume re-enters exactly at the gated call and skips executed siblings. (nodes.py:678, agent_graph.py:85). 🟢 - ingest_result writes the assistant message + each tool result back to the Context Engine (
IngestEvent) and incrementsloop_count, then the graph loops toassemble_context. (nodes.py:846). 🟢
2. Component & status catalog
| Component | Path | Status | Notes |
|---|---|---|---|
| Graph topology (build/compile) | graph/agent_graph.py:108 | 🟢 LIVE | 8 nodes, 3 conditional edges, 3 terminals |
| Conditional edges | graph/edges.py | 🟢 LIVE | after_validate, should_continue, after_execute_tools |
assemble_context (real CE) | graph/nodes.py:148 | 🟢 LIVE | Connect-over-HTTP; fail-safe to empty (#2030/#2031) |
validate_prompt (advisory) | graph/nodes.py:233 | 🟢 LIVE | fail-OPEN; integrity_check_skipped audit |
| PEP pre-LLM governance | graph/nodes.py:309/1285 | 🟢 LIVE | fail-closed; default-on when orchestrator wired |
call_llm + streaming | graph/nodes.py:290 | 🟢 LIVE | tokens/tool-calls/usage; cost tracked |
MCP tool discovery (tools/list) | graph/nodes.py:1004 | 🔵 DEV-ONLY | prefix intersection; no stub-fabrication fallback |
parse_response | graph/nodes.py:479 | 🟢 LIVE | tool-calls vs final answer |
| PEP per-tool (blacklist + governance) | graph/nodes.py:615/678 | 🟢 LIVE | per-call; MCP-namespaced skipped (edge decides) |
MCP tool execution (tools/call) | graph/nodes.py:736, mcp/client.py | 🔵 DEV-ONLY | idempotency-keyed; mcp_stub_mode default TRUE |
| Tool stub executor | graph/nodes.py:1355 | 🟡 STUBBED | prod-default path today |
ingest_result | graph/nodes.py:846 | 🟢 LIVE | IngestEvent + loop_count++ |
| Loop-limit / interrupt terminals | graph/agent_graph.py:49/85 | 🟢 LIVE | AR-F06 / T4-7 |
| Context Engine client | context/client.py | 🟢 LIVE | Connect unary, protobuf codec |
| Governance client (per-call PDP) | governance/client.py, main.py:107 | 🟢 LIVE | dials orchestrator; fail-closed |
| MCP client | mcp/client.py, main.py:97 | 🟢 wired · 🔵 used | always constructed; gated by mcp_stub_mode |
| Post-tool hook bridge | graph/nodes.py:761, hooks.py | 🟠 PENDING | fires only if hook_client wired (LLD 17) |
| ExecuteStep relay (Go, live) | internal/runtime/server/lifecycle_server.go:832 | 🟢 LIVE | LifecycleService relay; token mint per step |
RuntimeService.ExecuteStep (Go) | internal/runtime/server/runtime_server.go:57 | 🟠 STUB | Wave-1 synthetic completion; not the hot path |
| Autonomous loop (PLAN/PURSUE) | internal/runtime/autoloop/loop.go | 🟢 LIVE | EXECUTION_MODE_AUTONOMOUS (§6) |
| Claude SDK executor (alt runtime) | executor/claude_sdk_executor.py | 🟢 LIVE | per-agent selection (#1631, §7) |
3. The two in-loop PEPs (and why they aren't the gateway edge)
The loop enforces policy at three distinct places; keeping them straight matters:
| # | PEP | Where | When | Fail mode |
|---|---|---|---|---|
| A | Pre-LLM governance | call_llm → _governance_block_llm (nodes.py:309) | before the model is called | fail-closed → turn error |
| B | Per-tool governance + blacklist | execute_tools (nodes.py:615/678) | before each tool executes, per call | fail-closed → deny result / interrupt |
| C | Gateway edge (canonical) | internal/gateway/mcpproxy/dispatch.go | at MCP egress, un-bypassable | fail-closed → JSON-RPC -32602/-32001 |
- A and B are the deterministic PDP dialed back into the Go orchestrator's embedded
governance.Engine. They exist so a turn/tool can be blocked at the wrapper, deterministically, before any side effect — not only at egress. - C is the sole PEP for MCP tool calls (ADR-0024 D2). That is why B explicitly skips MCP-namespaced tools (
_is_mcp_namespaced_tool, nodes.py:1059): running the generic #288 default-deny on them would default-deny every MCP call (its taxonomy matches no seeded policy). Non-MCP built-in tools still run B unchanged. - Ordering inside B per call: blacklist → skip-done (idempotency) → governance verdict → execute. See the governed-MCP KT doc for the edge gate ordering (route → OAuth terminate → clearance floor → agent allowlist → tool policy → forward).
4. State, tokens & idempotency
- Per-turn tokens (minted by Go, forwarded by the worker):
agent_gateway_token(MCP, proto field 9 — empty ⇒ MCP off) andcontext_engine_token(CE audience, field 10). The worker never constructs or mutates them; theagent_idclaim is JWKS-signed and unforgeable. (lifecycle_server.go:305, gate onmcpGate.enabled(orgID)+ #1864). 🟢 code · 🟠 prod-parked for MCP (no platform signing key, #1864). - Idempotency key
sha256(session_id:loop_count:tool_call_id)is deterministic across suspend/resume/pod-churn and distinct across logical calls. Forwarded asX-Idempotency-Key; the gateway egress is the authoritative dedup, the worker's skip-done is the local half of exactly-once. (nodes.py:1137). 🟢 - StepProgress accumulators (
step_made_tool_calls,step_tool_calls_errored,step_produced_new_artifacts) OR/ADD across sub-loops within oneExecuteStepand reset per step — nothing persists across the macro-step boundary (stateless worker). (nodes.py:822). 🟢
5. Process & concurrency model (from the KB tab, still accurate)
- The worker is a single Python asyncio process shared by all agents, sessions, and tenants. Each turn is an async task — cooperative scheduling, not an OS thread. Session state (history, compiled graph) is an in-memory dict keyed by
session_id; a worker restart triggers lazy rebuild from the request snapshot (the #1622 fix). 🟢 - The GIL is largely idle: a turn is ~99% awaiting I/O (LLM over the network, gRPC frames, CE/vault lookups). The real single-process risk is event-loop blocking — one synchronous CPU-heavy chunk (huge serialization, big JSON parse) stalls every session on that core. 🟢
- Scaling = more worker containers, not threads. The orchestrator's router pins sessions to workers by health; today it is statically seeded with one worker (the CPU-only qwen latency floor of 30–90s/turn dwarfs any scheduling effect). 🟢
- Tenant isolation is logical today — every query and RPC scoped by
tenant_id; process/namespace isolation arrives with the GKE posture. 🔵
6. Autonomous mode (PLAN / PURSUE)
When the request carries EXECUTION_MODE_AUTONOMOUS and autoloopEnabled, the orchestrator drives an orchestrator-side loop over ExecuteStep instead of a single relay turn (lifecycle_server.go:804 → runAutonomous). The autoloop issues a first PLAN step (step_goal="decompose goal …" → Plan.Todos) then PURSUE steps (one worker cycle per active Todo). Each step carries the same per-step tokens and snapshot. (internal/runtime/autoloop/loop.go). 🟢 — see the T4 autonomous-loop notes.
7. The other runtime: Claude SDK agents
Per-agent runtime selection (#1631): if snapshot.runtime == claude_sdk, ExecuteStep routes to ClaudeSDKExecutor (executor/claude_sdk_executor.py) instead of the LangGraph graph. That executor spawns a claude CLI subprocess inside the worker container — one OS process per active SDK session (reused across messages) — the scoped stateless-worker exception recorded in ADR-0020, and why SDK sessions carry a bigger memory footprint. The governance/token plumbing is shared; the reasoning loop is the SDK's own. 🟢
8. Key files & flags
Worker (Python — services/agent-worker/src/agent_worker/)
- Graph:
graph/agent_graph.py,graph/edges.py,graph/nodes.py,graph/state.py - Entry/executors:
server.py:246,executor/langgraph_executor.py,executor/claude_sdk_executor.py - Clients:
context/client.py,mcp/client.py,governance/client.py,hooks.py - Flags (
config.py):mcp_stub_mode(default TRUE),mcp_team_gateway_url,governance_check_enabled(default TRUE),orchestrator_address
Orchestrator (Go — internal/runtime/)
- Live relay + token mint:
server/lifecycle_server.go:305/826/830/832 - Autonomous loop:
autoloop/loop.go,autoloop/driver.go - Legacy stub:
server/runtime_server.go:57(🟠 not the hot path)
Related: governed-MCP & team gateway KT (docs/architecture/governed-mcp-and-the-team-gateway.md); ADR-0019 (deterministic governance), ADR-0020 (SDK subprocess exception), ADR-0024 (MCP governance realignment). LLD #111 (agent execution), LLD #515/#1855 (MCP integration), #1442/#1443 (autonomous loop + in-step interrupt), #1622 (lazy re-init), #2030/#2031 (real Context Engine).
Reflects upsquad-core@9858af5f (2026-07-24). If mcp_stub_mode flips false or #1864 resolves, re-verify §1 steps 6/12 and the 🔵 rows in §2.