Files
deepseek-harness/packages/timeout/timeout-policy/src/index.ts
T
Tianyi Cui 32ed403500 Merge remote-tracking branch 'origin/master' into codex/simp-prune-tools-prompt-surface
# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/session/README.md
#	packages/core/session/tests/derived-cache.spec.ts
2026-07-14 17:30:34 +08:00

77 lines
3.3 KiB
TypeScript

/**
* Cooperative tool-call timeout enforcer. A tool declares `timeoutMs` and
* promises to honor `exec.signal`; this wrapper arms that deadline and maps its
* own expiry to `TOOL_TIMEOUT` without racing or abandoning the tool promise.
* @module @deepseek-ai/dsh-timeout-policy
*/
import type { Context } from 'cordis'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
/**
* The code owned by this plugin, used BOTH as the internal {@link deadline}
* classification code AND as the structured error `code` on the replacement
* tool result. Scoping {@link timeoutOf} to it keeps a nested outer deadline
* (another `tools/execute` wrapper's timer that fired first) from being misread
* as this plugin's own timeout — it reads as an ordinary upstream cancel.
*/
export const TOOL_TIMEOUT = 'TOOL_TIMEOUT'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'timeout-policy'
/** The tool registry seam this plugin wraps (`tools/execute`) and reads (`get`). */
export const inject = ['tools']
/**
* The structured result substituted when this plugin's deadline wins. `content`
* is the model-facing message; `error.code` is the same {@link TOOL_TIMEOUT}
* this plugin owns, so a retry/sandbox plugin (and replay) can route on it.
*
* @param timeoutMs - the elapsed budget, rendered into the model-facing message.
* @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error.
*/
function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
return {
content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
isError: true,
error: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT },
}
}
/**
* Register the timeout wrapper. It resolves the caller-visible tool definition,
* temporarily replaces `exec.signal`, delegates, restores the upstream signal,
* and replaces the result only when this wrapper's own timer fired.
*/
export function apply(ctx: Context): void {
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
const timeoutMs = ctx.tools.get(exec.name, exec.agent)?.timeoutMs
// A tool that declares no budget: no deadline, delegate unchanged.
if (timeoutMs === undefined) return next()
using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
// Swap the derived deadline onto exec for dispatch, then restore the
// caller's own signal so post-execute listeners never see this plugin's
// (possibly already-aborted) timeout signal. `undefined` is not assignable to
// the optional `signal` under exactOptionalPropertyTypes, so branch on it.
const upstream = exec.signal
exec.signal = d.signal
try {
const result = await next()
// If OUR timer fired (scoped by code — a nested outer deadline reads as
// undefined here), the tool/capability saw the abort and reached
// quiescence; replace whatever it returned (its own abort result) with the
// structured TOOL_TIMEOUT the model sees.
if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) {
return toolTimeoutResult(timeoutMs)
}
return result
} finally {
if (upstream === undefined) delete exec.signal
else exec.signal = upstream
}
})
}