refactor(packages): merge timeout/ into guard/, rename cordis/ to self-modification/
git mv timeout-policy beside repeat-tool-guard (both are loop-hygiene policies on the tool-execution pipeline, and the timeout/ group name collided with util/timeout) and tool-cordis into self-modification/ (naming the role the framework name obscured). Merged/renamed group README triplets, tsconfig globs, generator sources, hierarchy tables, catalogs, and the timeout-policy design note's group references follow. Adds the fifth FIXME marker (dsh-timeout-guard, recorded as a suggestion to settle at resolution time). guard + self-modification suites: 197 passed.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/guard/README.md
|
||||
README.md: 8da2f2ddcc1432e7963e600b35549f5e26121af2
|
||||
README.zh.md: aa8b5bd1f7d10d90d1dc84d07a7aad81bfb4259c
|
||||
README.md: 5791474a2fd6aeda728d4c335c930069ade6e023
|
||||
README.zh.md: 82d779d2f1834317672b8fb03f2a4a875bb566fc
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Behavioral guard plugins watch the agent loop for unproductive patterns and nudge the model back on course. A guard is a self-contained consumer of core seams, not a swappable capability.
|
||||
Behavioral guard plugins watch the agent loop for unproductive patterns and enforce per-call budgets. A guard is a self-contained consumer of core seams, not a swappable capability.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`repeat-tool-guard/`](repeat-tool-guard/README.md) | Advisory reminders for repeated tool calls | listens on tool and agent events |
|
||||
| [`timeout-policy/`](timeout-policy/README.md) | Arms per-call tool deadlines as deployment policy | registers a `tools/execute` listener |
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
行为 guard 插件监视 agent loop(智能体循环)中的无效模式,并推动模型回到正轨。guard 是 core seam 的自包含消费方,而非可替换能力。
|
||||
行为 guard 插件监视 agent loop(智能体循环)中的无效模式,并强制执行单次调用预算。guard 是 core seam 的自包含消费方,而非可替换能力。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`repeat-tool-guard/`](repeat-tool-guard/README.md) | 针对重复工具调用的建议性提醒 | 监听工具和 agent 事件 |
|
||||
| [`timeout-policy/`](timeout-policy/README.md) | 以部署策略形式设置单次工具调用截止时间 | 注册 `tools/execute` 监听器 |
|
||||
|
||||
6
packages/guard/timeout-policy/README.i18n.yaml
Normal file
6
packages/guard/timeout-policy/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/guard/timeout-policy/README.md
|
||||
README.md: 3e5769e2f8b95392e9d489659c6bae634ab3ff18
|
||||
README.zh.md: bfba62cdfae8bb956aec43bcf6e8df12407d8ba2
|
||||
57
packages/guard/timeout-policy/README.md
Normal file
57
packages/guard/timeout-policy/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# dsh-timeout-policy
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Tool-call timeout enforcer: a single `tools/execute` around-dispatch listener that arms a per-call cooperative deadline on `exec.signal` for a tool declaring `timeoutMs` on its `ToolDefinition` and returns a structured `TOOL_TIMEOUT` result when that deadline wins. The budget is read from the tool's own declaration (`ToolDefinition.timeoutMs`, set by the owning tool plugin), so this plugin is **zero-config**. It is the reference `tools/execute` wrapper and the enforcement home for model-facing tool-call budgets (the timeout-library Agent Note's foreseen middleware).
|
||||
|
||||
## Plugin (namespace: `timeout-policy`)
|
||||
|
||||
A function/namespace plugin (`name` / `inject` / `apply`), not a service. It registers no tool and takes no config — it consumes `ctx.tools`'s `tools/execute` waterfall (which the `dsh-tools` registry always provides) and reads each dispatched tool's declared `timeoutMs` from the registry (`ctx.tools.get(exec.name)`).
|
||||
|
||||
```yaml
|
||||
- id: timeout-policy
|
||||
name: '@deepseek-ai/dsh-timeout-policy'
|
||||
```
|
||||
|
||||
The per-tool budget is declared by the tool plugin (e.g. `dsh-tool-web`'s `fetchTimeoutMs`/`searchTimeoutMs` config, attached as `ToolDefinition.timeoutMs`); this plugin only enforces it, so a mistyped tool name is not possible.
|
||||
|
||||
### Behavior
|
||||
|
||||
For a tool that **declares a `timeoutMs`** the listener:
|
||||
|
||||
1. Reads the budget from the tool's own declaration in the registry (`ctx.tools.get(exec.name)?.timeoutMs`) and arms `deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')` — one signal fusing the caller's abort with this plugin's timer (`@deepseek-ai/dsh-timeout`).
|
||||
2. Swaps that derived signal onto `exec` for the downstream dispatch, then restores the caller's own signal afterward (cordis `next()` ignores passed arguments, so the wrapper mutates the shared `exec` in place; restoring keeps `tools/post-execute` seeing the caller's signal).
|
||||
3. After dispatch, if `timeoutOf(d.signal, 'TOOL_TIMEOUT')` matches — this plugin's own timer fired — replaces the result with a structured `TOOL_TIMEOUT` tool result: `{ isError: true, error: { message, info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' } }, content: 'Error: tool call timed out after <ms>ms' }`.
|
||||
|
||||
A tool that **declares no budget** delegates untouched (no deadline).
|
||||
|
||||
The base `next()` of `tools/execute` is the registry's dispatch-with-normalization thunk, so when the timeout signal reaches a provider that throws its own upstream-abort error, dispatch first turns it into a normal error result, and this wrapper then replaces that with `TOOL_TIMEOUT`. That ordering is why the replacement is keyed off the signal (`timeoutOf`), not off the dispatched result's shape.
|
||||
|
||||
### Cooperative, not a hard kill
|
||||
|
||||
The derived signal only **notifies**; termination stays with the tool and the capability it forwards `exec.signal` to (the `dsh-timeout` library owns no kill). **Declaring `timeoutMs` therefore means "cooperative with `exec.signal`"**: a tool that ignores the signal will not stop on timeout. Only signal-forwarding tools should declare it — the shipped `web_fetch`/`web_search` (which forward through `ctx.web` to providers) are the reference. `TOOL_TIMEOUT` needs no session event for reconstructability: it is the final model-facing `tool/result`, already logged by the loop.
|
||||
|
||||
### Composing with other `tools/execute` wrappers
|
||||
|
||||
Multiple `tools/execute` listeners compose by cordis registration order. Combined with a future retry/sandbox/metrics wrapper, registration order chooses the semantics — "timeout covers the whole retry operation" (timeout registered outer) versus "timeout covers each attempt" (timeout registered inner).
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Conditional tool result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
This plugin adds no prompt or schema. If a declared deadline wins, it replaces the provider's outcome with `Error: tool call timed out after <ms>ms` plus structured `TOOL_TIMEOUT`; otherwise the original result passes through unchanged.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero tokens on non-timeout calls. A timeout adds one small retained error result and can prevent a larger late provider result from entering context.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Cooperative, never a hard kill** — the deadline only notifies via `exec.signal`; a tool that ignores the signal does not stop on timeout (see § Cooperative, not a hard kill).
|
||||
- **No blanket budget** — only tools that declare `timeoutMs` on their `ToolDefinition` get a deadline; there is no registry-wide default for undeclared tools (the shipped `bash`/`read`/`write`/`edit` deliberately declare none).
|
||||
57
packages/guard/timeout-policy/README.zh.md
Normal file
57
packages/guard/timeout-policy/README.zh.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# dsh-timeout-policy
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
工具调用超时强制执行器:单个 `tools/execute` 环绕分发监听器,会在 `exec.signal` 上设置单次调用的协作式截止时间;适用于声明了 `timeoutMs` 且声明位于其 `ToolDefinition` 上的工具。该截止时间先到时,它返回结构化 `TOOL_TIMEOUT` 结果。预算从工具自身的声明中读取(`ToolDefinition.timeoutMs`,由拥有该工具的插件设置),因此此插件是**零配置**的。它是 `tools/execute` 包装层的参考实现,也是面向模型工具调用预算的强制执行归属地(超时库 Agent Note 所预见的中间件)。
|
||||
|
||||
## 插件(命名空间:`timeout-policy`)
|
||||
|
||||
它是函数/命名空间插件(`name`/`inject`/`apply`),而非服务。它不注册工具,也不接受配置;它消费 `ctx.tools` 的 `tools/execute` waterfall(瀑布式事件)(由 `dsh-tools` 注册表始终提供),并读取每个已分发工具声明的 `timeoutMs`;该声明来自注册表(`ctx.tools.get(exec.name)`)。
|
||||
|
||||
```yaml
|
||||
- id: timeout-policy
|
||||
name: '@deepseek-ai/dsh-timeout-policy'
|
||||
```
|
||||
|
||||
每工具预算由工具插件声明(例如 `dsh-tool-web` 的 `fetchTimeoutMs`/`searchTimeoutMs` 配置,会附加为 `ToolDefinition.timeoutMs`);此插件只负责强制执行,因此不可能拼错工具名。
|
||||
|
||||
### 行为
|
||||
|
||||
对 **声明了 `timeoutMs` 的工具**,监听器会:
|
||||
|
||||
1. 从注册表中的工具自身声明(`ctx.tools.get(exec.name)?.timeoutMs`)读取预算,并设置 `deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')`:一个将调用方中止与此插件计时器融合的信号(`@deepseek-ai/dsh-timeout`)。
|
||||
2. 将该派生信号替换到 `exec` 上用于下游分发,然后恢复调用方自身的信号(Cordis `next()` 忽略传入的参数,因此包装层会原地修改共享 `exec`;恢复可使 `tools/post-execute` 看到调用方的信号)。
|
||||
3. 分发后,如果 `timeoutOf(d.signal, 'TOOL_TIMEOUT')` 检测到此插件自身的计时器已触发,则将结果替换为结构化 `TOOL_TIMEOUT` 工具结果:`{ isError: true, error: { message, info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' } }, content: 'Error: tool call timed out after <ms>ms' }`。
|
||||
|
||||
**未声明预算的工具** 会原样委托(不启动截止时间)。
|
||||
|
||||
基础 `next()` 是注册表为 `tools/execute` 提供的、带规范化处理的分发 thunk,因此当超时信号到达抛出自身上游中止错误的提供方时,分发会先将其转换为普通错误结果,再由此包装层替换为 `TOOL_TIMEOUT`。这一顺序就是替换依据信号(`timeoutOf`)而非已分发结果形状的原因。
|
||||
|
||||
### 协作式,而非硬终止
|
||||
|
||||
派生信号只会 **通知**;是否终止仍取决于工具及其将 `exec.signal` 转发到的能力(`dsh-timeout` 库本身不负责硬终止)。**因此,声明 `timeoutMs` 意味着「与 `exec.signal` 协作」**:忽略该信号的工具不会在超时时停止。只有转发信号的工具才应声明该字段;已交付的 `web_fetch`/`web_search`(通过 `ctx.web` 转发给提供方)是参考实现。`TOOL_TIMEOUT` 无需会话事件以满足可重建性:它是最终面向模型的 `tool/result`,已由循环记录。
|
||||
|
||||
### 与其他 `tools/execute` 包装层组合
|
||||
|
||||
多个 `tools/execute` 监听器按 Cordis 注册顺序组合。与未来的重试/沙箱/指标包装层一起使用时,注册顺序决定语义:「超时覆盖整个重试操作」(超时注册在外层),或「超时覆盖每次尝试」(超时注册在内层)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 条件工具结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
此插件不添加提示词或 schema。如果已声明的截止时间先到,它会将提供方结果替换为 `Error: tool call timed out after <ms>ms` 与结构化 `TOOL_TIMEOUT`;否则原结果保持不变。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
未超时的调用不会增加 token。超时会添加一条会被保留的简短错误结果,并可防止体积更大、较晚返回的提供方结果进入上下文。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **协作式,绝不是硬终止**:截止时间只通过 `exec.signal` 通知;忽略该信号的工具不会在超时时停止(参见「协作式,而非硬终止」一节)。
|
||||
- **没有统一预算**:只有声明 `timeoutMs` 并将其放在 `ToolDefinition` 上的工具才会获得截止时间;未声明工具没有注册表级默认值(已交付的 `bash`/`read`/`write`/`edit` 有意不声明)。
|
||||
41
packages/guard/timeout-policy/package.json
Normal file
41
packages/guard/timeout-policy/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-timeout-policy",
|
||||
"description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
81
packages/guard/timeout-policy/src/index.ts
Normal file
81
packages/guard/timeout-policy/src/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* FIXME: settle the intended `@deepseek-ai/dsh-timeout-guard` rename before the
|
||||
* first tagged release — suggestion only, aligning the name with its `guard/`
|
||||
* home; decide at resolution time
|
||||
* ([regrouping Agent Note](../../../../.agents/notes/proposed/architecture/2026-07-29-package-regrouping.md)).
|
||||
*
|
||||
* @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 {
|
||||
const message = `tool call timed out after ${timeoutMs}ms`
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
error: { message, info: { 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.
|
||||
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 {
|
||||
exec.signal = upstream
|
||||
}
|
||||
})
|
||||
}
|
||||
30
packages/guard/timeout-policy/src/invariant.ts
Normal file
30
packages/guard/timeout-policy/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-timeout-policy`.
|
||||
* @module @deepseek-ai/dsh-timeout-policy/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-timeout-policy'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'timeout-policy-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this stateless policy plugin owns no package-local event history or mutable
|
||||
* data relation beyond the seam it intercepts.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
244
packages/guard/timeout-policy/tests/timeout-policy.spec.ts
Normal file
244
packages/guard/timeout-policy/tests/timeout-policy.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Unit + real-load-path coverage for @deepseek-ai/dsh-timeout-policy. The
|
||||
* timeout-wins cases drive the deadline under fake timers (deterministic — no
|
||||
* wall-clock race) and use a COOPERATIVE tool that settles only when its
|
||||
* `exec.signal` aborts, mirroring how a real capability forwards the signal and
|
||||
* reaches quiescence.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
||||
import { TOOL_TIMEOUT } from '@deepseek-ai/dsh-timeout-policy'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/** Mount the registry + the zero-config timeout-policy enforcer. */
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(timeoutPolicy)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** A cooperative tool that settles ONLY when its exec.signal aborts (returns text). */
|
||||
const cooperativeTool = defineContentToolFixture({
|
||||
name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
|
||||
execute(_args, exec): Promise<{ type: 'text'; text: string }[]> {
|
||||
const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
|
||||
if (exec.signal.aborted) return Promise.resolve(done)
|
||||
return new Promise((resolve) => { exec.signal.addEventListener('abort', () => { resolve(done) }) })
|
||||
},
|
||||
})
|
||||
|
||||
/** A cooperative tool that THROWS its own upstream-abort error when aborted (web-provider shape). */
|
||||
const abortThrowingTool = defineContentToolFixture({
|
||||
name: 'aborter', description: 'throws WEB_ABORTED when aborted', parameters: {}, timeoutMs: 100,
|
||||
execute(_args, exec): Promise<never> {
|
||||
if (exec.signal.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED'))
|
||||
return new Promise((_resolve, reject) => { exec.signal.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) })
|
||||
},
|
||||
})
|
||||
|
||||
describe('timeout-policy delegation (unconfigured / fast)', () => {
|
||||
it('delegates a tool with NO declared budget unchanged and does not touch exec.signal', async () => {
|
||||
const ctx = await setup()
|
||||
let seenSignal: AbortSignal | undefined
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {},
|
||||
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
|
||||
const upstream = new AbortController().signal
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(seenSignal).toBe(upstream)
|
||||
})
|
||||
|
||||
it('a tool with a budget that returns fast keeps its own result (no timeout)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
|
||||
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} })
|
||||
expect(result).toEqual({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
value: [{ type: 'text', text: 'ok' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => {
|
||||
const ctx = await setup()
|
||||
let seenSignal: AbortSignal | undefined
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
|
||||
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
|
||||
const upstream = new AbortController().signal
|
||||
await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
|
||||
expect(seenSignal).toBeDefined()
|
||||
expect(seenSignal).not.toBe(upstream)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeout-policy signal restoration', () => {
|
||||
it('restores the caller signal for post-execute after wrapping', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
|
||||
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
|
||||
let postSignal: AbortSignal | undefined | 'unset' = 'unset'
|
||||
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { postSignal = exec.signal; return next() })
|
||||
const upstream = new AbortController().signal
|
||||
await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {}, signal: upstream })
|
||||
expect(postSignal).toBe(upstream)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(cooperativeTool)
|
||||
const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'slow', arguments: {} })
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
const result = await pending
|
||||
expect(result).toEqual({
|
||||
content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }],
|
||||
isError: true,
|
||||
error: {
|
||||
message: 'tool call timed out after 100ms',
|
||||
info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT when the signal was ours', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(abortThrowingTool)
|
||||
const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'aborter', arguments: {} })
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({
|
||||
message: 'tool call timed out after 100ms',
|
||||
info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
|
||||
})
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' })
|
||||
})
|
||||
|
||||
it('preserves registry ABORTED when the caller aborts first (upstream cancel, not our timeout)', async () => {
|
||||
const ctx = await setup()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
|
||||
execute(_args, exec) {
|
||||
entered.resolve(undefined)
|
||||
const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
|
||||
if (exec.signal.aborted) return Promise.resolve(done)
|
||||
return new Promise((resolve) => {
|
||||
exec.signal.addEventListener('abort', () => { resolve(done) }, { once: true })
|
||||
})
|
||||
},
|
||||
}))
|
||||
const upstream = new AbortController()
|
||||
const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal })
|
||||
await entered.promise
|
||||
upstream.abort('user cancelled')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({
|
||||
message: 'tool call aborted',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED },
|
||||
})
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: tool call aborted' })
|
||||
})
|
||||
|
||||
it('preserves TOOL_TIMEOUT when the deadline wins before a later caller abort', async () => {
|
||||
const ctx = await setup()
|
||||
const sawAbort = Promise.withResolvers<undefined>()
|
||||
const releaseCleanup = Promise.withResolvers<undefined>()
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'slow-cleanup', description: 'settles after abort cleanup', parameters: {}, timeoutMs: 100,
|
||||
async execute(_args, exec) {
|
||||
if (!exec.signal.aborted) {
|
||||
await new Promise<undefined>((resolve) => {
|
||||
exec.signal.addEventListener('abort', () => { resolve(undefined) }, { once: true })
|
||||
})
|
||||
}
|
||||
sawAbort.resolve(undefined)
|
||||
await releaseCleanup.promise
|
||||
return [{ type: 'text' as const, text: 'cleanup complete' }]
|
||||
},
|
||||
}))
|
||||
const upstream = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('timeout-first'), name: 'slow-cleanup', arguments: {}, signal: upstream.signal,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await sawAbort.promise
|
||||
upstream.abort('too late to replace timeout')
|
||||
releaseCleanup.resolve(undefined)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
isError: true,
|
||||
error: {
|
||||
message: 'tool call timed out after 100ms',
|
||||
info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeout-policy contract', () => {
|
||||
it('exposes the owned code constant', () => {
|
||||
expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeout-policy disposal (HMR safety)', () => {
|
||||
it('removes its tools/execute listener when the plugin fiber disposes', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
let seenSignal: AbortSignal | undefined
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
|
||||
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
|
||||
const fiber = await ctx.plugin(timeoutPolicy)
|
||||
const upstream = new AbortController().signal
|
||||
await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
|
||||
expect(seenSignal).not.toBe(upstream)
|
||||
await fiber.dispose()
|
||||
await ctx.tools.execute({ callId: CallId('c2'), name: 'probe', arguments: {}, signal: upstream })
|
||||
expect(seenSignal).toBe(upstream)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-timeout-policy real-load-path guard', () => {
|
||||
it('has no default export and keeps name/inject through unwrapExports', () => {
|
||||
expect('default' in timeoutPolicy).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(timeoutPolicy) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(timeoutPolicy)
|
||||
expect(unwrapped.name).toBe('timeout-policy')
|
||||
expect(unwrapped.inject).toEqual(['tools'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
it('boots over ctx.tools through the unwrapped module and wraps a budgeted tool', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 5_000,
|
||||
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
|
||||
const fiber = await ctx.plugin(unwrapped)
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput)
|
||||
expect(result.isError).toBe(false)
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
31
packages/guard/timeout-policy/tsconfig.json
Normal file
31
packages/guard/timeout-policy/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user