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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
|
||||
@@ -45,6 +45,38 @@ function baseRequest(overrides: Partial<SubagentStartRequest> = {}): 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)
|
||||
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user