Merge remote-tracking branch 'origin/master' into feature/subagent-policy-inheritance
# Conflicts: # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # docs/cordis-catalog/services.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/core/session/README.i18n.yaml # packages/subagent/subagent-inprocess/README.i18n.yaml # packages/ui/user-approval/src/index.ts
This commit is contained in:
@@ -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
|
||||
README.md: 3a9082f99663586936ce1eafaf09264a91798e28
|
||||
README.zh.md: 763ae1195d2c7a2606806970a3ba6371114d9d1c
|
||||
README.md: db0598748f23b1f9d462984dd975497886e5f2c7
|
||||
README.zh.md: 2e0413cfd693684cb1a0c11bdce97196b0b1aa44
|
||||
|
||||
@@ -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 `HookContext` 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 `UserMessageData` 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.
|
||||
|
||||
@@ -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 保留每个延迟或后置执行的 `HookContext`。
|
||||
- `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`。
|
||||
- `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 之后、分发之前求值。
|
||||
|
||||
@@ -479,6 +479,12 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
|
||||
for (const context of result.additionalContexts ?? []) {
|
||||
exec.deferContext(context)
|
||||
}
|
||||
// Like the context forwarding above, cross-boundary facts travel
|
||||
// on the nested result and the composite forwards them: only a
|
||||
// successful nested result can carry the terminal marker
|
||||
// (ToolExecutionFailure types it never), so a policy-converted
|
||||
// failure cannot stop the turn through a recovering program.
|
||||
if (result.concludesTurn) exec.concludeTurn()
|
||||
settle(result)
|
||||
// Backpressure on the shaped-append side channel: pending log
|
||||
// tasks (each retaining a full result while a slow backend
|
||||
|
||||
@@ -10,9 +10,9 @@ import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } fr
|
||||
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { CallId, ContentBlock, ToolSchema } 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 } from '@deepseek-ai/dsh-agent'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue, UserMessageData } 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,16 @@ export interface ToolRunContext extends ToolExecution {
|
||||
* the agent loop. Contexts retain their individual source and metadata and
|
||||
* are emitted in call order.
|
||||
*/
|
||||
deferContext(context: HookContext): void
|
||||
deferContext(context: UserMessageData): void
|
||||
/**
|
||||
* Mark a successful final result as terminal for the current agent turn.
|
||||
* The marker rides this execution's own result (`concludesTurn` exists only
|
||||
* on {@link ToolExecutionSuccess}); a composite that dispatches nested
|
||||
* calls forwards it from the nested result, exactly like
|
||||
* `additionalContexts`, so only an authoritative nested success can
|
||||
* conclude the enclosing run.
|
||||
*/
|
||||
concludeTurn(): void
|
||||
}
|
||||
|
||||
/** Registry-owned live execution object; public pipeline views stay readonly. */
|
||||
@@ -475,7 +484,9 @@ export interface ToolExecutionSuccess {
|
||||
readonly content: ContentBlock[]
|
||||
readonly error?: never
|
||||
readonly meta?: JsonValue
|
||||
readonly additionalContexts?: HookContext[]
|
||||
readonly additionalContexts?: UserMessageData[]
|
||||
/** The agent loop stops after committing this successful result batch. */
|
||||
readonly concludesTurn?: true
|
||||
}
|
||||
|
||||
/** Failed canonical tool execution; failures never carry a successful value. */
|
||||
@@ -485,7 +496,8 @@ export interface ToolExecutionFailure {
|
||||
readonly value?: never
|
||||
readonly content: ContentBlock[]
|
||||
readonly meta?: JsonValue
|
||||
readonly additionalContexts?: HookContext[]
|
||||
readonly additionalContexts?: UserMessageData[]
|
||||
readonly concludesTurn?: never
|
||||
}
|
||||
|
||||
/** The discriminated, execution-local outcome of one tool call. */
|
||||
@@ -507,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?: HookContext[] }
|
||||
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessageData[] }
|
||||
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessageData[] }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessageData[] }
|
||||
|
||||
/**
|
||||
* Best-effort human-readable message from an arbitrary thrown value: Error
|
||||
@@ -702,7 +714,9 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
|
||||
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
|
||||
private deferredContexts = new WeakMap<ToolRunContext, UserMessageData[]>()
|
||||
/** Executions whose tool body declared the current turn complete. */
|
||||
private concludingExecutions = new WeakSet<ToolExecution>()
|
||||
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
|
||||
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
|
||||
/** Definition-owned final content transform snapshotted before policy begins. */
|
||||
@@ -1040,7 +1054,7 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {
|
||||
const deferredContexts: HookContext[] = []
|
||||
const deferredContexts: UserMessageData[] = []
|
||||
const token = createExecutionToken()
|
||||
const callId = exec.callId
|
||||
const name = exec.name
|
||||
@@ -1049,6 +1063,7 @@ export class ToolRegistry extends Service {
|
||||
const signal = exec.signal
|
||||
const definition = this.get(name, agent)
|
||||
const finalizeContent = definition?.finalizeContent?.bind(definition)
|
||||
const concludingExecutions = this.concludingExecutions
|
||||
const base = {
|
||||
token,
|
||||
callId,
|
||||
@@ -1056,9 +1071,12 @@ export class ToolRegistry extends Service {
|
||||
signal,
|
||||
...agent !== undefined ? { agent } : {},
|
||||
...parent !== undefined ? { parent } : {},
|
||||
deferContext(context: HookContext): void {
|
||||
deferContext(context: UserMessageData): void {
|
||||
deferredContexts.push(context)
|
||||
},
|
||||
concludeTurn(): void {
|
||||
concludingExecutions.add(this as unknown as ToolExecution)
|
||||
},
|
||||
}
|
||||
try {
|
||||
const detached = snapshotJsonValue(exec.arguments)
|
||||
@@ -1442,11 +1460,13 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
meta = snapshotProjection(tool.name, 'presentationMeta', projected)
|
||||
}
|
||||
const concludesTurn = this.concludingExecutions.has(exec)
|
||||
return this.markCanonical(exec, this.materializeFinalResult({
|
||||
isError: false,
|
||||
value,
|
||||
content,
|
||||
...meta !== undefined ? { meta } : {},
|
||||
...concludesTurn ? { concludesTurn: true as const } : {},
|
||||
}) as ToolExecutionSuccess)
|
||||
}
|
||||
|
||||
@@ -1481,7 +1501,11 @@ export class ToolRegistry extends Service {
|
||||
if (result.isError) {
|
||||
return materializePresentation({ isError: true as const, error: result.error, ...presentation })
|
||||
}
|
||||
const detached = materializePresentation({ isError: false as const, ...presentation })
|
||||
const detached = materializePresentation({
|
||||
isError: false as const,
|
||||
...presentation,
|
||||
...result.concludesTurn === true ? { concludesTurn: true as const } : {},
|
||||
})
|
||||
return deepFreeze({ ...detached, value: result.value })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,6 +743,46 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'done' }])
|
||||
})
|
||||
|
||||
it('forwards a nested terminal conclusion onto the successful run_code result', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'finalize',
|
||||
description: 'Terminal tool.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
execute(_args, exec) {
|
||||
exec.concludeTurn()
|
||||
return Promise.resolve('done')
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
await request.bindings[0]!.functions.finalize!({})
|
||||
return { logs: [], value: 'program complete' }
|
||||
}
|
||||
|
||||
const concluded = await runCode(ctx, 'await tools.finalize({})')
|
||||
expect(concluded.isError).toBe(false)
|
||||
expect(concluded.concludesTurn).toBe(true)
|
||||
|
||||
// A policy that converts the nested success into an error strips the
|
||||
// marker with the result type: the recovering program cannot conclude.
|
||||
const veto = ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
|
||||
if (exec.name !== 'finalize') return next()
|
||||
return { kind: 'block', feedback: [{ type: 'text', text: 'terminal rejected' }] }
|
||||
})
|
||||
runtime.behavior = async (request) => {
|
||||
await request.bindings[0]!.functions.finalize!({}).catch(() => undefined)
|
||||
return { logs: [], value: 'recovered' }
|
||||
}
|
||||
const recovered = await runCode(ctx, 'await tools.finalize({}).catch(() => {})')
|
||||
veto()
|
||||
expect(recovered.isError).toBe(false)
|
||||
expect(recovered.concludesTurn).toBeUndefined()
|
||||
})
|
||||
|
||||
it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const intervals: [string, string][] = []
|
||||
|
||||
@@ -553,6 +553,56 @@ describe('ToolRegistry', () => {
|
||||
expect(nested.isError ? undefined : nested.value).toBe('')
|
||||
})
|
||||
|
||||
it('carries a nested conclusion on the nested result for its composite to forward', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'terminal-nested',
|
||||
async execute(_args, exec) {
|
||||
exec.concludeTurn()
|
||||
return 'terminal'
|
||||
},
|
||||
})
|
||||
// A composite that forwards the marker from the nested result — the Code
|
||||
// Mode dispatch shape. A recovering composite (nested failure swallowed)
|
||||
// has no marker to forward: ToolExecutionFailure types concludesTurn as
|
||||
// never, so only an authoritative nested success can conclude the run.
|
||||
let call = 0
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'composite',
|
||||
async execute(_args, exec) {
|
||||
call += 1
|
||||
const nested = await ctx.tools.execute({
|
||||
signal: exec.signal, callId: CallId(`nested-${call}`), name: 'terminal-nested', arguments: {}, parent: exec.token,
|
||||
})
|
||||
if (nested.concludesTurn) exec.concludeTurn()
|
||||
return nested.isError ? 'nested failed, composite recovered' : 'nested succeeded'
|
||||
},
|
||||
})
|
||||
|
||||
// A policy converts the nested success into an error: the failed result
|
||||
// carries no marker, so the recovering composite does not conclude.
|
||||
const veto = ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
|
||||
if (exec.name !== 'terminal-nested') return next()
|
||||
return { kind: 'block', feedback: [{ type: 'text', text: 'nested success rejected' }] }
|
||||
})
|
||||
const recovered = await ctx.tools.execute({
|
||||
signal: testToolSignal, callId: CallId('composite-vetoed'), name: 'composite', arguments: {},
|
||||
})
|
||||
expect(recovered.isError).toBe(false)
|
||||
expect(recovered.concludesTurn).toBeUndefined()
|
||||
veto()
|
||||
|
||||
// The same nested call succeeding carries the marker; the composite
|
||||
// forwards it onto its own successful result.
|
||||
const concluded = await ctx.tools.execute({
|
||||
signal: testToolSignal, callId: CallId('composite-ok'), name: 'composite', arguments: {},
|
||||
})
|
||||
expect(concluded.isError).toBe(false)
|
||||
expect(concluded.concludesTurn).toBe(true)
|
||||
})
|
||||
|
||||
it('returns isError results for unknown tools and throwing tools', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
@@ -878,7 +928,7 @@ describe('ToolRegistry', () => {
|
||||
description: 'composite',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' }, meta: { n: 1 } })
|
||||
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' } })
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
@@ -912,7 +962,6 @@ describe('ToolRegistry', () => {
|
||||
{ kind: 'plugin', plugin: 'wrapper' },
|
||||
{ kind: 'plugin', plugin: 'post' },
|
||||
])
|
||||
expect(result.additionalContexts?.[0]?.meta).toEqual({ n: 1 })
|
||||
})
|
||||
|
||||
it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {
|
||||
|
||||
Reference in New Issue
Block a user