refactor: narrow synchronous extension contracts
This commit is contained in:
@@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:606`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:605`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
### `agent/pre-step` — serial
|
### `agent/pre-step` — serial
|
||||||
|
|
||||||
@@ -165,15 +165,15 @@ Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/t
|
|||||||
|
|
||||||
### `agent/turn-stop` — serial
|
### `agent/turn-stop` — serial
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
|
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
|
||||||
```
|
```
|
||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent/src/types.ts:589`](../../packages/core/agent/src/types.ts)
|
Source: [`packages/core/agent/src/types.ts:588`](../../packages/core/agent/src/types.ts)
|
||||||
|
|
||||||
## `approval/*`
|
## `approval/*`
|
||||||
|
|
||||||
@@ -419,12 +419,12 @@ Types: [ToolExecution](../core-data-structures/tools.md)
|
|||||||
|
|
||||||
Source: [`packages/core/tools/src/index.ts:101`](../../packages/core/tools/src/index.ts)
|
Source: [`packages/core/tools/src/index.ts:101`](../../packages/core/tools/src/index.ts)
|
||||||
|
|
||||||
### `tools/result` — parallel
|
### `tools/result` — emit
|
||||||
|
|
||||||
Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization. Unlike the three waterfalls, this seam cannot transform the result: each listener receives the now-frozen execution object and a deep-frozen result snapshot; listener failures are contained and logged, and ToolRegistry.execute still returns the outcome. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`, using the same carrier as the pipeline.
|
Synchronous notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization. Unlike the three waterfalls, this seam cannot transform the result: each listener receives the now-frozen execution object and a deep-frozen result snapshot; listener failures are contained and logged, and ToolRegistry.execute still returns the outcome. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`, using the same carrier as the pipeline.
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void
|
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined
|
||||||
```
|
```
|
||||||
|
|
||||||
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
|
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ Source: [`packages/core/agent-loop/src/index.ts:338`](../../packages/core/agent-
|
|||||||
Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory.
|
Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory.
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
setFactory(factory: AgentFactory): () => Promise<void> | void
|
setFactory(factory: AgentFactory): () => void
|
||||||
async create(options: CreateAgentOptions): Promise<AgentHandle>
|
async create(options: CreateAgentOptions): Promise<AgentHandle>
|
||||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
async resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
||||||
register(agent: Agent): () => Promise<void> | void
|
register(agent: Agent): () => void
|
||||||
enter(agent: Agent): () => void
|
enter(agent: Agent): () => void
|
||||||
announce(agent: Agent): void
|
announce(agent: Agent): void
|
||||||
get(id: AgentId): Agent | undefined
|
get(id: AgentId): Agent | undefined
|
||||||
@@ -225,8 +225,8 @@ Source: [`packages/core/session/src/index.ts:591`](../../packages/core/session/s
|
|||||||
Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand.
|
Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand.
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
registerProvider(provider: SkillProvider): () => Promise<void> | void
|
registerProvider(provider: SkillProvider): () => void
|
||||||
register(skill: SkillRegistration): () => Promise<void> | void
|
register(skill: SkillRegistration): () => void
|
||||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>
|
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>
|
||||||
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>
|
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>
|
||||||
```
|
```
|
||||||
@@ -238,7 +238,7 @@ Source: [`packages/skill/skill/src/index.ts:158`](../../packages/skill/skill/src
|
|||||||
Named provider registry and capability-checked start surface.
|
Named provider registry and capability-checked start surface.
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
registerProvider(provider: SubagentProvider): () => Promise<void> | void
|
registerProvider(provider: SubagentProvider): () => void
|
||||||
getProvider(name: string): SubagentProvider | undefined
|
getProvider(name: string): SubagentProvider | undefined
|
||||||
list(): string[]
|
list(): string[]
|
||||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||||
@@ -251,9 +251,9 @@ Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/
|
|||||||
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. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona).
|
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. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona).
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
section(section: PromptSection): () => Promise<void> | void
|
section(section: PromptSection): () => void
|
||||||
tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void
|
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void
|
||||||
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void
|
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void
|
||||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -266,9 +266,9 @@ Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop e
|
|||||||
Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One private visibility resolver feeds prompt assembly, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so what the model is shown, what a presenter renders, what a program can call, and what dispatches can never disagree.
|
Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One private visibility resolver feeds prompt assembly, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so what the model is shown, what a presenter renders, what a program can call, and what dispatches can never disagree.
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
register(definition: ToolDefinition): () => Promise<void> | void
|
register(definition: ToolDefinition): () => void
|
||||||
restrict(filter: ToolRestriction): () => Promise<void> | void
|
restrict(filter: ToolRestriction): () => void
|
||||||
guard(guard: ToolGuard): () => Promise<void> | void
|
guard(guard: ToolGuard): () => void
|
||||||
get(name: string, scope?: ScopeKey): ToolDefinition | undefined
|
get(name: string, scope?: ScopeKey): ToolDefinition | undefined
|
||||||
schemas(scope?: ScopeKey): ToolSchema[]
|
schemas(scope?: ScopeKey): ToolSchema[]
|
||||||
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ type PostToolDecision =
|
|||||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
|
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
|
||||||
```
|
```
|
||||||
|
|
||||||
Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` skips dispatch and yields an `isError` result. An `ask` resolves through the optional approval seam: only `allowed-once` proceeds, while every non-grant, missing channel/service, or agent-less request becomes a normalized denial. A registered `ToolGuard` then runs and can still impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The awaited `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn.
|
Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` skips dispatch and yields an `isError` result. An `ask` resolves through the optional approval seam: only `allowed-once` proceeds, while every non-grant, missing channel/service, or agent-less request becomes a normalized denial. A registered `ToolGuard` then runs and can still impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The synchronous `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn.
|
||||||
|
|
||||||
## The structured-output schema subset
|
## The structured-output schema subset
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
|||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:606`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:605`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:438`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
|
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:438`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
|
||||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:456`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:456`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||||
@@ -19,7 +19,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
|||||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:552`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:552`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:589`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:588`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||||
@@ -41,7 +41,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
|||||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:128`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
|
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:128`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
|
||||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:101`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:101`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||||
| `tools/result` | `parallel` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:96`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:96`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:106`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:106`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ Pure Cordis event taxonomy. The loop's extension seams are typed events with del
|
|||||||
|
|
||||||
- **waterfall** (around-middleware) where plugins transform, veto, or wrap: `agent/prompt-submit`, `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`.
|
- **waterfall** (around-middleware) where plugins transform, veto, or wrap: `agent/prompt-submit`, `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`.
|
||||||
- **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final.
|
- **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final.
|
||||||
- **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint and the immutable observe-only `tools/result` notification.
|
- **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint.
|
||||||
- **emit** (synchronous fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, and errors.
|
- **emit** (synchronous fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors, and the contained immutable `tools/result` observation.
|
||||||
|
|
||||||
The event vocabulary lives in interface packages (dsh-agent declares the agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete loop plugin and is itself swappable — nothing outside it may depend on it.
|
The event vocabulary lives in interface packages (dsh-agent declares the agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete loop plugin and is itself swappable — nothing outside it may depend on it.
|
||||||
|
|
||||||
|
|||||||
@@ -135,12 +135,6 @@ flowchart TB
|
|||||||
detach --> revoke["Dispose the agent scope"]
|
detach --> revoke["Dispose the agent scope"]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Subagent controls are an independent feature
|
|
||||||
|
|
||||||
In-process subagents consume agent scope by installing their local composition during unpublished setup. Their optional persona, live global-tool filter, and absolute depth cap are not intrinsic scope semantics; the [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) defines those controls, provider capability checks, and dynamic tool behavior.
|
|
||||||
|
|
||||||
`inheritsParentContext` describes conversation-history seeding only. It says nothing about Cordis scope, injected services, tools, or authority.
|
|
||||||
|
|
||||||
## Security and authority are non-goals
|
## Security and authority are non-goals
|
||||||
|
|
||||||
Agent scopes compose trusted same-process registrations. They do not sandbox plugins, define a parent-to-child authority lattice, freeze grants at creation, or guarantee that a child can do no more than its parent.
|
Agent scopes compose trusted same-process registrations. They do not sandbox plugins, define a parent-to-child authority lattice, freeze grants at creation, or guarantee that a child can do no more than its parent.
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ A fresh registry-assigned Symbol provides collision-free execution identity with
|
|||||||
|
|
||||||
Arguments are materialized once where model/tool JSON enters the pipeline. Pre-, around-, and post-execute listeners operate on the typed execution and decisions. Call ID correlation, approval, monotonic guards, and Code Mode nesting remain explicit relational checks.
|
Arguments are materialized once where model/tool JSON enters the pipeline. Pre-, around-, and post-execute listeners operate on the typed execution and decisions. Call ID correlation, approval, monotonic guards, and Code Mode nesting remain explicit relational checks.
|
||||||
|
|
||||||
After the last post-execute listener, the registry materializes and freezes the accepted final result once. Every `tools/result` observer receives that exact committed object, and observer failures are awaited and contained individually. An outer pipeline failure is normalized into a committed error result, so observers can discard staged work against the same authoritative boundary.
|
After the last post-execute listener, the registry materializes and freezes the accepted final result once. Every synchronous `tools/result` observer receives that exact committed object, and observer failures are contained individually. An outer pipeline failure is normalized into a committed error result, so observers can discard staged work against the same authoritative boundary.
|
||||||
|
|
||||||
### Contribution-owned finality protects only named invariants
|
### Contribution-owned finality protects only named invariants
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Every call follows one ordered pipeline: `tools/pre-execute` → monotonic guard
|
|||||||
- **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids.
|
- **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids.
|
||||||
- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch.
|
- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch.
|
||||||
- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContext`; in-place mutation of the result is not a transform channel, because the registry rebuilds the outcome from a protected snapshot plus the returned decision.
|
- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContext`; in-place mutation of the result is not a transform channel, because the registry rebuilds the outcome from a protected snapshot plus the returned decision.
|
||||||
- **`tools/result`** is the awaited parallel notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome.
|
- **`tools/result`** is the synchronous contained notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome.
|
||||||
|
|
||||||
Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist.
|
Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist.
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ flowchart TD
|
|||||||
fsGate["<code>fs/write-intent</code> or <code>fs/edit-intent</code><br/>tool-fs mutations only"]
|
fsGate["<code>fs/write-intent</code> or <code>fs/edit-intent</code><br/>tool-fs mutations only"]
|
||||||
owned["Tool-owned session events<br/><code>todo/write</code>, <code>fs/observed</code>, <code>hook/invoked</code>, <code>hook/result</code>, <code>tool/code-dispatch</code>"]
|
owned["Tool-owned session events<br/><code>todo/write</code>, <code>fs/observed</code>, <code>hook/invoked</code>, <code>hook/result</code>, <code>tool/code-dispatch</code>"]
|
||||||
post["<code>tools/post-execute</code> waterfall<br/>accept, block, replace, add context"]
|
post["<code>tools/post-execute</code> waterfall<br/>accept, block, replace, add context"]
|
||||||
final["<code>tools/result</code> parallel notification<br/>frozen authoritative outcome"]
|
final["<code>tools/result</code> synchronous notification<br/>frozen authoritative outcome"]
|
||||||
context["Buffered additionalContext<br/>context/message after all tool results"]
|
context["Buffered additionalContext<br/>context/message after all tool results"]
|
||||||
toolResult["Session event: <code>tool/result</code><br/>single model-facing outcome"]
|
toolResult["Session event: <code>tool/result</code><br/>single model-facing outcome"]
|
||||||
allResults["All calls in the step settled<br/>and tool/result events recorded"]
|
allResults["All calls in the step settled<br/>and tool/result events recorded"]
|
||||||
@@ -48,6 +48,6 @@ flowchart TD
|
|||||||
allResults --> context
|
allResults --> context
|
||||||
```
|
```
|
||||||
|
|
||||||
Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The awaited `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution's opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).
|
Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The synchronous `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution's opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).
|
||||||
|
|
||||||
Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs.
|
Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs.
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
key: 'agents',
|
key: 'agents',
|
||||||
summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.',
|
summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.',
|
||||||
methods: [
|
methods: [
|
||||||
'setFactory(factory: AgentFactory): () => Promise<void> | void',
|
'setFactory(factory: AgentFactory): () => void',
|
||||||
'async create(options: CreateAgentOptions): Promise<AgentHandle>',
|
'async create(options: CreateAgentOptions): Promise<AgentHandle>',
|
||||||
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
|
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
|
||||||
'register(agent: Agent): () => Promise<void> | void',
|
'register(agent: Agent): () => void',
|
||||||
'enter(agent: Agent): () => void',
|
'enter(agent: Agent): () => void',
|
||||||
'announce(agent: Agent): void',
|
'announce(agent: Agent): void',
|
||||||
'get(id: AgentId): Agent | undefined',
|
'get(id: AgentId): Agent | undefined',
|
||||||
@@ -169,8 +169,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
key: 'skills',
|
key: 'skills',
|
||||||
summary: 'Registry of skill providers.',
|
summary: 'Registry of skill providers.',
|
||||||
methods: [
|
methods: [
|
||||||
'registerProvider(provider: SkillProvider): () => Promise<void> | void',
|
'registerProvider(provider: SkillProvider): () => void',
|
||||||
'register(skill: SkillRegistration): () => Promise<void> | void',
|
'register(skill: SkillRegistration): () => void',
|
||||||
'async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>',
|
'async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>',
|
||||||
'async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>',
|
'async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>',
|
||||||
],
|
],
|
||||||
@@ -179,7 +179,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
key: 'subagents',
|
key: 'subagents',
|
||||||
summary: 'Named provider registry and capability-checked start surface.',
|
summary: 'Named provider registry and capability-checked start surface.',
|
||||||
methods: [
|
methods: [
|
||||||
'registerProvider(provider: SubagentProvider): () => Promise<void> | void',
|
'registerProvider(provider: SubagentProvider): () => void',
|
||||||
'getProvider(name: string): SubagentProvider | undefined',
|
'getProvider(name: string): SubagentProvider | undefined',
|
||||||
'list(): string[]',
|
'list(): string[]',
|
||||||
'async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>',
|
'async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>',
|
||||||
@@ -189,9 +189,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
key: 'systemPrompt',
|
key: 'systemPrompt',
|
||||||
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.',
|
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: [
|
methods: [
|
||||||
'section(section: PromptSection): () => Promise<void> | void',
|
'section(section: PromptSection): () => void',
|
||||||
'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void',
|
'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void',
|
||||||
'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void',
|
'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void',
|
||||||
'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
|
'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -199,9 +199,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
key: 'tools',
|
key: 'tools',
|
||||||
summary: 'Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline.',
|
summary: 'Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline.',
|
||||||
methods: [
|
methods: [
|
||||||
'register(definition: ToolDefinition): () => Promise<void> | void',
|
'register(definition: ToolDefinition): () => void',
|
||||||
'restrict(filter: ToolRestriction): () => Promise<void> | void',
|
'restrict(filter: ToolRestriction): () => void',
|
||||||
'guard(guard: ToolGuard): () => Promise<void> | void',
|
'guard(guard: ToolGuard): () => void',
|
||||||
'get(name: string, scope?: ScopeKey): ToolDefinition | undefined',
|
'get(name: string, scope?: ScopeKey): ToolDefinition | undefined',
|
||||||
'schemas(scope?: ScopeKey): ToolSchema[]',
|
'schemas(scope?: ScopeKey): ToolSchema[]',
|
||||||
'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
|
'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
|
||||||
@@ -311,7 +311,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
|||||||
{
|
{
|
||||||
name: 'agent/turn-stop',
|
name: 'agent/turn-stop',
|
||||||
mode: 'serial',
|
mode: 'serial',
|
||||||
signature: '\'agent/turn-stop\'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined',
|
signature: '\'agent/turn-stop\'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined',
|
||||||
summary: 'Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.',
|
summary: 'Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -442,9 +442,9 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'tools/result',
|
name: 'tools/result',
|
||||||
mode: 'parallel',
|
mode: 'emit',
|
||||||
signature: '\'tools/result\'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void',
|
signature: '\'tools/result\'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined',
|
||||||
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: 'Synchronous 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',
|
name: 'workflow/agent-end',
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): To
|
|||||||
* @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected.
|
* @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected.
|
||||||
* @returns the registry disposer for the registration.
|
* @returns the registry disposer for the registration.
|
||||||
*/
|
*/
|
||||||
export function sandboxRegisterTool(ctx: Context, tool: unknown): () => Promise<void> | void {
|
export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
|
||||||
assertDynamicTool(tool)
|
assertDynamicTool(tool)
|
||||||
return ctx.tools.register(tool)
|
return ctx.tools.register(tool)
|
||||||
}
|
}
|
||||||
@@ -264,7 +264,7 @@ function sandboxTools(ctx: Context): Record<string, unknown> {
|
|||||||
// view for today's global mounts, its agent's view if a mount ever runs
|
// view for today's global mounts, its agent's view if a mount ever runs
|
||||||
// under an agent scope.
|
// under an agent scope.
|
||||||
return {
|
return {
|
||||||
register: (tool: unknown): (() => Promise<void> | void) => sandboxRegisterTool(ctx, tool),
|
register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
|
||||||
schemas: () => ctx.tools.schemas(scopeOf(ctx)),
|
schemas: () => ctx.tools.schemas(scopeOf(ctx)),
|
||||||
get: (name: string) => ctx.tools.schemas(scopeOf(ctx)).find(schema => schema.name === name),
|
get: (name: string) => ctx.tools.schemas(scopeOf(ctx)).find(schema => schema.name === name),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
|
|||||||
|
|
||||||
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.
|
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.
|
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
|
||||||
- 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()`.
|
- 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.get(id: AgentId): Agent | undefined`
|
||||||
- `ctx.agents.list(): Agent[]`
|
- `ctx.agents.list(): Agent[]`
|
||||||
@@ -19,7 +19,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
|
|||||||
|
|
||||||
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.
|
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.setFactory(factory: AgentFactory): () => 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>` — 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.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.
|
- `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.
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export class AgentRegistry extends Service {
|
|||||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||||
* yield it directly — exact identity nests the teardown in order.
|
* yield it directly — exact identity nests the teardown in order.
|
||||||
*/
|
*/
|
||||||
setFactory(factory: AgentFactory): () => Promise<void> | void {
|
setFactory(factory: AgentFactory): () => void {
|
||||||
const dispose = this.ctx.effect(() => {
|
const dispose = this.ctx.effect(() => {
|
||||||
if (this.factory !== undefined) throw new Error('an agent factory is already registered')
|
if (this.factory !== undefined) throw new Error('an agent factory is already registered')
|
||||||
// Avoid stacking two Cordis shadow layers when a caller passes a Service
|
// Avoid stacking two Cordis shadow layers when a caller passes a Service
|
||||||
@@ -242,6 +242,7 @@ export class AgentRegistry extends Service {
|
|||||||
// caller's composite effect can yield it for in-order teardown; the
|
// caller's composite effect can yield it for in-order teardown; the
|
||||||
// loop's constructor effect returns it directly, identity-nesting the
|
// loop's constructor effect returns it directly, identity-nesting the
|
||||||
// registration under that effect.
|
// registration under that effect.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
return dispose
|
return dispose
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,11 +306,12 @@ export class AgentRegistry extends Service {
|
|||||||
* owner unload, unregistering the agent (and emitting `agent/disposed`)
|
* owner unload, unregistering the agent (and emitting `agent/disposed`)
|
||||||
* while its final turn is still draining.
|
* while its final turn is still draining.
|
||||||
*/
|
*/
|
||||||
register(agent: Agent): () => Promise<void> | void {
|
register(agent: Agent): () => void {
|
||||||
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
||||||
yield this.enter(agent)
|
yield this.enter(agent)
|
||||||
this.announce(agent)
|
this.announce(agent)
|
||||||
}.bind(this), 'agents.register()')
|
}.bind(this), 'agents.register()')
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
return dispose
|
return dispose
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -575,8 +575,7 @@ declare module 'cordis' {
|
|||||||
* returns `{ action: 'stop' }` to make this turn terminal, or `undefined`
|
* returns `{ action: 'stop' }` to make this turn terminal, or `undefined`
|
||||||
* to abstain. Terminal stop is monotonic: listener order and steering
|
* to abstain. Terminal stop is monotonic: listener order and steering
|
||||||
* cannot resume the turn, and pending steering is discarded rather than
|
* cannot resume the turn, and pending steering is discarded rather than
|
||||||
* becoming another step or turn. A malformed non-undefined result fails
|
* becoming another step or turn.
|
||||||
* the turn closed.
|
|
||||||
* @param agent - the agent whose composed continuation outcome may be stopped.
|
* @param agent - the agent whose composed continuation outcome may be stopped.
|
||||||
* @param turn - the turn at its terminal-stop checkpoint.
|
* @param turn - the turn at its terminal-stop checkpoint.
|
||||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||||
@@ -586,7 +585,7 @@ declare module 'cordis' {
|
|||||||
* `scopeTarget`/`agentEvents`.
|
* `scopeTarget`/`agentEvents`.
|
||||||
* @mode serial
|
* @mode serial
|
||||||
*/
|
*/
|
||||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
|
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
|
||||||
|
|
||||||
// ---- error notifications (emit) ----
|
// ---- error notifications (emit) ----
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||||
import { Context, Service, symbols } from 'cordis'
|
import { Context, Service, symbols } from 'cordis'
|
||||||
|
import type { Events } from 'cordis'
|
||||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import AgentRegistry, { AgentId, agentEvents } 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'
|
import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||||
|
|
||||||
function stubAgent(rawId: string): Agent {
|
function stubAgent(rawId: string): Agent {
|
||||||
const id = AgentId(rawId)
|
const id = AgentId(rawId)
|
||||||
@@ -21,6 +22,14 @@ function stubAgent(rawId: string): Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AgentRegistry', () => {
|
describe('AgentRegistry', () => {
|
||||||
|
it('keeps terminal stop decisions synchronous', () => {
|
||||||
|
type TurnStopListener = Events['agent/turn-stop']
|
||||||
|
type AsyncTurnStopListener = () => Promise<ContinuationStop | undefined>
|
||||||
|
|
||||||
|
expectTypeOf<AsyncTurnStopListener>().not.toExtend<TurnStopListener>()
|
||||||
|
expectTypeOf<ReturnType<TurnStopListener>>().toEqualTypeOf<ContinuationStop | undefined>()
|
||||||
|
})
|
||||||
|
|
||||||
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
|
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
@@ -34,7 +43,7 @@ describe('AgentRegistry', () => {
|
|||||||
expect(ctx.agents.list()).toEqual([agent])
|
expect(ctx.agents.list()).toEqual([agent])
|
||||||
expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
|
expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
|
||||||
|
|
||||||
await dispose()
|
dispose()
|
||||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||||
expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
|
expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
|
||||||
})
|
})
|
||||||
@@ -65,7 +74,7 @@ describe('AgentRegistry', () => {
|
|||||||
|
|
||||||
const dispose = ctx.agents.register(stubAgent('contained'))
|
const dispose = ctx.agents.register(stubAgent('contained'))
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
await dispose()
|
dispose()
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
|
|
||||||
expect(heard).toEqual(['contained'])
|
expect(heard).toEqual(['contained'])
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
|
|||||||
|
|
||||||
### Public API
|
### Public API
|
||||||
|
|
||||||
- `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.section(section: PromptSection): () => 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.tools(provider: (context: AssembleContext) => ToolProviderResult): () => 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.variable(name: string, provider: (context) => string | undefined): () => 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.
|
- `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
|
### Live events
|
||||||
|
|||||||
@@ -434,7 +434,7 @@ export class SystemPrompt extends Service {
|
|||||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||||
* yield it directly — exact identity nests the teardown in order.
|
* yield it directly — exact identity nests the teardown in order.
|
||||||
*/
|
*/
|
||||||
section(section: PromptSection): () => Promise<void> | void {
|
section(section: PromptSection): () => void {
|
||||||
if (!Number.isFinite(section.order)) {
|
if (!Number.isFinite(section.order)) {
|
||||||
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
|
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
|
||||||
}
|
}
|
||||||
@@ -481,8 +481,9 @@ export class SystemPrompt extends Service {
|
|||||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
// 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
|
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||||
// exact function identity, so a wrapper would silently break the nesting
|
// exact function identity, so a wrapper would silently break the nesting
|
||||||
// (the agents.register() lesson). Fire-and-forget callers may still
|
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||||
// discard the (always-resolved) promise.
|
// registration installs only synchronous state and notifications.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
return dispose
|
return dispose
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +503,7 @@ export class SystemPrompt extends Service {
|
|||||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||||
* yield it directly — exact identity nests the teardown in order.
|
* yield it directly — exact identity nests the teardown in order.
|
||||||
*/
|
*/
|
||||||
tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void {
|
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
|
||||||
const scope = scopeOf(this.ctx)
|
const scope = scopeOf(this.ctx)
|
||||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||||
const layer = scope === undefined
|
const layer = scope === undefined
|
||||||
@@ -527,8 +528,9 @@ export class SystemPrompt extends Service {
|
|||||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
// 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
|
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||||
// exact function identity, so a wrapper would silently break the nesting
|
// exact function identity, so a wrapper would silently break the nesting
|
||||||
// (the agents.register() lesson). Fire-and-forget callers may still
|
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||||
// discard the (always-resolved) promise.
|
// registration installs only synchronous state and notifications.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
return dispose
|
return dispose
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,7 +552,7 @@ export class SystemPrompt extends Service {
|
|||||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||||
* yield it directly — exact identity nests the teardown in order.
|
* yield it directly — exact identity nests the teardown in order.
|
||||||
*/
|
*/
|
||||||
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void {
|
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void {
|
||||||
if (!VARIABLE_NAME.test(name)) {
|
if (!VARIABLE_NAME.test(name)) {
|
||||||
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
|
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
|
||||||
}
|
}
|
||||||
@@ -581,8 +583,9 @@ export class SystemPrompt extends Service {
|
|||||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
// 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
|
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||||
// exact function identity, so a wrapper would silently break the nesting
|
// exact function identity, so a wrapper would silently break the nesting
|
||||||
// (the agents.register() lesson). Fire-and-forget callers may still
|
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||||
// discard the (always-resolved) promise.
|
// registration installs only synchronous state and notifications.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
return dispose
|
return dispose
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe('scoped tool providers and toolOrder × restriction', () => {
|
|||||||
const ctx = await mount()
|
const ctx = await mount()
|
||||||
const scope = await mintScope(ctx, 'child')
|
const scope = await mintScope(ctx, 'child')
|
||||||
const dispose = scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] }))
|
const dispose = scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] }))
|
||||||
await dispose()
|
dispose()
|
||||||
const after = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
|
const after = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
|
||||||
expect(after.tools.map(t => t.name)).toEqual([])
|
expect(after.tools.map(t => t.name)).toEqual([])
|
||||||
// Re-registering through the same scope starts a fresh layer.
|
// Re-registering through the same scope starts a fresh layer.
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ describe('SystemPrompt', () => {
|
|||||||
// registration emits change
|
// registration emits change
|
||||||
expect(changeCount).toBe(1)
|
expect(changeCount).toBe(1)
|
||||||
|
|
||||||
await dispose()
|
dispose()
|
||||||
// disposal emits change again
|
// disposal emits change again
|
||||||
expect(changeCount).toBe(2)
|
expect(changeCount).toBe(2)
|
||||||
})
|
})
|
||||||
@@ -341,7 +341,7 @@ describe('SystemPrompt', () => {
|
|||||||
const dispose = ctx.systemPrompt.section({ name: 'direct', order: 0, text: 'direct section' })
|
const dispose = ctx.systemPrompt.section({ name: 'direct', order: 0, text: 'direct section' })
|
||||||
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(1)
|
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(1)
|
||||||
|
|
||||||
await dispose()
|
dispose()
|
||||||
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0)
|
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ describe('SystemPrompt', () => {
|
|||||||
const dispose = ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'direct-tool', description: '', parameters: {} }] }))
|
const dispose = ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'direct-tool', description: '', parameters: {} }] }))
|
||||||
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
|
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
|
||||||
|
|
||||||
await dispose()
|
dispose()
|
||||||
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0)
|
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -370,7 +370,7 @@ describe('SystemPrompt', () => {
|
|||||||
// A provider returning undefined records "registered but no value here".
|
// A provider returning undefined records "registered but no value here".
|
||||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({ who: undefined })
|
expect((await ctx.systemPrompt.assemble()).variables).toEqual({ who: undefined })
|
||||||
|
|
||||||
await dispose()
|
dispose()
|
||||||
expect(changeCount).toBe(2)
|
expect(changeCount).toBe(2)
|
||||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
|
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ tools:
|
|||||||
|
|
||||||
### Public API
|
### Public API
|
||||||
|
|
||||||
- `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.register(definition: ToolDefinition): () => 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.restrict(filter: ToolRestriction): () => 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.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.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.guard(guard: ToolGuard): () => 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>` 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.
|
- `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
|
### Injected services
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ declare module 'cordis' {
|
|||||||
*/
|
*/
|
||||||
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||||
/**
|
/**
|
||||||
* Awaited notification of the authoritative FINAL tool outcome, after the
|
* Synchronous notification of the authoritative FINAL tool outcome, after the
|
||||||
* complete pre/execute/post pipeline, final lossless-JSON validation, and
|
* complete pre/execute/post pipeline, final lossless-JSON validation, and
|
||||||
* outer error normalization.
|
* outer error normalization.
|
||||||
* Unlike the three waterfalls, this seam cannot transform the result: each
|
* Unlike the three waterfalls, this seam cannot transform the result: each
|
||||||
@@ -158,9 +158,9 @@ declare module 'cordis' {
|
|||||||
* `exec.agent`, using the same carrier as the pipeline.
|
* `exec.agent`, using the same carrier as the pipeline.
|
||||||
* @param exec - the execution object that traversed the pipeline.
|
* @param exec - the execution object that traversed the pipeline.
|
||||||
* @param result - a deep-frozen snapshot of the final returned result.
|
* @param result - a deep-frozen snapshot of the final returned result.
|
||||||
* @mode parallel
|
* @mode emit
|
||||||
*/
|
*/
|
||||||
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void
|
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined
|
||||||
/**
|
/**
|
||||||
* A tool was registered or unregistered, or a scoped restriction changed
|
* A tool was registered or unregistered, or a scoped restriction changed
|
||||||
* (the available tool set changed — possibly for one scope only). An
|
* (the available tool set changed — possibly for one scope only). An
|
||||||
@@ -623,7 +623,7 @@ export class ToolRegistry extends Service {
|
|||||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||||
* yield it directly — exact identity nests the teardown in order.
|
* yield it directly — exact identity nests the teardown in order.
|
||||||
*/
|
*/
|
||||||
register(definition: ToolDefinition): () => Promise<void> | void {
|
register(definition: ToolDefinition): () => void {
|
||||||
const scope = scopeOf(this.ctx)
|
const scope = scopeOf(this.ctx)
|
||||||
const name = definition.name
|
const name = definition.name
|
||||||
const timeoutMs = definition.timeoutMs
|
const timeoutMs = definition.timeoutMs
|
||||||
@@ -669,8 +669,9 @@ export class ToolRegistry extends Service {
|
|||||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
// 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
|
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||||
// exact function identity, so a wrapper would silently break the nesting
|
// exact function identity, so a wrapper would silently break the nesting
|
||||||
// (the agents.register() lesson). Fire-and-forget callers may still
|
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||||
// discard the (always-resolved) promise.
|
// registration installs only synchronous state and notifications.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
return dispose
|
return dispose
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,7 +698,7 @@ export class ToolRegistry extends Service {
|
|||||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||||
* yield it directly — exact identity nests the teardown in order.
|
* yield it directly — exact identity nests the teardown in order.
|
||||||
*/
|
*/
|
||||||
restrict(filter: ToolRestriction): () => Promise<void> | void {
|
restrict(filter: ToolRestriction): () => void {
|
||||||
const scope = scopeOf(this.ctx)
|
const scope = scopeOf(this.ctx)
|
||||||
if (scope === undefined) {
|
if (scope === undefined) {
|
||||||
throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead')
|
throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead')
|
||||||
@@ -737,8 +738,9 @@ export class ToolRegistry extends Service {
|
|||||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
// 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
|
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||||
// exact function identity, so a wrapper would silently break the nesting
|
// exact function identity, so a wrapper would silently break the nesting
|
||||||
// (the agents.register() lesson). Fire-and-forget callers may still
|
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||||
// discard the (always-resolved) promise.
|
// registration installs only synchronous state and notifications.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
return dispose
|
return dispose
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,7 +754,7 @@ export class ToolRegistry extends Service {
|
|||||||
* @param guard - synchronous check; a returned string denies the execution.
|
* @param guard - synchronous check; a returned string denies the execution.
|
||||||
* @returns the exact disposer that unregisters the guard.
|
* @returns the exact disposer that unregisters the guard.
|
||||||
*/
|
*/
|
||||||
guard(guard: ToolGuard): () => Promise<void> | void {
|
guard(guard: ToolGuard): () => void {
|
||||||
const scope = scopeOf(this.ctx)
|
const scope = scopeOf(this.ctx)
|
||||||
const registration = { guard }
|
const registration = { guard }
|
||||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||||
@@ -763,6 +765,7 @@ export class ToolRegistry extends Service {
|
|||||||
if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
|
if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
|
||||||
}
|
}
|
||||||
}.bind(this), 'tools.guard()')
|
}.bind(this), 'tools.guard()')
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
return dispose
|
return dispose
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,7 +942,7 @@ export class ToolRegistry extends Service {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
execution = { ...base, arguments: undefined }
|
execution = { ...base, arguments: undefined }
|
||||||
const result = this.materializeFinalResult(toolErrorResult(callId, error))
|
const result = this.materializeFinalResult(toolErrorResult(callId, error))
|
||||||
await this.notifyResult(execution, result)
|
this.notifyResult(execution, result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
let result: ToolExecutionResult
|
let result: ToolExecutionResult
|
||||||
@@ -950,7 +953,7 @@ export class ToolRegistry extends Service {
|
|||||||
// waterfall machinery becomes an isError result, never a turn failure.
|
// waterfall machinery becomes an isError result, never a turn failure.
|
||||||
result = this.materializeFinalResult(toolErrorResult(execution.callId, error))
|
result = this.materializeFinalResult(toolErrorResult(execution.callId, error))
|
||||||
}
|
}
|
||||||
await this.notifyResult(execution, result)
|
this.notifyResult(execution, result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1018,20 +1021,20 @@ export class ToolRegistry extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
|
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
|
||||||
private async notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise<void> {
|
private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
|
||||||
// The pipeline is over: freeze the remaining mutable signal slot so every
|
// The pipeline is over: freeze the remaining mutable signal slot so every
|
||||||
// observer sees the SAME WeakMap-keyable execution without a mutation race.
|
// observer sees the SAME WeakMap-keyable execution without a mutation race.
|
||||||
Object.freeze(exec)
|
Object.freeze(exec)
|
||||||
const callbacks = this.ctx.events.dispatch('parallel', [
|
const callbacks = this.ctx.events.dispatch('emit', [
|
||||||
scopeTarget(this, exec.agent), 'tools/result', exec, result,
|
scopeTarget(this, exec.agent), 'tools/result', exec, result,
|
||||||
])
|
])
|
||||||
await Promise.all(callbacks.map(async (callback) => {
|
for (const callback of callbacks) {
|
||||||
try {
|
try {
|
||||||
await callback(exec, result)
|
callback(exec, result)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`)
|
this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`)
|
||||||
}
|
}
|
||||||
}))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ describe('mode-aware wire contribution', () => {
|
|||||||
expect(result.isError).toBe(false)
|
expect(result.isError).toBe(false)
|
||||||
expect(result.content).toEqual([{ type: 'text', text: 'echo' }])
|
expect(result.content).toEqual([{ type: 'text', text: 'echo' }])
|
||||||
|
|
||||||
await lift()
|
lift()
|
||||||
const unrestricted = await systemPrompt.assemble({ scope: agent })
|
const unrestricted = await systemPrompt.assemble({ scope: agent })
|
||||||
expect(unrestricted.tools.map(tool => tool.name)).toEqual(mode === 'code'
|
expect(unrestricted.tools.map(tool => tool.name)).toEqual(mode === 'code'
|
||||||
? [RUN_CODE_NAME]
|
? [RUN_CODE_NAME]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
|
import type { Events } from 'cordis'
|
||||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
@@ -50,6 +51,14 @@ async function run(ctx: Context, name: string, agent?: Agent): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('scoped tool registration', () => {
|
describe('scoped tool registration', () => {
|
||||||
|
it('keeps final-result observers synchronous', () => {
|
||||||
|
type ToolResultListener = Events['tools/result']
|
||||||
|
type AsyncToolResultListener = () => Promise<void>
|
||||||
|
|
||||||
|
expectTypeOf<AsyncToolResultListener>().not.toExtend<ToolResultListener>()
|
||||||
|
expectTypeOf<ReturnType<ToolResultListener>>().toEqualTypeOf<undefined>()
|
||||||
|
})
|
||||||
|
|
||||||
it('files a scoped tool in its layer: visible/executable for that scope only', async () => {
|
it('files a scoped tool in its layer: visible/executable for that scope only', async () => {
|
||||||
const ctx = await mount()
|
const ctx = await mount()
|
||||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||||
@@ -177,7 +186,7 @@ describe('restrict()', () => {
|
|||||||
const liftAllow = scope.ctx.tools.restrict({ allow: ['a', 'b'] })
|
const liftAllow = scope.ctx.tools.restrict({ allow: ['a', 'b'] })
|
||||||
scope.ctx.tools.restrict({ deny: ['b'] })
|
scope.ctx.tools.restrict({ deny: ['b'] })
|
||||||
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['a'])
|
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['a'])
|
||||||
await liftAllow()
|
liftAllow()
|
||||||
// The deny remains after the allow-list is lifted.
|
// The deny remains after the allow-list is lifted.
|
||||||
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c'])
|
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c'])
|
||||||
})
|
})
|
||||||
@@ -257,7 +266,7 @@ describe('scoped execution dispatch', () => {
|
|||||||
expect(await run(ctx, 't', other)).toBe('ran:t')
|
expect(await run(ctx, 't', other)).toBe('ran:t')
|
||||||
expect(bodyCalls).toBe(1)
|
expect(bodyCalls).toBe(1)
|
||||||
|
|
||||||
await liftFirst()
|
liftFirst()
|
||||||
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
|
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
|
||||||
await scope.dispose()
|
await scope.dispose()
|
||||||
expect(await run(ctx, 't', key)).toBe('ran:t')
|
expect(await run(ctx, 't', key)).toBe('ran:t')
|
||||||
@@ -607,7 +616,7 @@ describe('scoped execution dispatch', () => {
|
|||||||
const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
|
const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
|
||||||
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
|
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
|
||||||
expect(seen).toEqual([true, true])
|
expect(seen).toEqual([true, true])
|
||||||
expect(dispatchModes).toEqual(['parallel'])
|
expect(dispatchModes).toEqual(['emit'])
|
||||||
expect(warn).toHaveBeenCalledOnce()
|
expect(warn).toHaveBeenCalledOnce()
|
||||||
expect(String(warn.mock.calls[0]?.[0])).toContain('<unprintable thrown value>')
|
expect(String(warn.mock.calls[0]?.[0])).toContain('<unprintable thrown value>')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -677,7 +677,7 @@ describe('ToolRegistry', () => {
|
|||||||
const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' })
|
const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' })
|
||||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable'])
|
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable'])
|
||||||
|
|
||||||
await dispose()
|
dispose()
|
||||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -698,7 +698,7 @@ describe('ToolRegistry', () => {
|
|||||||
// exposed exactly once (the duplicate-name check is not wedged).
|
// exposed exactly once (the duplicate-name check is not wedged).
|
||||||
const dispose = ctx.tools.register(echoTool)
|
const dispose = ctx.tools.register(echoTool)
|
||||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
||||||
await dispose()
|
dispose()
|
||||||
expect(ctx.tools.get('echo')).toBeUndefined()
|
expect(ctx.tools.get('echo')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
|||||||
|
|
||||||
### Public API
|
### Public API
|
||||||
|
|
||||||
- `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.registerProvider(provider): () => 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.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.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.
|
- `ctx.skills.register(skill): () => 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
|
### Config
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export class SkillService extends Service {
|
|||||||
* @returns the exact Cordis effect disposer that unregisters this provider;
|
* @returns the exact Cordis effect disposer that unregisters this provider;
|
||||||
* composite effects may yield it directly to preserve teardown ordering.
|
* composite effects may yield it directly to preserve teardown ordering.
|
||||||
*/
|
*/
|
||||||
registerProvider(provider: SkillProvider): () => Promise<void> | void {
|
registerProvider(provider: SkillProvider): () => void {
|
||||||
const name = provider.name
|
const name = provider.name
|
||||||
if (name === RUNTIME_PROVIDER) {
|
if (name === RUNTIME_PROVIDER) {
|
||||||
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
|
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
|
||||||
@@ -210,6 +210,7 @@ export class SkillService extends Service {
|
|||||||
}
|
}
|
||||||
ctx.emit('skill/provider-added', provider)
|
ctx.emit('skill/provider-added', provider)
|
||||||
}, 'skills.registerProvider()')
|
}, 'skills.registerProvider()')
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
return dispose
|
return dispose
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +226,7 @@ export class SkillService extends Service {
|
|||||||
* contribution and invalidates caches; composite effects may yield it
|
* contribution and invalidates caches; composite effects may yield it
|
||||||
* directly to preserve teardown ordering.
|
* directly to preserve teardown ordering.
|
||||||
*/
|
*/
|
||||||
register(skill: SkillRegistration): () => Promise<void> | void {
|
register(skill: SkillRegistration): () => void {
|
||||||
validateRuntimeSkill(skill)
|
validateRuntimeSkill(skill)
|
||||||
const existing = this.runtime.get(skill.name)
|
const existing = this.runtime.get(skill.name)
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
@@ -245,6 +246,7 @@ export class SkillService extends Service {
|
|||||||
invalidateCache()
|
invalidateCache()
|
||||||
}
|
}
|
||||||
}, 'skills.register()')
|
}, 'skills.register()')
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
return dispose
|
return dispose
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ describe('SkillService registry', () => {
|
|||||||
},
|
},
|
||||||
})).toThrow('reserved')
|
})).toThrow('reserved')
|
||||||
|
|
||||||
await disposeMemory()
|
disposeMemory()
|
||||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
|
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -538,7 +538,7 @@ describe('SkillService registry', () => {
|
|||||||
path: 'memory://runtime-skill',
|
path: 'memory://runtime-skill',
|
||||||
metadata: { owner: 'tests' },
|
metadata: { owner: 'tests' },
|
||||||
})
|
})
|
||||||
await disposeRuntime()
|
disposeRuntime()
|
||||||
await ctx.skills.list({ cwd: '/tmp/first-cache-key' })
|
await ctx.skills.list({ cwd: '/tmp/first-cache-key' })
|
||||||
await ctx.skills.list({ cwd: '/tmp/second-cache-key' })
|
await ctx.skills.list({ cwd: '/tmp/second-cache-key' })
|
||||||
|
|
||||||
@@ -615,7 +615,7 @@ describe('SkillService registry', () => {
|
|||||||
|
|
||||||
const pending = ctx.skills.list()
|
const pending = ctx.skills.list()
|
||||||
await started
|
await started
|
||||||
await dispose()
|
dispose()
|
||||||
release?.()
|
release?.()
|
||||||
|
|
||||||
expect(await pending).toEqual([])
|
expect(await pending).toEqual([])
|
||||||
@@ -673,9 +673,9 @@ describe('SkillService registry', () => {
|
|||||||
|
|
||||||
const disposeFirst = ctx.skills.register({ name: 'same-skill', description: 'First', source: 'runtime', content: 'first' })
|
const disposeFirst = ctx.skills.register({ name: 'same-skill', description: 'First', source: 'runtime', content: 'first' })
|
||||||
const disposeSecond = ctx.skills.register({ name: 'same-skill', description: 'Second', source: 'runtime', content: 'second' })
|
const disposeSecond = ctx.skills.register({ name: 'same-skill', description: 'Second', source: 'runtime', content: 'second' })
|
||||||
await disposeSecond()
|
disposeSecond()
|
||||||
expect((await ctx.skills.get('same-skill'))?.description).toBe('First')
|
expect((await ctx.skills.get('same-skill'))?.description).toBe('First')
|
||||||
await disposeFirst()
|
disposeFirst()
|
||||||
expect(await ctx.skills.get('same-skill')).toBeUndefined()
|
expect(await ctx.skills.get('same-skill')).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
|||||||
// The capture COMMIT observes the immutable, authoritative result after the
|
// The capture COMMIT observes the immutable, authoritative result after the
|
||||||
// complete pipeline and outer error normalization. This notification cannot
|
// complete pipeline and outer error normalization. This notification cannot
|
||||||
// transform the outcome, so there is no wrapper outside the commit verdict.
|
// transform the outcome, so there is no wrapper outside the commit verdict.
|
||||||
childCtx.on('tools/result', function (this: unknown, exec, result): void {
|
childCtx.on('tools/result', function (this: unknown, exec, result) {
|
||||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||||
const entry = staged.get(exec)
|
const entry = staged.get(exec)
|
||||||
if (entry === undefined) return
|
if (entry === undefined) return
|
||||||
|
|||||||
@@ -742,7 +742,7 @@ describe('in-process structured output', () => {
|
|||||||
const run = await 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
|
// 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.
|
// from under the live child: the registration rides the CHILD's fiber.
|
||||||
await disposeProvider()
|
disposeProvider()
|
||||||
const result = await run.result
|
const result = await run.result
|
||||||
expect(result.structured).toEqual({ answer: 4 })
|
expect(result.structured).toEqual({ answer: 4 })
|
||||||
const child = ctx.agents.get(run.id)!
|
const child = ctx.agents.get(run.id)!
|
||||||
|
|||||||
@@ -134,8 +134,9 @@ export class SubagentService extends Service {
|
|||||||
* @param provider - the trusted provider implementation.
|
* @param provider - the trusted provider implementation.
|
||||||
* @returns the exact Cordis effect disposer.
|
* @returns the exact Cordis effect disposer.
|
||||||
*/
|
*/
|
||||||
registerProvider(provider: SubagentProvider): () => Promise<void> | void {
|
registerProvider(provider: SubagentProvider): () => void {
|
||||||
const name = provider.name
|
const name = provider.name
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||||
return this.ctx.effect(function* (this: SubagentService) {
|
return this.ctx.effect(function* (this: SubagentService) {
|
||||||
if (this.providers.has(name)) {
|
if (this.providers.has(name)) {
|
||||||
throw new SubagentError(`a subagent provider named "${name}" is already registered`, 'DUPLICATE_PROVIDER')
|
throw new SubagentError(`a subagent provider named "${name}" is already registered`, 'DUPLICATE_PROVIDER')
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe('SubagentService', () => {
|
|||||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||||
expect(provider.startCount).toBe(1)
|
expect(provider.startCount).toBe(1)
|
||||||
|
|
||||||
await dispose()
|
dispose()
|
||||||
expect(added).toEqual(['alpha'])
|
expect(added).toEqual(['alpha'])
|
||||||
expect(removed).toEqual(['alpha'])
|
expect(removed).toEqual(['alpha'])
|
||||||
expect(subagents.getProvider('alpha')).toBeUndefined()
|
expect(subagents.getProvider('alpha')).toBeUndefined()
|
||||||
@@ -213,7 +213,7 @@ describe('SubagentService', () => {
|
|||||||
ctx.on('subagent/provider-removed', name => void heard.push(name))
|
ctx.on('subagent/provider-removed', name => void heard.push(name))
|
||||||
const dispose = subagents.registerProvider(new StubProvider('contained'))
|
const dispose = subagents.registerProvider(new StubProvider('contained'))
|
||||||
|
|
||||||
await dispose()
|
dispose()
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
expect(heard).toEqual(['contained'])
|
expect(heard).toEqual(['contained'])
|
||||||
expect(warnings.some(message => message.includes('sync boom'))).toBe(true)
|
expect(warnings.some(message => message.includes('sync boom'))).toBe(true)
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
// available — deriving the wording from THAT provider — and unregister it
|
// available — deriving the wording from THAT provider — and unregister it
|
||||||
// when the provider goes away, so the description can never outlive or
|
// when the provider goes away, so the description can never outlive or
|
||||||
// predate the provider it describes.
|
// predate the provider it describes.
|
||||||
let disposeTool: (() => Promise<void> | void) | undefined
|
let disposeTool: (() => void) | undefined
|
||||||
const mount = (provider: SubagentProvider): void => {
|
const mount = (provider: SubagentProvider): void => {
|
||||||
const wording = providerWording(provider.inheritsParentContext)
|
const wording = providerWording(provider.inheritsParentContext)
|
||||||
disposeTool = ctx.tools.register(defineTool({
|
disposeTool = ctx.tools.register(defineTool({
|
||||||
@@ -283,7 +283,7 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
})
|
})
|
||||||
ctx.on('subagent/provider-removed', (name) => {
|
ctx.on('subagent/provider-removed', (name) => {
|
||||||
if (name !== config.provider || disposeTool === undefined) return
|
if (name !== config.provider || disposeTool === undefined) return
|
||||||
void disposeTool()
|
disposeTool()
|
||||||
disposeTool = undefined
|
disposeTool = undefined
|
||||||
})
|
})
|
||||||
const present = ctx.subagents.getProvider(config.provider)
|
const present = ctx.subagents.getProvider(config.provider)
|
||||||
|
|||||||
@@ -262,8 +262,8 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str
|
|||||||
// Session disposal uses direct callback resolution so teardown contains each
|
// Session disposal uses direct callback resolution so teardown contains each
|
||||||
// synchronous throw and returned-promise rejection independently.
|
// synchronous throw and returned-promise rejection independently.
|
||||||
{ event: 'session/disposed', pkg: 'session', method: 'events.dispatch' },
|
{ event: 'session/disposed', pkg: 'session', method: 'events.dispatch' },
|
||||||
// tools/result uses ctx.events.dispatch directly so the registry can await
|
// tools/result uses ctx.events.dispatch directly so the registry can invoke
|
||||||
// every observer while containing each callback independently.
|
// every synchronous observer while containing each callback independently.
|
||||||
{ event: 'tools/result', pkg: 'tools', method: 'events.dispatch' },
|
{ event: 'tools/result', pkg: 'tools', method: 'events.dispatch' },
|
||||||
// Subagent lifecycle events intentionally bypass ctx.emit and call
|
// Subagent lifecycle events intentionally bypass ctx.emit and call
|
||||||
// ctx.events.dispatch directly so one throwing listener cannot starve later
|
// ctx.events.dispatch directly so one throwing listener cannot starve later
|
||||||
@@ -779,7 +779,7 @@ function renderToolPipeline(): string {
|
|||||||
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
|
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
|
||||||
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
|
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
|
||||||
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
|
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
|
||||||
` final["${mermaidCode('tools/result')} parallel notification<br/>frozen authoritative outcome"]`,
|
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
|
||||||
' context["Buffered additionalContext<br/>context/message after all tool results"]',
|
' context["Buffered additionalContext<br/>context/message after all tool results"]',
|
||||||
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
|
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
|
||||||
' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
|
' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
|
||||||
@@ -808,7 +808,7 @@ function renderToolPipeline(): string {
|
|||||||
' allResults --> context',
|
' allResults --> context',
|
||||||
'```',
|
'```',
|
||||||
'',
|
'',
|
||||||
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The awaited `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).',
|
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The synchronous `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).',
|
||||||
'',
|
'',
|
||||||
...maintenanceFooter(maintenance),
|
...maintenanceFooter(maintenance),
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|||||||
Reference in New Issue
Block a user