Skip to main content

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 ContextEngineClient speaks the real Context Engine over Connect-over-HTTP; assemble_context, validate_prompt, and ingest_result all 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_prompt fails 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 is ToolPolicyService at the gateway edge. (context/client.py:26)
  • In-step interrupts (T4-7) are a real terminal branch. A requires_approval verdict mid-batch suspends the cycle at the gated call without running ingest_result, preserving executed-sibling results for a clean resume. (nodes.py:678, edges.py:69)
  • The RuntimeService.ExecuteStep in internal/runtime/server/runtime_server.go is a Wave-1 stub (synthetic completion). The live relay is LifecycleService in lifecycle_server.go:832. Don't read the former as the hot path.
  • MCP real tool execution is dev-only / flag-gated. mcp_stub_mode defaults TRUE and MCP_GATEWAY_ENABLED defaults 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 runtime picks the executor. Default is the LangGraph executor (this doc); a claude_sdk agent runs the alternative ClaudeSDKExecutor (§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)

  1. 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) and ContextEngineToken (CE scope). (lifecycle_server.go:810, mint at :826/:830). 🟢
  2. ExecuteStep entry. The worker validates session_id/tenant_id/size, selects the per-session executor by snapshot.runtime (#1631), and streams graph events out over the server-streaming RPC. (server.py:246, :328). 🟢
  3. assemble_context calls the real Context Engine AssembleContext (system prompt + immutable L1–L4 layers + RAG + history), then folds any prior-loop tool_results into the message list. Fail-safe: a CE error degrades to empty context + WARN, never a crash. (nodes.py:148). 🟢 (was 🟡 stub pre-#2030)
  4. validate_prompt calls ValidatePrompt against the snapshot's guardrail_hashes. Advisory / fail-OPEN: an unreachable CE emits an integrity_check_skipped audit log and proceeds; a real violation sets status=error and the after_validate edge routes to END. (nodes.py:233, edges.py:96). 🟢 (advisory tier)
  5. 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). 🟢
  6. 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, an MCPClient, a gateway token, and a surviving mcp:<server_id> freeze), it calls tools/list on /mcp/team/{unit} and intersects by server prefix (LLD #1855 §5). A hard discovery failure raises MCP_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.
  7. parse_response decides the branch: tool calls present ⇒ status=acting; else status=complete with the final answer. (nodes.py:479). 🟢
  8. should_continue (conditional edge): error ⇒ complete; loop_count ≥ max_loops ⇒ loop_limit; pending tool calls ⇒ execute_tools; else complete. (edges.py:18). 🟢
  9. loop_limit_handler — forced termination (AR-F06); emits LOOP_LIMIT_REACHED and returns the last LLM response as the partial final. (agent_graph.py:49). 🟢
  10. Final answer — no tool calls, no error → END with final_response.
  11. 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_toolbut 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 error ToolResult + continue; requires_approval → in-step interrupt. (nodes.py:531/615/669/678). 🟢
  12. execute_tools (allow path) runs the tool: MCP live ⇒ mcp_client.call_tool to /mcp/team/{unit} carrying {server}__{tool} verbatim + the deterministic X-Idempotency-Key (sha256(session:loop:tool_call_id)); else _execute_tool_stub. An optional fire-and-forget post_tool_call hook is emitted when a hook client is wired (default none → 🟠). Then the clean after_execute_tools edge routes to ingest_result. (nodes.py:725, edges.py:69). 🟢 (MCP call path 🔵)
  13. In-step interrupt (T4-7). A requires_approval verdict sets an interrupt descriptor; the after_execute_tools edge routes to interrupt_handler → END without ingest_result. tool_results and loop_count stay frozen; the gated + not-yet-reached calls remain in current_tool_calls so a post-approval resume re-enters exactly at the gated call and skips executed siblings. (nodes.py:678, agent_graph.py:85). 🟢
  14. ingest_result writes the assistant message + each tool result back to the Context Engine (IngestEvent) and increments loop_count, then the graph loops to assemble_context. (nodes.py:846). 🟢

2. Component & status catalog

ComponentPathStatusNotes
Graph topology (build/compile)graph/agent_graph.py:108🟢 LIVE8 nodes, 3 conditional edges, 3 terminals
Conditional edgesgraph/edges.py🟢 LIVEafter_validate, should_continue, after_execute_tools
assemble_context (real CE)graph/nodes.py:148🟢 LIVEConnect-over-HTTP; fail-safe to empty (#2030/#2031)
validate_prompt (advisory)graph/nodes.py:233🟢 LIVEfail-OPEN; integrity_check_skipped audit
PEP pre-LLM governancegraph/nodes.py:309/1285🟢 LIVEfail-closed; default-on when orchestrator wired
call_llm + streaminggraph/nodes.py:290🟢 LIVEtokens/tool-calls/usage; cost tracked
MCP tool discovery (tools/list)graph/nodes.py:1004🔵 DEV-ONLYprefix intersection; no stub-fabrication fallback
parse_responsegraph/nodes.py:479🟢 LIVEtool-calls vs final answer
PEP per-tool (blacklist + governance)graph/nodes.py:615/678🟢 LIVEper-call; MCP-namespaced skipped (edge decides)
MCP tool execution (tools/call)graph/nodes.py:736, mcp/client.py🔵 DEV-ONLYidempotency-keyed; mcp_stub_mode default TRUE
Tool stub executorgraph/nodes.py:1355🟡 STUBBEDprod-default path today
ingest_resultgraph/nodes.py:846🟢 LIVEIngestEvent + loop_count++
Loop-limit / interrupt terminalsgraph/agent_graph.py:49/85🟢 LIVEAR-F06 / T4-7
Context Engine clientcontext/client.py🟢 LIVEConnect unary, protobuf codec
Governance client (per-call PDP)governance/client.py, main.py:107🟢 LIVEdials orchestrator; fail-closed
MCP clientmcp/client.py, main.py:97🟢 wired · 🔵 usedalways constructed; gated by mcp_stub_mode
Post-tool hook bridgegraph/nodes.py:761, hooks.py🟠 PENDINGfires only if hook_client wired (LLD 17)
ExecuteStep relay (Go, live)internal/runtime/server/lifecycle_server.go:832🟢 LIVELifecycleService relay; token mint per step
RuntimeService.ExecuteStep (Go)internal/runtime/server/runtime_server.go:57🟠 STUBWave-1 synthetic completion; not the hot path
Autonomous loop (PLAN/PURSUE)internal/runtime/autoloop/loop.go🟢 LIVEEXECUTION_MODE_AUTONOMOUS (§6)
Claude SDK executor (alt runtime)executor/claude_sdk_executor.py🟢 LIVEper-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:

#PEPWhereWhenFail mode
APre-LLM governancecall_llm_governance_block_llm (nodes.py:309)before the model is calledfail-closed → turn error
BPer-tool governance + blacklistexecute_tools (nodes.py:615/678)before each tool executes, per callfail-closed → deny result / interrupt
CGateway edge (canonical)internal/gateway/mcpproxy/dispatch.goat MCP egress, un-bypassablefail-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) and context_engine_token (CE audience, field 10). The worker never constructs or mutates them; the agent_id claim is JWKS-signed and unforgeable. (lifecycle_server.go:305, gate on mcpGate.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 as X-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 one ExecuteStep and 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:804runAutonomous). 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.