fix(tools): scope canonical provenance to dispatch

This commit is contained in:
Tianyi Cui
2026-07-23 00:39:55 +08:00
parent 8d42d3c979
commit d626b2582d
6 changed files with 67 additions and 18 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-20-canonical-tool-output-contract.md: 4099568de5dcc21a89b7873d4a6d4c7e9c62f8e4 2026-07-20-canonical-tool-output-contract.md: 9cc7c97c1f0c9e0753ee826c1e20a3a425e2caf3
2026-07-20-canonical-tool-output-contract.zh.md: 01b50ef7493ea6548cd238f55e445a702e4d78b3 2026-07-20-canonical-tool-output-contract.zh.md: 183a7e6bf212e63dffca1a346aa907f66006444a

View File

@@ -24,7 +24,7 @@ output: {
`defineTool` infers the body return and both projectors from the unified `ValueSchemaSpec`. Raw and dynamic definitions provide the compiled `JsonSchemaNode` form. Registration rejects a missing declaration or unsupported raw schema; there is no content-return compatibility path. `defineTool` infers the body return and both projectors from the unified `ValueSchemaSpec`. Raw and dynamic definitions provide the compiled `JsonSchemaNode` form. Registration rejects a missing declaration or unsupported raw schema; there is no content-return compatibility path.
For each successful dispatch the registry snapshots the returned value as lossless `JsonValue`, validates it against `output.schema`, deep-freezes it, then invokes the pure renderer and, for a direct surface call, the optional metadata projector. Renderer, projector, schema, or lossless-JSON failures are contained as ordinary `ToolOutputError` results. An around `tools/execute` wrapper receives and returns the canonical success/failure union; a wrapper-authored success is normalized again through the resolved tool's output declaration instead of trusting independently authored content. For each successful dispatch the registry snapshots the returned value as lossless `JsonValue`, validates it against `output.schema`, deep-freezes it, then invokes the pure renderer and, for a direct surface call, the optional metadata projector. Renderer, projector, schema, or lossless-JSON failures are contained as ordinary `ToolOutputError` results. An around `tools/execute` wrapper receives and returns the canonical success/failure union; a wrapper-authored success is normalized again through the resolved tool's output declaration instead of trusting independently authored content. Canonical-result provenance is scoped to the immutable dispatch token, so returning a cached result from another call or tool triggers normalization under the active declaration rather than bypassing it.
```ts ignore-check ```ts ignore-check
type ToolExecutionResult = type ToolExecutionResult =

View File

@@ -24,7 +24,7 @@ output: {
`defineTool` 从统一的 `ValueSchemaSpec` 推导工具主体返回值和两个投影器的类型。原始定义和动态定义则提供编译后的 `JsonSchemaNode` 形式。注册时会拒绝缺失输出声明或采用不受支持原始 schema 的定义,不提供兼容旧式内容返回值的路径。 `defineTool` 从统一的 `ValueSchemaSpec` 推导工具主体返回值和两个投影器的类型。原始定义和动态定义则提供编译后的 `JsonSchemaNode` 形式。注册时会拒绝缺失输出声明或采用不受支持原始 schema 的定义,不提供兼容旧式内容返回值的路径。
每次成功分发时,注册表会将返回值快照为无损 `JsonValue`,依据 `output.schema` 校验并深度冻结,然后调用纯渲染器;对于直接的外层调用,还会调用可选的元数据投影器。渲染器、投影器、schema 或无损 JSON 处理失败都会被收敛为普通 `ToolOutputError` 结果。around `tools/execute` 包装层接收并返回规范的成功/失败联合;包装层自行产生的成功结果会再次通过已解析工具的输出声明完成归一化,而不会信任其独立编写的内容。 每次成功分发时,注册表会将返回值快照为无损 `JsonValue`,依据 `output.schema` 校验并深度冻结,然后调用纯渲染器;对于直接的外层调用,还会调用可选的元数据投影器。渲染器、投影器、schema 或无损 JSON 处理失败都会被收敛为普通 `ToolOutputError` 结果。around `tools/execute` 包装层接收并返回规范的成功/失败联合;包装层自行产生的成功结果会再次通过已解析工具的输出声明完成归一化,而不会信任其独立编写的内容。每个规范结果只归属于一个不可变的分发 token;因此,如果包装层返回来自其他调用或工具的缓存结果,系统会依据当前生效的输出声明重新执行归一化,而不会绕过这一步。
```ts ignore-check ```ts ignore-check
type ToolExecutionResult = type ToolExecutionResult =

View File

@@ -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. - 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/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. - `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). - 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. - MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.

View File

@@ -1093,7 +1093,7 @@ export class ToolRegistry extends Service {
if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution') if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0 const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
? normalized ? normalized
: this.markCanonical({ : this.markCanonical(exec, {
...normalized, ...normalized,
additionalContexts: [ additionalContexts: [
...deferredContexts, ...deferredContexts,
@@ -1244,7 +1244,7 @@ export class ToolRegistry extends Service {
const decisionContexts = decision.additionalContexts ?? [] const decisionContexts = decision.additionalContexts ?? []
if (decision.kind === 'block') { if (decision.kind === 'block') {
const message = failureMessageFromContent(decision.feedback) const message = failureMessageFromContent(decision.feedback)
return this.markCanonical({ return this.markCanonical(exec, {
content: decision.feedback, content: decision.feedback,
isError: true, isError: true,
error: { message }, error: { message },
@@ -1265,24 +1265,24 @@ export class ToolRegistry extends Service {
const tool = this.get(exec.name, exec.agent) const tool = this.get(exec.name, exec.agent)
if (tool === undefined) throw new ToolNotFoundError(exec.name) if (tool === undefined) throw new ToolNotFoundError(exec.name)
const replaced = this.createSuccessResult(exec, tool, decision.value) const replaced = this.createSuccessResult(exec, tool, decision.value)
return this.markCanonical({ return this.markCanonical(exec, {
...replaced, ...replaced,
...additionalContexts.length > 0 ? { additionalContexts } : {}, ...additionalContexts.length > 0 ? { additionalContexts } : {},
}) })
} }
return this.markCanonical({ return this.markCanonical(exec, {
...result, ...result,
...decision.content !== undefined ? { content: decision.content } : {}, ...decision.content !== undefined ? { content: decision.content } : {},
...additionalContexts.length > 0 ? { additionalContexts } : {}, ...additionalContexts.length > 0 ? { additionalContexts } : {},
}) })
} }
/** Results created by the registry already own a validated, frozen canonical value. */ /** Registry-normalized results and the exact dispatch that validated each value. */
private readonly canonicalResults = new WeakSet<object>() private readonly canonicalResults = new WeakMap<object, ToolExecutionToken>()
/** Mark a registry-normalized result without freezing presentation fields prematurely. */ /** Mark one registry-normalized result as canonical only for its owning dispatch. */
private markCanonical<T extends ToolExecutionResult>(result: T): T { private markCanonical<T extends ToolExecutionResult>(exec: ToolExecution, result: T): T {
this.canonicalResults.add(result) this.canonicalResults.set(result, exec.token)
return result return result
} }
@@ -1309,7 +1309,7 @@ export class ToolRegistry extends Service {
} }
meta = snapshotProjection(tool.name, 'presentationMeta', projected) meta = snapshotProjection(tool.name, 'presentationMeta', projected)
} }
return this.markCanonical(this.materializeFinalResult({ return this.markCanonical(exec, this.materializeFinalResult({
isError: false, isError: false,
value, value,
content, content,
@@ -1319,9 +1319,9 @@ export class ToolRegistry extends Service {
/** Normalize an around-dispatch wrapper's authored result through the owning output contract. */ /** Normalize an around-dispatch wrapper's authored result through the owning output contract. */
private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult { 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) { if (result.isError) {
return this.markCanonical({ return this.markCanonical(exec, {
isError: true, isError: true,
error: result.error, error: result.error,
content: result.content, content: result.content,
@@ -1332,7 +1332,7 @@ export class ToolRegistry extends Service {
const tool = this.get(exec.name, exec.agent) const tool = this.get(exec.name, exec.agent)
if (tool === undefined) throw new ToolNotFoundError(exec.name) if (tool === undefined) throw new ToolNotFoundError(exec.name)
const normalized = this.createSuccessResult(exec, tool, result.value) const normalized = this.createSuccessResult(exec, tool, result.value)
return this.markCanonical({ return this.markCanonical(exec, {
...normalized, ...normalized,
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
}) })

View File

@@ -1588,6 +1588,55 @@ describe('ToolRegistry', () => {
expect(result.content[0]).toMatchObject({ text: 'short-circuited' }) 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 () => { it('preserves additionalContexts supplied by an around-dispatch result', async () => {
const ctx = await setup() const ctx = await setup()
ctx.tools.register(echoTool) ctx.tools.register(echoTool)