Files
deepseek-harness/packages/core/agent-loop
Tianyi Cui 06cebf1bf4 fix(scope): make the dispatch carrier method-transparent for native-private subjects
ds-review-bot delta-round finding, verified: cordis hands the carrier to
listeners as `this`, and the event declarations type it Scoped<Agent> — so
driving the subject through it (this.send(...) in an agent/* listener) is a
SUPPORTED shape. The withProps-based carrier delegated gets with the PROXY
as receiver, so ReactLoopAgent's send/steer/cancel — which read the
native-private #carrier through a getter — threw TypeError when called that
way (private members do not exist on proxy receivers).

scopeTarget now builds its own proxy: overlay props (the composed filter and
the carrier mark) answer from a null-shadowed literal via hasOwn (`in` would
let Object.prototype's toString/constructor shadow the subject's), every
other get delegates with the BASE as receiver (getters see the real object)
and returns functions bound to the base (method calls execute on the real
receiver), sets land on the base. A proxy-invariant guard reports frozen own
function props unchanged (binding them would violate the get invariant).
This kills the class at the seam — any subject with native privates works,
today's agents and whatever carries them next — instead of patching the one
#carrier field.

Pinned both ways: a scope.spec matrix (native-#private method/getter through
the carrier mutates the real object; set delegation; frozen-own-prop
invariant; overlay non-shadowing) and the bot's exact end-to-end scenario
(an agent/session-start listener calling this.send drives a real turn) —
both fail with TypeError against the withProps carrier.
2026-07-09 14:21:58 +08:00
..

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

Lifecycle (scoped): the composite creation effect mints the agent's scope (agent.ctx), enters the session through it (the session's dispatch carrier), registers the agent, runs CreateAgentOptions.setup, emits agent/session-start, then starts the loop; teardown runs stop/drain → unregister → detach session → unwind scope, keeping store/registry rollback synchronous on every failure path. All agent/* dispatches go through agentEvents(ctx, agent); per-step assembly through assembleContextFor(agent); the turn-end durability checkpoint through ctx.sessions.flush(session).

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