فهرست منبع

feat(tools): add ToolDefinition.timeoutMs declared+validated via defineTool

A tool declares its cooperative timeout budget on its own definition
rather than a deployment naming it in a central config map. The field
never reaches the model (schemas() whitelists name/description/parameters)
and defineTool rejects a non-positive-finite value at authorship.
Dudu-0223 2 ماه پیش
والد
کامیت
5d451bb2a0
4فایلهای تغییر یافته به همراه65 افزوده شده و 1 حذف شده
  1. 3 1
      packages/core/tools/README.md
  2. 8 0
      packages/core/tools/src/index.ts
  3. 11 0
      packages/core/tools/src/schema.ts
  4. 43 0
      packages/core/tools/tests/tools.spec.ts

+ 3 - 1
packages/core/tools/README.md

@@ -26,7 +26,7 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
 
 ### Key types
 
-- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
+- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
 - `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
 - `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
 - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
@@ -72,6 +72,8 @@ A `defineTool` tool also **validates the model-generated arguments against its `
 
 See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
 
+`defineTool` also validates an optional `timeoutMs` at definition time when present: it must be a positive finite number, or the helper throws — the budget is attached to the produced `ToolDefinition` (for `@deepseek-ai/dsh-timeout-policy`) and never reaches the model.
+
 ### Structured-output schema subset
 
 A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it.

+ 8 - 0
packages/core/tools/src/index.ts

@@ -138,6 +138,14 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta
 /** A registered tool: its schema plus the execution function. */
 export interface ToolDefinition extends ToolSchema {
   execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
+  /**
+   * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
+   * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
+   * is NEVER sent to the model — `schemas()` whitelists only name/description/
+   * parameters. Declaring it asserts this tool forwards `exec.signal` to a
+   * cooperative implementation that can reach quiescence when the signal aborts.
+   */
+  timeoutMs?: number
   /**
    * Optional: how to present the PENDING state of one call in a UI, derived from
    * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows

+ 11 - 0
packages/core/tools/src/schema.ts

@@ -295,6 +295,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
    * standard JSON Schema at runtime.
    */
   parameters: S
+  /**
+   * Optional cooperative tool-call timeout budget in milliseconds. When given it
+   * must be a positive finite number; it is attached to the produced
+   * {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and
+   * is never sent to the model.
+   */
+  timeoutMs?: number
   /**
    * Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
    * casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
@@ -362,10 +369,14 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
   const userPresentCall = options.presentCall
   // eslint-disable-next-line @typescript-eslint/unbound-method
   const userPresentResult = options.presentResult
+  if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
+    throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
+  }
   const tool: ToolDefinition = {
     name: options.name,
     description: options.description,
     parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
+    ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
     async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
       // Validate the model-generated args before the typed body runs. On
       // mismatch we throw ToolArgsError; the registry turns it into an

+ 43 - 0
packages/core/tools/tests/tools.spec.ts

@@ -62,6 +62,17 @@ describe('ToolRegistry', () => {
     expect(schema.execute).toBeUndefined()
   })
 
+  it('schemas() excludes timeoutMs — the budget must never reach the model', async () => {
+    const ctx = await setup()
+    ctx.tools.register(defineTool({
+      name: 'budgeted', description: 'has a budget', parameters: {}, timeoutMs: 5_000,
+      async execute() { return [{ type: 'text' as const, text: 'ok' }] },
+    }))
+    const schema = ctx.tools.schemas().find(s => s.name === 'budgeted')
+    expect(schema).toBeDefined()
+    expect('timeoutMs' in (schema as object)).toBe(false)
+  })
+
   it('executes a tool and returns its content', async () => {
     const ctx = await setup()
     ctx.tools.register(echoTool)
@@ -1131,6 +1142,38 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
     expect(result.isError).toBe(false)
   })
+
+  it('attaches a positive-finite timeoutMs to the definition', () => {
+    const tool = defineTool({
+      name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000,
+      async execute() { return [{ type: 'text' as const, text: 'ok' }] },
+    })
+    expect(tool.timeoutMs).toBe(30_000)
+  })
+
+  it('omits timeoutMs when not declared', () => {
+    const tool = defineTool({
+      name: 'x', description: 'd', parameters: {},
+      async execute() { return [{ type: 'text' as const, text: 'ok' }] },
+    })
+    expect(tool.timeoutMs).toBeUndefined()
+  })
+
+  it('throws when timeoutMs is zero or negative', () => {
+    const make = (ms: number) => defineTool({
+      name: 'x', description: 'd', parameters: {}, timeoutMs: ms,
+      async execute() { return [{ type: 'text' as const, text: 'ok' }] },
+    })
+    expect(() => make(0)).toThrow('timeoutMs must be a positive finite number')
+    expect(() => make(-5)).toThrow('positive finite number')
+  })
+
+  it('throws when timeoutMs is non-finite', () => {
+    expect(() => defineTool({
+      name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity,
+      async execute() { return [{ type: 'text' as const, text: 'ok' }] },
+    })).toThrow('positive finite number')
+  })
 })
 
 describe('defineTool presentation (presentCall / presentResult)', () => {