@migor/cli 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +283 -2
- package/README.md +5 -5
- package/dist/AGENTS.md +132 -8
- package/dist/cli.js +4 -2
- package/dist/create.js +18 -3
- package/dist/dev-protocol.js +33 -0
- package/dist/dev-runner.js +445 -0
- package/dist/dev.js +947 -63
- package/dist/inspector-page.html +687 -34
- package/dist/inspector-sink.js +4 -5
- package/dist/inspector.js +326 -19
- package/dist/panel-logic.js +248 -0
- package/dist/registry.js +10 -3
- package/dist/templates/README.md +18 -2
- package/dist/templates/capabilities/subagent/index.ts +5 -1
- package/dist/templates/capabilities/subagent/system.md +15 -0
- package/dist/templates/gitignore +3 -0
- package/dist/templates/package.json +2 -2
- package/dist/templates/src/app.ts +106 -0
- package/dist/templates/src/dev.config.ts +40 -0
- package/dist/templates/src/main.ts +8 -26
- package/dist/templates/src/registry.ts +3 -0
- package/dist/templates/src/session-store.ts +68 -0
- package/dist/templates/src/tools/read-file/index.ts +70 -0
- package/dist/templates.js +51 -4
- package/package.json +1 -1
- package/dist/inspector-preload.js +0 -31
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev runner —— `agentia dev` 起的**子进程**,由 CLI 自己拥有(**不是**用户工程里的文件)。
|
|
3
|
+
*
|
|
4
|
+
* 它做的事只有一件:**import 用户的 `src/app.ts` 并驱动它**。
|
|
5
|
+
*
|
|
6
|
+
* 为什么要在 CLI 里(D9(d)):四个控件(能力选择 / 多轮 / 工作目录 / prompt)全都是
|
|
7
|
+
* `createApp` / `app.run` 的选项,而**调用者**能设它们。如果 `agentia dev` 自己 import
|
|
8
|
+
* 用户的 app 并调用,CLI 就是调用者 ⇒ 用户工程里**一个 dev 文件都不需要**。
|
|
9
|
+
* 反过来说,把 harness 放用户工程里等于每个工程一份会漂移的**逻辑副本**(CLI 修了 bug,
|
|
10
|
+
* 那些副本一个都不会变)—— 这正是 `dev.ts`(逻辑)被否掉、`dev.config.ts`(数据)留下的判据。
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ 进程 cwd 必须仍是**项目根**(dev.ts 负责):`loadEnvFile()` 按 cwd 找 `.env`、
|
|
13
|
+
* `discover` 的相对路径也按 cwd —— 把 cwd 设成「agent 工作目录」会让 `.env` 静默读错。
|
|
14
|
+
* 工作目录走 DI 注入(D3-D),不走 chdir。
|
|
15
|
+
*
|
|
16
|
+
* 与父进程的协议见 dev-protocol.ts(单一来源)。
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
import { createInspectSink } from './inspector-sink.js';
|
|
23
|
+
import { APP_ENTRY_REL, DEFAULT_BUDGET, DEV_CONFIG_REL, } from './dev-protocol.js';
|
|
24
|
+
const root = process.env.AGENTIA_DEV_ROOT || process.cwd();
|
|
25
|
+
function send(msg) {
|
|
26
|
+
process.send?.(msg);
|
|
27
|
+
}
|
|
28
|
+
// ---------- trace 出口(必须早于 import 用户的 app) ----------
|
|
29
|
+
/**
|
|
30
|
+
* 把 trace sink 注册进框架的**进程级**注册表。
|
|
31
|
+
*
|
|
32
|
+
* 为什么在 runner 里做而不是靠 `NODE_OPTIONS=--import` 的 preload:preload 那套是
|
|
33
|
+
* 「CLI 代码不在子进程里」时代的产物(当时子进程跑的是用户的 `main.ts`)。现在子进程
|
|
34
|
+
* 跑的就是 CLI 自己的 runner,于是两件事一起消失:
|
|
35
|
+
* - `--import` 的 Node 版本门槛(≥20.6 / ≥18.19);
|
|
36
|
+
* - 拼接 `NODE_OPTIONS`(沙箱 / 工具链里它是被改写的常客,而改写是静默的)。
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ 时序:必须在 **import 用户的 app.ts 之前** await 它 —— `createApp` 构造期对
|
|
39
|
+
* `defaultSinks` 做快照,晚注册的 sink 对已建应用不可见(静默丢 trace)。
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ 必须从**用户项目**(root)解析 `@migor/agentia`,不是从 CLI 的安装位置 ——
|
|
42
|
+
* 否则拿到另一个模块实例,注册表不共享,sink 静默失效。
|
|
43
|
+
*/
|
|
44
|
+
async function registerTraceSink() {
|
|
45
|
+
const port = Number(process.env.AGENTIA_INSPECT_PORT || 0);
|
|
46
|
+
if (port <= 0)
|
|
47
|
+
return;
|
|
48
|
+
const token = process.env.AGENTIA_INSPECT_TOKEN || undefined;
|
|
49
|
+
try {
|
|
50
|
+
const req = createRequire(join(root, 'package.json'));
|
|
51
|
+
const entry = req.resolve('@migor/agentia');
|
|
52
|
+
const mod = (await import(pathToFileURL(entry).href));
|
|
53
|
+
if (typeof mod.registerDefaultTraceSink !== 'function') {
|
|
54
|
+
throw new Error('@migor/agentia 未导出 registerDefaultTraceSink(版本过旧?)');
|
|
55
|
+
}
|
|
56
|
+
mod.registerDefaultTraceSink(createInspectSink({ port, ...(token ? { token } : {}) }));
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
// 面板是增强项:观测挂不上不该阻断 dev(与旧 preload 同一条纪律)
|
|
60
|
+
console.warn(`[agentia] inspector 未挂载:${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// ---------- dev.config.ts(**数据**,不是逻辑) ----------
|
|
64
|
+
/**
|
|
65
|
+
* 读 `src/dev.config.ts`。
|
|
66
|
+
*
|
|
67
|
+
* 口径(D8 ②):**文件缺失 ⇒ 全部单轮**(任务型是安全默认,不会串味)。
|
|
68
|
+
* 内容不合法 ⇒ **响亮告警 + 按缺省走**,绝不静默吞掉 —— 一份写错的 dev 配置
|
|
69
|
+
* 让人以为「我声明了多轮」,而实际每次都是单轮,那正是本仓最忌讳的静默不一致。
|
|
70
|
+
*/
|
|
71
|
+
async function loadDevConfig() {
|
|
72
|
+
const file = join(root, DEV_CONFIG_REL);
|
|
73
|
+
if (!existsSync(file))
|
|
74
|
+
return { config: {}, warning: null };
|
|
75
|
+
let mod;
|
|
76
|
+
try {
|
|
77
|
+
mod = (await import(pathToFileURL(file).href));
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
return {
|
|
81
|
+
config: {},
|
|
82
|
+
warning: `${DEV_CONFIG_REL} 读不了(${e.message}):本次全部按单轮处理`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const raw = mod.default;
|
|
86
|
+
if (raw === undefined || raw === null)
|
|
87
|
+
return { config: {}, warning: null };
|
|
88
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
89
|
+
return {
|
|
90
|
+
config: {},
|
|
91
|
+
warning: `${DEV_CONFIG_REL} 的 default export 必须是对象:本次全部按单轮处理`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const obj = raw;
|
|
95
|
+
const problems = [];
|
|
96
|
+
let multiTurn = [];
|
|
97
|
+
if (obj.multiTurn !== undefined) {
|
|
98
|
+
if (Array.isArray(obj.multiTurn) && obj.multiTurn.every((x) => typeof x === 'string')) {
|
|
99
|
+
multiTurn = obj.multiTurn;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
problems.push('multiTurn 必须是字符串数组');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
let workdir;
|
|
106
|
+
if (obj.workdir !== undefined) {
|
|
107
|
+
if (typeof obj.workdir === 'string' && obj.workdir.length > 0)
|
|
108
|
+
workdir = obj.workdir;
|
|
109
|
+
else
|
|
110
|
+
problems.push('workdir 必须是非空字符串');
|
|
111
|
+
}
|
|
112
|
+
let budget;
|
|
113
|
+
if (obj.budget !== undefined) {
|
|
114
|
+
if (obj.budget && typeof obj.budget === 'object' && !Array.isArray(obj.budget)) {
|
|
115
|
+
const b = obj.budget;
|
|
116
|
+
budget = {};
|
|
117
|
+
if (b.maxCostUsd !== undefined) {
|
|
118
|
+
if (typeof b.maxCostUsd === 'number' && Number.isFinite(b.maxCostUsd) && b.maxCostUsd > 0) {
|
|
119
|
+
budget.maxCostUsd = b.maxCostUsd;
|
|
120
|
+
}
|
|
121
|
+
else
|
|
122
|
+
problems.push('budget.maxCostUsd 必须是正数');
|
|
123
|
+
}
|
|
124
|
+
if (b.maxTotalTokens !== undefined) {
|
|
125
|
+
if (typeof b.maxTotalTokens === 'number' &&
|
|
126
|
+
Number.isSafeInteger(b.maxTotalTokens) &&
|
|
127
|
+
b.maxTotalTokens > 0) {
|
|
128
|
+
budget.maxTotalTokens = b.maxTotalTokens;
|
|
129
|
+
}
|
|
130
|
+
else
|
|
131
|
+
problems.push('budget.maxTotalTokens 必须是正整数');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
problems.push('budget 必须是对象');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
config: {
|
|
140
|
+
multiTurn,
|
|
141
|
+
...(workdir === undefined ? {} : { workdir }),
|
|
142
|
+
...(budget === undefined ? {} : { budget }),
|
|
143
|
+
},
|
|
144
|
+
warning: problems.length > 0 ? `${DEV_CONFIG_REL}:${problems.join(';')}(这几项按缺省走)` : null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// ---------- 能力菜单 ----------
|
|
148
|
+
/**
|
|
149
|
+
* 列出可收窄的能力 token。
|
|
150
|
+
*
|
|
151
|
+
* token 就是**能力文件夹名**(`discover.ts`:`{ provide: name, useClass: exported }`),
|
|
152
|
+
* 所以「读目录」和「装配收集」用的是同一个常量 —— 两侧不会漂移。
|
|
153
|
+
*
|
|
154
|
+
* 为什么要与 `container.registered()` **取交集**:目录里可能有个文件夹没有入口
|
|
155
|
+
* (discover 会跳过并告警),或者它的 provider 被显式 `providers` 顶掉了。
|
|
156
|
+
* 不取交集的话,面板会列出一个选了也没用的 token —— 那就是个静默的空操作。
|
|
157
|
+
*/
|
|
158
|
+
function listCapabilities(app, dirs) {
|
|
159
|
+
if (!Array.isArray(dirs) || dirs.some((d) => typeof d !== 'string')) {
|
|
160
|
+
return {
|
|
161
|
+
names: [],
|
|
162
|
+
warning: 'src/app.ts 没导出 CAPABILITY_DIRS(能力文件夹名数组)—— 能力选择器不可用,全量菜单照常。' +
|
|
163
|
+
'新模板会带上它;老工程按迁移说明补一行即可。',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
let registered;
|
|
167
|
+
try {
|
|
168
|
+
registered = new Set(app.container.registered().map((t) => String(t)));
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
return { names: [], warning: `读容器注册表失败(${e.message}):能力选择器不可用` };
|
|
172
|
+
}
|
|
173
|
+
const names = new Set();
|
|
174
|
+
for (const d of dirs) {
|
|
175
|
+
const abs = join(root, 'src', d);
|
|
176
|
+
if (!existsSync(abs))
|
|
177
|
+
continue;
|
|
178
|
+
for (const e of readdirSync(abs, { withFileTypes: true })) {
|
|
179
|
+
if (e.name.startsWith('.'))
|
|
180
|
+
continue;
|
|
181
|
+
const full = join(abs, e.name);
|
|
182
|
+
let isDir = e.isDirectory();
|
|
183
|
+
if (!isDir && e.isSymbolicLink()) {
|
|
184
|
+
// 软链目录(pnpm store / monorepo)的 isDirectory() 为 false —— 解引用后再判,
|
|
185
|
+
// 否则真能力目录被静默漏掉(discover.ts 的 isDirLike 同一条教训)
|
|
186
|
+
try {
|
|
187
|
+
isDir = statSync(full).isDirectory();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
isDir = false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!isDir)
|
|
194
|
+
continue;
|
|
195
|
+
if (registered.has(e.name))
|
|
196
|
+
names.add(e.name);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { names: [...names].sort(), warning: null };
|
|
200
|
+
}
|
|
201
|
+
/** 用户的 app 模块(import 缓存命中,重复 import 不重跑模块体) */
|
|
202
|
+
let appModule = null;
|
|
203
|
+
async function importAppModule() {
|
|
204
|
+
if (appModule)
|
|
205
|
+
return appModule;
|
|
206
|
+
const entry = join(root, APP_ENTRY_REL);
|
|
207
|
+
if (!existsSync(entry)) {
|
|
208
|
+
throw new Error(`找不到 ${APP_ENTRY_REL}。dev 环需要它导出 createAgentApp 工厂(装配与启动分离)——` +
|
|
209
|
+
'老工程按 CHANGELOG 的迁移说明,把 main.ts 里 createApp(...) 那一段整体搬进 app.ts 并包成工厂。');
|
|
210
|
+
}
|
|
211
|
+
const mod = (await import(pathToFileURL(entry).href));
|
|
212
|
+
if (typeof mod.createAgentApp !== 'function') {
|
|
213
|
+
throw new Error(`${APP_ENTRY_REL} 没有导出 createAgentApp(应为函数)。` +
|
|
214
|
+
'dev 环靠它拿到「接受选项的工厂」——四个控件都要从这里喂进去。');
|
|
215
|
+
}
|
|
216
|
+
appModule = mod;
|
|
217
|
+
return mod;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 启动期**验一次会话文件**(只有导出 `createSessionStore` 的工程才有这件东西)。
|
|
221
|
+
*
|
|
222
|
+
* 为什么必须有人验:`FileSessionStore` 对损坏的 JSON 是**响亮抛错**的(注释里承诺
|
|
223
|
+
* 「不静默当成空历史」),但框架在 `runtime/run.ts` 的 `loadSession` / `appendSession`
|
|
224
|
+
* 里**刻意吞掉**会话侧异常(既定口径:辅助动作不击穿 run;memory 水合同款防护,有单测钉着)。
|
|
225
|
+
* 两条纪律各自都对,合起来的后果却是最坏的一种:文件一坏,历史被当成「第一轮」,
|
|
226
|
+
* 而 `append` 里的 `readAll()` 同样抛 ⇒ **新历史永远写不进去**,且从头到尾一声不响。
|
|
227
|
+
* ⇒ 在 dev 环(唯一能让开发者当场看见的地方)启动期主动读一次,把原因送上 warning 通道。
|
|
228
|
+
*
|
|
229
|
+
* 只做一次、只在启动期:run 期间不再重复读(那是 store 自己的活)。
|
|
230
|
+
* 用一次性 id 触发即可 —— `readAll()` 读的是**整份**文件,id 是什么不影响解析。
|
|
231
|
+
*/
|
|
232
|
+
async function probeSessionFile(mod) {
|
|
233
|
+
if (typeof mod.createSessionStore !== 'function')
|
|
234
|
+
return null;
|
|
235
|
+
try {
|
|
236
|
+
await mod.createSessionStore().load('__dev_startup_probe__');
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
return (`会话文件读不出来(${e.message})⇒ 对话历史会被当成「第一轮」,` +
|
|
241
|
+
'而且之后的每一轮都写不进去(框架刻意不让会话故障打断 run,所以它不会自己报)。' +
|
|
242
|
+
'按提示删掉那个文件即可重新开始一段干净的对话。');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function build() {
|
|
246
|
+
const mod = await importAppModule();
|
|
247
|
+
const { config, warning: configWarning } = await loadDevConfig();
|
|
248
|
+
const budget = { ...DEFAULT_BUDGET, ...(config.budget ?? {}) };
|
|
249
|
+
const toolSources = parseToolSources(process.env.AGENTIA_DEV_TOOL_SOURCES);
|
|
250
|
+
// 缺省工作目录:dev.config 说了算,否则项目根。
|
|
251
|
+
// ⚠️ **不**用 chdir 实现它(D3 已排除 A 方案):项目根与 agent 工作目录是两件事,
|
|
252
|
+
// 而 `.env` / discover 相对路径都按 cwd 解析 —— 搬 cwd 会把它们一起搬走。
|
|
253
|
+
const workdir = config.workdir ?? root;
|
|
254
|
+
const app = await mod.createAgentApp({
|
|
255
|
+
...(toolSources === null ? {} : { toolSources }),
|
|
256
|
+
workdir,
|
|
257
|
+
});
|
|
258
|
+
const { names, warning: menuWarning } = listCapabilities(app, mod.CAPABILITY_DIRS);
|
|
259
|
+
const sessionWarning = await probeSessionFile(mod);
|
|
260
|
+
const warnings = [configWarning, menuWarning, sessionWarning].filter((w) => w !== null);
|
|
261
|
+
return {
|
|
262
|
+
app,
|
|
263
|
+
capabilities: names,
|
|
264
|
+
multiTurn: config.multiTurn ?? [],
|
|
265
|
+
warning: warnings.length > 0 ? warnings.join('\n') : null,
|
|
266
|
+
workdir,
|
|
267
|
+
budget,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/** 父进程经 env 传进来的能力选择(JSON 数组);解析不了就当全量 + 告警 */
|
|
271
|
+
function parseToolSources(raw) {
|
|
272
|
+
if (!raw)
|
|
273
|
+
return null;
|
|
274
|
+
try {
|
|
275
|
+
const v = JSON.parse(raw);
|
|
276
|
+
if (Array.isArray(v) && v.every((x) => typeof x === 'string') && v.length > 0) {
|
|
277
|
+
return v;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
/* 落到下面的告警 */
|
|
282
|
+
}
|
|
283
|
+
console.warn('[agentia:dev-runner] AGENTIA_DEV_TOOL_SOURCES 解析失败,本次按全量装配');
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
// ---------- 主循环 ----------
|
|
287
|
+
/**
|
|
288
|
+
* 常驻进程内的 app 缓存。
|
|
289
|
+
*
|
|
290
|
+
* 为什么工作目录变了是**进程内重建**而不是重启进程(D8 的配套形状:文件夹只起新 run):
|
|
291
|
+
* 重启一次要 ~0.67 s(其中 ~0.5 s 是 node+tsx 启动本身),而进程内重建是毫秒级。
|
|
292
|
+
* 换**能力选择**则相反 —— 那条路走重启,理由是**资源回收口**而不是成本:
|
|
293
|
+
* 框架没有 `AgentApp.close()`,MCP 连接器由用户代码持有,进程内反复重建会攒孤儿子进程。
|
|
294
|
+
* 这两条不对称是刻意的,见计划 §D8 的第三轮补充。
|
|
295
|
+
*/
|
|
296
|
+
let current = null;
|
|
297
|
+
function appKey(workdir, toolSources) {
|
|
298
|
+
return JSON.stringify([toolSources, workdir]);
|
|
299
|
+
}
|
|
300
|
+
async function ensureApp(workdir, toolSources) {
|
|
301
|
+
const key = appKey(workdir, toolSources);
|
|
302
|
+
if (current && current.key === key)
|
|
303
|
+
return current.app;
|
|
304
|
+
const mod = await importAppModule();
|
|
305
|
+
const app = await mod.createAgentApp({
|
|
306
|
+
...(toolSources === null ? {} : { toolSources }),
|
|
307
|
+
workdir,
|
|
308
|
+
});
|
|
309
|
+
current = { key, app };
|
|
310
|
+
return app;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* 在飞 run 的 AbortController(同一时刻只有一个 run —— 面板侧 `running` 已挡住并发)。
|
|
314
|
+
*
|
|
315
|
+
* 为什么不是「父进程直接杀子进程」:框架的 `signal` 是**协作式**的,abort 之后
|
|
316
|
+
* run 以 `stopReason='aborted'` **正常返回**(不抛)⇒ trace 照常落盘。
|
|
317
|
+
* 杀进程会把这次 run 的 trace 整个丢掉(trace 是收尾才 POST 的)。
|
|
318
|
+
*/
|
|
319
|
+
let currentAbort = null;
|
|
320
|
+
/**
|
|
321
|
+
* 「已经收到 `run`、还没收尾」—— 含 `currentAbort` **还没建出来**的那段窗口。
|
|
322
|
+
*
|
|
323
|
+
* 为什么要单独一个标志(而不是只看 `currentAbort`):`runOnce` 的顺序是
|
|
324
|
+
* `send('run-start')` → `await ensureApp(...)` → 才 `new AbortController()`。窗口内到达的
|
|
325
|
+
* `run-abort` 只能看到 `currentAbort === null`,旧写法据此把它当「没在跑」**静默丢掉**
|
|
326
|
+
* ⇒ run 照跑,而父进程 `ABORT_GRACE_MS`(5 s)到点会把这次**健康的** run 升级成
|
|
327
|
+
* 「重启兜底」:trace 丢掉、`lastError` 写成「工具不响应 signal」(一个错误的归因)。
|
|
328
|
+
*/
|
|
329
|
+
let runInFlight = false;
|
|
330
|
+
/** 窗口内到达的中止请求:建完 controller 立刻补一次 `abort()`(见 `runOnce` 的 await 顺序) */
|
|
331
|
+
let abortRequested = false;
|
|
332
|
+
async function runOnce(msg) {
|
|
333
|
+
const { request } = msg;
|
|
334
|
+
send({ type: 'run-start' });
|
|
335
|
+
// 从这一行起就算「在飞」—— `run-abort` 在这个窗口里必须被记住(见 runInFlight)
|
|
336
|
+
runInFlight = true;
|
|
337
|
+
const app = await ensureApp(request.workdir, request.toolSources);
|
|
338
|
+
const { config } = await loadDevConfig();
|
|
339
|
+
const budget = { ...DEFAULT_BUDGET, ...(config.budget ?? {}) };
|
|
340
|
+
const ac = new AbortController();
|
|
341
|
+
currentAbort = ac;
|
|
342
|
+
// 装配窗口内来过的中止:此刻补上。引擎在已 abort 的 signal 上会立刻以
|
|
343
|
+
// `stopReason='aborted'` 收尾 ⇒ **trace 照样落盘**(这正是「不杀进程」换来的东西)
|
|
344
|
+
if (abortRequested) {
|
|
345
|
+
abortRequested = false;
|
|
346
|
+
ac.abort();
|
|
347
|
+
}
|
|
348
|
+
const opts = {
|
|
349
|
+
maxCostUsd: budget.maxCostUsd,
|
|
350
|
+
maxTotalTokens: budget.maxTotalTokens,
|
|
351
|
+
signal: ac.signal,
|
|
352
|
+
};
|
|
353
|
+
try {
|
|
354
|
+
if (request.multiTurn) {
|
|
355
|
+
const mod = await importAppModule();
|
|
356
|
+
if (typeof mod.createSessionStore !== 'function') {
|
|
357
|
+
throw new Error('开了多轮,但 src/app.ts 没有导出 createSessionStore() —— ' +
|
|
358
|
+
'对话历史需要它(模板生成的是文件后端,跨进程重启接得上)。');
|
|
359
|
+
}
|
|
360
|
+
// 会话 id 由**父进程**给(「清空对话」换的就是它)—— 不在这里就地写常量,
|
|
361
|
+
// 否则面板换完 id、runner 还写旧的那本账。
|
|
362
|
+
opts.session = { store: mod.createSessionStore(), id: request.sessionId };
|
|
363
|
+
}
|
|
364
|
+
const { result } = await app.run([{ role: 'user', content: request.prompt }], opts);
|
|
365
|
+
send({
|
|
366
|
+
type: 'run-done',
|
|
367
|
+
traceId: result.trace?.traceId ?? null,
|
|
368
|
+
ok: !result.error,
|
|
369
|
+
stopReason: result.stopReason,
|
|
370
|
+
error: result.error ? result.error.message : null,
|
|
371
|
+
finalText: result.finalText ?? '',
|
|
372
|
+
});
|
|
373
|
+
// 与旧的 `tsx watch src/main.ts` 一致:回复正文打一份到终端(stdout 是 inherit 的)
|
|
374
|
+
if (result.finalText)
|
|
375
|
+
console.log(result.finalText);
|
|
376
|
+
if (result.error) {
|
|
377
|
+
// 中止也带结构化 error(引擎刻意如此:取消不是失败,但原因要可查)——
|
|
378
|
+
// 在终端把它打成「run 失败」会让人以为是自己把工程改坏了
|
|
379
|
+
if (result.stopReason === 'aborted')
|
|
380
|
+
console.error('[agentia] run 已中止(面板点的中止)');
|
|
381
|
+
else
|
|
382
|
+
console.error(`run 失败(stopReason=${result.stopReason}):${result.error.message}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
// 只清自己那一个:并发被面板挡住了,但 abort 与收尾之间仍可能插进下一次 run
|
|
387
|
+
if (currentAbort === ac)
|
|
388
|
+
currentAbort = null;
|
|
389
|
+
runInFlight = false;
|
|
390
|
+
abortRequested = false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async function main() {
|
|
394
|
+
// ⚠️ 顺序不能换:sink 必须早于用户 app 的 import(createApp 构造期对 defaultSinks 快照)
|
|
395
|
+
await registerTraceSink();
|
|
396
|
+
let built;
|
|
397
|
+
try {
|
|
398
|
+
built = await build();
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
// 装配失败不是「run 失败」:dev 环本身坏了,面板要能把原因显示出来(不静默)
|
|
402
|
+
send({ type: 'run-error', message: e.message });
|
|
403
|
+
console.error(`[agentia] dev runner 装配失败:${e.message}`);
|
|
404
|
+
process.exitCode = 1;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const toolSources = parseToolSources(process.env.AGENTIA_DEV_TOOL_SOURCES);
|
|
408
|
+
current = { key: appKey(built.workdir, toolSources), app: built.app };
|
|
409
|
+
send({
|
|
410
|
+
type: 'ready',
|
|
411
|
+
capabilities: built.capabilities,
|
|
412
|
+
multiTurn: built.multiTurn,
|
|
413
|
+
defaultWorkdir: built.workdir,
|
|
414
|
+
budget: built.budget,
|
|
415
|
+
warning: built.warning,
|
|
416
|
+
});
|
|
417
|
+
process.on('message', (raw) => {
|
|
418
|
+
const msg = raw;
|
|
419
|
+
if (msg?.type === 'shutdown') {
|
|
420
|
+
process.exit(0);
|
|
421
|
+
}
|
|
422
|
+
if (msg?.type === 'run-abort') {
|
|
423
|
+
// 两条:controller 已建 ⇒ 直接 abort;还在装配窗口里 ⇒ 记下来、建完立刻补
|
|
424
|
+
// (父进程已挡住「没在飞还发中止」,这里是第二道 —— 但它**不能**把在飞的请求丢掉)
|
|
425
|
+
if (currentAbort)
|
|
426
|
+
currentAbort.abort();
|
|
427
|
+
else if (runInFlight)
|
|
428
|
+
abortRequested = true;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (msg?.type === 'run') {
|
|
432
|
+
void runOnce(msg).catch((e) => {
|
|
433
|
+
// 装配/启动就失败时 `runOnce` 的 finally 还没轮到 ⇒ 在这里把在飞标志落下来,
|
|
434
|
+
// 否则下一个「中止」会被当成「上一个 run 的」记进 abortRequested(张冠李戴)
|
|
435
|
+
runInFlight = false;
|
|
436
|
+
abortRequested = false;
|
|
437
|
+
send({ type: 'run-error', message: e.message });
|
|
438
|
+
console.error(`[agentia] run 起不来:${e.message}`);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
// 父进程没了就跟着走(避免留下孤儿 runner 占着端口)
|
|
443
|
+
process.on('disconnect', () => process.exit(0));
|
|
444
|
+
}
|
|
445
|
+
void main();
|