Agent Loop Termination: DeepSeek Harness vs Pi Architecture Deep Dive

This article compares how DeepSeek Harness and Pi determine when an agent loop ends, analyzing their multi-layer boundary models, state machines, turn/step debt tracking, stop hooks, tool conclusion semantics, cancellation handling, reason taxonomies, goal-driven outer loops, and the engineering trade-offs between a heavyweight event-sourced runtime and a lightweight embeddable kernel.

Architecture and Beyond
Architecture and Beyond
Architecture and Beyond
Agent Loop Termination: DeepSeek Harness vs Pi Architecture Deep Dive

Five Boundary Layers

The article begins by defining five distinct termination boundaries in DeepSeek Harness, contrasting them with Pi's flatter model:

Model request end – HTTP/stream completion (normal stop, tool call, max tokens, error, cancel).

Step end – One model call plus its entire tool batch; a step may not end a turn if the model owes a follow-up.

Turn end – Requires both: model owes no further reply, and the next-step inbox is empty. A turn can contain multiple steps (user input → model → tool batch → model revisit → tool batch → final output).

Driver activity end – A driver processes consecutive turns until its inbox is exhausted, then returns to idle.

Long-running workflow end – Goal Driver listens for idle, checks persistent goal state, and may call followup() to start a new turn; Plan mode can span multiple turns.

Pi collapses these into fewer layers: one turn_end per assistant response + tool batch, and agent_end when the internal queue drains. The author warns that conflating similar terms (step vs turn, driver activity vs invocation) distorts metrics like turn count, tool round-trips, and task completion rates.

Driver Convergence & State Machine

DeepSeek Harness drives convergence with a simple loop:

while (await this.turn()) {}
turn()

returns true (continue), false (converge), or throws (caught, then idle). Underneath lies a three-phase state machine: idlemaintenancerunning. running holds current turn, step, AbortController, and wakeRequested. maintenance retains cancel/wake latches while appearing idle externally, solving a race where a new message arrives during maintenance: the wake intent is latched, maintenance finishes, inbox is re-checked, then a new driver may start.

Cancellation paths are similarly guarded: a waking input arriving after abort is reclassified to next-turn; send() reads aborted state before inbox insertion to avoid reentrant cancellation changing classification. whenIdle() waits by repeatedly comparing activityDone references, ensuring it doesn't return prematurely if a new driver replaces the old one.

Turn State Machine

Each turn writes turn/start before claiming input. Even if pre-step rejects, system prompt assembly fails, or first message is rewritten to empty, a complete turn/end is emitted. Two internal variables track progress: turnEnds (current known end reason) and target (whether pre-step should pull from next-turn or next-step). First step starts from next-turn; subsequent steps shift to next-step. Ordinary user prompts get independent turns; tool context, runtime context, and steering enter the current turn's next step.

The loop: create step number → claim inbox messages → assemble system prompt/runtime context → execute agent/pre-step → log step/start → write user/message → call model & execute tools → log step/end → decide continuation. If pre-step rejects, turn ends blocked (no model call). If first step message is empty, turn ends completed (no model call). If a step produces an end reason but next pre-step has no message, loop exits; otherwise continues. step() returns completed, max-tokens, or null. null means tools finished but model owes a reply – a protocol signal, not an error. Turn termination is jointly decided by model debt (from tool calls) and message debt (from inbox); checking only one leads to bugs.

Stop Hooks: Data-Driven over Boolean

DeepSeek Harness triggers agent/turn-stopping when a turn can close. Plugins don't return booleans; instead they write messages to next-step to request continuation. Core loop then re-checks next-step: empty → close; non-empty → next step. This avoids boolean-combination pitfalls (any/all/override/priority) by making continuation require producible data. The trade-off: plugin authors must understand inbox targets, turn boundaries, and wakeup semantics – higher learning curve but more reliable for auditable, multi-plugin, recoverable systems.

Step Debt & Max-Tokens Handling

step()

's key duty: decide if model owes a reply. After stream ends, BlockAssembler yields finish state. Error/abort → agent/request-error waterfall (plugins may retry). Normal assembly then checks in order:

Finish = max-tokens → return max-tokens (before tool parsing).

No tool calls → return completed.

Tool calls exist → execute batch.

Batch declares concluded → return completed.

Ordinary batch → return null.

Order is critical: max-tokens precedes tool execution because truncated output may contain incomplete JSON that parsers could “complete” but with missing semantic fields; executing such calls causes real side-effects. DeepSeek Harness conservatively ends the turn with max-tokens, leaving continuation to external policy (Goal Driver, user, plugin). Pi instead generates error results for all tools (noting possible truncation) and feeds them back to the model for immediate retry in the next turn. Author prefers Harness for write-enabled agents (avoid repeating side-effects), Pi for read-only research agents (fewer manual interventions).

Sticky Turn Reasons

Harness makes max-tokens sticky: if any step hits the ceiling, the final turn/end retains max-tokens even if later steps complete normally. Logic:

if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd

. This preserves turn-level quality signal; without it, monitoring loses token-ceiling stats and Goal Driver might treat a truncated turn as fully successful. Only max-tokens gets priority retention; errors/cancels exit main flow. Future states ( partial, policy-warning, budget-exhausted) would need explicit priority or accumulated facts.

Tool Conclusion: OR vs AND Aggregation

Harness allows a tool to set concludesTurn: true on its result. Batch uses OR: any submitted result with concludesTurn === true → whole batch concluded === true. This cancels the automatic model-revisit debt but does not interrupt parallel tools, clear next-step, or suppress steering. Running tools finish, results commit in model order, additionalContexts and steering go to next-step; if next-step non-empty, turn continues. Suits authoritative tools (user decision, irreversible commit) while preserving context/steering priority.

Pi uses AND: every finalized result in a non-empty batch must have terminate: true. More conservative – one tool cannot swallow another's needed model explanation. Choice depends on terminate 's business meaning: “any tool detects global stop” → OR; “this tool needs no model processing” → AND. Harness mitigates OR aggressiveness via next-step context but cannot cover all cases (e.g., one tool concludes, another returns report needing analysis with no added context). Tool registration must restrict which tools may conclude.

Ordered Submission

Harness scheduler: parallel execution, sequential commit. Maintains nextToStart, started, committed, inFlight, and slots (by call position). Completed tools fill their slot; commitReady() commits contiguously from committed. Later tools wait for earlier slots. Increases tail latency but guarantees stable tool/result order, additional context order, replay determinism, plugin observation order, and consistent log structure across runs. Pi uses Promise.all preserving input array order. Harness adds dynamic reclassification: before adding to parallel pool, re-read tool execution mode; a prior tool may change registry making later tool exclusive – scheduler pauses, drains pool, then handles exclusive call at next barrier.

Cancellation Semantics

Harness principles: (1) stop dispatching unstarted calls, (2) drain started calls, (3) write synthetic error results for skipped calls ( tool/call + tool/result(error: aborted before dispatch)) to maintain model-tool pairing for recovery/replay. Scheduler failures are stricter: stop new dispatches, wait started calls, throw first failure – no synthetic results for remaining calls because semantic state is unknown; fabricating results would mask scheduler bugs. Logs are recovery protocols, not just UI feeds.

Pi converts tool exceptions to error results, continues context. Cancellation checks signal after each tool (sequential) or stops preparing further calls (parallel). Goal: deliver complete message stream to caller; persistent log closure less strict.

Reason Taxonomy

Harness TurnEndReason (six core): completed (normal text stop, tool conclude, empty first step – no long-term goal promise), blocked (pre-step reject), max-tokens (at least one step hit ceiling, sticky), aborted (user/parent/hook/disposed/legacy), error (structured LlmFailure / LlmError preserved, others flattened to errorChain with UNKNOWN code), interrupted (persistence recovery layer closes open turn after crash – live loop never writes it). Separating crash repair ( interrupted) from runtime abort ( aborted) matters for side-effect audit, retry decisions, user prompts.

Pi keeps provider-centric stopReason: stop, length, toolUse, error, aborted. Harness aggregates multi-step turn results; Pi reflects last model call. Production needs both: Harness retains assistant/message, request events, turn/end – larger logs.

Goal Outer Loop

Long-term goals cannot rely on turn-level completed. Harness uses external Round Driver listening for idle, then checking: agent valid, no competing prompt, goal exists, goal phase active, activation armed, round count under limit. If satisfied, constructs goal-sourced user message, calls agent.followup() → enters next-turn → new turn. Sequence: turn/end(completed)agent/status: idle → Goal Driver reads persistent state → flush → followup(goal round)turn/start. Each Goal Round gets independent, recoverable lifecycle; a giant while-loop spanning dozens of rounds complicates persistence boundaries, user preemption, exception recovery.

Goal maintains persistent phase ( active, complete, blocked, paused) and in-process activation ( armed, disarmed). Separation handles resume/fork: active goal ≠ auto-continue; restored goal defaults disarmed, requiring human re-authorization to prevent mass revival on restart. Max rounds → blocked with round-limit. max-tokens, agent error, flush/driver failure, cancel → disarm/pause. Fail-closed default for cost-sensitive, write-enabled agents; auto-retry at higher layer with persistent budget/idempotency.

Goal Completion

When goal reaches complete or blocked, current turn usually makes one more model call. update_goal tool mutates persistent state and injects closing directive via deferred context. Tool result + <goal_complete> or <goal_blocked> context enters next step; model generates final user-facing explanation; turn ends completed; agent idle; Goal Driver sees non-active phase, stops. This solves state-completion vs visible-output timing gap. Alternative (conclude turn immediately) leaves UI with only tool card; alternative (model summary then update) risks goal staying active if process fails mid-summary. Harness chooses: commit state first, then display close. Cost: one extra model call, tokens, latency – deemed reasonable for delivery-grade output.

Pi Dual Loop

Pi's runLoop() uses inner/outer loops. Inner condition: hasMoreToolCalls || pendingMessages.length > 0. Iteration: prepareNextTurn → process pending steering → call model → execute tools → emit turn_endshouldStopAfterTurn → pull new steering. Inner loop exhausts → call getFollowUpMessages(); if follow-ups exist, set as pending, re-enter inner; else emit agent_end.

Three high-leverage callbacks: getSteeringMessages() (immediate intervention), getFollowUpMessages() (post-natural-close work), shouldStopAfterTurn() (host-level stop). Suits embedded apps: extensions don't need persistent inbox or state projections. Complexity shifts to host: merging follow-ups from multiple extensions, rebuilding callback-internal transient state on recovery, fragmented termination semantics if extensions define separate Goal/Plan/approval flows. Pi core aims for small composable runtime, no unified long-term task protocol – different product boundary than Harness.

Failure Paths

Pi: on assistant error or aborted, emits turn_end + agent_end, ends invocation. Harness: distinguishes stream finish error, explicit abort, generic exception. Request errors enter agent/request-error waterfall; plugins decide retry based on provider, failure, retry policy, signal. Retry happens in same step (recreate assembler, re-request); outer turn/step boundaries unchanged. Logs stay compact but need separate attempt tracking (provider may have billed failed request). Pi delegates retry to stream impl or upper config; Loop receives final message with stopReason. Lighter responsibility, easier model-layer swap. Harness folds request header, adapter defaults, request context, conversation surface into session semantics – heavier architecture, unified request reconstruction for retry/recovery.

Request Series

Harness compares current request header against session baseline on every build. Header includes: provider/model, reasoning effort/max tokens, adapter materialized defaults, system prompt, tool schemas. First header recorded as initial or resume; config change → change; explicit new message sequence or surface replacement → series. Termination logic doesn't directly control series, but series defines “continuous context body”. Goal auto-round can mark startsRequestSeries: true giving each long-task round independent series for cache/display/replay boundaries. Without series, tool revisits, follow-ups, Goal rounds, compaction continuations all collapse into one semantic bucket. Cost: more log events, header equality/canonicalization must be stable (tool schema ordering, empty fields, adapter defaults) – code uses canonical header + deep freeze to control drift.

Log Consumption Guide

For Harness logs, first clarify which layer the business wants:

Model call complete → read step's assistant message, stream finish, usage.

Turn closed → find paired turn/start + turn/end (assistant message alone insufficient; pre-step reject, empty turn, exception, crash repair produce different structures).

Agent idle → read status or await whenIdle() (last turn/end doesn't prove idle; driver may have opened next turn).

Long-term goal done → read Goal phase ( complete / blocked / paused have distinct meanings).

Plan exited → read latest plan/mode.active ( exit_plan_mode call only means model requested exit; approval may be rejected, pending intent not yet submitted).

Session permanently ended → observe agent disposed, removed from registry, external scheduler unable to send messages (idle/completed not permanent).

Pi consumption simpler: assistant.stopReason (model call), turn_end (model+tool batch), agent_end (invocation), Goal/Plan/workflow from specific extension state. Even after agent_end, host can re-invoke Loop – not permanent termination.

Engineering Trade-offs

Harness pays high complexity: phase state machine, inbox classification, wake latch, balanced turn/step events, surface projection, request header reconstruction, crash repair, Goal activation, Plan boundary intent, ordered tool commit. Increases code, test matrix, plugin barrier. Overkill for simple chat product.

Fits: cross-process resume, high-risk tools, multi-plugin steering, long-term goals with budget, audit/replay logs, Plan/approval/execution consistency across boundaries, parent/child agent cancel propagation.

Pi fits: clear single-invocation lifecycle, host owns persistence, few extensions, app-controlled workflows, fast multi-model/tool onboarding, no strong session event vocabulary constraints. Pi's small core reduces framework-internal state; as business adds Goals, approvals, auto follow-ups, concurrent tool strategies, recovery, budgets, host gradually rebuilds similar mechanisms – complexity moves layers, doesn't vanish.

Framework choice shouldn't compare Agent Loop line counts; should ask where “permission to continue running” lives. Harness distributes it across model finish, tool results, inbox, lifecycle hooks, persistent state machines, coordinated via event log. Pi concentrates it in current loop and a few host callbacks, letting extensions define higher protocols.

Design Recommendations

If building own Agent Loop, author would keep ten principles:

Separate model end, tool batch end, user turn end, long-task end – distinct types/event names, avoid single done field spanning layers.

Continuation must carry data: plugins submit steering/follow-up messages, not bare booleans (debugging/composition nightmare).

Model revisit after tool call = debt. Ordinary tool result creates reply debt; conclude/terminate cancels debt; steering adds message debt. State machine with this lens beats stacking if (hasToolCalls).

Max-tokens needs independent result; never execute suspected-truncated tool params. Auto-repair allowed but must explicitly log recovery.

Parallel tools may complete out-of-order; persistent results must commit in model order. Don't let network timing mutate context unless protocol explicitly supports unordered tool results.

Cancellation must distinguish started vs unstarted calls. Started: drain or record unknown result. Unstarted: full synthetic result. Choice depends on log protocol.

Idle ≠ long-term completion. Goal phase, workflow state, agent lifecycle need independent states.

Mode switches at request boundaries. Plan, permissions, model routing, tool catalog changes must not take effect mid-request-batch.

Post-recovery auto-continue default OFF. Long agents cost money, mutate externals. New process taking over old task must re-obtain authorization/lease.

Termination reasons must be machine-consumable. Structured union ( completed, blocked, max-tokens, aborted, error, interrupted) beats natural-language errors for monitoring, retry, audit.

Architecture Landing

Harness distributes termination judgment across four control planes: model output (step debt), inbox (turn debt), driver (activity queue), Goal/Plan (workflow). Pi places more judgment in single run loop: tool calls + pending steering drive inner loop; follow-ups drive outer loop; shouldStopAfterTurn gives host stop point; queue drain emits agent_end.

Harness ≈ event-sourced Agent Runtime. Pi ≈ embeddable Agent execution kernel. Evaluation meaningless; focus on system boundaries. If product needs only single-prompt tool loop, Pi easier to maintain. If cross-round Goals, approvals, recovery, multi-plugin steering, high-risk tools appear, Harness layering reduces later protocol conflicts.

Hardest part of Agent Loop isn't making model call tools again. Real engineering burn: why it continues, who allowed it, can it continue after failure, should it continue after recovery, and can logs explain every choice it made.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

state machineAI Architectureagent frameworkEvent SourcingPiAgent LoopDeepSeek HarnessTermination Logic
Architecture and Beyond
Written by

Architecture and Beyond

Focused on AIGC SaaS technical architecture and tech team management, sharing insights on architecture, development efficiency, team leadership, startup technology choices, large‑scale website design, and high‑performance, highly‑available, scalable solutions.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.