refactor(core): simplify tools prompts and trusted services

This commit is contained in:
Tianyi Cui
2026-07-12 22:39:01 +08:00
parent 28e04ff4fb
commit 02ca71db57
24 changed files with 636 additions and 2695 deletions

View File

@@ -63,19 +63,16 @@ describe('scoped sections', () => {
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
})
it.each([
[['reserved'], 'section "reserved"'],
[['first', 'second'], 'sections "first", "second"'],
])('rejects global protection added after scoped shadows (%j)', async (names, message) => {
it('rejects a global owner-final section added after a scoped shadow', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
for (const name of names) {
scope.ctx.systemPrompt.section({ name, order: 1, text: `scoped ${name}` })
}
scope.ctx.systemPrompt.section({ name: 'reserved', order: 1, text: 'scoped reserved' })
expect(() => ctx.systemPrompt.protect({ sections: names })).toThrow(message)
expect(() => ctx.systemPrompt.section({
name: 'reserved', order: 1, text: 'global reserved', ownerFinal: true,
})).toThrow('owner-final prompt section "reserved"')
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
.toContain(`scoped ${names[0]}`)
.toContain('scoped reserved')
})
})
@@ -164,13 +161,18 @@ describe('scoped assemble dispatch', () => {
expect(shaped).toHaveLength(1)
})
it('a scoped protection finalizes only its own assemblies and disappears with the scope', async () => {
it('scoped owner-final contributions finalize only their assemblies and disappear with the scope', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
const key = scopeKeyOf(scope)
ctx.systemPrompt.section({ name: 'required', order: 10, text: 'required' })
ctx.systemPrompt.tools(() => ({ schemas: [schema('required')] }))
scope.ctx.systemPrompt.protect({ sections: ['required'], tools: ['required'] })
scope.ctx.systemPrompt.section({
name: 'required', order: 10, text: 'scoped required', ownerFinal: true,
})
scope.ctx.systemPrompt.tools(() => ({
schemas: [schema('required')], ownerFinalNames: ['required'],
}))
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'required')

View File

@@ -111,86 +111,14 @@ describe('SystemPrompt', () => {
expect(contributed(assembly).map(s => s.text)).toEqual(['first'])
})
it('rejects malformed fixed registration fields before storing an effect', async () => {
it('rejects a non-finite section order', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const badName = { value: 'name' }
const badText = { value: 'text' }
expect(() => ctx.systemPrompt.section(null as unknown as Parameters<typeof ctx.systemPrompt.section>[0]))
.toThrow('requires a section object')
expect(() => ctx.systemPrompt.section(1 as unknown as Parameters<typeof ctx.systemPrompt.section>[0]))
.toThrow('requires a section object')
expect(() => ctx.systemPrompt.section({ name: badName as unknown as string, order: 1, text: 'x' }))
.toThrow('prompt section name must be a string')
expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: '1' as unknown as number, text: 'x' }))
.toThrow('order must be a finite number')
expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: Number.NaN, text: 'x' }))
.toThrow('order must be a finite number')
expect(() => ctx.systemPrompt.section({ name: 'bad-text', order: 1, text: badText as unknown as string }))
.toThrow('text must be a string or function')
expect(() => ctx.systemPrompt.tools(1 as unknown as Parameters<typeof ctx.systemPrompt.tools>[0]))
.toThrow('tool provider must be a function')
expect(() => ctx.systemPrompt.variable({} as unknown as string, () => 'x'))
.toThrow('prompt variable name must be a string')
expect(() => ctx.systemPrompt.variable('valid', 1 as unknown as Parameters<typeof ctx.systemPrompt.variable>[1]))
.toThrow('provider must be a function')
expect(() => ctx.systemPrompt.protect(null as unknown as Parameters<typeof ctx.systemPrompt.protect>[0]))
.toThrow('requires a protection object')
expect(() => ctx.systemPrompt.protect(1 as unknown as Parameters<typeof ctx.systemPrompt.protect>[0]))
.toThrow('requires a protection object')
expect(() => ctx.systemPrompt.protect({ sections: 'x' as unknown as string[] }))
.toThrow('sections must be an array of strings')
expect(() => ctx.systemPrompt.protect({ tools: 'x' as unknown as string[] }))
.toThrow('tools must be an array of strings')
expect(() => ctx.systemPrompt.protect({ sections: ['ok', {} as unknown as string] }))
.toThrow('sections must be an array of strings')
expect(() => ctx.systemPrompt.protect({ tools: [{} as unknown as string] }))
.toThrow('tools must be an array of strings')
expect(Object.isFrozen(badName)).toBe(false)
expect(Object.isFrozen(badText)).toBe(false)
expect(contributed(await ctx.systemPrompt.assemble())).toEqual([])
})
it('reads each section field and protection-name slot once at registration', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const reads = { name: 0, order: 0, text: 0, sections: 0, item: 0 }
const section = Object.defineProperties({}, {
name: {
enumerable: true,
get: () => (++reads.name === 1 ? 'stable' : 42),
},
order: {
enumerable: true,
get: () => (++reads.order === 1 ? 10 : Number.NaN),
},
text: {
enumerable: true,
get: () => (++reads.text === 1 ? 'stable text' : null),
},
}) as unknown as Parameters<typeof ctx.systemPrompt.section>[0]
const names = new Array<string>(1)
Object.defineProperty(names, 0, {
enumerable: true,
get: () => (++reads.item === 1 ? 'stable' : 'drifted'),
})
const protection = {
get sections(): string[] {
reads.sections += 1
return reads.sections === 1 ? names : ['drifted']
},
}
ctx.systemPrompt.section(section)
ctx.systemPrompt.protect(protection)
const assembly = await ctx.systemPrompt.assemble()
expect(reads).toEqual({ name: 1, order: 1, text: 1, sections: 1, item: 1 })
expect(assembly.sections).toContainEqual({ name: 'stable', order: 10, text: 'stable text' })
})
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -285,28 +213,21 @@ describe('SystemPrompt', () => {
expect(assembly.sections).toHaveLength(0)
})
describe('canonical contribution protection', () => {
it('restores exact protected definitions after every listener, in canonical relative order', async () => {
describe('owner-final contributions', () => {
it('restores exact owner-final definitions after every listener, in canonical relative order', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'before', order: 10, text: 'before' })
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section' })
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section', ownerFinal: true })
ctx.systemPrompt.section({ name: 'after', order: 30, text: 'after' })
ctx.systemPrompt.tools(() => ({ schemas: [
{ name: 'alpha', description: 'alpha', parameters: {} },
{ name: 'protected', description: 'canonical tool', parameters: { type: 'object', properties: { answer: { type: 'number' } } } },
{ name: 'zulu', description: 'zulu', parameters: {} },
] }))
const protection = { sections: ['protected'], tools: ['protected'] }
ctx.systemPrompt.protect(protection)
// Registration snapshots its arrays; caller mutation cannot change what
// the service makes authoritative.
protection.sections[0] = 'after'
protection.tools[0] = 'zulu'
], ownerFinalNames: ['protected'] }))
// Registered AFTER the protection and prepended: it is outside every
// ordinary listener that existed when protect() ran, but service-level
// finalization still restores the canonical entries after it returns.
// Service-level finalization restores the canonical entries after the
// complete listener chain returns.
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
return Object.freeze({
@@ -338,121 +259,18 @@ describe('SystemPrompt', () => {
expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu'])
})
it('reads protection accessors once so the checked names are the protected names', async () => {
it('makes an owner-final tool\'s canonical absence survive the waterfall', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' })
let reads = 0
const protection = {
get sections(): string[] {
reads += 1
return reads === 1 ? ['protected'] : undefined as unknown as string[]
},
}
ctx.systemPrompt.protect(protection)
ctx.systemPrompt.tools(() => ({ schemas: [], ownerFinalNames: ['mode-hidden'] }))
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'protected')
return result
})
const assembly = await ctx.systemPrompt.assemble()
expect(reads).toBe(1)
expect(assembly.sections).toContainEqual({ name: 'protected', order: 10, text: 'canonical' })
})
it('materializes waterfall entry names once before restoring protected definitions', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical section' })
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'protected', description: 'canonical tool', parameters: {} }] }))
ctx.systemPrompt.protect({ sections: ['protected'], tools: ['protected'] })
let sectionNameReads = 0
let toolNameReads = 0
const hostileSection = {
get name(): string {
sectionNameReads += 1
return sectionNameReads === 1 ? 'impostor-section' : 'protected'
},
order: 999,
text: 'listener section',
}
const hostileTool = {
get name(): string {
toolNameReads += 1
return toolNameReads === 1 ? 'impostor-tool' : 'protected'
},
description: 'listener tool',
parameters: {},
}
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = [
...result.sections.filter(section => section.name !== 'protected'),
hostileSection,
]
result.tools = [
...result.tools.filter(tool => tool.name !== 'protected'),
hostileTool,
]
return result
})
const assembly = await ctx.systemPrompt.assemble()
expect(sectionNameReads).toBe(1)
expect(toolNameReads).toBe(1)
expect(assembly.sections.map(section => section.name)).toEqual([
'harness:identity',
'deployment:persona',
'impostor-section',
'protected',
])
expect(assembly.tools.map(tool => tool.name)).toEqual(['impostor-tool', 'protected'])
})
it('protects canonical absence and rejects an empty protection', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
// Separate registrations exercise the set-union contract: protections
// may name only sections or only tools and still compose.
ctx.systemPrompt.protect({ sections: ['mode-hidden'] })
ctx.systemPrompt.protect({ tools: ['mode-hidden'] })
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections.push({ name: 'mode-hidden', order: 100, text: 'fabricated' })
result.tools.push({ name: 'mode-hidden', description: 'fabricated', parameters: {} })
return result
})
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.some(section => section.name === 'mode-hidden')).toBe(false)
expect(assembly.tools.some(tool => tool.name === 'mode-hidden')).toBe(false)
expect(() => ctx.systemPrompt.protect({})).toThrow(/at least one section or tool name/)
expect(() => ctx.systemPrompt.protect({ sections: [], tools: [] })).toThrow(/at least one section or tool name/)
})
it('removes a protection with its contributing fiber (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' })
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'protected')
return result
})
let changes = 0
ctx.on('system-prompt/change', () => { changes++ })
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.systemPrompt.protect({ sections: ['protected'] })
}, { inject: ['systemPrompt'] }))
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(true)
expect(changes).toBe(1)
await fiber.dispose()
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(false)
expect(changes).toBe(2)
})
})

View File

@@ -48,100 +48,6 @@ describe('SystemPrompt tool order', () => {
expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
})
it('reads provider schemas once so toolOrder validates the model-visible collection', async () => {
const ctx = await mount({ toolOrder: ['actual', TOOL_ORDER_REST] })
let reads = 0
ctx.systemPrompt.tools(() => ({
get schemas(): ToolSchema[] {
reads += 1
return reads === 1 ? [tool('actual')] : [tool('phantom')]
},
}))
const assembly = await ctx.systemPrompt.assemble()
expect(reads).toBe(1)
expect(names(assembly)).toEqual(['actual'])
})
it('reads each provider schema field once before detaching it', async () => {
const ctx = await mount()
const accepted = { type: 'object', properties: { accepted: { type: 'string' } } }
let reads = 0
const schema = {
name: 'stable',
description: 'stable',
get parameters(): object {
reads += 1
return reads === 1 ? accepted : { type: 'object', properties: { drifted: { type: 'number' } } }
},
} as ToolSchema
ctx.systemPrompt.tools(() => ({ schemas: [schema] }))
const assembly = await ctx.systemPrompt.assemble()
expect(reads).toBe(1)
expect(assembly.tools[0]?.parameters).toEqual(accepted)
})
it('rejects exotic provider parameters before model-visible assembly', async () => {
const ctx = await mount()
class ExoticParameters {
readonly type = 'object'
readonly properties = { value: { type: 'string' } }
}
ctx.systemPrompt.tools(() => ({
schemas: [{
name: 'exotic',
description: 'must not be sanitized',
parameters: new ExoticParameters() as unknown as ToolSchema['parameters'],
}],
}))
await expect(ctx.systemPrompt.assemble())
.rejects.toThrow(/parameters must be losslessly JSON-serializable/)
})
it('rejects malformed fixed provider fields without freezing caller objects', async () => {
const ctx = await mount()
const badName = { value: 'object-name' }
const badDescription = { value: 'object-description' }
ctx.systemPrompt.tools(() => ({
schemas: [{
name: badName as unknown as string,
description: 'bad name',
parameters: {},
}],
}))
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('name must be a string')
expect(Object.isFrozen(badName)).toBe(false)
const descriptions = await mount()
descriptions.systemPrompt.tools(() => ({
schemas: [{
name: 'bad-description',
description: badDescription as unknown as string,
parameters: {},
}],
}))
await expect(descriptions.systemPrompt.assemble()).rejects.toThrow('description must be a string')
expect(Object.isFrozen(badDescription)).toBe(false)
const knownNames = await mount()
knownNames.systemPrompt.tools(() => ({
schemas: [tool('valid')],
knownNames: [{} as unknown as string],
}))
await expect(knownNames.systemPrompt.assemble()).rejects.toThrow('knownNames must be an array of strings')
const nonArrayKnownNames = await mount()
nonArrayKnownNames.systemPrompt.tools(() => ({
schemas: [tool('valid')],
knownNames: 'valid' as unknown as string[],
}))
await expect(nonArrayKnownNames.systemPrompt.assemble()).rejects.toThrow('knownNames must be an array of strings')
})
it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => {
const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] })
ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] }))