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:
@@ -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 () => {
|
||||
|
||||
@@ -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<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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user