HLD: Autonomous Loop — LangGraph adopt-and-wrap
| Field | Value |
|---|---|
| Status | Draft — AWAITING FOUNDER APPROVAL (architectural direction) |
| Version | 1.0 |
| Date | 2026-06-17 |
| Author | Principal Technical Architect Agent |
| Issue | #1422 (T4) |
| Tracking | #1418 · Milestone #20 — Runtime Revamp (Adopt & Wrap, ADR-0019) |
| Ratified by | ADR-0019 (Decision §1–§2) |
| Depends on | T1 #1419 (tools real), T2 #1420 (compaction revived), T3 #1421 (governance wrapper) landing first |
| References | ADR-0012 (additive-vs-replacement), ADR-0013 (abstraction), ADR-0015 / #1226 (A2A), #1184 (decision receipt) |
| Implementation gate | Founder approval on #1422 before LLD or any code. This HLD adopts LangGraph as the autonomous-runtime foundation and rules on additive-vs-replacement + the hard on-prem rule. |
1. Executive summary
Verified on origin/main: the worker today is not a pure single-turn relay — it is a shallow LangGraph think-act-observe loop (graph/agent_graph.py) bounded by a single termination axis, loop_count >= max_loops (graph/edges.py:should_continue). It assembles context, calls the LLM, parses tool calls, executes them, ingests the result, and loops. What it lacks is depth: there is no explicit plan, no goal model, no todo decomposition, no notion of progress, and only one stop condition (budget). Tools are stubbed (MCP_STUB_MODE=true, execute_tools returns [stub]) and compaction is dead code — both fixed by T1/T2.
This HLD specifies the deep autonomous loop: a plan → act → observe → reflect cycle that decomposes a goal into a todo plan, pursues it across multiple turns, re-plans on new observations, and terminates on three axes — goal-complete, no-progress/stall, and budget — instead of budget alone. It keeps the Go shell as the outer authority (ADR-0019 §1): the loop calls out to the Context Engine for memory (assembly + RAG + the T2-revived compaction), to the BYOK gateway for the model, to the MCP proxy for tools, and to the embedded governance PDP for every hop.
The one load-bearing recommendation (see §4): the goal/plan loop lives orchestrator-side (Go), not inside the LangGraph worker graph. The worker stays a stateless single-cycle executor that the orchestrator drives step-by-step, rehydrating from a checkpoint each step. This is the only placement that preserves the stateless-worker and immutable-session tenets without re-introducing in-process loop state that survives across the iteration boundary.
This is additive (ADR-0012): the existing shallow-loop relay path is kept for on-prem / multi-tenant; the deep autonomous loop is one option, not a wholesale replacement. The runtime remains self-hostable OSS (hard on-prem rule).
2. Where this sits in the merged substrate (verified by inspection)
| Concern | What exists today | File |
|---|---|---|
| Loop topology | Shallow think-act-observe; `assemble → validate → call_llm → parse → [execute_tools → ingest → assemble] | END` |
| Termination (only budget) | should_continue: error → complete; loop_count >= max_loops → loop_limit; pending tool calls → continue; else complete | graph/edges.py:18 |
| Loop counter | loop_count incremented in ingest_result; max_loops default 25 from snapshot | graph/nodes.py:614, graph/state.py:139 |
| State schema | AgentState TypedDict — fully serializable, no plan/goal/progress fields | graph/state.py:54 |
| Stateless execution | LangGraphExecutor.execute_step runs compiled.ainvoke(state); checkpoint()/restore() serialize the whole state, strip keys + events | executor/langgraph_executor.py:155,286,307 |
| Tools (stubbed — T1) | execute_tools returns [stub]; MCP_STUB_MODE default true; mcp_client not injected in running path | graph/nodes.py:421,716 |
| Per-call tool deny | _is_tool_denied(name, denied_tools) blacklist check before execute | graph/nodes.py:454,708 |
| Context memory | assemble_context node calls ContextEngineClient (assembly + RAG) | graph/nodes.py:88, context/client.py |
| Compaction (dead — T2) | internal/context/compaction exists; assemble hook stubbed pipeline.go:365; worker never started in cmd/context-engine/main.go | (Go core) |
| BYOK adapter | LLMAdapterFactory builds adapter per-request from provider_keys; lazy compile (#993) | executor/langgraph_executor.py:43,146 |
| Orchestrator pause/resume | PauseSession/ResumeSession RPCs over session.PauseManager; checkpoint key derivation per iteration | internal/runtime/server/lifecycle_pause.go, lifecycle_pause_checkpoint_test.go |
| Approval chain | approval.Service (Wave 2 LLD 6) wired in gRPC server; pause primitive shared | internal/runtime/server/grpc.go:150,327 |
| Structured answer | Executor sniffs final response for typed payload (Quad renderer) | executor/langgraph_executor.py:385 |
The structural gap this HLD closes: today the loop has no memory of intent across turns beyond the message list, and the only reason it stops is running out of budget. A deep agent needs (a) an explicit, inspectable plan; (b) a progress signal to detect stalls; (c) goal-completion detection; and (d) all three living somewhere durable so a killed worker resumes mid-plan. The placement decision (§4) determines where (a)–(d) live.
3. Architecture diagram
Data flow: the orchestrator owns the plan and the cursor; each step it hands the worker the next sub-goal plus the rehydrated checkpoint, the worker runs one assemble→…→ingest cycle (which may fire several governed tool calls inside its ToolNode), returns a step result + progress signals, and the orchestrator updates the plan, re-evaluates the three termination axes, persists a new checkpoint, and decides whether to step again, pause for approval, or finish.
4. Placement decision — orchestrator-side goal/plan loop (RECOMMENDED)
Decision: the goal/plan loop is implemented orchestrator-side in Go. The LangGraph worker stays a stateless single-cycle executor.
4.1 The two options
| A. Orchestrator-side plan loop (RECOMMENDED) | B. In-graph planner | |
|---|---|---|
| Where plan/goal/progress live | Go orchestrator + agent_checkpoints | Inside AgentState, inside the LangGraph graph |
| Worker statefulness | Stateless per cycle; re-hydrated each step | Stateful across the whole plan (loop runs to completion in-process) |
| Iteration boundary | Every sub-goal = an ExecuteStep RPC + checkpoint write | One long ainvoke spanning the entire plan |
| Termination logic | Go (testable, governed, observable in core) | Python edges (should_continue-style) |
| Approval pause | Orchestrator decides between steps; clean resume from checkpoint | LangGraph interrupt() mid-graph; resume needs the in-process graph alive or a checkpointer |
4.2 Why A wins (justification against the tenets)
-
Stateless worker / immutable session (the dominant constraint). The worker today is explicitly stateless (
AR-F02,executordocstring: "worker can be killed and replaced at any checkpoint boundary"). Option B makes the worker hold the entire plan's live state across many turns inside oneainvoke— if the pod dies mid-plan, the plan dies with it unless we add a LangGraph persistent checkpointer that duplicatesagent_checkpoints. Option A keeps the existing per-cycle checkpoint boundary as the only state-survival mechanism; the plan is just more fields in the checkpoint the orchestrator already writes. -
Governed-every-hop. Termination, re-plan, and the budget gate are governance-relevant decisions. In Go they sit next to the embedded PDP and the audit chain; the policy bundle hash (#1184) and per-hop checks live there already. In Python they would be a second, harder-to-attest decision surface.
-
Approval pause/resume across a multi-step loop. Pause/approval today is an orchestrator concern (
lifecycle_pause.go,approval.Service). With A, the natural pause point is between sub-goals — the orchestrator already owns that boundary and the checkpoint key per iteration. With B, pause must use LangGraphinterrupt()deep inside a running graph, and resume must reconstruct that exact graph instance — fragile under pod churn. -
Observability + dogfooding. Plan, cursor, and progress in core means they show up in the same OTel spans, metrics, and audit chain we already emit per step. The agent team (us) can inspect a stalled plan in the same place we inspect everything else.
-
Minimal blast radius. A changes the worker the least: the existing single cycle is preserved; we add a
step_goalinput and structuredprogressoutput. B requires re-architecting the graph into a long-running planner with new in-graph state and a checkpointer.
4.3 What B still gives us (kept as a bounded inner capability)
We do not ban in-graph iteration entirely. Inside one orchestrator step the worker may still run its existing short assemble→…→ingest micro-loop and ToolNode may execute several tool calls — that is one "cycle." LangGraph's interrupt() is used only for an in-step approval (a single tool call that trips a require-approval policy), mapped to the orchestrator's approval service (§7.4). The macro plan loop is Go; the micro cycle is LangGraph. This split is the crux of the recommendation.
5. The deep autonomous loop (component design)
5.1 Plan model (orchestrator-side, persisted in the checkpoint)
A Plan is a typed structure the orchestrator owns:
Plan
├── goal : the user/parent objective for this run
├── todos[] : ordered sub-goals { id, text, status: pending|active|done|abandoned, attempts }
├── cursor : index of the active todo
├── progress[] : per-step progress signals (see 5.3)
├── replans : count of re-plan events (a stall axis)
└── budget : { loops_used, loops_max, tokens_used, tokens_max, wall_ms }
It is additive to AgentState — it rides in the checkpoint blob the orchestrator already writes (checkpoint() serializes the full state today; we extend the checkpoint schema, bumping the OQ-5 schema-version header). No new persistence store.
5.2 Phases per orchestrator step
- Plan / decompose (first step, or on re-plan): the orchestrator issues a planning
ExecuteStepwhosestep_goalis "decompose the goal into a todo plan." The LLM returns a structured plan; the orchestrator parses + stores it. Decomposition is an LLM call through BYOK, governed like any other. - Pursue (act): orchestrator selects the active todo, issues an
ExecuteStep(step_goal = active todo). The worker runs one assemble→…→ingest cycle;ToolNodefires governed tool calls. - Observe: worker returns step result + token/cost deltas + a progress signal (5.3).
- Reflect / re-plan: orchestrator updates todo status, appends progress, and decides: advance cursor, mark todo done, re-plan (insert/abandon todos), or stop.
- Terminate check: evaluate the three axes (5.4). If none fire, loop to step 2.
5.3 Progress signal (how we detect a stall — new)
Each step the worker returns a small structured StepProgress: { made_tool_calls, tool_calls_errored, produced_new_artifacts, todo_marked_complete_by_model, response_delta_hash }. The orchestrator derives a progress score and detects no-progress when, over a configurable window (e.g. 3 steps), there are no new artifacts, no completed todos, and a repeating response_delta_hash (the model is looping/repeating itself). This is the signal should_continue lacks today.
5.4 Termination conditions (three axes — today only one)
| Axis | Condition | Disposition | Today? |
|---|---|---|---|
| Goal-complete | All todos done, or model emits an explicit completion sentinel and the orchestrator's check confirms no pending todos | status=complete, emit final response | No |
| No-progress / stall | Progress score flat over the stall window (5.3) or replans exceeds a cap | status=stalled, emit best-effort partial + structured reason | No |
| Budget | loops_used >= loops_max (existing max_loops) or token/wall budget exceeded | status=budget_exhausted (today: forced loop_limit_handler) | Yes (only this) |
Budget is the only axis that exists today; the HLD adds the other two and unifies them behind the orchestrator's terminate check. The existing loop_limit_handler semantics are preserved as the budget axis.
5.5 Memory provider — Context Engine (assembly + RAG + compaction)
The worker's assemble_context node already calls the Context Engine for layered assembly + hybrid RAG. The deep loop changes nothing structurally here except that long autonomous runs now actually reach the context ceiling — which makes T2 compaction a hard dependency, not a nice-to-have. Without the T2 threshold producer (pipeline.go:365) and the started compaction worker (cmd/context-engine/main.go), a long plan truncates instead of summarizing and the agent loses earlier todos' results. This HLD's deep loop is non-functional past the context window until #1420 lands. RAG-as-attachable-entity (#1377) is the source-of-knowledge backend, orthogonal to loop control.
5.6 BYOK model adapter
Unchanged from today: LLMAdapterFactory builds a per-tenant adapter from provider_keys on the wire; Claude/Gemini are pluggable backends behind BYOK, never the runtime (ADR-0019 §2, ADR-0013). Planning and pursuit calls both route through this adapter. Provider server-side tools stay disabled (ADR-0019 §3 forbidden path) — enforced in code review.
5.7 Tools via the governed path
Tools execute only through the two governed paths (ADR-0019 §3): our ToolNode wrapped by the T3 deterministic governance wrapper (per-call engine.Check() inside the parallel-tool loop — not once per batch) and the MCP proxy egress as the un-bypassable PEP. T1 makes this real (inject mcp_client, flip MCP_STUB_MODE off). The deep loop adds no new tool path.
6. State, checkpointing & stateless-worker preservation
- The Plan (5.1) is serialized into the checkpoint blob the orchestrator already produces. Checkpoint schema version bumps (OQ-5 header);
restore()decodes it; older checkpoints without a plan degrade to a single-todo plan (= today's behaviour). - Per-step rehydration: every
ExecuteStephands the worker the checkpoint; the worker rebuildsAgentStateand runs exactly one cycle, then is free to be killed. No plan state lives in-process across the iteration boundary. This is the mechanism that keeps the worker stateless and the session immutable. - Provider keys remain stripped at checkpoint time (existing contract, #993); the BYOK adapter is rebuilt per step from keys on the wire.
7. Tenet preservation matrix
| Tenet | How the deep loop preserves it |
|---|---|
| Stateless worker | Plan lives in the orchestrator + checkpoint; worker re-hydrates per step and runs one cycle (§4.2.1, §6). Pod can die between any two sub-goals. |
| Immutable session | The session snapshot is unchanged across the run; only the checkpoint (mutable, append-only versions) carries plan progress. No mutation of the immutable config. |
| Governed-every-hop | Embedded PDP engine.Check() at pre-tool (per call inside ToolNode) and pre-handoff; MCP-proxy egress + Coordinator per-hop as backstop; both over the same engine (ADR-0019 §5). Plan/re-plan/terminate decisions sit next to the PDP in Go. |
| Budget gate | Preserved as one of three termination axes (§5.4); existing max_loops semantics intact; token + wall budgets added. |
| Approval pause/resume | §7.4 below. |
| Audit | Each step, plan mutation, and termination decision emits to the existing audit chain + OTel spans (orchestrator-side). |
7.4 Approval pause/resume across a multi-step loop (LangGraph interrupt() mapping)
Two pause granularities, both resolved against the existing approval.Service + PauseManager:
- Between-step pause (macro, primary). The orchestrator's natural decision point. When a policy requires approval before pursuing the next todo (or an operator pauses), the orchestrator simply does not issue the next
ExecuteStep; it persists the checkpoint and parks the session in the approval/pause lifecycle (lifecycle_pause.go). Resume = load checkpoint, continue the plan loop. This is the dominant, clean path and needs no LangGraph interrupt. - In-step pause (micro). A single tool call inside the worker's
ToolNodetrips arequire_approvalpolicy mid-cycle. Here we map LangGraphinterrupt()to the orchestrator: the worker raises an interrupt carrying the pending tool intent; the executor surfaces it as a structuredapproval_requiredevent; the orchestrator routes it throughapproval.Service. On approval, resume re-enters the graph at the interrupt point (LangGraph checkpointer scoped to the single step only — not the whole plan), executes the gated call, and the step completes normally. The macro plan state is untouched and still lives in Go.
LLD must pin (carried from ADR-0019 §3 gotchas): gate per-call inside the parallel-tool loop, not once per batch; you cannot gate post-stream without buffering; an in-step
interrupt()resume must re-run only the interrupted tool, not the whole cycle.
8. Additive coexistence (ADR-0012)
The deep autonomous path is selected per session (a snapshot-level mode, e.g. execution_mode = autonomous alongside the existing single_turn / multi_turn). The existing shallow-loop relay path is kept and is the default for on-prem / multi-tenant until the autonomous path is proven. No code on the relay path is removed by this HLD. Both paths share the same call-out layers (Context Engine, BYOK, MCP, governance) — only the loop driver differs. Treating the autonomous path as a wholesale replacement would reopen ADR-0012 and is out of scope; it is a founder decision.
Hard on-prem rule (ADR-0019): LangGraph is MIT-licensed and self-hostable; the deep loop adds no hosted dependency. The orchestrator-side plan loop is plain Go in our own core. Air-gapped on-prem stays intact.
9. Dependency sequencing
T1 #1419 (tools real) ─┐
T2 #1420 (compaction) ─┼─► T4 #1422 deep loop (this HLD) ──► LLD ──► implementation
T3 #1421 (gov wrapper)─┘
- T1 must land — a deep loop with stubbed tools does nothing.
- T2 must land — a deep loop without compaction truncates past the window (§5.5). Hardest dependency.
- T3 must land — the per-call governance wrapper is the interception surface the loop's tool calls ride.
- Cross-agent handoff inside a plan (delegating a todo to another agent) depends on ADR-0015 / A2A (#1226); this HLD does not re-decide that and treats single-agent plans as the v1 scope. Multi-agent plan delegation is a follow-on.
10. Open risks
| # | Risk | Severity | Mitigation |
|---|---|---|---|
| R1 | Re-plan thrash — the model re-decomposes endlessly, each re-plan looking like "progress." | High | replans cap as a stall sub-axis (§5.3); cap counts toward the no-progress termination. |
| R2 | Stall detection false-positives — a legitimately slow, single-tool task looks flat. | Med | Progress score uses new artifacts OR completed todos OR response novelty; tune window per execution_mode; surface as stalled (recoverable), not error. |
| R3 | Compaction (T2) not landing in time blocks the whole deep loop past the window. | High | Sequence T2 first; gate T4 LLD start on T2 merge. |
| R4 | Checkpoint bloat — plan + progress history grows the checkpoint blob each step. | Med | Cap progress[] to the stall window; store completed-todo summaries (lean on compaction), not full transcripts. |
| R5 | In-step interrupt() resume correctness — re-entering the graph could re-run already-executed tool calls (double execution / non-idempotency). | High | LLD must pin single-tool resume + idempotency keys on MCP calls; QA must test interrupt-resume-no-double-exec. |
| R6 | Provider server-side tools silently enabled reopen the forbidden path. | High | Code-review discipline (ADR-0019 §3); add a CI assertion that server-side tools are off. |
| R7 | Termination logic split brain if any stop axis leaks back into Python edges. | Med | All three axes evaluated only orchestrator-side; the worker reports signals, never decides termination. |
| R8 | Two runtime paths to maintain (relay + autonomous) per the additive decision. | Accepted | Explicitly accepted in ADR-0019; revisit deprecating relay once autonomous is proven (founder call). |
11. Recommended de-risking spike (before full build)
Recommendation: a time-boxed one-runtime spike to validate the orchestrator-side plan loop + in-step
interrupt()mapping end-to-end on a single tenant, single agent, before committing to the full LLD/build.
Spike scope (throwaway, behind a flag, dev only — no prod, no migration):
- Extend the checkpoint schema with a minimal
Plan(goal + todos + cursor). - Implement the orchestrator plan loop for one hardcoded multi-step task with 2–3 real MCP tools (depends on T1).
- Wire one
require_approvaltool to exercise the in-stepinterrupt()→ approval.Service → resume path and prove no double execution (R5). - Run a plan long enough to trip T2 compaction and confirm earlier-todo results survive (R3).
- Measure: added per-step latency from checkpoint write + rehydrate; whether stall detection (§5.3) fires correctly on a deliberately-looping prompt.
Exit criteria: interrupt-resume is idempotent, compaction keeps long-run context, and per-step overhead is within the sub-500ms-p95 budget envelope. Only then open the full LLD. The spike directly answers R1/R3/R5 — the three High risks — at a fraction of the build cost.
12. Open items deferred to LLD
- Exact
Planproto/schema + checkpoint OQ-5 version bump + backward-compat decode. step_goal/StepProgressadditions to theExecuteStepcontract.- Stall-window + replan-cap defaults per
execution_mode. - The two ADR-0019 freshness gaps: mid-session guardrail re-pin; policy-bundle hash / decision receipt (#1184) attestation per plan step.
- Single-tool
interrupt()resume mechanics + MCP idempotency keys (R5). - CI assertion that provider server-side tools are disabled (R6).
- Metrics/spans for plan lifecycle (plan created, todo done, re-plan, terminate-by-axis).
13. Sign-off
- Founder (Vaisakh / Ashik) — adopt LangGraph as the autonomous-runtime foundation; confirm the orchestrator-side plan-loop placement (§4); confirm additive-not-replacement (§8); confirm the hard on-prem rule (§8). This HLD does not proceed to LLD without this sign-off.
- Principal Architect — authored.
- Product Manager — downstream PRD for the autonomous runtime mode.
- Backend lead — implications for
services/agent-worker(graph + executor) andinternal/runtime(plan loop, checkpoint schema).