Sfoglia il codice sorgente

fix(tools): scope canonical provenance to dispatch

Tianyi Cui 2 mesi fa
parent
commit
d626b2582d

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml

@@ -2,5 +2,5 @@
 # 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:
 #   pnpm run verify-translation-pairing --write
-2026-07-20-canonical-tool-output-contract.md: 4099568de5dcc21a89b7873d4a6d4c7e9c62f8e4
-2026-07-20-canonical-tool-output-contract.zh.md: 01b50ef7493ea6548cd238f55e445a702e4d78b3
+2026-07-20-canonical-tool-output-contract.md: 9cc7c97c1f0c9e0753ee826c1e20a3a425e2caf3
+2026-07-20-canonical-tool-output-contract.zh.md: 183a7e6bf212e63dffca1a346aa907f66006444a

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md

@@ -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.
 
-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
 type ToolExecutionResult =

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md

@@ -24,7 +24,7 @@ output: {
 
 `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
 type ToolExecutionResult =

+ 1 - 1
packages/core/tools/README.md

@@ -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.

+ 13 - 13
packages/core/tools/src/index.ts

@@ -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 } : {},
     })

+ 49 - 0
packages/core/tools/tests/tools.spec.ts

@@ -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)