fix(pty): close final review gaps

This commit is contained in:
Tianyi Cui
2026-07-23 02:53:43 +08:00
parent db68a90f41
commit 2821826e2c
35 changed files with 271 additions and 114 deletions

View File

@@ -139,6 +139,18 @@ export interface ToolDefinition extends ToolSchema {
* @returns model-facing content plus optional private presentation metadata.
*/
execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
/**
* Synchronous last-mile transform for model-facing content. The registry
* snapshots this callback when execution starts and invokes it exactly once
* for every normalized outcome, including pipeline failures that bypass
* `tools/post-execute`, immediately before lossless materialization.
* Returning `undefined` preserves the content; every other result field
* remains registry-owned. The callback must be total and must not throw.
* @param exec - immutable execution identity and arguments.
* @param result - complete normalized outcome before materialization.
* @returns replacement content, or `undefined` to preserve it.
*/
finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
/**
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
@@ -301,9 +313,9 @@ export interface ToolRegistryScheduler {
prepare(exec: ToolExecutionInput): Promise<ScheduledToolPreparation>
/** Run only the around-dispatch/body stage. */
dispatch(exec: ToolRunContext): Promise<ScheduledToolDispatch>
/** Run ordered post-execute finalization, then materialize and notify the final outcome. */
/** Run post-execute and definition-owned content finalization, then materialize and notify. */
finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult>
/** Materialize and notify a final outcome that must bypass post-execute. */
/** Run definition-owned content finalization, then materialize and notify without post-execute. */
finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult
}
@@ -540,6 +552,8 @@ export class ToolRegistry extends Service {
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
/** 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. */
private contentFinalizers = new WeakMap<ToolRunContext, ToolDefinition['finalizeContent']>()
private readonly layers = new ScopedLayers(
scope => new ToolLayer(scope),
() => { this.ctx.emit('tools/change') },
@@ -617,7 +631,7 @@ export class ToolRegistry extends Service {
/**
* Register globally or in the calling agent scope. Scoped tools shadow
* globals; duplicates within one layer and the reserved `run_code` name fail.
* @param definition - the tool schema, execution, and optional presentation functions.
* @param definition - tool schema, execution, and optional finalization/presentation callbacks.
* @returns the exact disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void {
@@ -784,10 +798,11 @@ export class ToolRegistry extends Service {
}
/**
* Execute through pre-policy, guards, around-dispatch, post-policy, and final
* notification. Tool and listener failures resolve as materialized error
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
* the same lossless, frozen snapshot final observers receive. Cancellation
* Execute through pre-policy, guards, around-dispatch, post-policy,
* definition-owned content finalization, and final notification. Tool and
* listener failures resolve as materialized error results; an invisible tool
* reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen
* snapshot final observers receive. Cancellation
* arriving after entry and before final result materialization skips a
* not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a
* successful started outcome with `ABORTED`; already-started work is still
@@ -826,6 +841,8 @@ export class ToolRegistry extends Service {
const agent = exec.agent
const parent = exec.parent
const signal = exec.signal
const definition = this.get(name, agent)
const finalizeContent = definition?.finalizeContent?.bind(definition)
const base = {
token,
callId,
@@ -844,6 +861,7 @@ export class ToolRegistry extends Service {
}
const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) }
this.deferredContexts.set(execution, deferredContexts)
this.contentFinalizers.set(execution, finalizeContent)
this.cancellationStates.set(execution, {
callerSignal: signal,
bodyInvoked: false,
@@ -851,6 +869,7 @@ export class ToolRegistry extends Service {
return { kind: 'ready', exec: execution }
} catch (error: unknown) {
const execution: MutableToolRunContext = { ...base, arguments: undefined }
this.contentFinalizers.set(execution, finalizeContent)
return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
}
}
@@ -1008,7 +1027,8 @@ export class ToolRegistry extends Service {
}
/**
* Run ordered post-execute, then materialize and notify the final outcome.
* Run ordered post-execute, then apply definition-owned content finalization,
* materialize, and notify the final outcome.
* @param exec - the prepared execution.
* @param result - dispatch/pre result that still needs post-execute.
* @returns the materialized final result.
@@ -1029,7 +1049,8 @@ export class ToolRegistry extends Service {
}
/**
* Materialize and notify a final result that must bypass post-execute.
* Apply definition-owned content finalization, then materialize and notify a
* final result that must bypass post-execute.
* @param exec - the prepared execution.
* @param result - final result.
* @returns the materialized final result.
@@ -1038,7 +1059,7 @@ export class ToolRegistry extends Service {
private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
let finalResult: ToolExecutionResult
try {
finalResult = this.materializeFinalResult(result)
finalResult = this.materializeFinalResult(this.applyFinalContent(exec, result))
} catch (error: unknown) {
finalResult = this.materializeFinalResult(toolErrorResult(error))
}
@@ -1046,6 +1067,14 @@ export class ToolRegistry extends Service {
return finalResult
}
/** Apply the snapshotted tool-owned content transform without exposing other result fields. */
private applyFinalContent(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
const finalizeContent = this.contentFinalizers.get(exec)
if (finalizeContent === undefined) return result
const content = finalizeContent(exec, result)
return content === undefined ? result : { ...result, content }
}
/** Notify observers without exposing a mutation or error channel into the outcome. */
private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
// Freeze the registry's live object before observers receive its readonly

View File

@@ -1,7 +1,15 @@
/** 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, ToolRunContext, ToolResult } from './index.ts'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type {
ToolDefinition,
ToolExecuteReturn,
ToolExecution,
ToolExecutionResult,
ToolRunContext,
ToolResult,
} from './index.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
// ---------------------------------------------------------------------------
@@ -294,6 +302,15 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* presentation payload (see {@link ToolExecuteReturn}).
*/
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<ToolExecuteReturn>
/**
* Optional last-mile content transform for every normalized outcome. Unlike
* `execute`, arguments remain `unknown` because invalid-input failures also
* reach this callback. See {@link ToolDefinition.finalizeContent}.
* @param exec - immutable execution identity and arguments.
* @param result - complete normalized outcome before materialization.
* @returns replacement content, or `undefined` to preserve it.
*/
finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
/**
* 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
@@ -317,7 +334,7 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* inferred from its per-property schema. Raw JSON-Schema definitions remain
* valid inputs to {@link ToolRegistry.register}; this helper is authoring sugar.
* @param options - the tool's name, description, typed parameter schema,
* execute body, and optional presenters.
* execute body, and optional finalization/presentation callbacks.
* @returns a registry-ready definition with strict execution validation and
* soft presenter and classifier validation for replay compatibility.
*/
@@ -326,6 +343,8 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
// eslint-disable-next-line @typescript-eslint/unbound-method
const userExecute = options.execute
// eslint-disable-next-line @typescript-eslint/unbound-method
const userFinalizeContent = options.finalizeContent
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentResult = options.presentResult
@@ -349,6 +368,9 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
return userExecute(args as InferArgs<S>, exec)
},
}
if (userFinalizeContent) {
tool.finalizeContent = (exec, result) => userFinalizeContent(exec, result)
}
// Presentation is display-only and may run on REPLAY of arbitrary logged args
// (possibly from an older schema), so it must never throw: validate softly and
// fall back to `undefined` (a generic UI presentation) on any mismatch, rather