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.
This commit is contained in:
Tianyi Cui
2026-07-05 02:42:48 +08:00
parent f256f3961d
commit e85e21c8b0
12 changed files with 328 additions and 108 deletions

View File

@@ -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 |
|---|---|

View File

@@ -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<ContentBlock[]> {
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<ContentBlock[]> {
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`)
}
}

View File

@@ -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 () => {