Skip to main content

HLD: Autonomous Loop — LangGraph adopt-and-wrap

FieldValue
StatusDraft — AWAITING FOUNDER APPROVAL (architectural direction)
Version1.0
Date2026-06-17
AuthorPrincipal Technical Architect Agent
Issue#1422 (T4)
Tracking#1418 · Milestone #20 — Runtime Revamp (Adopt & Wrap, ADR-0019)
Ratified byADR-0019 (Decision §1–§2)
Depends onT1 #1419 (tools real), T2 #1420 (compaction revived), T3 #1421 (governance wrapper) landing first
ReferencesADR-0012 (additive-vs-replacement), ADR-0013 (abstraction), ADR-0015 / #1226 (A2A), #1184 (decision receipt)
Implementation gateFounder 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)

ConcernWhat exists todayFile
Loop topologyShallow 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 completegraph/edges.py:18
Loop counterloop_count incremented in ingest_result; max_loops default 25 from snapshotgraph/nodes.py:614, graph/state.py:139
State schemaAgentState TypedDict — fully serializable, no plan/goal/progress fieldsgraph/state.py:54
Stateless executionLangGraphExecutor.execute_step runs compiled.ainvoke(state); checkpoint()/restore() serialize the whole state, strip keys + eventsexecutor/langgraph_executor.py:155,286,307
Tools (stubbed — T1)execute_tools returns [stub]; MCP_STUB_MODE default true; mcp_client not injected in running pathgraph/nodes.py:421,716
Per-call tool deny_is_tool_denied(name, denied_tools) blacklist check before executegraph/nodes.py:454,708
Context memoryassemble_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 adapterLLMAdapterFactory builds adapter per-request from provider_keys; lazy compile (#993)executor/langgraph_executor.py:43,146
Orchestrator pause/resumePauseSession/ResumeSession RPCs over session.PauseManager; checkpoint key derivation per iterationinternal/runtime/server/lifecycle_pause.go, lifecycle_pause_checkpoint_test.go
Approval chainapproval.Service (Wave 2 LLD 6) wired in gRPC server; pause primitive sharedinternal/runtime/server/grpc.go:150,327
Structured answerExecutor 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.


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 liveGo orchestrator + agent_checkpointsInside AgentState, inside the LangGraph graph
Worker statefulnessStateless per cycle; re-hydrated each stepStateful across the whole plan (loop runs to completion in-process)
Iteration boundaryEvery sub-goal = an ExecuteStep RPC + checkpoint writeOne long ainvoke spanning the entire plan
Termination logicGo (testable, governed, observable in core)Python edges (should_continue-style)
Approval pauseOrchestrator decides between steps; clean resume from checkpointLangGraph interrupt() mid-graph; resume needs the in-process graph alive or a checkpointer

4.2 Why A wins (justification against the tenets)

  1. Stateless worker / immutable session (the dominant constraint). The worker today is explicitly stateless (AR-F02, executor docstring: "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 one ainvoke — if the pod dies mid-plan, the plan dies with it unless we add a LangGraph persistent checkpointer that duplicates agent_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.

  2. 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.

  3. 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 LangGraph interrupt() deep inside a running graph, and resume must reconstruct that exact graph instance — fragile under pod churn.

  4. 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.

  5. Minimal blast radius. A changes the worker the least: the existing single cycle is preserved; we add a step_goal input and structured progress output. 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

  1. Plan / decompose (first step, or on re-plan): the orchestrator issues a planning ExecuteStep whose step_goal is "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.
  2. Pursue (act): orchestrator selects the active todo, issues an ExecuteStep(step_goal = active todo). The worker runs one assemble→…→ingest cycle; ToolNode fires governed tool calls.
  3. Observe: worker returns step result + token/cost deltas + a progress signal (5.3).
  4. Reflect / re-plan: orchestrator updates todo status, appends progress, and decides: advance cursor, mark todo done, re-plan (insert/abandon todos), or stop.
  5. 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)

AxisConditionDispositionToday?
Goal-completeAll todos done, or model emits an explicit completion sentinel and the orchestrator's check confirms no pending todosstatus=complete, emit final responseNo
No-progress / stallProgress score flat over the stall window (5.3) or replans exceeds a capstatus=stalled, emit best-effort partial + structured reasonNo
Budgetloops_used >= loops_max (existing max_loops) or token/wall budget exceededstatus=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 ExecuteStep hands the worker the checkpoint; the worker rebuilds AgentState and 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

TenetHow the deep loop preserves it
Stateless workerPlan 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 sessionThe 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-hopEmbedded 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 gatePreserved as one of three termination axes (§5.4); existing max_loops semantics intact; token + wall budgets added.
Approval pause/resume§7.4 below.
AuditEach 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:

  1. 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.
  2. In-step pause (micro). A single tool call inside the worker's ToolNode trips a require_approval policy mid-cycle. Here we map LangGraph interrupt() to the orchestrator: the worker raises an interrupt carrying the pending tool intent; the executor surfaces it as a structured approval_required event; the orchestrator routes it through approval.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

#RiskSeverityMitigation
R1Re-plan thrash — the model re-decomposes endlessly, each re-plan looking like "progress."Highreplans cap as a stall sub-axis (§5.3); cap counts toward the no-progress termination.
R2Stall detection false-positives — a legitimately slow, single-tool task looks flat.MedProgress score uses new artifacts OR completed todos OR response novelty; tune window per execution_mode; surface as stalled (recoverable), not error.
R3Compaction (T2) not landing in time blocks the whole deep loop past the window.HighSequence T2 first; gate T4 LLD start on T2 merge.
R4Checkpoint bloat — plan + progress history grows the checkpoint blob each step.MedCap progress[] to the stall window; store completed-todo summaries (lean on compaction), not full transcripts.
R5In-step interrupt() resume correctness — re-entering the graph could re-run already-executed tool calls (double execution / non-idempotency).HighLLD must pin single-tool resume + idempotency keys on MCP calls; QA must test interrupt-resume-no-double-exec.
R6Provider server-side tools silently enabled reopen the forbidden path.HighCode-review discipline (ADR-0019 §3); add a CI assertion that server-side tools are off.
R7Termination logic split brain if any stop axis leaks back into Python edges.MedAll three axes evaluated only orchestrator-side; the worker reports signals, never decides termination.
R8Two runtime paths to maintain (relay + autonomous) per the additive decision.AcceptedExplicitly accepted in ADR-0019; revisit deprecating relay once autonomous is proven (founder call).

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):

  1. Extend the checkpoint schema with a minimal Plan (goal + todos + cursor).
  2. Implement the orchestrator plan loop for one hardcoded multi-step task with 2–3 real MCP tools (depends on T1).
  3. Wire one require_approval tool to exercise the in-step interrupt() → approval.Service → resume path and prove no double execution (R5).
  4. Run a plan long enough to trip T2 compaction and confirm earlier-todo results survive (R3).
  5. 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

  1. Exact Plan proto/schema + checkpoint OQ-5 version bump + backward-compat decode.
  2. step_goal / StepProgress additions to the ExecuteStep contract.
  3. Stall-window + replan-cap defaults per execution_mode.
  4. The two ADR-0019 freshness gaps: mid-session guardrail re-pin; policy-bundle hash / decision receipt (#1184) attestation per plan step.
  5. Single-tool interrupt() resume mechanics + MCP idempotency keys (R5).
  6. CI assertion that provider server-side tools are disabled (R6).
  7. 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) and internal/runtime (plan loop, checkpoint schema).