@migor/cli 0.3.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.
- package/dist/AGENTS.md +59 -23
- package/dist/add.js +2 -1
- package/dist/dev.js +2 -1
- package/dist/inspector/fromTrace.js +24 -5
- package/dist/inspector/index.js +9 -1
- package/dist/inspector/trace-view.css +51 -0
- package/dist/inspector/view.js +65 -5
- package/dist/inspector-page.html +6 -1
- package/dist/inspector.js +56 -7
- package/dist/npm-bin.js +7 -0
- package/dist/templates.js +1 -1
- package/package.json +2 -2
package/dist/AGENTS.md
CHANGED
|
@@ -133,7 +133,7 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
|
|
|
133
133
|
| `name` | 缺省取方法名 |
|
|
134
134
|
| `schema` | 模板化入参 schema;缺省空对象(无参资产) |
|
|
135
135
|
|
|
136
|
-
`@Prompt`
|
|
136
|
+
`@Prompt` **只支持方法形态**(标准装饰器下字段拿不到值/类引用)。实例方法与静态方法都**沿继承链**收集(父类的 `@Prompt` 资产子类自动带上)。方法体内用 `asset(import.meta.url, './x.md')` 读同目录长文本。
|
|
137
137
|
|
|
138
138
|
---
|
|
139
139
|
|
|
@@ -151,17 +151,19 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
|
|
|
151
151
|
| `model` | 缺省模型;不给则 `AGENTIA_MODEL` env,再回落 `claude-opus-5` |
|
|
152
152
|
| `maxTokens` | 缺省 `max_tokens` |
|
|
153
153
|
| `maxIterations` | 缺省循环上限 |
|
|
154
|
-
| `
|
|
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
|
|
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 结局**(定价缺失是宿主配置问题)。用它接告警 |
|
|
163
164
|
| `toolTimeoutMs` | 缺省单工具超时(毫秒);超时该条 tool_result 记 is_error,不杀 run |
|
|
164
165
|
| `maxToolConcurrency` | 缺省同回合并行工具上限;不设 = 不限(全并行) |
|
|
166
|
+
| `maxEventChars` | 缺省 trace 事件正文截断上限(可被单次 run 覆盖):数字 = 入参/出参统一用该上限,`false` = **不截断**(完整正文进 trace,面板里能展开看全文);不设 = 框架缺省 |
|
|
165
167
|
|
|
166
168
|
### `app.run(messages, opts?: RunAppOptions)`
|
|
167
169
|
|
|
@@ -173,18 +175,21 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
|
|
|
173
175
|
| `maxIterations` | 单次覆盖 |
|
|
174
176
|
| `client` | 注入 `ModelClient`(换 OpenAI 兼容端点等) |
|
|
175
177
|
| `onText` | 文本增量回调(SSE/终端) |
|
|
178
|
+
| `signal` | `AbortSignal`:中止则在飞请求被取消,run 以 `stopReason='aborted'` 收尾(算失败) |
|
|
176
179
|
| `blackboard` | 预置黑板种子(配 `Blackboard` 声明合并有键补全) |
|
|
177
180
|
| `contextPolicy` | 单次覆盖上下文策略 |
|
|
181
|
+
| `retry` | 单次覆盖重试策略:`false` 关闭,或 `{ maxAttempts, baseDelayMs, maxDelayMs, jitter, onRetry }` 调参(只重试「本次尝试尚未产出文本」的可重试失败) |
|
|
178
182
|
| `idempotencyKey` | 幂等键(异步宿主的 at-least-once 去重依据) |
|
|
179
183
|
| `rethrow` | 硬失败是否抛出;缺省 `true`(异步宿主置 `false`,落 failed 记录而非冒泡) |
|
|
180
|
-
| `tools` |
|
|
184
|
+
| `tools` | 单次覆盖工具菜单(**同样过装配期那条中间件链**,不是旁路 —— 否则 per-run 覆盖就绕开了鉴权/限流/审计) |
|
|
181
185
|
| `resultSchema` | 结构化结果 schema;配 `fromZod<T>` 可让 `result.typed` 自动是 `T` |
|
|
182
|
-
| `maxTotalTokens` | 成本硬管控:整条 run
|
|
186
|
+
| `maxTotalTokens` | 成本硬管控:整条 run(**含子 agent / skill 子循环**,各级共享同一 recorder 的累计账单)累计 token 上限;超限以 `stopReason='budget_exceeded'` 收尾(**算失败**) |
|
|
183
187
|
| `maxCostUsd` | 成本硬管控:累计成本(美元)上限;模型不在价格表内时**不触发**(用 `priceOverrides` 定价,或用 `maxTotalTokens` 兜底) |
|
|
184
188
|
| `priceOverrides` | 单次覆盖价格表(`$/1M tokens`);语义同 `createApp` 的 `priceOverrides` |
|
|
185
189
|
| `onUnpricedModel` | 单次覆盖未定价回调(**是函数,因此不在 transport 的 `RunInvocationOptions` 里** —— 异步宿主不会替你传) |
|
|
186
190
|
| `toolTimeoutMs` | 单个工具执行超时(毫秒);超时该条 tool_result 记 is_error,run 继续 |
|
|
187
191
|
| `maxToolConcurrency` | 同回合并行工具上限;缺省不限 |
|
|
192
|
+
| `maxEventChars` | trace 事件正文截断上限(字符):数字 = 入参/出参统一用该上限,`false` = **不截断**;缺省按类型收敛(入参/成功出参 2000、失败出参 1000)。**透传给子 agent/skill 的子循环** —— 同一棵调用树上口径一致。只影响**记账**,回给模型的 tool_result 永远完整 |
|
|
188
193
|
| `session` | 会话持久化 `{ store, id }`:run 前拼历史、成功收尾追加本轮(见 `SessionStore`) |
|
|
189
194
|
|
|
190
195
|
返回 `AgentRunOutput`:`{ run, result }`。`result` 含 `trace` / `stopReason` / `finalText` / `iterations` / `error` / `typed`。
|
|
@@ -306,7 +311,7 @@ const app = await createApp({ ... });
|
|
|
306
311
|
|
|
307
312
|
> ⚠️ **本机 export 过 `ANTHROPIC_API_KEY` 的人**(比如同时用 Claude Code):按上面的优先级,脚手架 `.env` 里的 key 会被**静默压住**。改了 `.env` 却「没生效」时,先 `echo $ANTHROPIC_API_KEY` 看看环境里是不是已经有一份。
|
|
308
313
|
|
|
309
|
-
解析规则(刻意窄,够用就好):`KEY=VALUE`,允许 `export ` 前缀与 `=` 两侧空白;`#` 整行注释;单引号内原样、双引号内认 `\n \r \t \" \\`;未加引号的值里 ` #`
|
|
314
|
+
解析规则(刻意窄,够用就好):`KEY=VALUE`,允许 `export ` 前缀与 `=` 两侧空白;`#` 整行注释;单引号内原样、双引号内认 `\n \r \t \" \\`;未加引号的值里 ` #` 起为行内注释。键名 `__proto__` **显式报错**(它会走原型 setter 被静默吞掉 —— 正是「以为配上了其实没配上」)。**不做变量展开、不合并续行**(需要就上专门的库);既不像 `KEY=VALUE` 又不是注释的行**直接报错并指出行号** —— 静默跳过等于让你以为「配上了其实没配上」。
|
|
310
315
|
|
|
311
316
|
### 宿主(换宿主不换语义)
|
|
312
317
|
|
|
@@ -321,7 +326,7 @@ const app = await createApp({ ... });
|
|
|
321
326
|
| `InMemoryTaskStore` | 内存任务存储(可设 `maxRecords` 做内存闸门) |
|
|
322
327
|
| `FileTaskStore` | JSONL 耐久存储(`compact()` 可压实日志) |
|
|
323
328
|
| `SqliteTaskStore` | `node:sqlite` 耐久存储(WAL + busy_timeout) |
|
|
324
|
-
| `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 时只传两参 |
|
|
325
330
|
|
|
326
331
|
### HTTP 端点速查(`createHttpHandler` 的路由)
|
|
327
332
|
|
|
@@ -356,13 +361,14 @@ const handler = createHttpHandler(callable, { runner });
|
|
|
356
361
|
| `authenticate` | 入口鉴权钩子:**除 `/healthz` 与 `/metrics` 外所有路径**都过它,且在**读 body 之前**(未通过就不收 body)。正常返回即通过;抛 `HttpException` 按其 `status`/`body` 回;抛别的错误回 401,原文只进服务端日志。框架**不实现策略**(不读 env、不碰凭据) |
|
|
357
362
|
| `metrics` | 指标出口:给 `metricsSink()`(或任意 `{ render() }` / 返回字符串的闭包)后,`GET /metrics` 回它的 Prometheus 文本。**不鉴权**(拉取端在集群内网);要保护请放反代后面。不给则该路径 404。⚠️ 这只管**渲染** —— 数字要真的累计,必须把**同一个** sink 注册进 `createApp({ sinks: [metrics] })`(它靠 run 收尾投递,不自己埋点),否则 `/metrics` 恒为 0 **且不报错** |
|
|
358
363
|
| `maxBodyBytes` | 请求 body 上限(字节),超限回 413;缺省 1 MiB |
|
|
359
|
-
| `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 |
|
|
360
366
|
| `exposeErrors` | 是否把内部异常原文回给调用方;缺省 `false`(细节只进服务端日志) |
|
|
361
367
|
| `runner` | 注入 `AsyncRunner`(共用 store / 并发上限 / `resumePending`);缺省内部 `new AsyncRunner(app)` |
|
|
362
368
|
|
|
363
369
|
返回值另外挂着两样(不影响 `(req,res)` 的调用形状):
|
|
364
370
|
|
|
365
|
-
- **`handler.drain(opts?)`** —— 优雅停机:拒新单(`POST /run` 与 `/tasks` → 503,`GET /tasks/:id` 仍可轮询)→ 等异步任务与在飞同步 run 收尾 → 强制收口仍开着的 SSE
|
|
371
|
+
- **`handler.drain(opts?)`** —— 优雅停机:拒新单(`POST /run` 与 `/tasks` → 503,`GET /tasks/:id` 仍可轮询)→ 等异步任务与在飞同步 run 收尾 → 强制收口仍开着的 SSE 流(**收口同时 abort 对应 run**,以 `stopReason='aborted'` 收尾 —— 只关流不中止会让 run 在后台继续烧 token)。返回是否排空干净;超时返回 `false`,**未完成的任务留在 store 里**,下次启动由 `resumePending` 续跑(不是丢弃)。`timeoutMs` 缺省 0 = 一直等。
|
|
366
372
|
**框架不订阅信号** —— `process.on('SIGTERM', () => handler.drain())` 是宿主的事(同「框架不读 env」)。
|
|
367
373
|
- **`handler.runner`** —— 内部 `AsyncRunner`,需要时手动控制(`resumePending` / `awaitTask` / `list`)。
|
|
368
374
|
|
|
@@ -402,7 +408,7 @@ process.on('SIGTERM', async () => {
|
|
|
402
408
|
| `isAbortError` | 判定异常是否为中断(`name === 'AbortError'`) |
|
|
403
409
|
| `mapWithConcurrency` | 有界并发 map(结果保序);`maxToolConcurrency` 的底座,也可自用 |
|
|
404
410
|
|
|
405
|
-
- **取消**:`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 或不设)。
|
|
406
412
|
- **重试**:缺省自动重试可重试失败(429 / 5xx / 连接失败),指数退避 + 抖动。`retry: false` 关闭,或 `retry: { maxAttempts, baseDelayMs, maxDelayMs, jitter, onRetry }` 调参。**只在本次尝试尚未产出任何文本时重试**(已吐出的字无法撤回)。⚠️ 与 SDK 内置重试叠加 —— 建议二选一调(这里 `maxAttempts: 1` 或把 SDK 的 `maxRetries` 调小)。
|
|
407
413
|
- **流式**:`POST /run` 带 `Accept: text/event-stream` → SSE 逐帧下发(`text.delta` / `run.end` / `error`);不带该头仍回一元 JSON。
|
|
408
414
|
- **工具超时 / 并发闸门**:`toolTimeoutMs` 超时**不杀 run**(该条 tool_result 记 `is_error`,模型可换路);`maxToolConcurrency` 给同回合的并行工具设上限(默认全并行)。⚠️ 超时 = **放弃等待**,`AgentTool.run` 没有 signal 参数,**副作用可能已发生** —— 想真停的工具请自行读 `ToolRunContext.signal`。
|
|
@@ -414,7 +420,7 @@ process.on('SIGTERM', async () => {
|
|
|
414
420
|
| `TraceSink` | `{ export(trace) }`,run 收尾(成功/失败)都投递,抛错被吞 |
|
|
415
421
|
| `registerDefaultTraceSink` | 注册全局默认 sink(构造期快照合并) |
|
|
416
422
|
| `TraceRecorder` | 内存 recorder(一次 run 一个) |
|
|
417
|
-
| `createOtlpExporter` | OTLP/JSON
|
|
423
|
+
| `createOtlpExporter` | OTLP/JSON 导出,零依赖;选项 `OtlpExporterOptions`:`endpoint` / `headers` / `serviceName` / `timeoutMs`(单次导出超时,缺省 10000,非正数 = 不限 —— 裸 fetch 无超时,collector 半开连接会让 run 收尾永久挂起;超时按导出失败处理,不击穿 run) |
|
|
418
424
|
| `metricsSink` | 指标累加器(Prometheus 文本 / OTLP metrics),满足 `TraceSink` 即接入 —— 见 §6「指标」 |
|
|
419
425
|
| `buildRunReport` | 从一条 trace 生成**调优报告**(能力/模型的耗时、token、成本、错误率排行)—— 见 §6「调优报告」 |
|
|
420
426
|
|
|
@@ -430,7 +436,14 @@ process.on('SIGTERM', async () => {
|
|
|
430
436
|
| `createBudgetPolicy` | 预算策略:超预算先 `trimToolPairs` 编辑,再 `compactMessages` 压缩(带滞回) |
|
|
431
437
|
| `trimToolPairs` | context editing:丢旧 tool 对(按**对数**,`keepToolPairs`) |
|
|
432
438
|
| `compactMessages` | compaction:旧前缀做摘要(摘要器由你注入,框架不替你造 token) |
|
|
433
|
-
| `estimateMessages` |
|
|
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`。
|
|
434
447
|
|
|
435
448
|
### 成本硬管控(**别与上面的上下文预算混为一谈**)
|
|
436
449
|
|
|
@@ -449,8 +462,8 @@ process.on('SIGTERM', async () => {
|
|
|
449
462
|
|
|
450
463
|
- 超限后 run 以 `stopReason='budget_exceeded'` 收尾(**算失败**),run 根记一条 `budget.exceeded` 事件(带 `{ kind, limit, actual, totalTokens, costUsd }`)。
|
|
451
464
|
- **不是硬实时**:一回合跑完才判,实际用量可能超上限一个回合的量。
|
|
452
|
-
- **模型自然收尾的那一回合超限不改判失败**(只留事件)—— 那次 run
|
|
453
|
-
-
|
|
465
|
+
- **模型自然收尾的那一回合超限不改判失败**(只留事件)—— 那次 run 的任务其实做完了,不该追认成失败。同理,超预算的回合仍照常处理 `submit_result`(纯内部的结构化提交、零副作用)—— 模型已把最终结果交出来,连同回合丢弃等于白烧这一回合。
|
|
466
|
+
- 预算是**整条 run(含各级子 agent / skill 子循环)**的口径:上限经 `ToolRunContext` 透传,各级循环共享同一 recorder 的累计账单、每回合各自检查。子循环超限以 `budget_exceeded` 收尾(该次能力调用记 `is_error`,capability span 上记 `budget.exceeded` 事件),主循环在下一回合**入口**拦住、不再发出新请求,整条 run 以 `budget_exceeded` 收尾。
|
|
454
467
|
|
|
455
468
|
```ts
|
|
456
469
|
const { result } = await app.run(messages, { maxTotalTokens: 200_000 });
|
|
@@ -505,7 +518,7 @@ if (result.stopReason === 'budget_exceeded') console.warn('这次 run 被预算
|
|
|
505
518
|
| `McpClientLike` | 最小结构面:`listTools()` + `callTool(name, args)`;框架**不 import** MCP SDK |
|
|
506
519
|
| `MCP_DEFAULT_TIMEOUT_MS` | 桥的缺省单次调用超时(60000 ms) |
|
|
507
520
|
|
|
508
|
-
- **名字**:`prefix + 归一化原名`(MCP 名里的 `-` / `.` / 空格 → `_
|
|
521
|
+
- **名字**:`prefix + 归一化原名`(MCP 名里的 `-` / `.` / 空格 → `_`)。归一化后**空名(原名不含任何 ASCII 字母/数字/下划线时产物为空,如全 emoji 名)/ 撞名 / 超 64 字符**一律**装配期抛错**(静默改名会得到一个调不回去的名字,比启动期报错难查得多)。
|
|
509
522
|
- **原名**:每次调用写进发起 turn 的 `mcp.tool` attribute —— 审计 / 回放要还原它才能回调 server。
|
|
510
523
|
- **入参 schema**:MCP 的 `inputSchema` 已是 JSON Schema → 原样透传,由 engine 的子集校验器在 `callTool` **之前**校验(非法入参根本不会发给 server,模型自己会改)。
|
|
511
524
|
- **失败**:`callTool` 抛错 → 该条 `tool_result` 记 `is_error`,**不杀 run**(与本地工具抛错同语义)。⚠️ **协议层的 `isError: true` 框架看不见** —— 连接器必须转成抛错,否则模型以为成功了。
|
|
@@ -580,7 +593,9 @@ if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
|
|
|
580
593
|
时长同时给两种口径,**并存不冲突**:
|
|
581
594
|
|
|
582
595
|
- **histogram**(`*_bucket` / `*_sum` / `*_count`,累积语义)—— 抓取端可**跨实例任意聚合**;
|
|
583
|
-
- **窗口内精确分位**(`*{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`)。
|
|
584
599
|
|
|
585
600
|
### `MetricsSinkOptions`(`metricsSink` 的选项)
|
|
586
601
|
|
|
@@ -590,6 +605,7 @@ if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
|
|
|
590
605
|
| `endpoint` | OTLP 采集端基地址(如 `http://localhost:4318`);尾部斜杠会被去掉 |
|
|
591
606
|
| `intervalMs` | OTLP 导出间隔(毫秒,缺省 60000);`0` = 每次 run 收尾立即导出。定时器已 `unref()`,不阻止进程退出 |
|
|
592
607
|
| `resourceAttributes` / `serviceName` | OTLP resource 属性(`service.name` 缺省 `agentia`) |
|
|
608
|
+
| `timeoutMs` | OTLP 单次导出请求超时(毫秒,缺省 10000,非正数 = 不限)——collector 半开连接时兜底,`intervalMs: 0` 模式不被挂死;超时按导出失败处理 |
|
|
593
609
|
| `onExportError` | 导出失败回调(缺省吞掉 —— 观测失败不得击穿业务) |
|
|
594
610
|
| `windowSize` | 时长分位保留的样本数(环形窗口,缺省 1024,**run / 能力 / 模型各自独立**);非正数抛错 |
|
|
595
611
|
| `prefix` | 指标名前缀,缺省 `agentia_` |
|
|
@@ -613,6 +629,18 @@ if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
|
|
|
613
629
|
- **内存上限** ≈ `(1 + 能力数 + 模型数) × windowSize` —— 能力数由 `maxCapabilities` 封顶,长跑宿主不会被拖住。
|
|
614
630
|
- `costUsd` 依赖模型在价格表内(不在表里时不计、并计入 `unpricedTurns` 与 `usage.unpriced` 事件);根 span 未收尾(如失败路径的半截 trace)的 run 不进延迟样本。
|
|
615
631
|
|
|
632
|
+
### 调用树面板(`agentia dev` 的本地面板 / 官网 Playground)
|
|
633
|
+
|
|
634
|
+
同一份 `@migor/trace-view` 渲染器,**面板 / Playground / `report` 的能力排行三处共用**,不各写一套。
|
|
635
|
+
|
|
636
|
+
- **折叠态是「一行一件事」**:事件行(`tool.input` / `tool.output`)只显示省略号收敛的摘要。
|
|
637
|
+
- **点事件行展开看完整正文**(正文可选中复制),再点收起。展开的正文**默认仍与标签同一行**,
|
|
638
|
+
只有在放不下时才整段换到下一行 —— 官网 Playground 的 trace 列(约 320px)就属于放不下。
|
|
639
|
+
- **可展开的行右端常显一个 caret**(`▸` / `▾`):这一行能不能展开随时看得见,不必先悬停才发现。
|
|
640
|
+
- **展开只能展开 trace 里存着的正文** —— 想看到被截断掉的部分,得在**记账时**就别截:`maxEventChars: false`(见 §4)。缺省截到 2000 字符,展开了也只有那 2000 字符。
|
|
641
|
+
- 入参折叠态的摘要被砍到 62 字符(4 个键 / 每值 21 字符);**展开拿到的是原文**,不是那份摘要 ——
|
|
642
|
+
本地面板与官网 Playground 两个宿主都是如此(两个宿主都不该只喂摘要,否则点开什么都没多出来)。
|
|
643
|
+
|
|
616
644
|
### 调优报告(**哪个能力慢 / 贵 / 爱失败**)
|
|
617
645
|
|
|
618
646
|
指标回答「整体怎么样」,报告回答「**该拧哪个旋钮**」:
|
|
@@ -659,9 +687,13 @@ const app = await createApp({ /* … */ sinks: [jsonl] });
|
|
|
659
687
|
### 生效配置快照(「这条 run 用了哪套旋钮」)
|
|
660
688
|
|
|
661
689
|
每个 run 的**根 span** 都带一组 `config.*` attributes(`config.maxTokens` / `config.maxCostUsd` /
|
|
662
|
-
`config.retry.maxAttempts` / `config.contextPolicy.budgetTokens` / `config.priceOverrides`
|
|
663
|
-
|
|
690
|
+
`config.retry.maxAttempts` / `config.contextPolicy.budgetTokens` / `config.priceOverrides` /
|
|
691
|
+
`config.maxEventChars` …)。
|
|
692
|
+
带缺省值的那几项(`maxTokens` / `maxIterations` / `contextPolicy` / `retry`)**缺省值也记**——
|
|
693
|
+
「没配」与「配了缺省值」因此可区分;可选项(`toolTimeoutMs` / `maxToolConcurrency` /
|
|
694
|
+
`maxEventChars`)只在设了才记。换参数前后对比、复现线上行为都有据可查。
|
|
664
695
|
函数型选项(`summarize` / `confirm` 之类)只记「配没配」,不记函数体。
|
|
696
|
+
截断关掉时记的是 `'off'` 而不是 `false` —— 后者在日志/看板里会被读成「上限为 0」。
|
|
665
697
|
|
|
666
698
|
### 提示词版本化
|
|
667
699
|
|
|
@@ -780,7 +812,11 @@ const callable = {
|
|
|
780
812
|
| 历史畸形就放弃裁剪 | `trimToolPairs` 遇到非严格交替历史会整体放弃(宁可少裁,也不切出孤立 tool_use 让请求 400) |
|
|
781
813
|
| 缺省内存 store 不淘汰 | 长跑宿主请设 `InMemoryTaskStore({ maxRecords })` 或换 `FileTaskStore` / `SqliteTaskStore` |
|
|
782
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` 是合法用法) |
|
|
783
818
|
| 取消要传进客户端才有效 | 传 `signal` 后框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发);不转发 `signal` 的自定义 `ModelClient` 只能「放弃等待」(请求在后台跑完、产物丢弃) |
|
|
819
|
+
| 工具阶段的 abort 有盲区 | abort 只在三处被观察:**回合边界 / 在飞模型请求 / 重试退避 sleep**。没设 `toolTimeoutMs` 且工具挂死时,abort 之后 run 也不会返回(工具的 Promise 永不 settle)—— 挂死的工具要么设超时,要么自己读 `ToolRunContext.signal` |
|
|
784
820
|
| 观测失败被吞 | sink 抛错不影响 run(观测是辅助动作);同理记忆水合/回写失败也不击穿 run |
|
|
785
821
|
| 框架不自动读 .env | 除 `AGENTIA_MODEL`(缺省模型覆盖)与 `OPENAI_API_KEY`(OpenAI 适配器)外,框架自己不去翻环境变量,也不读 `.env`;要读就在启动代码里调 `loadEnvFile()`(脚手架已内置那行),**真实环境变量优先**于文件 |
|
|
786
822
|
| 鉴权只是缝 | 框架**不实现** token / JWT / 签名策略,也不碰凭据 env —— `authenticate` 只承诺「拦在入口、读 body 之前」;策略是宿主或反代的事 |
|
|
@@ -792,13 +828,15 @@ const callable = {
|
|
|
792
828
|
| `maxCostUsd` 依赖价格表 | 模型不在价格表内(且未用 `priceOverrides` 覆盖)时成本恒为 0,这条护栏**不触发** —— 要无条件兜底用 `maxTotalTokens`。**但失效不再静默**:turn 上会记 `usage.unpriced` 事件、指标有 `model_unpriced_turns_total`、可回调 `onUnpricedModel` |
|
|
793
829
|
| 工具超时**不取消**工具 | `AgentTool.run` 没有 signal 参数,超时只是「不等了」;副作用可能已发生。想真停请让工具自己读 `ToolRunContext.signal` |
|
|
794
830
|
| 会话只存对话轮次 | `SessionStore` 存「用户输入 + 最终回复」,run 内部的 tool 往返**不进历史**(要完整过程用 `traceToMessages`);且只有**跑成功**的轮次才回写 |
|
|
831
|
+
| 同 session 并发 run 要自行串行化 | `SessionStore` 是 **append-only**:并发写不互相覆盖、不丢数据,但**不保证角色交替** —— 两个并发 run 共用同一 sessionId 时,各自追加的轮次可能交错成「连续两条 user」,下一轮 load 出来撞角色交替校验(400)。同一 session 的并发 run 请调用方自行串行化(每 session 一把锁 / 一条队列) |
|
|
795
832
|
| OpenAI 适配器听端点的话 | 请求发 `stream:true`,但**按响应形态解析**:端点回 JSON 就退回一次性(没有打字机效果),回 `event-stream` 才逐 token |
|
|
833
|
+
| OpenAI 流式的上游故障不再装成功 | 流中 `error` 分片(上游把故障塞进 200 的流)与「流正常结束却无文本无 tool_calls」都**抛错**按失败处理 —— 不再静默映射成「成功空回复」(与非流式空 `choices` 的守卫同口径) |
|
|
796
834
|
| MCP 只做 tools | `sampling`(server 反向请求模型)/ `resources` / `prompts` 原语不做;连接器(stdio / HTTP)不在框架内 |
|
|
797
835
|
| MCP 的协议层错误框架看不见 | `isError: true` 只有连接器能看见 —— 它必须转成抛错,否则模型收到的是一条「成功」的结果 |
|
|
798
836
|
| MCP 超时同样是「不等了」 | 桥自带的 `timeoutMs` 取消不了 server 侧执行(拿不到取消句柄);它与 engine 的 `toolTimeoutMs` **双重计时**,谁短谁生效 |
|
|
799
837
|
| MCP 名字可能被归一化 | 原名含 `-` / `.` / 空格 → 进菜单时变成 `_`;回调 server 用的仍是原名(`mcp.tool` attribute 里查得到) |
|
|
800
838
|
| MCP 工具不能进 DI 容器 | 它没有 provider token,也不能被别的能力的 `tools` 引用 —— 引用是 provider 粒度 |
|
|
801
|
-
| 指标分位是窗口内精确值 | `*{quantile=...}` 只反映最近 `windowSize`(缺省 1024)条样本;要跨实例聚合请用直方图(`*_bucket` / `_sum` / `_count`,累积语义) |
|
|
839
|
+
| 指标分位是窗口内精确值 | `*_last{quantile=...}` 只反映最近 `windowSize`(缺省 1024)条样本;要跨实例聚合请用直方图(`*_bucket` / `_sum` / `_count`,累积语义) |
|
|
802
840
|
| 指标是**进程内**累加 | 不做分布式聚合与持久化:多实例各算各的(直方图可相加),重启即清零。要长期保留请把 `render()` 抓走或用 `export:'otlp'` 推给采集端 |
|
|
803
841
|
| OTLP metrics 只推当前累计 | 按 `intervalMs` 周期导出**累积值**(CUMULATIVE),不做增量/背压;导出失败按 `onExportError` 处理(缺省吞掉,不重试、不阻塞 run) |
|
|
804
842
|
| `GET /metrics` 不鉴权 | 与 `/healthz` 同档(拉取端在集群内网)。要保护请放反代之后,或不传 `metrics` 选项自行在外层挂路由 |
|
|
@@ -837,7 +875,7 @@ const callable = {
|
|
|
837
875
|
| MCP 调用「成功」但内容是错误文本 | 连接器没把协议层 `isError: true` 转成抛错(框架只认抛错) |
|
|
838
876
|
| eval 里模型调了不存在的工具 | 脚本里的工具名必须是**菜单里的名字**(MCP 工具是归一化后的 `mcp_<server>_<name>`) |
|
|
839
877
|
| `metricsSink` 的数字一直是 0 | 没接进 `createApp({ sinks })`(或 `registerDefaultTraceSink`)—— 它靠 run 收尾投递,不自己埋点 |
|
|
840
|
-
|
|
|
878
|
+
| `metricsSink({ export: 'otlp' })` 构造期报错 | 没给 `endpoint` —— OTLP 导出必须知道往哪发,响亮失败好过静默不导出;补上 `endpoint`(如 `http://localhost:4318`)即可。`windowSize` / `maxCapabilities` 非正数、`buckets` 非严格升序同理是构造期配置校验 |
|
|
841
879
|
|
|
842
880
|
---
|
|
843
881
|
|
|
@@ -849,6 +887,4 @@ npm run typecheck:tests # 测试目录类型(含类型断言测试)
|
|
|
849
887
|
npm run test # 单测(node:test)
|
|
850
888
|
```
|
|
851
889
|
|
|
852
|
-
|
|
853
|
-
`examples/complete` 真起服务跑三种触发)、`npm run e2e:examples`(只跑后者)、
|
|
854
|
-
`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)。
|
package/dist/add.js
CHANGED
|
@@ -3,6 +3,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
3
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import { registerPackage } from './registry.js';
|
|
6
|
+
import { npmBin } from './npm-bin.js';
|
|
6
7
|
/** 从 add 参数解析真实包名:支持 name[@version]、@scope/name[@version]、本地路径(. / 前缀,或 file: 协议) */
|
|
7
8
|
export function resolvePackageName(arg) {
|
|
8
9
|
// file: 协议(npm install file:./pkg 的写法)先剥前缀,再按本地路径处理
|
|
@@ -33,7 +34,7 @@ export function packageNameToToken(pkgName) {
|
|
|
33
34
|
}
|
|
34
35
|
export function addPackage(arg) {
|
|
35
36
|
const cwd = process.cwd();
|
|
36
|
-
const r = spawnSync('npm', ['install', arg], { cwd, stdio: 'inherit' });
|
|
37
|
+
const r = spawnSync(npmBin('npm'), ['install', arg], { cwd, stdio: 'inherit' });
|
|
37
38
|
if (r.error || r.status !== 0) {
|
|
38
39
|
console.error(`错误:npm install ${arg} 失败,src/registry.ts 未改动`);
|
|
39
40
|
process.exitCode = 1;
|
package/dist/dev.js
CHANGED
|
@@ -14,6 +14,7 @@ import { existsSync } from 'node:fs';
|
|
|
14
14
|
import { dirname, join } from 'node:path';
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
16
|
import { startInspector } from './inspector.js';
|
|
17
|
+
import { npmBin } from './npm-bin.js';
|
|
17
18
|
/** --import 可用性:Node ≥20.6(≥18.19 已回移植) */
|
|
18
19
|
function supportsImport() {
|
|
19
20
|
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
@@ -43,7 +44,7 @@ export function devServer() {
|
|
|
43
44
|
process.on('SIGINT', forward('SIGINT'));
|
|
44
45
|
process.on('SIGTERM', forward('SIGTERM'));
|
|
45
46
|
const startChild = (env) => {
|
|
46
|
-
child = spawn('npx', ['tsx', 'watch', 'src/main.ts'], { cwd, stdio: 'inherit', env });
|
|
47
|
+
child = spawn(npmBin('npx'), ['tsx', 'watch', 'src/main.ts'], { cwd, stdio: 'inherit', env });
|
|
47
48
|
child.on('error', (err) => {
|
|
48
49
|
console.error(`错误:启动 dev 失败:${err.message}`);
|
|
49
50
|
process.exitCode = 1;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* 3. usage 只由 llm.turn 累计(capability span 的 usage 是子 span 聚合,计入会双算)。
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { fmtArg, capabilityTypeOf, CAP_ICO } from './view.js';
|
|
17
|
+
import { fmtArg, rawArg, capabilityTypeOf, CAP_ICO } from './view.js';
|
|
18
18
|
|
|
19
19
|
/** 从 span.attributes 认能力类型(框架用 `setAttribute(capabilityId, 'subagent', name)` 记类型) */
|
|
20
20
|
function spanType(s) {
|
|
@@ -67,6 +67,19 @@ function eventText(ev) {
|
|
|
67
67
|
return body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* 事件全文(展开态用)。
|
|
72
|
+
*
|
|
73
|
+
* 只有 `tool.input` 需要单独取:`fmtArg` 把入参摘要**砍到 4 个键 / 每值 21 字符 / 整串 62 字符**,
|
|
74
|
+
* 直接拿摘要当全文就是「假展开」—— 点开看到的还是那 62 个字符。出参与其他事件本身就是原文
|
|
75
|
+
* (`eventText` 原样返回 content),无需二次处理。
|
|
76
|
+
* 原文口径统一在 view.js 的 `rawArg`:官网 playground 的模拟回放也要用它,两处一处定义。
|
|
77
|
+
*/
|
|
78
|
+
function eventFull(ev) {
|
|
79
|
+
if (ev.name === 'tool.input') return rawArg(ev.body && ev.body.input);
|
|
80
|
+
return eventText(ev);
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
/**
|
|
71
84
|
* 把真实 Trace 线性回放给视图(同步、一次性;用于 run 结束后的静态展示)。
|
|
72
85
|
* 返回 false 表示 trace 空、未做任何渲染。
|
|
@@ -98,12 +111,17 @@ export function playTrace(view, trace) {
|
|
|
98
111
|
};
|
|
99
112
|
|
|
100
113
|
const items = [];
|
|
114
|
+
let seq = 0; // 出现顺序:时间缺失 / 并列时的确定性回退(NaN 参与比较会让排序结果随引擎不定)
|
|
115
|
+
const timeOf = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
|
|
101
116
|
for (const s of spans) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
const start = timeOf(s.startedAt, 0);
|
|
118
|
+
items.push({ t: start, ord: 0, d: depthOf(s), s, kind: 'start', seq: seq++ });
|
|
119
|
+
if (s.endedAt != null)
|
|
120
|
+
items.push({ t: timeOf(s.endedAt, start), ord: 2, d: 0, s, kind: 'end', seq: seq++ });
|
|
121
|
+
for (const ev of s.events || [])
|
|
122
|
+
items.push({ t: timeOf(ev.time, start), ord: 1, d: 0, s, kind: 'event', ev, seq: seq++ });
|
|
105
123
|
}
|
|
106
|
-
items.sort((a, b) => a.t - b.t || a.ord - b.ord || a.d - b.d);
|
|
124
|
+
items.sort((a, b) => a.t - b.t || a.ord - b.ord || a.d - b.d || a.seq - b.seq);
|
|
107
125
|
|
|
108
126
|
const rootRaw = String(root.name || 'run');
|
|
109
127
|
// 框架的 run 根名可能是 runName(如 'app')或已是 'run · x' 形态,避免重复前缀
|
|
@@ -126,6 +144,7 @@ export function playTrace(view, trace) {
|
|
|
126
144
|
eventToolName(it.ev.name, it.ev.body && it.ev.body.tool, typeMap),
|
|
127
145
|
eventText(it.ev),
|
|
128
146
|
!it.ev.body || it.ev.body.ok !== false,
|
|
147
|
+
eventFull(it.ev),
|
|
129
148
|
);
|
|
130
149
|
} else {
|
|
131
150
|
view.end({
|
package/dist/inspector/index.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
/* @migor/trace-view —— Agentia trace 调用树渲染器(框架无关、零依赖)。
|
|
2
2
|
* 官网 playground 与 CLI inspector 共用同一份,避免两处渲染漂移。 */
|
|
3
3
|
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
createTraceView,
|
|
6
|
+
capabilityTypeOf,
|
|
7
|
+
CAP_ICO,
|
|
8
|
+
fmtArg,
|
|
9
|
+
rawArg,
|
|
10
|
+
fmtNum,
|
|
11
|
+
fmtMs,
|
|
12
|
+
} from './view.js';
|
|
5
13
|
export { playTrace } from './fromTrace.js';
|
|
6
14
|
export { summarizeTrace, renderSummary } from './summary.js';
|
|
@@ -104,6 +104,57 @@
|
|
|
104
104
|
color: #f7768e;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/* —— 行展开:折叠时「一行一个语义单元」,点开的那一行改为显示**完整正文** ——
|
|
108
|
+
caret 绝对定位在行右端,不占 flex 宽度,所以行内其它元素的排版不受它影响。
|
|
109
|
+
**常显,并且是满不透明度的 `--faint`**:它是个**交互指示器**,不是装饰 ——
|
|
110
|
+
caret 用 0.75 的透明度试过,量出来是缺陷而非口味:WCAG 1.4.11 对非文本 UI 组件
|
|
111
|
+
要求 ≥3:1,而 0.75 的 `--faint`(#71717a) 在官网 #0c0c0e 面板上实际绘制成 #58585f,
|
|
112
|
+
只有 **2.77:1**(满不透明度 4.04:1)。
|
|
113
|
+
所以没有「hover 才变亮」这一档:静止时它已经是最亮的那一档了。 */
|
|
114
|
+
.tr-row[data-expandable] {
|
|
115
|
+
position: relative;
|
|
116
|
+
cursor: pointer;
|
|
117
|
+
}
|
|
118
|
+
.tr-caret {
|
|
119
|
+
position: absolute;
|
|
120
|
+
right: 0;
|
|
121
|
+
top: 3px; /* 对齐 .tr-row 的 padding: 3px 0 */
|
|
122
|
+
color: var(--faint);
|
|
123
|
+
font-size: 10px;
|
|
124
|
+
}
|
|
125
|
+
/* caret 常显 ⇒ 右端要给它留一条窄槽。不留的话,被截断的正文省略号「…」正好落在 caret 底下,
|
|
126
|
+
▸ 与 … 糊成一个字形。给正文加右内边距 = 内容盒右缘左移,省略号自行退到槽的左边。 */
|
|
127
|
+
.tr-row[data-expandable] .tr-io {
|
|
128
|
+
padding-right: 12px;
|
|
129
|
+
}
|
|
130
|
+
/* 只有点开的那一行换行;树的其余部分保持 nowrap 的列对齐 */
|
|
131
|
+
.tr-row.tr-open {
|
|
132
|
+
white-space: normal;
|
|
133
|
+
align-items: flex-start;
|
|
134
|
+
/* 允许正文换到下一行 —— 但**只在它真的放不下时**(判据是下面那条 min-width,不是无条件换行) */
|
|
135
|
+
flex-wrap: wrap;
|
|
136
|
+
}
|
|
137
|
+
/* 展开的正文**有高度上限**:工具出参动辄上千行(实测一条 60 行的出参就有 17878px 的
|
|
138
|
+
scrollWidth),不限高会把整个调用树推到屏幕之外,「看下一行」比「看全文」更常用。
|
|
139
|
+
超出部分在行内滚动 —— 正文仍在 DOM 里,选中复制不受影响。 */
|
|
140
|
+
.tr-row.tr-open .tr-io {
|
|
141
|
+
/* 正文与标签**同一行**(既定的阅读节奏:一行一个语义单元)。换行只发生在**放不下时**,
|
|
142
|
+
而这条线由 min-width 划 —— 所以 flex-basis 必须是 0:
|
|
143
|
+
· basis: auto ⇒ 换行判据是 max-content(正文动辄上百字符 ⇒ 几乎必然换行,等于无条件换行);
|
|
144
|
+
· basis: 0 ⇒ 判据回到 min-width,放得下就同排、放不下才整段换行。
|
|
145
|
+
为什么 min-width 不能省:正文用 overflow-wrap: anywhere,min-content 只有 1 个字符宽 ——
|
|
146
|
+
没有它时 flex 会心安理得地把正文压成一条 16px 的窄缝(59 个字符挤成 34 行、行高顶到上限),
|
|
147
|
+
而页面 scrollWidth === clientWidth 一切「干净」,只是读不了。
|
|
148
|
+
窄面板(官网 Playground 296px)里那行固定项本身就要 ~246px,所以那里它会如实地换行。 */
|
|
149
|
+
flex-basis: 0;
|
|
150
|
+
min-width: 22ch;
|
|
151
|
+
white-space: pre-wrap;
|
|
152
|
+
overflow-wrap: anywhere;
|
|
153
|
+
max-height: 60vh;
|
|
154
|
+
overflow: auto;
|
|
155
|
+
text-overflow: clip;
|
|
156
|
+
}
|
|
157
|
+
|
|
107
158
|
@keyframes tr-blink {
|
|
108
159
|
0%,
|
|
109
160
|
100% {
|
package/dist/inspector/view.js
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* 这个先后本身就是语义,不能拍平成一类;
|
|
10
10
|
* - 四类能力标识符(⚙ tool / ◆ skill / ¶ prompt / ⊕ subagent)只靠字形区分、不上类型色。
|
|
11
11
|
*
|
|
12
|
+
* 展开:折叠态**一字不变**(省略号收敛,事件行比 span 行更轻),点事件行可展开看**完整正文**。
|
|
13
|
+
* 展开状态存在渲染之外(每次事件都会全量重建 DOM),否则实时 run 里刚点开的行会在下一条事件
|
|
14
|
+
* 到来时自己合上。数据侧「行内摘要」与「展开全文」是两个字段:`text` 是摘要(入参经 fmtArg
|
|
15
|
+
* 砍到 62 字符),`full` 才是原文 —— 只有全文能撑起「调试时看工具结果」这件事。
|
|
16
|
+
*
|
|
12
17
|
* 宿主需提供的 CSS 变量:--ice --faint --text(见 trace-view.css)。
|
|
13
18
|
*/
|
|
14
19
|
|
|
@@ -46,6 +51,19 @@ export function fmtArg(v) {
|
|
|
46
51
|
return s.length > 62 ? s.slice(0, 61) + '…' : s;
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
/* span 入参**原文** —— fmtArg 的对偶:摘要给折叠态,原文给展开态。
|
|
55
|
+
* 两者必须定义在同一处:哪个宿主自己搓一个「原文」,「展开」就会在那里退回摘要
|
|
56
|
+
* (点开什么都没多出来),而两个宿主共用这一份渲染器,正是为了不让这种漂移发生。 */
|
|
57
|
+
export function rawArg(v) {
|
|
58
|
+
if (v == null) return '';
|
|
59
|
+
if (typeof v === 'string') return v;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.stringify(v) ?? String(v);
|
|
62
|
+
} catch {
|
|
63
|
+
return String(v);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
function el(tag, cls, text) {
|
|
50
68
|
const node = document.createElement(tag);
|
|
51
69
|
if (cls) node.className = cls;
|
|
@@ -67,6 +85,25 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
67
85
|
let traceRoot = null;
|
|
68
86
|
const spanMap = new Map();
|
|
69
87
|
const usageAcc = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
|
|
88
|
+
/* 展开态的行 key 集合 + 事件 key 发号器。两者都必须活在 renderTrace 之外:
|
|
89
|
+
渲染是【全量重建】(rootEl.innerHTML = ''),状态放进 DOM 或渲染过程里就活不过下一次事件。 */
|
|
90
|
+
const expanded = new Set();
|
|
91
|
+
let evSeq = 0;
|
|
92
|
+
|
|
93
|
+
/** 正在选文本时不要收起/展开 —— 鼠标拖选到行外松手会补一次 click,否则选完就自己合上了 */
|
|
94
|
+
function hasSelection() {
|
|
95
|
+
try {
|
|
96
|
+
return String(globalThis.getSelection?.() ?? '') !== '';
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toggleExpand(key) {
|
|
103
|
+
if (expanded.has(key)) expanded.delete(key);
|
|
104
|
+
else expanded.add(key);
|
|
105
|
+
renderTrace();
|
|
106
|
+
}
|
|
70
107
|
|
|
71
108
|
function renderTrace() {
|
|
72
109
|
rootEl.innerHTML = '';
|
|
@@ -89,6 +126,7 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
89
126
|
|
|
90
127
|
/* 事件行:没有 status 圈、没有耗时、不参与 usage 计数,只带入参/出参摘要 */
|
|
91
128
|
if (ev) {
|
|
129
|
+
const open = expanded.has(ev.key);
|
|
92
130
|
const row = el('div', 'tr-row tr-ev' + (ev.ok === false ? ' error' : ''));
|
|
93
131
|
row.dataset.ev = ev.type;
|
|
94
132
|
row.dataset.capability = capabilityTypeOf(ev.tool);
|
|
@@ -98,9 +136,19 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
98
136
|
// 非 tool.* 事件(usage.unpriced / llm.retry / budget.* / context.budget)没有工具名 ——
|
|
99
137
|
// 这一格整个不渲染,别把「无工具」显示成假工具名(.tr-name 是 flex:none,.tr-io 自然占满)
|
|
100
138
|
if (ev.tool) row.appendChild(el('span', 'tr-name', ev.tool));
|
|
101
|
-
|
|
102
|
-
io
|
|
139
|
+
// 展开态用全文(ev.full),折叠态用摘要(ev.text)—— 折叠态因此与加展开之前逐字一致
|
|
140
|
+
const io = el('span', 'tr-io', (open ? ev.full : ev.text) || '');
|
|
141
|
+
// 展开后正文已在行里,再挂一个占满整行的原生 tooltip 只会挡视线
|
|
142
|
+
io.title = open ? '' : ev.text || '';
|
|
103
143
|
row.appendChild(io);
|
|
144
|
+
if (ev.full) {
|
|
145
|
+
row.dataset.expandable = '1'; // 可展开标记:CSS 靠它出 caret / cursor,测试靠它找行
|
|
146
|
+
row.appendChild(el('span', 'tr-caret', open ? '▾' : '▸'));
|
|
147
|
+
if (open) row.className += ' tr-open';
|
|
148
|
+
row.addEventListener('click', () => {
|
|
149
|
+
if (!hasSelection()) toggleExpand(ev.key);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
104
152
|
rootEl.appendChild(row);
|
|
105
153
|
return;
|
|
106
154
|
}
|
|
@@ -175,6 +223,7 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
175
223
|
};
|
|
176
224
|
spanMap.clear();
|
|
177
225
|
spanMap.set('root', traceRoot);
|
|
226
|
+
expanded.clear(); // 展开态属于上一棵树:reset 开新树时一并清掉
|
|
178
227
|
usageAcc.input = 0;
|
|
179
228
|
usageAcc.output = 0;
|
|
180
229
|
usageAcc.cacheRead = 0;
|
|
@@ -206,11 +255,22 @@ export function createTraceView(rootEl, opts = {}) {
|
|
|
206
255
|
|
|
207
256
|
/* span 事件:框架把普通工具 / @Prompt 调用记成【turn 上的事件】,不给它们建 capability span
|
|
208
257
|
(只有 skill / subagent 会 recorder.begin('capability', …),见 engine/loop.ts)。
|
|
209
|
-
events 与 order 并存:events 是数据、order 负责与子 span 的先后顺序。
|
|
210
|
-
|
|
258
|
+
events 与 order 并存:events 是数据、order 负责与子 span 的先后顺序。
|
|
259
|
+
|
|
260
|
+
`text` = 行内摘要,`full` = 展开看的原文(省略时同 text —— 出参与多数事件本来就是原文,
|
|
261
|
+
只有 tool.input 的摘要被 fmtArg 砍过,调用方需要显式给 full)。
|
|
262
|
+
`key` 是展开状态的稳定标识:事件对象跨全量重建存活,所以键也稳定。 */
|
|
263
|
+
function event(id, type, tool, text, ok, full) {
|
|
211
264
|
const node = spanMap.get(id);
|
|
212
265
|
if (!node) return;
|
|
213
|
-
const e = {
|
|
266
|
+
const e = {
|
|
267
|
+
type,
|
|
268
|
+
tool,
|
|
269
|
+
text,
|
|
270
|
+
ok: ok !== false,
|
|
271
|
+
full: full === undefined ? text : full,
|
|
272
|
+
key: 'ev' + ++evSeq,
|
|
273
|
+
};
|
|
214
274
|
node.events.push(e);
|
|
215
275
|
node.order.push({ t: 'ev', e });
|
|
216
276
|
renderTrace();
|
package/dist/inspector-page.html
CHANGED
|
@@ -145,7 +145,12 @@
|
|
|
145
145
|
const es = new EventSource('/stream');
|
|
146
146
|
es.onmessage = async (m) => {
|
|
147
147
|
const s = JSON.parse(m.data);
|
|
148
|
-
|
|
148
|
+
// s.name 来自 POST /ingest 的 trace 根 span 名(模型可控内容)——
|
|
149
|
+
// 只能走 textContent,拼进 innerHTML 就是 XSS
|
|
150
|
+
liveEl.textContent = '最新 run:';
|
|
151
|
+
const nameEl = document.createElement('b');
|
|
152
|
+
nameEl.textContent = s.name;
|
|
153
|
+
liveEl.append(nameEl, ` · ${fmtMs(s.ms)}`);
|
|
149
154
|
await refresh();
|
|
150
155
|
await open(s.traceId);
|
|
151
156
|
};
|
package/dist/inspector.js
CHANGED
|
@@ -12,6 +12,41 @@ import { createServer } from 'node:http';
|
|
|
12
12
|
import { readFile } from 'node:fs/promises';
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
+
/** 入站校验:结构不符的 trace 直接拒(400),不让 NaN 之类的坏数据流进面板与 SSE 广播 */
|
|
16
|
+
function validateTrace(t) {
|
|
17
|
+
if (!t || typeof t !== 'object' || Array.isArray(t))
|
|
18
|
+
return 'trace 必须是对象';
|
|
19
|
+
const trace = t;
|
|
20
|
+
if (typeof trace.traceId !== 'string' || trace.traceId.length === 0)
|
|
21
|
+
return 'traceId 缺失';
|
|
22
|
+
if (trace.spans !== undefined && !Array.isArray(trace.spans))
|
|
23
|
+
return 'spans 必须是数组';
|
|
24
|
+
for (const s of trace.spans ?? []) {
|
|
25
|
+
if (!s || typeof s !== 'object')
|
|
26
|
+
return 'span 必须是对象';
|
|
27
|
+
if (typeof s.spanId !== 'string' || s.spanId.length === 0)
|
|
28
|
+
return 'span.spanId 缺失';
|
|
29
|
+
if (typeof s.name !== 'string')
|
|
30
|
+
return `span ${s.spanId} 缺 name`;
|
|
31
|
+
if (typeof s.startedAt !== 'number' || !Number.isFinite(s.startedAt)) {
|
|
32
|
+
return `span ${s.spanId} 缺 startedAt(必须是有穷 number)`;
|
|
33
|
+
}
|
|
34
|
+
if (s.endedAt !== undefined && (typeof s.endedAt !== 'number' || !Number.isFinite(s.endedAt))) {
|
|
35
|
+
return `span ${s.spanId} 的 endedAt 非法`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** 防 DNS rebinding:面板只服务本机,Host 不是 localhost/127.0.0.1/[::1] 的一律拒 */
|
|
41
|
+
function isLocalHostHeader(host) {
|
|
42
|
+
if (!host)
|
|
43
|
+
return false;
|
|
44
|
+
const name = host
|
|
45
|
+
.replace(/:\d+$/, '')
|
|
46
|
+
.replace(/^\[|\]$/g, '')
|
|
47
|
+
.toLowerCase();
|
|
48
|
+
return name === 'localhost' || name === '127.0.0.1' || name === '::1';
|
|
49
|
+
}
|
|
15
50
|
/** 环形缓冲上限:面板只保证「回看最近 N 条」,不做历史归档 */
|
|
16
51
|
const MAX_RUNS = 50;
|
|
17
52
|
/** 静态资源目录(构建期由 scripts/copy-assets.mjs 就位) */
|
|
@@ -81,19 +116,33 @@ export function startInspector(opts = {}) {
|
|
|
81
116
|
const handler = async (req, res) => {
|
|
82
117
|
const path = new URL(req.url || '/', `http://${host}`).pathname;
|
|
83
118
|
try {
|
|
119
|
+
if (!isLocalHostHeader(req.headers.host)) {
|
|
120
|
+
json(res, 403, { error: '仅允许本机访问(Host 须为 localhost / 127.0.0.1 / [::1])' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
84
123
|
if (req.method === 'POST' && path === '/ingest') {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
124
|
+
const body = await readBody(req); // 超 413 走外层 catch
|
|
125
|
+
let trace;
|
|
126
|
+
try {
|
|
127
|
+
trace = JSON.parse(body);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
json(res, 400, { error: 'body 不是合法 JSON' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const problem = validateTrace(trace);
|
|
134
|
+
if (problem) {
|
|
135
|
+
json(res, 400, { error: problem });
|
|
88
136
|
return;
|
|
89
137
|
}
|
|
90
|
-
|
|
91
|
-
|
|
138
|
+
const valid = trace;
|
|
139
|
+
if (!runs.has(valid.traceId)) {
|
|
140
|
+
order.push(valid.traceId);
|
|
92
141
|
if (order.length > MAX_RUNS)
|
|
93
142
|
runs.delete(order.shift());
|
|
94
143
|
}
|
|
95
|
-
runs.set(
|
|
96
|
-
broadcast(summarize(
|
|
144
|
+
runs.set(valid.traceId, valid);
|
|
145
|
+
broadcast(summarize(valid));
|
|
97
146
|
json(res, 200, { ok: true });
|
|
98
147
|
return;
|
|
99
148
|
}
|
package/dist/npm-bin.js
ADDED
package/dist/templates.js
CHANGED
|
@@ -60,7 +60,7 @@ export function projectPackageJson(name) {
|
|
|
60
60
|
dev: 'tsx src/main.ts',
|
|
61
61
|
typecheck: 'tsc --noEmit -p tsconfig.json',
|
|
62
62
|
},
|
|
63
|
-
dependencies: { '@migor/agentia': '^0.
|
|
63
|
+
dependencies: { '@migor/agentia': '^0.4.1' },
|
|
64
64
|
devDependencies: {
|
|
65
65
|
tsx: '^4.19.0',
|
|
66
66
|
typescript: '^7.0.2',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@migor/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Agentia 框架命令行工具:create 脚手架 + g 能力生成 + dev 本地可观测面板(trace inspector)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
|
-
"build": "tsc -p tsconfig.json",
|
|
31
|
+
"build": "tsc -p tsconfig.json && node scripts/copy-assets.mjs",
|
|
32
32
|
"test": "node --test \"test/*.test.mjs\"",
|
|
33
33
|
"prepublishOnly": "node ../../scripts/check-release.mjs && npm run build"
|
|
34
34
|
},
|