@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,248 @@
|
|
|
1
|
+
// ---------- 能力多选(D8) ----------
|
|
2
|
+
/**
|
|
3
|
+
* 多选结果 → `AppOptions.toolSources` 的实参。
|
|
4
|
+
*
|
|
5
|
+
* 三条口径(都来自 D8「形态已定 · ①多选」):
|
|
6
|
+
* - **全选 ⇒ `undefined`**:与「不传即全量」同义,且省掉一次白跑的孤儿能力告警
|
|
7
|
+
* (`module.ts` 的 `if (opts.toolSources)` 分支)。
|
|
8
|
+
* - **固定顺序**(字典序),不用点击顺序 —— 调试要可复现,否则同一组能力两次跑出
|
|
9
|
+
* 不同菜单序。
|
|
10
|
+
* - **空集也当全量**:`toolSources: []` 会收窄到**空菜单**(每个 provider 都成了孤儿),
|
|
11
|
+
* 是个纯陷阱值;UI 另外禁止「取消最后一个」,这里兜住程序性调用。
|
|
12
|
+
*
|
|
13
|
+
* 不在 `all` 里的名字(能力被删了、面板缓存旧了)**静默丢弃**——它不该让整次 run 失败,
|
|
14
|
+
* 但也**不是**无声的:调用方拿到的是「归一化之后」的数组,面板据此回写选中态。
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeToolSources(selected, all) {
|
|
17
|
+
const known = new Set(all);
|
|
18
|
+
const picked = [...new Set(selected)].filter((s) => known.has(s)).sort();
|
|
19
|
+
if (picked.length === 0 || picked.length === all.length)
|
|
20
|
+
return undefined;
|
|
21
|
+
return picked;
|
|
22
|
+
}
|
|
23
|
+
/** 「已选/总数」计数(D7:`3/3` 一眼看出这次是全量还是收窄,不是装饰) */
|
|
24
|
+
export function capabilityBadge(selected, all) {
|
|
25
|
+
return `${selected.length}/${all.length}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 多轮开关的初始值。
|
|
29
|
+
*
|
|
30
|
+
* 多选之后必然出现「`trip-planner`(多轮)+ `code-review`(单轮)同选」,
|
|
31
|
+
* 此时默认值**有歧义** —— 规则是 **OR**(只要有一个声明多轮就默认多轮),
|
|
32
|
+
* 并把**来源**一并返回,好让面板标出来(`多轮·trip-planner`)。
|
|
33
|
+
*
|
|
34
|
+
* 本仓一贯的偏好:**不要静默行为**。宁可多显示一句来源,也不要让用户面对
|
|
35
|
+
* 一个「为什么这次带了上下文」的谜。
|
|
36
|
+
*/
|
|
37
|
+
export function multiTurnDefault(selected, declared) {
|
|
38
|
+
const decl = new Set(declared);
|
|
39
|
+
const sources = [...new Set(selected)].filter((s) => decl.has(s)).sort();
|
|
40
|
+
return { value: sources.length > 0, sources };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 开关文案。**必须说清「现在是几轮」和「为什么」** —— 混选时默认值有歧义,
|
|
44
|
+
* 而一个没有来源标注的开关会让人面对「为什么这次带了上下文」的谜。
|
|
45
|
+
*
|
|
46
|
+
* - `多轮·trip-planner` —— 开关在默认位上,来源就是声明多轮的那个能力
|
|
47
|
+
* - `单轮(覆盖默认:trip-planner 声明多轮)` —— 用户手动关掉了(调试就是要试开/关)
|
|
48
|
+
* - `多轮(手动开)` —— 没有任何能力声明多轮,用户自己开的
|
|
49
|
+
*/
|
|
50
|
+
export function multiTurnLabel(value, sources, isDefault) {
|
|
51
|
+
if (value) {
|
|
52
|
+
if (sources.length > 0)
|
|
53
|
+
return `多轮·${sources.join(',')}`;
|
|
54
|
+
return isDefault ? '多轮' : '多轮(手动开)';
|
|
55
|
+
}
|
|
56
|
+
if (sources.length > 0)
|
|
57
|
+
return `单轮(覆盖默认:${sources.join(',')} 声明多轮)`;
|
|
58
|
+
return '单轮';
|
|
59
|
+
}
|
|
60
|
+
// ---------- prompt 回显(§6 的 (乙)) ----------
|
|
61
|
+
/** 回显栈上限(与 shell 的 HISTSIZE 同量级;面板只保证「调得回最近这些条」) */
|
|
62
|
+
export const PROMPT_HISTORY_LIMIT = 50;
|
|
63
|
+
/**
|
|
64
|
+
* 把一条 prompt 压进回显栈。
|
|
65
|
+
*
|
|
66
|
+
* 口径照 shell 的 `HISTCONTROL=ignoredups`:**只与栈顶比**,连续重复不入栈
|
|
67
|
+
* (按两次回车不该占两格)。空白串直接丢弃。
|
|
68
|
+
*
|
|
69
|
+
* ⚠️ 这是 (乙) **prompt 回显**,不是 (甲) 对话历史:它只是把文字填回输入框,
|
|
70
|
+
* **模型看不见**(模型收到的仍是你最终回车的那句),所以永远可用、零成本 ——
|
|
71
|
+
* 与「多轮开关」无关(§6 的两层读法)。
|
|
72
|
+
*/
|
|
73
|
+
export function pushPromptHistory(history, prompt, limit = PROMPT_HISTORY_LIMIT) {
|
|
74
|
+
const p = prompt.trim();
|
|
75
|
+
if (p.length === 0)
|
|
76
|
+
return history;
|
|
77
|
+
if (history[history.length - 1] === p)
|
|
78
|
+
return history;
|
|
79
|
+
const next = [...history, p];
|
|
80
|
+
return next.length > limit ? next.slice(next.length - limit) : next;
|
|
81
|
+
}
|
|
82
|
+
/** 游标停在草稿(当前输入框内容)上 */
|
|
83
|
+
export const HISTORY_DRAFT = -1;
|
|
84
|
+
/**
|
|
85
|
+
* ↑/↓ 的游标推进。游标语义 = **距栈尾的距离**:`-1` 是草稿、`0` 是最新一条、
|
|
86
|
+
* `n-1` 是最旧一条。`older`(↑)加一、`newer`(↓)减一,两端夹住不绕回
|
|
87
|
+
* —— 绕回会在按过头时把用户送回一个他没想到的位置。
|
|
88
|
+
*/
|
|
89
|
+
export function historyStep(history, cursor, dir) {
|
|
90
|
+
if (history.length === 0)
|
|
91
|
+
return HISTORY_DRAFT;
|
|
92
|
+
const next = cursor + (dir === 'older' ? 1 : -1);
|
|
93
|
+
if (next < HISTORY_DRAFT)
|
|
94
|
+
return HISTORY_DRAFT;
|
|
95
|
+
if (next > history.length - 1)
|
|
96
|
+
return history.length - 1;
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
99
|
+
/** 游标处的文本(草稿位返回草稿本身 —— 按 ↓ 回到你还没发出去的那句) */
|
|
100
|
+
export function historyText(history, cursor, draft) {
|
|
101
|
+
if (cursor === HISTORY_DRAFT || history.length === 0)
|
|
102
|
+
return draft;
|
|
103
|
+
return history[history.length - 1 - cursor] ?? draft;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 这个 run 算不算**失败**?
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ 这是语义判别,不能在各处直接读 `ok` —— 引擎的 `abortedResult()` 刻意给
|
|
109
|
+
* 「被人中止」的 run 带上结构化 error(取消不是失败,但原因要可查,见 `loop-result.ts`),
|
|
110
|
+
* 于是 `ok === false`。只看 `ok` 的两个表面会一起说谎:
|
|
111
|
+
* - run 列表把它标成红点(用户会以为是自己把工程改坏了);
|
|
112
|
+
* - 对话视图给这一轮打红边「(这一轮失败了…)」,而同一个 run 的通知条写着「已中止」。
|
|
113
|
+
*
|
|
114
|
+
* 判别顺序(**先 stopReason 后 ok**)与 `runDoneNotice` 是同一条语义,钉在单测里。
|
|
115
|
+
*/
|
|
116
|
+
export function runIsFailure(r) {
|
|
117
|
+
if (r.stopReason === 'aborted')
|
|
118
|
+
return false;
|
|
119
|
+
return !r.ok;
|
|
120
|
+
}
|
|
121
|
+
/** `MessageParam.content` 可能是 string,也可能是 content block 数组 —— 只取文本块 */
|
|
122
|
+
export function textOfContent(content) {
|
|
123
|
+
if (typeof content === 'string')
|
|
124
|
+
return content;
|
|
125
|
+
if (!Array.isArray(content))
|
|
126
|
+
return '';
|
|
127
|
+
const parts = [];
|
|
128
|
+
for (const block of content) {
|
|
129
|
+
if (typeof block === 'string') {
|
|
130
|
+
parts.push(block);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (block && typeof block === 'object') {
|
|
134
|
+
const t = block.text;
|
|
135
|
+
if (typeof t === 'string')
|
|
136
|
+
parts.push(t);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return parts.join('\n');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 把「会话文件里的消息」与「run 列表里失败的那些 run」拼成对话流。
|
|
143
|
+
*
|
|
144
|
+
* 合并规则(D6 评审补充落成的形状):
|
|
145
|
+
*
|
|
146
|
+
* > 对话流的每一轮 = 「会话文件里的一条消息」**∪**
|
|
147
|
+
* > 「run 列表里 `session.id` 相同、但没有回写会话的(失败 / 被中止)run」
|
|
148
|
+
*
|
|
149
|
+
* 失败轮的去重键用 **prompt 文本**:会话文件里已经有同一句 user 消息(说明那轮
|
|
150
|
+
* 其实回写成功了)就不重复追加。这比按序号对更稳 —— 会话文件会被裁剪、也会被
|
|
151
|
+
* 手工改(那正是「面板显示一份不存在的对话」的入口,所以面板**只读**它)。
|
|
152
|
+
*
|
|
153
|
+
* ⚠️ **中止轮也走这条 join**(`failed: false` / `aborted: true`):框架只在**成功**路径
|
|
154
|
+
* 回写会话,所以被中止的那轮同样「会话文件里没有」;把它排除掉会让面板显示一份
|
|
155
|
+
* **少了一轮**的对话 —— 与「多了一轮」同类的静默不一致,只是更难发现。
|
|
156
|
+
* 但它绝不能像失败轮那样标红(判别走 `runIsFailure`)。
|
|
157
|
+
*/
|
|
158
|
+
export function mergeConversation(messages, runs, sessionId) {
|
|
159
|
+
const turns = [];
|
|
160
|
+
let current = null;
|
|
161
|
+
for (const m of messages) {
|
|
162
|
+
const text = textOfContent(m.content);
|
|
163
|
+
if (m.role === 'user') {
|
|
164
|
+
current = { user: text, assistant: null, failed: false, aborted: false, traceId: null };
|
|
165
|
+
turns.push(current);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (m.role === 'assistant' && current) {
|
|
169
|
+
current.assistant = current.assistant === null ? text : `${current.assistant}\n${text}`;
|
|
170
|
+
}
|
|
171
|
+
// 其它角色(tool_result 等)不进对话流:它是实现细节,不是「对话」
|
|
172
|
+
}
|
|
173
|
+
// 失败 / 中止轮:同 session、终态非成功、且没在会话文件里出现过的那次输入
|
|
174
|
+
const seen = new Set(turns.map((t) => t.user));
|
|
175
|
+
for (const r of runs) {
|
|
176
|
+
if (r.ok)
|
|
177
|
+
continue;
|
|
178
|
+
if ((r.sessionId ?? null) !== sessionId)
|
|
179
|
+
continue;
|
|
180
|
+
const prompt = (r.prompt ?? '').trim();
|
|
181
|
+
if (prompt.length === 0 || seen.has(prompt))
|
|
182
|
+
continue;
|
|
183
|
+
seen.add(prompt);
|
|
184
|
+
// 判别顺序(先 stopReason 后 ok)在 runIsFailure 里,与通知条同一条语义
|
|
185
|
+
const failed = runIsFailure(r);
|
|
186
|
+
turns.push({ user: prompt, assistant: null, failed, aborted: !failed, traceId: r.traceId });
|
|
187
|
+
}
|
|
188
|
+
return turns;
|
|
189
|
+
}
|
|
190
|
+
// ---------- CLI 侧记账(面板发出去的东西,CLI 自己记一笔) ----------
|
|
191
|
+
/**
|
|
192
|
+
* 往 notes 表里记一条,并**按插入序**把表压在 `max` 以内。
|
|
193
|
+
*
|
|
194
|
+
* 为什么淘汰必须在这里(而不是只跟着 `runs`):`noteRun` 记的 traceId 可能
|
|
195
|
+
* **永远进不了** `runs` —— trace 是 run 收尾时经 `POST /ingest` 另一路投递的,
|
|
196
|
+
* sink 失败 / agent 进程被杀就没有那条 trace,这条 note 便无人认领。而 dev 是**长跑**
|
|
197
|
+
* 进程(一次调试会话里反复运行)⇒ 无人认领的 note 只涨不降。
|
|
198
|
+
*
|
|
199
|
+
* ⚠️ 旧实现把淘汰只挂在「traceId 进 `runs`」那一条路上,注释却写着「与 runs 同步淘汰」
|
|
200
|
+
* —— 「注释与事实不符」本身也是本仓在猎的东西,所以这段落到有单测的 panel-logic 里。
|
|
201
|
+
*/
|
|
202
|
+
export function rememberNote(notes, traceId, note, max) {
|
|
203
|
+
// 重新 set 同一个 key 不改变插入序(Map 语义)⇒ 不需要先 delete 再 set
|
|
204
|
+
notes.set(traceId, note);
|
|
205
|
+
while (notes.size > max) {
|
|
206
|
+
const oldest = notes.keys().next().value;
|
|
207
|
+
if (oldest === undefined)
|
|
208
|
+
break;
|
|
209
|
+
notes.delete(oldest);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// ---------- run 收尾的反馈语 ----------
|
|
213
|
+
/**
|
|
214
|
+
* `run-done` → 面板底部那句操作反馈。
|
|
215
|
+
*
|
|
216
|
+
* 为什么这是一条**规则**而不是 HTML 里的三行 if(那份没有单测):判别顺序是语义。
|
|
217
|
+
* 引擎的 `abortedResult()` **刻意**给已取消的 run 带上结构化 error(取消不是失败,
|
|
218
|
+
* 但原因要可查)⇒ 中止时 `ok` 也是 **false**。所以必须**先**认 `stopReason`:
|
|
219
|
+
* 反过来先看 `ok`,用户按的「中止」就会被显示成一句吓人的「run 失败」。
|
|
220
|
+
* 这个顺序错一次很难在 code review 里看出来,所以钉在单测里。
|
|
221
|
+
*/
|
|
222
|
+
export function runDoneNotice(ev) {
|
|
223
|
+
if (ev.stopReason === 'aborted') {
|
|
224
|
+
return { text: 'run 已中止(trace 保住了)', isError: false };
|
|
225
|
+
}
|
|
226
|
+
if (!ev.ok) {
|
|
227
|
+
return { text: `run 失败(${ev.stopReason}):${ev.error || '未知原因'}`, isError: true };
|
|
228
|
+
}
|
|
229
|
+
return { text: `run 完成(${ev.stopReason})`, isError: false };
|
|
230
|
+
}
|
|
231
|
+
// ---------- 路径显示 ----------
|
|
232
|
+
/**
|
|
233
|
+
* 把绝对路径压短给面板看(`~/proj/a`)。
|
|
234
|
+
*
|
|
235
|
+
* 只影响**显示**:发给子进程的永远是完整绝对路径(工具按它解析),
|
|
236
|
+
* 所以压短不会让「我以为在 A,其实在 B」发生 —— 那正是 D5 要防的时刻。
|
|
237
|
+
*/
|
|
238
|
+
export function shortenPath(abs, opts = {}) {
|
|
239
|
+
if (abs.length === 0)
|
|
240
|
+
return '';
|
|
241
|
+
const home = opts.home ?? '';
|
|
242
|
+
const cwd = opts.cwd ?? '';
|
|
243
|
+
if (home && (abs === home || abs.startsWith(`${home}/`)))
|
|
244
|
+
return `~${abs.slice(home.length)}`;
|
|
245
|
+
if (cwd && (abs === cwd || abs.startsWith(`${cwd}/`)))
|
|
246
|
+
return `.${abs.slice(cwd.length)}`;
|
|
247
|
+
return abs;
|
|
248
|
+
}
|
package/dist/registry.js
CHANGED
|
@@ -29,14 +29,21 @@ function insertAtMarkers(content, importLine, entryLine) {
|
|
|
29
29
|
function importPrefix(type) {
|
|
30
30
|
return './' + CAPABILITY_DIRS[type].replace(/^src\//, '');
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* 把能力登记进 src/registry.ts;已存在同名条目则跳过。标记行缺失时报错。
|
|
34
|
+
*
|
|
35
|
+
* `deps` 是给「构造期需要注入值」的能力用的(先例:脚手架的 `read-file` 要 `WORKDIR`)。
|
|
36
|
+
* `discover` 自动注册的 provider **没有 deps**(`discover.ts` 只产出 `{ provide, useClass }`),
|
|
37
|
+
* 所以这类能力必须走显式注册 —— 显式装配路线本来就该能表达「这个能力的根从哪来」。
|
|
38
|
+
*/
|
|
39
|
+
export function registerCapability(dir, name, type, deps = []) {
|
|
34
40
|
const file = ensureRegistry(dir);
|
|
35
41
|
const content = readFileSync(file, 'utf8');
|
|
36
42
|
const prefix = importPrefix(type);
|
|
37
43
|
const source = `${prefix}/${name}/index.js`;
|
|
38
44
|
const importLine = `import ${kebabToPascal(name)} from '${source}';`;
|
|
39
|
-
const
|
|
45
|
+
const depsPart = deps.length > 0 ? `, deps: [${deps.map((d) => `'${d}'`).join(', ')}]` : '';
|
|
46
|
+
const entryLine = ` { provide: '${name}', useClass: ${kebabToPascal(name)}${depsPart} },`;
|
|
40
47
|
if (content.includes(`'${source}'`) || content.includes(`provide: '${name}'`)) {
|
|
41
48
|
return 'already';
|
|
42
49
|
}
|
package/dist/templates/README.md
CHANGED
|
@@ -15,11 +15,26 @@
|
|
|
15
15
|
|
|
16
16
|
## 两条装配路线
|
|
17
17
|
|
|
18
|
-
1. **目录扫描**:`createApp({ discover: [...] })` 启动期按给定顺序扫各目录下的 `<name>/index.ts`,default export 为类时以文件夹名为 DI token 注册(见 `src/
|
|
18
|
+
1. **目录扫描**:`createApp({ discover: [...] })` 启动期按给定顺序扫各目录下的 `<name>/index.ts`,default export 为类时以文件夹名为 DI token 注册(见 `src/app.ts`)。
|
|
19
19
|
2. **显式装配**:`createApp({ providers, system })`,providers 来自 `src/registry.ts` 注册表(由 `agentia g` 自动维护,也可手工编辑)。
|
|
20
20
|
|
|
21
21
|
两者二选一或混用。
|
|
22
22
|
|
|
23
|
+
## 文件分工(**装配与启动是分开的**)
|
|
24
|
+
|
|
25
|
+
| 文件 | 作用 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `src/app.ts` | **装配**:导出 `createAgentApp({ toolSources?, workdir? })` 工厂 + `CAPABILITY_DIRS` + `createSessionStore()`;`.env` 也在这里读 |
|
|
28
|
+
| `src/main.ts` | **启动**:薄入口 —— 调工厂 → `app.run(...)` → 处理 `result.error` |
|
|
29
|
+
| `src/dev.config.ts` | 开发期**数据**声明(`multiTurn` / `budget` / `workdir`);只有 `agentia dev` 读它 |
|
|
30
|
+
| `src/session-store.ts` | 多轮的会话后端(`FileSessionStore`,落盘 `.agentia/session.json`) |
|
|
31
|
+
| `src/registry.ts` | 显式注册表(`agentia g` 自动维护) |
|
|
32
|
+
|
|
33
|
+
**为什么拆**:`agentia dev` 要**复用同一个工厂**才能把「这次调哪个能力 / 工作目录是哪个」喂进
|
|
34
|
+
`createApp`。所以装配必须以**函数**形态待在 `app.ts` 里 —— 别把 `createApp(...)` 搬回 `main.ts`:
|
|
35
|
+
搬回去 dev 环就起不来(`agentia dev` 会直接报错并给出迁移方法,不会静默降级成一个「面板能用但
|
|
36
|
+
什么都驱动不了」的空壳)。
|
|
37
|
+
|
|
23
38
|
## 生成能力
|
|
24
39
|
|
|
25
40
|
```bash
|
|
@@ -58,5 +73,6 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
58
73
|
npm run dev -- "你的问题"
|
|
59
74
|
```
|
|
60
75
|
|
|
61
|
-
`.env` 由 `src/
|
|
76
|
+
`.env` 由 `src/app.ts` 的 `loadEnvFile()` 读取(放在装配模块里,`npm run dev` 与 `npm start`
|
|
77
|
+
两个入口才都会读到)。框架**不会自动读**它 ——
|
|
62
78
|
读哪个文件、什么时候读由你的启动代码决定(这样「换目录跑」不会悄悄改变行为)。
|
|
@@ -10,7 +10,11 @@ export default class __CLASS_NAME__ {
|
|
|
10
10
|
required: ['task'],
|
|
11
11
|
additionalProperties: false,
|
|
12
12
|
},
|
|
13
|
-
|
|
13
|
+
// ⚠️ 写成**函数形态**(`() => asset(...)`)不是风格问题:值形态在**类定义时**求值,
|
|
14
|
+
// 于是 system.md 的改动要重启进程才生效(而 `.md` 不在 tsx 的 import 图里,
|
|
15
|
+
// 连「改了要重启」都不会提示你 —— 是个静默失效)。函数形态每次调用重新读文件。
|
|
16
|
+
// 同一条通则管着工具的根目录:**在模块加载期读 = 冻;在调用期读 = 热**。
|
|
17
|
+
system: () => asset(import.meta.url, './system.md'),
|
|
14
18
|
})
|
|
15
19
|
// 方法体不会执行:@SubAgent 只读取方法名与装饰器元数据,调用时由框架按 system 另起 agent 执行
|
|
16
20
|
__METHOD_NAME__(_input: { task: string }): void {}
|
|
@@ -1,3 +1,18 @@
|
|
|
1
1
|
# __NAME__
|
|
2
2
|
|
|
3
3
|
在此编写子代理的角色描述(作为其子 agent 的 system prompt)。
|
|
4
|
+
|
|
5
|
+
## 关于「你的回复就是报告」
|
|
6
|
+
|
|
7
|
+
你运行在**独立上下文**里:中间过程不外传,你的**最终回复会原样交回主 agent** 当作报告
|
|
8
|
+
(主 agent 看不到你读过哪些文件、调过哪些工具,只看得到这段文字)。所以:
|
|
9
|
+
|
|
10
|
+
- 结论、依据、以及可执行的下一步都写进最终回复,别只写「已完成」;
|
|
11
|
+
- 不要写「刚才那一步」「上面」这类指代 —— 报告必须自解释;
|
|
12
|
+
- 不确定的地方明说不确定,不要用自信的语气盖过去。
|
|
13
|
+
|
|
14
|
+
⚠️ 这句约定**必须由本文件自己写**,不能指望框架补:框架只对 `spec.system` 的
|
|
15
|
+
**值形态**自动追加 `REPORT_HINT`(见 `src/toolkit/subagent.ts`),**函数形态不追加**
|
|
16
|
+
(函数形态把 system 的决定权整个交给你)。本模板刻意用函数形态 —— 为的是改这份 `.md`
|
|
17
|
+
立刻生效、不必重启进程(值形态在类定义时就把文件读死了)。删掉上面这段 ⇒
|
|
18
|
+
子代理不知道自己的回复就是交付物,会退化成「一句话交差」。
|
package/dist/templates/gitignore
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@migor/agentia": "^0.
|
|
12
|
+
"@migor/agentia": "^0.9.0"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"@migor/cli": "^0.
|
|
15
|
+
"@migor/cli": "^0.9.0",
|
|
16
16
|
"tsx": "^4.19.0",
|
|
17
17
|
"typescript": "^7.0.2",
|
|
18
18
|
"@types/node": "^22.0.0"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createApp, loadEnvFile, SystemPrompt } from '@migor/agentia';
|
|
5
|
+
import type { AgentApp, SessionStore } from '@migor/agentia';
|
|
6
|
+
import { FileSessionStore } from './session-store.js';
|
|
7
|
+
import ReadFileTool from './tools/read-file/index.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 应用装配 —— **与启动分离**(app.ts 只负责「造一个 app」,不负责「跑一次」)。
|
|
11
|
+
*
|
|
12
|
+
* 为什么分开:开发期的调试环(`agentia dev` 的面板)需要把四个控件喂进应用 ——
|
|
13
|
+
* 调哪个能力(`toolSources`)、工作目录(`workdir`)、要不要多轮、以及每次的 prompt。
|
|
14
|
+
* 前两个是 `createApp` 的选项、后两个是 `app.run` 的选项,**都是调用者才能设的东西**。
|
|
15
|
+
* 拆出这个工厂之后,`agentia dev` 就是调用者,于是**工程里一个 dev 文件都不需要**。
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ 别把 `createApp(...)` 直接写回 main.ts:那样 dev 环就拿不到这个工厂,
|
|
18
|
+
* `agentia dev` 会明确报错(不是静默降级)。
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ 进程外资源(MCP 连接器最典型;sqlite 句柄、定时器同理)必须在**模块作用域**创建、
|
|
21
|
+
* 用 `useValue` 注入给能力,**不许写在某个 provider 的 constructor 里**。理由:
|
|
22
|
+
* dev 环换能力选择时会重建 app,构造函数里建 ⇒ 资源跟 **app** 同寿 ⇒ 每次重建重 spawn
|
|
23
|
+
* (MCP 冷握手实测 78 ms)且旧子进程变孤儿(框架没有 `AgentApp.close()`)。
|
|
24
|
+
* 模块作用域建 ⇒ 资源跟**进程**同寿,重建只是重解析实例、不动连接。
|
|
25
|
+
* 形状见 docs/plans/2026-09-22-dev-debug-loop.md 的「(d) 附一条硬约定」。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 读项目根的 `.env`(key 写文件里即可,不必每次 export)。框架**不自动**读 `.env` ——
|
|
30
|
+
* 读哪个文件、什么时候读由这里决定;已存在的真实环境变量优先,不会被文件覆盖。
|
|
31
|
+
* 想换路径/顺序:`loadEnvFile({ path: '.env.local' })`,或直接删掉这一行改用自己的加载器。
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ 必须在 **app.ts(装配模块)**,不能在 main.ts:`agentia dev` 的 runner 只 import
|
|
34
|
+
* 本文件、**从不执行 main.ts**。放在 main.ts 里会让 `npm run dev` 静默读不到 `.env`,
|
|
35
|
+
* 而 `npm start` 读得到 —— 同一份 `.env` 两个行为,且失败的形状是「忘了配 key」。
|
|
36
|
+
*/
|
|
37
|
+
loadEnvFile();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 能力分类目录(**顺序即装配顺序**)。
|
|
41
|
+
*
|
|
42
|
+
* 这个常量同时是 `agentia dev` 的能力选择器的**菜单来源** —— dev runner 读它、
|
|
43
|
+
* `readdir` 这些目录即得能力 token 列表。两侧同一个常量,不会漂移。
|
|
44
|
+
* ⚠️ 别改回 cwd 相对写法(形如 `src/tools` 的字符串):那样 `node dist/main.js`
|
|
45
|
+
* 会去加载 src 下的 .ts 源码,而装饰器不是可擦除的类型语法,Node 直接跑不了。
|
|
46
|
+
*/
|
|
47
|
+
export const CAPABILITY_DIRS = ['tools', 'skills', 'prompts', 'subagents'];
|
|
48
|
+
|
|
49
|
+
/** 能力目录按**本文件位置**解析,不是 cwd:开发态(src/app.ts)→ src/<分类>/,构建后(dist/app.js)→ dist/<分类>/ */
|
|
50
|
+
function capabilityPaths(): string[] {
|
|
51
|
+
return CAPABILITY_DIRS.map((d) => fileURLToPath(new URL(`${d}/`, import.meta.url))).filter((dir) =>
|
|
52
|
+
// 空分类目录在构建后不存在(tsc 不为空目录产出 dist/<分类>/),而 discover 对
|
|
53
|
+
// 显式给出的不存在路径是报错的 ——「这类暂时没有能力」不该让启动失败。
|
|
54
|
+
existsSync(dir),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 项目根(= app.ts / app.js 的上一层)。生产里它是工具缺省看到的树 */
|
|
59
|
+
export const PROJECT_ROOT = resolve(fileURLToPath(import.meta.url), '../..');
|
|
60
|
+
|
|
61
|
+
/** 会话文件:整段对话历史(跨进程重启接得上)。已进 .gitignore */
|
|
62
|
+
export const SESSION_FILE = resolve(PROJECT_ROOT, '.agentia', 'session.json');
|
|
63
|
+
|
|
64
|
+
export interface CreateAgentAppOptions {
|
|
65
|
+
/** 只收集这些 token(能力文件夹名)上的能力;不传 = 全量。dev 环的「能力选择」喂这里 */
|
|
66
|
+
toolSources?: string[];
|
|
67
|
+
/** agent 的工作目录(工具读写数据的那棵树);不传 = 项目根 */
|
|
68
|
+
workdir?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function createAgentApp(opts: CreateAgentAppOptions = {}): Promise<AgentApp> {
|
|
72
|
+
return createApp({
|
|
73
|
+
name: '__PROJECT_NAME__',
|
|
74
|
+
discover: capabilityPaths(),
|
|
75
|
+
providers: [
|
|
76
|
+
// 工作目录以**值**注入给需要它的能力(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
|
+
{ provide: 'WORKDIR', useValue: opts.workdir ?? PROJECT_ROOT },
|
|
82
|
+
// 例子:把工作目录注入给 read-file 工具(脚手架自带的第二个能力,
|
|
83
|
+
// 也是「面板上的文件夹选择器真的生效」的那个接线)
|
|
84
|
+
{ provide: 'read-file', useClass: ReadFileTool, deps: ['WORKDIR'] },
|
|
85
|
+
],
|
|
86
|
+
// 收窄发生在 createApp 期:toolSources 是 token 级白名单,一次收窄顺带把装配期
|
|
87
|
+
// 静态校验与 prompts.versions 那本账一起收窄。不传 = 全量(缺省路径)。
|
|
88
|
+
...(opts.toolSources && opts.toolSources.length > 0 ? { toolSources: opts.toolSources } : {}),
|
|
89
|
+
system: new SystemPrompt().add(
|
|
90
|
+
'role',
|
|
91
|
+
'你是 __PROJECT_NAME__ 的主 agent,按任务自主调度菜单里的能力。',
|
|
92
|
+
true,
|
|
93
|
+
),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 对话历史后端(多轮用)。**只在开了多轮时才会被读到**(dev 环按需调用)——
|
|
99
|
+
* 单轮 run 不碰它,所以任务型能力(code-review 那种「每次独立、跨仓库会串味」的)
|
|
100
|
+
* 天然不受影响。
|
|
101
|
+
*
|
|
102
|
+
* 想换成别的后端(sqlite / Redis / 内存):改这里就行,`SessionStore` 只有两个方法。
|
|
103
|
+
*/
|
|
104
|
+
export function createSessionStore(): SessionStore {
|
|
105
|
+
return new FileSessionStore(SESSION_FILE);
|
|
106
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev 环的**数据**声明(只有数据,没有逻辑)—— `agentia dev` 存在才读,缺省零样板。
|
|
3
|
+
*
|
|
4
|
+
* 为什么是「数据文件」而不是「逻辑文件」:逻辑副本会**漂移**(CLI 修了 bug、加了能力,
|
|
5
|
+
* 工程里那份副本一个都不会变,而且没人会主动维护它),数据声明不会。
|
|
6
|
+
* ⇒ 规则:`dev.ts`(逻辑)不要;`dev.config.ts`(数据)可以。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ 这个文件**只影响开发期的调试环**,生产路径(`npm start` → dist/main.js)完全不读它。
|
|
9
|
+
* 所以「每个能力各自要不要多轮」这种只在调试时成立的知识,放这里最合适
|
|
10
|
+
* (生产里没有能力选择器:一次对话就是一个 session,模型在对话内自己路由)。
|
|
11
|
+
*/
|
|
12
|
+
export default {
|
|
13
|
+
/**
|
|
14
|
+
* 这些能力按**多轮**调试(累积 session,模型看得见前面的对话);未列出的按单轮。
|
|
15
|
+
*
|
|
16
|
+
* 为什么要按能力声明而不是一个全局开关:同一个工程里两种形态都要 ——
|
|
17
|
+
* 「行程规划」要(「改成 3 天」「住宿换民宿」是常态),
|
|
18
|
+
* 「code-review」「查天气」不要(每次独立,把上一个仓库的评审串进来会误导)。
|
|
19
|
+
*
|
|
20
|
+
* 面板上那个多轮开关的**初始值** = 所选能力声明的 **OR**(只要有一个声明多轮就默认多轮),
|
|
21
|
+
* 且开关上会标出来源(如「多轮·trip-planner」)—— 混选时默认值有歧义,
|
|
22
|
+
* 标注来源是为了不让你面对一个「为什么这次带了上下文」的谜。开关可以随时覆盖。
|
|
23
|
+
*
|
|
24
|
+
* 例:multiTurn: ['trip-planner'],
|
|
25
|
+
*/
|
|
26
|
+
multiTurn: [],
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 面板上「工作目录」的初始值。缺省 = 项目根。
|
|
30
|
+
* 它只是**初始值** —— 面板随时能改,改的是这次 run 让工具去看哪棵树。
|
|
31
|
+
* 例:workdir: '/Users/me/work/another-repo',
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 单次 run 的预算护栏(缺省 $1 / 200000 token)。
|
|
36
|
+
* 面板上「点一下就跑一次真 agent」,而真 agent 真花钱 —— 一个跑偏的循环就是一张账单。
|
|
37
|
+
* 这里可以按工程调;调小更安全,调大更放得开。
|
|
38
|
+
* 例:budget: { maxCostUsd: 5, maxTotalTokens: 1_000_000 },
|
|
39
|
+
*/
|
|
40
|
+
};
|
|
@@ -1,36 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { createApp, loadEnvFile, SystemPrompt } from '@migor/agentia';
|
|
1
|
+
import { createAgentApp } from './app.js';
|
|
4
2
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
3
|
+
// 装配在 app.ts 里(与启动分离):`agentia dev` 的调试环要复用同一个工厂,
|
|
4
|
+
// 才能把「能力选择 / 工作目录」喂进 createApp。别把 createApp(...) 搬回这里。
|
|
5
|
+
// `.env` 也由 app.ts 读 —— 那才是 `agentia dev` 与 `npm start` **都**会经过的那条路
|
|
6
|
+
// (本文件在 dev 环下根本不执行),写在这里会让两个入口的行为不一致。
|
|
7
|
+
const app = await createAgentApp();
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
// 构建后(dist/main.js)解析到 dist/<分类>/ —— 从任何目录启动都成立。
|
|
12
|
-
// ⚠️ 别改回 cwd 相对写法(形如 src/tools 的字符串):那样 `node dist/main.js` 会去加载 src 下的
|
|
13
|
-
// .ts 源码,而装饰器不是可擦除的类型语法,Node 直接跑不了("Invalid or unexpected token")。
|
|
14
|
-
// filter:空分类目录在构建后不存在(tsc 不为空目录产出 dist/<分类>/),而 discover 对
|
|
15
|
-
// 显式给出的不存在路径是报错的 —— 「这类暂时没有能力」不该让启动失败。
|
|
16
|
-
const CAPABILITY_DIRS = ['tools', 'skills', 'prompts', 'subagents'];
|
|
17
|
-
const app = await createApp({
|
|
18
|
-
name: '__PROJECT_NAME__',
|
|
19
|
-
discover: CAPABILITY_DIRS.map((d) => fileURLToPath(new URL(d + '/', import.meta.url))).filter(
|
|
20
|
-
(dir) => existsSync(dir),
|
|
21
|
-
),
|
|
22
|
-
system: new SystemPrompt().add('role', '你是 __PROJECT_NAME__ 的主 agent,按任务自主调度菜单里的能力。', true),
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
const { result } = await app.run(
|
|
26
|
-
[{ role: 'user', content: process.argv[2] ?? '介绍一下你自己' }],
|
|
27
|
-
);
|
|
9
|
+
const { result } = await app.run([{ role: 'user', content: process.argv[2] ?? '介绍一下你自己' }]);
|
|
28
10
|
|
|
29
11
|
// 注意:run 失败**不会抛**(硬失败被记进 result.error 与 trace 后正常返回)—— 不显式检查就会
|
|
30
12
|
// 「打印一行空白 + 退出 0」,让首次运行(比如忘了配 ANTHROPIC_API_KEY)看起来像成功。
|
|
31
13
|
if (result.error) {
|
|
32
14
|
console.error(`run 失败(stopReason=${result.stopReason}):${result.error.message}`);
|
|
33
|
-
console.error('提示:模型调用读 ANTHROPIC_API_KEY —— 填进 .env
|
|
15
|
+
console.error('提示:模型调用读 ANTHROPIC_API_KEY —— 填进 .env(src/app.ts 的 loadEnvFile() 会读)或 export 均可;');
|
|
34
16
|
console.error(' 换端点 / 注入自定义 client 见项目内 AGENTS.md。');
|
|
35
17
|
process.exitCode = 1;
|
|
36
18
|
}
|
|
@@ -5,6 +5,9 @@ import type { Provider } from '@migor/agentia';
|
|
|
5
5
|
|
|
6
6
|
/** 显式装配路线:createApp({ providers, system: ... })(与 discover 目录扫描二选一或混用) */
|
|
7
7
|
export const providers: Provider[] = [
|
|
8
|
+
// 「工作目录」以**值**注入给需要它的能力(消费方式见 src/tools/read-file/index.ts)。
|
|
9
|
+
// 想改成固定路径 / 从环境变量取,改这一行即可 —— 这是显式装配路线的意义。
|
|
10
|
+
{ provide: 'WORKDIR', useValue: process.cwd() },
|
|
8
11
|
// @agentia:entries
|
|
9
12
|
// @agentia:entries-end
|
|
10
13
|
];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import type { MessageParam, SessionStore } from '@migor/agentia';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 文件后端 `SessionStore`:整段对话历史存在一个 JSON 文件里。
|
|
7
|
+
*
|
|
8
|
+
* 为什么需要文件后端:框架自带的 `InMemorySessionStore` 是**进程内**的,重启即丢。
|
|
9
|
+
* 而 dev 环(以及任何重启式宿主)每次 run 都可能换进程 —— 对话历史要接得上,
|
|
10
|
+
* 就必须落盘。这个类只有 ~30 行,放在模板里(而不是框架里):它是**可选件**,
|
|
11
|
+
* 只有对话型能力用得上,而放模板里用户能直接改(换 sqlite / Redis 只改这一个文件)。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ 与 `MemoryStore` 不是一回事:那是键值**黑板**(存「状态与事实」),
|
|
14
|
+
* 这是**对话历史**(存 messages 序列)。两者正交,可以同时用。
|
|
15
|
+
*/
|
|
16
|
+
export class FileSessionStore implements SessionStore {
|
|
17
|
+
constructor(private readonly file: string) {}
|
|
18
|
+
|
|
19
|
+
load(sessionId: string): MessageParam[] {
|
|
20
|
+
const cur = this.readAll()[sessionId];
|
|
21
|
+
// 返回浅拷贝:run 会把结果拼进自己的 messages 数组并继续 push,
|
|
22
|
+
// 直接把内部数组交出去会让那些 push 反向污染 store 里的历史。
|
|
23
|
+
return Array.isArray(cur) ? [...cur] : [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
append(sessionId: string, messages: MessageParam[]): void {
|
|
27
|
+
const all = this.readAll();
|
|
28
|
+
const cur = all[sessionId] ?? [];
|
|
29
|
+
for (const m of messages) cur.push(m);
|
|
30
|
+
all[sessionId] = cur;
|
|
31
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
32
|
+
// 原子写:先写同目录的临时文件再 rename。直接覆写的话,进程在写一半时被杀
|
|
33
|
+
// 会留下半截 JSON,下一次 load 直接抛 —— 整段历史因为一次崩溃而不可读。
|
|
34
|
+
const tmp = `${this.file}.tmp`;
|
|
35
|
+
writeFileSync(tmp, JSON.stringify({ sessions: all }, null, 2), 'utf8');
|
|
36
|
+
renameSync(tmp, this.file);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 读整份文件。文件不存在 = 还没有历史(空)。
|
|
41
|
+
* 解析失败**响亮报错**而不是当作空历史 —— 静默当成空会把「历史读不出来」
|
|
42
|
+
* 伪装成「这是第一轮」,然后下一轮 append 再把它覆盖掉:整段对话无声消失。
|
|
43
|
+
*/
|
|
44
|
+
private readAll(): Record<string, MessageParam[]> {
|
|
45
|
+
let raw: string;
|
|
46
|
+
try {
|
|
47
|
+
raw = readFileSync(this.file, 'utf8');
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
|
50
|
+
throw new Error(`读会话文件失败(${this.file}):${(e as Error).message}`);
|
|
51
|
+
}
|
|
52
|
+
if (raw.trim().length === 0) return {};
|
|
53
|
+
let parsed: unknown;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(raw);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`会话文件不是合法 JSON(${this.file}):${(e as Error).message}\n` +
|
|
59
|
+
'它是本地调试产物(已进 .gitignore),删掉它就能重新开始一段干净的对话。',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const sessions = (parsed as { sessions?: unknown }).sessions;
|
|
63
|
+
if (!sessions || typeof sessions !== 'object' || Array.isArray(sessions)) {
|
|
64
|
+
throw new Error(`会话文件结构不对(${this.file}):应为 { "sessions": { "<id>": [...] } }`);
|
|
65
|
+
}
|
|
66
|
+
return sessions as Record<string, MessageParam[]>;
|
|
67
|
+
}
|
|
68
|
+
}
|