Merge origin/master into parallel-tool-call

This commit is contained in:
Tianyi Cui
2026-07-17 22:08:40 +08:00
432 changed files with 28392 additions and 3077 deletions

View File

@@ -5,6 +5,7 @@
* @module @deepseek-ai/dsh-tools/src/code-mode
*/
import { parse } from 'node:path'
import { inspect } from 'node:util'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
@@ -16,11 +17,19 @@ import type { ToolDefinition, ToolRegistry } from './index.ts'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the
* deterministic sub-call id (`<parent>:code:<n>`), the tool `name` with its
* JSON-normalized `arguments` — the exact value dispatched, normalized before dispatch, so
* this append can never fail on payload shape — whether the sub-call errored, and a
* bounded `resultSummary` of its model-facing text.
* One bridged sub-dispatch from a `run_code` program: the parent
* `run_code` call id, the deterministic sub-call id
* (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
* `arguments` — the exact value dispatched, normalized BEFORE dispatch,
* so this append can never fail on payload shape — whether the sub-call
* errored, and a bounded `resultSummary` of its model-facing text. Before
* bounding, occurrences of a non-root session workspace path are
* normalized to `.` so host-specific absolute path lengths cannot change
* the summary.
* Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
* model context; persistence and UIs get every call. Appended inside the
* parent `run_code`'s execution (the bridge drains its queue before
* returning), so the turn-enclosure invariant holds by construction.
*/
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
}
@@ -70,9 +79,12 @@ function textOf(content: ContentBlock[]): string {
.join('\n')
}
/** Bound a sub-call's model-facing text for the log event's `resultSummary`. */
function summarize(text: string): string {
return text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}` : text
/** Normalize workspace paths, then bound a sub-call's model-facing text for its durable log summary. */
function summarize(text: string, cwd: string | undefined): string {
const stableText = cwd === undefined || cwd === parse(cwd).root
? text
: text.replaceAll(cwd, '.')
return stableText.length > SUMMARY_MAX_CHARS ? `${stableText.slice(0, SUMMARY_MAX_CHARS)}` : stableText
}
/**
@@ -189,10 +201,10 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
parent: exec.token,
signal: runController.signal,
})
for (const context of result.additionalContexts ?? []) {
exec.deferContext(context)
}
const text = textOf(result.content)
// Sub-call `additionalContext` is deliberately DROPPED here: the loop's buffering
// (append after the step's tool/results) has no safe analogue from inside a running
// run_code — injecting now would break tool-call/result adjacency.
exec.agent?.session.append('tool/code-dispatch', {
parentCallId: exec.callId,
subCallId,
@@ -202,7 +214,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
// this record from what it actually received.
arguments: normalized.logged,
isError: result.isError,
resultSummary: summarize(text),
resultSummary: summarize(text, exec.agent.session.header.cwd),
})
return { text, isError: result.isError }
})

View File

@@ -122,7 +122,7 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
/**
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
@@ -229,6 +229,21 @@ export interface ToolExecution extends ToolExecutionInput {
readonly token: ToolExecutionToken
}
/**
* Runtime context handed to a tool implementation after the registry has
* accepted a {@link ToolExecution}. A composite tool uses
* {@link deferContext} to ferry context produced by nested dispatches back to
* the outer result; the loop appends it only after the outer `tool/result`.
*/
export interface ToolRunContext extends ToolExecution {
/**
* Defer one nested-dispatch context until this tool's final result reaches
* the agent loop. Contexts retain their individual source, envelope, and
* metadata and are emitted in call order.
*/
deferContext(context: HookContext): void
}
/**
* Internal result of the scheduler-owned `tools/pre-execute` stage. Exported
* only so `dsh-agent-loop` can split ordered middleware from concurrent
@@ -236,9 +251,9 @@ export interface ToolExecution extends ToolExecutionInput {
* @internal
*/
export type ScheduledToolPreparation =
| { kind: 'dispatch'; exec: ToolExecution }
| { kind: 'post-result'; exec: ToolExecution; result: ToolExecutionResult }
| { kind: 'final-result'; exec: ToolExecution; result: ToolExecutionResult }
| { kind: 'dispatch'; exec: ToolRunContext }
| { kind: 'post-result'; exec: ToolRunContext; result: ToolExecutionResult }
| { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }
/**
* Internal result of the scheduler-owned `tools/execute` stage. A normal tool
@@ -262,11 +277,11 @@ export interface ToolRegistryScheduler {
/** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */
prepare(exec: ToolExecutionInput): Promise<ScheduledToolPreparation>
/** Run only the around-dispatch/body stage. */
dispatch(exec: ToolExecution): Promise<ScheduledToolDispatch>
dispatch(exec: ToolRunContext): Promise<ScheduledToolDispatch>
/** Run ordered post-execute finalization, then materialize and notify the final outcome. */
finalize(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult>
finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult>
/** Materialize and notify a final outcome that must bypass post-execute. */
finish(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult
finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult
}
/**
@@ -309,7 +324,7 @@ export interface ToolExecutionResult {
* Model-facing context for the next request, separate from this tool result.
* The loop buffers it until all step results are logged, preserving pairing.
*/
additionalContext?: HookContext
additionalContexts?: HookContext[]
/**
* The tool-private presentation payload from a successful `execute` (the object
* return form). Threaded onto the `tool/result` session event and back into
@@ -335,8 +350,8 @@ export type PreToolDecision =
* request, or block by turning corrective feedback into an error result.
*/
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
/**
* Best-effort human-readable message from an arbitrary thrown value: Error
@@ -444,6 +459,8 @@ export class ToolRegistry extends Service {
finish: (exec, result) => this.finishScheduledExecution(exec, result),
}
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
private global = new Map<string, ToolDefinition>()
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
/** Compiled restriction filters, per scope (see {@link restrict}). */
@@ -799,7 +816,8 @@ export class ToolRegistry extends Service {
}
/** Materialize caller input into the immutable identity object used by the pipeline. */
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolExecution } {
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } {
const deferredContexts: HookContext[] = []
const token = createExecutionToken()
const callId = exec.callId
const name = exec.name
@@ -813,15 +831,20 @@ export class ToolRegistry extends Service {
...agent !== undefined ? { agent } : {},
...parent !== undefined ? { parent } : {},
...signal !== undefined ? { signal } : {},
deferContext(context: HookContext): void {
deferredContexts.push(context)
},
}
try {
const detached = snapshotJsonValue(exec.arguments)
if (detached === undefined) {
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
}
return { kind: 'ready', exec: { ...base, arguments: deepFreeze(detached) } }
const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) }
this.deferredContexts.set(execution, deferredContexts)
return { kind: 'ready', exec: execution }
} catch (error: unknown) {
const execution: ToolExecution = { ...base, arguments: undefined }
const execution: ToolRunContext = { ...base, arguments: undefined }
return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
}
}
@@ -878,7 +901,7 @@ export class ToolRegistry extends Service {
* @returns whether the result still needs post-execute.
* @internal
*/
private async dispatchScheduledExecution(exec: ToolExecution): Promise<ScheduledToolDispatch> {
private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {
try {
const carrier = scopeTarget(this, exec.agent)
const result = await this.ctx.waterfall(
@@ -896,7 +919,19 @@ export class ToolRegistry extends Service {
}
},
)
return { kind: 'post-result', result }
const deferredContexts = this.deferredContexts.get(exec)
/* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
? result
: {
...result,
additionalContexts: [
...deferredContexts,
...result.additionalContexts ?? [],
],
}
return { kind: 'post-result', result: resultWithDeferredContexts }
} catch (error: unknown) {
return { kind: 'final-result', result: toolErrorResult(error) }
}
@@ -909,7 +944,7 @@ export class ToolRegistry extends Service {
* @returns the materialized final result.
* @internal
*/
private async finalizeScheduledExecution(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult> {
try {
return this.finishScheduledExecution(exec, await this.postExecute(exec, result))
} catch (error: unknown) {
@@ -924,7 +959,7 @@ export class ToolRegistry extends Service {
* @returns the materialized final result.
* @internal
*/
private finishScheduledExecution(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult {
private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
let finalResult: ToolExecutionResult
try {
finalResult = this.materializeFinalResult(result)
@@ -994,8 +1029,11 @@ export class ToolRegistry extends Service {
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
* `content` when given), `block` turns it into an `isError` whose content is
* the corrective `feedback`. Either decision may attach `additionalContext`,
* which is ferried on the returned result for the loop's per-step buffer.
* the corrective `feedback`. Either decision may attach `additionalContexts`,
* which are ferried on the returned result for the loop's per-step buffer.
* Context deferred by the tool body survives an accepted result but is
* discarded when the outer call is blocked; a block exposes only context the
* blocking decision explicitly supplied.
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
*/
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
@@ -1003,19 +1041,24 @@ export class ToolRegistry extends Service {
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
)
const additionalContext = decision.additionalContext
const decisionContexts = decision.additionalContexts ?? []
if (decision.kind === 'block') {
return {
content: decision.feedback,
isError: true,
...additionalContext ? { additionalContext } : {},
...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
}
}
// Accept: replace content if supplied and preserve the dispatched outcome.
// Accept: replace content if supplied, preserve the dispatched outcome, and
// append decision contexts after contexts deferred by the tool body.
const additionalContexts = [
...result.additionalContexts ?? [],
...decisionContexts,
]
return {
...result,
...decision.content ? { content: decision.content } : {},
...additionalContext ? { additionalContext } : {},
...additionalContexts.length > 0 ? { additionalContexts } : {},
}
}

View File

@@ -1,7 +1,7 @@
/** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
// ---------------------------------------------------------------------------
@@ -299,7 +299,7 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* content only) or a `{ content, meta }` object to also attach a tool-private
* presentation payload (see {@link ToolExecuteReturn}).
*/
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ToolExecuteReturn>
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<ToolExecuteReturn>
/**
* Optional: how to present the PENDING state of one call in a UI (an editor
* tool-call card, a CLI log line). `args` is the typed, schema-validated
@@ -345,7 +345,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
async execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> {
// Validate the model-generated args before the typed body runs. On
// mismatch we throw ToolArgsError; the registry turns it into an
// isError result so the model can self-correct. After this guard, the