refactor: narrow synchronous extension contracts

This commit is contained in:
Tianyi Cui
2026-07-13 11:58:55 +08:00
parent f7b9cea733
commit f32cfafa1a
33 changed files with 148 additions and 126 deletions

View File

@@ -13,9 +13,9 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => Promise<void> | void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. `ownerFinal: true` restores this section's canonical definition after the complete waterfall and reserves a global section against scoped shadows. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames?, ownerFinalNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`; `ownerFinalNames` makes those tools' canonical presence or absence survive the waterfall. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise<void> | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. `ownerFinal: true` restores this section's canonical definition after the complete waterfall and reserves a global section against scoped shadows. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames?, ownerFinalNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`; `ownerFinalNames` makes those tools' canonical presence or absence survive the waterfall. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores owner-final contributions from a private pre-waterfall snapshot. Restored entries keep canonical relative order without undoing listener reordering of ordinary entries. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
### Live events

View File

@@ -434,7 +434,7 @@ export class SystemPrompt extends Service {
* Cordis effect disposer (single-shot): composite (generator) effects may
* yield it directly — exact identity nests the teardown in order.
*/
section(section: PromptSection): () => Promise<void> | void {
section(section: PromptSection): () => void {
if (!Number.isFinite(section.order)) {
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
}
@@ -481,8 +481,9 @@ export class SystemPrompt extends Service {
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Fire-and-forget callers may still
// discard the (always-resolved) promise.
// (the agents.register() lesson). Cleanup is synchronous because this
// registration installs only synchronous state and notifications.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
@@ -502,7 +503,7 @@ export class SystemPrompt extends Service {
* Cordis effect disposer (single-shot): composite (generator) effects may
* yield it directly — exact identity nests the teardown in order.
*/
tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void {
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
@@ -527,8 +528,9 @@ export class SystemPrompt extends Service {
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Fire-and-forget callers may still
// discard the (always-resolved) promise.
// (the agents.register() lesson). Cleanup is synchronous because this
// registration installs only synchronous state and notifications.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
@@ -550,7 +552,7 @@ export class SystemPrompt extends Service {
* Cordis effect disposer (single-shot): composite (generator) effects may
* yield it directly — exact identity nests the teardown in order.
*/
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void {
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void {
if (!VARIABLE_NAME.test(name)) {
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
}
@@ -581,8 +583,9 @@ export class SystemPrompt extends Service {
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Fire-and-forget callers may still
// discard the (always-resolved) promise.
// (the agents.register() lesson). Cleanup is synchronous because this
// registration installs only synchronous state and notifications.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}

View File

@@ -116,7 +116,7 @@ describe('scoped tool providers and toolOrder × restriction', () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
const dispose = scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] }))
await dispose()
dispose()
const after = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
expect(after.tools.map(t => t.name)).toEqual([])
// Re-registering through the same scope starts a fresh layer.

View File

@@ -316,7 +316,7 @@ describe('SystemPrompt', () => {
// registration emits change
expect(changeCount).toBe(1)
await dispose()
dispose()
// disposal emits change again
expect(changeCount).toBe(2)
})
@@ -341,7 +341,7 @@ describe('SystemPrompt', () => {
const dispose = ctx.systemPrompt.section({ name: 'direct', order: 0, text: 'direct section' })
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(1)
await dispose()
dispose()
expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0)
})
@@ -352,7 +352,7 @@ describe('SystemPrompt', () => {
const dispose = ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'direct-tool', description: '', parameters: {} }] }))
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
await dispose()
dispose()
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0)
})
@@ -370,7 +370,7 @@ describe('SystemPrompt', () => {
// A provider returning undefined records "registered but no value here".
expect((await ctx.systemPrompt.assemble()).variables).toEqual({ who: undefined })
await dispose()
dispose()
expect(changeCount).toBe(2)
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
})