refactor: identify and freeze messages at creation

This commit is contained in:
_Kerman
2026-07-28 13:55:59 +08:00
parent c49c0ba497
commit fbf87e660c
345 changed files with 5220 additions and 2901 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: db0598748f23b1f9d462984dd975497886e5f2c7
README.zh.md: 2e0413cfd693684cb1a0c11bdce97196b0b1aa44
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
README.md: e5adb153e77d7a2d8c4068b016194ab6abb6473e
README.zh.md: 0177c2a6a4c2db121d90c83044bb1e3de7d0099f

View File

@@ -44,7 +44,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `UserMessageData` for the loop's post-result FIFO.
- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute identified `UserMessage` for the loop's post-result FIFO.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
- `ToolGuard``(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.

View File

@@ -44,7 +44,7 @@ tools:
- `ToolExecutionToken`:注册表分配的全新带品牌 `Symbol`。它只支持通过相等性进行关联,绝不会跨越模型、日志或 worker 边界。
- `ToolExecution`:只读流水线视图:不可变的 `{ token, callId, name, arguments, signal, agent?, parent? }`;注册表会另行保留并重新融合调用方的原始信号。`ToolDispatchExecution` 是仅供 `tools/execute` 使用的视图,其必填信号可变,因此包装层可以替换并还原它,但不能删除它。嵌套调用的 `parent``ToolExecutionToken`,而不是执行对象。
- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。组合工具借此把嵌套分发产生的上下文传递到外层结果,即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。
- `ToolExecutionResult`:可辨识的执行局部结果。成功形态为 `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`;失败形态为 `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }`,且不含值。调用身份保留在不可变的 `ToolExecution` 上。注册表会在呈现前快照、验证并冻结规范值,随后在最终观测前实体化持久呈现字段。`ToolFailure.info` 携带内部的 `{ name, code }`,用于表示 `HarnessError``additionalContexts` 为循环在结果后的 FIFO 保留每个延迟或后置执行的 `UserMessageData`
- `ToolExecutionResult`:可辨识的执行局部结果。成功形态为 `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`;失败形态为 `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }`,且不含值。调用身份保留在不可变的 `ToolExecution` 上。注册表会在呈现前快照、验证并冻结规范值,随后在最终观测前实体化持久呈现字段。`ToolFailure.info` 携带内部的 `{ name, code }`,用于表示 `HarnessError``additionalContexts` 为循环在结果后的 FIFO 保留每个延迟或后置执行且带标识`UserMessage`
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`。该类型有意不提供输入改写;`ask` 在挂载 [`ctx.approval`](../../ui/user-approval/README.md) 时由它处理,否则退化为拒绝。
- `PostToolDecision`:接受决定可以替换 `content``value`(不能同时替换),并可附加 `additionalContexts`;阻止决定会把反馈变成无值失败。替换内容会保留规范值和元数据。替换值会重新验证,并重新呈现内容/元数据。接受决定会先保留工具延迟的上下文,再附加决定上下文;阻止决定会丢弃工具延迟的上下文,只公开阻止决定显式提供的上下文。
- `ToolGuard``(execution) => string | undefined`;返回的字符串是最终单调拒绝理由,在可重排的前置执行 waterfall 之后、分发之前求值。

View File

@@ -12,7 +12,7 @@ import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue, UserMessageData } from '@deepseek-ai/dsh-session'
import type { JsonValue, UserMessage } from '@deepseek-ai/dsh-session'
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
@@ -343,7 +343,7 @@ export interface ToolRunContext extends ToolExecution {
* the agent loop. Contexts retain their individual source and metadata and
* are emitted in call order.
*/
deferContext(context: UserMessageData): void
deferContext(context: UserMessage): void
/**
* Mark a successful final result as terminal for the current agent turn.
* The marker rides this execution's own result (`concludesTurn` exists only
@@ -484,7 +484,7 @@ export interface ToolExecutionSuccess {
readonly content: ContentBlock[]
readonly error?: never
readonly meta?: JsonValue
readonly additionalContexts?: UserMessageData[]
readonly additionalContexts?: UserMessage[]
/** The agent loop stops after committing this successful result batch. */
readonly concludesTurn?: true
}
@@ -496,7 +496,7 @@ export interface ToolExecutionFailure {
readonly value?: never
readonly content: ContentBlock[]
readonly meta?: JsonValue
readonly additionalContexts?: UserMessageData[]
readonly additionalContexts?: UserMessage[]
readonly concludesTurn?: never
}
@@ -519,9 +519,9 @@ export type PreToolDecision =
* next request, or block by turning corrective feedback into an error result.
*/
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessageData[] }
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessageData[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessageData[] }
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] }
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] }
/**
* Best-effort human-readable message from an arbitrary thrown value: Error
@@ -714,7 +714,7 @@ export class ToolRegistry extends Service {
}
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
private deferredContexts = new WeakMap<ToolRunContext, UserMessageData[]>()
private deferredContexts = new WeakMap<ToolRunContext, UserMessage[]>()
/** Executions whose tool body declared the current turn complete. */
private concludingExecutions = new WeakSet<ToolExecution>()
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
@@ -1054,7 +1054,7 @@ export class ToolRegistry extends Service {
}
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {
const deferredContexts: UserMessageData[] = []
const deferredContexts: UserMessage[] = []
const token = createExecutionToken()
const callId = exec.callId
const name = exec.name
@@ -1071,7 +1071,7 @@ export class ToolRegistry extends Service {
signal,
...agent !== undefined ? { agent } : {},
...parent !== undefined ? { parent } : {},
deferContext(context: UserMessageData): void {
deferContext(context: UserMessage): void {
deferredContexts.push(context)
},
concludeTurn(): void {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -626,10 +626,10 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => {
postOrder.push(String(postExec.callId))
return {
kind: 'accept' as const,
additionalContexts: [{
additionalContexts: [createUserMessage({
content: [{ type: 'text' as const, text: `ctx:${String(postExec.callId)}` }],
source: { kind: 'plugin' as const, plugin: 'order-probe' },
}],
})],
}
}
return next()
@@ -947,11 +947,10 @@ describe('the run_code dispatch bridge', () => {
if (exec.name === 'echo') {
return Promise.resolve({
kind: 'accept' as const,
additionalContexts: [{
additionalContexts: [createUserMessage({
content: [{ type: 'text' as const, text: `context for ${exec.callId}` }],
source: { kind: 'plugin' as const, plugin: 'test' },
meta: { callId: exec.callId },
}],
})],
})
}
return next()
@@ -963,16 +962,16 @@ describe('the run_code dispatch bridge', () => {
}
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(false)
expect(result.additionalContexts).toEqual([
expect(result.additionalContexts).toMatchObject([
{
role: 'user',
content: [{ type: 'text', text: 'context for call-1:code:1' }],
source: { kind: 'plugin', plugin: 'test' },
meta: { callId: 'call-1:code:1' },
},
{
role: 'user',
content: [{ type: 'text', text: 'context for call-1:code:2' }],
source: { kind: 'plugin', plugin: 'test' },
meta: { callId: 'call-1:code:2' },
},
])
})
@@ -984,10 +983,10 @@ describe('the run_code dispatch bridge', () => {
if (exec.name !== 'echo') return next()
return Promise.resolve({
kind: 'accept',
additionalContexts: [{
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'nested context' }],
source: { kind: 'plugin', plugin: 'test' },
}],
})],
})
})
runtime.behavior = async (request) => {
@@ -1441,7 +1440,9 @@ describe('the run_code dispatch bridge', () => {
it('a tool/code-dispatch event never derives a model message', () => {
const session = new Session(SessionId('code-mode-derive'))
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('tool/code-dispatch', {
parentCallId: CallId('p1'),
subCallId: CallId('p1:code:1'),

View File

@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
@@ -364,7 +364,9 @@ describe('ToolRegistry', () => {
return {
kind: 'accept',
value: { text: 'policy value' },
additionalContexts: [{ content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' } }],
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' },
})],
}
})
@@ -505,7 +507,9 @@ describe('ToolRegistry', () => {
error: { message: 'wrapped failure' },
content: [{ type: 'text', text: 'wrapper content' }],
meta: { wrapped: true },
additionalContexts: [{ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' } }],
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' },
})],
}))
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('wrapper-failure'), name: 'echo', arguments: {} })
@@ -901,7 +905,9 @@ describe('ToolRegistry', () => {
({
kind: 'block',
feedback: [{ type: 'text', text: 'rejected' }],
additionalContexts: [{ content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }],
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' },
})],
}))
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
@@ -915,7 +921,9 @@ describe('ToolRegistry', () => {
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }] }))
({ kind: 'accept', additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' },
})] }))
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }])
@@ -928,8 +936,12 @@ describe('ToolRegistry', () => {
description: 'composite',
parameters: {},
async execute(_args, exec) {
exec.deferContext({ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' } })
exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' } })
exec.deferContext(createUserMessage({
content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' },
}))
exec.deferContext(createUserMessage({
content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' },
}))
return [{ type: 'text', text: 'done' }]
},
}))
@@ -939,7 +951,9 @@ describe('ToolRegistry', () => {
...result,
additionalContexts: [
...result.additionalContexts ?? [],
{ content: [{ type: 'text', text: 'wrapper' }], source: { kind: 'plugin', plugin: 'wrapper' } },
createUserMessage({
content: [{ type: 'text', text: 'wrapper' }], source: { kind: 'plugin', plugin: 'wrapper' },
}),
],
}
})
@@ -948,7 +962,9 @@ describe('ToolRegistry', () => {
return {
...downstream,
additionalContexts: [
{ content: [{ type: 'text', text: 'post' }], source: { kind: 'plugin', plugin: 'post' } },
createUserMessage({
content: [{ type: 'text', text: 'post' }], source: { kind: 'plugin', plugin: 'post' },
}),
...downstream.additionalContexts ?? [],
],
}
@@ -971,7 +987,9 @@ describe('ToolRegistry', () => {
description: 'failing composite',
parameters: {},
async execute(_args, exec) {
exec.deferContext({ content: [{ type: 'text', text: 'nested' }], source: { kind: 'plugin', plugin: 'nested' } })
exec.deferContext(createUserMessage({
content: [{ type: 'text', text: 'nested' }], source: { kind: 'plugin', plugin: 'nested' },
}))
throw new Error('outer failure')
},
}))
@@ -983,7 +1001,9 @@ describe('ToolRegistry', () => {
ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
kind: 'block',
feedback: [{ type: 'text', text: 'blocked' }],
additionalContexts: [{ content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' } }],
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' },
})],
}))
const blocked = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('blocked'), name: 'failing-composite', arguments: {} })
expect(blocked.isError).toBe(true)
@@ -1224,10 +1244,10 @@ describe('ToolRegistry', () => {
value: 'wrapper success',
content: [{ type: 'text', text: 'wrapper success' }],
isError: false,
additionalContexts: [{
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'wrapper context' }],
source: { kind: 'plugin', plugin: 'wrapper' },
}],
})],
}
})
const controller = new AbortController()
@@ -1257,10 +1277,10 @@ describe('ToolRegistry', () => {
...echoTool,
name: 'completed-before-wrapper',
async execute(_args, exec) {
exec.deferContext({
exec.deferContext(createUserMessage({
content: [{ type: 'text', text: 'completed child work' }],
source: { kind: 'plugin', plugin: 'child' },
})
}))
return 'body complete'
},
})
@@ -1294,10 +1314,10 @@ describe('ToolRegistry', () => {
...echoTool,
name: 'completed-before-post',
async execute(_args, exec) {
exec.deferContext({
exec.deferContext(createUserMessage({
content: [{ type: 'text', text: 'completed child work' }],
source: { kind: 'plugin', plugin: 'child' },
})
}))
return 'body complete'
},
})
@@ -1309,10 +1329,10 @@ describe('ToolRegistry', () => {
await release.promise
return {
...decision,
additionalContexts: [{
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'post context' }],
source: { kind: 'plugin', plugin: 'post' },
}],
})],
}
})
const controller = new AbortController()
@@ -1499,10 +1519,10 @@ describe('ToolRegistry', () => {
...echoTool,
name: 'uncooperative',
execute(_args, exec) {
exec.deferContext({
exec.deferContext(createUserMessage({
content: [{ type: 'text', text: 'nested outcome' }],
source: { kind: 'plugin', plugin: 'nested' },
})
}))
entered.resolve(undefined)
return release.promise
},
@@ -1789,10 +1809,10 @@ describe('ToolRegistry', () => {
content: [{ type: 'text', text: 'short-circuited with context' }],
isError: false,
value: 'short-circuited with context',
additionalContexts: [{
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'from around dispatch' }],
source: { kind: 'plugin', plugin: 'test' },
}],
})],
}))
const result = await ctx.tools.execute({