Merge PR #224 updates into prose cleanup

This commit is contained in:
Tianyi Cui
2026-07-12 23:36:49 +08:00
165 changed files with 11693 additions and 6395 deletions

View File

@@ -2,15 +2,17 @@
This directory contains all `@deepseek-ai/dsh-*` harness packages. Repo-wide conventions (effects, declaration merging, waterfall semantics, ESM, testing policy) are in the root [AGENTS.md](../AGENTS.md) § Conventions; the points below are packages-specific.
- **Plugin export shape: namespace or default, never both.** Service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. The Loader otherwise discards the namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **Read an optional, non-injected service with `ctx.get(name)`.** Use `ctx.<name>` only for injected services; its fiber-relative lookup is not safe for opportunistic sibling services ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **A plugin shipped through `cordis.yml` needs a real Loader-path test.** A hand-mounted plugin does not exercise `unwrapExports`; see [testing.md](../docs/testing.md).
- **Plugin export shape namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.<name>`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.<name>` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.<name>`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader/export path** hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape. Full testing policy (tiers, with-key generosity, real-entry-path guards): [docs/testing.md](../docs/testing.md).
- **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries.
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
Naming notes:
- A service `src/index.ts` default-exports the service class and named-exports public types; a function plugin named-exports its plugin namespace.
- A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (the export-shape rule above).
- `src/types.ts` contains only types — no runtime code.
- Tests live at package level under `tests/`, not `src/__tests__/`.
- Altered behavior updates the package README and JSDoc in the same commit; keep both concise under [the documentation standard](../docs/AGENTS.md).
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; prose accuracy stays on the author ([the documentation standard](../docs/AGENTS.md)).
Read the per-package README.md for package-specific details: service API, events, extension points, TODOs.

View File

@@ -1649,7 +1649,7 @@ describe('BasicCompactService under the real invariants plugin', () => {
async function setup(): Promise<{ ctx: Context; session: Session; svc: BasicCompactService }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(Invariants, {})
await ctx.plugin(Invariants)
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED'))
await ctx.plugin(BasicCompactService, cfg({ auto: false }))

View File

@@ -61,7 +61,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(Invariants, {})
await ctx.plugin(Invariants)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)

View File

@@ -54,11 +54,11 @@ export interface TypeApiEntry {
export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'agentLoop',
summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.',
summary: 'Concrete ReactLoopAgent factory and driver service.',
methods: [
'create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
'async createAgent(options: CreateAgentOptions): Promise<AgentHandle>',
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>',
'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>',
],
},
{
@@ -177,22 +177,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'subagents',
summary: 'The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface.',
summary: 'Named provider registry and capability-checked start surface.',
methods: [
'registerProvider(provider: SubagentProvider): () => Promise<void> | void',
'getProvider(name: string): SubagentProvider | undefined',
'list(): string[]',
'start(name: string, request: SubagentStartRequest): SubagentRun',
'async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>',
],
},
{
key: 'systemPrompt',
summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative contribution protections; the agent loop calls `assemble(context)` once per step.',
summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contributions; the agent loop calls `assemble(context)` once per step.',
methods: [
'section(section: PromptSection): () => Promise<void> | void',
'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void',
'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void',
'protect(protection: PromptProtection): () => Promise<void> | void',
'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
],
},
@@ -203,10 +202,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'register(definition: ToolDefinition): () => Promise<void> | void',
'restrict(filter: ToolRestriction): () => Promise<void> | void',
'guard(guard: ToolGuard): () => Promise<void> | void',
'visible(scope?: ScopeKey): ToolDefinition[]',
'get(name: string, scope?: ScopeKey): ToolDefinition | undefined',
'schemas(scope?: ScopeKey): ToolSchema[]',
'knownNames(scope?: ScopeKey): string[]',
'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
],
},
@@ -249,7 +246,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/disposed',
mode: 'emit',
signature: '\'agent/disposed\'(this: Scoped<Agent>, agent: Agent): void',
summary: 'An agent was removed from the registry after its driver and any in-flight turn reached quiescence.',
summary: 'An agent was removed from the registry.',
},
{
name: 'agent/error',
@@ -261,13 +258,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/pre-step',
mode: 'serial',
signature: '\'agent/pre-step\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void',
summary: 'Awaited checkpoint for surface mutation before `step/start` snapshots request history.',
summary: 'Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step\'s `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`.',
},
{
name: 'agent/prompt-submit',
mode: 'waterfall',
signature: '\'agent/prompt-submit\'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>',
summary: 'Waterfall: decide what happens to one drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.',
summary: 'Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.',
},
{
name: 'agent/queued',
@@ -285,7 +282,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/session-prefix',
mode: 'waterfall',
signature: '\'agent/session-prefix\'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>',
summary: 'Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the entire derived history (directly after the provider\'s system slot) on every request this loop instance sends.',
summary: 'Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider\'s system slot) on every request this loop instance sends.',
},
{
name: 'agent/session-start',
@@ -353,6 +350,12 @@ export const EVENT_API: readonly EventApiEntry[] = [
signature: '\'session/created\'(this: Scoped<Session>, session: Session): void',
summary: 'A session was created in the store.',
},
{
name: 'session/disposed',
mode: 'emit',
signature: '\'session/disposed\'(this: Scoped<Session>, session: Session): void',
summary: 'A previously announced session left the store.',
},
{
name: 'session/event',
mode: 'emit',
@@ -381,25 +384,25 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'subagent/end',
mode: 'emit',
signature: '\'subagent/end\'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void',
summary: 'A started subagent run settled — emitted when SubagentRun.result resolves (any stop reason) or rejects (reported as `error`).',
summary: 'A ready child settled.',
},
{
name: 'subagent/provider-added',
mode: 'emit',
signature: '\'subagent/provider-added\'(provider: SubagentProvider): void',
summary: 'A provider became resolvable in the SubagentService registry.',
summary: 'A provider became resolvable in the registry.',
},
{
name: 'subagent/provider-removed',
mode: 'emit',
signature: '\'subagent/provider-removed\'(name: string): void',
summary: 'A provider left the registry (its plugin\'s fiber was disposed — an unload or an HMR reload).',
summary: 'A provider left the registry.',
},
{
name: 'subagent/start',
mode: 'emit',
signature: '\'subagent/start\'(this: Scoped<SubagentService>, info: SubagentRunInfo): void',
summary: 'A subagent run started — emitted only after SubagentRun.started fulfills, when the provider has established a live child.',
summary: 'A provider established a ready child.',
},
{
name: 'system-prompt/assemble',
@@ -411,7 +414,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'system-prompt/change',
mode: 'emit',
signature: '\'system-prompt/change\'(): void',
summary: 'A section, tool provider, variable provider, or protection was registered or unregistered (the assembly inputs changed — possibly for one scope only).',
summary: 'A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only).',
},
{
name: 'tools/change',
@@ -428,20 +431,20 @@ export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'tools/post-execute',
mode: 'waterfall',
signature: '\'tools/post-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>',
summary: 'Waterfall after a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).',
signature: '\'tools/post-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>',
summary: 'Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).',
},
{
name: 'tools/pre-execute',
mode: 'waterfall',
signature: '\'tools/pre-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>',
summary: 'Waterfall before a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).',
summary: 'Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).',
},
{
name: 'tools/result',
mode: 'parallel',
signature: '\'tools/result\'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void',
summary: 'Awaited notification of the authoritative final tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
summary: 'Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
},
{
name: 'workflow/agent-end',
@@ -489,7 +492,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AgentFactory',
declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): Promise<AgentHandle>;\n resume(options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
},
{
name: 'AgentHandle',
@@ -513,7 +516,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ApprovalRequest',
declaration: 'export interface ApprovalRequest {\n agent: Agent;\n toolName: string;\n callId?: CallId;\n reason?: string;\n signal?: AbortSignal;\n}',
declaration: 'export interface ApprovalRequest {\n readonly agent: Agent;\n readonly toolName: string;\n readonly callId?: CallId;\n readonly reason?: string;\n readonly signal?: AbortSignal;\n}',
},
{
name: 'AskUserQuestionAnswer',
@@ -641,11 +644,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n agentId: AgentId;\n sessionId: SessionId;\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n };\n seed?: SessionEvent[];\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise<void> | void;\n}',
declaration: 'export interface CreateAgentOptions {\n readonly agentId: AgentId;\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
},
{
name: 'CreateSessionOptions',
declaration: 'export interface CreateSessionOptions {\n seed?: SessionEvent[];\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n createdAt?: number;\n seedLength?: number;\n };\n}',
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}',
},
{
name: 'DiffCallView',
@@ -743,13 +746,9 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PromptAssembly',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
},
{
name: 'PromptProtection',
declaration: 'export interface PromptProtection {\n sections?: readonly string[];\n tools?: readonly string[];\n}',
},
{
name: 'PromptSection',
declaration: 'export interface PromptSection {\n name: string;\n order: number;\n text: string | ((context: AssembleContext) => string);\n}',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly ownerFinal?: boolean;\n}',
},
{
name: 'ReasoningBlock',
@@ -757,7 +756,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ResumeAgentOptions',
declaration: 'export interface ResumeAgentOptions {\n agentId: AgentId;\n resumeSessionId: SessionId;\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise<void> | void;\n}',
declaration: 'export interface ResumeAgentOptions {\n readonly agentId: AgentId;\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
},
{
name: 'SandboxEnforcement',
@@ -797,7 +796,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionHeader',
declaration: 'export interface SessionHeader {\n version: number;\n id: SessionId;\n createdAt: number;\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n}',
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n}',
},
{
name: 'SessionId',
@@ -805,27 +804,27 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SkillCandidate',
declaration: 'export interface SkillCandidate extends SkillSummary {\n rank: number;\n locator: unknown;\n path?: string;\n metadata?: Record<string, unknown>;\n}',
declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',
},
{
name: 'SkillDefinition',
declaration: 'export interface SkillDefinition extends SkillSummary {\n content: string;\n path?: string;\n metadata?: Record<string, unknown>;\n}',
declaration: 'export interface SkillDefinition extends SkillSummary {\n readonly content: string;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',
},
{
name: 'SkillLookupOptions',
declaration: 'export interface SkillLookupOptions {\n cwd?: string | undefined;\n signal?: AbortSignal | undefined;\n}',
declaration: 'export interface SkillLookupOptions {\n readonly cwd?: string | undefined;\n readonly signal?: AbortSignal | undefined;\n}',
},
{
name: 'SkillProvider',
declaration: 'export interface SkillProvider {\n name: string;\n list(options: SkillLookupOptions): Promise<SkillCandidate[]>;\n get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>;\n}',
declaration: 'export interface SkillProvider {\n readonly name: string;\n readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>;\n readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>;\n}',
},
{
name: 'SkillRegistration',
declaration: 'export type SkillRegistration = Omit<SkillDefinition, \'provider\'> & {\n provider?: string;\n};',
declaration: 'export type SkillRegistration = Omit<SkillDefinition, \'provider\'> & {\n readonly provider?: string;\n};',
},
{
name: 'SkillResourceBase',
declaration: 'export type SkillResourceBase = {\n kind: \'directory\';\n path: string;\n} | {\n kind: \'url\';\n url: string;\n} | {\n kind: \'opaque\';\n description: string;\n};',
declaration: 'export type SkillResourceBase = {\n readonly kind: \'directory\';\n readonly path: string;\n} | {\n readonly kind: \'url\';\n readonly url: string;\n} | {\n readonly kind: \'opaque\';\n readonly description: string;\n};',
},
{
name: 'SkillSource',
@@ -833,7 +832,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SkillSummary',
declaration: 'export interface SkillSummary {\n name: string;\n description: string;\n whenToUse?: string;\n disableModelInvocation?: boolean;\n source: SkillSource;\n provider: string;\n resourceBase?: SkillResourceBase;\n}',
declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly disableModelInvocation?: boolean;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}',
},
{
name: 'StreamChunk',
@@ -857,23 +856,23 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentCapabilities',
declaration: 'export interface SubagentCapabilities {\n outputSchema: boolean;\n depthLimit: boolean;\n toolFilter: boolean;\n persona: boolean;\n}',
declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
},
{
name: 'SubagentProvider',
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): SubagentRun;\n}',
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise<SubagentRun>;\n}',
},
{
name: 'SubagentResult',
declaration: 'export interface SubagentResult {\n output: ContentBlock[];\n structured?: unknown;\n stopReason: SubagentStopReason;\n}',
declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}',
},
{
name: 'SubagentRun',
declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly started: Promise<void>;\n readonly result: Promise<SubagentResult>;\n cancel(reason?: string): void;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): SubagentRun;\n}',
declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
},
{
name: 'SubagentStartRequest',
declaration: 'export interface SubagentStartRequest {\n prompt: ContentBlock[];\n parent: Agent;\n signal?: AbortSignal;\n agentOptions?: AgentOptions;\n outputSchema?: StructuredOutputSchema;\n maxDepth?: number;\n toolFilter?: {\n allow?: string[];\n deny?: string[];\n };\n persona?: string;\n}',
declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: StructuredOutputSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
},
{
name: 'SubagentStopReason',
@@ -921,7 +920,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolDefinition',
declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n readonly ownerFinal?: boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
},
{
name: 'ToolErrorInfo',
@@ -945,7 +944,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolExecutionToken',
declaration: 'export interface ToolExecutionToken {\n readonly [toolExecutionTokenBrand]: true;\n}',
declaration: 'export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n};',
},
{
name: 'ToolGuard',
@@ -953,11 +952,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolProviderResult',
declaration: 'export interface ToolProviderResult {\n schemas: ToolSchema[];\n knownNames?: readonly string[];\n}',
declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n readonly ownerFinalNames?: readonly string[];\n}',
},
{
name: 'ToolRestriction',
declaration: 'export interface ToolRestriction {\n allow?: string[];\n deny?: string[];\n}',
declaration: 'export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n}',
},
{
name: 'ToolResult',

View File

@@ -0,0 +1,287 @@
import { Context, CordisError, FiberState, type Fiber } from 'cordis'
import { describe, expect, it } from 'vitest'
/**
* Direct regressions for the vendored Cordis ownership substrate used by
* tool-cordis's dynamic plugin tree and every other harness plugin.
*/
describe('Cordis effect ownership', () => {
it('makes an effect visible to a reentrant owner restart and awaits setup plus cleanup', async () => {
const ctx = new Context()
const setupGate = Promise.withResolvers<undefined>()
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
let restarted!: Promise<void>
let setupFinished = false
let cleanupFinished = false
ctx.effect(async () => {
restarted = ctx.fiber.restart()
await setupGate.promise
setupFinished = true
return async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
cleanupFinished = true
}
}, 'reentrant-restart')
let settled = false
void restarted.then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
setupGate.resolve(undefined)
await cleanupStarted.promise
expect(setupFinished).toBe(true)
await Promise.resolve()
expect(settled).toBe(false)
cleanupGate.resolve(undefined)
await restarted
expect(cleanupFinished).toBe(true)
expect(ctx.fiber.getEffects()).toEqual([])
})
it('rolls back collected cleanup and its owner-list entry when setup throws synchronously', () => {
const ctx = new Context()
let cleanups = 0
expect(() => ctx.effect(function* () {
yield () => { cleanups += 1 }
throw new Error('setup failed')
}, 'throwing-setup')).toThrow('setup failed')
expect(cleanups).toBe(1)
expect(ctx.fiber.getEffects()).toEqual([])
})
it('makes a reentrant owner restart await asynchronous rollback after synchronous setup failure', async () => {
const ctx = new Context()
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
let restarted!: Promise<void>
expect(() => ctx.effect(function* () {
yield async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
}
restarted = ctx.fiber.restart()
throw new Error('setup failed after restart')
}, 'reentrant-throw')).toThrow('setup failed after restart')
await cleanupStarted.promise
let settled = false
void restarted.then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
cleanupGate.resolve(undefined)
await restarted
expect(ctx.fiber.getEffects()).toEqual([])
})
it('keeps ordinary teardown synchronous and the public disposer single-shot', () => {
const ctx = new Context()
let cleanups = 0
const dispose = ctx.effect(() => () => { cleanups += 1 }, 'sync-effect')
expect(dispose()).toBeUndefined()
expect(cleanups).toBe(1)
expect(dispose()).toBeUndefined()
expect(cleanups).toBe(1)
expect(ctx.fiber.getEffects()).toEqual([])
})
it('rejects cleanup-time registration while a restart is unloading', async () => {
const ctx = new Context()
let registrationError: unknown
ctx.effect(() => () => {
try {
ctx.effect(() => () => {}, 'too-late')
} catch (error) {
registrationError = error
}
}, 'restart-cleanup')
await ctx.fiber.restart()
expect(registrationError).toBeInstanceOf(CordisError)
expect((registrationError as CordisError).code).toBe('INACTIVE_EFFECT')
expect(ctx.fiber.state).toBe(FiberState.ACTIVE)
expect(ctx.fiber.getEffects()).toEqual([])
})
it('keeps effect registration legal while child fibers are PENDING and LOADING', async () => {
const ctx = new Context()
let pendingCleanup = false
let loadingCleanup = false
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'state-probe' || fiber.uid === null) return
expect(fiber.state).toBe(FiberState.PENDING)
fiber.ctx.effect(() => () => { pendingCleanup = true }, 'pending-effect')
})
const fiber = await ctx.plugin({
name: 'state-probe',
apply(inner) {
expect(inner.fiber.state).toBe(FiberState.LOADING)
inner.effect(() => () => { loadingCleanup = true }, 'loading-effect')
},
})
await fiber.dispose()
expect(pendingCleanup).toBe(true)
expect(loadingCleanup).toBe(true)
})
it('resolves dependencies that internal/plugin adds before child activation', async () => {
const ctx = new Context()
ctx.provide('late-inject', {})
let applyCalls = 0
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'loader-shaped' || fiber.uid === null) return
fiber.inject['late-inject'] = {}
})
const fiber = await ctx.plugin({
name: 'loader-shaped',
apply() {
applyCalls += 1
},
})
expect(applyCalls).toBe(1)
expect(fiber.state).toBe(FiberState.ACTIVE)
})
})
describe('Cordis child publication ownership', () => {
it('rolls back parent and runtime ownership when internal/plugin publication throws', () => {
const ctx = new Context()
const plugin = { name: 'publication-failure', apply() {} }
ctx.on('internal/plugin', (fiber) => {
if (fiber.name === plugin.name) throw new Error('publication failed')
})
expect(() => ctx.plugin(plugin)).toThrow('publication failed')
expect(ctx.registry.has(plugin)).toBe(false)
})
it('contains teardown notification failures so ownership cleanup and peers complete', async () => {
const ctx = new Context()
const errors: unknown[] = []
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
const observed: string[] = []
ctx.on('internal/plugin', (fiber) => {
if (fiber.name === 'contained-teardown' && fiber.uid === null) {
throw new Error('broken teardown observer')
}
})
ctx.on('internal/plugin', (fiber) => {
if (fiber.name === 'contained-teardown' && fiber.uid === null) observed.push('disposed')
})
const child = await ctx.plugin({ name: 'contained-teardown', apply() {} })
await expect(child.dispose()).resolves.toBeUndefined()
expect(observed).toEqual(['disposed'])
expect(errors).toHaveLength(1)
expect(errors[0]).toEqual(expect.objectContaining({ message: 'broken teardown observer' }))
expect(child.uid).toBeNull()
})
it('makes a LOADING parent join child cleanup started before its unload snapshot', async () => {
const ctx = new Context()
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
let ownerFiber!: Fiber
let ownerDisposal!: Promise<void>
let childDisposal!: Promise<void>
let childFiber!: Fiber
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'loading-child' || fiber.uid === null) return
childFiber = fiber
fiber.ctx.effect(() => async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
}, 'loading-child-cleanup')
ownerDisposal = ownerFiber.dispose()
childDisposal = Promise.resolve(fiber.dispose())
})
const ownerMount = ctx.plugin({
name: 'loading-owner',
apply(inner) {
ownerFiber = inner.fiber
inner.plugin({ name: 'loading-child', apply() {} })
},
})
await cleanupStarted.promise
let ownerSettled = false
void ownerDisposal.then(() => { ownerSettled = true })
await Promise.resolve()
expect(ownerSettled).toBe(false)
cleanupGate.resolve(undefined)
await Promise.all([ownerDisposal, childDisposal, ownerMount])
expect(childFiber.uid).toBeNull()
expect(ownerFiber.uid).toBeNull()
})
it('lets parent disposal during internal/plugin await the unpublished child to quiescence', async () => {
const ctx = new Context()
let ownerCtx!: Context
const owner = await ctx.plugin({
name: 'owner',
apply(inner) {
ownerCtx = inner
},
})
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
let cleanupFinished = false
let childApplyCalls = 0
let parentDisposal!: Promise<void>
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'child' || fiber.uid === null) return
expect(fiber.state).toBe(FiberState.PENDING)
fiber.ctx.effect(() => async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
cleanupFinished = true
}, 'pending-child-cleanup')
})
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'child' || fiber.uid === null) return
parentDisposal = owner.dispose()
})
const child = ownerCtx.plugin({
name: 'child',
apply() {
childApplyCalls += 1
},
})
await cleanupStarted.promise
let settled = false
void parentDisposal.then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
cleanupGate.resolve(undefined)
await parentDisposal
expect(cleanupFinished).toBe(true)
expect(childApplyCalls).toBe(0)
expect(child.uid).toBeNull()
expect(child.state).toBe(FiberState.DISPOSED)
})
})

View File

@@ -8,16 +8,20 @@ This is the only package in the harness that contains concrete loop logic. Every
### Public API
Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. Resume installs an owner-liveness sentinel before persistence load, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`.
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` with optional session metadata. 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.
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
IDs are caller-chosen and assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same agent or session id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; IDs become reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-<uuid>` with optional metadata; the uuid avoids colliding with a prior durable log. Each call 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?, setup? }): Promise<AgentHandle>` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. 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`.
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. 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). `signal` is creation-only. 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.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
### Injected services
@@ -42,7 +46,7 @@ Agents listed in config are auto-created at startup. `cwd` applies only to fresh
- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy.
`Inbox`, `runLoop`, and the instance-bound enable/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals.
`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary.
### Loop lifecycle (`loop.ts`)
@@ -89,7 +93,7 @@ forever:
idle unless more queued
```
Error containment: a throwing plugin ends the **turn**, never the loop. A malformed or throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. 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.
Error containment: a throwing plugin ends the **turn**, never the loop. A throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. 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.)

View File

@@ -7,43 +7,51 @@
*/
import type { Context } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import { Inbox } from './inbox.ts'
import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session'
import { Inbox, type InboxMessage } from './inbox.ts'
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
/** Agents whose rollback-covered publication enabled driving. */
const driveEnabledAgents = new WeakSet<ReactLoopAgent>()
/** Sessions already claimed by a concrete driver construction. */
const claimedDriverSessions = new WeakSet<Session>()
/** Module-private driver entry: its symbol is absent from the package surface. */
const startDriver = Symbol('dsh.agent-loop.start-driver')
/** Module-private quiescent stop, valid both before and after driver start. */
const stopDriver = Symbol('dsh.agent-loop.stop-driver')
/** Module-private context binding for the mutually referential agent scope. */
const bindContext = Symbol('dsh.agent-loop.bind-context')
/** Module-private publication marker. */
const publishAgent = Symbol('dsh.agent-loop.publish-agent')
/** Factory-owned controls that can operate only on the agent created with them. */
export interface PreparedReactLoopAgent {
/** The unpublished concrete agent. */
agent: ReactLoopAgent
/** Open its driving verbs at the rollback-covered publication boundary. */
enableDrive(): void
/** Mark the agent public so teardown emits its status lifecycle. */
markPublished(): void
/** Stop the prepared instance even when publication has not started its loop. */
dispose(): Promise<void> | void
/**
* Start its driver after publication and session-start notification.
* The returned disposer reaches quiescence for both the loop and every
* fire-and-forget idle-injection flush the agent started.
*/
startDriver(): () => Promise<void>
startDriver(): () => Promise<void> | void
}
/**
* Construct one concrete agent together with unforgeable, instance-bound
* lifecycle controls. The package surface deliberately exposes neither source
* subpaths nor this helper: setup code may identify the concrete class, but it
* cannot enable or start the factory's unpublished instance.
* cannot publish or start the factory's unpublished instance.
* @param ctx - the agent-loop service context used for driving and events.
* @param id - the concrete agent identity.
* @param options - loop options for the agent.
@@ -56,15 +64,32 @@ export function prepareReactLoopAgent(
if (claimedDriverSessions.has(session)) {
throw new Error(`session "${session.id}" already has a concrete agent driver`)
}
claimedDriverSessions.add(session)
const agent = new ReactLoopAgent(ctx, id, options, session)
claimedDriverSessions.add(session)
const dispose = () => agent[stopDriver]()
return {
agent,
enableDrive: () => { driveEnabledAgents.add(agent) },
startDriver: () => agent[startDriver](),
markPublished: () => { agent[publishAgent]() },
dispose,
startDriver: () => {
agent[startDriver]()
return dispose
},
}
}
/**
* Install the concrete agent's scope context exactly once. Construction and
* scope minting are mutually referential (the scope key is the agent), so the
* factory performs this one post-construction binding before setup receives
* the unpublished agent. The module-private binding rejects a second bind.
* @param agent - the unpublished concrete agent to bind.
* @param ctx - its fully extended agent scope context.
*/
export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): void {
agent[bindContext](ctx)
}
/**
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
*
@@ -73,7 +98,7 @@ export function prepareReactLoopAgent(
* the agent/* event taxonomy — plugins never need this class.
*/
export class ReactLoopAgent implements Agent {
/** Queued + steering FIFOs; native-private so setup cannot bypass driving verbs. */
/** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */
readonly #inbox = new Inbox()
/**
@@ -84,21 +109,20 @@ export class ReactLoopAgent implements Agent {
* context are mutually referential (the scope is keyed BY this agent), so
* neither can exist strictly before the other.
*/
ctx!: Context
private boundContext: Context | undefined
/**
* The dispatch carrier for this agent's own emits (`agent/status`,
* `agent/queued`, `agent/error`): keyed by the agent, base = the agent
* (listener `this` is the agent). Built lazily because it is self-referential.
*/
private get carrier(): Scoped<Agent> {
return (this.#carrier ??= scopeTarget(this, this))
/** The agent's scoped composition context, bound once by its factory. */
get ctx(): Context {
if (this.boundContext === undefined) throw new Error(`agent "${this.id}" context is not bound`)
return this.boundContext
}
#carrier: Scoped<Agent> | undefined
private _status: AgentStatus = 'idle'
private currentAbort: AbortController | undefined
/** Whether runLoop has been installed into {@link done}. */
private driverStarted = false
/** Whether registry publication began and status disposal is externally visible. */
private published = false
/**
* Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the
* driver loop (via the LoopHandle) at every point a turn could start or
@@ -155,14 +179,13 @@ export class ReactLoopAgent implements Agent {
private setStatus(status: AgentStatus): void {
if (this._status === status || this._status === 'disposed') return
this._status = status
// Release quiescence waiters on a transition OUT of running before emitting (the disposer
// handles the disposed transition separately).
// Release quiescence waiters on a transition OUT of running BEFORE emitting
// (the disposer handles the disposed transition separately). Settling first
// means a throwing `agent/status` subscriber cannot starve a `whenIdle()`
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
// not hang on one bad listener).
if (status !== 'running') this.settleIdleWaiters()
try {
this.loopCtx.emit(this.carrier, 'agent/status', this, status)
} catch (error: unknown) {
this.loopCtx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`)
}
agentEvents(this.loopCtx, this).emit('agent/status', status)
}
/**
@@ -180,32 +203,45 @@ export class ReactLoopAgent implements Agent {
return options?.source ?? { kind: 'user' }
}
/** Reject every driving verb while creation setup still owns the agent. */
private assertDriveEnabled(action: string): void {
if (driveEnabledAgents.has(this)) return
throw new Error(`agent "${this.id}" cannot ${action} before creation setup completes`)
/**
* Accept one public send/steer payload as the exact detached record shared by
* the live notification and inbox. Lossless-JSON materialization reads every
* nested field once; deep freeze prevents an observer from rewriting queued
* work before the loop drains it.
*/
private acceptInboxMessage(content: ContentBlock[], options?: SendOptions): InboxMessage {
const source = this.resolveSource(options)
const accepted = snapshotJsonValue({ content, source })
if (accepted === undefined) {
throw new TypeError('agent message content and source must be losslessly JSON-serializable')
}
return deepFreeze(accepted)
}
/** Reject a driving operation once teardown has synchronously closed the agent. */
private assertNotDisposed(): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
}
send(content: ContentBlock[], options?: SendOptions): void {
this.assertDriveEnabled('send')
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
const source = this.resolveSource(options)
this.#inbox.enqueue({ content, source })
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: false })
this.assertNotDisposed()
const accepted = this.acceptInboxMessage(content, options)
this.#inbox.enqueue(accepted)
const info = { source: accepted.source, steering: false } as const
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
}
steer(content: ContentBlock[], options?: SendOptions): void {
this.assertDriveEnabled('steer')
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
this.assertNotDisposed()
if (this._status !== 'running') { this.send(content, options); return }
const source = this.resolveSource(options)
this.#inbox.steer({ content, source })
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: true })
const accepted = this.acceptInboxMessage(content, options)
this.#inbox.steer(accepted)
const info = { source: accepted.source, steering: true } as const
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
}
inject(content: ContentBlock[], options?: SendOptions): void {
this.assertDriveEnabled('inject')
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
this.assertNotDisposed()
const source = this.resolveSource(options)
if (isTurnOpen(this.session)) {
// A turn is open in the LOG (decided from the log, not agent status —
@@ -217,44 +253,47 @@ export class ReactLoopAgent implements Agent {
// No turn open: wrap the injection in a one-shot turn so every event stays
// turn-enclosed (the durability/replay boundary is the turn).
const turn = lastTurnNumber(this.session) + 1
// Once turn/start enters the log, a turn/end is OWED no matter what — even if a throwing
// `session/event` listener escapes from the turn/start append (Session.append pushes the
// event before notifying listeners) or the context/message append throws (non-serializable
// content, throwing listener).
// Once turn/start enters the log, a turn/end is owed even if the message
// append fails acceptance or pre-commit validation. The finally re-checks
// the log and closes only a turn that actually opened; post-commit observers
// are contained by Session and cannot create a false append failure.
try {
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
} finally {
// Close the turn if turn/start made it into the log.
// Close the turn if turn/start made it into the log. A pre-commit veto
// must escape rather than being mistaken for a committed turn/end.
if (isTurnOpen(this.session)) {
try {
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
} catch {
// turn/end is already in the log (pushed before the listener threw),
// so the turn is balanced; the throw is the listener's bug.
}
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
// Decide the durability checkpoint from the LOG, not a flag: a turn was recorded iff this
// turn's turn/start is logged (it may have been closed by a throwing-listener turn/end
// above, which still counts).
// Decide the durability checkpoint from the log: an accepted one-shot
// turn must be flushed even when its message append was the failing step.
const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
// Checkpoint the one-shot turn for durability, exactly as the loop does at every
// turn/end.
// Checkpoint the one-shot turn for durability, exactly as the loop does at
// every turn/end. The loop is NOT running (we are idle), so nothing else
// will flush this turn. Fire-and-forget with error containment: inject()
// is synchronous, and a persistence backend failing must not throw into
// the caller (e.g. a tool-bash task-done callback). Disposal still drains
// independently, so a slow flush is safe. The task is tracked until it
// settles: driver disposal awaits every pending idle-injection checkpoint
// before unregistering the agent or detaching the session. A flush failure
// is reported via agent/error (step 0 — the idle-injection convention,
// there is no real step) AND the logger, mirroring the loop's post-turn/end
// flush path so plugins monitoring agent/error see idle-injection
// persistence failures too. A throwing agent/error listener is contained.
if (turnRecorded) {
// Through the store's flush (the carrier owner), never a raw parallel.
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
const err = error instanceof Error ? error : new Error(String(error))
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`)
try {
this.loopCtx.emit(this.carrier, 'agent/error', this, turn, 0, err)
} catch {
// contained: the failure is already logged; a throwing agent/error
// listener must not escape this fire-and-forget catch.
}
const rendered = renderThrown(error)
const err = error instanceof Error ? error : new Error(rendered)
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
})
this.pendingIdleFlushes.add(flush)
// Attach the same retirement callback to both settlement arms so even a logger failure
// in the catch above cannot become an unhandled rejection.
// Attach the same retirement callback to both settlement arms so even a
// logger failure in the catch above cannot become an unhandled rejection.
// Teardown uses allSettled for the same reason: a reporting failure must
// not strand ownership.
const retire = (): void => { this.pendingIdleFlushes.delete(flush) }
void flush.then(retire, retire)
}
@@ -262,9 +301,15 @@ export class ReactLoopAgent implements Agent {
}
cancel(reason?: string): void {
this.assertDriveEnabled('cancel')
// Arm-gate: only mark a cancellation when there is actually work to cancel — a running
// turn, an in-flight step, or queued/steering work.
// Arm-gate: only mark a cancellation when there is actually work to cancel —
// a running turn, an in-flight step, or queued/steering work. An idle cancel
// with nothing pending is a true no-op; arming the marker then would wrongly
// drop the NEXT legitimate prompt (the marker is consumed only at the loop's
// turn-decision points, which an idle parked loop does not reach until woken
// by a real send()). Note the gate canNOT be `status === 'running'` alone:
// the pre-step window (a send() queued but the loop not yet flipped to
// running) has status `idle` with `hasQueued` true, and the marker exists
// precisely to cover it.
if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) {
this.cancelRequested = true
// Capture the resolved reason for the marker-only windows (pre-step /
@@ -272,8 +317,10 @@ export class ReactLoopAgent implements Agent {
// below; the marker path reads it via the LoopHandle's cancelReason().
this.cancelReason = reason ?? 'cancelled'
}
// Drop all pending queued + steering work (un-started prompts never run; the cancelled
// turn's steering is not re-enqueued).
// Drop all pending queued + steering work (un-started prompts never run; the
// cancelled turn's steering is not re-enqueued). Cleared directly even when
// the loop is parked in waitForQueued — there is no turn to stop and nothing
// left for the parked loop to run, so no wake is needed.
this.#inbox.clear()
// Interrupt an in-flight step immediately (the running turn observes the
// abort and ends `aborted`). The marker covers the windows where no step is
@@ -282,15 +329,29 @@ export class ReactLoopAgent implements Agent {
}
/**
* Resolve once the agent has reached quiescence after settling out of `running`.
* Resolve once the agent has reached quiescence after settling out of
* `running`. If it is already disposed, awaits {@link done} (the loop-exit
* promise) — `agent/status('disposed')` fires in the disposer BEFORE the
* driver loop has unwound, so it is NOT itself a quiescence signal. If it is
* idle AND has no queued work, resolves immediately. Otherwise queues an
* internal waiter (see {@link idleWaiters}) released on the next
* running→idle/disposed transition, resolving on `idle` directly (the turn
* fully ended) or chaining {@link done} on `disposed` (wait for the loop to
* actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner
* quiescence-observation hook, distinct from teardown (a lifecycle owner stops
* and unregisters via `AgentHandle.dispose()`, whose driver boundary awaits
* both {@link done} and outstanding idle-injection flushes, not through this).
*/
whenIdle(): Promise<void> {
if (this._status === 'disposed') return this.done
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
// Register an internal waiter (resolved by settleIdleWaiters on the next
// running→idle/disposed transition), not an effect-scoped `ctx.on` listener: a concurrent
// fiber disposal runs this agent's listener disposers, which could remove a `ctx.on` waiter
// before the `disposed` transition fires and hang the promise.
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
// a concurrent fiber disposal runs this agent's listener disposers, which
// could remove a `ctx.on` waiter before the `disposed` transition fires and
// hang the promise. On disposal the disposer settles the waiter AND we chain
// `done` here for true loop-exit quiescence (status flips to disposed before
// the loop unwinds); a plain idle transition resolves directly.
return new Promise<void>((resolve) => {
this.idleWaiters.push(() => {
resolve(this._status === 'disposed' ? this.done : undefined)
@@ -298,18 +359,25 @@ export class ReactLoopAgent implements Agent {
})
}
/** Bind the mutually referential scope context once. */
private [bindContext](ctx: Context): void {
if (this.boundContext !== undefined) throw new Error(`agent "${this.id}" context is already bound`)
this.boundContext = ctx
}
/** Mark that public lifecycle publication began. */
private [publishAgent](): void {
this.published = true
}
/**
* Start the driver loop. Returns a disposer: calling it sets status to
* `disposed`, emits `agent/status('disposed')`, resolves the disposed
* promise (unblocking the idle wait), releases any `whenIdle` waiters, and
* aborts the current request if any. Its returned promise resolves only after
* the loop exits and every idle-injection flush started by this agent settles.
* @returns the disposer — idempotent, synchronously marks the agent disposed,
* and asynchronously reaches loop + flush quiescence without rejecting (it
* runs inside the fiber's LIFO disposal chain, where a rejection would skip
* later disposers).
* Start the driver loop. The prepared controller already owns its stable
* disposer, so teardown can mark the agent disposed even in the narrow
* publication window before this method runs.
*/
[startDriver](): () => Promise<void> {
[startDriver](): void {
if (this._status === 'disposed') return
this.driverStarted = true
this.done = runLoop(this.loopCtx, this, {
inbox: this.#inbox,
setStatus: (status) => { this.setStatus(status) },
@@ -319,41 +387,63 @@ export class ReactLoopAgent implements Agent {
isCancelled: () => this.cancelRequested,
cancelReason: () => this.cancelReason,
clearCancel: () => { this.cancelRequested = false },
// Settle whenIdle() waiters WITHOUT a status transition — the pre-step cancel-skip path
// drops the about-to-run turn and re-parks without ever flipping running→idle, so a
// waiter registered in the pre-step window (status idle, hasQueued was true) would
// otherwise hang.
// Settle whenIdle() waiters WITHOUT a status transition — the pre-step
// cancel-skip path drops the about-to-run turn and re-parks without ever
// flipping running→idle, so a waiter registered in the pre-step window
// (status idle, hasQueued was true) would otherwise hang. This emits no
// agent/status, so an ACP agent/status listener never sees a spurious idle
// that would resolve a freshly-queued prompt as cancelled.
settleIdle: () => { this.settleIdleWaiters() },
})
// The disposer must be infallible: it runs inside the fiber's LIFO
// disposal chain, where a throw would skip later disposers (e.g. the
// registry unregistration) and leave `done` pending forever.
return async () => {
if (this._status !== 'disposed') {
this._status = 'disposed'
this.resolveDisposed()
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
// internal state that must settle even if a listener throws below. Each
// waiter chains `done`, so it resolves only once the loop actually exits.
this.settleIdleWaiters()
this.currentAbort?.abort('disposed')
// setStatus refuses transitions out of 'disposed', so emit directly —
// 'disposed' is part of the agent/status contract. Guarded: a throwing
// listener must not break the disposal chain.
try {
this.loopCtx.emit(this.carrier, 'agent/status', this, 'disposed')
} catch {
// listener error during disposal — nothing safe left to do with it
}
}
// An unexpected driver rejection must not skip registry/session/scope
// cleanup. The normal loop contains turn failures itself; allSettled is the
// final lifecycle backstop for anything outside those boundaries.
await Promise.allSettled([this.done])
// No new inject() can start after the synchronous disposed transition.
while (this.pendingIdleFlushes.size > 0) {
await Promise.allSettled([...this.pendingIdleFlushes])
}
/**
* Quiescent stop shared by pre-start rollback and live teardown. It marks the
* agent disposed synchronously, contains an unexpected loop rejection, and
* drains every idle-injection flush before resolving.
*/
private [stopDriver](): Promise<void> | void {
if (this._status !== 'disposed') {
this._status = 'disposed'
this.resolveDisposed()
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
// internal state that must settle even if a listener throws below. Each
// waiter chains `done`, so it resolves only once the loop actually exits.
this.settleIdleWaiters()
this.currentAbort?.abort('disposed')
// An unpublished rollback has no public status lifecycle to announce.
// Once publication begins, disposed is part of the agent/status contract.
if (this.published) {
agentEvents(this.loopCtx, this).emit('agent/status', 'disposed')
}
}
// Before runLoop starts there is normally nothing asynchronous to drain;
// keep publication rollback synchronous so create() cannot throw while its
// session/agent entries are still briefly live. A session-start listener
// may have called inject(), however, so preserve
// its durability checkpoint as a real quiescence boundary.
if (!this.driverStarted && this.pendingIdleFlushes.size === 0) return
return this.drainDriver()
}
/** Await the loop (when started) and every outstanding idle flush. */
private async drainDriver(): Promise<void> {
// An unexpected driver rejection must not skip registry/session/scope
// cleanup. The normal loop contains turn failures itself; allSettled is the
// final lifecycle backstop for anything outside those boundaries.
await Promise.allSettled([this.done])
// No new inject() can start after the synchronous disposed transition.
// Loop because settled tasks retire themselves in promise reactions that
// may run beside this continuation; either the set is empty or this waits
// the exact remaining quiescence boundary. allSettled keeps a failure in
// error reporting from skipping registry/session/scope disposers.
while (this.pendingIdleFlushes.size > 0) {
await Promise.allSettled([...this.pendingIdleFlushes])
}
}
}
/** Render an ordinary thrown value for the error event and log. */
function renderThrown(value: unknown): string {
return value instanceof Error ? value.message : String(value)
}

View File

@@ -1,8 +1,6 @@
/**
* THE concrete agent plugin: creates ReactLoopAgents, runs their loops, and
* registers them in ctx.agents. Deliberately thin — every behavior beyond
* "call the model, run the tools, repeat" belongs to plugins on the event
* taxonomy.
* Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them
* through the agent/session registries, and owns their ordered teardown.
*
* @module @deepseek-ai/dsh-agent-loop
*/
@@ -13,62 +11,334 @@ import z from 'schemastery'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
import type {
AgentFactory,
AgentHandle,
AgentId,
AgentOptions,
CreateAgentOptions,
ResumeAgentOptions,
SessionStartSource,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { prepareReactLoopAgent, ReactLoopAgent } from './agent.ts'
import {
bindReactLoopAgentContext,
prepareReactLoopAgent,
ReactLoopAgent,
} from './agent.ts'
import type { PreparedReactLoopAgent } from './agent.ts'
export { ReactLoopAgent } from './agent.ts'
/** Fiber states that cannot own or serve a new lifecycle. */
const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
FiberState.UNLOADING,
FiberState.DISPOSED,
FiberState.FAILED,
])
/** Factory-level ownership of every preparing or live transaction. */
class FactoryOwnership {
private accepting = true
private transactions = new Set<AgentCreationTransaction>()
constructor(private readonly fiber: Context['fiber']) {}
isActive(): boolean {
return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
}
track(transaction: AgentCreationTransaction): () => void {
this.transactions.add(transaction)
return () => { this.transactions.delete(transaction) }
}
async dispose(): Promise<void> {
this.accepting = false
const reason = new Error('agent loop is not active')
await Promise.all(
[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
)
}
}
/** Build the public cancellation error while preserving a caller-supplied cause. */
function signalAbortError(id: AgentId, signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
}
/**
* One create/resume transaction from caller ownership through unpublished
* setup, rollback-covered publication, and final quiescent teardown.
*
* The class deliberately owns the state machine in one place. Registries only
* arbitrate identity at their final `enter()` calls; before that point every
* resource is private to this transaction.
*/
class AgentCreationTransaction {
private active = true
private failure: Error | undefined
private readonly deactivation = Promise.withResolvers<void>()
private readonly publication = Promise.withResolvers<void>()
private readonly torndown = Promise.withResolvers<void>()
private readonly wrapperCompletion = Promise.withResolvers<void>()
private preparing: Promise<void> | undefined
private driver: PreparedReactLoopAgent | undefined
private scope: Scope | undefined
private session: Session | undefined
private lifecycleDispose: (() => Promise<void> | void) | undefined
private detachSession: (() => void) | undefined
private detachAgent: (() => void) | undefined
private publishing = false
private cleanupTask: Promise<void> | undefined
private ownerFollowing = true
private readonly ownerDispose: () => Promise<void> | void
private readonly untrackFactory: () => void
private readonly abortListener: (() => void) | undefined
readonly ownerAgent: Context['agent']
readonly ownerFiber: Context['fiber']
constructor(
private readonly loopCtx: Context,
private readonly ownerCtx: Context,
private readonly ownership: FactoryOwnership,
readonly id: AgentId,
signal?: AbortSignal,
) {
ownerCtx.fiber.assertActive()
this.ownerAgent = ownerCtx.agent
this.ownerFiber = ownerCtx.fiber
if (!ownership.isActive()) throw new Error('agent loop is not active')
this.ownerDispose = ownerCtx.effect(() => () => {
if (!this.ownerFollowing) return
return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
}, `agentLoop.owner(${id})`)
this.untrackFactory = ownership.track(this)
if (signal === undefined) {
this.abortListener = undefined
} else {
this.abortListener = () => {
/* v8 ignore next 3 -- transaction teardown contains callback/driver failures; rejection is a future-drift backstop. */
void this.dispose(signalAbortError(id, signal)).catch((error: unknown) => {
this.loopCtx.logger.error(error)
})
}
signal.addEventListener('abort', this.abortListener, { once: true })
if (signal.aborted) this.deactivate(signalAbortError(id, signal))
}
this.signal = signal
}
private readonly signal: AbortSignal | undefined
/** Whether caller, provider, and optional parent-agent ownership remain live. */
isActive(): boolean {
return this.active
&& this.ownership.isActive()
&& this.ownerFiber.uid !== null
&& !INACTIVE_STATES.has(this.ownerFiber.state)
&& this.ownerAgent?.status !== 'disposed'
}
/** Fail synchronously at every real lifecycle boundary after deactivation. */
assertActive(): void {
if (this.isActive()) return
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
throw this.failure ?? new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
}
/** Race an external async operation against structural/signal deactivation. */
async waitFor<T>(operation: PromiseLike<T> | T): Promise<T> {
this.assertActive()
return await Promise.race([
Promise.resolve(operation),
this.deactivation.promise.then(() => {
/* v8 ignore next -- deactivate() assigns failure before resolving deactivation. */
throw this.failure ?? new Error(`agent "${this.id}" creation deactivated`)
}),
])
}
/** Construct the driver and scope, then install their complete ordered lifecycle. */
prepare(options: AgentOptions, session: Session): ReactLoopAgent {
this.assertActive()
const gate = Promise.withResolvers<void>()
this.preparing = gate.promise
try {
this.session = session
const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session)
this.driver = driver
const agent = driver.agent
const scope = createScope(this.loopCtx, agent)
this.scope = scope
bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
this.installLifecycle(scope, driver)
this.assertActive()
return agent
} catch (error: unknown) {
if (!this.isActive() && error instanceof Error && /inactive context/.test(error.message)) {
throw this.failure ?? this.disposalReason()
}
throw error
} finally {
gate.resolve()
this.preparing = undefined
}
}
/** Register the exact scope disposer inside the ordered transaction effect. */
private installLifecycle(scope: Scope, driver: PreparedReactLoopAgent): void {
this.lifecycleDispose = this.ownerCtx.effect(function* (this: AgentCreationTransaction) {
// First yielded, disposed last.
yield () => { this.finish() }
yield scope.rawDispose
yield () => {
this.detachSession?.()
this.detachSession = undefined
}
yield () => {
this.detachAgent?.()
this.detachAgent = undefined
}
// Last yielded, disposed first.
yield () => {
this.deactivate(this.disposalReason())
if (this.publishing) {
return this.publication.promise.then(() => driver.dispose())
}
return driver.dispose()
}
}.bind(this), `agentLoop.lifecycle(${this.id})`)
}
/** Publish the exact prepared objects and start the driver. */
publish(source: SessionStartSource): AgentHandle {
this.assertActive()
const driver = this.driver
/* v8 ignore next -- publish() is private and every caller invokes prepare() first. */
if (driver === undefined) throw new Error(`agent "${this.id}" is not prepared`)
const agent = driver.agent
const session = this.session
/* v8 ignore next -- prepare() assigns the session before it can produce the driver above. */
if (session === undefined) throw new Error(`agent "${this.id}" has no prepared session`)
this.publishing = true
try {
this.detachSession = agent.ctx.sessions.enter(session)
this.detachAgent = this.loopCtx.agents.enter(agent)
agent.ctx.sessions.announce(session)
this.assertActive()
this.loopCtx.agents.announce(agent)
this.assertActive()
driver.markPublished()
agentEvents(this.loopCtx, agent).emit('agent/session-start', source)
this.assertActive()
driver.startDriver()
return { agent, dispose: () => this.dispose() }
} finally {
this.publishing = false
this.publication.resolve()
}
}
/** Mark the transaction inactive exactly once and wake load/setup races. */
private deactivate(reason: Error): void {
if (!this.active) return
this.active = false
this.failure = reason
this.deactivation.resolve()
}
/** Choose the structural cause when an owner/factory effect starts teardown first. */
private disposalReason(): Error {
if (this.failure !== undefined) return this.failure
if (!this.ownership.isActive()) return new Error('agent loop is not active')
if (this.ownerFiber.uid === null || INACTIVE_STATES.has(this.ownerFiber.state) || this.ownerAgent?.status === 'disposed') {
return new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
}
return new Error(`agent "${this.id}" lifecycle disposed`)
}
/** Complete ownership bookkeeping after every resource reached quiescence. */
private finish(): void {
this.untrackFactory()
this.ownerFollowing = false
void this.ownerDispose()
this.torndown.resolve()
}
/**
* Deactivate and quiesce this transaction. The promise is memoized because
* Cordis effect disposers are single-shot while handles promise shared
* quiescence to every racing owner.
*/
dispose(reason = new Error(`agent "${this.id}" lifecycle disposed`)): Promise<void> {
this.deactivate(reason)
return (this.cleanupTask ??= (async () => {
if (this.preparing !== undefined) await this.preparing
if (this.lifecycleDispose !== undefined) {
await this.lifecycleDispose()
await this.torndown.promise
return
}
try {
await this.driver?.dispose()
} finally {
try {
await this.scope?.dispose()
} finally {
this.finish()
}
}
})())
}
/** Mark the public create/resume continuation settled and detach its creation-only signal. */
finishWrapper(): void {
if (this.signal !== undefined && this.abortListener !== undefined) {
this.signal.removeEventListener('abort', this.abortListener)
}
this.wrapperCompletion.resolve()
}
/** Factory shutdown joins both resource teardown and the public wrapper's deactivation continuation. */
async disposeForFactory(reason: Error): Promise<void> {
await this.dispose(reason)
await this.wrapperCompletion.promise
}
}
declare module 'cordis' {
interface Context {
agentLoop: AgentLoop
}
}
/**
* Plugin config: the agents to create — or resume, via `resumeSessionId` —
* declaratively at startup, so a cordis.yml deployment needs no code.
*/
/** Plugin configuration for declarative startup agents. */
export interface Config {
/** Agents created from configuration at startup. */
/** Agents created or resumed at plugin startup. */
agents: (AgentOptions & {
/** Agent id to register under; also seeds the fresh per-run session id (`${id}-session-<uuid>`). */
/** Registry identity for the live agent. */
id: AgentId
/** Optional workspace cwd for the config-created fresh session. */
/** Optional workspace for a fresh session. */
cwd?: string
/**
* If set, the config agent RESUMES this persisted session id instead of starting a fresh
* `${id}-session-<uuid>`.
*/
/** Persisted session to resume instead of creating a fresh session. */
resumeSessionId?: SessionId
})[]
}
/**
* The agent-loop plugin (`ctx.agentLoop`): creates {@link ReactLoopAgent}s, runs
* their loops, and registers them in `ctx.agents`. Also implements the
* {@link AgentFactory} seam, so plugins create/resume agents through
* `ctx.agents` (the interface) without depending on this concrete package.
*
* The loop itself is deliberately thin — every behavior beyond "call the
* model, run the tools, repeat" belongs to plugins listening on the event
* taxonomy declared in @deepseek-ai/dsh-agent.
*/
/** Concrete ReactLoopAgent factory and driver service. */
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
/** IDs held by unpublished async creation transactions. */
private pendingAgentIds = new Set<AgentId>()
private pendingSessionIds = new Set<SessionId>()
// The schema validates plain strings (cordis.yml config values are untyped at runtime); the
// {@link Config} TYPE declares the branded `id`/`resumeSessionId` because the config format
// is the boundary where an id enters.
/** Runtime schema for declarative agents. */
static Config = z.object({
agents: z.array(z.object({
id: z.string().required(),
@@ -78,337 +348,143 @@ export class AgentLoop extends Service implements AgentFactory {
})).default([]),
}) as unknown as z<Config>
private readonly ownership: FactoryOwnership
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
private readonly runtime: { ctx: Context }
constructor(ctx: Context, public config: Config) {
super(ctx, 'agentLoop')
// Provide the agent-creation factory to the registry (effect-scoped: the
// slot is cleared on dispose).
ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
// The prompt variables the shipped loop provides, registered once.
this.ownership = new FactoryOwnership(ctx.fiber)
this.runtime = { ctx }
ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
if (resumeSessionId !== undefined && resumeSessionId !== '') {
// Wait for a late persistence service before resuming the configured session.
ctx.effect(() => {
const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => {
void this.resumeWith(childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options })
.catch((error: unknown) => {
this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
})
})
// Return the exact child-fiber disposer.
return fiber.dispose
}, `agentLoop.resume(${id})`)
} else {
if (resumeSessionId === undefined || resumeSessionId === '') {
this.create(id, options, cwd === undefined ? {} : { cwd })
continue
}
ctx.effect(() => {
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
void this.resumeWith(ctx, childCtx.sessionPersistence, {
agentId: id,
resumeSessionId,
agentOptions: options,
}).catch((error: unknown) => {
ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
})
})
return fiber.dispose
}, `agentLoop.resume(${id})`)
}
}
/**
* Create a config-driven agent with a unique session id for this run.
* @param id - agent id and generated-session prefix.
* @param options - loop options.
* @param meta - optional fresh-session metadata.
* @returns running agent owned by the calling fiber.
* Create an agent on a fresh per-run session, owned by the accessing fiber.
* Constructor-driven config calls use the loop fiber itself.
* @param id - agent registry id.
* @param options - concrete loop options.
* @param meta - optional fresh-session workspace metadata.
* @returns the published running agent.
*/
// TODO(demo): define a production resume-or-create policy for config-driven agents.
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
// The calling fiber owns the prepared session and agent lifecycle.
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta })
const { agent } = this.start(id, options, session, 'startup')
return agent
}
/**
* Programmatic factory create ({@link AgentFactory}): an agent on a caller-supplied
* `sessionId` (not `${id}-session`), with optional session metadata (validated `cwd`,
* lineage) and an optional `seed` event prefix.
*
* @param options - agent id, caller-supplied session id, optional seed/meta,
* and agent options.
* @returns the handle whose dispose tears down exactly this agent.
*/
async createAgent(options: CreateAgentOptions): Promise<AgentHandle> {
// Snapshot every caller-owned field before the first async setup boundary.
const agentId = options.agentId
const sessionId = options.sessionId
const setup = options.setup
const agentOptions = structuredClone(options.agentOptions ?? {})
const seed = options.seed === undefined ? undefined : structuredClone(options.seed)
const meta = structuredClone(options.meta ?? {})
const release = this.reserve(agentId, sessionId)
const loopCtx = this.runtime.ctx
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
try {
const session = this.ctx.sessions.prepare(sessionId, {
...seed !== undefined ? { seed } : {},
meta,
})
// A seeded (forked) create is still a fresh start, NOT a resume.
return await this.startOwned(agentId, agentOptions, session, 'startup', setup)
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
const session = loopCtx.sessions.prepare(sessionId, { meta })
const agent = transaction.prepare(options, session)
transaction.publish('startup')
return agent
} catch (error: unknown) {
void transaction.dispose(error instanceof Error ? error : new Error(String(error)))
throw error
} finally {
release()
transaction.finishWrapper()
}
}
/**
* Resume an agent on a persisted session ({@link AgentFactory}). Loads the session log +
* metadata via `ctx.sessionPersistence`, reconstructs the live session with the loaded
* events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on it.
* The live session id is the resumed id, not `${agentId}-session`.
*
* @param options - the persisted session id to reload, plus agent id/options.
* @returns the handle for the agent resumed on the reconstructed session.
* Create an owned agent on a caller-supplied session id.
* @param ownerCtx - caller context that structurally owns the transaction.
* @param options - identities, session seed/metadata, loop options, setup, and cancellation.
* @returns the published handle.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
// Read the service through `ctx.get('sessionPersistence')` — a direct global-store lookup
// keyed by the isolate symbol — not `this.ctx.sessionPersistence`.
const persistence = this.ctx.get('sessionPersistence')
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
const transaction = new AgentCreationTransaction(
this.runtime.ctx,
ownerCtx,
this.ownership,
options.agentId,
options.signal,
)
try {
const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
...options.seed === undefined ? {} : { seed: options.seed },
...options.meta === undefined ? {} : { meta: options.meta },
})
const agent = transaction.prepare(options.agentOptions ?? {}, session)
await transaction.waitFor(options.setup?.(agent.ctx))
transaction.assertActive()
return transaction.publish('startup')
} catch (error: unknown) {
await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
throw error
} finally {
transaction.finishWrapper()
}
}
/**
* Resume an owned agent from the configured persistence service.
* @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
* @param options - persisted identity, loop options, setup, and cancellation.
* @returns the published handle.
*/
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
const persistence = this.runtime.ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
}
return this.resumeWith(persistence, options)
return this.resumeWith(ownerCtx, persistence, options)
}
/**
* Resume against an EXPLICIT persistence handle. Factored out of {@link resume}
* so the config-driven path can pass the handle it obtained from a
* `ctx.inject(['sessionPersistence'], …)` child context: `this.ctx` (the
* service's own fiber) did not inject `sessionPersistence`, so reading it
* there from inside the inject child trips the cordis inject guard. The
* sessions store + registry are still read through `this.ctx` (both are in
* AgentLoop's static inject, so they resolve fine).
*/
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
// Persistence is an async trust boundary. Reserve, load, reconstruct, and
// publish only the identities/options accepted at entry—never fields
// reread from a caller-owned object after the await.
const agentId = options.agentId
const sessionId = options.resumeSessionId
const agentOptions = structuredClone(options.agentOptions ?? {})
const setup = options.setup
const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers<void>()
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
let observingOwner = true
// Resume must observe its caller from before persistence I/O begins.
const disposeLoadSentinel = this.ctx.effect(() => () => {
if (!observingOwner) return
markOwnerDisposed()
// Owner-triggered teardown does not reach quiescence until the resume
// transaction has observed disposal and released both reservations.
return transactionSettled
}, `agentLoop.resumeLoad(${agentId})`)
try {
const release = this.reserve(agentId, sessionId)
try {
const loadTask = persistence.load(sessionId)
const { meta, events } = await Promise.race([
loadTask,
ownerDisposed.then(() => {
throw new Error(`agent "${agentId}" resume aborted: owner disposed during persistence load`)
}),
])
// An out-of-band direct registry/session insertion can still race this
// service's reservation, so the public enter primitives re-check exact
// liveness at publication.
const session = this.ctx.sessions.prepare(sessionId, {
seed: events,
meta: {
createdAt: meta.createdAt,
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
},
})
// Calling startOwned synchronously installs the complete lifecycle
// effect before it reaches its first setup await. Only then disarm the
// load sentinel: ownership passes directly from one effect to the other
// with no disposal gap.
const starting = this.startOwned(agentId, agentOptions, session, 'resume', setup)
observingOwner = false
await disposeLoadSentinel()
return await starting
} finally {
release()
}
} finally {
try {
// Manual handoff/removal must not return transactionSettled: awaiting that promise from
// inside this transaction would deadlock it.
observingOwner = false
await disposeLoadSentinel()
} finally {
markTransactionSettled()
}
}
}
/**
* Reject a duplicate agent id BEFORE the session is entered into the store, so
* a failed factory call never leaves an orphaned live session (and lazy
* persistence state) behind. `register()` enforces the same uniqueness, but
* only after the session has already entered the store.
*/
private assertAgentIdFree(id: AgentId): void {
if (this.ctx.agents.get(id) !== undefined || this.pendingAgentIds.has(id)) {
throw new Error(`agent "${id}" is already registered`)
}
}
/** Reserve both public identities for one unpublished async transaction. */
private reserve(agentId: AgentId, sessionId: SessionId): () => void {
this.assertAgentIdFree(agentId)
if (this.ctx.sessions.get(sessionId) !== undefined || this.pendingSessionIds.has(sessionId)) {
throw new Error(`session "${sessionId}" already exists`)
}
this.pendingAgentIds.add(agentId)
this.pendingSessionIds.add(sessionId)
return () => {
this.pendingAgentIds.delete(agentId)
this.pendingSessionIds.delete(sessionId)
}
}
/**
* Construct an unpublished agent and synchronously install its complete
* teardown skeleton before any setup await. The closures are assigned their
* session/registry/loop disposers only at publication, while the exact scope
* disposer is nested immediately. Therefore owner unload during setup flips
* `active`, unwinds the scope, and wins the race without any late Cordis
* effect collection.
*/
private prepareLifecycle(id: AgentId, options: AgentOptions, session: Session): {
agent: ReactLoopAgent
active: () => boolean
deactivated: Promise<void>
publish: (source: SessionStartSource) => void
disposeAgent: () => Promise<void>
} {
// When creation is invoked through an agent scope (subagents), the owner agent's disposed
// status flips synchronously at handle teardown—earlier than Cordis reaches nested scope
// effects.
const ownerAgent = this.ctx.agent
const ownerFiber = this.ctx.fiber
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
const { agent } = driver
const scope: Scope = createScope(this.ctx, agent)
agent.ctx = scope.ctx.extend({ agent })
let active = true
let detachSession: (() => void) | undefined
let detachAgent: (() => void) | undefined
let stop: (() => Promise<void>) | undefined
const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers<void>()
const { promise: torndown, resolve: markTorndown } = Promise.withResolvers<void>()
const dispose = this.ctx.effect(function* () {
// First yielded, disposed last: every preceding teardown stage settled.
yield () => { markTorndown() }
// Exact identity moves the scope fiber out of the owner's concurrent
// sibling list and into this ordered transaction.
yield scope.rawDispose
yield () => {
detachSession?.()
detachSession = undefined
}
yield () => {
detachAgent?.()
detachAgent = undefined
}
// Last yielded, disposed first. Keep the pre-publication path
// synchronous: returning a Promise only after the loop actually began
// lets a failed announcement roll back registry/store before create's
// rejection is observed.
yield () => {
active = false
markDeactivated()
if (stop === undefined) return
return stop()
}
}, 'agentLoop.lifecycle()')
let disposing: Promise<void> | undefined
const disposeAgent = (): Promise<void> => (disposing ??= (async () => {
await dispose()
await torndown
})())
const publish = (source: SessionStartSource): void => {
// Publication is one synchronous, rollback-covered sequence. Setup has
// already completed, so its scoped listeners observe both announcements.
detachSession = agent.ctx.sessions.enter(session)
detachAgent = this.ctx.agents.enter(agent)
this.ctx.sessions.announce(session)
this.ctx.agents.announce(agent)
// Setup is over and both entries are live. Open the driving surface just
// before session-start so its listeners retain their supported ability to
// inject/queue, while setup itself can never drive an unpublished agent.
driver.enableDrive()
try {
agentEvents(this.ctx, agent).emit('agent/session-start', source)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
}
stop = driver.startDriver()
}
return {
agent,
active: () => active
&& ownerFiber.state !== FiberState.UNLOADING
&& ownerFiber.state !== FiberState.DISPOSED
&& ownerFiber.state !== FiberState.FAILED
&& ownerAgent?.status !== 'disposed',
deactivated,
publish,
disposeAgent,
}
}
/** Publish a no-setup config agent synchronously. */
private start(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
const lifecycle = this.prepareLifecycle(id, options, session)
try {
lifecycle.publish(source)
return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent }
} catch (error: unknown) {
void lifecycle.disposeAgent()
throw error
}
}
/**
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent.
*/
private async startOwned(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
setup?: (agentCtx: Context) => Promise<void> | void,
/** Resume through an explicit persistence handle used by the deferred config path. */
private async resumeWith(
ownerCtx: Context,
persistence: SessionPersistence,
options: ResumeAgentOptions,
): Promise<AgentHandle> {
const lifecycle = this.prepareLifecycle(id, options, session)
const transaction = new AgentCreationTransaction(
this.runtime.ctx,
ownerCtx,
this.ownership,
options.agentId,
options.signal,
)
try {
// The owner-disposal branch makes a never-settling setup unable to hold
// the transaction or its ID reservations forever. Promise.race installs
// rejection observation on setup even if owner disposal wins first.
const setupTask = Promise.resolve(setup?.(lifecycle.agent.ctx))
await Promise.race([
setupTask,
lifecycle.deactivated.then(() => {
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
}),
])
// Cordis begins a fiber unload synchronously but invokes nested effect disposers from its
// next microtask.
await Promise.resolve()
if (!lifecycle.active()) {
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
}
lifecycle.publish(source)
return { agent: lifecycle.agent, dispose: lifecycle.disposeAgent }
const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId))
transaction.assertActive()
const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
seed: loaded.events,
meta: {
createdAt: loaded.meta.createdAt,
...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd },
...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession },
...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength },
},
})
const agent = transaction.prepare(options.agentOptions ?? {}, session)
await transaction.waitFor(options.setup?.(agent.ctx))
transaction.assertActive()
return transaction.publish('resume')
} catch (error: unknown) {
await lifecycle.disposeAgent()
await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
throw error
} finally {
transaction.finishWrapper()
}
}
}

View File

@@ -11,7 +11,7 @@ import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, ContinuationStop, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
@@ -37,22 +37,20 @@ function toError(error: unknown): CodedError {
}
/**
* Validate the runtime result of the terminal-stop serial event. Event types
* protect TypeScript listeners, but JavaScript and casts can still return an
* arbitrary bail value; accepting one as an implicit stop would hide a broken
* policy plugin.
*/
function assertContinuationStop(value: unknown): asserts value is ContinuationStop | undefined {
if (value === undefined) return
const candidate = Object(value) as { action?: unknown }
if (candidate.action !== 'stop') {
throw new Error('agent/turn-stop returned an invalid result; expected { action: \'stop\' } or undefined')
}
}
/**
* Map a model-call {@link FinishReason} to the step error it should raise, or `undefined` when
* the step completed normally.
* Map a model-call {@link FinishReason} to the step error it should raise, or
* `undefined` when the step completed normally.
*
* Adapters report provider/transport failures one of two sanctioned ways (see
* the StreamChunk contract in dsh-llm): throw from `stream()` (handled by the
* caller's try/catch), OR end the stream with a finish-error/aborted chunk
* (the only option for adapters that can't throw mid-stream, e.g.
* library-backed ones). This translates the latter into a thrown step error
* so the turn ends error/aborted (the failure recorded on `turn/end.reason`),
* never as a normal `completed` assistant message.
*
* `FinishReason` is merge-extensible (plugins/adapters can add `kind`s), so
* the switch handles the known terminal-failure kinds and treats every other
* kind — `stop`, `tool-calls`, `max-tokens`, future additions — as success.
*/
function finishError(finish: FinishReason): CodedError | undefined {
switch (finish.kind) {
@@ -81,8 +79,17 @@ function errorData(err: CodedError): { message: string; code?: string } {
}
/**
* The turn-end contribution of a step's *successful* finish, or `undefined` when the step
* finished ordinarily (a plain `completed`).
* The turn-end contribution of a step's *successful* finish, or `undefined`
* when the step finished ordinarily (a plain `completed`).
*
* {@link finishError} has already converted `error`/`aborted` finishes into
* thrown step errors, so the finishes that reach here are `stop`,
* `tool-calls`, `max-tokens`, or a future merge-extensible kind. Only
* `max-tokens` carries forward as a distinct {@link TurnEndReason}: a step that
* hit the output-token ceiling ended the turn cut-short rather than by the
* model's choice. `stop`/`tool-calls`/unknown kinds contribute nothing beyond
* the default `completed`. {@link runTurn} applies this with the rule "any
* `max-tokens` step in the turn makes the turn end `max-tokens`".
*/
function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
switch (finish.kind) {
@@ -140,15 +147,66 @@ export interface LoopHandle {
}
/**
* The agent loop. One invocation drives one agent for its whole lifetime.
* The agent loop. One invocation 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 — a throwing plugin ends the turn, never the loop):
* 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror)
* allow → session('user/message'…) (+ inject additionalContext) | block → drop
* every prompt blocked → 'turn/end'(rejected), 0 steps
* STEP loop:
* drain steering → session('steering/message') ⟵ catches late steering
* assembly = ctx.systemPrompt.assemble(assembleContextFor(agent)) ⟵ waterfall system-prompt/assemble
* (scope-filtered; scoped sections/tools join); renderPrompt
* (persona section + {{variables}}) IS the full prompt
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen
* session prefix; logged on the header, never
* session history (scope-filtered, fused dispatch)
* await events.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step;
* pressure gates see the prefix the request carries
* boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the
* session('step/start') same sync frame, strictly before step/start
* config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches
* session('request/header'|'request/header-delta') ⟵ the header event this request owes the
* log (initial/resume anchor, delta, fallback)
* req = freeze({header..., messages: prefix+boundary, sessionId, signal})
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks, frozen req)
* session('assistant/chunk')
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
* session('assistant/message' {content, usage?}) session records what actually ran
* each tool-call in msg (sequential, abort-checked):
* session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask)
* → dispatch → tools/post-execute
* session('tool/result')
* append buffered post-execute additionalContext → session('context/message')(s)
* drain steering → session('steering/message')
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
* recorded as next-step steering
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
* terminal = serial agent/turn-stop ⟵ stop or abstain; after all ordinary
* continuation and steering folding
* if terminal: discard pending steering and break
* if action==stop: break
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
* await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier)
* re-enqueue leftover steering as queued ⟵ steering is never stranded
* idle (emit agent/status) unless more queued
* ```
* @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
* @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
*/
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
// Per-instance transmission bookkeeping: whether this loop instance has anchored the log's
// header fold yet (its first request logs a 'initial'/'resume' request/header snapshot).
// Per-instance transmission bookkeeping: whether THIS loop instance has
// anchored the log's header fold yet (its first request logs a
// 'initial'/'resume' request/header snapshot). Everything else the request
// needs is read from the session log itself — the loop holds no
// conversation state (the reconstructability RFC).
const transmission = createTransmissionLog()
const { session } = agent
@@ -161,8 +219,19 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
await handle.inbox.waitForQueued(handle.disposed)
if (handle.isDisposed()) break
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the idle wait but
// before we flip to `running`.
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
// idle wait but before we flip to `running`. The cancelled queued/steering
// work is already cleared by `cancel()`. Clear the marker, then:
// - if NOTHING new is queued, drop the about-to-run turn and re-park,
// settling any `whenIdle()` waiter DIRECTLY (no running→idle transition
// fires here to settle it) and WITHOUT emitting `agent/status` (an ACP
// listener must not see a spurious idle that resolves a freshly-queued
// prompt as cancelled);
// - if a NEW prompt was queued AFTER the cancel (a send() that raced in
// before the loop resumed), the marker was for the cancelled work only —
// fall through and run the new prompt's turn. Do NOT settle waiters here:
// a whenIdle() waiter must wait for that new turn's running→idle, not
// resolve before it runs (the quiescence contract).
if (handle.isCancelled()) {
handle.clearCancel()
if (!handle.inbox.hasQueued) {
@@ -173,8 +242,18 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
handle.setStatus('running')
// Pre-step cancel (window 2): `setStatus('running')` emits `agent/status` SYNCHRONOUSLY, so
// a `running` listener can `cancel()` in the gap between the check above and `runTurn`.
// Pre-step cancel (window 2): `setStatus('running')` emits `agent/status`
// SYNCHRONOUSLY, so a `running` listener can `cancel()` in the gap between the
// check above and `runTurn`. Mirror window 1: clear the marker, then
// - if NOTHING new is queued, drop the about-to-run turn and transition
// back to `idle` (`running` was already emitted, so a real idle
// transition balances the status AND settles `whenIdle()` waiters);
// - if a NEW prompt was queued AFTER the cancel (a `running` listener that
// cancels then sends), the marker was for the cancelled work only — fall
// through and run the new prompt's turn (status is already `running`), so
// a `whenIdle()` waiter resolves on THAT turn's running→idle, not before
// it runs. Settling here would resolve quiescence while the replacement
// is still queued and unrun (the same early-resolve race window 1 fixes).
if (handle.isCancelled()) {
handle.clearCancel()
if (!handle.inbox.hasQueued) {
@@ -183,14 +262,24 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
}
}
// Re-derive turn numbers because idle injection can advance the log.
// Re-derive the turn number from the log each iteration (do NOT keep a local
// counter): an idle `agent.inject()` can append its own one-shot turn while
// the loop waits above, so the next real turn must continue from whatever
// turn number is actually last in the log — a stale counter would collide.
const turn = lastTurnNumber(session) + 1
let terminalStopped = false
try {
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
} catch (error: unknown) {
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard before
// turn/start) — no turn/start was appended, so no turn is open and none is owed.
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard
// before turn/start) — no turn/start was appended, so no turn is open and
// none is owed. A session `error` here would land outside any turn (after
// the previous turn/end), where the persistence backend drops it as a
// crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the
// driver survives and moves on.
// Acceptance and internal dispatch validation can reject before
// turn/start commits. Report that supported pre-turn failure without
// inventing a turn/end for a turn that never opened.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
try {
@@ -198,12 +287,21 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
}
// Reset the cancel marker UNCONDITIONALLY here, after the turn returns and before the next
// iteration's idle wait.
// Reset the cancel marker UNCONDITIONALLY here, after the turn returns and
// before the next iteration's idle wait. NOT gated on the idle transition
// below: a `send()` that lands during the cancelled turn's flush window makes
// `hasQueued` true at the `setStatus('idle')` guard, so an idle-gated reset
// would never fire and the stale marker would wrongly drop that next prompt's
// turn. Resetting per iteration scopes the marker to exactly the turn that was
// cancelled.
handle.clearCancel()
// Steering that arrived too late to join an ordinary turn (turn-end listeners, flush)
// becomes queued input so it is never stranded.
// Steering that arrived too late to join an ordinary turn (turn-end
// listeners, flush) becomes queued input so it is never stranded. A
// terminal-stop owner is the deliberate exception: discard the steering
// again after the close + flush window so terminal policy cannot be undone
// after its in-turn drain. Ordinary queued sends live in a separate FIFO and
// remain untouched.
for (const message of handle.inbox.drainSteering()) {
if (!terminalStopped) handle.inbox.enqueue(message)
}
@@ -217,7 +315,10 @@ async function runTurn(
): Promise<boolean> {
const { session } = agent
// --- Pre-turn.
// --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
// turn/start has not been appended — so it propagates to runLoop's backstop
// untouched. The queued messages are drained here but appended AFTER
// turn/start (below), so every event in the log lives inside a turn.
const queued = handle.inbox.drainQueued()
const first = queued[0]
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
@@ -230,36 +331,28 @@ async function runTurn(
let errorReported = false
let terminalStopped = false
// Close the open step exactly once (idempotent via stepOpen).
const closeStep = (): boolean => {
if (!stepOpen) return false
// Close the open step exactly once (idempotent via stepOpen). Post-commit
// session/event observers are contained by Session; a pre-commit validator
// failure still escapes so the outer recovery path may retry the boundary or
// fail loudly without pretending an uncommitted step/end exists.
const closeStep = (): void => {
if (!stepOpen) return
session.append('step/end', { turn, step })
stepOpen = false
// Preserve step balance even when an event listener throws after append.
let failure: unknown
try {
session.append('step/end', { turn, step })
} catch (error: unknown) {
failure = error
}
// A throwing step/end session-event listener surfaces as a turn error via failTurn
// (idempotent).
if (failure !== undefined) {
failTurn(toError(failure))
return true
}
return false
}
// Record a step/turn failure exactly once: set the error reason (carrying the failing `step`
// — the durable failure lives entirely on turn/end.reason, there is no separate session error
// event) and emit agent/error (contained — trap: a throwing agent/error listener must not
// re-escape and strand the turn).
// Record a step/turn failure exactly once: set the error reason (carrying the
// failing `step` — the durable failure lives entirely on turn/end.reason, there
// is no separate session error event) and emit agent/error (contained — trap: a
// throwing agent/error listener must not re-escape and strand the turn).
// Disposal and abort set `reason` directly without calling this (they are not
// failures).
const failTurn = (err: CodedError): void => {
if (errorReported) return
errorReported = true
// The turn is always still open here: the only failure that can reach failTurn once
// turn/end is appended would be a throwing turn-boundary listener, and turn boundaries are
// durable session events with no agent/* mirror to throw.
// The turn is still open here. Post-commit observers cannot escape append,
// and a pre-commit turn/end veto leaves no closing boundary to overwrite.
// Set the reason that the next successful closeTurn will append.
reason = { kind: 'error', step, ...errorData(err) }
try {
events.emit('agent/error', turn, step, err)
@@ -269,23 +362,23 @@ async function runTurn(
}
}
// Close the turn.
// Close the turn. Post-commit observer failures are contained by Session;
// pre-commit validation failures escape to recovery instead of being mistaken
// for a committed boundary. Turn boundaries are durable session events only.
const closeTurn = (): void => {
// Session.append pushes turn/end before notifying session/event listeners, so a throwing
// listener leaves turn/end in the log (the turn is balanced) but would otherwise escape —
// from the outer catch it would propagate to the runLoop backstop.
try {
session.append('turn/end', { turn, reason })
} catch (error: unknown) {
ctx.logger.warn(`agent "${agent.id}": session/event listener threw on turn/end at turn ${turn}: ${toError(error).message}`)
}
session.append('turn/end', { turn, reason })
}
try {
// --- Turn boundary.
// --- Turn boundary. Once turn/start is appended, a turn/end is owed no
// matter what throws below; the catch + closeTurn guarantee it. A pre-commit
// veto leaves no turn/start in the log and therefore owes no turn/end.
session.append('turn/start', { turn, trigger })
// Each drained queued message runs the `agent/prompt-submit` waterfall before it becomes a
// `user/message` — a hook can rewrite the prompt or block it.
// Each drained queued message runs the `agent/prompt-submit` waterfall before
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
// throws) is caught below and the turn still closes.
let anyAllowed = false
// Seeded with a floor (only observable if the batch were empty, which
// runTurn never allows — it is called with ≥1 queued message); each `block`
@@ -299,7 +392,13 @@ async function runTurn(
)
if (decision.kind === 'block') {
lastBlockReason = decision.reason
// Log each veto because turn/end cannot represent every blocked prompt.
// Record the veto durably: `PromptDecision.reason` is the durable record
// of why a prompt was blocked, but a fully-blocked batch's `rejected`
// turn/end only preserves the LAST reason, and a MIXED batch (this prompt
// blocked, another allowed) does not end `rejected` at all — so without
// this append a blocked prompt would vanish from the log whenever any
// sibling prompt is allowed. `prompt/blocked` sits in the open turn in
// place of the `user/message` this prompt would have become.
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
continue
}
@@ -315,7 +414,11 @@ async function runTurn(
}
while (true) {
// A fully blocked batch ends as a balanced zero-step rejected turn.
// A fully-blocked batch (every prompt vetoed by prompt-submit) opens a
// zero-step turn that ends `rejected`: break BEFORE the first step so the
// boundary stays balanced (turn/start → turn/end) and the block is a
// durable in-turn fact. `anyAllowed` never changes inside the loop, so this
// only ever fires on the first iteration.
if (!anyAllowed) {
reason = { kind: 'rejected', reason: lastBlockReason }
break
@@ -326,29 +429,56 @@ async function runTurn(
// the request.
drainSteering(agent, handle.inbox, turn)
// The step's AbortController exists before any async pre-step work so a dispose() or
// cancel() — in a synchronous turn-start listener or an async listener whose effect fires
// before we block — always has an armed abort to cancel against. isDisposed below covers
// disposal, which does not set the cancel marker.
// The step's AbortController exists BEFORE any async pre-step work so a
// dispose() or cancel() — in a synchronous turn-start listener or an
// async listener whose effect fires before we block — always has an armed
// abort to cancel against. isDisposed below covers disposal, which does
// NOT set the cancel marker. Cleared on every exit path below.
const abort = new AbortController()
handle.setAbort(abort)
// Assemble the system prompt for this step.
// Assemble the system prompt for this step. Done HERE (before step/start)
// because the pre-step seam needs it: compaction measures token pressure
// against the system prompt (it counts toward the budget). runStep reuses
// this same assembly for the request, so the prompt is assembled once per
// step. renderPrompt IS the full prompt — the persona is the order-0
// section (owned by dsh-system-prompt) and `{{variable}}`
// interpolation happens in the render, so there is no separate join.
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
const fullSystemPrompt = renderPrompt(assembly)
// Interruption landing after assembly: dispose() or cancel() in a turn-start listener (or
// a listener whose promise resolved before the await above) arms either
// handle.isDisposed() or handle.isCancelled().
// Interruption landing after assembly: dispose() or cancel() in a
// turn-start listener (or a listener whose promise resolved before the
// await above) arms either handle.isDisposed() or handle.isCancelled().
// The Abort was created first, so any concurrent abort also lands on it.
// Drop the about-to-start step WITHOUT running the seam — no step is open
// yet, so end the turn accordingly (disposed wins for an unambiguous
// reason).
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
// Compose the session prefix ONCE per loop instance, lazily before the instance's first
// pre-step: request-only messages placed in front of the entire derived history on every
// request this instance sends.
// Compose the session prefix ONCE per loop instance, lazily before the
// instance's first pre-step: request-only messages placed in front of
// the ENTIRE derived history on every request this instance sends. It
// MUST precede the pre-step seam so compaction gates on THIS instance's
// prefix — reading a previous instance's logged prefix would let a
// resumed/forked instance whose contributor grew skip compaction and
// ship an over-window first request. The result is deep-cloned
// (decoupled from listener-held references), deep-frozen, and cached on
// the transmission bookkeeping, so reuse is structural — the prefix
// cannot change mid-session and the provider prefix cache holds by
// construction (resume = a new instance = a recompose, anchored by its
// 'resume' snapshot). The prefix is not session history — the header
// event in runStep is its only durable record
// (EpochHeader.messagePrefix). The frozen empty seed serves both the
// listener chain and the no-listener fallback: a contribution is a
// RETURNED extension of `await next()`, never an in-place push. This
// runs OUTSIDE the step, before the boundary snapshot: a composing
// listener's session append lands before the boundary and joins the
// CURRENT request.
if (transmission.sessionPrefix === undefined) {
const emptyPrefix: Message[] = deepFreeze([])
const composed = await events.waterfall(
@@ -356,9 +486,16 @@ async function runTurn(
() => Promise.resolve(emptyPrefix),
)
// Interruption landing during prefix composition: mirror the assembly window above —
// drop the about-to-start step without running the seam, and DISCARD the composition
// instead of caching it.
// Interruption landing during prefix composition: mirror the assembly
// window above — drop the about-to-start step without running the
// seam, and DISCARD the composition instead of caching it. An
// abort-aware listener may have returned a degraded fallback under
// the firing signal; committing it would ship a prefix no request
// ever used (and no header ever logged) on this instance's next real
// request. The next turn recomposes under a live signal — the cache
// only ever holds a fully composed prefix. The cache-hit path needs
// no such check: nothing awaits between the assembly check above and
// the pre-step seam.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
@@ -367,7 +504,19 @@ async function runTurn(
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
}
// Run compaction between steps so its surface events remain outside step brackets.
// Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the
// step: after `turn/start` (and the prior step's close) but before
// `step/start`, so a compaction's log-only `compact/*` records and its
// replacement node land cleanly outside any step (honest structure that
// crash-safety relies on — a dangling `compact/start` sits before the
// synthetic `turn/end` repair appends). Serial (awaited, in order, no
// veto): each listener completes its surface mutation before the next, so
// concurrent listeners cannot interleave their `session.append`s. A
// throwing listener escapes to the outer catch, which closes the (not-yet-
// open) step as a no-op and ends the turn via failTurn — a broken
// pre-step plugin ends the turn, not the loop. The composed session
// prefix rides along so token-pressure listeners count everything the
// request will actually carry.
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
// Interruption landing during the pre-step seam: do not open an empty step.
@@ -377,20 +526,28 @@ async function runTurn(
break
}
// The reconstruction boundary (the reconstructability RFC): the request's messages are
// snapshotted HERE, in the same synchronous frame as the step/start append directly below
// — so the snapshot is exactly the derivation over the log prefix strictly before
// step/start's seq.
// The reconstruction boundary (the reconstructability RFC): the request's
// messages are snapshotted HERE, in the same synchronous frame as the
// step/start append directly below — so the snapshot is exactly the
// derivation over the log prefix strictly before step/start's seq.
// Anything appended later by the request-window inject seam or a
// concurrent task lands after the boundary and joins the NEXT request.
// session/event itself is observe-only: append reentrancy is rejected
// until the current callback list drains. An external reconstructor
// recovers these exact messages by folding the surface over
// events[0..stepStartSeq).
const boundaryMessages = session.deriveMessages()
// Mark the step open before the append: Session.append pushes the event to the log before
// notifying session/event listeners, so a THROWING step/start listener leaves step/start
// in the log.
stepOpen = true
session.append('step/start', { turn, step })
// Only a committed step/start creates a balancing obligation. A
// pre-commit veto throws before this assignment; post-commit observers
// are contained inside Session.append().
stepOpen = true
// Cancel landing in the step-start window: a synchronous `session/event` step/start
// listener can cancel after the step is already open.
// Cancel landing in the step-start window: a synchronous `session/event`
// step/start listener can cancel after the step is already open. Check
// AFTER the step/start append and before `runStep`: drop the step, end the
// turn accordingly. closeStep balances the already-appended step/start.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
@@ -425,14 +582,20 @@ async function runTurn(
break
}
// Preserve max-tokens once any step reports it.
// The successful step's finish reason carries forward: a `max-tokens`
// step makes the whole turn end `max-tokens` (the ACP RFC's rule "any
// max-tokens step surfaces as max-tokens"). `stepFinishReason` returns
// `max-tokens` or `undefined`, so a later ordinary step never resets a
// max-tokens turn back to completed, and a never-truncated turn keeps the
// default `completed`. The disposal/abort/error branches above and the
// continuation-window disposal check below override this — they win.
const stepReason = stepFinishReason(stepOutcome.finish)
if (stepReason) reason = stepReason
// Steering that arrived during streaming/tool execution.
const steered = drainSteering(agent, handle.inbox, turn)
if (closeStep()) break
closeStep()
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
let decision: ContinuationDecision
@@ -461,12 +624,13 @@ async function runTurn(
// the next iteration's drain records it.
if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
// Terminal policy runs only after the extensible continuation waterfall, its optional
// reason, and late steering have all been folded.
// Terminal policy runs only AFTER the extensible continuation waterfall,
// its optional reason, and late steering have all been folded. Unlike the
// waterfall, this serial seam is monotonic: the first stop bail wins, and
// no later listener or steering override can resurrect the turn.
let terminalStop = false
try {
const stop = await events.strictSerial('agent/turn-stop', turn)
assertContinuationStop(stop)
const stop = await events.serial('agent/turn-stop', turn)
terminalStop = stop !== undefined
} catch (error: unknown) {
// A broken terminal policy is an ordinary continuation failure: fail
@@ -476,13 +640,19 @@ async function runTurn(
}
if (terminalStop) {
terminalStopped = true
// A continuation reason or listener may have queued steering before the terminal
// checkpoint.
// A continuation reason or listener may have queued steering before the
// terminal checkpoint. Discard only steering (never ordinary queued
// prompts) so it cannot become a next step or be re-enqueued as a fresh
// turn by runLoop's late-steering fallback.
handle.inbox.drainSteering()
shouldContinue = false
}
// A turn-scoped marker catches cancellation between step controllers.
// A cancel that landed during the continuation window — after the step's
// AbortController was cleared (setAbort(undefined)) but before the next
// step starts — has no controller to observe it, so the turn-scoped marker
// ends the turn here. cancel() also cleared the steering FIFO, so the
// override above did not re-arm continuation.
if (handle.isCancelled()) {
reason = { kind: 'aborted', reason: handle.cancelReason() }
break
@@ -498,11 +668,19 @@ async function runTurn(
// Normal / inline-error loop exit: close the turn.
closeTurn()
} catch (error: unknown) {
// Decide whether this turn was ever opened from the LOG, not a flag.
// Decide whether this turn opened from the LOG, not a speculative flag. A
// pre-commit validator or acceptance failure leaves no turn/start and owes
// no turn/end, so it propagates to runLoop's backstop. Once turn/start is
// present, this path balances any committed step and records the failure.
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
if (!turnStartLogged) throw error
closeStep()
// Choose the close reason.
// Choose the close reason. Disposal wins only if no error was already
// reported: a turn disposed mid-step sets reason=disposed in the step-error
// branch (without reporting an error), so preserve disposed rather than
// overwrite it. Otherwise a mid-step throw on a live agent is a real
// failure → failTurn. (errorReported is mutated only inside the failTurn
// closure, which the analyzer can't follow, hence the inline lint-disable.)
if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
reason = { kind: 'disposed' }
} else {
@@ -517,8 +695,13 @@ async function runTurn(
try {
await ctx.sessions.flush(session)
} catch (error: unknown) {
// The turn is already closed (turn/end appended above) and flush must run after turn/end to
// be a checkpoint — so there is no in-turn position left for a session `error` event.
// The turn is already closed (turn/end appended above) and flush must run
// AFTER turn/end to be a checkpoint — so there is no in-turn position left
// for a session `error` event. Appending one here would land it after the
// last turn/end, where the persistence backend treats it as a crash tail
// and drops it on resume (the turn-enclosure RFC: every event is turn-enclosed). Report
// the failure via agent/error + the logger only; persistence keeps the
// buffered events for the next flush/dispose, so nothing is lost.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
try {
@@ -560,17 +743,27 @@ async function runStep(
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
const { session, options } = agent
// Seed the call config: the first request of this loop instance seeds from current
// AgentOptions — explicit options always win over the logged baseline, which is what keeps
// fork model-overrides and resume-time reconfiguration correct.
// Seed the call config: the first request of THIS loop instance seeds from
// current AgentOptions — explicit options always win over the logged
// baseline, which is what keeps fork model-overrides and resume-time
// reconfiguration correct. Later steps seed from the log's folded header,
// which by then is exactly what this instance last logged.
// One deep-cloned, frozen seed serves BOTH the listener chain and the
// no-listener fallback: structuredClone decouples it from the session's
// cached header fold (a raw reference would let a delegating listener
// mutate the fold in place and silently skip the delta log), and the freeze
// makes in-place shaping unrepresentable — a switch is a RETURNED
// replacement, which the header event below records.
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
? session.requestHeader()!.config
: { model: options.model ?? '' }))
// Shape the call config: listeners return a replacement to switch model or sampling (the seed
// is frozen — content shaping is not expressible here; model-visible content flows through
// the log channels).
// Shape the call config: listeners return a replacement to switch model or
// sampling (the seed is frozen — content shaping is not expressible here;
// model-visible content flows through the log channels). The header event
// below records whatever the request ACTUALLY uses, so a listener's switch
// is a logged, reconstructable fact, never silent drift.
const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig))
if (!config.model) {
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
@@ -592,9 +785,11 @@ async function runStep(
})
recordRequestHeader(session, transmission, header)
// Build and freeze: the request is a pure function of (boundary snapshot, logged header) —
// llm/stream listeners and adapters read it, mutation throws. sessionId + frozen is the
// loop-built marker the dev invariant keys on.
// Build and freeze: the request is a pure function of (boundary snapshot,
// logged header) — llm/stream listeners and adapters read it, mutation
// throws. sessionId + frozen is the loop-built marker the dev invariant
// keys on. Message order: header.messagePrefix, then the boundary
// snapshot — the reconstruction equation the invariant recomputes.
const request: GenerateOptions = deepFreeze({
model: header.config.model,
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
@@ -618,16 +813,23 @@ async function runStep(
assembler.push(chunk)
}
// Normalize terminal error chunks into the same failure path as thrown adapter errors.
// Adapters report provider/transport failures one of two sanctioned ways
// (see the StreamChunk contract in dsh-llm): throw from stream() — already
// handled by the caller's try/catch — OR end the stream with a
// finish-error/aborted chunk. finishError() maps the latter to the step
// error to raise (turn ends error/aborted, not a normal completed message).
const stepError = finishError(assembler.finish)
if (stepError) throw stepError
if (assembler.finish.kind === 'max-tokens') {
let message: Message = withoutToolCalls(assembler.message())
message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)))
// Fire the assistant/message when there is content OR usage: a max-tokens step can be cut
// off with empty content but still carry token accounting, and assistant/message is the
// only host for usage (there is no standalone usage event).
// Fire the assistant/message when there is content OR usage: a max-tokens
// step can be cut off with empty content but still carry token accounting,
// and assistant/message is the only host for usage (there is no standalone
// usage event). An empty-content assistant/message is skipped by
// deriveMessages(), so hosting usage on it never injects a spurious assistant
// turn into derived history.
if (message.content.length > 0 || assembler.usage) {
// A max-tokens finish is itself a streamed `finish` chunk, so chunkSeqs is
// never empty here — pass the provenance unconditionally.
@@ -646,7 +848,14 @@ async function runStep(
let message: Message = assembler.message()
message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))
// Do not append an assistant message without content or usage; omit empty provenance too.
// Same content-or-usage guard as the max-tokens branch: a step that finishes
// with neither assembled content nor usage (e.g. a bare `stop` finish that
// streamed nothing) records no assistant/message — an empty-content message
// exists only to host usage, and deriveMessages() skips it either way, so
// appending one with no usage would be a pure trace-only row.
//
// sourceEventSeqs records the assistant/chunk provenance, but is omitted when
// no chunks streamed (the surface invariant rejects an empty sourceEventSeqs).
if (message.content.length > 0 || assembler.usage) {
session.append(
'assistant/message',
@@ -659,7 +868,11 @@ async function runStep(
// ToolRegistry.execute converts tool failures (including aborts) into
// isError results, so abort is re-checked around every call here.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
// Per-step buffer of `additionalContext` attached by tools/post-execute listeners.
// Per-step buffer of `additionalContext` attached by tools/post-execute
// listeners. Appended as context/message(s) only AFTER every tool/result for
// the step, so a multi-call step keeps tool-call/result adjacency
// (interleaving context between a call's result and the next call's would
// break the pairing the next model request relies on).
const pendingContext: HookContext[] = []
for (const call of toolCalls) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
@@ -671,7 +884,12 @@ async function runStep(
} catch {
parsedArguments = call.arguments
}
// TODO(pre-tool-input-rewrite): arguments cannot change after their audit and history events are logged.
// TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite
// `arguments` — tool/call (the audit record) and assistant/message (the
// model-history source) are logged BEFORE execute, and live consumers (ACP,
// tool-bash presentation) read the pre-execution args, so an execution-only
// rewrite would desync the UI from what ran. Designing that consistently is
// its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md).
const result = await ctx.tools.execute({
callId: call.id,
name: call.name,
@@ -681,10 +899,12 @@ async function runStep(
})
session.append('tool/result', {
turn, step,
// The correlation id must be the loop's authoritative call.id (the model-transcript id
// that deriveMessages turns into toolCallId), not result.callId — a post-execute
// waterfall listener returning a mismatched id would otherwise orphan the call↔result
// pairing in the next model request.
// The correlation id MUST be the loop's authoritative call.id (the
// model-transcript id that deriveMessages turns into toolCallId), NOT
// result.callId — a post-execute waterfall listener returning a
// mismatched id would otherwise orphan the call↔result pairing in the
// next model request. A listener-internal id, if ever needed, belongs in
// a separate diagnostic field, never overloaded onto callId.
callId: call.id,
content: result.content,
isError: result.isError,
@@ -728,9 +948,13 @@ export function lastTurnNumber(session: Session): number {
}
/**
* Whether a turn is currently open in the session log (a `turn/start` with no matching later
* `turn/end`).
*
* Whether a turn is currently open in the session log (a `turn/start` with no
* matching later `turn/end`). Decided from the LOG, not agent status: status
* can be `running` while no turn is open (an `agent/status` listener firing
* before `turn/start`, or the post-`turn/end` flush window before status
* returns to idle), so status is not a reliable open-turn signal. Used by
* `inject()` to choose between appending into an open turn vs. wrapping the
* injection in its own one-shot turn (the turn-enclosure RFC).
* @param session - the session whose log is inspected.
* @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
*/

View File

@@ -7,7 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
@@ -49,6 +49,33 @@ function send(agent: ReactLoopAgent, text: string) {
}
describe('ReactLoopAgent', () => {
it('rejects access before context binding and a second driver for one session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('exclusive-driver'))
const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { model: 'mock' }, session)
expect(() => prepared.agent.ctx).toThrow('context is not bound')
expect(() => prepareReactLoopAgent(ctx, AgentId('second-driver'), { model: 'mock' }, session))
.toThrow('already has a concrete agent driver')
await prepared.dispose()
await ctx.fiber.dispose()
})
it('borrows caller options and binds its scoped context exactly once', async () => {
const ctx = await harness(new MockAdapter([textResponse('unused')]))
const options = { model: 'mock' }
const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options)
expect(agent.options).toBe(options)
expect(agent.id).toBe('owned-bindings')
expect(agent.session.id).toMatch(/^owned-bindings-session-/)
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
await ctx.fiber.dispose()
})
it('send() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
@@ -140,8 +167,10 @@ describe('ReactLoopAgent', () => {
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Non-serializable injected content makes Session.append throw after turn/start was
// recorded.
// Non-serializable injected content makes Session.append throw AFTER
// turn/start was recorded. The turn/end must still be appended (finally),
// AND the durability checkpoint must still fire — the balanced turn is in
// memory and a crash before the next turn/dispose would otherwise lose it.
expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
@@ -157,7 +186,8 @@ describe('ReactLoopAgent', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// A session/event listener that throws on the synthetic turn/end.
// Session contains a throwing post-commit turn/end observer. The accepted
// boundary still triggers the idle injection's durability checkpoint.
let threw = false
ctx.on('session/event', (_s, event) => {
if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') }
@@ -197,8 +227,10 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A non-serializable source makes the turn/start append throw before the event is pushed
// (Session.append validates before push), so NO turn opens.
// A non-serializable source makes the turn/start append throw BEFORE the
// event is pushed (Session.append validates before push), so NO turn opens.
// The finally's isTurnOpen() guard sees no open turn and appends nothing —
// the log stays empty, not left with a dangling turn/start.
expect(() => {
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
}).toThrow(/non-JSON-serializable/)
@@ -231,7 +263,7 @@ describe('ReactLoopAgent', () => {
// Start the loop to get the disposer; the agent waits for messages
// (idle, never-resolving cancel), so it will stay idle.
prepared.enableDrive()
prepared.markPublished()
const dispose = prepared.startDriver()
// First dispose
@@ -244,6 +276,21 @@ describe('ReactLoopAgent', () => {
expect(agent.status).toBe('disposed')
})
it('a pre-start disposal makes a later driver-start attempt inert', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session)
await prepared.dispose()
expect(prepared.agent.status).toBe('disposed')
const dispose = prepared.startDriver()
await dispose()
await expect(prepared.agent.done).resolves.toBeUndefined()
expect(prepared.agent.session.events).toEqual([])
await ctx.fiber.dispose()
})
it('setting the same status does not emit agent/status again', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
@@ -319,9 +366,10 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter while running (not
// the fast path), then the disposer settles it and chains `done` (loop exit), not an eager
// resolve.
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
// while running (not the fast path), then the disposer settles it and chains
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
// internal driver disposer keeps the emit synchronous.
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -333,7 +381,7 @@ describe('ReactLoopAgent', () => {
const session = ctx.sessions.create(SessionId('bare'))
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const { agent } = prepared
prepared.enableDrive()
prepared.markPublished()
const dispose = prepared.startDriver()
agent.send([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
@@ -347,9 +395,11 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
// The waiter is internal agent state, not an effect-scoped ctx.on listener: disposing the
// OWNING fiber runs the agent's listener disposers, which would have dropped a ctx.on-based
// waiter before the 'disposed' transition and hung the promise.
// The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
// disposing the OWNING fiber runs the agent's listener disposers, which would
// have dropped a ctx.on-based waiter before the 'disposed' transition and
// hung the promise. With internal waiters, the fiber disposer still settles
// it. Regression for the round-3 whenIdle finding.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
@@ -367,8 +417,10 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => {
// The disposer emits agent/status('disposed') before the driver loop unwinds, so whenIdle()
// must chain `done` (true quiescence) on the disposed path.
// The disposer emits agent/status('disposed') BEFORE the driver loop
// unwinds, so whenIdle() must chain `done` (true quiescence) on the
// disposed path. Dispose a running agent, then assert whenIdle() resolves
// only after `done` — i.e. the loop has actually exited.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
@@ -404,7 +456,7 @@ describe('ReactLoopAgent', () => {
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on running'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
warn.mockRestore()
})
@@ -422,7 +474,7 @@ describe('ReactLoopAgent', () => {
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on idle'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
warn.mockRestore()
})
})

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
@@ -35,27 +36,24 @@ function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
describe('turn boundary listener throws (handled in-turn, loop survives)', () => {
it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => {
// Pre-append validation reports through agent/error without corrupting the log.
const adapter = new MockAdapter([textResponse('turn 2')])
describe('inbox acceptance', () => {
it('rejects non-serializable content or source synchronously before notification or enqueue', async () => {
const adapter = new MockAdapter([textResponse('turn 1')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let queued = 0
ctx.on('agent/queued', () => { queued += 1 })
const errors: { turn: number; step: number; message: string }[] = []
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
expect(() => {
agent.send([{ type: 'text', text: 'first', bad: 1n } as never])
}).toThrow(/losslessly JSON-serializable/)
expect(() => {
agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
}).toThrow(/losslessly JSON-serializable/)
expect(queued).toBe(0)
expect(agent.session.events).toHaveLength(0)
// A non-serializable source (BigInt) on the queued message.
agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.step).toBe(0)
expect(errors[0]!.message).toMatch(/non-JSON-serializable/)
// No turn boundary was written (the turn/start append threw before push).
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
// loop survives: a well-formed second turn runs normally.
// The rejected value never woke or poisoned the loop; a valid message runs.
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
@@ -125,13 +123,15 @@ describe('tool JSON parse', () => {
})
describe('toError normalization', () => {
it('normalizes non-Error throws from a turn/start session-event listener via toError', async () => {
it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('session/event', (_session, event) => {
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as SessionEvent
if (event.type === 'turn/start' && !threwOnce) {
threwOnce = true
throw 'naked string error' // non-Error throw, normalized via toError
@@ -144,11 +144,9 @@ describe('toError normalization', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toBe('naked string error')
// A non-Error throw is wrapped in a HarnessError with code UNKNOWN, so the
// turn-end error reason carries a routable code instead of degrading.
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN')
expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' })
expect(adapter.requests).toEqual([])
expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false)
})
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {

View File

@@ -268,7 +268,7 @@ describe('agent loop', () => {
expect(result.data.meta).toBeUndefined()
expect(result.data.content).toEqual([{
type: 'text',
text: 'Error: tools/execute must return a losslessly JSON-serializable ToolExecutionResult',
text: 'Error: tool result must be losslessly JSON-serializable',
}])
}
// The normalized failure was durably logged and fed back to the model; the
@@ -798,10 +798,10 @@ describe('agent loop', () => {
])
})
it('stops the turn when a step/end session-event listener failure has recorded an error', async () => {
it('contains a step/end observer failure without changing continuation', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'x' }),
textResponse('should not run'),
textResponse('continued after tool call'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
@@ -814,9 +814,8 @@ describe('agent loop', () => {
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threw = false
// A throwing step/end session-event listener is the surviving boundary-listener
// failure path (step boundaries have no agent/* mirror): closeStep contains it
// and surfaces it as a turn error rather than stranding the turn open.
// Post-commit session observers cannot control the loop. The tool call still
// drives the second model request, and the turn completes normally.
ctx.on('session/event', (_session, event) => {
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('bad step/end listener') }
})
@@ -824,9 +823,9 @@ describe('agent loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests).toHaveLength(2)
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
})
it('chains queued messages into consecutive turns', async () => {

View File

@@ -69,7 +69,29 @@ async function promptly<T>(task: Promise<T>): Promise<T> {
}
}
/** Throw an arbitrary callback value to exercise the public unknown-error boundary. */
function throwUnknown(value: unknown): never {
throw value
}
describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('normalizes a non-Error resume publication failure for rollback and rethrows it', async () => {
const sessionId = SessionId('unknown-resume-failure-s')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const failure = { source: 'resume' }
ctx.on('session/created', () => throwUnknown(failure))
await expect(ctx.agents.resume({
agentId: AgentId('unknown-resume-failure'),
resumeSessionId: sessionId,
})).rejects.toBe(failure)
expect(ctx.agents.get(AgentId('unknown-resume-failure'))).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
@@ -168,7 +190,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
order.push('session/created')
})
ctx.on('agent/created', (agent) => {
expect(() => { agent.cancel('too early') }).toThrow(/cannot cancel before creation setup completes/)
expect(agent.status).toBe('idle')
order.push('agent/created')
})
ctx.on('agent/session-start', (agent) => {
@@ -212,6 +234,27 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.fiber.dispose()
})
it('successful resume disposal retires its caller-owned transaction effects', async () => {
const sessionId = SessionId('resume-retired-effects-s')
const agentId = AgentId('resume-retired-effects')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const handle = await ctx.agents.resume({
agentId,
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
})
const transactionLabels = [
`agentLoop.owner(${agentId})`,
`agentLoop.lifecycle(${agentId})`,
]
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels))
await handle.dispose()
expect(ctx.fiber.getEffects().filter(effect => transactionLabels.includes(effect.label))).toEqual([])
await ctx.fiber.dispose()
})
it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => {
const sessionId = SessionId('resume-setup-reject')
const root = await persistSession(sessionId)
@@ -309,14 +352,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
}, { inject: ['agents'] }))
await loadStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/)
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
await promptly(owner.dispose())
expect(published).toEqual([])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
// owner.dispose() itself awaited transaction settlement and reservation
// release: reuse the same identities BEFORE awaiting the resume rejection.
// owner.dispose() awaited transaction settlement, so the same identities
// can be reused before awaiting the public rejection.
const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
await rejection
expect(loads).toBe(2)
@@ -335,40 +378,45 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.fiber.dispose()
})
it('snapshots resume identities and agent options before persistence load', async () => {
const sessionId = SessionId('resume-snapshot-source')
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
const sessionId = SessionId('resume-load-factory-unload')
const agentId = AgentId('resume-load-factory-race')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const loaded = await ctx.sessionPersistence.load(sessionId)
const loadGate = Promise.withResolvers<typeof loaded>()
ctx.sessionPersistence.load = () => loadGate.promise
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
const occupied = await ctx.agents.create({
agentId: AgentId('occupied-agent'),
sessionId: SessionId('occupied-session'),
agentOptions: { model: 'mock' },
})
const options = {
agentId: AgentId('accepted-agent'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
const snapshot = await ctx.sessionPersistence.load(sessionId)
const lateLoad = Promise.withResolvers<typeof snapshot>()
const loadStarted = Promise.withResolvers<undefined>()
ctx.sessionPersistence.load = (id) => {
expect(id).toBe(sessionId)
loadStarted.resolve(undefined)
return lateLoad.promise
}
const resuming = ctx.agents.resume(options)
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
options.agentId = AgentId('occupied-agent')
options.resumeSessionId = SessionId('occupied-session')
options.agentOptions.model = 'mutated-model'
loadGate.resolve(structuredClone(loaded))
const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
await loadStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
await promptly(loopFiber.dispose())
await rejection
const resumed = await resuming
expect(resumed.agent.id).toBe(AgentId('accepted-agent'))
expect(resumed.agent.session.id).toBe(sessionId)
expect(resumed.agent.options.model).toBe('mock')
expect(ctx.agents.get(AgentId('occupied-agent'))).toBe(occupied.agent)
expect(ctx.sessions.get(SessionId('occupied-session'))).toBe(occupied.agent.session)
await resumed.dispose()
await occupied.dispose()
expect(published).toEqual([])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
lateLoad.resolve(structuredClone(snapshot))
await Promise.resolve()
await Promise.resolve()
expect(published).toEqual([])
await ctx.fiber.dispose()
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
@@ -10,10 +10,7 @@ import { prepareReactLoopAgent } from '../src/agent.ts'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
/**
* Regression tests for the findings of the first architecture review
* (Codex + sub-agent, post phase-1). Each describe block names the finding.
*/
/** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */
async function harness(adapter: MockAdapter) {
const ctx = new Context()
@@ -114,8 +111,10 @@ describe('HIGH: abort during tool execution ends the turn', () => {
parameters: {},
async execute() {
executed.push('aborter')
// Fire the in-flight step's AbortController directly (the loop registers it on the
// agent).
// Fire the in-flight step's AbortController directly (the loop registers
// it on the agent). This is the bare step-abort path — distinct from
// cancel(), which would also clear the inbox; here the subject is the
// loop's response to its running step being aborted mid-tool.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
return [{ type: 'text', text: 'done' }]
},
@@ -169,8 +168,21 @@ describe('HIGH: steering from late extension points is never stranded', () => {
})
it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => {
// The /goal pattern steers from a step boundary so the model addresses a standing goal
// before stopping.
// The /goal pattern steers from a step boundary so the model addresses a
// standing goal before stopping. Step boundaries have no agent/* mirror, so
// the surviving hook point is the durable step/end session event. With a
// no-tools first step the default continuation is stop; the steering queued
// here must force the `!shouldContinue && hasSteering` override so the SAME
// turn runs another step.
//
// The override is what this test guards, so it asserts the same-turn shape —
// NOT merely that the content reaches requests[1]. Without the override the
// turn would stop, and leftover steering is re-enqueued as a next-turn queued
// message, which ALSO lands in requests[1] (just one turn later). So a
// content-only assertion passes with the override disabled and guards
// nothing. The discriminator is the turn/step shape: override ⇒ ONE turn with
// TWO steps and the steering recorded as a `steering/message` BEFORE step 2;
// re-enqueue fallback ⇒ TWO turns.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),
textResponse('after goal reminder'),
@@ -237,9 +249,11 @@ describe('HIGH: steering from late extension points is never stranded', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.steer([{ type: 'text', text: 'redirect' }])
// Abort only the in-flight step, via its AbortController directly — not cancel(), which
// clears the inbox and would drop the queued steering this test proves survives a step
// abort.
// Abort ONLY the in-flight step, via its AbortController directly — NOT
// cancel(), which clears the inbox and would drop the queued steering this
// test proves survives a step abort. There is no public step-only abort
// verb (cancel() is the only public stop primitive), so reach the private
// controller the loop registered.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
await waitForIdle(ctx, agent)
@@ -420,6 +434,94 @@ describe('MEDIUM: misc registry and config fixes', () => {
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : [])
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
})
it('send() owns content and source before notification and delivery', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('owned-send'), { model: 'mock' })
const content = [{ type: 'text' as const, text: 'accepted-send' }]
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
ctx.on('agent/queued', (subject, acceptedContent, info) => {
if (subject !== agent || info.steering) return
// Retain the exact notification references: cloning here would test the
// listener's copy rather than the event/inbox ownership boundary.
notifiedContent = acceptedContent
notifiedSource = info.source
})
agent.send(content, { source })
content[0]!.text = 'caller-mutated-send'
source.plugin = 'caller-mutated-source'
await waitForIdle(ctx, agent)
expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-send' }])
expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
expect(Object.isFrozen(notifiedContent)).toBe(true)
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
expect(Object.isFrozen(notifiedSource)).toBe(true)
const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
expect(recorded).toContainEqual({
content: [{ type: 'text', text: 'accepted-send' }],
source: { kind: 'plugin', plugin: 'accepted-source' },
})
const request = JSON.stringify(adapter.requests[0]!.messages)
expect(request).toContain('accepted-send')
expect(request).not.toContain('caller-mutated-send')
})
it('running steer() owns content and source before notification and delivery', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('owned-steer'), { model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.tools.register(defineTool({
name: 'gate',
description: '',
parameters: {},
async execute() {
entered.resolve(undefined)
await release.promise
return [{ type: 'text', text: 'tool done' }]
},
}))
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
ctx.on('agent/queued', (subject, acceptedContent, info) => {
if (subject !== agent || !info.steering) return
notifiedContent = acceptedContent
notifiedSource = info.source
})
agent.send([{ type: 'text', text: 'start' }])
await entered.promise
expect(agent.status).toBe('running')
const content = [{ type: 'text' as const, text: 'accepted-steer' }]
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
agent.steer(content, { source })
content[0]!.text = 'caller-mutated-steer'
source.plugin = 'caller-mutated-source'
const idle = waitForIdle(ctx, agent)
release.resolve(undefined)
await idle
expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-steer' }])
expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
expect(Object.isFrozen(notifiedContent)).toBe(true)
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
expect(Object.isFrozen(notifiedSource)).toBe(true)
const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : [])
expect(recorded).toContainEqual({
turn: 1,
content: [{ type: 'text', text: 'accepted-steer' }],
source: { kind: 'plugin', plugin: 'accepted-source' },
})
const request = JSON.stringify(adapter.requests[1]!.messages)
expect(request).toContain('accepted-steer')
expect(request).not.toContain('caller-mutated-steer')
})
})
describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => {
@@ -444,7 +546,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
const forked = prepared.agent
prepared.enableDrive()
prepared.markPublished()
ctx2.effect(() => prepared.startDriver())
const turns: number[] = []
@@ -480,9 +582,10 @@ describe('LOW: discriminated SessionEvent narrows without casts', () => {
describe('HIGH: a finish-error stream chunk ends the turn as error, not completed', () => {
it('translates finish {kind:error} into a turn error with a logged error event', async () => {
// The second sanctioned adapter error path (besides throwing): an adapter that cannot throw
// mid-stream ends the stream with a finish-error chunk (e.g. the pi-ai adapter mapping a
// provider 401).
// The second sanctioned adapter error path (besides throwing): an
// adapter that cannot throw mid-stream ends the stream with a
// finish-error chunk (e.g. the pi-ai adapter mapping a provider 401).
// The loop must NOT log a normal assistant/message + completed turn.
const errorStream: StreamChunk[] = [
{ type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } },
]
@@ -543,14 +646,16 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
})
})
describe('P1-6: a step/start session-event listener sees the event already in the log', () => {
describe('step boundary publication order', () => {
it('the step/start event is in session.events when its session/event listener fires', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' })
// Session.append pushes the event before notifying session/event listeners, so a step/start
// listener always finds the matching event already in the log.
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a step/start listener always finds the matching event already in the
// log. (Step boundaries have no agent/* mirror — the session log is the live
// feed.)
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/start') return
@@ -572,8 +677,11 @@ describe('P1-6: a step/start session-event listener sees the event already in th
})
})
describe('P1-5: a started turn (and any open step) is always closed on a boundary throw', () => {
// Invariants turn latent log imbalance into an immediate test failure.
describe('turn and step boundary recovery', () => {
// Harness with the invariants plugin loaded as an oracle: it throws on
// append if the log goes unbalanced (turn/end while a step is open,
// turn/start while a turn is open, etc.), so a regression surfaces as an
// InvariantError on the NEXT turn's append rather than a silent imbalance.
async function balancedHarness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -582,7 +690,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
@@ -600,13 +708,13 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
}
}
it('a throwing step/start session-event listener closes the open step then the turn (step/end before turn/end)', async () => {
const adapter = new MockAdapter([textResponse('never reached')])
it('a throwing step/start observer cannot change a successful turn', async () => {
const adapter = new MockAdapter([textResponse('request completed')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
// Step boundaries have no agent/* mirror; a throwing step/start session-event listener is
// the surviving step-boundary-listener failure.
// Session owns post-commit containment. The loop sees a successful append,
// runs the request, and balances the ordinary step and turn boundaries.
let threw = false
ctx.on('session/event', (_s, event) => {
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
@@ -619,8 +727,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
const e = [...agent.session.events]
const c = boundaryCounts(agent)
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
expect(errors.map(x => x.message)).toEqual(['boom step-start'])
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
expect(errors).toEqual([])
// step/end precedes turn/end (the invariants oracle would reject
// turn/end-while-step-open, but assert the order explicitly too).
const stepEndIdx = e.findIndex(x => x.type === 'step/end')
@@ -629,6 +737,101 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(stepEndIdx).toBeLessThan(turnEndIdx)
})
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepstart-veto'), { model: 'mock' })
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as SessionEvent
if (event.type === 'step/start' && !rejected) {
rejected = true
throw new Error('reject step-start before commit')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toEqual([])
expect(boundaryCounts(agent)).toMatchObject({
turnStart: 1,
turnEnd: 1,
stepStart: 0,
stepEnd: 0,
errors: 1,
})
expect(errors.map(error => error.message)).toEqual(['reject step-start before commit'])
})
it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => {
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }]
const adapter = new MockAdapter([errorStream])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-turnend-veto'), { model: 'mock' })
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as SessionEvent
if (event.type === 'turn/end' && !rejected) {
rejected = true
throw new Error('reject first turn-end')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors.map(error => error.message)).toEqual(['provider failed'])
expect(boundaryCounts(agent)).toMatchObject({
turnStart: 1,
turnEnd: 1,
stepStart: 1,
stepEnd: 1,
errors: 1,
})
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({
kind: 'error',
message: 'provider failed',
})
})
it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => {
const adapter = new MockAdapter([textResponse('completed before close validation')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepend-veto'), { model: 'mock' })
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as SessionEvent
if (event.type === 'step/end' && !rejected) {
rejected = true
throw new Error('reject first step-end')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(errors.map(error => error.message)).toEqual(['reject first step-end'])
expect(boundaryCounts(agent)).toMatchObject({
turnStart: 1,
turnEnd: 1,
stepStart: 1,
stepEnd: 1,
errors: 1,
})
})
it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => {
// First turn: model stream ends with a finish-error → step error path →
// failTurn emits agent/error, whose listener throws. The turn must still
@@ -691,8 +894,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests disposal AND
// throws.
// A pre-step listener requests disposal and then throws before the ordinary
// post-listener disposal check. The outer catch sees disposal already won
// and must preserve reason=disposed rather than rewrite it as a plugin error.
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
@@ -728,10 +932,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(errorEmits).toHaveLength(0)
})
it('a throwing session/event listener on the turn/start append still balances the turn', async () => {
// Session.append pushes the event before notifying session/event listeners, so a listener
// throwing on turn/start leaves turn/start IN THE LOG.
const adapter = new MockAdapter([textResponse('turn 2')])
it('a throwing turn/start observer cannot starve the loop or later turns', async () => {
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' })
@@ -745,10 +947,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
send(agent, 'go')
await waitForIdle(ctx, agent)
// The error was surfaced exactly once via agent/error.
expect(errors.map(e => e.message)).toEqual(['boom turn/start append'])
// The turn is BALANCED: turn/start is in the log (it was pushed before the listener threw),
// so a turn/end was owed and appended — no open turn.
expect(errors).toEqual([])
// Session contains the observer failure per listener, so the committed turn
// remains visible to later observers and executes normally.
const types = [...agent.session.events].map(e => e.type)
expect(types.filter(t => t === 'turn/start')).toHaveLength(1)
expect(types.filter(t => t === 'turn/end')).toHaveLength(1)
@@ -759,12 +960,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// loop survives: a second turn runs normally.
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests).toHaveLength(2)
})
it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => {
// closeStep() must surface a throwing step/end listener via failTurn so the turn ends with
// reason error, not a silent "completed" with the throw swallowed.
it('a throwing step/end observer cannot rewrite the turn outcome', async () => {
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
@@ -780,11 +979,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
await waitForIdle(ctx, agent)
const c = boundaryCounts(agent)
// step opened and closed; exactly one error turn-end; turn balanced.
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
expect(errors.map(e => e.message)).toEqual(['boom step-end'])
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
expect(errors).toEqual([])
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason)
.toEqual({ kind: 'error', step: 1, message: 'boom step-end' })
.toEqual({ kind: 'completed' })
// step/end precedes turn/end (ordering contract)
const e = [...agent.session.events]
@@ -802,8 +1000,11 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(c2.stepStart).toBe(c2.stepEnd)
})
it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => {
// A step/end listener failure must not prevent turn/end finalization.
it('a throwing step/end observer cannot interrupt error finalization', async () => {
// A finish-error stream opens a step then fails it, driving finalization
// through closeStep() with the step open. Session contains the observer
// failure after committing step/end, so closeTurn still records the model
// failure and balances the turn.
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await harness(adapter)
@@ -824,7 +1025,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(e.some(x => x.type === 'step/end')).toBe(true)
expect(e.some(x => x.type === 'turn/end')).toBe(true)
expect(e.at(-1)?.type).toBe('turn/end')
expect(errors.length).toBeGreaterThanOrEqual(1) // surfaced via agent/error
expect(errors.map(error => error.message)).toEqual(['provider 500'])
// loop survives.
send(agent, 'again')
@@ -833,10 +1034,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
// closeTurn appends turn/end; Session.append pushes it before notifying session/event
// listeners, so a throwing listener leaves turn/end in the log (the turn is balanced) but
// must not escape — from the normal-path closeTurn it would otherwise propagate; the append
// is contained so the loop continues.
// Session contains the observer failure after committing turn/end, so the
// boundary stays authoritative and the loop continues normally.
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
@@ -862,7 +1061,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
})
describe('P1-7: tool/result is logged under the originating call.id, not result.callId', () => {
describe('tool result call identity', () => {
it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => {
// Model emits a tool-call with id "c1", then a final text turn.
const adapter = new MockAdapter([
@@ -878,6 +1077,9 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
}))
// A post-execute listener transforms the result (accept-with-replacement).
// The loop must still record the tool/result under the model's authoritative
// call.id (the loop ignores result.callId — which the registry always sets to
// exec.callId anyway — and uses call.id, the model-transcript id).
ctx.on('tools/post-execute', (exec, _result) => {
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
@@ -910,8 +1112,11 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
describe('surface: assistant/message omits sourceEventSeqs when no chunks streamed', () => {
it('a step-result listener injecting content over an empty stream appends with surfaceOp but no sourceEventSeqs', async () => {
// An empty stream yields zero assistant/chunk events (finish defaults to `stop`), so
// chunkSeqs is empty.
// An empty stream yields zero assistant/chunk events (finish defaults to
// `stop`), so chunkSeqs is empty. A step-result listener injects content, so
// the content-or-usage guard fires and an assistant/message is appended. Its
// sourceEventSeqs MUST be omitted (not `[]`) — the surface invariant rejects
// an empty sourceEventSeqs, and the dev invariants plugin would throw on it.
const adapter = new MockAdapter([[]])
const ctx = await harness(adapter)
await ctx.plugin(Invariants)
@@ -936,9 +1141,14 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
describe('disposal and cancellation during pre-step assembly', () => {
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
// Block `system-prompt/assemble` on a promise.
// Block `system-prompt/assemble` on a promise. Start disposal (which
// calls stop() synchronously, setting status=disposed), then release the
// block. The loop must check isDisposed() after assembly and end the turn
// `disposed` — no LLM call. Don't await fiber.dispose() before releasing
// the blocker: the dispose chain awaits agent.done, which hangs until the
// loop unblocks.
const adapter = new MockAdapter(['hang'])
let releaseAssemble!: () => void
const blocked = new Promise<void>(r => void (releaseAssemble = r))
@@ -950,7 +1160,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
// Blocking listener on the parent context (survives fiber disposal).
@@ -1007,7 +1217,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
@@ -1049,8 +1259,9 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
})
it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => {
// Block the `agent/pre-step` serial seam on a promise we control, then dispose the agent's
// fiber.
// Block the `agent/pre-step` serial seam on a promise we control, then
// dispose the agent's fiber. When the block releases, the loop must see
// isDisposed() at the post-seam check and end the turn disposed.
const adapter = new MockAdapter(['hang'])
let releasePreStep!: () => void
const blocker = new Promise<void>(r => void (releasePreStep = r))
@@ -1062,7 +1273,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
@@ -1114,7 +1325,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
@@ -1163,7 +1374,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {

View File

@@ -1,27 +1,30 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context, symbols, type EffectMeta, type Fiber } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as concreteAgentModule from '../src/agent.ts'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])) {
async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<{ ctx: Context; loopFiber: Fiber }> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
return { ctx, loopFiber }
}
async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<Context> {
return (await harnessWithLoop(adapter)).ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
@@ -37,7 +40,108 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
/** Throw an arbitrary callback value to exercise the public unknown-error boundary. */
function throwUnknown(value: unknown): never {
throw value
}
/** Invoke the exact lifecycle effect to exercise same-stack reentrant teardown. */
function disposeCurrentLifecycle(ownerCtx: Context): void {
const lifecycle = [...ownerCtx.fiber._disposables]
.find((dispose) => {
const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect]
return effect?.label.startsWith('agentLoop.lifecycle(') === true
})
if (lifecycle === undefined) throw new Error('agent lifecycle effect not found')
void lifecycle()
}
describe('agent scope lifecycle', () => {
it('rejects an already-aborted creation signal before publishing either identity', async () => {
const ctx = await harness()
const reason = new Error('cancelled before creation')
const controller = new AbortController()
controller.abort(reason)
await expect(ctx.agents.create({
agentId: AgentId('pre-aborted'),
sessionId: SessionId('pre-aborted-s'),
signal: controller.signal,
})).rejects.toBe(reason)
expect(ctx.agents.get(AgentId('pre-aborted'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined()
const valueController = new AbortController()
valueController.abort('plain cancellation reason')
await expect(ctx.agents.create({
agentId: AgentId('pre-aborted-value'),
sessionId: SessionId('pre-aborted-value-s'),
signal: valueController.signal,
})).rejects.toMatchObject({
message: 'agent "pre-aborted-value" creation aborted',
cause: 'plain cancellation reason',
})
expect(ctx.agents.get(AgentId('pre-aborted-value'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('joins cleanup when an abort lands reentrantly during scope preparation', async () => {
const ctx = await harness()
const reason = new Error('cancelled while preparing')
const controller = new AbortController()
let aborted = false
ctx.on('internal/plugin', (fiber) => {
if (aborted || fiber.name !== 'scope') return
aborted = true
controller.abort(reason)
})
await expect(ctx.agents.create({
agentId: AgentId('prepare-abort'),
sessionId: SessionId('prepare-abort-s'),
signal: controller.signal,
})).rejects.toBe(reason)
expect(ctx.agents.get(AgentId('prepare-abort'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('normalizes non-Error create failures for rollback while rethrowing the original value', async () => {
const ctx = await harness()
let thrown: unknown
ctx.on('session/created', () => {
if (thrown === undefined) return
const value = thrown
thrown = undefined
throwUnknown(value)
})
const createFailure = { source: 'create' }
thrown = createFailure
let createCaught: unknown
try {
ctx.agentLoop.create(AgentId('unknown-create'))
} catch (error: unknown) {
createCaught = error
}
expect(createCaught).toBe(createFailure)
const ownedFailure = { source: 'createAgent' }
thrown = ownedFailure
await expect(ctx.agents.create({
agentId: AgentId('unknown-owned-create'),
sessionId: SessionId('unknown-owned-create-s'),
})).rejects.toBe(ownedFailure)
expect(ctx.agents.get(AgentId('unknown-create'))).toBeUndefined()
expect(ctx.agents.get(AgentId('unknown-owned-create'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
const ctx = await harness()
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -152,11 +256,9 @@ describe('agent scope lifecycle', () => {
expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined()
expect(order).toEqual(['setup:start'])
acceptedOptions.model = 'mutated while setup was pending'
gate.resolve(undefined)
const handle = await creating
expect(handle.agent.options.model).toBe('mock')
expect(handle.agent.options).toBe(acceptedOptions)
expect(order).toEqual([
'setup:start',
'setup:end',
@@ -169,61 +271,80 @@ describe('agent scope lifecycle', () => {
await handle.dispose()
})
it('reserves agent and session ids across concurrent async setup', async () => {
it('lets the final enter arbitrate unsupported concurrent same-id creation and rolls the loser back', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<undefined>()
const bothStarted = Promise.withResolvers<undefined>()
let started = 0
const setup = async (): Promise<void> => {
started += 1
if (started === 2) bothStarted.resolve(undefined)
await gate.promise
}
const agentId = AgentId('concurrent-final-enter')
const first = ctx.agents.create({
agentId: AgentId('reserved'),
sessionId: SessionId('reserved-s'),
agentId,
sessionId: SessionId('concurrent-final-enter-a'),
agentOptions: { model: 'mock' },
setup: () => gate.promise,
setup,
})
await expect(ctx.agents.create({
agentId: AgentId('reserved'),
sessionId: SessionId('other-s'),
const second = ctx.agents.create({
agentId,
sessionId: SessionId('concurrent-final-enter-b'),
agentOptions: { model: 'mock' },
})).rejects.toThrow(/already registered/)
await expect(ctx.agents.create({
agentId: AgentId('other'),
sessionId: SessionId('reserved-s'),
agentOptions: { model: 'mock' },
})).rejects.toThrow(/already exists/)
setup,
})
await bothStarted.promise
expect(ctx.agents.list()).toEqual([])
expect(ctx.sessions.list()).toEqual([])
gate.resolve(undefined)
const handle = await first
await handle.dispose()
const outcomes = await Promise.allSettled([first, second])
const fulfilled = outcomes.filter((outcome): outcome is PromiseFulfilledResult<Awaited<typeof first>> => outcome.status === 'fulfilled')
const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
expect(fulfilled).toHaveLength(1)
expect(rejected).toHaveLength(1)
expect(String(rejected[0]!.reason)).toMatch(/already registered/)
expect(ctx.agents.list()).toEqual([fulfilled[0]!.value.agent])
expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session])
await fulfilled[0]!.value.dispose()
expect(ctx.agents.list()).toEqual([])
expect(ctx.sessions.list()).toEqual([])
})
it('structurally rejects every driving verb during setup', async () => {
it('uses signal only for creation: aborts pending setup but not a returned live handle', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({
agentId: AgentId('no-drive'),
sessionId: SessionId('no-drive-s'),
const pendingController = new AbortController()
const setupStarted = Promise.withResolvers<undefined>()
const pending = ctx.agents.create({
agentId: AgentId('signal-pending'),
sessionId: SessionId('signal-pending-s'),
agentOptions: { model: 'mock' },
setup: (agentCtx) => {
const agent = agentCtx.agent!
// Even JavaScript or a cast to the exported concrete class cannot name
// a public start method. Driver startup is behind a module-private
// symbol used only by AgentLoop after rollback-covered publication.
expect(Reflect.get(agent as ReactLoopAgent, 'start')).toBeUndefined()
expect(Reflect.get(concreteAgentModule, 'enableAgentDrive')).toBeUndefined()
expect(Reflect.get(concreteAgentModule, 'startAgentDriver')).toBeUndefined()
expect(() => concreteAgentModule.prepareReactLoopAgent(
agentCtx, agent.id, agent.options, agent.session,
)).toThrow(/already has a concrete agent driver/)
expect(Reflect.get(agent as ReactLoopAgent, 'inbox')).toBeUndefined()
expect(() => { agent.send(text('queued too soon')) }).toThrow(/cannot send before creation setup completes/)
expect(() => { agent.steer(text('steered too soon')) }).toThrow(/cannot steer before creation setup completes/)
expect(() => { agent.inject(text('injected too soon')) }).toThrow(/cannot inject before creation setup completes/)
expect(() => { agent.cancel('cancel too soon') }).toThrow(/cannot cancel before creation setup completes/)
expect(agent.session.events).toEqual([])
signal: pendingController.signal,
setup: async () => {
setupStarted.resolve(undefined)
await new Promise<never>(() => {})
},
})
expect(handle.agent.session.events).toEqual([])
await handle.dispose()
await setupStarted.promise
pendingController.abort(new Error('cancel pending creation'))
await expect(pending).rejects.toThrow('cancel pending creation')
expect(ctx.agents.get(AgentId('signal-pending'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined()
const liveController = new AbortController()
const live = await ctx.agents.create({
agentId: AgentId('signal-live'),
sessionId: SessionId('signal-live-s'),
agentOptions: { model: 'mock' },
signal: liveController.signal,
})
liveController.abort(new Error('too late'))
await Promise.resolve()
expect(ctx.agents.get(live.agent.id)).toBe(live.agent)
expect(live.agent.status).toBe('idle')
await live.dispose()
})
it('owner unload aborts a pending setup and publishes nothing', async () => {
@@ -283,6 +404,382 @@ describe('agent scope lifecycle', () => {
expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined()
})
it('an AgentLoop unload aborts pending setup, awaits cleanup, and releases both ids', async () => {
const { ctx, loopFiber } = await harnessWithLoop()
const gate = Promise.withResolvers<undefined>()
const setupStarted = Promise.withResolvers<undefined>()
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
const creating = ctx.agents.create({
agentId: AgentId('factory-setup-race'),
sessionId: SessionId('factory-setup-race-s'),
agentOptions: { model: 'mock' },
setup: async () => {
setupStarted.resolve(undefined)
await gate.promise
},
})
await setupStarted.promise
await loopFiber.dispose()
await expect(creating).rejects.toThrow(/agent loop is not active/)
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined()
gate.resolve(undefined)
await ctx.fiber.dispose()
})
it('factory unload during scope minting skips setup and awaits provisional cleanup', async () => {
const { ctx, loopFiber } = await harnessWithLoop()
let unloaded = false
let setupCalls = 0
ctx.on('internal/plugin', (fiber) => {
if (unloaded || fiber.name !== 'scope') return
unloaded = true
void loopFiber.dispose()
})
const creating = ctx.agents.create({
agentId: AgentId('factory-scope-race'),
sessionId: SessionId('factory-scope-race-s'),
agentOptions: { model: 'mock' },
setup: () => { setupCalls += 1 },
})
await expect(creating).rejects.toThrow(/agent loop is not active/)
await loopFiber.dispose()
expect(setupCalls).toBe(0)
expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('caller unload during scope minting owns and drains the half-built child', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
let ownerFiber!: Fiber
let ownerDisposal!: Promise<void>
let scopeFiber: Fiber | undefined
let creating!: ReturnType<typeof ctx.agents.create>
ctx.on('internal/plugin', (fiber) => {
if (fiber.name !== 'scope' || scopeFiber !== undefined) return
scopeFiber = fiber
fiber.ctx.effect(() => async () => {
cleanupStarted.resolve(undefined)
await gate.promise
})
ownerDisposal = ownerFiber.dispose()
})
const owner = ctx.plugin(Object.assign((inner: Context) => {
ownerFiber = inner.fiber
creating = inner.agents.create({
agentId: AgentId('caller-scope-race'),
sessionId: SessionId('caller-scope-race-s'),
agentOptions: { model: 'mock' },
})
}, { inject: ['agents'] }))
await cleanupStarted.promise
let ownerSettled = false
void ownerDisposal.then(() => { ownerSettled = true })
await Promise.resolve()
expect(ownerSettled).toBe(false)
gate.resolve(undefined)
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await ownerDisposal
await owner
expect(scopeFiber?.uid).toBeNull()
expect(ctx.agents.get(AgentId('caller-scope-race'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined()
await owner.dispose()
await ctx.fiber.dispose()
})
it('synchronous create rechecks provider liveness before its first publication edge', async () => {
const { ctx, loopFiber } = await harnessWithLoop()
const sessionsBefore = ctx.sessions.list().length
let unloaded = false
ctx.on('internal/plugin', (fiber) => {
if (unloaded || fiber.name !== 'scope') return
unloaded = true
void loopFiber.dispose()
})
expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { model: 'mock' }))
.toThrow(/agent loop is not active/)
await loopFiber.dispose()
expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined()
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
await ctx.fiber.dispose()
})
it('synchronous create leaves no lifecycle state when session preparation fails', async () => {
const ctx = await harness()
const id = AgentId('config-prepare-failure')
expect(() => ctx.agentLoop.create(id, { model: 'mock' }, { cwd: 'relative' }))
.toThrow(/absolute path/)
const replacement = ctx.agentLoop.create(id, { model: 'mock' }, { cwd: '/recovered' })
expect(ctx.agents.get(id)).toBe(replacement)
await replacement.whenIdle()
await ctx.fiber.dispose()
})
it('factory unload awaits provisional cleanup when scope preparation throws', async () => {
const { ctx, loopFiber } = await harnessWithLoop()
let triggered = false
ctx.on('internal/plugin', (fiber) => {
if (triggered || fiber.name !== 'scope') return
triggered = true
void loopFiber.dispose()
throw new Error('scope preparation failed')
})
await expect(ctx.agents.create({
agentId: AgentId('factory-scope-throw'),
sessionId: SessionId('factory-scope-throw-s'),
agentOptions: { model: 'mock' },
})).rejects.toThrow('scope preparation failed')
await loopFiber.dispose()
expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => {
const { ctx, loopFiber } = await harnessWithLoop()
const loop = ctx.agentLoop
const agentId = AgentId('factory-live')
const handle = await ctx.agents.create({
agentId,
sessionId: SessionId('factory-live-s'),
agentOptions: { model: 'mock' },
})
await loopFiber.dispose()
expect(handle.agent.status).toBe('disposed')
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined()
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([])
// The consumer handle shares the provider's completed quiescence boundary.
await handle.dispose()
await expect(loop.createAgent(ctx, {
agentId: AgentId('factory-inactive'),
sessionId: SessionId('factory-inactive-s'),
})).rejects.toThrow('agent loop is not active')
await ctx.fiber.dispose()
})
it('keeps AgentLoop dependencies available when the caller injects only agents', async () => {
const ctx = await harness()
let creating!: ReturnType<typeof ctx.agents.create>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
creating = inner.agents.create({
agentId: AgentId('dependency-origin'),
sessionId: SessionId('dependency-origin-s'),
agentOptions: { model: 'mock' },
setup: (agentCtx) => {
agentCtx.tools.register({
name: 'dependency-origin-tool',
description: 'proves AgentLoop dependency origin',
parameters: {},
execute: () => Promise.resolve(text('ok')),
})
agentCtx.systemPrompt.section({
name: 'dependency-origin-section',
order: 1,
text: 'factory dependency surface',
})
},
})
}, { inject: ['agents'] }))
const handle = await creating
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(handle.agent))
expect(assembly.tools.map(tool => tool.name)).toContain('dependency-origin-tool')
expect(assembly.sections.map(section => section.name)).toContain('dependency-origin-section')
await handle.dispose()
await owner.dispose()
await ctx.fiber.dispose()
})
it('keeps both entries and the scope live through a reentrant session/created teardown', async () => {
const ctx = await harness()
let ownerCtx!: Context
let creating!: ReturnType<typeof ctx.agents.create>
const lifecycle: string[] = []
ctx.on('session/created', (session) => {
if (session.id !== SessionId('session-created-barrier-s')) return
lifecycle.push('session-created:dispose')
disposeCurrentLifecycle(ownerCtx)
})
ctx.on('session/created', (session) => {
if (session.id !== SessionId('session-created-barrier-s')) return
const agent = ctx.agents.get(AgentId('session-created-barrier'))!
expect(ctx.sessions.get(session.id)).toBe(session)
expect(agent.session).toBe(session)
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
lifecycle.push('session-created:observer')
})
ctx.on('agent/created', () => void lifecycle.push('agent-created'))
ctx.on('agent/disposed', () => void lifecycle.push('agent-disposed'))
ctx.on('session/disposed', (session) => {
if (session.id === SessionId('session-created-barrier-s')) lifecycle.push('session-disposed')
})
const owner = await ctx.plugin(Object.assign((inner: Context) => {
ownerCtx = inner
creating = inner.agents.create({
agentId: AgentId('session-created-barrier'),
sessionId: SessionId('session-created-barrier-s'),
agentOptions: { model: 'mock' },
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await owner.dispose()
expect(lifecycle).toEqual([
'session-created:dispose',
'session-created:observer',
'session-disposed',
'scope-disposed',
])
expect(ctx.agents.get(AgentId('session-created-barrier'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('keeps both entries and the scope live through a reentrant agent/created teardown', async () => {
const ctx = await harness()
let ownerCtx!: Context
let creating!: ReturnType<typeof ctx.agents.create>
const lifecycle: string[] = []
ctx.on('session/created', (session) => {
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
})
ctx.on('agent/created', (agent) => {
if (agent.id !== AgentId('agent-created-barrier')) return
lifecycle.push('agent-created:dispose')
disposeCurrentLifecycle(ownerCtx)
})
ctx.on('agent/created', (agent) => {
if (agent.id !== AgentId('agent-created-barrier')) return
expect(ctx.agents.get(agent.id)).toBe(agent)
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
lifecycle.push('agent-created:observer')
})
ctx.on('agent/disposed', (agent) => {
if (agent.id === AgentId('agent-created-barrier')) lifecycle.push('agent-disposed')
})
ctx.on('session/disposed', (session) => {
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed')
})
const owner = await ctx.plugin(Object.assign((inner: Context) => {
ownerCtx = inner
creating = inner.agents.create({
agentId: AgentId('agent-created-barrier'),
sessionId: SessionId('agent-created-barrier-s'),
agentOptions: { model: 'mock' },
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await owner.dispose()
expect(lifecycle).toEqual([
'session-created',
'agent-created:dispose',
'agent-created:observer',
'agent-disposed',
'session-disposed',
'scope-disposed',
])
expect(ctx.agents.get(AgentId('agent-created-barrier'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('rechecks caller liveness after creation listeners before unlocking the driver', async () => {
const ctx = await harness()
const starts: string[] = []
let ownerCtx!: Context
let creating!: ReturnType<typeof ctx.agents.create>
ctx.on('agent/session-start', agent => void starts.push(agent.id))
ctx.on('agent/created', (agent) => {
if (agent.id === AgentId('listener-dispose')) void ownerCtx.fiber.dispose()
})
const owner = await ctx.plugin(Object.assign((inner: Context) => {
ownerCtx = inner
creating = inner.agents.create({
agentId: AgentId('listener-dispose'),
sessionId: SessionId('listener-dispose-s'),
agentOptions: { model: 'mock' },
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner.dispose()
expect(starts).toEqual([])
expect(ctx.agents.get(AgentId('listener-dispose'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('rechecks caller liveness after session-start before starting the driver', async () => {
const ctx = await harness()
let ownerCtx!: Context
let creating!: ReturnType<typeof ctx.agents.create>
let announced!: ReactLoopAgent
const statuses: string[] = []
let scopeDisposed = false
let observerSawLive = false
ctx.on('agent/status', (agent, status) => {
if (agent.id === AgentId('session-start-dispose')) statuses.push(status)
})
ctx.on('agent/session-start', (agent) => {
if (agent.id !== AgentId('session-start-dispose')) return
announced = agent as ReactLoopAgent
disposeCurrentLifecycle(ownerCtx)
})
ctx.on('agent/session-start', (agent) => {
if (agent.id !== AgentId('session-start-dispose')) return
expect(ctx.agents.get(agent.id)).toBe(agent)
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
agent.ctx.effect(() => () => { scopeDisposed = true })
observerSawLive = true
})
const owner = await ctx.plugin(Object.assign((inner: Context) => {
ownerCtx = inner
creating = inner.agents.create({
agentId: AgentId('session-start-dispose'),
sessionId: SessionId('session-start-dispose-s'),
agentOptions: { model: 'mock' },
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await owner.dispose()
expect(announced.status).toBe('disposed')
expect(statuses).toEqual(['disposed'])
expect(observerSawLive).toBe(true)
expect(scopeDisposed).toBe(true)
expect(announced.session.events).toEqual([])
expect(ctx.agents.get(AgentId('session-start-dispose'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => {
const ctx = await harness()
const published: string[] = []
@@ -307,6 +804,36 @@ describe('agent scope lifecycle', () => {
await retry.dispose()
})
it('rejects an exotic durable seed before publishing either identity', async () => {
const ctx = await harness()
const published: string[] = []
ctx.on('session/created', () => { published.push('session') })
ctx.on('agent/created', () => { published.push('agent') })
class ExoticData { readonly value = 'not durable JSON' }
const seed = [{
seq: 0,
type: 'test/exotic-seed',
data: new ExoticData(),
}] as unknown as SessionEvent[]
await expect(ctx.agents.create({
agentId: AgentId('exotic-seed'),
sessionId: SessionId('exotic-seed-session'),
agentOptions: { model: 'mock' },
seed,
})).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/)
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('exotic-seed'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined()
const retry = await ctx.agents.create({
agentId: AgentId('exotic-seed'),
sessionId: SessionId('exotic-seed-session'),
agentOptions: { model: 'mock' },
})
await retry.dispose()
})
it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
const ctx = await harness()
let boom = true
@@ -327,6 +854,33 @@ describe('agent scope lifecycle', () => {
await retry.dispose()
})
it('pairs session and agent announcements when agent creation aborts publication', async () => {
const ctx = await harness()
const lifecycle: string[] = []
ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) })
ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) })
ctx.on('agent/created', (agent) => {
lifecycle.push(`agent-created:${agent.id}`)
throw new Error('agent observer failed')
})
ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) })
await expect(ctx.agents.create({
agentId: AgentId('partial-agent'),
sessionId: SessionId('partial-session'),
agentOptions: { model: 'mock' },
})).rejects.toThrow('agent observer failed')
expect(lifecycle).toEqual([
'session-created:partial-session',
'agent-created:partial-agent',
'agent-disposed:partial-agent',
'session-disposed:partial-session',
])
expect(ctx.agents.get(AgentId('partial-agent'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined()
})
it('the synchronous config helper rolls back when publication throws', async () => {
const ctx = await harness()
const sessionsBefore = ctx.sessions.list().length
@@ -363,27 +917,6 @@ describe('agent scope lifecycle', () => {
expect(heard).toEqual(['a1:2'])
})
it('a listener may drive the agent through its declared `this` (the carrier is method-transparent)', async () => {
// ds-review-bot regression: agent/* listeners are typed `this: Scoped<Agent>`, and
// ReactLoopAgent's send/steer/cancel read the native-private #carrier — a proxy-receiver
// carrier made `this.send(...)` throw TypeError.
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let followUpSent = false
ctx.on('agent/session-start', function (this: Agent) {
// Deliberately through `this`, not the args subject.
this.send(text('driven through this'))
followUpSent = true
})
const second = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
expect(followUpSent).toBe(true)
await second.whenIdle()
// The send actually reached the loop: the prompt ran a turn.
expect(second.session.events.some(e => e.type === 'turn/start')).toBe(true)
await agent.whenIdle()
})
it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
const ctx = await harness()
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
@@ -401,9 +934,11 @@ describe('agent scope lifecycle', () => {
order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
})
// Open a turn so the drain has real work: the loop must finish it before the registry entry
// goes away (the agent/disposed contract: "its fiber and any in-flight turn have been torn
// down").
// Open a turn so the drain has real work: the loop must finish it BEFORE
// the registry entry goes away (the agent/disposed contract: "its fiber
// and any in-flight turn have been torn down"). Wait for the turn to be
// OPEN in the log — a dispose landing in the pre-step window would drop
// the queued prompt without ever opening a turn.
const turnOpen = new Promise<void>((resolve) => {
const off = ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/start') { off(); resolve() }
@@ -437,6 +972,89 @@ describe('agent scope lifecycle', () => {
await unload
})
it('successful handle disposal retires its caller ownership effect', async () => {
const ctx = await harness()
const agentId = AgentId('retired-owner-effect')
const handle = await ctx.agents.create({
agentId,
sessionId: SessionId('retired-owner-effect-s'),
agentOptions: { model: 'mock' },
})
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${agentId})`)
await handle.dispose()
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([])
await ctx.fiber.dispose()
})
it('owner unload after handle-first teardown follows the same in-flight boundary', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
handle = await inner.agents.create({
agentId: AgentId('manual-first'),
sessionId: SessionId('manual-first-s'),
agentOptions: { model: 'mock' },
setup(agentCtx) {
agentCtx.effect(() => async () => {
cleanupStarted.resolve(undefined)
await gate.promise
})
},
})
}, { inject: ['agents'] }))
const disposing = handle.dispose()
await cleanupStarted.promise
let ownerSettled = false
const unloading = owner.dispose().then(() => { ownerSettled = true })
await Promise.resolve()
expect(ownerSettled).toBe(false)
gate.resolve(undefined)
await Promise.all([disposing, unloading])
expect(ctx.agents.get(AgentId('manual-first'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('reopens ids after detach while the prior private scope finishes quiescing', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
const sessionDisposed = Promise.withResolvers<undefined>()
const agentId = AgentId('quiescent-reuse')
const sessionId = SessionId('quiescent-reuse-s')
ctx.on('session/disposed', (session) => {
if (session.id === sessionId) sessionDisposed.resolve(undefined)
})
const first = await ctx.agents.create({
agentId,
sessionId,
agentOptions: { model: 'mock' },
setup(agentCtx) {
agentCtx.effect(() => async () => {
cleanupStarted.resolve(undefined)
await gate.promise
})
},
})
const disposing = first.dispose()
await Promise.all([sessionDisposed.promise, cleanupStarted.promise])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } })
expect(ctx.agents.get(agentId)).toBe(replacement.agent)
expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
gate.resolve(undefined)
await disposing
await replacement.dispose()
await ctx.fiber.dispose()
})
it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({

View File

@@ -156,12 +156,9 @@ describe('agent/turn-stop', () => {
expect(adapter.requests).toHaveLength(3)
})
it('fails throwing and malformed terminal policies closed while the driver survives', async () => {
it('fails a throwing terminal policy closed while the driver survives', async () => {
const adapter = new MockAdapter([
textResponse('throwing policy'),
textResponse('malformed continue policy'),
textResponse('malformed false policy'),
textResponse('malformed null policy'),
textResponse('healthy later turn'),
])
const ctx = await harness(adapter)
@@ -179,21 +176,10 @@ describe('agent/turn-stop', () => {
await send(agent, 'first')
disposeThrowing()
for (const [index, malformed] of [
{ action: 'continue' },
false,
null,
].entries()) {
const disposeMalformed = agent.ctx.on('agent/turn-stop', () => malformed as unknown as ContinuationStop)
await send(agent, `malformed ${index}`)
disposeMalformed()
}
await send(agent, 'healthy')
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'error', 'error', 'error', 'completed'])
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'completed'])
expect(errors).toContain('terminal policy exploded')
expect(errors).toContain("agent/turn-stop returned an invalid result; expected { action: 'stop' } or undefined")
expect(adapter.requests).toHaveLength(5)
expect(adapter.requests).toHaveLength(2)
})
})

View File

@@ -8,30 +8,30 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
### 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)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while the factory keeps the agent and session unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives: the concrete loop rejects driving verbs until the `agent/session-start` boundary.
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 reuses one stable routing carrier. `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 both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
- `ctx.agents.register(agent: Agent): () => Promise<void> | void` — record an **already-constructed** agent. Disposed with the calling fiber.
- Advanced ordered lifecycle: `enter(agent): () => void` inserts without announcing, and `announce(agent)` emits `agent/created` only for that exact live entry. The async factory uses this split after setup; ordinary plugins use `register()`.
- Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
- `ctx.agents.get(id: AgentId): Agent | undefined`
- `ctx.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.
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 and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories.
- `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/seed, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Agent/session IDs are reserved across setup; setup rejection or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emits `agent/disposed` during that rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability**only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit plus every outstanding idle-injection flush (quiescence — 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 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.
`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, `await`s 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](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#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` runs after the driver is quiescent and the agent leaves the registry, while ordered teardown may still be detaching its session and unwinding its scope.
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` 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 RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#owner-final-policy-boundaries).
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](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#owner-final-policy-four-narrow-boundaries).
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`](../tools/README.md).
@@ -39,8 +39,8 @@ Turn and step boundaries and the model token stream are durable `session/event`
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.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content).
- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and 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](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md))
- `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.

View File

@@ -1,5 +1,14 @@
/**
* Fused scope-carrier dispatch for agent-subject events, plus the assembly context builder.
* Fused scope-carrier dispatch for agent-subject operations, plus the assembly
* context builder. The sanctioned ordinary spelling is
* `agentEvents(ctx, agent).waterfall('agent/request', …)`: it builds the scope
* carrier ({@link scopeTarget} keyed by the agent) AND injects the subject as
* the first argument in one move, so a site cannot name a different subject.
* The registry lifecycle pair is the deliberate exception: `enter()` captures
* one stable carrier before commit and `announce()`/detach dispatch through it
* directly, so both lifecycle edges use the same routing identity. The dev
* scoped-dispatch invariant checks both shapes.
*
* @module @deepseek-ai/dsh-agent/dispatch
*/
@@ -37,7 +46,10 @@ type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [Agent, ...in
*/
export interface AgentEventDispatch {
/**
* Fire-and-forget notification (Cordis `emit`) in the agent's scope.
* Fire-and-forget notification in the agent's scope. Every listener is
* invoked; synchronous throws and returned-promise rejections are logged and
* contained per listener, so a notification cannot veto lifecycle progress
* or starve a later observer.
* @param name - the agent-subject event to emit.
* @param rest - the event's arguments after the injected agent.
*/
@@ -49,16 +61,6 @@ export interface AgentEventDispatch {
* @returns the serial chain's result (the first bail value, if any).
*/
serial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
/**
* Await listeners in order and return the first value other than `undefined`.
* Unlike Cordis `serial`, this does not silently treat `null` or `false` as
* abstentions. Use it for a runtime-validated public boundary whose declared
* abstention is exactly `undefined` (currently `agent/turn-stop`).
* @param name - the agent-subject event to dispatch.
* @param rest - the event's arguments after the injected agent.
* @returns the first non-undefined listener result, or undefined.
*/
strictSerial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
/**
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
* declared event parameters already end with the `next` callback, so `rest`
@@ -81,32 +83,35 @@ export interface AgentEventDispatch {
*/
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
const carrier: Scoped<Agent> = scopeTarget(agent, agent)
// The ordinary dispatch methods forward through Cordis' variadic mixins.
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
// list for the matching thisArg overload, but TypeScript cannot relate the
// generic Tail<K> spread back to that overload's conditional parameter
// tuple — hence one contained, shape-preserving cast per method.
return {
emit(name, ...rest) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
const emit = ctx.emit as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => void
emit(carrier, name, agent, ...rest)
// Cordis emit invokes callbacks through Array.map: one synchronous throw
// starves later listeners, and returned promises are discarded. Agent
// notifications are non-vetoing, so resolve the same filtered callback
// set ourselves and contain both failure modes independently.
const args: unknown[] = [carrier, name, agent, ...rest]
const callbacks = ctx.events.dispatch('emit', args)
for (const callback of callbacks) {
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`)
})
} catch (error: unknown) {
ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`)
}
}
},
async serial(name, ...rest) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
return await serial(carrier, name, agent, ...rest)
},
strictSerial(name, ...rest) {
return (async (): Promise<unknown> => {
// EventsService.dispatch applies the carrier filter and emits the same
// internal/dispatch instrumentation as ctx.serial, then mutates `args` down to the
// actual listener parameters.
const args: unknown[] = [carrier, name, agent, ...rest]
const callbacks = ctx.events.dispatch('serial', args)
for (const callback of callbacks) {
const result: unknown = await callback(...args)
if (result !== undefined) return result
}
return undefined
})() as Promise<Awaited<Return<Events[typeof name]>>>
},
waterfall(name, ...rest) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never

View File

@@ -5,8 +5,9 @@
* @module @deepseek-ai/dsh-agent
*/
import { Context, Service } from 'cordis'
import { Context, getTraceable, Service, symbols } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentId, AgentOptions } from './types.ts'
@@ -39,35 +40,52 @@ declare module 'cordis' {
*/
export interface CreateAgentOptions {
/** The agent's id (the registry handle). */
agentId: AgentId
readonly agentId: AgentId
/** The live session's id (NOT derived from agentId). */
sessionId: SessionId
readonly sessionId: SessionId
/**
* Session creation metadata: validated absolute `cwd`, `parentSession`
* fork lineage, and the `seedLength` seed boundary. Mirrors the
* `cwd`/`parentSession`/`seedLength` fields of
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only
* `createdAt`, used when reconstructing a persisted session, is deliberately
* excluded — a factory caller never sets it).
* excluded — a factory caller never sets it). This is durable session data,
* so the session boundary validates and snapshots it before asynchronous
* setup begins.
*/
meta?: { cwd?: string; parentSession?: SessionId; seedLength?: number }
readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number }
/**
* Seed events to reconstruct the child session's log from (the fork lineage
* primitive). When present, the factory creates the session with this event
* prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the
* in-process FORK subagent backend to seed a child with a balanced
* completed-turn prefix of the parent's log. The prefix MUST be contiguous
* from seq 0 and balanced (no open turn/step, no dangling tool-call), or the
* session constructor (and the dev-mode invariants replay) reject it. Absent
* for a fresh (spawn) child.
* from seq 0, carry only lossless-JSON data, and be balanced (no open
* turn/step, no dangling tool-call), or the session constructor (and the
* dev-mode invariants replay) reject it. The factory passes the raw seed to
* the session's durable validator/snapshot boundary. Absent for a fresh
* (spawn) child.
*/
seed?: SessionEvent[]
readonly seed?: readonly SessionEvent[]
/** Per-agent options (model, …). */
agentOptions?: AgentOptions
readonly agentOptions?: AgentOptions
/** Optional creation-only cancellation signal; detached before the returned handle becomes visible. */
readonly signal?: AbortSignal
/**
* Creation-time composition of the agent's scoped world.
* Creation-time composition of the agent's scoped world. The factory awaits
* setup after minting `agentCtx` but BEFORE inserting or announcing either
* the session or agent, so observers can never see a partially configured
* world. Everything registered through `agentCtx` (scoped tools, prompt
* sections/variables, `restrict()`, listeners, awaited child plugins) exists
* before `session/created`, `agent/created`, `agent/session-start`, and the
* first prompt assembly. A throw/rejection or owner disposal rolls the scope
* back without publishing either id.
*
* **Setup composes, it never drives**: the callback is trusted same-process
* code and receives the full scoped context, so this is a contract rather
* than a runtime restriction. Drive the agent only after creation resolves.
*/
setup?: (agentCtx: Context) => Promise<void> | void
readonly setup?: (agentCtx: Context) => Promise<void> | void
}
/**
@@ -76,26 +94,42 @@ export interface CreateAgentOptions {
*/
export interface ResumeAgentOptions {
/** The agent's id (the registry handle). */
agentId: AgentId
readonly agentId: AgentId
/** The persisted session id to load and resume on. */
resumeSessionId: SessionId
readonly resumeSessionId: SessionId
/** Per-agent options (model, …). */
agentOptions?: AgentOptions
readonly agentOptions?: AgentOptions
/** Optional creation-only cancellation signal for persistence load/setup; detached before return. */
readonly signal?: AbortSignal
/**
* Resume-time composition of the agent's fresh scoped world. Persistence is
* loaded first; the factory then mints `agentCtx` and awaits setup while the
* reconstructed session and agent remain unpublished. The callback has the
* same composition-only contract as {@link CreateAgentOptions.setup}: all
* registrations exist before either creation announcement, driving verbs are
* unavailable until the session-start boundary, and rejection or owner
* disposal rolls the transaction back without publishing either id.
* same trusted composition-only contract as
* {@link CreateAgentOptions.setup}: all registrations exist before either
* creation announcement, and rejection or owner disposal rolls the
* transaction back without publishing either id.
*/
setup?: (agentCtx: Context) => Promise<void> | void
readonly setup?: (agentCtx: Context) => Promise<void> | void
}
/**
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} / {@link
* AgentRegistry.resume}.
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers,
* only the holder can tear this agent down. The registered factory provider is
* also a structural owner because the scoped agent depends on that provider's
* service surface; provider unload stops and drains every live handle it made.
* `dispose()` stops the loop, awaits its exit and every outstanding
* idle-injection flush (quiescence — 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 {@link Agent} — the handle is
* exposed only to the consumer owner that created it; the structural provider
* reaches the same teardown internally. Config-created agents (the loop's own
* startup) are owned by the loop fiber and never need a handle.
*/
export interface AgentHandle {
agent: Agent
@@ -110,27 +144,55 @@ export interface AgentHandle {
*/
export interface AgentFactory {
/**
* Create a new agent on a caller-supplied session id.
*
* Create a new agent on a caller-supplied session id. Async because creation
* awaits unpublished setup, inserts both session and agent, emits their
* creation notifications in order, emits `agent/session-start`, and only
* then starts the loop. The sequence is
* rollback-covered, but notifications delivered before a later listener
* failure remain observable; every agent or session creation announcement
* that began is paired by `agent/disposed` or `session/disposed` during
* rollback. The owner disposes the resolved handle to stop/drain,
* unregister, remove the session, and unwind the scope.
* The registry passes a context carrying the `create()` caller's fiber and
* scope as `ownerCtx`. The implementation attaches the unpublished
* transaction and resulting lifecycle to that owner; it must not infer
* ownership from the factory object's registration context.
* @param ownerCtx - caller-bound context that owns the transaction and live handle.
* @param options - agent/session identity, configuration, and optional setup.
* @returns the owned handle after setup, both announcements, and loop start complete.
*/
createAgent(options: CreateAgentOptions): Promise<AgentHandle>
createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
/**
* Load a persisted session and resume an agent on it. Async because it awaits
* both `ctx.sessionPersistence.load` and the optional unpublished setup
* transaction; must be called after that service exists (consumers inject
* `sessionPersistence`). Publication and drive unlocking follow the same
* ordered boundary as {@link createAgent}.
* `sessionPersistence`). Publication follows the same ordered boundary as
* {@link createAgent}.
* @param ownerCtx - caller-bound context that owns load, setup, and the live handle.
* @param options - persisted identity, configuration, and optional setup.
* @returns the owned handle after setup, both announcements, and loop start complete.
*/
resume(options: ResumeAgentOptions): Promise<AgentHandle>
resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
}
/** Thrown when create/resume is called before an agent factory is registered. */
const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)'
/** All mutable lifecycle state for one exact registry entry. */
interface AgentEntry {
readonly id: AgentId
readonly agent: Agent
readonly carrier: Scoped<Agent>
announced: boolean
announcing: boolean
detachRequested: boolean
}
/** Plain holder prevents Cordis from tracing the factory field before the caller context is known. */
interface FactorySlot {
readonly target: AgentFactory
}
/**
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
* orchestrator plugins can find them without depending on the concrete loop
@@ -139,22 +201,28 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
* {@link setFactory}.
*/
export class AgentRegistry extends Service {
private store = new Map<AgentId, Agent>()
/** Entries whose `agent/created` announcement phase began. */
private announced = new WeakSet<Agent>()
private factory: AgentFactory | undefined
private store = new Map<AgentId, AgentEntry>()
private entries = new WeakMap<Agent, AgentEntry>()
private factory: FactorySlot | undefined
constructor(ctx: Context) {
super(ctx, 'agents')
// The `ctx.agent` DX accessor: default `undefined` on every context, so a plain plugin
// context reads cleanly instead of hitting the Cordis unknown-property throw.
// The `ctx.agent` DX accessor: default `undefined` on every context, so a
// plain plugin context reads cleanly instead of hitting the Cordis
// unknown-property throw. Each Agent.ctx shadows it with an own property
// (own properties resolve before the context proxy is consulted), so the
// accessor body never needs to resolve a scope itself. Effect-scoped:
// unwinds with this service's fiber.
ctx.accessor('agent', { get: () => undefined })
}
/**
* Register the agent-creation factory (the loop calls this on construction,
* effect-scoped). Throws if a factory is already registered. Returns the
* disposer; on dispose the factory slot is cleared.
* effect-scoped). A traced Cordis service is canonicalized to its concrete
* target; each create/resume call is then traced through that caller's
* context so ownership follows the caller without stacking proxy layers.
* Throws if a factory is already registered. Returns the disposer; on
* dispose the factory slot is cleared.
* @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.
* @returns the disposer that clears the factory slot. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
@@ -163,13 +231,26 @@ export class AgentRegistry extends Service {
setFactory(factory: AgentFactory): () => Promise<void> | void {
const dispose = this.ctx.effect(() => {
if (this.factory !== undefined) throw new Error('an agent factory is already registered')
this.factory = factory
// Avoid stacking two Cordis shadow layers when a caller passes a Service
// already read through a context. Calls are re-traced through their
// actual owner context below.
const target = (factory as AgentFactory & { [symbols.original]?: AgentFactory })[symbols.original] ?? factory
this.factory = { target }
return () => { this.factory = undefined }
}, 'agents.setFactory()')
// Return the exact Cordis disposer to preserve teardown nesting.
// The exact cordis effect disposer (the agents.register() convention): a
// caller's composite effect can yield it for in-order teardown; the
// loop's constructor effect returns it directly, identity-nesting the
// registration under that effect.
return dispose
}
/** Return the active creation factory. */
private requireFactory(): FactorySlot {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory
}
/**
* Create and publish a new agent through the registered factory.
* Distinct from {@link register} (which records an already-constructed
@@ -180,8 +261,15 @@ export class AgentRegistry extends Service {
* @returns the handle after setup, rollback-covered publication, and loop start complete.
*/
async create(options: CreateAgentOptions): Promise<AgentHandle> {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory.createAgent(options)
const ownerCtx = this.ctx
// Re-trace a Service-backed factory through the accessing context
// explicitly. This preserves AgentLoop's dependency origin while binding
// its effects to ownerCtx; plain factories receive ownerCtx as an explicit
// capability and need no Cordis tracker magic.
const { target } = this.requireFactory()
const receiver = getTraceable(ownerCtx, target)
// eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
return Reflect.apply(target.createAgent, receiver, [ownerCtx, options])
}
/**
@@ -192,16 +280,30 @@ export class AgentRegistry extends Service {
* @returns the handle after setup, rollback-covered publication, and loop start complete.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory.resume(options)
const ownerCtx = this.ctx
const { target } = this.requireFactory()
const receiver = getTraceable(ownerCtx, target)
// eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
return Reflect.apply(target.resume, receiver, [ownerCtx, options])
}
/**
* Register a live agent.
*
* Register a live agent. Throws if an agent with the same id is already
* registered. Emits `agent/created` on registration and `agent/disposed`
* when the calling fiber is disposed — both with the agent's scope carrier
* (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the
* emits are scope-filtered regardless of which context invoked `register`
* (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always
* requires passing the carrier). Returns the disposer.
* @param agent - the already-constructed agent to record in the store.
* @returns the EXACT Cordis effect disposer (single-shot; a repeat call returns undefined
* without awaiting an in-flight teardown).
* @returns the EXACT Cordis effect disposer (single-shot; a repeat call
* returns undefined without awaiting an in-flight teardown). Exact
* identity is load-bearing: a composite (generator) effect that owns a
* teardown ORDER — the agent factory's lifecycle chain — must yield THIS
* function so Cordis nests the unregistration at that yield position;
* yielding a wrapper would leave it disposing as a concurrent sibling on
* owner unload, unregistering the agent (and emitting `agent/disposed`)
* while its final turn is still draining.
*/
register(agent: Agent): () => Promise<void> | void {
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
@@ -219,25 +321,72 @@ export class AgentRegistry extends Service {
* calling {@link announce}. Ordinary callers use {@link register}.
* @param agent - the prepared, unpublished agent.
* @returns an idempotent closure that removes this exact entry and emits
* `agent/disposed` with listener failures contained.
* `agent/disposed` with listener failures contained. When called from a
* synchronous `agent/created` listener, removal and disposal wait until
* that creation dispatch unwinds.
*/
enter(agent: Agent): () => void {
if (this.store.has(agent.id)) {
throw new Error(`agent "${agent.id}" is already registered`)
const id = agent.id
const carrier = scopeTarget(agent, agent)
// This is the authoritative collision boundary. Concurrent create/resume
// operations may both prepare, but only one exact entry can publish.
if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`)
const entry: AgentEntry = {
id,
agent,
carrier,
announced: false,
announcing: false,
detachRequested: false,
}
this.store.set(agent.id, agent)
this.store.set(id, entry)
this.entries.set(agent, entry)
let entered = true
return () => {
const detach = (): void => {
if (!entered) return
entered = false
this.store.delete(agent.id)
// An insertion rolled back before announce was never externally created, so emitting
// disposed would invent an impossible lifecycle edge.
if (!this.announced.delete(agent)) return
// Every callback reached by this creation dispatch must observe the same
// live entry, and disposal must follow creation. A listener may own
// the advanced detach capability, so make that ordering structural:
// visibility and the paired disposal are deferred until announce()'s
// synchronous dispatch has unwound.
if (entry.announcing) {
entry.detachRequested = true
return
}
this.detachEntered(entry)
}
return detach
}
/** Remove one exact entered agent and emit its paired disposal when announced. */
private detachEntered(entry: AgentEntry): void {
entry.detachRequested = false
// A stale capability can never delete a later same-id lifecycle. The
// captured entry identity is the final boundary.
/* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
if (this.store.get(entry.id) !== entry) return
this.store.delete(entry.id)
this.entries.delete(entry.agent)
// An insertion rolled back before announce was never externally created,
// so emitting disposed would invent an impossible lifecycle edge. Marking
// happens before the created emit: if a later created listener throws,
// earlier listeners may already have observed it and must see disposal.
if (!entry.announced) return
this.emitDisposed(entry)
}
/** Emit the paired disposal edge through the entry's stable carrier. */
private emitDisposed(entry: AgentEntry): void {
const args: unknown[] = [entry.carrier, 'agent/disposed', entry.agent]
for (const callback of this.ctx.events.dispatch('emit', args)) {
try {
this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent)
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`agent "${entry.id}": agent/disposed listener rejected: ${String(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`)
this.ctx.logger.warn(`agent "${entry.id}": agent/disposed listener threw: ${String(error)}`)
}
}
}
@@ -245,14 +394,37 @@ export class AgentRegistry extends Service {
/**
* Announce an agent previously inserted with {@link enter}.
* @param agent - the live inserted agent to announce.
* @throws if `agent` is not the exact live registry entry for its id.
* @throws if `agent` is not the exact live registry entry for its id, or its
* creation announcement already began (including a reentrant call from a
* creation listener).
*/
announce(agent: Agent): void {
if (this.store.get(agent.id) !== agent) {
const entry = this.entries.get(agent)
if (entry === undefined || this.store.get(entry.id) !== entry) {
throw new Error(`agent "${agent.id}" is not live in this registry`)
}
this.announced.add(agent)
this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent)
if (entry.announced || entry.announcing) {
throw new Error(`agent "${entry.id}" was already announced`)
}
// Mark before dispatch so a listener cannot recursively create a second
// lifecycle edge; detach still pairs a partially delivered first edge.
entry.announcing = true
entry.announced = true
const args: unknown[] = [entry.carrier, 'agent/created', entry.agent]
try {
for (const callback of this.ctx.events.dispatch('emit', args)) {
// A synchronous creation failure vetoes publication and rolls back.
// Returned-promise rejection happens after this synchronous boundary, so
// observe and report it instead of leaking an unhandled rejection.
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`agent "${entry.id}": agent/created listener rejected: ${String(error)}`)
})
}
} finally {
entry.announcing = false
if (entry.detachRequested) this.detachEntered(entry)
}
}
/**
@@ -261,7 +433,7 @@ export class AgentRegistry extends Service {
* @returns the agent, or undefined when no live agent has that id.
*/
get(id: AgentId): Agent | undefined {
return this.store.get(id)
return this.store.get(id)?.agent
}
/**
@@ -269,7 +441,7 @@ export class AgentRegistry extends Service {
* @returns a fresh array; mutating it does not affect the registry.
*/
list(): Agent[] {
return [...this.store.values()]
return [...this.store.values()].map(entry => entry.agent)
}
}

View File

@@ -1,7 +1,47 @@
/**
* Agent interface and event taxonomy. Every plugin programs against the `Agent` handle defined
* here; the concrete implementation lives in `@deepseek-ai/dsh-agent-loop`.
* Scope-filtered dispatch: keyed to `agent`.
* Agent interface and event taxonomy. Every plugin programs against the
* `Agent` handle defined here; the concrete implementation lives in
* `@deepseek-ai/dsh-agent-loop`.
*
* Merge-extensible: `AgentOptions` supports declaration merging for
* plugin-specific creation options.
*
* ## Event-domain semantics (the boundary rule)
*
* The harness has three event domains, each with one job:
*
* - **`session/*`** (`@deepseek-ai/dsh-session`) — the DURABLE, replayable FACT
* log. Owns `SessionEventMap`; every entry is JSON-only (no live objects).
* One `session/event` emit per append, plus the `session/flush` parallel
* durability checkpoint. Answers "what happened, durably/replayably." A
* consumer that wants the live transcript subscribes here.
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
* `agent/request`/`agent/session-prefix`/`agent/step-result`/
* `agent/turn-continuation` waterfalls and the serial `agent/pre-step` /
* `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits
* (`agent/status`, `agent/error`, `agent/created`/
* `agent/disposed`, `agent/queued`, `agent/session-start`)
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
* they are durable `session/event` records. Answers "right now, with the agent
* object — intercept or observe."
* - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution.
*
* **The rule:** a durable, replayable fact is a SessionEvent; a live
* interception or a transient/live-object signal is an `agent`/`tools` Cordis
* event. A turn/step boundary is a durable fact: it lives in the session log
* and is read off the `session/event` feed — it is NOT mirrored as an `agent/*`
* emit. A consumer that needs the `Agent` handle (or its short id) at a boundary
* keeps a session-id→agent map from `agent/created`/`agent/disposed`.
* See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md`
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
*
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision;
* the terminal serial `agent/turn-stop` returns the stop-only subset. The
* convention is pinned by
* `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`.
*
* @module @deepseek-ai/dsh-agent/types
*/
@@ -70,22 +110,54 @@ export interface SendOptions {
*/
export type AgentStatus = 'idle' | 'running' | 'disposed'
/** Model-facing injected context with an explicit, non-defaulted source. */
/**
* Model-facing context an interception listener wants the agent to SEE on the
* next request — the canonical shape behind every "inject extra context"
* decision ({@link PromptDecision}, {@link PostToolDecision},
* {@link ContinuationDecision}). It is `agent.inject()`ed as a
* `context/message`, so it carries a REQUIRED {@link MessageSource}: `inject()`
* defaults a missing source to `{kind:'user'}`, which would MISLABEL plugin
* context as a user prompt and corrupt derived history. A bridge sets
* `{kind:'plugin', plugin:'…'}`; a native plugin names itself. Required, not
* optional — the label is load-bearing, never defaulted here.
*/
export interface HookContext {
content: ContentBlock[]
source: MessageSource
}
/**
* The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns for one
* drained queued message, before it becomes a `user/message`. Maps onto the Claude Code
* `UserPromptSubmit` hook's allow/block + `additionalContext`.
* The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns
* for ONE drained queued message, before it becomes a `user/message`. Maps onto
* the Claude Code `UserPromptSubmit` hook's allow/block + `additionalContext`.
*
* - `allow` proceeds with the prompt; optional `content` REPLACES the prompt
* bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a
* separate `context/message` the next request also sees.
* - `block` drops the prompt (it never becomes a `user/message`); `reason` is
* the durable record of why. The loop appends a `prompt/blocked` session event
* (carrying the original content, source, and `reason`) in place of the
* dropped `user/message`, so the veto survives replay even in a MIXED batch
* where a sibling prompt is allowed. A batch whose EVERY prompt is blocked
* additionally opens a zero-step turn that ends with {@link TurnEndReason}
* `rejected` (so the boundary stays balanced and a UI can render "blocked by
* hook").
*/
export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; reason: string }
/** Continuation override; a continue reason is recorded as next-step steering. */
/**
* The decision an {@link Agent} `agent/turn-continuation` waterfall listener
* returns. The loop computes the default (`continue` when the step had tool
* calls or steering was injected, else `stop`); listeners override it to
* force-continue (`/goal`, `/loop`) or force-stop (budget guards).
*
* A `continue` may carry a `reason`: model-facing context recorded as next-STEP
* steering within the SAME turn (the loop enqueues it through the steering
* channel, so the continued turn's next step sees it). This is the typed twin of
* the existing "steer from a step/end listener" `/goal` pattern.
*/
export type ContinuationDecision =
| { action: 'stop' }
| { action: 'continue'; reason?: HookContext }
@@ -130,140 +202,333 @@ export interface Agent {
*/
readonly ctx: Context
/** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */
/**
* Queue a user message. Starts a turn when idle; otherwise waits for the next
* turn. Content and the resolved source are accepted as one detached,
* deeply-frozen lossless-JSON record before notification or enqueue, so
* caller or `agent/queued` listener in-place mutation cannot change later
* log/model input. Throws synchronously when either value is not losslessly
* JSON-serializable; `agent/prompt-submit` may still return an explicit
* replacement.
*/
send(content: ContentBlock[], options?: SendOptions): void
/**
* Steer a running turn: content is injected between steps of the current
* turn. When idle, behaves like {@link send}.
* turn. Uses the same owned-value and synchronous-validation boundary as
* {@link send}; when idle, behaves exactly like that method.
*/
steer(content: ContentBlock[], options?: SendOptions): void
/**
* Inject in-session context (file-change notices, skill content, cron notifications, …):
* appends a `context/message` session event the next model request sees at its chronological
* position, rendered as tagged synthetic context rather than a user prompt. Does not run the
* model.
* Inject in-session context (file-change notices, skill content, cron
* notifications, …): appends a `context/message` session event the next model
* request sees at its chronological position, rendered as tagged synthetic
* context rather than a user prompt. Does not run the model.
*
* Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn;
* an inject while idle wraps its `context/message` in a one-shot `injection`
* turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for
* durability, so every event stays inside a turn and a persistence backend
* never loses a between-turn notice. The idle checkpoint is fire-and-forget
* from this synchronous method, but lifecycle disposal awaits it before
* unregistering the agent or detaching its session. A failing flush is
* reported via `agent/error` (step `0`) and the logger, never thrown into the
* caller.
*
* Live-adapter review has validated the tagged-envelope rendering against
* current DeepSeek behavior; provider-specific mismatches belong in that
* adapter, not in the canonical session vocabulary.
*/
inject(content: ContentBlock[], options?: SendOptions): void
/**
* Cancel ALL pending work for the agent. `cancel()`.
* Cancel ALL pending work for the agent. `cancel()`:
*
* - clears the queued FIFO (un-started prompts never run) and the steering
* FIFO (steering for the cancelled turn is dropped, not re-enqueued);
* - aborts the in-flight step if one is running (the turn ends `aborted`);
* - drops a turn that is about to start (a `cancel()` landing in the
* pre-step window — after a `send()` queued but before the loop flips to
* `running`, or after `running` is emitted but before the first step) so
* that queued prompt does not run and cannot be batched into the cancelled
* turn.
*
* After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state.
* `cancel()` on an idle agent with nothing queued or running is a safe no-op
* — it does NOT arm anything that would drop a later legitimate prompt.
*/
cancel(reason?: string): void
/**
* Resolve once the agent has reached quiescence after settling out of `running`, or
* immediately if it is already idle with no queued work.
* Resolve once the agent has reached quiescence after settling out of
* `running`, or immediately if it is already idle with no queued work. A
* non-owner's quiescence-observation hook: a consumer that does NOT own the
* agent's lifecycle awaits this to proceed only after queued/running work has
* fully stopped, rather than returning while the driver is still streaming or
* about to start a queued turn — without itself tearing the agent down. (A
* lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the
* loop-exit promise directly as part of stopping and unregistering. So this is
* for a non-owning observer — e.g. a test awaiting a turn to settle, or a
* monitor — that wants the settle signal but must not dispose the agent.)
*
* "Quiescence", not merely "status changed": a disposed agent emits
* `agent/status('disposed')` from inside its disposer, BEFORE the driver loop
* has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop
* to actually exit (the implementation chains the loop-exit promise), not just
* observe the status flip. A mid-step disposal that never reaches `idle` still
* unblocks the await this way.
*/
whenIdle(): Promise<void>
// Subagent backends create ordinary child Agent handles through the subagent seam.
// Subagent delegation is realized on top of this interface by the
// `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates
// the child through `ctx.agents.create` (fork seeds the child Session with a
// balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn
// starts fresh) and drives it as an ordinary Agent handle, so steer() and
// event subscription work uniformly. See docs/core-data-structures/subagent.md.
}
declare module 'cordis' {
interface Events {
// ---- lifecycle (emit) ----
/**
* An agent's fully composed scoped world was published in the {@link AgentRegistry}.
*
* An agent's fully composed scoped world was published in the
* {@link AgentRegistry}. Its session is already live in the session store.
* Setup is composition-only by contract; the subsequent
* `agent/session-start` boundary is the first supported place to inject or
* queue startup work. A synchronous listener throw
* vetoes publication and rollback emits the matching disposal edges;
* returned-promise rejection is observed and logged but cannot
* retroactively veto this synchronous boundary. A synchronous listener
* that requests the advanced registry detach does not remove the entry
* immediately: removal and the paired `agent/disposed` edge wait until the
* creation dispatch unwinds, so no later creation listener observes a
* disposal that preceded its own creation callback.
* @param agent - the newly registered agent with its live session and completed setup.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode emit
*/
'agent/created'(this: Scoped<Agent>, agent: Agent): void
/**
* An agent was removed from the registry after its driver and any in-flight turn
* reached quiescence.
*
* Scope-filtered dispatch: keyed to `agent`.
* @param agent - the deregistered agent; its driving handle is now inert.
* An agent was removed from the registry. The concrete AgentLoop lifecycle
* emits this only after its driver and any in-flight turn reach quiescence;
* a custom agent registered through the public registry owns its own driver
* contract, which the registry cannot infer. Ordered teardown may still be
* detaching the session and unwinding scoped registrations when this runs.
* @param agent - the exact agent removed from the registry.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode emit
*/
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
/**
* Agent status changed (`idle` ⇄ `running`, or → `disposed`).
*
* Scope-filtered dispatch: keyed to `agent`.
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive
* lifecycle off this transition, never off a status you just requested —
* `send()` does not flip status to `running` before it returns.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode emit
*/
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
/**
* A message entered the agent's inbox (queued or steering). `source` is the resolved
* source (defaults applied), not the caller's raw options.
*
* Scope-filtered dispatch: keyed to `agent`.
* A message entered the agent's inbox (queued or steering). Content and the
* resolved source are the detached, deeply-frozen values retained by the
* inbox. `source` has defaults applied and is not the caller's raw options.
* @param agent - the agent whose inbox received the message.
* @param content - the enqueued content blocks, verbatim.
* @param info - the resolved source plus whether it entered as steering.
* @param content - the accepted content blocks retained by the inbox.
* @param info - the accepted source plus whether it entered as steering.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode emit
*/
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
// ---- session lifecycle (emit) ----
/**
* The agent's session lifecycle began, fired once before its first turn. `source` says why
* ({@link SessionStartSource}: fresh startup, a resumed persisted session, …).
*
* Scope-filtered dispatch: keyed to `agent`.
* The agent's session lifecycle began, fired once before its first turn.
* `source` says why ({@link SessionStartSource}: fresh startup, a resumed
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): a
* listener cannot veto by returning a decision or throwing. A listener that
* wants to seed context does so via `agent.inject()` (a `context/message` the
* first request sees). A lifecycle owner can still dispose its structural
* ownership edge during this notification; publication rechecks liveness and
* then aborts before the driver starts.
* @param agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …).
* Dispatch is scoped to `agent`.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode emit
*/
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
// 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` session events off the
// `session/event` feed (the session log is the live transcript feed).
// 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` session events off the `session/event` feed (the session log is
// the live transcript feed). See the module doc's three-domain rule and the
// "remove agent boundary mirror events" RFC.
// ---- step/request extension seams (serial + waterfall) ----
/**
* Awaited checkpoint for surface mutation before `step/start` snapshots request history.
* Scope-filtered dispatch: keyed to `agent`.
* Awaited pre-step surface-mutation checkpoint, fired once per step AFTER
* `turn/start` (and after the prior step closed) but BEFORE this step's
* `step/start` — so anything a listener appends lands OUTSIDE the step,
* between `turn/start`/`step/end` and the upcoming `step/start`. `step` is
* the number of the step about to start. The loop awaits
* `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then
* opens the step and derives the request history ONCE from whatever the
* surface now holds. This is where compaction belongs: it mutates the session
* surface in place (shadowing an older range with a summary node) with its
* log-only `compact/*` records cleanly outside any step, and the single
* subsequent derive reflects the mutation — so there is no double-derive and
* no listener can see (or be expected to act on) an assembled `messages`
* array that does not exist yet.
*
* Serial (awaited in registration order), not a waterfall: a listener
* mutates the surface as a side effect; there is nothing to transform, but
* the loop must wait for the mutation to complete before opening the step
* and deriving. Cordis `serial` bails early if a listener returns a bail
* value; this event is typed and documented as `void`, so listeners must not
* return a semantic veto value. `fullSystemPrompt` is the assembled prompt a
* listener needs to measure pressure (the system prompt counts toward the
* budget), and `sessionPrefix` is the instance's composed
* {@link agent/session-prefix} product for the same reason — every request
* carries it in front of the derived history, and it is composed BEFORE
* this seam fires precisely so a pressure gate counts the prefix the
* request will actually send (never a stale logged one). `signal` cancels
* any in-flight work a listener starts (e.g. a
* summarization model call).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @param agent - the agent about to open the step.
* @param turn - open turn number.
* @param step - upcoming step number.
* @param turn - the already-open turn this step belongs to.
* @param step - the number of the step about to start.
* @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
* @param sessionPrefix - frozen prefix for the same measurement.
* @param sessionPrefix - the instance's frozen session prefix, for the same measurement.
* @param signal - aborts in-flight listener work when the turn is torn down.
* @mode serial
*/
// TODO: move prompt-pressure inputs behind compaction if no second consumer appears.
// TODO: `fullSystemPrompt`/`sessionPrefix` are a smell on a generic
// per-step seam — compaction
// is their only consumer, so a wide event carries payloads just one listener
// reads. Revisit if no second consumer appears: e.g. hand listeners a lazy
// prompt provider, or move token-pressure measurement behind a
// compaction-specific seam instead of the shared pre-step checkpoint.
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
/**
* Waterfall: decide what happens to one drained queued message before it becomes a
* `user/message` — allow (optionally rewriting the prompt bytes or attaching
* `additionalContext`) or block it.
*
* Scope-filtered dispatch: keyed to `agent`.
* Waterfall: decide what happens to ONE drained queued message before it
* becomes a `user/message` — allow (optionally rewriting the prompt bytes or
* attaching `additionalContext`) or block it. Fires inside the already-open
* turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook.
* Call `next()` to delegate to the default (allow unchanged), or return a
* {@link PromptDecision} without calling `next()` to short-circuit.
* @param agent - the agent draining its inbox.
* @param content - the drained message's blocks, as queued.
* @param source - the message's resolved source.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode waterfall
*/
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
/**
* Waterfall: shape the step's call configuration — model switching, sampling overrides
* — by returning a replacement {@link LlmCallConfig} (the frozen seed is the config the
* loop would otherwise use).
*
* Scope-filtered dispatch: keyed to `agent`.
* Waterfall: shape the step's call configuration — model switching,
* sampling overrides — by returning a replacement {@link LlmCallConfig}
* (the frozen seed is the config the loop would otherwise use). Config is
* ALL a listener shapes here: every request is a pure function of the
* session log (the reconstructability RFC), so model-visible content
* flows through the log channels — `inject()`, steering, prompt-submit
* `additionalContext`, prompt sections via `system-prompt/assemble`, or
* the header-logged session prefix via {@link agent/session-prefix}
* — never through request mutation, and the loop records whatever config
* the request actually uses as a `request/header*` event before dispatch.
* The step's messages are already snapshotted when this fires (the
* `step/start` boundary): an `inject()` from a listener here lands in the
* log but joins the NEXT request. For surface mutation that must precede
* the snapshot (compaction), use {@link agent/pre-step}. Call `next()` to
* delegate, or return an {@link LlmCallConfig} without it to
* short-circuit.
* @param agent - the agent making the model call.
* @param turn - the open turn number.
* @param step - the step whose request this is.
* @param config - the config the loop would use (frozen); return a replacement to
* switch.
* @param config - the config the loop would use (frozen); return a replacement to switch.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode waterfall
*/
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**
* Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the
* entire derived history (directly after the provider's system slot) on every request this
* loop instance sends.
* Waterfall: compose the SESSION PREFIX — request-only messages placed in
* front of the ENTIRE derived history (directly after the provider's
* system slot) on every request this loop instance sends. Fired ONCE per
* loop instance, lazily before its first step's {@link agent/pre-step}
* seam — BEFORE the pre-step so a token-pressure gate (compaction) counts
* the prefix this instance will actually send, never a previous
* instance's logged one. The composed
* result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the
* instance's anchoring `'initial'`/`'resume'` header snapshot, and reused
* verbatim for every subsequent request — never recomputed mid-session,
* so the provider prefix cache holds by construction (a process restart
* or `ctx.agents.resume()` is a new instance: it recomposes, and any
* drift lands attributably on the `'resume'` snapshot). Composition runs
* outside the step, before the boundary snapshot: a composing listener's
* session append joins the CURRENT request's derived history. A
* composition interrupted by a cancel/dispose landing inside the
* waterfall is discarded — never cached, logged, or sent — and the next
* turn recomposes under a live signal, so an abort-aware listener's
* degraded fallback cannot leak into later requests.
*
* Scope-filtered dispatch: keyed to `agent`.
* This is the home for session-stable openers the model must always see
* but that must NOT become durable history — a skills catalog, an
* AGENTS.md digest, a workspace baseline: `Session.deriveMessages()`
* never returns the prefix, and the header events are its only durable
* record, so the request stays reconstructable from the log. Content
* that CHANGES mid-session belongs in the append-only history channels
* instead — `agent.inject()`, a `tools/post-execute` decision's
* `additionalContext`, prompt-submit `additionalContext` — each a
* durable `context/message` paid once and prefix-cached thereafter.
*
* The seed is a frozen empty list; a contributing listener returns a NEW
* array — never an in-place push. The canonical contribution is a
* PREPEND, `[mine, ...await next()]`: the waterfall unwinds
* innermost-first (the LAST-registered listener's `next()` resolves
* first), so prepending yields registration order on the wire, and every
* plugin using it composes deterministically. The append form
* `[...await next(), mine]` is legal but places a contribution AFTER
* every later-registered plugin's — reverse registration order when all
* contributors append. Call `next()` to
* delegate, or return a list without it to short-circuit.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @param agent - the agent whose session prefix is being composed.
* @param prefix - the frozen empty seed; return an extended replacement to contribute.
* @param signal - aborts in-flight listener work (e.g. a discovery scan) when the step is torn down.
@@ -271,49 +536,71 @@ declare module 'cordis' {
*/
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
/**
* Waterfall: post-process the assembled assistant {@link Message} before tool dispatch
* (validation, content rewriting, …).
*
* Scope-filtered dispatch: keyed to `agent`.
* Waterfall: post-process the assembled assistant {@link Message} before
* tool dispatch (validation, content rewriting, …).
* @param agent - the agent that received the step's response.
* @param turn - the open turn number.
* @param step - the step that produced the message.
* @param message - the assistant message as assembled from the stream.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode waterfall
*/
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
/**
* Waterfall: override the turn-continuation decision via a typed {@link
* ContinuationDecision}.
*
* Scope-filtered dispatch: keyed to `agent`.
* Waterfall: override the turn-continuation decision via a typed
* {@link ContinuationDecision}. The loop's `defaultDecision` is `continue`
* when the step had tool calls or steering was injected, else `stop`.
* Listeners force-continue (`/goal`, `/loop` — optionally attaching a
* `reason` recorded as next-step steering) or force-stop (budget guards).
* Call `next()` to delegate to the default, or return a decision to override.
* @param agent - the agent deciding whether to run another step.
* @param turn - the turn being continued or stopped.
* @param defaultDecision - what the loop would do absent an override.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode waterfall
*/
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
/**
* Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall,
* any `continue.reason`, and the pending-steering continuation override have been folded.
*
* Scope-filtered dispatch: keyed to `agent`.
* Serial terminal-stop checkpoint after the ordinary
* `agent/turn-continuation` waterfall, any `continue.reason`, and the
* pending-steering continuation override have been folded. A listener
* returns `{ action: 'stop' }` to make this turn terminal, or `undefined`
* to abstain. Terminal stop is monotonic: listener order and steering
* cannot resume the turn, and pending steering is discarded rather than
* becoming another step or turn. A malformed non-undefined result fails
* the turn closed.
* @param agent - the agent whose composed continuation outcome may be stopped.
* @param turn - the turn at its terminal-stop checkpoint.
* Dispatch is scoped to `agent`.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode serial
*/
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
// ---- error notifications (emit) ----
/**
* A step or turn errored.
*
* Scope-filtered dispatch: keyed to `agent`.
* A step or turn errored. The loop reports a failure here (plus the logger)
* even when the error has no in-turn position for a session `error` event.
* @param agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced.
* @param error - the failure, verbatim.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode emit
*/
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: Error): void

View File

@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context, Service, symbols } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
function stubAgent(rawId: string): Agent {
const id = AgentId(rawId)
@@ -10,8 +11,6 @@ function stubAgent(rawId: string): Agent {
options: {},
session: new Session(SessionId(`${id}-session`)),
status: 'idle',
// A bare context stands in for the agent scope: registry tests never
// register through it, they only need the field present.
ctx: new Context(),
send() {},
steer() {},
@@ -22,150 +21,203 @@ function stubAgent(rawId: string): Agent {
}
describe('AgentRegistry', () => {
it('registers agents and emits created/disposed events', async () => {
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const created: string[] = []
const disposed: string[] = []
ctx.on('agent/created', agent => void created.push(agent.id))
ctx.on('agent/disposed', agent => void disposed.push(agent.id))
const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
const agent = stubAgent('a1')
const dispose = ctx.agents.register(agent)
expect(created).toEqual(['a1'])
expect(ctx.agents.get(AgentId('a1'))).toBe(agent)
expect(ctx.agents.get(agent.id)).toBe(agent)
expect(ctx.agents.list()).toEqual([agent])
expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
await dispose()
expect(disposed).toEqual(['a1'])
expect(ctx.agents.get(AgentId('a1'))).toBeUndefined()
expect(ctx.agents.get(agent.id)).toBeUndefined()
expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
})
it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => {
it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
ctx.agents.register(stubAgent('main'))
expect(() => ctx.agents.register(stubAgent('main'))).toThrow('already registered')
const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/created', () => { throw new Error('creation veto') })
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.agents.register(stubAgent('scoped'))
}, { inject: ['agents'] }))
expect(ctx.agents.list().map(a => a.id)).toEqual(['main', 'scoped'])
await fiber.dispose()
expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
expect(ctx.agents.get(AgentId('vetoed'))).toBeUndefined()
expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed'])
})
it('rolls back the agent entry when an agent/created listener throws (P1-1)', async () => {
it('contains asynchronous creation rejection and every disposal-listener failure', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const warnings: string[] = []
const heard: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never)
ctx.on('agent/disposed', () => { throw new Error('disposed sync') })
ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never)
ctx.on('agent/disposed', agent => void heard.push(agent.id))
let threw = false
ctx.on('agent/created', () => {
if (!threw) { threw = true; throw new Error('boom created listener') }
})
// The throwing emit must roll the entry back, not leak it.
expect(() => ctx.agents.register(stubAgent('main'))).toThrow('boom created listener')
expect(ctx.agents.get(AgentId('main'))).toBeUndefined() // rolled back, not leaked
// A subsequent listener-free register of the SAME id succeeds and is
// tracked exactly once (the duplicate-id check is not wedged).
const dispose = ctx.agents.register(stubAgent('main'))
expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
const dispose = ctx.agents.register(stubAgent('contained'))
await Promise.resolve()
await dispose()
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
await Promise.resolve()
expect(heard).toEqual(['contained'])
expect(warnings).toEqual([
'agent "contained": agent/created listener rejected: Error: created async',
'agent "contained": agent/disposed listener threw: Error: disposed sync',
'agent "contained": agent/disposed listener rejected: Error: disposed async',
])
})
it('splits insertion from announcement and makes the detach exact/idempotent', async () => {
it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const created: Agent[] = []
const disposed: Agent[] = []
ctx.on('agent/created', agent => void created.push(agent))
ctx.on('agent/disposed', agent => void disposed.push(agent))
const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
const first = stubAgent('split')
const detachFirst = ctx.agents.enter(first)
expect(ctx.agents.get(first.id)).toBe(first)
expect(created).toEqual([])
expect(lifecycle).toEqual([])
ctx.agents.announce(first)
expect(created).toEqual([first])
expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/)
detachFirst()
detachFirst()
expect(disposed).toEqual([first])
const replacement = stubAgent('split')
const detachReplacement = ctx.agents.enter(replacement)
// A stale repeated detach cannot remove the replacement.
detachFirst()
expect(ctx.agents.get(replacement.id)).toBe(replacement)
expect(() => { ctx.agents.announce(first) }).toThrow(/not live/)
detachReplacement()
// The replacement was inserted but never announced, so rollback produces
// no disposed-without-created notification.
expect(disposed).toEqual([first])
expect(lifecycle).toEqual(['created:split', 'disposed:split'])
})
it('defers detach requested by a creation listener until that dispatch unwinds', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const order: string[] = []
const agent = stubAgent('reentrant')
ctx.on('agent/created', () => {
order.push(`first:${ctx.agents.get(agent.id) === agent}`)
detach()
order.push(`after-detach:${ctx.agents.get(agent.id) === agent}`)
})
ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`))
ctx.on('agent/disposed', () => void order.push('disposed'))
const detach = ctx.agents.enter(agent)
ctx.agents.announce(agent)
expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed'])
expect(ctx.agents.get(agent.id)).toBeUndefined()
})
})
describe('agentEvents()', () => {
it('contains each synchronous throw and returned-promise rejection', async () => {
const ctx = new Context()
const warnings: string[] = []
const heard: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const agent = stubAgent('event')
ctx.on('agent/status', () => { throw new Error('sync listener') })
ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never)
ctx.on('agent/status', (_agent, status) => void heard.push(status))
agentEvents(ctx, agent).emit('agent/status', 'running')
await Promise.resolve()
expect(heard).toEqual(['running'])
expect(warnings).toEqual([
'agent event "agent/status" listener threw: Error: sync listener',
'agent event "agent/status" listener rejected: Error: async listener',
])
})
})
describe('AgentRegistry factory seam', () => {
/** A stub AgentFactory that records calls and returns a stub agent. */
function stubFactory() {
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
async createAgent(options) {
calls.create.push(options)
const calls: {
create: Array<{ ownerCtx: Context; options: CreateAgentOptions }>
resume: Array<{ ownerCtx: Context; options: ResumeAgentOptions }>
} = { create: [], resume: [] }
const factory: AgentFactory = {
async createAgent(ownerCtx, options) {
calls.create.push({ ownerCtx, options })
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
},
resume(options) {
calls.resume.push(options)
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
async resume(ownerCtx, options) {
calls.resume.push({ ownerCtx, options })
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
},
}
return { factory, calls }
}
it('create()/resume() throw when no factory is registered', async () => {
it('requires a factory and delegates through the calling context', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
})
it('setFactory registers a factory; create/resume delegate to it', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const { factory, calls } = stubFactory()
ctx.agents.setFactory(factory)
const created = await ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
expect(created.agent.id).toBe('c1')
expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
expect(resumed.agent.id).toBe('r1')
expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }])
})
it('setFactory rejects a second factory', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
ctx.agents.setFactory(stubFactory().factory)
expect(() => ctx.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
})
it('disposing the setFactory fiber clears the factory (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
let dispose!: () => Promise<void> | void
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
dispose = inner.agents.setFactory(stubFactory().factory)
let callerFiber: Context['fiber'] | undefined
await ctx.plugin(Object.assign(async (inner: Context) => {
callerFiber = inner.fiber
await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
}, { inject: ['agents'] }))
await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).resolves.toBeDefined()
void dispose
await fiber.dispose()
// factory slot cleared → create throws again
await expect(ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).rejects.toThrow(/no agent factory/)
expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber)
expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber)
})
it('rejects a second factory and clears the slot with its owner (HMR)', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const owner = await ctx.plugin(Object.assign((inner: Context) => {
inner.agents.setFactory(stubFactory().factory)
expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
}, { inject: ['agents'] }))
await expect(ctx.agents.create({ agentId: AgentId('before'), sessionId: SessionId('before-s') })).resolves.toBeDefined()
await owner.dispose()
await expect(ctx.agents.create({ agentId: AgentId('after'), sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/)
})
it('canonicalizes an already traced Service before tracing it for the caller', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const states = new WeakMap<object, string[]>()
class TracedFactory extends Service implements AgentFactory {
constructor(inner: Context) {
super(inner, 'tracedFactory')
states.set(this, [])
}
private calls(): string[] {
const original = (this as unknown as { [symbols.original]?: TracedFactory })[symbols.original] ?? this
const calls = states.get(original)
if (calls === undefined) throw new Error('factory receiver was not canonicalized')
return calls
}
async createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
this.calls().push('create')
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
}
async resume(_ownerCtx: Context, options: ResumeAgentOptions) {
this.calls().push('resume')
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
}
}
await ctx.plugin(TracedFactory)
const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
ctx.agents.setFactory(traced)
await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
expect(states.get(raw!)).toEqual(['create', 'resume'])
})
})

View File

@@ -1,18 +1,20 @@
# dsh-scope
Scoped Cordis registrations. `createScope(ctx, key)` returns a context whose registrations are visible only to the matching dispatch subject and are owned by one backing fiber. The agent loop creates one scope per live agent; lower-level packages depend only on the generic `ScopeKey` mechanism.
Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis context that TAGS everything registered through it with an opaque `ScopeKey` and OWNS those registrations' lifetime (one backing fiber drives both facts); `scopeOf(ctx)` reads the tag; `scopeTarget(base, key)` builds the dispatch carrier that makes an event scope-filtered — listeners registered through a scoped context fire only for their key's subject, while plain plugin listeners keep firing for every subject. The agent loop is the one scope minter today (one scope per live agent, key = the `Agent` object — the `Agent.ctx` contract in `dsh-agent`), but the mechanism is key-agnostic so packages below the agent layer (`dsh-session`, `dsh-system-prompt`) depend on it without a dependency cycle.
## Public API
- `createScope(ctx, key): Scope` creates a tagged child context. Derived contexts inherit the tag; a nested scope replaces it. Primitive keys and creation during disposal throw.
- `Scope.ctx` is the registration context.
- `Scope.rawDispose` is the exact Cordis disposer used when nesting the scope in a composite effect.
- `Scope.dispose(): Promise<void>` is the idempotent quiescence boundary for ordinary callers, including races started through `rawDispose`.
- `scopeOf(ctx)` returns the nearest key or `undefined` for global registration.
- `scopeTarget(base, key): Scoped<T>` creates the event receiver that admits global listeners plus listeners for `key`. An undefined key admits only global listeners; Cordis `{ global: true }` remains an explicit bypass.
- `Scoped<T>` brands scope-filtered event receivers at compile time. `isScopeCarrier()` and `carrierKeyOf()` support runtime invariants.
- `scopeHost(ctx, services)` provides a test/tooling host whose disposer awaits its fiber and all scopes it minted.
- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`).
- `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
- `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.
- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global.
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
Visibility and cleanup come from the same registration context, so a contribution cannot be visible to one scope but owned by another. A scoped context retains the minting plugin's injected service view; mint it from a context whose capabilities are appropriate for holders.
## Design contract
See [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) for rationale and lifecycle integration.
Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. This is trusted registration and listener routing, not sandboxing or an authority hierarchy: a same-process plugin is not confined, and a child scope need not be a subset of its parent's view. Rationale, alternatives, and the security non-goal: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.

View File

@@ -1,296 +1,116 @@
/**
* Scoped-context primitive: mint a Cordis context that TAGS everything registered through it
* with an opaque {@link ScopeKey}, and dispatch events so listeners registered through such a
* context fire only for their key's subject.
* Scoped-context primitive: mint a Cordis context that tags registrations with
* an opaque identity and build routing-only event carriers for that identity.
*
* @module @deepseek-ai/dsh-scope
*/
import type { Context, Fiber } from 'cordis'
import { Context as CordisContext } from 'cordis'
/**
* The identity a scope is keyed by. Opaque and compared by object identity —
* never inspected. The harness convention: a live `Agent` is the key of its
* own scope, so seam vocabularies that already carry the agent
* (`ToolExecution.agent`, `AssembleContext.scope`) name the layer directly.
*/
/** An opaque, identity-compared scope key. */
export type ScopeKey = object
/** The context tag {@link createScope} writes and {@link scopeOf} reads (module-private). */
/** Context tag written by {@link createScope}. */
const kScope = Symbol('dsh.scope')
/** The carrier mark {@link scopeTarget} writes and {@link carrierKeyOf} reads (module-private). */
const kCarrier = Symbol('dsh.scope.carrier')
declare const ScopedBrand: unique symbol
/**
* A dispatch carrier built by {@link scopeTarget}: structurally the `base` it
* overlays, branded so scope-filtered events can DEMAND a carrier as their
* `this` type — passing a bare subject where a `Scoped<T>` is required is a
* compile error, which is what makes "forgot the carrier" unrepresentable at
* dispatch sites. The brand is compile-time only; {@link isScopeCarrier} is
* the runtime counterpart (used by the dev invariants).
* A routing-only event receiver built by {@link scopeTarget}. The type
* parameter records the subject type for dispatch checking; the carrier does
* not expose the subject's properties. Event payloads carry the real subject.
*/
export type Scoped<T> = T & { readonly [ScopedBrand]: 'dsh.scope.carrier' }
export type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
/**
* A minted scope: the tagged context to register through, plus the disposers
* that unwind every registration made through it.
*/
/** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */
const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
/** A minted registration scope and its quiescent disposal boundaries. */
export interface Scope {
/**
* The scoped context. Registrations through it are tagged with the scope's
* key (scope-aware registries file them in that key's layer; `ctx.on`
* listeners fire only for dispatches targeted at that key) and owned by the
* scope's fiber (disposed together on {@link dispose}). Contexts DERIVED
* from it — an `extend`, a fiber mounted under it — inherit the tag through
* the prototype chain.
*/
/** Context through which scope-owned registrations are made. */
ctx: Context
/**
* The EXACT disposer Cordis registered on the minting fiber for the scope's
* backing fiber. A composite (generator) effect that owns the scope's
* position in an ordered teardown must yield THIS function: Cordis dedupes a
* nested effect out of the parent's concurrent disposal list by function
* identity, so yielding a wrapper would leave the scope disposing as an
* unordered sibling. Callers outside a composite effect use {@link dispose}.
* @returns the backing fiber's teardown promise (undefined on a repeat call
* — Cordis effect disposers are single-shot).
*/
/** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */
rawDispose: () => Promise<void> | void
/**
* Unwind the scope: dispose the backing fiber, running every collected
* registration disposer. Idempotent and always awaitable: repeat and racing
* calls share one completion even though the underlying Cordis disposer is
* single-shot and returns undefined after its first invocation.
* After disposal the scoped context is inert — a further registration
* through it throws Cordis's INACTIVE_EFFECT.
* @returns for the call that initiates teardown: resolves when every
* registration's disposer has settled. Every repeat/racing call awaits
* that same quiescence boundary, including when {@link rawDispose} claimed
* the underlying single-shot Cordis disposer first.
*/
/** Dispose every scope-owned registration; racing calls await the same completion. */
dispose(): Promise<void>
}
/**
* Dispose a Cordis fiber and await its lifecycle inertia even when some other
* caller claimed the single-shot raw disposer first. `Fiber.dispose()` returns
* `undefined` on a repeat call, but the fiber's `inertia` remains the
* authoritative promise while its async unload is running.
*/
/** Follow a Cordis fiber through asynchronous teardown even if its raw disposer was already claimed. */
async function quiesceFiber(fiber: Fiber): Promise<void> {
await Promise.resolve(fiber.dispose())
while (fiber.inertia !== undefined) await fiber.inertia
}
/**
* The shared no-op plugin every scope fiber mounts: named so diagnostics read
* `scope` and shared so all scopes join ONE plugin runtime (Cordis deletes the
* runtime record when its last fiber disposes, so idle deployments carry no
* residue).
*/
/** Shared no-op plugin used as the backing scope fiber. */
function scope(): void {}
/**
* Mint a registration scope for `key` under `ctx`.
*
* @param ctx - the context to mount the scope under; its fiber must be active
* (a disposing owner throws Cordis's INACTIVE_EFFECT), and its plugin's
* `inject` surface is what the scoped context resolves services against.
* @param key - the scope's identity ({@link ScopeKey}); must be an object
* (identity-compared), else this throws.
* @returns the tagged context plus its disposers ({@link Scope}).
* Mint a scope under `ctx`. The scoped context inherits the minting plugin's
* dependency surface and owns every registration made through it.
* @param ctx - active context whose dependency surface the scope inherits.
* @param key - opaque identity used for listener routing.
* @returns the scoped context and exact/shared disposal boundaries.
*/
export function createScope(ctx: Context, key: ScopeKey): Scope {
// Runtime guard behind the ScopeKey type: callers outside the typechecker
// (yml-configured plugins, JS consumers) can still pass a primitive.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if ((typeof key !== 'object' && typeof key !== 'function') || key === null) {
throw new TypeError('createScope: key must be a non-null object or function (scope keys are identity-compared)')
}
const fiber = ctx.plugin(scope)
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
let disposing: Promise<void> | undefined
return {
ctx: scoped,
// fiber.dispose IS the disposer Cordis pushed onto the minting fiber's
// disposable list — the identity a composite effect must yield (see
// Scope.rawDispose).
rawDispose: fiber.dispose,
// Memoize the public boundary and explicitly follow fiber inertia: the raw
// disposer must remain the exact Cordis function for ordered composition,
// so it cannot itself be wrapped to record a raw-first invocation.
dispose: () => (disposing ??= quiesceFiber(fiber)),
}
}
/**
* Read the scope key a context is tagged with, or `undefined` for an untagged
* (context-global) context. Walks the prototype chain, so any context DERIVED
* from a scoped context — service shadows, `extend`s, fibers mounted under it
* — reads as that scope; with nested scopes the nearest tag wins.
* @param ctx - the context to inspect (typically a registry method's
* `this.ctx`, i.e. the ACCESSING context).
* @returns the key given to {@link createScope}, or `undefined` when the
* context is not derived from any scope.
* Read the nearest scope tag inherited by a context.
* @param ctx - context to inspect.
* @returns its scope key, or `undefined` for an unscoped context.
*/
export function scopeOf(ctx: Context): ScopeKey | undefined {
// A plain (possibly proxied) property read: symbols bypass the Cordis
// context proxy's service resolution, and Reflect walks the prototype chain.
return (ctx as Context & { [kScope]?: ScopeKey })[kScope]
}
/**
* Build an event receiver admitting global listeners plus listeners tagged with `key`.
* The proxy preserves `base` filtering and binds subject methods to `base`.
* @param base - dispatch subject whose filter is preserved.
* @param key - subject scope, or `undefined` for global-only delivery.
* @returns branded receiver for the dispatch `thisArg`.
* Build the routing receiver for a scope-filtered event. Untagged listeners
* remain global; tagged listeners run only when their key matches. A base
* Cordis filter is composed before the scope predicate.
*
* The receiver is deliberately opaque: listener code obtains the real subject
* from event arguments, never from `this`.
* @param base - subject or service whose existing Cordis filter is preserved.
* @param key - routed scope identity, or `undefined` for an unscoped subject.
* @returns an opaque dispatch carrier.
*/
export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined): Scoped<T> {
const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter]
const filter = (ctx: Context): boolean => {
if (baseFilter && !baseFilter.call(base, ctx)) return false
const tag = scopeOf(ctx)
return tag === undefined || tag === key
}
const overlay: Record<string | symbol, unknown> = {
[CordisContext.filter]: filter,
[kCarrier]: { key },
}
// Bind through the real subject so native private fields remain accessible.
return new Proxy(base, {
get(target, prop) {
// Non-configurable own properties must be reported unchanged.
const own = Reflect.getOwnPropertyDescriptor(target, prop)
const pinned = own !== undefined && own.configurable === false
&& own.get === undefined && own.writable !== true
// `in` would let Object.prototype shadow subject properties.
if (!pinned && Object.hasOwn(overlay, prop)) return overlay[prop]
const value: unknown = Reflect.get(target, prop, target)
if (typeof value !== 'function' || pinned) return value
// Preserve class identity.
if (prop === 'constructor') return value
// `bind` is typed as `any`; keep the trap boundary `unknown`.
return value.bind(target) as unknown
const carrier = {
[CordisContext.filter](ctx: Context): boolean {
if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false
const tag = scopeOf(ctx)
return tag === undefined || tag === key
},
set(target, prop, value) {
return Reflect.set(target, prop, value, target)
},
}) as Scoped<T>
}
carrierKeys.set(carrier, key)
return carrier as unknown as Scoped<T>
}
/**
* Whether `value` is a carrier built by {@link scopeTarget} — the runtime
* counterpart of the {@link Scoped} brand, used by the dev invariants to
* assert that a scope-filtered event was dispatched with a carrier and not a
* bare subject.
* @param value - the dispatch `thisArg` to test.
* @returns true iff `value` came from {@link scopeTarget}.
* Test whether a value is a scope carrier.
* @param value - dispatch receiver to inspect.
* @returns whether {@link scopeTarget} created it.
*/
export function isScopeCarrier(value: unknown): value is Scoped<object> {
if (typeof value !== 'object' || value === null) return false
// A property READ, not an `in` check: the carrier overlays its marks in the
// get trap only (no `has` trap), so `kCarrier in carrier` would fall
// through to the wrapped base and always answer false.
return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier] !== undefined
return typeof value === 'object' && value !== null && carrierKeys.has(value)
}
/**
* The scope key a carrier was built for — `undefined` for a subject-less
* carrier, and also `undefined` for a non-carrier (pair with
* {@link isScopeCarrier} when the distinction matters). The dev invariants
* use it to assert the carrier's key IS the subject the event's arguments
* name.
* @param value - the dispatch `thisArg` to read.
* @returns the `key` given to {@link scopeTarget}, or `undefined`.
* Read a carrier's routing key.
* @param value - dispatch receiver to inspect.
* @returns the carrier key, or `undefined` for an unkeyed/non-carrier value.
*/
export function carrierKeyOf(value: unknown): ScopeKey | undefined {
if (!isScopeCarrier(value)) return undefined
// Optional-prop cast: the guard proves the mark is present at runtime, but
// the Scoped<> brand carries no structural kCarrier member to narrow from.
return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier]?.key
}
/**
* A test/tooling host for minting scopes: one mounted plugin whose `inject`
* list is the service surface every scope minted through it can reach.
*/
export interface ScopeHost {
/**
* Mint a scope under the host (see {@link createScope}); the scoped context
* resolves exactly the host's injected services.
* @param key - the scope's identity ({@link ScopeKey}).
* @returns the minted scope.
*/
mint(key: ScopeKey): Scope
/**
* Dispose the host fiber and with it every scope minted through it.
* Every racing/repeat caller observes the same completion, including when a
* child's raw disposer started before host disposal.
* @returns resolves when the host and every minted scope have reached
* quiescence.
*/
dispose(): Promise<void>
}
/**
* Mount a scope-minting host plugin that injects `services`, THE sanctioned way to mint scopes
* in tests (production scopes are minted by the agent loop).
*
* @param ctx - the context to mount the host under.
* @param services - the service names scopes minted through this host reach
* (the host plugin's `inject` list).
* @returns the host (mint scopes, dispose them all at once).
* @throws when any of `services` is not available on `ctx` — named, not the
* Cordis dead end.
*/
export async function scopeHost(ctx: Context, services: string[]): Promise<ScopeHost> {
let hostCtx: Context | undefined
// A named function statement (not Object.assign({name}) — Function.name is
// read-only) so diagnostics read `scopeHost`.
function scopeHostPlugin(inner: Context): void { hostCtx = inner }
const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: services }))
await fiber
if (hostCtx === undefined) {
// Dependency-pending: cordis resolves the await without running the
// callback. Name the absentees and unwind the pending fiber.
const missing = services.filter(name => ctx.get(name) === undefined)
await fiber.dispose()
/* v8 ignore next -- the '(unknown)' fallback is defensive: a pending
* fiber with zero absent services cannot occur (an all-present inject
* list runs the callback) */
const named = missing.map(name => `"${name}"`).join(', ') || '(unknown)'
throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${named} not available on this context — load the providing plugin(s) before minting scopes`)
}
const host = hostCtx
const scopes = new Set<Scope>()
let disposing: Promise<void> | undefined
const dispose = async (): Promise<void> => {
// Start every boundary before awaiting any one of them.
const tasks = [quiesceFiber(fiber), ...[...scopes].map(scope => scope.dispose())]
const results = await Promise.allSettled(tasks)
scopes.clear()
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : [])
if (errors.length === 1) throw errors[0]
if (errors.length > 1) throw new AggregateError(errors, 'scopeHost: disposal failed')
}
return {
mint: (key: ScopeKey) => {
const minted = createScope(host, key)
let disposing: Promise<void> | undefined
const tracked: Scope = {
ctx: minted.ctx,
// Preserve the exact Cordis identity: only the public shared boundary
// is wrapped to retire this child from the host's tracking set.
rawDispose: minted.rawDispose,
dispose: () => (disposing ??= minted.dispose().finally(() => { scopes.delete(tracked) })),
}
scopes.add(tracked)
return tracked
},
dispose: () => (disposing ??= dispose()),
}
return carrierKeys.get(value)
}

View File

@@ -1,368 +1,155 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { carrierKeyOf, createScope, isScopeCarrier, scopeHost, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scope, ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
declare module 'cordis' {
interface Events {
/**
* Test-only event for exercising scope-filtered dispatch.
* Test-only event for scope-filtered dispatch.
* @param value - opaque payload recorded by listeners.
* @mode emit
*/
'scope-test/ping'(value: string): void
/**
* Test-only waterfall for exercising carrier `this` shape.
* @param value - seed value listeners may wrap.
* @mode waterfall
*/
'scope-test/echo'(value: string, next: () => string): string
}
}
/** Mount a host plugin and mint a scope inside it, returning both. */
/** Mount a host plugin and mint a scope inside it. */
async function mintScope(ctx: Context, key: object): Promise<Scope> {
let scope!: Scope
await ctx.plugin((inner: Context) => {
scope = createScope(inner, key)
})
await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
return scope
}
describe('createScope', () => {
it('rejects primitive keys but accepts callable objects (matching ScopeKey)', async () => {
it('tags contexts and derived contexts, with the nearest tag winning', async () => {
const ctx = new Context()
// Typed through `unknown` so the ScopeKey type cannot argue the assertion
// away: this test exercises exactly the callers the typechecker misses.
const badKeys: unknown[] = ['k', null]
for (const bad of badKeys) {
expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be a non-null object or function/)
}
const outerKey = { name: 'outer' }
const innerKey = { name: 'inner' }
const outer = await mintScope(ctx, outerKey)
const inner = createScope(outer.ctx, innerKey)
const callable = Object.assign(() => {}, { nameForTest: 'callable-key' })
const scope = await mintScope(ctx, callable)
expect(scopeOf(scope.ctx)).toBe(callable)
await scope.dispose()
})
it('tags the scoped context, readable through derivations (nearest tag wins)', async () => {
const ctx = new Context()
const key = { name: 'a' }
const inner = { name: 'a.inner' }
const scope = await mintScope(ctx, key)
expect(scopeOf(scope.ctx)).toBe(key)
// An extend of the scoped context inherits the tag through the prototype chain.
expect(scopeOf(scope.ctx.extend({}))).toBe(key)
// A plain context carries no tag.
expect(scopeOf(ctx)).toBeUndefined()
// A fiber mounted UNDER the scoped context reads as that scope…
let mountedCtx!: Context
await scope.ctx.plugin((c: Context) => { mountedCtx = c })
expect(scopeOf(mountedCtx)).toBe(key)
// …and a nested scope shadows the outer tag (nearest wins).
const nested = createScope(scope.ctx, inner)
expect(scopeOf(nested.ctx)).toBe(inner)
expect(scopeOf(outer.ctx)).toBe(outerKey)
expect(scopeOf(outer.ctx.extend({}))).toBe(outerKey)
expect(scopeOf(inner.ctx)).toBe(innerKey)
await inner.dispose()
await outer.dispose()
})
it('is usable synchronously: registrations land before the fiber activates', async () => {
it('is usable synchronously before the backing fiber activates', async () => {
const ctx = new Context()
const events: string[] = []
let scope!: Scope
await ctx.plugin((inner: Context) => {
const scope = createScope(inner, { name: 'sync' })
// Same tick as createScope — no await between mint and use.
scope.ctx.effect(() => () => void events.push('effect-disposed'))
scope.ctx.on('scope-test/ping', value => void events.push(`heard:${value}`))
scope = createScope(inner, { name: 'sync' })
scope.ctx.effect(() => () => void events.push('disposed'))
events.push('registered')
})
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'nobody')
expect(events).toEqual(['registered'])
})
it('dispose() unwinds registrations, is idempotent, and inerts the context', async () => {
const ctx = new Context()
const scope = await mintScope(ctx, { name: 'd' })
const order: string[] = []
scope.ctx.effect(() => () => void order.push('a'))
scope.ctx.effect(() => () => void order.push('b'))
await scope.dispose()
expect(order).toEqual(['b', 'a']) // LIFO within the scope fiber
// Repeat dispose: the underlying cordis disposer returns undefined; the
// wrapper still resolves.
await expect(scope.dispose()).resolves.toBeUndefined()
// Registration through a disposed scope throws INACTIVE_EFFECT.
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
expect(events).toEqual(['registered', 'disposed'])
})
it('dispose() follows a rawDispose-first race through async quiescence', async () => {
it('shares quiescence across repeat and raw-disposer-first calls', async () => {
const ctx = new Context()
const scope = await mintScope(ctx, { name: 'raw-first' })
const scope = await mintScope(ctx, { name: 'quiescence' })
const gate = Promise.withResolvers<undefined>()
let cleanupFinished = false
let finished = false
scope.ctx.effect(() => async () => {
await gate.promise
cleanupFinished = true
finished = true
})
const raw = Promise.resolve(scope.rawDispose())
let publicSettled = false
const publicDispose = scope.dispose().then(() => { publicSettled = true })
const publicDispose = scope.dispose()
await Promise.resolve()
expect(publicSettled).toBe(false)
expect(cleanupFinished).toBe(false)
expect(finished).toBe(false)
gate.resolve(undefined)
await Promise.all([raw, publicDispose])
expect(cleanupFinished).toBe(true)
await expect(scope.dispose()).resolves.toBeUndefined()
await Promise.all([raw, publicDispose, scope.dispose()])
expect(finished).toBe(true)
})
it('rawDispose is the exact cordis disposer: yielding it nests the scope at its position', async () => {
it('exposes the exact raw disposer for ordered composite teardown', async () => {
const ctx = new Context()
const order: string[] = []
let composite!: () => Promise<void> | void
let dispose!: () => Promise<void> | void
await ctx.plugin((inner: Context) => {
composite = inner.effect(function* () {
yield () => void order.push('outermost') // disposed LAST
dispose = inner.effect(function* () {
yield () => void order.push('outer')
const scope = createScope(inner, { name: 'nested' })
scope.ctx.effect(() => () => void order.push('scope-registration'))
yield scope.rawDispose // disposed SECOND — nested by identity
yield () => void order.push('innermost') // disposed FIRST
scope.ctx.effect(() => () => void order.push('scope'))
yield scope.rawDispose
yield () => void order.push('inner')
})
})
await composite()
// The scope disposed exactly at its yield position (between the two
// neighbours), not as a concurrent sibling of the composite.
expect(order).toEqual(['innermost', 'scope-registration', 'outermost'])
await dispose()
expect(order).toEqual(['inner', 'scope', 'outer'])
})
})
describe('scopeTarget dispatch filtering', () => {
it('scoped listeners hear only their key; untagged listeners hear everything', async () => {
describe('scopeTarget', () => {
it('routes scoped listeners by key while untagged listeners remain global', async () => {
const ctx = new Context()
const keyA = { name: 'A' }
const keyB = { name: 'B' }
const scopeA = await mintScope(ctx, keyA)
const scopeB = await mintScope(ctx, keyB)
const heard: string[] = []
ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`))
ctx.emit(scopeTarget(ctx, keyA), 'scope-test/ping', 'to-A')
ctx.emit(scopeTarget(ctx, keyB), 'scope-test/ping', 'to-B')
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'to-nobody')
ctx.emit(scopeTarget(ctx, keyA), 'scope-test/ping', 'a')
ctx.emit(scopeTarget(ctx, keyB), 'scope-test/ping', 'b')
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'none')
expect(heard).toEqual([
'global:to-A', 'A:to-A',
'global:to-B', 'B:to-B',
'global:to-nobody',
])
expect(heard).toEqual(['global:a', 'A:a', 'global:b', 'B:b', 'global:none'])
await Promise.all([scopeA.dispose(), scopeB.dispose()])
})
it('{ global: true } listeners bypass scope filtering entirely', async () => {
it('preserves a base Cordis filter and its receiver', async () => {
const ctx = new Context()
const keyA = { name: 'A' }
const scopeA = await mintScope(ctx, keyA)
const heard: string[] = []
scopeA.ctx.on('scope-test/ping', value => void heard.push(`escape:${value}`), { global: true })
ctx.emit(scopeTarget(ctx, { name: 'other' }), 'scope-test/ping', 'foreign')
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'nobody')
expect(heard).toEqual(['escape:foreign', 'escape:nobody'])
})
it("composes the base's own Context.filter (a rejecting base filter wins)", async () => {
const ctx = new Context()
const keyA = { name: 'A' }
const scopeA = await mintScope(ctx, keyA)
const key = { name: 'A' }
const scope = await mintScope(ctx, key)
const heard: string[] = []
ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
scope.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
let receiverMatches = false
const base = {
[Context.filter](this: object): boolean {
receiverMatches = this === base
return false
},
}
// A base whose own filter rejects every listener context: nothing fires,
// scoped or not — the scope predicate never overrides the base's veto.
const vetoBase = { [Context.filter]: () => false }
ctx.emit(scopeTarget(vetoBase, keyA), 'scope-test/ping', 'vetoed')
ctx.emit(scopeTarget(base, key), 'scope-test/ping', 'vetoed')
expect(heard).toEqual([])
// A base whose filter accepts delegates to the scope predicate.
const openBase = { [Context.filter]: () => true }
ctx.emit(scopeTarget(openBase, keyA), 'scope-test/ping', 'open')
expect(heard).toEqual(['global:open', 'A:open'])
expect(receiverMatches).toBe(true)
await scope.dispose()
})
it('keeps listener `this` base-shaped through the carrier (waterfall)', async () => {
it('{ global: true } listeners retain Cordis global-listener semantics', async () => {
const ctx = new Context()
const base = { label: 'the-base' }
let seenLabel: string | undefined
ctx.on('scope-test/echo', function (this: { label: string }, value, next) {
seenLabel = this.label
return `${next()}+${value}`
})
const result = ctx.waterfall(scopeTarget(base, undefined), 'scope-test/echo', 'v', () => 'seed')
expect(result).toBe('seed+v')
expect(seenLabel).toBe('the-base')
const scope = await mintScope(ctx, { name: 'A' })
const heard: string[] = []
scope.ctx.on('scope-test/ping', value => void heard.push(value), { global: true })
ctx.emit(scopeTarget(ctx, { name: 'other' }), 'scope-test/ping', 'foreign')
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'none')
expect(heard).toEqual(['foreign', 'none'])
await scope.dispose()
})
it('is transparent for subjects with native #private fields: methods and getters through the carrier reach the real object', () => {
// The ds-review-bot regression: cordis hands the carrier to listeners as `this` (typed
// Scoped<Agent>), so subject method calls through it are a supported shape.
class Subject {
#count = 0
bump(): number { return ++this.#count }
get count(): number { return this.#count }
}
const subject = new Subject()
const carrier = scopeTarget(subject, subject)
expect(carrier.bump()).toBe(1) // method call: bound to the base
expect(subject.count).toBe(1) // ...and it mutated the REAL object
expect(carrier.count).toBe(1) // getter: runs with the base as receiver
// The get trap returns the method already bound to the base;
// detachability IS the assertion.
// eslint-disable-next-line @typescript-eslint/unbound-method
const detached = carrier.bump
expect(detached()).toBe(2)
})
it('delegates sets to the base and leaves frozen own function props unbound (proxy invariant)', () => {
const frozenFn = (): string => 'frozen'
const base: { mutable: number; pinned: () => string; toString: () => string } = {
mutable: 0,
pinned: frozenFn,
toString: () => 'base-str',
}
Object.defineProperty(base, 'pinned', { value: frozenFn, writable: false, configurable: false })
const carrier = scopeTarget(base, undefined)
carrier.mutable = 7
expect(base.mutable).toBe(7) // sets land on the base, not a detached overlay
// A non-configurable, non-writable own data prop must be reported
// unchanged (binding it would violate the proxy get invariant).
expect(carrier.pinned).toBe(frozenFn)
// The overlay literal inherits Object.prototype; hasOwn (not `in`) keeps
// it from shadowing the subject's own prototype-surface members.
expect(String(carrier)).toBe('base-str')
})
it('honors the get invariant even when an overlay key collides with a frozen own prop of the base', () => {
// Pathological but engine-enforced: a base whose own [Context.filter] is a
// non-configurable, non-writable data prop pins what any proxy over it may report for that
// key.
const pinnedFilter = (): boolean => true
const base = {}
Object.defineProperty(base, Context.filter, { value: pinnedFilter, writable: false, configurable: false })
const carrier = scopeTarget(base, { name: 'key' })
expect((carrier as Record<symbol, unknown>)[Context.filter]).toBe(pinnedFilter)
})
it('keeps the real constructor: class identity survives the carrier', () => {
class Subject { work(): string { return 'w' } }
const subject = new Subject()
const carrier = scopeTarget(subject, subject)
// `constructor` is looked up, never invoked as a subject method — binding
// it would break `carrier.constructor === Subject` for no benefit.
expect(carrier.constructor).toBe(Subject)
})
})
describe('carrier marks', () => {
it('isScopeCarrier / carrierKeyOf distinguish carriers, keys, and bare subjects', () => {
const base = { name: 'base' }
it('uses an opaque branded carrier with a separately tracked key', () => {
const key = { name: 'key' }
const keyed = scopeTarget(base, key)
const subjectless = scopeTarget(base, undefined)
expect(isScopeCarrier(keyed)).toBe(true)
expect(carrierKeyOf(keyed)).toBe(key)
expect(isScopeCarrier(subjectless)).toBe(true)
expect(carrierKeyOf(subjectless)).toBeUndefined()
expect(isScopeCarrier(base)).toBe(false)
expect(carrierKeyOf(base)).toBeUndefined()
expect(isScopeCarrier(null)).toBe(false)
expect(isScopeCarrier('x')).toBe(false)
})
it('brands the carrier type (compile-time)', () => {
const base = { name: 'base' }
const carrier = scopeTarget(base, undefined)
expectTypeOf(carrier).toExtend<Scoped<{ name: string }>>()
// A bare subject is NOT assignable where a carrier is demanded.
expectTypeOf(base).not.toExtend<Scoped<{ name: string }>>()
})
})
describe('scopeHost', () => {
it('mints scopes that reach the injected services; dispose unwinds them all', async () => {
const ctx = new Context()
ctx.provide('answers', { value: 42 })
const host = await scopeHost(ctx, ['answers'])
const scope = host.mint({ name: 'a' })
expect((scope.ctx as Context & { answers: { value: number } }).answers.value).toBe(42)
const order: string[] = []
scope.ctx.effect(() => () => void order.push('scoped-disposed'))
await host.dispose()
expect(order).toEqual(['scoped-disposed'])
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
})
it('dispose waits for a child whose raw disposer won the race', async () => {
const ctx = new Context()
ctx.provide('answers', { value: 42 })
const host = await scopeHost(ctx, ['answers'])
const scope = host.mint({ name: 'raw-first-child' })
const gate = Promise.withResolvers<undefined>()
let cleanupFinished = false
scope.ctx.effect(() => async () => {
await gate.promise
cleanupFinished = true
})
const raw = Promise.resolve(scope.rawDispose())
let hostSettled = false
const hostDispose = host.dispose().then(() => { hostSettled = true })
await Promise.resolve()
expect(hostSettled).toBe(false)
gate.resolve(undefined)
await Promise.all([raw, hostDispose])
expect(cleanupFinished).toBe(true)
await expect(host.dispose()).resolves.toBeUndefined()
})
it('reaches every child before surfacing one or multiple disposal failures', async () => {
const oneCtx = new Context()
oneCtx.provide('answers', { value: 42 })
const oneHost = await scopeHost(oneCtx, ['answers'])
const one = oneHost.mint({ name: 'one' })
one.dispose = () => Promise.reject(new Error('one failed'))
await expect(oneHost.dispose()).rejects.toThrow('one failed')
const manyCtx = new Context()
manyCtx.provide('answers', { value: 42 })
const manyHost = await scopeHost(manyCtx, ['answers'])
const a = manyHost.mint({ name: 'a' })
const b = manyHost.mint({ name: 'b' })
a.dispose = () => Promise.reject(new Error('a failed'))
b.dispose = () => Promise.reject(new Error('b failed'))
await expect(manyHost.dispose()).rejects.toMatchObject({
name: 'AggregateError',
message: 'scopeHost: disposal failed',
errors: [expect.objectContaining({ message: 'a failed' }), expect.objectContaining({ message: 'b failed' })],
})
})
it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => {
const ctx = new Context()
await expect(scopeHost(ctx, ['tools', 'systemPrompt']))
.rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
})
it('names a single absent service in the singular', async () => {
const ctx = new Context()
await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available')
const subject = { value: 1 }
const carrier = scopeTarget(subject, key)
expect(isScopeCarrier(carrier)).toBe(true)
expect(carrierKeyOf(carrier)).toBe(key)
expect(isScopeCarrier(subject)).toBe(false)
expect(carrierKeyOf(subject)).toBeUndefined()
expect('value' in carrier).toBe(false)
expectTypeOf(carrier).toEqualTypeOf<Scoped<typeof subject>>()
})
})

View File

@@ -4,40 +4,45 @@ Event-sourced session log and in-memory store. A `Session` is the append-only so
## Service: `SessionStore` (ctx key: `sessions`)
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event` and flush on `session/flush`.
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event`, flush on `session/flush`, and may mirror the paired `session/created`/`session/disposed` lifecycle.
### Public API
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
- `ctx.sessions.flush(session: Session): Promise<void>` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.
- `ctx.sessions.create(id?: SessionId, options?: { seed?: readonly SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. The persistence/replay seed and resulting header are validated, detached, and deep-frozen at this durable boundary. The store fills `version`/`id` and defaults `createdAt` to now; a persisted reconstruction supplies the original `createdAt` and `seedLength`. Disposed with the calling fiber.
- `ctx.sessions.flush(session: Session): Promise<void>` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Every captured listener starts, the call waits for all of them to settle, and a failure rejects only after the other listeners finish. Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
#### Advanced: ordered-teardown lifecycle primitives
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches`create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store attachment and publication hooks are removed`create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
- `ctx.sessions.prepare(id?, options?): Session` — validate the id/cwd and construct the `Session`, WITHOUT entering it into the store. Same options as `create`.
- `ctx.sessions.enter(session): () => void`wire `onAppend``session/event`, capture its scope carrier, and add the session to the store; returns the idempotent DETACH disposer, which clears both notification and carrier state. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved; a stale prepared object must not overwrite a live same-id session.
- `ctx.sessions.announce(session): void`emit `session/created` for an entered session.
- `ctx.sessions.prepare(id?, options?): Session` — validate durable seed/header data and construct the `Session` WITHOUT entering it into the store. Same options as `create`.
- `ctx.sessions.enter(session): () => void`perform the authoritative ID collision check, install append publication state, and insert the exact session without announcing it. Returns an idempotent detach bound to the captured entry object, so a stale disposer cannot remove a later same-ID replacement. Concurrent same-ID preparation is allowed; only one final entry succeeds.
- `ctx.sessions.announce(session): void`begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge.
`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload.
### Live service events
The store announces creation, publishes each append, and provides an awaited durability checkpoint. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly.
The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the scoped `session/event` callback list. The push is the commit point; callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and detach waits until publication unwinds. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md).
### Class: `Session`
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points). A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` controls how the event enters the surface linked list, and `sourceEventSeqs` records provenance (the seq numbers of events this one derives from). It is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The marker requirement is enforced two ways: the typed overload makes `opts` mandatory when `type` is a specific `SurfaceEventType` literal, AND `append` **throws** at runtime if a surface-eligible event arrives with no `surfaceOp` — covering the case where `type` widens to the `SessionEventType` union (a caller iterating raw events, where the conditional overload collapses to optional) so a marker-less message event can never silently land in the log and vanish from `deriveMessages()`.
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array snapshot per call over SHARED, deep-frozen `Message` objects — cloned once off the log at projection time, so a consumer can never mutate logged data (mutation throws). The surface is the single source of derived history — there is no raw-log fallback.
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: one event's derived message (an unfrozen clone), or `null` when it produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. Runtime surface validation covers widened unions and raw seed/load logs.
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback.
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled.
- `session.events`, `session.seq`, `session.id`
- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction.
- `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event.
- `session.seq`, `session.id` — current sequence and readonly typed identity.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`.
### Lossless JSON utilities
Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` recursively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization.
### Surface types
@@ -65,12 +70,12 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
### Metadata types (`types.ts`)
- `SessionHeader`immutable session metadata, written once: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
- `SessionHeader`session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
### Extension points
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME always-on invariants `append` enforces contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through.
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The constructor reads each seed entry once and uses the same one-pass lossless-JSON snapshot and exact surface-metadata shape checks as `append`, then enforces contiguous seqs and deep-freezes every accepted record; a stateful caller, exotic nested value, marker-less or malformed surface event, metadata on a non-surface event, or retained seed reference therefore cannot silently change the reconstructed history. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through.
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
### What is NOT here (TODO)

View File

@@ -3,7 +3,6 @@
* the derived LLM message history. Persistence is a plugin concern (subscribe
* to `session/event`, drain on `session/flush`).
*
* Scope-filtered dispatch: keyed to the session's captured owner.
* @module @deepseek-ai/dsh-session
*/
@@ -15,12 +14,12 @@ import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { isJsonValue } from './json.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
import { foldRequestHeader } from './request-header.ts'
export * from './types.ts'
export { isJsonValue } from './json.ts'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceNode } from './surface.ts'
@@ -35,25 +34,67 @@ declare module 'cordis' {
interface Events {
/**
* A session was created in the store.
* Dispatch uses the session's captured owner scope.
* A session was created in the store. A synchronous listener throw vetoes
* publication and rollback emits the matching `session/disposed` edge;
* returned-promise rejection is observed and logged but cannot retroactively
* veto this synchronous boundary. A synchronous listener that requests the
* advanced detach does not remove the entry immediately: removal and the
* paired `session/disposed` edge wait until the creation dispatch unwinds.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* @param session - the session just entered and announced.
* @mode emit
*/
'session/created'(this: Scoped<Session>, session: Session): void
/**
* An event was appended to a session log (sync, fire-and-forget).
*
* Scope-filtered dispatch: keyed to the session's captured owner.
* A previously announced session left the store. Emitted exactly once on
* normal detach or publication rollback, and never for a prepared/entered
* session whose `session/created` announcement did not begin. Listener
* failures (including returned-promise rejections) are logged and contained
* per listener so teardown always reaches quiescence.
* Scope-filtered dispatch uses the same owner carrier captured at entry;
* agent-scoped listeners hear only their own session's teardown.
* @param session - the session that is no longer live in the store.
* @mode emit
*/
'session/disposed'(this: Scoped<Session>, session: Session): void
/**
* An event was appended to a session log (sync, fire-and-forget). This is
* the per-append feed a UI or invariant plugin tails. The log push is the
* commit point; synchronous throws and returned-promise rejections from
* observers are logged and contained per listener, so they cannot make a
* committed append appear to fail or starve later listeners. The exact
* callback list and Cordis internal-dispatch checks resolve before the push;
* callbacks themselves run only after it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* @param session - the session whose log grew.
* @param event - the appended event, exactly as recorded.
* @mode emit
*/
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
/**
* Awaited durability checkpoint.
*
* Scope-filtered dispatch: keyed to the session's captured owner.
* Awaited durability checkpoint. The agent loop awaits
* `ctx.sessions.flush(session)` at every turn end; persistence
* plugins (JSONL, SQLite) drain their write-behind buffers here and on
* fiber dispose. Awaited (parallel), not a waterfall: every listener runs
* and the caller waits for all of them, but none can veto. Dispatch it
* through {@link SessionStore.flush} — the store owns the carrier — never
* via a raw `ctx.parallel`.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* @param session - the session whose buffered events must reach durable storage.
* @mode parallel
*/
@@ -80,6 +121,137 @@ function renderTagged(tag: string, content: ContentBlock[], source: MessageSourc
]
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
: source
const snapshot = snapshotJsonValue(input)
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
throw new Error('session header is not a plain JSON record')
}
const record = snapshot as Record<string, unknown>
if (record.version !== SESSION_FORMAT_VERSION) {
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
}
if (record.id !== id) {
throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
}
if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) {
throw new Error('session header createdAt must be a finite number')
}
if (record.cwd !== undefined) {
if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')
if (!isAbsolute(record.cwd)) {
throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`)
}
}
if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
throw new Error('session header parentSession must be a string')
}
if (record.seedLength !== undefined
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
throw new Error('session header seedLength must be a non-negative safe integer')
}
return deepFreeze(record as unknown as SessionHeader)
}
/** Validate the runtime shape of surface metadata after its JSON snapshot. */
function assertSurfaceMetadataShape(
type: string,
surfaceOp: unknown,
sourceEventSeqs: unknown,
): void {
const eligible = isSurfaceEligibleType(type)
if (!eligible) {
if (surfaceOp !== undefined || sourceEventSeqs !== undefined) {
throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`)
}
return
}
if (surfaceOp === undefined) {
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
}
if (surfaceOp !== 'append') {
if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) {
throw new Error(`session event "${type}" carries an invalid surfaceOp`)
}
const op = surfaceOp as Record<string, unknown>
const keys = Object.keys(op)
if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end')
|| op['op'] !== 'replace'
|| typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0
|| typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) {
throw new Error(`session event "${type}" carries an invalid replace surfaceOp`)
}
}
if (sourceEventSeqs !== undefined) {
if (!Array.isArray(sourceEventSeqs)
|| sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) {
throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`)
}
}
}
/** Validate the fixed event envelope after one-pass JSON materialization. */
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
const event = value
const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs'])
if (Object.keys(event).some(key => !allowed.has(key))
|| !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string'
|| !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number'
|| !Number.isSafeInteger(event['seq']) || event['seq'] < 0
|| !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number'
|| !Number.isSafeInteger(event['time']) || event['time'] < 0
|| !Object.hasOwn(event, 'data')) {
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
}
type SessionCallback = (...args: unknown[]) => unknown
/** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback[] {
return [...ctx.events.dispatch('emit', args)] as SessionCallback[]
}
/** Invoke one resolved observe-only listener snapshot with per-listener containment. */
function invokeContainedSessionObservers(
ctx: Context,
name: 'session/event' | 'session/disposed',
id: SessionId,
args: unknown[],
callbacks: SessionCallback[],
): void {
for (const callback of callbacks) {
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`)
})
} catch (error: unknown) {
ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(error)}`)
}
}
}
/** All mutable lifecycle state for one exact store entry. */
interface SessionEntry {
readonly id: SessionId
readonly session: Session
readonly carrier: Scoped<Session>
readonly emitCtx: Context
announced: boolean
announcing: boolean
appending: boolean
detachRequested: boolean
detach(): void
}
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
const attachments = new WeakMap<Session, SessionEntry>()
/**
* An event-sourced session: an append-only log of {@link SessionEvent}s.
*
@@ -88,8 +260,6 @@ function renderTagged(tag: string, content: ContentBlock[], source: MessageSourc
*/
export class Session {
private log: SessionEvent[] = []
/** Set by the store so appends are observable; undefined when detached. */
onAppend: ((event: SessionEvent) => void) | undefined
/**
* Derived surface — a cached linked list of message-producing events.
@@ -107,43 +277,65 @@ export class Session {
}
/**
* Immutable creation metadata (format version, cwd, lineage, seed boundary).
* Supplied by the store via `ctx.sessions.create()`. When a `Session` is
* constructed bare (tests, ad-hoc replay), a minimal header is synthesized
* (stamped with the current {@link SESSION_FORMAT_VERSION}) so
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
* `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
* `session.header` is always present. Kept out of the event log — it is a
* storage concern, not replayable conversation state.
*/
readonly header: SessionHeader
constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) {
constructor(public readonly id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
if (seed) {
// Validate seed JSON and contiguous sequence numbers just as append would.
seed.forEach((event, index) => {
if (event.seq !== index) {
throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`)
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
// live log that no persistence backend could store: each event's `data`
// must be JSON-serializable, and `seq` must be contiguous from 0 (the
// `seq = log.length` contract the whole system relies on). Without this,
// a bad seed would surface only later as a backend rejection or a silent
// divergence between the live log and disk.
this.log = Array.from(seed, (source, index) => {
// The seed is a persistence/replay boundary: validate and detach the
// complete event in one lossless-JSON pass.
const snapshot = snapshotJsonValue(source)
if (snapshot === undefined) {
throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
}
if (!isJsonValue(event.data)) {
throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`)
assertSessionEventEnvelope(snapshot, index)
if (snapshot.seq !== index) {
throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
}
// Seed events bypass append's overloads, so enforce surface markers at runtime.
if (isSurfaceEligibleType(event.type)
&& (event as SessionEvent<SurfaceEventType>).surfaceOp === undefined) {
throw new Error(`seed event "${event.type}" (seq ${event.seq}) is surface-eligible but carries no surfaceOp marker`)
// Surface-eligible events MUST carry a surfaceOp marker — the surface is
// the sole source of derived history, so a marker-less message event
// would load fine yet vanish from deriveMessages(). `append` enforces
// this at compile time via its typed overload; a seed arrives as raw
// SessionEvent[] (replay/fork/load), bypassing that, so re-check at
// runtime here rather than silently resuming with empty history.
const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
try {
assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs)
} catch (error: unknown) {
throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
}
return deepFreeze(snapshot)
})
// Clone seed events so callers cannot mutate the durable log after validation.
this.log = seed.map(event => structuredClone(event))
}
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
this.header = snapshotSessionHeader(id, header)
}
/** Cached immutable public snapshot of the private append-only log. */
private eventsSnapshot: readonly SessionEvent[] | undefined
/**
* The append-only event log, exposed live by reference (readonly-typed, not
* a snapshot): later appends are visible through the same array.
* An immutable snapshot of the append-only event log. The snapshot is reused
* until the next append; a previously returned array does not grow later.
* Events and their nested data are deep-frozen at acceptance, so neither a
* cast nor ordinary JavaScript can rewrite durable history.
*/
get events(): readonly SessionEvent[] {
return this.log
this.eventsSnapshot ??= Object.freeze([...this.log])
return this.eventsSnapshot
}
/** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
@@ -152,43 +344,91 @@ export class Session {
}
/**
* Append one typed event to the log and synchronously notify observers via `onAppend`. The
* hot path never blocks on I/O — persistence plugins buffer asynchronously.
* Append one typed event to the log and synchronously notify observers via
* the store-owned, module-private publication hooks. The hot path never blocks
* on I/O — persistence plugins buffer asynchronously. Once the event enters
* the log, the append is committed: observer failures are logged and
* contained per listener, so they do not change the return value or prevent
* later listeners from observing the same accepted event.
*
* @param type - The event type (key of {@link SessionEventMap}).
* @param data - The event payload; must be JSON-serializable.
* @param opts - required surface placement and optional provenance for message-producing events.
* @returns the event with assigned sequence, time, and snapshotted data.
* @throws if data is not losslessly JSON-serializable or surface placement is missing.
* @param opts - Surface metadata: `surfaceOp` controls how the event enters
* the surface linked list; `sourceEventSeqs` records provenance (the seq
* numbers of events this one derives from). REQUIRED for
* {@link SurfaceEventType} events (every message-producing event must
* declare how it joins the surface, the sole source of derived history) and
* rejected by the compiler for non-surface types like `turn/start` or
* `assistant/chunk`.
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
* `data` that entered the log, so reading `event.data` back sees the logged
* value, never the caller's still-mutable input.
* @throws if `type` is not a string, or if `data` or surface metadata is not
* losslessly JSON-serializable
* (BigInt, function, symbol, undefined, negative zero, non-finite number,
* circular reference, sparse array, or an exotic object such as
* Map/Set/Date/class instance). One recursive pass reads, validates, and
* copies each nested value once, so a stateful getter cannot supply one value
* to validation and another to storage. The event log is the durable source
* of truth, so a bad event fails at the append site rather than later during
* a backend flush. A synchronous internal dispatch validation failure or an
* append reentered while this acceptance/publication boundary is open also
* rejects before the log changes.
*/
append<T extends SessionEventType>(
type: T,
data: SessionEventMap[T],
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
): SessionEvent<T> {
if (!isJsonValue(data)) {
const surfaceOpts: SurfaceIntent | undefined = opts[0]
const surfaceMetadata = {
...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
}
const dataSnapshot = snapshotJsonValue(data)
if (dataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
}
const surfaceOpts: SurfaceIntent | undefined = opts[0]
// Recheck the conditional overload when `T` has widened to the full union.
if (isSurfaceEligibleType(type) && surfaceOpts?.surfaceOp === undefined) {
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
if (surfaceMetadataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
}
// Snapshot caller-owned data and metadata before they enter durable history.
// The generic conditional spreads require an internal union-boundary cast.
const event = {
assertSurfaceMetadataShape(
type,
seq: this.log.length,
time: Date.now(),
data: structuredClone(data),
...surfaceOpts?.sourceEventSeqs !== undefined ? { sourceEventSeqs: [...surfaceOpts.sourceEventSeqs] } : {},
...surfaceOpts?.surfaceOp !== undefined ? {
surfaceOp: typeof surfaceOpts.surfaceOp === 'string' ? surfaceOpts.surfaceOp : structuredClone(surfaceOpts.surfaceOp),
} : {},
} as unknown as SessionEvent<T>
this.log.push(event as unknown as SessionEvent)
this.onAppend?.(event as unknown as SessionEvent)
return event
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
)
const entry = attachments.get(this)
if (entry?.appending) {
throw new Error('session append cannot reenter while another append is being published')
}
if (entry !== undefined) entry.appending = true
try {
const event = deepFreeze({
type,
seq: this.log.length,
time: Date.now(),
data: dataSnapshot,
...surfaceMetadataSnapshot,
} as unknown as SessionEvent<T>)
let callbacks: SessionCallback[] | undefined
const callbackArgs: unknown[] = [this, event]
if (entry !== undefined) {
callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs])
}
this.log.push(event as SessionEvent)
this.eventsSnapshot = undefined
if (callbacks !== undefined && entry !== undefined) {
invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks)
}
return event
} finally {
if (entry !== undefined) {
entry.appending = false
if (entry.detachRequested && !entry.announcing) entry.detach()
}
}
}
/** Cached fold of the request-header events — see {@link requestHeader}. */
@@ -224,9 +464,21 @@ export class Session {
private derivedGeneration = 0
/**
* Derive the LLM message history by walking the session surface — the linked list of
* message-producing events maintained by `surfaceOp` markers.
* Derive the LLM message history by walking the session surface — the linked
* list of message-producing events maintained by `surfaceOp` markers. The
* surface is the single source of derived history: every message-producing
* append records its `surfaceOp`, so a raw event with no marker (a chunk, a
* turn boundary) is correctly absent, and a compaction `replace` deletes the
* shadowed nodes from the derivation. The projection rules are
* {@link deriveEventMessage}, folded per node.
*
* CACHED: each surface node is projected exactly once, when first seen — a
* call costs O(new nodes), and a surface rewrite (a `replace`;
* {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is
* a fresh snapshot per call (later appends never grow an array a caller
* already holds); the `Message` objects in it are SHARED and **deep-frozen**.
* Their content reuses the already frozen durable event data, so the cache
* needs no second deep clone and consumers still cannot mutate the log.
* @returns a fresh array of the shared, frozen derived history.
*/
deriveMessages(): Message[] {
@@ -252,10 +504,16 @@ export class Session {
}
/**
* Project a single event into the LLM message it derives to, or null when it produces none —
* a non-surface event (chunk, boundary, log-only record) or an empty-content
* assistant/message (which exists only to host usage).
*
* Project a single event into the LLM message it derives to, or null when
* it produces none — a non-surface event (chunk, boundary, log-only record)
* or an empty-content assistant/message (which exists only to host usage).
* The per-node pure function {@link deriveMessages} folds over the surface;
* an external reconstructor (or the dev invariant) folds the same function
* over a log prefix's surface to rebuild the exact messages any request was
* built from (the reconstructability RFC). The returned message wrapper is
* fresh; its content reuses the logged event's already deep-frozen durable
* data, so changing the wrapper cannot rewrite the log and changing content
* throws.
* @param event - the event to project.
* @returns the derived message, or null when the event produces none.
*/
@@ -266,29 +524,29 @@ export class Session {
switch (event.type) {
case 'user/message': {
return { role: 'user', content: structuredClone(event.data.content) }
return { role: 'user', content: event.data.content }
}
case 'assistant/message': {
// Skip an empty-content assistant/message: it exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
// turn into the provider transcript.
if (event.data.content.length === 0) return null
return { role: 'assistant', content: structuredClone(event.data.content) }
return { role: 'assistant', content: event.data.content }
}
case 'tool/result': {
const { callId, content, isError } = event.data
return {
role: 'user',
content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }],
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
}
}
case 'context/message': {
const { content, source } = event.data
return { role: 'user', content: renderTagged('context', structuredClone(content), source) }
return { role: 'user', content: renderTagged('context', content, source) }
}
case 'steering/message': {
const { content, source } = event.data
return { role: 'user', content: renderTagged('steering', structuredClone(content), source) }
return { role: 'user', content: renderTagged('steering', content, source) }
}
default:
// A non-surface event (boundary, chunk, log-only record) projects to
@@ -331,15 +589,7 @@ export class SessionForkError extends Error {
* subscribe to `session/event` and flush on `session/flush` / dispose.
*/
export class SessionStore extends Service {
private store = new Map<SessionId, Session>()
/**
* Each live session's dispatch carrier, captured at {@link enter} from the
* ENTERING context's scope tag (an agent session is entered through
* `agent.ctx` ⇒ its events dispatch in that agent's scope; a bare session ⇒
* subject-less carrier). WeakMap so a detached session drops its carrier
* with the entry.
*/
private carriers = new WeakMap<Session, Scoped<Session>>()
private store = new Map<SessionId, SessionEntry>()
private counter = 0
constructor(ctx: Context) {
@@ -347,15 +597,32 @@ export class SessionStore extends Service {
}
/**
* Create, enter, and announce a session owned by the calling fiber.
* Create a session owned by the calling fiber: disposing that fiber stops
* event notification and removes the session from the store. `options.seed`
* populates the session with a copy of those events (replay/fork);
* `options.meta` attaches creation metadata (validated absolute `cwd`,
* `parentSession` lineage) as the immutable {@link SessionHeader} (the store
* fills `version`/`id`/`createdAt`).
*
* For an agent whose session must be torn down IN ORDER with its loop (so the
* loop's final flush is captured before the store attachment ends), do NOT use this
* — fold the session lifecycle into the agent's own effect via
* {@link prepare} + {@link enter} + {@link announce} (see
* `dsh-agent-loop`'s creation transaction).
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - optional seed and header metadata.
* @param options - seed events and/or creation metadata for the header.
* @returns the live session, already entered and announced.
* @throws if the id exists or cwd is not absolute.
* @throws if a session with `id` already exists, metadata is not a plain
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
* non-absolute path (storage backends key directories off it).
*/
create(id?: SessionId, options?: CreateSessionOptions): Session {
const session = this.prepare(id, options)
// Yield detach before announcement so listener failure rolls back entry.
// Single effect owned by the calling fiber. Yield the detach BEFORE
// announcing so a throwing `session/created` listener rolls the attach back
// (the generator effect disposes already-yielded disposers on a throw)
// instead of leaking the store entry and its publication hooks.
this.ctx.effect(function* (this: SessionStore) {
yield this.enter(session)
this.announce(session)
@@ -364,68 +631,159 @@ export class SessionStore extends Service {
}
/**
* Build a session WITHOUT entering it into the store — validate the id/cwd and construct the
* {@link Session} (with its immutable {@link SessionHeader}).
* Build a session WITHOUT entering it into the store — validate the id/cwd and
* construct the {@link Session} (with its immutable {@link SessionHeader}).
* Pairs with {@link enter} + {@link announce}: a caller that owns a composite
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
* effect so a fiber unload tears the session + agent down as a single ORDERED
* chain rather than as racing sibling effects — which would remove the publication hooks
* before the loop's closing `session/flush`, dropping the closing events.
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
* @returns the constructed session, NOT yet in the store.
* @throws if a session with `id` already exists, or if `meta.cwd` is a
* @throws if a session with `id` already exists, metadata is not a plain
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
* non-absolute path.
*/
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
const sessionId = SessionId(id ?? `session-${++this.counter}`)
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
const cwd = options?.meta?.cwd
if (cwd !== undefined && !isAbsolute(cwd)) {
throw new Error(`session cwd must be an absolute path, got "${cwd}"`)
let sessionId: SessionId
if (id === undefined) {
do sessionId = SessionId(`session-${++this.counter}`)
while (this.store.has(sessionId))
} else {
sessionId = SessionId(id)
}
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
const seed = options?.seed
const meta = options?.meta
const header: SessionHeader = {
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: options?.meta?.createdAt ?? Date.now(),
...cwd !== undefined ? { cwd } : {},
...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
...options?.meta?.seedLength !== undefined ? { seedLength: options.meta.seedLength } : {},
createdAt: meta?.createdAt ?? Date.now(),
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
}
return new Session(sessionId, options?.seed, header)
return new Session(sessionId, seed, header)
}
/**
* Enter a {@link prepare}d session into the store: wire `onAppend` → `session/event` and
* add it to the store.
* Enter a {@link prepare}d session into the store: install the module-private
* append publication hooks and add it to the store. Returns the DETACH
* disposer (hooks + store removal). Does NOT emit `session/created` —
* the caller yields this disposer inside its effect and THEN calls
* {@link announce}, so a throwing `session/created` listener rolls the attach
* back instead of leaking it.
*
* Re-checks the id for a duplicate: `prepare` and `enter` are public
* cross-package primitives and a caller may interleave arbitrary work (or
* another create) between them, so a stale prepared session must NOT overwrite
* a live store entry of the same id — its detach disposer would later delete
* the REAL session. The {@link create} convenience and the agent factory call
* the two back-to-back so they never trip this, but the public seam cannot
* assume that.
*
* @param session - a {@link prepare}d session not yet in the store.
* @returns the detach disposer (`onAppend = undefined` + store removal).
* @returns the detach disposer (publication hooks + store removal). When called from
* a synchronous `session/created` listener, removal and disposal wait until
* that creation dispatch unwinds.
* @throws if a session with this id is already in the store.
*/
enter(session: Session): () => void {
if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`)
// The carrier is decided HERE, once, from the ENTERING context's scope tag (`this.ctx` is
// the caller's context — the tracker mechanism): every session/created|event|flush dispatch
// for this session uses it, so the session's whole event feed is scope-filtered
// consistently.
const id = session.id
const carrier = scopeTarget(session, scopeOf(this.ctx))
this.carriers.set(session, carrier)
const emitCtx = this.ctx
session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
this.store.set(session.id, session)
// This is the authoritative collision boundary after arbitrary unpublished
// preparation. Only one exact same-id transaction can publish.
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
if (attachments.has(session)) throw new Error(`session "${id}" is already attached to a store`)
const entry: SessionEntry = {
id,
session,
carrier,
emitCtx: this.ctx,
announced: false,
announcing: false,
appending: false,
detachRequested: false,
detach: () => { this.detachEntered(entry) },
}
this.store.set(id, entry)
attachments.set(session, entry)
let entered = true
return () => {
const detach = (): void => {
if (!entered) return
entered = false
session.onAppend = undefined
this.carriers.delete(session)
this.store.delete(session.id)
// A lifecycle listener may own the advanced detach capability. Keep the
// entry and its publication hooks live until synchronous creation or append
// publication unwinds, then publish the paired disposal edge.
if (entry.announcing || entry.appending) {
entry.detachRequested = true
return
}
entry.detach()
}
return detach
}
/** Remove one exact entered session and emit its paired disposal when announced. */
private detachEntered(entry: SessionEntry): void {
entry.detachRequested = false
// A stale capability cannot remove observers or storage belonging to a
// later same-id lifecycle.
/* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
if (this.store.get(entry.id) !== entry) return
this.store.delete(entry.id)
attachments.delete(entry.session)
if (entry.announced) this.emitDisposed(entry)
}
/** Emit `session/created` exactly once for an {@link enter}ed session (with
* the carrier {@link enter} captured). Separate from {@link enter} so the
* caller can yield the detach disposer first (rollback safety — see
* {@link enter}).
* @param session - the entered session to announce to listeners.
* @throws if the session is not live or its announcement already began,
* including a reentrant call from a creation listener. */
announce(session: Session): void {
const entry = this.liveEntryFor(session)
if (entry.announced || entry.announcing) {
throw new Error(`session "${entry.id}" was already announced`)
}
// Mark before emit: Cordis emit may deliver to earlier listeners and then
// throw. Rollback must still pair that partial creation with disposal, and
// a listener cannot recursively create a second lifecycle edge.
entry.announced = true
const callbackArgs: unknown[] = [session]
entry.announcing = true
try {
const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/created', session])
for (const callback of callbacks) {
// Synchronous throws intentionally propagate and veto publication; the
// yielded detach then emits the paired disposal edge. An async function
// is nevertheless assignable to a void listener, so observe its returned
// promise: rejection is too late to roll back and must be logged instead
// of becoming unhandled.
const returned: unknown = callback(...callbackArgs)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`)
})
}
} finally {
entry.announcing = false
if (entry.detachRequested && !entry.appending) entry.detach()
}
}
/** Emit `session/created` for an {@link enter}ed session (with the carrier
* {@link enter} captured). Separate from {@link enter} so the caller can
* yield the detach disposer first (rollback safety — see {@link enter}).
* @param session - the entered session to announce to listeners. */
announce(session: Session): void {
this.ctx.emit(this.liveCarrierFor(session), 'session/created', session)
/** Emit the paired teardown notification with per-listener containment. */
private emitDisposed(entry: SessionEntry): void {
const callbackArgs: unknown[] = [entry.session]
try {
const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/disposed', entry.session])
invokeContainedSessionObservers(this.ctx, 'session/disposed', entry.id, callbackArgs, callbacks)
} catch (error: unknown) {
this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`)
}
}
/**
@@ -436,26 +794,34 @@ export class SessionStore extends Service {
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
* scoped-dispatch invariant can pin it.
* @param session - the session whose buffered events must reach durable storage.
* @returns resolves when every flush listener has settled; rejects if one rejects.
* @returns resolves when every flush listener has settled; after all settle,
* rejects with the first registered listener failure if any listener failed.
*/
async flush(session: Session): Promise<void> {
await this.ctx.parallel(this.liveCarrierFor(session), 'session/flush', session)
const { carrier } = this.liveEntryFor(session)
const callbackArgs: unknown[] = [session]
const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
const results = await Promise.allSettled(callbacks.map((callback) => {
try {
return callback(...callbackArgs)
} catch (error: unknown) {
// Preserve the listener's exact rejection value; flush is a caller-owned
// failure boundary, and Cordis listeners may throw arbitrary values.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
return Promise.reject(error)
}
}))
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
if (failure !== undefined) throw failure.reason
}
/** Return the exact live session's carrier; detached/prepared objects reject. */
private liveCarrierFor(session: Session): Scoped<Session> {
if (this.store.get(session.id) !== session) {
/** Return the exact live entry; detached/prepared objects reject. */
private liveEntryFor(session: Session): SessionEntry {
const entry = attachments.get(session)
if (entry === undefined || this.store.get(entry.id) !== entry) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const carrier = this.carriers.get(session)
// enter() installs store + carrier in one synchronous sequence; a live
// session without one is an internal invariant violation, never fallback
// to subject-less dispatch (that would silently cross scope boundaries).
/* v8 ignore next -- enter installs store and carrier in one synchronous sequence */
if (carrier === undefined) {
throw new Error(`session "${session.id}" has no dispatch carrier`)
}
return carrier
return entry
}
/**
@@ -464,7 +830,7 @@ export class SessionStore extends Service {
* @returns the session, or undefined when no live session has that id.
*/
get(id: SessionId): Session | undefined {
return this.store.get(id)
return this.store.get(id)?.session
}
/**
@@ -472,16 +838,18 @@ export class SessionStore extends Service {
* @returns a fresh array; mutating it does not affect the store.
*/
list(): Session[] {
return [...this.store.values()]
return [...this.store.values()].map(entry => entry.session)
}
/**
* Create a live child session from a turn-enclosed prefix of a live source. `boundary` is
* an inclusive source event seq; omitted means the source's current last event.
* Create a live child session from a turn-enclosed prefix of a live source.
* `boundary` is an inclusive source event seq; omitted means the source's
* current last event. A non-empty selected slice must end at `turn/end`.
*
* @param source - Live source session object or id.
* @param boundary - Inclusive source event seq to fork through; omitted means the
* source's current last event, and omitted on an empty source forks an empty child.
* @param boundary - Inclusive source event seq to fork through; omitted means
* the source's current last event, and omitted on an empty source forks an
* empty child.
* @param childSessionId - Optional child session id; omitted delegates to
* `SessionStore`'s id policy.
* @returns The created live child session.
@@ -540,7 +908,7 @@ export class SessionStore extends Service {
)
}
return events.slice(0, boundary + 1).map(event => structuredClone(event))
return events.slice(0, boundary + 1)
}
private _resolveForkSource(source: SessionForkSource): Session {

View File

@@ -1,22 +1,127 @@
/**
* JSON-serializability validation for session event data.
* Lossless-JSON validation and snapshot materialization for session data.
*
* The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every
* `event.data` must round-trip losslessly through JSON so any persistence
* backend can store and reload it byte-identically. This invariant belongs to
* the log itself — `Session.append` enforces it at the source, so a
* non-serializable event never enters `session.events` and the live log can
* never diverge from what a backend can persist. Other public boundaries use
* {@link snapshotJsonValue} when they must validate and detach in one pass;
* {@link isJsonValue} remains the non-copying structural predicate.
*
* @module @deepseek-ai/dsh-session/json
*/
/**
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite
* number, a string, an array of such values, or a plain object whose values are
* such values. The static type companion to {@link isJsonValue} (which validates
* the same shape at runtime). Use it to type a payload that must survive
* session-log persistence and replay byte-identically — e.g. a tool's private
* presentation `meta`.
* number other than negative zero, a string, an array of such values, or a
* plain object whose values are such values. TypeScript cannot distinguish
* `-0` from `number`, so {@link isJsonValue} and {@link snapshotJsonValue}
* enforce that last numeric detail at runtime. Use this type for a payload that
* must survive session-log persistence and replay byte-identically — e.g. a
* tool's private presentation `meta`.
*/
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
/**
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers, booleans,
* strings, plain arrays, and plain objects of such values.
* Materialize one detached lossless-JSON snapshot in a SINGLE recursive pass.
* Each array slot or own enumerable string-keyed object value is read exactly
* once, validated, and copied immediately. This is intentionally not
* `isJsonValue(value)` followed by `structuredClone(value)`: a stateful getter
* could return plain JSON to the check and an exotic class instance to the
* clone, whose prototype `structuredClone` would erase before a later check.
*
* Accepts the same scalar/object vocabulary as {@link isJsonValue}: arrays use
* the ordinary `Array.prototype` (subclass instances are not plain JSON
* containers), while null-prototype objects are accepted and normalized to
* ordinary plain objects. Sparse arrays, cycles, negative zero, non-finite
* numbers, unsupported scalar types, and exotic object or array shells return
* `undefined`. A throwing getter is a caller failure and propagates unchanged.
*
* @param value - the candidate value to validate and detach.
* @returns the detached snapshot, or `undefined` when the value is not
* losslessly JSON-serializable.
*/
export function snapshotJsonValue<T>(value: T): T | undefined {
const ancestors = new Set<object>()
const visit = (current: unknown): JsonValue | undefined => {
if (current === null) return null
switch (typeof current) {
case 'boolean':
case 'string':
return current
case 'number':
return Number.isFinite(current) && !Object.is(current, -0) ? current : undefined
case 'bigint':
case 'function':
case 'symbol':
case 'undefined':
return undefined
case 'object':
break
}
if (ancestors.has(current)) return undefined
ancestors.add(current)
try {
if (Array.isArray(current)) {
if (Object.getPrototypeOf(current) !== Array.prototype) return undefined
const length = current.length
const snapshot: JsonValue[] = []
for (let index = 0; index < length; index++) {
if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined
const item = visit(current[index])
if (item === undefined) return undefined
snapshot.push(item)
}
return snapshot
}
const prototype = Object.getPrototypeOf(current) as unknown
if (prototype !== Object.prototype && prototype !== null) return undefined
const snapshot: { [key: string]: JsonValue } = {}
for (const key of Object.keys(current)) {
const item = visit((current as Record<string, unknown>)[key])
if (item === undefined) return undefined
// Define the key as data so a JSON field literally named "__proto__"
// cannot mutate the snapshot's prototype through ordinary assignment.
Object.defineProperty(snapshot, key, {
value: item,
enumerable: true,
configurable: true,
writable: true,
})
}
return snapshot
} finally {
ancestors.delete(current)
}
}
return visit(value) as T | undefined
}
/**
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers
* other than negative zero, booleans, strings, plain arrays, and plain objects
* of such values. Rejects `BigInt`, function, symbol, `undefined`, `-0` (which
* JSON rewrites to `0`), non-finite numbers (`NaN`/`Infinity`, which JSON turns
* into `null`), and exotic objects (`Map`/`Set`/`Date`/class instances) —
* anything `JSON.stringify` would drop, throw on, or convert lossily. Sparse
* arrays are rejected too: a hole serializes to `null`, so `[1, , 3]` would not
* round-trip. Detects circular references (which would throw) and reports them
* as non-serializable rather than propagating the throw.
*
* Scope — this is a structural plain-data predicate, not an invocation of
* `JSON.stringify`: only an object's OWN ENUMERABLE STRING-keyed properties are
* inspected (`Object.values`). Symbol-keyed and non-enumerable properties are
* omitted from the durable data surface. Custom `toJSON` behavior is not
* executed; boundaries that persist a value first materialize a new plain-data
* record with {@link snapshotJsonValue}. Getters are invoked during this check,
* so callers that need a stable detached value use that one-pass materializer
* instead of checking and then rereading a side-effecting record.
* @param value - the candidate event data to test.
* @param seen - objects on the current descent path, for circular-reference
* detection; the recursion threads it — callers omit it.
@@ -29,7 +134,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
case 'string':
return true
case 'number':
return Number.isFinite(value)
return Number.isFinite(value) && !Object.is(value, -0)
case 'bigint':
case 'function':
case 'symbol':
@@ -43,6 +148,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
seen.add(value)
try {
if (Array.isArray(value)) {
if (Object.getPrototypeOf(value) !== Array.prototype) return false
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
// lossily. Require every index 0..length-1 to be an OWN property.

View File

@@ -22,6 +22,9 @@ export const SESSION_FORMAT_VERSION = 0
/**
* Immutable session metadata — written once at creation and never rewritten.
* {@link Session} enforces that contract at runtime: it validates and detaches
* the accepted scalar fields, requires this header's id to match the session
* id, and deep-freezes the published record.
*
* Kept SEPARATE from the event log deliberately: format-version, cwd, and
* lineage are storage concerns, not conversation events, so they stay out of
@@ -35,20 +38,20 @@ export interface SessionHeader {
* session is created. A persistence backend rejects any other version on load
* (no migration — see the constant).
*/
version: number
readonly version: number
/** The session's id (mirrors the {@link Session}'s id). */
id: SessionId
readonly id: SessionId
/** Unix epoch milliseconds when the session was created. */
createdAt: number
readonly createdAt: number
/** Absolute working directory the session was created in (if any). */
cwd?: string
readonly cwd?: string
/** The session this one was forked from (seed lineage), if any. */
parentSession?: SessionId
readonly parentSession?: SessionId
/**
* How many leading events were INHERITED via a seed rather than produced by this session —
* the seed boundary.
*/
seedLength?: number
readonly seedLength?: number
}
/**
@@ -58,9 +61,10 @@ export interface SessionHeader {
*/
export interface CreateSessionOptions {
/** Events to seed the new session with (replay/fork). */
seed?: SessionEvent[]
readonly seed?: readonly SessionEvent[]
/**
* Creation metadata. The store fills in `version`/`id` and defaults
* Creation metadata. The store reads this plain record and each accepted
* field once, then fills in `version`/`id` and defaults
* `createdAt` to now; the caller supplies the storage-level fields (validated
* absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and
* — when reconstructing a persisted session — the original `createdAt` to
@@ -71,7 +75,12 @@ export interface CreateSessionOptions {
* length, not the original boundary — the caller must pass the persisted
* boundary back. A fresh fork passes its actual seeded-prefix length.
*/
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number }
readonly meta?: {
readonly cwd?: string
readonly parentSession?: SessionId
readonly createdAt?: number
readonly seedLength?: number
}
}
/**

View File

@@ -78,15 +78,15 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
})
it('clones content off the log: the projection never aliases the logged event', () => {
it('reuses the logged event\'s already frozen content', () => {
const session = new Session(SessionId('per-event-clone'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const message = session.deriveEventMessage(event)!
expect(message.content).not.toBe(event.data.content)
// deriveEventMessage returns an unfrozen clone (the cache freezes ITS
// copies); mutating it must not reach the log.
;(message.content[0] as { text: string }).text = 'mutated'
expect(message.content).toBe(event.data.content)
expect(Object.isFrozen(message.content)).toBe(true)
expect(Object.isFrozen(message.content[0])).toBe(true)
expect(() => { (message.content[0] as { text: string }).text = 'mutated' }).toThrow()
expect(session.deriveMessages().at(-1)!.content).toEqual([{ type: 'text', text: 'orig' }])
})

View File

@@ -60,7 +60,7 @@ describe('SessionStore.fork', () => {
})
})
it('forks the latest completed boundary by default and deep-clones seed events', async () => {
it('forks the latest completed boundary by default into detached frozen seed events', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source, 1, 'hello')
@@ -70,8 +70,11 @@ describe('SessionStore.fork', () => {
expect(child.events).toEqual(source.events)
expect(child.events).not.toBe(source.events)
expect(child.events[1]).not.toBe(source.events[1])
firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
expect(() => {
firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
}).toThrow(TypeError)
expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
expect(firstUserMessage(child.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
expect(child.header).toMatchObject({
id: SessionId('child'),
cwd: '/workspace',

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest'
import { isJsonValue, snapshotJsonValue } from '@deepseek-ai/dsh-session'
describe('snapshotJsonValue', () => {
it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
const unsupportedFunction = (): void => {}
expect(snapshotJsonValue(null)).toBeNull()
expect(snapshotJsonValue(true)).toBe(true)
expect(snapshotJsonValue('text')).toBe('text')
expect(snapshotJsonValue(1.25)).toBe(1.25)
expect(snapshotJsonValue(-0)).toBeUndefined()
expect(isJsonValue(-0)).toBe(false)
expect(snapshotJsonValue(Number.NaN)).toBeUndefined()
expect(snapshotJsonValue(Number.POSITIVE_INFINITY)).toBeUndefined()
expect(snapshotJsonValue(1n)).toBeUndefined()
expect(snapshotJsonValue(unsupportedFunction)).toBeUndefined()
expect(snapshotJsonValue(Symbol('value'))).toBeUndefined()
const unsupportedUndefined: unknown = undefined
expect(snapshotJsonValue(unsupportedUndefined)).toBeUndefined()
})
it('recursively detaches dense arrays and plain or null-prototype objects', () => {
const shared = { value: 1 }
const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, { shared })
const source = { list: [nullPrototype, shared], alias: shared }
const snapshot = snapshotJsonValue(source)!
shared.value = 2
expect(snapshot).toEqual({ list: [{ shared: { value: 1 } }, { value: 1 }], alias: { value: 1 } })
expect(snapshot).not.toBe(source)
expect(snapshot.list).not.toBe(source.list)
expect(snapshot.alias).not.toBe(shared)
expect(snapshot.list[0]).not.toBe(nullPrototype)
expect(Object.getPrototypeOf(snapshot.list[0])).toBe(Object.prototype)
})
it('reads each object value and array slot once while materializing', () => {
class Exotic {
readonly accepted = false
}
let objectReads = 0
let arrayReads = 0
const nested = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => {
objectReads += 1
return objectReads === 1 ? { accepted: true } : new Exotic()
},
})
const array = new Array<unknown>(1)
Object.defineProperty(array, 0, {
enumerable: true,
get: () => {
arrayReads += 1
return arrayReads === 1 ? nested : new Exotic()
},
})
expect(snapshotJsonValue(array)).toEqual([{ value: { accepted: true } }])
expect(objectReads).toBe(1)
expect(arrayReads).toBe(1)
})
it('rejects exotic containers, sparse arrays, cycles, and invalid children', () => {
class ExoticObject {
readonly value = 1
}
class ExoticArray extends Array<number> {}
const sparse = new Array<number>(1)
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
expect(snapshotJsonValue(new ExoticObject())).toBeUndefined()
expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined()
expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined()
expect(snapshotJsonValue(sparse)).toBeUndefined()
expect(snapshotJsonValue(cyclic)).toBeUndefined()
expect(snapshotJsonValue([undefined])).toBeUndefined()
expect(snapshotJsonValue({ value: undefined })).toBeUndefined()
})
it('preserves a literal __proto__ JSON key without changing the snapshot prototype', () => {
const source = Object.create(null) as Record<string, unknown>
source.__proto__ = { safe: true }
const snapshot = snapshotJsonValue(source)!
expect(Object.getPrototypeOf(snapshot)).toBe(Object.prototype)
expect(Object.prototype.hasOwnProperty.call(snapshot, '__proto__')).toBe(true)
expect(snapshot.__proto__).toEqual({ safe: true })
})
it('propagates a throwing getter after reading it once', () => {
const failure = new Error('getter failed')
let reads = 0
const source = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => {
reads += 1
throw failure
},
})
expect(() => snapshotJsonValue(source)).toThrow(failure)
expect(reads).toBe(1)
})
})
describe('isJsonValue', () => {
it('recognizes supported scalars and rejects every lossy scalar case', () => {
const unsupportedFunction = (): void => {}
const unsupportedUndefined: unknown = undefined
expect(isJsonValue(null)).toBe(true)
expect(isJsonValue(false)).toBe(true)
expect(isJsonValue('text')).toBe(true)
expect(isJsonValue(1.25)).toBe(true)
expect(isJsonValue(-0)).toBe(false)
expect(isJsonValue(Number.NaN)).toBe(false)
expect(isJsonValue(1n)).toBe(false)
expect(isJsonValue(unsupportedFunction)).toBe(false)
expect(isJsonValue(Symbol('value'))).toBe(false)
expect(isJsonValue(unsupportedUndefined)).toBe(false)
})
it('accepts dense arrays and plain objects, including null-prototype records', () => {
const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, { value: true })
expect(isJsonValue([1, { nested: null }, nullPrototype])).toBe(true)
expect(isJsonValue({ value: [1, 2] })).toBe(true)
expect(isJsonValue(nullPrototype)).toBe(true)
})
it('rejects sparse arrays, invalid children, exotic objects, and cycles', () => {
class Exotic {
readonly value = 1
}
class ExoticArray extends Array<number> {}
const sparse = new Array<number>(1)
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
expect(isJsonValue(sparse)).toBe(false)
expect(isJsonValue(new ExoticArray(1))).toBe(false)
expect(isJsonValue([undefined])).toBe(false)
expect(isJsonValue({ value: undefined })).toBe(false)
expect(isJsonValue(new Exotic())).toBe(false)
expect(isJsonValue(cyclic)).toBe(false)
})
})

View File

@@ -60,6 +60,23 @@ describe('session dispatch carriers', () => {
bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(heard).toEqual(['global:turn/start'])
})
it('reuses the captured owner carrier for the paired disposal notification', async () => {
const ctx = await mount()
const owner = await mintScope(ctx, 'owner')
const other = await mintScope(ctx, 'other')
const heard: string[] = []
ctx.on('session/disposed', (session) => { heard.push(`global:${session.id}`) })
owner.ctx.on('session/disposed', (session) => { heard.push(`owner:${session.id}`) })
other.ctx.on('session/disposed', (session) => { heard.push(`other:${session.id}`) })
const session = owner.ctx.sessions.prepare()
const detach = owner.ctx.sessions.enter(session)
owner.ctx.sessions.announce(session)
detach()
expect(heard).toEqual([`global:${session.id}`, `owner:${session.id}`])
})
})
describe('sessions.flush()', () => {
@@ -91,6 +108,40 @@ describe('sessions.flush()', () => {
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
})
it('does not let a synchronous flush failure starve later listeners', async () => {
const ctx = await mount()
const flushed: Session[] = []
ctx.on('session/flush', () => { throw new Error('disk full') })
ctx.on('session/flush', (session) => { flushed.push(session) })
const session = ctx.sessions.create()
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
expect(flushed).toEqual([session])
})
it('waits for slower flush listeners before reporting another listener failure', async () => {
const ctx = await mount()
const gate = Promise.withResolvers<undefined>()
let slowStarted = false
let settled = false
ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
ctx.on('session/flush', () => {
slowStarted = true
return gate.promise
})
const session = ctx.sessions.create()
const flushing = ctx.sessions.flush(session)
void flushing.finally(() => { settled = true }).catch(() => undefined)
await Promise.resolve()
expect(slowStarted).toBe(true)
expect(settled).toBe(false)
gate.resolve(undefined)
await expect(flushing).rejects.toThrow('disk full')
expect(settled).toBe(true)
})
it('rejects a never-entered session instead of inventing a carrier', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')

View File

@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventType, TodoItem } from '@deepseek-ai/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, TodoItem } from '@deepseek-ai/dsh-session'
describe('Session', () => {
it('derives message history from the event log', () => {
@@ -150,7 +150,7 @@ describe('Session', () => {
const badSeed = [
{ type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/non-JSON-serializable/)
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
})
it('validates seed events: rejects a non-contiguous seq', () => {
@@ -171,7 +171,7 @@ describe('Session', () => {
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/surface-eligible but carries no surfaceOp/)
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
})
it('accepts a well-formed contiguous serializable seed', () => {
@@ -184,6 +184,151 @@ describe('Session', () => {
expect(session.events).toHaveLength(3)
})
it('reads each seed array entry once so validation and storage use the same event', () => {
const accepted = {
type: 'turn/start' as const,
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
}
const drifted = { ...accepted, seq: 99, data: { invalid: 1n } }
let reads = 0
const seed = new Array<SessionEvent>(1)
Object.defineProperty(seed, 0, {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? accepted : drifted
},
})
const session = new Session(SessionId('seed-entry-snapshot'), seed)
expect(reads).toBe(1)
expect(session.events).toEqual([accepted])
})
it('reads a nested seed-data getter once and stores its first JSON value', () => {
let reads = 0
const data = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? 'accepted' : 1n
},
})
const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
const session = new Session(SessionId('seed-nested-drift'), seed)
expect(reads).toBe(1)
expect(session.events[0]!.data).toEqual({ value: 'accepted' })
})
it('rejects non-JSON surface metadata in a seed event', () => {
const seed = [{
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
surfaceOp: { op: 'replace', start: 1n, end: 2 },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-bad-metadata'), seed))
.toThrow(/losslessly JSON-serializable/)
})
it('rejects exotic seed metadata before cloning can erase its prototype', () => {
class ReplaceOp {
readonly op = 'replace' as const
readonly start = 0
readonly end = 0
}
const seed = [{
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
surfaceOp: new ReplaceOp(),
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-exotic-metadata'), seed))
.toThrow(/losslessly JSON-serializable/)
})
it('rejects an exotic seed event shell before spreading erases its prototype', () => {
class SeedEvent {
readonly type = 'turn/start' as const
readonly seq = 0
readonly time = 1
readonly data = { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }
}
const seed: SessionEvent[] = [new SeedEvent()]
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
.toThrow(/not losslessly JSON-serializable/)
})
it('accepts a null-prototype seed event shell as a plain JSON record', () => {
const event = Object.assign(Object.create(null) as Record<string, unknown>, {
type: 'turn/start' as const,
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
}) as unknown as SessionEvent
const session = new Session(SessionId('seed-null-prototype'), [event])
expect(session.events).toEqual([{ ...event }])
})
it('reads a nested seed-metadata getter once and stores its first JSON value', () => {
let reads = 0
const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? 0 : 1n
},
})
const seed = [{
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
surfaceOp,
}] as unknown as SessionEvent[]
const session = new Session(SessionId('seed-unstable-metadata'), seed)
const event = session.events[0]!
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
expect(reads).toBe(1)
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
})
it('adds seed context when surface validation throws a non-Error value', () => {
const originalHasOwn = Object.hasOwn
const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => {
if ((object as Record<string, unknown>)['op'] === 'replace') throw 'validator failed'
return originalHasOwn(object, property)
})
const seed = [{
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
surfaceOp: { op: 'replace', start: 0, end: 0 },
}] as unknown as SessionEvent[]
try {
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
.toThrow('invalid seed event at index 0: invalid surface metadata')
} finally {
hasOwn.mockRestore()
}
})
it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
const seed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
@@ -216,6 +361,261 @@ describe('Session', () => {
// The returned event carries the same snapshot, not the caller's input.
expect((event.data.content[0] as { text: string }).text).toBe('original')
})
it('reads a nested append-data getter once and stores its first JSON value', () => {
const session = new Session(SessionId('append-nested-drift'))
let reads = 0
const data = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? 'accepted' : 1n
},
})
const event = session.append('todo/write', data as never)
expect(reads).toBe(1)
expect(event.data).toEqual({ value: 'accepted' })
expect(session.events).toEqual([event])
})
it('rejects non-JSON surface metadata before appending the event', () => {
const session = new Session(SessionId('append-bad-metadata'))
expect(() => session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
{ surfaceOp: { op: 'replace', start: 1n, end: 2 } } as never,
)).toThrow(/non-JSON-serializable surface metadata/)
expect(session.events).toEqual([])
})
it('rejects exotic surface metadata before cloning can erase its prototype', () => {
class ReplaceOp {
readonly op = 'replace' as const
readonly start = 0
readonly end = 0
}
const session = new Session(SessionId('append-exotic-metadata'))
expect(() => session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
{ surfaceOp: new ReplaceOp() },
)).toThrow(/non-JSON-serializable surface metadata/)
expect(session.events).toEqual([])
})
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
const session = new Session(SessionId('append-unstable-metadata'))
let reads = 0
const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? 0 : 1n
},
})
const event = session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
{ surfaceOp } as never,
)
expect(reads).toBe(1)
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
expect(session.events).toEqual([event])
})
it('rejects invalid plain surface metadata shapes at append', () => {
const session = new Session(SessionId('append-invalid-surface-shape'))
const appendRaw = session.append.bind(session) as unknown as (
type: SessionEventType,
data: unknown,
opts?: unknown,
) => SessionEvent
const data = { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }
expect(() => appendRaw('user/message', data, { surfaceOp: 'invalid' }))
.toThrow(/invalid surfaceOp/)
expect(() => appendRaw('user/message', data, {
surfaceOp: { op: 'replace', start: -1, end: 0 },
})).toThrow(/invalid replace surfaceOp/)
expect(() => appendRaw('user/message', data, {
surfaceOp: 'append',
sourceEventSeqs: [0, -1],
})).toThrow(/non-negative safe integers/)
expect(session.events).toEqual([])
})
it('rejects surface metadata on non-surface append and seed events', () => {
const session = new Session(SessionId('non-surface-metadata'))
const appendRaw = session.append.bind(session) as unknown as (
type: SessionEventType,
data: unknown,
opts?: unknown,
) => SessionEvent
expect(() => appendRaw(
'turn/start',
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
{ surfaceOp: 'append' },
)).toThrow(/not surface-eligible and cannot carry surface metadata/)
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
surfaceOp: 'append',
} as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/)
expect(session.events).toEqual([])
})
it('deep-freezes seeded and appended event snapshots', () => {
const seeded = new Session(SessionId('seed-frozen'), [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
}])
const seededEvent = seeded.events[0]!
if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
expect(Object.isFrozen(seededEvent)).toBe(true)
expect(Object.isFrozen(seededEvent.data)).toBe(true)
expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true)
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
const appended = new Session(SessionId('append-frozen'))
const appendedEvent = appended.append('todo/write', {
todos: [{ content: 'first', status: 'pending' }],
})
expect(Object.isFrozen(appendedEvent)).toBe(true)
expect(Object.isFrozen(appendedEvent.data)).toBe(true)
expect(Object.isFrozen(appendedEvent.data.todos)).toBe(true)
expect(Object.isFrozen(appendedEvent.data.todos[0])).toBe(true)
expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
})
it('returns cached frozen event-array snapshots that do not grow after append', () => {
const session = new Session(SessionId('events-snapshot'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const before = session.events
const beforeEvent = before[0]!
if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
expect(session.events).toBe(before)
expect(Object.isFrozen(before)).toBe(true)
expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError)
expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const after = session.events
expect(before).toHaveLength(1)
expect(after).toHaveLength(2)
expect(after).not.toBe(before)
expect(session.events).toBe(after)
})
it('detaches and freezes an explicitly supplied session header', () => {
const input = {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-owned'),
createdAt: 123,
cwd: '/accepted',
parentSession: SessionId('parent'),
seedLength: 2,
}
const session = new Session(SessionId('header-owned'), undefined, input)
input.cwd = '/caller-mutated'
expect(session.header).toEqual({
version: SESSION_FORMAT_VERSION,
id: 'header-owned',
createdAt: 123,
cwd: '/accepted',
parentSession: 'parent',
seedLength: 2,
})
expect(session.header).not.toBe(input)
expect(Object.isFrozen(session.header)).toBe(true)
expect(Reflect.set(session.header, 'cwd', '/published-mutated')).toBe(false)
expect(session.id).toBe('header-owned')
expect(session.header.cwd).toBe('/accepted')
})
it('rejects an exotic, non-JSON, or mismatched supplied header', () => {
class ExoticHeader implements SessionHeader {
readonly version = SESSION_FORMAT_VERSION
readonly id = SessionId('header-invalid')
readonly createdAt = 123
}
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
.toThrow(/not losslessly JSON-serializable/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-invalid'),
createdAt: 123,
parentSession: 1n,
} as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('other'),
createdAt: 123,
})).toThrow(/does not match session id/)
})
it('rejects invalid scalar fields in an explicitly supplied header', () => {
const base = {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-shape'),
createdAt: 123,
}
const cases: Array<{ header: unknown; error: RegExp }> = [
{ header: 1, error: /not a plain JSON record/ },
{ header: null, error: /not a plain JSON record/ },
{ header: { ...base, version: 1 }, error: /header version/ },
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a finite number/ },
{ header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
{ header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
{ header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
{ header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ header: { ...base, seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
]
for (const { header, error } of cases) {
expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
}
})
it('rejects seed records with invalid fixed-envelope fields', () => {
const base = {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
}
const cases: unknown[] = [
{ ...base, extra: true },
{ ...base, type: 1 },
{ ...base, seq: '0' },
{ ...base, seq: 0.5 },
{ ...base, seq: -1 },
{ ...base, time: '1' },
{ ...base, time: 0.5 },
{ ...base, time: -1 },
{ type: base.type, seq: base.seq, time: base.time },
]
for (const [index, event] of cases.entries()) {
expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
.toThrow(/invalid event envelope/)
}
})
})
@@ -232,6 +632,10 @@ describe('SessionStore', () => {
const session = ctx.sessions.create()
expect(created).toEqual([session])
// The store-owned append publication hooks are module-private. A JavaScript caller
// may create an unrelated property with the old implementation's name,
// but cannot suppress the durable event feed.
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(events).toHaveLength(1)
expect(events[0]![0]).toBe(session)
@@ -285,6 +689,93 @@ describe('SessionStore', () => {
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
})
it('prevents simultaneous attachment of one session object to two stores', async () => {
const firstCtx = new Context()
const secondCtx = new Context()
await firstCtx.plugin(SessionStore)
await secondCtx.plugin(SessionStore)
const session = new Session(SessionId('owned-key'))
const detachFirst = firstCtx.sessions.enter(session)
expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
expect(firstCtx.sessions.get(SessionId('owned-key'))).toBe(session)
detachFirst()
expect(firstCtx.sessions.get(SessionId('owned-key'))).toBeUndefined()
const detachSecond = secondCtx.sessions.enter(session)
expect(secondCtx.sessions.get(SessionId('owned-key'))).toBe(session)
detachSecond()
})
it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let created = 0
let disposed = 0
let reentrantError = ''
ctx.on('session/created', (session) => {
created += 1
try {
ctx.sessions.announce(session)
} catch (error: unknown) {
reentrantError = String(error)
}
})
ctx.on('session/disposed', () => { disposed += 1 })
const session = ctx.sessions.prepare(SessionId('once'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
expect(reentrantError).toMatch(/already announced/)
expect(() => { ctx.sessions.announce(session) }).toThrow(/already announced/)
detach()
expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
})
it('defers a reentrant detach until the creation dispatch unwinds', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const order: string[] = []
const session = ctx.sessions.prepare(SessionId('reentrant-detach'))
const detach = ctx.sessions.enter(session)
ctx.on('session/created', (created) => {
order.push('created:first')
detach()
expect(ctx.sessions.get(created.id)).toBe(created)
})
ctx.on('session/created', (created) => {
order.push('created:second')
expect(ctx.sessions.get(created.id)).toBe(created)
})
ctx.on('session/disposed', (disposed) => {
order.push('disposed')
expect(ctx.sessions.get(disposed.id)).toBeUndefined()
})
ctx.sessions.announce(session)
expect(order).toEqual(['created:first', 'created:second', 'disposed'])
expect(ctx.sessions.get(session.id)).toBeUndefined()
detach()
})
it('rolls back create when its owner unloads from session/created', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let ownerCtx!: Context
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['sessions'] }))
const id = SessionId('create-unload-race')
ctx.on('session/created', (session) => {
if (session.id === id) void owner.dispose()
})
ownerCtx.sessions.create(id)
await owner.dispose()
expect(ctx.sessions.get(id)).toBeUndefined()
})
it('synthesizes a minimal current-version header for a bare-created session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -309,6 +800,26 @@ describe('SessionStore', () => {
})
})
it('rejects non-JSON and invalid scalar session metadata', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const cases: Array<{ meta: unknown; error: RegExp }> = [
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
{ meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ },
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
]
for (const [index, { meta, error }] of cases.entries()) {
expect(() => ctx.sessions.prepare(SessionId(`bad-meta-${index}`), {
meta: meta as NonNullable<CreateSessionOptions['meta']>,
})).toThrow(error)
}
})
it('rejects a non-absolute meta.cwd', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -343,11 +854,13 @@ describe('SessionStore', () => {
expect(observed).toBe(0)
})
it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => {
it('pairs a partial session/created announcement with disposal during rollback', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let threw = false
const disposed: Session[] = []
ctx.on('session/disposed', (session) => { disposed.push(session) })
ctx.on('session/created', () => {
if (!threw) { threw = true; throw new Error('boom created listener') }
})
@@ -355,9 +868,10 @@ describe('SessionStore', () => {
// The throwing emit must roll the store entry back, not leak it.
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener')
expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked
expect(disposed.map(session => session.id)).toEqual(['fixed'])
// A subsequent create of the SAME id succeeds (the already-exists check is
// not wedged) and its onAppend is correctly wired (events observable).
// not wedged) and its store-owned publication hooks are correctly wired.
const events: SessionEvent[] = []
ctx.on('session/event', (_session, event) => void events.push(event))
const session = ctx.sessions.create(SessionId('fixed'))
@@ -365,6 +879,243 @@ describe('SessionStore', () => {
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(events).toHaveLength(1)
})
it('contains session/event observer failures after the append commit point', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const session = ctx.sessions.create(SessionId('contained-event'))
const heard: SessionEvent[] = []
let committedBeforeNotify = false
ctx.on('session/event', (observedSession, event) => {
committedBeforeNotify = observedSession.events.at(-1) === event
throw new Error('sync event observer')
})
ctx.on('session/event', () => Promise.reject(new Error('async event observer')) as never)
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
let appended!: SessionEvent
expect(() => {
appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
}).not.toThrow()
expect(committedBeforeNotify).toBe(true)
expect(session.events).toEqual([appended])
expect(heard).toEqual([appended])
await Promise.resolve()
await Promise.resolve()
expect(warnings).toEqual([
'session "contained-event": session/event listener threw: Error: sync event observer',
'session "contained-event": session/event listener rejected: Error: async event observer',
])
})
it('runs internal dispatch validation on one frozen candidate before commit and resets after a veto', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('dispatch-veto'))
const validations: Array<{ event: SessionEvent; logLength: number; frozen: boolean }> = []
const observed: SessionEvent[] = []
let reject = true
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const [observedSession, event] = args as [Session, SessionEvent]
validations.push({
event,
logLength: observedSession.events.length,
frozen: Object.isFrozen(event) && Object.isFrozen(event.data),
})
if (reject) {
reject = false
throw new Error('reject first candidate')
}
})
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('reject first candidate')
expect(session.events).toEqual([])
expect(observed).toEqual([])
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([
{ logLength: 0, frozen: true },
{ logLength: 0, frozen: true },
])
expect(validations.map(({ event }) => event.seq)).toEqual([0, 0])
expect(validations[1]!.event).toBe(appended)
expect(session.events).toEqual([appended])
expect(observed).toEqual([appended])
})
it('resolves session/event dispatch before commit so instrumentation failure cannot hide a logged event', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('dispatch-check'))
const observed: SessionEvent[] = []
ctx.on('internal/dispatch', (_mode, name) => {
if (name === 'session/event') throw new Error('dispatch instrumentation rejected the carrier')
})
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('dispatch instrumentation rejected the carrier')
expect(session.events).toEqual([])
expect(observed).toEqual([])
})
it('contains a reentrant observer append without reordering later observers', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const session = ctx.sessions.create(SessionId('reentrant-observer'))
const heard: SessionEvent[] = []
ctx.on('session/event', (observedSession) => {
observedSession.append('todo/write', { todos: [] })
})
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(session.events).toEqual([appended])
expect(heard).toEqual([appended])
expect(warnings).toEqual([
'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being published',
])
})
it('defers detach through dispatch resolution, commit, and observer publication', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const order: string[] = []
const session = ctx.sessions.prepare(SessionId('detach-during-append'))
const detach = ctx.sessions.enter(session)
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const session = args[0] as Session
order.push(`resolve:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
detach()
})
ctx.on('session/event', (session) => {
order.push(`observe:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
})
ctx.on('session/disposed', (session) => {
order.push(`dispose:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
})
ctx.sessions.announce(session)
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(session.events).toEqual([appended])
expect(order).toEqual(['resolve:live', 'observe:live', 'dispose:detached'])
expect(ctx.sessions.get(session.id)).toBeUndefined()
})
it('observes async session/created rejection without rolling back or starving peers', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const heard: string[] = []
ctx.on('session/created', () => Promise.reject(new Error('late creation failure')) as never)
ctx.on('session/created', (session) => { heard.push(session.id) })
const session = ctx.sessions.create(SessionId('async-created'))
await Promise.resolve()
await Promise.resolve()
expect(ctx.sessions.get(session.id)).toBe(session)
expect(heard).toEqual(['async-created'])
expect(warnings).toEqual([
'session "async-created": session/created listener rejected: Error: late creation failure',
])
})
it('contains synchronous and async session/disposed listener failures per observer', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const heard: string[] = []
ctx.on('session/disposed', () => { throw new Error('sync disposed') })
ctx.on('session/disposed', () => Promise.reject(new Error('async disposed')) as never)
ctx.on('session/disposed', (session) => { heard.push(session.id) })
const unannounced = ctx.sessions.prepare(SessionId('never-announced'))
const detachUnannounced = ctx.sessions.enter(unannounced)
detachUnannounced()
expect(heard).toEqual([])
const announced = ctx.sessions.prepare(SessionId('contained-disposal'))
const detach = ctx.sessions.enter(announced)
ctx.sessions.announce(announced)
expect(() => { detach() }).not.toThrow()
await Promise.resolve()
await Promise.resolve()
expect(heard).toEqual(['contained-disposal'])
expect(warnings).toEqual([
'session "contained-disposal": session/disposed listener threw: Error: sync disposed',
'session "contained-disposal": session/disposed listener rejected: Error: async disposed',
])
})
it('contains internal dispatch failure after session detachment', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const heard: Session[] = []
ctx.on('internal/dispatch', (_mode, name) => {
if (name === 'session/disposed') throw new Error('disposed dispatch instrumentation')
})
ctx.on('session/disposed', (session) => { heard.push(session) })
const session = ctx.sessions.prepare(SessionId('disposed-dispatch'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
expect(() => { detach() }).not.toThrow()
expect(ctx.sessions.get(session.id)).toBeUndefined()
expect(heard).toEqual([])
expect(warnings).toEqual([
'session "disposed-dispatch": session/disposed dispatch threw: Error: disposed dispatch instrumentation',
])
})
it('does not let internal dispatch replace the disposed callback tuple', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const replacement = new Session(SessionId('replacement-disposed'))
const heard: Session[] = []
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name === 'session/disposed') args[0] = replacement
})
ctx.on('session/disposed', (session) => { heard.push(session) })
const session = ctx.sessions.prepare(SessionId('fixed-disposed-tuple'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
detach()
expect(heard).toEqual([session])
})
})
describe('todo/write event', () => {

View File

@@ -1,6 +1,6 @@
# dsh-system-prompt
System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative named protections; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it.
System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; contributions that implement required protocol may declare themselves owner-final. The agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it.
## Config
@@ -13,22 +13,20 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => Promise<void> | void` Contribute a section. The registry snapshots `name`, `order`, and the text value/callback, so later caller-object mutation cannot rename a stored section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the schemas' names) is the pre-restriction universe `toolOrder` validates against. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise<void> | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise<void> | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Inputs are snapshotted, empty protections throw, and disposal removes the protection.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name.
- `ctx.systemPrompt.section(section: PromptSection): () => Promise<void> | void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. `ownerFinal: true` restores this section's canonical definition after the complete waterfall and reserves a global section against scoped shadows. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames?, ownerFinalNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`; `ownerFinalNames` makes those tools' canonical presence or absence survive the waterfall. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise<void> | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores owner-final contributions from a private pre-waterfall snapshot. Restored entries keep canonical relative order without undoing listener reordering of ordinary entries. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
### Live events
Prompt assembly is the scope-filtered transformable seam; registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope. Exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Named protections apply only after a successful assembly waterfall returns and are owned by the service rather than represented as another event listener.
Prompt assembly is the scope-filtered transformable seam; registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope. Exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Owner-final restoration applies only after a successful assembly waterfall returns.
### Key types
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context).
- `PromptSection``{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona (both registered by this plugin), tool guidance uses `100199`; other negative orders also render before the persona.
- `PromptSection``{ name, order, text, ownerFinal? }`. Sections are concatenated in ascending `order`; `ownerFinal` is reserved for required protocol instructions. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100199`.
- `PromptAssembly``{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
- `PromptProtection``{ sections?: readonly string[], tools?: readonly string[] }`. Named contributions whose canonical pre-waterfall state is restored after all listeners; protections compose by set union rather than callback order, and global section names are reserved against scoped shadows.
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging.
@@ -39,7 +37,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
- The `system-prompt/assemble` waterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables).
- `systemPrompt.protect()`: reserve canonical section/tool contributions for invariants that ordinary waterfall listeners must not be able to remove or replace.
- Owner-final contributions: protocol owners declare finality on the section or tool contribution itself; there is no independent protection registry.
### What is NOT here

View File

@@ -1,6 +1,15 @@
/**
* System prompt assembly registry.
* Scope-filtered dispatch: keyed to `context.scope`.
* System prompt assembly registry. Plugins contribute ordered text sections,
* tool schema providers, and named prompt variables; protocol contributions
* may declare themselves owner-final. `assemble(context)` collates them through a waterfall that
* runs once per step, restores owner-final contributions, and `renderPrompt`
* interpolates `{{variable}}` references into the final text.
*
* The harness-owned prompt openers live here too: this plugin registers the
* static `harness:identity` section (order 100) and the deployment's
* `deployment:persona` section (order 0, from its `persona` config), so they
* exist for every agent regardless of which loop plugin drives it.
*
* @module @deepseek-ai/dsh-system-prompt
*/
@@ -17,19 +26,25 @@ declare module 'cordis' {
interface Events {
/**
* Waterfall around prompt assembly — mutate or extend the {@link PromptAssembly}
* (sections + tools + variables) before it is rendered.
*
* @param assembly - the assembly built from the registered sections, tool providers,
* and variable providers; listeners may mutate it or return a replacement.
* @param context - the per-assembly {@link AssembleContext} the caller passed to {@link
* SystemPrompt.assemble} (e.g. which agent the prompt is for), so a listener can
* filter or extend per agent.
* Waterfall around prompt assembly — mutate or extend the
* {@link PromptAssembly} (sections + tools + variables) before it is
* rendered. Bound to the {@link SystemPrompt} service; call `next()` to
* delegate.
* @param assembly - the assembly built from the registered sections, tool
* providers, and variable providers; listeners may mutate it or return a
* replacement.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
* by `context.scope` — a listener registered through `agent.ctx` fires only
* for that agent's assemblies; a plain plugin listener fires for every
* assembly (scope-less ones included, dispatched subject-less).
* @param context - the per-assembly {@link AssembleContext} the caller
* passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt
* is for), so a listener can filter or extend per agent.
* @mode waterfall
*/
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
/**
* A section, tool provider, variable provider, or protection was registered
* A section, tool provider, or variable provider was registered
* or unregistered (the assembly inputs changed — possibly for one scope
* only). An UNFILTERED registry-subject notification, deliberately not
* scope-filtered dispatch: a global change concerns every agent's next
@@ -65,19 +80,25 @@ export interface AssembleContext {
/** One contributed section of the system prompt (registry input). */
export interface PromptSection {
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */
name: string
readonly name: string
/**
* Sections are concatenated in ascending order. Convention: `-100` is the
* harness identity, `0` the deployment persona, tool guidance uses 100199;
* other negative orders also render before the persona.
*/
order: number
readonly order: number
/**
* Static text or a provider evaluated at each assembly with that assembly's
* {@link AssembleContext}. The text may reference `{{variable}}`s — they are
* interpolated later, by {@link renderPrompt}.
*/
text: string | ((context: AssembleContext) => string)
readonly text: string | ((context: AssembleContext) => string)
/**
* Whether this section's canonical presence and definition survive the
* complete assembly waterfall. Use this only for owner-required protocol
* instructions; ordinary sections remain transformable.
*/
readonly ownerFinal?: boolean
}
/** One section of an assembly: {@link PromptSection} with its text resolved. */
@@ -102,23 +123,32 @@ export interface AssembledSection {
*/
export interface ToolProviderResult {
/** The schemas this provider contributes to THIS assembly. */
schemas: ToolSchema[]
readonly schemas: readonly ToolSchema[]
/** The pre-restriction name universe for config validation (defaults to `schemas`' names). */
knownNames?: readonly string[]
}
/**
* Canonical prompt contributions that survive the assembly waterfall.
*/
export interface PromptProtection {
/** Section names whose canonical registry output is authoritative. */
sections?: readonly string[]
/** Tool names whose canonical provider output is authoritative. */
tools?: readonly string[]
readonly knownNames?: readonly string[]
/**
* Tool names this provider owns finally. The names need not be present in
* `schemas`: naming a mode-hidden tool makes its canonical absence final, so
* an assembly listener cannot fabricate it onto the wire.
*/
readonly ownerFinalNames?: readonly string[]
}
/**
* The assembled prompt.
*
* Tool schemas are part of the assembly by design: "what the model is told it
* can do" is one coherent thing managed here, even though adapters transmit
* `tools` as a separate wire field rather than prompt text. They arrive in
* the canonical model-facing order (see {@link Config.toolOrder}).
*
* `variables` carries every registered prompt variable resolved against this
* assembly's context — key present means registered, `undefined` value means
* "no value for this assembly" (referencing it renders an error). Section
* texts are resolved but NOT yet interpolated; {@link renderPrompt} applies
* the variables, so waterfall listeners can still add sections or variables.
*
* Merge-extensible: plugins can declare extra fields on this interface.
*/
export interface PromptAssembly {
sections: AssembledSection[]
@@ -163,9 +193,20 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine
}
/**
* Order collected tool schemas by the validated policy: with no configured list, plain
* lexicographic name order; with one, listed names take their listed position and every
* unlisted tool lands at the {@link TOOL_ORDER_REST} rest entry in lexicographic name order.
* Order collected tool schemas by the validated policy: with no configured
* list, plain lexicographic name order; with one, listed names take their
* listed position and every unlisted tool lands at the
* {@link TOOL_ORDER_REST} rest entry in lexicographic name order. A listed
* name outside `knownNames` — the providers' PRE-restriction name universe —
* throws: misconfiguration fails loud, and each assembly is the earliest
* moment the registered tool set exists to check against (tool plugins
* register after the service constructs, so load time is too early); the
* assembly rejects, failing the caller's turn before any model request. A
* listed name that is KNOWN but not collected (a tool restricted away for
* this assembly's scope) is a normal absence: its position simply
* contributes nothing — `toolOrder` stays compatible with per-agent
* `restrict()` masks. Never drops a collected tool, and both sorts are
* stable, so tools sharing a name keep their collection order.
*/
function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet<string>): ToolSchema[] {
const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
@@ -183,21 +224,26 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
}
/** Restore protected named entries from `canonical`, anchored before their next unprotected canonical neighbor. */
function restoreProtected<T extends { name: string }>(
canonical: readonly T[], result: readonly T[], protectedNames: ReadonlySet<string>,
/** Restore owner-final entries from `canonical`, anchored before their next ordinary canonical neighbor. */
function restoreOwnerFinal<T extends { name: string }>(
canonical: readonly T[], result: readonly T[], ownerFinalNames: ReadonlySet<string>,
): T[] {
const restored = result.filter(entry => !protectedNames.has(entry.name))
const restored = result.filter(entry => !ownerFinalNames.has(entry.name))
for (const [index, entry] of canonical.entries()) {
if (!protectedNames.has(entry.name)) continue
// Protected entries are inserted in canonical order.
if (!ownerFinalNames.has(entry.name)) continue
// Protected entries are inserted in canonical order. Anchor each one
// before the first later UNPROTECTED canonical neighbor that survived the
// waterfall; if none survived, it belongs at the end. Looking only at
// ordinary neighbors avoids reversing adjacent owner-final entries.
const following = new Set(
canonical.slice(index + 1)
.filter(candidate => !protectedNames.has(candidate.name))
.filter(candidate => !ownerFinalNames.has(candidate.name))
.map(candidate => candidate.name),
)
const next = restored.findIndex(candidate => following.has(candidate.name))
restored.splice(next < 0 ? restored.length : next, 0, structuredClone(entry))
// `canonical` is an owned snapshot made before the waterfall; no second
// clone is needed when moving its entries into the finalized assembly.
restored.splice(next < 0 ? restored.length : next, 0, entry)
}
return restored
}
@@ -210,23 +256,58 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number {
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
export interface Config {
/**
* The deployment's persona — the one deployment-authored fragment of the system prompt,
* rendered as the order-0 `deployment:persona` section (after the harness identity, before
* all tool guidance).
* The deployment's persona — the ONE deployment-authored fragment of the
* system prompt, rendered as the order-0 `deployment:persona` section
* (after the harness identity, before all tool guidance). Every agent in
* the context shares it by default; a per-agent persona is a SCOPED section
* of the same name registered through that agent's `agent.ctx` (it shadows
* this one for that agent — the subagent seam's `persona` request field does
* exactly that). Template, not free-form text:
* every complete `{{…}}` group is interpreted strictly against the
* registered prompt variables (the shipped agent loop registers `{{model}}`
* and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose
* yet (a deliberate deferral; see the prompt-variables RFC). Defaults to
* `''` — the empty section is dropped at render, so a persona-less
* deployment opens with the harness identity alone.
*/
persona?: string
/**
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed tools take their
* listed position, and tools absent from the list are inserted at the {@link
* TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in lexicographic name order.
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
* tools take their listed position, and tools absent from the list are
* inserted at the {@link TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in
* lexicographic name order. A configured list must contain the rest entry
* exactly once, no duplicate names, and no name without a registered tool —
* a misconfigured order blocks work instead of silently reaching a model
* request: shape violations throw at load, and an unregistered name rejects
* every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may
* not be a collected tool name; such a provider output also rejects the
* assembly. The single assembly-time validation rejects either failure
* before any model request — the earliest moment the registered tool set
* exists to check against, since tool plugins register after this service
* constructs. When omitted, tools are ordered lexicographically by name.
* Applied to the tools
* {@link SystemPrompt.assemble} collects, BEFORE the
* `system-prompt/assemble` waterfall — like the sections' `order` sort, it
* canonicalizes what the registry contributed (registration order is a
* plugin-load artifact); a waterfall listener that mutates the tool list
* owns the determinism of what it emits. Rationale (and why not per-plugin
* weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
*/
toolOrder?: string[]
}
/**
* Renders the text part of an assembly: interpolates `{{variable}}` references in each section
* from `assembly.variables`, drops empty sections, and joins the rest with blank lines.
* Renders the text part of an assembly: interpolates `{{variable}}`
* references in each section from `assembly.variables`, drops empty sections,
* and joins the rest with blank lines.
*
* Strict by design (fail loud beats shipping a malformed prompt): a reference
* to an unregistered variable, to a registered variable with no value for
* this assembly, a complete `{{…}}` group that is not a well-formed variable
* name (e.g. `{{ model }}`), or a `{{` that does not open a complete group
* while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A
* lone `{{` with no `}}` anywhere after it is ordinary prose and passes
* through verbatim. Substituted values are never re-scanned.
* @param assembly - the assembly to render (typically the awaited result of
* {@link SystemPrompt.assemble}); only `sections` and `variables` are read.
* @returns the full system prompt text; `''` when every section renders empty
@@ -283,35 +364,41 @@ function interpolate(section: AssembledSection, variables: Record<string, string
/**
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
* sections, tool-schema providers, named prompt variables, and authoritative
* contribution protections; the agent loop calls `assemble(context)` once per
* sections, tool-schema providers, named prompt variables, and owner-final
* contributions; the agent loop calls `assemble(context)` once per
* step. Registers the harness-owned `harness:identity` and
* `deployment:persona` sections itself (see {@link Config.persona}).
*/
export class SystemPrompt extends Service {
static Config: z<Config> = z.object({
persona: z.string().default(''),
// A schemastery array defaults to [] when omitted, but an omitted toolOrder must stay
// absent ("lexicographic order"), not become an explicitly-configured empty list (which is
// invalid — it lacks the rest entry).
// A schemastery array defaults to [] when omitted, but an omitted
// toolOrder must stay absent ("lexicographic order"), not become an
// explicitly-configured empty list (which is invalid — it lacks the
// rest entry). Forcing the default to undefined keeps the key out of the
// validated config; the cast is needed because .default() expects the
// array type.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
})
private sections: PromptSection[] = []
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
private protections: PromptProtection[] = []
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
private scopedSections = new Map<ScopeKey, PromptSection[]>()
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
private scopedProtections = new Map<ScopeKey, PromptProtection[]>()
private readonly toolOrder: string[] | undefined
constructor(ctx: Context, public config: Config) {
super(ctx, 'systemPrompt')
this.toolOrder = validateToolOrder(config.toolOrder)
// The harness-owned openers.
// The harness-owned openers. They live HERE (not on the loop plugin) so a
// deployment that swaps in a different loop keeps them: the identity is a
// harness fact stated ahead of everything, and the persona is the
// deployment's config, one section of the full prompt, never the whole.
// An empty persona still RESERVES the section name (one owner — a plugin
// re-registering it throws); renderPrompt drops the empty text.
this.section({
name: 'harness:identity',
order: -100,
@@ -327,22 +414,41 @@ export class SystemPrompt extends Service {
}
/**
* Contribute a text section to the system prompt.
*
* Contribute a text section to the system prompt. Order is determined by
* `section.order` (ascending). The layer is decided by the CALLING context
* (`@deepseek-ai/dsh-scope`): a plain plugin context contributes globally; a
* scoped context (`agent.ctx`) contributes to that scope alone — and a
* scoped section SHADOWS a same-named global section for that scope's
* assemblies (most-specific-wins; this is how a per-agent persona overrides
* `deployment:persona`) unless that global contribution is owner-final: it
* reserves its section name against scoped shadows so the
* registration owner—not a later scope—defines the canonical value. The
* readonly typed contribution is borrowed until disposal; only the semantic
* finite-order rule is checked at runtime. Throws if the SAME layer already has the name (a
* duplicate would silently double prompt text — e.g. a double-loaded tool
* plugin; the global-duplicate message names `agent.ctx` as the per-agent
* alternative). Removed when the calling fiber is disposed. Emits
* `system-prompt/change` on register/unregister.
* @param section - the section to contribute (name, order, text or provider).
* @returns the disposer that removes the section. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
* yield it directly — exact identity nests the teardown in order.
*/
section(section: PromptSection): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
const snapshot: PromptSection = {
name: section.name,
order: section.order,
text: section.text,
if (!Number.isFinite(section.order)) {
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
}
if (scope !== undefined && this.protections.some(record => record.sections?.includes(snapshot.name))) {
throw new Error(`prompt section "${snapshot.name}" is globally protected and cannot be shadowed in an agent scope`)
const scope = scopeOf(this.ctx)
if (scope !== undefined
&& this.sections.some(global => global.name === section.name && global.ownerFinal === true)) {
throw new Error(`prompt section "${section.name}" is globally owner-final and cannot be shadowed in an agent scope`)
}
if (scope === undefined && section.ownerFinal === true) {
const hasScopedShadow = [...this.scopedSections.values()]
.some(layer => layer.some(scoped => scoped.name === section.name))
if (hasScopedShadow) {
throw new Error(`owner-final prompt section "${section.name}" cannot be registered while a scoped shadow exists`)
}
}
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
@@ -352,18 +458,18 @@ export class SystemPrompt extends Service {
this.scopedSections.set(scope, created)
return created
})()
if (layer.some(existing => existing.name === snapshot.name)) {
if (layer.some(existing => existing.name === section.name)) {
throw new Error(scope === undefined
? `prompt section "${snapshot.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${snapshot.name}" is already registered in this scope`)
? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${section.name}" is already registered in this scope`)
}
layer.push(snapshot)
layer.push(section)
// Yield the rollback BEFORE emitting `system-prompt/change`: a generator
// effect collects each yielded disposer before the next step runs, so a
// throwing change listener removes the section instead of leaking it into
// every future assembly.
yield () => {
const index = layer.indexOf(snapshot)
const index = layer.indexOf(section)
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
@@ -371,15 +477,26 @@ export class SystemPrompt extends Service {
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.section()')
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Fire-and-forget callers may still
// discard the (always-resolved) promise.
return dispose
}
/**
* Contribute a tool-schema provider, evaluated at each assembly call with that assembly's
* {@link AssembleContext} (so it reflects the live registry state AND the assembly's scope —
* see {@link ToolProviderResult} for the `schemas`/`knownNames` split).
*
* Contribute a tool-schema provider, evaluated at each assembly call with
* that assembly's {@link AssembleContext} (so it reflects the live registry
* state AND the assembly's scope — see {@link ToolProviderResult} for the
* `schemas`/`knownNames` split). The layer is decided by the calling
* context: a scoped provider (registered through `agent.ctx`) is consulted
* only for that scope's assemblies. Removed when the calling fiber is
* disposed. A provider must not return a schema named
* {@link TOOL_ORDER_REST}; that name is reserved for
* {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits
* `system-prompt/change`.
* @param provider - evaluated at every {@link assemble} for fresh schemas.
* @returns the disposer that removes the provider. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
@@ -406,13 +523,27 @@ export class SystemPrompt extends Service {
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.tools()')
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Fire-and-forget callers may still
// discard the (always-resolved) promise.
return dispose
}
/**
* Contribute a named prompt variable, referenced from section text as `{{name}}`.
*
* Contribute a named prompt variable, referenced from section text as
* `{{name}}`. The provider is evaluated at each assembly with that
* assembly's {@link AssembleContext}; returning `undefined` means "no value
* for this assembly" (a section referencing it then fails to render — a
* deployment must not claim facts it does not have). The layer is decided
* by the calling context: a scoped variable (registered through
* `agent.ctx`) resolves only for that scope's assemblies and SHADOWS a
* same-named global variable there. Throws on a name that does not match
* `[a-z][a-z0-9_]*` (it could never be referenced) or one already registered
* in the SAME layer. Removed when the calling fiber is disposed; emits
* `system-prompt/change` on register/unregister.
* @param name - the reference name (matches `[a-z][a-z0-9_]*`).
* @param provider - evaluated at every {@link assemble} for the value.
* @returns the disposer that removes the variable. The exact
@@ -420,11 +551,11 @@ export class SystemPrompt extends Service {
* yield it directly — exact identity nests the teardown in order.
*/
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void {
if (!VARIABLE_NAME.test(name)) {
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
}
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
if (!VARIABLE_NAME.test(name)) {
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
}
const layer = scope === undefined
? this.variableProviders
: this.scopedVariableProviders.get(scope) ?? (() => {
@@ -446,77 +577,47 @@ export class SystemPrompt extends Service {
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.variable()')
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Fire-and-forget callers may still
// discard the (always-resolved) promise.
return dispose
}
/**
* Protect named section/tool contributions from the assembly waterfall.
*
* @param protection - section and/or tool names whose canonical presence and definitions are authoritative.
* @returns the exact Cordis effect disposer that removes the protection.
*/
protect(protection: PromptProtection): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
const snapshot: PromptProtection = {
...protection.sections !== undefined ? { sections: [...new Set(protection.sections)] } : {},
...protection.tools !== undefined ? { tools: [...new Set(protection.tools)] } : {},
}
if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) {
throw new Error('systemPrompt.protect() requires at least one section or tool name')
}
if (scope === undefined && snapshot.sections !== undefined) {
const protectedSections = new Set(snapshot.sections)
const conflicts = [...this.scopedSections.values()]
.flatMap(layer => layer.filter(section => protectedSections.has(section.name)).map(section => section.name))
if (conflicts.length > 0) {
throw new Error(`systemPrompt.protect() cannot globally protect section${conflicts.length > 1 ? 's' : ''} ${[...new Set(conflicts)].map(name => `"${name}"`).join(', ')} while scoped shadows are registered`)
}
}
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.protections
: this.scopedProtections.get(scope) ?? (() => {
const created: PromptProtection[] = []
this.scopedProtections.set(scope, created)
return created
})()
layer.push(snapshot)
yield () => {
const index = layer.indexOf(snapshot)
/* v8 ignore next 3 -- defensive: protection was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedProtections.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.protect()')
return dispose
}
/** Resolve the authoritative names registered for one assembly scope. */
private protectedNames(scope: ScopeKey | undefined): { sections: Set<string>; tools: Set<string> } {
const records = [
...this.protections,
...(scope === undefined ? [] : this.scopedProtections.get(scope)) ?? [],
]
return {
sections: new Set(records.flatMap(record => record.sections ?? [])),
tools: new Set(records.flatMap(record => record.tools ?? [])),
}
}
/**
* Assemble global contributions with one scope, then run the assembly waterfall and protection.
* @param context - assembly subject and scope; defaults to an empty context.
* Assemble the current prompt for one caller: the global layer merged with
* {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW
* same-named global ones — most-specific-wins) — section texts resolved
* against `context` and sorted by order across the union, tools collected
* from the global providers plus the scope's and put in the canonical
* model-facing order ({@link Config.toolOrder}, or lexicographic name order
* when unconfigured — provider registration order is a plugin-load artifact
* and never reaches the assembly; a configured order naming a tool outside
* the providers' `knownNames` universe rejects the assembly, while a known
* name restricted away for this scope is a normal absence), and every
* visible variable resolved against `context` into `assembly.variables`.
* Tool schemas are detached because assembly waterfalls may mutate them.
* Runs through the `system-prompt/assemble`
* waterfall, giving listeners the opportunity to mutate or replace the
* assembly, then restores every contribution whose owner declared it final
* from the pre-waterfall canonical assembly. Like the sections' `order` sort, tool
* canonicalization happens on the initial assembly; ordinary listener
* output owns its own determinism. Await the result before reading the
* assembly values — waterfall listeners may be async.
* Interpolation happens later, in {@link renderPrompt}.
* @param context - what this assembly is for (defaults to an empty context;
* see {@link AssembleContext}).
* @returns the assembly after the waterfall has run.
*/
// Async ensures validation failures are promise rejections.
// async so the misconfigured-toolOrder throw in orderTools surfaces as a
// rejection: a Promise-returning method must not throw synchronously
// (`assemble().catch(...)` would miss it).
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
const scope = context.scope
// Registrations arriving mid-assembly affect the next assembly.
const protectedNames = this.protectedNames(scope)
// Scoped variables shadow global names.
// Variables: global layer first, then the scope's layer OVERWRITES
// same-named entries (shadowing — a per-agent value wins for that agent).
const variables: Record<string, string | undefined> = {}
for (const [name, provider] of this.variableProviders) {
variables[name] = provider(context)
@@ -525,27 +626,44 @@ export class SystemPrompt extends Service {
for (const [name, provider] of scopedVariables ?? []) {
variables[name] = provider(context)
}
// Scoped sections shadow global names before the stable order sort.
// Sections: merge by name, scoped REPLACING same-named global entries
// (most-specific-wins — the per-agent persona mechanism), then sort by
// order across the union. Registration order within a layer is preserved
// for equal orders (stable sort).
const sectionByName = new Map<string, PromptSection>()
for (const section of this.sections) sectionByName.set(section.name, section)
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
sectionByName.set(section.name, section)
}
// `knownNames` validates order before per-scope restrictions hide schemas.
const ownerFinalSections = new Set(
[...sectionByName.values()]
.filter(section => section.ownerFinal === true)
.map(section => section.name),
)
// Tools: consult the global providers plus the scope's, each with this
// assembly's context. `schemas` are what the model may see (already
// post-restriction, per provider); `knownNames` (defaulting to the
// schemas' names) form the pre-restriction universe `toolOrder` is
// validated against, so a restricted-away tool is a normal absence while
// a config typo still fails every assembly loudly.
const providers = [
...this.toolProviders,
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],
]
const collected: ToolSchema[] = []
const knownNames = new Set<string>()
const ownerFinalTools = new Set<string>()
for (const provider of providers) {
const result = provider(context)
for (const tool of result.schemas) {
collected.push({ ...tool, parameters: structuredClone(tool.parameters) })
}
for (const name of result.knownNames ?? result.schemas.map(tool => tool.name)) {
knownNames.add(name)
}
const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({
name,
description,
parameters: structuredClone(parameters),
}))
const acceptedKnownNames = result.knownNames ?? schemas.map(tool => tool.name)
collected.push(...schemas)
for (const name of acceptedKnownNames) knownNames.add(name)
for (const name of result.ownerFinalNames ?? []) ownerFinalTools.add(name)
}
const assembly: PromptAssembly = {
sections: [...sectionByName.values()]
@@ -558,11 +676,11 @@ export class SystemPrompt extends Service {
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}
// Snapshot only the fields protection can restore. The waterfall receives
// Snapshot only the owner-final fields. The waterfall receives
// `assembly` by reference and may mutate it or return a replacement; these
// independent snapshots remain the authoritative registry product.
const canonicalSections = protectedNames.sections.size > 0 ? structuredClone(assembly.sections) : undefined
const canonicalTools = protectedNames.tools.size > 0 ? structuredClone(assembly.tools) : undefined
const canonicalSections = ownerFinalSections.size > 0 ? structuredClone(assembly.sections) : undefined
const canonicalTools = ownerFinalTools.size > 0 ? structuredClone(assembly.tools) : undefined
const result = await this.ctx.waterfall(
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
() => Promise.resolve(assembly),
@@ -573,10 +691,10 @@ export class SystemPrompt extends Service {
return {
...result,
...canonicalSections !== undefined
? { sections: restoreProtected(canonicalSections, result.sections, protectedNames.sections) }
? { sections: restoreOwnerFinal(canonicalSections, result.sections, ownerFinalSections) }
: {},
...canonicalTools !== undefined
? { tools: restoreProtected(canonicalTools, result.tools, protectedNames.tools) }
? { tools: restoreOwnerFinal(canonicalTools, result.tools, ownerFinalTools) }
: {},
}
}

View File

@@ -63,19 +63,16 @@ describe('scoped sections', () => {
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
})
it.each([
[['reserved'], 'section "reserved"'],
[['first', 'second'], 'sections "first", "second"'],
])('rejects global protection added after scoped shadows (%j)', async (names, message) => {
it('rejects a global owner-final section added after a scoped shadow', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
for (const name of names) {
scope.ctx.systemPrompt.section({ name, order: 1, text: `scoped ${name}` })
}
scope.ctx.systemPrompt.section({ name: 'reserved', order: 1, text: 'scoped reserved' })
expect(() => ctx.systemPrompt.protect({ sections: names })).toThrow(message)
expect(() => ctx.systemPrompt.section({
name: 'reserved', order: 1, text: 'global reserved', ownerFinal: true,
})).toThrow('owner-final prompt section "reserved"')
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
.toContain(`scoped ${names[0]}`)
.toContain('scoped reserved')
})
})
@@ -164,13 +161,18 @@ describe('scoped assemble dispatch', () => {
expect(shaped).toHaveLength(1)
})
it('a scoped protection finalizes only its own assemblies and disappears with the scope', async () => {
it('scoped owner-final contributions finalize only their assemblies and disappear with the scope', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
const key = scopeKeyOf(scope)
ctx.systemPrompt.section({ name: 'required', order: 10, text: 'required' })
ctx.systemPrompt.tools(() => ({ schemas: [schema('required')] }))
scope.ctx.systemPrompt.protect({ sections: ['required'], tools: ['required'] })
scope.ctx.systemPrompt.section({
name: 'required', order: 10, text: 'scoped required', ownerFinal: true,
})
scope.ctx.systemPrompt.tools(() => ({
schemas: [schema('required')], ownerFinalNames: ['required'],
}))
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'required')

View File

@@ -111,6 +111,14 @@ describe('SystemPrompt', () => {
expect(contributed(assembly).map(s => s.text)).toEqual(['first'])
})
it('rejects a non-finite section order', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: Number.NaN, text: 'x' }))
.toThrow('order must be a finite number')
expect(contributed(await ctx.systemPrompt.assemble())).toEqual([])
})
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -205,28 +213,21 @@ describe('SystemPrompt', () => {
expect(assembly.sections).toHaveLength(0)
})
describe('canonical contribution protection', () => {
it('restores exact protected definitions after every listener, in canonical relative order', async () => {
describe('owner-final contributions', () => {
it('restores exact owner-final definitions after every listener, in canonical relative order', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'before', order: 10, text: 'before' })
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section' })
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section', ownerFinal: true })
ctx.systemPrompt.section({ name: 'after', order: 30, text: 'after' })
ctx.systemPrompt.tools(() => ({ schemas: [
{ name: 'alpha', description: 'alpha', parameters: {} },
{ name: 'protected', description: 'canonical tool', parameters: { type: 'object', properties: { answer: { type: 'number' } } } },
{ name: 'zulu', description: 'zulu', parameters: {} },
] }))
const protection = { sections: ['protected'], tools: ['protected'] }
ctx.systemPrompt.protect(protection)
// Registration snapshots its arrays; caller mutation cannot change what
// the service makes authoritative.
protection.sections[0] = 'after'
protection.tools[0] = 'zulu'
], ownerFinalNames: ['protected'] }))
// Registered AFTER the protection and prepended: it is outside every
// ordinary listener that existed when protect() ran, but service-level
// finalization still restores the canonical entries after it returns.
// Service-level finalization restores the canonical entries after the
// complete listener chain returns.
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
return Object.freeze({
@@ -258,47 +259,18 @@ describe('SystemPrompt', () => {
expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu'])
})
it('protects canonical absence and rejects an empty protection', async () => {
it('makes an owner-final tool\'s canonical absence survive the waterfall', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
// Separate registrations exercise the set-union contract: protections
// may name only sections or only tools and still compose.
ctx.systemPrompt.protect({ sections: ['mode-hidden'] })
ctx.systemPrompt.protect({ tools: ['mode-hidden'] })
ctx.systemPrompt.tools(() => ({ schemas: [], ownerFinalNames: ['mode-hidden'] }))
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections.push({ name: 'mode-hidden', order: 100, text: 'fabricated' })
result.tools.push({ name: 'mode-hidden', description: 'fabricated', parameters: {} })
return result
})
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.some(section => section.name === 'mode-hidden')).toBe(false)
expect(assembly.tools.some(tool => tool.name === 'mode-hidden')).toBe(false)
expect(() => ctx.systemPrompt.protect({})).toThrow(/at least one section or tool name/)
expect(() => ctx.systemPrompt.protect({ sections: [], tools: [] })).toThrow(/at least one section or tool name/)
})
it('removes a protection with its contributing fiber (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' })
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'protected')
return result
})
let changes = 0
ctx.on('system-prompt/change', () => { changes++ })
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.systemPrompt.protect({ sections: ['protected'] })
}, { inject: ['systemPrompt'] }))
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(true)
expect(changes).toBe(1)
await fiber.dispose()
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(false)
expect(changes).toBe(2)
})
})

View File

@@ -11,18 +11,16 @@ tools:
mode: native # native (default) | code | both
```
`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry's canonical contribution is the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); an assembly listener may still deliberately add unrelated schemas. `both` contributes the visible native definitions, `run_code`, and the SDK section. In non-native modes both infrastructure pieces are protected rather than filterable capabilities: restrictions and assembly listeners cannot remove them, a scoped section cannot shadow the globally protected `tools:sdk`, and registering, shadowing, or explicitly filtering `run_code` fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry's canonical contribution is the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); an assembly listener may still deliberately add unrelated schemas. `both` contributes the visible native definitions, `run_code`, and the SDK section. In non-native modes both infrastructure pieces are owner-final rather than filterable capabilities: restrictions and assembly listeners cannot remove them, a scoped section cannot shadow the globally owner-final `tools:sdk`, and registering, shadowing, or explicitly filtering `run_code` fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
### Public API
- `ctx.tools.register(definition: ToolDefinition): () => Promise<void> | void` Register a tool as a frozen snapshot. Parameters must survive lossless-JSON validation before and after cloning; scalar fields are copied, and execute/presentation callbacks are bound once to the original definition as their method receiver, so later callback-property replacement cannot change dispatch. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations).
- `ctx.tools.restrict(filter: ToolRestriction): () => Promise<void> | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap).
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. Returned definitions are the registry's frozen snapshots.
- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` The canonical executable view — restricted global layer the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so presentation and dispatch resolve the same frozen definitions.
- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`.
- `ctx.tools.register(definition: ToolDefinition): () => Promise<void> | void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. `ownerFinal: true` makes the tool's canonical wire presence or absence survive prompt assembly listeners. Disposed with the calling fiber.
- `ctx.tools.restrict(filter: ToolRestriction): () => Promise<void> | void` Scoped-only (throws on a plain context): mask the global end-capability surface for the calling agent — `allow` keeps only the listed global tools, `deny` removes them; multiple restrictions intersect; scope-local registrations are merged afterward. The readonly arrays compile once into private sets. Every listed name must exist in the current pre-restriction global registry; scope-local, unknown, and reserved `run_code` names fail loudly. A deny-list admits a later global tool unless it names that tool; an allow-list excludes later names; neither filters a later scope-local registration. `restrict({})` rejects. This is live registration composition, not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.guard(guard: ToolGuard): () => Promise<void> | void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
- `ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>` Snapshot one single-use call input into a pipeline-owned execution, assign its opaque correlation token, require `arguments` to be losslessly JSON-serializable before and after cloning, deep-freeze the detached arguments, and protect its identity before running `tools/pre-execute` → guards → `tools/execute``tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. Validate the final result as losslessly JSON-serializable and freeze the complete execution before `tools/result` observers run. Invalid or unstable input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log.
- `ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>` Assign a fresh opaque correlation token, losslessly materialize and deep-freeze arguments once at the model/tool boundary, then run the call through `tools/pre-execute` → guards → `tools/execute``tools/post-execute`. Invalid arguments normalize through the same authoritative result path without reaching policy or the body. The final outcome is independently materialized and deep-frozen once before `tools/result`. Optional `signal` remains the operational field an around-dispatch wrapper may replace.
### Injected services
@@ -34,12 +32,12 @@ The live registry pipeline has three transformable waterfalls followed by the ow
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model. Registration stores a frozen snapshot with detached JSON parameters and once-bound callback identities.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; `arguments` must be losslessly JSON-serializable, and callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionToken` — a frozen, property-free identity value assigned by the registry. It supports equality correlation only and exposes no live outer execution state.
- `ToolDefinition``ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional `ownerFinal`. `ownerFinal` is reserved for protocol tools such as `run_code` and structured-output capture whose canonical wire state must survive assembly listeners.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry validates the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. The registry validates this exact union at runtime: a JavaScript/casted value with an unknown kind, a malformed reason, or extra fields fails closed as an `isError`; the tool body does not run and final observers still receive one result. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when a deployment mounts it (`allowed-once` proceeds to dispatch; `rejected`/`cancelled`/`unavailable` deny with distinct reasons) and degrades to `deny` when none is mounted or the execution carries no agent.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision``{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
- `ToolGuard``(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
@@ -77,7 +75,7 @@ ctx.tools.register(defineTool({
}))
```
The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.
The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.
A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs<S>` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, `default` is not applied, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input.
@@ -138,7 +136,7 @@ Under `mode: code` (or `both`) the registry turns the tool surface into a progra
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. A sub-call's `additionalContext` is deliberately dropped because inserting it inside a running parent call would break tool-call/result adjacency.
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free. With no deliberate schema-adding assembly listener, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens; protection guarantees that `run_code` and `tools:sdk` remain present, not that unrelated listener additions are erased. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL.
The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free. With no deliberate schema-adding assembly listener, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens; contribution-owned finality guarantees that `run_code` and `tools:sdk` remain present, not that unrelated listener additions are erased. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL.
### What is NOT here (TODO)

View File

@@ -131,6 +131,7 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition {
return defineTool({
name: RUN_CODE_NAME,
ownerFinal: true,
description:
'Execute a TypeScript program against the available tools. Write the BODY of an '
+ 'async function (erasable syntax only; top-level `await` and `return` work) and '

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,21 @@
/**
* Typed tool-parameter schema DSL.
*
* Plugin authors write per-property specs with `required: true` as a boolean
* (the `SchemaSpec` type). A type-level helper (`InferArgs`) maps a SchemaSpec
* to the TS argument type. At runtime, `schemaSpecToJsonSchema()` converts a
* SchemaSpec to standard JSON Schema (`type: 'object'`, `properties`,
* `required` array) for the wire format sent to the model.
*
* # Why a custom DSL and not schemastery?
*
* Schemastery is a validation/transformation library (StandardSchema v1) used
* for plugin Config. Tool parameters need JSON Schema specifically (the LLM
* wire format), not validation. A lightweight DSL focused on JSON Schema
* generation, with type inference for the tool's `execute` args, gives plugin
* authors the best DX with the smallest surface area. Schemastery would add
* unnecessary indirection and wouldn't cleanly produce JSON Schema.
*
* @module dsh-tools/schema
*/
@@ -247,10 +263,15 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
}
/**
* Validate model-generated `args` against a {@link SchemaSpec}, returning a list of
* human-readable violation messages (empty = valid). Total — never throws, regardless of how
* malformed `args` is.
* Validate model-generated `args` against a {@link SchemaSpec}, returning a
* list of human-readable violation messages (empty = valid). Total — never
* throws, regardless of how malformed `args` is.
*
* Semantics mirror {@link schemaSpecToJsonSchema} exactly: the top level must
* be a non-array object; required keys come only from `required: true`; extra
* keys are allowed (no `additionalProperties: false`); `default` is not
* applied; an `object`/`array` prop without `properties`/`items` only
* type-checks; `enum` is membership (strings only).
* @param spec - the declared parameter schema to validate against.
* @param args - the model-generated arguments, however malformed.
* @returns the violation messages in declaration order; empty means valid.
@@ -266,21 +287,23 @@ export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
/** Options for {@link defineTool}. */
export interface DefineToolOptions<S extends SchemaSpec> {
/** Tool name (must be unique). */
name: string
readonly name: string
/** Human-readable description sent to the model. */
description: string
readonly description: string
/**
* Parameter schema using the per-property-required DSL. Converted to
* standard JSON Schema at runtime.
*/
parameters: S
readonly parameters: S
/**
* Optional cooperative tool-call timeout budget in milliseconds. When given it
* must be a positive finite number; it is attached to the produced
* {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and
* is never sent to the model.
*/
timeoutMs?: number
readonly timeoutMs?: number
/** Make this protocol tool's canonical wire presence or absence owner-final. */
readonly ownerFinal?: boolean
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
@@ -309,6 +332,30 @@ export interface DefineToolOptions<S extends SchemaSpec> {
/**
* Define a tool with a typed parameter schema.
*
* Use this instead of constructing a raw {@link ToolDefinition} for all
* first-party tools. The `parameters` use the boolean-required style
* (`required: true` as a per-property flag), and `execute` receives typed
* args derived from the schema.
*
* ```ts
* const tool = defineTool({
* name: 'read_file',
* description: 'Read a file from disk.',
* parameters: {
* path: { type: 'string', required: true, description: 'Absolute file path' },
* offset: { type: 'number' },
* limit: { type: 'number', description: 'Max lines to read' },
* },
* async execute(args) {
* // args: { path: string; offset?: number; limit?: number }
* },
* })
* ```
*
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
* first-party plugin authors.
*
* @param options - the tool's name, description, typed parameter schema,
* execute body, and optional presenters.
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
@@ -333,8 +380,12 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
...(options.ownerFinal === true ? { ownerFinal: true } : {}),
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
// Validate the model-generated args before the typed body runs.
// Validate the model-generated args before the typed body runs. On
// mismatch we throw ToolArgsError; the registry turns it into an
// isError result so the model can self-correct. After this guard, the
// cast to InferArgs<S> reflects the validated shape.
const violations = validateArgs(options.parameters, args)
if (violations.length > 0) throw new ToolArgsError(violations)
return userExecute(args as InferArgs<S>, exec)

View File

@@ -215,41 +215,24 @@ describe('mode-aware wire contribution', () => {
expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
expect(() => scope.ctx.systemPrompt.section({ name: 'tools:sdk', order: -999, text: 'malicious SDK' }))
.toThrow(/globally protected and cannot be shadowed/)
.toThrow(/globally owner-final and cannot be shadowed/)
expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
const transport = ctx.tools.get(RUN_CODE_NAME)!
expect(Object.isFrozen(transport)).toBe(true)
expect(Object.isFrozen(transport.parameters)).toBe(true)
expect(() => { transport.name = 'mutated_transport' }).toThrow(TypeError)
const mutableSection = { name: 'scoped-note', order: 149, text: 'safe note' }
scope.ctx.systemPrompt.section(mutableSection)
mutableSection.name = 'tools:sdk'
mutableSection.text = 'mutated SDK'
const mutableTool = defineTool({
scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' })
scope.ctx.tools.register(defineTool({
name: 'scoped_safe',
description: 'Safe scoped tool.',
parameters: {},
execute: () => Promise.resolve([{ type: 'text' as const, text: 'safe' }]),
})
scope.ctx.tools.register(mutableTool)
mutableTool.name = RUN_CODE_NAME
mutableTool.description = 'Mutated transport impostor.'
const stored = ctx.tools.get('scoped_safe', agent)!
expect(Object.isFrozen(stored)).toBe(true)
expect(Object.isFrozen(stored.parameters)).toBe(true)
expect(() => { stored.name = RUN_CODE_NAME }).toThrow(TypeError)
}))
const assembly = await systemPrompt.assemble({ scope: agent })
const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME)
expect(transports).toHaveLength(1)
expect(transports[0]?.description).toContain('Execute a TypeScript program')
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).not.toContain('mutated SDK')
expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note')
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:')
expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
expect(ctx.tools.knownNames(agent)).not.toContain(RUN_CODE_NAME)
const result = await runCode(ctx, 'return 1', { agent })
expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }])
})
@@ -262,7 +245,6 @@ describe('mode-aware wire contribution', () => {
registerEcho(ctx)
const { agent } = await mintAgentScope(ctx)
expect(ctx.tools.knownNames(agent)).toEqual(['echo'])
const assembly = await systemPrompt.assemble({ scope: agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code'
? [RUN_CODE_NAME]

View File

@@ -4,7 +4,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
@@ -89,6 +89,35 @@ describe('scoped tool registration', () => {
expect(() => scope.ctx.tools.register(tool('y'))).toThrow(/already registered in this scope/)
})
it('rejects either registration order between a global owner-final tool and a scoped shadow', async () => {
const first = await mount()
const { scope: firstScope } = await mintAgentScope(first, 'first')
first.tools.register({ ...tool('reserved'), ownerFinal: true })
expect(() => firstScope.ctx.tools.register(tool('reserved')))
.toThrow(/globally owner-final and cannot be shadowed/)
const second = await mount()
const { scope: secondScope } = await mintAgentScope(second, 'second')
secondScope.ctx.tools.register(tool('reserved'))
expect(() => second.tools.register({ ...tool('reserved'), ownerFinal: true }))
.toThrow(/owner-final tool "reserved" cannot be registered while a scoped shadow exists/)
})
it('restores global and scoped owner-final tools removed by assembly middleware', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'owner-final')
ctx.tools.register({ ...tool('required'), ownerFinal: true })
scope.ctx.tools.register({ ...tool('scoped-required'), ownerFinal: true })
ctx.on('system-prompt/assemble', async assembly => ({
...assembly,
tools: assembly.tools.filter(schema => !schema.name.includes('required')),
}))
expect((await ctx.systemPrompt.assemble()).tools.map(schema => schema.name)).toContain('required')
expect((await ctx.systemPrompt.assemble({ scope: key })).tools.map(schema => schema.name))
.toEqual(expect.arrayContaining(['required', 'scoped-required']))
})
it('disposing the scope unwinds its registrations and leaves no residue', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
@@ -96,12 +125,12 @@ describe('scoped tool registration', () => {
expect(ctx.tools.get('mine', key)).toBeDefined()
await scope.dispose()
expect(ctx.tools.get('mine', key)).toBeUndefined()
expect(ctx.tools.knownNames(key)).toEqual([])
expect(ctx.tools.schemas(key)).toEqual([])
})
})
describe('restrict()', () => {
it('masks global tools for the scope; grants bypass; assembly and execute agree', async () => {
it('masks global tools, merges scope-local tools afterward, and keeps assembly with execution', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('read'))
@@ -109,7 +138,7 @@ describe('restrict()', () => {
scope.ctx.tools.register(tool('capture'))
scope.ctx.tools.restrict({ allow: ['read'] })
// The scoped grant survives the allow-list; the unlisted global is gone.
// The scope-local registration survives the allow-list; the unlisted global is gone.
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['capture', 'read'])
expect(await run(ctx, 'bash', key)).toBe('Error: unknown tool "bash"')
expect(await run(ctx, 'read', key)).toBe('ran:read')
@@ -118,6 +147,29 @@ describe('restrict()', () => {
expect(ctx.tools.schemas().map(t => t.name).sort()).toEqual(['bash', 'read'])
})
it('applies snapshotted filters to the live global registry before merging later scope-local tools', async () => {
const ctx = await mount()
const denied = await mintAgentScope(ctx, 'denied')
const allowed = await mintAgentScope(ctx, 'allowed')
ctx.tools.register(tool('read'))
ctx.tools.register(tool('bash'))
denied.scope.ctx.tools.restrict({ deny: ['bash'] })
allowed.scope.ctx.tools.restrict({ allow: ['read'] })
ctx.tools.register(tool('web'))
denied.scope.ctx.tools.register(tool('denied-local'))
allowed.scope.ctx.tools.register(tool('allowed-local'))
expect(ctx.tools.schemas(denied.key).map(t => t.name).sort())
.toEqual(['denied-local', 'read', 'web'])
expect(ctx.tools.schemas(allowed.key).map(t => t.name).sort())
.toEqual(['allowed-local', 'read'])
expect(await run(ctx, 'web', denied.key)).toBe('ran:web')
expect(await run(ctx, 'web', allowed.key)).toBe('Error: unknown tool "web"')
expect(await run(ctx, 'denied-local', denied.key)).toBe('ran:denied-local')
expect(await run(ctx, 'allowed-local', allowed.key)).toBe('ran:allowed-local')
})
it('composes multiple restrictions by intersection and lifts each independently', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
@@ -130,7 +182,7 @@ describe('restrict()', () => {
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c'])
})
it('snapshots the filter at registration (caller mutation changes nothing)', async () => {
it('compiles the readonly filter values at registration', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('a'))
@@ -141,19 +193,21 @@ describe('restrict()', () => {
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
})
it('fails loud on an unscoped call, an empty filter, and unknown names', async () => {
it('fails loud on an unscoped call, an empty filter, and non-global names', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('real'))
scope.ctx.tools.register(tool('local'))
expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/)
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/)
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown tools "ghost", "wraith"/)
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/)
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall"; known global tools: real/)
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/)
const emptyCtx = await mount()
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
.toThrow(/known tools for this scope: \(none\)/)
.toThrow(/known global tools: \(none\)/)
})
})
@@ -188,9 +242,8 @@ describe('scoped execution dispatch', () => {
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
},
})
let guardViewFrozen = false
const guard = (execution: Readonly<ToolExecution>): string => {
guardViewFrozen = Object.isFrozen(execution) && Object.isFrozen(execution.arguments)
expect(Object.isFrozen(execution.arguments)).toBe(true)
return 'terminal policy'
}
const liftFirst = scope.ctx.tools.guard(guard)
@@ -201,7 +254,6 @@ describe('scoped execution dispatch', () => {
scope.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'allow' }), { prepend: true })
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
expect(guardViewFrozen).toBe(true)
expect(await run(ctx, 't', other)).toBe('ran:t')
expect(bodyCalls).toBe(1)
@@ -229,13 +281,14 @@ describe('scoped execution dispatch', () => {
expect(bodyCalls).toBe(0)
})
it('protects call identity before policy and dispatch while leaving only signal mutable', async () => {
it('shares one token and materialized argument value across the pipeline', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
let safeCalls = 0
let dangerCalls = 0
let scopedResults = 0
let safeArguments: unknown
const tokens = new Set<ToolExecutionToken>()
ctx.tools.register({
...tool('safe'),
execute: (args) => {
@@ -253,17 +306,16 @@ describe('scoped execution dispatch', () => {
})
scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
ctx.on('tools/pre-execute', (exec, next) => {
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
expect(Reflect.set(exec, 'name', 'safe')).toBe(false)
expect(Reflect.set(exec.arguments as object, 'injected', true)).toBe(false)
tokens.add(exec.token)
expect(Object.isFrozen(exec.arguments)).toBe(true)
return next()
})
ctx.on('tools/execute', (exec, next) => {
expect(Reflect.set(exec, 'name', 'danger')).toBe(false)
tokens.add(exec.token)
return next()
})
ctx.on('tools/post-execute', (exec, _result, next) => {
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
tokens.add(exec.token)
return next()
})
scope.ctx.on('tools/result', () => { scopedResults += 1 })
@@ -281,6 +333,8 @@ describe('scoped execution dispatch', () => {
expect(safeArguments).not.toBe(callerArguments)
expect(Object.isFrozen(safeArguments)).toBe(true)
expect(callerArguments).toEqual({ source: true })
// One token for danger and one shared by every phase of safe.
expect(tokens.size).toBe(2)
expect({ safeCalls, dangerCalls, scopedResults }).toEqual({
safeCalls: 1,
dangerCalls: 0,
@@ -353,23 +407,114 @@ describe('scoped execution dispatch', () => {
expect(callerArguments.invalid).toBeTypeOf('function')
})
it('rejects a forged mutable parent token without exposing it to final observers', async () => {
it('reads a stateful parent accessor once before policy, dispatch, and result observation', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
const forged = { mutable: true } as unknown as ToolExecutionToken
let observedParent: ToolExecutionToken | undefined = forged
ctx.on('tools/result', (exec) => { observedParent = exec.parent })
const result = await ctx.tools.execute({
callId: CallId('forged-parent'), name: 't', arguments: {}, parent: forged,
const observed: (ToolExecutionToken | undefined)[] = []
ctx.tools.register({
...tool('t'),
execute: (_args, exec) => {
observed.push(exec.parent)
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
},
})
ctx.on('tools/pre-execute', (exec, next) => {
observed.push(exec.parent)
return next()
})
ctx.on('tools/execute', (exec, next) => {
observed.push(exec.parent)
return next()
})
ctx.on('tools/result', (exec) => { observed.push(exec.parent) })
const forged = { fake: true } as unknown as ToolExecutionToken
let parentReads = 0
const input = {
callId: CallId('stateful-parent'),
name: 't',
arguments: {},
get parent(): ToolExecutionToken | undefined {
parentReads += 1
return parentReads === 1 ? undefined : forged
},
} as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(false)
expect(parentReads).toBe(1)
expect(observed).toEqual([undefined, undefined, undefined, undefined])
})
it('uses one input snapshot for the normalized error shell', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'accepted')
const driftAgent = { id: 'drift' as AgentId } as Agent
ctx.tools.register(tool('parent'))
ctx.tools.register(tool('t'))
let parent!: ToolExecutionToken
const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
const acceptedSignal = new AbortController().signal
const driftSignal = new AbortController().signal
const forged = { fake: true } as unknown as ToolExecutionToken
const reads = { callId: 0, name: 0, arguments: 0, agent: 0, parent: 0, signal: 0 }
const input = {
get callId() { reads.callId += 1; return CallId('unstable-error') },
get name() { reads.name += 1; return 't' },
get arguments(): unknown { reads.arguments += 1; return { invalid: () => undefined } },
get agent() { reads.agent += 1; return reads.agent === 1 ? key : driftAgent },
get parent() { reads.parent += 1; return reads.parent <= 2 ? parent : forged },
get signal() { reads.signal += 1; return reads.signal === 1 ? acceptedSignal : driftSignal },
} as ToolExecutionInput
let observed: Readonly<ToolExecution> | undefined
let scopedObserved = 0
ctx.on('tools/result', (exec) => { observed = exec })
scope.ctx.on('tools/result', () => { scopedObserved += 1 })
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{
type: 'text', text: 'Error: tool execution parent must be a registry-minted opaque token',
}])
expect(observedParent).toBeUndefined()
expect(Object.isFrozen(forged)).toBe(false)
expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 })
expect(scopedObserved).toBe(1)
expect(observed).toMatchObject({
callId: CallId('unstable-error'),
name: 't',
agent: key,
parent,
signal: acceptedSignal,
})
expect(Object.isFrozen(observed)).toBe(true)
})
it('normalizes a throwing arguments accessor without rereading it or losing the final notification', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
let argumentReads = 0
let observed = 0
ctx.on('tools/result', (exec, result) => {
observed += 1
expect(exec.arguments).toBeUndefined()
expect(result.isError).toBe(true)
})
const input = {
callId: CallId('throwing-arguments'),
name: 't',
get arguments(): unknown {
argumentReads += 1
throw new Error('getter exploded')
},
} as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }])
expect(argumentReads).toBe(1)
expect(observed).toBe(1)
})
it.each([
@@ -408,7 +553,7 @@ describe('scoped execution dispatch', () => {
expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
})
it('rejects arguments that change to non-JSON data while being snapshotted', async () => {
it('reads nested arguments once into the executed snapshot', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
let reads = 0
@@ -421,12 +566,11 @@ describe('scoped execution dispatch', () => {
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
})
expect(reads).toBe(1)
expect(result).toEqual({
callId: CallId('unstable-arguments'),
content: [{
type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data',
}],
isError: true,
content: [{ type: 'text', text: 'ran:t' }],
isError: false,
})
})

View File

@@ -7,7 +7,7 @@ import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@de
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult, type ToolGuard,
type ToolExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -135,33 +135,6 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true)
})
it('normalizes a result that changes to non-JSON data while being snapshotted', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let reads = 0
const hostileBlock = Object.defineProperty({ type: 'text' }, 'text', {
enumerable: true,
get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
})
ctx.on('tools/execute', async exec => ({
callId: exec.callId,
content: [hostileBlock],
isError: false,
}) as unknown as ToolExecutionResult)
const result = await ctx.tools.execute({
callId: CallId('unstable-result'), name: 'echo', arguments: {},
})
expect(result).toEqual({
callId: CallId('unstable-result'),
content: [{
type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult',
}],
isError: true,
})
})
it('returns isError results for unknown tools and throwing tools', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -229,85 +202,6 @@ describe('ToolRegistry', () => {
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
})
it.each([
{
name: 'non-object decision',
replacement: null,
message: 'tools/pre-execute must return a PreToolDecision object',
},
{
name: 'unknown decision kind',
replacement: { kind: 'permit' },
message: 'tools/pre-execute must return an allow, deny, or ask decision',
},
{
name: 'allow decision carrying extra fields',
replacement: { kind: 'allow', reason: 'smuggled' },
message: 'tools/pre-execute allow decision must contain only kind',
},
{
name: 'deny decision without a reason',
replacement: { kind: 'deny' },
message: 'tools/pre-execute deny decision must contain only kind and a string reason',
},
{
name: 'deny decision with a non-string reason',
replacement: { kind: 'deny', reason: 42 },
message: 'tools/pre-execute deny decision must contain only kind and a string reason',
},
{
name: 'ask decision with a non-string reason',
replacement: { kind: 'ask', reason: true },
message: 'tools/pre-execute ask decision must contain only kind and an optional string reason',
},
{
name: 'ask decision carrying extra fields',
replacement: { kind: 'ask', cache: true },
message: 'tools/pre-execute ask decision must contain only kind and an optional string reason',
},
])('fails closed on a malformed tools/pre-execute $name', async ({ replacement, message }) => {
const ctx = await setup()
let bodyCalls = 0
const observed: ToolExecutionResult[] = []
ctx.tools.register({
...echoTool,
async execute() {
bodyCalls += 1
return []
},
})
ctx.on('tools/pre-execute', async () => replacement as unknown as PreToolDecision)
ctx.on('tools/result', (_exec, result) => { observed.push(result) })
const result = await ctx.tools.execute({
callId: CallId('malformed-pre'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
expect(bodyCalls).toBe(0)
expect(observed).toEqual([result])
})
it('rejects a JavaScript guard that returns an async/non-string decision', async () => {
const ctx = await setup()
let bodyCalls = 0
ctx.tools.register({
...echoTool,
async execute() {
bodyCalls += 1
return []
},
})
ctx.tools.guard((() => Promise.resolve('late denial')) as unknown as ToolGuard)
const result = await ctx.tools.execute({ callId: CallId('bad-guard'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]?.type === 'text' && result.content[0].text)
.toContain('tools.guard() must return')
expect(bodyCalls).toBe(0)
})
it('an ask decision degrades to deny when no approval seam is mounted', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -484,51 +378,6 @@ describe('ToolRegistry', () => {
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
})
it('a post-execute listener cannot mutate any nested part of the dispatched result', async () => {
// The decision is the only sanctioned channel to change the outcome.
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async (_exec, next) => {
await next()
return {
callId: CallId('c1'),
content: [{ type: 'text', text: 'original' }],
isError: true,
error: { name: 'OriginalError', code: 'ORIGINAL' },
meta: { nested: { label: 'original' } },
}
})
ctx.on('tools/post-execute', async (_exec, result, next) => {
const mutable = result as {
callId: string
isError: boolean
error?: { name: string; code: string }
content: { type: 'text'; text: string }[]
meta?: { nested: { label: string } }
}
mutable.callId = 'hijacked'
mutable.isError = false
if (mutable.error) {
mutable.error.name = 'Evil'
mutable.error.code = 'EVIL'
}
mutable.content[0]!.text = 'MUTATED'
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
if (mutable.meta) mutable.meta.nested.label = 'MUTATED'
return next() // delegate to the default accept — no decision-level override
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'OriginalError', code: 'ORIGINAL' })
expect(result.content).toHaveLength(1) // the in-place push did not leak in
expect(result.content[0]).toMatchObject({ text: 'original' })
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
expect(result.meta).toEqual({ nested: { label: 'original' } })
})
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -707,87 +556,20 @@ describe('ToolRegistry', () => {
})
})
it('normalizes malformed tools/execute results instead of treating them as success', async () => {
it('normalizes a tools/execute result with the wrong call id', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let observedError: boolean | undefined
ctx.on('tools/execute', async (_exec, next) => {
await next()
return {} as ToolExecutionResult
})
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
const result = await ctx.tools.execute({ callId: CallId('malformed'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: tools/execute must return a ToolExecutionResult with content[] and boolean isError',
})
expect(observedError).toBe(true)
})
it.each([
{
name: 'non-object result',
replacement: null,
message: 'tools/execute must return a ToolExecutionResult object',
},
{
name: 'wrong call id',
replacement: { callId: CallId('other'), content: [], isError: false },
message: 'tools/execute returned callId "other" for authoritative call "malformed-shape"',
},
])('normalizes a tools/execute $name', async ({ replacement, message }) => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => replacement as ToolExecutionResult)
ctx.on('tools/execute', async () => ({ callId: CallId('other'), content: [], isError: false }))
const result = await ctx.tools.execute({
callId: CallId('malformed-shape'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
})
it('normalizes malformed tools/post-execute decisions', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({ kind: 'accept', content: 'not blocks' }) as unknown as PostToolDecision)
const result = await ctx.tools.execute({ callId: CallId('malformed-post'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: tools/post-execute accept content must be an array',
text: 'Error: tools/execute returned callId "other" for authoritative call "malformed-shape"',
})
})
it.each([
{
name: 'non-object decision',
replacement: null,
message: 'tools/post-execute must return a PostToolDecision object',
},
{
name: 'block without feedback blocks',
replacement: { kind: 'block', feedback: 'not blocks' },
message: 'tools/post-execute block feedback must be an array',
},
{
name: 'unknown decision kind',
replacement: { kind: 'defer' },
message: 'tools/post-execute must return an accept or block decision',
},
])('normalizes a tools/post-execute $name', async ({ replacement, message }) => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => replacement as unknown as PostToolDecision)
const result = await ctx.tools.execute({
callId: CallId('malformed-post-shape'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
})
it('returns an isError result when a tools/execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -865,59 +647,12 @@ describe('ToolRegistry', () => {
}])
})
it.each([
['Map', new Map([['mutable', true]])],
['class instance', new (class Parameters { value = 1 })()],
])('rejects cloneable non-JSON tool parameters (%s) without registry residue', async (_kind, parameters) => {
it('rejects a non-positive or non-finite registration timeout', async () => {
const ctx = await setup()
const definition = {
...echoTool,
name: 'invalid-parameters',
parameters,
} as unknown as typeof echoTool
expect(() => ctx.tools.register(definition)).toThrow(
'tool parameters must be losslessly JSON-serializable',
)
expect(ctx.tools.get('invalid-parameters')).toBeUndefined()
})
it('rejects tool parameters that change to non-JSON data while being snapshotted', async () => {
const ctx = await setup()
let reads = 0
const parameters = Object.defineProperty({}, 'properties', {
enumerable: true,
get: () => ++reads === 1 ? {} : new Map([['mutable', true]]),
})
expect(() => ctx.tools.register({
...echoTool,
name: 'unstable-parameters',
parameters,
})).toThrow('tool parameters must be stable losslessly JSON-serializable data')
expect(ctx.tools.get('unstable-parameters')).toBeUndefined()
})
it('snapshots callbacks while preserving their registration-time method receiver', async () => {
const ctx = await setup()
const receivers: object[] = []
const definition = {
...echoTool,
name: 'callback-snapshot',
async execute() {
receivers.push(this)
return [{ type: 'text' as const, text: 'original' }]
},
}
ctx.tools.register(definition)
definition.execute = async () => [{ type: 'text' as const, text: 'replacement' }]
const result = await ctx.tools.execute({
callId: CallId('callback-snapshot'), name: definition.name, arguments: {},
})
expect(receivers).toEqual([definition])
expect(result.content).toEqual([{ type: 'text', text: 'original' }])
expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 }))
.toThrow('timeoutMs must be a positive finite number')
expect(() => ctx.tools.register({ ...echoTool, name: 'infinite-timeout', timeoutMs: Number.POSITIVE_INFINITY }))
.toThrow('timeoutMs must be a positive finite number')
})
it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
@@ -968,9 +703,16 @@ describe('ToolRegistry', () => {
})
it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => {
// The registry-disposer convention (set by agents.register): the returned function IS the
// cordis effect disposer, so a composite (generator) effect that yields it has the
// unregistration run at that yield's LIFO position on owner unload.
// The registry-disposer convention (set by agents.register): the returned
// function IS the cordis effect disposer, so a composite (generator)
// effect that yields it has the unregistration run at that yield's LIFO
// position on owner unload. A wrapper would leave the inner effect
// disposing as a CONCURRENT SIBLING of the composite; the async probe
// below (disposed first, LIFO) yields the event loop exactly like the
// agent factory's stop-and-drain link, and a sibling unregistration fires
// in that window — the probe would observe the tool already gone. Pins
// the convention for the whole register-method family (system-prompt
// registrars, registerProvider, setFactory share the same return).
const ctx = await setup()
const order: string[] = []
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
@@ -1670,9 +1412,10 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
presentCall: args => ({ card: 'generic', title: args.path }),
presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }),
})
// Unlike execute (which throws ToolArgsError on a mismatch), the display methods
// soft-validate and fall back to undefined so a UI never crashes replaying an old/foreign
// log entry.
// Unlike execute (which throws ToolArgsError on a mismatch), the display
// methods soft-validate and fall back to undefined so a UI never crashes
// replaying an old/foreign log entry. The ToolDefinition methods take
// `unknown`, so malformed shapes pass without a cast.
expect(tool.presentCall?.({})).toBeUndefined()
expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
})

View File

@@ -29,4 +29,4 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
## Write path
The plugin generalizes the example `session-jsonl.ts`: it subscribes to `session/created` (capture the header; persist a fork's seed once), `session/event` (snapshot each event when buffering — the live `session.events` object is mutable), and `session/flush`/dispose (drain the write-behind buffer through `append`). A per-session write cursor means a resumed session never re-appends already-stored events. Existing live sessions are seeded on plugin apply (HMR does not replay `session/created`). All backend operations for one session are serialized, and disposal awaits quiescence (every init + final drain) before returning, so no write lands after teardown.
The plugin generalizes the example `session-jsonl.ts`: it subscribes to `session/created` (capture the header; persist a fork's seed once), `session/event` (copy each already-frozen event into the persistence-owned write-behind buffer), and `session/flush`/dispose (drain that buffer through `append`). A per-session write cursor means a resumed session never re-appends already-stored events. Existing live sessions are seeded on plugin apply (HMR does not replay `session/created`). All backend operations for one session are serialized, and disposal awaits quiescence (every init + final drain) before returning, so no write lands after teardown.

View File

@@ -4,7 +4,7 @@ import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } fr
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { encodeSegment, logPath, scanLog, sessionDir } from '../src/format.ts'
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
@@ -13,6 +13,13 @@ import { runCoordinatorContract, type CoordinatorFixture } from '../../session-p
let root: string
const dirs: string[] = []
type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
/** Test-only mutable view used to verify that backends detach returned/caller metadata. */
function mutableHeader(header: SessionHeader): MutableSessionHeader {
return header
}
async function freshRoot(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
dirs.push(dir)
@@ -250,7 +257,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
const loaded = await ctx.sessionPersistence.load(m.id)
// A consumer mutates the returned meta's cwd. The backend's stored pathing
// metadata must be unaffected, so a later append still finds the right log.
loaded.meta.cwd = '/evil'
mutableHeader(loaded.meta).cwd = '/evil'
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
@@ -415,7 +422,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const m = meta('create-snap', '/orig')
const p = ctx.sessionPersistence.create(m)
// Mutate the caller's meta object immediately after calling create.
m.cwd = '/mutated'
mutableHeader(m).cwd = '/mutated'
await p
await ctx.sessionPersistence.append(SessionId('create-snap'), oneTurnLog())
// The log materialized under the ORIGINAL cwd, not the mutated one.

View File

@@ -27,4 +27,4 @@ interface Config {
## Write path
Like the JSONL backend, the plugin also installs the `session/event` → buffer → `session/flush` drain: it snapshots each event when buffered (the live `session.events` object is mutable), persists a fork's seed once on `session/created`, keeps a per-session write cursor so a resumed session never re-appends stored events, and seeds existing live sessions on apply (HMR does not replay `session/created`). Dispose awaits every in-flight init + final drain and then closes the database, so no write lands after teardown.
Like the JSONL backend, the plugin also installs the `session/event` → buffer → `session/flush` drain: it copies each already-frozen event into a persistence-owned buffer, persists a fork's seed once on `session/created`, keeps a per-session write cursor so a resumed session never re-appends stored events, and seeds existing live sessions on apply (HMR does not replay `session/created`). Dispose awaits every in-flight init + final drain and then closes the database, so no write lands after teardown.

View File

@@ -17,7 +17,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded.
- **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq.
- **JSON-serializable data.** `append` rejects non-serializable `event.data`; backends snapshot each event when buffering (the live `session.events` object is mutable).
- **JSON-serializable data.** `append` materializes each direct/replay batch through the shared one-pass lossless-JSON boundary. Live `Session` events are already deep-frozen, but the write coordinator still copies each event into a persistence-owned buffer.
- **Durability.** `append` returns only once the batch is durable.
## The write coordinator

View File

@@ -1,18 +1,45 @@
/**
* The backend-agnostic write-path orchestration shared by every first-party {@link
* SessionPersistence} backend.
* The backend-agnostic write-path orchestration shared by every first-party
* {@link SessionPersistence} backend.
*
* Every durable backend needs the same orchestration: the in-memory bookkeeping
* (the per-id state, the write-behind buffers, the per-id serialization chains,
* the per-session init promises), the `session/event` → buffer → `session/flush`
* drain, lazy materialization, crash-tail repair on load, the four
* `session/created` adoption cases (new / HMR-adopt / collision /
* ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives are
* backend-specific (file bytes for `dsh-session-persistence-jsonl`, `node:sqlite`
* rows for `dsh-session-persistence-sqlite`). {@link PersistenceCoordinator} owns
* the orchestration; a backend supplies the storage primitives as a small
* {@link PersistenceBackend} hook object.
*
* The abstract {@link SessionPersistence} service's public API is independent of
* this: a backend IS a `SessionPersistence` (its four public methods delegate to
* a coordinator it composes), so a third-party backend MAY implement the service
* directly without using the coordinator at all.
*
* See the write-coordinator RFC (docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)
* for the design rationale (composition over inheritance, the opaque torn marker).
*
* @module @deepseek-ai/dsh-session-persistence/coordinator
*/
import { Context } from 'cordis'
import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import { assertSerializable, seedCoversPrefix } from './index.ts'
import { seedCoversPrefix } from './index.ts'
/**
* A stored session's durable prefix as read back from a backend: its {@link SessionHeader},
* the preserved (seq-contiguous, parseable) event prefix, and an OPAQUE `tornMarker` that is
* present iff a never-committed torn tail must be truncated before further writes.
* A stored session's durable prefix as read back from a backend: its
* {@link SessionHeader}, the preserved (seq-contiguous, parseable) event prefix,
* and an OPAQUE `tornMarker` that is present iff a never-committed torn tail must
* be truncated before further writes.
*
* The coordinator NEVER inspects `tornMarker`'s value — it only tests
* `!== undefined` (is there a tail to repair?) and passes the value back to
* {@link PersistenceBackend.commitRepair}. Each backend chooses its own marker
* type: the JSONL backend uses the byte offset to truncate to, the SQLite
* backend uses the seq to delete from (both happen to be `number`).
*/
export interface StoredPrefix<TornMarker = unknown> {
meta: SessionHeader
@@ -85,11 +112,16 @@ interface SessionState {
/** The next seq the backend expects to append (the stored log length). */
cursor: number
/**
* Whether the backend has physically written this session (a JSONL file / SQLite row
* exists). `create()` registers state LAZILY — cursor 0, materialized false, nothing on disk
* — so an empty session leaves no artifact and the FIRST `appendBatch` writes the header +
* its events in one transaction (the "a row exists ⇔ it has events" invariant `list` relies
* on; a separate up-front materialize could crash leaving a row with zero events).
* Whether the backend has physically written this session (a JSONL file /
* SQLite row exists). `create()` registers state LAZILY — cursor 0,
* materialized false, nothing on disk — so an empty session leaves no
* artifact and the FIRST `appendBatch` writes the header + its events in ONE
* transaction (the "a row exists ⇔ it has events" invariant `list`
* relies on; a separate up-front materialize could crash leaving a row with
* zero events). The flag is the only signal that distinguishes a session
* registered-but-never-written from one durably present, which the reclaim
* path needs (an abandoned id with no artifact AND no buffered events is free
* to reuse; a materialized one is a real collision).
*/
materialized: boolean
/**
@@ -154,14 +186,18 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/**
* Register a new session's metadata (lazy: no physical write until the first
* {@link append}). Rejects if the id is already tracked or already persisted.
* @param meta - the immutable header (id, version, cwd, lineage) to record; snapshotted at call time.
* @param meta - the header (id, version, cwd, lineage) to record; materialized
* as a detached lossless-JSON snapshot at call time.
*/
create(meta: SessionHeader): Promise<void> {
// Snapshot the metadata at call time: the op runs later (behind the
// per-session chain) and the snapshot is stored as the lazy state, so keeping
// the caller's object by reference would let a later mutation of `id`/`cwd`
// register under one key but materialize under a different path/header.
const snapshot: SessionHeader = { ...meta }
const snapshot = snapshotJsonValue(meta)
if (snapshot === undefined) {
return Promise.reject(new TypeError('session metadata must be losslessly JSON-serializable'))
}
return this.serialize(snapshot.id, () => this.createCore(snapshot))
}
@@ -180,22 +216,25 @@ export class PersistenceCoordinator<TornMarker = unknown> {
this.states.set(meta.id, { meta, cursor: 0, materialized: false })
}
// `async` so the synchronous validate/clone below reject (not throw) per the
// Promise<void> contract — callers use `await expect(...).rejects`.
// `async` so synchronous materialization failures below reject (not throw) per
// the Promise<void> contract — callers use `await expect(...).rejects`.
/**
* Durably persist a batch of events. Honors the append-only and contiguous-seq
* contracts; rejects non-JSON-serializable `event.data`.
* @param id - the session the batch belongs to.
* @param events - the contiguous batch to persist, in seq order; deep-cloned at call time.
* @param events - the contiguous batch to persist, in seq order; materialized
* as a detached lossless-JSON snapshot at call time.
*/
async append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
// Validate serializability BEFORE cloning so a bad event surfaces the typed
// error rather than an opaque DataCloneError from structuredClone.
assertSerializable(events)
// Deep-snapshot the batch HERE, before the op waits behind the per-session chain: a caller
// that mutates a live array (e.g. session.events) — or an event inside it — before the op
// runs would otherwise have those changes persisted.
const batch = events.map(e => structuredClone(e))
// Validate and deep-snapshot the complete batch HERE, in one traversal,
// before the op waits behind the per-session chain. A check followed by
// structuredClone would reread accessors and could sanitize an exotic value
// into an apparently valid record; the single-pass materializer makes the
// checked value exactly the value persisted.
const batch = snapshotJsonValue(events)
if (batch === undefined) {
throw new TypeError('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data')
}
return this.serialize(id, () => this.appendCore(id, batch))
}
@@ -235,9 +274,11 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const { meta, events, tornMarker } = stored
this.assertVersion(meta)
// Crash-recovery: if the log ended mid-turn (real, preserved events but no closing
// turn/end), close it durably DURING load so disk, the returned log, and the cursor all
// agree.
// Crash-recovery: if the log ended mid-turn (real, preserved events but no
// closing turn/end), close it durably DURING load so disk, the returned log,
// and the cursor all agree. The interrupted turn's real events are preserved,
// never truncated (a turn can be huge — the session-persistence RFC); only a
// never-fully-written torn tail fragment is discarded.
const closers = interruptedTurnClosers(events)
const balanced = [...events, ...closers]
@@ -253,7 +294,12 @@ export class PersistenceCoordinator<TornMarker = unknown> {
return { meta, events: balanced }
}
// NOTE: there is deliberately no coordinator `list()`.
// NOTE: there is deliberately no coordinator `list()`. Listing needs none of
// the coordinator's orchestration (no per-id serialization, no cursor, no
// in-memory state) — it is a pure read of stored metadata. A backend's public
// `list()` IS the {@link PersistenceBackend.list} hook (one method); routing it
// through the coordinator would only forward to that same hook, so the
// coordinator stays out of the listing path entirely.
// --- per-id serialization + adoption helpers ---
@@ -298,9 +344,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
// promise so flush/dispose can await it (onCreated is async).
ctx.on('session/created', (session) => { void this.initFor(session) })
// Snapshot + buffer every event (the live object is mutable; clone so a later
// in-place mutation cannot rewrite a buffered event). Serializability is
// guaranteed at the source (Session.append), so structuredClone is safe.
// Session emits an owned frozen event. Keep a persistence-owned copy anyway
// so the write-behind queue owns exactly the record it will flush rather than
// retaining a product-layer record by identity. Serializability is guaranteed
// at the source, so structuredClone is safe.
ctx.on('session/event', (session, event) => {
let buffer = this.buffers.get(session)
if (!buffer) this.buffers.set(session, buffer = [])
@@ -351,12 +398,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const existing = this.inits.get(session)
if (existing) return existing
// Snapshot the seed SYNCHRONOUSLY — initFor runs inside the `session/created`
// emit, before any later `append` adds non-seed events. A clone freezes it
// against later mutation of the live event objects.
// emit, before any later append invalidates the public array snapshot. Events
// are already frozen; cloning gives persistence independent ownership.
const seed = session.events.map(e => structuredClone(e))
const p = this.onCreated(session, seed)
// Attach a no-op rejection handler so a failing init does not surface as an unhandled
// rejection if no flush observes `p` before it rejects.
// Attach a no-op rejection handler so a failing init does not surface as an
// unhandled rejection if no flush observes `p` before it rejects. The REAL
// error is still delivered: flush/dispose await the same `p` from the map.
p.catch(() => { /* observed by flush/dispose via the stored promise */ })
this.inits.set(session, p)
return p
@@ -377,6 +425,15 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/**
* On session/created: sync the backend's in-memory state to a live Session.
*
* Cases, by whether this backend tracks the id and whether an artifact exists:
* 1. Already tracked → no-op (or claim ownerless state if the seed matches,
* or reclaim a truly-abandoned id, else reject as a collision).
* 2. Not tracked, an artifact EXISTS at this cwd and is a seq-aligned PREFIX
* of the live events → ADOPT it (HMR/reload), persisting any live suffix.
* 3. Not tracked, an artifact EXISTS but is NOT a prefix → REJECT (collision).
* 4. Not tracked and NO artifact → a genuinely new session: register meta
* (lazy) and persist its seed once.
*/
private async onCreated(session: Session, seed: readonly SessionEvent[]): Promise<void> {
const id = session.header.id
@@ -386,7 +443,16 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */
if (tracked.owner === session) return
if (tracked.owner === undefined) {
// Ownerless state from the public create()/load() API.
// Ownerless state from the public create()/load() API. The FIRST live
// session claims it — but ONLY if BOTH the cwd scope and the seed match.
// The cwd guard mirrors case-2's cwd-scoped loadLive(): a same-id
// ownerless artifact at a DIFFERENT cwd is a collision, not a claim
// (claiming it would append the live cwd's events under the stored
// header's cwd, the exact cross-cwd corruption the loadLive scope
// prevents). The seed guard then ensures the live events reproduce the
// persisted prefix (else a fresh, unrelated session reusing the id would
// have its seq 0..cursor-1 events filtered as already-written and
// grafted on).
if (tracked.meta.cwd !== session.header.cwd) {
throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
}
@@ -460,8 +526,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
private async flush(session: Session): Promise<void> {
// Wait for the session's init (onCreated) so the state/cursor and any fork-seed persistence
// are in place before draining.
// Wait for the session's init (onCreated) so the state/cursor and any
// fork-seed persistence are in place before draining. Awaiting the same
// promise initFor stored also surfaces an init failure (e.g. a collision)
// here, where the caller of session/flush observes it.
await this.inits.get(session)
// Serialize the WHOLE drain (read cursor → append → splice) on the per-session
// chain so two concurrent flushes cannot both read the same cursor and
@@ -473,7 +541,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private async drain(session: Session): Promise<void> {
const buffer = this.buffers.get(session)
if (!buffer?.length) return
// Copy WITHOUT removing: the buffer is the only durable-pending copy of these events.
// Copy WITHOUT removing: the buffer is the only durable-pending copy of these
// events. Drain it only AFTER the append commits; events pushed during the
// await sit past batch.length and survive the prefix splice, so a
// retry/dispose re-drains the rest.
const batch = buffer.slice()
const state = this.states.get(session.header.id)
// Only append events at or beyond the write cursor (a resumed session's seed

View File

@@ -1,12 +1,28 @@
/**
* The durable session-persistence seam (`ctx.sessionPersistence`): an abstract service
* defining what a persistence backend does — durably store, reload, and list sessions —
* without saying how.
* The durable session-persistence seam (`ctx.sessionPersistence`): an abstract
* service defining WHAT a persistence backend does — durably store, reload,
* and list sessions — without saying HOW. Implementations subclass
* {@link SessionPersistence} and register themselves as the
* `sessionPersistence` service; `@deepseek-ai/dsh-session-persistence-jsonl`
* (an append-only JSONL log per session) is the first and
* `@deepseek-ai/dsh-session-persistence-sqlite` (`node:sqlite`, one row per
* event) is a second that validates the seam is backend-agnostic by passing
* the same `runPersistenceContract` suite. Further backends swap in an object
* store or a remote service without touching the consumers (the write-path
* plugin, the agent-loop resume seam).
*
* The persisted unit IS the existing {@link SessionEvent} — there is no
* parallel "persisted message" type the log must be converted to and from
* (faithful to the event-sourced model: the log is the single source of
* truth). Metadata that is NOT replayable conversation state (format version,
* cwd, lineage, seed boundary) travels separately as {@link SessionHeader},
* which is owned by `dsh-session` and re-exported here.
*
* @module @deepseek-ai/dsh-session-persistence
*/
import { Context, Service } from 'cordis'
import { isJsonValue } from '@deepseek-ai/dsh-session'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
// Re-export the metadata vocabulary so consumers import it from the seam.
@@ -23,10 +39,12 @@ declare module 'cordis' {
}
/**
* Whether a live session's seed reproduces a persisted prefix exactly. Backends use this
* collision check to distinguish a legitimate resume/HMR rebind from a different live session
* reusing an existing session id.
* Whether a live session's seed reproduces a persisted prefix exactly. Backends
* use this collision check to distinguish a legitimate resume/HMR rebind from a
* different live session reusing an existing session id.
*
* The comparison includes the full event payload, not just seq/type/time, so a
* mutated seed cannot be grafted onto a durable log with the same envelope.
* @param seed - the live session's creation-time event snapshot.
* @param prefix - the persisted prefix the seed must reproduce.
* @returns `true` when the prefix fits within the seed and every event matches by JSON text.
@@ -40,23 +58,46 @@ export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly
}
/**
* Reject non-JSON-serializable event data before a backend serializes a batch.
* Live session appends already enforce this; persistence append paths also
* accept replay/fork batches that may bypass a live session instance.
* @param events - the batch to validate; throws naming the offending event's type and seq.
* Reject a batch that is not wholly losslessly JSON-serializable. Live session
* appends already enforce this; persistence append paths also accept replay or
* direct batches that may bypass a live session instance. Validation uses the
* same one-pass materializer as the coordinator, so getters are read once.
* @param events - the complete event batch to validate.
*/
export function assertSerializable(events: readonly SessionEvent[]): void {
for (const event of events) {
if (!isJsonValue(event.data)) {
throw new Error(`event "${event.type}" carries non-JSON-serializable data (seq ${event.seq})`)
}
const snapshot = snapshotJsonValue(events)
if (snapshot === undefined) {
throw new Error('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data')
}
}
/**
* Abstract durable session-persistence service. Subclass, implement the abstract methods, and
* load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation
* per context; loading a second throws, cordis' standard duplicate-service behavior).
* Abstract durable session-persistence service. Subclass, implement the
* abstract methods, and load the subclass as a plugin — it registers as
* `ctx.sessionPersistence` (one implementation per context; loading a second
* throws, cordis' standard duplicate-service behavior).
*
* Contracts every implementation MUST honor (a DB backend asserts them inside
* a transaction; a file backend appends at EOF):
*
* - **Append-only; a crashed turn is closed, not truncated.** Committed events
* — those at or below a flushed `turn/end` — are never rewritten. A crash can
* leave an unclosed final turn whose events are real (and possibly large);
* {@link load} preserves them and closes the orphaned turn with synthetic
* boundary events (see {@link load}). Only a never-fully-written torn tail
* fragment is discarded.
* - **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`.
* {@link load} rejects a parse error or a `seq` gap in the COMMITTED region
* (unloadable); {@link append}'s first event `seq` MUST equal the backend's
* stored next-seq (after `load` has balanced any interrupted turn).
* - **JSON-serializable events.** `SessionEventMap` is merge-extensible, so
* {@link append} materializes each complete batch through the shared
* lossless-JSON boundary before buffering it. The public `session.events`
* view is immutable, but persistence still snapshots direct/replay callers at
* this independent trust boundary.
* - **Durability.** {@link append} returns only once the batch is durable
* (the file backend fsyncs; a DB commits). {@link create} MAY defer the
* physical write until the first {@link append} (lazy materialization).
*/
export abstract class SessionPersistence extends Service {
constructor(ctx: Context) {
@@ -84,10 +125,26 @@ export abstract class SessionPersistence extends Service {
abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>
/**
* Reload a session: its {@link SessionHeader} plus the event log up to the last durable
* checkpoint. Returns `meta` AND `events` so the live session is reconstructed with its
* `cwd`/lineage, not just its log.
* Reload a session: its {@link SessionHeader} plus the event log up to the last
* durable checkpoint. Returns `meta` AND `events` so the live session is
* reconstructed with its `cwd`/lineage, not just its log.
*
* The loop only flushes at `turn/end`, so a crash can leave a durable log
* whose final turn never closed: real, fully-written events sit after the last
* `turn/end`. Those events are PRESERVED — a single turn can be huge in a
* long-horizon task, so truncating it would destroy real work — and `load`
* CLOSES the orphaned turn by durably appending the minimal synthetic boundary
* events: an error `tool/result` for every `tool-call` the crash left
* unanswered (so the rehydrated history is a valid provider transcript — a
* dangling assistant tool-call is otherwise rejected), then a `step/end` if a
* step was open, then a `turn/end` carrying the `{ kind: 'interrupted' }`
* reason. The returned `events` therefore end on a balanced `turn/end` and are
* immediately usable as a session seed. Only a never-fully-written TORN tail
* fragment (a half-written final record) is discarded. Returned events are
* contiguous (`events[i].seq === i`); a parse error or a `seq` gap in the
* COMMITTED region (at or before the last real `turn/end`) makes the session
* unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for
* the crash-recovery contract.
* @param id - the persisted session to reload.
* @returns the header plus the event log, ending on a balanced `turn/end` —
* immediately usable as a session seed.

View File

@@ -237,7 +237,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
const events = [
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: bad } },
] as unknown as SessionEvent[]
await expect(persistence.append(mi.id, events)).rejects.toThrow(/user\/message/)
await expect(persistence.append(mi.id, events)).rejects.toThrow(/losslessly JSON-serializable/)
}
} finally {
await dispose()

View File

@@ -119,14 +119,15 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('snapshot-on-buffer: mutating an event after session/event does not corrupt the persisted copy', async () => {
it('source-frozen events cannot be mutated after buffering and persist unchanged', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } })
const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
// Mutate the live event object AFTER it was buffered by session/event.
;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
expect(() => {
;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
}).toThrow(TypeError)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)

View File

@@ -143,6 +143,17 @@ describe('SessionPersistence service registration', () => {
expect(loaded.events).toHaveLength(6)
await fiber.dispose()
})
it('rejects non-JSON session metadata before registering lazy state', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(MemoryPersistence)
const invalid = { ...meta('invalid-meta'), createdAt: 1n as unknown as number }
await expect(ctx.sessionPersistence.create(invalid))
.rejects.toThrow('session metadata must be losslessly JSON-serializable')
await fiber.dispose()
})
})
describe('shared persistence helpers', () => {
@@ -172,10 +183,10 @@ describe('shared persistence helpers', () => {
expect(() => { assertSerializable(oneTurnLog()) }).not.toThrow()
})
it('rejects non-JSON-serializable event data with type and seq context', () => {
it('rejects a batch containing non-JSON-serializable event data', () => {
const bad = [
{ type: 'user/message', seq: 0, time: 1, data: { content: 1n } },
] as unknown as SessionEvent[]
expect(() => { assertSerializable(bad) }).toThrow(/"user\/message".*seq 0/)
expect(() => { assertSerializable(bad) }).toThrow(/batch is not losslessly JSON-serializable/)
})
})

View File

@@ -8,26 +8,28 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Public API
- `ctx.skills.registerProvider(provider): () => Promise<void> | void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry snapshots the name and callback identities at registration, so replacing those fields later cannot change lookup or HMR cleanup; callbacks remain bound to the original provider object and can still read its mutable state. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
- `ctx.skills.list({ cwd?, signal? })` Returns model-invocable skill summaries for the current workspace, merged across providers and sorted by name.
- `ctx.skills.get(name, { cwd?, signal? })` Returns the full winning skill, including disabled-for-model skills.
- `ctx.skills.register(skill): () => Promise<void> | void` Registers a runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
- `ctx.skills.registerProvider(provider): () => Promise<void> | void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name.
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills.
- `ctx.skills.register(skill): () => Promise<void> | void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
### Config
| Field | Default | Meaning |
|---|---|---|
| `collectCacheMaxEntries` | `128` | Maximum completed cwd/provider catalog snapshots kept in memory. |
| `collectCacheMaxEntries` | `128` | Maximum completed cwd/provider catalogs kept in memory. |
## Provider Contract
A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Registration copies `name` and binds the current `list` and `get` methods once; replacing those fields on the caller-owned object later does not rewrite the live registry entry, and disposal always removes the original name. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting an uncooperative provider so agent cancellation cannot hang prefix composition. The provider later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a remote provider can store a URL, id, or version token.
A provider registers synchronously from its `apply()` and returns `readonly SkillCandidate[]` from `list(options)` when discovery is requested. The provider, lookup options, candidates, and loaded definitions are readonly same-process contracts: the registry borrows them rather than cloning, freezing, or rebinding callbacks. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting uncooperative discovery and loading work so agent cancellation cannot hang prefix composition or skill loading.
The registry validates candidate names, descriptions, ranks, and provider ownership. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers.
The registry validates parsed provider candidates before caching them and validates loaded definitions before returning them. The winning provider receives the exact candidate and opaque `locator` identity it returned from `list()`; a local provider can therefore use a file-path handle while a remote provider can use a URL, id, or version token. Callers and providers must honor the readonly contract after handing values to the registry.
Parsed candidate and loaded-definition fields are validated at the provider boundary: names/descriptions/content use their declared string types, ranks are finite numbers, and `disableModelInvocation` is boolean when present. Candidate contract violations fail fast because the provider or its parser is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers.
## Runtime Skills
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime definitions and nested resource metadata are borrowed readonly; the service only materializes the top-level definition needed to supply the default `provider`. Registration is first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
## Consumer boundary

View File

@@ -32,64 +32,65 @@ export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-d
/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
export type SkillResourceBase =
| { kind: 'directory'; path: string }
| { kind: 'url'; url: string }
| { kind: 'opaque'; description: string }
| { readonly kind: 'directory'; readonly path: string }
| { readonly kind: 'url'; readonly url: string }
| { readonly kind: 'opaque'; readonly description: string }
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */
export interface SkillSummary {
/** Kebab-case identifier used with the `skill` tool. */
name: string
readonly name: string
/** Short routing description shown to the model. */
description: string
readonly description: string
/** Optional extra routing guidance shown to the model. */
whenToUse?: string
readonly whenToUse?: string
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
disableModelInvocation?: boolean
readonly disableModelInvocation?: boolean
/** Discovery source that produced this winning skill. */
source: SkillSource
readonly source: SkillSource
/** Provider that owns this skill body. */
provider: string
readonly provider: string
/** Provider-specific base for relative resources. */
resourceBase?: SkillResourceBase
readonly resourceBase?: SkillResourceBase
}
/** Provider catalog entry used by the registry to merge and later load skills. */
export interface SkillCandidate extends SkillSummary {
/** Lower ranks win duplicate skill names before provider registration order is considered. */
rank: number
readonly rank: number
/** Opaque provider-owned handle passed back to `provider.get()`. */
locator: unknown
readonly locator: unknown
/** Absolute file path when the provider has one. */
path?: string
readonly path?: string
/** Parsed optional metadata object from provider-specific skill frontmatter. */
metadata?: Record<string, unknown>
readonly metadata?: Readonly<Record<string, unknown>>
}
/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */
export interface SkillDefinition extends SkillSummary {
/** Markdown instruction body after any provider-specific metadata removal. */
content: string
readonly content: string
/** Absolute file path when the skill came from disk. */
path?: string
readonly path?: string
/** Parsed optional metadata object from frontmatter. */
metadata?: Record<string, unknown>
readonly metadata?: Readonly<Record<string, unknown>>
}
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { provider?: string }
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider?: string }
/** Caller context used for cwd-sensitive and abortable provider work. */
export interface SkillLookupOptions {
cwd?: string | undefined
/** Workspace selector for the current lookup. */
readonly cwd?: string | undefined
/** Abort discovery or loading work for the current caller. */
signal?: AbortSignal | undefined
readonly signal?: AbortSignal | undefined
}
/** Provider interface for one source of skills, such as local directories or a remote registry. */
export interface SkillProvider {
/** Unique provider name in the `ctx.skills` registry. */
name: string
readonly name: string
/**
* List available skill candidates for the current lookup context. Provider
* plugins register synchronously during `apply()`; remote initialization,
@@ -98,20 +99,20 @@ export interface SkillProvider {
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns provider candidates with precedence ranks and opaque locators.
*/
list(options: SkillLookupOptions): Promise<SkillCandidate[]>
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>
/**
* Load a complete skill body for a previously listed candidate.
* @param candidate - the winning candidate originally returned by this provider.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns the full skill body, or `undefined` if it is no longer loadable.
*/
get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
}
/** Skill registry configuration. */
export interface Config {
/** Maximum number of completed cwd/provider catalog snapshots kept in memory. */
collectCacheMaxEntries?: number
/** Maximum number of completed cwd/provider catalogs kept in memory. */
readonly collectCacheMaxEntries?: number
}
declare module 'cordis' {
@@ -161,7 +162,7 @@ export class SkillService extends Service {
private readonly collectCacheMaxEntries: number
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
private readonly runtime = new Map<string, SkillDefinition>()
private readonly runtime = new Map<string, SkillRegistration>()
private readonly collectCache = new Map<string, IndexedCandidate[]>()
private providerRevision = 0
private nextProviderOrder = 0
@@ -174,36 +175,41 @@ export class SkillService extends Service {
}
/**
* Register a skill provider synchronously during the provider plugin's `apply()`.
*
* Register a skill provider synchronously during the provider plugin's
* `apply()`. Throws if another provider already owns the same provider name,
* including the reserved runtime provider name. Providers that need remote
* initialization do that work inside `list()` after registration. Providers
* are readonly same-process registrations: the registry borrows the provider
* object and invokes its methods directly. Effect-scoped and HMR-safe:
* disposing the caller's fiber unregisters the provider and invalidates
* cached catalogs.
* @param provider - the provider to register by `provider.name`.
* @returns the exact Cordis effect disposer that unregisters this provider;
* composite effects may yield it directly to preserve teardown ordering.
*/
registerProvider(provider: SkillProvider): () => Promise<void> | void {
// Snapshot the registration contract before entering the effect.
const snapshot: SkillProvider = Object.freeze({
name: provider.name,
list: provider.list.bind(provider),
get: provider.get.bind(provider),
})
const dispose = this.ctx.effect(function* (this: SkillService) {
if (snapshot.name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
}
if (this.providers.has(snapshot.name)) {
throw new Error(`a skill provider named "${snapshot.name}" is already registered`)
}
this.providers.set(snapshot.name, { provider: snapshot, order: this.nextProviderOrder })
this.nextProviderOrder += 1
this.invalidateCache()
const name = provider.name
if (name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
}
if (this.providers.has(name)) {
throw new Error(`a skill provider named "${name}" is already registered`)
}
const providers = this.providers
const ctx = this.ctx
const order = this.nextProviderOrder
const invalidateCache = (): void => { this.invalidateCache() }
this.nextProviderOrder += 1
const dispose = ctx.effect(function* () {
providers.set(name, { provider, order })
invalidateCache()
yield () => {
this.providers.delete(snapshot.name)
this.invalidateCache()
this.ctx.emit('skill/provider-removed', snapshot.name)
providers.delete(name)
invalidateCache()
ctx.emit('skill/provider-removed', name)
}
this.ctx.emit('skill/provider-added', snapshot)
}.bind(this), 'skills.registerProvider()')
ctx.emit('skill/provider-added', provider)
}, 'skills.registerProvider()')
return dispose
}
@@ -211,34 +217,41 @@ export class SkillService extends Service {
* Register a runtime skill contribution. Runtime registrations are treated as
* embedded provider entries with project-over-user priority. Same-name runtime
* registrations are first-wins: a duplicate logs a warning and gets a no-op
* disposer so it cannot remove the active contribution.
* disposer so it cannot remove the active contribution. Runtime definitions
* are readonly same-process registrations; the registry borrows their nested
* resource metadata.
* @param skill - the complete skill definition to expose for discovery.
* @returns the exact Cordis effect disposer that removes this runtime
* contribution and invalidates caches; composite effects may yield it
* directly to preserve teardown ordering.
*/
register(skill: SkillRegistration): () => Promise<void> | void {
const normalized = normalizeRuntimeSkill(skill)
const existing = this.runtime.get(normalized.name)
validateRuntimeSkill(skill)
const existing = this.runtime.get(skill.name)
if (existing !== undefined) {
this.ctx.logger.warn(`runtime skill "${normalized.name}" ignored because it is already registered`)
this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`)
return () => {}
}
const dispose = this.ctx.effect(function* (this: SkillService) {
this.runtime.set(normalized.name, normalized)
this.runtimeRevision += 1
this.invalidateCache()
const runtime = this.runtime
const updateRevision = (): void => { this.runtimeRevision += 1 }
const invalidateCache = (): void => { this.invalidateCache() }
const dispose = this.ctx.effect(function* () {
runtime.set(skill.name, skill)
updateRevision()
invalidateCache()
yield () => {
this.runtime.delete(normalized.name)
this.runtimeRevision += 1
this.invalidateCache()
runtime.delete(skill.name)
updateRevision()
invalidateCache()
}
}.bind(this), 'skills.register()')
}, 'skills.register()')
return dispose
}
/**
* List model-invocable skill summaries for a workspace.
* List model-invocable skill summaries for a workspace. Lookup options and
* provider candidates are readonly same-process values borrowed throughout
* discovery.
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
* @returns sorted summaries, excluding skills disabled for model invocation.
*/
@@ -251,20 +264,33 @@ export class SkillService extends Service {
}
/**
* Load one full skill definition by name.
* Load one full skill definition by name. The provider receives the winning
* candidate it returned during discovery, including its opaque locator, and
* the registry returns the provider's definition after validating it.
* Cancellation is rechecked after catalog
* selection (including a cache hit), and provider loading is raced against the
* same signal so an uncooperative provider cannot hang the caller.
* @param name - kebab-case skill name.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns the full skill, including body content, or `undefined`.
*/
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
if (!isSkillName(name)) return undefined
const match = (await this.collect(options)).find(entry => entry.candidate.name === name)
const collected = await this.collect(options)
throwIfAborted(options.signal)
const match = collected.find(entry => entry.candidate.name === name)
if (match === undefined) return undefined
return await match.provider.get(match.candidate, options)
const definition = await waitWithAbort(
match.provider.get(match.candidate, options),
options.signal,
)
if (definition === undefined) return undefined
validateDefinition(definition)
return definition
}
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
options.signal?.throwIfAborted()
throwIfAborted(options.signal)
while (true) {
const providerRevision = this.providerRevision
const runtimeRevision = this.runtimeRevision
@@ -273,7 +299,7 @@ export class SkillService extends Service {
if (cached !== undefined) return cached
const result = await this.collectFresh(options)
options.signal?.throwIfAborted()
throwIfAborted(options.signal)
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
if (result.cacheable) {
this.collectCache.set(key, result.entries)
@@ -304,7 +330,7 @@ export class SkillService extends Service {
}
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
options.signal?.throwIfAborted()
throwIfAborted(options.signal)
const candidates: IndexedCandidate[] = []
let cacheable = true
let runtimeOrder = 0
@@ -319,15 +345,19 @@ export class SkillService extends Service {
}
for (const { provider, order } of [...this.providers.values()]) {
let localOrder = 0
let listed: SkillCandidate[] | undefined
let output: unknown
try {
listed = await waitWithAbort(provider.list(options), options.signal)
output = await waitWithAbort(provider.list(options), options.signal)
} catch (error) {
if (options.signal?.aborted === true) throw toError(options.signal.reason)
cacheable = false
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
}
if (listed === undefined) continue
if (output === undefined) continue
if (!Array.isArray(output)) {
throw new TypeError(`skill provider "${provider.name}" list() must return an array`)
}
const listed = output as readonly SkillCandidate[]
for (const candidate of listed) {
validateCandidate(candidate, provider.name)
candidates.push({ candidate, provider, providerOrder: order, localOrder })
@@ -350,14 +380,20 @@ const RUNTIME_SKILL_PROVIDER: SkillProvider = {
return Promise.resolve([])
},
get(candidate) {
const skill = candidate.locator as SkillDefinition
return Promise.resolve({ ...skill })
const skill = candidate.locator as SkillRegistration
return Promise.resolve({ ...skill, provider: skill.provider ?? RUNTIME_PROVIDER })
},
}
function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
function runtimeCandidate(skill: SkillRegistration): SkillCandidate {
return {
...toSummary(skill),
name: skill.name,
description: skill.description,
...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {},
...skill.disableModelInvocation !== undefined ? { disableModelInvocation: skill.disableModelInvocation } : {},
source: skill.source,
provider: skill.provider ?? RUNTIME_PROVIDER,
...skill.resourceBase !== undefined ? { resourceBase: skill.resourceBase } : {},
rank: RUNTIME_RANK,
locator: skill,
...skill.path !== undefined ? { path: skill.path } : {},
@@ -366,28 +402,68 @@ function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
}
function validateCandidate(candidate: SkillCandidate, providerName: string): void {
if (typeof candidate.name !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`)
}
if (!SKILL_NAME.test(candidate.name)) {
throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`)
}
if (typeof candidate.description !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string description`)
}
if (candidate.description.length === 0) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
}
if (!Number.isFinite(candidate.rank)) {
if (candidate.disableModelInvocation !== undefined && typeof candidate.disableModelInvocation !== 'boolean') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-boolean disableModelInvocation`)
}
if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`)
}
if (typeof candidate.source !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string source`)
}
if (typeof candidate.rank !== 'number' || !Number.isFinite(candidate.rank)) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`)
}
if (typeof candidate.provider !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string provider`)
}
if (candidate.provider !== providerName) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`)
}
if (candidate.path !== undefined && typeof candidate.path !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string path`)
}
}
function normalizeRuntimeSkill(skill: SkillRegistration): SkillDefinition {
function validateRuntimeSkill(skill: SkillRegistration): void {
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)
return {
...skill,
provider: skill.provider ?? RUNTIME_PROVIDER,
source: skill.source,
}
/** Validate a definition loaded from a provider-controlled parser or remote source. */
function validateDefinition(skill: SkillDefinition): void {
const name = skill.name
const description = skill.description
const whenToUse = skill.whenToUse
const disableModelInvocation = skill.disableModelInvocation
const source = skill.source
const provider = skill.provider
const content = skill.content
const path = skill.path
if (typeof name !== 'string') throw new TypeError('loaded skill name must be a string')
if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`)
if (typeof description !== 'string') throw new TypeError(`loaded skill "${name}" description must be a string`)
if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`)
if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
throw new TypeError(`loaded skill "${name}" disableModelInvocation must be a boolean`)
}
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`)
if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`)
if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`)
if (typeof content !== 'string') throw new TypeError(`loaded skill "${name}" content must be a string`)
if (path !== undefined && typeof path !== 'string') throw new TypeError(`loaded skill "${name}" path must be a string`)
}
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
@@ -431,7 +507,7 @@ function collectCacheKey(options: SkillLookupOptions, providerRevision: number,
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return promise
signal.throwIfAborted()
throwIfAborted(signal)
return new Promise<T>((resolve, reject) => {
const cleanup = (): void => {
signal.removeEventListener('abort', onAbort)
@@ -451,16 +527,31 @@ function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined):
reject(toError(error))
},
)
if (signal.aborted) onAbort()
})
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
/** Throw a total Error for an already-aborted lookup. */
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted === true) throw toError(signal.reason)
}
/** Normalize an arbitrary abort or provider failure without trusting coercion. */
function toError(error: unknown): Error {
try {
if (error instanceof Error) return error
} catch {
// A hostile proxy may throw during instanceof; fall through to the total renderer.
}
return new Error(errorMessage(error))
}
/** Render an arbitrary provider failure without letting coercion escape containment. */
function errorMessage(error: unknown): string {
return String(error)
try {
return String(error)
} catch {
return '[unrenderable thrown value]'
}
}
export default SkillService

View File

@@ -107,62 +107,346 @@ describe('SkillService registry', () => {
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
})
it('snapshots a provider registration so caller mutation cannot corrupt HMR cleanup', async () => {
it('validates parsed candidate fields', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const badDescription = { value: 'object-description' }
ctx.skills.registerProvider({
name: 'bad-candidate',
list: () => Promise.resolve([{
...memorySkill('bad-candidate', 'placeholder', 1),
provider: 'bad-candidate',
description: badDescription as unknown as string,
disableModelInvocation: 'false' as unknown as boolean,
}]),
get: () => Promise.resolve(undefined),
})
await expect(ctx.skills.list()).rejects.toThrow('non-string description')
const badBoolean = new Context()
await badBoolean.plugin(SkillService)
badBoolean.skills.registerProvider({
name: 'bad-boolean',
list: () => Promise.resolve([{
...memorySkill('bad-boolean', 'Bad boolean', 1),
provider: 'bad-boolean',
disableModelInvocation: 'false' as unknown as boolean,
}]),
get: () => Promise.resolve(undefined),
})
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation')
})
it('rejects non-array provider results and every malformed candidate scalar', async () => {
const badList = new Context()
await badList.plugin(SkillService)
badList.skills.registerProvider({
name: 'non-array-list',
list: () => Promise.resolve({} as unknown as SkillCandidate[]),
get: () => Promise.resolve(undefined),
})
await expect(badList.skills.list()).rejects.toThrow('list() must return an array')
const cases: { patch: Partial<SkillCandidate>; expected: string }[] = [
{ patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' },
{ patch: { whenToUse: 1 as unknown as string }, expected: 'non-string whenToUse' },
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'non-string source' },
{ patch: { rank: '1' as unknown as number }, expected: 'invalid rank' },
{ patch: { provider: { value: 'provider' } as unknown as string }, expected: 'non-string provider' },
{ patch: { path: 1 as unknown as string }, expected: 'non-string path' },
]
for (const [index, { patch, expected }] of cases.entries()) {
const ctx = new Context()
await ctx.plugin(SkillService)
const providerName = `candidate-provider-${index}`
const candidate = {
name: `candidate-${index}`,
description: 'Candidate',
whenToUse: 'Use this candidate.',
disableModelInvocation: false,
provider: providerName,
source: 'test',
rank: 1,
locator: 'candidate',
path: '/skills/candidate/SKILL.md',
...patch,
} as SkillCandidate
ctx.skills.registerProvider({
name: providerName,
list: () => Promise.resolve([candidate]),
get: () => Promise.resolve(undefined),
})
await expect(ctx.skills.list()).rejects.toThrow(expected)
}
})
it('borrows the exact lookup options through discovery and loading', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const options: SkillLookupOptions = { cwd: '/workspace/a' }
let listedWith: SkillLookupOptions | undefined
let loadedWith: SkillLookupOptions | undefined
const candidate: SkillCandidate = {
name: 'stable-skill',
description: 'Stable skill',
provider: 'stable-provider',
name: 'skill-a',
description: 'Skill A',
provider: 'contextual',
source: 'test',
rank: 1,
locator: 'original',
locator: 'skill-a',
}
const originalList = vi.fn(() => Promise.resolve([candidate]))
const originalGet = vi.fn((listed: SkillCandidate) => Promise.resolve<SkillDefinition>({
...listed,
content: 'Original body.',
}))
const provider: SkillProvider = {
name: 'stable-provider',
list: originalList,
get: originalGet,
}
const added: SkillProvider[] = []
const removed: string[] = []
ctx.on('skill/provider-added', (registered) => { added.push(registered) })
ctx.on('skill/provider-removed', (name) => { removed.push(name) })
const owner = await ctx.plugin({
name: 'mutable-provider-owner',
inject: ['skills'],
apply(pluginCtx: Context) {
pluginCtx.skills.registerProvider(provider)
ctx.skills.registerProvider({
name: 'contextual',
async list(received) {
listedWith = received
return [candidate]
},
async get(received, lookup) {
expect(received).toBe(candidate)
loadedWith = lookup
return { ...received, content: 'Skill A body.' }
},
})
provider.name = 'mutated-provider'
const replacementList = vi.fn(() => Promise.resolve([]))
const replacementGet = vi.fn(() => Promise.resolve(undefined))
provider.list = replacementList
provider.get = replacementGet
expect((await ctx.skills.list(options)).map(skill => skill.name)).toEqual(['skill-a'])
expect(await ctx.skills.get('skill-a', options)).toMatchObject({ content: 'Skill A body.' })
expect(listedWith).toBe(options)
expect(loadedWith).toBe(options)
})
expect(added).toHaveLength(1)
expect(added[0]).not.toBe(provider)
expect(added[0]?.name).toBe('stable-provider')
expect(Object.isFrozen(added[0])).toBe(true)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['stable-skill'])
expect((await ctx.skills.get('stable-skill'))?.content).toBe('Original body.')
expect(originalList).toHaveBeenCalledOnce()
expect(originalGet).toHaveBeenCalledOnce()
expect(replacementList).not.toHaveBeenCalled()
expect(replacementGet).not.toHaveBeenCalled()
it('rechecks cancellation after cached discovery before provider loading', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let getCalls = 0
ctx.skills.registerProvider({
name: 'cached',
async list() {
return [{
name: 'cached-skill',
description: 'Cached skill',
provider: 'cached',
source: 'test',
rank: 1,
locator: 'cached',
}]
},
async get(candidate) {
getCalls += 1
return { ...candidate, content: 'Cached body.' }
},
})
await ctx.skills.list({ cwd: '/workspace/cache' })
const controller = new AbortController()
const reason = new Error('cancelled after cached discovery')
await owner.dispose()
expect(removed).toEqual(['stable-provider'])
expect(await ctx.skills.list()).toEqual([])
const replacement = new MemoryProvider([])
Object.defineProperty(replacement, 'name', { value: 'stable-provider' })
expect(() => ctx.skills.registerProvider(replacement)).not.toThrow()
const pending = ctx.skills.get('cached-skill', {
cwd: '/workspace/cache',
signal: controller.signal,
})
controller.abort(reason)
await expect(pending).rejects.toBe(reason)
expect(getCalls).toBe(0)
})
it('stops waiting for cached provider loading when a hostile abort reason fires', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let markStarted: (() => void) | undefined
let release: (() => void) | undefined
let seenSignal: AbortSignal | undefined
const started = new Promise<void>((resolve) => { markStarted = resolve })
const held = new Promise<SkillDefinition>((resolve) => {
release = () => {
resolve({
name: 'held-skill',
description: 'Held skill',
provider: 'held',
source: 'test',
content: 'Held body.',
})
}
})
ctx.skills.registerProvider({
name: 'held',
async list() {
return [{
name: 'held-skill',
description: 'Held skill',
provider: 'held',
source: 'test',
rank: 1,
locator: 'held',
}]
},
get(_candidate, options) {
seenSignal = options.signal
markStarted?.()
return held
},
})
await ctx.skills.list({ cwd: '/workspace/cache' })
const controller = new AbortController()
const hostileReason = {
[Symbol.toPrimitive]() {
throw new Error('abort reason coercion failed')
},
}
const pending = ctx.skills.get('held-skill', {
cwd: '/workspace/cache',
signal: controller.signal,
})
const outcome = pending.then(
() => 'resolved',
(error: unknown) => error instanceof Error && error.message === '[unrenderable thrown value]'
? 'aborted'
: 'other-error',
)
await started
controller.abort(hostileReason)
const settled = await Promise.race([
outcome,
new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 25)),
])
release?.()
await pending.catch(() => undefined)
expect(seenSignal).toBe(controller.signal)
expect(settled).toBe('aborted')
})
it('borrows cached candidates and loaded definitions from the provider', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const locator = { id: 'provider-owned' }
const candidate: SkillCandidate = {
name: 'stable-skill',
description: 'Stable description',
whenToUse: 'When stability matters.',
disableModelInvocation: false,
provider: 'detached',
source: 'test',
resourceBase: { kind: 'opaque', description: 'candidate resources' },
rank: 1,
locator,
path: '/skills/stable/SKILL.md',
metadata: { owner: 'candidate' },
}
const definition: SkillDefinition = {
name: 'stable-skill',
description: 'Stable description',
whenToUse: 'When stability matters.',
disableModelInvocation: false,
provider: 'detached',
source: 'test',
resourceBase: { kind: 'opaque', description: 'definition resources' },
path: '/skills/stable/SKILL.md',
metadata: { owner: 'definition' },
content: 'Stable body.',
}
let listCalls = 0
let received: SkillCandidate | undefined
ctx.skills.registerProvider({
name: 'detached',
async list() {
listCalls += 1
return [candidate]
},
async get(loaded) {
received = loaded
return definition
},
})
const listed = await ctx.skills.list()
expect(listed).toEqual([expect.objectContaining({
name: 'stable-skill',
description: 'Stable description',
resourceBase: { kind: 'opaque', description: 'candidate resources' },
})])
expect(listed[0]?.resourceBase).toBe(candidate.resourceBase)
expect(listCalls).toBe(1)
const loaded = await ctx.skills.get('stable-skill')
expect(received).toBe(candidate)
expect(received?.locator).toBe(locator)
expect(loaded).toBe(definition)
})
it('preserves readonly runtime resource identities while adding the default provider', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const resourceBase = { kind: 'opaque' as const, description: 'runtime resources' }
const metadata = { owner: 'runtime' }
const registration = {
name: 'runtime-skill',
description: 'Runtime',
whenToUse: 'When runtime data is needed.',
disableModelInvocation: false,
source: 'runtime',
resourceBase,
metadata,
content: 'Runtime body.',
}
ctx.skills.register(registration)
ctx.skills.register({
name: 'z-runtime',
description: 'Second runtime skill',
source: 'runtime',
content: 'Second runtime body.',
})
const listed = await ctx.skills.list()
const loaded = await ctx.skills.get('runtime-skill')
expect(listed[0]?.resourceBase).toBe(resourceBase)
expect(loaded?.resourceBase).toBe(resourceBase)
expect(loaded?.metadata).toBe(metadata)
expect(loaded?.provider).toBe('runtime')
})
it('rejects every malformed scalar in provider-loaded definitions', async () => {
const cases: { patch: Partial<SkillDefinition>; expected: string }[] = [
{ patch: { name: { value: 'loaded' } as unknown as string }, expected: 'loaded skill name must be a string' },
{ patch: { name: 'Bad_Name' }, expected: 'loaded skill has invalid name' },
{ patch: { description: { value: 'description' } as unknown as string }, expected: 'description must be a string' },
{ patch: { description: '' }, expected: 'requires a description' },
{ patch: { disableModelInvocation: 'false' as unknown as boolean }, expected: 'disableModelInvocation must be a boolean' },
{ patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' },
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'source must be a string' },
{ patch: { provider: { value: 'provider' } as unknown as string }, expected: 'provider must be a string' },
{ patch: { content: { value: 'content' } as unknown as string }, expected: 'content must be a string' },
{ patch: { path: 1 as unknown as string }, expected: 'path must be a string' },
]
for (const [index, { patch, expected }] of cases.entries()) {
const ctx = new Context()
await ctx.plugin(SkillService)
const providerName = `definition-provider-${index}`
const skillName = `definition-${index}`
ctx.skills.registerProvider({
name: providerName,
list: () => Promise.resolve([{
name: skillName,
description: 'Candidate',
provider: providerName,
source: 'test',
rank: 1,
locator: 'definition',
}]),
get: () => Promise.resolve({
name: skillName,
description: 'Definition',
whenToUse: 'Use this definition.',
disableModelInvocation: false,
provider: providerName,
source: 'test',
content: 'Definition body.',
path: '/skills/definition/SKILL.md',
...patch,
} as SkillDefinition),
})
await expect(ctx.skills.get(skillName)).rejects.toThrow(expected)
}
})
it('validates provider candidates and invalid registry caps', async () => {
@@ -282,6 +566,34 @@ describe('SkillService registry', () => {
expect(flakyCalls).toBe(3)
})
it('contains a provider rejection whose string coercion throws', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const hostileFailure = {
toString() {
throw new Error('provider failure coercion failed')
},
}
ctx.skills.registerProvider({
name: 'hostile-failure',
list() {
// Deliberately violate the provider contract to prove containment is total.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
return Promise.reject(hostileFailure)
},
async get() {
return undefined
},
})
await expect(ctx.skills.list()).resolves.toEqual([])
expect(warnings).toEqual([
'skill provider "hostile-failure" skipped: [unrenderable thrown value]',
])
})
it('abandons an in-flight catalog when provider registrations change', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
@@ -351,39 +663,6 @@ describe('SkillService registry', () => {
expect(settled).toBe('aborted')
})
it('does not miss an abort racing listener installation', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const reason = new Error('racing abort')
let aborted = false
const signal = {
get aborted() {
return aborted
},
reason,
throwIfAborted() {
if (aborted) throw reason
},
addEventListener(_type: string, listener: () => void) {
aborted = true
listener()
},
removeEventListener() {},
} as unknown as AbortSignal
ctx.skills.registerProvider({
name: 'racing-abort',
list() {
return Promise.reject(new Error('late provider failure'))
},
async get() {
return undefined
},
})
await expect(ctx.skills.list({ signal })).rejects.toBe(reason)
await Promise.resolve()
})
it('rejects invalid runtime skill registrations and ignores duplicates', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)

View File

@@ -1,32 +1,31 @@
# @deepseek-ai/dsh-subagent-acp
The out-of-process **ACP subagent backend**: runs each child agent in a spawned subprocess, driven over the [Agent Client Protocol](https://github.com/zed-industries/agent-client-protocol) (ACP) as the *client*. Registers a `SubagentProvider` on `ctx.subagents` (the [subagent seam](../subagent)), alongside the in-process [`-spawn`](../subagent-spawn)/[`-fork`](../subagent-fork) backends — multiple backends coexist by name.
The ACP provider runs each subagent in a fresh subprocess and drives it as an Agent Client Protocol client. It is the out-of-process alternative to spawn and fork: the child has its own runtime, session, model configuration, and tools.
It is the direction-inverted twin of the server-side bridge in [`@deepseek-ai/dsh-acp`](../../ui/acp): that package is the ACP *agent* (it answers `initialize`/`newSession`/`prompt`); this one is the ACP *client* (it *calls* them and implements the `sessionUpdate`/`requestPermission` callbacks). Point the configured command at the `acp-agent` example to "talk to our own process".
## Start and ownership
## What it does
`start(request)` performs `spawn` → ACP `initialize``newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped.
`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize``newSession``prompt`. `run.started` resolves after `newSession` publishes the remote session and rejects when initialization fails or cancellation wins first; the service emits no start/end pair for a child that never became live. The child's streamed `agent_message_chunk` text becomes the `SubagentResult.output`; the prompt's terminal `StopReason` maps to the stop reason. `dispose()` kills the subprocess and awaits its exit.
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
**Fresh process per run.** Each `start` spawns a new child, runs exactly one ACP session, and disposes it. Persistent-process pooling is a future optimization (see the RFC).
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, waits `disposeEofGraceMs`, escalates to SIGTERM, waits `disposeGraceMs`, and finally uses SIGKILL if necessary. Every run uses a fresh process; process pooling is not implemented.
Unlike the in-process backends, the child does NOT share this cordis context — it is a separate process with its own session, model client, and tools. So this backend:
- injects only `subagents` (no `ctx.agents`);
- advertises NO start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter);
- ignores `request.parent`.
## Capabilities and context
## Config
ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh and ignores `request.parent` beyond the seam's required attribution field.
| Key | Type | Default | Notes |
|---|---|---|---|
| `providerName` | string | `acp` | Registry name on `ctx.subagents`. |
| `command` | string | — (required) | The executable to spawn for each run (the child ACP agent). |
| `args` | string[] | `[]` | Arguments passed to `command`. |
| `cwd` | string | process cwd | Working directory for the child process and its ACP session. |
| `permission` | `'allow' \| 'reject'` | `reject` | How to auto-answer the child's `session/request_permission` prompts. `reject` declines every prompt (answer `cancelled`); `allow` approves via the first allow-shaped option. The first cut surfaces no prompt to a human. |
| `env` | Record<string,string> | `{}` | Extra env vars for the child (e.g. its own `DEEPSEEK_API_KEY`). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. |
| `disposeEofGraceMs` | number | `6000` | Dispose ladder tier 1: how long the child gets to quiesce on its own after stdin EOF (flush persistence, tear down its nested subprocesses) before SIGTERM. |
| `disposeGraceMs` | number | `3000` | Dispose ladder tier 2: grace between SIGTERM and the SIGKILL escalation. |
## Configuration
| Key | Default | Meaning |
|---|---|---|
| `providerName` | `acp` | Registry name on `ctx.subagents`. |
| `command` | required | Executable spawned for each run. |
| `args` | `[]` | Command arguments. |
| `cwd` | process cwd | Child process and ACP session working directory. |
| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. |
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before SIGTERM. |
| `disposeGraceMs` | `3000` | Grace after SIGTERM before SIGKILL. |
```yaml
- id: subagent-acp
@@ -40,32 +39,20 @@ Unlike the in-process backends, the child does NOT share this cordis context —
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
```
## StopReason mapping
## Stop-reason mapping
ACP `StopReason` → harness `SubagentStopReason`:
| ACP | harness |
| ACP | Harness |
|---|---|
| `end_turn` | `completed` |
| `max_tokens` | `max-tokens` |
| `refusal` | `refusal` |
| `cancelled` | `aborted` |
| `max_turn_requests` | `error` (no clean equivalent; the task did not finish) |
| _(unknown)_ | `error` |
| `max_turn_requests` or unknown | `error` |
A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was requested) — `result` never rejects on a child-level failure, per the seam contract.
## Process boundary
## Environment scrub
The child environment is built by [`buildChildEnv`](../subagent-subprocess/README.md): credential-shaped ambient variables are removed, then explicit `config.env` values are applied. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
The child env is built by [`buildChildEnv` from `@deepseek-ai/dsh-subagent-subprocess`](../subagent-subprocess/README.md) — the ambient env minus credential-shaped vars, with `config.env` layered on top after the scrub; the pattern and full semantics live there. For this backend that means the parent harness's own secrets never leak into the spawned agent implicitly, while the child's OWN `DEEPSEEK_API_KEY` is supplied deliberately via `config.env` and survives.
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
## Testing
- **Keyless** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio — connection setup, client callbacks, the prompt round-trip, stop-reason mapping, cancellation (including the early-cancel race and a torn-pipe-after-cancel), permission auto-answer, and quiescent disposal. No model, no key.
- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt and does real file work (verified on disk). Self-skips without `DEEPSEEK_API_KEY`.
`TODO(acp-subagent-replay)`: snapshot-tier coverage of an ACP child is a separate replay shape (each child is its own PROCESS with its own single-agent replay, distinct from the in-process per-session keying), deferred — see the RFC.
## Plugin export shape
Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)).
Keyless tests drive a scripted ACP subprocess over real stdio. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`.

View File

@@ -1,5 +1,24 @@
/**
* The out-of-process ACP subagent run driver.
* The out-of-process ACP subagent run driver. Spawns a child agent as a
* subprocess, speaks the Agent Client Protocol (ACP) to it over stdio as the
* CLIENT, drives one session to completion, and shapes the result into a
* {@link SubagentResult}. The mirror image of the server-side bridge in
* `@deepseek-ai/dsh-acp` (which is the ACP *agent* side): here we are the ACP
* *client*, so we CALL `initialize`/`newSession`/`prompt`/`cancel` and we
* IMPLEMENT the `Client` callbacks (`sessionUpdate`, `requestPermission`).
*
* One subprocess per run (fresh-process-per-run): `start` spawns, runs exactly
* one ACP session, and `dispose` kills the subprocess and awaits its exit.
* Persistent-process pooling is a future optimization (see the RFC).
*
* TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a
* distinct replay shape — each child is its own PROCESS with its own
* single-agent replay (the child boots under `DSH_SNAPSHOT=replay` with its own
* sessions-root + fixture), unlike the in-process per-session keying in
* `dsh-llm-replay`. Deferred to a follow-up; keyless coverage here is via a
* scripted mock ACP server subprocess, and the with-key e2e drives the real
* `acp-agent` example. See the ACP-subagent-backend RFC.
*
* @module @deepseek-ai/dsh-subagent-acp/run
*/
@@ -77,9 +96,16 @@ export interface AcpRunSpec {
}
/**
* Default grace for the child's EOF-driven quiesce on dispose (the `disposeEofGraceMs` config)
* — the window for it to flush persistence and tear down its own nested subprocesses (which
* may run their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a signal.
* Default grace for the child's EOF-driven quiesce on dispose (the
* `disposeEofGraceMs` config) — the window for it to flush persistence and tear
* down its OWN nested subprocesses (which may run their own `SIGTERM`→`SIGKILL`
* escalation) before the parent escalates to a signal. Deliberately LARGER than
* {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative child whose teardown is itself
* waiting on a signal-trapping grandchild (e.g. a bash subprocess in its own ~3s
* SIGTERM→SIGKILL grace) plus a final flush needs MORE than a single
* signal-grace of headroom, or the parent's SIGTERM cuts it off exactly as it
* reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, so this is
* a standalone generous default, NOT derived from any child's internals.
*/
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
@@ -150,32 +176,23 @@ function toError(value: unknown): Error {
/**
* Start an out-of-process ACP child for `request` and return a {@link SubagentRun}.
*
* @param request - the start request; the driver consumes `prompt` and `signal`
* (an already-aborted signal yields an inert `aborted` run with no spawn).
* Spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`,
* and drives one session: `initialize` → `newSession` → `prompt`. The accumulated
* `agent_message_chunk` text is the result output; the prompt's terminal
* `StopReason` maps to the stop reason. `result` never REJECTS on a child-level
* failure after publication resolves with `stopReason: 'error'`. A spawn,
* initialize, new-session, or pre-publication cancellation failure instead
* rejects only after the process has been reaped. `dispose()` requests ACP
* cancellation, then kills and reaps the subprocess.
* @param request - the start request; its signal is the cancellation channel.
* @param spec - the resolved spawn spec: command/args/cwd, env, permission
* policy, dispose graces, and the optional error sink.
* @returns the live run handle for the child subprocess.
* @returns the ready run handle for the child subprocess.
*/
export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): SubagentRun {
export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise<SubagentRun> {
const id = AgentId(randomUUID())
// A request already aborted before it starts never spawns the child at all —
// return an inert run that settled `aborted`, rather than launching the
// configured binary just to tear it down. `dispose`/`cancel` are no-ops.
if (request.signal?.aborted) {
const started = Promise.reject(new Error('subagent request was aborted before the ACP child started'))
// The result is derived from the same boundary so the readiness rejection
// is observed even when this provider is driven directly rather than
// through SubagentService.
const result: Promise<SubagentResult> = started.catch(() => ({ output: [], stopReason: 'aborted' }))
return {
id,
started,
result,
cancel(_reason?: string): void { /* nothing was started */ },
dispose(): Promise<void> { return Promise.resolve() },
}
}
if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started')
// Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP
// response channel, stderr = INHERIT so the child's diagnostics surface on the
@@ -192,10 +209,23 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
// `error` like any child failure.
const spawnFailed = spawnFailure(child)
// One memoized quiescence transaction is shared by startup rollback and the
// published run's disposer. Once start fulfills, only the holder can invoke
// it; before fulfillment the provider invokes it on every failure path.
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeChildProcess(child, {
disposeEofGraceMs: spec.disposeEofGraceMs,
disposeGraceMs: spec.disposeGraceMs,
}))
// Accumulate the child's streamed assistant text — the SubagentResult output.
const output: string[] = []
// `cancelled` records that a cancel was requested (signal or cancel()), so a run torn down
// before the prompt resolves settles `aborted` rather than the generic error mapping.
// `cancelled` records that the required signal or disposal requested cancel, so a
// run torn down before the prompt resolves settles `aborted` rather than the
// generic error mapping. Held on a mutable object so the async closures that
// set it (the abort listener) and the IIFE that reads it don't fight TS's
// control-flow narrowing of a bare `let` (which would type the catch-time read
// as always-`false`).
const flags = { cancelled: false }
const makeClient = (_agent: AcpAgent): Client => ({
@@ -231,19 +261,33 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
)
let sessionId: string | undefined
// Resolves when a cancel is requested, so `result` can settle `aborted` even if the child
// never cooperates with `session/cancel` (it ignores the notify, or the prompt wedges).
// Resolves when a cancel is requested, so `result` can settle `aborted` even
// if the child never cooperates with `session/cancel` (it ignores the notify,
// or the prompt wedges). The result path races this against the ACP drive: the
// FIRST to settle wins, so signal/dispose cancellation always honors the contract (`result`
// settles `aborted`) without waiting on a non-cooperative child. `dispose`
// still kills the process and reaps it; this only unblocks `result`. The
// executor runs synchronously, so `signalCancelSettled` is assigned before the
// Promise constructor returns (the `!` asserts the definite assignment).
let signalCancelSettled!: () => void
const cancelSettled = new Promise<void>((resolve) => { signalCancelSettled = resolve })
const requestCancel = (): void => {
if (flags.cancelled) return
flags.cancelled = true
signalCancelSettled()
// Best-effort: tell the child to cancel the in-flight turn.
// Best-effort: tell the child to cancel the in-flight turn. Swallows a
// rejection — the session may not exist yet, or the pipe may be gone; the
// dispose path kills the process regardless. If the session has NOT been
// created yet (cancel raced ahead of `newSession`), the `cancelled` flag
// alone carries it: the result path re-checks the flag after each await and
// settles `aborted` without running the prompt. The `.catch` swallow is
// defensive for a narrow transport race (child gone mid-send) — v8-ignored
// because dispose kills the process regardless, so it can't be hit in tests.
/* v8 ignore next */
if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ })
}
const onAbort = (): void => { requestCancel() }
request.signal?.addEventListener('abort', onAbort, { once: true })
request.signal.addEventListener('abort', onAbort, { once: true })
// The accumulated child text as harness ContentBlocks (empty array when the
// child streamed nothing). Read at every return so a partial answer survives
@@ -253,36 +297,41 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
return text.length > 0 ? [{ type: 'text', text }] : []
}
// A provider is "started" only once the remote child has completed ACP initialization and
// published a session.
const started: Promise<void> = Promise.race([
(async (): Promise<void> => {
await conn.initialize({
protocolVersion: PROTOCOL_VERSION,
// Advertise NO optional client capabilities (no fs, no terminal): the
// child self-serves in its own process.
clientCapabilities: {},
})
const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] })
sessionId = session.sessionId
if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
})(),
spawnFailed.then((err): never => { throw err }),
cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }),
])
// Establish the remote session before publishing a handle. Any failure owns
// the still-private process and therefore reaps it before rejecting.
try {
await Promise.race([
(async (): Promise<void> => {
await conn.initialize({
protocolVersion: PROTOCOL_VERSION,
// Advertise NO optional client capabilities (no fs, no terminal): the
// child self-serves in its own process.
clientCapabilities: {},
})
const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] })
sessionId = session.sessionId
if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started')
})(),
spawnFailed.then((err): never => { throw err }),
cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }),
])
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
await disposeProcess()
if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started')
throw toError(error)
}
const result: Promise<SubagentResult> = (async (): Promise<SubagentResult> => {
try {
// Readiness is the initialize → newSession phase above.
await started
// Race two post-start outcomes, first to settle wins: - prompt: the normal remote turn; -
// cancelSettled: a cancel was requested — settle `aborted` immediately rather than
// waiting on a child that may ignore `session/cancel` or wedge the prompt (the `cancel()`
// contract: `result` settles `aborted`).
// Race two post-publication outcomes, first to settle wins:
// - prompt: the normal remote turn;
// - cancelSettled: a cancel was requested — settle `aborted` immediately
// rather than waiting on a child that may ignore `session/cancel` or
// wedge the prompt (`result` settles `aborted`). After `newSession`
// succeeds, transport/process failure rejects the in-flight prompt RPC.
const prompt = async (): Promise<SubagentResult> => {
// `started` cannot fulfill without assigning the session id; the cast
// records that local invariant without an unreachable defensive arm.
// The startup phase cannot fulfill without assigning the session id.
const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) })
return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) }
}
@@ -291,8 +340,17 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })),
])
} catch (error: unknown) {
// A deterministic cancellation resolves `cancelSettled` before its
// best-effort ACP cancel can reject the prompt. This fallback is only for
// a process/pipe rejection already queued when the abort event fires; its
// first-outcome ordering cannot be forced without a timing-dependent test.
/* v8 ignore next */
if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' }
// The seam contract: result resolves (never rejects) on a child-level failure.
// The seam contract: result resolves (never rejects) on a child-level
// failure. Startup failures were already rejected before publication;
// every rejection here is a prompt transport/RPC failure.
// Flatten to `error` and surface the original via onError so a real fault
// is preserved rather than silently lost.
try {
spec.onError?.(toError(error), 'error')
} catch {
@@ -301,24 +359,30 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
// The child-level failure being reported still settles as `error`.
}
return { output: collectOutput(), stopReason: 'error' }
} finally {
request.signal.removeEventListener('abort', onAbort)
}
})()
let disposal: Promise<void> | undefined
return {
id,
started,
result,
cancel(_reason?: string): void {
dispose(): Promise<void> {
if (disposal !== undefined) return disposal
request.signal.removeEventListener('abort', onAbort)
requestCancel()
},
async dispose(): Promise<void> {
request.signal?.removeEventListener('abort', onAbort)
// Quiescent teardown via the shared ladder (stdin EOF → SIGTERM → SIGKILL, awaiting the
// actual exit).
await disposeChildProcess(child, {
disposeEofGraceMs: spec.disposeEofGraceMs,
disposeGraceMs: spec.disposeGraceMs,
})
// Quiescent teardown via the shared ladder (stdin EOF → SIGTERM →
// SIGKILL, awaiting the actual exit). For THIS child the EOF tier is the
// one that matters: our acp-agent has NO SIGTERM handler in a normal
// session — it tears down via the server bridge's connection-close path
// (conn.closed → per-agent dispose → final session/flush), driven by the
// stdin EOF, NOT by a signal — and a prompt response can resolve from a
// turn/end BEFORE that post-turn flush lands, so the child still has
// durable work owed when dispose runs (hence the wide EOF grace; see
// DEFAULT_DISPOSE_EOF_GRACE_MS).
disposal = disposeProcess()
return disposal
},
}
}

View File

@@ -31,6 +31,7 @@ const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1'
const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1'
const THOUGHT = process.env.MOCK_THOUGHT === '1'
const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1'
const CRASH_ON_PROMPT = process.env.MOCK_CRASH_ON_PROMPT === '1'
const IGNORE_CANCEL = process.env.MOCK_IGNORE_CANCEL === '1'
const READY_FILE = process.env.MOCK_READY_FILE
const FLUSH_ON_EOF = process.env.MOCK_FLUSH_ON_EOF
@@ -68,6 +69,7 @@ function makeAgent(conn: AgentSideConnection): Agent {
return Promise.resolve()
},
async prompt(params: PromptRequest): Promise<PromptResponse> {
if (CRASH_ON_PROMPT) process.exit(1)
if (WANT_PERMISSION) {
// Ask the client to approve before answering; honor its decision. Under
// MOCK_NO_ALLOW the only options are reject-shaped, so an `allow`-policy

View File

@@ -51,9 +51,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
},
})
const run = ctx.subagents.start('acp', {
const run = await ctx.subagents.start('acp', {
prompt: [{ type: 'text', text: 'Reply with exactly the word PONG and nothing else. Do not use any tools.' }],
parent: fakeParent,
signal: new AbortController().signal,
})
const result = await run.result
await run.dispose()
@@ -84,11 +85,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
},
})
const run = ctx.subagents.start('acp', {
const run = await ctx.subagents.start('acp', {
prompt: [{ type: 'text', text:
'Use the bash tool to write the text ACP_CHILD_WAS_HERE into a file named proof.txt '
+ 'in the current directory. Then reply DONE.' }],
parent: fakeParent,
signal: new AbortController().signal,
})
const result = await run.result
await run.dispose()

View File

@@ -27,6 +27,10 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m
/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */
const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
function request(text = 'p', signal = new AbortController().signal) {
return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
}
interface SetupEnv {
/** Mock-server scripting env: MOCK_TEXT / MOCK_STOP / MOCK_HANG / MOCK_PERMISSION. */
[key: string]: string
@@ -119,16 +123,18 @@ describe('buildChildEnv', () => {
describe('dsh-subagent-acp', () => {
it('drives a child process to completion and returns its streamed output', async () => {
const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn' })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'do X' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request('do X'))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('hello from acp child')
await run.dispose()
const disposal = run.dispose()
expect(run.dispose()).toBe(disposal)
await disposal
})
it('maps a max_tokens stop reason', async () => {
const ctx = await setup({ MOCK_TEXT: 'cut off', MOCK_STOP: 'max_tokens' })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
@@ -136,22 +142,23 @@ describe('dsh-subagent-acp', () => {
it('maps a refusal stop reason', async () => {
const ctx = await setup({ MOCK_TEXT: '', MOCK_STOP: 'refusal' })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('refusal')
await run.dispose()
})
it('cancelling a running child settles aborted (session/cancel via run.cancel)', async () => {
it('aborting the required signal cancels a running child', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'acp-cancel-'))
const readyFile = join(tmp, 'ready')
try {
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const controller = new AbortController()
const run = await ctx.subagents.start('acp', request('p', controller.signal))
// Wait until the child's prompt is in flight (condition, not a sleep),
// then cancel — so we exercise the mid-run session/cancel path.
await waitForFile(readyFile)
run.cancel('test')
controller.abort('test')
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
@@ -160,7 +167,7 @@ describe('dsh-subagent-acp', () => {
}
})
it('settles aborted WITHOUT spawning the child when the signal is already aborted', async () => {
it('rejects WITHOUT spawning the child when the signal is already aborted', async () => {
// A pre-aborted request must not even launch the configured binary. Point
// the command at one that would create a sentinel file if it ever ran, and
// assert the sentinel never appears.
@@ -169,17 +176,11 @@ describe('dsh-subagent-acp', () => {
try {
const controller = new AbortController()
controller.abort()
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal },
await expect(startAcpRun(
request('p', controller.signal),
// `touch <sentinel>` — runs only if the process is actually spawned.
{ command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
)
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
// cancel/dispose on the inert run are safe no-ops.
run.cancel('noop')
await run.dispose()
)).rejects.toThrow('aborted before the ACP child started')
// The binary was never launched — no sentinel.
expect(existsSync(sentinel)).toBe(false)
} finally {
@@ -188,8 +189,9 @@ describe('dsh-subagent-acp', () => {
})
it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
// The child traps SIGTERM and keeps its event loop alive, so a graceful term alone would
// hang dispose forever.
// The child traps SIGTERM and keeps its event loop alive, so a graceful
// term alone would hang dispose forever. With a short grace, dispose must
// escalate to SIGKILL and return once the process is actually gone.
const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-'))
const ready = join(tmp, 'trap-armed')
try {
@@ -205,7 +207,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 150,
disposeGraceMs: 150,
}
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
const run = await startAcpRun(request(), spec)
// Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a
// sleep) — otherwise SIGTERM races the trap install and the default handler
// terminates the child, never exercising the escalation.
@@ -223,8 +225,15 @@ describe('dsh-subagent-acp', () => {
})
it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => {
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears down on
// connection close, not on a signal) — and it has no SIGTERM handler.
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears
// down on connection close, NOT on a signal) — and it has no SIGTERM handler.
// Its EOF teardown can itself await a signal-trapping grandchild (a bash
// subprocess in its own SIGTERM→SIGKILL grace) plus a flush, so the EOF window
// must be a SEPARATE, WIDER grace than the SIGTERM tier — not the same value.
// The mock models a flush that takes LONGER than the SIGTERM grace but well
// under the EOF grace: it lands only because tier 1 waits eofGraceMs, not
// graceMs. (If dispose reused the small SIGTERM grace for the EOF wait — the
// round-2 bug — SIGTERM would fire mid-flush and the marker would be missing.)
const tmp = mkdtempSync(join(tmpdir(), 'acp-eof-'))
const ready = join(tmp, 'ready')
const flushed = join(tmp, 'flushed')
@@ -234,7 +243,10 @@ describe('dsh-subagent-acp', () => {
args: ['--import', tsxLoader, mockServer],
cwd: process.cwd(),
permission: 'reject',
// MOCK_HANG so the prompt never resolves on its own — we tear down a live child.
// MOCK_HANG so the prompt never resolves on its own — we tear down a live
// child. The flush beat (400ms) outlasts the 50ms SIGTERM grace but fits
// the 2000ms EOF grace; the marker lands iff the EOF tier honored its own
// wider grace.
env: {
MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready,
MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig,
@@ -242,7 +254,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 2000,
disposeGraceMs: 50,
}
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
const run = await startAcpRun(request(), spec)
// Wait until the child is fully booted with its prompt in flight (its ACP
// stdin reader is attached), so dispose's stdin EOF reaches a live child.
await waitForFile(ready)
@@ -256,9 +268,12 @@ describe('dsh-subagent-acp', () => {
})
it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => {
// A child that keeps its loop alive past stdin EOF (so the graceful window times out) but
// exits cooperatively on SIGTERM must die on the SIGTERM tier — dispose returns there,
// never reaching the SIGKILL tier.
// A child that keeps its loop alive past stdin EOF (so the graceful window
// times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier
// — dispose returns there, never reaching the SIGKILL tier. The child touches
// a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if
// dispose had skipped the middle rung (EOF→SIGKILL) the handler would never
// run and the marker would be absent — making this a GENUINE middle-tier guard.
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
const ready = join(tmp, 'ready')
const sigterm = join(tmp, 'sigterm')
@@ -276,7 +291,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 150,
disposeGraceMs: 2000,
}
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
const run = await startAcpRun(request(), spec)
await waitForFile(ready)
// Bound it so a hang fails loud rather than stalling the suite.
await expect(Promise.race([
@@ -291,21 +306,22 @@ describe('dsh-subagent-acp', () => {
}
})
it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => {
it('rejects after cleanup when the signal aborts during newSession', async () => {
// Gate the child at newSession: it signals `ready` and blocks until `go`.
// We cancel WHILE newSession is pending (sessionId still undefined, so the
// backend cannot send session/cancel) — the `cancelled` flag alone must
// settle the run aborted after newSession resolves, never issuing the prompt.
const tmp = mkdtempSync(join(tmpdir(), 'acp-early-'))
const ready = join(tmp, 'ready')
const go = join(tmp, 'go')
try {
const ctx = await setup({ MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go, MOCK_TEXT: 'should not run' })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const controller = new AbortController()
const starting = ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(ready) // newSession is now in flight, sessionId undefined
run.cancel('early') // sets cancelled; cannot send session/cancel yet
controller.abort('early')
writeFileSync(go, 'go') // let newSession resolve
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
await run.dispose()
await expect(starting).rejects.toThrow('aborted before the ACP child started')
} finally {
rmSync(tmp, { recursive: true, force: true })
}
@@ -317,7 +333,7 @@ describe('dsh-subagent-acp', () => {
try {
const controller = new AbortController()
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal })
const run = await ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(readyFile)
controller.abort()
const result = await run.result
@@ -330,7 +346,7 @@ describe('dsh-subagent-acp', () => {
it('auto-rejects a permission prompt by default (child settles cancelled→aborted)', async () => {
const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1' }, 'reject')
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
// The child asked permission, the backend rejected, the child returned cancelled.
expect(result.stopReason).toBe('aborted')
@@ -339,7 +355,7 @@ describe('dsh-subagent-acp', () => {
it('auto-approves a permission prompt under the allow policy', async () => {
const ctx = await setup({ MOCK_TEXT: 'approved answer', MOCK_PERMISSION: '1', MOCK_STOP: 'end_turn' }, 'allow')
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('approved answer')
@@ -350,7 +366,7 @@ describe('dsh-subagent-acp', () => {
// The child asks permission but offers ONLY reject-shaped options, so an
// allow-policy client finds nothing to select and must answer cancelled.
const ctx = await setup({ MOCK_PERMISSION: '1', MOCK_NO_ALLOW: '1' }, 'allow')
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
@@ -360,7 +376,7 @@ describe('dsh-subagent-acp', () => {
// The child streams an agent_thought_chunk before its answer; the backend
// must consume it but NOT include it in the result output.
const ctx = await setup({ MOCK_THOUGHT: '1', MOCK_TEXT: 'final answer', MOCK_STOP: 'end_turn' })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('completed')
// Only the message text, NOT the thought.
@@ -368,18 +384,11 @@ describe('dsh-subagent-acp', () => {
await run.dispose()
})
it('resolves error (not reject) when the spawn command does not exist', async () => {
// Direct startAcpRun with NO onError sink — the catch must still flatten the
// spawn failure to `error` (the onError call is optional, covering the
// absent-sink branch).
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
it('rejects a spawn failure after provider-owned cleanup', async () => {
await expect(startAcpRun(
request(),
{ command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
)
const result = await run.result
// The seam contract: a child-level failure resolves error, never rejects.
expect(result.stopReason).toBe('error')
await run.dispose()
)).rejects.toThrow()
})
it('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => {
@@ -401,7 +410,7 @@ describe('dsh-subagent-acp', () => {
disposeEofGraceMs: 150,
disposeGraceMs: 150,
})
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const run = await ctx.subagents.start('acp', request())
await waitForFile(ready)
await expect(Promise.race([
run.dispose(),
@@ -423,7 +432,7 @@ describe('dsh-subagent-acp', () => {
}
})
it('resolves error via the provider (real load path) when the command does not exist', async () => {
it('rejects a startup failure via the provider load path', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(acp, {
@@ -433,25 +442,23 @@ describe('dsh-subagent-acp', () => {
permission: 'reject',
env: {},
})
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const result = await run.result
expect(result.stopReason).toBe('error')
await run.dispose()
await expect(ctx.subagents.start('acp', request())).rejects.toThrow()
})
it('reports a flattened child failure through onError (preserved, not silently lost)', async () => {
// The seam forbids `result` rejecting, so a child-level failure is flattened to a stop
// reason — onError must still surface the original error so a real fault is logged, not
// swallowed.
// The seam forbids `result` rejecting, so a child-level failure is flattened
// to a stop reason — onError must still surface the original error so a real
// fault is logged, not swallowed. The child exits after its session is
// published but while prompt is in flight.
const errors: { message: string; stopReason: string }[] = []
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
const run = await startAcpRun(
request(),
{
command: '/nonexistent/acp-agent-binary',
args: [],
command: process.execPath,
args: ['--import', tsxLoader, mockServer],
cwd: process.cwd(),
permission: 'reject',
env: {},
env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig },
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) },
@@ -465,18 +472,31 @@ describe('dsh-subagent-acp', () => {
await run.dispose()
})
it('logs a flattened child failure through the registered provider', async () => {
const ctx = await setup({ MOCK_CRASH_ON_PROMPT: '1' })
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const run = await ctx.subagents.start('acp', request())
const result = await run.result
expect(result.stopReason).toBe('error')
expect(warnings).toEqual([
expect.stringContaining('subagent-acp "acp": child run failed (error):'),
])
await run.dispose()
})
it('resolves error (never rejects) even when the onError sink itself throws', async () => {
// onError is a caller-supplied callback boundary: its own exception must be
// contained, or it would reject `result` and break the seam's "result never
// rejects" contract that the flattening above exists to uphold.
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
const run = await startAcpRun(
request(),
{
command: '/nonexistent/acp-agent-binary',
args: [],
command: process.execPath,
args: ['--import', tsxLoader, mockServer],
cwd: process.cwd(),
permission: 'reject',
env: {},
env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig },
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
onError: () => { throw new Error('sink boom') },
@@ -488,15 +508,18 @@ describe('dsh-subagent-acp', () => {
})
it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
// The child hangs, we cancel, and instead of answering the child exits hard — the pending
// prompt RPC rejects.
// The child hangs, we cancel, and instead of answering the child exits hard
// — the pending prompt RPC rejects. With a cancel already requested, the
// backend's catch path must settle `aborted` (the failure is the cancel
// surfacing as a torn pipe), not `error`.
const tmp = mkdtempSync(join(tmpdir(), 'acp-crash-'))
const ready = join(tmp, 'ready')
try {
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_CRASH_ON_CANCEL: '1', MOCK_READY_FILE: ready })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const controller = new AbortController()
const run = await ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(ready)
run.cancel('crash it')
controller.abort('crash it')
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
@@ -505,15 +528,19 @@ describe('dsh-subagent-acp', () => {
}
})
it('settles aborted on cancel even when the child IGNORES session/cancel (non-cooperative)', async () => {
// The contract: run.cancel() → result settles `aborted`.
it('settles aborted on signal even when the child IGNORES session/cancel', async () => {
// The signal contract requires `result` to settle `aborted`. A child that hangs
// its prompt AND ignores session/cancel must not wedge the parent — the
// backend's own cancel-settle path resolves `aborted` without the child's
// cooperation, and dispose() still reaps the process.
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignorecancel-'))
const ready = join(tmp, 'ready')
try {
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent })
const controller = new AbortController()
const run = await ctx.subagents.start('acp', request('p', controller.signal))
await waitForFile(ready)
run.cancel('test')
controller.abort('test')
// Bound it: a regression (cancel only notifies the child, which ignores it)
// would hang result forever — fail loud instead of stalling the suite.
const result = await Promise.race([

View File

@@ -1,14 +1,20 @@
# @deepseek-ai/dsh-subagent-fork
In-process provider that starts a child [`Agent`](../../core/agent) from the parent's completed conversation prefix. It shares [`startInProcessRun`](../subagent-inprocess/README.md) with the [spawn provider](../subagent-spawn/README.md); the seed is the only backend difference.
The fork provider creates an in-process child seeded with the parent's completed conversation turns. It shares all run mechanics with spawn; the session seed is the only behavioral difference.
## Seed boundary
The delegating tool runs inside an open parent turn whose tool call has no result yet. Forking that tail would create an invalid, unbalanced child log, so the provider copies only the prefix through the last `turn/end`. A first-turn fork therefore starts with an empty seed. `CreateAgentOptions.seed` carries the contiguous prefix into session preparation.
The parent's current tool-calling turn is still open when a subagent starts: its log contains the assistant tool call but not the matching tool result or `turn/end`. Copying that raw log would give the child an invalid, unbalanced session.
## Capabilities
Fork therefore uses `completedTurnPrefix(parent.session.events)`: the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn.
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`
The seed transfers conversation history only. The child still receives a fresh flat registration scope; it does not inherit the parent's tool restrictions or authority.
## Start and capabilities
`start(request)` passes the completed-turn seed to [`startInProcessRun`](../subagent-inprocess/README.md) and awaits child publication. The shared driver owns cancellation, depth, customization, result reading, and disposal.
Fork advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`, identical to spawn.
## Config

View File

@@ -55,11 +55,11 @@ class ForkProvider implements SubagentProvider {
// Context contract: a forked child IS seeded with the parent's completed-turn prefix.
readonly inheritsParentContext = true
constructor(readonly name: string, private readonly ctx: Context) {}
constructor(readonly name: string) {}
start(request: SubagentStartRequest) {
const seed = completedTurnPrefix(request.parent)
return startInProcessRun(this.ctx, request, {
return startInProcessRun(request, {
// Only pass a seed when there's a completed turn to inherit; an empty seed
// is equivalent to a fresh child, so omit it to keep the session unseeded.
...seed.length > 0 ? { seed } : {},
@@ -68,5 +68,5 @@ class ForkProvider implements SubagentProvider {
}
export function apply(ctx: Context, config: Config): void {
ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx))
ctx.subagents.registerProvider(new ForkProvider(config.providerName))
}

View File

@@ -7,13 +7,17 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as fork from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
/**
* The two in-process backends coexist on one context: the SAME parent agent
* delegates to a `spawn` child (fresh) and a `fork` child (seeded with its log),
@@ -62,13 +66,13 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
const parentPrefixLen = parent.session.events.length
// Delegate to a fresh spawn child.
const spawnRun = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent })
const spawnRun = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent })
const spawnResult = await spawnRun.result
expect(spawnResult.stopReason).toBe('completed')
expect(text(spawnResult.output)).toBe('spawn child reply')
// Delegate to a fork child (seeded with the parent's turn-1 prefix).
const forkRun = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'fork task' }], parent })
const forkRun = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'fork task' }], parent })
const forkResult = await forkRun.result
expect(forkResult.stopReason).toBe('completed')
expect(text(forkResult.output)).toBe('fork child reply')

View File

@@ -8,7 +8,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import * as fork from '../src/index.ts'
@@ -17,16 +17,19 @@ import { completedTurnPrefix } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
/** A bare `stop` finish that streams no content → the turn ends `completed`
* with NO `assistant/message` of its own. */
const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }]
/**
* Drives the REAL fork backend with a real loop + scripted mock MODEL + the
* real dsh-invariants plugin. The invariants plugin re-replays a seeded child
* log on `session/created` (its freeze-check), so a malformed (unbalanced) fork
* seed makes these tests THROW — that is the regression guard for the
* completed-turn-prefix boundary.
* real dsh-invariants plugin. The plugin replays a seeded child log on
* `session/created`, so a malformed (unbalanced) fork seed makes these tests
* THROW — that is the regression guard for the completed-turn-prefix boundary.
*/
async function setup(script: Script) {
const ctx = new Context()
@@ -78,9 +81,9 @@ describe('dsh-subagent-fork', () => {
if (info.provider === 'fork') childAtStart = ctx.agents.get(info.id)
})
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const starting = start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
expect(childAtStart).toBeUndefined()
await run.started
const run = await starting
expect(childAtStart).toBe(ctx.agents.get(run.id))
expect(childAtStart?.id).toBe(run.id)
@@ -93,7 +96,7 @@ describe('dsh-subagent-fork', () => {
// the seed → the child runs fresh. Exercises the `seed.length > 0` false arm.
const { ctx, parent } = await setup([textResponse('fresh child')])
expect(completedTurnPrefix(parent)).toEqual([])
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('fresh child')
@@ -111,7 +114,7 @@ describe('dsh-subagent-fork', () => {
await parent.whenIdle()
const parentPrefixLen = parent.session.events.length
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('child answer')
@@ -142,7 +145,7 @@ describe('dsh-subagent-fork', () => {
await new Promise(r => setTimeout(r, 20)) // let the hanging turn open
// Forking now must NOT throw (the open second turn is excluded from the seed).
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('child')
@@ -164,7 +167,7 @@ describe('dsh-subagent-fork', () => {
])
parent.send([{ type: 'text', text: 'warm up' }])
await parent.whenIdle()
const run = ctx.subagents.start('fork', {
const run = await start(ctx, 'fork', {
prompt: [{ type: 'text', text: 'report structured' }],
parent,
outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
@@ -183,7 +186,7 @@ describe('dsh-subagent-fork', () => {
parent.send([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
const result = await run.result
// The child completed its own (empty) turn — completed, but with NO output
// borrowed from the seeded parent prefix.

View File

@@ -1,25 +1,41 @@
# @deepseek-ai/dsh-subagent-inprocess
Shared run driver for the in-process [spawn](../subagent-spawn/README.md) and [fork](../subagent-fork/README.md) providers. It creates a child agent on the same Cordis application; the providers differ only in the optional session seed.
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here.
## `startInProcessRun(ctx, request, options)`
## Start contract
The driver snapshots mutable request data, checks delegation depth, and creates one run-owner fiber under the parent. Parent teardown, provider teardown, manual disposal, and cancellation during creation converge on that owner.
`startInProcessRun(request, options): Promise<SubagentRun>` fulfills only after the child is published in `ctx.agents`. A rejected start has already quiesced the agent factory's unpublished creation transaction, so the caller never receives a half-created handle.
Child creation uses fresh IDs, lineage, an inherited or overridden model, and an unpublished setup callback for persona, tool restriction, and structured output. `run.started` resolves after the child is published. The result path sends one prompt, waits for idle, and derives output only from events after the seed boundary; a seeded parent answer cannot become the child's result.
The driver follows this sequence:
`dispose()` awaits creation or rollback and then the child handle's quiescent disposal. `cancel()` records pre-publication cancellation and applies it when the child exists. A cancelled attempt with no completed turn reports `aborted`.
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one.
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`.
5. Read the child's own last assistant message and terminal turn reason, excluding any fork seed.
`InProcessRunOptions` is `{ seed?: SessionEvent[] }`: absent for spawn and the completed-turn prefix for fork.
The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
## Cancellation and ownership
The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child.
After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed.
## Spawn and fork inputs
`InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output.
`depthOf(agent)` reads `AgentOptions.subagentDepth`, treating absence as top-level depth zero and rejecting malformed stored values. `SubagentDepthError` reports an attempted child depth above `maxDepth`; an unrepresentable depth above the safe-integer domain is a `RangeError`.
## Structured output
`attachStructuredRuntime(childCtx, schema)` installs a child-scoped capture tool, prompt instruction, protection, result observer, guard, and terminal turn policy. The actual schema is registered only for that child.
`attachStructuredRuntime(childCtx, schema)` installs the whole contract in the child's scope:
A validated value is staged by immutable execution identity and committed only after the authoritative `tools/result` succeeds. Code Mode also waits for the enclosing `run_code` result. Once pending or committed, later tool calls are denied; after commit, `agent/turn-stop` prevents another model step. A child that finishes without a committed value reports an error.
- A `structured_output` tool registered with the requested schema validates and stages the model's value.
- An order-190 system-prompt section tells the child that the tool call is the terminal answer.
- Both contributions use `ownerFinal: true`, so their owners control their final presence after prompt/tool assembly while unrelated contributions remain extensible.
- A `tools/result` observer commits a staged value only after that execution's authoritative final tool result succeeds, including the enclosing `run_code` result for Code Mode sub-dispatch.
- A monotonic tool guard blocks later calls after capture, and `agent/turn-stop` ends the turn after the structured result commits.
## Depth
`depthOf(agent)` reads merge-extensible `AgentOptions.subagentDepth` (default `0`). `startInProcessRun` throws `SubagentDepthError` when the next depth exceeds `maxDepth`.
See [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) for ownership and final-policy rationale.
A clean turn that never commits the required structured value reports `error`; the driver does not re-prompt. All registrations ride the child fiber and disappear with it.

View File

@@ -1,25 +1,24 @@
/**
* The shared in-process subagent run driver: run a child as a child {@link Agent} on the same
* cordis context (`ctx.agents`) — the cheapest transport, reusing the agent factory's
* quiescent {@link AgentHandle} teardown.
* Shared driver for in-process subagent providers. The agent factory's
* creation transaction owns unpublished setup and rollback; after publication
* the returned AgentHandle is the one quiescent lifecycle owner held by the
* provider's caller.
*
* @module @deepseek-ai/dsh-subagent-inprocess
*/
import { randomUUID } from 'node:crypto'
import type { Context, Fiber } from 'cordis'
import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, isJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { Context } from 'cordis'
import { AgentId, type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import {
attachStructuredRuntime,
type StructuredAttachment,
} from './structured.ts'
// The runtime itself (attach) is package-internal: runs attach it inside
// startInProcessRun's setup window, and no other package drives it. Only the
// model-facing vocabulary is public.
export {
STRUCTURED_OUTPUT_TOOL,
STRUCTURED_OUTPUT_INSTRUCTION,
@@ -27,28 +26,26 @@ export {
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/**
* The agent's delegation depth in the subagent tree — 0 for a top-level
* (config/ACP-created) agent, parent depth + 1 for a subagent. Set by the
* in-process backends on every child they create so a nested spawn reads its
* parent's depth from `parent.options.subagentDepth` and the `depthLimit`
* capability can cap the tree. Merge-extensible field (the seam owns it; the
* loop neither sets nor reads it).
*/
/** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */
subagentDepth?: number
}
}
/**
* Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0).
* @param agent - the agent whose options may carry `subagentDepth`.
* @returns 0 for a top-level agent, its parent's depth + 1 for a subagent.
* Read an agent's delegation depth, treating absence as top-level depth zero.
* @param agent - the agent whose options carry the depth.
* @returns its non-negative safe-integer depth.
*/
export function depthOf(agent: Agent): number {
return agent.options.subagentDepth ?? 0
const depth = agent.options.subagentDepth
if (depth === undefined) return 0
if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) {
throw new TypeError('agent subagentDepth must be a non-negative safe integer')
}
return depth
}
/** Thrown when a spawn would exceed the request's `maxDepth` cap. */
/** Thrown when starting a child would exceed the requested depth cap. */
export class SubagentDepthError extends Error {
constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) {
super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`)
@@ -56,7 +53,7 @@ export class SubagentDepthError extends Error {
}
}
/** Map a session `turn/end` reason to a {@link SubagentStopReason}. */
/** Map a session turn outcome to the subagent seam's terminal vocabulary. */
function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
switch (reason?.kind) {
case 'completed':
@@ -65,8 +62,6 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
return 'max-tokens'
case 'aborted':
return 'aborted'
// `disposed` (torn down mid-turn) and `interrupted` (crash-closed) both mean the turn did
// not finish cleanly; surface them as a generic failure rather than a clean completion.
case 'error':
case 'disposed':
case 'interrupted':
@@ -75,228 +70,120 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
}
}
/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */
/** Extra inputs the spawn and fork providers supply to the shared driver. */
export interface InProcessRunOptions {
/**
* The child session's seed: a balanced, contiguous-from-0 prefix of the
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
*/
/** Completed-turn seed for fork, or undefined for a fresh spawn. */
readonly seed?: SessionEvent[]
}
/** Dispose a run-owner fiber and follow an already-started unload to quiescence. */
async function quiesceFiber(fiber: Fiber): Promise<void> {
await Promise.resolve(fiber.dispose())
while (fiber.inertia !== undefined) await fiber.inertia
/** Error used when cancellation wins before the child publication boundary. */
function prePublicationAbort(): Error {
return new Error('subagent request was aborted before child publication')
}
/**
* Start an in-process child agent for `request` and return a {@link SubagentRun}.
*
* @param ctx - the provider context that owns the live run as a second
* structured-concurrency boundary alongside the parent agent.
* @param request - the start request (prompt, parent, signal, per-child options).
* @param options - the backend's optional child-session seed.
* @returns the live run handle for the child agent.
* Establish and drive one in-process child. Fulfillment means the agent is
* already published in the registry; rejection means the agent factory's
* creation transaction and any partially-created child have reached quiescence.
* @param request - the trusted typed start request, including its required signal.
* @param options - the optional fork seed.
* @returns a ready holder-owned run.
*/
export function startInProcessRun(
ctx: Context,
export async function startInProcessRun(
request: SubagentStartRequest,
options: InProcessRunOptions,
): SubagentRun {
// Snapshot the accepted request synchronously. The parent and signal are
// identity capabilities (kept live but never reread from the mutable request
// record); every data field is detached before asynchronous owner setup.
): Promise<SubagentRun> {
assertSubagentMaxDepth(request.maxDepth)
if (request.signal.aborted) throw prePublicationAbort()
const parent = request.parent
const signal = request.signal
const persona = request.persona
const toolFilter = request.toolFilter === undefined ? undefined : structuredClone(request.toolFilter)
const seed = options.seed === undefined ? undefined : structuredClone(options.seed)
const childDepth = depthOf(parent) + 1
if (!Number.isSafeInteger(childDepth)) {
throw new RangeError('subagent child depth exceeds the safe-integer range')
}
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
throw new SubagentDepthError(childDepth, request.maxDepth)
}
// Assert, then snapshot, the schema subset before any child exists (the service has already
// capability-gated; this rejects a schema outside the enforced subset loud).
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema)
// The accepted request owns a value snapshot, not the caller's mutable content array.
if (!isJsonValue(request.prompt)) {
throw new TypeError('subagent prompt must be losslessly JSON-serializable')
}
const prompt = structuredClone(request.prompt)
if (!isJsonValue(prompt)) {
throw new TypeError('subagent prompt must be stable losslessly JSON-serializable data')
}
const childId = AgentId(randomUUID())
// The child's OWN events begin after the seed (fork seeds the parent's
// completed-turn prefix; spawn seeds nothing). `readResult` scopes to this
// boundary so a child that produces no message of its own never returns the
// SEEDED parent's last assistant message as its result.
const seedLength = options.seed?.length ?? 0
const parentHeader = parent.session.header
// Inherit the parent's model by default (a child with no model cannot run); an explicit
// `request.agentOptions.model` overrides it.
const agentOptions: AgentOptions = structuredClone({
...parent.options.model !== undefined ? { model: parent.options.model } : {},
const parentModel = parent.options.model
const agentOptions: AgentOptions = {
...parentModel !== undefined ? { model: parentModel } : {},
...request.agentOptions,
subagentDepth: childDepth,
})
}
// The child's scoped world, composed in the factory's unpublished setup window.
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
if (persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: persona })
if (request.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
}
if (toolFilter !== undefined) {
childCtx.tools.restrict(toolFilter)
}
if (schema !== undefined) {
structured = attachStructuredRuntime(childCtx, schema)
if (request.toolFilter !== undefined) childCtx.tools.restrict(request.toolFilter)
if (request.outputSchema !== undefined) {
structured = attachStructuredRuntime(childCtx, request.outputSchema)
}
}
// Bridge the request's abort signal to the child (the consumer also bridges its own
// exec.signal, but a backend-level bridge keeps the contract local).
let cancelled = false
// An accessor, not an inline read: `cancelled` mutates from closures (the
// abort listener, run.cancel), which control-flow narrowing cannot see — an
// inline read at the result mapping would narrow to the initializer.
const isCancelled = (): boolean => cancelled
let child: Agent | undefined
let handle: AgentHandle | undefined
let disposeRequested = false
const isDisposeRequested = (): boolean => disposeRequested
const requestCancel = (reason: string): void => {
cancelled = true
child?.cancel(reason)
}
const onAbort = (): void => { requestCancel('subagent cancelled') }
// One run-owned Cordis fiber is the common ownership node.
let ownerCtx: Context | undefined
function subagentRunOwner(inner: Context): void { ownerCtx = inner }
let ownerFiber: (Fiber & PromiseLike<Fiber>) | undefined
let ownerSetupError: unknown
let ownerDisposing: Promise<void> | undefined
const disposeOwner = (): Promise<void> => (ownerDisposing ??= ownerFiber === undefined
? Promise.resolve()
: quiesceFiber(ownerFiber))
let manualDisposeRequested = false
const isManualDisposeRequested = (): boolean => manualDisposeRequested
const unlinkProvider = ctx.effect(() => () => {
requestCancel('subagent provider disposed')
return disposeOwner()
}, 'subagent-inprocess.run()')
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) requestCancel('subagent cancelled')
try {
ownerFiber = parent.ctx.plugin(Object.assign(subagentRunOwner, {
inject: ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'],
}))
} catch (error: unknown) {
ownerSetupError = error
const flags = { cancelled: false }
const handle = await parent.ctx.agents.create({
agentId: childId,
sessionId: SessionId(randomUUID()),
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
...seedLength > 0 ? { seedLength } : {},
},
...options.seed !== undefined ? { seed: options.seed } : {},
agentOptions,
signal: request.signal,
setup,
})
const child = handle.agent
// Agent creation detaches its creation-only abort listener before returning.
// Close the narrow handoff race before installing the live-run listener.
// Static analysis does not model the abort that may land between the
// factory's listener detachment and this continuation.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (request.signal.aborted) {
flags.cancelled = true
await handle.dispose()
throw prePublicationAbort()
}
const creation: Promise<Agent> = (async () => {
if (ownerSetupError !== undefined) {
throw ownerSetupError instanceof Error
? ownerSetupError
: new Error('subagent run owner setup failed with a non-Error value', { cause: ownerSetupError })
}
await ownerFiber
if (ownerCtx === undefined) {
throw new Error('subagent run owner became inactive before child creation')
}
// Invoke the factory THROUGH the parent scope.
const created = await ownerCtx.agents.create({
agentId: childId,
sessionId: SessionId(randomUUID()),
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
...seedLength > 0 ? { seedLength } : {},
},
...seed !== undefined ? { seed } : {},
agentOptions,
setup,
})
handle = created
child = created.agent
if (isCancelled()) created.agent.cancel('subagent cancelled')
return created.agent
})()
// Provider readiness is a distinct lifecycle boundary from accepting the request.
const started: Promise<void> = creation.then(() => undefined)
const onAbort = (): void => {
flags.cancelled = true
child.cancel('subagent request aborted')
}
request.signal.addEventListener('abort', onAbort, { once: true })
const result: Promise<SubagentResult> = (async () => {
try {
let liveChild: Agent
try {
await started
// `creation` assigns `child` before it fulfills, and `started` is its
// direct fulfillment projection. The cast records that local invariant
// without manufacturing an unreachable runtime branch.
liveChild = child as Agent
} catch (error: unknown) {
if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' }
throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error })
}
if (isCancelled() || isDisposeRequested()) return { output: [], stopReason: 'aborted' }
liveChild.send(prompt)
await liveChild.whenIdle()
// Deliberately NO re-prompt when a structured child finishes cleanly
// without calling structured_output: readResult maps that to `error` —
// the shortfall goes to the parent instead of buying extra model turns.
return readResult(liveChild, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
child.send(request.prompt)
await child.whenIdle()
return readResult(
child,
seedLength,
flags.cancelled,
structured ? { captured: structured.captured() } : undefined,
)
} finally {
signal?.removeEventListener('abort', onAbort)
request.signal.removeEventListener('abort', onAbort)
}
})()
let disposing: Promise<void> | undefined
return {
id: childId,
started,
result,
cancel(reason?: string): void {
requestCancel(reason ?? 'subagent cancelled')
},
async dispose(): Promise<void> {
return (disposing ??= (async () => {
signal?.removeEventListener('abort', onAbort)
disposeRequested = true
manualDisposeRequested = true
requestCancel('subagent disposed during creation')
// Removing provider ownership and disposing the common run-owner fiber
// are the same quiescence transaction; parent disposal may already have
// claimed it, in which case disposeOwner follows fiber inertia.
await unlinkProvider()
try {
await creation
} catch {
// Creation rollback already reached quiescence; there is no handle
// left to dispose, and dispose must not mask result's infrastructure
// rejection with the same error from a finally block.
return
}
await disposeOwner()
await handle?.dispose()
})())
dispose(): Promise<void> {
request.signal.removeEventListener('abort', onAbort)
flags.cancelled = true
return handle.dispose()
},
}
}
/**
* Read a settled child's terminal result from its session log, scoped to the child's own
* events (everything at or after `seedLength` — fork seeds the parent's completed-turn prefix,
* so a child that produced no message of its own must not return the seeded parent's last
* assistant message).
*/
/** Read one settled child's result from events after its optional fork seed. */
function readResult(
child: Agent,
seedLength: number,
@@ -304,17 +191,20 @@ function readResult(
structured?: { captured?: { value: unknown } | undefined },
): SubagentResult {
const own = child.session.events.slice(seedLength)
const lastMessage = own.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message')
const lastEnd = own.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end')
const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : []
const stopReason: SubagentStopReason = lastEnd === undefined && cancelled
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
const lastEnd = own.findLast((event): event is SessionEvent<'turn/end'> => event.type === 'turn/end')
const output: ContentBlock[] = lastMessage?.data.content ?? []
const recorded = toStopReason(lastEnd?.data.reason)
// Disposal can tear the owner down before the loop records its ordinary
// `aborted` end, yielding `disposed` instead. A requested cancellation owns
// every non-completed in-flight outcome; a turn already completed stays so.
const stopReason: SubagentStopReason = cancelled && recorded !== 'completed'
? 'aborted'
: toStopReason(lastEnd?.data.reason)
if (structured) {
if (structured.captured) return { output, structured: structured.captured.value, stopReason }
// No capture on a cleanly-completed turn: an ERROR when the run was left
// to finish (the nudges ran out), but ABORTED when a cancel is why the
// nudging stopped — the cancel contract outranks the schema shortfall.
: recorded
if (structured !== undefined) {
if (structured.captured !== undefined) {
return { output, structured: structured.captured.value, stopReason }
}
if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
}
return { output, stopReason }

View File

@@ -1,6 +1,45 @@
/**
* Child-scoped structured-output capture. Values commit only after the final
* tool outcome; guards and terminal turn policy prevent work after capture.
* Structured-output support for the in-process subagent backends: the
* mechanism behind `SubagentStartRequest.outputSchema` for children that run
* as agents on the same context.
*
* Everything is a SCOPED registration on the child agent's context
* (`child.ctx`, the dsh-scope seam): the `structured_output` capture tool
* carries the run's REAL schema as its registered parameters (each child sees
* exactly its own schema — two concurrent structured runs never interact), the
* demand instruction is an ordinary order-190 scoped section, and the
* enforcement listeners fire only for this child (scope-filtered dispatch).
* Registration lifetime rides the child's fiber, so a backend hot-reload
* mid-run cannot unregister the capture tool out from under a live child, and
* a disposed child leaves no residue — no placeholder schema,
* strip-for-everyone-else pass, or refcounted global runtime.
*
* The child scope's registrations enforce the contract:
*
* - `ownerFinal: true` on the capture tool and instruction declares that the
* owning registrations control their final presence. Prompt assembly restores their canonical state
* after EVERY assembly listener. Canonical absence is protected too: pure
* Code Mode keeps `structured_output` in the SDK only and never grows a
* second native wire tool. Code Mode independently declares its SDK section
* and `run_code` transport owner-final. The loop logs the finalized assembly as the
* request header, so the demand is reconstructable log state, never a
* wire-only mutation.
* - `agent/turn-stop` (serial, scoped): stop the child's turn once its output
* is captured. This terminal checkpoint runs after the ordinary continuation
* waterfall and steering folding, so listener order cannot resurrect a
* completed structured run or carry terminal steering into another turn.
* - `tools.guard()` is the monotonic terminal gate after the extensible
* pre-execute waterfall: once capture commits, no later listener can turn
* the denial back into a dispatched side effect.
* - `tools/result` is the capture COMMIT point. The tool body only STAGES the
* validated value in a WeakMap keyed by the execution object; the awaited,
* non-transforming notification promotes it only when the authoritative
* result after the whole pre/execute/post pipeline succeeds. For a Code Mode
* sub-dispatch, promotion waits again for the enclosing `run_code` result, so
* a runtime failure or outer post-policy block cannot report structured
* success. Execution identity makes call-id reuse and orphaned stages
* irrelevant.
*
* @module @deepseek-ai/dsh-subagent-inprocess/structured
*/
@@ -13,7 +52,11 @@ import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } f
/** The model-facing tool name a structured child must call to finish. */
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
/** Prompt instruction paired with the child-scoped capture tool. */
/**
* The instruction registered as the child's trailing (order-190, the end of
* the tool-guidance band) scoped prompt section: the demand travels with the
* tool, as ordinary prompt state of exactly one agent.
*/
export const STRUCTURED_OUTPUT_INSTRUCTION
= 'When you have your final answer, you MUST report it by calling the '
+ `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. `
@@ -21,18 +64,35 @@ export const STRUCTURED_OUTPUT_INSTRUCTION
/** One structured run's live handle: read the captured value once the child settles. */
export interface StructuredAttachment {
/** @returns the committed value, or `undefined` until one is accepted. */
/**
* The captured value, once the child called the tool with valid arguments
* and the authoritative final tool result accepted that call.
* @returns the committed value, or undefined while none was accepted.
*/
captured(): { value: unknown } | undefined
}
/**
* Install structured-output capture in a child's setup scope.
* @param childCtx - child agent scope context.
* @param schema - validated schema enforced by the capture tool.
* @returns handle for reading the committed value after settlement.
* Attach the structured-output runtime to a child for `schema`: register the
* scoped capture tool (real schema), the scoped instruction section, and the
* scoped enforcement registrations (see the module doc). Call from the
* agent-creation `setup` window with the child's scope context — every
* registration rides the child's fiber and unwinds with the child.
* @param childCtx - the child agent's scope context (`setup`'s argument).
* @param schema - the trusted, already-asserted schema subset to enforce (see
* `assertSupportedOutputSchema` in dsh-tools).
* @returns the attachment handle (read `captured()` after the child settles).
*/
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
// Stages are keyed by pipeline identity, not reusable model call ids.
/**
* Validated values staged by the capture tool body, awaiting THEIR OWN
* authoritative `tools/result` notification. The execution object's identity
* uniquely identifies a trip through the pipeline: adapter call ids may
* repeat across steps, but another execution can never reach this WeakMap
* entry. This is distinct from the opaque `ToolExecutionToken` used to
* correlate nested transports. The final notification always deletes its own
* stage, whether the result succeeded or failed.
*/
const staged = new WeakMap<ToolExecution, { value: unknown }>()
/** Successful nested capture waiting for its enclosing transport to commit. */
let pending: { parent: ToolExecution['token']; value: unknown } | undefined
@@ -43,17 +103,23 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
description:
'Report your final structured result. Call this exactly once, when your answer is complete; '
+ 'the arguments must match this tool\'s parameter schema exactly.',
// The validated subset is a wire-level JSON Schema object.
// ToolSchema.parameters is the wire-level JSON Schema object; the
// asserted subset type is structurally exactly that.
parameters: schema as unknown as Record<string, unknown>,
}
childCtx.tools.register({
...schemaEntry,
ownerFinal: true,
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
const violations = validateStructuredValue(schema, args)
// ToolArgsError → isError result with INVALID_ARGS: the model retries
// within the same turn, exactly like a schema-validated defineTool call.
if (violations.length > 0) throw new ToolArgsError(violations)
// Commit waits for this execution's final result.
staged.set(exec, { value: structuredClone(args) })
// Two-phase commit, keyed by THIS execution: later transformable
// waterfalls may still turn the success into an error. ToolRegistry has
// already frozen model-bound arguments at the actual input boundary.
staged.set(exec, { value: args })
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
},
})
@@ -62,23 +128,27 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
name: `tool:${STRUCTURED_OUTPUT_TOOL}`,
order: 190,
text: STRUCTURED_OUTPUT_INSTRUCTION,
ownerFinal: true,
})
// Protection preserves the mode-appropriate canonical presence or absence.
childCtx.systemPrompt.protect({
sections: [`tool:${STRUCTURED_OUTPUT_TOOL}`],
tools: [STRUCTURED_OUTPUT_TOOL],
})
// Stop the child's turn once its output is captured. This monotonic serial
// checkpoint runs after the ordinary continuation waterfall, its reason,
// and late-steering folding, so no ordering trick can resume a finished run.
childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined {
return captured === undefined ? undefined : { action: 'stop' }
})
// Calls earlier in the same response remain valid; later calls are terminally denied.
// Terminal WITHIN the step. Guards run after the whole pre-execute
// waterfall and compose monotonically (deny or abstain, never allow), so a
// later prepended listener cannot resurrect dispatch. Calls that precede
// capture in the same response remain untouched.
childCtx.tools.guard(exec => captured === undefined && pending === undefined
? undefined
: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`)
// The capture COMMIT observes the immutable, authoritative result after the
// complete pipeline and outer error normalization. This notification cannot
// transform the outcome, so there is no wrapper outside the commit verdict.
childCtx.on('tools/result', function (this: unknown, exec, result): void {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
const entry = staged.get(exec)

View File

@@ -36,7 +36,7 @@ const SCHEMA: StructuredOutputSchema = {
}
/**
* Real loop + scripted mock model + an INLINE spawn-shaped provider over the
* Real loop + scripted mock model + an INLINE fresh-conversation provider over the
* shared driver. The concrete backend plugins are deliberately NOT loaded —
* they would devDep-cycle this package (spawn/fork already depend on the
* driver), and the runtime under test is the driver's; plugin-level structured
@@ -65,7 +65,7 @@ async function setup(script: Script, options: SetupOptions = {}) {
name: 'spawn',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, {}),
start: (request: SubagentStartRequest) => startInProcessRun(request, {}),
})
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
@@ -73,7 +73,13 @@ async function setup(script: Script, options: SetupOptions = {}) {
}
function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial<SubagentStartRequest>): SubagentStartRequest {
return { prompt: [{ type: 'text', text: 'produce the answer' }], parent, outputSchema: SCHEMA, ...extra }
return {
prompt: [{ type: 'text', text: 'produce the answer' }],
parent,
signal: new AbortController().signal,
outputSchema: SCHEMA,
...extra,
}
}
/** The tool names of one recorded model request. */
@@ -86,7 +92,7 @@ describe('in-process structured output', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 42, note: 'done' })
@@ -98,7 +104,7 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
textResponse('MUST NOT BE CONSUMED'),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
// Default continuation would run a second step after the tool call; the
// structured runtime's turn-continuation veto stops the turn instead.
@@ -129,7 +135,7 @@ describe('in-process structured output', () => {
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 5 })
@@ -157,7 +163,7 @@ describe('in-process structured output', () => {
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after the child and prepended: this listener returns allow
// after every downstream pre-execute decision. The service-owned guard
// runs after the waterfall and can only deny, so the body still cannot run.
@@ -194,7 +200,7 @@ describe('in-process structured output', () => {
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// The call ran BEFORE captured was set: the deny gate only guards the
// window after the terminal answer landed.
@@ -203,42 +209,20 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('snapshots the schema at start(): caller mutation after start cannot drift enforcement', async () => {
const mutable: StructuredOutputSchema = {
type: 'object',
properties: { answer: { type: 'number' } },
required: ['answer'],
additionalProperties: false,
}
const pristine = structuredClone(mutable)
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: mutable }))
// Mutate the caller's object AFTER start() returned but before the child's
// first request assembles: with a live reference this would reach both the
// model-visible parameters and validateStructuredValue.
;(mutable.properties as Record<string, unknown>).answer = { type: 'string' }
const result = await run.result
expect(result.structured).toEqual({ answer: 3 })
// The child's request carried the PRISTINE schema, not the mutated one.
const childRequest = adapter.requests.at(-1)
const captureTool = (childRequest?.tools ?? []).find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
expect(captureTool?.parameters).toEqual(pristine)
await run.dispose()
})
it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
textResponse('MUST NOT BE CONSUMED'),
])
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
const run = ctx.subagents.start('spawn', structuredRequest(parent))
let wrapperInstalled = false
// Register this observer only after start() returns.
// Register before the ready-only start. The child session-start boundary is
// after unpublished setup attached structured output but before the loop
// can run. The wrapper awaits the
// explicit downstream stop above, then overwrites that result with continue.
// The later terminal checkpoint still wins.
ctx.on('agent/session-start', (child) => {
if (child.id !== run.id) return
if (child === parent) return
wrapperInstalled = true
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, next): Promise<ContinuationDecision> => {
const downstream = await next()
@@ -246,6 +230,7 @@ describe('in-process structured output', () => {
return { action: 'continue' }
}, { prepend: true })
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(wrapperInstalled).toBe(true)
expect(result.structured).toEqual({ answer: 7 })
@@ -259,9 +244,12 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
textResponse('MUST NOT BE CONSUMED'),
])
// The downstream ordinary policy says stop.
// The downstream ordinary policy says stop. A wrapper registered after
// start() delegates to that stop, then queues steering; ordinary folding
// would turn the stop back into continue. The terminal checkpoint runs
// afterwards and discards that steering.
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
ctx.on('agent/session-start', (child) => {
if (child.id !== run.id) return
child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, next): Promise<ContinuationDecision> => {
@@ -287,7 +275,7 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 7 })
expect(result.stopReason).toBe('completed')
@@ -304,7 +292,7 @@ describe('in-process structured output', () => {
textResponse('here is my answer in prose'),
textResponse('MUST NOT BE CONSUMED'),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.structured).toBeUndefined()
@@ -318,7 +306,7 @@ describe('in-process structured output', () => {
it('an errored child keeps its honest error result (no capture expected)', async () => {
// Script exhaustion on the first call → the child turn errors.
const { ctx, parent, adapter } = await setup([])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('error')
expect(adapter.requests.length).toBe(1)
@@ -327,12 +315,13 @@ describe('in-process structured output', () => {
it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
const { ctx, parent } = await setup([textResponse('prose, no capture')])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const controller = new AbortController()
const run = await ctx.subagents.start('spawn', structuredRequest(parent, { signal: controller.signal }))
// Cancel synchronously inside the turn's end recording: the cancel
// contract outranks the schema shortfall, so the result maps to aborted.
ctx.on('session/event', (session, event) => {
const child = ctx.agents.get(run.id)
if (session === child?.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
if (session === child?.session && event.type === 'turn/end') controller.abort('cancelled at turn end')
})
const result = await run.result
expect(result.stopReason).toBe('aborted')
@@ -341,20 +330,18 @@ describe('in-process structured output', () => {
it('rejects a schema outside the subset loud, before any child exists', async () => {
const { ctx, parent } = await setup([])
expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
}))).toThrow(/unsupported output schema/)
}))).rejects.toThrow(/unsupported output schema/)
expect(ctx.agents.get(AgentId('parent'))).toBeDefined()
})
it('a schema carrying non-JSON values fails as OutputSchemaError, never as a raw clone error', async () => {
it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => {
const { ctx, parent } = await setup([])
// Assertion runs BEFORE the defensive structuredClone: a function-valued
// annotation must surface as the subset violation it is, not escape as
// structuredClone's DataCloneError.
expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {
// Semantic assertion runs before provider startup.
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', default: () => {} } as unknown as StructuredOutputSchema,
}))).toThrow(/unsupported output schema.*annotation must be JSON data/)
}))).rejects.toThrow(/unsupported output schema.*annotation must be JSON data/)
})
it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
@@ -370,7 +357,7 @@ describe('in-process structured output', () => {
}
return next()
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// No capture was committed: the run reports the schema shortfall...
expect(result.structured).toBeUndefined()
@@ -396,7 +383,7 @@ describe('in-process structured output', () => {
}
return next()
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 8 })
@@ -408,7 +395,7 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
textResponse('capture was rejected'),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after attachment and prepended, so it wraps every listener
// the child installed. It delegates first, then converts the apparent
// capture success into the pipeline's authoritative failure.
@@ -435,7 +422,7 @@ describe('in-process structured output', () => {
// replace it (AgentOptions has no prompt field — the instruction is
// per-request wire state added by the final-request listener).
ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const childRequest = adapter.requests.at(-1)!
expect(childRequest.system).toContain('You are a counter.')
@@ -456,7 +443,7 @@ describe('in-process structured output', () => {
return { logs: [], value: 'captured' }
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// This listener is registered after the child's protection and prepended.
// Service finalization still restores the stripped transport and prompt
@@ -500,7 +487,7 @@ describe('in-process structured output', () => {
} as never
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toBeUndefined()
@@ -529,7 +516,7 @@ describe('in-process structured output', () => {
ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME
? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] })
: next())
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toBeUndefined()
@@ -546,7 +533,7 @@ describe('in-process structured output', () => {
parent.send([{ type: 'text', text: 'hello' }])
await parent.whenIdle()
expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
// The loop always assembles a base prompt (the harness identity section),
// so the instruction APPENDS — never replaces.
@@ -577,7 +564,7 @@ describe('in-process structured output', () => {
await parent.whenIdle()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const childRequest = adapter.requests[1]!
expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL)
@@ -610,8 +597,8 @@ describe('in-process structured output', () => {
return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args)
},
])
const runA = ctx.subagents.start('spawn', structuredRequest(parent))
const runB = ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
const runA = await ctx.subagents.start('spawn', structuredRequest(parent))
const runB = await ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
const [a, b] = await Promise.all([runA.result, runB.result])
expect(a.structured).toEqual({ answer: 1 })
expect(b.structured).toEqual({ verdict: 'real' })
@@ -640,7 +627,7 @@ describe('in-process structured output', () => {
variables: { ...replaced.variables },
}
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 5 })
const entries = adapter.requests[0]!.tools!.filter(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
@@ -665,7 +652,7 @@ describe('in-process structured output', () => {
variables: { ...replaced.variables },
}
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 5 })
const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)
@@ -690,7 +677,7 @@ describe('in-process structured output', () => {
execute: () => Promise.resolve([{ type: 'text', text: 'x' }]),
})
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const request = adapter.requests[0]!
const names = toolNames(request)
@@ -724,7 +711,7 @@ describe('in-process structured output', () => {
variables: { ...replaced.variables },
}
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 3 })
const request = adapter.requests[0]!
@@ -752,7 +739,7 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
])
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// A backend hot-reload mid-run must not unregister the capture tool out
// from under the live child: the registration rides the CHILD's fiber.
await disposeProvider()
@@ -793,7 +780,7 @@ describe('in-process structured output', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// A prepended post-execute listener blocks the first capture without
// delegating. The final-result notification discards that execution's
// stage when it observes the error.
@@ -834,7 +821,7 @@ describe('in-process structured output', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Block the first capture after its body stages a value. Its final error
// discards that execution's stage.
let blocks = 1
@@ -872,7 +859,7 @@ describe('in-process structured output', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Discard the first capture's stage via a final post-execute block.
let blocks = 1
ctx.on('tools/post-execute', (exec, _result, next) => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -13,13 +13,6 @@ import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
/**
* Drives the shared in-process run driver DIRECTLY (no provider package), so the
* driver's own contract — depth read/cap, the one-shot drive, the result read —
* is covered independently of which backend (spawn/fork) calls it. The only
* mocked boundary is the model; the real agent loop, SubagentService, and
* dsh-invariants are mounted, so a malformed child session log fails the test.
*/
async function setup(script: Script) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -35,224 +28,127 @@ async function setup(script: Script) {
return { ctx, parent }
}
function text(blocks: { type: string; text?: string }[]): string {
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
function request(parent: Agent, signal = new AbortController().signal) {
return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal }
}
function text(blocks: readonly { type: string; text?: string }[]): string {
return blocks.filter(block => block.type === 'text').map(block => block.text).join('')
}
describe('depthOf', () => {
it('reads 0 for an agent with no subagentDepth, the set value otherwise', async () => {
it('reads zero for a top-level agent and an explicit child depth', async () => {
const { parent } = await setup([])
expect(depthOf(parent)).toBe(0)
const withDepth = { options: { subagentDepth: 3 } } as unknown as Agent
expect(depthOf(withDepth)).toBe(3)
expect(depthOf({ options: { subagentDepth: 3 } } as unknown as Agent)).toBe(3)
})
it.each([Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1])('rejects malformed depth %s', (value) => {
expect(() => depthOf({ options: { subagentDepth: value } } as unknown as Agent))
.toThrow('non-negative safe integer')
})
})
describe('startInProcessRun', () => {
it('rejects a non-JSON prompt before acquiring any run ownership', async () => {
const { ctx, parent } = await setup([])
expect(() => startInProcessRun(ctx, {
prompt: [{ type: 'text', text: Number.NaN as unknown as string }],
parent,
}, {})).toThrow('subagent prompt must be losslessly JSON-serializable')
})
it('rejects a prompt whose getter becomes non-JSON while it is snapshotted', async () => {
const { ctx, parent } = await setup([])
let reads = 0
const prompt = [{
type: 'text' as const,
get text(): string {
reads += 1
return reads === 1 ? 'valid during pre-check' : Number.NaN as unknown as string
},
}]
expect(() => startInProcessRun(ctx, { prompt, parent }, {}))
.toThrow('subagent prompt must be stable losslessly JSON-serializable data')
expect(reads).toBe(2)
})
it('rejects when the run-owner fiber settles without installing its context', async () => {
const { ctx, parent } = await setup([])
function inertOwner(): void {}
const inertFiber = ctx.plugin(inertOwner)
await inertFiber
const parentWithoutOwnerContext = {
options: parent.options,
session: parent.session,
ctx: { plugin: () => inertFiber },
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithoutOwnerContext,
}, {})
await expect(run.result).rejects.toThrow('subagent run owner became inactive before child creation')
await run.dispose()
})
it('normalizes a non-Error thrown while installing the run-owner fiber', async () => {
const { ctx, parent } = await setup([])
const setupFailure = 'non-Error owner setup failure'
const parentWithFailingOwnerSetup = {
options: parent.options,
session: parent.session,
ctx: { plugin: () => { throw setupFailure } },
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithFailingOwnerSetup,
}, {})
await expect(run.result).rejects.toMatchObject({
message: 'subagent run owner setup failed with a non-Error value',
cause: setupFailure,
})
await run.dispose()
})
it('normalizes a non-Error rejected by asynchronous child creation', async () => {
const { ctx, parent } = await setup([])
const creationFailure = 'non-Error child creation failure'
function inertOwner(): void {}
const ownerFiber = ctx.plugin(inertOwner)
await ownerFiber
const rejectWithNonError = (): Promise<never> => {
// Deliberately violate the promise contract to exercise boundary normalization.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
return Promise.reject(creationFailure)
}
const rejectingOwnerCtx = {
agents: { create: rejectWithNonError },
} as unknown as Context
const parentWithRejectingFactory = {
options: parent.options,
session: parent.session,
ctx: {
plugin(plugin: (inner: Context) => void) {
plugin(rejectingOwnerCtx)
return ownerFiber
},
},
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithRejectingFactory,
}, {})
await expect(run.result).rejects.toMatchObject({
message: 'subagent child creation failed with a non-Error value',
cause: creationFailure,
})
await run.dispose()
})
it('follows owner-fiber inertia when raw teardown was already in flight', async () => {
const { ctx, parent } = await setup([])
const gate = Promise.withResolvers<undefined>()
let inertia: Promise<undefined> | undefined = gate.promise
const fakeFiber = {
dispose: vi.fn(() => undefined),
get inertia() { return inertia },
} as unknown as Fiber & PromiseLike<Fiber>
const rejectingOwnerCtx = {
agents: { create: () => Promise.reject(new Error('creation stopped by teardown')) },
} as unknown as Context
const parentWithDisposingOwner = {
options: parent.options,
session: parent.session,
ctx: {
plugin(plugin: (inner: Context) => void) {
plugin(rejectingOwnerCtx)
return fakeFiber
},
},
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithDisposingOwner,
}, {})
let settled = false
const disposing = run.dispose().then(() => { settled = true })
await Promise.resolve()
await Promise.resolve()
expect(fakeFiber.dispose).toHaveBeenCalledOnce()
expect(settled).toBe(false)
inertia = undefined
gate.resolve(undefined)
await disposing
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
})
it('does not attach an abort listener when provider ownership is already inactive', async () => {
const { ctx, parent } = await setup([])
let providerCtx: Context | undefined
function provider(inner: Context): void { providerCtx = inner }
const providerFiber = await ctx.plugin(provider)
await providerFiber.dispose()
if (providerCtx === undefined) throw new Error('provider context was not captured')
const inactiveProviderCtx = providerCtx
const controller = new AbortController()
const addListener = vi.spyOn(controller.signal, 'addEventListener')
expect(() => startInProcessRun(inactiveProviderCtx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent,
signal: controller.signal,
}, {})).toThrow(/inactive context/)
expect(addListener).not.toHaveBeenCalled()
})
it('drives a fresh child (no seed) to completion and returns its output', async () => {
const { ctx, parent } = await setup([textResponse('driver child answer')])
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, {})
it('returns only after publication, drives a fresh child, and disposes it', async () => {
const { ctx, parent } = await setup([textResponse('driver answer')])
const run = await startInProcessRun(request(parent), {})
expect(ctx.agents.get(run.id)).toBeDefined()
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('driver child answer')
expect(text(result.output)).toBe('driver answer')
expect(depthOf(ctx.agents.get(run.id)!)).toBe(1)
await run.dispose()
})
it('snapshots the prompt before asynchronous child creation', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const prompt = [{ type: 'text' as const, text: 'original prompt' }]
const run = startInProcessRun(ctx, { prompt, parent }, {})
prompt[0]!.text = 'mutated after start'
prompt.push({ type: 'text', text: 'also injected' })
await run.result
const child = ctx.agents.get(run.id)!
const userMessage = child.session.events.find(event => event.type === 'user/message')
expect(userMessage?.type === 'user/message' && userMessage.data.content)
.toEqual([{ type: 'text', text: 'original prompt' }])
await run.dispose()
expect(ctx.agents.get(run.id)).toBeUndefined()
})
it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
const { ctx, parent } = await setup([])
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, {}))
.toThrow(SubagentDepthError)
})
it('seeds the child session when a seed is supplied', async () => {
// Drive the parent through one real turn, then seed the child with that
// completed-turn prefix — the child must SEE the parent's history but its
// result is scoped to its OWN events (not the seeded parent message).
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('seeded child reply')])
parent.send([{ type: 'text', text: 'parent q' }])
it('seeds a forked child but reads only the child-owned output', async () => {
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
parent.send([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()
const seed = parent.session.events.slice()
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { seed })
const run = await startInProcessRun(request(parent), { seed })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('seeded child reply')
expect(text(result.output)).toBe('child answer')
const child = ctx.agents.get(run.id)!
// The child inherited the parent's prefix.
expect(child.session.events.slice(0, seed.length).some(e => e.type === 'user/message')).toBe(true)
expect(child.session.header.seedLength).toBe(seed.length)
expect(child.session.events.slice(0, seed.length)).toEqual(seed)
await run.dispose()
})
it('rejects invalid and exceeded depth before publication', async () => {
const { parent } = await setup([])
await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {}))
.rejects.toThrow('non-negative safe integer')
await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {}))
.rejects.toBeInstanceOf(SubagentDepthError)
const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER } } as unknown as Agent
await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError)
})
it('rejects an already-aborted request without publishing a child', async () => {
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const controller = new AbortController()
controller.abort('too late')
await expect(startInProcessRun(request(parent, controller.signal), {}))
.rejects.toThrow('aborted before child publication')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('uses the request signal after publication and dispose as cancellation paths', async () => {
const { parent } = await setup(['hang', 'hang'])
const controller = new AbortController()
const signalled = await startInProcessRun(request(parent, controller.signal), {})
await new Promise(resolve => setTimeout(resolve, 30))
controller.abort('stop child')
await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' })
await signalled.dispose()
const disposed = await startInProcessRun(request(parent), {})
await new Promise(resolve => setTimeout(resolve, 30))
await disposed.dispose()
await expect(disposed.result).resolves.toMatchObject({ stopReason: 'aborted' })
})
it('cleans a failed unpublished setup before rejecting', async () => {
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
await expect(startInProcessRun({
...request(parent),
toolFilter: { deny: ['unknown-tool'] },
}, {})).rejects.toThrow('unknown global tool')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('closes the abort handoff after the factory detaches its creation listener', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const parentWithAbortAtHandoff = {
options: parent.options,
session: parent.session,
ctx: {
agents: {
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
const handle = await ctx.agents.create(options)
// `create()` has detached its creation-only listener, but the
// provider continuation has not installed its live-run listener.
controller.abort('handoff race')
return handle
},
},
},
} as unknown as Agent
await expect(startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {}))
.rejects.toThrow('aborted before child publication')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
})

View File

@@ -1,12 +1,16 @@
# @deepseek-ai/dsh-subagent-spawn
In-process provider that runs each request as a fresh child [`Agent`](../../core/agent) on the same Cordis application. The child has a new session and no inherited conversation; it uses the parent model unless overridden.
The spawn provider creates a fresh child `Agent` in the current process. The child has its own session, sees no parent conversation history, and reuses the host's agent factory and LLM/tool services.
The package delegates lifecycle work to [`startInProcessRun`](../subagent-inprocess/README.md) with no seed. Child creation, persona, tool filtering, structured output, cancellation, and quiescent disposal are owned by the shared driver. `run.started` resolves only after publication, so `subagent/start` observers can resolve the child from `ctx.agents`.
## Behavior
`start(request)` delegates to [`startInProcessRun`](../subagent-inprocess/README.md) with no seed and awaits publication before returning. The child receives parent working-directory/session lineage and inherits the parent model unless overridden, but starts with an empty conversation.
The shared driver owns depth checking, persona and tool-filter setup, structured output, required-signal cancellation, one-shot execution, result reading, and quiescent disposal. A startup rejection leaves no published child; provider unload after fulfillment does not revoke the holder-owned run.
## Capabilities
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`
Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` because it controls the child's creation window and can enforce all four features.
## Config

View File

@@ -38,16 +38,16 @@ class SpawnProvider implements SubagentProvider {
// Context contract: a spawned child starts fresh — it never sees the parent conversation.
readonly inheritsParentContext = false
constructor(readonly name: string, private readonly ctx: Context) {}
constructor(readonly name: string) {}
start(request: SubagentStartRequest) {
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
// depth, drives the one-shot (including the structured capture when the
// request carries an outputSchema), and maps the result.
return startInProcessRun(this.ctx, request, {})
return startInProcessRun(request, {})
}
}
export function apply(ctx: Context, config: Config): void {
ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx))
ctx.subagents.registerProvider(new SpawnProvider(config.providerName))
}

View File

@@ -9,7 +9,7 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as spawn from '../src/index.ts'
import { depthOf, STRUCTURED_OUTPUT_TOOL, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess'
@@ -44,11 +44,15 @@ function text(blocks: { type: string; text?: string }[]): string {
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
}
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
describe('dsh-subagent-spawn', () => {
it('runs a fresh child to completion and returns its final assistant output', async () => {
// One model call for the child: a plain text answer.
const { ctx, parent } = await setup([textResponse('child answer')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('child answer')
@@ -62,11 +66,11 @@ describe('dsh-subagent-spawn', () => {
if (info.provider === 'spawn') childAtStart = ctx.agents.get(info.id)
})
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
const starting = start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
// Creation is asynchronous; no lifecycle claim is made while the child is
// still inside its unpublished setup transaction.
expect(childAtStart).toBeUndefined()
await run.started
const run = await starting
expect(childAtStart).toBe(ctx.agents.get(run.id))
expect(childAtStart?.id).toBe(run.id)
@@ -76,7 +80,7 @@ describe('dsh-subagent-spawn', () => {
it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => {
const { ctx, parent } = await setup([textResponse('hi')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await run.result
const child = ctx.agents.get(run.id)!
expect(child.session.header.id).not.toBe(parent.session.header.id)
@@ -92,7 +96,7 @@ describe('dsh-subagent-spawn', () => {
const parentEventCount = parent.session.events.length
expect(parentEventCount).toBeGreaterThan(0)
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent })
await run.result
const child = ctx.agents.get(run.id)!
// The child's first user/message is its OWN prompt, not the parent's history.
@@ -103,7 +107,7 @@ describe('dsh-subagent-spawn', () => {
it('disposes the child to quiescence (agent removed from the registry)', async () => {
const { ctx, parent } = await setup([textResponse('x')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await run.result
expect(ctx.agents.get(run.id)).toBeDefined()
await run.dispose()
@@ -114,7 +118,7 @@ describe('dsh-subagent-spawn', () => {
it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => {
const { ctx, parent } = await setup([textResponse('x')])
expect(depthOf(parent)).toBe(0)
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await run.result
const child = ctx.agents.get(run.id)!
expect(depthOf(child)).toBe(1)
@@ -124,13 +128,13 @@ describe('dsh-subagent-spawn', () => {
it('refuses to spawn past maxDepth (depthLimit capability)', async () => {
const { ctx, parent } = await setup([])
// parent is depth 0, child would be depth 1 — cap at 0 forbids any child.
expect(() => ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }))
.toThrow(SubagentDepthError)
await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }))
.rejects.toThrow(SubagentDepthError)
})
it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => {
const { ctx, parent } = await setup([maxTokensResponse('cut off')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
@@ -140,52 +144,31 @@ describe('dsh-subagent-spawn', () => {
// Empty script: the child's first model call throws "script exhausted", the
// turn ends `error`, and there is no assistant/message → empty output.
const { ctx, parent } = await setup([])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.output).toEqual([])
await run.dispose()
})
it('settles aborted (without running the child) when the request signal is ALREADY aborted', async () => {
// Regression: a signal aborted before the run starts never fires an `abort` event, so the
// listener can't catch it.
it('rejects without publishing when the request signal is already aborted', async () => {
// Regression: a signal aborted BEFORE the run starts never fires an `abort`
// event, so the listener can't catch it. The driver must check the
// already-aborted case up front and settle `aborted` without running the
// child — otherwise an already-cancelled request runs to `completed`. The
// empty script proves the child's model is never called.
const controller = new AbortController()
controller.abort()
const { ctx, parent } = await setup([])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
await run.dispose()
await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }))
.rejects.toThrow('aborted before child publication')
})
it('cancelling BEFORE the child turn starts settles aborted, not error', async () => {
// Regression: a cancel landing in the pre-turn window clears the queued prompt before any
// `turn/end` is logged.
const { ctx, parent } = await setup([])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
run.cancel('early')
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
await run.dispose()
})
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
const { ctx, parent } = await setup([])
ctx.on('agent/queued', (agent) => {
if (agent.id === run.id) run.cancel('queued-window')
})
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const result = await run.result
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
const child = ctx.agents.get(run.id)!
expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false)
await run.dispose()
})
it('dispose during async child creation waits for rollback and leaves no orphan', async () => {
it('same-tick cancellation rejects start and prevents child publication', async () => {
// Regression: cancellation before publication used to set a flag but let the
// async factory publish a child anyway, so `started` fulfilled and lifecycle
// observers saw an agent for an attempt the caller had already cancelled.
// The empty script also proves no model turn can run.
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
@@ -193,22 +176,36 @@ describe('dsh-subagent-spawn', () => {
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
ctx.on('subagent/start', () => void published.push('subagent/start'))
ctx.on('subagent/end', () => void published.push('subagent/end'))
const controller = new AbortController()
const starting = start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
controller.abort('early')
// Same tick: the factory has reserved ids and entered its async setup
// transaction, but has not published the child yet.
await run.dispose()
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted', output: [] })
await expect(starting).rejects.toThrow()
await Promise.resolve()
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
expect(published).toEqual([])
})
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
ctx.on('agent/queued', () => { controller.abort('queued-window') })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
const result = await run.result
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
const child = ctx.agents.get(run.id)!
expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false)
await run.dispose()
})
it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => {
// 'hang' makes the child's model stream one chunk then wait until aborted.
const controller = new AbortController()
const { ctx, parent } = await setup(['hang'])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
// Let the child's turn start, then abort via the request signal (the
// backend bridges it to child.cancel()).
await new Promise(r => setTimeout(r, 30))
@@ -218,29 +215,18 @@ describe('dsh-subagent-spawn', () => {
await run.dispose()
})
it('run.cancel() also cancels the child directly', async () => {
it('dispose cancels the child and reaches quiescence', async () => {
const { ctx, parent } = await setup(['hang'])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await new Promise(r => setTimeout(r, 30))
run.cancel('test cancel')
await run.dispose()
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
})
it('run.cancel() with no reason uses the default cancel reason', async () => {
const { ctx, parent } = await setup(['hang'])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
await new Promise(r => setTimeout(r, 30))
run.cancel()
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
})
it('does not expose the optional runtime methods (sendMessage/resume) in this cut', async () => {
const { ctx, parent } = await setup([textResponse('x')])
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
expect('sendMessage' in run).toBe(false)
expect('resume' in run).toBe(false)
await run.result
@@ -256,7 +242,7 @@ describe('dsh-subagent-spawn', () => {
meta: { cwd: '/tmp/parent-workspace' },
agentOptions: { model: 'mock' },
})
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent })
await run.result
const child = ctx.agents.get(run.id)!
expect(child.session.header.cwd).toBe('/tmp/parent-workspace')
@@ -273,7 +259,7 @@ describe('dsh-subagent-spawn', () => {
agentOptions: {},
})
// The request supplies the child's model explicitly.
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'p' }],
parent: parentHandle.agent,
agentOptions: { model: 'mock' },
@@ -305,7 +291,7 @@ describe('dsh-subagent-spawn', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
])
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'produce the answer' }],
parent,
outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
@@ -318,7 +304,7 @@ describe('dsh-subagent-spawn', () => {
await run.dispose()
})
it('a backend unload mid-structured-run settles the run and releases the runtime', async () => {
it('a backend unload does not revoke an accepted holder-owned run', async () => {
// Rebuild the stack by hand so we hold the backend's fiber.
const ctx = new Context()
const adapter = new MockAdapter(['hang'])
@@ -333,50 +319,27 @@ describe('dsh-subagent-spawn', () => {
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const run = ctx.subagents.start('spawn', {
const controller = new AbortController()
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'q' }],
parent,
signal: controller.signal,
outputSchema: { type: 'object', properties: { a: { type: 'number' } } },
})
// Let the child's step start streaming, then unload the backend.
// Provider removal prevents new starts but the returned run belongs to its
// holder and remains live.
await new Promise(resolve => setTimeout(resolve, 30))
await fiber.dispose()
expect(ctx.subagents.getProvider('spawn')).toBeUndefined()
expect(ctx.agents.get(run.id)).toBeDefined()
controller.abort('test complete')
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.stopReason).toBe('aborted')
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
await run.dispose()
})
it('a backend unload during child creation prevents every publication notification', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'must never run' }], parent,
})
await fiber.dispose()
await run.result.catch(() => undefined)
await run.dispose()
expect(ctx.agents.get(run.id)).toBeUndefined()
expect(published).toEqual([])
})
it('a start racing an already-unloading backend cannot mint a run-owner fiber', async () => {
it('a start racing an already-unloading backend cannot begin child creation', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -393,10 +356,10 @@ describe('dsh-subagent-spawn', () => {
ctx.on('agent/created', () => void published.push('agent/created'))
const unloading = fiber.dispose()
expect(() => ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'must never start' }], parent,
})).toThrow(/inactive context/)
await unloading
await expect(start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'must never start' }], parent,
})).rejects.toThrow(/no subagent provider/)
expect(parent.ctx.fiber.getEffects()).toHaveLength(parentEffects)
expect(published).toEqual([])
@@ -423,7 +386,7 @@ describe('dsh-subagent-spawn', () => {
parent.send([{ type: 'text', text: 'hi' }])
await parent.whenIdle()
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,
persona: 'You are the tersest test runner.',
@@ -446,7 +409,7 @@ describe('dsh-subagent-spawn', () => {
name: 'forbidden_tool', description: 'global', parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]),
})
const run = ctx.subagents.start('spawn', {
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,
toolFilter: { deny: ['forbidden_tool'] },
@@ -466,13 +429,11 @@ describe('dsh-subagent-spawn', () => {
it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => {
const { ctx, parent } = await setup([])
const before = ctx.agents.list().length
const run = ctx.subagents.start('spawn', {
await expect(start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,
toolFilter: { deny: ['no_such_tool'] },
})
await expect(run.result).rejects.toThrow(/unknown tool "no_such_tool"/)
await run.dispose()
})).rejects.toThrow(/unknown global tool "no_such_tool"/)
expect(ctx.agents.list().length).toBe(before)
})
})
@@ -492,12 +453,10 @@ describe('dsh-subagent-spawn', () => {
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
await expect(start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent: parentHandle.agent,
})
await expect(run.result).rejects.toThrow(/inactive context/)
await run.dispose()
})).rejects.toThrow(/inactive context/)
expect(ctx.agents.list().length).toBe(before)
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
expect(published).toEqual([])
@@ -515,18 +474,16 @@ describe('dsh-subagent-spawn', () => {
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
const starting = start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'must never run' }],
parent: parentHandle.agent,
})
// The factory has entered its awaited unpublished setup transaction. Parent
// ownership was installed before that await, so disposal wins without an
// The factory has entered its awaited unpublished setup transaction. The
// parent context owns that transaction, so disposal wins without an
// observer ever seeing the child.
await parentHandle.dispose()
await expect(run.result).rejects.toThrow(/owner disposed during setup|inactive context/)
await run.dispose()
await expect(starting).rejects.toThrow(/owner disposed during setup|inactive context/)
expect(ctx.agents.get(run.id)).toBeUndefined()
expect(published).toEqual([])
})
})

View File

@@ -1,43 +1,61 @@
# @deepseek-ai/dsh-subagent
The **subagent seam**: an abstract `SubagentService` (`ctx.subagents`) for an agent delegating work to another agent. A *subagent* is a child agent; a `SubagentProvider` is one transport for running it.
The subagent seam lets one agent delegate work to a child through a named provider. Callers use one service API (`ctx.subagents`); providers decide whether the child runs in this process, in another process, or through a future transport.
This package is the interface third of the capability seam, split so each concern evolves (and swaps) independently:
## Package roles
The family separates the stable interface from implementations and model-facing tools:
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-subagent` (this) | the interface: registry service + vocabulary types |
| `@deepseek-ai/dsh-subagent-spawn` | an implementation: fresh in-process child |
| `@deepseek-ai/dsh-subagent-fork` | an implementation: in-process child seeded from the parent's log |
| `@deepseek-ai/dsh-subagent-acp` | an implementation: ACP client driving another process |
| `@deepseek-ai/dsh-tool-subagent` | the model-facing tool over `ctx.subagents` |
| `@deepseek-ai/dsh-subagent` | Provider registry, request/result types, and lifecycle events. |
| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child. |
| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns. |
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child. |
| `@deepseek-ai/dsh-tool-subagent` | Model-facing tool over one configured provider. |
Unlike the bash seam (one executor per context, second load throws), **multiple providers coexist** here. Each registers under a unique name and a caller picks one by name — the shape mirrors the LLM adapter registry (`LlmService.registerAdapter`), not the single-service bash executor. This is the requirement that rules out the bash shape: an agent may want an in-process child for a cheap subtask and an out-of-process ACP child for an isolated one, in the same runtime.
Multiple providers may coexist under different names. This lets a deployment expose, for example, a cheap in-process child and an isolated ACP child without changing the service contract.
## Service API (`ctx.subagents`)
## Service API
| Member | Semantics |
`SubagentService` has four main operations:
| Member | Meaning |
|---|---|
| `registerProvider(provider)` | Register a frozen acceptance snapshot under `provider.name`; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. |
| `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). |
| `list()` | Registered provider names (insertion order). |
| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start`. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. |
| `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. |
| `getProvider(name)` | Return the provider, or `undefined` when absent. |
| `list()` | Return provider names in insertion order. |
| `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. |
## Capabilities: two kinds, discovered two ways
`SubagentStartRequest.signal` is required and is the canonical cancellation channel. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona.
- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter/persona`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored.
- **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path.
Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries.
Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it.
## Capabilities
## Run lifecycle
Start-time features are advertised in `provider.capabilities` because the service must reject an unsupported request before child creation:
`provider.start(request)` returns a `SubagentRun`: a handle with `started` (the publication/readiness promise), `result` (the terminal outcome), `cancel()`, `dispose()`, and the optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session.
- `outputSchema` — enforce a structured final result.
- `depthLimit` — enforce `maxDepth`.
- `toolFilter` — apply the requested child tool restriction.
- `persona` — apply a per-child persona.
The service emits provider-added and provider-removed after registry changes, so consumers track membership without assuming sibling load order. A run emits `subagent/start` only after readiness and `subagent/end` only after that announced run settles; readiness rejection emits neither. Both are observe-only. Result settlement is observed immediately, cloned, and buffered until start to prevent unhandled rejection, preserve start-before-end ordering, and isolate listener mutation. Settled output appears as `lastAssistantMessage`; infrastructure rejection omits it. Remote providers need not publish a local agent.
Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a live child, while `resume?` asynchronously creates a continuation run. Method presence is the capability check.
## Scope (first cut)
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority.
The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
## Ownership and lifecycle
See `src/types.ts` for the full contracts.
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent.
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order.
## Collection model
The current model-facing tool collects synchronously: it awaits the child result and disposes the run before returning. Background collection and polling remain outside this seam. See the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md) and `src/types.ts` for the complete contracts.

View File

@@ -1,15 +1,21 @@
/**
* The subagent seam (`ctx.subagents`): a named-provider registry plus a capability-validating
* `start` surface. A subagent is an agent delegating work to another agent; a {@link
* SubagentProvider} is one transport for running that child (in-process spawn/fork, ACP to
* another process, and — later — A2A, the Codex app-server, the Claude Code Agent SDK).
* The subagent seam (`ctx.subagents`): a named-provider registry plus a
* capability-validating asynchronous start surface. Providers establish a
* child before returning its run, so fulfillment is the single publication and
* ownership-transfer boundary.
*
* Same-process providers are trusted typed collaborators. Requests, provider
* descriptors, results, and lifecycle payloads are borrowed immutable values;
* serialization and hostile-input validation belong at real process, worker,
* persistence, and model boundaries.
*
* @module @deepseek-ai/dsh-subagent
*/
import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
@@ -31,6 +37,21 @@ export type {
SubagentStopReasonMap,
} from './types.ts'
/**
* Reject a recursion cap that cannot represent an exact delegation depth.
* @param maxDepth - the optional runtime value to validate.
*/
export function assertSubagentMaxDepth(maxDepth: unknown): void {
if (maxDepth !== undefined && (
typeof maxDepth !== 'number'
|| !Number.isSafeInteger(maxDepth)
|| maxDepth < 0
|| Object.is(maxDepth, -0)
)) {
throw new TypeError('subagent maxDepth must be a non-negative safe integer')
}
}
declare module 'cordis' {
interface Context {
subagents: SubagentService
@@ -38,80 +59,59 @@ declare module 'cordis' {
interface Events {
/**
* A provider became resolvable in the {@link SubagentService} registry.
* Consumers that derive state from a named provider (e.g. the model-facing
* tool wording in `dsh-tool-subagent`) react HERE instead of assuming load
* order — the cordis Loader starts sibling plugins concurrently, so
* "listed earlier in cordis.yml" does not mean "registered earlier".
* @param provider - the registry's frozen acceptance snapshot of the provider.
* A provider became resolvable in the registry.
* @param provider - the registered provider.
* @mode emit
*/
'subagent/provider-added'(provider: SubagentProvider): void
/**
* A provider left the registry (its plugin's fiber was disposed — an
* unload or an HMR reload). Consumers holding provider-derived state drop
* it here; a reload re-fires `subagent/provider-added` with the fresh
* provider. Delivered with per-listener containment: a throwing
* subscriber is logged, never starves later subscribers, and never
* disrupts the provider's teardown.
* @param name - the registry name that no longer resolves.
* A provider left the registry. Accepted runs remain holder-owned.
* @param name - the provider name that no longer resolves.
* @mode emit
*/
'subagent/provider-removed'(name: string): void
/**
* A subagent run started — emitted only after {@link SubagentRun.started} fulfills, when
* the provider has established a live child.
*
* Scope-filtered dispatch: keyed to the delegating parent.
* @param info - which provider started which child agent.
* A provider established a ready child. For in-process providers,
* `ctx.agents.get(info.id)` resolves during this notification.
* Scope-filtered dispatch keys the carrier by the delegating parent, so a
* parent-scoped listener observes only its own delegations. Paired with
* `subagent/end`.
* @param info - the provider and ready child identity.
* @mode emit
*/
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
/**
* A started subagent run settled — emitted when {@link SubagentRun.result}
* resolves (any stop reason) or rejects (reported as `error`). Paired with
* {@link Events['subagent/start']}; a run whose readiness rejected emits
* neither event.
* Dispatch is scoped to the delegating parent.
* Scope-filtered dispatch: keyed to the delegating parent.
* @param info - the run identity plus stop reason and final output.
* A ready child settled. Scope-filtered dispatch uses the same delegating
* parent carrier as `subagent/start`, so the lifecycle pair reaches the
* same scoped audience.
* @param info - the run identity and terminal outcome.
* @mode emit
*/
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
}
}
/** Identifying detail for a started subagent run (the `subagent/start` payload). */
/** Observe-only identifying detail for a ready subagent run. */
export interface SubagentRunInfo {
/** The provider that started the run. */
provider: string
/** The provider that established the run. */
readonly provider: string
/** The child agent's id. */
id: AgentId
readonly id: AgentId
}
/** Outcome detail for a settled subagent run (the `subagent/end` payload). */
/** Observe-only outcome detail for a settled subagent run. */
export interface SubagentRunEndInfo {
/** The provider that ran it. */
provider: string
readonly provider: string
/** The child agent's id. */
id: AgentId
readonly id: AgentId
/** The terminal stop reason. */
stopReason: SubagentResult['stopReason']
/**
* The child's final assistant output ({@link SubagentResult.output}), carried
* onto the end event so an observer sees WHAT the subagent produced without
* holding the run. Absent when the run rejected at the infrastructure level
* (no {@link SubagentResult} was produced — the seam only knows `stopReason:
* 'error'`).
*/
lastAssistantMessage?: ContentBlock[]
readonly stopReason: SubagentResult['stopReason']
/** The child's final assistant output, absent on infrastructure rejection. */
readonly lastAssistantMessage?: ContentBlock[]
}
/**
* Typed error for subagent-seam failures. Extends {@link HarnessError}, so the
* `code` string (`DUPLICATE_PROVIDER`, `NO_PROVIDER`, `UNSUPPORTED_CAPABILITY`)
* is shared, machine-routable taxonomy.
*/
/** Typed error for provider lookup, registration, and capability failures. */
export class SubagentError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
@@ -119,10 +119,7 @@ export class SubagentError extends HarnessError {
}
}
/**
* The `subagents` service: a registry of named {@link SubagentProvider}s and a
* capability-checked {@link start} surface.
*/
/** Named provider registry and capability-checked start surface. */
export class SubagentService extends Service {
private providers = new Map<string, SubagentProvider>()
@@ -131,148 +128,88 @@ export class SubagentService extends Service {
}
/**
* Register a provider under its `provider.name`.
*
* @param provider - the provider; its `name` is the registry key.
* @returns the disposer that unregisters the provider. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
* yield it directly — exact identity nests the teardown in order.
* Register a provider under its name. Registration is effect-scoped and HMR
* safe; removing a provider blocks new starts but does not revoke runs that
* were already returned to their holders.
* @param provider - the trusted provider implementation.
* @returns the exact Cordis effect disposer.
*/
registerProvider(provider: SubagentProvider): () => Promise<void> | void {
// Snapshot the accepted registration contract before entering the effect.
const capabilities: SubagentCapabilities = Object.freeze({
outputSchema: provider.capabilities.outputSchema,
depthLimit: provider.capabilities.depthLimit,
toolFilter: provider.capabilities.toolFilter,
persona: provider.capabilities.persona,
})
const snapshot: SubagentProvider = Object.freeze({
name: provider.name,
capabilities,
inheritsParentContext: provider.inheritsParentContext,
start: provider.start.bind(provider),
})
const dispose = this.ctx.effect(function* (this: SubagentService) {
if (this.providers.has(snapshot.name)) {
throw new SubagentError(`a subagent provider named "${snapshot.name}" is already registered`, 'DUPLICATE_PROVIDER')
const name = provider.name
return this.ctx.effect(function* (this: SubagentService) {
if (this.providers.has(name)) {
throw new SubagentError(`a subagent provider named "${name}" is already registered`, 'DUPLICATE_PROVIDER')
}
this.providers.set(snapshot.name, snapshot)
// Yield the rollback before emitting `subagent/provider-added`: a throwing added-listener
// then unregisters the provider (and announces the removal) instead of leaking it into
// the registry.
this.providers.set(name, provider)
yield () => {
this.providers.delete(snapshot.name)
this.emitLifecycle('subagent/provider-removed', snapshot.name)
this.providers.delete(name)
this.emitLifecycle('subagent/provider-removed', name)
}
this.ctx.emit('subagent/provider-added', snapshot)
// A throwing added-listener unwinds the yielded rollback, matching the
// repository's fail-loud registration semantics.
this.ctx.emit('subagent/provider-added', provider)
}.bind(this), 'subagents.registerProvider()')
// Return the exact Cordis disposer so generator effects preserve teardown nesting.
return dispose
}
/**
* Look up the registry's frozen provider snapshot by its accepted name
* (`undefined` if absent).
* @param name - the provider name accepted at registration.
* @returns the frozen acceptance snapshot, or undefined when the name is unknown.
* Look up a provider by name.
* @param name - the provider name.
* @returns the provider, or undefined when absent.
*/
getProvider(name: string): SubagentProvider | undefined {
return this.providers.get(name)
}
/**
* The names of all registered providers (insertion order).
* @returns the registered provider names.
* List registered provider names in insertion order.
* @returns the registered names.
*/
list(): string[] {
return [...this.providers.keys()]
}
/**
* Start a subagent run on the named provider.
*
* @param name - the provider to run on.
* @param request - the child's prompt, capabilities, and options.
* @returns the live run (its `result` resolves when the child settles).
* Establish a ready child on the named provider. Capability and semantic
* checks run before delegation. Provider ownership lasts until its promise
* fulfills; a rejection therefore has no run for the caller to dispose and
* emits no run lifecycle events.
* @param name - the provider to use.
* @param request - child prompt, parent, signal, and optional capabilities.
* @returns the ready holder-owned run.
*/
start(name: string, request: SubagentStartRequest): SubagentRun {
// Parent is the lifecycle scope identity accepted at start. Never reread it
// from the caller-owned request after the provider/result async boundary,
// or start/end could be dispatched into different agent scopes.
const parent = request.parent
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {
const provider = this.providers.get(name)
if (!provider) {
if (provider === undefined) {
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
}
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
// Detach every data field before crossing into a provider.
const accepted: SubagentStartRequest = {
prompt: structuredClone(request.prompt),
parent,
...request.signal !== undefined ? { signal: request.signal } : {},
...request.agentOptions !== undefined ? { agentOptions: structuredClone(request.agentOptions) } : {},
...request.outputSchema !== undefined ? { outputSchema: structuredClone(request.outputSchema) } : {},
...request.maxDepth !== undefined ? { maxDepth: request.maxDepth } : {},
...request.toolFilter !== undefined ? { toolFilter: structuredClone(request.toolFilter) } : {},
...request.persona !== undefined ? { persona: request.persona } : {},
}
const run = provider.start(accepted)
// Observe result settlement IMMEDIATELY, before waiting on readiness.
let readiness: 'pending' | 'started' | 'failed' = 'pending'
let pendingEnd: SubagentRunEndInfo | undefined
const deliverEnd = (info: SubagentRunEndInfo): void => {
if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent)
else if (readiness === 'pending') pendingEnd = info
// A pre-publication readiness failure has no lifecycle pair; result
// remains observable by the run's consumer, but telemetry must not claim
// that a child started.
}
const parent = request.parent
const run = await provider.start(request)
// Attach the terminal observer before dispatching start. Promise reactions
// still run after this synchronous start emission, preserving start → end.
void run.result.then(
(result) => {
// Snapshot before the caller's own `await run.result` continuation.
let lastAssistantMessage: SubagentResult['output'] | undefined
try {
lastAssistantMessage = structuredClone(result.output)
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
}
deliverEnd({
this.emitLifecycle('subagent/end', {
provider: name,
id: run.id,
stopReason: result.stopReason,
...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {},
})
},
() => { deliverEnd({ provider: name, id: run.id, stopReason: 'error' }) },
)
// Readiness is the publication boundary owned by the provider.
void run.started.then(
() => {
readiness = 'started'
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
if (pendingEnd !== undefined) {
const info = pendingEnd
pendingEnd = undefined
this.emitLifecycle('subagent/end', info, parent)
}
lastAssistantMessage: result.output,
}, parent)
},
() => {
readiness = 'failed'
pendingEnd = undefined
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent)
},
)
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
return run
}
/**
* Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch each
* subscriber individually and log (never propagate) a thrown one, so one bad subscriber can
* neither strand the already-live run, surface as an unhandled rejection on the detached
* settle hook, NOR starve the listeners registered after it.
* Emit lifecycle events with per-listener synchronous and asynchronous
* exception containment. Payloads are borrowed immutable values.
*/
private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void
private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void
@@ -282,26 +219,22 @@ export class SubagentService extends Service {
info: SubagentRunInfo | SubagentRunEndInfo | string,
parent?: Agent,
): void {
// Run lifecycle events dispatch in the DELEGATING PARENT's scope (a parent-scoped listener
// observes only its own delegations); the provider-removed registry notification stays
// unfiltered.
const dispatchArgs: unknown[] = parent === undefined
? [name, info]
: [scopeTarget(this, parent), name, info]
for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) {
try {
callback(info)
const returned: unknown = callback(info)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: ${name} listener threw: ${String(error)}`)
this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`)
}
}
}
/**
* Reject a request that needs a start-time capability the provider lacks.
* Each optional request field maps to one {@link SubagentCapabilities} flag;
* the first unmet one throws `UNSUPPORTED_CAPABILITY`.
*/
/** Reject the first requested capability that the provider lacks. */
private assertCapabilities(provider: SubagentProvider, request: SubagentStartRequest): void {
const needs: { when: boolean; cap: keyof SubagentCapabilities }[] = [
{ when: request.outputSchema !== undefined, cap: 'outputSchema' },
@@ -320,4 +253,13 @@ export class SubagentService extends Service {
}
}
/** Render any listener-thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}
export default SubagentService

View File

@@ -8,7 +8,7 @@
import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
/**
* Which START-TIME features a provider supports. Checked by the service before delegating to
@@ -18,13 +18,13 @@ import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
*/
export interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
outputSchema: boolean
readonly outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
depthLimit: boolean
readonly depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
toolFilter: boolean
readonly toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
persona: boolean
readonly persona: boolean
}
/**
@@ -35,22 +35,24 @@ export interface SubagentCapabilities {
*/
export interface SubagentStartRequest {
/** The task/prompt for the child agent (a user message in the child session). */
prompt: ContentBlock[]
readonly prompt: ContentBlock[]
/**
* The spawning ("parent") agent — the one whose tool call started this
* subagent. REQUIRED: in-process backends read `parent.session.header` for
* the working directory, the `parentSession` lineage to stamp on the child,
* and the parent's delegation depth. Out-of-process backends (ACP) ignore it.
*/
parent: Agent
readonly parent: Agent
/**
* Cancellation signal from the spawning context (the tool's `exec.signal`).
* A provider that honors it aborts the child when the signal fires; the
* consumer also bridges it to {@link SubagentRun.cancel} explicitly.
* This is the canonical cancellation channel both before and after startup:
* a provider rejects `start()` after cleaning partial resources when it
* fires before publication, and cancels a published child when it fires
* afterward.
*/
signal?: AbortSignal
readonly signal: AbortSignal
/** Per-child agent options (model, system prompt). */
agentOptions?: AgentOptions
readonly agentOptions?: AgentOptions
/**
* Optional structured-output schema — an object-rooted JSON Schema within the
* enforced subset (see `assertSupportedOutputSchema` in dsh-tools; a schema
@@ -61,12 +63,14 @@ export interface SubagentStartRequest {
* data — a caller holding foreign-realm data materializes it first.
* Requesting it against a provider that lacks the capability is rejected at start.
*/
outputSchema?: StructuredOutputSchema
readonly outputSchema?: StructuredOutputSchema
/**
* Optional recursion cap (max delegation depth below this child). Requires
* {@link SubagentCapabilities.depthLimit}; rejected at start otherwise.
* Optional absolute delegation-depth cap for the child being started: its
* computed depth must be less than or equal to this non-negative safe
* integer. Requires {@link SubagentCapabilities.depthLimit}; rejected at
* start otherwise.
*/
maxDepth?: number
readonly maxDepth?: number
/**
* Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter};
* rejected at start otherwise. In-process backends apply it as a scoped
@@ -74,7 +78,7 @@ export interface SubagentStartRequest {
* from the child's prompt AND refuse to execute (one visibility), with loud
* unknown-name validation.
*/
toolFilter?: { allow?: string[]; deny?: string[] }
readonly toolFilter?: ToolRestriction
/**
* Optional per-child persona. Requires {@link SubagentCapabilities.persona};
* rejected at start otherwise. In-process backends register it as a scoped
@@ -82,7 +86,7 @@ export interface SubagentStartRequest {
* persona for this child alone — same template semantics as the deployment
* persona (strict `{{…}}` interpolation against the registered variables).
*/
persona?: string
readonly persona?: string
}
/**
@@ -94,7 +98,7 @@ export interface SubagentStartRequest {
export interface SubagentStopReasonMap {
/** The child finished its turn normally. */
completed: 'completed'
/** The run was cancelled (parent signal, explicit `cancel()`, or peer cancel). */
/** The run was cancelled by its request signal or by disposal. */
aborted: 'aborted'
/** The child failed (model error, transport error). */
error: 'error'
@@ -112,7 +116,7 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM
*/
export interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
output: ContentBlock[]
readonly output: ContentBlock[]
/**
* The structured result after a requested `outputSchema` was successfully
* satisfied. Requesting a schema does not guarantee presence: a provider can
@@ -120,32 +124,24 @@ export interface SubagentResult {
* valid capture. Shape is validated against the request schema by the
* provider; `unknown` here because the seam is schema-agnostic.
*/
structured?: unknown
readonly structured?: unknown
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
stopReason: SubagentStopReason
readonly stopReason: SubagentStopReason
}
/**
* A live subagent run: a handle the consumer holds while a child executes.
* Returned by {@link SubagentProvider.start} (via the service). The consumer
* awaits {@link result}, may {@link cancel} mid-flight, and MUST {@link dispose}
* on every path to reach child quiescence (no leaked idle child / session).
* Returned by {@link SubagentProvider.start} (via the service) only after the
* child is ready. The consumer awaits {@link result} and MUST {@link dispose}
* on every path to cancel any remaining work and reach child quiescence.
*
* {@link sendMessage} and {@link resume} are OPTIONAL: a provider that supports
* the runtime capability defines the method; one that doesn't omits it. The
* presence of the method IS the capability — narrow before calling.
*/
export interface SubagentRun {
/** The child agent's id (local in-process runs publish it in `ctx.agents`; remote transports need not). */
/** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */
readonly id: AgentId
/**
* The provider's publication/readiness boundary. Resolves only after a real
* child is established: an in-process agent is live in `ctx.agents`, or a
* remote transport has created its child session. Rejects when the attempt
* fails or is cancelled before that boundary. The service emits the paired
* `subagent/start`/`subagent/end` lifecycle only after this fulfills.
*/
readonly started: Promise<void>
/**
* Resolves with the child's terminal {@link SubagentResult} when the run
* settles. Does NOT reject on a child-level failure — a model/transport
@@ -154,12 +150,10 @@ export interface SubagentRun {
* cannot represent as a stop reason.
*/
readonly result: Promise<SubagentResult>
/** Request cancellation of the in-flight run; {@link result} settles `aborted`. */
cancel(reason?: string): void
/**
* Reach child quiescence and release the run's resources (in-process: dispose
* the owned agent handle and remove its session; ACP: kill the subprocess).
* Idempotent; awaits the child actually stopping, not merely requesting it.
* Cancel remaining work, reach child quiescence, and release the run's
* resources (in-process: dispose the owned agent and remove its session;
* ACP: kill and reap the subprocess). Idempotent.
*/
dispose(): Promise<void>
/**
@@ -171,7 +165,7 @@ export interface SubagentRun {
* OPTIONAL (resume capability): send a follow-up task to a settled child,
* continuing its session, and return a fresh run for the continuation.
*/
resume?(content: ContentBlock[]): SubagentRun
resume?(content: ContentBlock[]): Promise<SubagentRun>
}
/**
@@ -179,8 +173,8 @@ export interface SubagentRun {
* spawn/fork, ACP to another process, …). Implementations register under a
* unique name via {@link SubagentService.registerProvider}; multiple providers
* coexist in one context (unlike the single-implementation bash seam). The
* service freezes the public descriptor and callback identity at registration;
* the captured `start` remains bound to the original provider receiver.
* Providers are trusted same-process implementations; callers treat their
* descriptors and returned values as borrowed immutable data.
*/
export interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
@@ -188,22 +182,24 @@ export interface SubagentProvider {
/** The start-time features this provider supports (see {@link SubagentCapabilities}). */
readonly capabilities: SubagentCapabilities
/**
* The provider's context contract: `true` when a child SEES the parent
* The provider's conversation-history descriptor: `true` when a child SEES the parent
* conversation (fork — the child is seeded with the parent's completed-turn
* prefix), `false` when it starts fresh (spawn, ACP). A DESCRIPTIVE fact,
* not a start-time capability: the service validates nothing against it —
* the model-facing consumer (`dsh-tool-subagent`) derives truthful tool
* wording from it, so a tool bound to a fork provider stops telling the
* model the child "does not see this conversation".
* model the child "does not see this conversation". This descriptor concerns
* conversation history only; it says nothing about tool registrations,
* injected services, or authority inheritance.
*/
readonly inheritsParentContext: boolean
/**
* Start preparing a child run and return its handle synchronously. The
* Establish a child and return its handle only after publication. The
* service has already validated that every requested start-time capability
* is supported, so an implementation may assume e.g. `request.maxDepth` is
* honorable when present. The returned {@link SubagentRun.started} must mark
* the real publication/readiness boundary; the result path must observe that
* promise immediately so a pre-start rejection cannot become unhandled.
* honorable when present. If setup fails or `request.signal` aborts before
* fulfillment, the provider owns and cleans all partial resources before this
* promise rejects. Ownership transfers to the caller only on fulfillment.
*/
start(request: SubagentStartRequest): SubagentRun
start(request: SubagentStartRequest): Promise<SubagentRun>
}

View File

@@ -5,6 +5,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
import SubagentService, {
SubagentError,
assertSubagentMaxDepth,
type SubagentCapabilities,
type SubagentProvider,
type SubagentResult,
@@ -12,570 +13,218 @@ import SubagentService, {
type SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
/** A minimal parent Agent stand-in — the service only reads `parent.id`. */
function fakeParent(id = 'parent-1'): Agent {
return { id: AgentId(id) } as unknown as Agent
}
const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }
const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }
/** A scripted provider whose run settles immediately with a fixed result. */
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
return {
prompt: [{ type: 'text', text: 'do a thing' }],
parent: fakeParent(),
signal: new AbortController().signal,
...overrides,
}
}
class StubProvider implements SubagentProvider {
startCount = 0
readonly inheritsParentContext = false
startCount = 0
constructor(
readonly name: string,
readonly capabilities: SubagentCapabilities = ALL_CAPS,
private readonly result: SubagentResult = { output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' },
private readonly outcome: SubagentResult = {
output: [{ type: 'text', text: 'ok' }],
stopReason: 'completed',
},
) {}
start(request: SubagentStartRequest): SubagentRun {
this.startCount++
async start(request: SubagentStartRequest): Promise<SubagentRun> {
this.startCount += 1
return {
id: AgentId(`child:${this.name}:${request.parent.id}`),
started: Promise.resolve(),
result: Promise.resolve(this.result),
cancel() {},
result: Promise.resolve(this.outcome),
async dispose() {},
}
}
}
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
return { prompt: [{ type: 'text', text: 'do a thing' }], parent: fakeParent(), ...overrides }
async function service(): Promise<{ ctx: Context; subagents: SubagentService }> {
const ctx = new Context()
await ctx.plugin(SubagentService)
return { ctx, subagents: ctx.subagents }
}
describe('SubagentService', () => {
it('announces provider lifecycle: added on register, removed on dispose', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
it('registers, lists, looks up, starts, and removes providers', async () => {
const { ctx, subagents } = await service()
const added: string[] = []
const removed: string[] = []
ctx.on('subagent/provider-added', provider => void added.push(provider.name))
ctx.on('subagent/provider-removed', name => void removed.push(name))
const dispose = ctx.subagents.registerProvider(new StubProvider('alpha'))
expect(added).toEqual(['alpha'])
expect(removed).toEqual([])
await dispose()
expect(removed).toEqual(['alpha'])
})
it('rolls back the registration when a provider-added listener throws', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
let threw = false
const off = ctx.on('subagent/provider-added', () => {
if (!threw) { threw = true; throw new Error('boom added listener') }
})
expect(() => ctx.subagents.registerProvider(new StubProvider('alpha'))).toThrow('boom added listener')
expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // nothing leaked
off()
ctx.subagents.registerProvider(new StubProvider('alpha'))
expect(ctx.subagents.getProvider('alpha')).toBeDefined()
})
it('contains a throwing provider-removed listener: later mirrors still hear it, teardown completes', async () => {
// provider-removed fires inside the registration's DISPOSER, so a propagating listener
// would disrupt the backend's teardown; and cordis emit halts on the first throw, so an
// uncontained one would starve every mirror registered after it (a stale model-facing
// tool).
const ctx = new Context()
await ctx.plugin(SubagentService)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => void warnings.push(String(message))) as typeof ctx.logger.warn
ctx.on('subagent/provider-removed', () => { throw new Error('boom removed listener') })
const heard: string[] = []
ctx.on('subagent/provider-removed', name => void heard.push(name))
const dispose = ctx.subagents.registerProvider(new StubProvider('alpha'))
expect(() => void dispose()).not.toThrow()
expect(heard).toEqual(['alpha']) // the listener AFTER the thrower still ran
expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // teardown reached quiescence
expect(warnings.some(w => w.includes('boom removed listener'))).toBe(true)
})
it('registers a provider and starts a run on it by name', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const provider = new StubProvider('alpha')
ctx.subagents.registerProvider(provider)
expect(ctx.subagents.list()).toEqual(['alpha'])
expect(ctx.subagents.getProvider('alpha')).toMatchObject({ name: 'alpha' })
const run = ctx.subagents.start('alpha', baseRequest())
expect(provider.startCount).toBe(1)
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
})
it('lets multiple providers coexist (the defining requirement)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider('spawn'))
ctx.subagents.registerProvider(new StubProvider('acp'))
expect(ctx.subagents.list()).toEqual(['spawn', 'acp'])
expect(ctx.subagents.getProvider('spawn')).toBeDefined()
expect(ctx.subagents.getProvider('acp')).toBeDefined()
})
it('throws NO_PROVIDER when starting on an unregistered name', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
try {
ctx.subagents.start('missing', baseRequest())
expect.fail('expected NO_PROVIDER')
} catch (error: unknown) {
expect(error).toBeInstanceOf(SubagentError)
expect((error as SubagentError).code).toBe('NO_PROVIDER')
}
})
it('rejects duplicate provider names with DUPLICATE_PROVIDER', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider('dup'))
try {
ctx.subagents.registerProvider(new StubProvider('dup'))
expect.fail('expected DUPLICATE_PROVIDER')
} catch (error: unknown) {
expect(error).toBeInstanceOf(SubagentError)
expect((error as SubagentError).code).toBe('DUPLICATE_PROVIDER')
}
})
it('unregisters a provider when its owning fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.subagents.registerProvider(new StubProvider('scoped'))
}, { inject: ['subagents'] }))
expect(ctx.subagents.list()).toEqual(['scoped'])
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
})
it('snapshots a provider registration so caller mutation cannot corrupt dispatch or HMR cleanup', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const capabilities: SubagentCapabilities = {
outputSchema: true,
depthLimit: true,
toolFilter: true,
persona: true,
}
const provider = new StubProvider('stable', capabilities)
const added: SubagentProvider[] = []
const removed: string[] = []
ctx.on('subagent/provider-added', registered => void added.push(registered))
ctx.on('subagent/provider-removed', name => void removed.push(name))
const owner = await ctx.plugin({
name: 'mutable-provider-owner',
inject: ['subagents'],
apply(pluginCtx: Context) {
pluginCtx.subagents.registerProvider(provider)
},
})
const accepted = ctx.subagents.getProvider('stable')
const mutable = provider as unknown as {
name: string
capabilities: SubagentCapabilities
inheritsParentContext: boolean
start: SubagentProvider['start']
}
mutable.name = 'mutated'
capabilities.outputSchema = false
capabilities.depthLimit = false
capabilities.toolFilter = false
capabilities.persona = false
mutable.capabilities = NO_CAPS
mutable.inheritsParentContext = true
const replacementStart = vi.fn((_request: SubagentStartRequest): SubagentRun => {
throw new Error('replacement start must not run')
})
mutable.start = replacementStart
expect(added).toEqual([accepted])
expect(accepted).not.toBe(provider)
expect(Object.isFrozen(accepted)).toBe(true)
expect(Object.isFrozen(accepted?.capabilities)).toBe(true)
expect(accepted).toMatchObject({
name: 'stable',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
inheritsParentContext: false,
})
expect(ctx.subagents.list()).toEqual(['stable'])
expect(ctx.subagents.getProvider('mutated')).toBeUndefined()
const run = ctx.subagents.start('stable', baseRequest({
outputSchema: { type: 'object', properties: { answer: { type: 'string' } } },
maxDepth: 2,
toolFilter: { deny: ['bash'] },
persona: 'reviewer',
}))
const dispose = subagents.registerProvider(provider)
expect(subagents.list()).toEqual(['alpha'])
expect(subagents.getProvider('alpha')).toBe(provider)
const run = await subagents.start('alpha', baseRequest())
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
expect(provider.startCount).toBe(1)
expect(replacementStart).not.toHaveBeenCalled()
await owner.dispose()
expect(removed).toEqual(['stable'])
expect(ctx.subagents.list()).toEqual([])
expect(() => ctx.subagents.registerProvider(new StubProvider('stable'))).not.toThrow()
})
it('re-registers a name after its prior registration is disposed (not wedged)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const dispose = ctx.subagents.registerProvider(new StubProvider('reuse'))
expect(ctx.subagents.list()).toEqual(['reuse'])
await dispose()
expect(ctx.subagents.list()).toEqual([])
const disposeAgain = ctx.subagents.registerProvider(new StubProvider('reuse'))
expect(ctx.subagents.list()).toEqual(['reuse'])
await disposeAgain()
expect(ctx.subagents.list()).toEqual([])
expect(added).toEqual(['alpha'])
expect(removed).toEqual(['alpha'])
expect(subagents.getProvider('alpha')).toBeUndefined()
})
describe('start-time capability validation (fail loud, before any child)', () => {
it.each([
{ field: 'outputSchema', request: baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } } }) },
{ field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) },
{ field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) },
])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => {
const ctx = new Context()
return ctx.plugin(SubagentService).then(() => {
const provider = new StubProvider('weak', NO_CAPS)
ctx.subagents.registerProvider(provider)
try {
ctx.subagents.start('weak', request)
expect.fail('expected UNSUPPORTED_CAPABILITY')
} catch (error: unknown) {
expect(error).toBeInstanceOf(SubagentError)
expect((error as SubagentError).code).toBe('UNSUPPORTED_CAPABILITY')
}
// The child was never started — the check is pre-spawn.
expect(provider.startCount).toBe(0)
})
})
it('allows a capability request when the provider supports it', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const provider = new StubProvider('strong', ALL_CAPS)
ctx.subagents.registerProvider(provider)
ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 }))
expect(provider.startCount).toBe(1)
})
it('rolls registration back when provider-added throws', async () => {
const { ctx, subagents } = await service()
ctx.on('subagent/provider-added', () => { throw new Error('added boom') })
expect(() => { subagents.registerProvider(new StubProvider('alpha')) }).toThrow('added boom')
expect(subagents.getProvider('alpha')).toBeUndefined()
})
it('emits subagent/start then subagent/end around a run', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider('events'))
const started = vi.fn()
const ended = vi.fn()
ctx.on('subagent/start', started)
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('events', baseRequest())
await run.started
expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id }))
await run.result
// `subagent/end` fires from a `.then` on the result — let the microtask run.
await Promise.resolve()
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
it('rejects duplicate and absent provider names with typed errors', async () => {
const { subagents } = await service()
subagents.registerProvider(new StubProvider('dup'))
expect(() => { subagents.registerProvider(new StubProvider('dup')) })
.toThrow(expect.objectContaining({ code: 'DUPLICATE_PROVIDER' }))
await expect(subagents.start('missing', baseRequest()))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
})
it('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const readiness = Promise.withResolvers<undefined>()
ctx.subagents.registerProvider({
name: 'delayed-start',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('delayed-child'),
started: readiness.promise,
// Already rejected: SubagentService must attach its result handler in
// the same synchronous start() call, before awaiting readiness.
result: Promise.reject(new Error('early infrastructure fault')),
cancel() {},
async dispose() {},
}),
})
const lifecycle: string[] = []
ctx.on('subagent/start', () => void lifecycle.push('start'))
ctx.on('subagent/end', info => void lifecycle.push(`end:${info.stopReason}`))
const run = ctx.subagents.start('delayed-start', baseRequest())
await expect(run.result).rejects.toThrow('early infrastructure fault')
expect(lifecycle).toEqual([])
readiness.resolve(undefined)
await run.started
expect(lifecycle).toEqual(['start', 'end:error'])
it.each([
['outputSchema', { outputSchema: { type: 'object', properties: {} } }],
['depthLimit', { maxDepth: 1 }],
['toolFilter', { toolFilter: { deny: ['bash'] } }],
['persona', { persona: 'reviewer' }],
] as const)('rejects unsupported %s before provider startup', async (_capability, override) => {
const { subagents } = await service()
const provider = new StubProvider('weak', NO_CAPS)
subagents.registerProvider(provider)
await expect(subagents.start('weak', baseRequest(override)))
.rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' })
expect(provider.startCount).toBe(0)
})
it('emits no lifecycle pair when readiness rejects before a child exists', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const readiness = Promise.withResolvers<undefined>()
it('validates depth and schema semantics before provider startup', async () => {
const { subagents } = await service()
const provider = new StubProvider('strong')
subagents.registerProvider(provider)
await expect(subagents.start('strong', baseRequest({ maxDepth: -1 })))
.rejects.toThrow('non-negative safe integer')
await expect(subagents.start('strong', baseRequest({ outputSchema: { type: 'string' } as never })))
.rejects.toThrow()
expect(provider.startCount).toBe(0)
expect(() => { assertSubagentMaxDepth(undefined) }).not.toThrow()
})
it('publishes lifecycle only after async provider start and keeps parent scope', async () => {
const { ctx, subagents } = await service()
const ready = Promise.withResolvers<SubagentRun>()
const result = Promise.withResolvers<SubagentResult>()
ctx.subagents.registerProvider({
name: 'never-started',
subagents.registerProvider({
name: 'deferred',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('never-started-child'),
started: readiness.promise,
result: result.promise,
cancel() {},
async dispose() {},
}),
start: () => ready.promise,
})
const parent = fakeParent('delegator')
const events: string[] = []
const keys: unknown[] = []
ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) })
ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) })
const starting = subagents.start('deferred', baseRequest({ parent }))
await Promise.resolve()
expect(events).toEqual([])
ready.resolve({ id: AgentId('child'), result: result.promise, async dispose() {} })
const run = await starting
expect(events).toEqual(['start'])
result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
await run.result
await Promise.resolve()
expect(events).toEqual(['start', 'end'])
expect(keys).toEqual([parent, parent])
})
it('emits no run lifecycle when provider startup rejects', async () => {
const { ctx, subagents } = await service()
subagents.registerProvider({
name: 'failed',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: async () => { throw new Error('setup rolled back') },
})
const lifecycle = vi.fn()
ctx.on('subagent/start', lifecycle)
ctx.on('subagent/end', lifecycle)
const run = ctx.subagents.start('never-started', baseRequest())
readiness.reject(new Error('publication rolled back'))
await expect(run.started).rejects.toThrow('publication rolled back')
result.resolve({ output: [], stopReason: 'aborted' })
await run.result
await Promise.resolve()
await expect(subagents.start('failed', baseRequest())).rejects.toThrow('setup rolled back')
expect(lifecycle).not.toHaveBeenCalled()
})
it('pins start and end to the parent accepted at start despite caller mutation', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const gate = Promise.withResolvers<SubagentResult>()
let acceptedRequest: SubagentStartRequest | undefined
ctx.subagents.registerProvider({
name: 'deferred',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: (accepted) => {
acceptedRequest = accepted
return {
id: AgentId('deferred-child'),
started: Promise.resolve(),
result: gate.promise,
cancel() {},
async dispose() {},
}
},
it('emits an enriched end event and maps result rejection to error telemetry', async () => {
const { ctx, subagents } = await service()
const completed = new StubProvider('completed', NO_CAPS, {
output: [{ type: 'text', text: 'answer' }],
stopReason: 'completed',
})
const accepted = fakeParent('accepted-parent')
const replacement = fakeParent('replacement-parent')
const keys: unknown[] = []
ctx.on('subagent/start', function () { keys.push(carrierKeyOf(this)) })
ctx.on('subagent/end', function () { keys.push(carrierKeyOf(this)) })
const request = baseRequest({ parent: accepted })
const run = ctx.subagents.start('deferred', request)
request.parent = replacement
request.prompt[0] = { type: 'text', text: 'mutated prompt' }
expect(acceptedRequest?.parent).toBe(accepted)
expect(acceptedRequest?.prompt).toEqual([{ type: 'text', text: 'do a thing' }])
expect(acceptedRequest?.prompt).not.toBe(request.prompt)
gate.resolve({ output: [], stopReason: 'completed' })
await run.result
await Promise.resolve()
expect(keys).toEqual([accepted, accepted])
})
it('carries lastAssistantMessage (the child output) onto the end event', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider(
'enriched',
ALL_CAPS,
{ output: [{ type: 'text', text: 'the child answer' }], stopReason: 'completed' },
))
const started = vi.fn()
subagents.registerProvider(completed)
const ended = vi.fn()
ctx.on('subagent/start', started)
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('enriched', baseRequest())
await run.started
expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'enriched', id: run.id }))
const run = await subagents.start('completed', baseRequest())
await run.result
await Promise.resolve()
expect(ended).toHaveBeenCalledWith(expect.objectContaining({
provider: 'enriched',
id: run.id,
provider: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'answer' }],
stopReason: 'completed',
lastAssistantMessage: [{ type: 'text', text: 'the child answer' }],
}))
})
it('observe-only: a subagent/end listener mutating lastAssistantMessage cannot corrupt the caller\'s result', async () => {
// The subagent/end emit fires from a detached `.then` registered before start() returns —
// i.e.
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider(
'clone',
ALL_CAPS,
{ output: [{ type: 'text', text: 'original' }], stopReason: 'completed' },
))
ctx.on('subagent/end', (info) => {
// A hostile/buggy listener reaches in and mutates the event's array.
const blocks = info.lastAssistantMessage
if (blocks?.[0]?.type === 'text') blocks[0].text = 'HIJACKED'
blocks?.push({ type: 'text', text: 'injected' })
})
const run = ctx.subagents.start('clone', baseRequest())
const result = await run.result
await Promise.resolve() // let the detached settle hook (and its listener) run
// The caller's result.output is untouched by the listener's mutation.
expect(result.output).toEqual([{ type: 'text', text: 'original' }])
})
it('omits lastAssistantMessage on the reject path (no SubagentResult was produced)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'rej',
const failure = Promise.withResolvers<SubagentResult>()
subagents.registerProvider({
name: 'infra',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('rej-child'),
started: Promise.resolve(),
result: Promise.reject(new Error('infra fault')),
cancel() {},
dispose: async () => {},
}),
async start() {
return { id: AgentId('infra-child'), result: failure.promise, async dispose() {} }
},
})
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('rej', baseRequest())
await run.result.catch(() => {})
const failedRun = await subagents.start('infra', baseRequest())
failure.reject(new Error('transport'))
await expect(failedRun.result).rejects.toThrow('transport')
await Promise.resolve()
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
expect(endInfo.stopReason).toBe('error')
expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'infra', stopReason: 'error' }))
})
it('contains a structuredClone failure: emits subagent/end without lastAssistantMessage (no unhandled rejection)', async () => {
// The clone runs inside onFulfilled, outside emitLifecycle's per-listener containment.
const ctx = new Context()
await ctx.plugin(SubagentService)
const warn = vi.fn(); ctx.logger.warn = warn as never
// An output value structuredClone cannot handle (a function is uncloneable).
const uncloneable = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output']
ctx.subagents.registerProvider({
name: 'unclone',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('unclone-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult),
cancel() {},
dispose: async () => {},
}),
})
it('contains synchronous and asynchronous lifecycle observer failures', async () => {
const { ctx, subagents } = await service()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => void warnings.push(String(message))) as typeof ctx.logger.warn
const heard: string[] = []
ctx.on('subagent/provider-removed', () => { throw new Error('sync boom') })
// Runtime listeners may return thenables even though the declaration's observable result is void.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment
ctx.on('subagent/provider-removed', async () => { throw new Error('async boom') })
ctx.on('subagent/provider-removed', () => { throw { toString: () => { throw new Error('coercion') } } })
ctx.on('subagent/provider-removed', name => void heard.push(name))
const dispose = subagents.registerProvider(new StubProvider('contained'))
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('unclone', baseRequest())
await run.result
await dispose()
await Promise.resolve()
const endInfo = ended.mock.calls[0]![0] as Record<string, unknown>
expect(endInfo.stopReason).toBe('completed') // the real outcome is preserved
expect('lastAssistantMessage' in endInfo).toBe(false) // clone failed → omitted, not crashed
expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not clone'))
expect(heard).toEqual(['contained'])
expect(warnings.some(message => message.includes('sync boom'))).toBe(true)
expect(warnings.some(message => message.includes('async boom'))).toBe(true)
expect(warnings.some(message => message.includes('<unrenderable thrown value>'))).toBe(true)
})
it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
// A provider whose run.result REJECTS (an infrastructure fault — the seam
// contract says child-level failures resolve with stopReason 'error', but a
// rejection is still surfaced as an 'error' telemetry event).
ctx.subagents.registerProvider({
name: 'rejecter',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: () => ({
id: AgentId('rej-child'),
started: Promise.resolve(),
result: Promise.reject(new Error('infra fault')),
cancel() {},
dispose: async () => {},
}),
})
const ended = vi.fn()
ctx.on('subagent/end', ended)
const run = ctx.subagents.start('rejecter', baseRequest())
// Observe (and swallow) the rejection the consumer would see, then let the
// detached `.then` settle the telemetry emit.
await run.result.catch(() => {})
await Promise.resolve()
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'rejecter', id: run.id, stopReason: 'error' }))
})
it('contains a throwing subagent/start listener per-listener: a later listener still observes the event and start() returns the run', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider('contain'))
// Two listeners; the FIRST throws.
const second = vi.fn()
ctx.on('subagent/start', () => { throw new Error('bad start listener') })
ctx.on('subagent/start', second)
const run = ctx.subagents.start('contain', baseRequest())
expect(run.id).toBeDefined()
await run.started
expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain', id: run.id }))
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
})
it('contains a throwing subagent/end listener per-listener: a later listener still observes the settle, no unhandled rejection', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider(new StubProvider('contain-end'))
const second = vi.fn()
ctx.on('subagent/end', () => { throw new Error('bad end listener') })
ctx.on('subagent/end', second)
const run = ctx.subagents.start('contain-end', baseRequest())
await run.result
// Let the detached `.then` + the contained emit run.
await Promise.resolve()
await Promise.resolve()
expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain-end', id: run.id, stopReason: 'completed' }))
})
it('SubagentError extends the shared HarnessError base', () => {
const err = new SubagentError('boom', 'NO_PROVIDER')
expect(err).toBeInstanceOf(HarnessError)
expect(err.name).toBe('SubagentError')
expect(err.code).toBe('NO_PROVIDER')
it('SubagentError participates in the harness error taxonomy', () => {
const error = new SubagentError('boom', 'NO_PROVIDER')
expect(error).toBeInstanceOf(HarnessError)
expect(error.name).toBe('SubagentError')
expect(error.code).toBe('NO_PROVIDER')
})
})

View File

@@ -1,22 +1,28 @@
# @deepseek-ai/dsh-tool-subagent
Model-facing delegation tool over the [`ctx.subagents`](../subagent/README.md) provider registry. The selected provider may be in-process or out-of-process without changing the model's `{ description, prompt }` request shape.
The `subagent` tool lets the model delegate one self-contained task and collect the child's final output. It is a thin consumer of `ctx.subagents`; changing the configured provider changes the transport without changing the model-facing execution contract.
## Provider binding
## Provider selection
Each plugin load binds one `Config.provider`. To expose multiple providers, load the plugin under distinct `toolName` values. The tool description is derived from `provider.inheritsParentContext`, telling the model whether the child already sees completed parent turns.
Each plugin instance binds to exactly one provider. The model sees `{ description, prompt }`, not a provider selector. To expose multiple transports, load the plugin multiple times with distinct `toolName` values.
The tool follows provider availability through `subagent/provider-added` and `subagent/provider-removed`; it has no Loader-order dependency and disappears while its provider is absent.
The description is derived from `provider.inheritsParentContext`: spawn and ACP tell the model to provide a standalone prompt, while fork says the child already sees completed conversation turns. The plugin follows `subagent/provider-added` and `subagent/provider-removed`, so concurrent Cordis plugin loading does not create a registration-order dependency.
| Config key | Meaning |
## Lifecycle
`execute` passes the tool execution's abort signal directly as the required `SubagentStartRequest.signal`, awaits `ctx.subagents.start(...)`, then awaits `run.result` inside a `try/finally` that always calls `run.dispose()`. The same signal therefore covers startup and live execution, while disposal guarantees quiescence on success, failure, and abort.
A non-`completed` stop reason becomes an `isError` tool result; partial child output is never reported as success. The current tool blocks the parent turn until collection finishes; background and polling modes are deferred.
## Config
| Key | Meaning |
|---|---|
| `provider` (required) | Provider name on `ctx.subagents`. |
| `toolName` | Model-facing name (default `subagent`). |
| `agentOptions` | Default child options (`model?`). |
| `persona` | Child persona; requires provider support. |
| `toolFilter` | Child global-tool restriction; requires provider support. |
| `maxDepth` | Delegation-depth cap; requires provider support. |
| `provider` | Required `ctx.subagents` provider name. |
| `toolName` | Model-facing tool name (default `subagent`). Must be unique per plugin instance. |
| `agentOptions` | Default child agent options, currently including `model`. |
| `persona` | Per-child persona; requires provider `persona` capability. |
| `toolFilter` | Per-child global-tool restriction; requires provider `toolFilter` capability. |
| `maxDepth` | Absolute delegation-depth cap; requires provider `depthLimit` capability. |
## Execution
`execute` starts a run, bridges the tool abort signal to `run.cancel()`, awaits `run.result`, and always disposes the run. Non-completed stop reasons return error tool results rather than successful partial output. Collection is synchronous; background polling remains deferred in the [subagent seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
`toolFilter` changes the child's visible global tool layer; it is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).

View File

@@ -1,8 +1,34 @@
/**
* The model-facing `subagent` tool: delegate a task to a child agent and return its final
* output. Pure schema + lifecycle shaping — every transport concern lives behind the
* `ctx.subagents` provider registry (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or
* future A2A backend swaps in without touching what the model sees.
* The model-facing `subagent` tool: delegate a task to a child agent and return
* its final output. Pure schema + lifecycle shaping — every transport concern
* lives behind the `ctx.subagents` provider registry
* (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or future A2A backend
* swaps in without touching what the model sees.
*
* Provider selection is config, not model-facing: this plugin is bound to
* EXACTLY ONE provider name (`Config.provider`). To expose more than one
* transport, load the plugin more than once, each bound to a different provider
* — there is no provider/type parameter in the model-facing schema. The model
* sees only `{ description, prompt }`.
*
* The tool DESCRIPTION is derived from the bound provider's conversation-history
* descriptor ({@link providerWording}): a fresh-conversation provider (spawn,
* ACP) gets the standalone-prompt wording, while a seeded-conversation provider
* (fork) tells the model the child already sees the conversation's completed
* turns. This descriptor says nothing about Cordis scope, services, tools, or
* authority. The tool MIRRORS the
* provider's lifecycle via `subagent/provider-added`/`-removed` — it registers
* when the provider is (or becomes) available and unregisters when the
* provider goes away — so no load-order requirement exists and an HMR reload
* of the backend re-derives the wording from the fresh provider.
*
* Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits
* `run.result` inside a `try/finally` that always disposes the run, so the
* owned child agent/session is torn down on every path (success, error, abort)
* and never leaks as a live idle child. A non-`completed` stop reason maps to an
* `isError` tool result (by throwing) rather than returning partial output as
* success.
*
* @module @deepseek-ai/dsh-tool-subagent
*/
@@ -11,6 +37,7 @@ import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
export const name = 'tool-subagent'
@@ -60,8 +87,9 @@ export interface Config {
* Recursion cap applied to every child this tool spawns (see
* `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper
* than this in the delegation tree is rejected. Requires the provider's
* `depthLimit` capability. Omitted ⇒ unbounded (bound it in deployments
* that expose this tool to children).
* `depthLimit` capability. Must be a non-negative safe integer and is
* validated when the plugin loads. Omitted ⇒ unbounded (bound it in
* deployments that expose this tool to children).
*/
maxDepth?: number
}
@@ -77,13 +105,21 @@ export const Config: z<Config> = z.object({
model: z.string(),
}).default(undefined as unknown as { model: string }),
persona: z.string(),
// A schemastery object materializes {} (with [] for nested arrays) when the key is omitted —
// for toolFilter that would mean an EMPTY ALLOW-LIST, i.e. deny-everything, silently.
// A schemastery object materializes {} (with [] for nested arrays) when the
// key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e.
// deny-everything, silently. Force the omitted key to stay absent (the same
// shape discipline as SystemPrompt's toolOrder); the cast is needed because
// .default() expects the object type.
// The NESTED arrays get the same treatment as the object itself: a partial
// filter ({deny: […]}) must not materialize allow: [] beside it — an empty
// allow-list means deny-EVERYTHING, so the materialized default would turn
// a deny-one config into deny-all. An EXPLICIT allow: [] (grant-only
// children) survives, since only the omitted key defaults to undefined.
toolFilter: z.object({
allow: z.array(z.string()).default(undefined as unknown as string[]),
deny: z.array(z.string()).default(undefined as unknown as string[]),
}).default(undefined as unknown as { allow: string[]; deny: string[] }),
maxDepth: z.number(),
maxDepth: z.natural().max(Number.MAX_SAFE_INTEGER),
})
/**
@@ -120,16 +156,19 @@ function stopReasonError(result: SubagentResult): string | undefined {
}
/**
* Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}).
* Model-facing wording from the provider's conversation-history descriptor
* ({@link SubagentProvider.inheritsParentContext}).
* A fresh child needs a standalone prompt; a forked child already sees the
* conversation's completed turns — telling the model to restate everything
* (or, worse, that the child "does not see this conversation") would be false
* for a fork. Exported for tests.
* @param inherits - the bound provider's context contract.
* @param inheritsConversation - whether the child's conversation is seeded
* with the parent's completed turns; this says nothing about tool, service,
* scope, or authority inheritance.
* @returns the tool `description` and the `prompt` parameter description.
*/
export function providerWording(inherits: boolean): { description: string; promptDescription: string } {
if (inherits) {
export function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } {
if (inheritsConversation) {
return {
description:
'Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all '
@@ -156,16 +195,23 @@ export function providerWording(inherits: boolean): { description: string; promp
}
export function apply(ctx: Context, config: Config): void {
// Keep misconfiguration at plugin load even when a caller invokes apply()
// directly and bypasses Schemastery's natural/max metadata.
assertSubagentMaxDepth(config.maxDepth)
// Misconfiguration fails loud AT LOAD (the check is self-contained): an
// explicit `toolFilter: {}` would otherwise pass the capability gate and
// kill every delegation later, in the child-setup `restrict({})` throw.
if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) {
throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter')
}
// The tool MIRRORS its provider's lifecycle instead of assuming load order: the cordis Loader
// starts sibling entries concurrently, so "backend listed first in cordis.yml" does not
// guarantee "provider registered first", and an HMR reload of the backend replaces the
// provider while this fiber stays loaded.
// The tool MIRRORS its provider's lifecycle instead of assuming load order:
// the cordis Loader starts sibling entries concurrently, so "backend listed
// first in cordis.yml" does not guarantee "provider registered first", and
// an HMR reload of the backend replaces the provider while this fiber stays
// loaded. Register the tool when the bound provider is (or becomes)
// available — deriving the wording from THAT provider — and unregister it
// when the provider goes away, so the description can never outlive or
// predate the provider it describes.
let disposeTool: (() => Promise<void> | void) | undefined
const mount = (provider: SubagentProvider): void => {
const wording = providerWording(provider.inheritsParentContext)
@@ -196,22 +242,14 @@ export function apply(ctx: Context, config: Config): void {
const request: SubagentStartRequest = {
prompt: [{ type: 'text', text: args.prompt }],
parent,
...exec.signal ? { signal: exec.signal } : {},
signal: exec.signal ?? new AbortController().signal,
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
}
const run: SubagentRun = ctx.subagents.start(config.provider, request)
// Bridge the tool's abort signal to the run: if the parent step is
// aborted while the child is in flight, cancel the child too.
const onAbort = (): void => { run.cancel('parent step aborted') }
exec.signal?.addEventListener('abort', onAbort, { once: true })
// `addEventListener` does not fire for a signal already aborted before this line, so a
// step cancelled before the tool ran would never reach the child.
if (exec.signal?.aborted) run.cancel('parent step aborted')
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
try {
const result = await run.result
@@ -223,7 +261,6 @@ export function apply(ctx: Context, config: Config): void {
}
return [{ type: 'text', text: outputText(result.output) }]
} finally {
exec.signal?.removeEventListener('abort', onAbort)
// Always reach child quiescence — never leak a live idle child/session.
await run.dispose()
}
@@ -231,8 +268,16 @@ export function apply(ctx: Context, config: Config): void {
}))
}
// Register listeners before the synchronous presence check to avoid an activation gap.
// TODO(subagent-dup-toolname): validate intended tool names before provider activation.
// Listeners first, then the presence check: both run synchronously, so no
// registration can slip between them; the `disposeTool === undefined` guard
// makes a same-tick added-event after a successful mount a no-op.
// TODO(subagent-dup-toolname): two WAITING fibers configured with the same
// toolName collide only when their provider finally arrives — the duplicate
// tool-name throw then propagates through `subagent/provider-added` and
// rolls back the PROVIDER registration, so an invalid config blasts the
// backend's fiber instead of the misconfigured tool's. Config-time detection
// would need a cross-fiber registry of intended tool names; revisit if a
// real deployment ever hits it.
ctx.on('subagent/provider-added', (provider) => {
if (provider.name === config.provider && disposeTool === undefined) mount(provider)
})
@@ -246,6 +291,8 @@ export function apply(ctx: Context, config: Config): void {
mount(present)
} else {
// Not an error: the backend's fiber may simply activate after this one.
// The tool appears the moment the provider registers; a typo'd provider
// name shows up as this note plus a tool that never materializes.
ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`)
}
}

View File

@@ -113,11 +113,9 @@ describe('dsh-tool-subagent', () => {
name: 'weird',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('weird-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
cancel() {},
dispose: async () => {},
}),
})
@@ -140,13 +138,11 @@ describe('dsh-tool-subagent', () => {
name: 'capture',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('capture-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},
@@ -171,13 +167,11 @@ describe('dsh-tool-subagent', () => {
name: 'bare',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('bare-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},
@@ -220,7 +214,7 @@ describe('dsh-tool-subagent', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
const backend = await ctx.plugin(mock, { name: 'mock' }) // spawn-shaped (inherits: false)
const backend = await ctx.plugin(mock, { name: 'mock' }) // fresh conversation (descriptor: false)
await ctx.plugin(tool, { provider: 'mock' })
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation')
@@ -228,7 +222,7 @@ describe('dsh-tool-subagent', () => {
await backend.dispose()
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false)
// Backend reloads with a DIFFERENT contract: the wording is re-derived
// Backend reloads with a DIFFERENT conversation-history descriptor: the wording is re-derived
// from the fresh provider, not served stale from the first mount.
await ctx.plugin(mock, { name: 'mock', inheritsParentContext: true })
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('INHERITS this conversation')
@@ -273,7 +267,7 @@ describe('dsh-tool-subagent', () => {
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true)
})
it('derives spawn-shaped wording from a fresh-context provider (default mock)', async () => {
it('derives spawn-shaped wording from a fresh-conversation provider (default mock)', async () => {
const ctx = await setup({ provider: 'mock' })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('does not see this conversation')
@@ -281,7 +275,7 @@ describe('dsh-tool-subagent', () => {
expect(props['prompt']!.description).toContain('include everything it needs')
})
it('derives fork-shaped wording from an inheriting provider (the description stops lying)', async () => {
it('derives fork-shaped wording from a seeded-conversation provider (the description stops lying)', async () => {
const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('INHERITS this conversation')
@@ -302,11 +296,9 @@ describe('dsh-tool-subagent', () => {
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('spy-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => void disposed(),
}),
})
@@ -326,11 +318,9 @@ describe('dsh-tool-subagent', () => {
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('spy-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
cancel() {},
dispose: async () => void disposed(),
}),
})
@@ -341,7 +331,7 @@ describe('dsh-tool-subagent', () => {
expect(disposed).toHaveBeenCalledTimes(1)
})
it('bridges the tool abort signal to run.cancel()', async () => {
it('passes the tool abort signal as the provider cancellation channel', async () => {
const cancelled = vi.fn()
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -351,17 +341,17 @@ describe('dsh-tool-subagent', () => {
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => {
start: async (request) => {
if (request.signal.aborted) throw new Error('start aborted')
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
request.signal.addEventListener('abort', () => {
cancelled()
resolveResult({ output: [], stopReason: 'aborted' })
}, { once: true })
return {
id: AgentId('spy-child'),
started: Promise.resolve(),
result,
cancel: () => {
cancelled()
resolveResult({ output: [], stopReason: 'aborted' })
},
dispose: async () => {},
}
},
@@ -370,9 +360,7 @@ describe('dsh-tool-subagent', () => {
const controller = new AbortController()
const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
// Abort after the tool body has had a chance to register its abort listener
// (ctx.tools.execute now awaits the tools/pre-execute waterfall before the body runs, so
// the listener is not registered synchronously).
// Let provider.start install its listener before aborting.
await Promise.resolve()
await Promise.resolve()
controller.abort()
@@ -381,11 +369,8 @@ describe('dsh-tool-subagent', () => {
expect(result.isError).toBe(true)
})
it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => {
// `addEventListener('abort')` does not fire for a signal already aborted before the
// listener is added, so a step cancelled before the tool ran would never reach the child
// unless the bridge re-checks `signal.aborted`.
const cancelled = vi.fn()
it('passes an already-aborted signal so provider startup rejects', async () => {
const sawAborted = vi.fn()
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -394,19 +379,9 @@ describe('dsh-tool-subagent', () => {
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => {
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
return {
id: AgentId('spy-child'),
started: Promise.resolve(),
result,
cancel: () => {
cancelled()
resolveResult({ output: [], stopReason: 'aborted' })
},
dispose: async () => {},
}
start: async (request) => {
if (request.signal.aborted) sawAborted()
throw new Error('start aborted')
},
})
await ctx.plugin(tool, { provider: 'spy' })
@@ -414,7 +389,7 @@ describe('dsh-tool-subagent', () => {
const controller = new AbortController()
controller.abort() // already aborted BEFORE the tool runs
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
expect(cancelled).toHaveBeenCalledTimes(1)
expect(sawAborted).toHaveBeenCalledTimes(1)
expect(result.isError).toBe(true)
})
@@ -437,7 +412,10 @@ describe('dsh-tool-subagent', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
// Loader must retain this namespace's injection metadata.
// Postmortem 0001 guard: this plugin HAS `inject = ['tools','subagents']`, so
// a stray `export default apply` would collapse the module via
// `unwrapExports` (`exports.default ?? exports`), DROP `inject`, and crash at
// load with "cannot get property … without inject". Guard the shape directly.
expect('default' in tool).toBe(false)
expect(tool.name).toBe('tool-subagent')
expect(tool.inject).toEqual(['tools', 'subagents'])
@@ -461,13 +439,11 @@ describe('dsh-tool-subagent', () => {
name: 'capture2',
capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('capture2-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},
@@ -485,8 +461,33 @@ describe('dsh-tool-subagent', () => {
expect(seen?.maxDepth).toBe(2)
})
it.each([
{ label: 'null', value: null as unknown as number },
{ label: 'a string', value: '1' as unknown as number },
{ label: 'NaN', value: Number.NaN },
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },
{ label: 'negative infinity', value: Number.NEGATIVE_INFINITY },
{ label: 'a negative integer', value: -1 },
{ label: 'a fractional number', value: 1.5 },
{ label: 'negative zero', value: -0 },
{ label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 },
])('rejects maxDepth=$label when the plugin loads', async ({ value }) => {
await expect(setup({ provider: 'mock', maxDepth: value }))
.rejects.toThrow()
})
it('validates maxDepth when apply() is invoked directly without Schemastery', () => {
const ctx = new Context()
expect(() => {
tool.apply(ctx, {
provider: 'unused',
maxDepth: Number.NaN,
})
}).toThrow('subagent maxDepth must be a non-negative safe integer')
})
it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => {
let seen: { toolFilter?: { allow?: string[]; deny?: string[] } } | undefined
let seen: { toolFilter?: { readonly allow?: readonly string[]; readonly deny?: readonly string[] } } | undefined
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -495,13 +496,11 @@ describe('dsh-tool-subagent', () => {
name: 'capture3',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('capture3-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},
@@ -526,13 +525,11 @@ describe('dsh-tool-subagent', () => {
name: 'capture4',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('capture4-child'),
started: Promise.resolve(),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},

View File

@@ -5,7 +5,7 @@ Packages that exist to serve development, testing, and the examples rather than
| Package | Role | ctx key |
|---|---|---|
| `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) |
| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) |
| `invariants/` | Dev-mode event-contract assertions | (listens on `session/*`, `agent/*`) |
| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
| `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) |

View File

@@ -1,9 +1,13 @@
# dsh-invariants
Dev-mode event-contract invariants and session-log freeze. A pure-listener plugin (everything is a plugin) that asserts the harness event contract at runtime and, optionally, freezes logged session-event data so any code that mutates history throws instead of corrupting silently.
Dev-mode event-contract assertions. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests at runtime; it does not own or change product behavior.
**Off in production.** Enable it in tests and the demos, where a contract violation should fail loudly. It costs nothing when not registered, and doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express.
Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only.
## Plugin
A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does):
@@ -14,17 +18,10 @@ import * as Invariants from '@deepseek-ai/dsh-invariants'
declare const ctx: Context
await ctx.plugin(Invariants) // freeze on (default)
await ctx.plugin(Invariants, { freeze: false }) // assert contract, don't freeze
await ctx.plugin(Invariants)
```
`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist (so a hot reload mid-turn doesn't falsely reject the next event). It listens on `session/created`, `session/event`, and `agent/status`.
### Config
| Key | Default | Meaning |
|---|---|---|
| `freeze` | `true` | Deep-freeze each logged event's data so mutating a logged event throws. Set `false` to assert the contract without freezing. |
`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist, so a hot reload mid-turn does not falsely reject the next event. The oracle listeners are explicitly global so pre-commit staging and post-commit application keep the same audience even if the plugin is mounted under a scoped context; their cleanup still belongs to that mounting fiber. The plugin has no configuration.
## Invariants asserted
@@ -46,10 +43,10 @@ Model requests (on `llm/stream`):
On any violation it throws `InvariantError` (`code: 'INVARIANT'`).
## Why runtime, not deep-readonly types
## Why runtime assertions remain useful
A `DeepReadonly<SessionEvent>` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
Session enforces the per-record storage boundary at runtime, where a cast cannot bypass it. Pervasive `DeepReadonly<SessionEvent>` types would add noise across consumers without expressing relationships such as turn/step nesting, subject-correct scoped dispatch, or equality between a request and its log reconstruction. This plugin checks those relationships in development while `dsh-session` keeps history immutable in every composition. See [source-owned session immutability and dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
## Seeded sessions
A seeded/forked session arrives with events already in its log (the `Session` constructor copies the seed without emitting `session/event`). On `session/created` the plugin replays the existing log through the checker and freezes those entries, so seeded history is held to the same contract.
A seeded or forked session arrives with events already in its log because construction does not emit `session/event` for each seed record. `Session` validates, snapshots, and freezes every seed record before accepting it; on `session/created`, this plugin replays the accepted log only to rebuild and check its relational trace state.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-invariants",
"description": "Dev-mode event-contract invariants + session-log freeze for the DeepSeek Harness",
"description": "Dev-mode event-contract assertions for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -26,8 +26,6 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
@@ -35,8 +33,6 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -1,15 +1,25 @@
/**
* Dev-mode invariants: a pure-listener plugin that asserts the harness event contract at
* runtime, and (optionally) freezes logged session-event data so any code that mutates history
* throws instead of corrupting silently.
* Dev-mode invariants: a pure-listener plugin that asserts relationships in
* the harness event contract at runtime.
*
* Everything is a plugin — this is just listeners on `session/created`,
* `session/event`, `agent/status`, and the scoped dispatch and request seams.
* It is **off in production**: enable it in tests and demos, where a contract
* violation should be a loud failure rather than a subtle one. It doubles as
* executable documentation of the event taxonomy: the assertions below are
* the contract.
*
* Session owns immutable log storage: it snapshots and deep-freezes every
* accepted event at the source. This plugin checks relationships that one
* event's types and immutability cannot express, including turn/step nesting,
* scoped dispatch, status transitions, and request reconstructability.
*
* @module @deepseek-ai/dsh-invariants
*/
import type { Context } from 'cordis'
import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
@@ -30,16 +40,6 @@ export class InvariantError extends HarnessError {
}
}
/** Plugin config. */
export interface Config {
/**
* Deep-freeze logged session-event data so mutating a logged event throws.
* Default true — this plugin only runs in dev/test, where freezing is the
* point. Set false to assert the event contract without freezing.
*/
freeze?: boolean
}
/** Per-session bookkeeping for the session-log invariants. */
interface SessionTrace {
/** Highest `seq` seen so far (must strictly increase). */
@@ -68,32 +68,32 @@ interface SessionTrace {
surface: number[]
}
/** One accepted event's deferred mutation of a live session trace. */
interface SessionTraceTransition {
/** Scalar state after the event commits. */
scalars: Pick<SessionTrace, 'lastSeq' | 'openTurn' | 'openStep' | 'nextTurn' | 'nextStep'>
/** The event's mutation of the open step's pending call set. */
pendingCalls:
| { kind: 'none' }
| { kind: 'add' | 'delete'; callId: CallId }
| { kind: 'clear' }
/** The event's mutation of the derived surface order. */
surface:
| { kind: 'none' | 'append' }
| { kind: 'replace'; start: number; count: number }
/** The committed event sequence to add to the known-sequence set. */
seq: number
}
/** Event payload prefix for scoped seams whose first argument names its agent. */
interface AgentSubject {
agent: Agent
}
/**
* Deep-freeze a value and everything reachable from it.
*
* Walks every object's own properties even when the object itself is already
* frozen: `Session.append()` accepts event data from arbitrary plugins/tools,
* so a caller can hand us a SHALLOW-frozen object whose descendants are still
* mutable. Skipping an already-frozen node (the obvious idempotence shortcut)
* would leave exactly the kind of mutable history the dev-invariants RFC means to catch. A
* `WeakSet` of visited objects keeps it terminating on cycles and avoids
* re-walking shared subtrees / already-processed seed events.
*/
function deepFreeze(value: unknown, seen: WeakSet<object> = new WeakSet()): void {
if (value === null || typeof value !== 'object') return
if (seen.has(value)) return
seen.add(value)
// Freeze the node (no-op if a caller pre-froze it), then ALWAYS descend —
// a frozen container can still hold mutable children.
Object.freeze(value)
for (const key of Object.keys(value)) {
deepFreeze((value as Record<string, unknown>)[key], seen)
}
/** Structural subject fields used without coupling this dev plugin to owning services. */
interface ScopedSubjectFields {
agent?: Agent
scope?: object
}
/** Assert that a step-scoped event names the currently open turn and step. */
@@ -105,22 +105,29 @@ function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step:
}
}
/** Assert one appended event against the per-session invariants. */
function checkEvent(trace: SessionTrace, event: SessionEvent): void {
/** Validate one candidate event without mutating the committed session trace. */
function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTransition {
// seq is strictly monotonic — the spine of replay equivalence. lastSeq
// starts at -1, so the first event (seq 0) passes.
if (event.seq <= trace.lastSeq) {
throw new InvariantError(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`)
}
trace.lastSeq = event.seq
let openTurn = trace.openTurn
let openStep = trace.openStep
let nextTurn = trace.nextTurn
let nextStep = trace.nextStep
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
let surface: SessionTraceTransition['surface'] = { kind: 'none' }
// --- Surface invariants ---
// Surface metadata (sourceEventSeqs, surfaceOp) is only valid on
// surface-eligible event types. The compiler enforces this at append()
// call sites; this runtime check catches casts and persisted data.
const SURFACE_TYPES = new Set<string>(['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message'])
// Cast to surface-eligible event type so we can access surfaceOp and sourceEventSeqs
// (optional on SessionEvent, mandatory on SurfaceEvent).
// Cast to surface-eligible event type so we can access surfaceOp and
// sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent).
// SurfaceEvent's mandatory surfaceOp is too strict here — we need to
// CHECK whether surface metadata is present, not assume it.
const se = event as SessionEvent<SurfaceEventType>
if (!SURFACE_TYPES.has(event.type)) {
if (se.sourceEventSeqs !== undefined) {
@@ -152,7 +159,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
// positional range — every shadowed node must appear in sourceEventSeqs.
if (se.surfaceOp !== undefined) {
if (se.surfaceOp === 'append') {
trace.surface.push(event.seq)
surface = { kind: 'append' }
} else {
const { start, end } = se.surfaceOp
const startIdx = trace.surface.indexOf(start)
@@ -174,15 +181,14 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
if (missing.length > 0) {
throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
}
// Apply the replace to the tracked surface: the new node takes the
// range's position so order stays in sync for later replaces.
trace.surface.splice(startIdx, shadowed.length, event.seq)
surface = { kind: 'replace', start: startIdx, count: shadowed.length }
}
}
// Boundary/step-scoped events have explicit cases; every OTHER event type — including
// plugin-added (merge-extensible) SessionEventMap keys — is caught by the `default` and must
// be turn-enclosed (the turn-enclosure RFC).
// Boundary/step-scoped events have explicit cases; every OTHER event type —
// including plugin-added (merge-extensible) SessionEventMap keys — is caught
// by the `default` and must be turn-enclosed (the turn-enclosure RFC). No assertNever: an
// unknown variant is valid, not a compile error.
switch (event.type) {
case 'turn/start': {
if (trace.openTurn !== null) {
@@ -194,8 +200,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
if (event.data.turn !== trace.nextTurn) {
throw new InvariantError(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
}
trace.openTurn = event.data.turn
trace.nextStep = 1
openTurn = event.data.turn
nextStep = 1
break
}
case 'turn/end': {
@@ -205,8 +211,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
if (trace.openStep !== null) {
throw new InvariantError(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
}
trace.openTurn = null
trace.nextTurn += 1
openTurn = null
nextTurn += 1
break
}
case 'step/start': {
@@ -220,16 +226,16 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
if (event.data.step !== trace.nextStep) {
throw new InvariantError(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`)
}
trace.openStep = event.data.step
openStep = event.data.step
break
}
case 'step/end': {
requireOpenStep(trace, 'step/end', event.data.turn, event.data.step)
// A result must arrive in the step that issued the call; orphan calls
// (a step that errored before its result) do not carry to the next step.
trace.pendingCalls.clear()
trace.openStep = null
trace.nextStep += 1
pendingCalls = { kind: 'clear' }
openStep = null
nextStep += 1
break
}
case 'assistant/chunk': {
@@ -242,20 +248,31 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
}
case 'tool/call': {
requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step)
trace.pendingCalls.add(event.data.callId)
pendingCalls = { kind: 'add', callId: event.data.callId }
break
}
case 'tool/result': {
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step)
// A result needs a prior matching call in the same step.
// A result needs a prior matching call in the same step. (The converse
// does NOT hold: a call may have no result — a throwing tool-execution
// pipeline step ends the turn with no tool/result, which is legal.)
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
if (!trace.pendingCalls.delete(event.data.callId) && !syntheticInterrupted) {
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
}
pendingCalls = { kind: 'delete', callId: event.data.callId }
break
}
// Turn-enclosure (the turn-enclosure RFC): every session event not handled by a boundary
// case above must sit inside an open turn.
// Turn-enclosure (the turn-enclosure RFC): EVERY session event not handled by a boundary
// case above must sit inside an open turn. The durable session log uses the
// turn as its commit/replay boundary (the JSONL backend treats anything
// after the last turn/end as a crash tail), so a bare event between turns is
// silently dropped on reload. The loop records queued user messages after
// turn/start, and an idle agent.inject() wraps its context/message in a
// one-shot turn. A `default`
// (not an enumerated list) is deliberate: SessionEventMap is
// merge-extensible, so a PLUGIN-added event type appended while idle must
// also fail here rather than fall through and be dropped on resume.
default: {
if (trace.openTurn === null) {
throw new InvariantError(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
@@ -263,8 +280,52 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
break
}
}
// Track every seq seen — used above to validate sourceEventSeqs references.
trace.knownSeqs.add(event.seq)
return {
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },
pendingCalls,
surface,
seq: event.seq,
}
}
/** Apply one already-validated transition after its event commits. */
function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void {
Object.assign(trace, transition.scalars)
switch (transition.pendingCalls.kind) {
case 'none':
break
case 'add':
trace.pendingCalls.add(transition.pendingCalls.callId)
break
case 'delete':
trace.pendingCalls.delete(transition.pendingCalls.callId)
break
case 'clear':
trace.pendingCalls.clear()
break
/* v8 ignore next -- validateEvent produces this closed transition union */
default:
assertNever(transition.pendingCalls, 'session trace pending-call transition')
}
switch (transition.surface.kind) {
case 'none':
break
case 'append':
trace.surface.push(transition.seq)
break
case 'replace':
trace.surface.splice(transition.surface.start, transition.surface.count, transition.seq)
break
/* v8 ignore next -- validateEvent produces this closed transition union */
default:
assertNever(transition.surface, 'session trace surface transition')
}
trace.knownSeqs.add(transition.seq)
}
/** Validate and apply one event while rebuilding an already-committed log. */
function replayEvent(trace: SessionTrace, event: SessionEvent): void {
applyTransition(trace, validateEvent(trace, event))
}
/** Legal agent status transitions (the only state machine the loop guarantees). */
@@ -284,14 +345,19 @@ function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void {
/**
* Register the dev-mode invariants. Contributions are effect-scoped, so
* disposing the plugin fiber removes all listeners and stops freezing
* (HMR-safe). On (re-)apply the trace state is rebuilt by replaying each
* existing session's log, so a hot reload mid-turn does not falsely reject the
* next event.
* disposing the plugin fiber removes all listeners (HMR-safe). On (re-)apply
* the trace state is rebuilt by replaying each existing session's log, so a
* hot reload mid-turn does not falsely reject the next event.
*
* @param ctx - Cordis context that receives the invariant listeners.
*/
export function apply(ctx: Context, config: Config = {}): void {
const freeze = config.freeze ?? true
export function apply(ctx: Context): void {
const traces = new WeakMap<Session, SessionTrace>()
const stagedTransitions = new WeakMap<SessionEvent, {
session: Session
trace: SessionTrace
transition: SessionTraceTransition
}>()
// Agent status has no stored history to replay; the first observation after
// (re-)apply seeds the baseline, so a reload never produces a false positive.
const lastStatus = new WeakMap<Agent, AgentStatus>()
@@ -307,20 +373,19 @@ export function apply(ctx: Context, config: Config = {}): void {
surface: [],
})
/** Build (or rebuild) a session's trace by replaying its whole log; freeze it. */
/** Build (or rebuild) a session's trace by replaying its whole log. */
const seedSession = (session: Session): SessionTrace => {
const trace = freshTrace()
traces.set(session, trace)
for (const event of session.events) {
checkEvent(trace, event)
if (freeze) deepFreeze(event)
replayEvent(trace, event)
}
return trace
}
// Every store-created session (the only kind that emits session/event) is
// seeded first — via ctx.sessions.list() at apply or session/created — so
// the fallback is a defensive guard, never hit in practice.
// seeded first — via ctx.sessions.list() at apply or session/created — so the
// fallback is a defensive guard, never hit in practice.
/* v8 ignore next -- traceFor's fallback: session/event always follows a seed */
const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session)
@@ -331,19 +396,39 @@ export function apply(ctx: Context, config: Config = {}): void {
// A newly created session may arrive seeded/forked (the constructor copies
// the seed WITHOUT emitting session/event), so replay its log here too.
ctx.on('session/created', (session) => { seedSession(session) })
ctx.on('session/created', (session) => { seedSession(session) }, { global: true })
ctx.on('session/event', (session, event) => {
checkEvent(traceFor(session), event)
if (freeze) deepFreeze(event)
})
// Session resolves dispatch before committing, so internal/dispatch has
// already staged this exact event. A later dispatch veto skips every
// session/event callback and therefore leaves the live trace unchanged.
const staged = stagedTransitions.get(event)
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
if (staged === undefined || staged.session !== session) {
throw new InvariantError('session/event reached publication without matching pre-commit validation')
}
stagedTransitions.delete(event)
applyTransition(staged.trace, staged.transition)
}, { global: true })
ctx.on('agent/status', (agent, status) => {
checkTransition(lastStatus.get(agent), status)
lastStatus.set(agent, status)
})
}, { global: true })
// Scope-filtered events must carry a scopeTarget keyed to their subject.
// --- Scoped-dispatch invariants (the agent-scoping seam) ---------------
//
// Every scope-filtered event family must dispatch with a scope carrier
// (scopeTarget) whose key IS the subject the event's arguments name —
// a dispatch without one silently reverts that event to global delivery
// (agent-scoped listeners over-hear foreign agents), and a mis-keyed one
// delivers to the wrong agent's listeners. `internal/dispatch` fires
// synchronously before listener delivery, so a violation throws at the
// dispatching call site. The table maps each family to how its subject is
// read from the event arguments; `null` = the subject is not recoverable
// from the arguments (session events key by the OWNING agent; subagent
// lifecycle events key by the delegating parent), so only carrier
// PRESENCE is asserted there.
const scopedSubject: Record<string, ((args: unknown[]) => unknown) | null> = {
'agent/created': args => args[0],
'agent/disposed': args => args[0],
@@ -359,12 +444,13 @@ export function apply(ctx: Context, config: Config = {}): void {
'agent/turn-stop': args => args[0],
'agent/error': args => args[0],
'approval/request': args => (args[0] as AgentSubject).agent,
'tools/pre-execute': args => (args[0] as ToolExecution).agent,
'tools/execute': args => (args[0] as ToolExecution).agent,
'tools/post-execute': args => (args[0] as ToolExecution).agent,
'tools/result': args => (args[0] as ToolExecution).agent,
'system-prompt/assemble': args => (args[1] as AssembleContext).scope,
'tools/pre-execute': args => (args[0] as ScopedSubjectFields).agent,
'tools/execute': args => (args[0] as ScopedSubjectFields).agent,
'tools/post-execute': args => (args[0] as ScopedSubjectFields).agent,
'tools/result': args => (args[0] as ScopedSubjectFields).agent,
'system-prompt/assemble': args => (args[1] as ScopedSubjectFields).scope,
'session/created': null,
'session/disposed': null,
'session/event': null,
'session/flush': null,
'subagent/start': null,
@@ -383,35 +469,43 @@ export function apply(ctx: Context, config: Config = {}): void {
`"${name}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — `
+ 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))')
}
// The assembly context must never carry the agent DX field without the
// scope layer selector: the assembly would silently miss the agent's
// scoped sections/tools (use assembleContextFor(agent)).
if (name === 'system-prompt/assemble') {
const context = args[1] as AssembleContext
if (context.agent !== undefined && context.scope !== context.agent) {
throw new InvariantError(
'an assembly context carries `agent` without `scope` (or with a mismatched scope) — '
+ 'use assembleContextFor(agent) so the assembly resolves the agent\'s scoped layer')
}
if (name === 'session/event') {
const [session, event] = args as [Session, SessionEvent]
const trace = traceFor(session)
const transition = validateEvent(trace, event)
// The exact event identity reaches the contained post-commit listener.
// A later internal/dispatch listener may still veto; because validation
// is pure, abandoning this weakly keyed transition does not advance the
// committed trace or retain the session.
stagedTransitions.set(event, { session, trace, transition })
}
}, { global: true })
// --- Setup-drives invariant CreateAgentOptions.setup COMPOSES the agent's scoped world; it
// must not DRIVE the agent.
const sessionStarted = new WeakSet<Session>()
for (const agent of ctx.get('agents')?.list() ?? []) sessionStarted.add(agent.session)
ctx.on('agent/session-start', (agent) => { sessionStarted.add(agent.session) })
ctx.on('session/event', (session, event) => {
if (event.type !== 'turn/start' || sessionStarted.has(session)) return
const owner = ctx.get('agents')?.list().find(agent => agent.session === session)
if (owner === undefined) return
throw new InvariantError(
`agent "${owner.id}": a turn opened before agent/session-start fired — `
+ 'CreateAgentOptions.setup composes the scoped world, it must not drive the agent '
+ '(send/steer/inject belong after creation returns)')
})
// Frozen loop requests must equal reconstruction from the header and pre-step log prefix.
// Request-reconstruction cross-check (the reconstructability RFC): a
// loop-built request — frozen envelope + live sessionId is the marker; a
// hand-built one-shot (compaction summarize) is unfrozen and skipped — must
// be EXACTLY what the session log reconstructs:
//
// - messages: the folded header's session prefix (messagePrefix — the
// `agent/session-prefix` product, logged on the header because no
// session event carries it) followed by the
// derivation over the log prefix strictly before the in-flight step's
// `step/start` (the reconstruction boundary). The derivation is compared
// against a FRESH Session built over that prefix — the same projection
// code with zero shared state, so the live cache under test cannot vouch
// for itself. Boundary-correct by construction: content appended after
// the boundary (an `agent/request`-window inject) is legitimately absent
// from this request, and a current-surface comparison would false-fire.
// - header: every non-content field must equal the fold of the log's
// `request/header*` events — the loop logs the header event BEFORE
// dispatch, so the fold already covers this request.
//
// Registered with `prepend: true` so a short-circuiting llm/stream listener
// (the replay adapter returns its chunks without calling next()) cannot
// silence the check by registering first. Prepend beats APPEND-registered
// listeners only — two prepended listeners have no defined mutual order
// (cordis unshift) — which is fine: correctness rests on the seq-bounded
// fold below, never on listener timing.
ctx.on('llm/stream', (options: GenerateOptions, next) => {
if (options.sessionId === undefined || !Object.isFrozen(options)) return next()
// GenerateOptions types sessionId as Branded<'SessionId'>, which IS
@@ -423,7 +517,9 @@ export function apply(ctx: Context, config: Config = {}): void {
}
const events = session.events
// seq === index (checked above), so the last step/start's seq bounds the prefix directly.
// seq === index (checked above), so the last step/start's seq bounds the
// prefix directly. The in-flight step's step/start is necessarily the
// last one: the loop cannot open another step while this call streams.
let boundary = -1
for (let i = events.length - 1; i >= 0; i -= 1) {
if (events[i]?.type === 'step/start') {
@@ -439,9 +535,12 @@ export function apply(ctx: Context, config: Config = {}): void {
throw new InvariantError('a loop-built request with no request/header event in its session log')
}
const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary)))
// The reconstruction equation: the folded header's session prefix, then the boundary
// derivation — the loop logs the header event before dispatch, so the fold already covers
// this request's prefix.
// The reconstruction equation: the folded header's session prefix, then
// the boundary derivation — the loop
// logs the header event BEFORE dispatch, so the fold already covers this
// request's prefix. JSON equality is sound here: both sides are
// structuredClones produced by the same projection/build code path, so key
// insertion order matches when the values do.
const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()]
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
@@ -457,5 +556,5 @@ export function apply(ctx: Context, config: Config = {}): void {
throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the folded request header`)
}
return next()
}, { prepend: true })
}, { global: true, prepend: true })
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -8,10 +8,10 @@ import * as Invariants from '@deepseek-ai/dsh-invariants'
import { InvariantError } from '@deepseek-ai/dsh-invariants'
/** A Context with the session store and the invariants plugin registered. */
async function setup(config?: { freeze?: boolean }) {
async function setup() {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(Invariants, config ?? {})
const fiber = await ctx.plugin(Invariants)
return { ctx, fiber }
}
@@ -21,8 +21,27 @@ function mockAgent(id: string): Agent {
}
describe('session-log invariants', () => {
it('keeps pre-commit staging and post-commit application global when mounted under a scope', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let scopedCtx!: Context
await ctx.plugin(Object.assign((inner: Context) => {
scopedCtx = createScope(inner, {}).ctx
}, { inject: ['sessions'] }))
await scopedCtx.plugin(Invariants)
const globalSession = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
expect(() => {
globalSession.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
globalSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('accepts a well-formed turn/step/tool sequence', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -37,8 +56,49 @@ describe('session-log invariants', () => {
}).not.toThrow()
})
it('does not advance the trace when a later internal-dispatch listener vetoes', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create(SessionId('dispatch-veto-rollback'))
let veto = true
ctx.on('internal/dispatch', (_mode, name) => {
if (name !== 'session/event' || !veto) return
veto = false
throw new Error('later dispatch veto')
})
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('later dispatch veto')
expect(session.events).toEqual([])
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
})
it('applies the committed transition after a prepended observer throws', async () => {
const { ctx } = await setup()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const session = ctx.sessions.create(SessionId('postcommit-peer'))
ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
expect(warnings).toEqual([
'session "postcommit-peer": session/event listener threw: Error: hostile observer',
'session "postcommit-peer": session/event listener threw: Error: hostile observer',
])
})
it('rejects a non-monotonic seq (replay spine)', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
// Session.append enforces seq-contiguity at the source, so drive the
// invariants seq check directly via session/event with a regressing seq.
@@ -48,7 +108,7 @@ describe('session-log invariants', () => {
})
it('rejects a turn/start while another turn is open', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
@@ -56,7 +116,7 @@ describe('session-log invariants', () => {
})
it('rejects a turn/end that does not match the open turn', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
@@ -64,14 +124,14 @@ describe('session-log invariants', () => {
})
it('rejects a step/start outside its declared turn', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
})
it('rejects a step/end that does not match the open step', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
@@ -79,7 +139,7 @@ describe('session-log invariants', () => {
})
it('rejects an assistant/chunk outside an open step', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }))
@@ -87,7 +147,7 @@ describe('session-log invariants', () => {
})
it('rejects a message event appended outside any open turn (turn-enclosure)', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
// No turn open: every message-bearing event must be turn-enclosed (the turn-enclosure RFC).
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
@@ -97,7 +157,7 @@ describe('session-log invariants', () => {
})
it('rejects steering and plugin-added events appended outside any open turn', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
// steering/message is turn-scoped: outside a turn it would land past the
// commit boundary and be dropped on resume (the turn-enclosure RFC).
@@ -113,7 +173,7 @@ describe('session-log invariants', () => {
})
it('accepts message events once a turn is open', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
@@ -121,7 +181,7 @@ describe('session-log invariants', () => {
})
it('rejects a tool/result with no prior tool/call', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
@@ -130,7 +190,7 @@ describe('session-log invariants', () => {
})
it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -152,7 +212,7 @@ describe('session-log invariants', () => {
})
it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -164,7 +224,7 @@ describe('session-log invariants', () => {
})
it('holds seeded sessions to the contract on session/created', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
// A seq-contiguous, serializable seed (so it passes Session's constructor
// validation) that nonetheless violates turn nesting — a second turn/start
// while the first turn is still open — must be rejected by the invariants
@@ -177,7 +237,7 @@ describe('session-log invariants', () => {
})
it('tracks turns per session independently', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const a = ctx.sessions.create(SessionId('a'))
const b = ctx.sessions.create(SessionId('b'))
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -186,7 +246,7 @@ describe('session-log invariants', () => {
})
it('accepts multiple steps in a turn and consecutive turns', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -203,7 +263,7 @@ describe('session-log invariants', () => {
})
it('rejects a skipped turn number', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -212,7 +272,7 @@ describe('session-log invariants', () => {
})
it('rejects a skipped step number within a turn', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
@@ -222,7 +282,7 @@ describe('session-log invariants', () => {
})
it('rejects a turn/end while a step is still open', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
@@ -231,7 +291,7 @@ describe('session-log invariants', () => {
})
it('rejects a step/start while a step is still open', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
@@ -239,7 +299,7 @@ describe('session-log invariants', () => {
})
it('rejects a tool/result satisfying a call from a previous step', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
@@ -252,7 +312,7 @@ describe('session-log invariants', () => {
})
it('rejects an assistant/message naming the wrong step', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
@@ -266,7 +326,7 @@ describe('HMR state rebuild', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
// First registration, mid-turn: a turn is open when the plugin reloads.
const first = await ctx.plugin(Invariants, { freeze: false })
const first = await ctx.plugin(Invariants)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
@@ -274,7 +334,7 @@ describe('HMR state rebuild', () => {
// Re-apply (HMR): the fresh fiber must replay the existing log so the open
// step is known — the next chunk must NOT be a false positive.
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }))
.not.toThrow()
// And a genuine violation is still caught after the rebuild.
@@ -283,67 +343,49 @@ describe('HMR state rebuild', () => {
})
})
describe('dev-freeze', () => {
it('freezes appended event data so mutating a logged event throws', async () => {
const { ctx } = await setup() // freeze defaults true
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
describe('session immutability', () => {
it('always freezes appended event data without the invariants plugin', () => {
const session = new Session(SessionId('appended'))
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(Object.isFrozen(event)).toBe(true)
expect(Object.isFrozen(event.data)).toBe(true)
expect(Object.isFrozen(event.data.content)).toBe(true)
expect(Object.isFrozen(event.data.content[0])).toBe(true)
expect(Object.isFrozen(session.events)).toBe(true)
expect(() => { (event.data.content[0] as { text: string }).text = 'HACKED' }).toThrow()
})
it('does not freeze when freeze:false', async () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(Object.isFrozen(event)).toBe(false)
})
it('freezes seeded events on session/created', async () => {
const { ctx } = await setup()
it('always freezes seeded events without the invariants plugin', () => {
const seed = [
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'user/message' as const, seq: 1, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
]
const session = ctx.sessions.create(undefined, { seed })
const session = new Session(SessionId('seeded'), seed)
expect(Object.isFrozen(seed[0])).toBe(false)
expect(Object.isFrozen(session.events)).toBe(true)
expect(Object.isFrozen(session.events[0])).toBe(true)
expect(Object.isFrozen(session.events[0]?.data)).toBe(true)
expect(Object.isFrozen(session.events[1]?.data)).toBe(true)
})
it('freezes mutable descendants of a shallow-frozen event datum', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// deepFreeze must traverse a shallow-frozen event clone and freeze its nested data.
it('snapshots and freezes descendants of a shallow-frozen caller value', () => {
const session = new Session(SessionId('shallow-frozen'))
const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }]
const block = Object.freeze({ type: 'tool-result' as const, toolCallId: CallId('c1'), content: innerContent, isError: false })
const event = session.append('user/message', { content: [block], source: { kind: 'user' } }, { surfaceOp: 'append' })
const logged = event.data.content[0] as { content: { type: 'text'; text: string }[] }
expect(Object.isFrozen(innerContent)).toBe(false)
expect(Object.isFrozen(logged.content)).toBe(true)
expect(Object.isFrozen(logged.content[0])).toBe(true)
innerContent[0]!.text = 'caller mutation'
expect(logged.content[0]!.text).toBe('inner')
expect(() => { logged.content.push({ type: 'text', text: 'mutation' }) }).toThrow()
})
it('terminates on a cyclic event datum (WeakSet guard)', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
// The deep-freeze WeakSet guard must terminate on a self-referential structure rather than
// recursing forever.
ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
cyclic['self'] = cyclic
const event = { type: 'user/message', seq: 1, time: 1, data: { content: [cyclic], source: { kind: 'user' } } }
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }).not.toThrow()
expect(Object.isFrozen(cyclic)).toBe(true)
})
})
describe('agent status invariants', () => {
it('accepts legal transitions: idle→running→idle and →disposed', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const agent = mockAgent('a1')
expect(() => {
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
@@ -354,28 +396,28 @@ describe('agent status invariants', () => {
})
it('accepts running→disposed', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const agent = mockAgent('a2')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') }).not.toThrow()
})
it('rejects a no-op transition', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const agent = mockAgent('a3')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }).toThrow(/no-op transition/)
})
it('rejects leaving the terminal disposed state', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const agent = mockAgent('a4')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/)
})
it('tracks status per agent independently', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const a = mockAgent('a5')
const b = mockAgent('b5')
ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running')
@@ -392,10 +434,10 @@ describe('HMR safety', () => {
await fiber.dispose()
// After disposal: no freezing, no assertions. An event that WOULD have
// violated the open-turn rule now passes silently, and is not frozen.
// After disposal the plugin's assertions are gone, so an event that would
// violate the open-turn rule passes. Session still owns immutability.
const event = session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(Object.isFrozen(event)).toBe(false)
expect(Object.isFrozen(event)).toBe(true)
// A no-op status transition no longer throws either.
const agent = mockAgent('hmr')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
@@ -410,16 +452,17 @@ describe('HMR safety', () => {
expect(err.message).toBe('invariant violated: seq must strictly increase')
})
it('does not leak listeners across dispose (no stale freezing)', async () => {
it('does not leak listeners across dispose', async () => {
const { ctx, fiber } = await setup()
await fiber.dispose()
const spy = vi.fn()
ctx.on('session/event', spy)
const session = ctx.sessions.create()
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
// our own spy fires, proving events still flow — but the plugin's frozen.
// The spy proves events still flow after plugin disposal. Session, not the
// disposed listener, freezes the accepted record.
expect(spy).toHaveBeenCalledOnce()
expect(Object.isFrozen(session.events[0])).toBe(false)
expect(Object.isFrozen(session.events[0])).toBe(true)
})
})
@@ -498,8 +541,9 @@ describe('surface invariants', () => {
})
it('rejects sourceEventSeqs referencing unknown seq (gap in event log)', async () => {
// The unknown-seq check fires when a ref passes the "earlier" test but is not in knownSeqs
// — only possible with a gap in seqs.
// The unknown-seq check fires when a ref passes the "earlier" test but is
// not in knownSeqs — only possible with a gap in seqs. We create a gap by
// directly manipulating the private log array to skip a seq.
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -513,7 +557,9 @@ describe('surface invariants', () => {
time: Date.now(),
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } },
})
// Now the log has seqs 0, 1, 3 (gap at 2).
// Now the log has seqs 0, 1, 3 (gap at 2). Append at what session believes
// is seq 3 (log.length). Reference seq 2: passes earlier (2 < 3) but not
// in knownSeqs ({0, 1, 3} — gap at 2).
expect(() => {
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] })
}).toThrow(/unknown seq 2/)
@@ -605,8 +651,10 @@ describe('surface invariants', () => {
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
// Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the head seq (4) is
// numerically GREATER than the tail seq (3): the surface is not seq-ordered.
// Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the
// head seq (4) is numerically GREATER than the tail seq (3): the surface is
// not seq-ordered. A replace spanning start=4 (pos 0) … end=3 (pos 1) is
// valid positionally and must be accepted even though start seq > end seq.
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4
expect(() => {
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 4, end: 3 }, sourceEventSeqs: [4, 3] }) // seq 5
@@ -626,7 +674,7 @@ describe('surface invariants', () => {
})
it('catches an incomplete-provenance replace on the load/seed path', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const badSeed = [
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'step/start' as const, seq: 1, time: 0, data: { turn: 1, step: 1 } },
@@ -641,10 +689,10 @@ describe('surface invariants', () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// Type system prevents surface metadata on non-surface events; this test
// exercises the runtime guard against casts or persisted-data bypass.
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { sourceEventSeqs: [0] }))
// Session rejects this at its own acceptance boundary. Emit a hand-built
// record to cover the listener's defensive check for alternate producers.
const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, sourceEventSeqs: [0] }
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) })
.toThrow(/cannot carry sourceEventSeqs/)
})
@@ -652,8 +700,8 @@ describe('surface invariants', () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { surfaceOp: 'append' }))
const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, surfaceOp: 'append' }
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) })
.toThrow(/cannot carry surfaceOp/)
})
})
@@ -661,7 +709,7 @@ describe('surface invariants', () => {
describe('request-reconstruction cross-check (llm/stream)', () => {
/** Session with a boundary: one derivable user message, an open step, and the header event the loop would have logged. */
async function requestSetup() {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create(SessionId('req-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
@@ -723,7 +771,7 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
})
it('rejects a loop-built request with no header event or no step/start in its log', async () => {
const { ctx } = await setup({ freeze: false })
const { ctx } = await setup()
const session = ctx.sessions.create(SessionId('req-bare'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
@@ -755,13 +803,16 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
describe('request cross-check ordering (prepend)', () => {
it('runs ahead of a short-circuiting llm/stream listener registered before it', async () => {
// The replay adapter returns its chunks WITHOUT calling next(), which would silence a
// later-registered check — snapshot compositions load replay before the app bundle that
// loads invariants.
// The replay adapter returns its chunks WITHOUT calling next(), which
// would silence a later-registered check — snapshot compositions load
// replay before the app bundle that loads invariants. The check prepends,
// so it fires ahead of append-registered listeners regardless of load
// order. (Prepend orders it against APPENDED listeners only; correctness
// rests on the seq-bounded rebuild, not on listener timing.)
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next()
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
const session = ctx.sessions.create(SessionId('prepend-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -842,53 +893,4 @@ describe('scoped-dispatch invariants', () => {
.not.toThrow()
})
it('rejects an assembly context carrying agent without scope', async () => {
const ctx = await scopedCtx()
const agent = { id: 'a1' } as unknown as Agent
const base = { name: 'systemPrompt' }
const assembly = { sections: [], tools: [], variables: {} }
const bad = { agent }
expect(() => {
// The carrier base stands in for the SystemPrompt service (the declared `this`); the invariant only reads the carrier marks.
void ctx.waterfall(scopeTarget(base, undefined) as never, 'system-prompt/assemble', assembly as never, bad as never, () => Promise.resolve(assembly as never))
}).toThrow(/agent.*without.*scope|assembleContextFor/)
const good = { agent, scope: agent }
expect(() => {
void ctx.waterfall(scopeTarget(base, agent) as never, 'system-prompt/assemble', assembly as never, good as never, () => Promise.resolve(assembly as never))
}).not.toThrow()
})
it('backstops alternate agents that open a turn before agent/session-start', async () => {
const ctx = await scopedCtx()
// A live agent whose session is in the store but whose session-start has
// not fired: appending turn/start must throw the teaching error.
const session = ctx.sessions.create(SessionId('drive-s'))
const agent = { id: 'driver', session } as unknown as Agent
// Provide a minimal agents lookup: the invariant reads ctx.get('agents').
const registryStub = { list: () => [agent] }
ctx.root.provide('agents', registryStub as never)
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
}).toThrow(/turn opened before agent\/session-start/)
// After session-start fires, turns open freely.
ctx.emit(scopeTarget(agent, agent), 'agent/session-start', agent, 'startup')
expect(() => {
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
}).not.toThrow()
})
it('marks sessions of agents that predate the plugin as started (HMR re-apply safety)', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('pre-s'))
const agent = { id: 'pre', session } as unknown as Agent
ctx.root.provide('agents', { list: () => [agent] } as never)
// Invariants apply AFTER the agent exists: its ordering is unknowable, so
// a turn opening without an observed session-start must NOT false-positive.
await ctx.plugin(Invariants)
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
}).not.toThrow()
})
})

View File

@@ -25,12 +25,6 @@
},
{
"path": "../../core/scope"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
}
]
}

View File

@@ -2,7 +2,7 @@
A scripted `SubagentProvider` for testing the [subagent seam](../../subagent/subagent/README.md) without a model or a real child agent — the subagent analog of [`dsh-llm-replay`](../llm-replay/README.md).
It lets a test drive `ctx.subagents` and the model-facing `dsh-tool-subagent` through the **real cordis Loader / export path**, exercising provider registration, start-time capability validation, the run lifecycle (`result` / `cancel` / `dispose`), and the structured-output branch — all deterministically and keylessly.
It lets a test drive `ctx.subagents` and the model-facing `dsh-tool-subagent` through the real Cordis loader/export path, exercising provider registration, async start, start-time capability validation, required-signal cancellation, `result`, `dispose`, and structured output deterministically and keylessly.
## Usage
@@ -13,8 +13,8 @@ Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no defa
| `name` | `mock` | Registry name to register the provider under. |
| `reply` | `mock subagent reply` | The scripted child's final answer text. |
| `stopReason` | `completed` | The stop reason `result` settles with. |
| `capabilities` | all `true` | Which start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`) the provider advertises. |
| `inheritsParentContext` | `false` | The context contract to declare; `true` exercises the fork-shaped tool wording in consumer tests. |
| `capabilities` | all `true` | Which start-time capabilities (`outputSchema`, `depthLimit`, `toolFilter`, and `persona`) the provider advertises. |
| `inheritsParentContext` | `false` | Conversation-history descriptor: `false` means fresh, while `true` exercises seeded/fork wording. It says nothing about tool, service, scope, or authority inheritance. |
| `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. |
A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable.
Aborting the required request signal or disposing before `result` settles flips the stop reason to `aborted`, so both holder-facing cancellation paths are observable.

View File

@@ -22,11 +22,10 @@ const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal']
const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }
/**
* A scripted provider: every {@link start} returns a run whose `result`
* resolves on a microtask with the configured reply (and a structured value
* when the request asked for one and the capability is on). `dispose` is a
* no-op; a `cancel()` before the result settles flips the stop reason to
* `aborted`, so the cancellation path is observable in a test.
* A scripted provider: every {@link start} returns a ready run whose `result`
* resolves on the next task with the configured reply (and a structured value
* when the request asked for one and the capability is on). The required
* signal and `dispose()` both flip an unsettled result to `aborted`.
*/
class MockSubagentProvider implements SubagentProvider {
readonly capabilities: SubagentCapabilities
@@ -40,12 +39,22 @@ class MockSubagentProvider implements SubagentProvider {
this.inheritsParentContext = config.inheritsParentContext ?? false
}
start(request: SubagentStartRequest): SubagentRun {
async start(request: SubagentStartRequest): Promise<SubagentRun> {
if (request.signal.aborted) throw new Error('mock subagent start aborted before publication')
const reply = this.config.reply ?? 'mock subagent reply'
const output: ContentBlock[] = [{ type: 'text', text: reply }]
const wantsStructured = request.outputSchema !== undefined && this.capabilities.outputSchema
const baseStop: SubagentStopReason = this.config.stopReason ?? 'completed'
let cancelled = false
const flags = { cancelled: false }
const onAbort = (): void => { flags.cancelled = true }
request.signal.addEventListener('abort', onAbort, { once: true })
// Make publication genuinely asynchronous so a same-turn abort is still
// a provider-owned startup failure rather than a returned live run.
await Promise.resolve()
if (flags.cancelled) {
request.signal.removeEventListener('abort', onAbort)
throw new Error('mock subagent start aborted before publication')
}
// A deterministic child id derived from the parent — no clock/random (both
// banned in deterministic paths here, and unnecessary for a scripted run).
@@ -53,21 +62,22 @@ class MockSubagentProvider implements SubagentProvider {
const resultFor = (): SubagentResult => ({
output,
structured: wantsStructured ? (this.config.structured ?? { reply }) : undefined,
stopReason: cancelled ? 'aborted' : baseStop,
...wantsStructured ? { structured: this.config.structured ?? { reply } } : {},
stopReason: flags.cancelled ? 'aborted' : baseStop,
})
const result = new Promise<SubagentResult>((resolve) => {
setTimeout(() => { resolve(resultFor()) }, 0)
}).finally(() => {
request.signal.removeEventListener('abort', onAbort)
})
return {
id,
// A scripted run has no asynchronous publication phase; it is ready as
// soon as the provider returns the handle.
started: Promise.resolve(),
result: Promise.resolve().then(resultFor),
cancel() {
cancelled = true
},
async dispose() {
// Scripted run holds no resources — nothing to await.
result,
dispose(): Promise<void> {
flags.cancelled = true
request.signal.removeEventListener('abort', onAbort)
return Promise.resolve()
},
}
}
@@ -87,9 +97,11 @@ export interface Config {
/** Which start-time capabilities to advertise (default: all `true`). */
capabilities?: Partial<SubagentCapabilities>
/**
* The context contract to declare ({@link SubagentProvider.inheritsParentContext});
* default `false` (spawn-like). Set `true` to exercise the fork-shaped tool
* wording in consumer tests.
* The conversation-history descriptor to declare
* ({@link SubagentProvider.inheritsParentContext}); default `false` (fresh
* conversation). Set `true` to exercise seeded/fork wording in consumer
* tests. This flag says nothing about tool, service, scope, or authority
* inheritance.
*/
inheritsParentContext?: boolean
/**

View File

@@ -11,7 +11,7 @@ function fakeParent(id = 'parent-1'): Agent {
}
function baseRequest(over: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), ...over }
return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), signal: new AbortController().signal, ...over }
}
async function mount(config: Partial<mock.Config> = {}): Promise<Context> {
@@ -26,12 +26,13 @@ describe('dsh-subagent-mock', () => {
const ctx = await mount({ reply: 'hello from mock' })
expect(ctx.subagents.list()).toEqual(['mock'])
const run = ctx.subagents.start('mock', baseRequest())
const run = await ctx.subagents.start('mock', baseRequest())
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'hello from mock' }],
structured: undefined,
stopReason: 'completed',
})
await run.dispose()
})
it('registers under a configurable name', async () => {
@@ -41,13 +42,13 @@ describe('dsh-subagent-mock', () => {
it('surfaces a structured result when the request carries an outputSchema', async () => {
const ctx = await mount({ reply: 'r', structured: { answer: 42 } })
const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } }))
const run = await ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } }))
await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } })
})
it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => {
const ctx = await mount({ reply: 'fallback reply' })
const run = ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } }))
const run = await ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } }))
await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } })
})
@@ -56,23 +57,44 @@ describe('dsh-subagent-mock', () => {
// The service rejects an outputSchema request against a no-cap provider, so
// the structured path is only reachable when the cap is on; with it off and
// no schema requested, the result has no structured field.
const run = ctx.subagents.start('mock', baseRequest())
await expect(run.result).resolves.toMatchObject({ structured: undefined })
const run = await ctx.subagents.start('mock', baseRequest())
const result = await run.result
expect(result).not.toHaveProperty('structured')
})
it('honors a configured stop reason', async () => {
const ctx = await mount({ stopReason: 'refusal' })
const run = ctx.subagents.start('mock', baseRequest())
const run = await ctx.subagents.start('mock', baseRequest())
await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' })
})
it('flips the stop reason to aborted when cancelled before the result settles', async () => {
it('flips the stop reason to aborted when the signal fires before the result settles', async () => {
const ctx = await mount()
const run = ctx.subagents.start('mock', baseRequest())
run.cancel()
const controller = new AbortController()
const run = await ctx.subagents.start('mock', baseRequest({ signal: controller.signal }))
controller.abort()
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' })
})
it('rejects an already-aborted request before starting publication', async () => {
const ctx = await mount()
const controller = new AbortController()
controller.abort()
await expect(ctx.subagents.start('mock', baseRequest({ signal: controller.signal })))
.rejects.toThrow('mock subagent start aborted before publication')
})
it('rejects when cancellation wins the asynchronous publication handoff', async () => {
const ctx = await mount()
const controller = new AbortController()
const pending = ctx.subagents.start('mock', baseRequest({ signal: controller.signal }))
controller.abort()
await expect(pending).rejects.toThrow('mock subagent start aborted before publication')
})
it('unregisters the provider when the owning fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)

View File

@@ -36,7 +36,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
## Multi-session
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there.
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so agent-scoped approval events demultiplex in O(1). Every `session/event` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there.
## Session config options
@@ -71,7 +71,7 @@ When the client does NOT advertise the capability, none of the `_meta`/terminal
## Settle-exactly-once
A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a peer `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang.
A `session/prompt` resolves or rejects exactly once from the canonical `session/event` stream. The listener captures the prompt's owning turn from `turn/start` and settles in a `finally` block when the matching `turn/end` is appended, so a presentation/streaming failure cannot strand the RPC after the durable terminal event exists. Correlation by turn id prevents a late end from a cancelled prompt from settling its successor. A turn ending in `error` rejects the RPC with an internal error carrying the failure message because ACP has no error stop reason; every other reason resolves through the codec. An empty or whitespace-only prompt is rejected before enqueue because it would start no turn and otherwise leave the RPC pending.
## Permission prompts

View File

@@ -1,7 +1,37 @@
/**
* The Agent Client Protocol (ACP) bridge: a client-driver / UI plugin that exposes the harness
* agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive
* it. The structured analogue of the readline `stdio-chat` plugin.
* The Agent Client Protocol (ACP) bridge: a client-driver / UI plugin that
* exposes the harness agent as an ACP server over JSON-RPC stdio, so editors
* (Zed and other ACP clients) can drive it. The structured analogue of the
* readline `stdio-chat` plugin.
*
* This is NOT a loop change and NOT an ADR-0009 capability seam: it consumes
* the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory,
* and `dsh-session-persistence` (for `session/load`). It maps:
*
* - `initialize` → protocol-version negotiation, text-only capabilities
* - `session/new` → `ctx.agents.create({ sessionId, meta:{cwd} })`
* - `session/load` → `ctx.agents.resume(...)` then replay the event log
* - `session/prompt` → `agent.send()`, settle on the owning turn's end (a turn
* that ends in `error` rejects the RPC)
* - `session/cancel` → `agent.cancel()` (the queue-aware cancel: aborts a
* running step, clears queued + steering work, and drops a
* turn about to start) + settle the in-flight prompt
*
* Multi-session (RFC 011): N concurrent sessions per connection, each mapped to
* its own `ReactLoopAgent`. Sessions are keyed by id in `sessions` (forward) with an
* `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
* `session/event` and `agent/*` event is routed strictly to its owning session
* record, so two sessions streaming at once never interleave their
* `session/update` notifications. Permission prompts ride the same ownership
* map: the bridge answers `approval/request` for its own agents over
* `session/request_permission` (see the approval answerer below) — whether a
* call ASKS is policy (a hook or plugin returning `ask`), not the bridge's.
*
* stdout is the protocol: this plugin must run in an example that loads NO
* stdout logger (the console logger writes to stdout and would corrupt the
* JSON-RPC frames). The guarantee is config-only — see the package README and
* RFC 010 § Risks.
*
* @module @deepseek-ai/dsh-acp
*/
@@ -41,7 +71,7 @@ import {
} from '@agentclientprotocol/sdk'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
@@ -72,7 +102,11 @@ import {
} from './codec.ts'
export const name = 'acp'
// Persistence enables loadSession; tools own call and result rendering.
// The bridge programs against the interface packages only (architecture rule:
// plugins never depend on dsh-agent-loop). `sessionPersistence` is required
// because `initialize` advertises `loadSession: true`. `tools` lets a tool own
// how its calls render (`presentCall`/`presentResult`); the bridge looks up the
// definition by name and falls back to a generic presentation when absent.
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
/**
@@ -261,28 +295,54 @@ interface SessionRecord {
*/
terminalEnabled: boolean
/**
* The in-flight `session/prompt`, or `undefined` when none is pending. A prompt resolves
* with a {@link StopReason} or rejects with an Error (a turn that ended in failure). Settled
* exactly once via {@link settlePrompt}.
* The in-flight `session/prompt`, or `undefined` when none is pending. A
* prompt resolves with a {@link StopReason} or rejects with an Error (a
* turn that ended in failure). Settled exactly once by its matching
* `turn/end`, direct cancellation, or teardown.
*
* `turn` is the loop turn number this prompt owns, captured from the log's
* `turn/start` after `send()`. Until then it is `undefined` (the turn has not
* begun). Only a `turn/end` whose turn number equals `turn` settles the prompt
* — so a *previous* prompt's late `turn/end` (e.g. an aborted turn whose end
* arrives after the next prompt is already installed) can never settle the
* wrong prompt. A direct cancel/dispose settle clears the whole in-flight slot,
* so a later stale `turn/end` finds no pending prompt.
*
*/
inflight: {
resolve: (reason: StopReason) => void
reject: (error: Error) => void
turn: number | undefined
logWatermark: number
} | undefined
/**
* Config switches accepted while the session was IDLE, not yet anchored in its log.
* Config switches accepted while the session was IDLE, not yet anchored in
* its log. The turn-enclosure contract makes a bare between-turns append
* invalid (the JSONL backend treats a post-`turn/end` tail as crash
* garbage, and dev invariants throw), so an idle switch waits here and is
* anchored at the next turn's prompt-submit — before anything in that
* turn assembles a prompt or runs a call, and last write
* per knob wins (an idle flip-flop anchors as one event). Until anchored,
* the switch lives only in bridge memory: the set/new/load responses
* overlay it truthfully, and a restart before the next turn reverts it —
* which `session/load` then reports honestly from the log's fold.
*/
pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy }
}
/**
* Drive the in-flight prompt's settle from the harness event stream.
* Drive the in-flight prompt's settle from the harness event stream. The bridge
* settles off the durable `turn/end` event for the prompt's own turn. Session
* contains post-commit observers independently, and this listener performs
* correlation in a `finally` so presentation failure cannot starve settlement.
*/
export function apply(ctx: Context, config: AcpConfig): void {
// Capture the injected services NOW, during apply(), while we are inside this plugin's fiber
// (where `inject` grants access).
// Capture the injected services NOW, during apply(), while we are inside this
// plugin's fiber (where `inject` grants access). The ACP method handlers run
// LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is
// NOT this fiber's injection scope — so reading `ctx.agents` / `ctx.logger` /
// `ctx.sessionPersistence` lazily inside a handler throws "cannot get property
// … without inject". Resolving the references here and closing over them keeps
// the handlers working regardless of which fiber later invokes them.
const agents = ctx.agents
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
@@ -292,16 +352,25 @@ export function apply(ctx: Context, config: AcpConfig): void {
// this warn sink so a throwing tool presenter is logged, not propagated.
const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent)
// Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId reverse map so
// `agent/*` events (which carry only the Agent) demux in O(1).
// Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId
// reverse map so `agent/*` events (which carry only the Agent) demux in O(1).
// The two stay in lockstep: a record is added to `sessions` and the agent to
// `bySession` together, and removed together.
const sessions = new Map<SessionId, SessionRecord>()
const bySession = new WeakMap<Agent, SessionId>()
// Session ids whose `session/load` is mid-`resume()` (the slot is reserved before the async
// resume so a pipelined load/new for the same id can't create two agents).
// Session ids whose `session/load` is mid-`resume()` (the slot is reserved
// before the async resume so a pipelined load/new for the SAME id can't create
// two agents). Distinct ids load concurrently; a given id loads once at a time.
const loadingIds = new Set<SessionId>()
// Set once the bridge has torn down (disposal or client disconnect).
// Set once the bridge has torn down (disposal or client disconnect). An async
// `session/load` mid-`resume()` when teardown ran must observe this after its
// await and NOT install a record (which would resurrect a live agent/listeners
// after the bridge closed). Checked after every load await.
let closed = false
// Whether the client advertised the Zed `_meta.terminal_output` capability in `initialize`.
// Whether the client advertised the Zed `_meta.terminal_output` capability in
// `initialize`. When true, a tool's terminal presentation is rendered as a
// terminal card (content + `_meta.terminal_*`); when false, the bridge uses
// the tool's text fallback. Set once in `initialize`, read on every tool event.
let terminalOutputCap = false
// Assigned at the bottom, before any agent event can fire (a session only
@@ -369,7 +438,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
/** Push a `session/update` notification, swallowing post-close rejections. */
const notify = (notification: SessionNotification): void => {
// sessionUpdate returns a promise; a closed connection rejects it.
// sessionUpdate returns a promise; a closed connection rejects it. The
// update is best-effort UI feed, never load-bearing for correctness, so a
// throwing/rejecting send must not break the turn (the chunk is emitted
// inside the model step — see docs/defensive-patterns.md "contain callback exceptions").
/* v8 ignore next 3 -- the rejection only fires on a stdout/connection write
failure (closed pipe), which the in-memory test transport never induces;
the swallow is a defensive best-effort guard like the loop's emit traps */
@@ -400,74 +472,56 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- Stream the harness event taxonomy to ACP session/update --------------
// All content streaming AND the prompt settle flow through `session/event`, the canonical
// log: every assistant/chunk and tool/call/result is logged, so translating from the log
// makes live streaming and `session/load` replay share the identical path
// (streamSessionEventUpdate).
// All content streaming AND the prompt settle flow through `session/event`,
// the canonical log: every assistant/chunk and tool/call/result is logged, so
// translating from the log makes live streaming and `session/load` replay
// share the identical path (streamSessionEventUpdate). Both the owning-turn
// capture and the settle key off the log's own `turn/start`/`turn/end` — the
// durable boundary events (there is no agent/* turn mirror). `closeTurn`
// appends `turn/end` to the log unconditionally, and `turn/start` is appended
// before any step runs, so within this one listener we always see the
// prompt's turn-start (tag `inflight.turn`) then its turn-end (settle). A
// `turn/end` settles the prompt ONLY when it is the prompt's OWN turn
// (`inflight.turn === event.data.turn`) — a previous, already-cancelled turn
// whose end arrives late is ignored (see
// SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP
// has no error stop reason); other reasons resolve via the codec. Demux
// strictly by session id: a `session/event` is routed to its own record, so
// two sessions streaming at once never cross-settle or interleave updates.
ctx.on('session/event', (session, event: SessionEvent) => {
const rec = sessions.get(session.header.id)
if (rec === undefined) return
streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, {
enabled: rec.terminalEnabled,
cwd: session.header.cwd,
}, { includeUserMessages: false })
const inflight = rec.inflight
if (inflight === undefined) return
if (event.type === 'turn/start') {
// Tag the in-flight prompt with its owning turn — but only a `message`-triggered turn
// (the kind a `send()` prompt produces).
if (inflight.turn === undefined && event.data.trigger.kind === 'message') {
inflight.turn = event.data.turn
try {
streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, {
enabled: rec.terminalEnabled,
cwd: session.header.cwd,
}, { includeUserMessages: false })
} finally {
const inflight = rec.inflight
if (inflight !== undefined && event.type === 'turn/start') {
// The first message-triggered turn after prompt installation owns the
// prompt; injection-triggered turns must not settle it early.
if (inflight.turn === undefined && event.data.trigger.kind === 'message') {
inflight.turn = event.data.turn
}
} else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
rec.inflight = undefined
settleFromTurnEnd(inflight, event.data.reason)
}
return
}
// Settle only on the OWNING turn's end.
if (event.type !== 'turn/end' || inflight.turn !== event.data.turn) return
rec.inflight = undefined
settleFromTurnEnd(inflight, event.data.reason)
})
// Settle fallback: a `session/event` listener registered before ACP that throws (on
// `turn/start` OR `turn/end`) would, via cordis `emit`'s stop-on-throw, starve ACP's listener
// above — the prompt would hang or, if only the turn number was missed, settle as the wrong
// outcome.
const settleFromLog = (rec: SessionRecord): void => {
const inflight = rec.inflight
if (inflight === undefined) return
const events = rec.agent.session.events
// The owning turn number: the captured one, or — if the live capture was starved — inferred
// from the log as the first MESSAGE-triggered turn opened at/after the watermark.
const owningTurn = inflight.turn ?? events.slice(inflight.logWatermark).find(
(e): e is Extract<SessionEvent, { type: 'turn/start' }> =>
e.type === 'turn/start' && e.data.trigger.kind === 'message',
)?.data.turn
// The owning turn's end in the log.
const end = events.findLast(
(e): e is Extract<SessionEvent, { type: 'turn/end' }> =>
e.type === 'turn/end' && e.data.turn === owningTurn,
)
rec.inflight = undefined
if (end === undefined) {
// No owning turn / no clean turn/end (torn down mid-turn) → cancelled.
inflight.resolve('cancelled')
return
}
settleFromTurnEnd(inflight, end.data.reason)
}
// On a settle to idle/disposed, reconcile any still-pending prompt from the log (covers a
// starved `session/event` listener — see settleFromLog).
ctx.on('agent/status', (agent, status: AgentStatus) => {
const sessionId = bySession.get(agent)
if (sessionId === undefined) return
const rec = sessions.get(sessionId)
if (rec === undefined) return
if (status === 'idle' || status === 'disposed') settleFromLog(rec)
})
// --- Approval answerer The bridge is the approval channel for the agents it owns: an `ask`
// routed through `ctx.approval` (dsh-tools asks and sandbox escalation) becomes an editor
// permission prompt attached to the already-streamed tool call.
// --- Approval answerer -----------------------------------------------------
// The bridge is the approval channel for the agents it owns: an `ask` routed
// through `ctx.approval` (dsh-tools asks and sandbox escalation) becomes
// an editor permission prompt attached to the already-streamed tool call. The
// listener occupies the single decision slot ONLY for its own agents — a
// foreign or call-less request delegates via next() so another answerer (or
// the fail-closed `unavailable` default) takes the question. A rejected
// `requestPermission` (client gone, bridge torn down) propagates and the
// ApprovalService contains it as `unavailable`. Options are one-shot only:
// allow_always is a grant-storage design the approval RFC defers, so the
// prompt never offers a durable grant the harness could not honor.
ctx.on('approval/request', (req, next) => {
const sessionId = bySession.get(req.agent)
// The protocol requires `toolCall` (the prompt renders attached to it), so
@@ -491,11 +545,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- The ACP Agent method surface -----------------------------------------
/**
* The session config options this composition can honor, with current values folded from the
* AGENT'S own session log (`effectiveSandboxMode` / `effectiveApprovalPolicy` — the log is
* the per-session store, so a `session/load` reports a resumed session's overrides with no
* catch-up machinery), overlaid with the record's not-yet-anchored pending switches (see
* {@link SessionRecord.pendingSwitches}).
* The session config options this composition can honor, with current
* values folded from the AGENT'S OWN session log (`effectiveSandboxMode` /
* `effectiveApprovalPolicy` — the log is the per-session store, so a
* `session/load` reports a resumed session's overrides with no catch-up
* machinery), overlaid with the record's not-yet-anchored pending switches
* (see {@link SessionRecord.pendingSwitches}). Capability-gated like every
* advertised lever: the sandbox option exists only when the mounted
* executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval
* option only when the approval seam is composed — both read
* opportunistically so this bridge keeps working in compositions without
* them.
*/
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
const options: SessionConfigOption[] = []
@@ -565,7 +625,15 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
}
// Anchor idle switches during prompt-submit so persistence observes ordered in-turn events.
// Idle-accepted switches anchor at the next turn's prompt-submit: the turn
// is open (the seam fires inside it, per drained message — the first flush
// empties the slot, later ones no-op), the loop has not yet assembled
// anything for it, and — unlike appending from inside a `session/event`
// listener — this seam fires OUTSIDE any log emit, so peer listeners
// (the dev invariants, persistence) observe the anchored events in strict
// log order. A turn with no prompt (an idle inject's one-shot injection
// turn) leaves the switch pending — it runs no step, so nothing executes
// or assembles under a stale value.
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
const sessionId = bySession.get(agent)
const rec = sessionId === undefined ? undefined : sessions.get(sessionId)
@@ -618,7 +686,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
})
// Creation is now asynchronous because it awaits the unpublished setup transaction.
// Creation is now asynchronous because it awaits the unpublished setup
// transaction. A client disconnect can therefore close this bridge
// after the entry check but before the handle resolves; never install a
// post-close record that quiesce() could not have seen.
/* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC
immediately on close; real stdio may let the handler resume */
if (closed) {
@@ -649,13 +720,25 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
validateWorkspaceParams(params)
validateMcpServers(params)
// Reserve this id's load slot before the await.
// Reserve THIS id's load slot BEFORE the await. Without it, two pipelined
// loads for the same id could both pass the guard above while the first
// resume() is pending, then both install a record and leak a second
// agent. (Distinct ids load concurrently — the set is keyed by id.) The
// slot is released in `finally` so a rejected load never wedges the id.
loadingIds.add(sessionId)
try {
// Validate the persisted cwd before resuming — `list()` is a metadata-only read (no
// full-log parse), so this rejects a session we can't honor WITHOUT ever
// constructing/registering an agent (a post-resume reject would leak the registered
// agent — cancel() does not unregister it — and wedge the id against re-load).
// Validate the PERSISTED cwd BEFORE resuming — `list()` is a
// metadata-only read (no full-log parse), so this rejects a session we
// can't honor WITHOUT ever constructing/registering an agent (a
// post-resume reject would leak the registered agent — cancel() does not
// unregister it — and wedge the id against re-load). The session's bash
// workdir is derived from its persisted `header.cwd` and the request
// `cwd` does NOT override it (resume takes no cwd), so a session with no
// absolute persisted cwd would silently run bash in the SERVER's launch
// dir, not the client's workspace. A session created by this bridge
// always has a cwd (session/new requires it); reject the rest loudly.
// (An id unknown to `list()` falls through to resume, which rejects with
// the backend's not-found error.)
const meta = (await sessionPersistence.list()).find(m => m.id === sessionId)
if (meta !== undefined) {
const persistedCwd = meta.cwd
@@ -673,8 +756,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
resumeSessionId: sessionId,
agentOptions: agentOptions(config),
})
// The bridge may have torn down (disposal / client disconnect) while resume() was
// pending.
// The bridge may have torn down (disposal / client disconnect) while
// resume() was pending. Its listeners are gone, so installing a record
// now would resurrect a live agent the bridge can no longer drive. Bail —
// and tear down the just-resumed agent (unregister + stop + remove its
// session) before throwing, so it does not leak: it has no SessionRecord,
// so quiesce() would never see it.
/* v8 ignore next 4 -- the in-memory test transport rejects the in-flight
session/load request the instant it closes (before this post-await
code runs), so the guard can't be hit in tests; it protects the real
@@ -699,7 +786,19 @@ export function apply(ctx: Context, config: AcpConfig): void {
pendingSwitches: {},
}
sessions.set(sessionId, record)
// Replay the persisted event log to the client as session/update.
// Replay the persisted event log to the client as session/update. Use
// the raw event log (NOT deriveMessages, which drops assistant/chunk
// and trace events): RFC 010's load contract reconstructs the streamed
// turns — user prompts (user/message → user_message_chunk), assistant
// text and reasoning (assistant/chunk), and tool calls/results.
//
// Replay through a THROWAWAY presenter, NOT `record.presenter`: a
// historical turn that was interrupted mid-tool (a `tool/call` with no
// matching `tool/result` in the persisted log) would otherwise leave a
// stale in-flight entry on the live presenter, which then serves all
// future live events for this session. The throwaway pairs call→result
// as the log replays in order (same as live) and is discarded after,
// so the record's presenter starts clean for the post-load live stream.
const replayPresenter = makePresenter(agent)
const replayTerminal: TerminalRendering = {
enabled: terminalEnabled,
@@ -731,10 +830,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
// waiting for a settle that never comes.
throw invalidParams('empty prompt')
}
// Install the in-flight slot before send() (send does not synchronously flip status to
// running; the session/event listener records the turn number and settle/rejects it).
// Install the in-flight slot BEFORE send() (send does not synchronously
// flip status to running; the session/event listener records the turn
// number and settle/rejects it). Capture the log length now as the
// A turn that ends in error rejects this promise (the codec never
// produces an error stop reason).
const stopReason = await new Promise<StopReason>((resolve, reject) => {
rec.inflight = { resolve, reject, turn: undefined, logWatermark: rec.agent.session.events.length }
rec.inflight = { resolve, reject, turn: undefined }
rec.agent.send([{ type: 'text', text }])
})
return { stopReason }
@@ -743,7 +845,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
cancel(params: CancelNotification): Promise<void> {
const rec = sessions.get(SessionId(params.sessionId))
if (rec === undefined) return Promise.resolve()
// Queue-aware cancellation drops pending prompts as well as the active step.
// session/cancel maps to the queue-aware agent.cancel(reason): it aborts
// a RUNNING step, clears the queued + steering FIFOs, and drops a
// turn that is about to start (the pre-step window) — so a queued-but-
// not-yet-started prompt never runs, and a prompt accepted right after
// cannot be batched into the cancelled turn. Scoped to THIS session's
// agent — a cancel in one session never touches another's stream or
// pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt
// as cancelled directly here: do NOT rely on the resulting turn/end to
// settle it, because cancel() may drop the turn before any turn/end is
// emitted, and removing this direct settle would move the RPC's
// resolution onto a later observer path, changing its timing.
rec.agent.cancel('session/cancel')
settlePrompt(rec, 'cancelled')
return Promise.resolve()
@@ -757,10 +869,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (typeof params.value !== 'string') {
throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`)
}
// The setters append one log-only event on this session's own log — the log is the
// store (the sandbox RFC § Per-session mode switching): execution, the prompt section,
// and the narrator all fold it from there, and a resumed session reports the override
// back through configOptionsFor.
// The setters append ONE log-only event on this session's own log —
// the log is the store (the sandbox RFC § Per-session mode switching): execution, the
// prompt section, and the narrator all fold it from there, and a
// resumed session reports the override back through
// configOptionsFor. A switch while a turn is OPEN anchors
// immediately (the next step sees it); an IDLE switch waits in
// pendingSwitches for the next `turn/start` (turn-enclosure: a bare
// between-turns append would be dropped as crash tail on reload).
// Values are validated against the same closed lists the options
// advertised; an id this composition never advertised (or an unknown
// one) rejects.
switch (params.configId) {
case 'sandbox-mode': {
const defaultMode = ctx.get('bash')?.sandboxMode
@@ -802,7 +921,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- Connection lifecycle --------------------------------------------------
// The transport stream.
// The transport stream. Production wires stdio (stdout carries the protocol);
// tests inject an in-memory pipe pair via config.stream to drive the bridge
// without a subprocess. ndJsonStream is the SDK's stdio framing helper. The
// AgentSideConnection constructor synchronously invokes makeAgent (assigning
// the outer `conn`), so `conn` is set before any agent method runs.
/* v8 ignore next 4 -- production stdio wiring; tests always inject config.stream */
const stream: Stream = config.stream ?? ndJsonStream(
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
@@ -812,11 +935,29 @@ export function apply(ctx: Context, config: AcpConfig): void {
/**
* Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach
* quiescence"): for each session settle any pending prompt `cancelled`, then run that
* session's {@link AgentHandle} `dispose()` — which stops the loop (sets `disposed`, aborts
* the in-flight step), AWAITS the loop's exit (the final `turn/end` + `session/flush` are
* captured while `onAppend` is still attached), unregisters the agent, and removes its
* session from the store.
* quiescence"): for each session settle any pending prompt `cancelled`, then
* run that session's {@link AgentHandle} `dispose()` — which stops the loop
* (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the
* final `turn/end` + `session/flush` are captured while the store-owned publication hooks are still
* attached), unregisters the agent, and removes its session from the store.
* The per-session disposes run in parallel. Idempotent — clears the `sessions`
* map first and memoizes, so a second call (close racing dispose) is a no-op.
* Shared by Cordis disposal AND client disconnect (`conn.closed`).
*
* Per-agent disposal closes the queued-before-run window through the DISPOSED
* path, not `cancel()`: the start-disposer resolves `handle.disposed`, which
* wakes the parked loop, and `isDisposed()` breaks the loop before a
* queued-but-not-yet-running turn can start (a turn cut off mid-flight ends
* with reason `disposed`, not `aborted`). A bare client disconnect (resolves
* `conn.closed` WITHOUT disposing the fiber) thus leaves NO registered agent
* and NO session-store entry — not an idled-but-still-registered one. When the
* fiber IS disposed (whole-context or an ACP-only HMR
* `acpFiber.dispose()`), this same memoized teardown runs first; the factory's
* register+start+session effects are ALSO bound to the bridge fiber (the
* factory is reached through this bridge's traceable service proxy, so
* `AgentLoop.start`'s `this.ctx.effect(...)` binds to the CALLER context — the
* bridge fiber), so any agent this path did not reach is still reclaimed by
* fiber disposal.
*/
let quiescing: Promise<void> | undefined
const quiesce = (): Promise<void> => {
@@ -845,9 +986,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
return quiescing
}
// Client disconnect: when the ACP transport closes (editor quits, pipe EOF), the in-flight
// turn would otherwise keep running and its `session/update` writes would be silently
// swallowed by `notify()`.
// Client disconnect: when the ACP transport closes (editor quits, pipe EOF),
// the in-flight turn would otherwise keep running and its `session/update`
// writes would be silently swallowed by `notify()`. Tear the session down so
// a vanished client does not leave an orphaned running agent. `conn.closed`
// rejects/resolves once; contain any teardown throw (nothing else can act on
// it — the connection is already gone). The Cordis disposer below still runs
// on normal shutdown and is idempotent with this.
/* v8 ignore start -- the .catch arrow is a defensive guard: conn.closed
settling rejected or quiesce() throwing on an already-closed connection is
not reproducible through the in-memory test transport (it never severs
@@ -875,9 +1020,22 @@ export function agentOptions(config: AcpConfig): { model?: string } {
}
/**
* Validate the `cwd`/`additionalDirectories` contract shared by `session/new` and
* `session/load`: `cwd` must be absolute (a relative path would be ambiguous as a workspace
* root).
* Validate the `cwd`/`additionalDirectories` contract shared by `session/new`
* and `session/load`: `cwd` must be absolute (a relative path would be ambiguous
* as a workspace root). The persisted-cwd equality check for `session/load`
* happens after the metadata lookup; this validator only enforces request shape:
* - `session/new`: the validated `cwd` becomes the session's `SessionHeader.cwd`
* (via `agents.create({meta:{cwd}})`) and thus the default bash workdir.
* - `session/load`: the request `cwd` must be absolute AND must match the
* PERSISTED `header.cwd`, which stays authoritative for the bash workdir —
* the request cwd does not override it.
* Any absolute path is accepted (the per-session cwd flows to the bash executor
* — see `dsh-tool-bash`), so the server no longer has to launch in the
* workspace. `additionalDirectories` must still be empty: widening the
* tool/filesystem scope beyond the single cwd is a separate, unimplemented
* concern (a sandbox seam), and silently ignoring extra roots would desync the
* client's filesystem-scope UI. Both request shapes carry `cwd: string` and
* `additionalDirectories?: string[]`, so one validator covers both.
*/
function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void {
if (!isAbsolute(params.cwd)) {
@@ -895,13 +1053,38 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
}
/**
* Translate one session event into zero or more ACP updates.
* Translate a single harness {@link SessionEvent} into the `session/update`
* notification(s) it produces, pushing each via `notify`. Shared by live
* streaming (`session/event`) and `session/load` replay so both paths emit an
* identical update stream from the same event log.
*
* - `assistant/chunk` text-delta/reasoning-delta → message/thought chunks
* - `user/message` → `user_message_chunk` during load replay only — so a
* loaded transcript reconstructs the USER side of each turn without echoing
* a live `session/prompt` back to the client
* - `tool/call` → `tool_call` (pending)
* - `tool/result` → `tool_call_update` (completed/failed)
*
* Tool-call presentation (title/kind/rawInput, and the completed-state content)
* is owned by each TOOL via `presentCall`/`presentResult` — the bridge never
* special-cases tool names. `presenter` resolves those from the tool registry
* and remembers each call's `(name, args)` so the completed `tool/result` (which
* carries neither) can find its tool. A {@link nullToolPresenter} gives the
* generic fallback (title = tool name, raw args as input) when no registry is
* available (e.g. pure translator tests).
*
* Other event types (turn/step boundaries, context/message, …) produce
* no client update.
* @param sessionId - the ACP session id stamped on every emitted notification.
* @param event - the harness session event to translate.
* @param notify - best-effort update sink.
* @param presenter - tool render resolver; defaults to generic presentation.
* @param terminal - terminal rendering context; disabled by default.
* @param options - controls replay of user messages.
* @param notify - sink for each produced `session/update` notification; called
* zero or more times per event (best-effort UI feed, never load-bearing).
* @param presenter - resolves tool-owned render intent for tool events;
* defaults to the generic-fallback {@link nullToolPresenter}.
* @param terminal - the connection's terminal-rendering context; defaults to
* disabled (the plain-text console-block fallback).
* @param options - `includeUserMessages` (default `true`): live streaming
* passes `false` so a prompt the client just sent is not echoed back.
*/
export function streamSessionEventUpdate(
sessionId: SessionId,
@@ -988,11 +1171,26 @@ export interface TerminalRendering {
const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
/**
* Resolves tool-owned presentation for a session's tool-call events. A tool declares
* `presentCall`/`presentResult` (see `dsh-tools`) returning a `card`-tagged {@link
* ToolCallView}/{@link ToolResultView}; this looks them up by name in the registry and applies
* a generic fallback when a tool defines neither. The returned view is what {@link
* streamSessionEventUpdate} switches on.
* Resolves tool-owned presentation for a session's tool-call events. A tool
* declares `presentCall`/`presentResult` (see `dsh-tools`) returning a
* `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up
* by name in the registry and applies a generic fallback when a tool defines
* neither. The returned view is what {@link streamSessionEventUpdate} switches on.
*
* The `tool/result` session event does NOT carry the tool name or args — so to
* call a tool's `presentResult` (which needs both), the presenter remembers each
* `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the
* matching result. The map is bridge-LOCAL (not a change to the event schema or a
* core service): one presenter per live session
* (and a throwaway per `session/load` replay), and each entry is removed when its
* result arrives. In the normal loop a `tool/call` is always followed by a
* `tool/result` (the registry turns even a thrown tool into an isError result),
* so the map holds only currently-in-flight calls. The one exception is a step
* torn down mid-tool (an abort between `tool/call` and `tool/result`), which can
* leave a single stale entry per such call; this is bounded by the session
* lifetime (the whole presenter is dropped on teardown) and never affects
* correctness — a later result for a different callId is unaffected, and the
* stale entry's only cost is one map slot until the session ends.
*/
export class ToolPresenter {
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
@@ -1037,38 +1235,49 @@ export class ToolPresenter {
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
present = undefined
}
// No tool-owned presentation: fall back to the tool name as the title, the full parsed args
// as the raw input, and kind `other` (the generic card).
// No tool-owned presentation: fall back to the tool name as the title, the
// full parsed args as the raw input, and kind `other` (the generic card).
// The kind is never sniffed from the name — the bridge does not special-case
// tool names; a tool that wants a richer kind declares `presentCall`.
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args }
this.pending.set(callId, { name, args, card: view.card })
return view
}
/**
* Resolve completed presentation from the remembered tool call.
* @param callId - matching call id; unknown ids use raw content.
* @param content - fallback result content.
* @param isError - result error flag.
* @param meta - optional tool metadata.
* @returns tool-owned view or normalized generic fallback.
* Completed-state render intent for a `tool/result`; consumes the remembered
* `(name, args, card)`.
* @param callId - the id of the matching `tool/call`; an unknown or late id
* falls back to the raw content.
* @param content - the result's content blocks (the fallback and fill-in body).
* @param isError - whether the result is an error, forwarded to `presentResult`.
* @param meta - the result's machine-readable meta, forwarded when present.
* @returns the tool-owned view — an orphaned `terminal` result (no terminal
* call side) and a content-less `generic` are normalized — or the raw-content
* generic card when the tool defines no `presentResult` or threw.
*/
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
const call = this.pending.get(callId)
this.pending.delete(callId)
// No remembered call (unknown/late callId) → nothing to present from; raw content.
if (call === undefined) return { card: 'generic', content }
let present: ToolResultView | undefined
try {
present = this.tools.get(call.name, this.agent)
?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} })
} catch (error: unknown) {
// Presentation failure falls back without breaking replay or streaming.
// A throwing presentResult must not break streaming/replay: log + fall back.
this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`)
present = undefined
}
if (present === undefined) return { card: 'generic', content }
// A terminal result requires a terminal call card.
// Orphan guard: only honor a `terminal` result when the PENDING call was a
// terminal. A result-only terminal with no matching call-side terminal would
// orphan `_meta.terminal_output` to a terminal Zed never made — drop it back
// to the raw content.
if (present.card === 'terminal' && call.card !== 'terminal') return { card: 'generic', content }
// Preserve raw content when a generic presenter changes only metadata.
// A generic result that reformats no content keeps the RAW result content
// (the tool replaced only the title); fill it so the card is never blanked.
if (present.card === 'generic' && present.content === undefined) return { ...present, content }
return present
}
@@ -1116,14 +1325,24 @@ type AcpToolCallContent =
| { type: 'terminal'; terminalId: string }
/**
* Relativize a file card's TITLE path against the session workspace cwd, so a card reads `Read
* src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the reference ACP adapter's
* `toDisplayPath`.
* Relativize a file card's TITLE path against the session workspace cwd, so a
* card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the
* reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the
* card's `locations`/`diff` paths stay RAW (the editor opens the real path). The
* pure tool presenter can't see the session cwd, so this happens here where the
* bridge knows it. The rewrite is an exact substring replace of the known raw
* path (a card carries the same path in `locations[0]`/`diffs[0]`), never a
* heuristic. A path outside the workspace, or an absent/relative session cwd, is
* left unchanged.
*/
function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string {
if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title
const rel = relativePath(sessionCwd, rawPath)
// Relativize only paths contained by the workspace; keep the workspace root absolute.
// Only relativize a target that stays INSIDE the workspace. `relative` prefixes
// a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone
// or `..<sep>…`), NOT a bare `..` char prefix, so a sibling like `..cache/x`
// (a real in-workspace name) still relativizes. Never relativize to the empty
// string (rawPath === cwd — a non-file target).
if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title
return title.split(rawPath).join(rel)
}
@@ -1182,9 +1401,11 @@ function toolCallUpdate(callId: CallId, view: ToolCallView, terminal: TerminalRe
}
}
case 'terminal': {
// A terminal-rendered call gets a terminal CARD when the client supports it: the
// description renders ABOVE the card, then the terminal block, plus `_meta.terminal_info`
// (the cwd header).
// A terminal-rendered call gets a terminal CARD when the client supports it:
// the description renders ABOVE the card, then the terminal block, plus
// `_meta.terminal_info` (the cwd header). Without the capability it is an
// ordinary execute card whose body is the description and whose rawInput is
// the command; the output arrives as text on the result.
const asTerminal = terminal.enabled
const description: AcpToolCallContent[] = view.description !== undefined
? [{ type: 'content', content: { type: 'text', text: view.description } }]
@@ -1229,7 +1450,16 @@ function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExi
}
/**
* Build the `tool_call_update` (completed) `session/update` from a result render intent.
* Build the `tool_call_update` (completed) `session/update` from a result render
* intent. A `generic` result sends its reformatted content (or the raw result);
* a `terminal` result rides its output/exit on `_meta` when the client is capable
* (the terminal card consumes them and `content` is OMITTED — a
* `tool_call_update.content` REPLACES the call's content collection in Zed, so
* re-sending would clobber the terminal block the call installed) and otherwise
* derives the fenced ```console fallback from `output`. A `diff` result emits its
* `{ type: 'diff' }` content blocks (an applied hunk, or a whole-file diff for a
* create), which replace the diff the call installed — so the model-facing result
* text can never clobber it.
*/
function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate {
const status = isError ? 'failed' as const : 'completed' as const
@@ -1271,7 +1501,12 @@ function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean
...view.title !== undefined ? { title: view.title } : {},
}
case 'diff': {
// Result diff content replaces the pending card's call-side diff.
// A result-time diff: emit one `{ type: 'diff' }` content block per entry
// (an applied hunk for an edit/overwrite, or a whole-file diff for a
// create), mirroring the call-side diff arm. `tool_call_update.content`
// REPLACES the call's content in an editor, so this result diff supersedes
// the diff the pending card installed (and keeps the model-facing result
// text from clobbering it).
const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText }))
// Relativize the replacement title against the session cwd from the diff
// path, exactly as the call-side card does — `tool_call_update.title`

View File

@@ -36,8 +36,10 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => {
// Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop stay up and the
// transport is still live.
// Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop
// stay up and the transport is still live. A late session/new must hit the
// `closed` guard and reject — NOT create an agent the disposed bridge can no
// longer stream or settle. Verify the world: no agent appeared.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const before = harness.ctx.agents.list().length
@@ -49,9 +51,14 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
// The factory (`ctx.agents.create`) is reached through the bridge's traceable service
// proxy, so `AgentLoop.start`'s `this.ctx.effect(...)` registration binds to the CALLER
// context — the bridge fiber — not the AgentLoop fiber.
// The factory (`ctx.agents.create`) is reached through the bridge's
// traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)`
// registration binds to the CALLER context — the bridge fiber — not the
// AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload)
// must therefore reclaim the agent's registry entry, even though agents/
// agent-loop stay up. This pins the fiber-ownership the bridge's teardown
// doc comment relies on; if a refactor rebinds the registration to the
// AgentLoop fiber, the agent would survive bridge dispose and this fails.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -63,8 +70,10 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
// After teardown (here a client disconnect sets `closed`), a late `session/new` must not
// create an orphan agent the bridge can no longer drive/settle.
// After teardown (here a client disconnect sets `closed`), a late
// `session/new` must NOT create an orphan agent the bridge can no longer
// drive/settle. The transport is gone so the RPC rejects; assert the world:
// no new agent appeared in the registry.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const before = harness.ctx.agents.list().length
@@ -76,7 +85,10 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => {
// The ACP transport closes (editor quits) while a turn runs.
// The ACP transport closes (editor quits) while a turn runs. The bridge must
// settle the in-flight prompt cancelled and DISPOSE the agent (the session's
// per-agent AgentHandle teardown) rather than leaving an orphaned running —
// or even idled-but-still-registered — agent whose updates are swallowed.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -94,7 +106,14 @@ describe('acp bridge — disposal & HMR safety', () => {
// The agent's loop has stopped: status `disposed`.
expect(agent.status).toBe('disposed')
// Await bridge quiescence without disposing root agent and session services.
// Await the bridge teardown to completion WITHOUT tearing down the root
// agents/sessions services (so we can still query them). acpFiber.dispose()
// invokes the SAME memoized quiesce() the disconnect started and awaits its
// promise — which resolves only after every rec.dispose() (loop exit +
// session removal) has finished, closing the whenIdle()/owned.dispose()
// microtask race. The AgentHandle dispose has run: the agent is unregistered
// and its session removed from the store, not merely idled (the old
// behavior). The services live on the root ctx, so they survive this.
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
@@ -103,6 +122,9 @@ describe('acp bridge — disposal & HMR safety', () => {
it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => {
// conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously.
// They must share one teardown promise: dispose() must NOT return before the
// disconnect teardown's whenIdle() has settled (a `record === undefined`-only
// guard would let the second caller return early mid-teardown).
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -135,10 +157,14 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => {
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop, AWAIT its exit (so
// the loop's final `turn/end` + `session/flush` fire through the still-attached
// `session.onAppend` → `session/event`), and only THEN detach onAppend + remove the
// session.
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop,
// AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire
// through the still-attached store observer → `session/event`), and only
// THEN remove its publication hooks and session entry. If the order were inverted
// (detach first), the closing events would never reach persistence. Drive a
// CLEAN turn to completion, dispose JUST the bridge, then re-load the
// persisted log from disk and assert the closing turn/end is on disk — the
// world, not the agent's self-report.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -160,8 +186,18 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => {
// The teardown-order contract only earns its keep when the closing events are produced BY
// the dispose itself.
// The teardown-order contract only earns its keep when the closing events are
// produced BY the dispose itself. Here the model stream HANGS, so the turn is
// still open when teardown runs: the composite agent effect stops the loop,
// the loop unwinds and appends `turn/end {disposed}` + runs its final
// `session/flush` — all while the store-owned publication hooks are still attached (the session
// detach is the LAST disposer in the same effect's LIFO chain) — and only
// THEN is the session detached. If the order were inverted (or the session
// were a racing SIBLING effect), the abort-produced `turn/end` would never
// reach disk and a re-load would instead show crash-recovery's synthetic
// `interrupted` closer. Re-load from disk and assert the REAL `disposed`
// reason landed — proving the loop's own closing event was captured, not a
// recovered substitute.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -187,8 +223,11 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('per-session AgentHandle dispose leaves sibling agents untouched', async () => {
// The factory returns a per-agent AgentHandle whose dispose() tears down EXACTLY that agent
// + its session — RFC 011 isolation.
// The factory returns a per-agent AgentHandle whose dispose() tears down
// EXACTLY that agent + its session — RFC 011 isolation. Create two agents
// directly through the registry factory (the same path the ACP bridge uses),
// dispose one handle, and assert the other survives, registered and
// queryable, with its session still in the store.
const harness = await makeBridgeHarness({ storageDir, script: [] })
const handleA = await harness.ctx.agents.create({
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
@@ -212,8 +251,14 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => {
// The AgentHandle teardown folds session-detach, register, and loop-stop into one composite
// effect whose disposers run as a `.then()` chain.
// The AgentHandle teardown folds session-detach, register, and loop-stop
// into ONE composite effect whose disposers run as a `.then()` chain. The
// register disposer emits `agent/disposed`; if a listener throws and the
// emit is UNCONTAINED, the rejected chain skips the LATER session-detach
// disposer — stranding the session in the store with its publication hooks attached (a
// leak AND a durability hole, since the new design relies on detach
// running). The emit must be contained. Register a throwing listener, drive
// a clean turn, dispose, and assert the session was STILL removed.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
const handle = await harness.ctx.agents.create({
@@ -231,10 +276,11 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => {
// The handle's dispose() must memoize: the underlying cordis effect disposer is
// single-shot, so a second dispose() while the first is mid-teardown would otherwise
// resolve IMMEDIATELY (effect epoch already cleared) — before the first call's await
// agent.done + final flush finished.
// The handle's dispose() must memoize: the underlying cordis effect disposer
// is single-shot, so a second dispose() while the first is mid-teardown would
// otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the
// first call's await agent.done + final flush finished. Every caller must
// observe the same quiescence boundary.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
const handle = await harness.ctx.agents.create({
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },

View File

@@ -19,7 +19,9 @@ describe('acp bridge — demux & config edges', () => {
})
it('ignores events from an agent the bridge does not own (strict id demux)', async () => {
// A second agent created directly on the registry (not via the bridge) runs a turn.
// A second agent created directly on the registry (NOT via the bridge) runs
// a turn. Its session events must NOT produce ACP updates and
// must not settle anything — the bridge demuxes strictly by its own id.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })

View File

@@ -40,8 +40,9 @@ describe('acp bridge — turn outcomes', () => {
})
it('rejects the prompt RPC when a turn fails (no misleading end_turn)', async () => {
// ACP has no "error" stop reason; a failed turn must surface as a rejected session/prompt,
// not a normal end_turn that hides the failure from the client.
// ACP has no "error" stop reason; a failed turn must surface as a rejected
// session/prompt, not a normal end_turn that hides the failure from the
// client. The bridge rejects via the turn/end{error} log record.
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('provider boom')] })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
@@ -79,9 +80,12 @@ describe('acp bridge — turn outcomes', () => {
})
it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => {
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline stand-in, so this
// verifies the actual presentCall/presentResult the editor sees (docs/testing.md "prefer
// the real implementation over a mock").
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline
// stand-in, so this verifies the actual presentCall/presentResult the editor
// sees (docs/testing.md "prefer the real implementation over a mock").
// The mock MODEL still scripts the tool call (no real LLM needed), but the
// tool and executor are real: a real `echo` runs and its real output flows
// back through the bridge.
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -120,8 +124,11 @@ describe('acp bridge — turn outcomes', () => {
})
it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => {
// Drive the real bash tool, and advertise the Zed `_meta.terminal_output` capability in
// initialize.
// Drive the REAL bash tool, and advertise the Zed `_meta.terminal_output`
// capability in initialize. The bridge must then emit the terminal CARD: the
// description content block THEN a terminal content block + `_meta.terminal_info`
// (cwd header) on the call, and `_meta.terminal_output`/`terminal_exit` on the
// result — and OMIT the update's text content (it would clobber the card).
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -157,7 +164,11 @@ describe('acp bridge — turn outcomes', () => {
})
it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => {
// The session is created with the capability ON.
// The session is created with the capability ON. A SECOND initialize then
// turns it OFF at the connection level — but this session keeps its snapshot,
// so its bash call STILL renders as a terminal card (call + result agree).
// Without the snapshot, the result path would re-read the now-OFF capability
// and either clobber the card (content sent) or be inconsistent with the call.
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -181,9 +192,10 @@ describe('acp bridge — turn outcomes', () => {
})
it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => {
// A buggy tool whose presentCall throws must not fail the live turn — the bridge's
// presenter contains the throw (logging via its onError sink) and falls back to the generic
// title=name presentation.
// A buggy tool whose presentCall throws must not fail the live turn — the
// bridge's presenter contains the throw (logging via its onError sink) and
// falls back to the generic title=name presentation. Exercises the real
// bridge wiring of the per-session presenter's error sink.
harness = await makeBridgeHarness({
storageDir,
script: [toolCallResponse('c1', 'kaboom', { x: 1 }), textResponse('done')],
@@ -223,10 +235,9 @@ describe('acp bridge — turn outcomes', () => {
expect(failed).toHaveLength(1)
})
it('settles via the log fallback when a prior session/event listener throws (starvation)', async () => {
// A peer session/event listener that runs before the bridge's listener throws on turn/end
// (prepend: true puts it first). cordis emit stops at the throw, so the bridge's
// session/event listener never sees turn/end and cannot settle there.
it('settles successfully when an earlier turn/end observer throws', async () => {
// Session contains each post-commit observer failure, so a prepended peer
// cannot starve the bridge's live turn/end delivery.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
harness.ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/end') throw new Error('peer listener boom')
@@ -236,9 +247,7 @@ describe('acp bridge — turn outcomes', () => {
expect(res.stopReason).toBe('end_turn')
})
it('log fallback REJECTS when the starved turn ended in error', async () => {
// Same starvation as above, but the turn fails: the idle-fallback must
// reject the RPC from the logged turn/end{error}, not resolve.
it('still rejects a failed turn when an earlier turn/end observer throws', async () => {
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('starved boom')] })
harness.ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/end') throw new Error('peer listener boom')
@@ -248,21 +257,26 @@ describe('acp bridge — turn outcomes', () => {
.rejects.toThrow(/turn failed: starved boom/)
})
it('log fallback infers the owning turn when turn/START capture is starved', async () => {
// A peer listener throws on turn/START (not turn/end): the bridge never captures
// inflight.turn via the live stream.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('never runs')] })
it('captures and settles the owning turn when an earlier turn-start observer throws', async () => {
// Turn correlation still reaches the bridge after the throwing peer and
// captures inflight.turn via the live stream. A throwing turn/start listener
// Session contains post-commit callbacks independently.
// The model request and normal turn outcome therefore still occur.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
harness.ctx.on('session/event', (_s, event) => {
if (event.type === 'turn/start') throw new Error('peer listener boom on start')
}, { prepend: true })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/turn failed:/)
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(result.stopReason).toBe('end_turn')
})
it('a between-turn injection does not settle the prompt early (message-trigger correlation)', async () => {
// A plugin injects context (a one-shot injection-triggered turn) right after the prompt is
// queued but before the prompt's own message turn runs.
// A plugin injects context (a one-shot injection-triggered turn) right after
// the prompt is queued but before the prompt's own message turn runs. The
// bridge must NOT mistake the injection turn's turn/end for the prompt's —
// it correlates only to message-triggered turns. The prompt settles on its
// OWN turn with the real model answer.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(AgentId(sessionId))!
@@ -310,9 +324,11 @@ describe('acp bridge — turn outcomes', () => {
})
it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => {
// Over the async JSON-RPC transport the loop usually wakes before cancel arrives, so this
// is a running/mid-step cancel (the synchronous pre-step DROP is unit-tested in
// agent-loop/cancel.spec.ts).
// Over the async JSON-RPC transport the loop usually wakes before cancel
// arrives, so this is a running/mid-step cancel (the synchronous pre-step
// DROP is unit-tested in agent-loop/cancel.spec.ts). The ACP-level guarantee:
// the prompt settles cancelled, the agent reaches idle, and no second/leaked
// turn runs afterward.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] })
const sessionId = await newSession(harness)
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
@@ -329,9 +345,10 @@ describe('acp bridge — turn outcomes', () => {
})
it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => {
// The ACP bridge settles the cancel RPC synchronously and accepts the next prompt WITHOUT
// awaiting quiescence — so this drives cancel→prompt with NO whenIdle() between, the
// production race.
// The ACP bridge settles the cancel RPC synchronously and accepts the next
// prompt WITHOUT awaiting quiescence — so this drives cancel→prompt with NO
// whenIdle() between, the production race. An idle cancel must be a no-op that
// does NOT drop the following prompt.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
// Cancel while idle (no prompt in flight) — a no-op.
@@ -367,8 +384,10 @@ describe('acp bridge — turn outcomes', () => {
})
it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => {
// Regression: prompt A runs; cancel settles A and frees the slot; A's aborted turn/end is
// still pending in the loop.
// Regression: prompt A runs; cancel settles A and frees the slot; A's
// aborted turn/end is still pending in the loop. Prompt B is sent before
// A's turn/end arrives. A's late turn/end (an EARLIER turn number) must NOT
// settle B — B owns a later turn. B then completes on its OWN turn/end.
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] })
const sessionId = await newSession(harness)

View File

@@ -2,11 +2,11 @@
User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI.
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service shallow-freezes a detached request record before dispatch, preserving the exact `agent` and `AbortSignal` identities while making later caller mutation unable to redirect scope, payload, cancellation, or either audit event. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair. The one precondition: ask from inside an open turn the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything.
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its answerer phase always produces an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. `ApprovalRequest` is a readonly same-process contract: the service borrows the exact request, agent, session, and abort signal rather than cloning or freezing them. The request requires an open turn because the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask rejects before appending. Either audit append may reject before commit because returning an unlogged decision would violate the pair. Session contains post-commit observer failures, so an authoritative audit append cannot reject the request or suppress its matching event.
The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates.
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'``'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'``'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md).

View File

@@ -1,9 +1,35 @@
/**
* Approval seam: `ctx.approval` answers exactly one question — "may this specific action
* proceed?" — by dispatching the `approval/request` waterfall to whatever answerers the
* deployment composed (an ACP editor prompt, an auto-decide policy, a scripted test listener)
* and returning a closed {@link ApprovalOutcome}.
* Scope-filtered dispatch: keyed to `req.agent`.
* Approval seam: `ctx.approval` answers exactly one question — "may this
* specific action proceed?" — by dispatching the `approval/request` waterfall
* to whatever answerers the deployment composed (an ACP editor prompt, an
* auto-decide policy, a scripted test listener) and returning a closed
* {@link ApprovalOutcome}. With no answerer the waterfall falls through to the
* built-in default `'unavailable'`: absence of a UI can never grant anything.
*
* The service is the MECHANISM (dispatch, cancellation, audit); answerers are
* the POLICY. It serves both ask paths the sandbox RFC names — the
* `tools/pre-execute` `ask` decision and the sandbox post-denial escalation —
* so every asker shares one outcome
* vocabulary and one audit trail. Grants are one-shot by design: an
* `'allowed-once'` outcome authorizes the single action it was asked about,
* never a class of future actions.
*
* Every request lands two log-only session events on the requesting agent's
* log (`approval/asked` / `approval/decided`, paired by
* {@link ApprovalRequestId}) — an audit trail, deliberately NOT part of the
* model-visible transcript: the model only ever sees the tool result the
* caller derives from the outcome.
*
* The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching):
* `effective = fold(the session's 'approval/policy' events, last one wins)
* ?? config.policy` — the session log is the store, so an override survives
* restart by replay. The service resolves `'never'` sessions to
* `'rejected'` inside `request()` before dispatching any answerer (no
* registration order, including a later `prepend`, can precede it); a prompt section states `'never'`
* (and only `'never'` — an availability promise is unknowable without
* asking); an `agent/pre-step` narrator explains a switch to the model in at
* most one coalesced notice per step.
*
* @module @deepseek-ai/dsh-user-approval
*/
@@ -26,8 +52,19 @@ declare module 'cordis' {
interface Events {
/**
* Waterfall asking the composed answerers to decide one approval request.
*
* @param req - the accepted decision (agent, tool identity, reason, signal).
* Dispatched only from {@link ApprovalService.request} — callers go through
* the service (which owns cancellation and the audit events), never through
* `ctx.waterfall` directly. A listener that can answer for this request's
* agent returns an outcome WITHOUT calling `next()` (the decision slot is
* single-occupancy, first listener to answer wins); a listener that does
* not recognize the agent MUST call `next()` so another answerer — or the
* fail-closed default `'unavailable'` — gets the question. Throwing is
* contained by the service and yields `'unavailable'`.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a
* listener registered through `agent.ctx` receives only that agent's
* questions, while a plain-context listener receives every agent's.
* `req` is a readonly same-process value borrowed from the caller.
* @param req - the pending decision (agent, tool identity, reason, signal).
* @mode waterfall
*/
'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
@@ -184,12 +221,16 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean {
* THE write path for a session's approval-policy override: appends exactly
* one `approval/policy` event — the switch IS its event; nothing mutates
* policy state out of band. Takes effect on the session's next ask and next
* prompt assembly (the consumers fold on every read).
* prompt assembly (the consumers fold on every read). Rejects a value outside
* {@link APPROVAL_POLICIES} before appending anything.
* @param session - the session the override belongs to.
* @param policy - the policy every subsequent ask for this session resolves
* under (until the next switch).
*/
export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {
if (!APPROVAL_POLICIES.includes(policy)) {
throw new TypeError('approval policy must be one of "ask" or "never"')
}
session.append('approval/policy', { policy })
}
@@ -198,9 +239,9 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi
* for an answerer to present it and for the audit events to reconstruct what
* was asked — it deliberately does NOT carry tool arguments: a UI answerer
* attaches the prompt to the already-streamed tool call via `callId` instead
* of re-rendering the call. `request()` synchronously copies and shallow-freezes
* this record before crossing an asynchronous boundary. Scalar fields are
* detached; the `agent` and `signal` identity capabilities are preserved.
* of re-rendering the call. This is a readonly same-process contract:
* `request()` borrows the request and its `agent` and `signal` capabilities
* directly rather than treating them as serialized input.
*/
export interface ApprovalRequest {
/**
@@ -208,21 +249,21 @@ export interface ApprovalRequest {
* UI answerer only answers for agents it owns) and receives the audit
* events on its session log.
*/
agent: Agent
readonly agent: Agent
/** The tool the question is about (presentation and audit). */
toolName: string
readonly toolName: string
/**
* The exact tool call being decided, when the asker has one — lets a UI
* attach the prompt to the tool call it already streamed.
*/
callId?: CallId
readonly callId?: CallId
/** The asker's human-readable explanation of WHY it is asking. */
reason?: string
readonly reason?: string
/**
* Aborting withdraws the question: the request settles `'cancelled'`
* immediately and a late answer from a still-pending answerer is discarded.
*/
signal?: AbortSignal
readonly signal?: AbortSignal
}
/** Plugin config. All optional — `static Config` supplies the defaults. */
@@ -233,13 +274,22 @@ export interface Config {
* (fail-closed with none); `'never'` auto-rejects every ask without
* prompting (the deterministic CI/unattended stance).
*/
policy?: ApprovalPolicy
readonly policy?: ApprovalPolicy
}
/**
* The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the `approval/request`
* waterfall and audits every ask/outcome pair to the requesting agent's session log. Stateless
* between requests — grants are returned to the caller, never stored here.
* The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the
* `approval/request` waterfall and audits every ask/outcome pair to the
* requesting agent's session log. Stateless between requests — grants are
* returned to the caller, never stored here.
*
* Owns the policy tier too (`effective = fold(the session's 'approval/policy'
* events) ?? config.policy`): `request()` resolves `'never'` to `'rejected'`
* before dispatching any interactive answerer, a per-agent prompt section
* states a `'never'` policy (and only that one in prose — an `'ask'` promise
* could overclaim an answerer that headless compositions do not have), and an
* `agent/pre-step` narrator injects at most one coalesced notice when a
* session's effective policy moved past what the model was last told.
*/
export class ApprovalService extends Service {
static Config: z<Config> = z.object({
@@ -249,12 +299,14 @@ export class ApprovalService extends Service {
constructor(ctx: Context, public config: Config) {
super(ctx, 'approval')
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent)
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
// Visibility layer 1, scoped on the prompt registry so headless compositions mount the seam
// without it: state the one deterministic policy per session. 'ask' renders only a
// source-owned state marker — stating "you will be asked" would overclaim in a composition
// with no answerer.
// Visibility layer 1, scoped on the prompt registry so headless
// compositions mount the seam without it: state the one deterministic
// policy per session. 'ask' renders only a source-owned state marker —
// stating "you will be asked" would overclaim in a composition with no
// answerer. The marker, not deployment-controlled prose, is what the
// restart narrator reads back from the logged request header.
ctx.inject(['systemPrompt'], (scope: Context) => {
scope.systemPrompt.section({
name: 'approval:policy',
@@ -269,10 +321,16 @@ export class ApprovalService extends Service {
})
})
// Visibility layer 2: the boundary narrator. pre-step runs after prompt assembly but before
// the request history is derived, so the notice is seen by this step's request: idle-time
// flip-flops coalesce at the turn's first step (net-zero → nothing), and a mid-turn switch
// is narrated no later than the next step.
// Visibility layer 2: the boundary narrator. pre-step runs after prompt
// assembly but before the request history is derived, so the notice is
// seen by THIS step's request: idle-time flip-flops coalesce at the
// turn's first step (net-zero → nothing), and a mid-turn switch is
// narrated no later than the next step. What each session was last told
// is in-memory with a log-derived fallback (the folded header's system
// text), so restarts lose nothing. Attribution is positional: an
// override event after the log's last `request/header*` was a runtime
// switch by the user; otherwise the configured default moved under the
// session (operator/config).
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
ctx.on('agent/pre-step', (agent) => {
const session = agent.session
@@ -289,7 +347,7 @@ export class ApprovalService extends Service {
}
// Same fold effectivePolicy performs — override is scanned here anyway
// for POSITIONAL attribution; the default lives once, in the method.
const current = this.effectivePolicy(agent)
const current = this.effectivePolicy(session)
const header = session.requestHeader()
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
narrated.set(session, current)
@@ -305,26 +363,25 @@ export class ApprovalService extends Service {
}
/**
* Ask the composed answerers to decide one request.
*
* Ask the composed answerers to decide one readonly same-process request.
* The service borrows the request, agent, session, and live signal directly.
* The request requires an open turn because the audit pair must be enclosed
* by the durable log's commit/replay boundary; an idle ask rejects before
* appending anything. The answerer phase always produces an outcome: an
* aborted signal yields `'cancelled'`, a missing or throwing answerer yields
* `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
* normalized to `'unavailable'`. A failure that prevents either audit append
* from committing still rejects because returning an unlogged decision would
* violate the pair. Session contains post-commit observer failures, so an
* authoritative append cannot reject the request or suppress its matching
* audit event.
* @param req - the pending decision (agent, tool identity, reason, signal).
* @returns the closed outcome; `'allowed-once'` is the only grant.
* @throws when no turn is open or either audit event fails before the session
* append commit point.
*/
async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
// Accept one immutable request shape before the first async boundary.
const agent = req.agent
const toolName = req.toolName
const callId = req.callId
const reason = req.reason
const signal = req.signal
const accepted: Readonly<ApprovalRequest> = Object.freeze({
agent,
toolName,
...callId !== undefined ? { callId } : {},
...reason !== undefined ? { reason } : {},
...signal !== undefined ? { signal } : {},
})
const session = accepted.agent.session
const session = req.agent.session
if (!hasOpenTurn(session.events)) {
throw new Error(
'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
@@ -333,61 +390,43 @@ export class ApprovalService extends Service {
)
}
const id = ApprovalRequestId(randomUUID())
this.appendAudit(session, 'approval/asked', id, () => {
session.append('approval/asked', {
id,
toolName: accepted.toolName,
...accepted.callId !== undefined ? { callId: accepted.callId } : {},
...accepted.reason !== undefined ? { reason: accepted.reason } : {},
})
})
const outcome = await this.decide(accepted)
this.appendAudit(session, 'approval/decided', id, () => {
session.append('approval/decided', { id, outcome })
session.append('approval/asked', {
id,
toolName: req.toolName,
...req.callId !== undefined ? { callId: req.callId } : {},
...req.reason !== undefined ? { reason: req.reason } : {},
})
const outcome = await this.decide(req, session)
session.append('approval/decided', { id, outcome })
return outcome
}
/**
* Append one audit event while distinguishing a post-append observer throw from a failure
* that prevented the event entering the log.
*
* @param session - the captured session receiving both audit events.
* @param type - the audit event currently being appended.
* @param id - the request id, used to identify the contained failure.
* @param append - the single concrete `Session.append` call.
*/
private appendAudit(
session: Session,
type: 'approval/asked' | 'approval/decided',
id: ApprovalRequestId,
append: () => void,
): void {
const length = session.events.length
try {
append()
} catch (error) {
if (session.events.length === length) throw error
this.ctx.logger.warn(`approval request "${id}": ${type} observer threw after the event was appended`)
}
}
/**
* The session's effective policy: its own `approval/policy` fold, else the
* configured default (the schema already defaulted an omitted policy to
* `'ask'`; the `??` only narrows the optional-input TYPE).
* @param agent - the agent whose session's policy applies.
* @returns the policy every ask for this agent resolves under right now.
* @param session - the exact accepted session whose policy applies.
* @returns the policy every ask for this session resolves under right now.
*/
private effectivePolicy(agent: Agent): ApprovalPolicy {
return effectiveApprovalPolicy(agent.session.events) ?? this.config.policy ?? 'ask'
private effectivePolicy(session: Session): ApprovalPolicy {
return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask'
}
/** Dispatch the waterfall, contained and raced against the accepted signal. */
private async decide(req: Readonly<ApprovalRequest>): Promise<ApprovalOutcome> {
if (req.signal?.aborted) return 'cancelled'
// Enforce never before dispatch so listener order cannot bypass it.
if (this.effectivePolicy(req.agent) === 'never') return 'rejected'
/**
* Dispatch the waterfall, contained and raced against the request signal.
* @param req - the borrowed public request.
* @param session - the request agent's session used for policy lookup.
* @returns the normalized closed outcome.
*/
private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
const signal = req.signal
if (signal?.aborted) return 'cancelled'
// The 'never' policy is decided HERE, before any dispatch: a listener
// registered with `prepend: true` after this service mounts would sit
// ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
// documented promise that 'never' rejects deterministically regardless
// of registration order — only the service's own request path can.
if (this.effectivePolicy(session) === 'never') return 'rejected'
// Enter the promise chain BEFORE dispatching: a listener that throws
// SYNCHRONOUSLY (before its first await) must land in the same rejection
// path as an async one — `Promise.resolve(call())` would let it escape
@@ -405,10 +444,12 @@ export class ApprovalService extends Service {
// tool call open — the seam contains its callbacks.
() => 'unavailable',
)
const signal = req.signal
if (signal === undefined) return answer
return await new Promise<ApprovalOutcome>((resolve) => {
const onAbort = () => { resolve('cancelled') }
const onAbort = () => {
signal.removeEventListener('abort', onAbort)
resolve('cancelled')
}
signal.addEventListener('abort', onAbort, { once: true })
void answer.then((outcome) => {
signal.removeEventListener('abort', onAbort)

View File

@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, scopeHost } from '@deepseek-ai/dsh-scope'
import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -78,77 +79,38 @@ describe('ApprovalService.request', () => {
expect(Object.keys(appended[0]?.data ?? {}).sort()).toEqual(['id', 'toolName'])
})
it('snapshots request identity, scope, payload, and audit before deferred dispatch', async () => {
it('borrows the exact readonly request for scoped dispatch and audit', async () => {
const ctx = await mounted()
const { agent: acceptedAgent, appended: acceptedAudit } = fakeAgent()
const { agent: replacementAgent, appended: replacementAudit } = fakeAgent()
const host = await scopeHost(ctx, ['approval'])
const acceptedScope = host.mint(acceptedAgent)
const replacementScope = host.mint(replacementAgent)
const dispatchStarted = Promise.withResolvers<'started'>()
const answer = Promise.withResolvers<ApprovalOutcome>()
const originalSignal = new AbortController().signal
const replacementSignal = new AbortController().signal
let heardBy: 'accepted' | 'replacement' | undefined
const { agent, appended } = fakeAgent()
let scope!: Scope
const scopeFiber = await ctx.plugin(Object.assign((inner: Context) => {
scope = createScope(inner, agent)
}, { inject: ['approval'] }))
let received: ApprovalRequest | undefined
let carrier: unknown
acceptedScope.ctx.on('approval/request', function (req) {
heardBy = 'accepted'
scope.ctx.on('approval/request', function (req) {
received = req
carrier = carrierKeyOf(this)
dispatchStarted.resolve('started')
return answer.promise
return Promise.resolve<ApprovalOutcome>('allowed-once')
})
replacementScope.ctx.on('approval/request', function (req) {
heardBy = 'replacement'
received = req
carrier = carrierKeyOf(this)
dispatchStarted.resolve('started')
return answer.promise
})
const request = requestOf(acceptedAgent, {
toolName: 'original-tool',
callId: CallId('original-call'),
reason: 'original reason',
signal: originalSignal,
const request = requestOf(agent, {
toolName: 'scoped-tool',
callId: CallId('scoped-call'),
reason: 'scoped reason',
})
const pending = ctx.approval.request(request)
// request() has returned, but the answerer dispatch is deliberately queued
// in a microtask. Mutating the caller-owned record must not redirect it.
request.agent = replacementAgent
request.toolName = 'mutated-before-dispatch'
request.callId = CallId('mutated-call')
request.reason = 'mutated reason'
request.signal = replacementSignal
await dispatchStarted.promise
// Mutation while the answer is pending must not redirect the final audit.
request.toolName = 'mutated-after-dispatch'
request.reason = 'mutated again'
answer.resolve('allowed-once')
await expect(pending).resolves.toBe('allowed-once')
expect(heardBy).toBe('accepted')
expect(carrier).toBe(acceptedAgent)
expect(received).not.toBe(request)
expect(Object.isFrozen(received)).toBe(true)
expect(received).toMatchObject({
agent: acceptedAgent,
toolName: 'original-tool',
callId: 'original-call',
reason: 'original reason',
signal: originalSignal,
await expect(ctx.approval.request(request)).resolves.toBe('allowed-once')
expect(carrier).toBe(agent)
expect(received).toBe(request)
expect(appended).toHaveLength(2)
expect(appended[0]?.data).toMatchObject({
toolName: 'scoped-tool',
callId: 'scoped-call',
reason: 'scoped reason',
})
expect(acceptedAudit).toHaveLength(2)
expect(acceptedAudit[0]?.data).toMatchObject({
toolName: 'original-tool',
callId: 'original-call',
reason: 'original reason',
})
expect(acceptedAudit[1]?.data).toMatchObject({ outcome: 'allowed-once' })
expect(acceptedAudit[1]?.data['id']).toBe(acceptedAudit[0]?.data['id'])
expect(replacementAudit).toEqual([])
await host.dispose()
expect(appended[1]?.data).toMatchObject({ outcome: 'allowed-once' })
expect(appended[1]?.data['id']).toBe(appended[0]?.data['id'])
await scopeFiber.dispose()
})
it('contains an approval/asked observer throw after append and still completes the pair', async () => {
@@ -171,7 +133,7 @@ describe('ApprovalService.request', () => {
const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
expect(decided?.data.id).toBe(asked?.data.id)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/asked observer threw'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after asked append'))
})
it('contains an approval/decided observer throw after append and still resolves', async () => {
@@ -194,10 +156,10 @@ describe('ApprovalService.request', () => {
const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/decided observer threw'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after decided append'))
})
it('does not misclassify a pre-append failure as an observer failure', async () => {
it('propagates an append failure that prevented audit log growth', async () => {
const ctx = await mounted()
const failure = new Error('append failed before log growth')
const agent = {
@@ -236,9 +198,12 @@ describe('ApprovalService.request', () => {
const ctx = await mounted()
const { agent: agentA } = fakeAgent()
const { agent: agentB } = fakeAgent()
const host = await scopeHost(ctx, ['approval'])
const scopeA = host.mint(agentA)
const scopeB = host.mint(agentB)
let scopeA!: Scope
let scopeB!: Scope
const scopesFiber = await ctx.plugin(Object.assign((inner: Context) => {
scopeA = createScope(inner, agentA)
scopeB = createScope(inner, agentB)
}, { inject: ['approval'] }))
const heard: string[] = []
ctx.on('approval/request', (req, next) => {
heard.push(req.agent === agentA ? 'global:A' : 'global:B')
@@ -257,14 +222,16 @@ describe('ApprovalService.request', () => {
await expect(ctx.approval.request(requestOf(agentB))).resolves.toBe('unavailable')
expect(heard).toEqual(['global:A', 'scoped:A', 'global:B', 'scoped:B'])
await host.dispose()
await scopesFiber.dispose()
})
it('keys the scoped dispatch carrier to the exact request agent', async () => {
const ctx = await mounted()
const { agent } = fakeAgent()
const host = await scopeHost(ctx, ['approval'])
const scope = host.mint(agent)
let scope!: Scope
const scopeFiber = await ctx.plugin(Object.assign((inner: Context) => {
scope = createScope(inner, agent)
}, { inject: ['approval'] }))
let seenKey: object | undefined
scope.ctx.on('approval/request', function (req, next) {
seenKey = carrierKeyOf(this)
@@ -275,7 +242,7 @@ describe('ApprovalService.request', () => {
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
expect(seenKey).toBe(agent)
await host.dispose()
await scopeFiber.dispose()
})
it('contains a throwing answerer as unavailable', async () => {
@@ -420,6 +387,15 @@ describe('approval policy (the approval/policy fold)', () => {
expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } })
})
it('rejects a policy outside the closed vocabulary before appending', () => {
const append = vi.fn()
const session = { append } as unknown as Session
expect(() => { setApprovalPolicy(session, 'sometimes' as Parameters<typeof setApprovalPolicy>[1]) })
.toThrow('approval policy must be one of "ask" or "never"')
expect(append).not.toHaveBeenCalled()
})
it('defaults a schema-less construction to ask (the ?? narrows the optional TYPE)', async () => {
// Direct construction bypasses the plugin schema (the SystemPrompt-test
// precedent for covering a defaulted Config field's type-narrowing ??).

View File

@@ -1,53 +1,80 @@
# @deepseek-ai/dsh-workflow-workerthread
The [`WorkflowService`](../workflow/README.md) implementation, on **`node:worker_threads`**: each run gets its OWN worker thread (one run = one worker, no pooling — a run is heavyweight, so the ~tens-of-ms thread spin-up is noise), the script executes in a vm context INSIDE that worker with the workflow hooks injected, and every `agent()` call bridges back over the message port to [`ctx.subagents`](../../subagent/README.md) on the host. Child agents are I/O-bound LLM loops and stay on the host event loop; the thread isolates the SCRIPT, the only part that can spin synchronously.
This package implements `WorkflowService` with one Node worker thread per run. The worker executes the orchestration script; child agents remain on the host and are reached through `ctx.subagents` over a typed host/worker protocol.
## Trust premise: what the thread buys (and what it does not)
The split has one primary purpose: a synchronous script loop cannot block the harness event loop, and a script that ignores cancellation can be terminated with its worker. It is not a security sandbox.
Workflow scripts are **model-written** — the same trust level as the model's existing bash access — so this engine defends against **buggy** scripts, never hostile ones. A worker thread is NOT a security boundary: the vm context inside it is escapable by construction (`node:vm` shares object machinery with its surrounding realm, so a script can reach the `Function` constructor via `globalThis.constructor.constructor` and from it `process` and every Node builtin), and an escapee holds the same process privileges as the host — Node's permission model is process-wide. The absent globals are API surface that keeps honest scripts portable, not walls. What the thread concretely buys:
## Trust and isolation boundary
- **The host never blocks**: `start()` returns without running any script code on the host; a synchronous spin anywhere in the script occupies the worker's loop, not the harness's.
- **Termination is real**: a script that outlives its post-cancel grace is `worker.terminate()`d — nothing of it survives `dispose()`, where an in-process engine could only abandon the spin on its own loop.
- **No ambient credentials**: the worker spawns with an EMPTY environment (`env: {}` plus hermetic `execArgv`, the same stance as `dsh-code-runtime-worker`; the unbuilt dev shape forwards exactly one loader variable, `TSX_TSCONFIG_PATH` — path plumbing, not a secret), so an escapee reading `process.env` finds no harness secrets — ambient-channel hardening only; the process-wide privileges above (fs and the rest) remain, so a genuine sandbox is still the engine swap.
- **Serialization by construction**: everything crossing the thread is structured-clone data, and plain JSON before that — the `materializeFromRealm` walk rejects loud what JSON cannot carry, which is also what makes every postMessage hop total.
Workflow scripts are model-written and have the same trust premise as the model's existing bash access. `node:vm` inside a worker is an API-shaping mechanism, not a security boundary: an escaped script can recover Node capabilities with the host process's privileges.
What the seam guarantees regardless, because benign scripts hit these constantly: `result` never rejects, a dropped hook promise never becomes an unhandled rejection, values JSON cannot carry are rejected **loud** instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item `null`. Genuine sandboxing (containing what an escaped script may touch) remains an isolated-vm/separate-process engine swap behind the seam, still deferred.
The worker still provides useful containment:
## The script contract it executes
- Script CPU work and synchronous spins stay off the host event loop.
- `worker.terminate()` gives disposal a real final stop.
- The worker starts with an empty environment, except unbuilt loader plumbing, so ambient credentials do not cross through `process.env`.
- Host/worker messages use structured-clone data, with plain-JSON validation at the script boundary.
- **Meta as DATA** (`validateMeta`, host-side): the workflow's identity arrives on the start request as plain JSON (the tool carries it as its schema-validated `meta` parameter — never as script text) and is shape-validated loud, every violation named (`name`/`description` required; unknown fields rejected). The engine deliberately evaluates NO script text to obtain meta: an evaluated meta literal could smuggle getters that run on the host outside any vm timeout — the exact spin the worker thread exists to isolate. A body that still opens with a Claude Code-style `export const meta` statement is rejected with a pointed `SCRIPT_PARSE` message.
- **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline).
- **No ambient APIs**: no timers, filesystem, or Node APIs are injected into the context (absence is API surface, not containment — see the trust premise).
A genuinely untrusted-script sandbox would require a different engine behind the same workflow seam.
## How a run executes
## Script contract
`start()` shape-validates the meta DATA host-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `META_INVALID`/`SCRIPT_PARSE` throws; one redundant parse per run is the deliberate price. It then spawns the worker (`src/worker.ts` unbuilt via an explicit tsx `execArgv`; the sibling `lib/worker.js` bundle when built) with the meta, body, `args`, and worker-side limits as `workerData`.
The workflow's `meta` is host-provided data, not evaluated script text. The engine validates its required `name` and `description`, rejects unknown fields, and parse-checks the body before returning a run.
Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child on `ctx.subagents` with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through.
Inside the worker, the script receives `args` and these hooks:
The host observes `run.result` immediately but buffers its snapshotted wire projection until `run.started` fulfills. It then replies `child-started` with the child id before forwarding settlement, so `workflow/agent-start` always names a ready child and precedes its end. A readiness rejection replies `child-start-error`, emits no workflow agent pair, and makes the host dispose the attempt because the worker never received a handle; the worker classifies it as fatal `AGENT_START` unless cancellation already owns the run. If readiness fulfills, an infrastructure result rejection crosses as `child-failed`/`AGENT_RESULT` regardless of whether that rejection settled before or after readiness. Child disposal acknowledgements complete the RPC.
- `agent(prompt, { label, phase, schema, model })` starts one host-side subagent. With a schema it returns the structured value; otherwise it returns final text. An ordinary failed child yields `null`.
- `parallel(thunks)` runs thunks under the configured concurrency limit.
- `pipeline(items, ...stages)` passes `(previous, item, index)` without a cross-stage barrier.
- `phase(title)` and `log(message)` emit observer narration.
Observer narration (`phase`/`log`/`agent-start`/`agent-end`) crosses as messages and re-emits as the seam's `workflow/*` events. A **ready→go handshake** gates the body: a cancellation racing worker boot arrives before `go`, so a run cancelled before start never executes the body at all.
Unknown options, malformed arguments, unsupported schemas, tripped caps, provider-start failures, and infrastructure result failures are fatal workflow errors. No timers, filesystem API, or Node globals are intentionally injected, though the trust caveat above still applies.
## The value boundary
## Run sequence
Values LEAVING the script (hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into plain containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud; both run in the WORKER, never on the host. Values ENTERING the realm (`args`, `agent()` results, hook promises and their failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` is cloned once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is built OUTSIDE the script's vm context, so `e instanceof Error` inside the script is `false` — branch on `e.name`/`e.code` instead (the combinators recognize fatality by `instanceof` against their own realm's class, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved).
`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice.
## Cancellation, death, disposal
For each `agent()` call:
Per-run limits: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` posts the cancel to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels** — the shared request signal aborts AND each registered child's explicit `cancel()` is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). The grace then arms: a run still unsettled `disposeGraceMs` later force-settles `cancelled` and the worker is **terminated**. A cancellation that lands before the body runs (the ready→go handshake) reports `cancelled` without executing anything; a worker `result` racing an in-flight host cancellation reports `cancelled` too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel `phase`/`log` narration is suppressed host-side, while cancelled children still deliver their paired `agent-end`.
1. The worker sends `child-start` with a plain-data prompt and options.
2. The host calls the configured provider through async `SubagentService.start`, passing the workflow's parent and one canonical per-run abort signal.
3. If start rejects, the host sends `child-start-error`; provider startup has already reached quiescence and no child lifecycle event is emitted.
4. If start fulfills while the workflow still admits work, the host records the run, observes `result`, then sends `child-started`. Even an already-settled result is forwarded afterward, preserving start-before-result order.
5. The worker emits paired `workflow/agent-start` and `workflow/agent-end` narration and requests child disposal after collection.
A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + immediate host-driven disposal of every registered child (a wedged worker can relay no dispose RPC, so child teardown overlaps the grace instead of starting after it; the worker's own dispose RPCs join the same per-child disposal) + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Once a run settles, stray children a script fired without awaiting are cancelled too, and `dispose()` waits for their disposal (bounded by the grace) before returning. `agent-start`/`agent-end` pairing is host-guaranteed the same way: forwarded starts live in a ledger, worker-reported ends pair them on the graceful paths, and the termination paths (grace force-settle, worker death) synthesize the missing ends (outcome `cancelled`) before the run settles — a start still in flight across the force-settle can surface after `workflow/end`, immediately paired the same way.
Provider starts are tracked separately from published children. If cancellation, worker death, or normal workflow settlement closes admission while a start is pending, the shared signal aborts it. A provider that nevertheless fulfills after closure is disposed by the host and never announced to the worker.
**Engine-specific limitations**: worker startup is paid per run; on a termination path `agentsStarted` reports the HOST-observed count (accepted `child-start`s — calls still queued worker-side for a concurrency slot are unknowable then); and a returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — with the value-boundary guard applying to the resolution.
## Value boundary
Values leaving the script pass through `materializeFromRealm`, which accepts plain, lossless JSON data and rejects exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, and nested `undefined`. The walk runs in the worker, and defines object keys as data properties so `__proto__` cannot mutate a prototype.
Child results are projected and snapshotted before crossing from the host to the worker. This is a real process-like serialization boundary; it is deliberately different from trusted same-process workflow and subagent event payloads, which are borrowed immutable values.
## Cancellation and disposal
`WorkflowRun.cancel()` records the first reason, tells the worker to cancel, aborts the one signal shared by every pending and published child, and arms the `disposeGraceMs` timer. Worker hooks then throw `CANCELLED` at their next await. If the run remains unsettled at the deadline, the host resolves it as cancelled, pairs stranded child lifecycle events, and terminates the worker.
The subagent seam has one cancellation channel: the request signal. There is no separate child-cancel RPC. Published child teardown uses `run.dispose()`; pending provider starts remain provider-owned until their promise rejects or fulfills.
Normal settlement also aborts pending starts and begins disposing any published fire-and-forget children before the result becomes externally settled. The host's quiescence condition includes both pending starts and published child disposals, so cleanup does not forget an async startup transaction.
`dispose()` is idempotent. It cancels the run, starts host-driven disposal immediately, waits for result plus child quiescence up to the same grace, terminates the worker unconditionally, and performs a final survivor sweep. Per-child disposal is memoized so worker RPC, host cancellation, death cleanup, and public disposal all join one operation.
## Outcome and event guarantees
Terminal outcome is first-wins at host claim points. An accepted external cancellation overrides a later non-cancelled worker result; a result or worker death that claims first cannot be rewritten by reentrant cleanup callbacks.
Worker error, message failure, or premature exit closes message admission before cleanup, then resolves `error` unless cancellation already owns the run. Late queued messages cannot create children or narrate after that logical boundary.
The host keeps a ledger of forwarded child starts. A graceful worker supplies their ends; death or force termination synthesizes any missing end as cancelled. Every forwarded `workflow/agent-start` is therefore paired exactly once, although cleanup after an already-arrived workflow result may complete afterward.
## Config
| Key | Default | Meaning |
|---|---|---|
| `provider` | `spawn` | The `ctx.subagents` provider children run on (host-side). |
| `maxConcurrentAgents` | `0` (auto) | Concurrent `agent()` ceiling; `0` resolves to `min(16, max(1, cores - 2))`. |
| `maxTotalAgents` | `1000` | Total `agent()` calls one run may start (runaway-loop backstop). |
| `maxItemsPerCall` | `4096` | Items accepted by one `parallel()`/`pipeline()` call. |
| `syncTimeoutMs` | `5000` | vm timeout for the script's initial synchronous slice (in the worker). |
| `disposeGraceMs` | `5000` | How long a cancelled run may stay unsettled before force-settle + terminate; also bounds `dispose()`. |
| `provider` | `spawn` | Host-side subagent provider used by `agent()`. |
| `maxConcurrentAgents` | `0` | Concurrent `agent()` ceiling; `0` resolves from available CPU parallelism. |
| `maxTotalAgents` | `1000` | Total `agent()` calls in one run. |
| `maxItemsPerCall` | `4096` | Items accepted by one `parallel()` or `pipeline()` call. |
| `syncTimeoutMs` | `5000` | VM timeout for the script's initial synchronous slice. |
| `disposeGraceMs` | `5000` | Bound before force-settlement/termination and for public disposal. |

View File

@@ -30,6 +30,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-workflow": "^0.0.1",

View File

@@ -1,16 +1,53 @@
/**
* The host half of one worker-engine run: spawn the Worker, bridge its child RPC onto
* `ctx.subagents`, fan its observer messages into the engine's events, and own cancellation,
* the settle-within-grace guarantee, and child cleanup.
* The host half of one worker-engine run: spawn the Worker, bridge its child
* RPC onto the holder-bound subagent service, fan its observer messages into
* the engine's events, and own cancellation, the settle-within-grace
* guarantee, and child cleanup. The worker's lifetime IS the run's lifetime:
* `dispose()` always ends with `worker.terminate()`, so no thread outlives its
* run.
*
* The run's `result` promise settles exactly once, from whichever of these
* lands first: receipt of the worker's `result` message, an unexpected worker
* death (`error`/`messageerror`/premature `exit` → `stopReason: 'error'`, or
* `'cancelled'` when a cancel was in flight), or the post-cancel grace timer (a
* script that never settles is force-settled `cancelled` and its worker
* terminated — the real kill an in-process engine could not perform). At
* `result` receipt the host snapshots whether caller/signal/dispose
* cancellation is already in flight: an earlier cancellation overrides a
* non-cancelled report; otherwise the report wins before settlement-only child
* cleanup invokes arbitrary provider callbacks. Worker death uses the same
* boundary: it claims `error` (or a previously requested `cancelled`) before
* reaping children, so cleanup callbacks cannot rewrite the outcome. That
* first signal also closes inbound message admission: Node may emit `error`,
* then deliver queued messages, then emit `exit`, but those late messages may
* neither create work nor narrate after settlement. If Result or grace already
* owns the outcome, death preserves it while still cleaning resources; the
* eventual exit performs a final disposal-only sweep without repeating child
* cancellation.
*
* Provider starts and published children are tracked separately. Every start
* receives one shared per-run abort signal; the provider owns partial setup
* until its promise fulfills. If admission closes while a start is pending,
* the signal aborts it; a late fulfillment is disposed without publication to
* the worker. Ready runs enter a callId registry whose memoized disposal is
* shared by graceful worker RPC, public disposal, normal-settlement reap, and
* worker-death cleanup. Quiescence requires both pending starts and published
* children to drain. Lifecycle pairing is host-guaranteed independently:
* every forwarded `agent-start` enters a ledger, and a dead or terminated
* worker's missing `agent-end` is synthesized exactly once as cancelled. On a
* termination path `agentsStarted` reports the host-observed child-start count;
* calls still queued worker-side for a concurrency slot are unknowable.
*
* @module @deepseek-ai/dsh-workflow-workerthread/host
*/
import { fileURLToPath } from 'node:url'
import { Worker } from 'node:worker_threads'
import type { WorkerOptions } from 'node:worker_threads'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentRun } from '@deepseek-ai/dsh-subagent'
import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import { renderThrown } from './realm.ts'
@@ -19,9 +56,36 @@ import { HostToWorkerType, WorkerToHostType } from './protocol.ts'
import type { HostToWorkerPayloads, WorkerToHostMessage } from './protocol.ts'
import type { ChildResult, ChildStartRequest, WorkerInit } from './types.ts'
/** One published child and its shared quiescent-disposal transaction. */
interface ChildRecord {
readonly run: SubagentRun
disposal?: Promise<void>
}
/**
* Resolve the worker entry and spawn options for the current runtime shape.
* Unbuilt (tsx demos, vitest — `import.meta.url` points into `src/`), the
* entry is a JavaScript data-URL bootstrap. That bootstrap runs INSIDE the
* user worker, registers tsx's ESM AND CommonJS transforms there, and only
* then imports the TypeScript sibling. The whole mixed-module source graph
* therefore receives TypeScript transformation and the tsconfig paths map in
* the worker's own module-loader realm. A worker inherits no
* transform pipeline from vitest (vite transforms in-process), and a parent
* `--import tsx` registration is not a contract that user workers share on
* every supported Node line. Built (`lib/index.js`), the entry is the sibling
* bundle the package tsdown config emits and no loader is needed (`execArgv`
* pinned empty in both shapes — hermetic, like the environment).
*
* Both shapes spawn with an EMPTY environment (`env: {}`): the documented vm
* escape reaches `process`, and the harness's ambient credentials
* (`DEEPSEEK_API_KEY` et al.) must not ride along — the same stance as
* `dsh-code-runtime-worker`, stronger than the scrubbed env the
* defensive-patterns rule requires for spawned commands (a shell needs PATH;
* this worker needs nothing). Sole exception: the unbuilt shape forwards
* `TSX_TSCONFIG_PATH` when the parent carries it (loader plumbing the paths
* map depends on outside the repo cwd, not a secret). This closes the
* AMBIENT channel only — an escapee still holds process-wide privileges
* like fs access (the README's trust premise stands).
* @param init - the run payload, passed as `workerData`.
* @returns the entry URL and the Worker options to spawn it with.
*/
@@ -30,14 +94,31 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti
if (!import.meta.url.endsWith('.ts')) {
return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init, env: {}, execArgv: [] } }
}
// Lazy tsx resolution: only the unbuilt shape needs it, so the built bundle never requires
// tsx to be installed.
// Resolve tsx lazily: only the unbuilt shape executes this arm, so a built
// consumer never needs the dev-only loader installed. A JavaScript entry is
// essential — it can install tsx's ESM and CommonJS hooks from INSIDE the
// user worker before any TypeScript enters Node's native strip-only parser.
// Both hooks are load-bearing because the source graph crosses both module
// shapes on supported Node lines. TSX_TSCONFIG_PATH is
// the one variable forwarded through the scrub: a parent running outside
// the repo cwd (the ACP snapshot harness is the real case) pins the paths
// map through it. Loader plumbing, not a secret.
const workerEntry = new URL('./worker.ts', import.meta.url)
const tsxEsmApiEntry = import.meta.resolve('tsx/esm/api')
const tsxCjsApiEntry = import.meta.resolve('tsx/cjs/api')
const bootstrap = [
`import { register as registerEsm } from ${JSON.stringify(tsxEsmApiEntry)}`,
`import { register as registerCjs } from ${JSON.stringify(tsxCjsApiEntry)}`,
'registerCjs()',
'registerEsm()',
`await import(${JSON.stringify(workerEntry.href)})`,
].join('\n')
return {
entry: new URL('./worker.ts', import.meta.url),
entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`),
options: {
workerData: init,
env: process.env.TSX_TSCONFIG_PATH === undefined ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH },
execArgv: ['--import', fileURLToPath(import.meta.resolve('tsx'))],
execArgv: [],
},
}
}
@@ -45,15 +126,21 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti
/**
* One live worker-engine run — the seam's {@link WorkflowRun}, returned by
* `start()` directly. Owns the Worker, the child registry, and the result
* settlement; `result` never rejects. `meta` is this handle's OWN clone
* (event payloads carry separate clones), so a consumer mutating it corrupts
* nothing.
* settlement; `result` never rejects. `meta` is trusted same-process data
* borrowed as immutable by the handle and lifecycle events. The holder-bound
* SubagentService handle is captured before the
* engine returns this run, so unloading the engine removes only the ability to
* start another workflow; this run can still start and clean up its children.
*/
export class WorkerRun implements WorkflowRun {
/** Settles exactly once with the run's outcome; never rejects. */
readonly result: Promise<WorkflowResult>
private settleResolve!: (result: WorkflowResult) => void
private settled = false
/** A Result/death/grace outcome atomically won before teardown callbacks. */
private terminalClaimed = false
/** The first death signal closes worker-message admission and owns failure-time cleanup. */
private workerDeathObserved = false
private cancelReason: string | undefined
private graceTimer: NodeJS.Timeout | undefined
private readonly worker: Worker
@@ -61,19 +148,23 @@ export class WorkerRun implements WorkflowRun {
private workerGone = false
/** Accepted `child-start` messages — the terminate-path `agentsStarted` (see module doc). */
private hostStarted = 0
/** Live children by callId; an entry leaves ONLY after its dispose settles (quiescence = empty). */
private readonly children = new Map<number, SubagentRun>()
/** In-flight child disposals by callId — the memo that gives every path (worker RPC, dispose(), reap) ONE shared disposal per child. */
private readonly childDisposals = new Map<number, Promise<void>>()
/** Published children by callId; an entry leaves only after disposal settles. */
private readonly children = new Map<number, ChildRecord>()
/** Provider starts that have not yet fulfilled or rejected. */
private readonly pendingStarts = new Set<Promise<void>>()
/** Started-but-not-ended agents by seq — the pairing ledger the HOST guarantees (see {@link endAgent}). */
private readonly liveAgents = new Map<number, WorkflowAgentInfo>()
private readonly quiescenceWaiters: (() => void)[] = []
/** The per-run abort fanout every child start request carries. */
private readonly controller = new AbortController()
/** External start signal and the exact callback installed on it, retained only until first settle/teardown. */
private inputSignal: AbortSignal | undefined
private inputSignalAbort: (() => void) | undefined
private disposed: Promise<void> | undefined
constructor(
private readonly ctx: Context,
private readonly subagents: SubagentService,
readonly id: WorkflowRunId,
readonly meta: WorkflowMeta,
private readonly parent: Agent,
@@ -90,34 +181,53 @@ export class WorkerRun implements WorkflowRun {
const { entry, options } = resolveWorkerSpawn(init)
this.worker = new Worker(entry, options)
this.worker.on('message', (message: WorkerToHostMessage) => { this.onMessage(message) })
this.worker.on('error', (error) => { this.onWorkerDeath(`workflow worker failed: ${renderThrown(error)}`) })
this.worker.on('error', (error) => { this.onWorkerDeath(`workflow worker failed: ${renderThrown(error)}`, false) })
/* v8 ignore next -- messageerror: not constructible from the engine's own protocol (every payload is JSON data) */
this.worker.on('messageerror', (error) => { this.onWorkerDeath(`workflow worker message failed to deserialize: ${renderThrown(error)}`) })
this.worker.on('messageerror', (error) => { this.onWorkerDeath(`workflow worker message failed to deserialize: ${renderThrown(error)}`, false) })
this.worker.on('exit', (code) => {
this.workerGone = true
this.onWorkerDeath(`workflow worker exited before the run settled (exit code ${code})`)
this.onWorkerDeath(`workflow worker exited before the run settled (exit code ${code})`, true)
})
if (signal?.aborted) {
this.cancel('workflow start signal already aborted')
} else {
signal?.addEventListener('abort', () => { this.cancel('workflow signal aborted') }, { once: true })
} else if (signal !== undefined) {
const onAbort = (): void => {
this.detachInputSignal()
this.cancel('workflow signal aborted')
}
this.inputSignal = signal
this.inputSignalAbort = onAbort
signal.addEventListener('abort', onAbort, { once: true })
}
}
/**
* Cancel the worker and host-owned children, then arm forced settlement.
* Cancel the run: the worker is told (its hooks start throwing and the
* script dies at its next await), the required signal shared by every child
* start is aborted, and the grace timer
* arms: a run still unsettled `disposeGraceMs` later force-settles
* `cancelled` and its worker is TERMINATED. Idempotent; the first reason
* wins.
* @param reason - human-readable cause (default `'workflow cancelled'`).
*/
cancel(reason?: string): void {
// Do not arm a grace timer after settlement.
if (this.settled || this.cancelReason !== undefined) return
// A settled run has nothing left to cancel, and a terminal source claimed
// before its cleanup callbacks must exclude cancellation reentered by one
// of those callbacks. Without the settled guard the
// ordinary consumer path (await result, then dispose -> cancel) would arm
// a grace timer nothing ever clears, pinning the run and its Worker
// closure until the grace expires - a bounded leak per completed run.
if (this.settled || this.terminalClaimed || this.cancelReason !== undefined) return
this.cancelReason = reason ?? 'workflow cancelled'
this.post(HostToWorkerType.Cancel, { reason: this.cancelReason })
this.controller.abort(this.cancelReason)
// Host-side cancellation still reaches children when the worker is wedged.
for (const run of this.children.values()) run.cancel(this.cancelReason)
this.abortChildren(this.cancelReason)
this.graceTimer = setTimeout(() => {
// Pair stranded child starts before terminal workflow events.
// Cancellation already owns the race through cancelReason; close the
// terminal boundary explicitly before observer teardown callbacks.
this.terminalClaimed = true
// The worker may no longer speak (it is about to be terminated): pair
// every stranded start before the run settles, so ends precede
// workflow/end.
this.endStrandedAgents()
this.settleResult(this.cancelledResult(this.hostStarted))
void this.worker.terminate()
@@ -127,14 +237,36 @@ export class WorkerRun implements WorkflowRun {
}
/**
* Cancel + bounded settle + termination.
*
* Cancel + bounded settle + termination. Host-drives every registered
* child's disposal IMMEDIATELY — a wedged worker can relay no dispose RPC,
* and deferring child teardown to the post-terminate reap would spend the
* whole grace waiting for a quiescence that cannot start, then return with
* the disposals still in flight — so child disposal overlaps the same
* grace the worker gets to settle (the worker's own dispose RPCs join the
* shared per-child disposal). Waits (at most the grace) for the result and
* child quiescence, then terminates the worker unconditionally — the
* thread never outlives its run — and reaps whatever children remain
* (their disposal is contained, not awaited past the grace, the same
* abandonment the seam documents for a slow-disposing child). Idempotent;
* safe on every path.
* @returns resolves when the run's resources are released or abandoned.
*/
dispose(): Promise<void> {
this.disposed ??= (async () => {
if (this.disposed !== undefined) return this.disposed
// Claim the public transaction BEFORE its body invokes child/provider
// disposal. A raw provider callback can reenter handle.dispose(); it must
// join this promise rather than start a second traversal.
const claimed = Promise.withResolvers<undefined>()
this.disposed = claimed.promise
void (async () => {
this.detachInputSignal()
this.cancel('workflow disposed')
for (const [callId, run] of [...this.children]) void this.disposeChild(callId, run)
// cancel() deliberately becomes a no-op after terminal settlement, but
// disposal still owns every registered child. Reap independently so an
// already-settled workflow cannot wait on child quiescence before it has
// started the surviving children's disposals. On an unsettled run this
// joins the cancel path through the per-call cancellation/disposal gates.
this.reapChildren('workflow disposed')
await Promise.race([
(async () => {
await this.result
@@ -144,13 +276,17 @@ export class WorkerRun implements WorkflowRun {
])
await this.worker.terminate()
this.reapChildren('workflow disposed')
})()
})().then(
() => { claimed.resolve(undefined) },
/* v8 ignore next -- result/quiescence never reject and Worker.terminate is the only external promise */
(error: unknown) => { claimed.reject(error) },
)
return this.disposed
}
/** Post one message to the worker (payload looked up from the tag's map entry), tolerating a thread that is already gone. */
private post<T extends HostToWorkerType>(type: T, payload: HostToWorkerPayloads[T]): void {
if (this.workerGone) return
if (this.workerGone || this.workerDeathObserved) return
try {
this.worker.postMessage({ type, ...payload })
} catch (error: unknown) {
@@ -163,6 +299,11 @@ export class WorkerRun implements WorkflowRun {
}
private onMessage(message: WorkerToHostMessage): void {
// Node may emit `error`, then deliver an already-queued `message`, then
// emit `exit`. The first death signal is the host's logical delivery
// barrier: nothing arriving afterward may create a child, narrate after
// workflow/end, or compete with the chosen outcome.
if (this.workerDeathObserved) return
switch (message.type) {
case WorkerToHostType.Ready:
this.post(HostToWorkerType.Go, {})
@@ -192,9 +333,6 @@ export class WorkerRun implements WorkflowRun {
case WorkerToHostType.ChildStart:
this.onChildStart(message.callId, message.request)
break
case WorkerToHostType.ChildCancel:
this.children.get(message.callId)?.cancel(message.reason)
break
case WorkerToHostType.ChildDispose:
this.onChildDispose(message.callId)
break
@@ -207,18 +345,44 @@ export class WorkerRun implements WorkflowRun {
}
}
private onChildStart(callId: number, request: ChildStartRequest): void {
/** Why a ready provider result may no longer be admitted to the worker. */
private childAdmissionFailure(): { reason: string; rendered: string } | undefined {
if (this.cancelReason !== undefined) {
// The worker's start raced our cancel: refuse — a child must never
// start on an already-aborted signal (a provider subscribing only to
// future abort events would never observe it).
this.post(HostToWorkerType.ChildStartError, { callId, rendered: `workflow run cancelled: ${this.cancelReason}` })
return { reason: this.cancelReason, rendered: `workflow run cancelled: ${this.cancelReason}` }
}
if (this.workerDeathObserved) {
return { reason: 'workflow worker gone', rendered: 'workflow worker is no longer available' }
}
if (this.terminalClaimed) {
return { reason: 'workflow settled', rendered: 'workflow run already settled' }
}
return undefined
}
private onChildStart(callId: number, request: ChildStartRequest): void {
const initialFailure = this.childAdmissionFailure()
if (initialFailure !== undefined) {
// Refuse after a terminal boundary: a child must never start on an
// already-aborted signal (a provider subscribing only to future abort
// events would never observe it).
this.post(HostToWorkerType.ChildStartError, { callId, rendered: initialFailure.rendered })
return
}
this.hostStarted += 1
const task = this.startChild(callId, request)
this.pendingStarts.add(task)
void task.then(
() => { this.finishPendingStart(task) },
/* v8 ignore next -- startChild contains provider and cleanup failures */
() => { this.finishPendingStart(task) },
)
}
/** Await one provider-owned startup transaction and publish only while admitted. */
private async startChild(callId: number, request: ChildStartRequest): Promise<void> {
let run: SubagentRun
try {
run = this.ctx.subagents.start(this.provider, {
run = await this.subagents.start(this.provider, {
prompt: [{ type: 'text', text: request.prompt }],
parent: this.parent,
signal: this.controller.signal,
@@ -226,21 +390,38 @@ export class WorkerRun implements WorkflowRun {
...request.model !== undefined ? { agentOptions: { model: request.model } } : {},
})
} catch (error: unknown) {
this.post(HostToWorkerType.ChildStartError, { callId, rendered: renderThrown(error) })
const failure = this.childAdmissionFailure()
this.post(HostToWorkerType.ChildStartError, {
callId,
rendered: failure?.rendered ?? renderThrown(error),
})
return
}
const failure = this.childAdmissionFailure()
if (failure !== undefined) {
this.post(HostToWorkerType.ChildStartError, { callId, rendered: failure.rendered })
try {
await run.dispose()
} catch (error: unknown) {
this.ctx.logger.warn(`workflow-workerthread: refused child dispose failed: ${renderThrown(error)}`)
}
return
}
this.children.set(callId, run)
const childId = run.id
// Observe settlement IMMEDIATELY, before readiness.
const record: ChildRecord = { run }
this.children.set(callId, record)
// Attach result forwarding before publishing the child handle. Because the
// callback itself runs in a later microtask, ChildStarted is still posted
// first even for an already-settled scripted provider.
const forwardResult = run.result.then<() => void, () => void>(
(result) => {
try {
const snapshot: ChildResult = structuredClone({
const snapshot = snapshotJsonValue<ChildResult>({
output: result.output,
...result.structured !== undefined ? { structured: result.structured } : {},
stopReason: result.stopReason,
})
if (snapshot === undefined) throw new TypeError('child result is not losslessly JSON-serializable')
return () => { this.post(HostToWorkerType.ChildSettled, { callId, result: snapshot }) }
} catch (error: unknown) {
const rendered = `workflow child result could not cross the worker boundary: ${renderThrown(error)}`
@@ -252,88 +433,104 @@ export class WorkerRun implements WorkflowRun {
return () => { this.post(HostToWorkerType.ChildFailed, { callId, rendered }) }
},
)
// The provider owns the publication boundary.
void run.started.then(
() => {
this.post(HostToWorkerType.ChildStarted, { callId, childId })
void forwardResult.then((forward) => { forward() })
},
(error: unknown) => {
this.post(HostToWorkerType.ChildStartError, { callId, rendered: renderThrown(error) })
if (this.children.get(callId) === run) void this.disposeChild(callId, run)
},
)
this.post(HostToWorkerType.ChildStarted, { callId, childId: run.id })
void forwardResult.then((forward) => { forward() })
}
private onChildDispose(callId: number): void {
const run = this.children.get(callId)
if (run === undefined) {
const record = this.children.get(callId)
if (record === undefined) {
// Already disposed host-side (a dispose() drive or a death reap beat
// the RPC) — the ack is still owed (the worker-side wrapper awaits it).
this.post(HostToWorkerType.ChildDisposed, { callId })
return
}
// disposeChild never rejects (containment is inside), so the ack always follows.
void this.disposeChild(callId, run).then(() => { this.post(HostToWorkerType.ChildDisposed, { callId }) })
void this.disposeChild(callId, record).then(() => { this.post(HostToWorkerType.ChildDisposed, { callId }) })
}
/**
* Start (or join) one registered child's disposal; the registry entry leaves when it
* settles.
*
* Start (or join) one registered child's disposal; the registry entry
* leaves when it settles. Memoized per callId: the worker's dispose RPC,
* the dispose() host drive, and the reap can all land on the same child —
* the child's `dispose()` runs once and every caller awaits that one
* settlement. A rejection is contained (the subagent seam's dispose() is
* not supposed to reject, but a backend that does anyway must not break
* quiescence): logged, and the child still leaves the registry.
* @param callId - the child's registry key.
* @param run - the registered child (the caller looked it up).
* @param record - the registered child (the caller looked it up).
* @returns resolves when the disposal settled either way; never rejects.
*/
private disposeChild(callId: number, run: SubagentRun): Promise<void> {
let disposal = this.childDisposals.get(callId)
if (disposal === undefined) {
// The seam promises a Promise, but invoke inside an async boundary so a
// contract-violating synchronous throw is contained exactly like a
// rejected disposal and cannot break host quiescence.
disposal = (async () => { await run.dispose() })().then(
() => { this.finishChild(callId) },
(error: unknown) => {
this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`)
this.finishChild(callId)
},
)
this.childDisposals.set(callId, disposal)
}
return disposal
private disposeChild(callId: number, record: ChildRecord): Promise<void> {
if (record.disposal !== undefined) return record.disposal
record.disposal = Promise.resolve()
.then(() => record.run.dispose())
.catch((error: unknown) => {
this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`)
})
.then(() => { this.finishChild(callId) })
return record.disposal
}
/** Drop a child from the registry (and its disposal memo), releasing quiescence waiters at zero. */
/** Drop a child record and release quiescence waiters when all work ends. */
private finishChild(callId: number): void {
this.children.delete(callId)
this.childDisposals.delete(callId)
if (this.children.size === 0) {
for (const waiter of this.quiescenceWaiters.splice(0)) waiter()
}
this.notifyChildQuiescence()
}
/** Resolves once the child registry is empty (every disposal settled). */
/** Retire one provider startup transaction. */
private finishPendingStart(task: Promise<void>): void {
this.pendingStarts.delete(task)
this.notifyChildQuiescence()
}
/** Release waiters only after both pending starts and published children end. */
private notifyChildQuiescence(): void {
if (this.children.size !== 0 || this.pendingStarts.size !== 0) return
for (const waiter of this.quiescenceWaiters.splice(0)) waiter()
}
/** Resolves once every pending start and published child has reached quiescence. */
private childQuiescence(): Promise<void> {
if (this.children.size === 0) return Promise.resolve()
if (this.children.size === 0 && this.pendingStarts.size === 0) return Promise.resolve()
return new Promise((resolve) => { this.quiescenceWaiters.push(resolve) })
}
/** Abort + dispose every registered child (worker death / final teardown); disposal is contained, not awaited. */
private reapChildren(reason: string): void {
this.controller.abort(this.cancelReason ?? reason)
for (const [callId, run] of [...this.children]) {
run.cancel(this.cancelReason ?? reason)
void this.disposeChild(callId, run)
this.abortChildren(this.cancelReason ?? reason)
for (const [callId, record] of [...this.children]) {
void this.disposeChild(callId, record)
}
}
/** Abort the one canonical signal shared by pending and published children. */
private abortChildren(reason: string): void {
if (!this.controller.signal.aborted) this.controller.abort(reason)
}
private onResult(result: WorkflowResult): void {
// The worker's settle-reap already child-cancel()s every stray; this
// abort fires the seam signal too, for providers that only honor the
// request signal (both channels, on every path).
if (this.cancelReason === undefined) this.controller.abort('workflow settled')
if (this.cancelReason !== undefined && result.stopReason !== 'cancelled') {
// The owned worker session sends one Result. Keep a late duplicate or a
// Result queued behind another terminal source completely side-effect-free.
if (this.terminalClaimed) return
// First-wins is decided when the Result message reaches the host. If no
// external cancellation was already in flight, this result won. Reaping a
// stray child below may synchronously reenter cancel() through provider
// callbacks, but that internal post-result cleanup must not retroactively
// rewrite the worker result that arrived first.
const cancellationWasRequested = this.cancelReason !== undefined
// Claim before settlement cleanup invokes provider disposal. Once Result
// won, a later cancellation cannot rewrite it.
this.terminalClaimed = true
// Abort pending starts and begin disposing published children before the
// workflow becomes externally settled. Cleanup remains independently
// tracked by childQuiescence and the holder's dispose().
this.reapChildren('workflow settled')
if (!cancellationWasRequested) {
this.settleResult(result)
return
}
if (result.stopReason !== 'cancelled') {
// The script settled while our cancel was crossing the thread boundary
// — the seam-visible result had NOT settled when cancellation was
// requested, so report cancelled (the vm drive()'s post-settle check,
@@ -344,21 +541,39 @@ export class WorkerRun implements WorkflowRun {
this.settleResult(result)
}
/** An unexpected worker death (or the expected exit after termination). */
private onWorkerDeath(message: string): void {
// Whatever the worker left behind must not leak — abort + dispose it all.
if (this.children.size > 0) this.reapChildren('workflow worker gone')
// The thread is gone: no more worker-authored agent-ends can arrive —
// pair every stranded start (a start that crossed between the grace
// force-settle and this exit included) before the run settles.
this.endStrandedAgents()
// settleResult no-ops on an already-settled run (the expected exit after
// a dispose's terminate lands here too).
if (this.cancelReason !== undefined) {
this.settleResult(this.cancelledResult(this.hostStarted))
return
/** Process an error/messageerror/exit signal; `exit` also performs the final disposal sweep. */
private onWorkerDeath(message: string, isExit: boolean): void {
if (!this.workerDeathObserved) {
// Close message admission BEFORE cleanup callbacks: Node can deliver a
// message queued before the crash after its `error` event. Treating the
// first death signal as a logical barrier prevents that late message
// from creating work or narrating after workflow/end.
this.workerDeathObserved = true
const outcomeWasClaimed = this.terminalClaimed
const cancellationWasRequested = this.cancelReason !== undefined
// When death is itself the terminal source, claim BEFORE child reap or
// synthesized observer callbacks. Either can reenter cancel(); a death
// that arrived first remains an error, while a cancellation already
// accepted before death remains cancelled. If Result/grace already won,
// preserve it while still performing prompt failure-time cleanup.
if (!outcomeWasClaimed) this.terminalClaimed = true
if (this.children.size > 0 || this.pendingStarts.size > 0) this.reapChildren('workflow worker gone')
this.endStrandedAgents()
if (!outcomeWasClaimed) {
if (cancellationWasRequested) {
this.settleResult(this.cancelledResult(this.hostStarted))
} else {
this.settleResult({ value: null, stopReason: 'error', error: message, agentsStarted: this.hostStarted })
}
}
}
this.settleResult({ value: null, stopReason: 'error', error: message, agentsStarted: this.hostStarted })
if (!isExit) return
// `error` is not Node's physical delivery barrier: a queued message may
// precede `exit`. Admission is already closed, so this final sweep only
// joins/starts disposal for registry survivors; it deliberately does not
// repeat explicit provider cancellation.
for (const [callId, record] of [...this.children]) void this.disposeChild(callId, record)
this.endStrandedAgents()
}
/**
@@ -377,11 +592,14 @@ export class WorkerRun implements WorkflowRun {
/**
* Synthesize the missing `agent-end` for every started-but-unpaired agent,
* outcome `'cancelled'`: the reap cancels every child, and a real
* settlement racing the force-settle loses to the cancellation — the same
* first-wins override {@link onResult} applies to the run's own result.
* settlement racing the force-settle loses to that already-started external
* cancellation. The atomic terminal boundaries in {@link onResult} and
* {@link onWorkerDeath} deliberately exclude teardown callbacks as contenders.
* Called where the worker can no longer speak (the grace force-settle,
* worker death), BEFORE settleResult, so the paired ends reach observers
* before `workflow/end`.
* worker death, physical exit). When grace/death is the terminal source it
* runs before settleResult, so already-known pairs precede `workflow/end`;
* after an earlier Result, exit cleanup may close a survivor afterward.
* The ledger preserves exactly-once pairing in both orders.
*/
private endStrandedAgents(): void {
for (const info of [...this.liveAgents.values()]) {
@@ -397,10 +615,25 @@ export class WorkerRun implements WorkflowRun {
return { value: null, stopReason: 'cancelled', error: `workflow run cancelled: ${reason}`, agentsStarted }
}
/** First settle wins; disarms the grace timer. */
/** Remove the exact abort callback installed on the caller's start signal. */
private detachInputSignal(): void {
const signal = this.inputSignal
const onAbort = this.inputSignalAbort
if (signal === undefined || onAbort === undefined) return
this.inputSignal = undefined
this.inputSignalAbort = undefined
signal.removeEventListener('abort', onAbort)
}
/** First settle wins; disarms the grace timer and releases the caller signal. */
private settleResult(result: WorkflowResult): void {
// Every current terminal source claims ownership before calling here; keep
// the fallback local so a future caller cannot resolve twice.
/* v8 ignore next -- defensive fallback outside the claimed state machine */
if (this.settled) return
this.terminalClaimed = true
this.settled = true
this.detachInputSignal()
clearTimeout(this.graceTimer)
this.settleResolve(result)
}

Some files were not shown because too many files have changed in this diff Show More