@migor/agentia 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +283 -2
  2. package/README.en.md +2 -2
  3. package/README.md +7 -5
  4. package/dist/AGENTS.md +132 -8
  5. package/dist/core/limits.d.ts +226 -0
  6. package/dist/core/limits.js +216 -0
  7. package/dist/core/limits.js.map +1 -0
  8. package/dist/core/trace.d.ts +66 -2
  9. package/dist/core/trace.js +37 -2
  10. package/dist/core/trace.js.map +1 -1
  11. package/dist/engine/forwarded.d.ts +84 -0
  12. package/dist/engine/forwarded.js +39 -0
  13. package/dist/engine/forwarded.js.map +1 -0
  14. package/dist/engine/run-config.d.ts +4 -1
  15. package/dist/engine/run-config.js +14 -1
  16. package/dist/engine/run-config.js.map +1 -1
  17. package/dist/engine/spec.d.ts +26 -1
  18. package/dist/engine/spec.js.map +1 -1
  19. package/dist/engine/tracer.d.ts +68 -1
  20. package/dist/engine/tracer.js +136 -14
  21. package/dist/engine/tracer.js.map +1 -1
  22. package/dist/index.d.ts +4 -3
  23. package/dist/index.js +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/adapter-options.d.ts +0 -7
  26. package/dist/integrations/adapter-options.js +2 -1
  27. package/dist/integrations/adapter-options.js.map +1 -1
  28. package/dist/integrations/mcp-stdio.js +9 -1
  29. package/dist/integrations/mcp-stdio.js.map +1 -1
  30. package/dist/integrations/metrics-state.d.ts +2 -1
  31. package/dist/integrations/metrics-state.js +6 -2
  32. package/dist/integrations/metrics-state.js.map +1 -1
  33. package/dist/runtime/run.d.ts +13 -1
  34. package/dist/runtime/run.js +14 -3
  35. package/dist/runtime/run.js.map +1 -1
  36. package/dist/toolkit/module.d.ts +29 -1
  37. package/dist/toolkit/module.js +16 -0
  38. package/dist/toolkit/module.js.map +1 -1
  39. package/dist/toolkit/skill.js +4 -13
  40. package/dist/toolkit/skill.js.map +1 -1
  41. package/dist/toolkit/subagent.js +6 -17
  42. package/dist/toolkit/subagent.js.map +1 -1
  43. package/dist/transport/async.d.ts +119 -0
  44. package/dist/transport/async.js +125 -9
  45. package/dist/transport/async.js.map +1 -1
  46. package/dist/transport/http-route.d.ts +6 -1
  47. package/dist/transport/http-route.js +9 -1
  48. package/dist/transport/http-route.js.map +1 -1
  49. package/dist/transport/http.js +99 -1
  50. package/dist/transport/http.js.map +1 -1
  51. package/dist/transport/sse.d.ts +6 -2
  52. package/dist/transport/sse.js +3 -2
  53. package/dist/transport/sse.js.map +1 -1
  54. package/dist/transport/task-events.d.ts +90 -0
  55. package/dist/transport/task-events.js +159 -0
  56. package/dist/transport/task-events.js.map +1 -0
  57. package/package.json +5 -3
package/dist/AGENTS.md CHANGED
@@ -83,7 +83,7 @@ console.log(result.finalText, result.stopReason, result.trace.totalUsage);
83
83
  npx @migor/cli create my-app # 首次创建:必须带 scope(短名 agentia 在 npm 上是别人的包)
84
84
  cd my-app && npm install # 框架与 CLI 都装进工程
85
85
  $EDITOR .env # 填 ANTHROPIC_API_KEY(脚手架已生成,且已被 .gitignore 挡住)
86
- npm run dev # = agentia dev:tsx watch + 本地 inspector 面板
86
+ npm run dev # = agentia dev:本地 inspector 面板(输入 prompt / 选能力 / 选工作目录)
87
87
  npx agentia g tool fetch-weather # 生成到 src/tools/fetch-weather/(skill/prompt/subagent 同理)
88
88
  npx agentia doctor # 静态体检(未登记/悬空/命名/重复)
89
89
  npx agentia --version # CLI 版本(= -v)
@@ -96,12 +96,94 @@ npx agentia --version # CLI 版本(= -v)
96
96
 
97
97
  四分类目录,一能力一文件夹:`src/tools/` · `src/skills/` · `src/prompts/` · `src/subagents/` —— **目录名就是类型**,不用记别名。每个文件夹的 `index.ts` 是入口,`default export` 支持三种形态:**类**(token = 文件夹名)、**Provider 对象**、**Provider 数组**。显式注册表在 `src/registry.ts`(`agentia g` 自动维护,也可手改)。
98
98
 
99
- > **脚手架 `src/main.ts` 怎么找这些目录**:按**本文件位置**解析(`fileURLToPath(new URL('tools/', import.meta.url))`),
99
+ ### 2.1 脚手架生成的文件(**装配与启动是分开的**)
100
+
101
+ | 文件 | 作用 |
102
+ |---|---|
103
+ | `src/app.ts` | **装配**:导出 `createAgentApp({ toolSources?, workdir? })` 工厂 + `CAPABILITY_DIRS` + `createSessionStore()`,并在模块顶部 `loadEnvFile()` 读 `.env` |
104
+ | `src/main.ts` | **启动**:薄入口 —— `createAgentApp()` → `app.run(...)`,再处理 `result.error` |
105
+ | `src/dev.config.ts` | **数据**(不是逻辑):开发期的声明,如 `multiTurn: ['trip-planner']`;见 §2.2 |
106
+ | `src/session-store.ts` | `FileSessionStore`:把多轮对话落成 `.agentia/session.json`(原子写);见 §6.4「对话历史」 |
107
+ | `src/registry.ts` | 显式注册表(`agentia g` 自动维护) |
108
+ | `src/tools/read-file/` | 示例工具:读**工作目录**下的文本文件(工作目录由 DI 注入,见 §2.2) |
109
+
110
+ **为什么拆**:`agentia dev` 的调试环要**复用同一个工厂**,才能把「这次调哪个能力 / 工作目录是哪个」
111
+ 喂进 `createApp`。所以装配必须在 `app.ts` 里、以**函数**形态存在 —— 别把 `createApp(...)` 搬回
112
+ `main.ts`(搬回去 dev 环就起不来了)。
113
+
114
+ > **脚手架 `src/app.ts` 怎么找这些目录**:按**本文件位置**解析(`fileURLToPath(new URL('tools/', import.meta.url))`),
100
115
  > 所以 dev 解析到 `src/`、`npm run build` 之后解析到 `dist/` —— 从任何目录启动都成立,也不受 cwd 影响。
101
116
  > 别改成 cwd 相对写法(形如 `src/tools` 的字符串):那样 `node dist/main.js` 会去加载 `src/` 下的 `.ts`
102
117
  > 源码,而装饰器不是可擦除的类型语法,Node 直接跑不了。空分类目录(还没有该类型的能力 ⇒ 构建后没有
103
118
  > 对应 `dist/<分类>/`)要过滤掉,否则 `discover` 会因「显式给出的路径不存在」而报错。
104
119
 
120
+ > **进程外资源的所有权**(`app.ts` 里最容易写错的一处):MCP 连接器、数据库句柄、定时器这类
121
+ > **进程外资源一律在模块作用域创建、再以 `useValue` 注入**,不许在 provider 的构造函数里建。
122
+ > 理由见 §2.2 的重启策略:框架没有 `AgentApp.close()`,构造函数里建的东西会随每次重建攒下孤儿进程。
123
+
124
+ ### 2.2 开发期调试环(`agentia dev`)
125
+
126
+ `npm run dev` 起一个**本地 inspector 面板**,四组输入都在面板上(不用改代码重跑)。
127
+ 另有两个操作按钮(都不是「装饰」—— 缺了各自的后果见下面两段):**■ 中止**(只在有 run 在飞时出现)
128
+ 与**清空对话**(只在对话视图出现时显示):
129
+
130
+ | 输入 | 口径 |
131
+ |---|---|
132
+ | **prompt** | 输入框 + `↑`/`↓` 调回历史(像 shell)。面板上「点一下 = 一次**真** run」 |
133
+ | **能力** | 多选,缺省全选。收窄的是**菜单**(主 agent 仍在环里),不是「只可能调它」—— 别的能力 `tools` 里的显式引用仍能调到被排除的那个 |
134
+ | **工作目录** | 目录选择器(`浏览…`)。**它是这个环的主控件**:任务型 agent 的 prompt 几乎不变,变的是目录 |
135
+ | **多轮** | 跟能力走:`src/dev.config.ts` 里 `multiTurn: [...]` 声明哪些能力该多轮,混选时取 **OR** 且面板**标出来源**(`多轮·trip-planner`) |
136
+
137
+ `npm run dev -- "你的问题"` 可以直接把第一句 prompt 带上(等价于启动后在输入框里敲)。
138
+
139
+ **为什么用 `agentia dev` 而不是 `tsx watch`**:`agentia dev` 自己 import 你的 `src/app.ts` 并驱动它,
140
+ 于是**它才是调用方**,四个输入才可能生效。它也**自己管文件监视**(不再叠一层 `tsx watch`):
141
+
142
+ - **看什么**:`.ts` / `.mts` / `.cts` / `.tsx` / `.js` / `.mjs` / `.cjs` / `.json` / **`.md`** / `.env` / `.env.local`。
143
+ `.md` **必须在**里面 —— 文本资产(`@Prompt` 拉的 `.md`、子 agent 的 `system.md`)不在 tsx 的
144
+ import 图里,交给 `tsx watch` 会**静默无感**:改了没反应、也不提示。
145
+ - **看哪里**:代码与文本资产看 `<项目根>/src/` **整棵树**(含启动后新建的子目录);
146
+ `.env` / `.env.local` 在**项目根**,所以那两份**单独**盯。**只有**这两份 —— 项目根的
147
+ `README.md` / `package.json` 不触发重启(把根抬到项目根会让「改文档也重启」)。
148
+ - **不看什么**:`node_modules` / `dist` / `.git` / `.agentia` / `coverage`(`.agentia` 是 dev 环自己的
149
+ 对话历史,看它就变成「每次 run 重启一次」的自噬循环)。
150
+ - **什么时候重启**:改代码或**改能力选择** ⇒ 重启子进程;改**工作目录** / prompt / 多轮 ⇒ 不重启。
151
+ 在飞 run 期间需要重启时**延后到它结束**,不杀在飞的 run。
152
+ - **面板怎么知道它起来了**:runner 装配完成会广播 `runner-ready`,面板收到就重刷 `/api/dev`。
153
+ 少了这条事件,面板加载时那次查询(早于装配完成)拿到的 `capabilities` 是父进程的初值空数组,
154
+ 能力选择器会**空着且没有任何解释**,要等你先跑一次才填上。
155
+
156
+ **子进程是 `node <tsx/cli>`,不是 `npx tsx`**:`npx` 是包装器,它**不给孙进程转发 fd 3 的 IPC 通道**,
157
+ 于是 runner 里 `process.send` 变成 `undefined` —— 而代码写的是 `process.send?.()`(可选链),
158
+ 所以所有协议消息被**静默**丢弃:面板永远等不到 `ready`、`POST /run` 永远回不来;没有 IPC 通道后
159
+ 事件循环排空,进程还会以 `code=0` 干净退出(看起来像「用户代码自己跑完了」)。这条有 e2e 钉着
160
+ (`scripts/e2e-dev.ts`)。
161
+
162
+ **`.env` 由 `src/app.ts` 读**:runner 只 import `app.ts`、**从不执行 `main.ts`**,所以读 `.env`
163
+ 的那一行必须在装配模块里 —— 放 `main.ts` 会让 `npm run dev` 静默读不到、而 `npm start` 读得到。
164
+
165
+ **安全**:面板只绑 `127.0.0.1`,并做两层校验 —— `Origin` 校验(缺失放行,`Origin: null` 拒绝)
166
+ + **每次启动生成的一次性 token**(首帧 `?t=` 换成 `HttpOnly` cookie,后续请求带着走)。
167
+ token 的作用是挡住**本机其它进程**,别把它当网络边界:这个面板**不要**暴露到 `0.0.0.0`。
168
+
169
+ **成本**:面板缺省给每次 run 套一层预算护栏(`maxCostUsd: 1` / `maxTotalTokens: 200_000`,
170
+ 可用 `dev.config.ts` 的 `budget` 覆盖)。手快点两下就是两次真 run —— 护栏就是为这个存在的。
171
+
172
+ **会话历史**:开了多轮的能力才有对话视图,对话落在 `.agentia/session.json`(已被 `.gitignore` 挡住)。
173
+ 失败的轮次**不在会话文件里**(框架只在成功轮次回写),面板靠 trace 的 `session.id` 把它们 join 回来。
174
+ 对话视图旁边有一个**「清空对话」**按钮:清空 = **换一个 `sessionId`**(`dev` → `dev-2` → …),
175
+ **不删** `session.json` —— 旧对话还在盘上、run 列表里也还指得到,只是模型不再带着它跑。
176
+ 当前用哪个 id 记在 `.agentia/dev-session-id`(**不是** `session.json` 的一部分):只放内存的话,
177
+ 重启 `npm run dev` 之后刚清空的对话会**自己回来**。有 run 在飞时不能清空(会 409)。
178
+
179
+ **中止在飞的 run**:run 跑着的时候输入条上会多一个**「■ 中止」**按钮(没在飞时不显示)。
180
+ 点了之后:
181
+ - run 在**回合边界**以 `stopReason='aborted'` **正常收尾**,**trace 照常落盘**(面板也会说「已中止」,
182
+ 不是「失败」—— 中止的 `ok` 是 `false`,判别看 `stopReason`);
183
+ - 在飞时的第二次 `POST /run` 会被**拒绝**(409,提示先等它结束或先中止);
184
+ - ⚠️ 中断是**协作式**的:工具若不读 `signal`(例如某些 MCP 在途调用)就没人理。所以 5 秒后
185
+ **兜底重启 runner**,此时这次 run 的 trace 会丢 —— 面板会**明说**这一点(不静默)。
186
+
105
187
  > **陷阱**:装饰器注册表是模块级 `WeakMap`。框架必须是**单一模块实例** —— 混用 `src` 与 `dist`、或在一个仓库里装两份 agentia,会让能力收集为空。让 CLI 生成的 `package.json` 里只依赖一份框架即可。
106
188
 
107
189
  ---
@@ -181,6 +263,7 @@ npx agentia --version # CLI 版本(= -v)
181
263
  | `tools` | 直接追加到主菜单的**裸工具**(`AgentTool[]`):给「构造期才知道有哪些工具」的场合(典型:MCP 桥,见 §6)。与能力**同过中间件、同进重名查重**,不是旁路 |
182
264
  | `middleware` | 能力调用中间件(洋葱链,链序 = 注册顺序) |
183
265
  | `sinks` | trace 出口,run 收尾投递 |
266
+ | `onTraceEvent` | **增量记账出口的缺省值**:run **进行中**逐笔回调(`span.begin` / `span.end` / `span.event` / `span.attribute` / `span.link`),给等不了收尾的消费者(面板 / SSE / 任务流)。与 `sinks` 是**两条缝**、可同时配;单次 run 给了自己的那个是**叠加**(应用级在前)而非覆盖。⚠️ **不保证送达**(宿主自己的流断了就断了),也不替代 `sinks` |
184
267
  | `maxTotalTokens` | 缺省成本硬管控:整条 run(**含子 agent / skill 子循环**,上限经 `ToolRunContext` 透传)累计 token 上限(可被单次 run 覆盖) |
185
268
  | `maxCostUsd` | 缺省成本硬管控:累计成本(美元)上限(**依赖模型在价格表内**,见 `priceOverrides`;未定价模型会留 `usage.unpriced` 事件,所以「护栏有没有真的生效」看得见) |
186
269
  | `priceOverrides` | 价格表覆盖/追加(`$/1M tokens`):覆盖内置同名项,或给非 Anthropic 模型定价(如 `{ 'deepseek-chat': { in: 0.27, out: 1.10 } }`)。**透传给子 agent/skill 的子循环** —— 不会「主 agent 有成本、子 agent 恒 0」。非法单价在 run 开始即抛错 |
@@ -188,6 +271,7 @@ npx agentia --version # CLI 版本(= -v)
188
271
  | `toolTimeoutMs` | 缺省单工具超时(毫秒);超时该条 tool_result 记 is_error,不杀 run |
189
272
  | `maxToolConcurrency` | 缺省同回合并行工具上限;不设 = 不限(全并行) |
190
273
  | `maxEventChars` | 缺省 trace 事件正文截断上限(可被单次 run 覆盖):数字 = 入参/出参统一用该上限,`false` = **不截断**(完整正文进 trace,面板里能展开看全文);不设 = 框架缺省 |
274
+ | `traceLimits` | **记账的数量上限** `{ maxEvents? }`(可被单次 run 覆盖):整条 trace 的事件总数闸。超限即停止记账,并在交付时于 run 根写一笔 `trace.truncated{droppedEvents, limit}` —— 缺口位置可预测(尾巴)且**有计数**。与管**长度**的 `maxEventChars` 正交(一个管「多长」、一个管「多少」);不设 = 不限(全量记账是本框架的承诺)。坏值(NaN / 负数 / 小数)在 run 入口抛 `TypeError` |
191
275
 
192
276
  ### `app.run(messages, opts?: RunAppOptions)`
193
277
 
@@ -199,6 +283,8 @@ npx agentia --version # CLI 版本(= -v)
199
283
  | `maxIterations` | 单次覆盖 |
200
284
  | `client` | 注入 `ModelClient`(换 OpenAI 兼容端点等) |
201
285
  | `onText` | 文本增量回调(SSE/终端) |
286
+ | `onTraceEvent` | 单次 run 的**增量记账出口**(见 `createApp` 同名项):与**应用级那个叠加**(应用级在前),不是覆盖 |
287
+ | `traceLimits` | 单次 run 的记账数量上限(覆盖应用级缺省);见 `createApp` 同名项 |
202
288
  | `signal` | `AbortSignal`:中止则在飞请求被取消,run 以 `stopReason='aborted'` 收尾(算失败) |
203
289
  | `traceContext` | 入站链路上下文 `{ traceId, spanId? }`:触发本次 run 的上游 span 记成 run 根的一条 `links`(不改 `traceId == runId`)。HTTP 宿主认 `traceparent` 头,自动填 —— 见 §6「跨进程关联」 |
204
290
  | `blackboard` | 预置黑板种子(配 `Blackboard` 声明合并有键补全) |
@@ -355,11 +441,15 @@ const app = await createApp({ ... });
355
441
 
356
442
  三条语义(都刻意,别当成实现细节):
357
443
 
358
- - **框架不自动读 `.env`** —— 读哪个文件、什么时候读是宿主的启动决策。塞进 `createApp` 里自动做,会让「同一份代码换个目录跑结果不同」变成要花时间排查的悬案;而放 CLI 里只有 `agentia dev` 生效。写在**你的** `main.ts` 里,`node dist/main.js`、docker、别的宿主都一样读得到。
444
+ - **框架不自动读 `.env`** —— 读哪个文件、什么时候读是宿主的启动决策。塞进 `createApp` 里自动做,会让「同一份代码换个目录跑结果不同」变成要花时间排查的悬案;而放 CLI 里只有 `agentia dev` 生效。写在**你的** `app.ts` 里,`npm run dev`、`node dist/main.js`、docker、别的宿主都一样读得到。
359
445
  - **真实环境变量优先**(缺省不覆盖):`process.env` 里已定义(哪怕空串)的键保持不动 —— CI / docker / `FOO=bar npm start` 永远赢过文件。要让文件里的值压过环境变量就 `loadEnvFile({ override: true })`。
360
446
  - **想知道「生效没」看返回值**,别去看文件:被挡下的键不在返回对象里。
361
447
 
362
- `agentia create` 生成的脚手架把 `.env`、`.env.example` 与 `main.ts` 首行的 `loadEnvFile();` 都备好了,并在 `.gitignore` 里挡住 `.env` —— 生成 `.env` 却不 ignore,等于把 key 送进用户的第一个 commit。
448
+ `agentia create` 生成的脚手架把 `.env`、`.env.example` 与 `app.ts` 里的 `loadEnvFile();` 都备好了,并在 `.gitignore` 里挡住 `.env` —— 生成 `.env` 却不 ignore,等于把 key 送进用户的第一个 commit。
449
+
450
+ > ⚠️ **`loadEnvFile()` 必须在 `app.ts`,不能在 `main.ts`**:`agentia dev` 的 runner 只 import
451
+ > `app.ts`、**从不执行 `main.ts`**。放错一侧的失败形状是静默的 —— `npm run dev` 读不到 `.env`
452
+ > 而 `npm start` 读得到,用户只会看到「没配 key」然后去怀疑框架。这条有 e2e 钉着(`scripts/e2e-dev.ts`)。
363
453
 
364
454
  > ⚠️ **本机 export 过 `ANTHROPIC_API_KEY` 的人**(比如同时用 Claude Code):按上面的优先级,脚手架 `.env` 里的 key 会被**静默压住**。改了 `.env` 却「没生效」时,先 `echo $ANTHROPIC_API_KEY` 看看环境里是不是已经有一份。
365
455
 
@@ -463,6 +553,7 @@ npm run client # 另一个终端:把四个 RPC 跑一遍
463
553
  | `POST /run` | body 是 `RunInput`(string / messages / `{prompt\|text\|messages}`);带 `Accept: text/event-stream` 则走 SSE | 200 `{ runId, status, stopReason, finalText, typed?, trace, error? }` —— **`status=failed` 也照返 200**(`rethrow:false` 语义:硬失败以 `error` 字段表达,不用 HTTP 错误码) |
464
554
  | `POST /tasks` | `{ input, idempotencyKey?, options? }` —— `input` 同 `RunInput`;`options` 是 `RunInvocationOptions` | 202 `TaskRecord`(`status: 'queued'`);同 `idempotencyKey` 未失败则去重、直接返回既有记录(**同步 store** 当场判定;**异步 store** 下只保证**同进程内并发提交**不重复执行,跨进程与终态后重提仍是 at-least-once —— 见 §7「同键去重的能力边界」) |
465
555
  | `GET /tasks/:id` | — | 200 `TaskRecord`;不存在 → 404。**停机中仍可轮询**(否则拿不到在飞任务的结果) |
556
+ | `GET /tasks/:id/stream` | — | **任务进度流(SSE)**:先在 `id:` 里给流序号,逐帧下发 `trace.event`(body 即 `TraceRecordEvent`),终态发 `task.end` 并关闭。断线重连带 `Last-Event-ID`(或 `?from=<序号>`)即可续订 —— 只补该序号之后的事件。缓冲超限先发一帧 `stream.truncated{droppedBefore}`;别的进程在跑的任务发 `stream.unavailable` 后收口(**不假装实时**)。任务不存在 → 404;方法不对 → 405。⚠️ 它的读者是**旁观者**:背压/断开只收口这条流,**不中止任务** |
466
557
  | `POST /tasks/:id/approve` | `{ decisions: { <tool_use_id>: { approved, reason? } }, decidedBy? }` | 200 `TaskRecord`(HITL 审批:批准/拒绝挂起任务,见 §6.6「人工审批」);任务不存在 → 404;不在 `awaiting_approval` 状态 → 409;body 非法 → 400。**停机中仍可审批**(与 GET 轮询同理由) |
467
558
  | `GET /healthz` | — | 200 `HealthResponse`;**不鉴权**,停机中也回 200 |
468
559
  | `GET /metrics` | — | 200 Prometheus 文本(`text/plain; version=0.0.4`);**需在 `createHttpHandler` 里传 `metrics`**,**不鉴权**(与 `/healthz` 同档),停机中也回 |
@@ -539,7 +630,7 @@ process.on('SIGTERM', async () => {
539
630
 
540
631
  - **取消**:`app.run(messages, { signal })` 传 `AbortSignal` —— 框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发 `signal`),run 以 `stopReason='aborted'` 收尾(算失败)。`createHttpHandler` 已内置「客户端断开即中止」;`AsyncRunner.runTimeoutMs` 到点同样是**真中止**(构造期校验:必须 ≥ 0 的**有限**数 —— NaN/Infinity 会被 `setTimeout` 钳到 1ms,等于每个任务立即超时,故直接抛错;要「不限」传 0 或不设)。
541
632
  - **重试**:缺省自动重试可重试失败(429 / 5xx / 连接失败),指数退避 + 抖动。`retry: false` 关闭,或 `retry: { maxAttempts, baseDelayMs, maxDelayMs, jitter, onRetry }` 调参。**只在本次尝试尚未产出任何文本时重试**(已吐出的字无法撤回)。⚠️ 与底层 client 的**内置重试**叠加 —— **两条内置适配器口径一致**(`createAnthropicClient` / `createOpenAIClient` 都有 `maxRetries`,缺省 2,重试同一状态码集合 408/409/429/5xx)—— 建议二选一调(这里 `maxAttempts: 1`,或 `<适配器>({ maxRetries: 0 })`)。`maxRetries` 在**构造期**校验:只收非负安全整数(`0` = 不重试),NaN / ±Infinity / 负数 / 小数一律抛 `TypeError` —— 判定是 `attempt >= maxRetries`,NaN 恒假、Infinity 永不达到,两者都等于**无限重试**(且静默)。
542
- - **流式**:`POST /run` 带 `Accept: text/event-stream` → SSE 逐帧下发(`text.delta` / `run.end` / `error`);不带该头仍回一元 JSON。
633
+ - **流式**:`POST /run` 带 `Accept: text/event-stream` → SSE 逐帧下发(`text.delta` / `run.end` / `error`,外加一族 **`trace.event`** —— 增量记账事件,body 即 `TraceRecordEvent`,用它做实时面板;不认识这一族的老客户端行为零变化);不带该头仍回一元 JSON。
543
634
  - **工具超时 / 并发闸门**:`toolTimeoutMs` 超时**不杀 run**(该条 tool_result 记 `is_error`,模型可换路);`maxToolConcurrency` 给同回合的并行工具设上限(默认全并行)。⚠️ 超时 = **放弃等待**:`AgentTool.run` 没有 signal 参数,**副作用可能已发生**;但引擎放弃等待时会 abort `ToolRunContext.abandoned` —— 想真停的工具监听它自行收尾(框架自带的 @SubAgent / @Skill 已这么做:超时即中止子循环,capability span 以 error 收尾)。**超时判定只有一个裁判**:`toolTimeoutMs` 是唯一判据 —— 工具自带的超时(如 MCP 桥的 `timeoutMs`)在设了本项时**不参与**判定;反过来说,工具自判的超时(抛 `code='timeout'` 的错误)与引擎判的记**同一类账**(`errorKind='timeout'`),并同样回 `is_error`。
544
635
 
545
636
  ### 6.3 上下文预算与成本
@@ -629,6 +720,7 @@ trace 出去之后能干什么:指标、调用树面板、调优报告、生
629
720
  | `TraceRecorder` | 内存 recorder(一次 run 一个);`addLink(spanId, { traceId, spanId? })` 记一条跨 trace 链路(见 §6「跨进程关联」) |
630
721
  | `parseTraceparent` | 解析 W3C `traceparent` 头 → `{ traceId, spanId? }`;**非法 / 缺头一律返回 `undefined`**(不抛、不打 400)—— 结果直接交给 `traceContext` 选项,见 §6「跨进程关联」 |
631
722
  | `createOtlpExporter` | OTLP/JSON 导出,零依赖;选项见下面「`OtlpExporterOptions`」表 |
723
+ | `TraceRecordEvent` | **增量记账事件**(`onTraceEvent` 的回调参数):`span.begin` / `span.end` / `span.event` / `span.attribute` / `span.link`,每条带单调 `seq`。载荷是**增量 + 此刻的拷贝**(`span.begin` 只给初始形状,属性/事件/链路各走自己的类型);按 `seq` 升序折回必须**逐字等于**收尾时的 trace |
632
724
  | `metricsSink` | 指标累加器(Prometheus 文本 / OTLP metrics),满足 `TraceSink` 即接入 —— 见 §6「指标」 |
633
725
  | `buildRunReport` | 从一条 trace 生成**调优报告**(能力/模型的耗时、token、成本、错误率排行)—— 见 §6「调优报告」 |
634
726
  | `Score` | 质量评分:`{ name; value; source?; comment? }` —— LLM-judge / 人工标注 / eval 结论挂到 trace 上;约定 `value` 为 0–1(布尔结论用 0/1),`source` 记评分来源(eval 名 / `'human'` / judge 模型 id) |
@@ -696,6 +788,12 @@ runner.submit(msg.value, {
696
788
  });
697
789
  ```
698
790
 
791
+ 这条配方**有门禁真跑**:`tests/transport/queueConsumer.test.ts`(内存版 broker + 真
792
+ `AsyncRunner` + 真引擎)把三件事逐条跑出来 —— 同键重投**不重复执行**(断言的是副作用计数,
793
+ 不只是 taskId)、`traceparent` 随 `spec.options` 落库后**他进程 `resumePending` 续跑**那次 run
794
+ 仍带得上同一条 link、失败不 ack 必须 nack 重投(且**失败**的键允许新任务,否则重投永远拿不到
795
+ 第二次执行)。真接 broker 时仍然要自己接:门禁守的是**提交位移与幂等的时机**,不是协议实现。
796
+
699
797
  异步任务可以在 `POST /tasks` 的 body 里显式给 `options.traceContext`,它优先于 `traceparent` 头。
700
798
  换宿主这条缝不变:gRPC 宿主把 metadata 的 `traceparent` 翻进 `options.traceContext`,
701
799
  见 §6.2「gRPC 宿主」。
@@ -810,6 +908,28 @@ async placeOrder(input: { sku: string }) {
810
908
  - 入参折叠态的摘要被砍到 62 字符(4 个键 / 每值 21 字符);**展开拿到的是原文**,不是那份摘要 ——
811
909
  本地面板与官网 Playground 两个宿主都是如此(两个宿主都不该只喂摘要,否则点开什么都没多出来)。
812
910
 
911
+ > 本地面板比 Playground 多一层**输入端**(prompt / 能力 / 工作目录 / 多轮四组控件 + 输入框历史),
912
+ > 见 §2.2。Playground 是只读展示 trace 的。
913
+
914
+ #### 对话历史(多轮)从哪来
915
+
916
+ 开了**多轮**的能力,面板上多一个「对话」页签,内容 = 会话文件里的消息 **∪ 失败的 run**。
917
+
918
+ | 概念 | 落点 | 说明 |
919
+ |---|---|---|
920
+ | 会话存储 | `SessionStore` 接口 | `load()` / `append()`;脚手架给的是 `FileSessionStore`(写 `.agentia/session.json`,**原子写**:临时文件 + rename) |
921
+ | 会话标识 | `RunInvocationOptions.sessionId` | 同 id 的多次 run 共享历史;面板的 dev 环固定用 `'dev'` |
922
+ | 回写时机 | `runtime/run.ts` | ⚠️ **只在成功轮次回写** ⇒ 失败的轮次**在会话文件里不存在** |
923
+ | 面板怎么补上失败轮 | run 根 span 的 `attributes['session.id']` | 用它与 run 列表 join,再按 prompt 文本对上;少了这一步就是「显示一份**少了**一轮的对话」 |
924
+
925
+ **两层「历史」别混**(这是开发期最容易误导人的一处):
926
+
927
+ - **prompt 回显**(输入框 `↑`/`↓`):只是把文字填回输入框,**模型看不见** —— 你还能改,
928
+ 模型收到的仍是你最终回车的那句。与多轮开关无关,**永远有**。
929
+ - **对话历史**(对话页签):前面说过的话**真的进模型上下文**(更长、更贵、可能被裁剪)。
930
+ **只在开了多轮时出现** —— 没开多轮时一次 run = 一句输入 + 一段输出,凭空多一个对话视图
931
+ 会让人以为上个仓库的评审被带进来了(它没有,而且不该有)。
932
+
813
933
  #### 调优报告(**哪个能力慢 / 贵 / 爱失败**)
814
934
 
815
935
  指标回答「整体怎么样」,报告回答「**该拧哪个旋钮**」:
@@ -860,7 +980,7 @@ const app = await createApp({ /* … */ sinks: [jsonl] });
860
980
 
861
981
  每个 run 的**根 span** 都带一组 `config.*` attributes(`config.maxTokens` / `config.maxCostUsd` /
862
982
  `config.retry.maxAttempts` / `config.contextPolicy.budgetTokens` / `config.priceOverrides` /
863
- `config.maxEventChars` …)。
983
+ `config.maxEventChars` / `config.traceLimits.maxEvents` …)。
864
984
  带缺省值的那几项(`maxTokens` / `maxIterations` / `contextPolicy` / `retry`)**缺省值也记**——
865
985
  「没配」与「配了缺省值」因此可区分;可选项(`toolTimeoutMs` / `maxToolConcurrency` /
866
986
  `maxEventChars`)只在设了才记。换参数前后对比、复现线上行为都有据可查。
@@ -1255,6 +1375,10 @@ const callable = {
1255
1375
  | schema 校验是**子集** | 只覆盖 `type/properties/required/additionalProperties/enum/items`;`format`/`minimum`/`oneOf` 一律放行 |
1256
1376
  | 历史畸形就放弃裁剪 | `trimToolPairs` 遇到非严格交替历史会整体放弃(宁可少裁,也不切出孤立 tool_use 让请求 400) |
1257
1377
  | 同键去重的能力边界 | `idempotencyKey` 的去重分三档:**同步 store** 下 `submit` 当场返回既有记录;**异步 store**(Redis / SQLite 等)下 `submit` 是同步门面、无法 await,去重靠**进程内认领表**,只覆盖「**同进程内并发提交**」(2026-09-21 修复前这里会两次都执行);**跨进程并发**与**终态之后重提同键**仍是 at-least-once —— store 的 idem 索引是 last-wins(重提即新任务,`redisStore` / `fsStore` / `sqliteStore` 头注释同口径)。要严格一次,请让副作用自身幂等(或在 store 层做唯一约束) |
1378
+ | 增量出口与 sink 是**两条缝** | `onTraceEvent` 是「运行期逐笔」,`sinks` 是「收尾拿整棵」——消费者与保证都不同:sink 的抛错被吞但仍会**投递**(有兜底语义),`onTraceEvent` **不保证送达**(宿主自己的流断了就断了,没有重试/重放)。要「一条都不能少」用 `sinks`;要「现在就看到」用 `onTraceEvent` |
1379
+ | 任务进度流的边界(内存 / 跨进程) | `GET /tasks/:id/stream` 的事件缓冲在**跑任务的进程内存**里:每任务最近 500 条(可用 `AsyncRunner` 的 `streamBufferEvents` 调,须为正整数,坏值构造期抛错),超限丢**最旧**并先发一帧 `stream.truncated`;终态流只留最近 16 条。**跨进程**(队列消费者在别的进程)时没有实时流:发一帧 `stream.unavailable` 后**立即收口** —— 任务已终态则补 `task.end`(随后关连接);**非终态**则补一帧 `stream.closed`(流级收尾,不是伪造终态)并关连接,客户端此后应转去轮询 `GET /tasks/:id`。要跨进程实时请用 `onTraceEvent` 把事件转发到宿主自己的总线(Redis Streams / Kafka) |
1380
+ | `traceLimits` 与 `maxEventChars` 各管一头 | `maxEventChars` 管**单个事件正文多长**(既有),`traceLimits.maxEvents` 管**整条 trace 多少个事件**(本版)。两者正交、都「不设 = 不限」;上限触发时**丢弃量写在 run 根的 `trace.truncated`** 上(不静默)。⚠️ 实测 `maxEventChars: false` + 大出参会让 trace 放大 **13.7×**(`npm run bench:trace` 可复现)—— 先收长度再谈采样,收益顺序比反过来大 |
1381
+ | **采样是导出决策,不是记账决策** | 采样在 `sink` 外做(配方见 `docs/observability.md` 2.3):被采样掉的 trace 在框架内**仍然完整记账**,只是没发给下游。所以别拿「有采样」当「可以少记账」;也正因如此,出站 `traceparent` 的 flags 恒 `00`(记录/导出决策发生在收尾之后,运行期不可知——不替下游声明) |
1258
1382
  | 缺省内存 store 不淘汰 | 长跑宿主请设 `InMemoryTaskStore({ maxRecords })` 或换 `FileTaskStore` / `SqliteTaskStore` |
1259
1383
  | 能力引用两种粒度 | `tools` 写 **provider token** = 整片能力菜单;写 `'<token>/<能力名>'` = 只引单个能力(@Tool/@Skill/@SubAgent/@Prompt 都可点名,装配期校验,名字不存在即抛错并列出可用名单) |
1260
1384
  | 能力名有格式校验 | 装饰器能力名(`name` 或缺省的方法名)必须匹配 `^[A-Za-z0-9_-]{1,64}$`(与 MCP 桥同口径),非法名在 `createApp` **装配期即抛错** —— 含空格/点/中文的名字会让模型 API 400,宁可在启动期拦住 |
@@ -1282,7 +1406,7 @@ const callable = {
1282
1406
  | MCP 只做 tools | `sampling`(server 反向请求模型)/ `resources` / `prompts` 原语不做;出厂连接器同样只做 `tools/list` + `tools/call` |
1283
1407
  | MCP 的协议层错误框架看不见 | `isError: true` 只有连接器能看见 —— 它必须转成抛错,否则模型收到的是一条「成功」的结果(出厂连接器已代你处理) |
1284
1408
  | MCP 超时同样是「不等了」 | 桥的 `timeoutMs` 取消不了 server 侧执行(拿不到取消句柄);它只是**兜底** —— 引擎设了 `toolTimeoutMs` 时**不参与**判定(一次调用只有一个裁判;**显式 `toolTimeoutMs: 0` 也算设了** —— 那是引擎表态「不限」,桥不会再自作主张判 60s),两条路径**同判定、同账**(`errorKind='timeout'`) |
1285
- | 已中止的 MCP 调用**不发请求** | 信号在**发送前**就已中止 ⇒ 立刻以 `AbortError` 收场,请求不出门(2026-09-21 前是「照样 write、Promise 永不 settle」:副作用真送达、调用方永久挂起)。发送**之后**才中止的,请求已在路上、取消不了 —— 那是「不等了」,与 `toolTimeoutMs` 同口径 |
1409
+ | 已中止的 MCP 调用**不发请求** | 信号在**发送前**就已中止 ⇒ 立刻以 `AbortError` 收场,请求不出门(2026-09-21 前是「照样 write、Promise 永不 settle」:副作用真送达、调用方永久挂起)。发送**之后**才中止的,请求已在路上、取消不了 server 侧的执行,但返回的 Promise 同样立刻以 `AbortError` 收场、簿记同步清掉 —— 直接 await 连接器 API 的宿主不会永久挂起(2026-09-22 前是「只删簿记不 reject」:Promise 永不 settle,与 HTTP 连接器的在途中止行为不一致);取消不了的只是 server 侧执行,这点与 `toolTimeoutMs` 同口径 |
1286
1410
  | MCP 连接器的超时只管装配期 | 连接器自带的 `timeoutMs` 只作用于**握手 + `tools/list`**(那两步**没有任何别的裁判** —— server 卡住会让 `createApp` 永久挂起);`callTool` 仍是引擎 / 桥那一个裁判 |
1287
1411
  | MCP 连接的 `close()` 保证子进程已终止 | stdio 先 `SIGTERM`、`MCP_CLOSE_GRACE_MS`(2000 ms)后 `SIGKILL`,然后**等真正的 `'exit'`** —— **返回即代表进程已被回收**(此前到点即返回,会留孤儿进程而调用方无从知晓);HTTP 尽力 `DELETE` 会话(server 不认也无所谓) |
1288
1412
  | StreamableHTTP 会话过期**自愈** | 带会话 id 收到 `404` = 会话已终止、**该请求未被 server 执行** ⇒ 丢会话 → 重新握手 → 把**这一次**重试一次(**只一次**,不再循环)。自愈本身是静默的 ⇒ 用 `onSessionExpired` 去计数 / 告警,否则它和「静默失效」在监控上看不出区别。`404` **之外**的失败仍按 `classifyError` 分流抛出,不重试 |
@@ -1312,7 +1436,7 @@ const callable = {
1312
1436
  | 同步 `/run` 撞上审批没人可批 | 同步 RPC 会带着 `stopReason: 'awaiting_approval'` 收尾返回 —— 但响应体(`toHttpBody`)**不含** `suspendedMessages`,也没有任务记录可审批(待决清单只能去 trace 的 `approval.requested` 事件里看)。**要审批请走 `POST /tasks` 异步宿主** |
1313
1437
  | Scheduler 调度表不落库 | `every` / `at` 的调度本身只在内存:已 submit 的任务记录能经 `resumePending` 续跑,但「未来某刻再触发」的调度在重启后不存在(远期单发由宿主自己的 cron 驱动)。另:`drain()` 不停 Scheduler —— 停机窗口内到点的 tick 会打一条触发失败日志(无害但吵),介意就 `scheduler.stop()` 先行 |
1314
1438
  | file store 的撕裂写只在启动时自愈 | 写入中途失败(磁盘满等)留下的残行由 `healTail` 在**构造期**修复;同进程内继续 append 会把新记录粘在残行尾部、下次启动时一起丢弃 —— 磁盘满告警后先恢复写入能力再继续依赖它 |
1315
- | 终态落库失败无告警 | AsyncRunner 终态 save 失败被吞(「不击穿主流程」的代价):store 抖动时任务可能永远停在 `running`,重启后 `resumePending` 会重跑一个**实际已成功**(副作用已发生)的任务 —— 耐久 store 的故障告警是宿主的事 |
1439
+ | 终态落库失败 ⇒ 重启会重跑 | AsyncRunner 终态 `save` 失败**不遮罩主流程**(「不击穿业务」的代价):store 抖动时任务可能永远停在 `running`,重启后 `resumePending` 会重跑一个**实际已成功**(副作用已发生)的任务 —— 所以副作用工具必须自身幂等。**但失败本身不再静默**:`new AsyncRunner(app, { onPersistError })` 会收到 `{ record, error, phase }`(`phase: 'initial' \| 'outcome'`,后者就是这条)。⚠️ 框架**修不了**它(写不进去就是写不进去)—— 出口的职责是让你能对账、让「记录无声丢失」不再是默认行为(与 `createOtlpExporter({ onExportError })` 同因同形) |
1316
1440
  | `contextPolicy` 不进子循环 | 应用级 `contextPolicy` / `onText` 只对主循环生效:@SubAgent / @Skill 的子运行不做上下文裁剪(长跑子 agent 撞上下文上限会以 api 错误收尾)。预算护栏(`maxTotalTokens` / `maxCostUsd`)正常透传 |
1317
1441
  | 记忆没有删除语义 | `MemoryStore` 只有 load/save:run 内 `ctx.delete` 掉的键回写时不会从 store 移除(下一轮水合会复活)。要真删请直接操作 store 实现 |
1318
1442
  | 内容护栏不给实现 | 同「配额」:只给缝(入参包 `app.run` / 工具前 `middleware` / 出参包返回值或 `sinks`),策略(正则 / 分类器 / 外部 API)是你的 |
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Agentia —— **limits 语义的单一真源**。
3
+ *
4
+ * 为什么要有这个文件(`docs/guards.md` §2 的第一条待守形状):
5
+ *
6
+ * 本仓把「非正数 = 机制关掉」当成一条统一口径,但它被**每个旋钮各自解释**了一遍 ——
7
+ * 同一个 `0` 在这里是「不限」、在那里是「一次都不做」、在第三个地方是「配置错误」。
8
+ * 历史事故就出在这条缝上:`handler.drain({ timeoutMs: 1 })` 跨过 deadline 后**永不返回**
9
+ * (调用方按「1 毫秒 = 马上就超时」写,代码里却是另一套读法),而当时没有任何一处
10
+ * 集中写着「这个旋钮的 0 是什么意思」。改到相关代码时只能靠人去猜 —— 于是又猜错。
11
+ *
12
+ * 本文件把「每个旋钮的 0 是什么」变成**可执行的数据**,`tests/limits.test.ts` 再拿这份
13
+ * 表去**驱动真实站点**逐条对账:表里声明 `0 = 不限`,用例就去调那个真 API 并断言它真的
14
+ * 不限。所以:
15
+ *
16
+ * - 表与实现漂了 ⇒ 集中用例红(不是文档过期,是构建失败);
17
+ * - 表加了旋钮而探针没加 ⇒ 用例里 `Record<LimitKnob, …>` 少一项,`typecheck:tests` 红。
18
+ *
19
+ * ⚠️ 守卫方向的如实口径:`LimitKnob` 类型**来自这张表本身**,所以
20
+ * `Record<LimitKnob, …>` 只守得住「表 ↔ 探针」这一个方向的对账(表加了没探针、
21
+ * 探针加了表里摘了,都会被拦住);它**抓不到**「代码里新增了旋钮却根本没进表」——
22
+ * 那一步只能靠改代码的人主动登记(这张表就是登记处),别把它当成全自动的网。
23
+ * 表与探针的互查另有一条运行期用例兜底(`tests/limits.test.ts` 末尾)——
24
+ * tsx 不跑类型检查,光有 `Record` 的编译期守卫在单跑测试时看不见。
25
+ *
26
+ * ⚠️ 本模块**不是公共 API**(不进 `src/index.ts`):它是内部口径的真源,不是给使用者的旋钮。
27
+ * 面向使用者的口径在 `docs/usage-guide.md` 的「边界」一节。
28
+ *
29
+ * ⚠️ 表里的 `zeroClause` 是**报错文案的一部分**:有构造期校验的旋钮由实现代码直接把它插进
30
+ * 错误消息里(`runTimeoutMs` / `approvalTimeoutMs` / `traceLimits.maxEvents` / `maxRetries` /
31
+ * `streamBufferEvents` / `retainTerminal`)
32
+ * (`…(0 = 不重试),收到 -1 —— …`)。所以「文案里怎么解释 0」与「代码里怎么实现 0」
33
+ * 是同一个值,改一处等于同时改另一处 —— 这正是先前缺的那条单源。
34
+ *
35
+ * ⚠️ **别在这里写死「几处」**:计数会随旋钮增减立刻过期 —— 本行曾写「五处」并列举五个,
36
+ * 而漏掉的正是**同一次改动里新增**的 `retainTerminal`(实为六处)。要核对当前全集:
37
+ * `grep -rn 'zeroClauseOf' src/`。清单本身有价值,计数没有。
38
+ */
39
+ /**
40
+ * 一个旋钮在取 `0`(或「非正数」)时的**含义分类**。四选一,没有第五种:
41
+ *
42
+ * - `unlimited` —— **0 = 不限**。时间是「等多久 / 最多多久」的上限,0 表示去掉这个上限。
43
+ * 使用者的直觉通常是这个;本仓只有一部分旋钮真的是这个意思,这正是要对账的原因。
44
+ * - `disabled` —— **0 = 机制关掉 / 一次都不做**。数量是「做几次」,0 次就是不做。
45
+ * ⚠️ 与 `unlimited` 是**相反的**读法:`maxRetries: 0` 是「不重试」而不是「无限重试」;
46
+ * `maxEvents: 0` 是「一条都不记」而不是「不限条数」。
47
+ * - `immediate` —— **0 = 立即执行,不等**。等待预算为 0 ⇒ 这次等待压根不存在。
48
+ * 与 `disabled` 的区别:机制没被关掉,只是这一次立刻发生(`metricsSink` 的
49
+ * `intervalMs: 0` = 立即导出;`interruptibleSleep(0)` = 不睡)。
50
+ * - `invalid` —— **0 = 非法配置,构造期响亮失败**。0 在这里没有任何合理读法
51
+ * (`concurrency: 0` 没有 worker;`Scheduler.every(0)` 是空转),静默接受只会
52
+ * 把它变成另一件事(Node 会把 `setInterval(0)` 钳到 1ms ⇒ 每毫秒一轮的忙轮询)。
53
+ */
54
+ export type ZeroMeaning = 'unlimited' | 'disabled' | 'immediate' | 'invalid';
55
+ /** 坏值(非有限 / 负数 / 小数 / 类型不对)的处理方式 */
56
+ export type BadValuePolicy =
57
+ /** 构造期抛错 —— 使用者以为自己设了上限,静默接受就会变成别的行为 */
58
+ 'throws'
59
+ /** 一律视为「不限」(旧行为),不报错 */
60
+ | 'coerced-to-unlimited'
61
+ /** 无校验(该旋钮只做算术,坏值无处可藏) */
62
+ | 'none';
63
+ export interface LimitSemantic {
64
+ /** 稳定标识(用例按它建表;改名 = 用例红,故意的) */
65
+ readonly knob: string;
66
+ /** 实现位置(文件名:符号),改代码时的入口 */
67
+ readonly where: string;
68
+ readonly unit: 'ms' | 'count' | 'chars' | 'n/a';
69
+ readonly zero: ZeroMeaning;
70
+ /**
71
+ * 报错文案里那句「0 = 什么」(无报错的旋钮也写上 —— 文档与用例都读它)。
72
+ * 有构造期校验的旋钮由实现代码直接插进错误消息。
73
+ */
74
+ readonly zeroClause: string;
75
+ readonly badValue: BadValuePolicy;
76
+ /** 为什么是这个读法(一句话;踩过坑的把坑写上) */
77
+ readonly note: string;
78
+ }
79
+ /**
80
+ * 全部 limits 旋钮的 0 语义。**分组按读法**,好让人一眼看出哪几个是相反的。
81
+ */
82
+ export declare const LIMIT_SEMANTICS: readonly [{
83
+ readonly knob: 'AsyncRunner.runTimeoutMs';
84
+ readonly where: 'transport/async.ts';
85
+ readonly unit: 'ms';
86
+ readonly zero: 'unlimited';
87
+ readonly zeroClause: '0 = 不限';
88
+ readonly badValue: 'throws';
89
+ readonly note: 'NaN/Infinity 会被 setTimeout 钳到 1ms ⇒ 每个任务立即「超时」失败,且 NaN 会绕过 `< 0` 静默通过;所以要「不限」必须显式传 0。';
90
+ }, {
91
+ readonly knob: 'AsyncRunner.approvalTimeoutMs';
92
+ readonly where: 'transport/async.ts';
93
+ readonly unit: 'ms';
94
+ readonly zero: 'unlimited';
95
+ readonly zeroClause: '0 = 不限';
96
+ readonly badValue: 'throws';
97
+ readonly note: '同 runTimeoutMs:非有限数会让「已挂起多久」的比较静默失效或立即超时。';
98
+ }, {
99
+ readonly knob: 'AsyncRunner.drain.timeoutMs';
100
+ readonly where: 'transport/drain-gate.ts';
101
+ readonly unit: 'ms';
102
+ readonly zero: 'unlimited';
103
+ readonly zeroClause: '非正 = 一直等';
104
+ readonly badValue: 'none';
105
+ readonly note: '**历史事故现场**:`http.ts` 的优雅停机把剩余预算算成 `timeoutMs - elapsed` 再交给它,deadline 已过时算出 1 或 0 ⇒ 旧读法(`0` = 已到点)让它跨过 deadline 后**永不返回**。现在 `http.ts` 自己先判「已到点就认账」,不把 0 交给下游去猜。';
106
+ }, {
107
+ readonly knob: 'mapWithConcurrency.limit';
108
+ readonly where: 'engine/concurrency.ts';
109
+ readonly unit: 'count';
110
+ readonly zero: 'unlimited';
111
+ readonly zeroClause: '非正 = 不限';
112
+ readonly badValue: 'coerced-to-unlimited';
113
+ readonly note: '⚠️ 与 `maxToolConcurrency` 成对:这是**唯一**「数量」旋钮取 0 却读作「不限」的。另有一条反向的坑:`(0,1)` 区间的小数若交给 `Math.floor` 会压成 0 worker ⇒ 工具被静默丢弃,所以正数一律至少 1 个 worker(`maxToolConcurrency: cpus().length / 8` 在多核 < 8 的机器上正落在那个区间)。';
114
+ }, {
115
+ readonly knob: 'traceLimits.maxEvents';
116
+ readonly where: 'engine/tracer.ts';
117
+ readonly unit: 'count';
118
+ readonly zero: 'disabled';
119
+ readonly zeroClause: '0 = 一条都不记';
120
+ readonly badValue: 'throws';
121
+ readonly note: '⚠️ 与 `maxEventChars`(管「多长」)正交,一个管「多少」。0 是**有意义的值**(一条都不记,但计数照记)—— 不是「不限条数」。';
122
+ }, {
123
+ readonly knob: 'maxRetries';
124
+ readonly where: 'integrations/adapter-options.ts';
125
+ readonly unit: 'count';
126
+ readonly zero: 'disabled';
127
+ readonly zeroClause: '0 = 不重试';
128
+ readonly badValue: 'throws';
129
+ readonly note: '坏值必须响亮失败:判定是 `attempt >= maxRetries` ⇒ `NaN` 比较恒假、`Infinity` 永远达不到,两者都是**无限重试**(429 场景下每一次都是真金白银);`-1` 静默变成「不重试」;`1.5` 实际只允许 1 次。';
130
+ }, {
131
+ readonly knob: 'maxEventChars';
132
+ readonly where: 'engine/tool-events.ts';
133
+ readonly unit: 'chars';
134
+ readonly zero: 'disabled';
135
+ readonly zeroClause: '0 = 截到零长度;false = 不截断';
136
+ readonly badValue: 'none';
137
+ readonly note: '两个「关掉」的写法语义不同:`false` 才是「不截断」(调试期看全文),`0` 是把正文截成空串。⚠️ 靠 `!= null` 判定透传(真值判定会吃掉 `false`)。';
138
+ }, {
139
+ readonly knob: 'runAgent.maxIterations';
140
+ readonly where: 'engine/loop.ts';
141
+ readonly unit: 'count';
142
+ readonly zero: 'disabled';
143
+ readonly zeroClause: '0 = 一次都不跑';
144
+ readonly badValue: 'none';
145
+ readonly note: '循环写成 `for (iteration = 0; iteration < maxIterations; ...)` ⇒ 0 时循环体一次不执行,直接以「达到循环上限」收尾(**算失败**)。是「已到点」而非「不限」,与 `mapToolConcurrency` 的读法**相反**。';
146
+ }, {
147
+ readonly knob: 'withTimeout.ms';
148
+ readonly where: 'core/timeout.ts';
149
+ readonly unit: 'ms';
150
+ readonly zero: 'disabled';
151
+ readonly zeroClause: '非正 = 不设超时(原样透传)';
152
+ readonly badValue: 'none';
153
+ readonly note: '`toolTimeoutMs` 的底座。⚠️ 漏透传这个值不是「少一层保险」而是**反的**:`withTimeout(p, 0)` 直接返回原 promise = 永不超时,同时 MCP 桥找不到引擎预算又起自己的 60s 兜底 ⇒ 双计时器、双账本(`toolkit/subagent.ts` / `skill.ts` 的透传注释指的就是这条)。';
154
+ }, {
155
+ readonly knob: 'TaskEventStreams.retainTerminal';
156
+ readonly where: 'transport/task-events.ts';
157
+ readonly unit: 'count';
158
+ readonly zero: 'disabled';
159
+ readonly zeroClause: '0 = 不留终态流(终态即忘)';
160
+ readonly badValue: 'throws';
161
+ readonly note: '判定是 `excess <= 0 提前返回` ⇒ NaN 恒假、**全部**无订阅者的终态流被清空(保留机制静默失效,方向与 maxEvents 的「闸失效」相反、同族)。⚠️ 只跳过「还有订阅者」的终态流 —— 那是还没被读完的流,丢了下游会莫名断在半路。';
162
+ }, {
163
+ readonly knob: 'interruptibleSleep.ms';
164
+ readonly where: 'core/timeout.ts';
165
+ readonly unit: 'ms';
166
+ readonly zero: 'immediate';
167
+ readonly zeroClause: '非正 = 不睡';
168
+ readonly badValue: 'none';
169
+ readonly note: '⚠️ 这一判**先于** aborted 检查 —— 别把它「修」成「已中止就该 reject」。2026-09-19 外部复核把它当一致性缺口改反过一次,被 `tests/core/timeout.test.ts` + `tests/core/sse-text-stats.test.ts` 拦住。';
170
+ }, {
171
+ readonly knob: 'metricsSink.intervalMs';
172
+ readonly where: 'integrations/metrics.ts';
173
+ readonly unit: 'ms';
174
+ readonly zero: 'immediate';
175
+ readonly zeroClause: '0 = 立即导出';
176
+ readonly badValue: 'none';
177
+ readonly note: '⚠️ **同名反义**:`Scheduler.every` 的 `intervalMs: 0` 是**配置错误**(抛错),这里却是「关掉定时器、每次累加后立即导出」。两个同名旋钮两种读法 —— 改任何一个之前先看这张表。';
178
+ }, {
179
+ readonly knob: 'AsyncRunner.concurrency';
180
+ readonly where: 'transport/async.ts';
181
+ readonly unit: 'count';
182
+ readonly zero: 'invalid';
183
+ readonly zeroClause: '必须为正数(0 = 没有 worker)';
184
+ readonly badValue: 'throws';
185
+ readonly note: '0 个 worker 的池子会让每个任务永远排在队列里(既不跑也不失败)。';
186
+ }, {
187
+ readonly knob: 'AsyncRunner.streamBufferEvents';
188
+ readonly where: 'transport/task-events.ts';
189
+ readonly unit: 'count';
190
+ readonly zero: 'invalid';
191
+ readonly zeroClause: '必须为正安全整数(0 没有「缓冲几条」的读法)';
192
+ readonly badValue: 'throws';
193
+ readonly note: '0 既不是「关掉流」(live 推送仍在工作)也不是「不限」,三种合法读法都不沾 ⇒ 配置错误;旧实现 `Math.max(1, …)` 把 0 静默抬成 1(替使用者改配置),且 NaN 穿过后 `length > NaN` 恒假 ⇒ 每任务内存闸静默失效(使用者以为设了上限)。';
194
+ }, {
195
+ readonly knob: 'Scheduler.every.intervalMs';
196
+ readonly where: 'transport/scheduler.ts';
197
+ readonly unit: 'ms';
198
+ readonly zero: 'invalid';
199
+ readonly zeroClause: '必须为正有限数(0 会空转)';
200
+ readonly badValue: 'throws';
201
+ readonly note: '`setInterval(0)` 退化成「尽快重复」的空转循环(Node 钳到 1ms,仍是每毫秒一轮忙轮询);超过 2^31-1ms 会被**静默**钳到 1ms,同款失败。';
202
+ }, {
203
+ readonly knob: 'createAnthropicClient.timeout';
204
+ readonly where: 'integrations/anthropic.ts';
205
+ readonly unit: 'ms';
206
+ readonly zero: 'invalid';
207
+ readonly zeroClause: '不设 = 不限;0 = 非法';
208
+ readonly badValue: 'throws';
209
+ readonly note: '「不限」的表达方式是**不传**,不是传 0 —— 与 `runTimeoutMs` 的读法**相反**(那个要求显式传 0)。';
210
+ }, {
211
+ readonly knob: 'metricsSink.windowSize';
212
+ readonly where: 'integrations/metrics.ts';
213
+ readonly unit: 'count';
214
+ readonly zero: 'invalid';
215
+ readonly zeroClause: '必须为正数(0 = 窗口里什么都没有)';
216
+ readonly badValue: 'throws';
217
+ readonly note: '同款的还有 `maxCapabilities` / `maxModels` / `maxScores` —— 都是「必须为正数」。';
218
+ }];
219
+ /** 表里全部旋钮的标识(用例按它建穷尽表:少一项 `typecheck:tests` 就红) */
220
+ export type LimitKnob = (typeof LIMIT_SEMANTICS)[number]['knob'];
221
+ /** 按旋钮取那行(拼错旋钮名 = 编译期报错,不是运行期 undefined) */
222
+ export declare function limitSemanticOf(knob: LimitKnob): LimitSemantic;
223
+ /**
224
+ * 报错文案里那句「(0 = …)」。**构造期校验直接插它**,所以文案与表不可能各说各话。
225
+ */
226
+ export declare function zeroClauseOf(knob: LimitKnob): string;