mocode-ai 0.1.3 → 0.1.5
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/README.md +196 -161
- package/dist/agent/core.js +302 -0
- package/dist/agent/index.js +85 -202
- package/dist/agent/mode.js +46 -0
- package/dist/agent/spawn.js +123 -0
- package/dist/commands/config.js +8 -26
- package/dist/config/file.js +48 -0
- package/dist/config/index.js +76 -22
- package/dist/llm/index.js +11 -9
- package/dist/memory/discover.js +49 -0
- package/dist/memory/index.js +45 -0
- package/dist/memory/reflect.js +265 -0
- package/dist/memory/store.js +339 -0
- package/dist/repl/index.js +334 -87
- package/dist/session/compact.js +51 -3
- package/dist/skills/index.js +2 -2
- package/dist/tools/builtins/ask-human.js +50 -0
- package/dist/tools/builtins/codegraph.js +113 -0
- package/dist/tools/builtins/edit-file.js +3 -3
- package/dist/tools/builtins/glob.js +3 -2
- package/dist/tools/builtins/grep.js +4 -3
- package/dist/tools/builtins/index.js +19 -1
- package/dist/tools/builtins/memory-forget.js +32 -0
- package/dist/tools/builtins/memory-list.js +34 -0
- package/dist/tools/builtins/memory-save.js +51 -0
- package/dist/tools/builtins/memory-search.js +42 -0
- package/dist/tools/builtins/memory-update.js +41 -0
- package/dist/tools/builtins/read-file.js +5 -4
- package/dist/tools/builtins/run-command.js +40 -7
- package/dist/tools/builtins/switch-mode.js +46 -0
- package/dist/tools/builtins/task.js +62 -0
- package/dist/tools/builtins/use-skill.js +2 -2
- package/dist/tools/builtins/web-fetch.js +16 -3
- package/dist/tools/builtins/web-search.js +6 -6
- package/dist/tools/builtins/write-file.js +3 -3
- package/dist/tools/constants.js +29 -0
- package/dist/tools/registry.js +11 -4
- package/dist/ui/content.js +25 -17
- package/dist/ui/diff.js +28 -26
- package/dist/ui/intervention.js +318 -0
- package/dist/ui/layout.js +402 -103
- package/dist/ui/markdown.js +607 -0
- package/dist/ui/mouse.js +93 -0
- package/dist/ui/prompt.js +469 -27
- package/dist/ui/render.js +19 -1
- package/dist/ui/spinner.js +15 -0
- package/dist/ui/theme.js +147 -13
- package/package.json +2 -2
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// agent 核心循环(纯逻辑,无 TUI 依赖):流式 chat → 工具执行 → 回灌。
|
|
2
|
+
// 所有展示副作用经 AgentHooks 注入——主 agent 注入 TUI 渲染(layout + spinner + diff),
|
|
3
|
+
// 子 agent 注入静默/摘要 hooks(不写屏)。逻辑层共享,避免重复实现循环 / 分组 / abort 还原。
|
|
4
|
+
//
|
|
5
|
+
// 与 index.ts 的关系:index.ts 的 runAgent = runAgentCore + TUI hooks 薄封装(行为不变)。
|
|
6
|
+
// spawn.ts 的 spawnAgent = runAgentCore + 静默 hooks(子 agent)。
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { chat, planChatTools, } from '../llm/index.js';
|
|
10
|
+
import { executeTool } from '../tools/registry.js';
|
|
11
|
+
import { PLAN_DISABLED_TOOLS } from '../tools/constants.js';
|
|
12
|
+
import { getAgentMode, setAgentMode } from './mode.js';
|
|
13
|
+
import { maybeCompact, capToolResultForHistory, contextState, } from '../session/index.js';
|
|
14
|
+
import { config } from '../config/index.js';
|
|
15
|
+
/** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
|
|
16
|
+
function parseArgs(raw) {
|
|
17
|
+
try {
|
|
18
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** 只读工具集:一轮多个时,连续的只读工具成组 Promise.all 并行(无副作用、互不依赖)。 */
|
|
25
|
+
const READ_TOOL_NAMES = new Set([
|
|
26
|
+
'read_file',
|
|
27
|
+
'glob',
|
|
28
|
+
'grep',
|
|
29
|
+
'codegraph',
|
|
30
|
+
'web_search',
|
|
31
|
+
'web_fetch',
|
|
32
|
+
]);
|
|
33
|
+
/** mutation 工具:写盘 + 在 executeTool 内记回滚 before 快照,必须串行保快照序。 */
|
|
34
|
+
const isMutationTool = (name) => name === 'edit_file' || name === 'write_file';
|
|
35
|
+
/** mutation 执行前读旧内容供 diff:write_file 取整文件旧内容(不存在→null=新建),
|
|
36
|
+
* edit_file 取 old_string 起始行号(供 diff 显示真实文件行号)。读不到则 diff 退化为相对行号。
|
|
37
|
+
* 非 mutation 或参数非法返 { preWriteOld: null, editStartLine: 1 }。失败不阻断。 */
|
|
38
|
+
function readDiffContext(tc, parsed) {
|
|
39
|
+
if (!parsed)
|
|
40
|
+
return { preWriteOld: null, editStartLine: 1 };
|
|
41
|
+
const p = String(parsed.path ?? '');
|
|
42
|
+
if (!p)
|
|
43
|
+
return { preWriteOld: null, editStartLine: 1 };
|
|
44
|
+
if (tc.name === 'write_file') {
|
|
45
|
+
try {
|
|
46
|
+
return { preWriteOld: readFileSync(resolve(p), 'utf8'), editStartLine: 1 };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { preWriteOld: null, editStartLine: 1 }; // 文件不存在(新建)或不可读
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (tc.name === 'edit_file') {
|
|
53
|
+
const oldStr = String(parsed.old_string ?? '');
|
|
54
|
+
try {
|
|
55
|
+
const data = readFileSync(resolve(p), 'utf8');
|
|
56
|
+
const idx = oldStr ? data.indexOf(oldStr) : -1;
|
|
57
|
+
return {
|
|
58
|
+
preWriteOld: null,
|
|
59
|
+
editStartLine: idx >= 0 ? data.slice(0, idx).split('\n').length : 1,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return { preWriteOld: null, editStartLine: 1 }; // 读不到:diff 退化为相对行号
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { preWriteOld: null, editStartLine: 1 };
|
|
67
|
+
}
|
|
68
|
+
/** 回灌 tool 结果到 history(裁到单条上限);tool_call_id 与 assistant.tool_calls 按序配对。 */
|
|
69
|
+
function pushToolResult(history, tc, output) {
|
|
70
|
+
history.push({
|
|
71
|
+
role: 'tool',
|
|
72
|
+
tool_call_id: tc.id,
|
|
73
|
+
content: capToolResultForHistory(tc.name, output),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* agent 核心循环(纯逻辑):
|
|
78
|
+
* 流式调 LLM(经 hooks.onText 实时渲染)→ 有 tool_calls 就分组执行并回灌
|
|
79
|
+
* → 否则流式正文即最终回复。history 在调用间持久,由调用方持有。
|
|
80
|
+
* 步前经 session/maybeCompact 自动压缩(接近窗口上限时三层压缩);
|
|
81
|
+
* 工具结果进 history 前经 capToolResultForHistory 裁到单条上限。
|
|
82
|
+
*
|
|
83
|
+
* 中断语义:signal 经 executeTool(name, args, signal) 串进工具;run_command/web_fetch 等 abort 即时杀
|
|
84
|
+
* (树杀子进程 / 取消 fetch),循环顶 if(signal.aborted) 兜底还原。不会留下未配对的 tool_call_id。
|
|
85
|
+
* abort 时 history 还原到本 turn 前(savedHistory 浅拷贝),模式还原,调 hooks.onAbort。
|
|
86
|
+
*
|
|
87
|
+
* 所有展示副作用经 hooks 注入;core 自身不直接调 layout / spinner(不依赖 ui/layout.ts)。
|
|
88
|
+
* 但 core 仍依赖 ui/render.ts 的纯函数(summarizeToolCall / truncateDisplay / fmtElapsed)——
|
|
89
|
+
* 这些是纯字符串格式化,无副作用,共享安全。
|
|
90
|
+
*/
|
|
91
|
+
export async function runAgentCore(opts) {
|
|
92
|
+
const { history, userInput, signal, onContextUpdate, hooks, skipRollback } = opts;
|
|
93
|
+
const maxSteps = opts.maxSteps ?? config.maxSteps;
|
|
94
|
+
// 中断回滚快照:入口(本 turn push 任何消息前)整段浅拷贝。abort 时 length=0;push(...saved) 还原。
|
|
95
|
+
// 用 slice() 而非 length:maybeCompact 会原地重建(length=0;push(...rebuilt)),savedLen 会失效。
|
|
96
|
+
const savedHistory = history.slice();
|
|
97
|
+
// 中断还原:LLM 中途可能调 switch_mode 切了模式,abort 时连同模式一起还原回轮首。
|
|
98
|
+
const savedMode = getAgentMode();
|
|
99
|
+
// 本轮计时:从入口到完毕(正常 return / 达上限),供 finally 打 ✻ Worked for 摘要行。
|
|
100
|
+
const t0 = Date.now();
|
|
101
|
+
let done = false; // 正常完毕 / 达上限 true;中断 false(不显摘要)
|
|
102
|
+
history.push({ role: 'user', content: userInput });
|
|
103
|
+
// 本轮流式状态:首个正文 token 到达即停 spinner(思考期间 spinner 持续转「思考中…」,不写思考内容)。
|
|
104
|
+
let mode = 'idle';
|
|
105
|
+
let gotText = false;
|
|
106
|
+
let lastChar = '';
|
|
107
|
+
const onText = (s) => {
|
|
108
|
+
hooks.onText?.(s); // 主 agent:走 markdown 渲染写内容区
|
|
109
|
+
mode = 'text';
|
|
110
|
+
gotText = true;
|
|
111
|
+
if (s)
|
|
112
|
+
lastChar = s[s.length - 1];
|
|
113
|
+
};
|
|
114
|
+
const onToolCall = (name) => {
|
|
115
|
+
// 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
|
|
116
|
+
// 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
|
|
117
|
+
if (lastChar && lastChar !== '\n') {
|
|
118
|
+
hooks.onTextEnd?.(); // 主 agent:layout.contentWrite('\n')
|
|
119
|
+
lastChar = '\n';
|
|
120
|
+
}
|
|
121
|
+
hooks.onToolCall?.(name); // 主 agent:spinner.start(`生成 ${name}…`)
|
|
122
|
+
};
|
|
123
|
+
// 中断还原:停 spinner + 补换行 + (已中断)提示 + history 还原到本 turn 前 + 模式还原。
|
|
124
|
+
// 两处共用:① await chat() 抛 AbortError 的 catch;② 工具被 abort 杀后循环顶检查。
|
|
125
|
+
const abortRestore = () => {
|
|
126
|
+
hooks.onAbort?.();
|
|
127
|
+
history.length = 0;
|
|
128
|
+
history.push(...savedHistory);
|
|
129
|
+
setAgentMode(savedMode);
|
|
130
|
+
};
|
|
131
|
+
try {
|
|
132
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
133
|
+
// 上一步工具被 abort 杀(run_command/web_fetch 等)→ signal.aborted,直接还原退出,不等 maybeCompact + chat()
|
|
134
|
+
if (signal?.aborted) {
|
|
135
|
+
abortRestore();
|
|
136
|
+
return { completed: false, finalText: null };
|
|
137
|
+
}
|
|
138
|
+
// 步前:接近窗口上限时自动压缩(三层)。此时 spinner 已停,通知行干净。
|
|
139
|
+
await maybeCompact(history);
|
|
140
|
+
hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
|
|
141
|
+
mode = 'idle';
|
|
142
|
+
gotText = false;
|
|
143
|
+
lastChar = '';
|
|
144
|
+
let result;
|
|
145
|
+
try {
|
|
146
|
+
// 每步读实时模式:LLM 可能在上一步调 switch_mode 切了模式,这里立即用对应工具集
|
|
147
|
+
// (auto=chatTools 全量;plan=planChatTools 只读子集)。模式由 src/agent/mode.ts 单一持有。
|
|
148
|
+
// 调用方可传 toolsOverride 覆盖(子 agent 受限工具子集)。
|
|
149
|
+
result = await chat(history, { onText, onToolCall }, signal, opts.toolsOverride ??
|
|
150
|
+
(getAgentMode() === 'plan' ? planChatTools : undefined));
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
// 中断(用户运行中 Ctrl+C):chat() 抛 AbortError(signal.aborted)→ 还原 history + 模式 + return(不抛)。
|
|
154
|
+
// 工具执行现已串 signal:run_command/web_fetch 被 abort 即时杀,循环顶检查兜底(不会留未配对 tool_call_id)。
|
|
155
|
+
if (signal?.aborted ||
|
|
156
|
+
(e instanceof Error &&
|
|
157
|
+
(e.name === 'AbortError' || e.name === 'APIUserAbortError'))) {
|
|
158
|
+
abortRestore();
|
|
159
|
+
return { completed: false, finalText: null };
|
|
160
|
+
}
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
contextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
|
|
164
|
+
hooks.onChatDone?.(); // 主 agent:spinner.stop()
|
|
165
|
+
// lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
|
|
166
|
+
onContextUpdate?.();
|
|
167
|
+
if (result.toolCalls.length > 0) {
|
|
168
|
+
// 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
|
|
169
|
+
if (mode !== 'idle' && lastChar !== '\n')
|
|
170
|
+
hooks.onTextEnd?.();
|
|
171
|
+
// 带工具调用的 assistant 消息原样回灌(OpenAI 格式要求)
|
|
172
|
+
history.push({
|
|
173
|
+
role: 'assistant',
|
|
174
|
+
content: result.content,
|
|
175
|
+
tool_calls: result.toolCalls.map((tc) => ({
|
|
176
|
+
id: tc.id,
|
|
177
|
+
type: 'function',
|
|
178
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
179
|
+
})),
|
|
180
|
+
});
|
|
181
|
+
// 工具分组执行(保 tool_calls 原顺序):连续的只读工具(READ_TOOL_NAMES)成组并发——一次性启动全部
|
|
182
|
+
// executeTool(调用即开始 I/O),再按原顺序逐个 await + 渲染(● 头与 ↳ 结果紧邻,修并行时"全 ● 后全 ↳"分离)。
|
|
183
|
+
// mutation(write_file/edit_file)及 run_command/use_skill 各为单步串行屏障——mutation 串行保
|
|
184
|
+
// recordMutation 调用序 = 回滚快照序(executeTool 内写前记 before 快照,同文件多次写需按序)。
|
|
185
|
+
// 渲染与 history 回灌一律按原顺序;并发只影响执行时序,tool_call_id 仍按序配对。
|
|
186
|
+
// executeTool 永不抛错(调度器 try/catch 返字符串),故 await 单个 promise 不会抛(永远 resolve 为字符串)。
|
|
187
|
+
const calls = result.toolCalls;
|
|
188
|
+
let i = 0;
|
|
189
|
+
while (i < calls.length) {
|
|
190
|
+
if (READ_TOOL_NAMES.has(calls[i].name)) {
|
|
191
|
+
// 收集连续只读组(≥1),并发执行:先一次性启动所有(executeTool 调用即开始 I/O),
|
|
192
|
+
// 再按原顺序逐个 await + 渲染——● 头与 ↳ 结果紧邻、顺序 = tool_calls 序(修"全 ● 后全 ↳"分离 bug)。
|
|
193
|
+
// 异步工具(web_fetch 等)并发跑、总耗时 ≈ 最慢一个;同步工具(glob/grep)map 时已顺序跑完,await 即返。
|
|
194
|
+
let j = i;
|
|
195
|
+
while (j < calls.length && READ_TOOL_NAMES.has(calls[j].name))
|
|
196
|
+
j++;
|
|
197
|
+
const batch = calls.slice(i, j);
|
|
198
|
+
const started = batch.map((tc) => executeTool(tc.name, tc.arguments, signal, { skipRollback }));
|
|
199
|
+
for (let k = 0; k < batch.length; k++) {
|
|
200
|
+
const tc = batch[k];
|
|
201
|
+
hooks.onToolHeader?.(tc);
|
|
202
|
+
hooks.onToolStart?.(tc.name);
|
|
203
|
+
const output = await started[k];
|
|
204
|
+
hooks.onToolDone?.();
|
|
205
|
+
hooks.onToolResult?.(tc, output, null, null, 1); // 只读工具无 diff
|
|
206
|
+
pushToolResult(history, tc, output);
|
|
207
|
+
}
|
|
208
|
+
i = j;
|
|
209
|
+
}
|
|
210
|
+
else if (calls[i].name === 'task') {
|
|
211
|
+
// task 并发组:连续的 task 调用成组并发(子 agent 并行跑,各自独立 history)。
|
|
212
|
+
// 一次性启动全部(executeTool 即 spawnAgent,子 agent 开始跑),再并发 await + 渲染。
|
|
213
|
+
// task 是长任务,并发 fan-out 总耗时 ≈ 最慢一个子 agent。
|
|
214
|
+
// task 与 mutation/run_command 之间串行屏障(task 子 agent 可能有文件改动,不能和 write_file 乱序)。
|
|
215
|
+
// 渲染:先批量打印所有 ● 头 + 启 spinner(让用户看到多个 task 同时在跑),再逐个 await 出结果。
|
|
216
|
+
// (若像只读组那样「header → await → result」串行,长 task 的第二个 header 要等第一个跑完才出现,
|
|
217
|
+
// 视觉上只有一个在跑——与并发事实不符。)
|
|
218
|
+
//
|
|
219
|
+
// plan 模式防御 backstop(与单步串行分支同语义):schema 已剔除 task,正常不会进这里;
|
|
220
|
+
// 防后端幻觉调用——不执行(绝不派生子 agent,子 agent 可能有 mutation,违反只读),直接返错回灌。
|
|
221
|
+
if (getAgentMode() === 'plan') {
|
|
222
|
+
const tc = calls[i];
|
|
223
|
+
hooks.onToolHeader?.(tc);
|
|
224
|
+
const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
|
|
225
|
+
hooks.onToolResult?.(tc, err, null, null, 1);
|
|
226
|
+
pushToolResult(history, tc, err);
|
|
227
|
+
i++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
let j = i;
|
|
231
|
+
while (j < calls.length && calls[j].name === 'task')
|
|
232
|
+
j++;
|
|
233
|
+
const batch = calls.slice(i, j);
|
|
234
|
+
const started = batch.map((tc) => executeTool(tc.name, tc.arguments, signal, { skipRollback }));
|
|
235
|
+
// 先批量打印所有头 + 启 spinner(多 task 并发,spinner 只显一个,但 ● 头都打出来)
|
|
236
|
+
for (const tc of batch) {
|
|
237
|
+
hooks.onToolHeader?.(tc);
|
|
238
|
+
}
|
|
239
|
+
hooks.onToolStart?.(batch[0].name); // spinner:多 task 共用一个「执行 task…」
|
|
240
|
+
// 逐个 await 出结果(按 tool_calls 原序,保 tool_call_id 配对);结果到即渲染 ↳
|
|
241
|
+
for (let k = 0; k < batch.length; k++) {
|
|
242
|
+
const tc = batch[k];
|
|
243
|
+
const output = await started[k];
|
|
244
|
+
hooks.onToolResult?.(tc, output, null, null, 1); // task 结果是摘要,无 diff
|
|
245
|
+
pushToolResult(history, tc, output);
|
|
246
|
+
}
|
|
247
|
+
hooks.onToolDone?.();
|
|
248
|
+
i = j;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
// 单步串行(mutation / run_command / use_skill)——逐个执行,保快照序
|
|
252
|
+
const tc = calls[i];
|
|
253
|
+
// plan 模式防御 backstop:schema 已剔除这些工具,正常不会进这里;防后端幻觉调用——
|
|
254
|
+
// 不执行,直接返错回灌(让模型看到「plan 模式禁用」并停止),绝不写盘 / 跑命令。
|
|
255
|
+
if (getAgentMode() === 'plan' && PLAN_DISABLED_TOOLS.has(tc.name)) {
|
|
256
|
+
hooks.onToolHeader?.(tc);
|
|
257
|
+
const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
|
|
258
|
+
hooks.onToolResult?.(tc, err, null, null, 1);
|
|
259
|
+
pushToolResult(history, tc, err);
|
|
260
|
+
i++;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
hooks.onToolHeader?.(tc);
|
|
264
|
+
const parsed = isMutationTool(tc.name)
|
|
265
|
+
? parseArgs(tc.arguments)
|
|
266
|
+
: null;
|
|
267
|
+
const { preWriteOld, editStartLine } = readDiffContext(tc, parsed);
|
|
268
|
+
hooks.onToolStart?.(tc.name);
|
|
269
|
+
const output = await executeTool(tc.name, tc.arguments, signal, { skipRollback });
|
|
270
|
+
hooks.onToolDone?.();
|
|
271
|
+
hooks.onToolResult?.(tc, output, parsed, preWriteOld, editStartLine);
|
|
272
|
+
pushToolResult(history, tc, output);
|
|
273
|
+
i++;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// 工具步末尾补一空行:与下一轮的思考 / 正文分隔(否则 ↳ 后紧接 ▎ 思考,无空行不好看;
|
|
277
|
+
// 与正文→● 的 1 空行对称)。工具结果已以 \n 收尾,此处再补 \n 恰好 1 空行。
|
|
278
|
+
hooks.onToolBatchEnd?.();
|
|
279
|
+
continue; // 带着工具结果再调一次 LLM
|
|
280
|
+
}
|
|
281
|
+
if (mode !== 'idle' && lastChar !== '\n')
|
|
282
|
+
hooks.onTextEnd?.(); // 流式末尾补换行
|
|
283
|
+
// 没有工具调用:流式正文即最终回复(已实时打印)
|
|
284
|
+
if (!gotText)
|
|
285
|
+
hooks.onNoReply?.();
|
|
286
|
+
history.push({ role: 'assistant', content: result.content });
|
|
287
|
+
done = true;
|
|
288
|
+
return { completed: true, finalText: result.content };
|
|
289
|
+
}
|
|
290
|
+
hooks.onMaxSteps?.();
|
|
291
|
+
done = true;
|
|
292
|
+
return { completed: true, finalText: null };
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
// 跑完(正常 / 达上限)在回复末尾打耗时摘要行(仿 Claude Code);中断 done=false 不打。
|
|
296
|
+
if (done) {
|
|
297
|
+
hooks.onDone?.(Date.now() - t0);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// ── 导出共享辅助(主 agent 的 TUI hooks 实现要用)──────────────────────────
|
|
302
|
+
export { parseArgs, readDiffContext, isMutationTool, READ_TOOL_NAMES };
|
package/dist/agent/index.js
CHANGED
|
@@ -1,50 +1,64 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// 主 agent:runAgentCore + TUI 渲染 hooks 的薄封装。
|
|
2
|
+
// 循环逻辑(流式 chat → 工具分组执行 → 回灌 / abort 还原 / maybeCompact)全部在 core.ts,
|
|
3
|
+
// 本文件只负责把展示副作用(layout.contentWrite / Spinner / diff / 回滚轮次)注入为 AgentHooks。
|
|
4
|
+
// 行为与重构前的单文件 runAgent 完全一致——这是安全重构,实跑回归验证。
|
|
5
5
|
import { ui } from '../ui/theme.js';
|
|
6
6
|
import { Spinner } from '../ui/spinner.js';
|
|
7
|
-
import { summarizeToolCall, summarizeToolResult, truncateDisplay, } from '../ui/render.js';
|
|
7
|
+
import { summarizeToolCall, summarizeToolResult, truncateDisplay, fmtElapsed, } from '../ui/render.js';
|
|
8
8
|
import { renderFileChange } from '../ui/diff.js';
|
|
9
9
|
import * as layout from '../ui/layout.js';
|
|
10
10
|
import { beginTurn } from '../rollback/index.js';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
import { config } from '../config/index.js';
|
|
12
|
+
import { runAgentCore, isMutationTool, } from './core.js';
|
|
13
|
+
/** 工具调用 ● 头:工具名 + 参数摘要(按 tool_calls 原顺序打印,让用户看到本轮跑哪些工具)。 */
|
|
14
|
+
function writeToolHeader(tc) {
|
|
15
|
+
const summary = summarizeToolCall(tc.name, tc.arguments);
|
|
16
|
+
layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}${tc.name}${ui.reset} ${ui.dim}${summary}${ui.reset}\n`);
|
|
17
|
+
}
|
|
18
|
+
/** 渲染工具结果:mutation 成功走 diff 块(行号 + 语法高亮,仿 Claude Code);其余走一行 preview。 */
|
|
19
|
+
function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
|
|
20
|
+
if (isMutationTool(tc.name) && parsed && !output.startsWith('错误')) {
|
|
21
|
+
layout.contentWrite(renderFileChange({
|
|
22
|
+
path: String(parsed.path ?? ''),
|
|
23
|
+
kind: tc.name === 'edit_file' ? 'edit' : 'write',
|
|
24
|
+
oldStr: tc.name === 'edit_file'
|
|
25
|
+
? String(parsed.old_string ?? '')
|
|
26
|
+
: preWriteOld,
|
|
27
|
+
newStr: String((tc.name === 'edit_file' ? parsed.new_string : parsed.content) ?? ''),
|
|
28
|
+
startLine: tc.name === 'edit_file' ? editStartLine : 1,
|
|
29
|
+
}));
|
|
17
30
|
}
|
|
18
|
-
|
|
19
|
-
|
|
31
|
+
else {
|
|
32
|
+
const preview = summarizeToolResult(tc.name, output);
|
|
33
|
+
if (preview) {
|
|
34
|
+
layout.contentWrite(` ${ui.gray}↳ ${preview}${ui.reset}\n`);
|
|
35
|
+
}
|
|
20
36
|
}
|
|
21
37
|
}
|
|
22
38
|
/**
|
|
23
|
-
* agent
|
|
24
|
-
* 流式调 LLM(onText
|
|
39
|
+
* agent 核心循环(主 agent,TUI 渲染版):
|
|
40
|
+
* 流式调 LLM(onText 实时写内容区)→ 有 tool_calls 就执行并回灌
|
|
25
41
|
* → 否则流式正文即最终回复。history 在调用间持久,由 REPL 持有。
|
|
26
42
|
* 步前经 session/maybeCompact 自动压缩(接近窗口上限时三层压缩);
|
|
27
43
|
* 工具结果进 history 前经 capToolResultForHistory 裁到单条上限。
|
|
28
44
|
*
|
|
29
45
|
* 所有正文写经 layout.contentWrite(保证落在内容区、跟踪续写位、底栏不被顶);
|
|
30
46
|
* spinner 经 onFrame 回调刷状态行(layout.drawStatusBar),等待时内容区静止、底栏转圈。
|
|
31
|
-
*
|
|
32
|
-
*
|
|
47
|
+
* 思考期间不写思考内容,只让 spinner 持续转「思考中…」(首个正文 / tool_call token 到达才停)。
|
|
48
|
+
*
|
|
49
|
+
* 循环逻辑委托 runAgentCore;本函数只注入 TUI 渲染 hooks(layout + spinner + diff + 回滚轮次)。
|
|
50
|
+
* 开新轮次(beginTurn)在入口调——core 不依赖 rollback,回滚轮次是主 agent 的展示副作用。
|
|
33
51
|
*/
|
|
34
|
-
export async function runAgent(history, userInput,
|
|
52
|
+
export async function runAgent(history, userInput, signal,
|
|
35
53
|
/** 每步 chat() 返回后回调:repl 据此重算并重画状态行 context 用量条(运行中实时刷新,不冻结在轮首)。 */
|
|
36
54
|
onContextUpdate) {
|
|
37
|
-
// 中断回滚快照:入口(本 turn push 任何消息前)整段浅拷贝。abort 时 length=0;push(...saved) 还原。
|
|
38
|
-
// 用 slice() 而非 length:maybeCompact 会原地重建(length=0;push(...rebuilt)),savedLen 会失效。
|
|
39
|
-
const savedHistory = history.slice();
|
|
40
|
-
history.push({ role: 'user', content: userInput });
|
|
41
55
|
// 开新轮次(回滚用):首行截断 40,供 /rollback 轮次菜单展示。
|
|
42
56
|
beginTurn(truncateDisplay(userInput.split('\n')[0] ?? '', 40));
|
|
43
|
-
layout.contentMode(); //
|
|
44
|
-
// spinner
|
|
45
|
-
//
|
|
57
|
+
layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
|
|
58
|
+
// spinner:续写位内联转圈(思考中 / 执行 工具时,内容区不再「干等」)。
|
|
59
|
+
// 只走内联 paintLiveAtCursor——不调 setStatus,状态行不重复 spinner 文字(状态行只显走时,
|
|
60
|
+
// 由 turnTimer 200ms 续刷);内联帧不进缓冲、停时清掉,随后结果即写在该行——故 spinner 不入历史、PgUp 看不到。
|
|
46
61
|
const spinner = new Spinner((msg, frame) => {
|
|
47
|
-
layout.setStatus(msg, frame ?? undefined);
|
|
48
62
|
if (frame) {
|
|
49
63
|
layout.paintLiveAtCursor(` ${ui.brightMagenta}${frame}${ui.reset} ${ui.dim}${msg}…${ui.reset}`);
|
|
50
64
|
}
|
|
@@ -52,188 +66,57 @@ onContextUpdate) {
|
|
|
52
66
|
layout.clearLiveAtCursor();
|
|
53
67
|
}
|
|
54
68
|
});
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
let gotText = false;
|
|
69
|
+
// lastChar 镜像:core 跟踪流式末字符决定补换行,但 TUI hooks 需读它决定 layout.contentWrite('\n')。
|
|
70
|
+
// core 的 onTextEnd hook 只在 lastChar !== '\n' 时才调,调后置 '\n';镜像与此同步。
|
|
58
71
|
let lastChar = '';
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// 之后任何输出按 text 走,不再回到 thinking(避免后续再被错误折叠)
|
|
71
|
-
mode = 'text';
|
|
72
|
-
};
|
|
73
|
-
const onThinking = (s) => {
|
|
74
|
-
if (mode === 'idle') {
|
|
75
|
-
spinner.stop();
|
|
76
|
-
if (lastChar && lastChar !== '\n')
|
|
72
|
+
const hooks = {
|
|
73
|
+
onText: (s) => {
|
|
74
|
+
spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
|
|
75
|
+
layout.contentWriteMd(s); // 正文走 markdown 渲染(代码块高亮 / 标题 / 列表 / 行内 …),见 ui/markdown.ts
|
|
76
|
+
if (s)
|
|
77
|
+
lastChar = s[s.length - 1];
|
|
78
|
+
},
|
|
79
|
+
onToolCall: (name) => {
|
|
80
|
+
// 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
|
|
81
|
+
// 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
|
|
82
|
+
if (lastChar && lastChar !== '\n') {
|
|
77
83
|
layout.contentWrite('\n');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
lastChar = '\n';
|
|
85
|
+
}
|
|
86
|
+
if (name)
|
|
87
|
+
spinner.start(`生成 ${name}…`);
|
|
88
|
+
},
|
|
89
|
+
onStepStart: () => spinner.start('思考中'),
|
|
90
|
+
onChatDone: () => spinner.stop(),
|
|
91
|
+
onTextEnd: () => {
|
|
92
|
+
if (lastChar && lastChar !== '\n') {
|
|
93
|
+
layout.contentWrite('\n');
|
|
94
|
+
lastChar = '\n';
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
onToolHeader: (tc) => writeToolHeader(tc),
|
|
98
|
+
onToolStart: (name) => spinner.start(`执行 ${name}`),
|
|
99
|
+
onToolDone: () => spinner.stop(),
|
|
100
|
+
onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine),
|
|
101
|
+
onToolBatchEnd: () => layout.contentWrite('\n'),
|
|
102
|
+
onNoReply: () => layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`),
|
|
103
|
+
onMaxSteps: () => layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${config.maxSteps}),本轮停止。${ui.reset}\n`),
|
|
104
|
+
onAbort: () => {
|
|
105
|
+
spinner.stop();
|
|
83
106
|
if (lastChar && lastChar !== '\n')
|
|
84
107
|
layout.contentWrite('\n');
|
|
85
|
-
layout.
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
mode = 'thinking';
|
|
89
|
-
thinkingBuffer += s;
|
|
90
|
-
layout.contentWrite(`${ui.dim}${s}${ui.reset}`);
|
|
91
|
-
if (s)
|
|
92
|
-
lastChar = s[s.length - 1];
|
|
93
|
-
};
|
|
94
|
-
const onText = (s) => {
|
|
95
|
-
flushThinkCollapsed(); // 思考→正文过渡时折叠
|
|
96
|
-
spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
|
|
97
|
-
mode = 'text';
|
|
98
|
-
gotText = true;
|
|
99
|
-
layout.contentWrite(s);
|
|
100
|
-
if (s)
|
|
101
|
-
lastChar = s[s.length - 1];
|
|
102
|
-
};
|
|
103
|
-
const onToolCall = (name) => {
|
|
104
|
-
// 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
|
|
105
|
-
// 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
|
|
106
|
-
if (lastChar && lastChar !== '\n') {
|
|
107
|
-
layout.contentWrite('\n');
|
|
108
|
-
lastChar = '\n';
|
|
109
|
-
}
|
|
110
|
-
if (name)
|
|
111
|
-
spinner.start(`生成 ${name}…`);
|
|
108
|
+
layout.contentWrite(`${ui.dim}(已中断)${ui.reset}\n`);
|
|
109
|
+
},
|
|
110
|
+
onDone: (elapsedMs) => layout.contentWrite(` ${ui.dim}✻ Worked for ${fmtElapsed(elapsedMs)}${ui.reset}\n`),
|
|
112
111
|
};
|
|
113
112
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
let result;
|
|
122
|
-
try {
|
|
123
|
-
result = await chat(history, { onText, onThinking, onToolCall }, signal);
|
|
124
|
-
}
|
|
125
|
-
catch (e) {
|
|
126
|
-
// 中断(用户运行中 Ctrl+C):停 spinner、补换行、提示、history 还原到本 turn 前、return(不抛)。
|
|
127
|
-
// abort 只在 await chat() 期生效;tool 执行不可中断,故不会留下未配对的 tool_call_id。
|
|
128
|
-
if (signal?.aborted ||
|
|
129
|
-
(e instanceof Error &&
|
|
130
|
-
(e.name === 'AbortError' || e.name === 'APIUserAbortError'))) {
|
|
131
|
-
spinner.stop();
|
|
132
|
-
if (lastChar && lastChar !== '\n')
|
|
133
|
-
layout.contentWrite('\n');
|
|
134
|
-
layout.contentWrite(`${ui.dim}(已中断)${ui.reset}\n`);
|
|
135
|
-
history.length = 0;
|
|
136
|
-
history.push(...savedHistory);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
throw e;
|
|
140
|
-
}
|
|
141
|
-
contextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
|
|
142
|
-
spinner.stop();
|
|
143
|
-
// lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
|
|
144
|
-
onContextUpdate?.();
|
|
145
|
-
if (result.toolCalls.length > 0) {
|
|
146
|
-
// 思考后直接进入 tool_call(无 text):先把可见的思考段折叠
|
|
147
|
-
flushThinkCollapsed();
|
|
148
|
-
// 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
|
|
149
|
-
if (mode !== 'idle' && lastChar !== '\n')
|
|
150
|
-
layout.contentWrite('\n');
|
|
151
|
-
// 带工具调用的 assistant 消息原样回灌(OpenAI 格式要求)
|
|
152
|
-
history.push({
|
|
153
|
-
role: 'assistant',
|
|
154
|
-
content: result.content,
|
|
155
|
-
tool_calls: result.toolCalls.map((tc) => ({
|
|
156
|
-
id: tc.id,
|
|
157
|
-
type: 'function',
|
|
158
|
-
function: { name: tc.name, arguments: tc.arguments },
|
|
159
|
-
})),
|
|
160
|
-
});
|
|
161
|
-
for (const tc of result.toolCalls) {
|
|
162
|
-
const summary = summarizeToolCall(tc.name, tc.arguments);
|
|
163
|
-
layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}${tc.name}${ui.reset} ${ui.dim}${summary}${ui.reset}\n`);
|
|
164
|
-
const isMutation = tc.name === 'edit_file' || tc.name === 'write_file';
|
|
165
|
-
const parsed = isMutation ? parseArgs(tc.arguments) : null;
|
|
166
|
-
// write_file 覆盖场景:执行前读旧内容供 diff(不存在→null=新建)。
|
|
167
|
-
// edit_file:执行前读文件定位 old_string 起始行,供 diff 显示真实文件行号。
|
|
168
|
-
// 两者皆失败不阻断(读不到则 diff 退化为相对行号 / 不渲染)。
|
|
169
|
-
let preWriteOld = null;
|
|
170
|
-
let editStartLine = 1;
|
|
171
|
-
if (parsed) {
|
|
172
|
-
const p = String(parsed.path ?? '');
|
|
173
|
-
if (p) {
|
|
174
|
-
if (tc.name === 'write_file') {
|
|
175
|
-
try {
|
|
176
|
-
preWriteOld = readFileSync(resolve(p), 'utf8');
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
preWriteOld = null; // 文件不存在(新建)或不可读
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
else if (tc.name === 'edit_file') {
|
|
183
|
-
const oldStr = String(parsed.old_string ?? '');
|
|
184
|
-
try {
|
|
185
|
-
const data = readFileSync(resolve(p), 'utf8');
|
|
186
|
-
const idx = oldStr ? data.indexOf(oldStr) : -1;
|
|
187
|
-
if (idx >= 0)
|
|
188
|
-
editStartLine = data.slice(0, idx).split('\n').length;
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
// 读不到:startLine 保持 1(diff 退化为相对行号)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
spinner.start(`执行 ${tc.name}`);
|
|
197
|
-
const output = await executeTool(tc.name, tc.arguments);
|
|
198
|
-
spinner.stop();
|
|
199
|
-
// edit_file / write_file 成功:渲染 diff 块(行号 + 语法高亮,仿 Claude Code);其余工具走一行 preview。
|
|
200
|
-
if (isMutation && parsed && !output.startsWith('错误')) {
|
|
201
|
-
layout.contentWrite(renderFileChange({
|
|
202
|
-
path: String(parsed.path ?? ''),
|
|
203
|
-
kind: tc.name === 'edit_file' ? 'edit' : 'write',
|
|
204
|
-
oldStr: tc.name === 'edit_file'
|
|
205
|
-
? String(parsed.old_string ?? '')
|
|
206
|
-
: preWriteOld,
|
|
207
|
-
newStr: String((tc.name === 'edit_file' ? parsed.new_string : parsed.content) ??
|
|
208
|
-
''),
|
|
209
|
-
startLine: tc.name === 'edit_file' ? editStartLine : 1,
|
|
210
|
-
}));
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
const preview = summarizeToolResult(tc.name, output);
|
|
214
|
-
if (preview) {
|
|
215
|
-
layout.contentWrite(` ${ui.gray}↳ ${preview}${ui.reset}\n`);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
history.push({
|
|
219
|
-
role: 'tool',
|
|
220
|
-
tool_call_id: tc.id,
|
|
221
|
-
content: capToolResultForHistory(tc.name, output),
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
continue; // 带着工具结果再调一次 LLM
|
|
225
|
-
}
|
|
226
|
-
// 思考后既无 tool_call 也无 text(纯思考):兜底折叠
|
|
227
|
-
flushThinkCollapsed();
|
|
228
|
-
if (mode !== 'idle' && lastChar !== '\n')
|
|
229
|
-
layout.contentWrite('\n'); // 流式末尾补换行
|
|
230
|
-
// 没有工具调用:流式正文即最终回复(已实时打印)
|
|
231
|
-
if (!gotText)
|
|
232
|
-
layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`);
|
|
233
|
-
history.push({ role: 'assistant', content: result.content });
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${MAX_STEPS}),本轮停止。${ui.reset}\n`);
|
|
113
|
+
await runAgentCore({
|
|
114
|
+
history,
|
|
115
|
+
userInput,
|
|
116
|
+
signal,
|
|
117
|
+
onContextUpdate,
|
|
118
|
+
hooks,
|
|
119
|
+
});
|
|
237
120
|
}
|
|
238
121
|
finally {
|
|
239
122
|
spinner.stop();
|