11 KiB
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 for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair deliberately reuses the stable carrier captured before entry commit and applies the same per-listener containment directly. assembleContextFor(agent) builds the per-agent assembly context (agent + scope together). CreateAgentOptions.setup(agentCtx) and ResumeAgentOptions.setup(agentCtx) compose a fresh or resumed agent's scoped world while registry/store-owned reservation capabilities keep both identities unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives or publishes: driving verbs and ordinary agent/session insertion both reject until the owning publication boundary.
ctx.agents.register(agent: Agent): () => Promise<void> | void— record an already-constructed agent. Disposed with the calling fiber.- Advanced ordered lifecycle:
reserve(id)returns an opaque unpublished-identity capability whosereleaseis the exact owner effect disposer, allowing the factory to place ID release after scope quiescence instead of racing owner unload as a sibling.enter(agent, reservation?): () => voidclaims the ID across runtime pinning and stable lifecycle-carrier construction, then inserts without announcing; a Proxy trap or filter getter cannot reentrantly overwrite the commit.announce(agent)reuses that carrier and emitsagent/createdexactly once for the exact live entry, rejecting repeat or reentrant announcement. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach is exact-object guarded, so a later listener cannot observe inverted lifecycle edges and a stale capability cannot delete a replacement. While reserved, bareregister/entercalls for the id reject, including from setup. The factory uses this split; ordinary plugins useregister(). ctx.agents.get(id: AgentId): Agent | undefinedctx.agents.list(): Agent[]
Factory seam (creation)
Agent creation is provided by the plugin implementing AgentFactory (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. The registry canonicalizes an already traced Service to its concrete target, captures and validates the factory's createAgent and resume callbacks once at registration, retains that target as their intentional receiver, and passes each call an explicit caller-bound ownerCtx; later method replacement cannot redirect a transaction, double tracing cannot break raw-identity state, and a plain non-Cordis factory receives enough context to implement caller ownership.
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): Promise<AgentHandle>— snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert both session and agent, then recheck caller and factory liveness before the first creation announcement and after each later notification boundary. Only a still-live transaction opensagent/session-startand starts a new loop on the caller-suppliedsessionId. Registry/store reservation capabilities block every competing public insertion across setup; seed rejection, setup rejection, caller unload, factory unload, or cancellation from a creation listener publishes no drivable agent. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; any creation announcement that began is paired byagent/disposedorsession/disposed. Rejects if no factory is registered.ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>— snapshot caller-owned IDs/options, load a persisted session (session persistence), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert both → pre-announcement liveness check → session announcement → liveness check → agent announcement → liveness check → session-start → final liveness check → loop-start boundary. The IDs are reserved across persistence load, setup, and teardown quiescence; load/setup rejection, caller unload, or factory unload leaves no drivable or live publication, while any creation edge that already began is paired during rollback. Rejects if no factory is registered or session persistence is unconfigured.
AgentHandle = { agent: Agent; dispose(): Promise<void> }. The disposer is a consumer capability — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. dispose() from any owner reaches one memoized quiescence boundary: it stops the loop, awaits its exit plus every outstanding idle-injection flush (not just the disposed status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started session/flush before the session is detached and keeps scoped listeners alive through those checkpoints. ctx.agents.get(id) still returns a bare Agent; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.
Live events
dsh-agent declares the live agent/* coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated Cordis event catalog; the architecture turn flow shows their order relative to durable session events.
The lifecycle edges have two important local caveats. agent/created runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following agent/session-start; that non-vetoing notification is the first supported startup injection point. agent/disposed always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls returning seam-specific decisions. agent/pre-step is a serial surface-mutation checkpoint, while agent/turn-stop is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the agent-scope runtime-design RFC.
Turn and step boundaries and the model token stream are durable session/event facts rather than mirrored agent/* notifications. Consumers read turn/*, step/*, and assistant/chunk from the session feed; tool policy and outcome observation belong to the complete pipeline documented by dsh-tools.
Agent interface (types.ts)
The handle every plugin programs against:
agent.send(content, options?)— queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record beforeagent/queuedand enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (agent/prompt-submitstill rewrites by returning replacement content).agent.steer(content, options?)— steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves likesendwhen idleagent.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-shotinjectionturn 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/ACPsession/cancelmaps 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 ofrunning(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 viaAgentHandle.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 (indsh-agent-loop), while programmatic consumers create/resume owned agents throughctx.agents.create()/ctx.agents.resume(). Replace the loop by implementingAgentand registering viactx.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 onAgent; providers create or drive ordinaryAgenthandles 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.subagentsseam.