@migor/agentia 0.9.4 → 0.10.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 (211) hide show
  1. package/CHANGELOG.md +1280 -4
  2. package/README.en.md +61 -3
  3. package/README.md +66 -4
  4. package/dist/AGENTS.md +330 -45
  5. package/dist/container/container.js +21 -0
  6. package/dist/container/container.js.map +1 -1
  7. package/dist/core/limits.d.ts +101 -14
  8. package/dist/core/limits.js +103 -13
  9. package/dist/core/limits.js.map +1 -1
  10. package/dist/core/line-framing.d.ts +36 -0
  11. package/dist/core/line-framing.js +39 -0
  12. package/dist/core/line-framing.js.map +1 -0
  13. package/dist/core/message.d.ts +11 -0
  14. package/dist/core/run.d.ts +68 -4
  15. package/dist/core/run.js +27 -1
  16. package/dist/core/run.js.map +1 -1
  17. package/dist/core/schema.js +59 -8
  18. package/dist/core/schema.js.map +1 -1
  19. package/dist/core/timeout.d.ts +43 -1
  20. package/dist/core/timeout.js +76 -3
  21. package/dist/core/timeout.js.map +1 -1
  22. package/dist/core/tool.d.ts +25 -1
  23. package/dist/core/trace.d.ts +40 -1
  24. package/dist/core/trace.js +26 -0
  25. package/dist/core/trace.js.map +1 -1
  26. package/dist/engine/budget.js +14 -0
  27. package/dist/engine/budget.js.map +1 -1
  28. package/dist/engine/defer.d.ts +49 -0
  29. package/dist/engine/defer.js +72 -0
  30. package/dist/engine/defer.js.map +1 -0
  31. package/dist/engine/errors.d.ts +23 -1
  32. package/dist/engine/errors.js +24 -0
  33. package/dist/engine/errors.js.map +1 -1
  34. package/dist/engine/forwarded.d.ts +14 -5
  35. package/dist/engine/forwarded.js +4 -1
  36. package/dist/engine/forwarded.js.map +1 -1
  37. package/dist/engine/loop-result.d.ts +65 -7
  38. package/dist/engine/loop-result.js +24 -5
  39. package/dist/engine/loop-result.js.map +1 -1
  40. package/dist/engine/loop.d.ts +2 -0
  41. package/dist/engine/loop.js +157 -17
  42. package/dist/engine/loop.js.map +1 -1
  43. package/dist/engine/mcp-server-http.d.ts +2 -0
  44. package/dist/engine/mcp-server-http.js +215 -0
  45. package/dist/engine/mcp-server-http.js.map +1 -0
  46. package/dist/engine/mcp-server-stdio.d.ts +15 -0
  47. package/dist/engine/mcp-server-stdio.js +106 -0
  48. package/dist/engine/mcp-server-stdio.js.map +1 -0
  49. package/dist/engine/mcp-server.d.ts +134 -0
  50. package/dist/engine/mcp-server.js +232 -0
  51. package/dist/engine/mcp-server.js.map +1 -0
  52. package/dist/engine/menu-drift.d.ts +34 -0
  53. package/dist/engine/menu-drift.js +81 -0
  54. package/dist/engine/menu-drift.js.map +1 -0
  55. package/dist/engine/policy.d.ts +2 -1
  56. package/dist/engine/policy.js +7 -1
  57. package/dist/engine/policy.js.map +1 -1
  58. package/dist/engine/replay.d.ts +2 -1
  59. package/dist/engine/replay.js +3 -2
  60. package/dist/engine/replay.js.map +1 -1
  61. package/dist/engine/resume-input.d.ts +10 -0
  62. package/dist/engine/resume-input.js +11 -0
  63. package/dist/engine/resume-input.js.map +1 -1
  64. package/dist/engine/run-config.d.ts +26 -2
  65. package/dist/engine/run-config.js +60 -0
  66. package/dist/engine/run-config.js.map +1 -1
  67. package/dist/engine/spec.d.ts +33 -4
  68. package/dist/engine/spec.js.map +1 -1
  69. package/dist/engine/tool-context.d.ts +10 -5
  70. package/dist/engine/tool-context.js +5 -1
  71. package/dist/engine/tool-context.js.map +1 -1
  72. package/dist/engine/tracer.d.ts +43 -5
  73. package/dist/engine/tracer.js +73 -10
  74. package/dist/engine/tracer.js.map +1 -1
  75. package/dist/engine/trimming.js +28 -2
  76. package/dist/engine/trimming.js.map +1 -1
  77. package/dist/engine/turn.d.ts +60 -3
  78. package/dist/engine/turn.js +180 -71
  79. package/dist/engine/turn.js.map +1 -1
  80. package/dist/engine/types.d.ts +89 -3
  81. package/dist/engine/types.js +38 -2
  82. package/dist/engine/types.js.map +1 -1
  83. package/dist/eval/export.d.ts +48 -0
  84. package/dist/eval/export.js +146 -0
  85. package/dist/eval/export.js.map +1 -0
  86. package/dist/eval/harvest.js +7 -2
  87. package/dist/eval/harvest.js.map +1 -1
  88. package/dist/index.d.ts +8 -5
  89. package/dist/index.js +6 -1
  90. package/dist/index.js.map +1 -1
  91. package/dist/integrations/adapter-options.d.ts +22 -0
  92. package/dist/integrations/adapter-options.js +51 -0
  93. package/dist/integrations/adapter-options.js.map +1 -1
  94. package/dist/integrations/anthropic.d.ts +4 -2
  95. package/dist/integrations/anthropic.js +27 -27
  96. package/dist/integrations/anthropic.js.map +1 -1
  97. package/dist/integrations/file-sink.d.ts +20 -0
  98. package/dist/integrations/file-sink.js +27 -0
  99. package/dist/integrations/file-sink.js.map +1 -0
  100. package/dist/integrations/mcp-http.d.ts +3 -3
  101. package/dist/integrations/mcp-http.js +1 -1
  102. package/dist/integrations/mcp-http.js.map +1 -1
  103. package/dist/integrations/mcp-protocol.d.ts +115 -0
  104. package/dist/integrations/mcp-protocol.js +69 -0
  105. package/dist/integrations/mcp-protocol.js.map +1 -0
  106. package/dist/integrations/mcp-stdio.d.ts +3 -3
  107. package/dist/integrations/mcp-stdio.js +17 -3
  108. package/dist/integrations/mcp-stdio.js.map +1 -1
  109. package/dist/integrations/mcp.d.ts +6 -95
  110. package/dist/integrations/mcp.js +24 -66
  111. package/dist/integrations/mcp.js.map +1 -1
  112. package/dist/integrations/metrics-otlp.js +52 -7
  113. package/dist/integrations/metrics-otlp.js.map +1 -1
  114. package/dist/integrations/metrics-render.d.ts +15 -0
  115. package/dist/integrations/metrics-render.js +79 -2
  116. package/dist/integrations/metrics-render.js.map +1 -1
  117. package/dist/integrations/metrics-state.d.ts +99 -1
  118. package/dist/integrations/metrics-state.js +128 -4
  119. package/dist/integrations/metrics-state.js.map +1 -1
  120. package/dist/integrations/metrics.d.ts +62 -6
  121. package/dist/integrations/metrics.js +49 -8
  122. package/dist/integrations/metrics.js.map +1 -1
  123. package/dist/integrations/openai.js +9 -31
  124. package/dist/integrations/openai.js.map +1 -1
  125. package/dist/integrations/otlp.d.ts +3 -3
  126. package/dist/integrations/report.d.ts +7 -0
  127. package/dist/integrations/report.js +20 -1
  128. package/dist/integrations/report.js.map +1 -1
  129. package/dist/runtime/memory.d.ts +61 -1
  130. package/dist/runtime/memory.js +47 -7
  131. package/dist/runtime/memory.js.map +1 -1
  132. package/dist/runtime/run.d.ts +12 -2
  133. package/dist/runtime/run.js +126 -17
  134. package/dist/runtime/run.js.map +1 -1
  135. package/dist/store/fsStore.js +10 -4
  136. package/dist/store/fsStore.js.map +1 -1
  137. package/dist/store/record.d.ts +41 -0
  138. package/dist/store/record.js +54 -0
  139. package/dist/store/record.js.map +1 -0
  140. package/dist/store/redisStore.d.ts +24 -1
  141. package/dist/store/redisStore.js +42 -7
  142. package/dist/store/redisStore.js.map +1 -1
  143. package/dist/store/sqliteStore.d.ts +23 -0
  144. package/dist/store/sqliteStore.js +103 -8
  145. package/dist/store/sqliteStore.js.map +1 -1
  146. package/dist/store/store.d.ts +65 -2
  147. package/dist/store/store.js +5 -2
  148. package/dist/store/store.js.map +1 -1
  149. package/dist/toolkit/capability-cycles.d.ts +50 -0
  150. package/dist/toolkit/capability-cycles.js +88 -0
  151. package/dist/toolkit/capability-cycles.js.map +1 -0
  152. package/dist/toolkit/capability-slice.d.ts +86 -0
  153. package/dist/toolkit/capability-slice.js +98 -0
  154. package/dist/toolkit/capability-slice.js.map +1 -0
  155. package/dist/toolkit/module.d.ts +7 -1
  156. package/dist/toolkit/module.js +65 -32
  157. package/dist/toolkit/module.js.map +1 -1
  158. package/dist/transport/approval-policy.d.ts +1 -1
  159. package/dist/transport/approval-policy.js +8 -4
  160. package/dist/transport/approval-policy.js.map +1 -1
  161. package/dist/transport/approval-supervisor.d.ts +73 -0
  162. package/dist/transport/approval-supervisor.js +252 -0
  163. package/dist/transport/approval-supervisor.js.map +1 -0
  164. package/dist/transport/async.d.ts +159 -56
  165. package/dist/transport/async.js +627 -269
  166. package/dist/transport/async.js.map +1 -1
  167. package/dist/transport/drain-gate.d.ts +6 -2
  168. package/dist/transport/drain-gate.js +14 -2
  169. package/dist/transport/drain-gate.js.map +1 -1
  170. package/dist/transport/http-endpoints.d.ts +151 -0
  171. package/dist/transport/http-endpoints.js +480 -0
  172. package/dist/transport/http-endpoints.js.map +1 -0
  173. package/dist/transport/http-io.d.ts +66 -0
  174. package/dist/transport/http-io.js +145 -0
  175. package/dist/transport/http-io.js.map +1 -0
  176. package/dist/transport/http-route.d.ts +10 -3
  177. package/dist/transport/http-route.js +18 -3
  178. package/dist/transport/http-route.js.map +1 -1
  179. package/dist/transport/http-shapes.d.ts +11 -0
  180. package/dist/transport/http-shapes.js +31 -1
  181. package/dist/transport/http-shapes.js.map +1 -1
  182. package/dist/transport/http.d.ts +22 -35
  183. package/dist/transport/http.js +98 -476
  184. package/dist/transport/http.js.map +1 -1
  185. package/dist/transport/owner-id.d.ts +29 -0
  186. package/dist/transport/owner-id.js +46 -0
  187. package/dist/transport/owner-id.js.map +1 -0
  188. package/dist/transport/owner-liveness.d.ts +8 -0
  189. package/dist/transport/owner-liveness.js +56 -0
  190. package/dist/transport/owner-liveness.js.map +1 -0
  191. package/dist/transport/resume-policy.d.ts +48 -10
  192. package/dist/transport/resume-policy.js +25 -10
  193. package/dist/transport/resume-policy.js.map +1 -1
  194. package/dist/transport/resume-scanner.d.ts +55 -0
  195. package/dist/transport/resume-scanner.js +219 -0
  196. package/dist/transport/resume-scanner.js.map +1 -0
  197. package/dist/transport/scheduler.js +9 -23
  198. package/dist/transport/scheduler.js.map +1 -1
  199. package/dist/transport/signal-supervisor.d.ts +80 -0
  200. package/dist/transport/signal-supervisor.js +129 -0
  201. package/dist/transport/signal-supervisor.js.map +1 -0
  202. package/dist/transport/slot-pool.d.ts +9 -1
  203. package/dist/transport/slot-pool.js +9 -1
  204. package/dist/transport/slot-pool.js.map +1 -1
  205. package/dist/transport/task-events.d.ts +34 -3
  206. package/dist/transport/task-events.js +93 -5
  207. package/dist/transport/task-events.js.map +1 -1
  208. package/dist/transport/wake-policy.d.ts +50 -0
  209. package/dist/transport/wake-policy.js +43 -0
  210. package/dist/transport/wake-policy.js.map +1 -0
  211. package/package.json +14 -4
package/dist/AGENTS.md CHANGED
@@ -243,7 +243,7 @@ token 的作用是挡住**本机其它进程**,别把它当网络边界:这
243
243
  | `description` | 同 `@Tool` |
244
244
  | `schema` | 主 agent 填给子 agent 的任务入参 schema |
245
245
  | `name` | 缺省取方法名 |
246
- | `system` | 子 agent 的角色提示:`string` / `SystemPrompt` / `(task) => SystemParam` |
246
+ | `system` | 子 agent 的角色提示:`string` / `SystemPrompt` / `(task) => SystemParam`。⚠️ 前两形态会**自动追加**一句运行提示(「你运行在独立上下文,全部中间过程不外传;你的最终回复将作为报告原样交回主 agent」),**函数形态不会** —— 要这句提示请在函数里自己拼 |
247
247
  | `tools` | 子 agent 可调工具:**provider token 列表**,或 `'<token>/<能力名>'` 能力级路径 |
248
248
  | `model` | 子 agent 自己的模型 |
249
249
  | `maxTokens` | 同上 |
@@ -277,6 +277,9 @@ token 的作用是挡住**本机其它进程**,别把它当网络边界:这
277
277
  | `modules` | 能力包(`defineModule({ providers, middleware })`),模块级先注册、应用级可覆盖同 token |
278
278
  | `discover` | 能力目录路径:**一个目录或一组目录**(数组顺序即装配顺序,典型是四分类目录)。给出后 `createApp` 返回 `Promise<AgentApp>`;数组里任一目录不存在会**报错**(显式给出的搜索路径不该静默落空) |
279
279
  | `model` | 缺省模型;不给则 `AGENTIA_MODEL` env,再回落 `claude-opus-5` |
280
+ | `fallbacks` | 缺省模型 fallback 链(可被单次 run 覆盖):`[{ model, client? }]`,主模型本回合最终失败且错误可换(rate_limit / server / timeout / connection)时按序换环;每环独立 llm.turn span(成本归对模型),切换记 `llm.fallback` 事件。护栏:aborted 永不换、吐过字不换、每回合从主环重新起;子 agent / skill 子循环不继承。⚠️ 链环的 `client` 是进程内对象:持久化异步任务崩溃续跑后读回的是空壳(run 入口响亮抛 TypeError),跨重启仍成立的链只写 `model` |
281
+ | `traceContent` | `'full'` = 每回合的 assistant 文本落 llm.turn span 的 `output.text` 属性(过 `maxEventChars` 同一道截断闸 —— 它管「多长」,这个管「记不记」);缺省不记。⚠️ 体积显著增大,且模型输出从此进入要脱敏的面(出库前走 `docs/observability.md` 配方 2.4)。可被单次 run 覆盖 |
282
+ | `labels` | 缺省归因标签(`Record<string, string>`,可被单次 run 覆盖 —— 注意是**整体替换**不是合并):落 run 根的 `labels.<key>` 属性(trace 侧无基数问题,随便加;与框架自写的 `source` 触发来源审计正交)。键值都必须是字符串(键非空),否则 run 入口抛 `TypeError`;可序列化,异步任务随 TaskRecord 落库、续跑不丢。⚠️ 进 metrics 是**另一个开关**:`metricsSink({ labelKeys })` 显式点名哪些键上指标标签(缺省一个都不上,防 Prometheus 基数爆炸) |
280
283
  | `maxTokens` | 缺省 `max_tokens` |
281
284
  | `maxIterations` | 缺省循环上限 |
282
285
  | `retry` | 缺省模型请求重试策略(可被单次 run 覆盖):缺省**开启**(`DEFAULT_RETRY`:maxAttempts=3、指数退避 + 抖动);`false` 关闭 |
@@ -293,7 +296,7 @@ token 的作用是挡住**本机其它进程**,别把它当网络边界:这
293
296
  | `toolTimeoutMs` | 缺省单工具超时(毫秒);超时该条 tool_result 记 is_error,不杀 run |
294
297
  | `maxToolConcurrency` | 缺省同回合并行工具上限;不设 = 不限(全并行) |
295
298
  | `maxEventChars` | 缺省 trace 事件正文截断上限(可被单次 run 覆盖):数字 = 入参/出参统一用该上限,`false` = **不截断**(完整正文进 trace,面板里能展开看全文);不设 = 框架缺省 |
296
- | `traceLimits` | **记账的数量上限** `{ maxEvents? }`(可被单次 run 覆盖):整条 trace 的事件总数闸。超限即停止记账,并在交付时于 run 根写一笔 `trace.truncated{droppedEvents, limit}` —— 缺口位置可预测(尾巴)且**有计数**。与管**长度**的 `maxEventChars` 正交(一个管「多长」、一个管「多少」);不设 = 不限(全量记账是本框架的承诺)。坏值(NaN / 负数 / 小数)在 run 入口抛 `TypeError` |
299
+ | `traceLimits` | **记账的数量上限** `{ maxEvents? }`(可被单次 run 覆盖):整条 trace 的事件总数闸。超限即停止记账,并在 run 根留**两笔** `trace.truncated` —— **起点标记** `{limit}`(断点在这里)+ **收尾摘要** `{droppedEvents, limit}`(丢了多少),两笔**同时**进交付的 trace 与 `onTraceEvent` 增量流。缺口位置可预测(尾巴)且**有计数**。与管**长度**的 `maxEventChars` 正交(一个管「多长」、一个管「多少」);不设 = 不限(全量记账是本框架的承诺)。坏值(NaN / 负数 / 小数)在 run 入口抛 `TypeError` |
297
300
 
298
301
  ### `app.run(messages, opts?: RunAppOptions)`
299
302
 
@@ -301,6 +304,9 @@ token 的作用是挡住**本机其它进程**,别把它当网络边界:这
301
304
  |---|---|
302
305
  | `system` | 单次覆盖 system(volatile 段建议每 run 重建) |
303
306
  | `model` | 单次覆盖模型 |
307
+ | `fallbacks` | 单次覆盖模型 fallback 链(`[{ model, client? }]`);语义同 `createApp` 的 `fallbacks` |
308
+ | `traceContent` | 单次覆盖 assistant 文本记录开关(`'full'`);语义同 `createApp` 的 `traceContent` |
309
+ | `labels` | 单次覆盖归因标签(**整体替换**缺省,不是合并);语义同 `createApp` 的 `labels` |
304
310
  | `maxTokens` | 单次覆盖 |
305
311
  | `maxIterations` | 单次覆盖 |
306
312
  | `client` | 注入 `ModelClient`(换 OpenAI 兼容端点等) |
@@ -324,12 +330,14 @@ token 的作用是挡住**本机其它进程**,别把它当网络边界:这
324
330
  | `maxToolConcurrency` | 同回合并行工具上限;缺省不限 |
325
331
  | `maxEventChars` | trace 事件正文截断上限(字符):数字 = 入参/出参统一用该上限,`false` = **不截断**;缺省按类型收敛(入参/成功出参 2000、失败出参 1000)。**透传给子 agent/skill 的子循环** —— 同一棵调用树上口径一致。只影响**记账**,回给模型的 tool_result 永远完整 |
326
332
  | `session` | 会话持久化 `{ store, id }`:run 前拼历史、成功收尾追加本轮(见 `SessionStore`) |
333
+ | `sessionId` | 会话**引用**(可序列化):单次覆盖后**记进 run 根 `session.id`**(引擎侧读它,见 `loop.ts` 的属性写入)。⚠️ 直接 `app.run` 时它**不注入历史** —— 注入要上一行的 `session: { store, id }`;「`sessionId` + runner 的 `sessionStore`」是**异步宿主**那条路(任务里只落 id,store 实例不可序列化) |
327
334
  | `memory` | 跨 run 记忆 `{ store, keys }`:run 前水合进 blackboard(用户种子优先)、收尾写回;与 `session` 正交(见 `MemoryStore`) |
328
335
  | `beforeFlush` | `(trace, result) => void \| Promise<void>`:**sinks 冲刷之前**的最后一笔(run 正常收尾后调一次,抛错被吞)。给「**拿到结果才判得出**的结论」用的缝 —— 典型是 `defineEval` 的 score:等 `app.run` 返回再 `attachScore`,`metricsSink` 早在导出那一刻聚完账,分数就永远进不了指标。读 trace 就够的判断不必用它,写进 sinks 里即可(见 §6 判官配方) |
329
- | `approvals` | HITL 审批决定(`Record<tool_use_id, ApprovalDecision>`):恢复 `awaiting_approval` 的 run 时传入(异步宿主会自动带,见 §6.6「人工审批」);手工续跑「assistant 结尾带 tool_use」的消息历史时也可直接给 |
336
+ | `approvals` | HITL 审批决定(`Record<tool_use_id, ApprovalDecision>`):恢复 `suspended` 的 run 时传入(异步宿主会自动带,见 §6.6「人工审批」);手工续跑「assistant 结尾带 tool_use」的消息历史时也可直接给 |
337
+ | `events` | run 事件(`TaskEvent[]`,`{ eventId?, type, payload }` 全是字符串):挂起期间投递、随 `TaskRecord` 落库的事件,续跑段由引擎在**未决 tool_use 解决之后**渲染成 user 文本消息注入消息流(异步宿主经 `signalTask` 自动带,见 §6.6「事件投递」);手工续跑时也可直接给 |
330
338
 
331
339
  返回 `AgentRunOutput`:`{ run, result }`。`result` 含 `trace` / `stopReason` / `finalText` / `iterations` / `error` / `typed`;
332
- `stopReason === 'awaiting_approval'`(HITL 挂起)时另有 `suspendedMessages`(完整消息历史,末尾是含未决 tool_use 的 assistant 消息)与 `pendingApprovals`(待决 tool_use_id 列表),未挂起时两者为 `undefined`。
340
+ `stopReason === 'suspended'`(挂起:等人工审批 / 等一个时刻)时另有 `suspendedMessages`(完整消息历史,末尾是含未决 tool_use 的 assistant 消息)、`pendingApprovals`(待决 tool_use_id 列表)与 `suspendedReason`(`'approval'` / `'timer'`),未挂起时三者皆为 `undefined`。
333
341
 
334
342
  ---
335
343
 
@@ -356,6 +364,18 @@ ctx.get('profil'); // ✗ 编译期报错(键不存在)
356
364
 
357
365
  动态键(运行期算出来的 `string`)拿不到字面量联合,按文档断言:`ctx.get(key as BlackboardKey)`。
358
366
 
367
+ #### 嵌套能力**共用同一块黑板**(不隔离,有意为之)
368
+
369
+ `@SubAgent` / `@Skill` 的执行体跑在**同一个** `RunContext` 上:单例 ALS 只在 run 创建时进一次
370
+ (`runtime/context.ts` 的 `withRunContext` 全仓只有 `src/runtime/run.ts` 那一个调用点),子循环不会再进一层。
371
+
372
+ - **所以**:子 agent / 技能里 `RunContext.current()?.get('k')` 读到的就是**父 run 的那块黑板**
373
+ (同一份 `Map`),子 agent 写的键父 run 也看得见 —— 这是**有意**的,「把你的发现留在黑板上」
374
+ 正是子 agent 的用法(也是 `@Skill` 第二参能拿到 ctx 的原因)。
375
+ - **代价是「没有隔离」**:并发跑的两个子 agent 若写同一个键,是**后者覆盖前者**(last-wins),
376
+ 与跨 run 记忆 `flushMemory` 的取舍同款。要隔离就**自己命名空间**(`agentA.state` /
377
+ `agentB.state`),框架不替你拆键、也不做深拷贝。(「要不要按子树隔离」是开放项,见 `spec.md` §11。)
378
+
359
379
  ### 5.2 schema 即单一事实来源:`fromZod<T>`
360
380
 
361
381
  ```ts
@@ -486,7 +506,7 @@ const app = await createApp({ ... });
486
506
  | API | 说明 |
487
507
  |---|---|
488
508
  | `createHttpHandler` | `(req,res)` handler:`POST /run` 同步(带 `Accept: text/event-stream` 则 SSE 流式)、`POST /tasks` 异步、`GET /tasks/:id`、`GET /healthz`;返回值另带 `drain()` 与 `runner` |
489
- | `AsyncRunner` | 异步任务宿主(`submit` / `poll` / `awaitTask` / `approve` / `resumePending` / `drain`);`approve(taskId, decisions, { decidedBy? })` 审批挂起任务(HITL,见 §6.6「人工审批」) |
509
+ | `AsyncRunner` | 异步任务宿主(`submit` / `poll` / `awaitTask` / `approve` / `signalTask` / `cancel` / `resumePending` / `drain`);`approve(taskId, decisions, { decidedBy? })` 审批挂起任务(HITL,见 §6.6「人工审批」);`signalTask(taskId, event)` 投事件给挂起任务(见 §6.6「事件投递」) |
490
510
  | `TaskSink` | 任务完成回调 `{ onFinished(rec) }`,配 `AsyncRunner({ taskSinks })`;抛错被吞 |
491
511
  | `HttpException` | 鉴权钩子抛出以自定 HTTP 状态与响应体(抛别的错误一律按 401 处理) |
492
512
  | `Scheduler` | 定时触发(`every` / `at`) |
@@ -496,6 +516,14 @@ const app = await createApp({ ... });
496
516
  | `SqliteTaskStore` | `node:sqlite` 耐久存储(WAL + busy_timeout) |
497
517
  | `RedisTaskStore` | duck-typed Redis 存储(可设 `ttlSeconds`);客户端结构面 `get` / `set` / `del` / `keys`(或 `scanIterator`),外加设 TTL 时必需的 `expire`。`set` **只传两参** —— 尾参的选项形状两家相反:ioredis 认位置参数 `('EX', n)`、node-redis 认对象 `{ EX: n }`,取任何一种都会在另一家上失效(ioredis 会把对象字符串化成 `"[object Object]"` 报语法错;**node-redis 的 `SET` 只声明三个形参,位置参数被静默丢弃**)。所以 TTL 一律走 `expire(key, seconds)`(两家同名同形);设了 `ttlSeconds > 0` 却没给 `expire` 时**构造期抛错**,不静默丢掉 TTL |
498
518
 
519
+ **`compact()` / `close()` 是接口上的可选能力,但由宿主按需调**:`FileTaskStore.compact()`
520
+ (压实 JSONL:一 task 一行、丢掉历史覆写行)与 `SqliteTaskStore.close()` 此前只活在具体类上 ——
521
+ 宿主拿到的若是 `TaskStore` 类型(DI / 工厂 / 配置驱动选 store 都是这形态),想调只能 `as` 强转,
522
+ 而强转**不报错**(换 store 后没有那个方法,要到运行期才炸)。现在它们在 `TaskStore` 上是**可选成员**,
523
+ 调用点写 `store.compact?.()` 即可(`tsc` 知道它可能有、也可能没有)。
524
+ ⚠️ 框架**刻意不替你调**:`drain()` 不关 store(同一个 store 可能被调度器或另一个宿主共用,
525
+ 关掉它是**宿主的**生命周期决定)。压实频率建议低频(如 cron 每小时一次)。
526
+
499
527
  #### gRPC 宿主(第 4 个宿主,**框架不内置**)
500
528
 
501
529
  gRPC 不是「另一种 broker」,它与 HTTP 是同一档的东西:**触发宿主**。所以正确形状与 HTTP 宿主
@@ -576,9 +604,11 @@ npm run client # 另一个终端:把四个 RPC 跑一遍
576
604
  | `POST /tasks` | `{ input, idempotencyKey?, options? }` —— `input` 同 `RunInput`;`options` 是 `RunInvocationOptions` | 202 `TaskRecord`(`status: 'queued'`);同 `idempotencyKey` 未失败则去重、直接返回既有记录(**同步 store** 当场判定;**异步 store** 下只保证**同进程内并发提交**不重复执行,跨进程与终态后重提仍是 at-least-once —— 见 §7「同键去重的能力边界」) |
577
605
  | `GET /tasks/:id` | — | 200 `TaskRecord`;不存在 → 404。**停机中仍可轮询**(否则拿不到在飞任务的结果) |
578
606
  | `GET /tasks/:id/stream` | — | **任务进度流(SSE)**:先在 `id:` 里给流序号,逐帧下发 `trace.event`(body 即 `TraceRecordEvent`),终态发 `task.end` 并关闭。断线重连带 `Last-Event-ID`(或 `?from=<序号>`)即可续订 —— 只补该序号之后的事件。缓冲超限先发一帧 `stream.truncated{droppedBefore}`;别的进程在跑的任务发 `stream.unavailable` 后收口(**不假装实时**)。任务不存在 → 404;方法不对 → 405。⚠️ 它的读者是**旁观者**:背压/断开只收口这条流,**不中止任务** |
579
- | `POST /tasks/:id/approve` | `{ decisions: { <tool_use_id>: { approved, reason? } }, decidedBy? }` | 200 `TaskRecord`(HITL 审批:批准/拒绝挂起任务,见 §6.6「人工审批」);任务不存在 → 404;不在 `awaiting_approval` 状态 → 409;body 非法 → 400。**停机中仍可审批**(与 GET 轮询同理由) |
607
+ | `POST /tasks/:id/approve` | `{ decisions: { <tool_use_id>: { approved, reason? } }, decidedBy? }` | 200 `TaskRecord`(HITL 审批:批准/拒绝挂起任务,见 §6.6「人工审批」);任务不存在 → 404;**挂起原因不是 `approval`**(含未挂起)→ 409;`decisions` 里有不在该任务 `pendingApprovals` 里的 id → **400(整批拒,记录一字不动)**;body 非法 → 400。**停机中仍可审批**(与 GET 轮询同理由) |
608
+ | `POST /tasks/:id/cancel` | 无 body | 200 `TaskRecord`(取消:在跑的**真中断**、在睡的**不再醒**、在排队的**绝不起跑**)。落库 `status: 'cancelled'` + `error.type: 'aborted'`(取消不是失败,但原因可查)。任务不存在 → 404;已终态 → 409;**在跑但本进程没有它的句柄、或宿主不认 `signal`**(宽限 2s 内没收尾)→ 409 —— 取消**未发生**就如实说,不假装。**停机中仍可用**(停机窗口正是最想取消在飞任务的时候) |
609
+ | `POST /tasks/:id/events` | `{ eventId?, type, payload }` —— **白名单**:全是字符串,多一个字段 → 400;`payload` 的字节上限就是 `maxBodyBytes` 那一道闸(超限 413) | 200 `TaskRecord`(事件投递:把一条事件投给**挂起**的任务,投完即续跑,事件渲染成一条 user 文本消息进消息历史 —— 见 §6.6「事件投递」)。任务不存在 → 404;**不在 `suspended` 状态**(含已终态)→ 409;同 `eventId` 重复投递 → 409(不给 `eventId` 则没有恰好一次:重复投递 = 重复进历史);**待注入事件已达上限(64 条)→ 409**(本次事件**没有被记录** —— 先让这条 run 跑起来)。**停机中仍可用**(与审批同理由:挂起的任务只有外部输入能推进) |
580
610
  | `GET /healthz` | — | 200 `HealthResponse`;**不鉴权**,停机中也回 200 |
581
- | `GET /metrics` | — | 200 Prometheus 文本(`text/plain; version=0.0.4`);**需在 `createHttpHandler` 里传 `metrics`**,**不鉴权**(与 `/healthz` 同档),停机中也回 |
611
+ | `GET /metrics` | — | 200 指标文本(Content-Type 跟 sink 的 `contentType` 走:缺省 `text/plain; version=0.0.4`,`export:'openmetrics'` 的 sink 发 `application/openmetrics-text`);**需在 `createHttpHandler` 里传 `metrics`**,**不鉴权**(与 `/healthz` 同档),停机中也回 |
582
612
 
583
613
  方法不符 → 405(带 `Allow` 头);路径不符 → 404;body 非法 JSON → 400;body 超 `maxBodyBytes` → 413;
584
614
  `POST /run` 超 `maxConcurrentRuns` → 503 + `Retry-After`;停机中 `POST /run`、`POST /tasks` → 503。
@@ -611,6 +641,12 @@ const handler = createHttpHandler(callable, { runner });
611
641
  返回值另外挂着两样(不影响 `(req,res)` 的调用形状):
612
642
 
613
643
  - **`handler.drain(opts?)`** —— 优雅停机:拒新单(`POST /run` 与 `/tasks` → 503,`GET /tasks/:id` 仍可轮询)→ 等异步任务与在飞同步 run 收尾 → 强制收口仍开着的 SSE 流(**收口同时 abort 对应 run**,以 `stopReason='aborted'` 收尾 —— 只关流不中止会让 run 在后台继续烧 token)。返回是否排空干净;超时返回 `false`,**未完成的任务留在 store 里**,下次启动由 `resumePending` 续跑(不是丢弃)。`timeoutMs` 缺省 0 = 一直等。
644
+ - ⚠️ **「一直等」只对在飞工作成立,活连接(SSE)不享受无限窗口**(2026-09-28 修):唯一能结束一条
645
+ 「自己不结束的流」的机制就是收口,而收口原先排在等待**之后** ⇒ 缺省调用会死等。现在没有 deadline
646
+ 时**先收口再等**;给了 `timeoutMs` 时保持既有的优雅等待。
647
+ - 返回值**如实分两种收口**:收口切掉了**在飞的 run**(`/run` 的流)⇒ `false` —— 那些工作是被
648
+ `abort` 收尾的,不是排空的;只关掉**旁观者长连**(`GET /tasks/:id/stream`)⇒ 不损失任何工作,
649
+ 不影响这个布尔。宿主的 SIGTERM 决策(退出 / 再等)就依赖它。
614
650
  **框架不订阅信号** —— `process.on('SIGTERM', () => handler.drain())` 是宿主的事(同「框架不读 env」)。
615
651
  - **`handler.runner`** —— 内部 `AsyncRunner`,需要时手动控制(`resumePending` / `awaitTask` / `list`)。
616
652
 
@@ -638,6 +674,10 @@ process.on('SIGTERM', async () => {
638
674
  | `inFlight` | 在飞工作量 = 正在处理的同步 run(含 SSE 流)+ 已受理未完成的异步任务(queued + running);与 `drain()` 等的范围一致 |
639
675
  | `uptimeMs` | 本 handler 创建至今的毫秒数 |
640
676
  | `draining` | 是否已进入优雅停机 —— 负载均衡据此摘流量 |
677
+ | `suspended` | 挂起读数(配套 6):按原因分组的条数 + 最早的目标时刻,由下面三项组成。口径 = **本进程**看得见的记录(与 `inFlight` 同一张表)—— 跨进程部署要合并看板请自己聚合,这里不假装是全局面 |
678
+ | `approval` | `suspended` 里:等人工决定(HITL)的挂起条数 |
679
+ | `timer` | `suspended` 里:等一个时刻(durable timer)的挂起条数 |
680
+ | `nextWakeAt` | `suspended` 里:最早的目标时刻(epoch ms);一条时间挂起都没有时为 `null` —— **不是 `0`**:`0` 在 JSON 里是个合法时刻,监控端拿它算 `nextWakeAt - now` 会得到巨大的负数,看着像「早就该醒却没人醒」 |
641
681
 
642
682
  **不鉴权**(探针带不了凭据),且停机中也照回 200。非 `GET` 回 405。
643
683
 
@@ -650,7 +690,7 @@ process.on('SIGTERM', async () => {
650
690
  | `isAbortError` | 判定异常是否为中断(`name === 'AbortError'`) |
651
691
  | `mapWithConcurrency` | 有界并发 map(结果保序);`maxToolConcurrency` 的底座,也可自用 |
652
692
 
653
- - **取消**:`app.run(messages, { signal })` 传 `AbortSignal` —— 框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发 `signal`),run 以 `stopReason='aborted'` 收尾(算失败)。`createHttpHandler` 已内置「客户端断开即中止」;`AsyncRunner.runTimeoutMs` 到点同样是**真中止**(构造期校验:必须 ≥ 0 的**有限**数 —— NaN/Infinity 会被 `setTimeout` 钳到 1ms,等于每个任务立即超时,故直接抛错;要「不限」传 0 或不设)。
693
+ - **取消**:`app.run(messages, { signal })` 传 `AbortSignal` —— 框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发 `signal`),run 以 `stopReason='aborted'` 收尾(算失败)。`createHttpHandler` 已内置「客户端断开即中止」;`AsyncRunner.runTimeoutMs` 到点同样是**真中止**(构造期校验:必须 ≥ 0 的有限数、且不超过 `2^31-1`ms ≈ 24.86 天 —— NaN/Infinity 与超上限的值都会被 `setTimeout` 钳到 1ms,等于每个任务立即超时,故直接抛错;要「不限」传 0 或不设)。
654
694
  - **重试**:缺省自动重试可重试失败(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 永不达到,两者都等于**无限重试**(且静默)。
655
695
  - **流式**:`POST /run` 带 `Accept: text/event-stream` → SSE 逐帧下发(`text.delta` / `run.end` / `error`,外加一族 **`trace.event`** —— 增量记账事件,body 即 `TraceRecordEvent`,用它做实时面板;不认识这一族的老客户端行为零变化);不带该头仍回一元 JSON。
656
696
  - **工具超时 / 并发闸门**:`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`。
@@ -668,7 +708,7 @@ process.on('SIGTERM', async () => {
668
708
  | `compactMessages` | compaction:旧前缀做摘要(摘要器由你注入,框架不替你造 token) |
669
709
  | `estimateMessages` | 估算一组消息的 token(预算决策用,不是精确记账)。⚠️ **图片块按上界估**:块里没有宽高,而官方图片计费按**尺寸**(28×28 像素 = 1 token,与文件字节数无关)⇒ 框架按「长边缩到 1568px」的**上界 3136 token/块**计(宁可高估让 `maxTotalTokens` 提前拦,也不要低估让它迟触发)。未知块按**未截断**负载估(同上理由) |
670
710
  | `defaultEstimateTokens` | 缺省的单文本估算函数(CJK 感知启发式:CJK ≈ 1.5 字/token、其余 ≈ 4 字符/token) |
671
- | `renderMessages` | 把 messages 渲染成纯文本 —— 喂给你注入的 compaction 摘要器(`summarize`)用。⚠️ **产物是有界的**:图片块只给 `[image image/png ~150000B]` 这类占位(**含 `tool_result` 正文里嵌套的图片** —— 工具返回截图是常见的入图路径)、未知块截断到 200 字符、**工具参数**截断到 2000 字符,两种截断都**留计数**(不静默丢)—— **绝不展开 base64**(否则等于把整段 payload 当输入 token 发给摘要模型,真金白银 + 摘要质量一起毁)。⚠️ **纯文本**(`text` 块与 `tool_result` 的字符串正文)**刻意不截断**:那是摘要器要读的内容,截掉等于让 compaction 永久丢掉历史 |
711
+ | `renderMessages` | 把 messages 渲染成纯文本 —— 喂给你注入的 compaction 摘要器(`summarize`)用。⚠️ **产物是有界的**:图片块只给 `[image image/png ~150000B]` 这类占位(**含 `tool_result` 正文里嵌套的图片** —— 工具返回截图是常见的入图路径)、未知块截断到 200 字符、**工具参数**截断到 2000 字符,两种截断都**留计数**(不静默丢)—— 这三处**一律不展开 base64**(否则等于把整段 payload 当输入 token 发给摘要模型,真金白银 + 摘要质量一起毁)。⚠️ **纯文本**(`text` 块与 `tool_result` 的字符串正文)**不按长度截断**:那是摘要器要读的内容,截掉等于让 compaction 永久丢掉历史。但**纯载荷长串会被折叠**:字符集落在 base64/hex 内、**连续 ≥ 4000 字符**的串折成 `⟨载荷 N 字符已折叠⟩`(工具把截图 base64 / 大文件当**字符串**返回时,这是唯一的收口 —— 判据是**字符集**而非「无空白」,所以中文与英文散文一字不折)。⚠️ **这不是上界保证**:折行载荷(PEM 式每 76 字符换行)够不着下限,照样整段进摘要器 |
672
712
 
673
713
  **per-run 隔离(`ContextPolicy.forRun`)**:策略可能被配成应用级单例(`createApp({ contextPolicy })`)
674
714
  被所有 run 复用。带状态的实现(滞回计数、token 缓存等)应实现可选的 `forRun(): ContextPolicy` ——
@@ -693,6 +733,7 @@ process.on('SIGTERM', async () => {
693
733
  - 超限后 run 以 `stopReason='budget_exceeded'` 收尾(**算失败**),run 根记一条 `budget.exceeded` 事件(带 `{ kind, limit, actual, totalTokens, costUsd }`)。
694
734
  - **不是硬实时**:一回合跑完才判,实际用量可能超上限一个回合的量。
695
735
  - **模型自然收尾的那一回合超限不改判失败**(只留事件)—— 那次 run 的任务其实做完了,不该追认成失败。同理,超预算的回合仍照常处理 `submit_result`(纯内部的结构化提交、零副作用)—— 模型已把最终结果交出来,连同回合丢弃等于白烧这一回合。
736
+ - **超预算当回合的普通工具不执行**:判出超限后,本回合模型要求的工具调用被**连带丢弃** —— 不执行、不回灌 tool_result,trace 里**也没有**对应的 `tool.input` / `tool.output` 事件(「避免超预算的 run 继续产生副作用」);run 随即以 `budget_exceeded` 收尾。所以别指望「超限那一回合的工具结果会进 trace」—— 它们根本没有发生;唯一例外是上面说的 `submit_result`。
696
737
  - 预算是**整条 run(含各级子 agent / skill 子循环)**的口径:上限经 `ToolRunContext` 透传,各级循环共享同一 recorder 的累计账单、每回合各自检查。子循环超限以 `budget_exceeded` 收尾(该次能力调用记 `is_error`,capability span 上记 `budget.exceeded` 事件),主循环在下一回合**入口**拦住、不再发出新请求,整条 run 以 `budget_exceeded` 收尾。
697
738
 
698
739
  ```ts
@@ -719,7 +760,7 @@ if (result.stopReason === 'budget_exceeded') console.warn('这次 run 被预算
719
760
 
720
761
  | 字段 | 说明 |
721
762
  |---|---|
722
- | `budgetTokens` | 预算(估算 input tokens);缺省 60000 |
763
+ | `budgetTokens` | 预算(**估算 `messages` 的** input tokens;**不含 system prompt 与 tools schema** —— 真实请求的 input tokens 恒 ≥ 这个数);缺省 60000 |
723
764
  | `keepRecent` | compaction 保留的最近消息**条数**;缺省 20 |
724
765
  | `keepToolPairs` | context editing 保留的最近工具**对数**;缺省 1 |
725
766
  | `estimateTokens` | token 估算函数(预算决策用,非精确记账) |
@@ -737,11 +778,12 @@ trace 出去之后能干什么:指标、调用树面板、调优报告、生
737
778
 
738
779
  | API | 说明 |
739
780
  |---|---|
740
- | `TraceSink` | `{ export(trace) }`,run 收尾(成功/失败)都投递,抛错被吞 |
781
+ | `TraceSink` | `{ export(trace) }`,run 收尾(成功/失败)都投递,抛错被吞(吞之前落一条 `console.warn`,文案含「trace sink」) |
741
782
  | `registerDefaultTraceSink` | 注册全局默认 sink(构造期快照合并) |
742
783
  | `TraceRecorder` | 内存 recorder(一次 run 一个);`addLink(spanId, { traceId, spanId? })` 记一条跨 trace 链路(见 §6「跨进程关联」) |
743
784
  | `parseTraceparent` | 解析 W3C `traceparent` 头 → `{ traceId, spanId? }`;**非法 / 缺头一律返回 `undefined`**(不抛、不打 400)—— 结果直接交给 `traceContext` 选项,见 §6「跨进程关联」 |
744
785
  | `createOtlpExporter` | OTLP/JSON 导出,零依赖;选项见下面「`OtlpExporterOptions`」表 |
786
+ | `jsonlTraceSink` | JSONL 文件 sink:`jsonlTraceSink({ path })` → `TraceSink`,一行一条裸 Trace 追加落盘(父目录自动创建、追加不覆盖)—— **`agentia report` / `diff` / `harvest` 的输入格式**,见 §6「调优报告」 |
745
787
  | `TraceRecordEvent` | **增量记账事件**(`onTraceEvent` 的回调参数):`span.begin` / `span.end` / `span.event` / `span.attribute` / `span.link`,每条带单调 `seq`。载荷是**增量 + 此刻的拷贝**(`span.begin` 只给初始形状,属性/事件/链路各走自己的类型);按 `seq` 升序折回必须**逐字等于**收尾时的 trace |
746
788
  | `metricsSink` | 指标累加器(Prometheus 文本 / OTLP metrics),满足 `TraceSink` 即接入 —— 见 §6「指标」 |
747
789
  | `buildRunReport` | 从一条 trace 生成**调优报告**(能力/模型的耗时、token、成本、错误率排行)—— 见 §6「调优报告」 |
@@ -756,7 +798,7 @@ trace 出去之后能干什么:指标、调用树面板、调优报告、生
756
798
  | `headers` | 追加的请求头(鉴权 / 租户标) |
757
799
  | `serviceName` | OTLP resource 的 `service.name`,缺省 `agentia` |
758
800
  | `timeoutMs` | 单次导出请求超时(毫秒,缺省 10000,非正数 = 不限)——裸 `fetch` 没有超时,collector 半开连接会让 run 收尾**永久挂起**;超时按导出失败处理 |
759
- | `onExportError` | 导出失败回调:给了它,**所有**失败(非 2xx / 超时 / **HTTP 200 但 collector 报部分接收**)都交给它、不再向 `TraceSink` 调用方抛;不给则维持既有行为(抛出,由 `flushSinks` 吞掉 —— 观测失败不击穿业务)。存在理由:`TraceSink` 的失败缺省是**静默**的,「导出其实少了一半数据」这类消息得有人能收到 |
801
+ | `onExportError` | 导出失败回调:给了它,**所有**失败(非 2xx / 超时 / **HTTP 200 但 collector 报部分接收**)都交给它、不再向 `TraceSink` 调用方抛;不给则维持既有行为(抛出,由 `flushSinks` 吞掉并落 `console.warn` —— 观测失败不击穿业务)。存在理由:`TraceSink` 的失败缺省只有一条泛化的 warn,「导出其实少了一半数据」这类**带分类的**消息得有人能收到 |
760
802
 
761
803
  导出器的两条线缆口径(都有守卫钉着,别按直觉改):
762
804
 
@@ -779,6 +821,7 @@ run 根 → `gen_ai.operation.name=invoke_agent` + `gen_ai.agent.name`(attribu
779
821
  `llm.turn` → `gen_ai.operation.name=chat` + `gen_ai.request.model` + `gen_ai.usage.input_tokens` / `output_tokens`;
780
822
  capability span 按 **attributes** 分(`subagent` / `skill`;span 的 `name` 是**裸能力名**):`subagent` → `invoke_agent` + `gen_ai.agent.name`,`skill` → `execute_tool` + `gen_ai.tool.name`;
781
823
  `score` 事件 → `gen_ai.evaluation.result`。映射集中在 `createOtlpExporter` 一处,下游(Langfuse / Grafana / Datadog)按 1.37+ 识别这批键做 GenAI 专项视图。
824
+ 从 Tempo / Grafana 按 run 找回 trace 的做法(id 的 hex 投影、run 根按 `invoke_agent` 过滤)见 `docs/observability.md` §2.7。
782
825
 
783
826
  > **生产落地**(按 runId 落库检索 / 日志关联 / 采样 / 脱敏)见 `docs/observability.md` ——
784
827
  > 框架只保证 trace 出口,这些都在缝外用 sink 组合;四条现成 sink 的实码在
@@ -825,17 +868,22 @@ runner.submit(msg.value, {
825
868
  **具体 span**(回合 / 能力调用)的 link,而不是只到 run 粒度:
826
869
 
827
870
  ```ts
828
- import { currentTraceparent } from '@migor/agentia';
829
-
830
- @Tool({ description: '把工单交给订单服务' })
831
- async placeOrder(input: { sku: string }) {
832
- const tp = currentTraceparent(); // 不在 run 内 → undefined(那时不该编一个)
833
- const res = await fetch(orderSvc, {
834
- method: 'POST',
835
- headers: { 'content-type': 'application/json', ...(tp ? { traceparent: tp } : {}) },
836
- body: JSON.stringify(input),
837
- });
838
- return res.json();
871
+ import { Tool, currentTraceparent } from '@migor/agentia';
872
+
873
+ class OrderTools {
874
+ @Tool({
875
+ description: '把工单交给订单服务',
876
+ schema: { type: 'object', properties: { sku: { type: 'string' } }, required: ['sku'], additionalProperties: false },
877
+ })
878
+ async placeOrder(input: { sku: string }) {
879
+ const tp = currentTraceparent(); // 不在 run 内 → undefined(那时不该编一个)
880
+ const res = await fetch(orderSvc, {
881
+ method: 'POST',
882
+ headers: { 'content-type': 'application/json', ...(tp ? { traceparent: tp } : {}) },
883
+ body: JSON.stringify(input),
884
+ });
885
+ return res.json();
886
+ }
839
887
  }
840
888
  ```
841
889
 
@@ -865,7 +913,8 @@ async placeOrder(input: { sku: string }) {
865
913
  - **模型级** —— 按模型(`llm.turn` 的 span name)归因 turn 数 / token / 成本 / 耗时,并单独给出
866
914
  `model_unpriced_turns_total`(算不出成本的 turn 数 —— **成本护栏失效的显式信号**)。
867
915
  同理,**标签基数折叠不是静默的**:`agentia_dropped_keys{kind="capability"|"model"|"score"}`
868
- (恒定发三个样本,即使为 0 —— 「0 → N」这个变化本身就是要告警的信号)。
916
+ (恒定发三个样本,即使为 0 —— 「0 → N」这个变化本身就是要告警的信号);开了 `labelKeys` 时
917
+ 另按每键加 `kind="label:<key>"`、组合数封顶时加 `kind="label:combos"`(没开那几行一个都没有)。
869
918
  - **评分级** —— 来自 run 根 span 的 `score` 事件(`attachScore` 写入):
870
919
  `agentia_score{name,source}` gauge 记**最近一次**值(分数不是累加量),`agentia_score_total{name,source}` counter 记条数;
871
920
  `snapshot().scores` 以 `name@source` 为键(source 缺省时裸 name)暴露 `{ value, count, sum }`(平均 = sum/count),
@@ -885,7 +934,7 @@ async placeOrder(input: { sku: string }) {
885
934
 
886
935
  | 字段 | 说明 |
887
936
  |---|---|
888
- | `export` | 输出形态;缺省 `'prometheus'`。`'otlp'` 走 OTLP/JSON 导出(**必须同时给 `endpoint`**,不给就构造期抛错) |
937
+ | `export` | 输出形态;缺省 `'prometheus'`。`'openmetrics'` 出带 exemplars 的 OpenMetrics 文本(指标 ↔ trace 互跳,见 `docs/observability.md` §4)。`'otlp'` 走 OTLP/JSON 导出(**必须同时给 `endpoint`**,不给就构造期抛错) |
889
938
  | `endpoint` | OTLP 采集端基地址(如 `http://localhost:4318`);尾部斜杠会被去掉 |
890
939
  | `intervalMs` | OTLP 导出间隔(毫秒,缺省 60000);`0` = 每次 run 收尾立即导出。定时器已 `unref()`,不阻止进程退出 |
891
940
  | `resourceAttributes` / `serviceName` | OTLP resource 属性(`service.name` 缺省 `agentia`) |
@@ -897,6 +946,9 @@ async placeOrder(input: { sku: string }) {
897
946
  | `maxCapabilities` | 能力标签基数上限(缺省 200):超出后新能力归入 `capability="__other__"`(防标签爆炸);非正数抛错 |
898
947
  | `maxModels` | 模型维度基数上限(缺省 50):超出后新模型归入 `model="__other__"` ——`model` 是 per-run 可覆盖的,上游把版本号拼进模型 id 时键会无界增长;非正数抛错 |
899
948
  | `maxScores` | 评分维度基数上限(缺省 200):评分键是 `name@source`,eval 名带时间戳时同样无界;超出的归入 `name="__other__"`;非正数抛错 |
949
+ | `labelKeys` | 归因标签维度(R8-P4):**显式点名** run 根 `labels.<key>` 里哪些键上指标标签(缺省 `[]` 一个都不上)。键必须是合法 Prometheus 标签名(`/^[a-zA-Z_][a-zA-Z0-9_]*$/`),否则构造期抛错。出口形态:`runs_total` / `runs_failed_total` / `tokens_total` / `cost_usd_total` 四个家族在全局样本之外追加带标签样本(全局那行仍是总量)。⚠️ 开了它以后 `sum(agentia_runs_total)` 会把全局行与分行**重复计数** —— 总量用不带标签的序列 |
950
+ | `maxLabelValues` | 每个 labelKey 的相异值数上限(缺省 100,**必须为正数**):超出归入 `__other__`(折叠只丢粒度不丢量);被折叠的不同值数见 `snapshot().droppedLabelValues` 与 `agentia_dropped_keys{kind="label:<key>"}`。这道上限刻意不可关 —— opt-in 挡不住「明知几千租户偏要上」,sink 内存不变量要求每个新基数维度都有 cap |
951
+ | `maxLabelCombos` | **标签组合数**上限(缺省 200,**必须为正数**):`maxLabelValues` 封的是**每个键的值域**,而进内存的是键的**组合**(叉乘 —— 值域 100 配 3 个键就是一百万条常驻条目,且 `droppedLabelValues` 只报每键折叠数、看不出组合已经爆了)。超限的**新组合**折进一个全 `__other__` 的桶(量照收、只丢标签粒度);被折的组合数见 `snapshot().droppedLabelCombos` 与 `agentia_dropped_keys{kind="label:combos"}`。与 `maxLabelValues` 一样刻意不可关;单键配置(最常见)下组合数 ≈ 值数,行为不变 |
900
952
  | `buckets` | 直方图桶边界(毫秒,严格升序);缺省 `DEFAULT_BUCKETS` |
901
953
 
902
954
  #### `MetricsSink`(`metricsSink()` 的返回值)
@@ -904,8 +956,9 @@ async placeOrder(input: { sku: string }) {
904
956
  | 成员 | 说明 |
905
957
  |---|---|
906
958
  | `export` | `TraceSink` 的实现(run 收尾投递)—— 也是接进 `sinks` 的形状 |
907
- | `snapshot` | `{ runs, failed, latencyP50, latencyP95, tokens, costUsd, capabilities, models, scores, droppedCapabilities, droppedModels, droppedScores }` |
959
+ | `snapshot` | `{ runs, failed, latencyP50, latencyP95, tokens, costUsd, capabilities, models, scores, runLabels, droppedCapabilities, droppedModels, droppedScores, droppedLabelValues, droppedLabelCombos, exemplars }` |
908
960
  | `render` | Prometheus 文本(`/metrics` 直接回它) |
961
+ | `contentType` | `render()` 产物的 Content-Type(随 `export` 模式走):内置 `/metrics` 路由读它发响应头,自己挂端点时**也该读它而不是写死** —— `0.0.4` 与 OpenMetrics 不是可互换的两种写法,拿 `0.0.4` 的头去发带 exemplar 的文本,严格的抓取端会解析失败。可选成员(兼容手写的 `MetricsSink` 实现;本工厂返回的一定带) |
909
962
  | `flush` | 主动导出一次(`export:'otlp'` 时有意义;prometheus 模式为空操作) |
910
963
  | `stop` | 停掉定时导出(进程收尾 / 测试用) |
911
964
  | `reset` | 清空累计(含能力 / 模型 / 评分三个维度,以及各自的基数配额) |
@@ -913,10 +966,10 @@ async placeOrder(input: { sku: string }) {
913
966
  - `tokens` 口径 = **四类之和**(input + output + cacheRead + cacheCreation),与 `BudgetGuard` 一致;分项在 `render()` 里以 label 给出,不会丢。
914
967
  - 分位是**窗口内精确值**(最近 rank 法),只反映最近 `windowSize` 条样本;**直方图计数是累积的**(全历史),两者语义不同、各有各的用处。
915
968
  - **内存上限** ≈ `(1 + 能力数 + 模型数) × windowSize` —— 三个维度都由基数上限封顶(`maxCapabilities` / `maxModels` / `maxScores`),长跑宿主不会被拖住。
916
- - 超上限的键折叠进 `__other__`:**丢的只是标签粒度,量不丢** —— `__other__` 桶照常累加,`snapshot()` 里各维度的总数仍然对得上。被折叠的**不同**键数见 `droppedCapabilities` / `droppedModels` / `droppedScores`(各自最多记账 1024 个键,满了以后是下界)。
969
+ - 超上限的键折叠进 `__other__`:**丢的只是标签粒度,量不丢** —— `__other__` 桶照常累加,`snapshot()` 里各维度的总数仍然对得上。被折叠的**不同**键数见 `droppedCapabilities` / `droppedModels` / `droppedScores`(各自最多记账 1024 个键,满了以后是下界),归因标签维度另见 `droppedLabelValues`(按 labelKey 分键计)与 `droppedLabelCombos`(组合数上限折掉的那批)。
917
970
  **同样的数在 `/metrics` 上也看得见**(`render()`):`agentia_dropped_keys{kind=…}` —— 只看 Prometheus
918
971
  不看 `snapshot()` 的部署不会漏掉折叠。
919
- - `costUsd` 依赖模型在价格表内(不在表里时不计、并计入 `unpricedTurns` 与 `usage.unpriced` 事件);根 span 未收尾(如失败路径的半截 trace)的 run 不进延迟样本。
972
+ - `costUsd` 依赖模型在价格表内(不在表里时不计、并计入 `unpricedTurns` 与 `usage.unpriced` 事件);**上游没回报 usage** 时另计入 `usageMissingTurns` 并记 `usage.missing` 事件 —— 那种情况下成本会**看起来**是 0(`maxCostUsd` 因此不可用),与「模型没定价」是两件事、分开计数(指标侧 `agentia_model_unpriced_turns_total` / `agentia_model_usage_missing_turns_total` 各一条);根 span 未收尾(如失败路径的半截 trace)的 run 不进延迟样本。
920
973
 
921
974
  #### 调用树面板(`agentia dev` 的本地面板 / 官网 Playground)
922
975
 
@@ -982,19 +1035,20 @@ failures, capabilities, totals }`)、没有人类装饰,适合脚本与 CI
982
1035
  > 要看分位请用 `mergeRunReports` 汇总多条,或用 `metricsSink` 的直方图。
983
1036
  > CLI 报告的聚合口径与 `agentia dev` 面板的能力排行同源(同一份 `@migor/trace-view` 实现)。
984
1037
 
985
- 那个 jsonl 从哪来 —— 框架不替你落盘(观测出口是缝),自己接一个 sink 就行,零依赖:
1038
+ 那个 jsonl 从哪来 —— 框架内置了文件 sink,一行接入(零依赖)。它产出的格式就是
1039
+ **CLI 三件套的输入**:`agentia report` / `agentia diff` / `agentia harvest` 都吃这个文件
1040
+ (一行一个 JSON,裸 Trace):
986
1041
 
987
1042
  ```ts
988
- import { appendFileSync } from 'node:fs';
989
- import { createApp, type TraceSink } from '@migor/agentia';
1043
+ import { createApp, jsonlTraceSink } from '@migor/agentia';
990
1044
 
991
- const jsonl: TraceSink = {
992
- export: (trace) => appendFileSync('trace.jsonl', `${JSON.stringify(trace)}\n`, 'utf8'),
993
- };
994
- const app = await createApp({ /* … */ sinks: [jsonl] });
995
- // 之后:agentia report trace.jsonl
1045
+ const app = await createApp({ /* … */ sinks: [jsonlTraceSink({ path: 'trace.jsonl' })] });
1046
+ // 之后:agentia report trace.jsonl / agentia diff a.jsonl b.jsonl / agentia harvest trace.jsonl
996
1047
  ```
997
1048
 
1049
+ 父目录不存在会自动递归创建;**追加不覆盖**(多跑几次就累积多行);写失败抛给 `flushSinks`
1050
+ 吞掉并落 `console.warn`(观测失败不击穿业务,但不再零信号)。
1051
+
998
1052
  异步宿主更省事:把 `FileTaskStore` 的落盘文件直接喂给它 —— `TaskRecord` 里带 `result.trace`,
999
1053
  `report` 认这种形态,不用另写 sink。
1000
1054
 
@@ -1017,6 +1071,8 @@ const app = await createApp({ /* … */ sinks: [jsonl] });
1017
1071
  - 直连 `runAgent` / `executeRun` 时可用引擎级选项 `systemVersion` 显式给。
1018
1072
  - **`@Prompt` 资产版本**:`@Prompt({ version })` 声明后,装配期把菜单里全部带版本的 @Prompt 收集成 `{ 能力名: 版本 }` 表(与主菜单同一条收集路径,`toolSources` 收窄同样生效),每次 run 落 run 根 span 的 `prompts.versions` attribute(`name@ver` 逗号拼接、按名排序、空表不记)—— 质量回归能定位到具体资产版本。直连 `runAgent` 时用引擎级选项 `promptVersions` 显式给。
1019
1073
  - **会话标识**:`app.run(..., { session })` / `executeRun` 给了 `session` 时,session id 自动落 run 根 span 的 `session.id` attribute(OTLP 导出时映射 `gen_ai.conversation.id`)—— 多轮对话的 run 由此可按会话聚合,不用手填。直连 `runAgent` 时用引擎级选项 `sessionId` 显式给。
1074
+ - **菜单签名**:每次 run 落 run 根 span 的 `tools.names`(装配后菜单的名字、按名排序、超长截断带 `…(+N)`)与 `tools.menuHash`(12 位摘要,材料 = 排序后的「名字 + **输入 schema**」规范序列化)—— 与 `prompts.versions` 同动机:质量回归能回答「这两段 run 跑在同一个菜单上吗」。口径:与菜单顺序、schema 键序无关;**`description` 不参与**(润色文案不算菜单变化,代价是「只改描述」看不见 —— 有意)。给了 `tools` 就自动记,无需开关。
1075
+ - **菜单漂移(续跑时菜单变了)**:挂起段之后**续跑**时,未决 tool_use 引用的工具若已不在当前菜单(删了 / 改名了),框架记**三处信号**:`menu.drift` 事件(含 `{ missing, tool_use_ids, menu_size }`;时间线与 `GET /tasks/:id/stream` 都看得到)、父 span 的 `menu.drift` attribute(如 `missing:danger`)、一条 `console.warn`。⚠️ **run 照常收尾**(不判失败):那些 tool_use 会以 `unknown tool` 回给模型 —— 为什么不做严格模式见 spec §10 2026-09-27 ⑧。注意这与「回合内模型编了个不存在的工具名」不同:那个是幻觉,走既有 `unknown_tool` 路径、**不**报漂移。
1020
1076
 
1021
1077
  ### 6.5 集成
1022
1078
 
@@ -1035,6 +1091,55 @@ const app = await createApp({ /* … */ sinks: [jsonl] });
1035
1091
  | `diffTraces` | 两条 trace 的 A/B 比对(prompt / 模型实验):run 级 summary + 逐 span 字段差;纯函数,llm.turn 配对**忽略模型名**,缺省忽略墙钟 |
1036
1092
  | `applyMiddleware` | 手动包裹配置菜单(装配层已自动做) |
1037
1093
 
1094
+ #### 并发 run 共用同一 keys(回写会不会互相覆盖)
1095
+
1096
+ `memory: { store, keys }` 的水合/回写是**读 → 改 → 写**三步:`load` 在 run 开始,`save` 在收尾。
1097
+ 两条 run 拿**同一份 store + 同一批 keys** 并发跑时,后写的那条会把先写那条改过的键**整片覆盖**回去,
1098
+ 而它自己看不出任何异常 —— 这就是 lost update(**落后写赢**:先写完的那条被静默丢掉)。
1099
+ 框架能发现它的**唯一依据是版本号**,所以这是一对**可选**成员:
1100
+
1101
+ ```ts
1102
+ class MyMemoryStore implements MemoryStore {
1103
+ load(keys: string[]): Record<string, unknown> | Promise<Record<string, unknown>> { /* 必填 */ }
1104
+ save(entries: Record<string, unknown>): void | Promise<void> { /* 必填 */ }
1105
+
1106
+ // 可选①:带版本号的读 —— 多回一个**不透明**句柄(框架不解释它,只在本 run 回写时原样递回)
1107
+ loadWithRev(keys: string[]): MemorySnapshot { /* → { values, rev } */ }
1108
+
1109
+ // 可选②:CAS 写 —— 版本对不上就**一个字都不写**,并如实回报
1110
+ saveIfRev(entries: Record<string, unknown>, rev: unknown): MemoryWriteResult {
1111
+ // 版本对不上 ⇒ return { committed: false, reason: 'conflict' } // 写成功的路径不许半写
1112
+ // 写成功 ⇒ return { committed: true }
1113
+ }
1114
+ }
1115
+ ```
1116
+
1117
+ 三条规则:
1118
+
1119
+ 1. **成对实现**:只实现一个是**装配错误** —— `executeRun` 入口当场抛 `TypeError`。
1120
+ 半个 CAS 与「不支持」等效(回写照旧静默覆盖),却让「有没有被覆盖」看起来有据可查 ——
1121
+ 本仓把这类「看起来在岗、其实不在岗」一律当错误处理。
1122
+ 2. **冲突时框架不写、也不替你合并**:`saveIfRev` 回 `committed: false` ⇒ 本轮回写被拒
1123
+ (一个字都没写)+ 一条 `console.warn`(带 keys 与冲突事实)。「后写赢 / 逐键赢 / 按时间戳赢」
1124
+ 都是策略,属于 store 与宿主的决定 —— 框架只负责把「丢了一条写」变成**有声**。
1125
+ 3. **回写被拒不改 run 结局**(仍是 `succeeded`)—— 与 sink 抛错、store 故障同款:辅助动作
1126
+ 不击穿业务。**代价如实**:这一轮的记忆**没落库**,所以它是一条日志而不是一句承诺。
1127
+ 要重试请在自己的 store 里做(合并策略是你的):
1128
+
1129
+ ```ts
1130
+ for (let attempt = 0; attempt < 3; attempt++) {
1131
+ const { values, rev } = store.loadWithRev(keys);
1132
+ const merged = { ...values, ...myChanges }; // ← 「怎么合并」由你定,框架不猜
1133
+ if (store.saveIfRev(merged, rev).committed) break;
1134
+ }
1135
+ ```
1136
+
1137
+ ⚠️ **不实现这一对**(只有 `load` / `save`):**保持原行为**(last-write-wins),框架照常跑 ——
1138
+ 但**第一次**用这个 store 时会打一条**一次性**提示(每个 store 实例一次),说明「同 keys 的并发
1139
+ 回写丢写不会被发现」,让装配的人自己决定要不要补 CAS。框架**检测不了**没有版本号的覆盖:
1140
+ 这不是「暂时不查」,是没有依据可查。
1141
+ `InMemoryMemoryStore` 已带版本号(**整店一个单调计数**,粒度粗 ⇒ 宁可多报冲突,也不漏报真实覆盖)。
1142
+
1038
1143
  #### MCP 桥与连接器(MCP 是「工具来源」,不是新机制)
1039
1144
 
1040
1145
  | API | 说明 |
@@ -1104,6 +1209,60 @@ const app = createApp({ system, providers: [...], tools });
1104
1209
  | `onSessionExpired` | 会话过期自愈时被调一次(见「已知边界」)—— 要计数 / 告警 / 打日志就挂它 |
1105
1210
  | `fetchImpl` | 注入 `fetch`(测试用;缺省全局 `fetch`,与 `createOpenAIClient` 同款) |
1106
1211
 
1212
+ #### MCP 反向桥(把能力菜单暴露成 MCP server)
1213
+
1214
+ 正向桥是「接进来」(`mcpTools` 把外部 server 的工具接进菜单);反向桥是「**暴露出去**」:
1215
+ `createMcpServer(app, opts)` 把 `app.tools`(装配后、过了中间件的那份菜单)变成一个 MCP
1216
+ server —— Claude Code / Cursor / 任何 MCP 宿主都能直接调你的 `@Tool`。`app` 入参是鸭子类型
1217
+ (`AgentApp` 结构满足),也可以手拼 `{ tools: [...] }`。
1218
+
1219
+ | API | 说明 |
1220
+ |---|---|
1221
+ | `createMcpServer` | 把能力菜单暴露成 MCP server(协议范围**只到 tools**:`initialize` / `notifications/initialized` / `tools/list` / `tools/call`,另有 `ping`;其余 method 一律 -32601,params 形状坏 -32602) |
1222
+ | `McpServerApp` | `app` 入参的鸭子类型:`{ tools: AgentTool[] }` |
1223
+ | `McpServerOptions` | 传输与行为选项(见下表) |
1224
+ | `McpServer` | 返回句柄:`url`(http 模式的实际 endpoint;stdio 为 `undefined`)/ `ready` / `close()`(幂等,中止在飞调用的 signal;自有 server 才关,挂进来的只摘 handler) |
1225
+
1226
+ - **两个传输都只用标准库**:stdio = stdin/stdout 换行分隔 JSON-RPC(日志只去 stderr,stdout 是协议面);http = StreamableHTTP(POST 收报文、应答 `application/json`,简单应答不上 SSE;GET → 405、DELETE → 200)。
1227
+ - **trace 叙事不破**:每次 `tools/call` 造一棵 trace —— run 根 `mcp.tools/call` 下挂一个 capability span(name = 工具名),记 `tool.input` / `tool.output` 事件(与引擎同账目形状),收尾投递 `sinks`;`initialize` / `tools/list` 不建 trace。capability span 无 skill/subagent 属性 ⇒ metrics 里以 `capability:<工具名>` 标签进能力指标。
1228
+ - **结果映射与正向桥方向对称**:工具返回 string → `content: [{ type: 'text', text }]`,其他 JSON 化进 text;抛错 → 协议层成功 + `isError: true`(MCP 惯例;正向桥的连接器正好把 `isError` 转回抛错)。
1229
+ - **取消是协作式**:客户端断连 / `close()` 会中止该次调用的 `ToolRunContext.signal`;`toolTimeoutMs` 与引擎同款语义(非正数 / 不设 = 不限;超时 = **放弃等待** + abort `abandoned`,该次调用回 `isError`,server 不挂)。
1230
+ - **默认 client 是惰性构造的**:`client` 不给时第一次 `tools/call` 才走 `createAnthropicClient()`(与引擎默认同款)—— 纯工具 server 不需要 API key 在场。
1231
+
1232
+ ```ts
1233
+ import { createMcpServer, jsonlTraceSink } from '@migor/agentia';
1234
+
1235
+ // stdio(Claude Code / Cursor 这类宿主:它们 spawn 这个进程)
1236
+ createMcpServer(app, { transport: 'stdio', sinks: [jsonlTraceSink({ path: 'mcp-trace.jsonl' })] });
1237
+
1238
+ // http(服务形态):port 0 = 系统分配,读返回值的 url 拿实际端口
1239
+ const mcp = createMcpServer(app, {
1240
+ transport: 'http',
1241
+ port: 8787,
1242
+ auth: (req) => {
1243
+ // 只给缝:token/JWT 策略是你的(与 createHttpHandler 的 authenticate 同纪律)
1244
+ if (req.headers['x-api-key'] !== expected) throw new Error('bad key');
1245
+ },
1246
+ });
1247
+ await mcp.ready;
1248
+ console.log(mcp.url); // http://127.0.0.1:8787/mcp
1249
+ ```
1250
+
1251
+ #### `McpServerOptions`(反向桥的选项)
1252
+
1253
+ | 字段 | 说明 |
1254
+ |---|---|
1255
+ | `transport` | `'stdio'` / `'http'`(必填) |
1256
+ | `host` | http 监听地址,缺省 `127.0.0.1` |
1257
+ | `port` | http 监听端口,缺省 `0`(系统分配);自带 `server` 时无效 |
1258
+ | `path` | http endpoint 路径,缺省 `/mcp` |
1259
+ | `server` | 挂进**既有** `http.Server`(框架不替它 listen;`close()` 只摘本 handler 不关它)。非本 `path` 的请求本 handler 直接忽略(交给宿主自己的路由) |
1260
+ | `client` | 进 `ToolRunContext.client`(子 agent / skill 类工具要它拉子循环);缺省惰性 `createAnthropicClient()` |
1261
+ | `sinks` | trace 出口:每次 `tools/call` 收尾投递(抛错被吞 + `console.warn`,观测不击穿业务) |
1262
+ | `toolTimeoutMs` | 单次 `tools/call` 的工具执行超时(毫秒),语义与引擎 `toolTimeoutMs` 同款;非正数 / 不设 = 不限 |
1263
+ | `auth` | http 侧鉴权钩子:在读 body 之前调用,抛错即 401(原文只进服务端日志);stdio 侧信任父进程、本项不生效 |
1264
+ | `name` | `serverInfo.name`(握手回给客户端),缺省 `'agentia'` |
1265
+
1107
1266
  #### evals(把 mockClient 提升为一等能力)
1108
1267
 
1109
1268
  | API | 说明 |
@@ -1115,6 +1274,7 @@ const app = createApp({ system, providers: [...], tools });
1115
1274
  - 断言源是既有 `Trace`:「先 `search` 才 `summarize`」这类顺序断言全从 trace 读,框架不为此新增埋点。
1116
1275
  - `run()` **不抛**(用例失败进报告,一次跑完能看到所有回归,而不是修一个跑一次);只有「应用建不起来」才冒泡 —— 那是环境错误,不是回归。失败 case 带 `trace`,直接看现场。
1117
1276
  - `scriptedClient` 的步骤**在 `finalMessage()` 成功返回后才前进**:抛错的步骤(函数步骤 `throw` 模拟 429)会在重试时**重放同一步**,想验重试就这么写。
1277
+ - **脚本消息要带 `usage`,否则记账全 0**:脚本是模型响应的线缆形状,`usage` 用 snake_case 四字段 —— `input_tokens` / `output_tokens` / `cache_read_input_tokens` / `cache_creation_input_tokens`(缺了的字段按 0 计)。整个 `usage` 缺省**不报错**,但该回合的 token 与成本记账为 0 —— 想在 eval 里演示 `maxTotalTokens` / `maxCostUsd` 护栏时,护栏会永远「不触发」。现成形状见 `tests/helpers.ts` 的 `U`。
1118
1278
  - **用例结论自动落 score**:每个用例跑完,结论以 `{ name: 'eval', value: 0|1, source: eval 名, comment: 失败原因 }` 自动 `attachScore` 到该用例的 trace —— eval 的 trace 自带质量结论,下游 sink / `metricsSink` 可直接聚合「这个 eval 的通过率」(`app.run` 抛错拿不到 trace 时不挂)。
1119
1279
 
1120
1280
  ```ts
@@ -1154,6 +1314,28 @@ agentia harvest trace.jsonl --out evals/harvested.ts --force # 覆盖已存在
1154
1314
  - 预填的 `expect` 是从原 trace **抄录的实际轨迹** —— 发生过 ≠ 应该发生;
1155
1315
  - `EvalCase` 没有 `expect` 字段,粘贴时把断言搬进 `defineEval({ expect })`(脚手架注释会教)。
1156
1316
 
1317
+ #### trace → 训练数据集(`agentia export`)
1318
+
1319
+ harvest 的孪生(R8-P3b):harvest 产**回归用例**,export 产**训练数据**(JSONL,一行一份
1320
+ `{ messages, meta }`)。配 `traceContent: 'full'`(§4)跑出来的 trace 导出的是带真 assistant
1321
+ 文本的完整对话;没开的导出工具轨迹(text 块缺席**不造占位** —— 占位文本进训练数据是投毒)。
1322
+
1323
+ ```bash
1324
+ agentia export trace.jsonl # 全部记录 → JSONL 打到 stdout
1325
+ agentia export trace.jsonl --ok-only --min-score 0.8 --out dataset.jsonl
1326
+ ```
1327
+
1328
+ - 输入同 `agentia report` / `harvest`;`--min-score` 按 run 根 score 事件的最大值过滤
1329
+ (**没带分数的 run 在此过滤下被排除**:没判过 ≠ 及格);`--ok-only` 只留 status ok。
1330
+ - 每条记录的 `meta.incomplete` 如实标注缺口,值是机器可 grep 的固定词:`input` = 原始输入
1331
+ 未入 trace(首条 user 恒为占位);`assistant-text` = 模型正文缺席(终端回合没记到,
1332
+ 或**整棵 trace 一行正文都没有**且本 run 没开 `traceContent` —— 后者连混合回合的真文本
1333
+ 也拿不回来);`no-final-assistant` = 末条是 user,**这条样本没有 loss 目标**(半截 run /
1334
+ 只剩工具往返;`--ok-only` 挡不住它 —— status ok ≠ 有终答)。`meta.nestedTurns` 记略去的
1335
+ 子 agent 嵌套回合数(它们不进主线 —— 其能力出参已在主线的 tool_result 里)。
1336
+ - 缺输出的 tool_use 补 is_error 占位块(协议要求配对合法);有输出配不上对的计数进
1337
+ `meta.droppedOutputs`,不静默丢。
1338
+
1157
1339
  #### prompt / 模型 A/B(trace diff 与分叉重放)
1158
1340
 
1159
1341
  同一份输入跑两条 run(换模型、换 `SystemPrompt` 版本、换 prompt 都行),用 `diffTraces` 比出**结构与成本差**;
@@ -1312,9 +1494,9 @@ class DeployTools {
1312
1494
  流程(异步任务宿主):
1313
1495
 
1314
1496
  1. `POST /tasks` 提交任务;模型调到 `deploy` 时 run **挂起**:任务状态变
1315
- `awaiting_approval`,**整个回合一个工具都不执行**(全有或全无 —— 协议要求每个
1497
+ `suspended`,**整个回合一个工具都不执行**(全有或全无 —— 协议要求每个
1316
1498
  tool_use 配对 tool_result,部分执行 + 部分挂起会产出配不平的历史)。
1317
- 2. 轮询 `GET /tasks/:id` 看到 `status: 'awaiting_approval'` + `pendingApprovals`
1499
+ 2. 轮询 `GET /tasks/:id` 看到 `status: 'suspended'` + `pendingApprovals`
1318
1500
  (待决的 tool_use_id 列表)+ `spec.messages`(完整消息历史,末尾是含未决 tool_use
1319
1501
  的那条 assistant 消息)。挂起**不占并发槽**、不算终态(`awaitTask` 继续等)、
1320
1502
  `resumePending` 不会把它当孤儿捡走。
@@ -1352,6 +1534,95 @@ class DeployTools {
1352
1534
 
1353
1535
  **已知边界**(也收录在 §7):见 §7 表的「审批」相关行。
1354
1536
 
1537
+ #### 时间挂起(睡到某个时刻,durable timer)
1538
+
1539
+ 与「人工审批」是**同一套挂起骨架的两种原因**:审批 = 执行**前**等一个人的决定,
1540
+ 时间挂起 = 执行**中**等一个时刻到来。差别决定了用法:
1541
+
1542
+ - 审批由**宿主**在执行前给决定(`approval: 'required'` 的工具,`args.approvals` 里没有决定
1543
+ 就整回合挂起、一个工具都不跑);
1544
+ - 时间挂起由**工具自己**在跑的时候提出来 —— 所以那个回合的工具**已经跑过了**(见下面的整批语义)。
1545
+
1546
+ ```ts
1547
+ import type { AgentTool } from '@migor/agentia';
1548
+
1549
+ const waitForBatch: AgentTool = {
1550
+ name: 'fetch_daily_report',
1551
+ description: '取当天的批处理报表(没跑完就到明天早上再问)',
1552
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
1553
+ async run(_input, ctx) {
1554
+ const due = await nextBatchWindow(); // 你自己的判据(外部系统回填 / 限流窗口 / 收盘)
1555
+ if (due.getTime() > Date.now()) {
1556
+ ctx?.deferUntil?.(due); // 「现在还不是时候,T 之后再问我」
1557
+ return 'deferred'; // 返回值会被丢弃(见整批语义)
1558
+ }
1559
+ return await fetchReport();
1560
+ },
1561
+ };
1562
+ ```
1563
+
1564
+ 宿主侧(`AsyncRunner`):引擎把该回合收尾成 `stopReason: 'suspended'` +
1565
+ `suspendedReason: 'timer'` + 目标时刻,任务落库为 `status: 'suspended'`、`wakeAt` 进
1566
+ `TaskRecord`(**活得比进程久**)。到点由 `resumePending()` / `poll()` 的**惰性**扫描唤醒
1567
+ (与审批超时同一套纪律:不起定时器、不养在飞回调),醒来后**重跑那一批**工具、拿到结果接着跑。
1568
+
1569
+ 三条要写进心里的:
1570
+
1571
+ 1. **时刻必须是将来**:`deferUntil` 传非有限数或 `≤ 现在` 会**当场抛 TypeError** ——
1572
+ 该条 `tool_result` 记 `is_error`、run 照常往前走(模型能看到「时刻必须在将来」并自己换路)。
1573
+ 这不是苛刻:允许过去时刻就等于允许「醒来 → 再请求同一个过去时刻」**自己打转**。
1574
+ 2. **整批语义(副作用会重复)**:挂起是回合级的(协议要求每个 `tool_use` 都有配对
1575
+ `tool_result`),所以同回合**已经执行完**的其他工具会在醒来后**重跑**一遍。框架把它
1576
+ 变成看得见的:trace 记 `defer.requested`(`{ wake_at, tool_use_ids, discarded }`),
1577
+ 真有兄弟工具被作废时落一条 `console.warn`。**要精确控制就让模型单独调这个工具,或者把它
1578
+ 做成幂等读。** 同回合多条请求取**最早**的那个时刻。
1579
+ 3. **停机与可见性**:`drain()` 之后**不再唤醒**睡着的 run(停机 = 不再往前推,否则部署要等一条
1580
+ 天级任务);重启后由新进程的首次 `resumePending()` 唤醒。`GET /healthz` 的
1581
+ `suspended: { approval, timer, nextWakeAt }` 就是运维要看的那半(口径 = **本进程**看得见的
1582
+ 记录;无时间挂起时 `nextWakeAt` 是 `null`,不是 `0`)。
1583
+
1584
+ **取消**:`runner.cancel(taskId)` / `POST /tasks/:id/cancel`(见 §6.6 与上面的路由表)——
1585
+ 对一条在睡的 run 调 `cancel` 就是「不再醒」的合法出路(落终态 `cancelled`,两条唤醒闸
1586
+ 随之失效)。对一条 `timer` 挂起调 `approve()`
1587
+ 会回 409(原因不对,不是状态不对)。
1588
+
1589
+ **已知边界**:`resumePending()` 的扫描是 **O(全表)**(挂起读数重建 / 审批超时 / 孤儿认领
1590
+ 三条职责的输入仍是全表);其中「到期唤醒」那一半在 **sqlite** 上走 `listDue` 到期索引
1591
+ (派生列 + `(status, wake_at)` 索引,O(到期数) 而不是 O(全表) —— 实测 10k 记录:
1592
+ 全表 list ~38ms vs listDue ~0.1ms;file / memory store 的 list 本来就在内存里,不实现)。
1593
+ `wakeAt` 到点后**必须有人读**才会醒(没人调 `resumePending()` / `poll()` 的宿主里,
1594
+ 任务不会自己动 —— 与审批超时同一条纪律)。
1595
+
1596
+ #### 事件投递(run 事件投入口:webhook / 外部系统叫醒一条挂起的 run)
1597
+
1598
+ 审批是「人等决定」的特例;**事件投递**是一般形:外部系统(webhook、另一个模块、定时
1599
+ 巡查脚本)把一条**事件**投给一条挂起的任务,任务醒来时事件进消息历史。
1600
+
1601
+ - 入口两个(与 approve/cancel 对称):宿主方法 `runner.signalTask(taskId, event)` 与
1602
+ `POST /tasks/:id/events`。事件体是**白名单** `{ eventId?, type, payload }` ——
1603
+ **全是字符串**,多一个字段即 400。这是外部输入第一次进入消息历史:外部永远不能构造
1604
+ 消息块(投毒面只到「内容注入」为止),事件由引擎渲染成**一条 user 文本消息**,
1605
+ 在未决 tool_use 解决之后注入(直接追加到历史末尾会破坏续跑判定,见 spec §10 ⑥)。
1606
+ - **只对挂起生效**:任务不存在 → 404;不在 `suspended` 状态(含已终态)→ 409。
1607
+ 投完即续跑(先落库再派发:崩在窗口里不丢事件);对 timer 挂起这就是**提前醒**
1608
+ (不沿旧 `wakeAt` —— 醒来重跑那一批时工具可以重新 `deferUntil` 落定新时刻);
1609
+ 对审批挂起,决定仍不齐就再挂起(零模型调用),事件留在 `TaskRecord.pendingEvents`
1610
+ 里等真正跑通的那次续跑注入。
1611
+ - **幂等**:给了 `eventId` 就按它去重(簿记在 `TaskRecord.deliveredEventIds`,随记录
1612
+ 落库、有界 FIFO 256 条)—— 同一任务重复投递同一个 `eventId` ⇒ 409。**不给
1613
+ `eventId` 就没有恰好一次**:重复投递 = 重复进历史(webhook 重试场景请带上 id)。
1614
+ - **待注入缓冲有上限(64 条)**:满了 → 409,且**不收下**(「本次事件没有被记录」,与
1615
+ 「重复投递」(那条本来就在里面)是两种不同的 409)。缓冲在下次跑通时注入并清空 ——
1616
+ 上限只管「一个挂起窗口里能攒多少」;满了说明该先让这条 run 走起来,而不是拿它当队列。
1617
+ - **留痕**:注入时续跑段的 run 根记 `task.event` 事件(`{ injected, event_type,
1618
+ event_id? }` —— `injected` 的口径是「注入进本段消息流」,**不等于**「模型已看到」:
1619
+ 该段若在首个模型请求前就中止/失败,事件不进持久化历史,但这条留痕仍成立);
1620
+ 离开挂起态时 `/healthz` 的挂起读数照常除名。
1621
+
1622
+ **已知边界**:事件由**收到它的那个进程**派发(落库先于派发,所以进程崩了不丢 ——
1623
+ 重启后由 `resumePending` 认领续跑);事件不进**会话历史**(会话只存对话轮次,
1624
+ 事件属于 run 的内部过程,完整过程在 trace 里)。
1625
+
1355
1626
  #### 内容护栏
1356
1627
 
1357
1628
  和「多租户配额」同一个形状:策略千差万别(正则 / 分类模型 / 外部审核 API),框架硬编码必错,**只给缝**。
@@ -1398,11 +1669,16 @@ const callable = {
1398
1669
  | 历史畸形就放弃裁剪 | `trimToolPairs` 遇到非严格交替历史会整体放弃(宁可少裁,也不切出孤立 tool_use 让请求 400) |
1399
1670
  | 同键去重的能力边界 | `idempotencyKey` 的去重分三档:**同步 store** 下 `submit` 当场返回既有记录;**异步 store**(Redis / SQLite 等)下 `submit` 是同步门面、无法 await,去重靠**进程内认领表**,只覆盖「**同进程内并发提交**」(2026-09-21 修复前这里会两次都执行);**跨进程并发**与**终态之后重提同键**仍是 at-least-once —— store 的 idem 索引是 last-wins(重提即新任务,`redisStore` / `fsStore` / `sqliteStore` 头注释同口径)。要严格一次,请让副作用自身幂等(或在 store 层做唯一约束) |
1400
1671
  | 增量出口与 sink 是**两条缝** | `onTraceEvent` 是「运行期逐笔」,`sinks` 是「收尾拿整棵」——消费者与保证都不同:sink 的抛错被吞但仍会**投递**(有兜底语义),`onTraceEvent` **不保证送达**(宿主自己的流断了就断了,没有重试/重放)。要「一条都不能少」用 `sinks`;要「现在就看到」用 `onTraceEvent` |
1401
- | 任务进度流的边界(内存 / 跨进程) | `GET /tasks/:id/stream` 的事件缓冲在**跑任务的进程内存**里:每任务最近 500 条(可用 `AsyncRunner` 的 `streamBufferEvents` 调,须为正整数,坏值构造期抛错),超限丢**最旧**并先发一帧 `stream.truncated`;终态流只留最近 16 条。**跨进程**(队列消费者在别的进程)时没有实时流:发一帧 `stream.unavailable` 后**立即收口** —— 任务已终态则补 `task.end`(随后关连接);**非终态**则补一帧 `stream.closed`(流级收尾,不是伪造终态)并关连接,客户端此后应转去轮询 `GET /tasks/:id`。要跨进程实时请用 `onTraceEvent` 把事件转发到宿主自己的总线(Redis Streams / Kafka) |
1402
- | `traceLimits` 与 `maxEventChars` 各管一头 | `maxEventChars` 管**单个事件正文多长**(既有),`traceLimits.maxEvents` 管**整条 trace 多少个事件**(本版)。两者正交、都「不设 = 不限」;上限触发时**丢弃量写在 run 根的 `trace.truncated`** 上(不静默)。⚠️ 实测 `maxEventChars: false` + 大出参会让 trace 放大 **13.7×**(`npm run bench:trace` 可复现)—— 先收长度再谈采样,收益顺序比反过来大 |
1672
+ | 任务进度流的边界(内存 / 跨进程) | `GET /tasks/:id/stream` 的事件缓冲在**跑任务的进程内存**里:每任务最近 500 条(可用 `AsyncRunner` 的 `streamBufferEvents` 调,须为正整数,坏值构造期抛错),超限丢**最旧**并先发一帧 `stream.truncated`;终态流只留最近 16 条。**没跑完**的流另有一道**缓冲配额**(`streamNonTerminalBuffers`,缺省 32 条流;`0` = 不留):超出即从最旧、**没有订阅者**的流开始回收**回放历史** —— 表项与序号都留着,连上来的读者先收一帧 `stream.truncated` 再转实时(**不会**变成 `stream.unavailable`,那是对别的进程说的话);正在被订阅的流不受配额约束(不抽走正在读的数据)。**跨进程**(队列消费者在别的进程)时没有实时流:发一帧 `stream.unavailable` 后**立即收口** —— 任务已终态则补 `task.end`(随后关连接);**非终态**则补一帧 `stream.closed`(流级收尾,不是伪造终态)并关连接,客户端此后应转去轮询 `GET /tasks/:id`。要跨进程实时请用 `onTraceEvent` 把事件转发到宿主自己的总线(Redis Streams / Kafka) |
1673
+ | 异步任务的**排队段**边界(内存) | `concurrency` 管「同时在跑几个」,**不管**「跑不上、排在后面」那一段 —— 缺省**排队段无上限**:`submit` 总是收单、`POST /tasks` 总是 202,灌多少就在内存里排多少(每条 = 一条记录 + 一棵悬挂的 `#execute` promise + 一个槽位等待者)。要背压就配 `AsyncRunner({ maxQueued })`:超限 `submit` 抛 `TaskQueueFullError`、HTTP 回 **503 + `Retry-After`**(**只在 `submit` 判** —— 恢复路径推进的是已受理任务,拦下来等于把它搁死在 store 里;幂等键命中的重复提交也不吃 503)。`runner.queued` 是实时读数(与闸同一判据)。⚠️ 进程内拒单**不等于**集群级背压:多副本各算各的,要全局就得上队列 |
1674
+ | `traceLimits` 与 `maxEventChars` 各管一头 | `maxEventChars` 管**单个事件正文多长**(既有),`traceLimits.maxEvents` 管**整条 trace 记多少条记录事件**(本版)。两者正交、都「不设 = 不限」;超限即**停止记账**,并在 run 根留**两笔** `trace.truncated`:**起点标记** `{limit}`(断点在这里)+ **收尾摘要** `{droppedEvents, limit}`(丢了多少)—— 两笔**同时**进交付的 trace 与 `onTraceEvent` 增量流(⇒ 按 `seq` 折回仍逐字等于收尾那份;两笔簿记**不占** `maxEvents` 配额)。⚠️ 实测 `maxEventChars: false` + 大出参会让 trace 放大 **13.7×**(`npm run bench:trace` 可复现)—— 先收长度再谈采样,收益顺序比反过来大 |
1403
1675
  | **采样是导出决策,不是记账决策** | 采样在 `sink` 外做(配方见 `docs/observability.md` 2.3):被采样掉的 trace 在框架内**仍然完整记账**,只是没发给下游。所以别拿「有采样」当「可以少记账」;也正因如此,出站 `traceparent` 的 flags 恒 `00`(记录/导出决策发生在收尾之后,运行期不可知——不替下游声明) |
1676
+ | 菜单漂移只**标记**、不判失败 | 续跑时未决 tool_use 的工具不在当前菜单 ⇒ 三处信号(`menu.drift` 事件 / 父 span attribute / `console.warn`),run **照常收尾**(那些 tool_use 以 `unknown tool` 回给模型)。**射程如实标注**:判据只看**名字** —— 「工具还在、schema 变了」能被 `tools.menuHash` 比出来,但**不会**在续跑时报 `menu.drift`;**只改 `description`** 连签名都不变(有意:润色文案不算漂移)。严格模式(带具名 error 的收尾)与「结果级字段」记在 spec §11 开放项 |
1677
+ | `approve` 只收**待决的** id(多一个 → 400 整批拒) | `decisions` 的键必须都在该任务的 `pendingApprovals` 里(调用方从记录的 `pendingApprovals` 读)。多出任何一个 ⇒ **400 + 整批拒 + 记录一字不动**。不校验的代价(已修,2026-09-28 外部深评):多出来的键会写进 `TaskRecord.approvals`,随每次 `save` 全文重写、且终态也不清 ⇒ 一个**认证调用方**单次请求就能把记录撑大(实证:一次 5000 个无关键 = +419 KB),反复调用可无限叠加 |
1678
+ | `TaskRecord.approvals` 有界但**不清** | 单次 approve 能写的键被钉在当前 `pendingApprovals` 里 ⇒ 单次输入无法放大体积;跨多轮挂起会累计**历次**待决的并集(每轮都对应真跑出来的模型回合),增长只随真实运行发生 ⇒ 天然有界,**不需要**常量上限;但**终态也不清**(审批记录是审计的一部分)—— 想减负请换 store 的清理/TTL 策略 |
1404
1679
  | 缺省内存 store 不淘汰 | 长跑宿主请设 `InMemoryTaskStore({ maxRecords })` 或换 `FileTaskStore` / `SqliteTaskStore` |
1405
1680
  | 能力引用两种粒度 | `tools` 写 **provider token** = 整片能力菜单;写 `'<token>/<能力名>'` = 只引单个能力(@Tool/@Skill/@SubAgent/@Prompt 都可点名,装配期校验,名字不存在即抛错并列出可用名单) |
1681
+ | 能力引用**成环 ⇒ 装配期抛错**(2026-09-28) | `@SubAgent` / `@Skill` 的 `tools` 不得绕回自己(直接自引用,或经另一个 provider 绕回来都算)。为什么必须要拦:`subagentToTool` 的引用解析是**延迟求值**的,环只在**运行期**成立,而 `maxIterations` 只限一回合的**宽度**、深度没有任何闸 ⇒ 模型一次自我调用就无限递归(树按 `宽度^深度` 炸开)。抛错信息给出**环上的节点序列**。没有「深度上限」可以配 —— 这是装配错误,不是运行参数 |
1406
1682
  | 能力名有格式校验 | 装饰器能力名(`name` 或缺省的方法名)必须匹配 `^[A-Za-z0-9_-]{1,64}$`(与 MCP 桥同口径),非法名在 `createApp` **装配期即抛错** —— 含空格/点/中文的名字会让模型 API 400,宁可在启动期拦住 |
1407
1683
  | `discover` 入口会回落 | 能力目录里源码与编译产物并存(`index.ts` + `index.js`)时,首选 `.ts` 加载失败会**回落 `.js` 并 warn** —— 命中的可能是**陈旧编译产物**(刚改过源码时注意);全部候选都失败才抛错并列出各自原因 |
1408
1684
  | `asset()` 的 rel 必须是相对路径 | 带 scheme(`file:` / `https:` …)的 rel 会让 `new URL(rel, base)` 整个忽略 base(「以为读了能力目录、实际读了别处」),显式抛错;`../` 越出能力目录是**有意放行**(共享资产如 `../../shared/x.md` 是合法用法) |
@@ -1410,12 +1686,18 @@ const callable = {
1410
1686
  | 默认 client 的真端点验证范围 | `e2e:live` 跑在 DeepSeek 的 Anthropic **兼容**端点上;官方 Anthropic 端点的行为差异(thinking 细节、cache TTL 语义、新块型)目前只有本地假端点测试在守 —— mock 全绿发现不了厂商真实行为 —— 接入官方端点前自己跑一遍 `npm run e2e:live` |
1411
1687
  | thinking 块「能收、不主动请求」 | 框架**从不**在请求里开 extended thinking;默认 client 能收拼 thinking 块(`signature_delta` 会累积),`redacted_thinking` 与未知块型**原样透传**不丢 —— 但官方 API 的 thinking 回灌要求带合法 `signature`,自定义 client 开 thinking 时自己验证这条链 |
1412
1688
  | 工具阶段的 abort 有盲区 | abort 只在三处被观察:**回合边界 / 在飞模型请求 / 重试退避 sleep**。没设 `toolTimeoutMs` 且工具挂死时,abort 之后 run 也不会返回(工具的 Promise 永不 settle)—— 挂死的工具要么设超时,要么自己读 `ToolRunContext.signal` |
1413
- | 观测失败被吞 | sink 抛错不影响 run(观测是辅助动作);同理记忆水合/回写失败也不击穿 run |
1689
+ | 观测失败被吞 | sink 抛错不影响 run(观测是辅助动作);但吞之前落一条 `console.warn`(含「trace sink」字样)—— sink 天天挂不再零信号。同理记忆水合/回写失败也不击穿 run |
1414
1690
  | 框架不自动读 .env | 除 `AGENTIA_MODEL`(缺省模型覆盖)与 `OPENAI_API_KEY`(OpenAI 适配器)外,框架自己不去翻环境变量,也不读 `.env`;要读就在启动代码里调 `loadEnvFile()`(脚手架已内置那行),**真实环境变量优先**于文件 |
1415
1691
  | 鉴权只是缝 | 框架**不实现** token / JWT / 签名策略,也不碰凭据 env —— `authenticate` 只承诺「拦在入口、读 body 之前」;策略是宿主或反代的事 |
1416
- | 运行时是 Node | 按 Node ≥ 18 设计与测试(`engines` 写明,CI 在 18/20/22 上守);**未对 Deno / edge 做验证**。`SqliteTaskStore` 需 Node ≥ 22.5(`node:sqlite`),未提供时构造期抛可读报错 |
1692
+ | 运行时是 Node | 按 Node ≥ 18 设计与测试(`engines` 写明;CI 覆盖 18 / 20 / 24 —— `import-floor` 矩阵跑下限、其余 job 跟当前 LTS);**未对 Deno / edge 做验证**。`SqliteTaskStore` 需 Node ≥ 22.5(`node:sqlite`),未提供时构造期抛可读报错 |
1417
1693
  | 停机不由框架触发 | 框架给 `drain()` 但**不订阅** `SIGTERM`/`SIGINT`(不做进程级决策);信号处理是宿主的 |
1418
- | 停机可能切断 SSE | `drain()` 超时后会强制关闭仍开着的 SSE 流,其 run 以 `stopReason='aborted'` 收尾 —— 客户端应把断流当作可重试 |
1694
+ | 停机可能切断 SSE | `drain()` 收口时会强制关闭仍开着的 SSE 流,`/run` 的流被收口时其 run 以 `stopReason='aborted'` 收尾(客户端应把断流当作可重试)。**没有 `timeoutMs` 时也收口**(否则等待没有终点,见上);只关旁观者长连(`/tasks/:id/stream`)**不损失工作**,那种收口不影响 `drain()` 的返回值 |
1695
+ | 多进程共库:续跑认领靠**租约**,不靠时间(2026-09-28) | `resumePending({ staleAfterMs })` 的 `staleAfterMs` **不是**租约而是**兜底启发式**。`> 0` 时按两档判他进程的记录:① `ownerId` 里的主机名是本机 ⇒ **问那个 pid 还在不在**(`process.kill(pid, 0)`)——**在的绝不抢**(不管记录多老)、**不在的立刻可抢**(崩溃孤儿不等保鲜期);② **判不了**(异主机 / 升级前写下的旧格式 `ownerId` / 自定义串)⇒ 才用 `staleAfterMs`(「记录看起来还新」)。缺省 `0` = 不问他进程,与升级前逐字一致 |
1696
+ | `ownerId` 是**内部标识**,别解析它 | 形状 `p<pid>@<host>-<8 位十六进制>`(2026-09-28 起带主机名,**此前没有**)。混版本部署安全:旧进程写下的无 `@host` 记录一律走「判不了 ⇒ 新鲜度」,行为与升级前一致。宿主如果需要自己的任务归属标记,请写进 `spec.options`,别依赖这个字段的格式 |
1697
+ | 租约判定的两个**残留风险**:只晚捡、不抢错(2026-09-28) | **PID 复用**与**僵尸进程**(父进程未收尸,pid 仍在进程表里)都会被判成「主人在」⇒ 那条孤儿记录要等那个 pid 真的消失才可捡。框架刻意**不加时间硬上限**兜它们 —— 加上限 = 把租约换回新鲜度启发式,「长跑被抢」那个 bug 原样回来。取舍一句话:宁可偶尔晚捡,不可偶尔抢跑 |
1698
+ | `beforeTurn` 抛错 ⇒ 本回合降级,不判死 run(2026-09-28) | `ContextPolicy.beforeTurn` 里**可能走网络**(compaction 的 `summarize` 是模型调用)⇒ 一次 429 / 超时原先会让整条 run 以「请求失败」收尾。现在降级为「本回合原样放行」,**且出声**:run 根记 `context.policy_failed` + 一条 `console.warn`。⚠️ 代价:降级只是「这一回合不压」,不是「不再压」——历史偏长时先看有没有这条事件 |
1699
+ | 压缩失败**不烧**滞回额度(2026-09-28) | `compactEvery` 的语义是「距上次**成功**压缩至少隔几个回合」——失败不记账、下一回合重试(原先失败也记,于是摘要器抖一下会让后面几个回合都不再尝试)。代价说清:摘要器**持续**失败时每回合都会再调一次(多一次失败的模型往返),要抑制请在 `summarize` 里自己退避 |
1700
+ | `drain()` 之后本进程不再推进任何任务(单向闩) | 置位后**没有复位路径**,且 `false`(排空超时)同样意味着**宿主必须退出**:停机窗口里已落库的记录(`running`/`queued` + 本进程 ownerId)在本进程内**没有自愈路径** —— `resumePending` 按 `own-process` 跳过、`submit` 已整体关闭 ⇒ 只能等下一次启动认领(ownerId 含 pid)。宿主若不退出,那些记录会一直停在 `running`(`awaitTask` 不返回、`GET /tasks/:id/stream` 还会说 `not-in-this-process`)。首次被拒时有 `[agentia] 停机中:…` 的 `console.warn`(可 grep),别忽略 |
1419
1701
  | 鉴权失败即断连 | 未通过鉴权时在读到 body 之前就回响应,连接**不可复用**(显式 `connection: close`);这是「不收body省资源」的代价 |
1420
1702
  | 预算护栏不是硬实时 | 一回合记账完才判,实际用量可能超上限一个回合的量;并行子循环(一回合多个子 agent)各自过闸,超支上限是「**每个在飞分支**各一个回合」而非「总共一个回合」;模型自然收尾的那回合超限**不算失败**(只留 `budget.exceeded` 事件) |
1421
1703
  | `maxCostUsd` 依赖价格表 | 模型不在价格表内(且未用 `priceOverrides` 覆盖)时成本恒为 0,这条护栏**不触发** —— 要无条件兜底用 `maxTotalTokens`。**失效会响**:turn 上会记 `usage.unpriced` 事件、指标有 `model_unpriced_turns_total`、可回调 `onUnpricedModel` |
@@ -1429,6 +1711,7 @@ const callable = {
1429
1711
  | OpenAI 流式的上游故障按失败处理 | 三种形态都**抛错**按失败处理:流中 `error` 分片(上游把故障塞进 200 的流;按 `type`/`code` 反推 status,限流能被引擎重试认出);**未收到 `[DONE]` 也无 `finish_reason`**(流被上游/代理截断 —— 哪怕已吐出半句、有累积文本,也按不完整响应抛错,不报 `end_turn`);正常终止却无任何文本与工具调用(与非流式空 `choices` 同一守卫)。**例外**:`finish_reason=content_filter` 的空流是合法 refusal,不抛 —— 与非流式路径同一个响应同一个结论 |
1430
1712
  | OpenAI 兼容端点回 legacy `function_call` 形态时**不支持** | 适配器只认现代 `tool_calls`(请求侧也只发这个形态)。收到 `finish_reason=function_call` 会**响亮失败**(400,落 `api`/不可重试),**不**按 `end_turn` 收尾 —— 那种回法里的调用在 `message.function_call` 里、读不出来,报成正常收尾会让「模型要调工具、工具却没执行」记成成功。换支持 `tool_calls` 的端点或模型即可(legacy `functions` 形态 OpenAI 2023 已废弃) |
1431
1713
  | MCP 只做 tools | `sampling`(server 反向请求模型)/ `resources` / `prompts` 原语不做;出厂连接器同样只做 `tools/list` + `tools/call` |
1714
+ | MCP 反向桥不校验会话(无状态 server) | `createMcpServer` 的 http 模式:`initialize` 铸 `mcp-session-id` 头但后续请求**不带也服务**(宽容是有意的);GET → 405、DELETE → 200;鉴权只有 `auth` 钩子一道(stdio 信任父进程)。范围同样只到 tools(与正向桥同一张 YAGNI 清单) |
1432
1715
  | MCP 的协议层错误框架看不见 | `isError: true` 只有连接器能看见 —— 它必须转成抛错,否则模型收到的是一条「成功」的结果(出厂连接器已代你处理) |
1433
1716
  | MCP 超时同样是「不等了」 | 桥的 `timeoutMs` 取消不了 server 侧执行(拿不到取消句柄);它只是**兜底** —— 引擎设了 `toolTimeoutMs` 时**不参与**判定(一次调用只有一个裁判;**显式 `toolTimeoutMs: 0` 也算设了** —— 那是引擎表态「不限」,桥不会再自作主张判 60s),两条路径**同判定、同账**(`errorKind='timeout'`) |
1434
1717
  | 已中止的 MCP 调用**不发请求** | 信号在**发送前**就已中止 ⇒ 立刻以 `AbortError` 收场,请求不出门(2026-09-21 前是「照样 write、Promise 永不 settle」:副作用真送达、调用方永久挂起)。发送**之后**才中止的,请求已在路上、取消不了 server 侧的执行,但返回的 Promise 同样立刻以 `AbortError` 收场、簿记同步清掉 —— 直接 await 连接器 API 的宿主不会永久挂起(2026-09-22 前是「只删簿记不 reject」:Promise 永不 settle,与 HTTP 连接器的在途中止行为不一致);取消不了的只是 server 侧执行,这点与 `toolTimeoutMs` 同口径 |
@@ -1447,7 +1730,7 @@ const callable = {
1447
1730
  | 模型 / 评分维度也有基数上限 | `maxModels`(缺省 50)/ `maxScores`(缺省 200):`model` 与评分键(`name@source`)都可能是无界键(上游把版本号拼进模型 id、eval 名带时间戳),而每个模型键都持一份时长窗口 —— 光是能力封顶不够。折叠只丢标签粒度,`__other__` 桶照常累加,总数仍对得上;被折叠的不同键数见 `droppedModels` / `droppedScores` |
1448
1731
  | 提示词版本只是标记 | 框架不存版本库、不回滚:`version` 只落 run 根 attribute;`system` 传已拼好的 `SystemParam` 时无版本可记 |
1449
1732
  | `agentia harvest` 的产物是轨迹骨架 | trace **不记 assistant 文本**(llm.turn 只记 usage/事件),故 harvest 用例脚本里的 text 块是占位、预填 `expect` 是从原 trace 抄录的实际轨迹 —— 脚手架不是成品,人工核对后再进 CI(见 §6「线上 trace 回流」) |
1450
- | 分叉重放不是续跑 | `forkMessages` 与 `traceToMessages` / harvest **同源有损**:trace 不记 assistant 文本与 run 原始输入(重放里 assistant 是标注占位、首尾 user 是合成),也不记 blackboard(分叉种子经 `RunInvocationOptions.blackboard` 自带);它产出喂回 `app.run` 的 messages、起的是**新 run**,不是接着原 run 的循环位置跑 |
1733
+ | 分叉重放不是续跑 | `forkMessages` 与 `traceToMessages` / harvest **同源有损**:trace **缺省**不记 assistant 文本(opt-in 例外:`traceContent: 'full'`,见 §4)与 run 原始输入(重放里 assistant 是标注占位、首尾 user 是合成),也不记 blackboard(分叉种子经 `RunInvocationOptions.blackboard` 自带);它产出喂回 `app.run` 的 messages、起的是**新 run**,不是接着原 run 的循环位置跑 |
1451
1734
  | 评分来自 run 之外 | `Score` 走 run 根 `score` **事件**而非 span 字段(评分通常在 run 跑完后才产生);`attachScore` 找不到根 span 时静默忽略,多次调用即多条事件(不同维度各记各的) |
1452
1735
  | 链路关联:入站自动、**出站只给读取器** | `traceContext` / `traceparent` 头把**上游**接进来(run 根的 `links`);出站方向给 `currentTraceparent()`(当前 span 的 W3C 串,回合 / 能力调用粒度),但框架**不替你做注入** —— 它不创建出站请求,那一行由宿主的 `fetch` / metadata 自己写。两个边界:① `run` 根 span 由 `runAgent` 打开 ⇒ 更早的 `contextInit` / 记忆水合取到 `undefined`(那时确实没有 span);② flags 恒 `00`(本框架不采样)。id 宽度经**同一份投影**压到 16 位(与 OTLP 导出共用,单一真源)—— 故下游收到的 span id 与 collector 里的是同一个数。另:link 只落在 run 根(子 span 不散),且**一进程内**不跨进程自动传播 —— 队列场景要自己把 `traceContext` 传下去(HTTP 头带走,或随 `TaskRecord.spec.options` 落库) |
1453
1736
  | 配额不是框架子系统 | 只给缝(middleware + TraceSink + BudgetGuard),计数放哪(内存 / Redis / DB)与超限怎么办都是你的策略 |
@@ -1458,12 +1741,13 @@ const callable = {
1458
1741
  | 挂起/恢复间预算重新起算 | `maxTotalTokens` / `maxCostUsd` 在恢复段从 0 重新计(新树新账,与 `resumePending` 续跑同口径) |
1459
1742
  | 恢复段的会话回写是进程内快照 | 带 `sessionId` 的任务挂起时,「本轮用户输入」快照只存进程内存(恢复段由 AsyncRunner 自己补写「用户输入 + 最终回复」,不再经 run 层重复拼历史)—— 进程崩在「挂起 → 重启 → approve」之间会**丢这一次会话回写**(会话少一轮,但绝不写进坏历史;审批决定本身已落库) |
1460
1743
  | 嵌套能力内的审批不支持挂起 | @SubAgent / @Skill 子循环里的 `approval: 'required'` 工具无法把整个 run 挂起 —— 子循环挂起会以 `is_error` 交回主 agent(要审批的能力请放主菜单) |
1461
- | 同步 `/run` 撞上审批没人可批 | 同步 RPC 会带着 `stopReason: 'awaiting_approval'` 收尾返回 —— 但响应体(`toHttpBody`)**不含** `suspendedMessages`,也没有任务记录可审批(待决清单只能去 trace 的 `approval.requested` 事件里看)。**要审批请走 `POST /tasks` 异步宿主** |
1744
+ | 同步 `/run` 撞上审批没人可批 | 同步 RPC 会带着 `stopReason: 'suspended'` 收尾返回 —— 但响应体(`toHttpBody`)**不含** `suspendedMessages`,也没有任务记录可审批(待决清单只能去 trace 的 `approval.requested` 事件里看)。**要审批请走 `POST /tasks` 异步宿主** |
1462
1745
  | Scheduler 调度表不落库 | `every` / `at` 的调度本身只在内存:已 submit 的任务记录能经 `resumePending` 续跑,但「未来某刻再触发」的调度在重启后不存在(远期单发由宿主自己的 cron 驱动)。另:`drain()` 不停 Scheduler —— 停机窗口内到点的 tick 会打一条触发失败日志(无害但吵),介意就 `scheduler.stop()` 先行 |
1463
1746
  | file store 的撕裂写只在启动时自愈 | 写入中途失败(磁盘满等)留下的残行由 `healTail` 在**构造期**修复;同进程内继续 append 会把新记录粘在残行尾部、下次启动时一起丢弃 —— 磁盘满告警后先恢复写入能力再继续依赖它 |
1464
1747
  | 终态落库失败 ⇒ 重启会重跑 | AsyncRunner 终态 `save` 失败**不遮罩主流程**(「不击穿业务」的代价):store 抖动时任务可能永远停在 `running`,重启后 `resumePending` 会重跑一个**实际已成功**(副作用已发生)的任务 —— 所以副作用工具必须自身幂等。**但失败本身不再静默**:`new AsyncRunner(app, { onPersistError })` 会收到 `{ record, error, phase }`(`phase: 'initial' \| 'outcome'`,后者就是这条)。⚠️ 框架**修不了**它(写不进去就是写不进去)—— 出口的职责是让你能对账、让「记录无声丢失」不再是默认行为(与 `createOtlpExporter({ onExportError })` 同因同形) |
1465
1748
  | `contextPolicy` 不进子循环 | 应用级 `contextPolicy` / `onText` 只对主循环生效:@SubAgent / @Skill 的子运行不做上下文裁剪(长跑子 agent 撞上下文上限会以 api 错误收尾)。预算护栏(`maxTotalTokens` / `maxCostUsd`)正常透传 |
1466
1749
  | 记忆没有删除语义 | `MemoryStore` 只有 load/save:run 内 `ctx.delete` 掉的键回写时不会从 store 移除(下一轮水合会复活)。要真删请直接操作 store 实现 |
1750
+ | 同 keys 的并发回写不合并 | CAS(`loadWithRev` + `saveIfRev`)只做到「冲突时**不写 + 出声**」:框架**不合并、不重试**(合并语义是 store 与宿主的决定,配方见 §6.5)。只有 `load` / `save` 的 store 连冲突都**看不见** —— 版本号是唯一依据,没有它就检测不了覆盖;`InMemoryMemoryStore` 的版本粒度是**整店一个计数**(宁可多报冲突,不漏报真实覆盖) |
1467
1751
  | 内容护栏不给实现 | 同「配额」:只给缝(入参包 `app.run` / 工具前 `middleware` / 出参包返回值或 `sinks`),策略(正则 / 分类器 / 外部 API)是你的 |
1468
1752
  | 框架不执行模型生成的代码 | 无沙箱可言:`@Skill` 跑你写的方法、`@Tool` 是你写的函数,模型输出只成文本 / `tool_result`;代码执行工具的隔离是**工具实现内部**的事;工具起的子进程取消时要自己 kill |
1469
1753
 
@@ -1484,6 +1768,7 @@ const callable = {
1484
1768
  | 长跑内存涨 | 缺省内存 store 不淘汰;设 `InMemoryTaskStore({ maxRecords })` 或换耐久 store |
1485
1769
  | 鉴权钩子抛错,客户端只看到「未通过鉴权」 | 这是设计:非 `HttpException` 的错误原文只进服务端日志(要回给调用方就抛 `HttpException(status, body)`) |
1486
1770
  | 停机后 `POST /tasks` 回 503 | `drain()` 已被调用(或注入的 runner 已 drain)—— 这是「拒新单」的正常行为,任务没丢 |
1771
+ | `drain()` 之后有些任务停在 `running` 不动 | **宿主没退出**。`drain()` 是**单向闩**:返回之后本进程不再推进任何任务,且没有任何复位路径。停机窗口里被拒掉的记录已落库(`running`/`queued` + **本进程的 ownerId**)⇒ 本进程的 `resumePending` 按 `own-process` 跳过、`submit` 已整体关闭 ⇒ **本进程内没有自愈路径**,只能等**下一次启动**认领(ownerId 含 pid)。所以 `drain()` 返回 `false`(排空超时)同样意味着「必须退出」——参考宿主都是 `await drain(...)` 后 `process.exit(...)`。首次被拒时会打一条 `[agentia] 停机中:…` 的 `console.warn`(可 grep),别忽略它 |
1487
1772
  | `stopReason` 是 `budget_exceeded`、任务被判失败 | 这是设计(护栏拦下的 run **没跑完**)。只想「记一笔」不想改结局,就自己用 `createBudgetGuard` 读 trace |
1488
1773
  | 工具超时了,副作用却还是发生了 | 超时是「放弃等待」不是强制取消。引擎放弃时会 abort `ToolRunContext.abandoned`(@SubAgent/@Skill 已靠它自中止)—— 你自己的工具要真停就监听它 |
1489
1774
  | 用 OpenAI 端点没看到打字机效果 | 端点没按 `stream:true` 回 `event-stream`(回了一整份 JSON)—— 适配器按响应形态解析,此时退回一次性 |