@migor/cli 0.10.2 → 0.10.4

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.
@@ -110,6 +110,13 @@ const STATIC = new Map([
110
110
  ['fromTrace.js', join(ASSETS, 'fromTrace.js')],
111
111
  ['summary.js', join(ASSETS, 'summary.js')],
112
112
  ['trace-view.css', join(ASSETS, 'trace-view.css')],
113
+ /* 单文件 bundle(`agentia view` 内联进自包含 HTML 用)。⚠️ **页面不 import 它** ——
114
+ * 面板仍走上面的分文件;它是 CLI 自己 readFile 后内联的(`src/view.ts`)。
115
+ * 之所以还列进白名单:copy-assets 是**整树**拷 trace-view 的 dist ⇒ 它必然出现在
116
+ * dist/inspector/,而 `structure.test.mjs` 的 W5 要求「dist/inspector 的每个文件都在
117
+ * 白名单里」(防 trace-view 拆模块后浏览器 404 白屏)。与其放宽那条守卫的安全语义,
118
+ * 不如显式批一个本来就公开的前端渲染器文件。 */
119
+ ['trace-view.bundle.js', join(ASSETS, 'trace-view.bundle.js')],
113
120
  ['panel-logic.js', join(HERE, 'panel-logic.js')],
114
121
  // 面板的 Markdown 解析器(零依赖、按构造不产生 HTML —— 见 src/markdown.ts 的头注)。
115
122
  // 与 panel-logic.js 同处 dist 根:两个都是 CLI 自己编译出来的模块,单测直接 import 它们。
package/dist/redact.js ADDED
@@ -0,0 +1,82 @@
1
+ /** `keys` 命中的字段整体抹成的文案(不看值,最严的一档) */
2
+ export const REDACTED = '[REDACTED]';
3
+ /**
4
+ * 内置正则预设(脱敏面 = agent 服务最常见的四类泄密:LLM key / 令牌 / PII)。
5
+ * 每条预设的替换文案带**类别标签**(`[REDACTED:email]`)—— 让「这里被改过、改的是哪类」
6
+ * 在 trace 里可见:静默替换会让下游排查误以为数据本来如此。
7
+ * 预设按数组序依次应用(bearer 先于 jwt —— `Bearer eyJ…` 整体归 bearer,不被 jwt 截走)。
8
+ *
9
+ * ⚠️ 与 `examples/observability/src/index.ts` 的 `REDACT_PRESETS` **逐字同形**,
10
+ * 由 `packages/cli/test/view.test.mjs` 的对拍守着(改任一处必须两边同步)。
11
+ */
12
+ export const REDACT_PRESETS = [
13
+ { name: 'bearer', re: /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/g },
14
+ { name: 'jwt', re: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g },
15
+ { name: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/g },
16
+ { name: 'llm-api-key', re: /\bsk-[A-Za-z0-9]{16,}\b/g },
17
+ { name: 'email', re: /[\w.+-]+@[\w-]+\.[\w.]+/g },
18
+ { name: 'phone-cn', re: /(?<!\d)1[3-9]\d{9}(?!\d)/g },
19
+ ];
20
+ function hitKey(key, keys) {
21
+ const k = key.toLowerCase();
22
+ return keys.some((needle) => k.includes(needle.toLowerCase()));
23
+ }
24
+ /** 应用一组「正则 → 替换文案」规则;规则 regex 带 /g,跨字符串复用前必须重置 lastIndex */
25
+ function applyRules(s, rules) {
26
+ let out = s;
27
+ for (const { re, label } of rules) {
28
+ re.lastIndex = 0;
29
+ out = out.replace(re, label);
30
+ }
31
+ return out;
32
+ }
33
+ /** 递归深拷贝 + 抹值:原 trace 不被改动(调用方可能还要拿原文) */
34
+ function redactValue(value, keys, rules) {
35
+ if (typeof value === 'string')
36
+ return applyRules(value, rules);
37
+ if (value === null || typeof value !== 'object')
38
+ return value;
39
+ if (Array.isArray(value))
40
+ return value.map((v) => redactValue(v, keys, rules));
41
+ const out = {};
42
+ for (const [k, v] of Object.entries(value)) {
43
+ out[k] = hitKey(k, keys) ? REDACTED : redactValue(v, keys, rules);
44
+ }
45
+ return out;
46
+ }
47
+ function buildRules(presets) {
48
+ if (presets === false)
49
+ return [];
50
+ const selected = presets ?? REDACT_PRESETS.map((p) => p.name);
51
+ return REDACT_PRESETS.filter((p) => selected.includes(p.name)).map((p) => ({ re: p.re, label: `[REDACTED:${p.name}]` }));
52
+ }
53
+ /** 预设名(供产物里「已脱敏 · 覆盖 X 类」那句话用) */
54
+ export function activePresetNames(presets) {
55
+ return presets === false ? [] : (presets ?? REDACT_PRESETS.map((p) => p.name));
56
+ }
57
+ /**
58
+ * 脱敏一条 trace(**返回副本**,不改入参)。
59
+ *
60
+ * 规则顺序:预设(默认全开)→ 字段级 `keys`(后者整字段抹平,最严)。
61
+ */
62
+ export function redactTrace(trace, opts = {}) {
63
+ const keys = opts.keys ?? [];
64
+ const rules = buildRules(opts.presets);
65
+ const spans = (trace.spans ?? []).map((span) => ({
66
+ ...span,
67
+ attributes: redactValue(span.attributes, keys, rules),
68
+ events: (span.events ?? []).map((e) => ({ ...e, body: redactValue(e.body, keys, rules) })),
69
+ ...(span.error
70
+ ? { error: { ...span.error, message: applyRules(span.error.message ?? '', rules) } }
71
+ : {}),
72
+ }));
73
+ return {
74
+ ...trace,
75
+ totalUsage: trace.totalUsage ? { ...trace.totalUsage } : trace.totalUsage,
76
+ spans,
77
+ // 见文件头注:比 examples 多覆盖的一层(手写 trace 可能有顶层 events)
78
+ ...(Array.isArray(trace.events)
79
+ ? { events: redactValue(trace.events, keys, rules) }
80
+ : {}),
81
+ };
82
+ }
package/dist/report.js CHANGED
@@ -8,6 +8,11 @@
8
8
  * - 裸 Trace(含 `spans`)—— 如 `createOtlpExporter` 之外的本地落盘、或自己 writeFile 的记录;
9
9
  * - TaskRecord(含 `result.trace` 或 `trace`)—— 如 `FileTaskStore` / `SqliteTaskStore` 的导出。
10
10
  *
11
+ * ⚠️ TaskRecord 导出是 **append-only**(FileTaskStore 每次 save 追加一行):审批流里同一棵
12
+ * 挂起段 trace 会被写多次(挂起 save / approve 落库 / 接管再 save,各行的 `result.trace`
13
+ * 是同一份)⇒ 按行计会把 runs / 成功率 / 成本全部翻倍。因此解析后按 **`traceId` 去重,
14
+ * 同一 traceId 只留最后一行**(append-only 里后面的是新状态),能力表与会话表共用这份输入。
15
+ *
11
16
  * 聚合实现**不在这里** —— 复用 `@migor/trace-view` 的 `summarizeTrace`
12
17
  * (构建期由 scripts/copy-assets.mjs 拷进 dist/inspector/)。CLI 因此保持零 npm 运行时依赖,
13
18
  * 且与 inspector 面板/官网 playground 共用同一份聚合口径(避免两处漂移)。
@@ -29,6 +34,51 @@ export function extractTrace(v) {
29
34
  return trace;
30
35
  return null;
31
36
  }
37
+ /** TaskRecord 的任务键(`FileTaskStore` / `SqliteTaskStore` 导出的顶层 `taskId`)。裸 Trace 没有它。 */
38
+ function taskKeyOf(v) {
39
+ if (!v || typeof v !== 'object')
40
+ return null;
41
+ const id = v.taskId;
42
+ return typeof id === 'string' && id.length > 0 ? id : null;
43
+ }
44
+ /**
45
+ * run 根 span —— 优先 `kind === 'run'`,跑挂的 trace 没有就退到第一个 span。
46
+ * 与 `inspector-routes.ts` 的 `summarize` 同款取法(两处都在读 run 根的 attribute,口径要一致)。
47
+ */
48
+ function rootSpanOf(t) {
49
+ const spans = (t.spans ?? []);
50
+ return spans.find((s) => s?.kind === 'run') ?? spans[0] ?? null;
51
+ }
52
+ /** 「没有会话」桶的名字 —— 常量而非字面量:断言与渲染必须指同一个东西 */
53
+ export const NO_SESSION = '(无会话)';
54
+ /** 分组键:会话优先,其次任务,最后落「无会话」桶 */
55
+ function groupKeyOf(t, taskKey) {
56
+ const attrs = rootSpanOf(t)?.attributes;
57
+ const sid = attrs?.['session.id'];
58
+ if (typeof sid === 'string' && sid.length > 0)
59
+ return sid;
60
+ return taskKey ?? NO_SESSION;
61
+ }
62
+ /** 单条 run 的根 span 耗时(`endedAt - startedAt`);读不出计 0 */
63
+ function runMs(t) {
64
+ const root = rootSpanOf(t);
65
+ const s = Number(root?.startedAt);
66
+ const e = Number(root?.endedAt);
67
+ return Number.isFinite(s) && Number.isFinite(e) && e >= s ? e - s : 0;
68
+ }
69
+ /**
70
+ * 单条 run 的成本(美元)。
71
+ *
72
+ * ⚠️ **刻意不从 `summarizeTrace` 的能力行反加**:能力行只覆盖进了 capability 的
73
+ * `llm.turn`,run 根直属的回合(主 agent 自己问的)不在任何能力行里 ⇒ 反加会**漏**。
74
+ * `Trace.totalUsage` 才是权威口径(各 `llm.turn` 之和,刻意不含 capability 聚合以免
75
+ * 重复计数,`src/core/trace.ts`)。读不出(手工写的 trace / 未定价模型)⇒ null
76
+ * (渲染成 `-`),**不伪装成 0**。
77
+ */
78
+ function runCostUsd(t) {
79
+ const c = t.totalUsage?.costEstimate;
80
+ return typeof c === 'number' && Number.isFinite(c) ? c : null;
81
+ }
32
82
  /** 打印一张按总耗时降序的能力排行(跨多行记录时按能力合并) */
33
83
  /** 用法串(cli.ts 的子命令 `--help` 也从这里取,避免两处各写一份) */
34
84
  export const USAGE = '用法:agentia report <trace.jsonl> [--json]';
@@ -50,16 +100,21 @@ export async function reportCommand(args) {
50
100
  catch (e) {
51
101
  throw new Error(`读不到文件 ${file}(${e.message})`);
52
102
  }
53
- const traces = [];
103
+ // 解析时同时留下**外层记录**的任务键(TaskRecord 的 `taskId`)—— 它与 trace 本体不同源
104
+ // (在 wrapper 上,不在 `spans` 里),故在解析处一并取出。
105
+ // ⚠️ 「一行 = 一条 run」对 append-only 导出不成立(同一棵 trace 可能占多行),
106
+ // 去重在解析**之后**统一做(见下),别在循环里顺手去重 —— 坏行计数会被带歪。
107
+ const entries = [];
54
108
  let badLines = 0;
55
109
  for (const line of raw.split('\n')) {
56
110
  const s = line.trim();
57
111
  if (!s)
58
112
  continue;
59
113
  try {
60
- const t = extractTrace(JSON.parse(s));
114
+ const v = JSON.parse(s);
115
+ const t = extractTrace(v);
61
116
  if (t)
62
- traces.push(t);
117
+ entries.push({ trace: t, taskKey: taskKeyOf(v) });
63
118
  else
64
119
  badLines += 1;
65
120
  }
@@ -67,10 +122,30 @@ export async function reportCommand(args) {
67
122
  badLines += 1;
68
123
  }
69
124
  }
70
- if (traces.length === 0) {
125
+ if (entries.length === 0) {
71
126
  throw new Error(`${file} 里没有可识别的 trace(支持裸 Trace 或含 result.trace / trace 的记录)` +
72
127
  (badLines > 0 ? `;另有 ${badLines} 行无法解析` : ''));
73
128
  }
129
+ // **按 traceId 去重,同一 traceId 只留最后一行**。FileTaskStore 是 append-only:审批流里
130
+ // 同一棵挂起段 trace 会被写多次(挂起 save / approve 落库 / 接管再 save),各行的
131
+ // `result.trace` 是同一份 —— 不去重则 runs / 成功率 / 成本按行数翻倍。
132
+ // - 取**最后**一行:append-only 里后面的是新状态(接管后的记录才是终态);
133
+ // - 键是 traceId 而不是 taskId:一个任务可以跨**多段** run(挂起 → 恢复 → 再挂起),
134
+ // 每段一棵新 trace(traceId == runId),按 taskId 去重会把不同段错并成一条;
135
+ // - 无 traceId 的行(手写 trace)无从判同,全部保留;
136
+ // - 过滤保序:留下的是各 traceId 在文件里**最后一次出现的位置**,输出顺序与直觉一致。
137
+ // 能力表、runs 总计、会话表**共用**这份去重后的输入(两处各算各的必然漂移)。
138
+ const lastByTraceId = new Map();
139
+ entries.forEach((e, i) => {
140
+ const id = e.trace.traceId;
141
+ if (typeof id === 'string' && id.length > 0)
142
+ lastByTraceId.set(id, i);
143
+ });
144
+ const deduped = entries.filter((e, i) => {
145
+ const id = e.trace.traceId;
146
+ return typeof id !== 'string' || id.length === 0 || lastByTraceId.get(id) === i;
147
+ });
148
+ const traces = deduped.map((e) => e.trace);
74
149
  // 聚合口径与 inspector 面板 / 官网 playground 同源(构建期拷进来的 trace-view 产物)
75
150
  // 这份聚合实现由 packages/cli/scripts/copy-assets.mjs 在构建期从 trace-view 产物拷进来。
76
151
  // 缺它只可能是「装坏了」或「直接跑未构建的源码」—— 原始 ERR_MODULE_NOT_FOUND 里全是
@@ -105,6 +180,42 @@ export async function reportCommand(args) {
105
180
  }
106
181
  }
107
182
  const rows = [...merged.values()].sort((a, b) => b.totalMs - a.totalMs || b.calls - a.calls);
183
+ // G6:会话 / 任务维度 —— 与上面的能力表**互补**:能力表看单个能力,这里看一整段会话/任务
184
+ // (多轮 run 的合计 + 成功率)。成功率**必须带分母**:只报百分比在小组上是噪声。
185
+ const byGroup = new Map();
186
+ for (const e of deduped) {
187
+ const key = groupKeyOf(e.trace, e.taskKey);
188
+ let row = byGroup.get(key);
189
+ if (!row) {
190
+ row = {
191
+ session: key,
192
+ runs: 0,
193
+ succeeded: 0,
194
+ failed: 0,
195
+ totalMs: 0,
196
+ costUsd: null,
197
+ costPerSuccess: null,
198
+ };
199
+ byGroup.set(key, row);
200
+ }
201
+ row.runs += 1;
202
+ // 成败判据与上面的 `okRuns` **同一条**(`status !== 'error'`)—— 两处各写一份会漂移。
203
+ if (e.trace.status !== 'error')
204
+ row.succeeded += 1;
205
+ else
206
+ row.failed += 1;
207
+ row.totalMs += runMs(e.trace);
208
+ const c = runCostUsd(e.trace);
209
+ if (c != null)
210
+ row.costUsd = (row.costUsd ?? 0) + c;
211
+ }
212
+ for (const row of byGroup.values()) {
213
+ // ⚠️ 成功数为 0 ⇒ null(渲染成 `-`)。写成 0 是把「除不了」伪装成「每个成功任务不花钱」,
214
+ // 正好说反 —— 这正是本项要防的那笔假账。
215
+ row.costPerSuccess =
216
+ row.costUsd != null && row.succeeded > 0 ? row.costUsd / row.succeeded : null;
217
+ }
218
+ const sessions = [...byGroup.values()].sort((a, b) => b.totalMs - a.totalMs || b.runs - a.runs || a.session.localeCompare(b.session));
108
219
  const failRows = traces
109
220
  .filter((t) => t.status === 'error')
110
221
  .map((t) => {
@@ -124,12 +235,25 @@ export async function reportCommand(args) {
124
235
  skippedLines: badLines,
125
236
  failures: failRows,
126
237
  capabilities: rows,
238
+ sessions,
127
239
  totals: { totalMs, errors: errTotal, capabilities: rows.length },
128
240
  };
129
241
  console.log(JSON.stringify(payload, null, 2));
130
242
  return 0;
131
243
  }
132
- const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
244
+ // 按**显示宽度**补齐:`(无会话)` 里的汉字占 2 列而 `String.length` 只算 1 ⇒ 用字符数补齐
245
+ // 会把那一行整体右推 3 格。而它偏偏是「宿主没开会话」时的**唯一**一行,歪得很显眼。
246
+ // 判据是启发式(≥ U+2E80 记 2 列):覆盖 CJK 与全角标点;`…`(U+2026)等仍是 1 列。
247
+ const dispWidth = (s) => {
248
+ let w = 0;
249
+ for (const ch of s)
250
+ w += (ch.codePointAt(0) ?? 0) >= 0x2e80 ? 2 : 1;
251
+ return w;
252
+ };
253
+ const pad = (s, n) => {
254
+ const w = dispWidth(s);
255
+ return w >= n ? s : s + ' '.repeat(n - w);
256
+ };
133
257
  const fmtMs = (ms) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`);
134
258
  console.log(`trace 文件 ${file}`);
135
259
  console.log(`runs ${traces.length}(失败 ${traces.length - okRuns})${badLines > 0 ? ` · 跳过无法解析 ${badLines} 行` : ''}`);
@@ -141,6 +265,18 @@ export async function reportCommand(args) {
141
265
  if (failRows.length > 3)
142
266
  console.log(` …另有 ${failRows.length - 3} 条失败 run`);
143
267
  console.log('');
268
+ // G6 区块:**无论有没有会话都打**。没配 `session: { store, id }` 时只会有一行
269
+ // `(无会话)` —— 那是**如实呈现**(说明宿主没开会话),不是缺陷(见设计稿 §6)。
270
+ console.log(`${pad('session', 34)} ${pad('runs', 6)} ${pad('ok', 5)} ${pad('fail', 6)} ${pad('total', 9)} ${pad('cost', 12)} ${pad('cost/ok', 12)}`);
271
+ for (const r of sessions) {
272
+ console.log(`${pad(r.session, 34)} ${pad(String(r.runs), 6)} ${pad(String(r.succeeded), 5)} ` +
273
+ `${pad(String(r.failed), 6)} ${pad(fmtMs(r.totalMs), 9)} ` +
274
+ `${pad(r.costUsd != null ? r.costUsd.toFixed(6) : '-', 12)} ` +
275
+ // `costPerSuccess` 为 null 有两种来路(没成本 / 没成功),两者都只能给 `-` ——
276
+ // 区别在 ok 列上写着(ok = 0 时就是后者)。
277
+ `${pad(r.costPerSuccess != null ? r.costPerSuccess.toFixed(6) : '-', 12)}`);
278
+ }
279
+ console.log('');
144
280
  if (rows.length === 0) {
145
281
  console.log('(没有可归因的能力:这些 trace 里既没有 capability span,也没有 tool.output 事件)');
146
282
  return 0;
@@ -1,6 +1,6 @@
1
1
  # __PROJECT_NAME__
2
2
 
3
- 基于 [Agentia](https://github.com/retrychx/agentia) 框架的 agent 应用。
3
+ 基于 [Agentia](https://github.com/migoric/agentia) 框架的 agent 应用。
4
4
 
5
5
  ## 目录约定
6
6
 
@@ -93,6 +93,59 @@ npm start # 起 HTTP 服务(= node dist/main.js)
93
93
  (`@migor/agentia` 同样导出、同一套 API,代价是单写者前提)。`npm run dev` 与
94
94
  `npm run start:batch` 不碰它,它们的 Node 下限仍是 18。
95
95
 
96
+ ## 本地怎么调
97
+
98
+ ⚠️ 先认下这条,否则会照着别的框架的经验找错东西:**这里没有「一个业务一个 URL」的接口层**。
99
+ HTTP 面就上面那几条**固定**端点 —— 加一个 `@Tool` 不会多出一条路,**调哪个能力由模型决定,
100
+ 不在 URL 里**。⇒ 想要「一个能力一个接口」的形态,走下面的 ③。
101
+
102
+ **① 不起服务(最快)**:
103
+
104
+ ```bash
105
+ npm run build && npm run start:batch -- "你的问题" # 只跑一轮、打印结果、退出
106
+ ```
107
+
108
+ **② 起了服务之后**(`npm start`,端口 `PORT` 缺省 3000):
109
+
110
+ ```bash
111
+ curl -s localhost:3000/healthz # 先确认活着(不鉴权)
112
+ curl -s localhost:3000/run \
113
+ -H 'content-type: application/json' \
114
+ -d '{"prompt":"介绍一下你自己"}' # body 也收裸字符串 "..." 与 messages 数组
115
+ curl -sN localhost:3000/run \
116
+ -H 'content-type: application/json' \
117
+ -H 'Accept: text/event-stream' -d '"你的问题"' # -N 关掉 curl 缓冲 ⇒ 逐帧滚动
118
+ ```
119
+
120
+ ⚠️ 两个别只看 curl 退出码的判据:
121
+ ① `POST /run` **失败也回 200** —— 硬失败在响应体的 `status: "failed"` + `error` 字段里
122
+ (框架用 `rethrow:false` 语义:失败不用 HTTP 错误码表达)⇒ 脚本里判 `status`,别判 `$?`;
123
+ ② SSE 那条必须加 `-N`,否则是 curl 自己在缓冲,看起来像「服务没吐东西」。
124
+
125
+ **③ 想单挑某一个工具**(不想让模型决定):用 `createMcpServer(app, { transport: 'stdio' })`,
126
+ 每个 `@Tool` 变成一个标准 MCP tool,`tools/call` 直接调 —— 这是「一个能力一个接口」的形态,
127
+ 只是它长在 MCP 层、不是 HTTP 路由层。范围**只到 tools**(不含 skill / subagent / prompt)。
128
+
129
+ **④ 带调用树可视化地调**:`npm run dev`(= `agentia dev`)—— 终端打出
130
+ `Inspector: http://127.0.0.1:<随机端口>/?t=<token>`,浏览器里选能力 / 工作目录、输 prompt,
131
+ 右栏实时调用树;改代码自动重启。
132
+
133
+ ## trace 出口(默认开着)
134
+
135
+ 每次 run 的调用树默认落盘到 `.agentia/traces.jsonl`(已进 `.gitignore`)—— 接的是
136
+ `src/app.ts` 里那一行 `sinks: [jsonlTraceSink({ path: TRACE_FILE })]`。dev 面板、同步
137
+ `/run`、异步 `/tasks` 三条路都经 `app.run` ⇒ 一处接入、三条全覆盖:
138
+
139
+ ```bash
140
+ agentia report .agentia/traces.jsonl # 哪个能力慢 / 贵 / 爱失败
141
+ agentia diff a.jsonl b.jsonl # 两次 run 的调用树 A/B
142
+ ```
143
+
144
+ ⚠️ 落的是**完整 trace**(含 prompt 与工具出入参)⇒ 上线前按你的合规要求处置。删掉 `sinks`
145
+ 那一行即关闭 —— **关掉之后异步 `/tasks` 的 trace 会落进任务库而没有任何人导出**(同步
146
+ `/run` 的 trace 仍随响应回去,所以「好像也没什么区别」正是这个坑的形状)。
147
+ 不确定自己关没关:`agentia doctor` 会查 `src/app.ts` 里这一行。
148
+
96
149
  ## 一次性任务(Job 形态)
97
150
 
98
151
  cron / CI / 容器里「跑一次就退出」的那种,用 `batch.ts`:
@@ -106,7 +159,7 @@ npm run build && npm run start:batch -- "你的问题" # = node dist/batch.js
106
159
  ## 更完整的生产配方
107
160
 
108
161
  要 metrics(`/metrics`)/ OTLP 导出 / Dockerfile / compose 编排,直接抄框架仓库的
109
- [`examples/deploy/`](https://github.com/retrychx/agentia/tree/main/examples/deploy)
162
+ [`examples/deploy/`](https://github.com/migoric/agentia/tree/main/examples/deploy)
110
163
  与 `docs/deployment.md` —— `src/main.ts` 是它的精简版,两者同一套 API;上线前的决定清单
111
164
  也在那份 `docs/deployment.md` 里。API 细节见本项目 `AGENTS.md` 的「触发与宿主」节。
112
165
 
@@ -5,7 +5,8 @@ dist
5
5
  .env
6
6
  .env.local
7
7
 
8
- # dev 环的本地状态:对话历史(.agentia/session.json)等 —— 是本机调试产物,不进版本库
8
+ # dev 环与运行期的本地状态:对话历史(.agentia/session.json)、trace 落盘(.agentia/traces.jsonl)
9
+ # —— 都是本机产物,且 trace 含 prompt 与工具出入参,绝不进版本库
9
10
  .agentia/
10
11
 
11
12
  # 默认服务入口(src/main.ts)的 SQLite 任务库(wal/shm 是它的伴生文件)
@@ -0,0 +1,36 @@
1
+ # __PROJECT_NAME__
2
+
3
+ 一个最小 Agentia 应用:一个入口文件、一个裸工具,没有装饰器、DI 或服务端样板。
4
+
5
+ ## 运行
6
+
7
+ ```bash
8
+ npm install
9
+ $EDITOR .env # 填 ANTHROPIC_API_KEY
10
+ npm run dev -- "你的问题" # tsx src/main.ts,跑一次并退出
11
+ ```
12
+
13
+ 构建后运行:
14
+
15
+ ```bash
16
+ npm run typecheck
17
+ npm run build
18
+ npm start -- "你的问题" # node dist/main.js
19
+ ```
20
+
21
+ `src/main.ts` 里的 `result` 主要包含:
22
+
23
+ - `finalText`:最终文本;先检查 `result.error`,不要只看它是否为空。
24
+ - `stopReason` / `error`:结束原因与结构化错误,失败时进程退出码非零。
25
+ - `trace`:完整调用树、token 与成本;需要文件、指标或 OTLP 出口时接 `TraceSink`。
26
+
27
+ ## 需要更多能力时
28
+
29
+ 当前 minimal 模板**不包含** inspector 面板、能力目录/registry、异步任务或 HTTP 宿主。
30
+ 需要这些时,用默认完整模板另建项目:
31
+
32
+ ```bash
33
+ npx @migor/cli create my-service
34
+ ```
35
+
36
+ 也可以直接在当前 `tools` 数组里继续添加裸 `AgentTool`,不必先引入装饰器。
@@ -0,0 +1,35 @@
1
+ import { createApp, loadEnvFile } from '@migor/agentia';
2
+ import type { AgentTool } from '@migor/agentia';
3
+
4
+ loadEnvFile();
5
+
6
+ const echo = {
7
+ name: 'echo',
8
+ description: '原样返回输入文本,用来验证模型与工具链路',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: { text: { type: 'string' } },
12
+ required: ['text'],
13
+ additionalProperties: false,
14
+ },
15
+ run: (input) => (input as { text: string }).text,
16
+ } satisfies AgentTool;
17
+
18
+ const app = createApp({
19
+ name: '__PROJECT_NAME__',
20
+ system: '你是简洁的助手,需要原样返回文本时调用 echo 工具。',
21
+ tools: [echo],
22
+ });
23
+
24
+ const { result } = await app.run([
25
+ { role: 'user', content: process.argv[2] ?? '用 echo 返回:hello agentia' },
26
+ ]);
27
+
28
+ if (result.error) {
29
+ console.error(`run 失败(stopReason=${result.stopReason}):${result.error.message}`);
30
+ console.error('请把 ANTHROPIC_API_KEY 填进 .env,或设置对应的环境变量。');
31
+ process.exitCode = 1;
32
+ } else {
33
+ console.log(result.finalText);
34
+ // result.trace 已包含本次调用的完整调用树与 token/成本;需要落盘时再接 TraceSink。
35
+ }
@@ -11,10 +11,10 @@
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json"
12
12
  },
13
13
  "dependencies": {
14
- "@migor/agentia": "^0.10.2"
14
+ "@migor/agentia": "^0.10.4"
15
15
  },
16
16
  "devDependencies": {
17
- "@migor/cli": "^0.10.2",
17
+ "@migor/cli": "^0.10.4",
18
18
  "tsx": "^4.19.0",
19
19
  "typescript": "^7.0.2",
20
20
  "@types/node": "^22.0.0"
@@ -1,10 +1,9 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { createApp, loadEnvFile, SystemPrompt } from '@migor/agentia';
4
+ import { createApp, jsonlTraceSink, loadEnvFile, SystemPrompt } from '@migor/agentia';
5
5
  import type { AgentApp, SessionStore } from '@migor/agentia';
6
6
  import { FileSessionStore } from './session-store.js';
7
- import ReadFileTool from './tools/read-file/index.js';
8
7
 
9
8
  /**
10
9
  * 应用装配 —— **与启动分离**(app.ts 只负责「造一个 app」,不负责「跑一次」)。
@@ -61,6 +60,20 @@ export const PROJECT_ROOT = resolve(fileURLToPath(import.meta.url), '../..');
61
60
  /** 会话文件:整段对话历史(跨进程重启接得上)。已进 .gitignore */
62
61
  export const SESSION_FILE = resolve(PROJECT_ROOT, '.agentia', 'session.json');
63
62
 
63
+ /**
64
+ * trace 落盘文件(一行一条裸 Trace)。已进 .gitignore。
65
+ *
66
+ * 为什么**默认就接上**:框架的差异化是「每次 run 都产出可观测调用树」,但框架刻意
67
+ * **不内建**任何出口(spec §9.3 —— 出口只有 `sinks` 这条缝),于是「缝在那儿、没人接」
68
+ * 时表现是:同步 `/run` 的 trace 随响应回去了,而**异步 `/tasks` 的 trace 落进任务库里
69
+ * 没有任何人导出**,「这个任务为什么慢」要排查时它正好不在。接上这一条之后
70
+ * `agentia report` / `diff` / `harvest` / `export` 开箱可读。
71
+ *
72
+ * ⚠️ 落的是**完整 trace**(含 prompt 与工具出入参)⇒ 上线前按你自己的合规要求处置
73
+ * (删这一行即关掉;换 OTLP / 指标见 docs/observability.md 的配方)。
74
+ */
75
+ export const TRACE_FILE = resolve(PROJECT_ROOT, '.agentia', 'traces.jsonl');
76
+
64
77
  export interface CreateAgentAppOptions {
65
78
  /** 只收集这些 token(能力文件夹名)上的能力;不传 = 全量。dev 环的「能力选择」喂这里 */
66
79
  toolSources?: string[];
@@ -74,21 +87,23 @@ export async function createAgentApp(opts: CreateAgentAppOptions = {}): Promise<
74
87
  discover: capabilityPaths(),
75
88
  providers: [
76
89
  // 工作目录以**值**注入给需要它的能力(D3-D:现成 DI,零框架改动)。
77
- // 消费方式:能力类写 `constructor(private readonly root: string)`,并在 providers
78
- // 里声明 `{ provide: '<能力 token>', useClass: Xxx, deps: ['WORKDIR'] }`
79
- // (discover 自动注册的 provider **没有 deps**,所以要注入的能力得从 discover 挪到显式
80
- // providers;同一个 token 混用不会重复收集菜单)。
81
- // ⚠️ 若同时用 src/registry.ts(那里也有一份 `WORKDIR = process.cwd()`):
82
- // 同 token **后注册覆盖先注册**,registry 的 providers 必须排在**这份之前**,
83
- // 否则面板喂进来的 workdir 会被 process.cwd() 静默顶掉。
90
+ // 消费侧两选一,推荐第一种(依赖声明跟着能力走,装配文件不必知道):
91
+ // ① 能力类自带 `static readonly deps = ['WORKDIR']` —— discover 扫到就带上,
92
+ // 脚手架的 read-file 就是这么接的;
93
+ // ② 在这里显式声明 `{ provide: '<token>', useClass: Xxx, deps: ['WORKDIR'] }`
94
+ // (能力不走 discover、或依赖是装配期才知道的值时用这个)。
95
+ // ⚠️ **别两条同时用**:同一 token 出现两份时框架会告警(后注册覆盖先注册,
96
+ // 谁生效取决于顺序)。`src/registry.ts` 里也有一份 `WORKDIR`(显式装配路线),
97
+ // 值与这里相同 ⇒ 混用不会改变语义,但真要二选一时留 `app.ts` 这份。
84
98
  { provide: 'WORKDIR', useValue: opts.workdir ?? PROJECT_ROOT },
85
- // 例子:把工作目录注入给 read-file 工具(脚手架自带的第二个能力,
86
- // 也是「面板上的文件夹选择器真的生效」的那个接线)
87
- { provide: 'read-file', useClass: ReadFileTool, deps: ['WORKDIR'] },
88
99
  ],
89
100
  // 收窄发生在 createApp 期:toolSources 是 token 级白名单,一次收窄顺带把装配期
90
101
  // 静态校验与 prompts.versions 那本账一起收窄。不传 = 全量(缺省路径)。
91
102
  ...(opts.toolSources && opts.toolSources.length > 0 ? { toolSources: opts.toolSources } : {}),
103
+ // trace 出口(观测)。框架刻意不内建 ⇒ 不接就是「跑得挺好、什么都看不见」;
104
+ // 接入后 dev 面板、同步 /run、异步 /tasks 三条路都经 `app.run` ⇒ 这一处全覆盖。
105
+ // 删掉即关闭;换 OTLP / 指标配方见框架 docs/observability.md。
106
+ sinks: [jsonlTraceSink({ path: TRACE_FILE })],
92
107
  system: new SystemPrompt().add(
93
108
  'role',
94
109
  '你是 __PROJECT_NAME__ 的主 agent,按任务自主调度菜单里的能力。',
@@ -1,18 +1,20 @@
1
1
  // Agentia 能力注册表 —— 由 agentia CLI 维护(agentia g 自动更新,也可手工编辑)
2
2
  // @agentia:imports
3
3
  // @agentia:imports-end
4
+ import { resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
4
6
  import type { Provider } from '@migor/agentia';
5
7
 
6
8
  /** 显式装配路线:createApp({ providers, system: ... })(与 discover 目录扫描二选一或混用) */
7
9
  export const providers: Provider[] = [
8
10
  // 「工作目录」以**值**注入给需要它的能力(消费方式见 src/tools/read-file/index.ts)。
9
- // 想改成固定路径 / 从环境变量取,改这一行即可 —— 这是显式装配路线的意义。
10
- //
11
- // ⚠️ 与 src/app.ts 里那份 `WORKDIR` 是**二选一**的关系,混用时**顺序承重**:
12
- // createApp 对同 token 是「后注册覆盖先注册」—— 把这份 providers 拼在 app.ts 那份
13
- // **之后**,dev 面板喂进来的 workdir 会被这里的 `process.cwd()` 静默顶掉。
14
- // 要混用就让本注册表排在前(让 app.ts 的值赢),否则只留一边。
15
- { provide: 'WORKDIR', useValue: process.cwd() },
11
+ // 项目根按**本文件位置**解析(src/registry.ts → 上一层),与 src/app.ts 那份同值 ——
12
+ // ⚠️ 此前这里是 `process.cwd()`:换目录启动(systemd / 容器 `-w`)时两份 `WORKDIR`
13
+ // 值不同,混用两条装配路线时谁生效取决于拼接顺序(「后注册覆盖先注册」),
14
+ // 而框架现在会对同 token 重复注册出声告警 ⇒ 两份同值之后,混用不再改变语义。
15
+ // resolve 顺手去掉尾斜杠:与 app.ts 的 PROJECT_ROOT **逐字同值**(两份若只差一个尾斜杠,
16
+ // 能力侧拿到的字符串就不一样,而「两份 WORKDIR 值不同」正是最难查的那类差异)
17
+ { provide: 'WORKDIR', useValue: resolve(fileURLToPath(new URL('..', import.meta.url))) },
16
18
  // @agentia:entries
17
19
  // @agentia:entries-end
18
20
  ];
@@ -10,8 +10,13 @@ const LIST_LIMIT = 200;
10
10
  *
11
11
  * 它是脚手架自带的第二个能力,作用是把「工作目录」这条线**接通**:
12
12
  * `agentia dev` 的面板上有「工作目录」控件,而只有当**某个能力真的消费它**时,
13
- * 那个控件才有可观测的效果。做法就是这里 —— 根由 DI 注入(`WORKDIR`),
14
- * 不在代码里写死。见 app.ts 的 providers。
13
+ * 那个控件才有可观测的效果。做法就是这里 —— 根由 DI 注入(`WORKDIR`),不在代码里写死。
14
+ *
15
+ * ⚠️ **依赖声明就在本类上**(`static readonly deps = ['WORKDIR']`,见下):`discover`
16
+ * 读到它就会把 deps 带上,所以**装配文件不需要知道这个能力要什么**。
17
+ * 此前(2026-10-16 前)discover 对「类」只产 `useClass`,需要注入的能力只能挪到
18
+ * `app.ts` 的显式 providers 里**再注册一遍** ⇒ 同一 token 出现两份(一份无 deps、
19
+ * 一份有),靠「后注册覆盖先注册」碰巧成立 —— 顺序一变就静默失效。现在没这个必要了。
15
20
  *
16
21
  * 为什么是一对而不是只有 read_file:换了目录之后,模型既不知道自己此刻在哪个目录、
17
22
  * 也不知道里面有什么(run 的 prompt 不会变)⇒ 只能瞎猜文件名,行为看起来「没变」。
@@ -22,14 +27,20 @@ const LIST_LIMIT = 200;
22
27
  * 值从构造器进来,方法里用 `this.root`。
23
28
  */
24
29
  export default class ReadFileTool {
30
+ /**
31
+ * 依赖声明(discover 会读它并带上 deps ⇒ `app.ts` 不必为这个能力再注册一遍)。
32
+ * ⚠️ 必须是**字符串数组**:写成 `deps = 'WORKDIR'` 时 discover 会响亮报错
33
+ * (不当成「没有依赖」去无参构造 —— 那正是要避免的静默失效)。
34
+ */
35
+ static readonly deps = ['WORKDIR'];
36
+
25
37
  constructor(private readonly root: string) {
26
- // discover 自动注册的 provider **没有 deps** —— 如果哪天 app.ts 里那行显式 provider
27
- // 被删掉,容器会无参构造这个类,root 变成 undefined,工具就会静默地按 cwd 解析。
28
- // 与其静默,不如在这里响亮报错。
38
+ // 兜底:若哪天 deps 被摘掉(或能力被以无 deps 的方式注册),容器会无参构造这个类,
39
+ // root 变成 undefined,工具就会静默地按 cwd 解析。与其静默,不如在这里响亮报错。
29
40
  if (typeof root !== 'string' || root.length === 0) {
30
41
  throw new Error(
31
- 'ReadFileTool 需要注入工作目录:请在 src/app.ts 的 providers 里保留 ' +
32
- "{ provide: 'read-file', useClass: ReadFileTool, deps: ['WORKDIR'] }",
42
+ "ReadFileTool 需要注入工作目录:请在能力类上保留 `static readonly deps = ['WORKDIR']`" +
43
+ "(discover 会带上它),或在显式 providers 里声明 { provide: 'read-file', deps: ['WORKDIR'] }",
33
44
  );
34
45
  }
35
46
  // ⚠️ **归一化**:调用方给的工作目录可能带尾斜杠(面板输入、dev.config 手写、
@@ -7,6 +7,8 @@
7
7
  "ES2022"
8
8
  ],
9
9
  "strict": true,
10
+ "exactOptionalPropertyTypes": true,
11
+ "forceConsistentCasingInFileNames": true,
10
12
  "rootDir": "src",
11
13
  "outDir": "dist",
12
14
  "types": [