@migor/agentia 0.6.0 → 0.6.2

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 (94) hide show
  1. package/CHANGELOG.md +343 -0
  2. package/README.md +1 -2
  3. package/dist/AGENTS.md +256 -191
  4. package/dist/core/abort.js +4 -1
  5. package/dist/core/abort.js.map +1 -1
  6. package/dist/core/sse.d.ts +14 -0
  7. package/dist/core/sse.js +45 -0
  8. package/dist/core/sse.js.map +1 -0
  9. package/dist/core/stats.d.ts +18 -0
  10. package/dist/core/stats.js +24 -0
  11. package/dist/core/stats.js.map +1 -0
  12. package/dist/core/text.d.ts +19 -0
  13. package/dist/core/text.js +8 -0
  14. package/dist/core/text.js.map +1 -0
  15. package/dist/core/timeout.d.ts +92 -0
  16. package/dist/core/timeout.js +157 -0
  17. package/dist/core/timeout.js.map +1 -0
  18. package/dist/core/tool.d.ts +11 -0
  19. package/dist/core/trace.d.ts +54 -0
  20. package/dist/core/trace.js +43 -0
  21. package/dist/core/trace.js.map +1 -1
  22. package/dist/engine/concurrency.d.ts +6 -30
  23. package/dist/engine/concurrency.js +14 -65
  24. package/dist/engine/concurrency.js.map +1 -1
  25. package/dist/engine/errors.js +14 -4
  26. package/dist/engine/errors.js.map +1 -1
  27. package/dist/engine/loop.js +16 -1
  28. package/dist/engine/loop.js.map +1 -1
  29. package/dist/engine/replay.js +4 -1
  30. package/dist/engine/replay.js.map +1 -1
  31. package/dist/engine/retry.d.ts +17 -2
  32. package/dist/engine/retry.js +41 -22
  33. package/dist/engine/retry.js.map +1 -1
  34. package/dist/engine/spec.d.ts +22 -0
  35. package/dist/engine/spec.js +19 -4
  36. package/dist/engine/spec.js.map +1 -1
  37. package/dist/engine/tracer.d.ts +6 -1
  38. package/dist/engine/tracer.js +17 -2
  39. package/dist/engine/tracer.js.map +1 -1
  40. package/dist/engine/trimming.d.ts +3 -1
  41. package/dist/engine/trimming.js +35 -3
  42. package/dist/engine/trimming.js.map +1 -1
  43. package/dist/engine/turn.d.ts +8 -0
  44. package/dist/engine/turn.js +39 -18
  45. package/dist/engine/turn.js.map +1 -1
  46. package/dist/engine/types.d.ts +7 -1
  47. package/dist/engine/usage.js +4 -1
  48. package/dist/engine/usage.js.map +1 -1
  49. package/dist/eval/defineEval.d.ts +3 -0
  50. package/dist/eval/defineEval.js +42 -12
  51. package/dist/eval/defineEval.js.map +1 -1
  52. package/dist/eval/harvest.d.ts +0 -7
  53. package/dist/eval/harvest.js +20 -9
  54. package/dist/eval/harvest.js.map +1 -1
  55. package/dist/index.d.ts +3 -3
  56. package/dist/index.js +2 -2
  57. package/dist/index.js.map +1 -1
  58. package/dist/integrations/anthropic.js +35 -66
  59. package/dist/integrations/anthropic.js.map +1 -1
  60. package/dist/integrations/mcp.d.ts +13 -4
  61. package/dist/integrations/mcp.js +31 -24
  62. package/dist/integrations/mcp.js.map +1 -1
  63. package/dist/integrations/metrics.d.ts +35 -5
  64. package/dist/integrations/metrics.js +101 -39
  65. package/dist/integrations/metrics.js.map +1 -1
  66. package/dist/integrations/openai.d.ts +13 -0
  67. package/dist/integrations/openai.js +62 -49
  68. package/dist/integrations/openai.js.map +1 -1
  69. package/dist/integrations/otlp.js +41 -11
  70. package/dist/integrations/otlp.js.map +1 -1
  71. package/dist/integrations/report.js +2 -14
  72. package/dist/integrations/report.js.map +1 -1
  73. package/dist/runtime/run.d.ts +21 -1
  74. package/dist/runtime/run.js +17 -0
  75. package/dist/runtime/run.js.map +1 -1
  76. package/dist/runtime/session.js +5 -1
  77. package/dist/runtime/session.js.map +1 -1
  78. package/dist/toolkit/env.js +43 -7
  79. package/dist/toolkit/env.js.map +1 -1
  80. package/dist/toolkit/module.d.ts +24 -1
  81. package/dist/toolkit/module.js +12 -0
  82. package/dist/toolkit/module.js.map +1 -1
  83. package/dist/toolkit/prompt.js +18 -4
  84. package/dist/toolkit/prompt.js.map +1 -1
  85. package/dist/toolkit/skill.js +3 -0
  86. package/dist/toolkit/skill.js.map +1 -1
  87. package/dist/toolkit/subagent.js +7 -0
  88. package/dist/toolkit/subagent.js.map +1 -1
  89. package/dist/transport/async.d.ts +3 -0
  90. package/dist/transport/async.js +28 -2
  91. package/dist/transport/async.js.map +1 -1
  92. package/dist/transport/http.js +58 -7
  93. package/dist/transport/http.js.map +1 -1
  94. package/package.json +3 -2
package/dist/AGENTS.md CHANGED
@@ -2,10 +2,20 @@
2
2
 
3
3
  > 本文件是 **AI 辅助编码的权威入口**,也是人类速查表。
4
4
  > 仓库根部的 `AGENTS.md` 讲的是「怎么改这个仓库」;**本文件讲的是「怎么用这个框架」**。
5
- > 文中所有 API 名与选项名都由框架仓库的测试对着源码校验 —— 改名会立刻失败,不会静默过期。
6
5
 
7
6
  包名:`@migor/agentia`(框架)/ `@migor/cli`(命令行)。Node ≥ 18,ESM,TypeScript 7。
8
7
 
8
+ **目录**
9
+ - [0. 心智模型(先读这一段)](#0-心智模型先读这一段)
10
+ - [1. 最小可运行示例](#1-最小可运行示例)
11
+ - [2. 项目结构(CLI 约定)](#2-项目结构cli-约定)
12
+ - [3. 装饰器 spec 字段速查](#3-装饰器-spec-字段速查)
13
+ - [4. createApp 与 app.run 选项](#4-createapp-与-apprun-选项)
14
+ - [5. 类型链路(这块决定「编辑器给不给提示」)](#5-类型链路这块决定编辑器给不给提示)
15
+ - [6. 运行时 API](#6-运行时-api)
16
+ - [7. 已知边界(如实标注,不要指望框架替你兜)](#7-已知边界如实标注不要指望框架替你兜)
17
+ - [8. 常见错误](#8-常见错误)
18
+
9
19
  ---
10
20
 
11
21
  ## 0. 心智模型(先读这一段)
@@ -177,6 +187,7 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
177
187
  | `client` | 注入 `ModelClient`(换 OpenAI 兼容端点等) |
178
188
  | `onText` | 文本增量回调(SSE/终端) |
179
189
  | `signal` | `AbortSignal`:中止则在飞请求被取消,run 以 `stopReason='aborted'` 收尾(算失败) |
190
+ | `traceContext` | 入站链路上下文 `{ traceId, spanId? }`:触发本次 run 的上游 span 记成 run 根的一条 `links`(不改 `traceId == runId`)。HTTP 宿主认 `traceparent` 头,自动填 —— 见 §6「跨进程关联」 |
180
191
  | `blackboard` | 预置黑板种子(配 `Blackboard` 声明合并有键补全) |
181
192
  | `contextPolicy` | 单次覆盖上下文策略 |
182
193
  | `retry` | 单次覆盖重试策略:`false` 关闭,或 `{ maxAttempts, baseDelayMs, maxDelayMs, jitter, onRetry }` 调参(只重试「本次尝试尚未产出文本」的可重试失败) |
@@ -192,6 +203,8 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
192
203
  | `maxToolConcurrency` | 同回合并行工具上限;缺省不限 |
193
204
  | `maxEventChars` | trace 事件正文截断上限(字符):数字 = 入参/出参统一用该上限,`false` = **不截断**;缺省按类型收敛(入参/成功出参 2000、失败出参 1000)。**透传给子 agent/skill 的子循环** —— 同一棵调用树上口径一致。只影响**记账**,回给模型的 tool_result 永远完整 |
194
205
  | `session` | 会话持久化 `{ store, id }`:run 前拼历史、成功收尾追加本轮(见 `SessionStore`) |
206
+ | `memory` | 跨 run 记忆 `{ store, keys }`:run 前水合进 blackboard(用户种子优先)、收尾写回;与 `session` 正交(见 `MemoryStore`) |
207
+ | `beforeFlush` | `(trace, result) => void \| Promise<void>`:**sinks 冲刷之前**的最后一笔(run 正常收尾后调一次,抛错被吞)。给「**拿到结果才判得出**的结论」用的缝 —— 典型是 `defineEval` 的 score:等 `app.run` 返回再 `attachScore`,`metricsSink` 早在导出那一刻聚完账,分数就永远进不了指标。读 trace 就够的判断不必用它,写进 sinks 里即可(见 §6 判官配方) |
195
208
 
196
209
  返回 `AgentRunOutput`:`{ run, result }`。`result` 含 `trace` / `stopReason` / `finalText` / `iterations` / `error` / `typed`。
197
210
 
@@ -274,7 +287,11 @@ Anthropic Messages API 逐字对齐(snake_case),并与 SDK 的对应类型
274
287
 
275
288
  ## 6. 运行时 API
276
289
 
277
- ### `RunContext`(`RunContext.current()`,run 内任意异步上下文可取)
290
+ ### 6.1 运行时上下文与装配
291
+
292
+ run 内取上下文(黑板 / `@Skill` 第二参)、把装饰器装配成 app、环境变量与 `.env` 的读法。
293
+
294
+ #### `RunContext`(`RunContext.current()`,run 内任意异步上下文可取)
278
295
 
279
296
  | 成员 | 说明 |
280
297
  |---|---|
@@ -285,14 +302,14 @@ Anthropic Messages API 逐字对齐(snake_case),并与 SDK 的对应类型
285
302
  | `delete` | 删除键 |
286
303
  | `keys` | 当前全部键 |
287
304
 
288
- ### `SkillContext`(`@Skill` 方法第二参)
305
+ #### `SkillContext`(`@Skill` 方法第二参)
289
306
 
290
307
  | 成员 | 说明 |
291
308
  |---|---|
292
309
  | `model` | 本次技能的缺省模型 |
293
310
  | `llm` | 受限子运行:`await ctx.llm({ prompt })` / `{ messages, system, model, maxTokens, maxIterations }` |
294
311
 
295
- ### 装配与执行
312
+ #### 装配与执行
296
313
 
297
314
  | API | 说明 |
298
315
  |---|---|
@@ -308,7 +325,7 @@ Anthropic Messages API 逐字对齐(snake_case),并与 SDK 的对应类型
308
325
  | `isSuccessStopReason` | `end_turn` / `stop_sequence` 都算正常收尾 |
309
326
  | `resolveDefaultModel` | 显式 > `AGENTIA_MODEL` > `claude-opus-5` |
310
327
 
311
- ### 环境变量与 `.env`(`loadEnvFile`)
328
+ #### 环境变量与 `.env`(`loadEnvFile`)
312
329
 
313
330
  | API | 说明 |
314
331
  |---|---|
@@ -333,7 +350,11 @@ const app = await createApp({ ... });
333
350
 
334
351
  解析规则(刻意窄,够用就好):`KEY=VALUE`,允许 `export ` 前缀与 `=` 两侧空白;`#` 整行注释;单引号内原样、双引号内认 `\n \r \t \" \\`;未加引号的值里 ` #` 起为行内注释。键名 `__proto__` **显式报错**(它会走原型 setter 被静默吞掉 —— 正是「以为配上了其实没配上」)。**不做变量展开、不合并续行**(需要就上专门的库);既不像 `KEY=VALUE` 又不是注释的行**直接报错并指出行号** —— 静默跳过等于让你以为「配上了其实没配上」。
335
352
 
336
- ### 宿主(换宿主不换语义)
353
+ ### 6.2 触发与宿主
354
+
355
+ 同一个 app 换宿主不换语义:HTTP handler、异步任务、定时、取消与并发闸门。
356
+
357
+ #### 宿主(换宿主不换语义)
337
358
 
338
359
  | API | 说明 |
339
360
  |---|---|
@@ -348,7 +369,7 @@ const app = await createApp({ ... });
348
369
  | `SqliteTaskStore` | `node:sqlite` 耐久存储(WAL + busy_timeout) |
349
370
  | `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 |
350
371
 
351
- ### HTTP 端点速查(`createHttpHandler` 的路由)
372
+ #### HTTP 端点速查(`createHttpHandler` 的路由)
352
373
 
353
374
  | 端点 | 请求 | 响应 |
354
375
  |---|---|---|
@@ -374,7 +395,7 @@ const handler = createHttpHandler(callable, { runner });
374
395
  (**不随 npm 包发布** —— 包里只有 `dist/`、README、LICENSE 与本说明):
375
396
  <https://github.com/retrychx/agentia/tree/main/examples/complete>
376
397
 
377
- ### `createHttpHandler(app, opts?: HttpHandlerOptions)`
398
+ #### `createHttpHandler(app, opts?: HttpHandlerOptions)`
378
399
 
379
400
  | 选项 | 说明 |
380
401
  |---|---|
@@ -408,7 +429,7 @@ process.on('SIGTERM', async () => {
408
429
  });
409
430
  ```
410
431
 
411
- ### `GET /healthz` → `HealthResponse`
432
+ #### `GET /healthz` → `HealthResponse`
412
433
 
413
434
  | 字段 | 说明 |
414
435
  |---|---|
@@ -419,7 +440,7 @@ process.on('SIGTERM', async () => {
419
440
 
420
441
  **不鉴权**(探针带不了凭据),且停机中也照回 200。非 `GET` 回 405。
421
442
 
422
- ### 取消 / 重试 / 流式 / 并发闸门
443
+ #### 取消 / 重试 / 流式 / 并发闸门
423
444
 
424
445
  | API | 说明 |
425
446
  |---|---|
@@ -431,37 +452,13 @@ process.on('SIGTERM', async () => {
431
452
  - **取消**:`app.run(messages, { signal })` 传 `AbortSignal` —— 框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发 `signal`),run 以 `stopReason='aborted'` 收尾(算失败)。`createHttpHandler` 已内置「客户端断开即中止」;`AsyncRunner.runTimeoutMs` 到点同样是**真中止**(构造期校验:必须 ≥ 0 的**有限**数 —— NaN/Infinity 会被 `setTimeout` 钳到 1ms,等于每个任务立即超时,故直接抛错;要「不限」传 0 或不设)。
432
453
  - **重试**:缺省自动重试可重试失败(429 / 5xx / 连接失败),指数退避 + 抖动。`retry: false` 关闭,或 `retry: { maxAttempts, baseDelayMs, maxDelayMs, jitter, onRetry }` 调参。**只在本次尝试尚未产出任何文本时重试**(已吐出的字无法撤回)。⚠️ 与底层 client 的内置重试叠加(默认 client 的 `maxRetries` 缺省 2)—— 建议二选一调(这里 `maxAttempts: 1`,或 `createAnthropicClient({ maxRetries: 0 })`)。
433
454
  - **流式**:`POST /run` 带 `Accept: text/event-stream` → SSE 逐帧下发(`text.delta` / `run.end` / `error`);不带该头仍回一元 JSON。
434
- - **工具超时 / 并发闸门**:`toolTimeoutMs` 超时**不杀 run**(该条 tool_result 记 `is_error`,模型可换路);`maxToolConcurrency` 给同回合的并行工具设上限(默认全并行)。⚠️ 超时 = **放弃等待**,`AgentTool.run` 没有 signal 参数,**副作用可能已发生** —— 想真停的工具请自行读 `ToolRunContext.signal`。
455
+ - **工具超时 / 并发闸门**:`toolTimeoutMs` 超时**不杀 run**(该条 tool_result 记 `is_error`,模型可换路);`maxToolConcurrency` 给同回合的并行工具设上限(默认全并行)。⚠️ 超时 = **放弃等待**,`AgentTool.run` 没有 signal 参数,**副作用可能已发生** —— 想真停的工具请自行读 `ToolRunContext.signal`。**超时判定只有一个裁判**:`toolTimeoutMs` 是唯一判据 —— 工具自带的超时(如 MCP 桥的 `timeoutMs`)在设了本项时**不参与**判定;反过来说,工具自判的超时(抛 `code='timeout'` 的错误)与引擎判的记**同一类账**(`errorKind='timeout'`),并同样回 `is_error`。
435
456
 
436
- ### 观测
457
+ ### 6.3 上下文预算与成本
437
458
 
438
- | API | 说明 |
439
- |---|---|
440
- | `TraceSink` | `{ export(trace) }`,run 收尾(成功/失败)都投递,抛错被吞 |
441
- | `registerDefaultTraceSink` | 注册全局默认 sink(构造期快照合并) |
442
- | `TraceRecorder` | 内存 recorder(一次 run 一个) |
443
- | `createOtlpExporter` | OTLP/JSON 导出,零依赖;选项 `OtlpExporterOptions`:`endpoint` / `headers` / `serviceName` / `timeoutMs`(单次导出超时,缺省 10000,非正数 = 不限 —— 裸 fetch 无超时,collector 半开连接会让 run 收尾永久挂起;超时按导出失败处理,不击穿 run) |
444
- | `metricsSink` | 指标累加器(Prometheus 文本 / OTLP metrics),满足 `TraceSink` 即接入 —— 见 §6「指标」 |
445
- | `buildRunReport` | 从一条 trace 生成**调优报告**(能力/模型的耗时、token、成本、错误率排行)—— 见 §6「调优报告」 |
446
- | `Score` | 质量评分:`{ name; value; source?; comment? }` —— LLM-judge / 人工标注 / eval 结论挂到 trace 上;约定 `value` 为 0–1(布尔结论用 0/1),`source` 记评分来源(eval 名 / `'human'` / judge 模型 id) |
447
- | `attachScore` | `attachScore(trace, score)`:把评分挂到 run 根 span(一条 `score` 事件,body 即 `Score`)。评分通常来自 run **之外**(跑完才评),所以走事件而非 span 字段;trace 找不到根 span 时静默忽略(观测不击穿业务) |
459
+ 长上下文怎么裁、成本怎么硬停 —— 两套独立旋钮,各自的选项表跟在对应小节后。
448
460
 
449
- **评分链路**(R7 质量闭环):`attachScore` 写 run 根 `score` 事件 → OTLP 导出时译为 `gen_ai.evaluation.result`
450
- (`gen_ai.evaluation.score.name` / `.value`,`source` / `comment` 走自有 `agentia.score.*` 键)→
451
- `metricsSink` 聚合成 `agentia_score` 指标族(见 §6「指标」)。eval / 在线评估怎么用见 §6「evals」与「在线评估采样」。
452
-
453
- **OTLP 的 `gen_ai.*` 对齐**(R7,对齐 OTel GenAI semconv **v1.37**,**additive** —— 只追加 `gen_ai.*` 键,既有 `usage.*` 等键一律保留):
454
- run 根 → `gen_ai.operation.name=invoke_agent` + `gen_ai.agent.name`(attributes 有 `session.id` 时另发 `gen_ai.conversation.id`);
455
- `llm.turn` → `gen_ai.operation.name=chat` + `gen_ai.request.model` + `gen_ai.usage.input_tokens` / `output_tokens`;
456
- capability span 按前缀分:`subagent:*` → `invoke_agent` + `gen_ai.agent.name`,`skill:*` → `execute_tool` + `gen_ai.tool.name`;
457
- `score` 事件 → `gen_ai.evaluation.result`。映射集中在 `createOtlpExporter` 一处,下游(Langfuse / Grafana / Datadog)按 1.37+ 识别这批键做 GenAI 专项视图。
458
-
459
- > **生产落地**(按 runId 落库检索 / 日志关联 / 采样 / 脱敏)见 `docs/observability.md` ——
460
- > 框架只保证 trace 出口,这些都在缝外用 sink 组合;四条现成 sink 的实码在
461
- > `examples/observability/`。**完整的示例**(四类能力 + 三种触发 + 鉴权 + 全观测栈)在 `examples/complete/`;
462
- > 最小可交付示例(Dockerfile + compose)在 `examples/deploy/`。
463
-
464
- ### 长上下文
461
+ #### 长上下文
465
462
 
466
463
  | API | 说明 |
467
464
  |---|---|
@@ -477,7 +474,7 @@ capability span 按前缀分:`subagent:*` → `invoke_agent` + `gen_ai.agent.n
477
474
  引擎在每条 run 开始时调一次,拿**本 run 专用**的实例(`createBudgetPolicy` 已实现它);
478
475
  不实现的自定义策略按单例复用,状态跨 run(含并发 run)共享 —— 适合无状态策略,有状态请实现 `forRun`。
479
476
 
480
- ### 成本硬管控(**别与上面的上下文预算混为一谈**)
477
+ #### 成本硬管控(**别与上面的上下文预算混为一谈**)
481
478
 
482
479
  | API | 说明 |
483
480
  |---|---|
@@ -504,20 +501,20 @@ if (result.stopReason === 'budget_exceeded') console.warn('这次 run 被预算
504
501
 
505
502
  选项字段(`TrimOptions` / `CompactOptions` / `BudgetPolicyOptions`):
506
503
 
507
- ### `TrimOptions`(`trimToolPairs` 的选项)
504
+ #### `TrimOptions`(`trimToolPairs` 的选项)
508
505
 
509
506
  | 字段 | 说明 |
510
507
  |---|---|
511
508
  | `keepToolPairs` | 保留的最近工具**对数**(tool_use→tool_result);缺省 1 |
512
509
 
513
- ### `CompactOptions`(`compactMessages` 的选项)
510
+ #### `CompactOptions`(`compactMessages` 的选项)
514
511
 
515
512
  | 字段 | 说明 |
516
513
  |---|---|
517
514
  | `keepRecent` | 保留的最近消息**条数**;缺省 20 |
518
515
  | `summarize` | 摘要器:输入被弃旧前缀的渲染文本,返回摘要(框架不替你造 token) |
519
516
 
520
- ### `BudgetPolicyOptions`(`createBudgetPolicy` 的选项)
517
+ #### `BudgetPolicyOptions`(`createBudgetPolicy` 的选项)
521
518
 
522
519
  | 字段 | 说明 |
523
520
  |---|---|
@@ -531,141 +528,70 @@ if (result.stopReason === 'budget_exceeded') console.warn('这次 run 被预算
531
528
 
532
529
  > `keepRecent`(**消息条数**,compaction 用)与 `keepToolPairs`(**工具对数**,context editing 用)是两种单位,刻意分开命名 —— 别拿同一个值套过去。
533
530
 
534
- ### 多模型 / 记忆 / 重放
531
+ ### 6.4 观测与调优
535
532
 
536
- | API | 说明 |
537
- |---|---|
538
- | `createAnthropicClient` | 默认 ModelClient(Anthropic):自定义只传 `apiKey` / `baseURL`;框架**零运行时依赖**,不装厂商 SDK |
539
- | `createOpenAIClient` | OpenAI 兼容端点适配(DeepSeek 等;**真流式**、图片块转 `image_url`、cache token 恒 0) |
540
- | `InMemoryMemoryStore` | 跨 run 的**键值黑板**记忆(`{ store, keys }` 配 `executeRun`) |
541
- | `InMemorySessionStore` | 跨 run 的**对话历史**(`{ store, id }` 配 `executeRun` / `app.run`);与前者正交,可同时用 |
542
- | `traceToMessages` | 把 trace 还原成 messages(重放基底) |
543
- | `forkMessages` | 分叉重放:在主循环第 `atTurn` 回合之前截断重放历史、拼上 `append` 新消息喂回 `app.run`(「从第 N 回合换个问法重跑」的基底,**不是续跑**) |
544
- | `diffTraces` | 两条 trace 的 A/B 比对(prompt / 模型实验):run 级 summary + 逐 span 字段差;纯函数,llm.turn 配对**忽略模型名**,缺省忽略墙钟 |
545
- | `applyMiddleware` | 手动包裹配置菜单(装配层已自动做) |
533
+ trace 出去之后能干什么:指标、调用树面板、调优报告、生效配置快照、资产版本与会话标记。
546
534
 
547
- ### MCP 桥(MCP 是「工具来源」,不是新机制)
535
+ #### 观测
548
536
 
549
537
  | API | 说明 |
550
538
  |---|---|
551
- | `mcpTools` | 把 MCP server 的 `tools/list` 映射成框架 `AgentTool[]`(进 `createApp({ tools })`) |
552
- | `McpClientLike` | 最小结构面:`listTools()` + `callTool(name, args)`;框架**不 import** MCP SDK |
553
- | `MCP_DEFAULT_TIMEOUT_MS` | 桥的缺省单次调用超时(60000 ms) |
539
+ | `TraceSink` | `{ export(trace) }`,run 收尾(成功/失败)都投递,抛错被吞 |
540
+ | `registerDefaultTraceSink` | 注册全局默认 sink(构造期快照合并) |
541
+ | `TraceRecorder` | 内存 recorder(一次 run 一个);`addLink(spanId, { traceId, spanId? })` 记一条跨 trace 链路(见 §6「跨进程关联」) |
542
+ | `parseTraceparent` | 解析 W3C `traceparent` 头 → `{ traceId, spanId? }`;**非法 / 缺头一律返回 `undefined`**(不抛、不打 400)—— 结果直接交给 `traceContext` 选项,见 §6「跨进程关联」 |
543
+ | `createOtlpExporter` | OTLP/JSON 导出,零依赖;选项 `OtlpExporterOptions`:`endpoint` / `headers` / `serviceName` / `timeoutMs`(单次导出超时,缺省 10000,非正数 = 不限 —— 裸 fetch 无超时,collector 半开连接会让 run 收尾永久挂起;超时按导出失败处理,不击穿 run) |
544
+ | `metricsSink` | 指标累加器(Prometheus 文本 / OTLP metrics),满足 `TraceSink` 即接入 —— 见 §6「指标」 |
545
+ | `buildRunReport` | 从一条 trace 生成**调优报告**(能力/模型的耗时、token、成本、错误率排行)—— 见 §6「调优报告」 |
546
+ | `Score` | 质量评分:`{ name; value; source?; comment? }` —— LLM-judge / 人工标注 / eval 结论挂到 trace 上;约定 `value` 为 0–1(布尔结论用 0/1),`source` 记评分来源(eval 名 / `'human'` / judge 模型 id) |
547
+ | `attachScore` | `attachScore(trace, score)`:把评分挂到 run 根 span(一条 `score` 事件,body 即 `Score`)。评分通常来自 run **之外**(跑完才评),所以走事件而非 span 字段;trace 找不到根 span 时静默忽略(观测不击穿业务) |
554
548
 
555
- - **名字**:`prefix + 归一化原名`(MCP 名里的 `-` / `.` / 空格 → `_`)。归一化后**空名(原名不含任何 ASCII 字母/数字/下划线时产物为空,如全 emoji 名)/ 撞名 / 超 64 字符**一律**装配期抛错**(静默改名会得到一个调不回去的名字,比启动期报错难查得多)。
556
- - **原名**:每次调用写进发起 turn 的 `mcp.tool` attribute —— 审计 / 回放要还原它才能回调 server。
557
- - **入参 schema**:MCP 的 `inputSchema` 已是 JSON Schema → 原样透传,由 engine 的子集校验器在 `callTool` **之前**校验(非法入参根本不会发给 server,模型自己会改)。
558
- - **失败**:`callTool` 抛错 → 该条 `tool_result` 记 `is_error`,**不杀 run**(与本地工具抛错同语义)。⚠️ **协议层的 `isError: true` 框架看不见** —— 连接器必须转成抛错,否则模型以为成功了。
559
- - **连接器不在框架里**(守「零运行时依赖」):stdio / StreamableHTTP 归独立可选包,或你自己接 SDK 后实现 `McpClientLike`。本仓库 `scripts/e2e-mcp.ts` 有一份最小连接器可参考。
549
+ **评分链路**:`attachScore` 写 run 根 `score` 事件 → OTLP 导出时译为 `gen_ai.evaluation.result`
550
+ (`gen_ai.evaluation.name` / `.score.value`,`source` / `comment` 走自有 `agentia.score.*` 键)→
551
+ `metricsSink` 聚合成 `agentia_score` 指标族(见 §6「指标」)。eval / 在线评估怎么用见 §6「evals」与「在线评估采样」。
560
552
 
561
- ```ts
562
- // 任意实现了 listTools/callTool 的对象都能接(duck-typed,无需继承)
563
- const client: McpClientLike = myStdioConnector;
564
- const tools = await mcpTools(client, { server: 'time' }); // → mcp_time_get_current_time …
553
+ **OTLP 的 `gen_ai.*` 对齐**(对齐 OTel GenAI semconv **v1.37**,**additive** —— 只追加 `gen_ai.*` 键,既有 `usage.*` 等键一律保留):
554
+ run 根 → `gen_ai.operation.name=invoke_agent` + `gen_ai.agent.name`(attributes 有 `session.id` 时另发 `gen_ai.conversation.id`);
555
+ `llm.turn` → `gen_ai.operation.name=chat` + `gen_ai.request.model` + `gen_ai.usage.input_tokens` / `output_tokens`;
556
+ capability span 按 **attributes** 分(`subagent` / `skill`;span 的 `name` 是**裸能力名**):`subagent` → `invoke_agent` + `gen_ai.agent.name`,`skill` → `execute_tool` + `gen_ai.tool.name`;
557
+ `score` 事件 → `gen_ai.evaluation.result`。映射集中在 `createOtlpExporter` 一处,下游(Langfuse / Grafana / Datadog)按 1.37+ 识别这批键做 GenAI 专项视图。
565
558
 
566
- // 与本地 @Tool 同池:同过中间件链、同进重名查重
567
- const app = createApp({ system, providers: [...], tools });
568
- ```
559
+ > **生产落地**(按 runId 落库检索 / 日志关联 / 采样 / 脱敏)见 `docs/observability.md` ——
560
+ > 框架只保证 trace 出口,这些都在缝外用 sink 组合;四条现成 sink 的实码在
561
+ > `examples/observability/`。**完整的示例**(四类能力 + 三种触发 + 鉴权 + 全观测栈)在 `examples/complete/`;
562
+ > 最小可交付示例(Dockerfile + compose)在 `examples/deploy/`。
569
563
 
570
- ### `McpToolsOptions`(`mcpTools` 的选项)
564
+ #### 跨进程关联(这条 run 是谁触发的)
571
565
 
572
- | 字段 | 说明 |
573
- |---|---|
574
- | `prefix` | 工具名前缀;缺省 `mcp_<server>_`(没给 `server` 时 `mcp_`);`''` = 不加前缀(撞名自负) |
575
- | `server` | server 标识,只用于拼缺省前缀(不会发给 server) |
576
- | `timeoutMs` | 单次 `callTool` 超时(毫秒);缺省 60000,非正数 = 不限 |
566
+ 一次 run 仍是一条**自洽**的 trace(`traceId == runId` 的 1:1 不变量不变),但可以用一条 **link**
567
+ 把它接回上游 —— 触发它的那个 span(网关请求 / 队列消息 / 另一个服务)记在 run 根 span 的 `links` 上,
568
+ OTLP 导出时就是标准的 **span links**,后端能把因果边画出来。
577
569
 
578
- ### evals(把 mockClient 提升为一等能力)
570
+ **入站两种给法**:
579
571
 
580
- | API | 说明 |
572
+ | 场景 | 写法 |
581
573
  |---|---|
582
- | `scriptedClient` | 按脚本依次返回模型响应(**真把文本块经 `on('text')` 吐出去**);脚本耗时报错 |
583
- | `defineEval` | 定义「用例 + 断言」,`run()` 返回 `EvalReport` |
574
+ | HTTP 宿主(网关 / 消费者通过 HTTP 打过来) | 请求头 `traceparent: 00-<32位trace>-<16位span>-01`(W3C)—— `createHttpHandler` 自动解析,`POST /run` 与 `POST /tasks` 都认;**畸形头静默当作没有上游**,不会打 400 |
575
+ | 程序内直接调 `app.run` / `runner.submit` | `options.traceContext: { traceId, spanId? }` —— 也可写 `parseTraceparent(头值)` 自己解析 |
584
576
 
585
- - **为什么需要**:单测覆盖的是框架语义,evals 覆盖的是**你的 agent 语义** —— 改 prompt / 换模型 / 加工具之后有没有回归,靠断言而不是人眼。
586
- - 断言源是既有 `Trace`:「先 `search` 才 `summarize`」这类顺序断言全从 trace 读,框架不为此新增埋点。
587
- - `run()` **不抛**(用例失败进报告,一次跑完能看到所有回归,而不是修一个跑一次);只有「应用建不起来」才冒泡 —— 那是环境错误,不是回归。失败 case 带 `trace`,直接看现场。
588
- - `scriptedClient` 的步骤**在 `finalMessage()` 成功返回后才前进**:抛错的步骤(函数步骤 `throw` 模拟 429)会在重试时**重放同一步**,想验重试就这么写。
589
- - **用例结论自动落 score**(R7):每个用例跑完,结论以 `{ name: 'eval', value: 0|1, source: eval 名, comment: 失败原因 }` 自动 `attachScore` 到该用例的 trace —— eval 的 trace 自带质量结论,下游 sink / `metricsSink` 可直接聚合「这个 eval 的通过率」(`app.run` 抛错拿不到 trace 时不挂)。
577
+ **队列消费者(Kafka / RabbitMQ / SQS)的形态**:拿消息键当 `idempotencyKey`,把消息里带的
578
+ `traceparent` 一并传进去 —— 它随 `spec.options` 落进 `TaskRecord`,所以**另一个进程
579
+ `resumePending` 接着跑的那次 run 也带得上**,关联不断链:
590
580
 
591
581
  ```ts
592
- const ev = defineEval<{ summary: string }>({
593
- name: 'doc-review',
594
- app: () => createApp({ system: new SystemPrompt({ version: 'v3' }).add('role', R), providers: [...] }),
595
- // 每 case 可带 opts(透传 app.run):注入 resultSchema 就能断言 result.typed
596
- cases: [{ name: '先检索再总结', input: '总结这份文档', client: scriptedClient([searchMsg, submitMsg]) }],
597
- expect: (r, { trace }) => {
598
- assert.equal(r.stopReason, 'end_turn');
599
- const order = trace.spans.flatMap((s) => s.events)
600
- .filter((e) => e.name === 'tool.input')
601
- .map((e) => (e.body as { tool: string }).tool);
602
- assert.deepEqual(order, ['search', 'summarize']); // 顺序断言从 trace 读
603
- },
582
+ // 消费者回调里(不要 await run 跑完:位移提交点与 run 终态不是一个时刻,重复靠幂等键兜)
583
+ runner.submit(msg.value, {
584
+ idempotencyKey: msg.key,
585
+ options: { traceContext: parseTraceparent(msg.headers.traceparent) },
604
586
  });
605
- const report = await ev.run();
606
- if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
607
- ```
608
-
609
- ### 线上 trace 回流 eval 数据集(`agentia harvest`)
610
-
611
- 线上事故 → 回归用例:CLI 把 trace 落盘文件翻成 eval 用例脚手架。
612
-
613
- ```bash
614
- agentia harvest trace.jsonl # 全部记录 → 脚手架打到 stdout
615
- agentia harvest trace.jsonl --failed --limit 5 --out evals/harvested.ts
616
587
  ```
617
588
 
618
- - 输入同 `agentia report`:每行一个 JSON(裸 Trace,或含 `result.trace` / `trace` 的 TaskRecord,如 `FileTaskStore` 的导出);`--failed` 只留失败记录。
619
- - 产物是**可粘贴进 eval 文件的用例字面量**:`client: scriptedClient([...])` 按 trace 的主循环 llm.turn 逐回合重建,`expect` 预填「主循环工具序列」的轨迹断言(文件顶部附跑法注释)。
620
- - ⚠️ **脚手架不是成品,人工核对后再进 CI**:
621
- - **trace 不记 assistant 文本**(llm.turn 只记 usage/事件),脚本里的 text 块是占位 `'[harvest] assistant 文本未入 trace'`;
622
- - 只重建**直属 run 根**的主循环回合 —— 子 agent 的嵌套回合不走主循环脚本(要覆盖子 agent 请单独写 eval);
623
- - 预填的 `expect` 是从原 trace **抄录的实际轨迹** —— 发生过 ≠ 应该发生;
624
- - `EvalCase` 没有 `expect` 字段,粘贴时把断言搬进 `defineEval({ expect })`(脚手架注释会教)。
625
-
626
- ### prompt / 模型 A/B(trace diff 与分叉重放)
589
+ 异步任务可以在 `POST /tasks` 的 body 里显式给 `options.traceContext`,它优先于 `traceparent` 头。
627
590
 
628
- 同一份输入跑两条 run(换模型、换 `SystemPrompt` 版本、换 prompt 都行),用 `diffTraces` 比出**结构与成本差**;
629
- 想「从第 3 回合换个问法重跑」,用 `forkMessages` 在分叉点截断、拼上新消息喂回 `app.run`:
630
-
631
- ```ts
632
- import { diffTraces, forkMessages } from '@migor/agentia';
633
-
634
- // A/B:同输入,只换模型(或换 system 版本),各跑一条
635
- const a = await app.run(input, { model: 'claude-sonnet-5' });
636
- const b = await app.run(input, { model: 'claude-opus-5' });
591
+ > **只做入站**:框架**不生成**出站 `traceparent`(运行中没有「当前 span」这个概念,硬造一个会是假的
592
+ > spanId,比不做更糟)。要从 run 往外传,用结果里的 `runId`(== `traceId`)拼你自己的头,见 §7 已知边界。
637
593
 
638
- const diff = diffTraces(a.result.trace, b.result.trace);
639
- // diff.summary:status / totalUsage.* / 根 attributes 差 —— A/B 模型第一眼就看 attributes.model
640
- // diff.spans:逐 span 字段级差异,path 形如 run:main/llm.turn#0/capability:search
641
- if (!diff.equal) console.log(diff.summary, diff.spans);
642
-
643
- // 分叉重放:在主循环第 3 回合(0-based)之前截断,该回合及其后丢弃,换个问法继续
644
- const messages = forkMessages(a.result.trace, {
645
- atTurn: 3, // 合法范围 0..主循环回合数-1,越界抛可读错误
646
- append: [{ role: 'user', content: '换个思路:先给结论,再补证据。' }],
647
- });
648
- const c = await app.run(messages); // 起一条新 run,沿着分叉点前的真实 tool 历史继续
649
- ```
650
-
651
- 不落代码也可以直比两份 trace 导出:`agentia diff a.jsonl b.jsonl`(输入形态同 `agentia report`),
652
- 打印 run 级 summary + 逐 span 差异,**有差异时退出码 1**(diff(1) 语义)——可直接进 CI 挡
653
- 「换 prompt / 模型后轨迹漂移」。
654
-
655
- - **配对语义**(结构性配对,字段差异不影响配对):llm.turn 按回合序配对、**忽略 span name** ——
656
- name 是模型 id,而「换模型重跑」正是 A/B 主用例,按 name 配对会把两侧所有 turn 报成缺失;
657
- 模型差异降格为配对 turn 的 `name` 字段差。capability span 按 `kind:name` 配对
658
- (`skill:foo` vs `skill:bar` 是不同能力,不该配上)。一侧多出的调用树记**一条缺侧记录**
659
- (`SpanDiff.fields` 为空、`path` 照给),整支子树不再下钻。
660
- - **墙钟缺省不比**:`ignoreTiming` 缺省 true(A/B 不关心时序);传 `false` 改比 span 时长
661
- (`duration`),绝对时间戳永不比;`traceId` 是身份不是行为,同样永不比。
662
- - ⚠️ **有损边界(与 `traceToMessages` / harvest 同源)**:trace **不记 assistant 文本与 run 的
663
- 原始输入** —— 重放里 assistant 是标注占位(非逐字原文)、首尾说明性 user 是合成。
664
- 因此 `forkMessages` **不是「续跑」**:它产出的是一份喂回 `app.run` / `runAgent` 的 messages,
665
- 跑的是一条**新 run**,不是接着原 run 的循环位置。分叉的 blackboard 种子由调用方自带
666
- (`app.run(messages, { blackboard: {…} })` —— trace 不记 blackboard)。
667
-
668
- ### 指标(从 trace 派生)
594
+ #### 指标(从 trace 派生)
669
595
 
670
596
  | API | 说明 |
671
597
  |---|---|
@@ -680,7 +606,7 @@ const c = await app.run(messages); // 起一条新 run,沿着分叉点前的
680
606
  来自 `capability` span。**`@Prompt` 不建 span、无独立耗时,因此不产出能力指标**(如实缺省,不硬凑)。
681
607
  - **模型级** —— 按模型(`llm.turn` 的 span name)归因 turn 数 / token / 成本 / 耗时,并单独给出
682
608
  `model_unpriced_turns_total`(算不出成本的 turn 数 —— **成本护栏失效的显式信号**)。
683
- - **评分级**(R7)—— 来自 run 根 span 的 `score` 事件(`attachScore` 写入):
609
+ - **评分级** —— 来自 run 根 span 的 `score` 事件(`attachScore` 写入):
684
610
  `agentia_score{name,source}` gauge 记**最近一次**值(分数不是累加量),`agentia_score_total{name,source}` counter 记条数;
685
611
  `snapshot().scores` 以 `name@source` 为键(source 缺省时裸 name)暴露 `{ value, count, sum }`(平均 = sum/count),
686
612
  OTLP metrics payload 同样带这两个家族,`reset()` 一并清空。
@@ -695,7 +621,7 @@ const c = await app.run(messages); // 起一条新 run,沿着分叉点前的
695
621
  分位 gauge 与 histogram **必须不同名**(同名指标只允许一种 TYPE,混发会被 expfmt 判硬错误、整次 scrape 失败),
696
622
  故分位家族统一带 `_last` 后缀;capability / model 维度同理(`capability_duration_ms_last` / `model_duration_ms_last`)。
697
623
 
698
- ### `MetricsSinkOptions`(`metricsSink` 的选项)
624
+ #### `MetricsSinkOptions`(`metricsSink` 的选项)
699
625
 
700
626
  | 字段 | 说明 |
701
627
  |---|---|
@@ -709,25 +635,28 @@ const c = await app.run(messages); // 起一条新 run,沿着分叉点前的
709
635
  | `prefix` | 指标名前缀,缺省 `agentia_` |
710
636
  | `labelMode` | 能力标签粒度:`'capability'`(缺省,`tool:search` 这种)/ `'kind'`(只按类型,基数极小)/ `'none'`(不产出能力指标) |
711
637
  | `maxCapabilities` | 能力标签基数上限(缺省 200):超出后新能力归入 `capability="__other__"`(防标签爆炸);非正数抛错 |
638
+ | `maxModels` | 模型维度基数上限(缺省 50):超出后新模型归入 `model="__other__"` ——`model` 是 per-run 可覆盖的,上游把版本号拼进模型 id 时键会无界增长;非正数抛错 |
639
+ | `maxScores` | 评分维度基数上限(缺省 200):评分键是 `name@source`,eval 名带时间戳时同样无界;超出的归入 `name="__other__"`;非正数抛错 |
712
640
  | `buckets` | 直方图桶边界(毫秒,严格升序);缺省 `DEFAULT_BUCKETS` |
713
641
 
714
- ### `MetricsSink`(`metricsSink()` 的返回值)
642
+ #### `MetricsSink`(`metricsSink()` 的返回值)
715
643
 
716
644
  | 成员 | 说明 |
717
645
  |---|---|
718
646
  | `export` | `TraceSink` 的实现(run 收尾投递)—— 也是接进 `sinks` 的形状 |
719
- | `snapshot` | `{ runs, failed, latencyP50, latencyP95, tokens, costUsd, capabilities, models, scores, droppedCapabilities }` |
647
+ | `snapshot` | `{ runs, failed, latencyP50, latencyP95, tokens, costUsd, capabilities, models, scores, droppedCapabilities, droppedModels, droppedScores }` |
720
648
  | `render` | Prometheus 文本(`/metrics` 直接回它) |
721
649
  | `flush` | 主动导出一次(`export:'otlp'` 时有意义;prometheus 模式为空操作) |
722
650
  | `stop` | 停掉定时导出(进程收尾 / 测试用) |
723
- | `reset` | 清空累计(含能力与模型维度) |
651
+ | `reset` | 清空累计(含能力 / 模型 / 评分三个维度,以及各自的基数配额) |
724
652
 
725
653
  - `tokens` 口径 = **四类之和**(input + output + cacheRead + cacheCreation),与 `BudgetGuard` 一致;分项在 `render()` 里以 label 给出,不会丢。
726
654
  - 分位是**窗口内精确值**(最近 rank 法),只反映最近 `windowSize` 条样本;**直方图计数是累积的**(全历史),两者语义不同、各有各的用处。
727
- - **内存上限** ≈ `(1 + 能力数 + 模型数) × windowSize` —— 能力数由 `maxCapabilities` 封顶,长跑宿主不会被拖住。
655
+ - **内存上限** ≈ `(1 + 能力数 + 模型数) × windowSize` —— 三个维度都由基数上限封顶(`maxCapabilities` / `maxModels` / `maxScores`),长跑宿主不会被拖住。
656
+ - 超上限的键折叠进 `__other__`:**丢的只是标签粒度,量不丢** —— `__other__` 桶照常累加,`snapshot()` 里各维度的总数仍然对得上。被折叠的**不同**键数见 `droppedCapabilities` / `droppedModels` / `droppedScores`(各自最多记账 1024 个键,满了以后是下界)。
728
657
  - `costUsd` 依赖模型在价格表内(不在表里时不计、并计入 `unpricedTurns` 与 `usage.unpriced` 事件);根 span 未收尾(如失败路径的半截 trace)的 run 不进延迟样本。
729
658
 
730
- ### 调用树面板(`agentia dev` 的本地面板 / 官网 Playground)
659
+ #### 调用树面板(`agentia dev` 的本地面板 / 官网 Playground)
731
660
 
732
661
  同一份 `@migor/trace-view` 渲染器,**面板 / Playground / `report` 的能力排行三处共用**,不各写一套。
733
662
 
@@ -739,7 +668,7 @@ const c = await app.run(messages); // 起一条新 run,沿着分叉点前的
739
668
  - 入参折叠态的摘要被砍到 62 字符(4 个键 / 每值 21 字符);**展开拿到的是原文**,不是那份摘要 ——
740
669
  本地面板与官网 Playground 两个宿主都是如此(两个宿主都不该只喂摘要,否则点开什么都没多出来)。
741
670
 
742
- ### 调优报告(**哪个能力慢 / 贵 / 爱失败**)
671
+ #### 调优报告(**哪个能力慢 / 贵 / 爱失败**)
743
672
 
744
673
  指标回答「整体怎么样」,报告回答「**该拧哪个旋钮**」:
745
674
 
@@ -782,7 +711,7 @@ const app = await createApp({ /* … */ sinks: [jsonl] });
782
711
  异步宿主更省事:把 `FileTaskStore` 的落盘文件直接喂给它 —— `TaskRecord` 里带 `result.trace`,
783
712
  `report` 认这种形态,不用另写 sink。
784
713
 
785
- ### 生效配置快照(「这条 run 用了哪套旋钮」)
714
+ #### 生效配置快照(「这条 run 用了哪套旋钮」)
786
715
 
787
716
  每个 run 的**根 span** 都带一组 `config.*` attributes(`config.maxTokens` / `config.maxCostUsd` /
788
717
  `config.retry.maxAttempts` / `config.contextPolicy.budgetTokens` / `config.priceOverrides` /
@@ -793,16 +722,161 @@ const app = await createApp({ /* … */ sinks: [jsonl] });
793
722
  函数型选项(`summarize` / `confirm` 之类)只记「配没配」,不记函数体。
794
723
  截断关掉时记的是 `'off'` 而不是 `false` —— 后者在日志/看板里会被读成「上限为 0」。
795
724
 
796
- ### 提示词版本化与会话标记(run 根 attribute)
725
+ #### 提示词版本化与会话标记(run 根 attribute)
797
726
 
798
727
  - `new SystemPrompt({ version: 'git-abc123' })` → 自动写到 **run 根 span 的 `system.version` attribute**:trace 里能查出「这个结果是哪个版本的提示词产出的」(换 prompt 前后对比、排查回归都靠它)。
799
728
  - 版本号怎么来(git sha / 语义版本 / 手工)由你决定 —— 框架**不做**版本库与回滚平台。
800
729
  - 单次 `app.run(..., { system })` 覆盖时,版本**跟当次那个 `SystemPrompt` 走**;`system` 传已拼好的 `SystemParam` 则无版本可记(不写空串冒充实有版本)。
801
730
  - 直连 `runAgent` / `executeRun` 时可用引擎级选项 `systemVersion` 显式给。
802
- - **`@Prompt` 资产版本**(R7):`@Prompt({ version })` 声明后,装配期把菜单里全部带版本的 @Prompt 收集成 `{ 能力名: 版本 }` 表(与主菜单同一条收集路径,`toolSources` 收窄同样生效),每次 run 落 run 根 span 的 `prompts.versions` attribute(`name@ver` 逗号拼接、按名排序、空表不记)—— 质量回归能定位到具体资产版本。直连 `runAgent` 时用引擎级选项 `promptVersions` 显式给。
803
- - **会话标识**(R7 thread 维度):`app.run(..., { session })` / `executeRun` 给了 `session` 时,session id 自动落 run 根 span 的 `session.id` attribute(OTLP 导出时映射 `gen_ai.conversation.id`)—— 多轮对话的 run 由此可按会话聚合,不用手填。直连 `runAgent` 时用引擎级选项 `sessionId` 显式给。
731
+ - **`@Prompt` 资产版本**:`@Prompt({ version })` 声明后,装配期把菜单里全部带版本的 @Prompt 收集成 `{ 能力名: 版本 }` 表(与主菜单同一条收集路径,`toolSources` 收窄同样生效),每次 run 落 run 根 span 的 `prompts.versions` attribute(`name@ver` 逗号拼接、按名排序、空表不记)—— 质量回归能定位到具体资产版本。直连 `runAgent` 时用引擎级选项 `promptVersions` 显式给。
732
+ - **会话标识**:`app.run(..., { session })` / `executeRun` 给了 `session` 时,session id 自动落 run 根 span 的 `session.id` attribute(OTLP 导出时映射 `gen_ai.conversation.id`)—— 多轮对话的 run 由此可按会话聚合,不用手填。直连 `runAgent` 时用引擎级选项 `sessionId` 显式给。
733
+
734
+ ### 6.5 集成
735
+
736
+ 换模型 / 接 MCP / 离线 eval / prompt 与模型的 A-B。
737
+
738
+ #### 多模型 / 记忆 / 重放
739
+
740
+ | API | 说明 |
741
+ |---|---|
742
+ | `createAnthropicClient` | 默认 ModelClient(Anthropic):自定义只传 `apiKey` / `baseURL`;框架**零运行时依赖**,不装厂商 SDK |
743
+ | `createOpenAIClient` | OpenAI 兼容端点适配(DeepSeek 等;**真流式**、图片块转 `image_url`、cache token 恒 0) |
744
+ | `InMemoryMemoryStore` | 跨 run 的**键值黑板**记忆(`{ store, keys }` 配 `executeRun`) |
745
+ | `InMemorySessionStore` | 跨 run 的**对话历史**(`{ store, id }` 配 `executeRun` / `app.run`);与前者正交,可同时用 |
746
+ | `traceToMessages` | 把 trace 还原成 messages(重放基底) |
747
+ | `forkMessages` | 分叉重放:在主循环第 `atTurn` 回合之前截断重放历史、拼上 `append` 新消息喂回 `app.run`(「从第 N 回合换个问法重跑」的基底,**不是续跑**) |
748
+ | `diffTraces` | 两条 trace 的 A/B 比对(prompt / 模型实验):run 级 summary + 逐 span 字段差;纯函数,llm.turn 配对**忽略模型名**,缺省忽略墙钟 |
749
+ | `applyMiddleware` | 手动包裹配置菜单(装配层已自动做) |
750
+
751
+ #### MCP 桥(MCP 是「工具来源」,不是新机制)
752
+
753
+ | API | 说明 |
754
+ |---|---|
755
+ | `mcpTools` | 把 MCP server 的 `tools/list` 映射成框架 `AgentTool[]`(进 `createApp({ tools })`) |
756
+ | `McpClientLike` | 最小结构面:`listTools()` + `callTool(name, args)`;框架**不 import** MCP SDK |
757
+ | `MCP_DEFAULT_TIMEOUT_MS` | 桥的**兜底**单次调用超时(60000 ms)—— 引擎设了 `toolTimeoutMs` 时**不参与**判定 |
758
+
759
+ - **名字**:`prefix + 归一化原名`(MCP 名里的 `-` / `.` / 空格 → `_`)。归一化后**空名(原名不含任何 ASCII 字母/数字/下划线时产物为空,如全 emoji 名)/ 撞名 / 超 64 字符**一律**装配期抛错**(静默改名会得到一个调不回去的名字,比启动期报错难查得多)。
760
+ - **原名**:每次调用写进发起 turn 的两条 attribute —— `mcp.tool.<菜单名>`(每次调用各一条,并行调用互不覆盖,审计 / 回放靠它把菜单名还原成 server 认识的原名)与 `mcp.tool`(本次 turn **最近一次**的原名,兼容既有查询)。
761
+ - **入参 schema**:MCP 的 `inputSchema` 已是 JSON Schema → 原样透传,由 engine 的子集校验器在 `callTool` **之前**校验(非法入参根本不会发给 server,模型自己会改)。
762
+ - **失败**:`callTool` 抛错 → 该条 `tool_result` 记 `is_error`,**不杀 run**(与本地工具抛错同语义)。⚠️ **协议层的 `isError: true` 框架看不见** —— 连接器必须转成抛错,否则模型以为成功了。
763
+ - **超时**:**只有一个裁判**。引擎设了 `toolTimeoutMs` 时,桥的 `timeoutMs`(缺省 `MCP_DEFAULT_TIMEOUT_MS` = 60000)**不参与判定**;它只在「桥脱离引擎单用」或「引擎没设 `toolTimeoutMs`」时作为兜底。两条路径共用同一判定(`core/timeout.ts`:**看实测耗时,不看竞速**),超时都记 `errorKind='timeout'` + `is_error` 回模型、**不杀 run**。你自己写的工具要报超时,抛一个 `code === 'timeout'` 的错误即可(不必 import 框架的类)。
764
+ - **连接器不在框架里**(守「零运行时依赖」):stdio / StreamableHTTP 归独立可选包,或你自己接 SDK 后实现 `McpClientLike`。本仓库 `scripts/e2e-mcp.ts` 有一份最小连接器可参考。
765
+
766
+ ```ts
767
+ // 任意实现了 listTools/callTool 的对象都能接(duck-typed,无需继承)
768
+ const client: McpClientLike = myStdioConnector;
769
+ const tools = await mcpTools(client, { server: 'time' }); // → mcp_time_get_current_time …
770
+
771
+ // 与本地 @Tool 同池:同过中间件链、同进重名查重
772
+ const app = createApp({ system, providers: [...], tools });
773
+ ```
774
+
775
+ #### `McpToolsOptions`(`mcpTools` 的选项)
776
+
777
+ | 字段 | 说明 |
778
+ |---|---|
779
+ | `prefix` | 工具名前缀;缺省 `mcp_<server>_`(没给 `server` 时 `mcp_`);`''` = 不加前缀(撞名自负) |
780
+ | `server` | server 标识,只用于拼缺省前缀(不会发给 server) |
781
+ | `timeoutMs` | 单次 `callTool` 超时(毫秒);缺省 60000,非正数 = 不限 |
804
782
 
805
- ### 在线评估采样(recipe,不是框架功能)
783
+ #### evals(把 mockClient 提升为一等能力)
784
+
785
+ | API | 说明 |
786
+ |---|---|
787
+ | `scriptedClient` | 按脚本依次返回模型响应(**真把文本块经 `on('text')` 吐出去**);脚本耗时报错 |
788
+ | `defineEval` | 定义「用例 + 断言」,`run()` 返回 `EvalReport` |
789
+
790
+ - **为什么需要**:单测覆盖的是框架语义,evals 覆盖的是**你的 agent 语义** —— 改 prompt / 换模型 / 加工具之后有没有回归,靠断言而不是人眼。
791
+ - 断言源是既有 `Trace`:「先 `search` 才 `summarize`」这类顺序断言全从 trace 读,框架不为此新增埋点。
792
+ - `run()` **不抛**(用例失败进报告,一次跑完能看到所有回归,而不是修一个跑一次);只有「应用建不起来」才冒泡 —— 那是环境错误,不是回归。失败 case 带 `trace`,直接看现场。
793
+ - `scriptedClient` 的步骤**在 `finalMessage()` 成功返回后才前进**:抛错的步骤(函数步骤 `throw` 模拟 429)会在重试时**重放同一步**,想验重试就这么写。
794
+ - **用例结论自动落 score**:每个用例跑完,结论以 `{ name: 'eval', value: 0|1, source: eval 名, comment: 失败原因 }` 自动 `attachScore` 到该用例的 trace —— eval 的 trace 自带质量结论,下游 sink / `metricsSink` 可直接聚合「这个 eval 的通过率」(`app.run` 抛错拿不到 trace 时不挂)。
795
+
796
+ ```ts
797
+ const ev = defineEval<{ summary: string }>({
798
+ name: 'doc-review',
799
+ app: () => createApp({ system: new SystemPrompt({ version: 'v3' }).add('role', R), providers: [...] }),
800
+ // 每 case 可带 opts(透传 app.run):注入 resultSchema 就能断言 result.typed
801
+ cases: [{ name: '先检索再总结', input: '总结这份文档', client: scriptedClient([searchMsg, submitMsg]) }],
802
+ expect: (r, { trace }) => {
803
+ assert.equal(r.stopReason, 'end_turn');
804
+ const order = trace.spans.flatMap((s) => s.events)
805
+ .filter((e) => e.name === 'tool.input')
806
+ .map((e) => (e.body as { tool: string }).tool);
807
+ assert.deepEqual(order, ['search', 'summarize']); // 顺序断言从 trace 读
808
+ },
809
+ });
810
+ const report = await ev.run();
811
+ if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
812
+ ```
813
+
814
+ #### 线上 trace 回流 eval 数据集(`agentia harvest`)
815
+
816
+ 线上事故 → 回归用例:CLI 把 trace 落盘文件翻成 eval 用例脚手架。
817
+
818
+ ```bash
819
+ agentia harvest trace.jsonl # 全部记录 → 脚手架打到 stdout
820
+ agentia harvest trace.jsonl --failed --limit 5 --out evals/harvested.ts
821
+ agentia harvest trace.jsonl --out evals/harvested.ts --force # 覆盖已存在的产物(默认拒绝)
822
+ ```
823
+
824
+ - 输入同 `agentia report`:每行一个 JSON(裸 Trace,或含 `result.trace` / `trace` 的 TaskRecord,如 `FileTaskStore` 的导出);`--failed` 只留失败记录。
825
+ - 产物是**可粘贴进 eval 文件的用例字面量**:`client: scriptedClient([...])` 按 trace 的主循环 llm.turn 逐回合重建,`expect` 预填「主循环工具序列」的轨迹断言(文件顶部附跑法注释)。
826
+ - `--out` 指向已存在的文件时**默认拒绝覆盖**(产物是要人工核对的脚手架,重跑一次就抹掉你改过的断言与 input);要覆盖显式加 `--force`。
827
+ - ⚠️ **脚手架不是成品,人工核对后再进 CI**:
828
+ - **trace 不记 assistant 文本**(llm.turn 只记 usage/事件),脚本里的 text 块是占位 `'[harvest] assistant 文本未入 trace'`;
829
+ - 只重建**直属 run 根**的主循环回合 —— 子 agent 的嵌套回合不走主循环脚本(要覆盖子 agent 请单独写 eval);
830
+ - 预填的 `expect` 是从原 trace **抄录的实际轨迹** —— 发生过 ≠ 应该发生;
831
+ - `EvalCase` 没有 `expect` 字段,粘贴时把断言搬进 `defineEval({ expect })`(脚手架注释会教)。
832
+
833
+ #### prompt / 模型 A/B(trace diff 与分叉重放)
834
+
835
+ 同一份输入跑两条 run(换模型、换 `SystemPrompt` 版本、换 prompt 都行),用 `diffTraces` 比出**结构与成本差**;
836
+ 想「从第 3 回合换个问法重跑」,用 `forkMessages` 在分叉点截断、拼上新消息喂回 `app.run`:
837
+
838
+ ```ts
839
+ import { diffTraces, forkMessages } from '@migor/agentia';
840
+
841
+ // A/B:同输入,只换模型(或换 system 版本),各跑一条
842
+ const a = await app.run(input, { model: 'claude-sonnet-5' });
843
+ const b = await app.run(input, { model: 'claude-opus-5' });
844
+
845
+ const diff = diffTraces(a.result.trace, b.result.trace);
846
+ // diff.summary:status / totalUsage.* / 根 attributes 差 —— A/B 模型第一眼就看 attributes.model
847
+ // diff.spans:逐 span 字段级差异,path 形如 run:main/llm.turn#0/capability:search
848
+ if (!diff.equal) console.log(diff.summary, diff.spans);
849
+
850
+ // 分叉重放:在主循环第 3 回合(0-based)之前截断,该回合及其后丢弃,换个问法继续
851
+ const messages = forkMessages(a.result.trace, {
852
+ atTurn: 3, // 合法范围 0..主循环回合数-1,越界抛可读错误
853
+ append: [{ role: 'user', content: '换个思路:先给结论,再补证据。' }],
854
+ });
855
+ const c = await app.run(messages); // 起一条新 run,沿着分叉点前的真实 tool 历史继续
856
+ ```
857
+
858
+ 不落代码也可以直比两份 trace 导出:`agentia diff a.jsonl b.jsonl`(输入形态同 `agentia report`),
859
+ 打印 run 级 summary + 逐 span 差异,**有差异时退出码 1**(diff(1) 语义)——可直接进 CI 挡
860
+ 「换 prompt / 模型后轨迹漂移」。
861
+
862
+ - **配对语义**(结构性配对,字段差异不影响配对):llm.turn 按回合序配对、**忽略 span name** ——
863
+ name 是模型 id,而「换模型重跑」正是 A/B 主用例,按 name 配对会把两侧所有 turn 报成缺失;
864
+ 模型差异降格为配对 turn 的 `name` 字段差。capability span 按 `kind:name` 配对
865
+ (`skill:foo` vs `skill:bar` 是不同能力,不该配上)。一侧多出的调用树记**一条缺侧记录**
866
+ (`SpanDiff.fields` 为空、`path` 照给),整支子树不再下钻。
867
+ - **墙钟缺省不比**:`ignoreTiming` 缺省 true(A/B 不关心时序);传 `false` 改比 span 时长
868
+ (`duration`),绝对时间戳永不比;`traceId` 是身份不是行为,同样永不比。
869
+ - ⚠️ **有损边界(与 `traceToMessages` / harvest 同源)**:trace **不记 assistant 文本与 run 的
870
+ 原始输入** —— 重放里 assistant 是标注占位(非逐字原文)、首尾说明性 user 是合成。
871
+ 因此 `forkMessages` **不是「续跑」**:它产出的是一份喂回 `app.run` / `runAgent` 的 messages,
872
+ 跑的是一条**新 run**,不是接着原 run 的循环位置。分叉的 blackboard 种子由调用方自带
873
+ (`app.run(messages, { blackboard: {…} })` —— trace 不记 blackboard)。
874
+
875
+ ### 6.6 横切缝(框架只给缝,不建子系统)
876
+
877
+ 以下五件事策略千差万别(正则 / 分类模型 / 外部审核 API / 你自己的配额口径),框架硬编码必错 —— 所以只给缝,每节给出可直接粘贴的拼法。
878
+
879
+ #### 在线评估采样
806
880
 
807
881
  生产流量按 N% 采样跑 LLM-judge、把分数回挂 trace —— 用**现有 sink 机制**拼:采样(`examples/observability` 的 `sampleSink` 配方)+ 对抽中的 run 调一次 judge(一次 `app.run` 或裸 client 调用)+ `attachScore` 回挂 + `metricsSink` 聚合。框架不提供 judge 子系统 —— 评什么、用什么模型评、采样率多少,都是你的策略:
808
882
 
@@ -832,7 +906,7 @@ createApp({ /* … */ sinks: [onlineEval, metrics] }); // metrics 必须同链
832
906
  - judge 本身的 run 也会产生 trace —— 给它单独一个 app / runName,或按 run 名在 judge sink 里跳过自己,避免「评估评估的评估」。
833
907
  - 成本自控:judge 调一次模型就是一份钱,采样率与 judge 模型档位是你的旋钮(judge 的 run 同样受 `maxCostUsd` 等护栏约束)。
834
908
 
835
- ### 多租户配额(组合既有缝,不是子系统)
909
+ #### 多租户配额
836
910
 
837
911
  框架不提供配额组件 —— 用 `middleware`(拦在能力调用前)+ `TraceSink`(收尾后记账)+ `BudgetGuard`(单次 run 上限)组合即可,存储与策略是你的事:
838
912
 
@@ -866,7 +940,7 @@ createApp({ system, providers: [...], middleware: [quota], sinks: [billing] });
866
940
  - 拦下来的那次 run **仍然要记账**(模型的钱已经花了)—— 记账在 sink 里、拦截在 middleware 里,两者独立。
867
941
  - 被拦下的能力**不会执行**(副作用不发生),但 run 继续跑(模型可以换路)。
868
942
 
869
- ### 人工介入:审批闸门(缺口只在「跨进程挂起」,闸门现成)
943
+ #### 人工审批闸门
870
944
 
871
945
  框架不做审批子系统,但**闸门**这一层已经具备 —— `middleware` 可以 `await` 决策再放行,
872
946
  引擎会等工具结果(`Promise.resolve(tool.run(...))`):
@@ -896,7 +970,7 @@ createApp({ system, providers: [...], middleware: [requireApproval] });
896
970
  且 `traceToMessages` 重放**有损**(assistant 原文未记录)—— 拿它假装续跑只会拿到降级的上下文。
897
971
  要跨重启审批,就自己上工作流引擎(见 §7)。
898
972
 
899
- ### 内容护栏(三处缝,不是子系统)
973
+ #### 内容护栏
900
974
 
901
975
  和「多租户配额」同一个形状:策略千差万别(正则 / 分类模型 / 外部审核 API),框架硬编码必错,**只给缝**。
902
976
 
@@ -911,13 +985,13 @@ const callable = {
911
985
  name: app.name,
912
986
  async run(msgs, opts) {
913
987
  const out = await app.run(redactInput(msgs), opts); // 入参护栏
914
- if (flagged(out.finalText)) throw new Error('输出被护栏拦下'); // 出参护栏
988
+ if (flagged(out.result.finalText)) throw new Error('输出被护栏拦下'); // 出参护栏
915
989
  return out;
916
990
  },
917
991
  };
918
992
  ```
919
993
 
920
- ### 代码执行隔离(沙箱是工具的事,不是框架的)
994
+ #### 代码执行隔离
921
995
 
922
996
  **框架从不执行模型生成的代码** —— `@Skill` 跑的是你写的方法体、`@Tool` 是你写的函数,
923
997
  模型输出只会变成文本 / `tool_result`。所以「要不要沙箱」等价于「你那个*代码执行工具*要不要隔离」:
@@ -946,7 +1020,7 @@ const callable = {
946
1020
  | `discover` 入口会回落 | 能力目录里源码与编译产物并存(`index.ts` + `index.js`)时,首选 `.ts` 加载失败会**回落 `.js` 并 warn** —— 命中的可能是**陈旧编译产物**(刚改过源码时注意);全部候选都失败才抛错并列出各自原因 |
947
1021
  | `asset()` 的 rel 必须是相对路径 | 带 scheme(`file:` / `https:` …)的 rel 会让 `new URL(rel, base)` 整个忽略 base(「以为读了能力目录、实际读了别处」),显式抛错;`../` 越出能力目录是**有意放行**(共享资产如 `../../shared/x.md` 是合法用法) |
948
1022
  | 取消要传进客户端才有效 | 传 `signal` 后框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发);不转发 `signal` 的自定义 `ModelClient` 只能「放弃等待」(请求在后台跑完、产物丢弃) |
949
- | 默认 client 的真端点验证范围 | `e2e:live` 跑在 DeepSeek 的 Anthropic **兼容**端点上;官方 Anthropic 端点的行为差异(thinking 细节、cache TTL 语义、新块型)目前只有本地假端点测试在守 —— 「mock 全绿发现不了厂商真实行为」是本框架自己记过的教训,接入官方端点前自己跑一遍 `npm run e2e:live` |
1023
+ | 默认 client 的真端点验证范围 | `e2e:live` 跑在 DeepSeek 的 Anthropic **兼容**端点上;官方 Anthropic 端点的行为差异(thinking 细节、cache TTL 语义、新块型)目前只有本地假端点测试在守 —— mock 全绿发现不了厂商真实行为 —— 接入官方端点前自己跑一遍 `npm run e2e:live` |
950
1024
  | thinking 块「能收、不主动请求」 | 框架**从不**在请求里开 extended thinking;默认 client 能收拼 thinking 块(`signature_delta` 会累积),`redacted_thinking` 与未知块型**原样透传**不丢 —— 但官方 API 的 thinking 回灌要求带合法 `signature`,自定义 client 开 thinking 时自己验证这条链 |
951
1025
  | 工具阶段的 abort 有盲区 | abort 只在三处被观察:**回合边界 / 在飞模型请求 / 重试退避 sleep**。没设 `toolTimeoutMs` 且工具挂死时,abort 之后 run 也不会返回(工具的 Promise 永不 settle)—— 挂死的工具要么设超时,要么自己读 `ToolRunContext.signal` |
952
1026
  | 观测失败被吞 | sink 抛错不影响 run(观测是辅助动作);同理记忆水合/回写失败也不击穿 run |
@@ -957,16 +1031,16 @@ const callable = {
957
1031
  | 停机可能切断 SSE | `drain()` 超时后会强制关闭仍开着的 SSE 流,其 run 以 `stopReason='aborted'` 收尾 —— 客户端应把断流当作可重试 |
958
1032
  | 鉴权失败即断连 | 未通过鉴权时在读到 body 之前就回响应,连接**不可复用**(显式 `connection: close`);这是「不收body省资源」的代价 |
959
1033
  | 预算护栏不是硬实时 | 一回合记账完才判,实际用量可能超上限一个回合的量;模型自然收尾的那回合超限**不算失败**(只留 `budget.exceeded` 事件) |
960
- | `maxCostUsd` 依赖价格表 | 模型不在价格表内(且未用 `priceOverrides` 覆盖)时成本恒为 0,这条护栏**不触发** —— 要无条件兜底用 `maxTotalTokens`。**但失效不再静默**:turn 上会记 `usage.unpriced` 事件、指标有 `model_unpriced_turns_total`、可回调 `onUnpricedModel` |
1034
+ | `maxCostUsd` 依赖价格表 | 模型不在价格表内(且未用 `priceOverrides` 覆盖)时成本恒为 0,这条护栏**不触发** —— 要无条件兜底用 `maxTotalTokens`。**失效会响**:turn 上会记 `usage.unpriced` 事件、指标有 `model_unpriced_turns_total`、可回调 `onUnpricedModel` |
961
1035
  | 工具超时**不取消**工具 | `AgentTool.run` 没有 signal 参数,超时只是「不等了」;副作用可能已发生。想真停请让工具自己读 `ToolRunContext.signal` |
962
1036
  | 会话只存对话轮次 | `SessionStore` 存「用户输入 + 最终回复」,run 内部的 tool 往返**不进历史**(要完整过程用 `traceToMessages`);且只有**跑成功**的轮次才回写 |
963
1037
  | 同 session 并发 run 要自行串行化 | `SessionStore` 是 **append-only**:并发写不互相覆盖、不丢数据,但**不保证角色交替** —— 两个并发 run 共用同一 sessionId 时,各自追加的轮次可能交错成「连续两条 user」,下一轮 load 出来撞角色交替校验(400)。同一 session 的并发 run 请调用方自行串行化(每 session 一把锁 / 一条队列) |
964
1038
  | OpenAI 适配器听端点的话 | 请求发 `stream:true`,但**按响应形态解析**:端点回 JSON 就退回一次性(没有打字机效果),回 `event-stream` 才逐 token |
965
- | OpenAI 流式的上游故障不再装成功 | 流中 `error` 分片(上游把故障塞进 200 的流)与「流正常结束却无文本无 tool_calls」都**抛错**按失败处理 —— 不再静默映射成「成功空回复」(与非流式空 `choices` 的守卫同口径) |
1039
+ | OpenAI 流式的上游故障按失败处理 | 三种形态都**抛错**按失败处理:流中 `error` 分片(上游把故障塞进 200 的流;按 `type`/`code` 反推 status,限流能被引擎重试认出);**未收到 `[DONE]` 也无 `finish_reason`**(流被上游/代理截断 —— 哪怕已吐出半句、有累积文本,也按不完整响应抛错,不报 `end_turn`);正常终止却无任何文本与工具调用(与非流式空 `choices` 同一守卫)。**例外**:`finish_reason=content_filter` 的空流是合法 refusal,不抛 —— 与非流式路径同一个响应同一个结论 |
966
1040
  | MCP 只做 tools | `sampling`(server 反向请求模型)/ `resources` / `prompts` 原语不做;连接器(stdio / HTTP)不在框架内 |
967
1041
  | MCP 的协议层错误框架看不见 | `isError: true` 只有连接器能看见 —— 它必须转成抛错,否则模型收到的是一条「成功」的结果 |
968
- | MCP 超时同样是「不等了」 | 桥自带的 `timeoutMs` 取消不了 server 侧执行(拿不到取消句柄);它与 engine 的 `toolTimeoutMs` **双重计时**,谁短谁生效 |
969
- | MCP 名字可能被归一化 | 原名含 `-` / `.` / 空格 → 进菜单时变成 `_`;回调 server 用的仍是原名(`mcp.tool` attribute 里查得到) |
1042
+ | MCP 超时同样是「不等了」 | 桥的 `timeoutMs` 取消不了 server 侧执行(拿不到取消句柄);它只是**兜底** —— 引擎设了 `toolTimeoutMs` 时**不参与**判定(一次调用只有一个裁判;**显式 `toolTimeoutMs: 0` 也算设了** —— 那是引擎表态「不限」,桥不会再自作主张判 60s),两条路径**同判定、同账**(`errorKind='timeout'`) |
1043
+ | MCP 名字可能被归一化 | 原名含 `-` / `.` / 空格 → 进菜单时变成 `_`;回调 server 用的仍是原名(`mcp.tool.<菜单名>` attribute 逐次可查;`mcp.tool` 是最近一次) |
970
1044
  | MCP 工具不能进 DI 容器 | 它没有 provider token,也不能被别的能力的 `tools` 引用(两种引用粒度都要先有 token) |
971
1045
  | 指标分位是窗口内精确值 | `*_last{quantile=...}` 只反映最近 `windowSize`(缺省 1024)条样本;要跨实例聚合请用直方图(`*_bucket` / `_sum` / `_count`,累积语义) |
972
1046
  | 指标是**进程内**累加 | 不做分布式聚合与持久化:多实例各算各的(直方图可相加),重启即清零。要长期保留请把 `render()` 抓走或用 `export:'otlp'` 推给采集端 |
@@ -975,10 +1049,12 @@ const callable = {
975
1049
  | 工具没有 token/成本指标 | 工具是**你的代码**、本身不消耗 token,所以只产出调用数/失败数/耗时;token 与成本只对 `skill`/`subagent`(有 `capability` span)与模型维度产出 |
976
1050
  | `@Prompt` 没有能力指标 | 资产类能力不建 span、无独立耗时,故不出现在能力排行里(这是刻意的:硬凑一个假耗时会误导调优) |
977
1051
  | 能力标签有基数上限 | `labelMode:'capability'`(缺省)+ `maxCapabilities`(缺省 200),超出的能力归入 `capability="__other__"`;`snapshot().droppedCapabilities` 给出被归并的能力个数。要完整明细请用 `buildRunReport`(不设上限) |
1052
+ | 模型 / 评分维度也有基数上限 | `maxModels`(缺省 50)/ `maxScores`(缺省 200):`model` 与评分键(`name@source`)都可能是无界键(上游把版本号拼进模型 id、eval 名带时间戳),而每个模型键都持一份时长窗口 —— 光是能力封顶不够。折叠只丢标签粒度,`__other__` 桶照常累加,总数仍对得上;被折叠的不同键数见 `droppedModels` / `droppedScores` |
978
1053
  | 提示词版本只是标记 | 框架不存版本库、不回滚:`version` 只落 run 根 attribute;`system` 传已拼好的 `SystemParam` 时无版本可记 |
979
1054
  | `agentia harvest` 的产物是轨迹骨架 | trace **不记 assistant 文本**(llm.turn 只记 usage/事件),故 harvest 用例脚本里的 text 块是占位、预填 `expect` 是从原 trace 抄录的实际轨迹 —— 脚手架不是成品,人工核对后再进 CI(见 §6「线上 trace 回流」) |
980
1055
  | 分叉重放不是续跑 | `forkMessages` 与 `traceToMessages` / harvest **同源有损**:trace 不记 assistant 文本与 run 原始输入(重放里 assistant 是标注占位、首尾 user 是合成),也不记 blackboard(分叉种子经 `RunInvocationOptions.blackboard` 自带);它产出喂回 `app.run` 的 messages、起的是**新 run**,不是接着原 run 的循环位置跑 |
981
1056
  | 评分来自 run 之外 | `Score` 走 run 根 `score` **事件**而非 span 字段(评分通常在 run 跑完后才产生);`attachScore` 找不到根 span 时静默忽略,多次调用即多条事件(不同维度各记各的) |
1057
+ | 链路关联只做**入站** | `traceContext` / `traceparent` 头只把**上游**接进来(run 根的 `links`);框架**不生成**出站 `traceparent` —— 运行中没有「当前 span」可导出,硬造会给出假 spanId。要从 run 往外传,用 `result.trace.traceId`(== `runId`)自行拼头。另:link 只落在 run 根(子 span 不散),且**一进程内**不跨进程自动传播 —— 队列场景要自己把 `traceContext` 传下去(HTTP 头带走,或随 `TaskRecord.spec.options` 落库) |
982
1058
  | 配额不是框架子系统 | 只给缝(middleware + TraceSink + BudgetGuard),计数放哪(内存 / Redis / DB)与超限怎么办都是你的策略 |
983
1059
  | 人工介入只到「闸门」 | `middleware` 能 `await` 审批决策再放行;**跨进程挂起/续跑框架不做** —— `RunStatus` 无「待批准」态、循环位置不落库,`traceToMessages` 重放有损,不能拿它假装续跑(要跨重启审批请上工作流引擎)|
984
1060
  | 内容护栏不给实现 | 同「配额」:只给缝(入参包 `app.run` / 工具前 `middleware` / 出参包返回值或 `sinks`),策略(正则 / 分类器 / 外部 API)是你的 |
@@ -1010,16 +1086,5 @@ const callable = {
1010
1086
  | MCP 调用「成功」但内容是错误文本 | 连接器没把协议层 `isError: true` 转成抛错(框架只认抛错) |
1011
1087
  | eval 里模型调了不存在的工具 | 脚本里的工具名必须是**菜单里的名字**(MCP 工具是归一化后的 `mcp_<server>_<name>`) |
1012
1088
  | `metricsSink` 的数字一直是 0 | 没接进 `createApp({ sinks })`(或 `registerDefaultTraceSink`)—— 它靠 run 收尾投递,不自己埋点 |
1013
- | `metricsSink({ export: 'otlp' })` 构造期报错 | 没给 `endpoint` —— OTLP 导出必须知道往哪发,响亮失败好过静默不导出;补上 `endpoint`(如 `http://localhost:4318`)即可。`windowSize` / `maxCapabilities` 非正数、`buckets` 非严格升序同理是构造期配置校验 |
1014
-
1015
- ---
1016
-
1017
- ## 9. 提交前自检
1018
-
1019
- ```bash
1020
- npm run typecheck # src 类型
1021
- npm run typecheck:tests # 测试目录类型(含类型断言测试)
1022
- npm run test # 单测(node:test)
1023
- ```
1089
+ | `metricsSink({ export: 'otlp' })` 构造期报错 | 没给 `endpoint` —— OTLP 导出必须知道往哪发,响亮失败好过静默不导出;补上 `endpoint`(如 `http://localhost:4318`)即可。`windowSize` / `maxCapabilities` / `maxModels` / `maxScores` 非正数、`buckets` 非严格升序同理是构造期配置校验 |
1024
1090
 
1025
- 框架仓库另有这些门禁:`npm run typecheck:types`(针对构建产物的类型测试)、`npm run e2e`(三步链:CLI 端到端 + `examples/complete` 与 `examples/deploy` 真起服务)、`npm run e2e:examples` / `npm run e2e:deploy`(单跑对应一步)、`npm run e2e:mcp`(真接一个 MCP server 走完「映射 → 菜单 → run」;无网时自动回落本地夹具 server)。