refactor(scope): migrate tool and prompt layers

This commit is contained in:
Tianyi Cui
2026-07-21 22:14:49 +08:00
parent 8351cdbe65
commit 1d209932d6
5 changed files with 195 additions and 203 deletions

View File

@@ -6,8 +6,8 @@
import { Context, Service } from 'cordis' import { Context, Service } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { ToolSchema } from '@deepseek-ai/dsh-llm' import type { ToolSchema } from '@deepseek-ai/dsh-llm'
declare module 'cordis' { declare module 'cordis' {
@@ -209,6 +209,39 @@ function interpolate(section: AssembledSection, variables: Record<string, string
return result + text.slice(last) return result + text.slice(last)
} }
/** One tool-schema provider stored in a prompt layer. */
type ToolProvider = (context: AssembleContext) => ToolProviderResult
/** One prompt-variable provider stored in a prompt layer. */
type VariableProvider = (context: AssembleContext) => string | undefined
/** All prompt registrations owned by one global or scoped layer. */
class PromptLayer implements ScopeLayer {
readonly sections: NamedEntries<PromptSection>
readonly toolProviders = new AnonymousEntries<ToolProvider>()
readonly variables: NamedEntries<VariableProvider>
/**
* Create one prompt layer with diagnostics specific to its ownership scope.
* @param scope - the scoped owner, or `undefined` for global registrations.
*/
constructor(scope: ScopeKey | undefined) {
this.sections = new NamedEntries(name => new Error(scope === undefined
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${name}" is already registered in this scope`))
this.variables = new NamedEntries(name => new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`))
}
/** @returns whether this layer owns no prompt registrations. */
isEmpty(): boolean {
return this.sections.isEmpty()
&& this.toolProviders.isEmpty()
&& this.variables.isEmpty()
}
}
/** Registry service for the prompt inputs assembled before each model step. */ /** Registry service for the prompt inputs assembled before each model step. */
export class SystemPrompt extends Service { export class SystemPrompt extends Service {
static Config: z<Config> = z.object({ static Config: z<Config> = z.object({
@@ -217,13 +250,10 @@ export class SystemPrompt extends Service {
toolOrder: z.array(z.string()).default(undefined as unknown as string[]), toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
}) })
private sections: PromptSection[] = [] private readonly layers = new ScopedLayers(
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = [] scope => new PromptLayer(scope),
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>() () => { this.ctx.emit('system-prompt/change') },
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */ )
private scopedSections = new Map<ScopeKey, PromptSection[]>()
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
private readonly toolOrder: string[] | undefined private readonly toolOrder: string[] | undefined
constructor(ctx: Context, config: Config) { constructor(ctx: Context, config: Config) {
@@ -255,34 +285,11 @@ export class SystemPrompt extends Service {
if (!Number.isFinite(section.order)) { if (!Number.isFinite(section.order)) {
throw new TypeError(`prompt section "${section.name}" order must be a finite number`) throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
} }
const scope = scopeOf(this.ctx) return this.layers.effect(
const dispose = this.ctx.effect(function* (this: SystemPrompt) { this.ctx,
const layer = scope === undefined layer => layer.sections.insert(section.name, section),
? this.sections { label: 'systemPrompt.section()' },
: this.scopedSections.get(scope) ?? (() => { )
const created: PromptSection[] = []
this.scopedSections.set(scope, created)
return created
})()
if (layer.some(existing => existing.name === section.name)) {
throw new Error(scope === undefined
? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${section.name}" is already registered in this scope`)
}
layer.push(section)
// Install rollback before notifying listeners that may throw.
yield () => {
const index = layer.indexOf(section)
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.section()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
} }
/** /**
@@ -293,29 +300,11 @@ export class SystemPrompt extends Service {
* @returns the exact Cordis effect disposer. * @returns the exact Cordis effect disposer.
*/ */
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void { tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
const scope = scopeOf(this.ctx) return this.layers.effect(
const dispose = this.ctx.effect(function* (this: SystemPrompt) { this.ctx,
const layer = scope === undefined layer => layer.toolProviders.append(provider),
? this.toolProviders { label: 'systemPrompt.tools()' },
: this.scopedToolProviders.get(scope) ?? (() => { )
const created: ((context: AssembleContext) => ToolProviderResult)[] = []
this.scopedToolProviders.set(scope, created)
return created
})()
layer.push(provider)
// Install rollback before notifying listeners that may throw.
yield () => {
const index = layer.indexOf(provider)
/* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.tools()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
} }
/** /**
@@ -330,32 +319,11 @@ export class SystemPrompt extends Service {
if (!VARIABLE_NAME.test(name)) { if (!VARIABLE_NAME.test(name)) {
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`) throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
} }
const scope = scopeOf(this.ctx) return this.layers.effect(
const dispose = this.ctx.effect(function* (this: SystemPrompt) { this.ctx,
const layer = scope === undefined layer => layer.variables.insert(name, provider),
? this.variableProviders { label: 'systemPrompt.variable()' },
: this.scopedVariableProviders.get(scope) ?? (() => { )
const created = new Map<string, (context: AssembleContext) => string | undefined>()
this.scopedVariableProviders.set(scope, created)
return created
})()
if (layer.has(name)) {
throw new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`)
}
layer.set(name, provider)
// Install rollback before notifying listeners that may throw.
yield () => {
layer.delete(name)
if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.variable()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
} }
/** /**
@@ -370,23 +338,19 @@ export class SystemPrompt extends Service {
const scope = context.scope const scope = context.scope
// Scoped variables shadow globals. // Scoped variables shadow globals.
const variables: Record<string, string | undefined> = {} const variables: Record<string, string | undefined> = {}
for (const [name, provider] of this.variableProviders) { for (const [name, provider] of this.layers.global.variables.entries()) {
variables[name] = provider(context) variables[name] = provider(context)
} }
const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope) const scopedVariables = this.layers.peek(scope)?.variables
for (const [name, provider] of scopedVariables ?? []) { for (const [name, provider] of scopedVariables?.entries() ?? []) {
variables[name] = provider(context) variables[name] = provider(context)
} }
// Scoped sections shadow globals before the stable order sort. // Scoped sections shadow globals before the stable order sort.
const sectionByName = new Map<string, PromptSection>() const sectionByName = this.layers.merge(scope, layer => layer.sections)
for (const section of this.sections) sectionByName.set(section.name, section)
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
sectionByName.set(section.name, section)
}
// Validate order against pre-restriction names while collecting visible schemas. // Validate order against pre-restriction names while collecting visible schemas.
const providers = [ const providers = [
...this.toolProviders, ...this.layers.global.toolProviders.values(),
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [], ...(this.layers.peek(scope)?.toolProviders.values() ?? []),
] ]
const collected: ToolSchema[] = [] const collected: ToolSchema[] = []
const knownNames = new Set<string>() const knownNames = new Set<string>()

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis' import { Context } from 'cordis'
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope' import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope' import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
@@ -63,6 +63,21 @@ describe('scoped sections', () => {
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/) expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
}) })
it('shadows a global section before evaluating either text provider', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
const globalText = vi.fn(() => 'global text')
const scopedText = vi.fn(() => 'scoped text')
ctx.systemPrompt.section({ name: 'shared', order: 1, text: globalText })
scope.ctx.systemPrompt.section({ name: 'shared', order: 1, text: scopedText })
const assembly = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
expect(assembly.sections.find(section => section.name === 'shared')?.text).toBe('scoped text')
expect(globalText).not.toHaveBeenCalled()
expect(scopedText).toHaveBeenCalledOnce()
})
}) })
describe('scoped variables', () => { describe('scoped variables', () => {

View File

@@ -157,6 +157,24 @@ describe('SystemPrompt', () => {
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t']) expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
}) })
it('snapshots tool-provider membership before evaluating an assembly', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
let added = false
ctx.systemPrompt.tools(() => {
if (!added) {
added = true
ctx.systemPrompt.tools(() => ({
schemas: [{ name: 'late', description: '', parameters: {} }],
}))
}
return { schemas: [{ name: 'first', description: '', parameters: {} }] }
})
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first'])
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first', 'late'])
})
it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => { it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SystemPrompt) await ctx.plugin(SystemPrompt)
@@ -314,6 +332,24 @@ describe('SystemPrompt', () => {
expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
}) })
it('live-iterates variables registered by an earlier provider', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
let added = false
ctx.systemPrompt.variable('first', () => {
if (!added) {
added = true
ctx.systemPrompt.variable('late', () => 'second value')
}
return 'first value'
})
expect((await ctx.systemPrompt.assemble()).variables).toEqual({
first: 'first value',
late: 'second value',
})
})
it('rejects a duplicate variable name and an unreferenceable name', async () => { it('rejects a duplicate variable name and an unreferenceable name', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SystemPrompt) await ctx.plugin(SystemPrompt)

View File

@@ -6,8 +6,8 @@
import { Context, Service } from 'cordis' import { Context, Service } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
@@ -463,9 +463,40 @@ interface ToolView {
*/ */
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
/** One guard registration; the wrapper preserves independent duplicate registrations. */ /** One scope's complete tool-registry contribution. */
interface ToolGuardRegistration { class ToolLayer implements ScopeLayer {
guard: ToolGuard readonly tools: NamedEntries<ToolDefinition>
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
readonly guards = new AnonymousEntries<ToolGuard>()
constructor(scope: ScopeKey | undefined) {
this.tools = new NamedEntries(name => new Error(scope === undefined
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${name}" is already registered in this scope`))
}
/** Whether every contribution table in this aggregate layer is empty. */
isEmpty(): boolean {
return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
}
/** Whether every compiled restriction in this layer admits a global tool name. */
admits(name: string): boolean {
for (const filter of this.restrictions.values()) {
if ((filter.allow !== undefined && !filter.allow.has(name))
|| (filter.deny !== undefined && filter.deny.has(name))) return false
}
return true
}
/** First monotonic denial from this layer's live guard registrations. */
guardReason(exec: ToolExecution): string | undefined {
for (const guard of this.guards.values()) {
const reason = guard(exec)
if (reason !== undefined) return reason
}
return undefined
}
} }
/** Approval decision plus whether the approval channel reported cancellation. */ /** Approval decision plus whether the approval channel reported cancellation. */
@@ -509,13 +540,10 @@ export class ToolRegistry extends Service {
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>() private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */ /** Original caller cancellation, kept outside the wrapper-mutable execution object. */
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>() private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
private global = new Map<string, ToolDefinition>() private readonly layers = new ScopedLayers(
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>() scope => new ToolLayer(scope),
/** Compiled restriction filters, per scope (see {@link restrict}). */ () => { this.ctx.emit('tools/change') },
private restrictions = new Map<ScopeKey, CompiledToolRestriction[]>() )
/** Monotonic post-policy guards, split into global and per-agent layers. */
private globalGuards = new Set<ToolGuardRegistration>()
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
private readonly mode: ToolPresentationMode private readonly mode: ToolPresentationMode
/** Reserved presentation transport, kept outside the filterable registration layers. */ /** Reserved presentation transport, kept outside the filterable registration layers. */
private readonly codeTransport: ToolDefinition | undefined private readonly codeTransport: ToolDefinition | undefined
@@ -593,7 +621,6 @@ export class ToolRegistry extends Service {
* @returns the exact disposer that unregisters the tool. * @returns the exact disposer that unregisters the tool.
*/ */
register(definition: ToolDefinition): () => void { register(definition: ToolDefinition): () => void {
const scope = scopeOf(this.ctx)
const name = definition.name const name = definition.name
const timeoutMs = definition.timeoutMs const timeoutMs = definition.timeoutMs
if (timeoutMs !== undefined if (timeoutMs !== undefined
@@ -603,26 +630,11 @@ export class ToolRegistry extends Service {
if (this.codeTransport !== undefined && name === RUN_CODE_NAME) { if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`) throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
} }
const dispose = this.ctx.effect(function* (this: ToolRegistry) { return this.layers.effect(
const layer = scope === undefined ? this.global : this.layerFor(scope) this.ctx,
if (layer.has(name)) { layer => layer.tools.insert(name, definition),
throw new Error(scope === undefined { label: 'tools.register()' },
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)` )
: `tool "${name}" is already registered in this scope`)
}
layer.set(name, definition)
// Install rollback before notifying listeners.
yield () => {
layer.delete(name)
// Drop empty scope layers.
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.register()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
} }
/** /**
@@ -655,22 +667,11 @@ export class ToolRegistry extends Service {
if (unknown.length > 0) { if (unknown.length > 0) {
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`) throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
} }
const dispose = this.ctx.effect(function* (this: ToolRegistry) { return this.layers.effect(
const list = this.restrictions.get(scope) ?? [] this.ctx,
this.restrictions.set(scope, list) layer => layer.restrictions.append(compiled),
list.push(compiled) { label: 'tools.restrict()' },
yield () => { )
const index = list.indexOf(compiled)
/* v8 ignore next 3 -- defensive: the compiled restriction was pushed, so indexOf is guaranteed >= 0 */
if (index >= 0) list.splice(index, 1)
if (list.length === 0) this.restrictions.delete(scope)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.restrict()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
} }
/** /**
@@ -684,63 +685,18 @@ export class ToolRegistry extends Service {
* @returns the exact disposer that unregisters the guard. * @returns the exact disposer that unregisters the guard.
*/ */
guard(guard: ToolGuard): () => void { guard(guard: ToolGuard): () => void {
const scope = scopeOf(this.ctx) return this.layers.effect(
const registration = { guard } this.ctx,
const dispose = this.ctx.effect(function* (this: ToolRegistry) { layer => layer.guards.append(guard),
const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope) { label: 'tools.guard()', notify: false },
layer.add(registration) )
yield () => {
layer.delete(registration)
if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
}
}.bind(this), 'tools.guard()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
/** The (created-on-demand) scoped layer for `scope`. */
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
let layer = this.scoped.get(scope)
if (!layer) {
layer = new Map()
this.scoped.set(scope, layer)
}
return layer
}
/** Get or create the guard layer for one agent scope. */
private guardLayerFor(scope: ScopeKey): Set<ToolGuardRegistration> {
let layer = this.scopedGuards.get(scope)
if (layer === undefined) {
layer = new Set()
this.scopedGuards.set(scope, layer)
}
return layer
} }
/** First monotonic denial from the global then matching scoped guard layers. */ /** First monotonic denial from the global then matching scoped guard layers. */
private guardReason(exec: ToolExecution): string | undefined { private guardReason(exec: ToolExecution): string | undefined {
for (const { guard } of this.globalGuards) { const globalReason = this.layers.global.guardReason(exec)
const reason = guard(exec) if (globalReason !== undefined) return globalReason
if (reason !== undefined) return reason return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec)
}
if (exec.agent !== undefined) {
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
const reason = guard(exec)
if (reason !== undefined) return reason
}
}
return undefined
}
/** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
private admits(scope: ScopeKey | undefined, name: string): boolean {
if (scope === undefined) return true
const filters = this.restrictions.get(scope)
if (!filters) return true
return filters.every(filter =>
(filter.allow === undefined || filter.allow.has(name))
&& (filter.deny === undefined || !filter.deny.has(name)))
} }
/** /**
@@ -752,18 +708,18 @@ export class ToolRegistry extends Service {
* @returns the complete derived view for that scope. * @returns the complete derived view for that scope.
*/ */
private view(scope?: ScopeKey): ToolView { private view(scope?: ScopeKey): ToolView {
const layer = scope === undefined ? undefined : this.scoped.get(scope) const layer = this.layers.peek(scope)
const visible = new Map<string, ToolDefinition>() const visible = new Map<string, ToolDefinition>()
const knownNames = new Set<string>() const knownNames = new Set<string>()
const restrictableNames = new Set<string>() const restrictableNames = new Set<string>()
for (const [name, definition] of this.global) { for (const [name, definition] of this.layers.global.tools.entries()) {
knownNames.add(name) knownNames.add(name)
restrictableNames.add(name) restrictableNames.add(name)
if (this.admits(scope, name)) visible.set(name, definition) if (layer?.admits(name) ?? true) visible.set(name, definition)
} }
// Scoped layer second: same-name entries REPLACE (shadow) the global ones, // Scoped layer second: same-name entries REPLACE (shadow) the global ones,
// and scope-local registrations are never part of the global filter above. // and scope-local registrations are never part of the global filter above.
for (const [name, definition] of layer ?? []) { for (const [name, definition] of layer?.tools.entries() ?? []) {
knownNames.add(name) knownNames.add(name)
visible.set(name, definition) visible.set(name, definition)
} }

View File

@@ -266,6 +266,27 @@ describe('scoped execution dispatch', () => {
expect(bodyCalls).toBe(0) expect(bodyCalls).toBe(0)
}) })
it('live-iterates a guard registered by an earlier guard', async () => {
const ctx = await mount()
const calls: string[] = []
let added = false
ctx.tools.register(tool('t'))
ctx.tools.guard(() => {
calls.push('first')
if (!added) {
added = true
ctx.tools.guard(() => {
calls.push('late')
return 'late denial'
})
}
return undefined
})
expect(await run(ctx, 't')).toBe('Error: late denial')
expect(calls).toEqual(['first', 'late'])
})
it('shares one token and materialized argument value across the pipeline', async () => { it('shares one token and materialized argument value across the pipeline', async () => {
const ctx = await mount() const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a') const { scope, key } = await mintAgentScope(ctx, 'a')