index.ts 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
  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. *
  6. * FIXME: settle the intended `@deepseek-ai/dsh-timeout-guard` rename before the
  7. * first tagged release — suggestion only, aligning the name with its `guard/`
  8. * home; decide at resolution time
  9. * ([regrouping Agent Note](../../../../.agents/notes/archived/architecture/2026-07-29-package-regrouping.md)).
  10. *
  11. * @module @deepseek-ai/dsh-tool-call-timeout-policy
  12. */
  13. import type { Context } from '@deepseek-ai/cordis'
  14. import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
  15. import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
  16. /**
  17. * The code owned by this plugin, used BOTH as the internal {@link deadline}
  18. * classification code AND as the structured error `code` on the replacement
  19. * tool result. Scoping {@link timeoutOf} to it keeps a nested outer deadline
  20. * (another `tools/execute` wrapper's timer that fired first) from being misread
  21. * as this plugin's own timeout — it reads as an ordinary upstream cancel.
  22. */
  23. export const TOOL_TIMEOUT = 'TOOL_TIMEOUT'
  24. /** Cordis plugin name used by loader diagnostics. */
  25. export const name = 'timeout-policy'
  26. /** The tool registry service this plugin wraps (`tools/execute`) and reads (`get`). */
  27. export const inject = ['tools']
  28. /**
  29. * The structured result substituted when this plugin's deadline wins. `content`
  30. * is the model-facing message; `error.code` is the same {@link TOOL_TIMEOUT}
  31. * this plugin owns, so a retry/sandbox plugin (and replay) can route on it.
  32. *
  33. * @param timeoutMs - the elapsed budget, rendered into the model-facing message.
  34. * @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error.
  35. */
  36. function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
  37. const message = `tool call timed out after ${timeoutMs}ms`
  38. return {
  39. content: [{ type: 'text', text: `Error: ${message}` }],
  40. isError: true,
  41. error: { message, info: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT } },
  42. }
  43. }
  44. /**
  45. * Register the timeout wrapper. It resolves the caller-visible tool definition,
  46. * temporarily replaces `exec.signal`, delegates, restores the upstream signal,
  47. * and replaces the result only when this wrapper's own timer fired.
  48. */
  49. export function apply(ctx: Context): void {
  50. ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
  51. const timeoutMs = ctx.tools.get(exec.name, exec.agent)?.timeoutMs
  52. // A tool that declares no budget: no deadline, delegate unchanged.
  53. if (timeoutMs === undefined) return next()
  54. using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
  55. // Swap the derived deadline onto exec for dispatch, then restore the
  56. // caller's own signal so post-execute listeners never see this plugin's
  57. // (possibly already-aborted) timeout signal.
  58. const upstream = exec.signal
  59. exec.signal = d.signal
  60. try {
  61. const result = await next()
  62. // If OUR timer fired (scoped by code — a nested outer deadline reads as
  63. // undefined here), the tool/capability saw the abort and reached
  64. // quiescence; replace whatever it returned (its own abort result) with the
  65. // structured TOOL_TIMEOUT the model sees.
  66. if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) {
  67. return toolTimeoutResult(timeoutMs)
  68. }
  69. return result
  70. } finally {
  71. exec.signal = upstream
  72. }
  73. })
  74. }