index.ts 3.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
  1. /**
  2. * Cooperative tool-call timeout enforcer. A tool declares `timeoutMs` and
  3. * promises to honor `exec.signal`; this wrapper arms that deadline and maps its
  4. * own expiry to `TOOL_TIMEOUT` without racing or abandoning the tool promise.
  5. * @module @deepseek-ai/dsh-timeout-policy
  6. */
  7. import type { Context } from 'cordis'
  8. import type { CallId } from '@deepseek-ai/dsh-llm'
  9. import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
  10. import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
  11. /**
  12. * The code owned by this plugin, used BOTH as the internal {@link deadline}
  13. * classification code AND as the structured error `code` on the replacement
  14. * tool result. Scoping {@link timeoutOf} to it keeps a nested outer deadline
  15. * (another `tools/execute` wrapper's timer that fired first) from being misread
  16. * as this plugin's own timeout — it reads as an ordinary upstream cancel.
  17. */
  18. export const TOOL_TIMEOUT = 'TOOL_TIMEOUT'
  19. /** Cordis plugin name used by loader diagnostics. */
  20. export const name = 'timeout-policy'
  21. /** The tool registry seam this plugin wraps (`tools/execute`) and reads (`get`). */
  22. export const inject = ['tools']
  23. /**
  24. * The structured result substituted when this plugin's deadline wins. `content`
  25. * is the model-facing message; `error.code` is the same {@link TOOL_TIMEOUT}
  26. * this plugin owns, so a retry/sandbox plugin (and replay) can route on it.
  27. *
  28. * @param callId - the timed-out call's id, carried onto the replacement result.
  29. * @param timeoutMs - the elapsed budget, rendered into the model-facing message.
  30. * @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error.
  31. */
  32. export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult {
  33. return {
  34. callId,
  35. content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
  36. isError: true,
  37. error: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT },
  38. }
  39. }
  40. /**
  41. * Register the timeout wrapper. It resolves the caller-visible tool definition,
  42. * temporarily replaces `exec.signal`, delegates, restores the upstream signal,
  43. * and replaces the result only when this wrapper's own timer fired.
  44. */
  45. export function apply(ctx: Context): void {
  46. ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
  47. const timeoutMs = ctx.tools.get(exec.name, exec.agent)?.timeoutMs
  48. // A tool that declares no budget: no deadline, delegate unchanged.
  49. if (timeoutMs === undefined) return next()
  50. using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
  51. // Swap the derived deadline onto exec for dispatch, then restore the
  52. // caller's own signal so post-execute listeners never see this plugin's
  53. // (possibly already-aborted) timeout signal. `undefined` is not assignable to
  54. // the optional `signal` under exactOptionalPropertyTypes, so branch on it.
  55. const upstream = exec.signal
  56. exec.signal = d.signal
  57. try {
  58. const result = await next()
  59. // If OUR timer fired (scoped by code — a nested outer deadline reads as
  60. // undefined here), the tool/capability saw the abort and reached
  61. // quiescence; replace whatever it returned (its own abort result) with the
  62. // structured TOOL_TIMEOUT the model sees.
  63. if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) {
  64. return toolTimeoutResult(exec.callId, timeoutMs)
  65. }
  66. return result
  67. } finally {
  68. if (upstream === undefined) delete exec.signal
  69. else exec.signal = upstream
  70. }
  71. })
  72. }