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

@@ -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!')
})
})
})