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

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

View File

@@ -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 `100199`; negative orders render before the persona.
- `PromptAssembly``{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
- `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.

View File

@@ -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, string | undefined>): 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<string, string
if (value === undefined) {
throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (section "${section.name}")`)
}
return value
})
result += text.slice(last, open) + value
last = open + group[0].length
}
return result + text.slice(last)
}
/**

View File

@@ -325,7 +325,7 @@ describe('SystemPrompt', () => {
})).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!')
})
})
})