index.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. /**
  2. * `@deepseek-ai/dsh-timeout-policy`: the tool-call timeout ENFORCER. It registers
  3. * ONE `tools/execute` around-dispatch listener that, for a tool declaring a
  4. * `timeoutMs` on its {@link ToolDefinition}, arms a per-call deadline on
  5. * `exec.signal` and returns a structured `TOOL_TIMEOUT` result when that deadline
  6. * wins. The budget is DECLARED by the tool (see `ToolDefinition.timeoutMs`, set
  7. * by the owning tool plugin from its own config); this plugin only enforces it,
  8. * so it is zero-config and there is no tool-name map to mistype.
  9. *
  10. * This is a COOPERATIVE deadline, not a hard kill: the derived signal only
  11. * NOTIFIES. A tool that declares `timeoutMs` (and the capability it forwards
  12. * `exec.signal` to) must honor that signal and reach quiescence — the plugin
  13. * never races the tool promise or terminates work itself (see the timeout-library
  14. * RFC's rejection of `Promise.race`). Declaring `timeoutMs` therefore MEANS "this
  15. * tool is cooperative with `exec.signal`": a tool that ignores the signal will
  16. * not stop on timeout, so only signal-forwarding tools should declare it (the
  17. * shipped web tools are the reference).
  18. *
  19. * Ownership of the `TOOL_TIMEOUT` code is entirely here: it is both the internal
  20. * {@link deadline} code (so {@link timeoutOf} scopes the classification to THIS
  21. * plugin's own timer, reading a foreign/nested outer deadline as an ordinary
  22. * cancel) and the structured `{ name, code }` on the replacement tool result.
  23. * No new session event is needed for reconstructability: the `TOOL_TIMEOUT`
  24. * result IS the final model-facing `tool/result`, already logged by the loop.
  25. *
  26. * Why a `tools/execute` around seam and not a `pre`/`post` pair: the deadline
  27. * needs ONE lexical scope — arm on `exec.signal`, delegate to dispatch, classify
  28. * the result, dispose the timer — which the around seam gives directly. A
  29. * pre/post split would spread one deadline's lifetime across two independent
  30. * waterfalls (a call-id map, cleanup on every deny/throw/dispose path).
  31. *
  32. * @module @deepseek-ai/dsh-timeout-policy
  33. */
  34. import type { Context } from 'cordis'
  35. import type { CallId } from '@deepseek-ai/dsh-llm'
  36. import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
  37. import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
  38. /**
  39. * The code owned by this plugin, used BOTH as the internal {@link deadline}
  40. * classification code AND as the structured error `code` on the replacement
  41. * tool result. Scoping {@link timeoutOf} to it keeps a nested outer deadline
  42. * (another `tools/execute` wrapper's timer that fired first) from being misread
  43. * as this plugin's own timeout — it reads as an ordinary upstream cancel.
  44. */
  45. export const TOOL_TIMEOUT = 'TOOL_TIMEOUT'
  46. /** Cordis plugin name used by loader diagnostics. */
  47. export const name = 'timeout-policy'
  48. /** The tool registry seam this plugin wraps (`tools/execute`) and reads (`get`). */
  49. export const inject = ['tools']
  50. /**
  51. * The structured result substituted when this plugin's deadline wins. `content`
  52. * is the model-facing message; `error.code` is the same {@link TOOL_TIMEOUT}
  53. * this plugin owns, so a retry/sandbox plugin (and replay) can route on it.
  54. *
  55. * @param callId - the timed-out call's id, carried onto the replacement result.
  56. * @param timeoutMs - the elapsed budget, rendered into the model-facing message.
  57. * @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error.
  58. */
  59. export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult {
  60. return {
  61. callId,
  62. content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
  63. isError: true,
  64. error: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT },
  65. }
  66. }
  67. /**
  68. * Register the tool-call timeout enforcer. For a tool whose {@link ToolDefinition}
  69. * declares `timeoutMs`, the listener arms a {@link deadline} on the caller's
  70. * `exec.signal`, swaps it onto `exec` for the downstream dispatch (cordis
  71. * `next()` ignores passed arguments, so a wrapper mutates the shared `exec` in
  72. * place), restores the original signal afterward so `tools/post-execute` sees the
  73. * caller's own signal, and replaces the result with {@link toolTimeoutResult}
  74. * when its own timer fired. A tool that declares no budget delegates untouched.
  75. *
  76. * The budget source is the tool's own declaration read from the registry
  77. * (`ctx.tools.get(exec.name, exec.agent)?.timeoutMs`), NOT a plugin config map —
  78. * `exec.name` is the tool being dispatched, so the lookup always resolves and
  79. * there is no mistypable tool name and no unknown-name path to warn or throw
  80. * about. Resolution goes through the CALLER's visible view (the `exec.agent`
  81. * scope), exactly like dispatch itself: a scoped tool's own `timeoutMs` governs
  82. * its calls, and a global name-twin's budget is never misapplied to a shadowing
  83. * per-agent variant.
  84. */
  85. export function apply(ctx: Context): void {
  86. ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
  87. const timeoutMs = ctx.tools.get(exec.name, exec.agent)?.timeoutMs
  88. // A tool that declares no budget: no deadline, delegate unchanged.
  89. if (timeoutMs === undefined) return next()
  90. using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
  91. // Swap the derived deadline onto exec for dispatch, then restore the
  92. // caller's own signal so post-execute listeners never see this plugin's
  93. // (possibly already-aborted) timeout signal. `undefined` is not assignable to
  94. // the optional `signal` under exactOptionalPropertyTypes, so branch on it.
  95. const upstream = exec.signal
  96. exec.signal = d.signal
  97. try {
  98. const result = await next()
  99. // If OUR timer fired (scoped by code — a nested outer deadline reads as
  100. // undefined here), the tool/capability saw the abort and reached
  101. // quiescence; replace whatever it returned (its own abort result) with the
  102. // structured TOOL_TIMEOUT the model sees.
  103. if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) {
  104. return toolTimeoutResult(exec.callId, timeoutMs)
  105. }
  106. return result
  107. } finally {
  108. if (upstream === undefined) delete exec.signal
  109. else exec.signal = upstream
  110. }
  111. })
  112. }