@migor/agentia 0.4.0 → 0.4.1

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 (75) hide show
  1. package/README.md +2 -2
  2. package/dist/AGENTS.md +39 -21
  3. package/dist/core/abort.d.ts +7 -0
  4. package/dist/core/abort.js +28 -2
  5. package/dist/core/abort.js.map +1 -1
  6. package/dist/core/schema.js +5 -3
  7. package/dist/core/schema.js.map +1 -1
  8. package/dist/core/tool.d.ts +9 -0
  9. package/dist/engine/loop.d.ts +9 -0
  10. package/dist/engine/loop.js +95 -23
  11. package/dist/engine/loop.js.map +1 -1
  12. package/dist/engine/policy.js +5 -0
  13. package/dist/engine/policy.js.map +1 -1
  14. package/dist/engine/spec.d.ts +1 -0
  15. package/dist/engine/spec.js.map +1 -1
  16. package/dist/engine/tracer.js +13 -1
  17. package/dist/engine/tracer.js.map +1 -1
  18. package/dist/engine/trimming.d.ts +7 -2
  19. package/dist/engine/trimming.js +20 -6
  20. package/dist/engine/trimming.js.map +1 -1
  21. package/dist/engine/types.d.ts +17 -5
  22. package/dist/engine/usage.d.ts +3 -3
  23. package/dist/engine/usage.js +3 -3
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/integrations/mcp.js +9 -1
  27. package/dist/integrations/mcp.js.map +1 -1
  28. package/dist/integrations/metrics.d.ts +8 -2
  29. package/dist/integrations/metrics.js +120 -46
  30. package/dist/integrations/metrics.js.map +1 -1
  31. package/dist/integrations/openai.js +14 -1
  32. package/dist/integrations/openai.js.map +1 -1
  33. package/dist/integrations/otlp.d.ts +7 -0
  34. package/dist/integrations/otlp.js +5 -2
  35. package/dist/integrations/otlp.js.map +1 -1
  36. package/dist/runtime/run.d.ts +7 -3
  37. package/dist/runtime/run.js +4 -0
  38. package/dist/runtime/run.js.map +1 -1
  39. package/dist/runtime/session.d.ts +5 -0
  40. package/dist/runtime/session.js.map +1 -1
  41. package/dist/store/fsStore.d.ts +2 -1
  42. package/dist/store/fsStore.js +12 -5
  43. package/dist/store/fsStore.js.map +1 -1
  44. package/dist/store/redisStore.d.ts +23 -3
  45. package/dist/store/redisStore.js +14 -3
  46. package/dist/store/redisStore.js.map +1 -1
  47. package/dist/toolkit/asset.d.ts +5 -0
  48. package/dist/toolkit/asset.js +8 -0
  49. package/dist/toolkit/asset.js.map +1 -1
  50. package/dist/toolkit/collect.d.ts +6 -0
  51. package/dist/toolkit/collect.js +29 -4
  52. package/dist/toolkit/collect.js.map +1 -1
  53. package/dist/toolkit/discover.js +24 -7
  54. package/dist/toolkit/discover.js.map +1 -1
  55. package/dist/toolkit/env.js +5 -0
  56. package/dist/toolkit/env.js.map +1 -1
  57. package/dist/toolkit/module.d.ts +3 -1
  58. package/dist/toolkit/module.js +8 -1
  59. package/dist/toolkit/module.js.map +1 -1
  60. package/dist/toolkit/prompt.d.ts +1 -1
  61. package/dist/toolkit/prompt.js +19 -12
  62. package/dist/toolkit/prompt.js.map +1 -1
  63. package/dist/toolkit/skill.js +3 -0
  64. package/dist/toolkit/skill.js.map +1 -1
  65. package/dist/toolkit/subagent.js +3 -0
  66. package/dist/toolkit/subagent.js.map +1 -1
  67. package/dist/transport/async.d.ts +2 -1
  68. package/dist/transport/async.js +19 -9
  69. package/dist/transport/async.js.map +1 -1
  70. package/dist/transport/http.d.ts +4 -4
  71. package/dist/transport/http.js +15 -2
  72. package/dist/transport/http.js.map +1 -1
  73. package/dist/transport/scheduler.js +13 -1
  74. package/dist/transport/scheduler.js.map +1 -1
  75. package/package.json +3 -2
package/README.md CHANGED
@@ -30,7 +30,7 @@ $EDITOR my-app/.env # 填 ANTHROPIC_API_KEY(也可直接 expo
30
30
  > 框架**不自动**读 `.env`:脚手架 `src/main.ts` 首行的 `loadEnvFile()` 负责把它读进 `process.env`。
31
31
  > 真实环境变量优先(CI / docker / 命令行永远赢过文件),`loadEnvFile({ override: true })` 才反过来。
32
32
 
33
- > **版本**:`0.4.0`(`@migor/agentia` 与 `@migor/cli` 均已发布到 npm)。`examples/` **刻意**用
33
+ > **版本**:`0.4.1`(`@migor/agentia` 与 `@migor/cli` 均已发布到 npm)。`examples/` **刻意**用
34
34
  > `file:../..` 指向本仓库而不是 npm 版本 —— 跑的是**工作区代码**,理由见
35
35
  > [`examples/README.md`](examples/README.md) 的「依赖说明」。
36
36
 
@@ -339,7 +339,7 @@ npm run build # 编译框架(dist/)
339
339
  npm run typecheck # 类型检查
340
340
  npm run lint # lint + 格式检查(Biome)
341
341
  npm test # 单元测试(node:test)
342
- npm run e2e # 端到端:CLI 脚手架 → 装配 mock run;再真跑 examples/complete 的完整服务
342
+ npm run e2e # 端到端:CLI 脚手架 → 装配 mock run;再真跑 examples/complete 与 examples/deploy(含崩溃续跑)
343
343
  ```
344
344
 
345
345
  > 注:本仓库的 `npm run dev`(tsx)前需先 `npm approve-scripts` 批准 esbuild/tsx 的 postinstall;
package/dist/AGENTS.md CHANGED
@@ -133,7 +133,7 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
133
133
  | `name` | 缺省取方法名 |
134
134
  | `schema` | 模板化入参 schema;缺省空对象(无参资产) |
135
135
 
136
- `@Prompt` **只支持方法形态**(标准装饰器下字段拿不到值/类引用)。方法体内用 `asset(import.meta.url, './x.md')` 读同目录长文本。
136
+ `@Prompt` **只支持方法形态**(标准装饰器下字段拿不到值/类引用)。实例方法与静态方法都**沿继承链**收集(父类的 `@Prompt` 资产子类自动带上)。方法体内用 `asset(import.meta.url, './x.md')` 读同目录长文本。
137
137
 
138
138
  ---
139
139
 
@@ -151,12 +151,13 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
151
151
  | `model` | 缺省模型;不给则 `AGENTIA_MODEL` env,再回落 `claude-opus-5` |
152
152
  | `maxTokens` | 缺省 `max_tokens` |
153
153
  | `maxIterations` | 缺省循环上限 |
154
- | `contextPolicy` | 上下文预算策略(`createBudgetPolicy(...)`) |
154
+ | `retry` | 缺省模型请求重试策略(可被单次 run 覆盖):缺省**开启**(`DEFAULT_RETRY`:maxAttempts=3、指数退避 + 抖动);`false` 关闭 |
155
+ | `contextPolicy` | 上下文预算策略(`createBudgetPolicy(...)`);带状态的策略应实现 `forRun()` 按 run 隔离(见 §长上下文) |
155
156
  | `toolSources` | 白名单:只把这些 provider 的能力放进主菜单 |
156
157
  | `tools` | 直接追加到主菜单的**裸工具**(`AgentTool[]`):给「构造期才知道有哪些工具」的场合(典型:MCP 桥,见 §6)。与能力**同过中间件、同进重名查重**,不是旁路 |
157
158
  | `middleware` | 能力调用中间件(洋葱链,链序 = 注册顺序) |
158
159
  | `sinks` | trace 出口,run 收尾投递 |
159
- | `maxTotalTokens` | 缺省成本硬管控:整条 run 累计 token 上限(可被单次 run 覆盖) |
160
+ | `maxTotalTokens` | 缺省成本硬管控:整条 run(**含子 agent / skill 子循环**,上限经 `ToolRunContext` 透传)累计 token 上限(可被单次 run 覆盖) |
160
161
  | `maxCostUsd` | 缺省成本硬管控:累计成本(美元)上限(**依赖模型在价格表内**,见 `priceOverrides`;未定价模型会留 `usage.unpriced` 事件,所以「护栏有没有真的生效」看得见) |
161
162
  | `priceOverrides` | 价格表覆盖/追加(`$/1M tokens`):覆盖内置同名项,或给非 Anthropic 模型定价(如 `{ 'deepseek-chat': { in: 0.27, out: 1.10 } }`)。**透传给子 agent/skill 的子循环** —— 不会「主 agent 有成本、子 agent 恒 0」。非法单价在 run 开始即抛错 |
162
163
  | `onUnpricedModel` | 遇到价格表外的模型时回调(`{ model, spanId }`,每个循环作用域内每模型一次);抛错被吞,**不改变 run 结局**(定价缺失是宿主配置问题)。用它接告警 |
@@ -174,13 +175,15 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
174
175
  | `maxIterations` | 单次覆盖 |
175
176
  | `client` | 注入 `ModelClient`(换 OpenAI 兼容端点等) |
176
177
  | `onText` | 文本增量回调(SSE/终端) |
178
+ | `signal` | `AbortSignal`:中止则在飞请求被取消,run 以 `stopReason='aborted'` 收尾(算失败) |
177
179
  | `blackboard` | 预置黑板种子(配 `Blackboard` 声明合并有键补全) |
178
180
  | `contextPolicy` | 单次覆盖上下文策略 |
181
+ | `retry` | 单次覆盖重试策略:`false` 关闭,或 `{ maxAttempts, baseDelayMs, maxDelayMs, jitter, onRetry }` 调参(只重试「本次尝试尚未产出文本」的可重试失败) |
179
182
  | `idempotencyKey` | 幂等键(异步宿主的 at-least-once 去重依据) |
180
183
  | `rethrow` | 硬失败是否抛出;缺省 `true`(异步宿主置 `false`,落 failed 记录而非冒泡) |
181
- | `tools` | 单次覆盖工具菜单 |
184
+ | `tools` | 单次覆盖工具菜单(**同样过装配期那条中间件链**,不是旁路 —— 否则 per-run 覆盖就绕开了鉴权/限流/审计) |
182
185
  | `resultSchema` | 结构化结果 schema;配 `fromZod<T>` 可让 `result.typed` 自动是 `T` |
183
- | `maxTotalTokens` | 成本硬管控:整条 run 累计 token 上限;超限以 `stopReason='budget_exceeded'` 收尾(**算失败**) |
186
+ | `maxTotalTokens` | 成本硬管控:整条 run(**含子 agent / skill 子循环**,各级共享同一 recorder 的累计账单)累计 token 上限;超限以 `stopReason='budget_exceeded'` 收尾(**算失败**) |
184
187
  | `maxCostUsd` | 成本硬管控:累计成本(美元)上限;模型不在价格表内时**不触发**(用 `priceOverrides` 定价,或用 `maxTotalTokens` 兜底) |
185
188
  | `priceOverrides` | 单次覆盖价格表(`$/1M tokens`);语义同 `createApp` 的 `priceOverrides` |
186
189
  | `onUnpricedModel` | 单次覆盖未定价回调(**是函数,因此不在 transport 的 `RunInvocationOptions` 里** —— 异步宿主不会替你传) |
@@ -308,7 +311,7 @@ const app = await createApp({ ... });
308
311
 
309
312
  > ⚠️ **本机 export 过 `ANTHROPIC_API_KEY` 的人**(比如同时用 Claude Code):按上面的优先级,脚手架 `.env` 里的 key 会被**静默压住**。改了 `.env` 却「没生效」时,先 `echo $ANTHROPIC_API_KEY` 看看环境里是不是已经有一份。
310
313
 
311
- 解析规则(刻意窄,够用就好):`KEY=VALUE`,允许 `export ` 前缀与 `=` 两侧空白;`#` 整行注释;单引号内原样、双引号内认 `\n \r \t \" \\`;未加引号的值里 ` #` 起为行内注释。**不做变量展开、不合并续行**(需要就上专门的库);既不像 `KEY=VALUE` 又不是注释的行**直接报错并指出行号** —— 静默跳过等于让你以为「配上了其实没配上」。
314
+ 解析规则(刻意窄,够用就好):`KEY=VALUE`,允许 `export ` 前缀与 `=` 两侧空白;`#` 整行注释;单引号内原样、双引号内认 `\n \r \t \" \\`;未加引号的值里 ` #` 起为行内注释。键名 `__proto__` **显式报错**(它会走原型 setter 被静默吞掉 —— 正是「以为配上了其实没配上」)。**不做变量展开、不合并续行**(需要就上专门的库);既不像 `KEY=VALUE` 又不是注释的行**直接报错并指出行号** —— 静默跳过等于让你以为「配上了其实没配上」。
312
315
 
313
316
  ### 宿主(换宿主不换语义)
314
317
 
@@ -323,7 +326,7 @@ const app = await createApp({ ... });
323
326
  | `InMemoryTaskStore` | 内存任务存储(可设 `maxRecords` 做内存闸门) |
324
327
  | `FileTaskStore` | JSONL 耐久存储(`compact()` 可压实日志) |
325
328
  | `SqliteTaskStore` | `node:sqlite` 耐久存储(WAL + busy_timeout) |
326
- | `RedisTaskStore` | duck-typed Redis 存储(可设 `ttlSeconds`) |
329
+ | `RedisTaskStore` | duck-typed Redis 存储(可设 `ttlSeconds`);`RedisLike.set` 用**位置参数**形态 `set(key, value, 'EX', seconds)` —— ioredis 原生 / node-redis legacy 变参通吃(对象形态 `{EX}` 是 node-redis 独有,ioredis 会把它字符串化成 `"[object Object]"` 发出);不设 TTL 时只传两参 |
327
330
 
328
331
  ### HTTP 端点速查(`createHttpHandler` 的路由)
329
332
 
@@ -358,13 +361,14 @@ const handler = createHttpHandler(callable, { runner });
358
361
  | `authenticate` | 入口鉴权钩子:**除 `/healthz` 与 `/metrics` 外所有路径**都过它,且在**读 body 之前**(未通过就不收 body)。正常返回即通过;抛 `HttpException` 按其 `status`/`body` 回;抛别的错误回 401,原文只进服务端日志。框架**不实现策略**(不读 env、不碰凭据) |
359
362
  | `metrics` | 指标出口:给 `metricsSink()`(或任意 `{ render() }` / 返回字符串的闭包)后,`GET /metrics` 回它的 Prometheus 文本。**不鉴权**(拉取端在集群内网);要保护请放反代后面。不给则该路径 404。⚠️ 这只管**渲染** —— 数字要真的累计,必须把**同一个** sink 注册进 `createApp({ sinks: [metrics] })`(它靠 run 收尾投递,不自己埋点),否则 `/metrics` 恒为 0 **且不报错** |
360
363
  | `maxBodyBytes` | 请求 body 上限(字节),超限回 413;缺省 1 MiB |
361
- | `maxConcurrentRuns` | 同时在跑的 `POST /run` 上限,超限回 503 + `Retry-After`;缺省 32(传 `Infinity` 恢复无上限) |
364
+ | `maxConcurrentRuns` | 同时在跑的 `POST /run` 上限,超限回 503 + `Retry-After`;缺省 32(传 `Infinity` 恢复无上限)。构造期校验:必须 > 0 或 Infinity —— NaN 会让闸门静默失效、0/负数会全部 503,故直接抛错 |
365
+ | `sseMaxBufferedBytes` | SSE 下游积压上限(字节,`res.writableLength` 超过即收口该 SSE 流并 **abort 对应 run** —— 客户端已经不消费了,继续逐 token 生成只是白烧 token);缺省 8 MiB |
362
366
  | `exposeErrors` | 是否把内部异常原文回给调用方;缺省 `false`(细节只进服务端日志) |
363
367
  | `runner` | 注入 `AsyncRunner`(共用 store / 并发上限 / `resumePending`);缺省内部 `new AsyncRunner(app)` |
364
368
 
365
369
  返回值另外挂着两样(不影响 `(req,res)` 的调用形状):
366
370
 
367
- - **`handler.drain(opts?)`** —— 优雅停机:拒新单(`POST /run` 与 `/tasks` → 503,`GET /tasks/:id` 仍可轮询)→ 等异步任务与在飞同步 run 收尾 → 强制收口仍开着的 SSE 流。返回是否排空干净;超时返回 `false`,**未完成的任务留在 store 里**,下次启动由 `resumePending` 续跑(不是丢弃)。`timeoutMs` 缺省 0 = 一直等。
371
+ - **`handler.drain(opts?)`** —— 优雅停机:拒新单(`POST /run` 与 `/tasks` → 503,`GET /tasks/:id` 仍可轮询)→ 等异步任务与在飞同步 run 收尾 → 强制收口仍开着的 SSE 流(**收口同时 abort 对应 run**,以 `stopReason='aborted'` 收尾 —— 只关流不中止会让 run 在后台继续烧 token)。返回是否排空干净;超时返回 `false`,**未完成的任务留在 store 里**,下次启动由 `resumePending` 续跑(不是丢弃)。`timeoutMs` 缺省 0 = 一直等。
368
372
  **框架不订阅信号** —— `process.on('SIGTERM', () => handler.drain())` 是宿主的事(同「框架不读 env」)。
369
373
  - **`handler.runner`** —— 内部 `AsyncRunner`,需要时手动控制(`resumePending` / `awaitTask` / `list`)。
370
374
 
@@ -404,7 +408,7 @@ process.on('SIGTERM', async () => {
404
408
  | `isAbortError` | 判定异常是否为中断(`name === 'AbortError'`) |
405
409
  | `mapWithConcurrency` | 有界并发 map(结果保序);`maxToolConcurrency` 的底座,也可自用 |
406
410
 
407
- - **取消**:`app.run(messages, { signal })` 传 `AbortSignal` —— 框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发 `signal`),run 以 `stopReason='aborted'` 收尾(算失败)。`createHttpHandler` 已内置「客户端断开即中止」;`AsyncRunner.runTimeoutMs` 到点同样是**真中止**。
411
+ - **取消**:`app.run(messages, { signal })` 传 `AbortSignal` —— 框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发 `signal`),run 以 `stopReason='aborted'` 收尾(算失败)。`createHttpHandler` 已内置「客户端断开即中止」;`AsyncRunner.runTimeoutMs` 到点同样是**真中止**(构造期校验:必须 ≥ 0 的**有限**数 —— NaN/Infinity 会被 `setTimeout` 钳到 1ms,等于每个任务立即超时,故直接抛错;要「不限」传 0 或不设)。
408
412
  - **重试**:缺省自动重试可重试失败(429 / 5xx / 连接失败),指数退避 + 抖动。`retry: false` 关闭,或 `retry: { maxAttempts, baseDelayMs, maxDelayMs, jitter, onRetry }` 调参。**只在本次尝试尚未产出任何文本时重试**(已吐出的字无法撤回)。⚠️ 与 SDK 内置重试叠加 —— 建议二选一调(这里 `maxAttempts: 1` 或把 SDK 的 `maxRetries` 调小)。
409
413
  - **流式**:`POST /run` 带 `Accept: text/event-stream` → SSE 逐帧下发(`text.delta` / `run.end` / `error`);不带该头仍回一元 JSON。
410
414
  - **工具超时 / 并发闸门**:`toolTimeoutMs` 超时**不杀 run**(该条 tool_result 记 `is_error`,模型可换路);`maxToolConcurrency` 给同回合的并行工具设上限(默认全并行)。⚠️ 超时 = **放弃等待**,`AgentTool.run` 没有 signal 参数,**副作用可能已发生** —— 想真停的工具请自行读 `ToolRunContext.signal`。
@@ -416,7 +420,7 @@ process.on('SIGTERM', async () => {
416
420
  | `TraceSink` | `{ export(trace) }`,run 收尾(成功/失败)都投递,抛错被吞 |
417
421
  | `registerDefaultTraceSink` | 注册全局默认 sink(构造期快照合并) |
418
422
  | `TraceRecorder` | 内存 recorder(一次 run 一个) |
419
- | `createOtlpExporter` | OTLP/JSON 导出,零依赖 |
423
+ | `createOtlpExporter` | OTLP/JSON 导出,零依赖;选项 `OtlpExporterOptions`:`endpoint` / `headers` / `serviceName` / `timeoutMs`(单次导出超时,缺省 10000,非正数 = 不限 —— 裸 fetch 无超时,collector 半开连接会让 run 收尾永久挂起;超时按导出失败处理,不击穿 run) |
420
424
  | `metricsSink` | 指标累加器(Prometheus 文本 / OTLP metrics),满足 `TraceSink` 即接入 —— 见 §6「指标」 |
421
425
  | `buildRunReport` | 从一条 trace 生成**调优报告**(能力/模型的耗时、token、成本、错误率排行)—— 见 §6「调优报告」 |
422
426
 
@@ -432,7 +436,14 @@ process.on('SIGTERM', async () => {
432
436
  | `createBudgetPolicy` | 预算策略:超预算先 `trimToolPairs` 编辑,再 `compactMessages` 压缩(带滞回) |
433
437
  | `trimToolPairs` | context editing:丢旧 tool 对(按**对数**,`keepToolPairs`) |
434
438
  | `compactMessages` | compaction:旧前缀做摘要(摘要器由你注入,框架不替你造 token) |
435
- | `estimateMessages` | 估算 token(预算决策用,不是精确记账) |
439
+ | `estimateMessages` | 估算一组消息的 token(预算决策用,不是精确记账) |
440
+ | `defaultEstimateTokens` | 缺省的单文本估算函数(CJK 感知启发式:CJK ≈ 1.5 字/token、其余 ≈ 4 字符/token) |
441
+ | `renderMessages` | 把 messages 渲染成纯文本 —— 喂给你注入的 compaction 摘要器(`summarize`)用 |
442
+
443
+ **per-run 隔离(`ContextPolicy.forRun`)**:策略可能被配成应用级单例(`createApp({ contextPolicy })`)
444
+ 被所有 run 复用。带状态的实现(滞回计数、token 缓存等)应实现可选的 `forRun(): ContextPolicy` ——
445
+ 引擎在每条 run 开始时调一次,拿**本 run 专用**的实例(`createBudgetPolicy` 已实现它);
446
+ 不实现的自定义策略按单例复用,状态跨 run(含并发 run)共享 —— 适合无状态策略,有状态请实现 `forRun`。
436
447
 
437
448
  ### 成本硬管控(**别与上面的上下文预算混为一谈**)
438
449
 
@@ -451,8 +462,8 @@ process.on('SIGTERM', async () => {
451
462
 
452
463
  - 超限后 run 以 `stopReason='budget_exceeded'` 收尾(**算失败**),run 根记一条 `budget.exceeded` 事件(带 `{ kind, limit, actual, totalTokens, costUsd }`)。
453
464
  - **不是硬实时**:一回合跑完才判,实际用量可能超上限一个回合的量。
454
- - **模型自然收尾的那一回合超限不改判失败**(只留事件)—— 那次 run 的任务其实做完了,不该追认成失败。
455
- - 子 agent 的 token **计入**总账(口径 = 整个 trace 的 `totalUsage`)。
465
+ - **模型自然收尾的那一回合超限不改判失败**(只留事件)—— 那次 run 的任务其实做完了,不该追认成失败。同理,超预算的回合仍照常处理 `submit_result`(纯内部的结构化提交、零副作用)—— 模型已把最终结果交出来,连同回合丢弃等于白烧这一回合。
466
+ - 预算是**整条 run(含各级子 agent / skill 子循环)**的口径:上限经 `ToolRunContext` 透传,各级循环共享同一 recorder 的累计账单、每回合各自检查。子循环超限以 `budget_exceeded` 收尾(该次能力调用记 `is_error`,capability span 上记 `budget.exceeded` 事件),主循环在下一回合**入口**拦住、不再发出新请求,整条 run 以 `budget_exceeded` 收尾。
456
467
 
457
468
  ```ts
458
469
  const { result } = await app.run(messages, { maxTotalTokens: 200_000 });
@@ -507,7 +518,7 @@ if (result.stopReason === 'budget_exceeded') console.warn('这次 run 被预算
507
518
  | `McpClientLike` | 最小结构面:`listTools()` + `callTool(name, args)`;框架**不 import** MCP SDK |
508
519
  | `MCP_DEFAULT_TIMEOUT_MS` | 桥的缺省单次调用超时(60000 ms) |
509
520
 
510
- - **名字**:`prefix + 归一化原名`(MCP 名里的 `-` / `.` / 空格 → `_`)。归一化后**空名 / 撞名 / 超 64 字符**一律**装配期抛错**(静默改名会得到一个调不回去的名字,比启动期报错难查得多)。
521
+ - **名字**:`prefix + 归一化原名`(MCP 名里的 `-` / `.` / 空格 → `_`)。归一化后**空名(原名不含任何 ASCII 字母/数字/下划线时产物为空,如全 emoji 名)/ 撞名 / 超 64 字符**一律**装配期抛错**(静默改名会得到一个调不回去的名字,比启动期报错难查得多)。
511
522
  - **原名**:每次调用写进发起 turn 的 `mcp.tool` attribute —— 审计 / 回放要还原它才能回调 server。
512
523
  - **入参 schema**:MCP 的 `inputSchema` 已是 JSON Schema → 原样透传,由 engine 的子集校验器在 `callTool` **之前**校验(非法入参根本不会发给 server,模型自己会改)。
513
524
  - **失败**:`callTool` 抛错 → 该条 `tool_result` 记 `is_error`,**不杀 run**(与本地工具抛错同语义)。⚠️ **协议层的 `isError: true` 框架看不见** —— 连接器必须转成抛错,否则模型以为成功了。
@@ -582,7 +593,9 @@ if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
582
593
  时长同时给两种口径,**并存不冲突**:
583
594
 
584
595
  - **histogram**(`*_bucket` / `*_sum` / `*_count`,累积语义)—— 抓取端可**跨实例任意聚合**;
585
- - **窗口内精确分位**(`*{quantile="..."}` gauge)—— 单实例排障时更好读。
596
+ - **窗口内精确分位**(`*_last{quantile="..."}` gauge,如 `agentia_run_duration_ms_last`)—— 单实例排障时更好读。
597
+ 分位 gauge 与 histogram **必须不同名**(同名指标只允许一种 TYPE,混发会被 expfmt 判硬错误、整次 scrape 失败),
598
+ 故分位家族统一带 `_last` 后缀;capability / model 维度同理(`capability_duration_ms_last` / `model_duration_ms_last`)。
586
599
 
587
600
  ### `MetricsSinkOptions`(`metricsSink` 的选项)
588
601
 
@@ -592,6 +605,7 @@ if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
592
605
  | `endpoint` | OTLP 采集端基地址(如 `http://localhost:4318`);尾部斜杠会被去掉 |
593
606
  | `intervalMs` | OTLP 导出间隔(毫秒,缺省 60000);`0` = 每次 run 收尾立即导出。定时器已 `unref()`,不阻止进程退出 |
594
607
  | `resourceAttributes` / `serviceName` | OTLP resource 属性(`service.name` 缺省 `agentia`) |
608
+ | `timeoutMs` | OTLP 单次导出请求超时(毫秒,缺省 10000,非正数 = 不限)——collector 半开连接时兜底,`intervalMs: 0` 模式不被挂死;超时按导出失败处理 |
595
609
  | `onExportError` | 导出失败回调(缺省吞掉 —— 观测失败不得击穿业务) |
596
610
  | `windowSize` | 时长分位保留的样本数(环形窗口,缺省 1024,**run / 能力 / 模型各自独立**);非正数抛错 |
597
611
  | `prefix` | 指标名前缀,缺省 `agentia_` |
@@ -798,7 +812,11 @@ const callable = {
798
812
  | 历史畸形就放弃裁剪 | `trimToolPairs` 遇到非严格交替历史会整体放弃(宁可少裁,也不切出孤立 tool_use 让请求 400) |
799
813
  | 缺省内存 store 不淘汰 | 长跑宿主请设 `InMemoryTaskStore({ maxRecords })` 或换 `FileTaskStore` / `SqliteTaskStore` |
800
814
  | 能力引用是 provider 粒度 | 子 agent / skill 的 `tools` 写的是 **provider token**,不是单个工具名 |
815
+ | 能力名有格式校验 | 装饰器能力名(`name` 或缺省的方法名)必须匹配 `^[A-Za-z0-9_-]{1,64}$`(与 MCP 桥同口径),非法名在 `createApp` **装配期即抛错** —— 含空格/点/中文的名字会让模型 API 400,宁可在启动期拦住 |
816
+ | `discover` 入口会回落 | 能力目录里源码与编译产物并存(`index.ts` + `index.js`)时,首选 `.ts` 加载失败会**回落 `.js` 并 warn** —— 命中的可能是**陈旧编译产物**(刚改过源码时注意);全部候选都失败才抛错并列出各自原因 |
817
+ | `asset()` 的 rel 必须是相对路径 | 带 scheme(`file:` / `https:` …)的 rel 会让 `new URL(rel, base)` 整个忽略 base(「以为读了能力目录、实际读了别处」),显式抛错;`../` 越出能力目录是**有意放行**(共享资产如 `../../shared/x.md` 是合法用法) |
801
818
  | 取消要传进客户端才有效 | 传 `signal` 后框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发);不转发 `signal` 的自定义 `ModelClient` 只能「放弃等待」(请求在后台跑完、产物丢弃) |
819
+ | 工具阶段的 abort 有盲区 | abort 只在三处被观察:**回合边界 / 在飞模型请求 / 重试退避 sleep**。没设 `toolTimeoutMs` 且工具挂死时,abort 之后 run 也不会返回(工具的 Promise 永不 settle)—— 挂死的工具要么设超时,要么自己读 `ToolRunContext.signal` |
802
820
  | 观测失败被吞 | sink 抛错不影响 run(观测是辅助动作);同理记忆水合/回写失败也不击穿 run |
803
821
  | 框架不自动读 .env | 除 `AGENTIA_MODEL`(缺省模型覆盖)与 `OPENAI_API_KEY`(OpenAI 适配器)外,框架自己不去翻环境变量,也不读 `.env`;要读就在启动代码里调 `loadEnvFile()`(脚手架已内置那行),**真实环境变量优先**于文件 |
804
822
  | 鉴权只是缝 | 框架**不实现** token / JWT / 签名策略,也不碰凭据 env —— `authenticate` 只承诺「拦在入口、读 body 之前」;策略是宿主或反代的事 |
@@ -810,13 +828,15 @@ const callable = {
810
828
  | `maxCostUsd` 依赖价格表 | 模型不在价格表内(且未用 `priceOverrides` 覆盖)时成本恒为 0,这条护栏**不触发** —— 要无条件兜底用 `maxTotalTokens`。**但失效不再静默**:turn 上会记 `usage.unpriced` 事件、指标有 `model_unpriced_turns_total`、可回调 `onUnpricedModel` |
811
829
  | 工具超时**不取消**工具 | `AgentTool.run` 没有 signal 参数,超时只是「不等了」;副作用可能已发生。想真停请让工具自己读 `ToolRunContext.signal` |
812
830
  | 会话只存对话轮次 | `SessionStore` 存「用户输入 + 最终回复」,run 内部的 tool 往返**不进历史**(要完整过程用 `traceToMessages`);且只有**跑成功**的轮次才回写 |
831
+ | 同 session 并发 run 要自行串行化 | `SessionStore` 是 **append-only**:并发写不互相覆盖、不丢数据,但**不保证角色交替** —— 两个并发 run 共用同一 sessionId 时,各自追加的轮次可能交错成「连续两条 user」,下一轮 load 出来撞角色交替校验(400)。同一 session 的并发 run 请调用方自行串行化(每 session 一把锁 / 一条队列) |
813
832
  | OpenAI 适配器听端点的话 | 请求发 `stream:true`,但**按响应形态解析**:端点回 JSON 就退回一次性(没有打字机效果),回 `event-stream` 才逐 token |
833
+ | OpenAI 流式的上游故障不再装成功 | 流中 `error` 分片(上游把故障塞进 200 的流)与「流正常结束却无文本无 tool_calls」都**抛错**按失败处理 —— 不再静默映射成「成功空回复」(与非流式空 `choices` 的守卫同口径) |
814
834
  | MCP 只做 tools | `sampling`(server 反向请求模型)/ `resources` / `prompts` 原语不做;连接器(stdio / HTTP)不在框架内 |
815
835
  | MCP 的协议层错误框架看不见 | `isError: true` 只有连接器能看见 —— 它必须转成抛错,否则模型收到的是一条「成功」的结果 |
816
836
  | MCP 超时同样是「不等了」 | 桥自带的 `timeoutMs` 取消不了 server 侧执行(拿不到取消句柄);它与 engine 的 `toolTimeoutMs` **双重计时**,谁短谁生效 |
817
837
  | MCP 名字可能被归一化 | 原名含 `-` / `.` / 空格 → 进菜单时变成 `_`;回调 server 用的仍是原名(`mcp.tool` attribute 里查得到) |
818
838
  | MCP 工具不能进 DI 容器 | 它没有 provider token,也不能被别的能力的 `tools` 引用 —— 引用是 provider 粒度 |
819
- | 指标分位是窗口内精确值 | `*{quantile=...}` 只反映最近 `windowSize`(缺省 1024)条样本;要跨实例聚合请用直方图(`*_bucket` / `_sum` / `_count`,累积语义) |
839
+ | 指标分位是窗口内精确值 | `*_last{quantile=...}` 只反映最近 `windowSize`(缺省 1024)条样本;要跨实例聚合请用直方图(`*_bucket` / `_sum` / `_count`,累积语义) |
820
840
  | 指标是**进程内**累加 | 不做分布式聚合与持久化:多实例各算各的(直方图可相加),重启即清零。要长期保留请把 `render()` 抓走或用 `export:'otlp'` 推给采集端 |
821
841
  | OTLP metrics 只推当前累计 | 按 `intervalMs` 周期导出**累积值**(CUMULATIVE),不做增量/背压;导出失败按 `onExportError` 处理(缺省吞掉,不重试、不阻塞 run) |
822
842
  | `GET /metrics` 不鉴权 | 与 `/healthz` 同档(拉取端在集群内网)。要保护请放反代之后,或不传 `metrics` 选项自行在外层挂路由 |
@@ -855,7 +875,7 @@ const callable = {
855
875
  | MCP 调用「成功」但内容是错误文本 | 连接器没把协议层 `isError: true` 转成抛错(框架只认抛错) |
856
876
  | eval 里模型调了不存在的工具 | 脚本里的工具名必须是**菜单里的名字**(MCP 工具是归一化后的 `mcp_<server>_<name>`) |
857
877
  | `metricsSink` 的数字一直是 0 | 没接进 `createApp({ sinks })`(或 `registerDefaultTraceSink`)—— 它靠 run 收尾投递,不自己埋点 |
858
- | 拿 `metricsSink({ export: 'otlp' })` 报错 | 这是**故意的**:OTLP metrics 导出后置,构造期响亮失败好过给你一份空指标 |
878
+ | `metricsSink({ export: 'otlp' })` 构造期报错 | 没给 `endpoint` —— OTLP 导出必须知道往哪发,响亮失败好过静默不导出;补上 `endpoint`(如 `http://localhost:4318`)即可。`windowSize` / `maxCapabilities` 非正数、`buckets` 非严格升序同理是构造期配置校验 |
859
879
 
860
880
  ---
861
881
 
@@ -867,6 +887,4 @@ npm run typecheck:tests # 测试目录类型(含类型断言测试)
867
887
  npm run test # 单测(node:test)
868
888
  ```
869
889
 
870
- 框架仓库另有四道:`npm run typecheck:types`(针对构建产物的类型测试)、`npm run e2e`(CLI 端到端 +
871
- `examples/complete` 真起服务跑三种触发)、`npm run e2e:examples`(只跑后者)、
872
- `npm run e2e:mcp`(真接一个 MCP server 走完「映射 → 菜单 → run」;无网时自动回落本地夹具 server)。
890
+ 框架仓库另有这些门禁:`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)。
@@ -8,3 +8,10 @@
8
8
  */
9
9
  /** 合成多个中断源:任一已中止 / 后中止即中止;忽略 undefined;全空返回**永不中止**的 signal。 */
10
10
  export declare function combineSignals(...signals: Array<AbortSignal | undefined>): AbortSignal;
11
+ /**
12
+ * run 正常收尾(没有任何源中止)时主动摘除 combineSignals 挂在各源上的监听器。
13
+ * 对非 combineSignals 产物 / 已清理的 signal 幂等空操作。
14
+ *
15
+ * 模块级 export(不进公共面):调用方(如 transport/async)在 run settle 后调用。
16
+ */
17
+ export declare function releaseCombinedSignal(signal: AbortSignal): void;
@@ -6,6 +6,8 @@
6
6
  *
7
7
  * Node 18 没有 `AbortSignal.any`,这里手写兜底(Node 20.3+ 才有)。
8
8
  */
9
+ /** 合成 signal → 其「摘除全部源监听器」的清理函数(仅 combineSignals 多源分支的产物在表内) */
10
+ const cleanups = new WeakMap();
9
11
  /** 合成多个中断源:任一已中止 / 后中止即中止;忽略 undefined;全空返回**永不中止**的 signal。 */
10
12
  export function combineSignals(...signals) {
11
13
  const real = signals.filter((s) => s !== undefined);
@@ -20,9 +22,33 @@ export function combineSignals(...signals) {
20
22
  }
21
23
  }
22
24
  if (!ac.signal.aborted) {
23
- for (const s of real)
24
- s.addEventListener('abort', () => ac.abort(s.reason), { once: true });
25
+ // 每个源一个具名监听(存进 map 供摘除):合成 signal 中止后,其余源上的监听器
26
+ // 就再也没用 —— 不摘掉的话,宿主级共享 signal(长寿)每跑一条 run 多挂一个,
27
+ // 累积 >10 触发 MaxListenersExceededWarning,闭包也跟着滞留。
28
+ const listeners = new Map();
29
+ const detach = () => {
30
+ for (const [s, l] of listeners)
31
+ s.removeEventListener('abort', l);
32
+ listeners.clear();
33
+ cleanups.delete(ac.signal);
34
+ };
35
+ for (const s of real) {
36
+ const l = () => ac.abort(s.reason);
37
+ listeners.set(s, l);
38
+ s.addEventListener('abort', l, { once: true });
39
+ }
40
+ ac.signal.addEventListener('abort', detach, { once: true });
41
+ cleanups.set(ac.signal, detach);
25
42
  }
26
43
  return ac.signal;
27
44
  }
45
+ /**
46
+ * run 正常收尾(没有任何源中止)时主动摘除 combineSignals 挂在各源上的监听器。
47
+ * 对非 combineSignals 产物 / 已清理的 signal 幂等空操作。
48
+ *
49
+ * 模块级 export(不进公共面):调用方(如 transport/async)在 run settle 后调用。
50
+ */
51
+ export function releaseCombinedSignal(signal) {
52
+ cleanups.get(signal)?.();
53
+ }
28
54
  //# sourceMappingURL=abort.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"abort.js","sourceRoot":"","sources":["../../src/core/abort.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,gEAAgE;AAChE,MAAM,UAAU,cAAc,CAAC,GAAG,OAAuC;IACvE,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAoB,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC;IACtE,yBAAyB;IACzB,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,CAAC,CAAC;IAEtC,MAAM,EAAE,GAAG,IAAI,eAAe,EAAE,CAAC;IACjC,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC;YACd,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;YACnB,MAAM;QACR,CAAC;IACH,CAAC;IACD,IAAI,CAAC,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACvB,KAAK,MAAM,CAAC,IAAI,IAAI;YAAE,CAAC,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9F,CAAC;IACD,OAAO,EAAE,CAAC,MAAM,CAAC;AACnB,CAAC"}
1
+ {"version":3,"file":"abort.js","sourceRoot":"","sources":["../../src/core/abort.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,gEAAgE;AAChE,MAAM,QAAQ,GAAG,IAAI,OAAO,EAA2B,CAAC;AAExD,gEAAgE;AAChE,MAAM,UAAU,cAAc,CAAC,GAAG,OAAuC;IACvE,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAoB,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC;IACtE,yBAAyB;IACzB,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,CAAC,CAAC;IAEtC,MAAM,EAAE,GAAG,IAAI,eAAe,EAAE,CAAC;IACjC,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC;YACd,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;YACnB,MAAM;QACR,CAAC;IACH,CAAC;IACD,IAAI,CAAC,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACvB,+CAA+C;QAC/C,gDAAgD;QAChD,iDAAiD;QACjD,MAAM,SAAS,GAAG,IAAI,GAAG,EAA2B,CAAC;QACrD,MAAM,MAAM,GAAG,GAAS,EAAE;YACxB,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,SAAS;gBAAE,CAAC,CAAC,mBAAmB,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;YAClE,SAAS,CAAC,KAAK,EAAE,CAAC;YAClB,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC;QAC7B,CAAC,CAAC;QACF,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;YACrB,MAAM,CAAC,GAAG,GAAS,EAAE,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;YACzC,SAAS,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YACpB,CAAC,CAAC,gBAAgB,CAAC,OAAO,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;QACjD,CAAC;QACD,EAAE,CAAC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;QAC5D,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,CAAC;IACD,OAAO,EAAE,CAAC,MAAM,CAAC;AACnB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CAAC,MAAmB;IACvD,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;AAC3B,CAAC"}
@@ -57,19 +57,21 @@ function checkObject(schema, value, path) {
57
57
  }
58
58
  const obj = value;
59
59
  for (const key of schema.required ?? []) {
60
- if (!(key in obj))
60
+ // 用 Object.hasOwn 而非 `in`:后者会命中原型链,required:['constructor'] 就恒通过了
61
+ if (!Object.hasOwn(obj, key))
61
62
  return `${path}: 缺少必需属性 "${key}"`;
62
63
  }
63
64
  const properties = schema.properties ?? {};
64
65
  for (const [key, sub] of Object.entries(properties)) {
65
- if (key in obj) {
66
+ if (Object.hasOwn(obj, key)) {
66
67
  const err = check(sub, obj[key], `${path}.${key}`);
67
68
  if (err)
68
69
  return err;
69
70
  }
70
71
  }
71
72
  if (schema.additionalProperties === false) {
72
- const extra = Object.keys(obj).filter((k) => !(k in properties));
73
+ // 同上:`k in properties` 会把 toString 之类的原型键当成「已声明」放行
74
+ const extra = Object.keys(obj).filter((k) => !Object.hasOwn(properties, k));
73
75
  if (extra.length > 0) {
74
76
  return `${path}: 存在 schema 未声明的属性 ${extra.map((k) => `"${k}"`).join(', ')}`;
75
77
  }
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/core/schema.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/core/schema.ts"],"names":[],"mappings":"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"}
@@ -125,6 +125,15 @@ export interface ToolRunContext {
125
125
  * 不设(undefined)= 子循环用各自缺省(不是"不截断")。
126
126
  */
127
127
  maxEventChars?: number | false;
128
+ /**
129
+ * 成本硬管控(C1)透传:整条 run 累计 token 上限(见 `RunAgentOptions.maxTotalTokens`)。
130
+ * 嵌套能力(@SubAgent / @Skill)拉起自己的 llm 循环时必须原样传下去 —— 预算是
131
+ * **整条 run(含各级子 agent)** 的口径,子循环不拿到它就等于护栏在子循环期间离线
132
+ * (最坏可超一整个子 run 的用量)。各级循环共享同一 recorder,按同一份累计账单判断。
133
+ */
134
+ maxTotalTokens?: number;
135
+ /** 成本硬管控(C1)透传:累计成本(美元)上限;同 maxTotalTokens 的透传语义 */
136
+ maxCostUsd?: number;
128
137
  }
129
138
  export interface AgentTool<I = unknown, O = unknown> {
130
139
  /** 模型可见的唯一名(建议 snake_case) */
@@ -56,6 +56,15 @@ export declare function runAgentScoped<S extends JsonSchema = JsonSchema>(opts:
56
56
  model: string;
57
57
  spanId: string;
58
58
  }) => void;
59
+ /**
60
+ * 成本硬管控(C1):由发起它的能力从 ToolRunContext 透传 —— 预算是整条 run 的口径
61
+ * (各级循环共享同一 recorder,按同一份累计账单判断),子循环每回合同样检查;
62
+ * 子循环超限以 stopReason='budget_exceeded' 收尾,由能力层包成 is_error 回主循环,
63
+ * 主循环回合入口的预算检查随即将整条 run 停掉。
64
+ */
65
+ maxTotalTokens?: number;
66
+ /** 成本硬管控(C1):累计成本(美元)上限;同 maxTotalTokens */
67
+ maxCostUsd?: number;
59
68
  }): Promise<AgentLoopResult<SchemaType<S>>>;
60
69
  /**
61
70
  * 用 `next` 原地替换 `target` 的全部内容(保持数组引用不变 —— 循环各处持同一数组)。
@@ -47,6 +47,17 @@ function appendResultInstruction(system) {
47
47
  return `${system}\n\n${RESULT_INSTRUCTION}`;
48
48
  return [...system, { type: 'text', text: RESULT_INSTRUCTION }];
49
49
  }
50
+ /**
51
+ * 上下文策略按 run 隔离:实现提供 `forRun` 时每条 run 拿一个全新实例 ——
52
+ * 内置 createBudgetPolicy 的滞回计数(lastCompactAt)与增量 token 缓存都是
53
+ * **per-run 状态**,应用级单例(AppOptions.contextPolicy)被多 run 复用时,
54
+ * 不隔离会让「run A 第 39 回合刚压缩过」卡住「run B 前 40 回合永不压缩」,
55
+ * 并发 run 交替调用还会让计数缓存每次从零重算。
56
+ * 未实现 forRun 的自定义策略原样复用(无状态策略本来就不需要隔离)。
57
+ */
58
+ function forkPolicyPerRun(policy) {
59
+ return policy?.forRun ? policy.forRun() : policy;
60
+ }
50
61
  /** 循环体核心:带父 span 跑一轮 manual loop。请求失败按 error 收掉 turn 后抛出,由外层收尾。 */
51
62
  async function agentLoop(args) {
52
63
  const { client, model, recorder, parentSpanId } = args;
@@ -100,6 +111,13 @@ async function agentLoop(args) {
100
111
  },
101
112
  })
102
113
  : undefined;
114
+ const budgetError = (over) => ({
115
+ type: 'budget_exceeded',
116
+ message: over === 'tokens'
117
+ ? `累计 token 已超过上限 ${args.maxTotalTokens}`
118
+ : `累计成本已超过上限 $${args.maxCostUsd}`,
119
+ retryable: false,
120
+ });
103
121
  for (let iteration = 0; iteration < args.maxIterations; iteration++) {
104
122
  // 调用方已取消:不再发起新回合,直接以 aborted 收尾(不抛异常,语义确定)
105
123
  if (signal?.aborted) {
@@ -108,6 +126,19 @@ async function agentLoop(args) {
108
126
  finished = true;
109
127
  break;
110
128
  }
129
+ // 成本硬管控(C1)回合入口再判一次:上一回合的**工具执行**(子 agent 循环等嵌套能力)
130
+ // 可能已把整条 run 的累计用量推过上限(回合末的那次判断当时还没超)—— 在发起新
131
+ // 请求前拦住:不再发请求 = 不再花钱。与「模型自然收尾不改判」不冲突:自然收尾在
132
+ // 上一回合就 break 了,走不到这里。
133
+ if (budget) {
134
+ const over = budget.check(recorder.snapshot('ok'));
135
+ if (over) {
136
+ stopReason = 'budget_exceeded';
137
+ error = budgetError(over);
138
+ finished = true;
139
+ break;
140
+ }
141
+ }
111
142
  // 发送前给上下文策略一个机会(编辑/压缩预算超限的历史)
112
143
  if (args.contextPolicy) {
113
144
  const next = await args.contextPolicy.beforeTurn(messages, { iteration, model });
@@ -142,7 +173,12 @@ async function agentLoop(args) {
142
173
  });
143
174
  stream.on('text', (delta) => {
144
175
  emitted = true;
145
- args.onText?.(delta);
176
+ try {
177
+ args.onText?.(delta);
178
+ }
179
+ catch {
180
+ /* 观测是辅助动作:回调抛错不得影响 run(与 onUnpricedModel 同口径) */
181
+ }
146
182
  });
147
183
  message = await stream.finalMessage();
148
184
  break;
@@ -164,7 +200,12 @@ async function agentLoop(args) {
164
200
  throw e; // 冒泡:runAgent 或子 agent 运行器负责标记根/capability 与收尾
165
201
  const delayMs = backoffDelay(attempt, retryCfg);
166
202
  recorder.event(turnId, 'llm.retry', { attempt, delayMs, error: errInfo.type });
167
- retryCfg.onRetry({ attempt, delayMs, error: errInfo });
203
+ try {
204
+ retryCfg.onRetry({ attempt, delayMs, error: errInfo });
205
+ }
206
+ catch {
207
+ /* 观测是辅助动作:回调抛错不得影响 run(与 onUnpricedModel 同口径) */
208
+ }
168
209
  try {
169
210
  await sleep(delayMs, signal);
170
211
  }
@@ -228,6 +269,13 @@ async function agentLoop(args) {
228
269
  if (message.stop_reason === 'max_tokens') {
229
270
  stopReason = 'max_tokens';
230
271
  finalText = textOf(message);
272
+ // 与 budget_exceeded / refusal 同口径:非正常收尾都带结构化 error(进 run 根 span),
273
+ // 否则 trace 里这类 run「失败却没有原因」
274
+ error = {
275
+ type: 'max_tokens',
276
+ message: `模型输出触顶被截断(max_tokens=${args.maxTokens})`,
277
+ retryable: false,
278
+ };
231
279
  finished = true;
232
280
  break;
233
281
  }
@@ -235,6 +283,11 @@ async function agentLoop(args) {
235
283
  // 无 server tools 时正常不会到;避免无限循环直接停
236
284
  stopReason = 'pause_turn';
237
285
  finalText = textOf(message);
286
+ error = {
287
+ type: 'pause_turn',
288
+ message: '模型返回 pause_turn(无 server tools 的场景不应出现),防死循环直接收尾',
289
+ retryable: false,
290
+ };
238
291
  finished = true;
239
292
  break;
240
293
  }
@@ -265,22 +318,15 @@ async function agentLoop(args) {
265
318
  }
266
319
  // 成本硬管控(C1):走得到这里说明循环还要继续(模型要求调工具)—— 超限就停,
267
320
  // 连带不执行这批工具(避免超预算的 run 继续产生副作用)。已产出的文本保留。
268
- if (overBudget) {
269
- stopReason = 'budget_exceeded';
270
- finalText = textOf(message);
271
- error = {
272
- type: 'budget_exceeded',
273
- message: overBudget === 'tokens'
274
- ? `累计 token 已超过上限 ${args.maxTotalTokens}`
275
- : `累计成本已超过上限 $${args.maxCostUsd}`,
276
- retryable: false,
277
- };
278
- finished = true;
279
- break;
280
- }
321
+ // 例外:submit_result 是纯内部的结构化提交(零副作用、不触外部系统),超预算也照常
322
+ // 处理本回合的它 —— 模型已经把最终结果交出来了,连同回合一起丢弃等于白烧这一回合
323
+ // (与「自然收尾不因超预算改判失败」同口径)。
324
+ const runnable = overBudget
325
+ ? toolUses.filter((u) => args.resultSchema !== undefined && u.name === SUBMIT_RESULT)
326
+ : toolUses;
281
327
  // —— 执行工具:默认全并行,可由 maxToolConcurrency 收窄(C2);
282
328
  // 单条 user 消息回全部 tool_result(抑制并行是反模式)——
283
- const toolResults = await mapWithConcurrency(toolUses, args.maxToolConcurrency ?? Number.POSITIVE_INFINITY, async (use) => {
329
+ const toolResults = await mapWithConcurrency(runnable, args.maxToolConcurrency ?? Number.POSITIVE_INFINITY, async (use) => {
284
330
  const tool = args.tools.find((t) => t.name === use.name);
285
331
  // 工具级时序(E1):起点在事件之前 —— durationMs 覆盖「入参校验 + 执行 + 超时等待」
286
332
  // 的完整处理时长,是「哪一步慢」的可信基线。并行工具各记各的(tool_use_id 配对)。
@@ -300,6 +346,10 @@ async function agentLoop(args) {
300
346
  ...(args.priceOverrides ? { priceOverrides: args.priceOverrides } : {}),
301
347
  // 事件截断口径同样透传:调试期开了全文,子 agent 的工具事件不该还是被截断的
302
348
  ...(args.maxEventChars != null ? { maxEventChars: args.maxEventChars } : {}),
349
+ // 成本护栏(C1)同样透传:预算是整条 run(含各级子 agent)的口径,
350
+ // 子循环拿不到就等于护栏在子循环期间离线(各级共享同一 recorder,按同一账单判断)
351
+ ...(args.maxTotalTokens != null ? { maxTotalTokens: args.maxTotalTokens } : {}),
352
+ ...(args.maxCostUsd != null ? { maxCostUsd: args.maxCostUsd } : {}),
303
353
  };
304
354
  let ok = true;
305
355
  let content = '';
@@ -319,9 +369,13 @@ async function agentLoop(args) {
319
369
  }
320
370
  else {
321
371
  content = 'submitted';
322
- // 模型提交的 input 已过 resultSchema 校验 → 断言为 SchemaType<S>(信任边界在此)
323
- typed = use.input;
324
- submitted = true;
372
+ // 同回合并行多个 submit_result:**先到先得**(首个校验通过的生效,后续忽略)——
373
+ // 不 guarded 赋值的话 typed 由并发完成顺序竞态决定,同输入可能产出不同结果
374
+ if (!submitted) {
375
+ // 模型提交的 input 已过 resultSchema 校验 → 断言为 SchemaType<S>(信任边界在此)
376
+ typed = use.input;
377
+ submitted = true;
378
+ }
325
379
  }
326
380
  }
327
381
  catch (e) {
@@ -386,7 +440,8 @@ async function agentLoop(args) {
386
440
  is_error: !ok,
387
441
  };
388
442
  });
389
- messages.push({ role: 'user', content: toolResults });
443
+ if (toolResults.length > 0)
444
+ messages.push({ role: 'user', content: toolResults });
390
445
  if (submitted) {
391
446
  // submit_result 校验通过:结构化结果落定,循环正常收尾(finalText 取该回合文本,可空)
392
447
  stopReason = 'end_turn';
@@ -394,10 +449,24 @@ async function agentLoop(args) {
394
449
  finished = true;
395
450
  break;
396
451
  }
452
+ // 超预算且本回合未落定结构化结果:不再发起下一回合,以 budget_exceeded 收尾
453
+ if (overBudget) {
454
+ stopReason = 'budget_exceeded';
455
+ finalText = textOf(message);
456
+ error = budgetError(overBudget);
457
+ finished = true;
458
+ break;
459
+ }
397
460
  }
398
461
  if (!finished) {
399
- // 循环因 maxIterations 上限退出而非正常终止(所有置 stopReason 的分支都已同时置 finished)
462
+ // 循环因 maxIterations 上限退出而非正常终止(所有置 stopReason 的分支都已同时置 finished)。
463
+ // 与 budget_exceeded / refusal 同口径:非正常收尾都带结构化 error(进 run 根 span)。
400
464
  stopReason = 'max_iterations';
465
+ error = {
466
+ type: 'max_iterations',
467
+ message: `达到循环上限(maxIterations=${args.maxIterations})仍未收尾`,
468
+ retryable: false,
469
+ };
401
470
  }
402
471
  return { stopReason, finalText, error, iterations: progress.iterations, typed };
403
472
  }
@@ -430,7 +499,8 @@ export async function runAgent(options) {
430
499
  onText: options.onText,
431
500
  signal: options.signal,
432
501
  retry: options.retry,
433
- contextPolicy: options.contextPolicy,
502
+ // 按 run 分叉策略实例:per-run 状态(滞回/计数缓存)不跨 run 泄漏
503
+ contextPolicy: forkPolicyPerRun(options.contextPolicy),
434
504
  resultSchema: options.resultSchema,
435
505
  progress,
436
506
  maxTotalTokens: options.maxTotalTokens,
@@ -483,13 +553,15 @@ export async function runAgentScoped(opts) {
483
553
  onText: opts.onText,
484
554
  signal: opts.signal,
485
555
  retry: opts.retry,
486
- contextPolicy: opts.contextPolicy,
556
+ contextPolicy: forkPolicyPerRun(opts.contextPolicy),
487
557
  resultSchema: opts.resultSchema,
488
558
  toolTimeoutMs: opts.toolTimeoutMs,
489
559
  maxToolConcurrency: opts.maxToolConcurrency,
490
560
  maxEventChars: opts.maxEventChars,
491
561
  priceOverrides: opts.priceOverrides,
492
562
  onUnpricedModel: opts.onUnpricedModel,
563
+ maxTotalTokens: opts.maxTotalTokens,
564
+ maxCostUsd: opts.maxCostUsd,
493
565
  });
494
566
  }
495
567
  function toApiTool(t) {