Files
deepseek-harness/packages/core/agent
Tianyi Cui 7c5133488a refactor(core): every registry register-method returns the exact effect disposer
The exact-disposer fix (5fbac8be B1) repaired agents.register but left the
same wrapper (return () => void dispose()) at seven sibling sites:
tools.register, tools.restrict, systemPrompt.section/tools/variable,
agents.setFactory, and subagents.registerProvider. A wrapper makes correct
composite usage unrepresentable — the exact disposer cannot be recovered, so
a generator effect yielding it leaves the inner effect disposing as a
CONCURRENT SIBLING on owner unload, silently reproducing B1's ordering
corruption. The exact disposer serves both usages (composite-nestable AND
fire-and-forget callable); all seven now return it, typed
() => Promise<void> | void, with the convention pinned by a discriminating
test: an async-link composite probe that passes with the exact disposer and
observes the sibling unregistration firing mid-drain with a wrapper.

Re-auditing also surfaced that B1 itself SHIPPED a full-lint failure: it
changed register()'s return type without updating cross-file consumers
(agent.spec.ts dispose() statements, tool-bash's disposer list), which the
staged-scoped pre-commit lint never saw — pnpm run lint was red at HEAD.
Those three sites and this change's own fallout are fixed together: tests
now await disposers (stronger — they observe the full unwind), sync
paths void them, and the two annotation sites carry the honest union type.
agents.register's README line had drifted the same way (B1 updated the
JSDoc, not the README) — all seven README signatures now match; services
catalog regenerated.
2026-07-09 13:05:44 +08:00
..

dsh-agent

Agent interface, registry, and agent/* event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the Agent handle defined here — it has zero loop dependency, so the loop is swappable.

Service: AgentRegistry (ctx key: agents)

Tracks live agents so UI, hook, and orchestrator plugins can find them without importing the concrete loop package.

Public API

The scoped-registration surface: Agent.ctx is the agent's scope context (dsh-scope, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. agentEvents(ctx, agent) is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); assembleContextFor(agent) builds the per-agent assembly context (agent + scope together). CreateAgentOptions.setup(agentCtx) composes a child's scoped world at creation — setup registers, it never drives.

  • ctx.agents.register(agent: Agent): () => Promise<void> | void — record an already-constructed agent. Disposed with the calling fiber.
  • ctx.agents.get(id: AgentId): Agent | undefined
  • ctx.agents.list(): Agent[]

Factory seam (creation)

Agent creation is provided by whichever plugin implements AgentFactory (phase 1: dsh-agent-loop), registered via setFactory. This keeps creation on the dsh-agent interface so consumers (UI, the ACP bridge) program against ctx.agents without depending on the concrete loop package.

  • ctx.agents.setFactory(factory: AgentFactory): () => Promise<void> | void — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
  • ctx.agents.create(options: CreateAgentOptions): AgentHandle — construct, start, AND register a new agent on a caller-supplied sessionId (with optional meta.cwd/meta.parentSession/meta.seedLength and optional seed events for forked children). Distinct from register (which only records). Throws if no factory is registered.
  • ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle> — load a persisted session (session persistence) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured.

AgentHandle = { agent: Agent; dispose(): Promise<void> }. The disposer is a capability — only the holder can tear this agent down. dispose() stops the loop, awaits its exit (quiescence — NOT just the disposed status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final session/flush before the session is detached. ctx.agents.get(id) still returns a bare Agent — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.

Events

The full agent/* event taxonomy is declared via declaration merging in dsh-agent (not dsh-agent-loop), so plugins depend only on this package.

Lifecycle (emit)

  • agent/created, agent/disposed — registration/deregistration
  • agent/status — idle / running / disposed transition
  • agent/queued — message entered inbox (source-resolved, steering flag)
  • agent/session-start — the session lifecycle began (once, before turn 1), carrying a SessionStartSource (startup for a fresh or forked create, resume for a reloaded persisted session; clear/compact reserved). A pure notification — it cannot block startup; a listener seeds context via agent.inject() (a context/message the first request sees).

Boundaries are durable session events, not agent/* emits

Turn and step boundaries are NOT mirrored as agent/* emits: a consumer that needs them reads the durable turn/start/turn/end/step/start/step/end events off the session/event feed (the session log is the live boundary feed, carrying the Session — the turn/step numbers and reasons ride on the event data). See the event-domain-semantics RFC and the remove-boundary-mirror-events RFC.

Interception seams

agent/pre-step is a serial surface-mutation checkpoint; the rest are waterfalls that return a small, seam-specific typed Decision union (the unified idiom across the taxonomy — a CC/Codex bridge maps its permissionDecision/decision/continue fields onto these, a native plugin returns them directly):

  • agent/session-start (emit) — fired once before the first turn; a listener seeds context via agent.inject() (it cannot veto startup).
  • agent/prompt-submit — decide what happens to one drained queued message before it becomes a user/message: PromptDecision = allow (optionally rewriting the prompt content or attaching additionalContext) or block (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends rejected). Maps onto Claude Code's UserPromptSubmit.
  • agent/pre-step (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after turn/start and before step/start, so a listener's appended events land outside the step.
  • agent/request — shape the call config before the model call: a frozen LlmCallConfig seed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log (reconstructability RFC); the loop logs whatever config the request actually uses as a request/header* event
  • agent/step-result — post-process the assembled assistant message before tool dispatch (validates what the log records)
  • agent/turn-continuation — override the continue/stop decision via ContinuationDecision = {action:'stop'} or {action:'continue', reason?} (a continue reason is recorded as next-step steering in the same turn — the typed /goal pattern). Force-continue /loop, force-stop budget guard.

Tool interception is the tools/pre-execute / tools/post-execute pair in dsh-tools (PreToolDecision allow/deny/ask, PostToolDecision accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam.

Error notifications (emit)

  • agent/error — step/turn error

The model's token stream is NOT an agent/* event: read it off the durable session/event feed as assistant/chunk (the same feed persistence and the ACP bridge use).

Agent interface (types.ts)

The handle every plugin programs against:

  • agent.send(content, options?) — queue a message; starts a turn when idle
  • agent.steer(content, options?) — steer a running turn (inject between steps); behaves like send when idle
  • agent.inject(content, options?) — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot injection turn so every event stays turn-enclosed (the turn-enclosure invariant)
  • agent.cancel(reason?) — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP session/cancel maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
  • agent.whenIdle() — resolve once the agent reaches quiescence after settling out of running (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via AgentHandle.dispose(), which awaits the loop exit directly.
  • agent.session, agent.status, agent.options, agent.id

Extension points

  • Agent creation: AgentLoop.create() is the concrete config-path implementation (in dsh-agent-loop), while programmatic consumers create/resume owned agents through ctx.agents.create() / ctx.agents.resume(). Replace the loop by implementing Agent and registering via ctx.agents.register().
  • Event listeners: all agent/* events are declared here — no dependency on the loop package needed.
  • Subagent delegation: implemented by @deepseek-ai/dsh-subagent, not by a method on Agent; providers create or drive ordinary Agent handles through the factory seam, so spawn/fork/ACP transports stay outside the core agent interface.

What is NOT here (TODO)

  • Inter-agent channels beyond delegation — shared state, streaming child output, and background/poll semantics remain outside the current synchronous ctx.subagents seam.