From e85e21c8b05fb74e3c95723c50a644da4b4d7ed0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:42:48 +0800 Subject: [PATCH] fix(review): close interpolation strictness holes; make tool-subagent mirror provider lifecycle Codex round-1 findings, both confirmed: - renderPrompt: variable lookup now uses Object.hasOwn (an unregistered {{constructor}} previously resolved through Object.prototype and spliced function source into the prompt), and a {{ that opens no complete group while a }} still follows ({{{model}}}, {{a{b}}) now throws instead of passing or partially interpolating. A lone {{ with no }} after it stays verbatim; substituted values are never re-scanned. - tool-subagent: the apply-time provider lookup assumed a load order the cordis Loader does not guarantee (siblings start concurrently). The seam now announces subagent/provider-added/-removed and the tool mirrors the provider's lifecycle: registers when the provider is (or becomes) available, unregisters when it goes away, re-derives wording on reload. No load-order requirement remains. - loop.spec containment test now proves live continuation: after the contained render failure, a waterfall listener rescues {{cwd}} and the same agent completes a real model turn. RFC/READMEs updated to the shipped contract; cordis catalog regenerated. --- docs/cordis-catalog/events-and-services.md | 28 ++- ...t-variables-and-tool-guidance-ownership.md | 13 +- packages/core/agent-loop/tests/loop.spec.ts | 24 ++- packages/core/system-prompt/README.md | 2 +- packages/core/system-prompt/src/index.ts | 45 +++-- .../system-prompt/tests/system-prompt.spec.ts | 40 ++++- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/src/index.ts | 28 ++- .../subagent/subagent/tests/service.spec.ts | 32 ++++ packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 167 ++++++++++-------- .../tool-subagent/tests/tool-subagent.spec.ts | 53 +++++- 12 files changed, 328 insertions(+), 108 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 8433f5c9ab..6eede54b97 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -245,7 +245,27 @@ A subagent run settled — emitted when SubagentRun.result resolves (any stop re 'subagent/end'(info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:77`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:96`](../../packages/subagent/subagent/src/index.ts) + +#### `subagent/provider-added` — emit + +A provider became resolvable in the SubagentService registry. Consumers that derive state from a named provider (e.g. the model-facing tool wording in `dsh-tool-subagent`) react HERE instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier in cordis.yml" does not mean "registered earlier". + +```ts cordis-catalog +'subagent/provider-added'(provider: SubagentProvider): void +``` + +Source: [`packages/subagent/subagent/src/index.ts:72`](../../packages/subagent/subagent/src/index.ts) + +#### `subagent/provider-removed` — emit + +A provider left the registry (its plugin's fiber was disposed — an unload or an HMR reload). Consumers holding provider-derived state drop it here; a reload re-fires `subagent/provider-added` with the fresh provider. + +```ts cordis-catalog +'subagent/provider-removed'(name: string): void +``` + +Source: [`packages/subagent/subagent/src/index.ts:81`](../../packages/subagent/subagent/src/index.ts) #### `subagent/start` — emit @@ -255,7 +275,7 @@ A subagent run started — emitted after the provider is resolved and its capabi 'subagent/start'(info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:70`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:89`](../../packages/subagent/subagent/src/index.ts) ### `system-prompt/*` @@ -486,7 +506,7 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts) ### `ctx.systemPrompt` — `SystemPrompt` @@ -499,7 +519,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:149`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:174`](../../packages/core/system-prompt/src/index.ts) ### `ctx.tools` — `ToolRegistry` diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 14b9990881..0802e56aac 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -24,7 +24,7 @@ The assembled system prompt had four defects, all of one family: facts the harne ### Prompt variables -Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; prompt text references them as `{{name}}`. Providers are functions of the `AssembleContext` and may return `undefined` — "no value for THIS assembly". `assemble()` resolves every registered variable into `PromptAssembly.variables` (waterfall listeners can see, add, or override); `renderPrompt` interpolates. Rendering is STRICT — fail loud beats shipping a malformed prompt: a reference to an unregistered name throws (listing what exists), a registered-but-valueless reference throws, and a complete `{{…}}` group that is not a well-formed name (`[a-z][a-z0-9_]*`, e.g. `{{ model }}`) throws. Only complete double-brace groups are interpreted; a lone `{{` passes through verbatim. Registration rejects duplicate and unreferenceable names, mirroring the tool registry — and `section()` now rejects duplicate section names, making the documented dedup real. +Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; prompt text references them as `{{name}}`. Providers are functions of the `AssembleContext` and may return `undefined` — "no value for THIS assembly". `assemble()` resolves every registered variable into `PromptAssembly.variables` (waterfall listeners can see, add, or override); `renderPrompt` interpolates. Rendering is STRICT — fail loud beats shipping a malformed prompt: a reference to an unregistered name throws (listing what exists; lookup is `Object.hasOwn`, so a prototype property like `{{constructor}}` is unknown, not a function spliced into the prompt), a registered-but-valueless reference throws, a complete `{{…}}` group that is not a well-formed name (`[a-z][a-z0-9_]*`, e.g. `{{ model }}`) throws, and a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`, `{{a{b}}`) throws. A lone `{{` with no `}}` anywhere after it is ordinary prose and passes through verbatim; substituted values are never re-scanned. Registration rejects duplicate and unreferenceable names, mirroring the tool registry — and `section()` now rejects duplicate section names, making the documented dedup real. `dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). @@ -38,7 +38,7 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship ### The subagent context contract -`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child inherits the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. `apply` resolves the provider at LOAD time and throws if it is not registered — the backend plugin must be listed before the tool plugin in `cordis.yml`; a wiring mistake fails loudly at boot instead of shipping a lying description. +`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child inherits the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Because the description is fixed at tool registration while providers arrive on their own fibers, the registry announces provider lifecycle (`subagent/provider-added`/`subagent/provider-removed`) and the tool MIRRORS it: it registers when its provider is (or becomes) available, unregisters when the provider goes away, and re-derives the wording on re-registration (HMR). There is deliberately NO load-order requirement — the cordis Loader starts sibling entries concurrently (`Promise.all` over the group), so "listed first" never guaranteed "registered first"; while the provider is absent the tool does not exist, which cannot lie. ## Rejected alternatives @@ -47,12 +47,13 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship - **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this RFC cures. - **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. - **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, the P2 disease again. **Keying wording off the provider NAME** — `providerName` is itself config, so a renamed provider silently gets the wrong words. -- **Resolving the subagent flag lazily (section-only wording)** — would tolerate any load order, but the DESCRIPTION is fixed at tool registration and is where tool-choice guidance belongs; a deterministic load-order requirement with a loud, actionable failure is the smaller cost. +- **Resolving the provider at `apply` time and throwing when absent (a load-order requirement)** — the first implementation. Rejected after review reproduced the failure: the Loader starts sibling entries concurrently and `Entry.init()` does not await activation, so a backend whose activation is delayed leaves the tool's fiber permanently failed even when "listed first" — the ordering the requirement leaned on is not a contract the Loader offers ("async state is not synchronous state"). Provider-lifecycle events make the ordering question disappear instead of documenting it. +- **Section-only subagent wording (lazily resolved at assemble)** — would also tolerate any load order, but the DESCRIPTION is fixed at tool registration and is where tool-choice guidance belongs; reactive registration keeps the description authoritative AND order-free. ## What we give up - `{{model}}` reflects `AgentOptions.model` at assembly time. A plugin that switches models in the `agent/request` waterfall makes the prompt's claim stale for that step; such a plugin can rewrite `options.system` in the same waterfall if it cares. Accepted. -- `dsh-tool-subagent` now has a hard load-order requirement on its backend. The examples already ordered backends first; the failure mode is an immediate boot error naming the fix. +- While a bound provider is absent (not yet activated, unloaded, mid-HMR-reload), the subagent tool does not exist and a model request in that window simply lacks it. That is the honest state — the alternative was a registered tool whose description or execution could not be trusted. - Strictness means a persona can fail a turn at render (e.g. `{{cwd}}` on a cwd-less session). The failure is contained — the turn ends `error`, the loop survives — and it is an authoring error we WANT loud. - No escape syntax for a literal `{{name}}` in prompt prose yet; add one if a real prompt ever needs it. @@ -64,6 +65,6 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship ## Acceptance criteria - `renderPrompt(assemble({agent}))` for the coding-agent example contains the persona FIRST (with the agent's model name interpolated), then fs/bash/web guidance sections; the loop contains no other prompt-composition path. -- The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. Loading `dsh-tool-subagent` before its backend fails at load with a message naming the ordering fix. -- Unknown/valueless/malformed `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw. +- The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. The tool follows its provider: absent before the backend activates, present after, gone when the backend unloads, re-worded from the fresh provider on reload. +- Unknown/valueless/malformed/unbalanced `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw. - The gating runs (`test:coverage`, `test:snapshot`, `doc-sync`, `build`, `hygiene`) are green; no golden re-record is needed (replay never re-verifies the outgoing request). diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 014ddb548c..6a0763814b 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -182,11 +182,13 @@ describe('agent loop', () => { expect(adapter.requests[0]!.system).toBe('Working in /work/space.') }) - it('contains a strict-variable render failure: the turn errors, the loop survives', async () => { + it('contains a strict-variable render failure: the turn errors, the loop keeps serving turns', async () => { // A persona claiming {{cwd}} on a session with NO cwd is a deployment // authoring error — renderPrompt throws, the turn ends with an error, and - // the agent (and loop) stay alive for the next prompt. - const adapter = new MockAdapter([textResponse('never reached'), textResponse('ok')]) + // the same agent must then RUN a later turn to completion (not merely + // report idle status): a rescue listener supplies the variable and the + // follow-up prompt reaches the model. + const adapter = new MockAdapter([textResponse('ok after rescue')]) const ctx = await harness(adapter) const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) @@ -199,7 +201,21 @@ describe('agent loop', () => { expect(errors.some(e => e.message.includes('no value for this assembly'))).toBe(true) const turnEnd = agent.session.events.find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error') - expect(agent.status).toBe('idle') // contained: the loop is still serving + + // The loop survived: a waterfall listener rescues {{cwd}} and the SAME + // agent completes a real model turn. + ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + assembly.variables['cwd'] = '/rescued' + return next() + }) + send(agent, 'again') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(1) + expect(adapter.requests[0]!.system).toBe('In /rescued.') + const turnEnds = agent.session.events.filter(e => e.type === 'turn/end') + expect(turnEnds).toHaveLength(2) + expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed') }) it('records raw chunks for replay as assistant/chunk session events', async () => { diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 28d382f54a..6207571618 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -23,7 +23,7 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- - `AssembleContext` — what one `assemble()` call is FOR. Declared empty here and merge-extensible; `dsh-agent` declares `agent?: Agent`, so providers project per-agent facts. Providers must tolerate absent fields (a bare `assemble()` carries an empty context). - `PromptSection` — `{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `0` is the per-agent persona (registered by the agent loop), tool guidance uses `100–199`; negative orders render before the persona. - `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. -- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference, a registered-but-valueless reference, or a malformed complete `{{…}}` group throws (fail loud beats shipping a malformed prompt). Only complete double-brace groups are interpreted; a lone `{{` passes through verbatim. +- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging. diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 3faf2373fa..62889a2793 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -101,8 +101,8 @@ export interface PromptAssembly { /** Valid variable names: how they are written between the braces. */ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ -/** A complete `{{...}}` reference group (any inner content, validated after). */ -const REFERENCE = /\{\{([^{}]*)\}\}/g +/** A complete `{{...}}` reference group at the scan position (validated after). */ +const GROUP_AT = /^\{\{([^{}]*)\}\}/ /** * Renders the text part of an assembly: interpolates `{{variable}}` @@ -111,10 +111,11 @@ const REFERENCE = /\{\{([^{}]*)\}\}/g * * Strict by design (fail loud beats shipping a malformed prompt): a reference * to an unregistered variable, to a registered variable with no value for - * this assembly, or a complete `{{...}}` group that is not a well-formed - * variable name (e.g. `{{ model }}`) throws. Only complete double-brace - * groups are interpreted; a lone `{{` without a closing `}}` passes through - * verbatim. + * this assembly, a complete `{{…}}` group that is not a well-formed variable + * name (e.g. `{{ model }}`), or a `{{` that does not open a complete group + * while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A + * lone `{{` with no `}}` anywhere after it is ordinary prose and passes + * through verbatim. Substituted values are never re-scanned. */ export function renderPrompt(assembly: PromptAssembly): string { return assembly.sections @@ -125,11 +126,33 @@ export function renderPrompt(assembly: PromptAssembly): string { /** Interpolate one section's `{{variable}}` references (see {@link renderPrompt}). */ function interpolate(section: AssembledSection, variables: Record): string { - return section.text.replace(REFERENCE, (_match, name: string) => { + const text = section.text + let result = '' + let last = 0 + for (let open = text.indexOf('{{'); open >= 0; open = text.indexOf('{{', last)) { + const group = GROUP_AT.exec(text.slice(open)) + if (group === null) { + // No complete simple group starts at this `{{`. A `}}` further on means + // a mangled reference (extra or nested braces) — fail loud. With no + // closing `}}` anywhere after, it is ordinary prose (shell, JSON) and + // passes through verbatim. + if (text.indexOf('}}', open + 2) >= 0) { + throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in section "${section.name}" (references are complete simple {{name}} groups)`) + } + result += text.slice(last, open + 2) + last = open + 2 + continue + } + // group[0] is the whole `{{...}}` match (a plain string, no optional + // index): the name is its interior. `{{}}` yields '' → the malformed path. + const name = group[0].slice(2, -2) if (!VARIABLE_NAME.test(name)) { throw new Error(`malformed prompt variable reference "{{${name}}}" in section "${section.name}" (variable names match ${String(VARIABLE_NAME)})`) } - if (!(name in variables)) { + // Object.hasOwn, NOT `in`: `in` walks the prototype chain, so an + // unregistered `{{constructor}}` would resolve to Object.prototype's and + // splice a function's source text into the prompt instead of throwing. + if (!Object.hasOwn(variables, name)) { const known = Object.keys(variables) throw new Error(`unknown prompt variable "{{${name}}}" in section "${section.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`) } @@ -137,8 +160,10 @@ function interpolate(section: AssembledSection, variables: Record { })).toThrow('malformed prompt variable reference "{{ model }}" in section "s"') }) - it('leaves a lone {{ without a closing }} verbatim (only complete groups are interpreted)', () => { + it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => { const text = renderPrompt({ sections: [{ name: 's', order: 0, text: 'shell ${X:-{{fallback} stays' }], tools: [], @@ -333,5 +333,43 @@ describe('SystemPrompt', () => { }) expect(text).toBe('shell ${X:-{{fallback} stays') }) + + it.each([ + { text: '{{{model}}}', label: 'extra outer braces' }, + { text: 'x {{a{b}} y {{model}}', label: 'nested brace inside a would-be group' }, + ])('throws on a mangled reference with a }} still following ($label)', ({ text }) => { + expect(() => renderPrompt({ + sections: [{ name: 's', order: 0, text }], + tools: [], + variables: { model: 'm' }, + })).toThrow('malformed prompt variable reference at') + }) + + it('rejects {{constructor}} as UNKNOWN — prototype properties are not variables', () => { + // `in` would find Object.prototype.constructor and splice function + // source into the prompt; Object.hasOwn must reject it instead. + expect(() => renderPrompt({ + sections: [{ name: 's', order: 0, text: 'on {{constructor}}' }], + tools: [], + variables: { model: 'm' }, + })).toThrow('unknown prompt variable "{{constructor}}"') + }) + + it('a variable NAMED like a prototype property works once actually registered', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 's', order: 0, text: '{{constructor}}' }) + ctx.systemPrompt.variable('constructor', () => 'own-value') + expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe('own-value') + }) + + it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => { + const text = renderPrompt({ + sections: [{ name: 's', order: 0, text: 'v = {{model}}!' }], + tools: [], + variables: { model: 'literal {{sneaky}} inside' }, + }) + expect(text).toBe('v = literal {{sneaky}} inside!') + }) }) }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 9ccfb249d8..8bab4c61c9 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -34,7 +34,7 @@ Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: ` `provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. -The service emits `subagent/start` (payload `SubagentRunInfo`) and `subagent/end` (payload `SubagentRunEndInfo`) around the run — both **observe-only** (plain `emit`s; `subagent/end` fires from a detached `.then` and awaits no listener). `subagent/end` carries `lastAssistantMessage` (a deep clone of the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. The clone keeps the surface observe-only: the end emit fires from a detached `.then` before the caller's `await run.result` resumes, so a shared reference would let a mutating listener corrupt the caller's result. A `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)`; a `subagent/end` listener can only observe (the run has settled). Any run-affecting decision (continuation, injection that changes the run) is out of scope for this observe-only surface. +The service also announces provider lifecycle: `subagent/provider-added` (the live provider) fires after a registration and `subagent/provider-removed` (the name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". The service emits `subagent/start` (payload `SubagentRunInfo`) and `subagent/end` (payload `SubagentRunEndInfo`) around the run — both **observe-only** (plain `emit`s; `subagent/end` fires from a detached `.then` and awaits no listener). `subagent/end` carries `lastAssistantMessage` (a deep clone of the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. The clone keeps the surface observe-only: the end emit fires from a detached `.then` before the caller's `await run.result` resumes, so a shared reference would let a mutating listener corrupt the caller's result. A `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)`; a `subagent/end` listener can only observe (the run has settled). Any run-affecting decision (continuation, injection that changes the run) is out of scope for this observe-only surface. ## Scope (first cut) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index b0514edcac..a9541eabce 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -60,6 +60,25 @@ declare module 'cordis' { } interface Events { + /** + * A provider became resolvable in the {@link SubagentService} registry. + * Consumers that derive state from a named provider (e.g. the model-facing + * tool wording in `dsh-tool-subagent`) react HERE instead of assuming load + * order — the cordis Loader starts sibling plugins concurrently, so + * "listed earlier in cordis.yml" does not mean "registered earlier". + * @param provider - the provider that just registered, live in the registry. + * @mode emit + */ + 'subagent/provider-added'(provider: SubagentProvider): void + /** + * A provider left the registry (its plugin's fiber was disposed — an + * unload or an HMR reload). Consumers holding provider-derived state drop + * it here; a reload re-fires `subagent/provider-added` with the fresh + * provider. + * @param name - the registry name that no longer resolves. + * @mode emit + */ + 'subagent/provider-removed'(name: string): void /** * A subagent run started — emitted after the provider is resolved and its * capabilities validated, as the child run begins. Paired with @@ -130,7 +149,9 @@ export class SubagentService extends Service { /** * Register a provider under its `provider.name`. Throws {@link SubagentError} * (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed - * with the calling fiber (HMR-safe). + * with the calling fiber (HMR-safe). Emits `subagent/provider-added` after + * the registration and `subagent/provider-removed` on unregistration, so + * consumers can mirror provider lifecycle instead of assuming load order. * @param provider - the provider; its `name` is the registry key. * @returns the disposer that unregisters the provider. */ @@ -140,9 +161,14 @@ export class SubagentService extends Service { throw new SubagentError(`a subagent provider named "${provider.name}" is already registered`, 'DUPLICATE_PROVIDER') } this.providers.set(provider.name, provider) + // Yield the rollback BEFORE emitting `subagent/provider-added`: a + // throwing added-listener then unregisters the provider (and announces + // the removal) instead of leaking it into the registry. yield () => { this.providers.delete(provider.name) + this.ctx.emit('subagent/provider-removed', provider.name) } + this.ctx.emit('subagent/provider-added', provider) }.bind(this), 'subagents.registerProvider()') // ctx.effect's disposer returns Promise; our disposer API is // synchronous fire-and-forget — discard the (always-resolved) promise. diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 6aa5dfe021..6b5e737a3d 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -45,6 +45,38 @@ function baseRequest(overrides: Partial = {}): SubagentSta } describe('SubagentService', () => { + it('announces provider lifecycle: added on register, removed on dispose', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const added: string[] = [] + const removed: string[] = [] + ctx.on('subagent/provider-added', provider => void added.push(provider.name)) + ctx.on('subagent/provider-removed', name => void removed.push(name)) + + const dispose = ctx.subagents.registerProvider(new StubProvider('alpha')) + expect(added).toEqual(['alpha']) + expect(removed).toEqual([]) + + dispose() + expect(removed).toEqual(['alpha']) + }) + + it('rolls back the registration when a provider-added listener throws', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + let threw = false + const off = ctx.on('subagent/provider-added', () => { + if (!threw) { threw = true; throw new Error('boom added listener') } + }) + + expect(() => ctx.subagents.registerProvider(new StubProvider('alpha'))).toThrow('boom added listener') + expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // nothing leaked + + off() + ctx.subagents.registerProvider(new StubProvider('alpha')) + expect(ctx.subagents.getProvider('alpha')).toBeDefined() + }) + it('registers a provider and starts a run on it by name', async () => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index df00f6f169..fd04fba3b2 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -8,7 +8,7 @@ This plugin binds to **exactly one** provider (`Config.provider`). The model see ## The description states the provider's context contract -The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-context provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), an inheriting provider (fork) tells the model the child already sees the conversation's completed turns and its prompt should state only what is new. Because the description is fixed at tool registration, `apply` resolves the provider at LOAD time and **throws if it is not registered yet — list the backend plugin before this one in `cordis.yml`**; a wiring mistake fails loudly at boot instead of shipping a lying description. +The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-context provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), an inheriting provider (fork) tells the model the child already sees the conversation's completed turns and its prompt should state only what is new. Because the description is fixed at tool registration, the tool **mirrors the provider's lifecycle** (`subagent/provider-added`/`-removed`): it registers when the bound provider is (or becomes) available and unregisters when the provider goes away — no load-order requirement (the cordis Loader starts sibling entries concurrently, so "listed first" never guaranteed "registered first"), and an HMR reload of the backend re-derives the wording from the fresh provider. While the provider is absent the tool simply does not exist (a `ctx.logger` note records the wait; a typo'd provider name shows up as a tool that never materializes). | Config key | Meaning | |---|---| diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 766e3711f3..357172db9a 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -14,9 +14,11 @@ * The tool DESCRIPTION is derived from the bound provider's context contract * ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the * standalone-prompt wording, an inheriting provider (fork) tells the model the - * child already sees the conversation's completed turns. `apply` therefore - * resolves the provider at load time and throws if it is not registered yet — - * list the backend plugin before this one in `cordis.yml`. + * child already sees the conversation's completed turns. The tool MIRRORS the + * provider's lifecycle via `subagent/provider-added`/`-removed` — it registers + * when the provider is (or becomes) available and unregisters when the + * provider goes away — so no load-order requirement exists and an HMR reload + * of the backend re-derives the wording from the fresh provider. * * Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits * `run.result` inside a `try/finally` that always disposes the run, so the @@ -33,7 +35,7 @@ import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' export const name = 'tool-subagent' export const inject = ['tools', 'subagents'] @@ -136,73 +138,96 @@ export function providerWording(inherits: boolean): { description: string; promp } export function apply(ctx: Context, config: Config): void { - // Resolve the bound provider NOW: the tool description must state the - // provider's context contract, so the backend plugin must be loaded before - // this one (list it earlier in cordis.yml). Fail loud at load, not with a - // lying description at model time. - const provider = ctx.subagents.getProvider(config.provider) - if (provider === undefined) { - throw new Error( - `subagent provider "${config.provider}" is not registered; load its backend plugin before tool-subagent`) - } - const wording = providerWording(provider.inheritsParentContext) - ctx.tools.register(defineTool({ - name: config.toolName ?? 'subagent', - description: wording.description, - parameters: { - description: { - type: 'string', - required: true, - description: 'A short (3-5 word) description of the delegated task, for display.', + // The tool MIRRORS its provider's lifecycle instead of assuming load order: + // the cordis Loader starts sibling entries concurrently, so "backend listed + // first in cordis.yml" does not guarantee "provider registered first", and + // an HMR reload of the backend replaces the provider while this fiber stays + // loaded. Register the tool when the bound provider is (or becomes) + // available — deriving the wording from THAT provider — and unregister it + // when the provider goes away, so the description can never outlive or + // predate the provider it describes. + let disposeTool: (() => void) | undefined + const mount = (provider: SubagentProvider): void => { + const wording = providerWording(provider.inheritsParentContext) + disposeTool = ctx.tools.register(defineTool({ + name: config.toolName ?? 'subagent', + description: wording.description, + parameters: { + description: { + type: 'string', + required: true, + description: 'A short (3-5 word) description of the delegated task, for display.', + }, + prompt: { + type: 'string', + required: true, + description: wording.promptDescription, + }, }, - prompt: { - type: 'string', - required: true, - description: wording.promptDescription, - }, - }, - async execute(args, exec): Promise { - const parent = exec.agent - if (!parent) { - // The loop sets `exec.agent` for every model-driven call; its absence - // means a non-agent caller invoked the tool directly, which has no - // parent to attribute the child to. Fail loud rather than guess. - throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') - } - - const request: SubagentStartRequest = { - prompt: [{ type: 'text', text: args.prompt }], - parent, - ...exec.signal ? { signal: exec.signal } : {}, - ...config.agentOptions ? { agentOptions: config.agentOptions } : {}, - } - - const run: SubagentRun = ctx.subagents.start(config.provider, request) - - // Bridge the tool's abort signal to the run: if the parent step is - // aborted while the child is in flight, cancel the child too. - const onAbort = (): void => { run.cancel('parent step aborted') } - exec.signal?.addEventListener('abort', onAbort, { once: true }) - // `addEventListener` does NOT fire for a signal already aborted before this - // line, so a step cancelled before the tool ran would never reach the - // child. Cancel explicitly in that case — the bridge must honor an - // already-aborted signal, not lean on each provider re-checking it. - if (exec.signal?.aborted) run.cancel('parent step aborted') - - try { - const result = await run.result - const error = stopReasonError(result) - if (error !== undefined) { - // Map a non-clean finish to an isError result (the registry turns a - // throw into an isError). Report the reason, not partial output. - throw new Error(error) + async execute(args, exec): Promise { + const parent = exec.agent + if (!parent) { + // The loop sets `exec.agent` for every model-driven call; its absence + // means a non-agent caller invoked the tool directly, which has no + // parent to attribute the child to. Fail loud rather than guess. + throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') } - return [{ type: 'text', text: outputText(result.output) }] - } finally { - exec.signal?.removeEventListener('abort', onAbort) - // Always reach child quiescence — never leak a live idle child/session. - await run.dispose() - } - }, - })) + + const request: SubagentStartRequest = { + prompt: [{ type: 'text', text: args.prompt }], + parent, + ...exec.signal ? { signal: exec.signal } : {}, + ...config.agentOptions ? { agentOptions: config.agentOptions } : {}, + } + + const run: SubagentRun = ctx.subagents.start(config.provider, request) + + // Bridge the tool's abort signal to the run: if the parent step is + // aborted while the child is in flight, cancel the child too. + const onAbort = (): void => { run.cancel('parent step aborted') } + exec.signal?.addEventListener('abort', onAbort, { once: true }) + // `addEventListener` does NOT fire for a signal already aborted before this + // line, so a step cancelled before the tool ran would never reach the + // child. Cancel explicitly in that case — the bridge must honor an + // already-aborted signal, not lean on each provider re-checking it. + if (exec.signal?.aborted) run.cancel('parent step aborted') + + try { + const result = await run.result + const error = stopReasonError(result) + if (error !== undefined) { + // Map a non-clean finish to an isError result (the registry turns a + // throw into an isError). Report the reason, not partial output. + throw new Error(error) + } + return [{ type: 'text', text: outputText(result.output) }] + } finally { + exec.signal?.removeEventListener('abort', onAbort) + // Always reach child quiescence — never leak a live idle child/session. + await run.dispose() + } + }, + })) + } + + // Listeners first, then the presence check: both run synchronously, so no + // registration can slip between them; the `disposeTool === undefined` guard + // makes a same-tick added-event after a successful mount a no-op. + ctx.on('subagent/provider-added', (provider) => { + if (provider.name === config.provider && disposeTool === undefined) mount(provider) + }) + ctx.on('subagent/provider-removed', (name) => { + if (name !== config.provider || disposeTool === undefined) return + disposeTool() + disposeTool = undefined + }) + const present = ctx.subagents.getProvider(config.provider) + if (present !== undefined) { + mount(present) + } else { + // Not an error: the backend's fiber may simply activate after this one. + // The tool appears the moment the provider registers; a typo'd provider + // name shows up as this note plus a tool that never materializes. + ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) + } } diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 8f00c01366..3756806de3 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -195,19 +195,56 @@ describe('dsh-tool-subagent', () => { expect(text(result)).toContain('requires a calling agent') }) - it('fails loud AT LOAD when the bound provider is not registered (backend must load first)', async () => { - // The tool description states the provider's context contract, so apply() - // resolves the provider at load time — a missing backend is a wiring error - // surfaced immediately, not a lying description discovered at model time. + it('registers when the provider appears LATER — no load-order requirement (Loader starts siblings concurrently)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - await expect(async () => { - await ctx.plugin(tool, { provider: 'does-not-exist' }) - await new Promise(r => setTimeout(r, 20)) - }).rejects.toThrow('is not registered; load its backend plugin before tool-subagent') + // Tool first: no provider yet — the tool must be absent, not broken. + // Direct apply (schema bypass): also covers the waiting-note's default + // toolName fallback, which validated config pre-fills. + tool.apply(ctx, { provider: 'mock' }) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) + // Backend arrives (as a delayed sibling fiber would): the tool appears. + await ctx.plugin(mock, { name: 'mock', reply: 'late but fine' }) + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(text(result)).toBe('late but fine') + }) + + it('mirrors the provider lifecycle: gone on backend dispose, re-derived wording on re-registration', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + const backend = await ctx.plugin(mock, { name: 'mock' }) // spawn-shaped (inherits: false) + await ctx.plugin(tool, { provider: 'mock' }) + expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation') + + // Backend unloads (HMR shape): the tool must not outlive its provider. + await backend.dispose() + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) + + // Backend reloads with a DIFFERENT contract: the wording is re-derived + // from the fresh provider, not served stale from the first mount. + await ctx.plugin(mock, { name: 'mock', inheritsParentContext: true }) + expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('INHERITS this conversation') + }) + + it('ignores lifecycle events for OTHER providers', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + await ctx.plugin(mock, { name: 'mock' }) + await ctx.plugin(tool, { provider: 'mock' }) + // An unrelated provider registering (added-event with another name) and + // unregistering (removed-event with another name) must not touch the tool. + const other = await ctx.plugin(mock, { name: 'other', inheritsParentContext: true }) + expect(ctx.tools.schemas().filter(s => s.name === 'subagent')).toHaveLength(1) + expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation') + await other.dispose() + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) }) it('derives spawn-shaped wording from a fresh-context provider (default mock)', async () => {