Files
deepseek-harness/packages/core/agent-loop/README.md
Tianyi Cui 2093a8898b loop: every request is built from the log — boundary snapshot, header events, config-only waterfall
The loop is now transmission-stateless; a request is a pure function of
(session log, this step's rendered assembly, current AgentOptions):

- The reconstruction boundary is step/start: the messages snapshot is
  taken in the same synchronous frame immediately before the step/start
  append, so the request's messages are exactly the derivation over
  events[0..stepStartSeq) — an inject() from an agent/request listener
  (or any concurrent task) lands after the boundary and joins the NEXT
  request. This changes behavior for a synchronous step/start
  session/event listener that appends content (master derived after the
  append, so such a listener could reach the current request):
  agent/pre-step is the sanctioned seam for current-request content.
- agent/request is re-typed to config-only: (agent, turn, step,
  config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes
  from AgentOptions on a loop instance's first request (explicit options
  beat the logged baseline — fork overrides and resume reconfiguration
  stay correct) and from the log's folded header afterwards; listeners
  return a replacement to switch. Content shaping through the request is
  no longer expressible — model-visible content flows through the log
  channels.
- recordRequestHeader appends whatever header event the request owes the
  log before dispatch: an 'initial'/'resume' snapshot anchoring each
  loop instance, a round-trip-verified delta on change, a 'fallback'
  snapshot when the encoding cannot express it. Session.requestHeader()
  is the log's incrementally-folded baseline.
- Requests are deep-frozen before dispatch (deepFreeze exempts the
  AbortSignal — freezing one breaks AbortController.abort() outright);
  frozen + sessionId is the loop-built marker the dev invariant keys on.

Ported from #162 and re-anchored on the log: the append-extension /
frozen-end-to-end / compaction-resend / prompt-change property tests,
plus new specs for the boundary semantics, resume anchoring, and the
end-to-end theorem (every recorded request rebuilds byte-equal from the
log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against
the real DeepSeek API. Snapshot goldens intentionally stale until the
single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00

7.3 KiB

dsh-agent-loop

THE concrete agent plugin: ReactLoopAgent and the loop driver. Implements the Agent interface and drives the session/turn/step lifecycle.

This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here.

Service: AgentLoop (ctx key: agentLoop)

Public API

  • ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent — config-driven create: an agent on a fresh per-run session id ${id}-session-<uuid> (no cwd). Used for cordis.yml-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.

AgentLoop also implements the AgentFactory seam and registers itself via ctx.agents.setFactory(this), so plugins create/resume agents through ctx.agents (the interface):

  • ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle — programmatic create on a caller-supplied sessionId (e.g. an ACP-generated id), NOT ${id}-session; meta carries cwd/lineage/seed-boundary metadata and seed reconstructs a forked child prefix. Returns an AgentHandle — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).
  • ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise<AgentHandle> — load a persisted session via ctx.sessionPersistence (session persistence) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; resume rejects with a clear error when persistence is absent). Returns an AgentHandle.

The config-driven ctx.agentLoop.create() path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.

Injected services

agents, sessions, llm, tools, systemPrompt — all five interface services.

Configuration (schemastery)

interface Config {
  agents: Array<{
    id: string                 // required
    model?: string
  }>
}

Agents listed in config are auto-created at startup. (There is no per-agent persona: the deployment persona is dsh-system-prompt's own persona config, shared by every agent in the context.) The plugin registers the built-in model/cwd prompt variables on ctx.systemPrompt, resolved per step from the assemble({ agent }) context — runtime facts of the agents THIS loop drives, unlike the harness:identity/deployment:persona sections, which live on dsh-system-prompt so they survive a swapped loop plugin.

Classes

  • ReactLoopAgent — the concrete Agent implementation. Owns the inbox (Inbox), the per-step AbortController, and the loop driver. Everything observable happens through session events and the agent/* event taxonomy.
  • Inbox — per-agent queued + steering FIFOs (enqueue, steer, drainQueued, drainSteering, waitForQueued).

Loop lifecycle (loop.ts)

One invocation of runLoop() drives one agent for its whole lifetime:

create agent → emit agent/session-start(source)   ⟵ once, before turn 1
forever:
  wait for queued messages (idle)
  TURN (error-contained):
    'turn/start'
    each queued: waterfall agent/prompt-submit → allow (→ session('user/message'),
      inject additionalContext) | block (→ session('prompt/blocked'), drop)
    if every prompt blocked: 'turn/end'(rejected), no step  ⟵ zero-step turn
    STEP loop:
      drain steering
      assembly = systemPrompt.assemble({agent})  ⟵ renderPrompt(assembly) IS the full prompt
      await serial agent/pre-step        ⟵ surface mutation (compaction) outside the step
      boundary = session.deriveMessages()   ⟵ reconstruction boundary: same sync frame,
      session('step/start')                     strictly before step/start
      config = waterfall agent/request       ⟵ frozen seed; return a replacement to switch
      session('request/header'[-delta])      ⟵ the header event this request owes the log
      stream llm.stream(freeze({header..., messages: boundary})) → session('assistant/chunk')
      message = waterfall agent/step-result
      session('assistant/message')
      each tool-call: session('tool/call')
        → tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute]
        → session('tool/result')
      append buffered post-execute additionalContext as session('context/message')(s)
      drain steering → session('steering/message')
      cont = waterfall agent/turn-continuation → ContinuationDecision
        ({action:'continue', reason?} records reason as next-step steering)
      if action==stop (and no pending steering): break
    session('turn/end')
    await session/flush
    re-enqueue leftover steering as queued
  idle unless more queued

Error containment: a throwing plugin ends the turn, never the loop. Dispose mid-turn emits agent/status('disposed') and ends with reason disposed. A step that hits the model's output-token ceiling makes the turn end max-tokens (the rule: any max-tokens step in the turn surfaces as max-tokens; disposed/aborted/error still take precedence) — distinct from a clean completed stop.

Cancellation: agent.cancel() is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the running flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends aborted; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step AbortController directly on disposal and from cancel(); that controller is loop-internal, not a public verb.)

What is NOT here

Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:

  • Hooks: agent/session-start, agent/prompt-submit, agent/pre-step, agent/request, agent/step-result, tools/pre-execute, tools/post-execute, agent/turn-continuation
  • Compaction: agent/pre-step
  • Sandbox, permission, plan mode: tools/pre-execute (deny/ask gate), tools/post-execute
  • Sub-agents: implemented outside the loop as ctx.subagents providers; in-process providers use ctx.agents.create() and owned AgentHandle teardown, while child streaming/progress and background/poll collection remain deferred.
  • Persistence: session/event + session/flush
  • UI: session/event (assistant token stream, boundaries, tool activity) + agent/* control events (agent/status, agent/created/agent/disposed)