Files
deepseek-harness/packages/agent-loop
Tianyi Cui 3783f3e178 fix(agent-loop): surface throwing step-end listener as turn error via failTurn
closeStep() previously caught and silently swallowed a throw from
agent/step-end emit. In the normal no-tool/no-steering path, this
caused runTurn to reach closeTurn(true) with reason still
{kind:completed}, so the session recorded a completed turn with
zero error events — even though a plugin had failed at a loop
boundary. This violates the contract that a throwing plugin is
contained as a turn error, not a silent success.

Now the catch calls failTurn(toError(error)), which appends the
single error event and sets reason={kind:error,…}. failTurn is
idempotent (errorReported guard), so existing error paths that
call failTurn after closeStep are unaffected.

Add regression test: a throwing agent/step-end listener during a
successful step now produces exactly one error event, a turn/end
with reason error, balanced boundaries (step/end before turn/end),
and a surviving loop.
2026-06-15 17:43:33 +08:00
..
2026-06-11 10:54:31 +08:00

dsh-agent-loop

THE concrete agent plugin: LoopAgent 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): LoopAgent Create an agent, start its loop, and register it in ctx.agents. Disposed with the calling fiber.

Injected services

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

Configuration (schemastery)

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

Agents listed in config are auto-created at startup.

Classes

  • LoopAgent — 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:

forever:
  wait for queued messages (idle)
  TURN (error-contained):
    drain queued → session('user/message') → 'turn/start'
    STEP loop:
      drain steering
      assembly = systemPrompt.assemble()
      request = waterfall agent/request
      stream llm.stream(request) → session('assistant/chunk')
      message = waterfall agent/step-result
      session('assistant/message')
      each tool-call: session('tool/call') → tools.execute() → session('tool/result')
      drain steering → session('steering/message')
      cont = waterfall agent/turn-continuation
      if !cont: 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.

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/request, agent/step-result, tools/execute, agent/turn-continuation
  • Compaction: agent/request
  • Sandbox, permission, plan mode: tools/execute
  • Sub-agents: TODO seam on AgentLoop.create()
  • Persistence: session/event + session/flush
  • UI: agent/stream-chunk + agent/* events