zhiqu 0.1.0 → 0.2.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/README.md +45 -10
- package/package.json +2 -1
- package/skills/code-audit/SKILL.md +49 -0
- package/skills/codebase-tour/SKILL.md +65 -0
- package/skills/commit/SKILL.md +72 -0
- package/skills/debug/SKILL.md +49 -0
- package/skills/perf/SKILL.md +42 -0
- package/skills/refactor/SKILL.md +40 -0
- package/skills/security-review/SKILL.md +71 -0
- package/skills/test-first/SKILL.md +50 -0
- package/src/agent.js +242 -26
- package/src/cli.js +137 -23
- package/src/commands.js +1 -0
- package/src/config.js +34 -3
- package/src/desktop.js +25 -46
- package/src/direct.js +274 -0
- package/src/host/code-host.js +154 -25
- package/src/host/sandbox-launch.js +1 -1
- package/src/prompt.js +2 -2
- package/src/replay.js +8 -3
- package/src/review.js +5 -2
- package/src/selfupdate.js +112 -0
- package/src/skills.js +12 -3
- package/src/steps.js +219 -0
- package/src/subagents.js +475 -0
- package/src/tools/drafts.js +45 -0
- package/src/tools/local.js +6 -3
- package/src/tools/sandbox.js +20 -15
- package/src/ui.js +69 -4
- package/src/update.js +2 -2
package/src/agent.js
CHANGED
|
@@ -26,6 +26,8 @@ import { parseTodos, TODO_TOOL, todoBlock, todoLines, todoNudge, todoReply, todo
|
|
|
26
26
|
import { spillLongInput } from './longinput.js';
|
|
27
27
|
import { applyGoalUpdate, DEFAULT_MAX_GOAL_TURNS, GOAL_TOOL, goalBlock, goalSchema, MAX_VERIFY_FAILURES, parseVerdict, verificationMaterial, VERIFY_INSTRUCTIONS } from './goal.js';
|
|
28
28
|
import { monotonic } from './clock.js';
|
|
29
|
+
import { AGENT_TOOLS, agentSchemas, describeAgentCall } from './subagents.js';
|
|
30
|
+
import { judgmentText, listDetail, patchDetail, runDetail, writeDetail } from './steps.js';
|
|
29
31
|
|
|
30
32
|
export const REMOTE_READ_TOOLS = new Set(['search_wiki', 'read_wiki_page', 'read_memory', 'list_tasks']);
|
|
31
33
|
/** 远程工具里**直接生效**的(不是草稿):plan 档不给,ask 档先问(第二十九轮,用户定的「改状态直接做」)。 */
|
|
@@ -146,6 +148,9 @@ function shellPolicy(ctx) {
|
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
/** 这一轮给模型哪些工具。 */
|
|
151
|
+
/** 这一次下发的 shell 有没有 background(toolset 里同一个条件) */
|
|
152
|
+
const offeredBackground = (ctx) => Boolean(ctx.local && ctx.local.sandbox && !ctx.local.hosted);
|
|
153
|
+
|
|
149
154
|
export function toolset(ctx) {
|
|
150
155
|
const plan = ctx.mode === 'plan';
|
|
151
156
|
const out = [];
|
|
@@ -159,7 +164,8 @@ export function toolset(ctx) {
|
|
|
159
164
|
if (!plan || READ_TOOLS.has(t.function.name)) out.push({ schema: t, kind: 'local' });
|
|
160
165
|
}
|
|
161
166
|
// 服务器上没有后台命令(第一百一十七轮):要管它们活多久、占多少,先不给
|
|
162
|
-
|
|
167
|
+
// 子 agent 也给(第一百二十六轮,用户定的:子 agent 能跑命令;但它停下之前,它自己起的后台命令要先停下 —— 见 subagents.js 的 settle)
|
|
168
|
+
if (sandboxed) for (const t of shellSchemas({ background: offeredBackground(ctx) })) out.push({ schema: t, kind: 'local' });
|
|
163
169
|
for (const t of ctx.remoteTools || []) {
|
|
164
170
|
if (!plan || REMOTE_READ_TOOLS.has(t.function.name)) out.push({ schema: t, kind: 'remote' });
|
|
165
171
|
}
|
|
@@ -182,6 +188,8 @@ export function toolset(ctx) {
|
|
|
182
188
|
// exec 的只读没有人批准计划:把结论写出来就是了(第一百一十三轮)
|
|
183
189
|
if (plan && !ctx.execReadOnly) out.push({ schema: exitPlanSchema(), kind: 'plan' });
|
|
184
190
|
if (ctx.goal && ctx.goal.status === 'active') out.push({ schema: goalSchema(), kind: 'goal' });
|
|
191
|
+
// 子 agent(第一百二十三轮):只有主 agent 有,子 agent 不能再派(深度 1);命令行建了 ctx.agents 才有(网页的代码模式这一轮不给)
|
|
192
|
+
if (ctx.agents && !ctx.subagent) for (const t of agentSchemas()) out.push({ schema: t, kind: 'agent' });
|
|
185
193
|
// 同名只留第一个:本地工具优先于远程 / MCP(MCP 名字有前缀,本不会撞)
|
|
186
194
|
const seen = new Set();
|
|
187
195
|
return out.filter((t) => (seen.has(t.schema.function.name) ? false : seen.add(t.schema.function.name)));
|
|
@@ -273,6 +281,8 @@ function truncatedArgs(raw) {
|
|
|
273
281
|
// ── 模型调用 ─────────────────────────────────────────────────────────────
|
|
274
282
|
|
|
275
283
|
export const MODEL_RETRY_WAITS = [1000, 3000];
|
|
284
|
+
/** 一段思考在步骤里最多留多少字(点开看的,不是给模型的) */
|
|
285
|
+
export const THINKING_KEEP = 6000;
|
|
276
286
|
|
|
277
287
|
/**
|
|
278
288
|
* 调模型,失败了在安全的时候重来:只有「还没收到任何实质输出」且错误是临时性的(连接断开、服务器 5xx / 429、
|
|
@@ -314,6 +324,17 @@ async function callModelOnce(ctx, tools, signal, { render = true, maxTokens = DE
|
|
|
314
324
|
};
|
|
315
325
|
if (ctx.effort) body.reasoning = ctx.effort; // 不说就听服务端的默认
|
|
316
326
|
let thought = 0;
|
|
327
|
+
let thinkingFrom = null;
|
|
328
|
+
let thinkingMs = 0;
|
|
329
|
+
// 第一个正文 / 工具调用到了:思考到此为止(第一百二十八轮)。原来只在 delta / tool_call 里算,
|
|
330
|
+
// 模型想完直接调工具、一个字正文都没写时 thinkingMs 一直是 0 —— 网页上那一步成了「思考 1 秒」,
|
|
331
|
+
// 看着像没思考(用户:「思考过程反而不见了」)。现在收尾时也按「第一个思考字到此刻」算
|
|
332
|
+
const stopThinking = () => {
|
|
333
|
+
if (thinkingFrom != null && !thinkingMs) thinkingMs = monotonic() - thinkingFrom;
|
|
334
|
+
};
|
|
335
|
+
// 思考的原文(第一百二十八轮:网页上「思考 100 秒」点开看得到想了什么)—— 只在有人要(stepLog)时才攒,攒到上限为止
|
|
336
|
+
const keepThinking = Boolean(ctx.stepLog);
|
|
337
|
+
let thinkingText = '';
|
|
317
338
|
// 安静模式不边收边打:这时断了,已经收到的那一段就这么没了(第二十二轮实测:回答到一半服务器重启,只剩一句「断开了」)。
|
|
318
339
|
// 把它打出来 —— 用户至少看得到说到哪了。断的两种样子都要:连接出错(下面的 catch)、流好好结束了却没有 done
|
|
319
340
|
const showPartial = () => {
|
|
@@ -323,8 +344,8 @@ async function callModelOnce(ctx, tools, signal, { render = true, maxTokens = DE
|
|
|
323
344
|
partial.feed(text);
|
|
324
345
|
partial.finish();
|
|
325
346
|
};
|
|
326
|
-
|
|
327
|
-
|
|
347
|
+
// 直连供应商(第一百二十九轮):配了 direct 就不经服务器,事件形状两边一样(见 direct.js)
|
|
348
|
+
const onEvent = (name, data) => {
|
|
328
349
|
if (name !== 'start') stopWaiting();
|
|
329
350
|
if (name === 'start') {
|
|
330
351
|
if (data.droppedMessages > 0 || data.elidedToolOutputs > 0) {
|
|
@@ -334,12 +355,16 @@ async function callModelOnce(ctx, tools, signal, { render = true, maxTokens = DE
|
|
|
334
355
|
} else if (name === 'reasoning') {
|
|
335
356
|
// 思考不打进终端(要回传的内容在 done 的 message 里,见 HarnessModelGateway),只说它在想:不然模型想一分钟,看着像卡死了
|
|
336
357
|
thought += String(data.text || '').length;
|
|
358
|
+
if (keepThinking && thinkingText.length < THINKING_KEEP) thinkingText += String(data.text || '').slice(0, THINKING_KEEP - thinkingText.length);
|
|
359
|
+
if (thinkingFrom == null) thinkingFrom = monotonic();
|
|
337
360
|
if (hush) ui.setActivity(`思考中 ${formatBytes(thought)}`);
|
|
338
361
|
else if (render) ui.status.set(ui.paint.dim(`… 思考中 ${formatBytes(thought)}`));
|
|
339
362
|
} else if (name === 'delta') {
|
|
363
|
+
stopThinking();
|
|
340
364
|
text += data.text;
|
|
341
365
|
if (md) md.feed(data.text);
|
|
342
366
|
} else if (name === 'tool_call') {
|
|
367
|
+
stopThinking();
|
|
343
368
|
toolNames.set(data.index, data.name);
|
|
344
369
|
if (md && md.wroteAnything) md.finish(); // 正文说完了才轮到工具:先把这段收尾换行
|
|
345
370
|
if (hush) ui.setActivity(`准备 ${data.name}`);
|
|
@@ -351,7 +376,10 @@ async function callModelOnce(ctx, tools, signal, { render = true, maxTokens = DE
|
|
|
351
376
|
} else if (name === 'done') {
|
|
352
377
|
done = data;
|
|
353
378
|
}
|
|
354
|
-
}
|
|
379
|
+
};
|
|
380
|
+
try {
|
|
381
|
+
if (ctx.modelClient) await ctx.modelClient.stream(body, onEvent, { signal });
|
|
382
|
+
else await ctx.api.stream('/api/harness/model/stream', body, onEvent, { signal });
|
|
355
383
|
} catch (e) {
|
|
356
384
|
if (!(e && e.name === 'AbortError')) showPartial();
|
|
357
385
|
throw e;
|
|
@@ -371,7 +399,10 @@ async function callModelOnce(ctx, tools, signal, { render = true, maxTokens = DE
|
|
|
371
399
|
// 输入里命中供应商前缀缓存的部分(第九十三轮):网关在供应商报了时才给;命中的按命中价算钱
|
|
372
400
|
ctx.usage.cached = (ctx.usage.cached || 0) + (done.usage ? done.usage.cachedTokens || 0 : 0);
|
|
373
401
|
}
|
|
374
|
-
|
|
402
|
+
if (ctx.onUsage && done.usage) ctx.onUsage(done.usage);
|
|
403
|
+
// 这一次想了多久(第一百二十七轮:网页回答里「思考 N 秒」那一步)—— 从第一个思考字到第一个正文 / 工具调用
|
|
404
|
+
stopThinking();
|
|
405
|
+
return { ...done, text, maxTokens: done.maxTokens || maxTokens, thought: { chars: thought, ms: thinkingMs, text: thinkingText } };
|
|
375
406
|
}
|
|
376
407
|
|
|
377
408
|
/** /usage 里输入后面那一句「(缓存命中 N,P%)」;没命中、供应商没报就不说。 */
|
|
@@ -519,6 +550,7 @@ export function describeCall(name, args) {
|
|
|
519
550
|
case 'load_skill': return `读取 skill ${args.name}${args.file ? ` / ${args.file}` : ''}`;
|
|
520
551
|
case 'find_mcp_tool': return `找 MCP 工具「${args.query}」`;
|
|
521
552
|
case 'exit_plan_mode': return '提交计划';
|
|
553
|
+
case 'spawn_agent': case 'wait_agent': case 'send_input': case 'close_agent': return describeAgentCall(name, args);
|
|
522
554
|
case TODO_TOOL: return '更新任务清单';
|
|
523
555
|
case GOAL_TOOL: return `目标状态:${args.status}`;
|
|
524
556
|
case 'search_wiki': return wikiSearchLabel(args);
|
|
@@ -581,11 +613,17 @@ function hushedUi(ui) {
|
|
|
581
613
|
function refuseUnoffered(ctx, name, offered) {
|
|
582
614
|
const ui = ctx.ui;
|
|
583
615
|
const available = [...offered.keys()].join('、');
|
|
616
|
+
if (ctx.subagent && AGENT_TOOLS.has(name)) {
|
|
617
|
+
ui.step(`${name}(子 agent 不能再派子 agent)`);
|
|
618
|
+
ui.result('已拒绝', false);
|
|
619
|
+
return `你是子 agent,不能再派子 agent:自己做完,把结论交回去。能用的工具:${available}。`;
|
|
620
|
+
}
|
|
584
621
|
if (existingToolNames(ctx).has(name)) {
|
|
585
622
|
ui.step(`${name}(${ctx.mode} 档不给这个工具)`);
|
|
586
623
|
ui.result('已拒绝', false);
|
|
587
624
|
return `${name} 在当前的 ${ctx.mode} 档没有下发,没有执行。`
|
|
588
|
-
+ (ctx.
|
|
625
|
+
+ (ctx.subagent && ctx.subagent.role === 'explorer' ? '你是只读探索的子 agent:不能改文件、不能跑会改东西的命令。把要改什么写进结论交回去,由主 agent 决定。'
|
|
626
|
+
: ctx.execReadOnly ? '现在是只读的(zhiqu exec 默认):改不了文件。把结论写出来;用户要改的话得用 --mode auto 重跑。'
|
|
589
627
|
: ctx.mode === 'plan' ? '现在是只读的 plan 档:想好之后用 exit_plan_mode 提交计划,用户批准后才能写、才能跑。' : '')
|
|
590
628
|
+ `这一档能用的:${available}。`;
|
|
591
629
|
}
|
|
@@ -631,8 +669,20 @@ const windowReminder = (p, offset) => `你在接着上一次往下读 ${p}(第
|
|
|
631
669
|
* 不调工具的那条回复之后还要不要推一句(清单没做完)。在问用户问题、最后一轮不推。
|
|
632
670
|
* 要推的话那条先不当结论打出来 —— 原来安静模式下先打一遍结论、推完再打一遍(第八十九轮真模型试出来的)。
|
|
633
671
|
*/
|
|
672
|
+
/** 子 agent 自己起的、还在跑的后台命令 */
|
|
673
|
+
function runningOwnJobs(ctx) {
|
|
674
|
+
if (!ctx.ownJobs || !ctx.ownJobs.size || !ctx.local || !ctx.local.jobs) return [];
|
|
675
|
+
return ctx.local.jobs.running().map((j) => j.id).filter((id) => ctx.ownJobs.has(id));
|
|
676
|
+
}
|
|
677
|
+
const ownJobsNudge = (ids) => `(你起的后台命令 ${ids.join('、')} 还在跑。交结论之前先处理掉:要结果就用 shell_output 看完,不用了就 shell_stop 停掉。`
|
|
678
|
+
+ '你交了结论就算停下,到时还在跑的会被停掉。)';
|
|
679
|
+
|
|
634
680
|
function pendingNudge(ctx, text, { nudged, claimChecked, lastRound }) {
|
|
635
681
|
if (lastRound || /[??]\s*$/.test((text || '').trim())) return null;
|
|
682
|
+
// 派出去的子 agent 还在跑就收尾:推一次去 wait_agent(第一百二十三轮)
|
|
683
|
+
if (ctx.agents && !ctx.subagent && !ctx.agents.nudged && ctx.agents.running().length) return 'agents';
|
|
684
|
+
// 子 agent 要交结论时它起的后台命令还在跑:推一次「看完输出、停掉」(第一百二十六轮);还不停,交结论时由 settle 停掉
|
|
685
|
+
if (ctx.subagent && !ctx.jobsNudged && runningOwnJobs(ctx).length) return 'jobs';
|
|
636
686
|
if (!nudged && ctx.mode !== 'plan' && unfinished(ctx.todos).length) return 'todo';
|
|
637
687
|
// 交活时说的和这一轮做的对不上:宿主给的核对(第九十五轮:网页代码模式里一个工具没调就说「已生成三个文件的草稿」),只推一次。命令行里没有这个钩子
|
|
638
688
|
if (!claimChecked && ctx.claimCheck && ctx.claimCheck(text)) return 'claim';
|
|
@@ -693,17 +743,63 @@ const sameEditReminder = (file) => (n) => `你已经连续 ${n} 次没能改成
|
|
|
693
743
|
const runReminder = (line) => (n) => `同一条命令 ${line} 已经连续 ${n} 次失败。先把上面的错误信息完整读一遍,找出根因(不是症状)、`
|
|
694
744
|
+ '想清楚要改哪里再改,一次改对;如果是环境问题或者需要用户决定,就停下来说明,不要换着花样重跑。';
|
|
695
745
|
|
|
746
|
+
/**
|
|
747
|
+
* 一次工具调用记成一步(第一百二十七轮):开始一条、结束一条。各分支用 ctx.stepNote({...}) 补结果 / detail,
|
|
748
|
+
* 没补的就按给模型的那句话当小结。没有 ctx.stepLog(命令行的终端)就什么都不做 —— 终端的显示不变。
|
|
749
|
+
*/
|
|
696
750
|
async function executeTool(ctx, call, offered, signal) {
|
|
751
|
+
const log = ctx.stepLog;
|
|
752
|
+
if (!log) return runTool(ctx, call, offered, signal);
|
|
753
|
+
const name = call.function && call.function.name;
|
|
754
|
+
const parsed = parseArgs(call.function.arguments);
|
|
755
|
+
const step = log.start(name || '', parsed.ok ? parsed.value : {});
|
|
756
|
+
let note = null;
|
|
757
|
+
ctx.stepNote = (n) => { note = { ...(note || {}), ...n }; };
|
|
758
|
+
try {
|
|
759
|
+
const content = await runTool(ctx, call, offered, signal);
|
|
760
|
+
const n = note || {};
|
|
761
|
+
log.finish(step, { summary: n.summary ?? firstLineOf(content), ok: n.ok ?? true, detail: n.detail, kind: n.kind, title: n.title });
|
|
762
|
+
return content;
|
|
763
|
+
} catch (e) {
|
|
764
|
+
log.finish(step, { summary: e && e.message ? e.message : String(e), ok: false });
|
|
765
|
+
throw e;
|
|
766
|
+
} finally {
|
|
767
|
+
ctx.stepNote = null;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const firstLineOf = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
|
|
771
|
+
/** 远程工具的结果多半是一整段 JSON(read_wiki_page 是 {title, content: 正文}):不拿 JSON 当小结(review),只说读到了什么 */
|
|
772
|
+
function remoteSummary(content) {
|
|
773
|
+
const text = String(content || '').trim();
|
|
774
|
+
if (!text.startsWith('{') && !text.startsWith('[')) return firstLineOf(text);
|
|
775
|
+
try {
|
|
776
|
+
const v = JSON.parse(text);
|
|
777
|
+
if (Array.isArray(v)) return `${v.length} 条`;
|
|
778
|
+
if (Array.isArray(v.results)) return `${v.results.length} 条`;
|
|
779
|
+
if (Array.isArray(v.pages)) return `${v.pages.length} 页`;
|
|
780
|
+
if (typeof v.title === 'string') return `「${v.title.slice(0, 60)}」`;
|
|
781
|
+
return '完成';
|
|
782
|
+
} catch {
|
|
783
|
+
return '完成';
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
/** 改动只是草稿(网页选了文件夹);生成到产物(ArtifactTools 继承了 drafts 这个 Map)写的是真的 */
|
|
787
|
+
const isDraft = (local) => Boolean(local && local.drafts && !local.artifact);
|
|
788
|
+
/** 有就记(runTool 里的各分支用)。n 可以是函数:要算 diff 的那几种只在真的要记时才算(终端里没有 stepLog,不白算) */
|
|
789
|
+
const stepNote = (ctx, n) => { if (ctx.stepNote) ctx.stepNote(typeof n === 'function' ? n() : n); };
|
|
790
|
+
|
|
791
|
+
async function runTool(ctx, call, offered, signal) {
|
|
697
792
|
const name = call.function && call.function.name;
|
|
698
793
|
// 读 / 搜 / 列目录、没下发的名字、参数坏了 —— 都是过程,安静模式下只在状态行里一闪,不留成行
|
|
699
794
|
const hush = quiet(ctx) && (QUIET_TOOLS.has(name) || !offered.get(name));
|
|
700
795
|
const ui = hush ? hushedUi(ctx.ui) : ctx.ui;
|
|
701
796
|
const kind = offered.get(name);
|
|
702
|
-
if (!kind) return refuseUnoffered({ ...ctx, ui }, name, offered);
|
|
797
|
+
if (!kind) { stepNote(ctx, { ok: false, summary: '没有这个工具 / 这一档不给' }); return refuseUnoffered({ ...ctx, ui }, name, offered); }
|
|
703
798
|
const parsed = parseArgs(call.function.arguments);
|
|
704
799
|
if (!parsed.ok) {
|
|
705
800
|
ui.step(`${name}(参数不对)`);
|
|
706
801
|
ui.result(parsed.error, false);
|
|
802
|
+
stepNote(ctx, { ok: false, summary: `参数不对:${parsed.error}` });
|
|
707
803
|
return `参数不是合法的 JSON(${parsed.error}),没有执行。请重新调用。`;
|
|
708
804
|
}
|
|
709
805
|
const args = parsed.value;
|
|
@@ -720,23 +816,30 @@ async function executeTool(ctx, call, offered, signal) {
|
|
|
720
816
|
// 读文件按这个模型一次能看的量给(留一点给头部):否则它以为看全了,其实后半截被截断了
|
|
721
817
|
const cap = toolOutputCap(ctx.model ? ctx.model.effectiveContextWindow : null) - 300;
|
|
722
818
|
const r = name === 'list_files' ? local.listFiles(args) : name === 'read_file' ? local.readFile(args, { maxChars: cap }) : local.search(args);
|
|
723
|
-
if (r.error) { ui.result(r.error, false); return r.error; }
|
|
819
|
+
if (r.error) { ui.result(r.error, false); stepNote(ctx, { ok: false, summary: r.error }); return r.error; }
|
|
724
820
|
ui.result(r.summary);
|
|
821
|
+
// list_files 的头一行是「src:」这样的说明,search 没有(review:原来一律去掉头一行,搜索的第一条命中没了)
|
|
822
|
+
stepNote(ctx, () => ({ summary: r.summary, detail: name === 'read_file' ? { path: String(args.path || '') }
|
|
823
|
+
: listDetail(name === 'list_files' ? r.content.split('\n').slice(1).join('\n') : r.content) }));
|
|
725
824
|
return r.content;
|
|
726
825
|
}
|
|
727
826
|
if (name === 'write_file' || name === 'edit_file') {
|
|
728
827
|
const spinKey = `write:${String(args.path || '')}`;
|
|
729
828
|
const prep = name === 'edit_file' ? local.prepareEdit(args) : local.prepareWrite(args);
|
|
730
|
-
if (prep.error) { ui.result(prep.error, false); return failed(ctx, spinKey, prep.error, (prep.unchanged ? sameEditReminder : writeReminder)(args.path)); }
|
|
829
|
+
if (prep.error) { ui.result(prep.error, false); stepNote(ctx, { ok: false, summary: prep.error }); return failed(ctx, spinKey, prep.error, (prep.unchanged ? sameEditReminder : writeReminder)(args.path)); }
|
|
731
830
|
if (prep.newDirectories.length) ui.note(` (会连同新建目录 ${prep.newDirectories.join('、')})`);
|
|
732
831
|
if (!(await confirmWrite(ctx, prep))) {
|
|
733
832
|
ui.result('用户没有同意,没写', false);
|
|
833
|
+
stepNote(ctx, { ok: false, summary: '用户没有同意,没写' });
|
|
734
834
|
return `用户没有同意写入 ${prep.rel},文件没有改动。不要原样重试;问问用户想怎么改,或者换个做法。`;
|
|
735
835
|
}
|
|
736
836
|
const r = local.commitWrite(prep);
|
|
737
|
-
if (r.error) { ui.result(r.error, false); return r.error; }
|
|
837
|
+
if (r.error) { ui.result(r.error, false); stepNote(ctx, { ok: false, summary: r.error }); return r.error; }
|
|
738
838
|
succeeded(ctx, spinKey);
|
|
739
839
|
ctx.changedFiles.add(prep.rel);
|
|
840
|
+
// 新建的算 create,改已有的(edit_file、覆盖、追加)算 edit;草稿模式另说一句「草稿,没落盘」
|
|
841
|
+
stepNote(ctx, () => ({ kind: prep.creating ? 'create' : 'edit', title: prep.creating ? '新建' : prep.kind === 'append' ? '追加' : '修改',
|
|
842
|
+
summary: `+${r.added} -${r.removed}${isDraft(local) ? '(草稿)' : ''}${r.syntax ? ',语法错误' : ''}`, ok: !r.syntax, detail: writeDetail(prep, r) }));
|
|
740
843
|
ui.result(ui.paint.green(`✓ ${r.content.split('(')[0]} +${r.added} -${r.removed}`));
|
|
741
844
|
if (r.syntax) ui.result(`语法错误:${r.syntax.split('\n')[0]}`, false);
|
|
742
845
|
return r.content;
|
|
@@ -745,78 +848,119 @@ async function executeTool(ctx, call, offered, signal) {
|
|
|
745
848
|
const spinKey = `patch:${String(args.patch || '').slice(0, 300)}`;
|
|
746
849
|
const shown = patchFiles(args.patch).join('、') || '补丁';
|
|
747
850
|
const prep = local.preparePatch(args);
|
|
748
|
-
if (prep.error) { ui.result(prep.error, false); return failed(ctx, spinKey, prep.error, (prep.unchanged ? sameEditReminder : writeReminder)(shown)); }
|
|
851
|
+
if (prep.error) { ui.result(prep.error, false); stepNote(ctx, { ok: false, summary: prep.error }); return failed(ctx, spinKey, prep.error, (prep.unchanged ? sameEditReminder : writeReminder)(shown)); }
|
|
749
852
|
if (!(await confirmPatch(ctx, prep))) {
|
|
750
853
|
ui.result('用户没有同意,没改', false);
|
|
854
|
+
stepNote(ctx, { ok: false, summary: '用户没有同意,没改' });
|
|
751
855
|
return `用户没有同意这个补丁,${shown} 都没有改动。不要原样重试;问问用户想怎么改,或者换个做法。`;
|
|
752
856
|
}
|
|
753
857
|
const r = local.commitPatch(prep);
|
|
754
|
-
if (r.error) { ui.result(r.error, false); return r.error; }
|
|
858
|
+
if (r.error) { ui.result(r.error, false); stepNote(ctx, { ok: false, summary: r.error }); return r.error; }
|
|
755
859
|
succeeded(ctx, spinKey);
|
|
756
860
|
for (const rel of r.changed) ctx.changedFiles.add(rel);
|
|
861
|
+
stepNote(ctx, () => ({ summary: `${r.summary}${isDraft(local) ? '(草稿)' : ''}${r.syntax ? ',语法错误' : ''}`, ok: !r.syntax, detail: patchDetail(prep) }));
|
|
757
862
|
ui.result(ui.paint.green(`✓ ${r.summary}`));
|
|
758
863
|
if (r.syntax) ui.result(`语法错误:${r.syntax.split('\n')[0]}`, false);
|
|
759
864
|
return r.content;
|
|
760
865
|
}
|
|
761
866
|
if (name === 'delete_file') {
|
|
762
867
|
const prep = local.prepareDelete(args);
|
|
763
|
-
if (prep.error) { ui.result(prep.error, false); return prep.error; }
|
|
868
|
+
if (prep.error) { ui.result(prep.error, false); stepNote(ctx, { ok: false, summary: prep.error }); return prep.error; }
|
|
764
869
|
if (!(await confirmDelete(ctx, prep))) {
|
|
765
870
|
ui.result('用户没有同意,没删', false);
|
|
871
|
+
stepNote(ctx, { ok: false, summary: '用户没有同意,没删' });
|
|
766
872
|
return `用户没有同意删除 ${prep.rel},文件还在。不要原样重试;问问用户想怎么处理。`;
|
|
767
873
|
}
|
|
768
874
|
const r = local.commitDelete(prep);
|
|
769
|
-
if (r.error) { ui.result(r.error, false); return r.error; }
|
|
875
|
+
if (r.error) { ui.result(r.error, false); stepNote(ctx, { ok: false, summary: r.error }); return r.error; }
|
|
770
876
|
ctx.changedFiles.add(`${prep.rel}(已删除)`);
|
|
877
|
+
stepNote(ctx, { summary: `${prep.lines} 行,挪进 .zhiqu/trash/` });
|
|
771
878
|
ui.result(ui.paint.green(`✓ 已删除 ${prep.rel}`) + ui.paint.dim(` → ${r.trashed}`));
|
|
772
879
|
return r.content;
|
|
773
880
|
}
|
|
774
881
|
if (name === 'shell') {
|
|
775
882
|
const spinKey = `shell:${args.command}${args.cwd ? `@${args.cwd}` : ''}`;
|
|
776
883
|
const prep = local.prepareShell(args);
|
|
777
|
-
if (prep.error) { ui.result(prep.error, false); return failed(ctx, spinKey, prep.error, runReminder(String(args.command))); }
|
|
884
|
+
if (prep.error) { ui.result(prep.error, false); stepNote(ctx, { ok: false, summary: prep.error }); return failed(ctx, spinKey, prep.error, runReminder(String(args.command))); }
|
|
778
885
|
if (ctx.mode === 'ask' && !(await confirmShell(ctx, prep))) {
|
|
779
886
|
ui.result('用户没有同意,没运行', false);
|
|
887
|
+
stepNote(ctx, { ok: false, summary: '用户没有同意,没运行' });
|
|
780
888
|
return `用户没有同意运行 ${prep.command}。不要原样重试;问问用户,或者换个做法。`;
|
|
781
889
|
}
|
|
782
|
-
|
|
890
|
+
// 没下发 background(服务器上)就不当后台命令起:模型照着别处的说法传了 background: true,原来照样起了(第一百二十六轮 review)
|
|
891
|
+
if (args.background && offeredBackground(ctx)) {
|
|
783
892
|
const job = await local.startJob(prep, { policy: shellPolicy(ctx), signal });
|
|
784
|
-
if (job.error) { ui.result(job.error, false); return failed(ctx, spinKey, job.error, runReminder(prep.command)); }
|
|
893
|
+
if (job.error) { ui.result(job.error, false); stepNote(ctx, { ok: false, summary: job.error }); return failed(ctx, spinKey, job.error, runReminder(prep.command)); }
|
|
785
894
|
succeeded(ctx, spinKey);
|
|
895
|
+
stepNote(ctx, { summary: `${job.id} ${job.snap.running ? '在后台跑着' : `结束了(退出码 ${job.snap.exitCode})`}`, ok: job.snap.running || job.snap.exitCode === 0,
|
|
896
|
+
detail: runDetail(prep, { output: job.snap.output, exitCode: job.snap.running ? null : job.snap.exitCode }) });
|
|
897
|
+
// 子 agent 起的记在它自己名下:它停下之前要先停掉(subagents.js 的 settle)
|
|
898
|
+
if (ctx.ownJobs) ctx.ownJobs.add(job.id);
|
|
786
899
|
ui.result(`${job.id} 在后台${job.snap.running ? '跑着' : `结束了(退出码 ${job.snap.exitCode})`}`, job.snap.running || job.snap.exitCode === 0);
|
|
787
900
|
return job.content;
|
|
788
901
|
}
|
|
789
902
|
let tail = '';
|
|
790
903
|
const onOutput = (s) => { tail = (tail + s).slice(-4000); };
|
|
791
|
-
|
|
904
|
+
// 跑命令期间状态行说「运行中 N 秒 · 命令」(第一百二十九轮):原来一直是「思考中」,分不清在等模型还是在等命令
|
|
905
|
+
ui.startRun(prep.command);
|
|
906
|
+
let r;
|
|
907
|
+
try {
|
|
908
|
+
r = await local.commitShell(prep, { policy: shellPolicy(ctx), signal, onOutput });
|
|
909
|
+
} finally {
|
|
910
|
+
ui.endRun();
|
|
911
|
+
}
|
|
792
912
|
// 被沙箱挡住:只有 ask 档、用户当场点了才不带沙箱再跑一次(默认不)。plan、auto 只把边界告诉模型
|
|
793
913
|
if (r.blocked && ctx.mode === 'ask' && await confirmUnsandboxed(ctx, prep)) {
|
|
794
|
-
|
|
914
|
+
ui.startRun(prep.command);
|
|
915
|
+
try {
|
|
916
|
+
r = await local.commitShell(prep, { unsandboxed: true, signal, onOutput });
|
|
917
|
+
} finally {
|
|
918
|
+
ui.endRun();
|
|
919
|
+
}
|
|
795
920
|
}
|
|
796
921
|
for (const l of shownOutput(r.output.split('\n').filter((x) => x.trim()))) ui.line(` ${ui.paint.dim(termSafe(l.length > 200 ? `${l.slice(0, 200)}…` : l, { singleLine: true }))}`);
|
|
797
922
|
ui.result(r.summary, r.exitCode === 0);
|
|
923
|
+
stepNote(ctx, { summary: r.summary, ok: r.exitCode === 0, detail: runDetail(prep, r) });
|
|
798
924
|
if (r.exitCode === 0) { succeeded(ctx, spinKey); return r.content; }
|
|
799
925
|
return failed(ctx, spinKey, r.content, runReminder(prep.command));
|
|
800
926
|
}
|
|
801
927
|
if (name === 'shell_output' || name === 'shell_stop') {
|
|
928
|
+
// 子 agent 只管自己起的(别人的 dev server 它停不得)
|
|
929
|
+
if (ctx.ownJobs && !ctx.ownJobs.has(String(args.id || ''))) {
|
|
930
|
+
const mine = [...ctx.ownJobs];
|
|
931
|
+
const msg = `${args.id} 不是你起的后台命令${mine.length ? `:你起的是 ${mine.join('、')}` : ':你还没起过后台命令'}。`;
|
|
932
|
+
ui.result(msg, false);
|
|
933
|
+
stepNote(ctx, { ok: false, summary: msg });
|
|
934
|
+
return msg;
|
|
935
|
+
}
|
|
802
936
|
const r = name === 'shell_output' ? await local.readJob(args, { signal }) : await local.stopJob(args, { signal });
|
|
803
|
-
if (r.error) { ui.result(r.error, false); return r.error; }
|
|
937
|
+
if (r.error) { ui.result(r.error, false); stepNote(ctx, { ok: false, summary: r.error }); return r.error; }
|
|
804
938
|
ui.result(r.snap.running ? `${r.snap.id} 还在跑` : `${r.snap.id} 已结束(退出码 ${r.snap.exitCode})`);
|
|
939
|
+
stepNote(ctx, { summary: r.snap.running ? `${r.snap.id} 还在跑` : `${r.snap.id} 已结束(退出码 ${r.snap.exitCode})`, ok: r.snap.running || r.snap.exitCode === 0,
|
|
940
|
+
detail: runDetail({ command: r.snap.command || '' }, { output: r.snap.output, exitCode: r.snap.running ? null : r.snap.exitCode }) });
|
|
805
941
|
return r.content;
|
|
806
942
|
}
|
|
807
943
|
if (name === 'run_command') {
|
|
808
944
|
const line = [args.command, ...(Array.isArray(args.args) ? args.args : [])].join(' ');
|
|
809
945
|
const spinKey = `run:${line}${args.cwd ? `@${args.cwd}` : ''}`;
|
|
810
946
|
const prep = local.prepareRun(args);
|
|
811
|
-
if (prep.error) { ui.result(prep.error, false); return failed(ctx, spinKey, prep.error, runReminder(line)); }
|
|
947
|
+
if (prep.error) { ui.result(prep.error, false); stepNote(ctx, { ok: false, summary: prep.error }); return failed(ctx, spinKey, prep.error, runReminder(line)); }
|
|
812
948
|
if (!(await confirmRun(ctx, prep))) {
|
|
813
949
|
ui.result('用户没有同意,没运行', false);
|
|
950
|
+
stepNote(ctx, { ok: false, summary: '用户没有同意,没运行' });
|
|
814
951
|
return `用户没有同意运行 ${[prep.command, ...prep.args].join(' ')}。不要原样重试;问问用户,或者换个做法。`;
|
|
815
952
|
}
|
|
816
953
|
let tail = '';
|
|
817
|
-
|
|
954
|
+
ui.startRun(line);
|
|
955
|
+
let r;
|
|
956
|
+
try {
|
|
957
|
+
r = await local.commitRun(prep, { signal, onOutput: (s) => { tail = (tail + s).slice(-4000); } });
|
|
958
|
+
} finally {
|
|
959
|
+
ui.endRun();
|
|
960
|
+
}
|
|
818
961
|
for (const l of shownOutput(r.output.split('\n').filter((x) => x.trim()))) ui.line(` ${ui.paint.dim(termSafe(l.length > 200 ? `${l.slice(0, 200)}…` : l, { singleLine: true }))}`);
|
|
819
962
|
ui.result(r.summary, r.exitCode === 0);
|
|
963
|
+
stepNote(ctx, { summary: r.summary, ok: r.exitCode === 0, detail: runDetail(prep, r) });
|
|
820
964
|
if (r.exitCode === 0) { succeeded(ctx, spinKey); return r.content; }
|
|
821
965
|
return failed(ctx, spinKey, r.content, runReminder(line));
|
|
822
966
|
}
|
|
@@ -832,9 +976,11 @@ async function executeTool(ctx, call, offered, signal) {
|
|
|
832
976
|
: await ctx.api.post('/api/harness/tools/call', { sessionId: ctx.session.id, name, arguments: args }, { signal, timeoutMs: 60_000 });
|
|
833
977
|
const drafts = r.drafts || [];
|
|
834
978
|
ui.result(drafts.length ? `草稿:${drafts.map((d) => d.title).join('、')} → 到网页里确认` : '完成');
|
|
979
|
+
stepNote(ctx, { summary: drafts.length ? `草稿:${drafts.map((d) => d.title).join('、')}` : remoteSummary(r.content) });
|
|
835
980
|
return r.content;
|
|
836
981
|
} catch (e) {
|
|
837
982
|
ui.result(e.message, false);
|
|
983
|
+
stepNote(ctx, { ok: false, summary: e.message });
|
|
838
984
|
// 没拿到回应(服务器可能已经做了):别说成「失败」—— 模型会换个说法再调一次;照原样再调,同一个键,不会重复
|
|
839
985
|
return e.uncertain ? `远程工具没拿到回应:${e.message}` : `远程工具调用失败:${e.message}`;
|
|
840
986
|
}
|
|
@@ -864,6 +1010,12 @@ async function executeTool(ctx, call, offered, signal) {
|
|
|
864
1010
|
if (kind === 'plan') {
|
|
865
1011
|
return approvePlan(ctx, String(args.plan || ''));
|
|
866
1012
|
}
|
|
1013
|
+
if (kind === 'agent') {
|
|
1014
|
+
const r = await ctx.agents.call(ctx, name, args, { signal });
|
|
1015
|
+
if (r.error) { ui.result(r.error, false); return r.error; }
|
|
1016
|
+
ui.result(r.summary, r.ok !== false);
|
|
1017
|
+
return r.content;
|
|
1018
|
+
}
|
|
867
1019
|
if (kind === 'todo') {
|
|
868
1020
|
const r = parseTodos(args);
|
|
869
1021
|
if (r.error) { ui.result(r.error, false); return r.error; }
|
|
@@ -871,6 +1023,8 @@ async function executeTool(ctx, call, offered, signal) {
|
|
|
871
1023
|
ctx.roundsSinceTodo = 0;
|
|
872
1024
|
const [head, ...items] = todoLines(r.todos, ui.paint);
|
|
873
1025
|
ui.result(head);
|
|
1026
|
+
// 网页里每项带状态(画成什么由前端定,不把终端的 ☑ ◐ ☐ 带过去)
|
|
1027
|
+
stepNote(ctx, { summary: head, detail: { todos: r.todos.slice(0, 30).map((t) => ({ content: String(t.content).slice(0, 300), status: t.status })) } });
|
|
874
1028
|
for (const l of items) ui.line(` ${l}`);
|
|
875
1029
|
if (ctx.store && ctx.session) ctx.store.append(ctx.session.id, { type: 'todos', todos: ctx.todos });
|
|
876
1030
|
return todoReply(r.todos);
|
|
@@ -938,6 +1092,12 @@ export async function compactNow(ctx, signal, { manual = false } = {}) {
|
|
|
938
1092
|
|
|
939
1093
|
export async function runTurn(ctx, userText, { signal, origin = null } = {}) {
|
|
940
1094
|
ctx.steps = [];
|
|
1095
|
+
// 网页存档的步骤每一轮(主 agent)重开一份:上一轮被 Ctrl+C 打断 / 模型出错时 archiveTurn 没跑,
|
|
1096
|
+
// 步骤会留在数组里,被下一轮的成功存档一起发上去 —— 网页上下一轮的回答上方会多出上一轮的步骤。
|
|
1097
|
+
// 子 agent 的 ctx 是从父 ctx 浅拷贝来的,共用这个数组,不能清(它的步骤要由父 agent 的「派 / 等」两步代表)。
|
|
1098
|
+
// 只在人说的那一句开头清:exec --output-schema 不合格式时推回去重写也走 runTurn(origin 'system'),
|
|
1099
|
+
// 那一次清掉的话,存档里只剩重写那几步、真正干活的那一段没了(第一百二十八轮 review)
|
|
1100
|
+
if (!origin && !ctx.subagent) resetArchive(ctx);
|
|
941
1101
|
ctx.spin = new Map();
|
|
942
1102
|
ctx.repeat = null;
|
|
943
1103
|
ctx.changedFiles = ctx.changedFiles || new Set();
|
|
@@ -954,12 +1114,15 @@ export async function runTurn(ctx, userText, { signal, origin = null } = {}) {
|
|
|
954
1114
|
}
|
|
955
1115
|
let finalText = '';
|
|
956
1116
|
let stalled = false;
|
|
1117
|
+
let exhausted = false; // 轮数用完了、模型还在调工具:没有结论(宿主据此照实说「用完了 N 轮」,不拿半路被推回去的那句顶上)
|
|
957
1118
|
let continuations = 0;
|
|
958
1119
|
let nudged = false;
|
|
959
1120
|
let claimChecked = false;
|
|
960
1121
|
ctx.turnStats = { read: 0, search: 0, list: 0 };
|
|
961
1122
|
ctx.readStreak = 0;
|
|
962
1123
|
ctx.readWindows = new Map(); ctx.readHinted = new Set();
|
|
1124
|
+
const lead = ctx.agents && !ctx.subagent; // 主 agent 的一句话:管着这一句派出去的子 agent
|
|
1125
|
+
if (lead) ctx.agents.beginTurn(ctx, signal);
|
|
963
1126
|
if (quiet(ctx)) ctx.ui.startThinking();
|
|
964
1127
|
try {
|
|
965
1128
|
const maxRounds = ctx.maxRounds || 60;
|
|
@@ -978,11 +1141,17 @@ export async function runTurn(ctx, userText, { signal, origin = null } = {}) {
|
|
|
978
1141
|
const res = await callModel(ctx, tools, signal);
|
|
979
1142
|
const message = res.message || { role: 'assistant', content: res.text };
|
|
980
1143
|
const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
1144
|
+
// 思考那一步在工具调用之后才收尾(第一百二十八轮):原来紧跟着模型调用就记,那时还不知道这一轮要调工具,
|
|
1145
|
+
// 网页上「思考」排在「读取 / 运行」后面 —— 和实际顺序反了。现在它排在这一次调用的工具前面,用时也照实
|
|
1146
|
+
if (ctx.stepLog && res.thought) ctx.stepLog.thinking(res.thought.chars, res.thought.ms, res.thought.text);
|
|
1147
|
+
// 调工具前的那一句(第一百三十轮):有字就马上打一行,排在工具前面。结论还是只认最后那句(下面不调工具的那条)。
|
|
1148
|
+
// --verbose 边收边打过了,这里不重复打;步骤照样记(网页 / 存档要这一条)
|
|
1149
|
+
if (calls.length) showJudgment(ctx, message.content);
|
|
981
1150
|
// 原地重复:历史和回答里只留开头一段
|
|
982
1151
|
const looping = res.finishReason === 'length' && !calls.length && repeating(message.content);
|
|
983
1152
|
if (looping) message.content = loopTrimmed(message.content);
|
|
984
1153
|
|
|
985
|
-
//
|
|
1154
|
+
// 安静模式:不调工具的那条才是结论,收完才打(被截断的结论也是结论的一段)。调工具前的那一句已经在上面打过一行
|
|
986
1155
|
const nudge = calls.length || res.finishReason === 'length' ? null
|
|
987
1156
|
: pendingNudge(ctx, message.content, { nudged, claimChecked, lastRound: round === maxRounds - 1 });
|
|
988
1157
|
if (quiet(ctx) && !calls.length && !nudge && !looping && (message.content || '').trim()) showAnswer(ctx, message.content);
|
|
@@ -1022,6 +1191,15 @@ export async function runTurn(ctx, userText, { signal, origin = null } = {}) {
|
|
|
1022
1191
|
record(ctx, { role: 'user', content: todoNudge(ctx.todos) }, 'system');
|
|
1023
1192
|
continue;
|
|
1024
1193
|
}
|
|
1194
|
+
if (nudge === 'jobs') {
|
|
1195
|
+
ctx.jobsNudged = true;
|
|
1196
|
+
record(ctx, { role: 'user', content: ownJobsNudge(runningOwnJobs(ctx)) }, 'system');
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
if (nudge === 'agents') {
|
|
1200
|
+
record(ctx, { role: 'user', content: ctx.agents.nudgeText() || '(派出去的子 agent 刚做完:用 wait_agent 拿到它们的结论再收尾。)' }, 'system');
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1025
1203
|
if (nudge === 'claim') {
|
|
1026
1204
|
claimChecked = true;
|
|
1027
1205
|
record(ctx, { role: 'user', content: ctx.claimCheck(message.content) }, 'system');
|
|
@@ -1076,15 +1254,18 @@ export async function runTurn(ctx, userText, { signal, origin = null } = {}) {
|
|
|
1076
1254
|
break;
|
|
1077
1255
|
}
|
|
1078
1256
|
if (round === maxRounds - 1) {
|
|
1257
|
+
exhausted = true;
|
|
1079
1258
|
ctx.ui.warn(`已经连续调用了 ${maxRounds} 轮工具,先停在这里。说「继续」可以接着做。`);
|
|
1080
1259
|
}
|
|
1081
1260
|
}
|
|
1082
1261
|
} finally {
|
|
1262
|
+
// 这一句结束时还在跑的子 agent 停掉,不留到下一句(Codex 有完成了的子 agent 一直占名额的 issue)
|
|
1263
|
+
if (lead) await ctx.agents.endTurn(ctx);
|
|
1083
1264
|
ctx.ui.stopThinking();
|
|
1084
1265
|
}
|
|
1085
1266
|
if (signal && signal.aborted) ctx.messages = dropDanglingToolCalls(ctx.messages);
|
|
1086
1267
|
if (ctx.store && ctx.session) ctx.store.touch(ctx.session.id, { messages: ctx.messages.length, model: ctx.model ? ctx.model.label : null });
|
|
1087
|
-
return { finalText, stalled, steps: ctx.steps.slice(), changedFiles: [...ctx.changedFiles], stats: { ...ctx.turnStats } };
|
|
1268
|
+
return { finalText, stalled, exhausted, steps: ctx.steps.slice(), changedFiles: [...ctx.changedFiles], stats: { ...ctx.turnStats } };
|
|
1088
1269
|
}
|
|
1089
1270
|
|
|
1090
1271
|
/** 在会话历史后面追加一问一答,不走模型(/review 的结论放回会话,才能接着说「修第 1 条」)。历史只追加。 */
|
|
@@ -1097,6 +1278,14 @@ export function recordExchange(ctx, userText, answer) {
|
|
|
1097
1278
|
ctx.store.touch(ctx.session.id, { title: ctx.session.title, messages: ctx.messages.length });
|
|
1098
1279
|
}
|
|
1099
1280
|
|
|
1281
|
+
/** 调工具前的那一句判断:安静模式打一行灰字;步骤里一条 note(点不开)。空白不打、不记。 */
|
|
1282
|
+
function showJudgment(ctx, text) {
|
|
1283
|
+
const said = judgmentText(text);
|
|
1284
|
+
if (!said) return;
|
|
1285
|
+
if (quiet(ctx) && ctx.ui) ctx.ui.note(`· ${said}`);
|
|
1286
|
+
if (ctx.stepLog) ctx.stepLog.note(said);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1100
1289
|
/** 结论:一次整段打(按 Markdown 渲染)。 */
|
|
1101
1290
|
function showAnswer(ctx, text) {
|
|
1102
1291
|
if (ctx.exec) return; // zhiqu exec:结论由它写到 stdout,stderr 里不再打一遍
|
|
@@ -1122,14 +1311,38 @@ export function statsNote(stats) {
|
|
|
1122
1311
|
* 失败了那一轮就永远缺在网页上。现在 archiveTurn 只入队,真正的发送串在 ctx.archiveChain 上;发不出去的留在队列里,
|
|
1123
1312
|
* 下一轮连同新的一起补;退出前 flushArchive 再等一会儿。
|
|
1124
1313
|
*/
|
|
1314
|
+
/** 存到网站上的一轮最多这么多步(和后端 TraceSteps.MAX_STEPS 一样);多出来的只记个数,网页上说「后面还有 N 步没存」 */
|
|
1315
|
+
export const ARCHIVE_STEPS = 200;
|
|
1316
|
+
|
|
1317
|
+
/** 记一步(StepLog 每次变化都叫):同一步先 running 后收尾,按 id 留最后那条;满了的只记 id、不留内容 */
|
|
1318
|
+
export function archiveStep(ctx, step) {
|
|
1319
|
+
if (!Array.isArray(ctx.archiveSteps)) ctx.archiveSteps = [];
|
|
1320
|
+
const at = ctx.archiveSteps.findIndex((x) => x.id === step.id);
|
|
1321
|
+
if (at >= 0) ctx.archiveSteps[at] = step;
|
|
1322
|
+
else if (ctx.archiveSteps.length < ARCHIVE_STEPS) ctx.archiveSteps.push(step);
|
|
1323
|
+
else (ctx.archiveOverflow || (ctx.archiveOverflow = new Set())).add(step.id);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/** 这一轮的存档步骤从头记(主 agent 每一句开头、archiveTurn 发完之后) */
|
|
1327
|
+
export function resetArchive(ctx) {
|
|
1328
|
+
if (Array.isArray(ctx.archiveSteps)) ctx.archiveSteps = [];
|
|
1329
|
+
ctx.archiveOverflow = null;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1125
1332
|
export function archiveTurn(ctx, userText, result) {
|
|
1126
1333
|
if (!ctx.api || !ctx.session || ctx.archiveDisabled) return Promise.resolve();
|
|
1127
|
-
|
|
1334
|
+
// 每一步单独给(第一百二十八轮):原来拼成一行「> 过程:列出 . · 运行 … 共 32 步」接在回答正文后面,
|
|
1335
|
+
// 网页上 heredoc 被当成 Markdown / 公式渲染成一堆乱码,也和网页代码模式那种一步一行的样子对不上(用户:「不是说所有显示都要像 codex 那样吗」)
|
|
1336
|
+
const steps = Array.isArray(ctx.archiveSteps) ? ctx.archiveSteps.slice(0, ARCHIVE_STEPS) : [];
|
|
1337
|
+
// 截断要说出来:满了以后没存的步数一起交上去(原来一声不响地只存前 200 步)
|
|
1338
|
+
const dropped = (ctx.archiveOverflow ? ctx.archiveOverflow.size : 0) + (Array.isArray(ctx.archiveSteps) ? ctx.archiveSteps.length - steps.length : 0);
|
|
1128
1339
|
ctx.archivePending = ctx.archivePending || [];
|
|
1129
1340
|
ctx.archivePending.push({
|
|
1130
1341
|
sessionId: ctx.session.id, title: ctx.session.title || userText.slice(0, 40),
|
|
1131
|
-
messages: [{ role: 'user', content: userText }, { role: 'assistant', content:
|
|
1342
|
+
messages: [{ role: 'user', content: userText }, { role: 'assistant', content: result.finalText || '(没有文字回答)',
|
|
1343
|
+
...(steps.length ? { steps } : {}), ...(dropped > 0 ? { stepsDropped: dropped } : {}) }],
|
|
1132
1344
|
});
|
|
1345
|
+
resetArchive(ctx);
|
|
1133
1346
|
ctx.archiveChain = (ctx.archiveChain || Promise.resolve()).then(() => sendPending(ctx));
|
|
1134
1347
|
return ctx.archiveChain;
|
|
1135
1348
|
}
|
|
@@ -1184,6 +1397,9 @@ export async function verifyGoal(ctx, signal) {
|
|
|
1184
1397
|
*/
|
|
1185
1398
|
export async function runGoal(ctx, { signal, maxTurns = DEFAULT_MAX_GOAL_TURNS, onTurn } = {}) {
|
|
1186
1399
|
const ui = ctx.ui;
|
|
1400
|
+
// 存到网站的步骤从这里开始记(目标的每一轮 origin 是 goal,runTurn 不替它清;每一轮存完 archiveTurn 自己清):
|
|
1401
|
+
// 上一句被打断时没存档,它的步骤不能挂到目标第一轮的回答上面
|
|
1402
|
+
resetArchive(ctx);
|
|
1187
1403
|
const goal = ctx.goal;
|
|
1188
1404
|
ctx.goalChangedFiles = ctx.goalChangedFiles || new Set();
|
|
1189
1405
|
let text = goal.turns === 0 ? `目标:${goal.text}\n开始做。` : `继续朝目标推进:${goal.text}`;
|