fix(tools): scope canonical provenance to dispatch
This commit is contained in:
@@ -52,7 +52,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
|
||||
|
||||
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
|
||||
- `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it.
|
||||
- `tools/execute` wraps normalized canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration.
|
||||
- `tools/execute` wraps normalized canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration. Canonical-result provenance belongs to one immutable dispatch token, so a cached result from another call or tool is revalidated under the active declaration.
|
||||
- `tools/post-execute` may replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. Content replacement is not a confidentiality boundary: block or replace the value when programmatic consumers must not receive it.
|
||||
- Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md).
|
||||
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
|
||||
|
||||
@@ -1093,7 +1093,7 @@ export class ToolRegistry extends Service {
|
||||
if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
|
||||
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
|
||||
? normalized
|
||||
: this.markCanonical({
|
||||
: this.markCanonical(exec, {
|
||||
...normalized,
|
||||
additionalContexts: [
|
||||
...deferredContexts,
|
||||
@@ -1244,7 +1244,7 @@ export class ToolRegistry extends Service {
|
||||
const decisionContexts = decision.additionalContexts ?? []
|
||||
if (decision.kind === 'block') {
|
||||
const message = failureMessageFromContent(decision.feedback)
|
||||
return this.markCanonical({
|
||||
return this.markCanonical(exec, {
|
||||
content: decision.feedback,
|
||||
isError: true,
|
||||
error: { message },
|
||||
@@ -1265,24 +1265,24 @@ export class ToolRegistry extends Service {
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
if (tool === undefined) throw new ToolNotFoundError(exec.name)
|
||||
const replaced = this.createSuccessResult(exec, tool, decision.value)
|
||||
return this.markCanonical({
|
||||
return this.markCanonical(exec, {
|
||||
...replaced,
|
||||
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
||||
})
|
||||
}
|
||||
return this.markCanonical({
|
||||
return this.markCanonical(exec, {
|
||||
...result,
|
||||
...decision.content !== undefined ? { content: decision.content } : {},
|
||||
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/** Results created by the registry already own a validated, frozen canonical value. */
|
||||
private readonly canonicalResults = new WeakSet<object>()
|
||||
/** Registry-normalized results and the exact dispatch that validated each value. */
|
||||
private readonly canonicalResults = new WeakMap<object, ToolExecutionToken>()
|
||||
|
||||
/** Mark a registry-normalized result without freezing presentation fields prematurely. */
|
||||
private markCanonical<T extends ToolExecutionResult>(result: T): T {
|
||||
this.canonicalResults.add(result)
|
||||
/** Mark one registry-normalized result as canonical only for its owning dispatch. */
|
||||
private markCanonical<T extends ToolExecutionResult>(exec: ToolExecution, result: T): T {
|
||||
this.canonicalResults.set(result, exec.token)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1309,7 +1309,7 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
meta = snapshotProjection(tool.name, 'presentationMeta', projected)
|
||||
}
|
||||
return this.markCanonical(this.materializeFinalResult({
|
||||
return this.markCanonical(exec, this.materializeFinalResult({
|
||||
isError: false,
|
||||
value,
|
||||
content,
|
||||
@@ -1319,9 +1319,9 @@ export class ToolRegistry extends Service {
|
||||
|
||||
/** Normalize an around-dispatch wrapper's authored result through the owning output contract. */
|
||||
private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult {
|
||||
if (this.canonicalResults.has(result)) return result
|
||||
if (this.canonicalResults.get(result) === exec.token) return result
|
||||
if (result.isError) {
|
||||
return this.markCanonical({
|
||||
return this.markCanonical(exec, {
|
||||
isError: true,
|
||||
error: result.error,
|
||||
content: result.content,
|
||||
@@ -1332,7 +1332,7 @@ export class ToolRegistry extends Service {
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
if (tool === undefined) throw new ToolNotFoundError(exec.name)
|
||||
const normalized = this.createSuccessResult(exec, tool, result.value)
|
||||
return this.markCanonical({
|
||||
return this.markCanonical(exec, {
|
||||
...normalized,
|
||||
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
||||
})
|
||||
|
||||
@@ -1588,6 +1588,55 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
|
||||
})
|
||||
|
||||
it('revalidates a cached canonical result returned from a different dispatch', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({ ...echoTool, name: 'string-output', async execute() { return 'cached' } })
|
||||
let objectBodyRan = false
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'object-output',
|
||||
description: 'Return one closed object.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { ok: { type: 'boolean', required: true } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: String(value.ok) }],
|
||||
},
|
||||
execute() {
|
||||
objectBodyRan = true
|
||||
return Promise.resolve({ ok: true })
|
||||
},
|
||||
}))
|
||||
let cached: ToolExecutionResult | undefined
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
if (exec.name === 'string-output') {
|
||||
cached = await next()
|
||||
return cached
|
||||
}
|
||||
if (exec.name === 'object-output') {
|
||||
if (cached === undefined) throw new Error('expected the first dispatch result')
|
||||
return cached
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal, callId: CallId('cached-first'), name: 'string-output', arguments: {},
|
||||
})
|
||||
const second = await ctx.tools.execute({
|
||||
signal: testToolSignal, callId: CallId('cached-second'), name: 'object-output', arguments: {},
|
||||
})
|
||||
|
||||
expect(first.isError ? undefined : first.value).toBe('cached')
|
||||
expect(objectBodyRan).toBe(false)
|
||||
expect(second).toMatchObject({
|
||||
isError: true,
|
||||
error: { info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves additionalContexts supplied by an around-dispatch result', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
Reference in New Issue
Block a user