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/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
- if (sandboxed) for (const t of shellSchemas({ background: !ctx.local.hosted })) out.push({ schema: t, kind: 'local' });
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
- try {
327
- await ctx.api.stream('/api/harness/model/stream', body, (name, data) => {
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
- }, { signal });
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
- return { ...done, text, maxTokens: done.maxTokens || maxTokens };
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.execReadOnly ? '现在是只读的(zhiqu exec 默认):改不了文件。把结论写出来;用户要改的话得用 --mode auto 重跑。'
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
- if (args.background) {
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
- let r = await local.commitShell(prep, { policy: shellPolicy(ctx), signal, onOutput });
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
- r = await local.commitShell(prep, { unsandboxed: true, signal, onOutput });
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
- const r = await local.commitRun(prep, { signal, onOutput: (s) => { tail = (tail + s).slice(-4000); } });
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
- const steps = result.steps.length ? `\n\n> 过程:${result.steps.slice(0, 30).join(' · ')}${result.steps.length > 30 ? ` …共 ${result.steps.length} 步` : ''}` : '';
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: (result.finalText || '(没有文字回答)') + steps }],
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}`;