@migor/agentia 0.8.1 → 0.8.3

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 (70) hide show
  1. package/CHANGELOG.md +163 -1
  2. package/README.md +1 -1
  3. package/dist/AGENTS.md +45 -5
  4. package/dist/core/limits.d.ts +198 -0
  5. package/dist/core/limits.js +186 -0
  6. package/dist/core/limits.js.map +1 -0
  7. package/dist/core/timeout.d.ts +9 -0
  8. package/dist/core/timeout.js +13 -3
  9. package/dist/core/timeout.js.map +1 -1
  10. package/dist/core/trace.d.ts +66 -2
  11. package/dist/core/trace.js +37 -2
  12. package/dist/core/trace.js.map +1 -1
  13. package/dist/engine/forwarded.d.ts +84 -0
  14. package/dist/engine/forwarded.js +39 -0
  15. package/dist/engine/forwarded.js.map +1 -0
  16. package/dist/engine/spec.d.ts +26 -1
  17. package/dist/engine/spec.js.map +1 -1
  18. package/dist/engine/tracer.d.ts +62 -1
  19. package/dist/engine/tracer.js +130 -14
  20. package/dist/engine/tracer.js.map +1 -1
  21. package/dist/index.d.ts +3 -2
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/integrations/adapter-options.d.ts +15 -0
  25. package/dist/integrations/adapter-options.js +33 -0
  26. package/dist/integrations/adapter-options.js.map +1 -0
  27. package/dist/integrations/anthropic.js +4 -1
  28. package/dist/integrations/anthropic.js.map +1 -1
  29. package/dist/integrations/mcp-stdio.js +11 -7
  30. package/dist/integrations/mcp-stdio.js.map +1 -1
  31. package/dist/integrations/metrics-otlp.js +7 -0
  32. package/dist/integrations/metrics-otlp.js.map +1 -1
  33. package/dist/integrations/metrics-state.d.ts +9 -0
  34. package/dist/integrations/metrics-state.js +13 -0
  35. package/dist/integrations/metrics-state.js.map +1 -1
  36. package/dist/integrations/metrics.js +3 -2
  37. package/dist/integrations/metrics.js.map +1 -1
  38. package/dist/integrations/openai.js +4 -2
  39. package/dist/integrations/openai.js.map +1 -1
  40. package/dist/integrations/otlp-partial.d.ts +27 -0
  41. package/dist/integrations/otlp-partial.js +63 -0
  42. package/dist/integrations/otlp-partial.js.map +1 -0
  43. package/dist/integrations/otlp.d.ts +11 -1
  44. package/dist/integrations/otlp.js +54 -30
  45. package/dist/integrations/otlp.js.map +1 -1
  46. package/dist/runtime/run.d.ts +13 -1
  47. package/dist/runtime/run.js +14 -3
  48. package/dist/runtime/run.js.map +1 -1
  49. package/dist/toolkit/module.d.ts +22 -1
  50. package/dist/toolkit/module.js +12 -0
  51. package/dist/toolkit/module.js.map +1 -1
  52. package/dist/toolkit/skill.js +4 -13
  53. package/dist/toolkit/skill.js.map +1 -1
  54. package/dist/toolkit/subagent.js +6 -17
  55. package/dist/toolkit/subagent.js.map +1 -1
  56. package/dist/transport/async.d.ts +67 -0
  57. package/dist/transport/async.js +129 -2
  58. package/dist/transport/async.js.map +1 -1
  59. package/dist/transport/http-route.d.ts +6 -1
  60. package/dist/transport/http-route.js +9 -1
  61. package/dist/transport/http-route.js.map +1 -1
  62. package/dist/transport/http.js +93 -1
  63. package/dist/transport/http.js.map +1 -1
  64. package/dist/transport/sse.d.ts +6 -2
  65. package/dist/transport/sse.js +3 -2
  66. package/dist/transport/sse.js.map +1 -1
  67. package/dist/transport/task-events.d.ts +83 -0
  68. package/dist/transport/task-events.js +141 -0
  69. package/dist/transport/task-events.js.map +1 -0
  70. package/package.json +5 -4
package/CHANGELOG.md CHANGED
@@ -5,6 +5,166 @@
5
5
  (0.x 阶段:minor 可含破坏性变更,每个破坏性变更都在对应版本的「迁移」小节里写明)。
6
6
  决策的完整证据链在 `docs/spec.md` §10(带时间线的决策日志)。
7
7
 
8
+ ## [0.8.3] - 2026-09-21
9
+
10
+ ### 变更
11
+
12
+ > 本版主题(窗口 `0.8.2 → 0.8.3`,含 #115 与 #116):**增量 trace 出口落地**。记账与交付之间
13
+ > 此前**没有缝** —— `TraceRecorder` 的唯一出口是收尾的 `snapshot()`,所以「等不了 run 收尾」
14
+ > 的消费者(终端面板 / SSE 前端 / 异步任务进度流)拿不到任何东西。本版补上那条缝,顺手把
15
+ > spec §7 F3 的两条后置项与 §9.4 的记录成本问题同日收口。
16
+ > **框架 API 无破坏性变更**:只新增(`onTraceEvent` / `traceLimits.maxEvents` /
17
+ > `AsyncRunner.streamBufferEvents` / `GET /tasks/:id/stream` / `/run` SSE 的 `trace.event` 帧),
18
+ > **既有签名、既有三帧 SSE、既有默认行为逐字不变** —— 不认识新帧的老客户端行为零变化。
19
+ > #116 全部落在**仓库自身**(守卫 + 一次行为等价的重构),不含任何运行时行为变更。
20
+
21
+ ### 新增
22
+
23
+ - **`onTraceEvent`:run 进行中逐笔拿记账事件**。`RunInvocationOptions.onTraceEvent`(单次)与
24
+ `AppOptions.onTraceEvent`(应用级缺省)**叠加**(应用级在前)而不是覆盖 —— 观察者是注册不是
25
+ 值覆盖,覆盖会让「某次 run 顺手传了个面板回调」把应用级那条静默顶掉。载荷是
26
+ `TraceRecordEvent`(`span.begin` / `span.end` / `span.event` / `span.attribute` / `span.link`,
27
+ **增量 + 此刻的拷贝**)。
28
+ 四条纪律:同步派发不 await(订阅者是观察者,不该把 run 变成它的调度)、抛错被吞(与
29
+ `flushSinks` 同款)、无订阅者零派发、**seq 每次记账动作都占号**(与有没有订阅者无关 ——
30
+ 否则「订阅晚的人」看到的序号会与一直订阅的人不一致,重放与去重都会错位)。
31
+ 它与 `TraceSink` **不互相替代**:sink 是收尾拿整棵、不保证运行期可见;这条是运行期逐笔、
32
+ 不保证送达。要「收尾的整棵」继续用 sink。
33
+ - **`POST /run` 的 SSE 新增一族 `trace.event` 帧**。帧名取**一族** + body 里带 `type`,而不是
34
+ 每类型一帧:将来加新事件类型时,老客户端只是漏掉一种 `type`,而不是漏掉一种**帧名**(后者
35
+ 更隐蔽)。既有三帧(`text.delta` / `run.end` / `error`)逐字未变。
36
+ - **`GET /tasks/:id/stream`:异步任务的进度流**。从头重放 → 转实时 → 终态以 `task.end` 收口关流;
37
+ `Last-Event-ID` / `?from=` 指**流自己的序号**(一个任务可能跨多个 run 段 —— HITL 挂起→恢复、
38
+ 崩溃重投,每段是独立的一次 run、`seq` 从 1 重来,而流的读者要的是一条**连续的**流)。
39
+ 四条边界:`awaiting_approval` **不是终态**(流继续开着 —— 关掉的话「等审批结果的前端」正好在
40
+ 最需要的时候断线);这条流的读者是**旁观者**,背压 / 断开**不 abort 任务**(与 `/run` 的 SSE
41
+ 刻意相反:那里的下游是 run 的所有者,背压等于「别继续烧 token 了」);跨进程(别的 runner 跑的
42
+ 任务、同一个 store)→ 一帧 `stream.unavailable` + 终态 `task.end`,**不假装实时**;缓冲超限 →
43
+ 丢最旧并先发一帧 `stream.truncated`(**不静默**)。缓冲用 `AsyncRunner` 的
44
+ `streamBufferEvents` 调(缺省每任务 500 条;终态流只留最近 16 条)。事件**不落 store** 是有意
45
+ 的:实测事件数 = 2 × 工具调用、正文 KB 级 ⇒ 每条工具调用要把 KB 级正文写库两次(写放大),而
46
+ 宿主本来就有自己的总线(`onTraceEvent` 就是给它的缝)。
47
+ - **`traceLimits.maxEvents`:整条 trace 的事件总数上限**。超限即**停止记账**,交付时在 run 根写
48
+ 一笔 `trace.truncated{droppedEvents, limit}` —— 缺口位置可预测(尾巴)且**有计数**。
49
+ 刻意**不做**环形缓冲(丢最旧、留最近):那会让 trace 中间出现空洞,而空洞比「尾巴截断」难解释
50
+ 得多(「这一回合怎么没有工具事件」)。`0` = 一条都不记(**有意义的值**,仍有计数);不设 = 不限。
51
+ 坏值(NaN / ±Infinity / 负数 / 小数)在 run 入口抛 `TypeError` —— 静默接受会让闸门**形同不存在**
52
+ (`NaN` 让 `>=` 恒假),而使用者以为自己设了上限。
53
+ 与既有的 `maxEventChars` **正交**:一个管「单个事件正文多长」、一个管「多少」。
54
+
55
+ ### 文档
56
+
57
+ - `docs/usage-guide.md`:`onTraceEvent` / `traceLimits` / `streamBufferEvents` 与
58
+ `GET /tasks/:id/stream` 的用法;§7 边界表补任务进度流的边界(内存 / 跨进程)。
59
+ - `docs/observability.md`:采样在「不内建」之外补上**可算 + 可数** —— §2.3 新增采样率换算表,
60
+ 示例 `sampleSink` 增加丢弃计数(`dropped()` / `onDrop`)。**框架仍然不内建采样器**(既有决策
61
+ 不变:采样是配方 2.3,`examples/observability` 已有成品;再造一个就是同一件事的两份实现)。
62
+ - `docs/spec.md` §9.4 从「唯一剩下的开放问题」变为**决定**:默认全量、截断默认开、采样不内建,
63
+ 框架侧只新增「数量上限 + 丢弃计数」这一件 —— 让「少记了数据」可数。
64
+ - **一处面向使用者的说明被更正**:`formatTraceparent` 的 flags 继续恒 `00`,但理由从「本框架
65
+ 不采样」改为**「运行期不可知」** —— 记录 / 导出决策发生在**收尾之后**,出站调用发生在
66
+ **运行期**,那时没有答案。所以它**不会**跟随采样率变成 `01`:原理由在采样成为推荐配法后已不
67
+ 严谨,若照它改成「跟随采样」反而是错的。
68
+
69
+ ### 仓库自身(不面向使用者)
70
+
71
+ - **`docs/guards.md` §2 从 4 行清到 1 行** —— 三条待守形状建成了机器守卫(`0` 的语义真源 /
72
+ 穷尽转发 / 队列消费者配方门禁),细节见该文件 §1.2 / §1.3。顺带登记了两个此前无人写明的事实:
73
+ `intervalMs` 在 `Scheduler.every`(必须 > 0)与 `metricsSink`(`0` = 立即导出)里**同名反义**;
74
+ 数量类旋钮里只有 `mapWithConcurrency` 把 `0` 读作「不限」(`maxRetries` / `maxEvents` /
75
+ `maxIterations` 都是「就是不做」)。
76
+ - `scripts/verify-all.sh` 与 CI 的**步数不变**:新增检查一律折进已有步骤(步骤数写在 CI 的必需
77
+ 状态检查名里,加一步就要同时改 workflow 与分支保护)。
78
+
79
+ **迁移**:无(框架 API 无破坏性变更,既有代码不需要任何改动)。若你的宿主自己解析 `/run` 的 SSE,
80
+ 它**不需要**认识新的 `trace.event` 帧 —— 不认识就忽略,行为与升级前逐字一致。要消费增量事件,
81
+ 新接 `onTraceEvent`(程序内)或 `GET /tasks/:id/stream`(远程看进度)即可。
82
+
83
+ ## [0.8.2] - 2026-09-21
84
+
85
+ ### 变更
86
+
87
+ > 本版主题(窗口 `0.8.1 → 0.8.2`,含 #112):**外部双模型复核逐条复现收口** —— 9 条真缺陷
88
+ > (7 条复核成立 + 改的过程中照出的 2 条),全部落在「不报错地不干活」这一类:静默丢观测数据、
89
+ > 静默无限重试、无声永久挂起、生成出来的工程一跑就崩。
90
+ > **框架 API 无破坏性变更**:只新增一个选项(`createOtlpExporter({ onExportError })`),
91
+ > 既有签名与行为在「你没传坏值 / 没按 200 当全部成功」的前提下逐字不变。
92
+ > **一处需要你确认的收紧**:`maxRetries` 的坏值改在**构造期抛错**(见「修复」与「迁移」)。
93
+
94
+ ### 修复(观测出口 —— 三条都属于「看板少数据而框架说一切正常」)
95
+
96
+ - **OTLP 导出的 `status.code` 从名字符串改成整数**(`'STATUS_CODE_OK'` / `'STATUS_CODE_ERROR'`
97
+ → `1` / `2`)。OTLP 规范对此是**明文 MUST**,而且专门点出它与 protobuf 的通用 JSON 映射不同:
98
+
99
+ > *Values of enum fields MUST be encoded as integer values.*
100
+ > *Unlike the standard Protobuf JSON Mapping, which allows values of enum fields to be encoded as
101
+ > either integer values or as enum name strings, only integer enum values are allowed in OTLP JSON
102
+ > Protobuf Encoding; the enum name strings MUST NOT be used.*
103
+ > —— [OTLP 规范 · JSON Protobuf Encoding](https://opentelemetry.io/docs/specs/otlp/)
104
+
105
+ (同期 `kind` 一直是整数 1,只有 `status` 漏了。)**后果取决于你那侧 collector 的宽容度**:
106
+ 照规范校验的实现会判非法并**整批拒收**(也就说这些 span 在采集端**根本没落库**,而框架侧只看得到
107
+ 「HTTP 200」);按通用 protobuf JSON 映射的宽容实现(enum 名与整数都收)能落库。
108
+ 受影响范围:**`v0.2.2` → `v0.8.1`**(`createOtlpExporter` 自 `v0.2.2` 起就带这个错)。
109
+ 动作:升级即可,不需要改代码。**但请顺手去采集端确认一眼**这期间(用严格 collector 的话)
110
+ 的 trace 是否为空 —— 你的实现属于哪一档,看那里比看本文档准;丢掉的**历史数据补不回来**。
111
+ - **HTTP 200 不再等于「全部接收」**:collector 可以回 `200 + partialSuccess` 表示「收了一部分」。
112
+ 此前 `res.ok` 为真就算成功 ⇒ 少了一半数据也没人知道。现在「**真拒收**(键在场且值 > 0)
113
+ **或**非空 `errorMessage`」判为导出失败;`{}` 与 `rejectedSpans: 0` 仍算全部接收
114
+ (有 collector 恒发这种形状,不能反着误报)。
115
+ - **新增 `createOtlpExporter({ onExportError })`**(与 `metricsSink` 的同名选项对称):
116
+ 不给,维持既有行为(抛出 → `flushSinks` 吞掉,观测失败不击穿业务);给了,**所有**导出失败
117
+ (非 2xx / 超时 / **HTTP 200 但部分接收**)都交给它。存在的理由:`TraceSink` 的失败缺省是
118
+ **静默**的,「导出其实少了一半数据」这类消息得有人能收到 —— 否则它和「一切正常」在监控上
119
+ 看不出区别。
120
+ - **`metricsSink` 的 `reset()` 语义明确为「开启新窗口」**:计数清零**且**数据点起点前移。
121
+ 此前 `reset()` 只清计数、而数据点的 `startTimeUnixNano` 取自 sink **创建时刻的常量** ⇒
122
+ 同一 `startTime` 下 counter 从 2 退到 1(CUMULATIVE 指标的契约是「同一区间单调不减」,
123
+ 后端会算出负增量或直接丢样本)。同一毫秒内连按两次 `reset()` 时起点也**严格**前进。
124
+
125
+ ### 修复(其余)
126
+
127
+ - **`maxRetries` 的坏值改在构造期抛 `TypeError`**(`createAnthropicClient` /
128
+ `createOpenAIClient`,两条适配器共用一份判定)。重试判定是 `attempt >= maxRetries`,于是
129
+ 四类坏值此前被**静默接受**、后果各不相同:`NaN` ⇒ 比较恒假 ⇒ **无限重试**;`Infinity` ⇒
130
+ 永不达到 ⇒ **无限重试**;`-1` ⇒ 静默变成「不重试」;`1.5` ⇒ 实际只允许 1 次(读数上看不出来)。
131
+ 使用者以为自己设了上限,实际没有 —— 429 场景下每多一次重试都是真金白银。
132
+ **`0` 与不传仍然合法**(`0` = 不重试,是有意义的值;缺省 2);坏的是「非整数 / 负数 / 非有限」。
133
+ - **已中止的 MCP 调用不再发请求、不再永久挂起**:此前的写法是「把 pending 条目删掉、然后照样
134
+ `write`」⇒ ① 副作用请求**仍然送达** server(取消在传输层是无效的);② 返回的 Promise
135
+ **永远不 settle**(条目已删,没人能 resolve/reject)⇒ 调用方**永久挂起**
136
+ (引擎侧靠 `toolTimeoutMs` 兜底才没炸)。现在判据在 `write()` **之前**,以 `AbortError` 收场
137
+ (取消不是超时,记账仍归 `aborted`)。发送**之后**才中止的,请求已在路上、取消不了 ——
138
+ 那是「不等了」,与 `toolTimeoutMs` 同口径。
139
+ - **异步 store 下同键并发提交不再重复执行**:`submit` 是**同步门面**、无法 await 异步 store 的
140
+ `byIdempotency`,而 `#executeInner` 只采纳已 `succeeded` 的既有记录 ⇒ 「同时提交两个相同
141
+ `idempotencyKey`」在异步 store(Redis / SQLite)下**两次都执行**(文档承诺的同键去重实际只对
142
+ 同步 store 成立)。现在补一张**进程内认领表**:认领在同步前段完成(两次连续 `submit` 之间
143
+ 没有窗口),且**只在终态释放**(挂起还在等人,放了会让同键另起一个任务)。
144
+ **边界(同时写进 `usage-guide` §7)**:修的是「同进程内并发提交」这一档;**跨进程并发**与
145
+ **终态之后重提同键**仍是 at-least-once(store 的 idem 索引 last-wins)。
146
+ - **`@migor/cli`:模板漏写 `scripts/clean.mjs`**。本版首次给脚手架模板加「构建前先清 dist」,
147
+ 并在发布前发现 `create.ts` 忘了把该脚本写出去 —— 生成的新工程 `npm run build` **第一步就
148
+ `MODULE_NOT_FOUND`**。**已发布版本(≤0.8.1)的模板不含这一步,你此前生成的工程不受影响**,
149
+ 也不需要改;本版发布的是修好的形态。仓库自身与模板的构建现在都先清 dist(`tsc` 不会删除
150
+ 它不再产出的文件 —— 目录重构后旧产物会原样进包),并新增「模板目录 ↔ 源码双向引用」守卫
151
+ (模板文件必须被引用、每个 accessor 必须有调用方)与 e2e 的**真删能力再重建**对照。
152
+
153
+ ### 仓库自身(不面向使用者)
154
+
155
+ - e2e-cli 的构建步骤从「测试复刻 `clean → tsc → copy-assets` 三步」改成**字面跑产物自己的
156
+ `npm run typecheck` / `npm run build`** —— 这条改法当场照出上面那条真缺陷(测试复刻命令的
157
+ 版本照不出来)。同时删掉一条会在合法重构时误报的字面量断言。
158
+ - `docs/guards.md` §1 增补五条守卫(OTLP enum + partialSuccess、CUMULATIVE 窗口起点、模板重建
159
+ 清 dist、幂等键进程内认领、模板 ↔ 源码双向引用);`docs/spec.md` §10 ⑤ 记逐条定性、
160
+ 反向验证与「报告自称已证伪」的抽查范围。
161
+ - `tests/docs/usage-guide.test.ts` 把 `OtlpExporterOptions` 登记进成员表校验 —— 新增选项自此
162
+ 被文档守卫钉住(注入假成员验证过会红)。
163
+
164
+ **迁移**:无(框架 API 无破坏性变更,既有代码不需要任何改动)。唯一需要动作的是 `maxRetries`:
165
+ 如果你此前给它传过 `NaN` / `±Infinity` / 负数 / 小数,升级后**构造期会抛 `TypeError`** ——
166
+ 这是有意的(它此前是静默失效),改成非负整数即可;传 `0` 或不传的代码不受影响。
167
+
8
168
  ## [0.8.1] - 2026-09-21
9
169
 
10
170
  ### 变更
@@ -779,7 +939,9 @@
779
939
  首个公开发布:`@migor/agentia` + `@migor/cli`(scope `@migor/*`),两包版本同步。
780
940
  框架本体单包;CLI 独立成包(workspaces)。
781
941
 
782
- [Unreleased]: https://github.com/retrychx/agentia/compare/v0.8.1...HEAD
942
+ [Unreleased]: https://github.com/retrychx/agentia/compare/v0.8.3...HEAD
943
+ [0.8.3]: https://github.com/retrychx/agentia/releases/tag/v0.8.3
944
+ [0.8.2]: https://github.com/retrychx/agentia/releases/tag/v0.8.2
783
945
  [0.8.1]: https://github.com/retrychx/agentia/releases/tag/v0.8.1
784
946
  [0.8.0]: https://github.com/retrychx/agentia/releases/tag/v0.8.0
785
947
  [0.7.2]: https://github.com/retrychx/agentia/releases/tag/v0.7.2
package/README.md CHANGED
@@ -34,7 +34,7 @@ npm run dev # = agentia dev:tsx watch + 本地 inspec
34
34
  > 框架**不自动**读 `.env`:脚手架 `src/main.ts` 首行的 `loadEnvFile()` 负责把它读进 `process.env`。
35
35
  > 真实环境变量优先(CI / docker / 命令行永远赢过文件),`loadEnvFile({ override: true })` 才反过来。
36
36
 
37
- > **版本**:`0.8.1`(`@migor/agentia` 与 `@migor/cli` 均已发布到 npm)。`examples/` **刻意**用
37
+ > **版本**:`0.8.3`(`@migor/agentia` 与 `@migor/cli` 均已发布到 npm)。`examples/` **刻意**用
38
38
  > `file:../..` 指向本仓库而不是 npm 版本 —— 跑的是**工作区代码**,理由见
39
39
  > [`examples/README.md`](examples/README.md) 的「依赖说明」。
40
40
 
package/dist/AGENTS.md CHANGED
@@ -181,6 +181,7 @@ npx agentia --version # CLI 版本(= -v)
181
181
  | `tools` | 直接追加到主菜单的**裸工具**(`AgentTool[]`):给「构造期才知道有哪些工具」的场合(典型:MCP 桥,见 §6)。与能力**同过中间件、同进重名查重**,不是旁路 |
182
182
  | `middleware` | 能力调用中间件(洋葱链,链序 = 注册顺序) |
183
183
  | `sinks` | trace 出口,run 收尾投递 |
184
+ | `onTraceEvent` | **增量记账出口的缺省值**:run **进行中**逐笔回调(`span.begin` / `span.end` / `span.event` / `span.attribute` / `span.link`),给等不了收尾的消费者(面板 / SSE / 任务流)。与 `sinks` 是**两条缝**、可同时配;单次 run 给了自己的那个是**叠加**(应用级在前)而非覆盖。⚠️ **不保证送达**(宿主自己的流断了就断了),也不替代 `sinks` |
184
185
  | `maxTotalTokens` | 缺省成本硬管控:整条 run(**含子 agent / skill 子循环**,上限经 `ToolRunContext` 透传)累计 token 上限(可被单次 run 覆盖) |
185
186
  | `maxCostUsd` | 缺省成本硬管控:累计成本(美元)上限(**依赖模型在价格表内**,见 `priceOverrides`;未定价模型会留 `usage.unpriced` 事件,所以「护栏有没有真的生效」看得见) |
186
187
  | `priceOverrides` | 价格表覆盖/追加(`$/1M tokens`):覆盖内置同名项,或给非 Anthropic 模型定价(如 `{ 'deepseek-chat': { in: 0.27, out: 1.10 } }`)。**透传给子 agent/skill 的子循环** —— 不会「主 agent 有成本、子 agent 恒 0」。非法单价在 run 开始即抛错 |
@@ -188,6 +189,7 @@ npx agentia --version # CLI 版本(= -v)
188
189
  | `toolTimeoutMs` | 缺省单工具超时(毫秒);超时该条 tool_result 记 is_error,不杀 run |
189
190
  | `maxToolConcurrency` | 缺省同回合并行工具上限;不设 = 不限(全并行) |
190
191
  | `maxEventChars` | 缺省 trace 事件正文截断上限(可被单次 run 覆盖):数字 = 入参/出参统一用该上限,`false` = **不截断**(完整正文进 trace,面板里能展开看全文);不设 = 框架缺省 |
192
+ | `traceLimits` | **记账的数量上限** `{ maxEvents? }`(可被单次 run 覆盖):整条 trace 的事件总数闸。超限即停止记账,并在交付时于 run 根写一笔 `trace.truncated{droppedEvents, limit}` —— 缺口位置可预测(尾巴)且**有计数**。与管**长度**的 `maxEventChars` 正交(一个管「多长」、一个管「多少」);不设 = 不限(全量记账是本框架的承诺)。坏值(NaN / 负数 / 小数)在 run 入口抛 `TypeError` |
191
193
 
192
194
  ### `app.run(messages, opts?: RunAppOptions)`
193
195
 
@@ -199,6 +201,8 @@ npx agentia --version # CLI 版本(= -v)
199
201
  | `maxIterations` | 单次覆盖 |
200
202
  | `client` | 注入 `ModelClient`(换 OpenAI 兼容端点等) |
201
203
  | `onText` | 文本增量回调(SSE/终端) |
204
+ | `onTraceEvent` | 单次 run 的**增量记账出口**(见 `createApp` 同名项):与**应用级那个叠加**(应用级在前),不是覆盖 |
205
+ | `traceLimits` | 单次 run 的记账数量上限(覆盖应用级缺省);见 `createApp` 同名项 |
202
206
  | `signal` | `AbortSignal`:中止则在飞请求被取消,run 以 `stopReason='aborted'` 收尾(算失败) |
203
207
  | `traceContext` | 入站链路上下文 `{ traceId, spanId? }`:触发本次 run 的上游 span 记成 run 根的一条 `links`(不改 `traceId == runId`)。HTTP 宿主认 `traceparent` 头,自动填 —— 见 §6「跨进程关联」 |
204
208
  | `blackboard` | 预置黑板种子(配 `Blackboard` 声明合并有键补全) |
@@ -461,8 +465,9 @@ npm run client # 另一个终端:把四个 RPC 跑一遍
461
465
  | 端点 | 请求 | 响应 |
462
466
  |---|---|---|
463
467
  | `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
- | `POST /tasks` | `{ input, idempotencyKey?, options? }` —— `input` 同 `RunInput`;`options` 是 `RunInvocationOptions` | 202 `TaskRecord`(`status: 'queued'`);同 `idempotencyKey` 未失败则去重,直接返回既有记录 |
468
+ | `POST /tasks` | `{ input, idempotencyKey?, options? }` —— `input` 同 `RunInput`;`options` 是 `RunInvocationOptions` | 202 `TaskRecord`(`status: 'queued'`);同 `idempotencyKey` 未失败则去重、直接返回既有记录(**同步 store** 当场判定;**异步 store** 下只保证**同进程内并发提交**不重复执行,跨进程与终态后重提仍是 at-least-once —— 见 §7「同键去重的能力边界」) |
465
469
  | `GET /tasks/:id` | — | 200 `TaskRecord`;不存在 → 404。**停机中仍可轮询**(否则拿不到在飞任务的结果) |
470
+ | `GET /tasks/:id/stream` | — | **任务进度流(SSE)**:先在 `id:` 里给流序号,逐帧下发 `trace.event`(body 即 `TraceRecordEvent`),终态发 `task.end` 并关闭。断线重连带 `Last-Event-ID`(或 `?from=<序号>`)即可续订 —— 只补该序号之后的事件。缓冲超限先发一帧 `stream.truncated{droppedBefore}`;别的进程在跑的任务发 `stream.unavailable` 后收口(**不假装实时**)。任务不存在 → 404;方法不对 → 405。⚠️ 它的读者是**旁观者**:背压/断开只收口这条流,**不中止任务** |
466
471
  | `POST /tasks/:id/approve` | `{ decisions: { <tool_use_id>: { approved, reason? } }, decidedBy? }` | 200 `TaskRecord`(HITL 审批:批准/拒绝挂起任务,见 §6.6「人工审批」);任务不存在 → 404;不在 `awaiting_approval` 状态 → 409;body 非法 → 400。**停机中仍可审批**(与 GET 轮询同理由) |
467
472
  | `GET /healthz` | — | 200 `HealthResponse`;**不鉴权**,停机中也回 200 |
468
473
  | `GET /metrics` | — | 200 Prometheus 文本(`text/plain; version=0.0.4`);**需在 `createHttpHandler` 里传 `metrics`**,**不鉴权**(与 `/healthz` 同档),停机中也回 |
@@ -538,8 +543,8 @@ process.on('SIGTERM', async () => {
538
543
  | `mapWithConcurrency` | 有界并发 map(结果保序);`maxToolConcurrency` 的底座,也可自用 |
539
544
 
540
545
  - **取消**:`app.run(messages, { signal })` 传 `AbortSignal` —— 框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发 `signal`),run 以 `stopReason='aborted'` 收尾(算失败)。`createHttpHandler` 已内置「客户端断开即中止」;`AsyncRunner.runTimeoutMs` 到点同样是**真中止**(构造期校验:必须 ≥ 0 的**有限**数 —— NaN/Infinity 会被 `setTimeout` 钳到 1ms,等于每个任务立即超时,故直接抛错;要「不限」传 0 或不设)。
541
- - **重试**:缺省自动重试可重试失败(429 / 5xx / 连接失败),指数退避 + 抖动。`retry: false` 关闭,或 `retry: { maxAttempts, baseDelayMs, maxDelayMs, jitter, onRetry }` 调参。**只在本次尝试尚未产出任何文本时重试**(已吐出的字无法撤回)。⚠️ 与底层 client 的**内置重试**叠加 —— **两条内置适配器口径一致**(`createAnthropicClient` / `createOpenAIClient` 都有 `maxRetries`,缺省 2,重试同一状态码集合 408/409/429/5xx)—— 建议二选一调(这里 `maxAttempts: 1`,或 `<适配器>({ maxRetries: 0 })`)。
542
- - **流式**:`POST /run` 带 `Accept: text/event-stream` → SSE 逐帧下发(`text.delta` / `run.end` / `error`);不带该头仍回一元 JSON。
546
+ - **重试**:缺省自动重试可重试失败(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 永不达到,两者都等于**无限重试**(且静默)。
547
+ - **流式**:`POST /run` 带 `Accept: text/event-stream` → SSE 逐帧下发(`text.delta` / `run.end` / `error`,外加一族 **`trace.event`** —— 增量记账事件,body 即 `TraceRecordEvent`,用它做实时面板;不认识这一族的老客户端行为零变化);不带该头仍回一元 JSON。
543
548
  - **工具超时 / 并发闸门**:`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
549
 
545
550
  ### 6.3 上下文预算与成本
@@ -628,12 +633,35 @@ trace 出去之后能干什么:指标、调用树面板、调优报告、生
628
633
  | `registerDefaultTraceSink` | 注册全局默认 sink(构造期快照合并) |
629
634
  | `TraceRecorder` | 内存 recorder(一次 run 一个);`addLink(spanId, { traceId, spanId? })` 记一条跨 trace 链路(见 §6「跨进程关联」) |
630
635
  | `parseTraceparent` | 解析 W3C `traceparent` 头 → `{ traceId, spanId? }`;**非法 / 缺头一律返回 `undefined`**(不抛、不打 400)—— 结果直接交给 `traceContext` 选项,见 §6「跨进程关联」 |
631
- | `createOtlpExporter` | OTLP/JSON 导出,零依赖;选项 `OtlpExporterOptions`:`endpoint` / `headers` / `serviceName` / `timeoutMs`(单次导出超时,缺省 10000,非正数 = 不限 —— 裸 fetch 无超时,collector 半开连接会让 run 收尾永久挂起;超时按导出失败处理,不击穿 run) |
636
+ | `createOtlpExporter` | OTLP/JSON 导出,零依赖;选项见下面「`OtlpExporterOptions`」表 |
637
+ | `TraceRecordEvent` | **增量记账事件**(`onTraceEvent` 的回调参数):`span.begin` / `span.end` / `span.event` / `span.attribute` / `span.link`,每条带单调 `seq`。载荷是**增量 + 此刻的拷贝**(`span.begin` 只给初始形状,属性/事件/链路各走自己的类型);按 `seq` 升序折回必须**逐字等于**收尾时的 trace |
632
638
  | `metricsSink` | 指标累加器(Prometheus 文本 / OTLP metrics),满足 `TraceSink` 即接入 —— 见 §6「指标」 |
633
639
  | `buildRunReport` | 从一条 trace 生成**调优报告**(能力/模型的耗时、token、成本、错误率排行)—— 见 §6「调优报告」 |
634
640
  | `Score` | 质量评分:`{ name; value; source?; comment? }` —— LLM-judge / 人工标注 / eval 结论挂到 trace 上;约定 `value` 为 0–1(布尔结论用 0/1),`source` 记评分来源(eval 名 / `'human'` / judge 模型 id) |
635
641
  | `attachScore` | `attachScore(trace, score)`:把评分挂到 run 根 span(一条 `score` 事件,body 即 `Score`)。评分通常来自 run **之外**(跑完才评),所以走事件而非 span 字段;trace 找不到根 span 时静默忽略(观测不击穿业务) |
636
642
 
643
+ #### `OtlpExporterOptions`(`createOtlpExporter` 的选项)
644
+
645
+ | 字段 | 说明 |
646
+ |---|---|
647
+ | `endpoint` | collector 基地址(如 `http://localhost:4318`);导出即 POST `${endpoint}/v1/traces` |
648
+ | `headers` | 追加的请求头(鉴权 / 租户标) |
649
+ | `serviceName` | OTLP resource 的 `service.name`,缺省 `agentia` |
650
+ | `timeoutMs` | 单次导出请求超时(毫秒,缺省 10000,非正数 = 不限)——裸 `fetch` 没有超时,collector 半开连接会让 run 收尾**永久挂起**;超时按导出失败处理 |
651
+ | `onExportError` | 导出失败回调:给了它,**所有**失败(非 2xx / 超时 / **HTTP 200 但 collector 报部分接收**)都交给它、不再向 `TraceSink` 调用方抛;不给则维持既有行为(抛出,由 `flushSinks` 吞掉 —— 观测失败不击穿业务)。存在理由:`TraceSink` 的失败缺省是**静默**的,「导出其实少了一半数据」这类消息得有人能收到 |
652
+
653
+ 导出器的两条线缆口径(都有守卫钉着,别按直觉改):
654
+
655
+ - **enum 一律整数编码**:`status.code` = `1`(ok)/ `2`(error)、`kind` = `1`(INTERNAL)。
656
+ OTLP 规范是**明文 MUST**,且专门区别于 protobuf 的通用 JSON 映射:*「Values of enum fields
657
+ MUST be encoded as integer values… only integer enum values are allowed in OTLP JSON Protobuf
658
+ Encoding; the enum name strings MUST NOT be used.」*(曾经发的是 `'STATUS_CODE_OK'` 这种字符串,
659
+ 本地假 collector 只做 `JSON.parse`,所以一直没被发现)。**代价取决于 collector 的宽容度**:
660
+ 照规范校验的会整批拒收(数据没落库、框架只见 HTTP 200),按通用 protobuf JSON 映射的能收下。
661
+ - **HTTP 200 不等于全部接收**:collector 可以回 `200 + partialSuccess`(部分接收 / 拒收若干)。
662
+ 导出器把「**真拒收**(键在场且值 > 0)**或**非空 `errorMessage`」判为失败;`{}` 与
663
+ `rejectedSpans: 0` 属于「全部接收」的另一种写法(有 collector 恒发),不算失败。
664
+
637
665
  **评分链路**:`attachScore` 写 run 根 `score` 事件 → OTLP 导出时译为 `gen_ai.evaluation.result`
638
666
  (`gen_ai.evaluation.name` / `.score.value`,`source` / `comment` 走自有 `agentia.score.*` 键)→
639
667
  `metricsSink` 聚合成 `agentia_score` 指标族(见 §6「指标」)。eval / 在线评估怎么用见 §6「evals」与「在线评估采样」。
@@ -674,6 +702,12 @@ runner.submit(msg.value, {
674
702
  });
675
703
  ```
676
704
 
705
+ 这条配方**有门禁真跑**:`tests/transport/queueConsumer.test.ts`(内存版 broker + 真
706
+ `AsyncRunner` + 真引擎)把三件事逐条跑出来 —— 同键重投**不重复执行**(断言的是副作用计数,
707
+ 不只是 taskId)、`traceparent` 随 `spec.options` 落库后**他进程 `resumePending` 续跑**那次 run
708
+ 仍带得上同一条 link、失败不 ack 必须 nack 重投(且**失败**的键允许新任务,否则重投永远拿不到
709
+ 第二次执行)。真接 broker 时仍然要自己接:门禁守的是**提交位移与幂等的时机**,不是协议实现。
710
+
677
711
  异步任务可以在 `POST /tasks` 的 body 里显式给 `options.traceContext`,它优先于 `traceparent` 头。
678
712
  换宿主这条缝不变:gRPC 宿主把 metadata 的 `traceparent` 翻进 `options.traceContext`,
679
713
  见 §6.2「gRPC 宿主」。
@@ -1232,6 +1266,11 @@ const callable = {
1232
1266
  | `strict` 只是透传 | 框架**不校验** schema 的合规性(是否 `additionalProperties:false` 等) |
1233
1267
  | schema 校验是**子集** | 只覆盖 `type/properties/required/additionalProperties/enum/items`;`format`/`minimum`/`oneOf` 一律放行 |
1234
1268
  | 历史畸形就放弃裁剪 | `trimToolPairs` 遇到非严格交替历史会整体放弃(宁可少裁,也不切出孤立 tool_use 让请求 400) |
1269
+ | 同键去重的能力边界 | `idempotencyKey` 的去重分三档:**同步 store** 下 `submit` 当场返回既有记录;**异步 store**(Redis / SQLite 等)下 `submit` 是同步门面、无法 await,去重靠**进程内认领表**,只覆盖「**同进程内并发提交**」(2026-09-21 修复前这里会两次都执行);**跨进程并发**与**终态之后重提同键**仍是 at-least-once —— store 的 idem 索引是 last-wins(重提即新任务,`redisStore` / `fsStore` / `sqliteStore` 头注释同口径)。要严格一次,请让副作用自身幂等(或在 store 层做唯一约束) |
1270
+ | 增量出口与 sink 是**两条缝** | `onTraceEvent` 是「运行期逐笔」,`sinks` 是「收尾拿整棵」——消费者与保证都不同:sink 的抛错被吞但仍会**投递**(有兜底语义),`onTraceEvent` **不保证送达**(宿主自己的流断了就断了,没有重试/重放)。要「一条都不能少」用 `sinks`;要「现在就看到」用 `onTraceEvent` |
1271
+ | 任务进度流的边界(内存 / 跨进程) | `GET /tasks/:id/stream` 的事件缓冲在**跑任务的进程内存**里:每任务最近 500 条(可用 `AsyncRunner` 的 `streamBufferEvents` 调),超限丢**最旧**并先发一帧 `stream.truncated`;终态流只留最近 16 条。**跨进程**(队列消费者在别的进程)时没有实时流,只能拿到一帧 `stream.unavailable` + 终态 —— 要跨进程实时请用 `onTraceEvent` 把事件转发到宿主自己的总线(Redis Streams / Kafka) |
1272
+ | `traceLimits` 与 `maxEventChars` 各管一头 | `maxEventChars` 管**单个事件正文多长**(既有),`traceLimits.maxEvents` 管**整条 trace 多少个事件**(本版)。两者正交、都「不设 = 不限」;上限触发时**丢弃量写在 run 根的 `trace.truncated`** 上(不静默)。⚠️ 实测 `maxEventChars: false` + 大出参会让 trace 放大 **13.7×**(`npm run bench:trace` 可复现)—— 先收长度再谈采样,收益顺序比反过来大 |
1273
+ | **采样是导出决策,不是记账决策** | 采样在 `sink` 外做(配方见 `docs/observability.md` 2.3):被采样掉的 trace 在框架内**仍然完整记账**,只是没发给下游。所以别拿「有采样」当「可以少记账」;也正因如此,出站 `traceparent` 的 flags 恒 `00`(记录/导出决策发生在收尾之后,运行期不可知——不替下游声明) |
1235
1274
  | 缺省内存 store 不淘汰 | 长跑宿主请设 `InMemoryTaskStore({ maxRecords })` 或换 `FileTaskStore` / `SqliteTaskStore` |
1236
1275
  | 能力引用两种粒度 | `tools` 写 **provider token** = 整片能力菜单;写 `'<token>/<能力名>'` = 只引单个能力(@Tool/@Skill/@SubAgent/@Prompt 都可点名,装配期校验,名字不存在即抛错并列出可用名单) |
1237
1276
  | 能力名有格式校验 | 装饰器能力名(`name` 或缺省的方法名)必须匹配 `^[A-Za-z0-9_-]{1,64}$`(与 MCP 桥同口径),非法名在 `createApp` **装配期即抛错** —— 含空格/点/中文的名字会让模型 API 400,宁可在启动期拦住 |
@@ -1259,6 +1298,7 @@ const callable = {
1259
1298
  | MCP 只做 tools | `sampling`(server 反向请求模型)/ `resources` / `prompts` 原语不做;出厂连接器同样只做 `tools/list` + `tools/call` |
1260
1299
  | MCP 的协议层错误框架看不见 | `isError: true` 只有连接器能看见 —— 它必须转成抛错,否则模型收到的是一条「成功」的结果(出厂连接器已代你处理) |
1261
1300
  | MCP 超时同样是「不等了」 | 桥的 `timeoutMs` 取消不了 server 侧执行(拿不到取消句柄);它只是**兜底** —— 引擎设了 `toolTimeoutMs` 时**不参与**判定(一次调用只有一个裁判;**显式 `toolTimeoutMs: 0` 也算设了** —— 那是引擎表态「不限」,桥不会再自作主张判 60s),两条路径**同判定、同账**(`errorKind='timeout'`) |
1301
+ | 已中止的 MCP 调用**不发请求** | 信号在**发送前**就已中止 ⇒ 立刻以 `AbortError` 收场,请求不出门(2026-09-21 前是「照样 write、Promise 永不 settle」:副作用真送达、调用方永久挂起)。发送**之后**才中止的,请求已在路上、取消不了 —— 那是「不等了」,与 `toolTimeoutMs` 同口径 |
1262
1302
  | MCP 连接器的超时只管装配期 | 连接器自带的 `timeoutMs` 只作用于**握手 + `tools/list`**(那两步**没有任何别的裁判** —— server 卡住会让 `createApp` 永久挂起);`callTool` 仍是引擎 / 桥那一个裁判 |
1263
1303
  | MCP 连接的 `close()` 保证子进程已终止 | stdio 先 `SIGTERM`、`MCP_CLOSE_GRACE_MS`(2000 ms)后 `SIGKILL`,然后**等真正的 `'exit'`** —— **返回即代表进程已被回收**(此前到点即返回,会留孤儿进程而调用方无从知晓);HTTP 尽力 `DELETE` 会话(server 不认也无所谓) |
1264
1304
  | StreamableHTTP 会话过期**自愈** | 带会话 id 收到 `404` = 会话已终止、**该请求未被 server 执行** ⇒ 丢会话 → 重新握手 → 把**这一次**重试一次(**只一次**,不再循环)。自愈本身是静默的 ⇒ 用 `onSessionExpired` 去计数 / 告警,否则它和「静默失效」在监控上看不出区别。`404` **之外**的失败仍按 `classifyError` 分流抛出,不重试 |
@@ -1266,7 +1306,7 @@ const callable = {
1266
1306
  | MCP 工具不能进 DI 容器 | 它没有 provider token,也不能被别的能力的 `tools` 引用(两种引用粒度都要先有 token) |
1267
1307
  | 指标分位是窗口内精确值 | `*_last{quantile=...}` 只反映最近 `windowSize`(缺省 1024)条样本;要跨实例聚合请用直方图(`*_bucket` / `_sum` / `_count`,累积语义) |
1268
1308
  | 指标是**进程内**累加 | 不做分布式聚合与持久化:多实例各算各的(直方图可相加),重启即清零。要长期保留请把 `render()` 抓走或用 `export:'otlp'` 推给采集端 |
1269
- | OTLP metrics 只推当前累计 | 按 `intervalMs` 周期导出**累积值**(CUMULATIVE),不做增量/背压;导出失败按 `onExportError` 处理(缺省吞掉,不重试、不阻塞 run) |
1309
+ | OTLP metrics 只推当前累计 | 按 `intervalMs` 周期导出**累积值**(CUMULATIVE),不做增量/背压;导出失败按 `onExportError` 处理(缺省吞掉,不重试、不阻塞 run)。`reset()` 语义是**开启新窗口**:计数清零**且**数据点起点前移(同一 `startTime` 下 counter 只能单调不减,只清零会让后端算出负增量或丢样本) |
1270
1310
  | `GET /metrics` 不鉴权 | 与 `/healthz` 同档(拉取端在集群内网)。要保护请放反代之后,或不传 `metrics` 选项自行在外层挂路由 |
1271
1311
  | 工具没有 token/成本指标 | 工具是**你的代码**、本身不消耗 token,所以只产出调用数/失败数/耗时;token 与成本只对 `skill`/`subagent`(有 `capability` span)与模型维度产出 |
1272
1312
  | `@Prompt` 没有能力指标 | 资产类能力不建 span、无独立耗时,故不出现在能力排行里(这是刻意的:硬凑一个假耗时会误导调优) |
@@ -0,0 +1,198 @@
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
+ * ⚠️ 本模块**不是公共 API**(不进 `src/index.ts`):它是内部口径的真源,不是给使用者的旋钮。
20
+ * 面向使用者的口径在 `docs/usage-guide.md` 的「边界」一节。
21
+ *
22
+ * ⚠️ 表里的 `zeroClause` 是**报错文案的一部分**:四处构造期校验(`runTimeoutMs` /
23
+ * `approvalTimeoutMs` / `traceLimits.maxEvents` / `maxRetries`)把它插进错误消息里
24
+ * (`…(0 = 不重试),收到 -1 —— …`)。所以「文案里怎么解释 0」与「代码里怎么实现 0」
25
+ * 是同一个值,改一处等于同时改另一处 —— 这正是先前缺的那条单源。
26
+ */
27
+ /**
28
+ * 一个旋钮在取 `0`(或「非正数」)时的**含义分类**。四选一,没有第五种:
29
+ *
30
+ * - `unlimited` —— **0 = 不限**。时间是「等多久 / 最多多久」的上限,0 表示去掉这个上限。
31
+ * 使用者的直觉通常是这个;本仓只有一部分旋钮真的是这个意思,这正是要对账的原因。
32
+ * - `disabled` —— **0 = 机制关掉 / 一次都不做**。数量是「做几次」,0 次就是不做。
33
+ * ⚠️ 与 `unlimited` 是**相反的**读法:`maxRetries: 0` 是「不重试」而不是「无限重试」;
34
+ * `maxEvents: 0` 是「一条都不记」而不是「不限条数」。
35
+ * - `immediate` —— **0 = 立即执行,不等**。等待预算为 0 ⇒ 这次等待压根不存在。
36
+ * 与 `disabled` 的区别:机制没被关掉,只是这一次立刻发生(`metricsSink` 的
37
+ * `intervalMs: 0` = 立即导出;`interruptibleSleep(0)` = 不睡)。
38
+ * - `invalid` —— **0 = 非法配置,构造期响亮失败**。0 在这里没有任何合理读法
39
+ * (`concurrency: 0` 没有 worker;`Scheduler.every(0)` 是空转),静默接受只会
40
+ * 把它变成另一件事(Node 会把 `setInterval(0)` 钳到 1ms ⇒ 每毫秒一轮的忙轮询)。
41
+ */
42
+ export type ZeroMeaning = 'unlimited' | 'disabled' | 'immediate' | 'invalid';
43
+ /** 坏值(非有限 / 负数 / 小数 / 类型不对)的处理方式 */
44
+ export type BadValuePolicy =
45
+ /** 构造期抛错 —— 使用者以为自己设了上限,静默接受就会变成别的行为 */
46
+ 'throws'
47
+ /** 一律视为「不限」(旧行为),不报错 */
48
+ | 'coerced-to-unlimited'
49
+ /** 无校验(该旋钮只做算术,坏值无处可藏) */
50
+ | 'none';
51
+ export interface LimitSemantic {
52
+ /** 稳定标识(用例按它建表;改名 = 用例红,故意的) */
53
+ readonly knob: string;
54
+ /** 实现位置(文件名:符号),改代码时的入口 */
55
+ readonly where: string;
56
+ readonly unit: 'ms' | 'count' | 'chars' | 'n/a';
57
+ readonly zero: ZeroMeaning;
58
+ /**
59
+ * 报错文案里那句「0 = 什么」(无报错的旋钮也写上 —— 文档与用例都读它)。
60
+ * 有构造期校验的旋钮由实现代码直接插进错误消息。
61
+ */
62
+ readonly zeroClause: string;
63
+ readonly badValue: BadValuePolicy;
64
+ /** 为什么是这个读法(一句话;踩过坑的把坑写上) */
65
+ readonly note: string;
66
+ }
67
+ /**
68
+ * 全部 limits 旋钮的 0 语义。**分组按读法**,好让人一眼看出哪几个是相反的。
69
+ */
70
+ export declare const LIMIT_SEMANTICS: readonly [{
71
+ readonly knob: 'AsyncRunner.runTimeoutMs';
72
+ readonly where: 'transport/async.ts';
73
+ readonly unit: 'ms';
74
+ readonly zero: 'unlimited';
75
+ readonly zeroClause: '0 = 不限';
76
+ readonly badValue: 'throws';
77
+ readonly note: 'NaN/Infinity 会被 setTimeout 钳到 1ms ⇒ 每个任务立即「超时」失败,且 NaN 会绕过 `< 0` 静默通过;所以要「不限」必须显式传 0。';
78
+ }, {
79
+ readonly knob: 'AsyncRunner.approvalTimeoutMs';
80
+ readonly where: 'transport/async.ts';
81
+ readonly unit: 'ms';
82
+ readonly zero: 'unlimited';
83
+ readonly zeroClause: '0 = 不限';
84
+ readonly badValue: 'throws';
85
+ readonly note: '同 runTimeoutMs:非有限数会让「已挂起多久」的比较静默失效或立即超时。';
86
+ }, {
87
+ readonly knob: 'AsyncRunner.drain.timeoutMs';
88
+ readonly where: 'transport/drain-gate.ts';
89
+ readonly unit: 'ms';
90
+ readonly zero: 'unlimited';
91
+ readonly zeroClause: '非正 = 一直等';
92
+ readonly badValue: 'none';
93
+ readonly note: '**历史事故现场**:`http.ts` 的优雅停机把剩余预算算成 `timeoutMs - elapsed` 再交给它,deadline 已过时算出 1 或 0 ⇒ 旧读法(`0` = 已到点)让它跨过 deadline 后**永不返回**。现在 `http.ts` 自己先判「已到点就认账」,不把 0 交给下游去猜。';
94
+ }, {
95
+ readonly knob: 'mapWithConcurrency.limit';
96
+ readonly where: 'engine/concurrency.ts';
97
+ readonly unit: 'count';
98
+ readonly zero: 'unlimited';
99
+ readonly zeroClause: '非正 = 不限';
100
+ readonly badValue: 'coerced-to-unlimited';
101
+ readonly note: '⚠️ 与 `maxToolConcurrency` 成对:这是**唯一**「数量」旋钮取 0 却读作「不限」的。另有一条反向的坑:`(0,1)` 区间的小数若交给 `Math.floor` 会压成 0 worker ⇒ 工具被静默丢弃,所以正数一律至少 1 个 worker(`maxToolConcurrency: cpus().length / 8` 在多核 < 8 的机器上正落在那个区间)。';
102
+ }, {
103
+ readonly knob: 'traceLimits.maxEvents';
104
+ readonly where: 'engine/tracer.ts';
105
+ readonly unit: 'count';
106
+ readonly zero: 'disabled';
107
+ readonly zeroClause: '0 = 一条都不记';
108
+ readonly badValue: 'throws';
109
+ readonly note: '⚠️ 与 `maxEventChars`(管「多长」)正交,一个管「多少」。0 是**有意义的值**(一条都不记,但计数照记)—— 不是「不限条数」。';
110
+ }, {
111
+ readonly knob: 'maxRetries';
112
+ readonly where: 'integrations/adapter-options.ts';
113
+ readonly unit: 'count';
114
+ readonly zero: 'disabled';
115
+ readonly zeroClause: '0 = 不重试';
116
+ readonly badValue: 'throws';
117
+ readonly note: '坏值必须响亮失败:判定是 `attempt >= maxRetries` ⇒ `NaN` 比较恒假、`Infinity` 永远达不到,两者都是**无限重试**(429 场景下每一次都是真金白银);`-1` 静默变成「不重试」;`1.5` 实际只允许 1 次。';
118
+ }, {
119
+ readonly knob: 'maxEventChars';
120
+ readonly where: 'engine/tool-events.ts';
121
+ readonly unit: 'chars';
122
+ readonly zero: 'disabled';
123
+ readonly zeroClause: '0 = 截到零长度;false = 不截断';
124
+ readonly badValue: 'none';
125
+ readonly note: '两个「关掉」的写法语义不同:`false` 才是「不截断」(调试期看全文),`0` 是把正文截成空串。⚠️ 靠 `!= null` 判定透传(真值判定会吃掉 `false`)。';
126
+ }, {
127
+ readonly knob: 'runAgent.maxIterations';
128
+ readonly where: 'engine/loop.ts';
129
+ readonly unit: 'count';
130
+ readonly zero: 'disabled';
131
+ readonly zeroClause: '0 = 一次都不跑';
132
+ readonly badValue: 'none';
133
+ readonly note: '循环写成 `for (iteration = 0; iteration < maxIterations; ...)` ⇒ 0 时循环体一次不执行,直接以「达到循环上限」收尾(**算失败**)。是「已到点」而非「不限」,与 `mapToolConcurrency` 的读法**相反**。';
134
+ }, {
135
+ readonly knob: 'withTimeout.ms';
136
+ readonly where: 'core/timeout.ts';
137
+ readonly unit: 'ms';
138
+ readonly zero: 'disabled';
139
+ readonly zeroClause: '非正 = 不设超时(原样透传)';
140
+ readonly badValue: 'none';
141
+ readonly note: '`toolTimeoutMs` 的底座。⚠️ 漏透传这个值不是「少一层保险」而是**反的**:`withTimeout(p, 0)` 直接返回原 promise = 永不超时,同时 MCP 桥找不到引擎预算又起自己的 60s 兜底 ⇒ 双计时器、双账本(`toolkit/subagent.ts` / `skill.ts` 的透传注释指的就是这条)。';
142
+ }, {
143
+ readonly knob: 'interruptibleSleep.ms';
144
+ readonly where: 'core/timeout.ts';
145
+ readonly unit: 'ms';
146
+ readonly zero: 'immediate';
147
+ readonly zeroClause: '非正 = 不睡';
148
+ readonly badValue: 'none';
149
+ readonly note: '⚠️ 这一判**先于** aborted 检查 —— 别把它「修」成「已中止就该 reject」。2026-09-19 外部复核把它当一致性缺口改反过一次,被 `tests/core/timeout.test.ts` + `tests/core/sse-text-stats.test.ts` 拦住。';
150
+ }, {
151
+ readonly knob: 'metricsSink.intervalMs';
152
+ readonly where: 'integrations/metrics.ts';
153
+ readonly unit: 'ms';
154
+ readonly zero: 'immediate';
155
+ readonly zeroClause: '0 = 立即导出';
156
+ readonly badValue: 'none';
157
+ readonly note: '⚠️ **同名反义**:`Scheduler.every` 的 `intervalMs: 0` 是**配置错误**(抛错),这里却是「关掉定时器、每次累加后立即导出」。两个同名旋钮两种读法 —— 改任何一个之前先看这张表。';
158
+ }, {
159
+ readonly knob: 'AsyncRunner.concurrency';
160
+ readonly where: 'transport/async.ts';
161
+ readonly unit: 'count';
162
+ readonly zero: 'invalid';
163
+ readonly zeroClause: '必须为正数(0 = 没有 worker)';
164
+ readonly badValue: 'throws';
165
+ readonly note: '0 个 worker 的池子会让每个任务永远排在队列里(既不跑也不失败)。';
166
+ }, {
167
+ readonly knob: 'Scheduler.every.intervalMs';
168
+ readonly where: 'transport/scheduler.ts';
169
+ readonly unit: 'ms';
170
+ readonly zero: 'invalid';
171
+ readonly zeroClause: '必须为正有限数(0 会空转)';
172
+ readonly badValue: 'throws';
173
+ readonly note: '`setInterval(0)` 退化成「尽快重复」的空转循环(Node 钳到 1ms,仍是每毫秒一轮忙轮询);超过 2^31-1ms 会被**静默**钳到 1ms,同款失败。';
174
+ }, {
175
+ readonly knob: 'createAnthropicClient.timeout';
176
+ readonly where: 'integrations/anthropic.ts';
177
+ readonly unit: 'ms';
178
+ readonly zero: 'invalid';
179
+ readonly zeroClause: '不设 = 不限;0 = 非法';
180
+ readonly badValue: 'throws';
181
+ readonly note: '「不限」的表达方式是**不传**,不是传 0 —— 与 `runTimeoutMs` 的读法**相反**(那个要求显式传 0)。';
182
+ }, {
183
+ readonly knob: 'metricsSink.windowSize';
184
+ readonly where: 'integrations/metrics.ts';
185
+ readonly unit: 'count';
186
+ readonly zero: 'invalid';
187
+ readonly zeroClause: '必须为正数(0 = 窗口里什么都没有)';
188
+ readonly badValue: 'throws';
189
+ readonly note: '同款的还有 `maxCapabilities` / `maxModels` / `maxScores` —— 都是「必须为正数」。';
190
+ }];
191
+ /** 表里全部旋钮的标识(用例按它建穷尽表:少一项 `typecheck:tests` 就红) */
192
+ export type LimitKnob = (typeof LIMIT_SEMANTICS)[number]['knob'];
193
+ /** 按旋钮取那行(拼错旋钮名 = 编译期报错,不是运行期 undefined) */
194
+ export declare function limitSemanticOf(knob: LimitKnob): LimitSemantic;
195
+ /**
196
+ * 报错文案里那句「(0 = …)」。**构造期校验直接插它**,所以文案与表不可能各说各话。
197
+ */
198
+ export declare function zeroClauseOf(knob: LimitKnob): string;