mocode-ai 0.7.0 → 0.7.1

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 CHANGED
@@ -14,7 +14,7 @@ MoCode isn't a chat box with a coat of paint — it's an agent that actually get
14
14
 
15
15
  - **Autonomous multi-step execution** — In a single conversation, the agent chains multiple steps on its own: read code, edit code, run tests, fix based on errors, and so on. It decides the next step without you nagging it. When it hits a decision point, it calls `ask_human` to pop up a panel and ask you (blocking until you respond).
16
16
  - **Parallel read-only tools** — Consecutive read-only operations in a turn (reading files, grep, glob, codegraph, web search/fetch) run concurrently, so total time is roughly the slowest single call instead of the sum of all of them. Operations with side effects (writing/editing files) stay sequential to preserve snapshot ordering and data safety.
17
- - **Sub-agents divide and conquer** — Complex tasks can spawn independent sub-agents, each with its own conversation history (isolated from the main thread), an optional restricted toolset, and a step cap. They can explore multiple code areas or directions in parallel and report back only a summary, which the main thread uses to decide what's next.
17
+ - **Sub-agents divide and conquer** — Complex tasks can spawn independent sub-agents, each with its own conversation history (isolated from the main thread), an optional restricted toolset, and a step cap. Sub-agent calls execute serially while they share the main workspace, preventing concurrent writes from racing; each returns only a summary to the main thread.
18
18
  - **Plan / Auto dual mode** — In `plan` mode the agent is read-only (reads code, queries indexes, searches — never writes to disk, runs commands, or spawns sub-agents) and produces a plan; `auto` mode unlocks the full toolset. The agent can switch between the two on its own — scope out an unfamiliar codebase first, then start making changes.
19
19
  - **Automatic context compression** — As the context window fills up, a three-tier compression kicks in (trim individual results → compact older tool results in place → summarize older turns), so long sessions never overflow. `/context` shows live token usage; `/compact` triggers manual compression (optionally with a focus hint to preserve what matters).
20
20
  - **Cross-session long-term memory** — The agent can save project architecture, conventions, and lessons learned as long-term memory, auto-loaded in future sessions. A background process periodically reflects on conversations to mine things worth remembering. Memories can be created, searched, updated, and forgotten, with recall-based decay.
@@ -105,6 +105,7 @@ Common backend `base_url` values:
105
105
  | `COMPACT_THRESHOLD` | Auto-compaction trigger threshold (fraction of window) | `0.85` |
106
106
  | `LLM_STREAM_USAGE` | Include `stream_options.include_usage` on streaming requests for real usage | `true` |
107
107
  | `AUTO_COMPACT` | Auto-compaction master switch | `true` |
108
+ | `MOCODE_AUTO_VALIDATE` | Auto-run the lowest-cost discovered validation command after code changes; feed failures back to the agent | `true` |
108
109
  | `AUTO_REFLECT` | Background reflection pass master switch (periodically mines memories from conversations) | `true` |
109
110
  | `REFLECT_EVERY_N` | Trigger a background reflection every N turns (runs alongside the agent, non-blocking) | `5` |
110
111
  | `ANYSEARCH_API_KEY` | Web search API key (falls back to anonymous free quota if unset) | none |
@@ -148,7 +149,7 @@ The agent operates in **the working directory it was launched from** — to have
148
149
  | `ask_human` | Pop up a Q&A panel at decision points; user picks a preset or types freely (blocks until answered) |
149
150
  | `switch_mode` | Switch between `plan` (read-only planning) and `auto` (full execution); the agent can call this itself to explore before acting |
150
151
  | `drop_context` | Replace irrelevant old tool results in history with stubs to free up context (preserves tool_call_id pairing, leaves system prompt and current turn untouched, idempotent) |
151
- | `task` | Spawn a sub-agent for an independent subtask (isolated history, optional restricted toolset, optional step cap); consecutive calls run in parallel automatically, returning only a summary |
152
+ | `task` | Spawn a sub-agent for an independent subtask (isolated history, optional restricted toolset, optional step cap); calls run serially while sharing the workspace and return only a summary |
152
153
 
153
154
  | `memory_save` | Save a piece of cross-session long-term memory (title indexed, body fetched on demand) |
154
155
  | `memory_search` | Search memory bodies by keyword; hits boost the recall count (affects forgetting decay) |
@@ -239,4 +240,4 @@ npm run typecheck # tsc --noEmit
239
240
 
240
241
  ## Future extensions
241
242
 
242
- MCP tool integration, a permission confirmation UI, and a real worktree-isolated sub-agent mode. The current version is a streaming, reasoning-visible, rollback-capable terminal coding agent with 20 tools, working-notepad planning, cross-session memory, parallel sub-agents, and an optional desktop pet.
243
+ MCP tool integration, finer-grained capability locks, and a real worktree-isolated sub-agent mode. The current version is a streaming, reasoning-visible, rollback-capable terminal coding agent with 20 tools, working-notepad planning, cross-session memory, capability-aware tool scheduling, serial workspace-sharing sub-agents, and an optional desktop pet.
package/README.zh-CN.md CHANGED
@@ -14,7 +14,7 @@ mocode 不是一个套壳聊天框,而是一个能真正动手干活的 agent:
14
14
 
15
15
  - **自主多步推进** — 一次对话里连续多步:读代码、改代码、跑测试、根据报错再改……agent 自己决定下一步,中途不用你反复催。遇到卡点会调 `ask_human` 弹面板问你(阻塞到回应)。
16
16
  - **只读工具并行执行** — 一轮里连续的只读操作(读文件、grep、glob、codegraph、联网搜索/抓取)自动并发跑,总耗时 ≈ 最慢一个,而不是逐个排队。写文件 / 改文件这类有副作用的操作仍串行,保快照顺序与数据安全。
17
- - **子 agent 分而治之** — 复杂任务可派生独立子 agent:各自有自己的对话历史(不污染主线),可限定只读工具集和步数上限,并行探查多片代码 / 多个方向,最后只把摘要回灌主线。主线据此决定下一步。
17
+ - **子 agent 分而治之** — 复杂任务可派生独立子 agent:各自有自己的对话历史(不污染主线),可限定工具集和步数上限。共享主工作区期间多个 task 串行执行,避免并发写冲突;每个子任务最后只把摘要回灌主线。
18
18
  - **计划 / 执行双模式** — `plan` 模式下只读探查(读代码、查索引、搜索,绝不写盘、不跑命令、不派生子 agent),产出计划;`auto` 模式全量工具放开。agent 还能在两者间自切换——先把陌生代码库摸清,再动手改。
19
19
  - **上下文自动压缩** — 接近窗口上限时三层压缩(单条结果裁剪 → 旧工具结果原地微压缩 → 旧对话摘要),长会话也不爆窗口;`/context` 实时显示 token 用量,`/compact` 可手动压缩(能带焦点指令聚焦保留)。
20
20
  - **跨会话长期记忆** — agent 能把项目架构、约定、踩过的坑存成长期记忆,下次会话自动加载;后台还会定期从对话里反思挖掘值得记住的事。记忆可增删改、带召回衰减。
@@ -147,7 +147,7 @@ agent 工作在**启动时所在的工作目录**——想让它操作某个项
147
147
  | `ask_human` | 决策点弹终端问答面板,用户选预设项或自由输入(阻塞至回应) |
148
148
  | `switch_mode` | 在 `plan`(只读规划)与 `auto`(全量执行)间切换;agent 可自行调用,先探查再动手 |
149
149
  | `drop_context` | 把历史里无关的旧工具结果替换为存根释放上下文(保 tool_call_id 配对,不动 system 与当前轮;幂等) |
150
- | `task` | 派生子 agent 执行独立子任务(独立历史、可受限工具集、可设步数上限);连续多个自动并行,只回摘要 |
150
+ | `task` | 派生子 agent 执行独立子任务(独立历史、可受限工具集、可设步数上限);共享工作区期间串行执行,只回摘要 |
151
151
 
152
152
  | `memory_save` | 存一条跨会话长期记忆(标题进索引,正文按需取) |
153
153
  | `memory_search` | 按关键词搜记忆正文,命中即提升召回计数(影响遗忘衰减) |
@@ -221,4 +221,4 @@ npm run typecheck # tsc --noEmit
221
221
 
222
222
  ## 可后续扩展
223
223
 
224
- MCP 工具集成、权限确认 UI、真·worktree 隔离的子 agent 模式。当前版本已是流式、思考可见、可回滚的终端编码 agent:20 个工具、工作记事本规划、跨会话记忆、并行子 agent、可选桌宠。
224
+ MCP 工具集成、更细粒度的 capability 资源锁、真·worktree 隔离的子 agent 模式。当前版本已是流式、思考可见、可回滚的终端编码 agent:20 个工具、工作记事本规划、跨会话记忆、能力感知工具调度、共享工作区串行子 agent、可选桌宠。
@@ -6,9 +6,9 @@
6
6
  // spawn.ts 的 spawnAgent = runAgentCore + 静默 hooks(子 agent)。
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { chat, estimatePromptTokens, planChatTools, chatTools, } from '../llm/index.js';
9
- import { executeTool, tools } from '../tools/registry.js';
9
+ import { executeToolOutcome, getToolCapabilities, isFileMutationTool, tools, } from '../tools/registry.js';
10
10
  import { checkPermission } from '../permissions/index.js';
11
- import { getPlanDisabledTools } from '../tools/constants.js';
11
+ import { getPlanDisabledTools, getRuntimeDisabledTools } from '../tools/constants.js';
12
12
  import { getAgentMode, setAgentMode } from './mode.js';
13
13
  import { maybeCompact, contextState, dropContextFromHistory } from '../session/index.js';
14
14
  import { createBudgetScheduler } from '../session/scheduler.js';
@@ -17,9 +17,12 @@ import { createAgeAwareEncodingState, } from '../context/age-aware.js';
17
17
  import { createRelevancePruner } from '../context/relevance.js';
18
18
  import { isToolResultSuccess } from '../context/utils.js';
19
19
  import { config } from '../config/index.js';
20
+ import { t } from '../i18n/index.js';
20
21
  import { jailResolve } from '../sandbox/index.js';
21
22
  import { createLifecycleEngine } from '../context/lifecycle.js';
22
23
  import { getTokenCalibration, updateTokenCalibration, } from '../context/token-calibration.js';
24
+ import { getCurrentTurnMutationState } from '../rollback/index.js';
25
+ import { runAutomaticValidation, } from '../verification/index.js';
23
26
  /** Stable per-history age state survives user turns; WeakMap avoids retaining closed sessions. */
24
27
  const ageAwareStateByHistory = new WeakMap();
25
28
  function ageAwareStateFor(history) {
@@ -57,17 +60,22 @@ function thrashHint(name, args, count) {
57
60
  '- edit_file → old_string mismatch; re-read the file to find the exact text\n' +
58
61
  '- otherwise → re-read the tool description; the argument shape may be wrong');
59
62
  }
60
- /** 只读工具集:一轮多个时,连续的只读工具成组 Promise.all 并行(无副作用、互不依赖)。 */
61
- const READ_TOOL_NAMES = new Set([
62
- 'read_file',
63
- 'glob',
64
- 'grep',
65
- 'codegraph',
66
- 'web_search',
67
- 'web_fetch',
68
- ]);
69
- /** mutation 工具:写盘 + 在 executeTool 内记回滚 before 快照,必须串行保快照序。 */
70
- const isMutationTool = (name) => name === 'edit_file' || name === 'write_file';
63
+ /** 只有显式声明 parallel 且无需权限确认的工具才可并发;未知扩展保守串行。 */
64
+ function isParallelTool(name) {
65
+ const tool = tools.find((candidate) => candidate.name === name);
66
+ return !!tool && (tool.risk ?? 'safe') === 'safe' &&
67
+ getToolCapabilities(tool).concurrency === 'parallel';
68
+ }
69
+ /** 文件 mutation 由 capability metadata 判定,供 diff、回滚与上下文失效共用。 */
70
+ const isMutationTool = (name) => isFileMutationTool(name);
71
+ function deniedOutcome(name) {
72
+ return {
73
+ status: 'denied',
74
+ code: 'PERMISSION_DENIED',
75
+ retryable: false,
76
+ output: `错误:用户拒绝了工具 ${name} 的执行。`,
77
+ };
78
+ }
71
79
  /** mutation 执行前读旧内容供 diff:write_file 取整文件旧内容(不存在→null=新建),
72
80
  * edit_file 取 old_string 起始行号(供 diff 显示真实文件行号)。读不到则 diff 退化为相对行号。
73
81
  * 非 mutation 或参数非法返 { preWriteOld: null, editStartLine: 1 }。失败不阻断。 */
@@ -116,8 +124,8 @@ function readDiffContext(tc, parsed) {
116
124
  * - lifecycle 也在每个 runAgentCore 实例化一次,登记 grep/glob/codegraph 等 producer
117
125
  * 与 read/edit/write 的 consumer 关系;孤立+老化自动 STUB(观察类工具永不到 STUB)。
118
126
  * - 开关关闭时 lifecycle=null 完全跳过。 */
119
- function pushToolResult(history, tc, output, pruner, lifecycle, _scheduler, runtimeContextState = contextState) {
120
- const succeeded = isToolResultSuccess(output);
127
+ function pushToolResult(history, tc, output, pruner, lifecycle, _scheduler, runtimeContextState = contextState, succeededOverride) {
128
+ const succeeded = succeededOverride ?? isToolResultSuccess(output);
121
129
  const ageAware = config.contextOptimize ? ageAwareStateFor(history) : null;
122
130
  const encodingContext = ageAware?.preparePush(tc, succeeded);
123
131
  const msg = {
@@ -158,6 +166,11 @@ export async function runAgentCore(opts) {
158
166
  // 本轮计时:从入口到完毕(正常 return / 达上限),供 finally 打 ✻ Worked for 摘要行。
159
167
  const t0 = Date.now();
160
168
  let done = false; // 正常完毕 / 达上限 true;中断 false(不显摘要)
169
+ let traceStatus = 'error';
170
+ let toolCallCount = 0;
171
+ let latestValidation;
172
+ let validatedMutationVersion = getCurrentTurnMutationState().version;
173
+ const validator = opts.validator ?? runAutomaticValidation;
161
174
  // 本轮 token 累计:每步 chat() 返回后把 result.usage 累加,供 onDone 摘要行 + AgentRunResult.usage
162
175
  // 透传给 repl(显示在底栏模式 chip 右边)。未开启 include_usage 或全失败时为 undefined。
163
176
  let turnUsage;
@@ -251,7 +264,14 @@ export async function runAgentCore(opts) {
251
264
  // 上一步工具被 abort 杀(run_command/web_fetch 等)→ signal.aborted,直接还原退出,不等 maybeCompact + chat()
252
265
  if (signal?.aborted) {
253
266
  abortRestore();
254
- return { completed: false, finalText: null };
267
+ traceStatus = 'aborted';
268
+ const mutation = getCurrentTurnMutationState();
269
+ return {
270
+ completed: false,
271
+ finalText: null,
272
+ validation: latestValidation,
273
+ changedFiles: mutation.changedFiles.map((item) => item.path),
274
+ };
255
275
  }
256
276
  // 本步只计算一次实际工具集合,调度、请求和 usage 校准必须使用完全相同的 schema。
257
277
  const activeTools = opts.toolsOverride
@@ -297,7 +317,14 @@ export async function runAgentCore(opts) {
297
317
  (e instanceof Error &&
298
318
  (e.name === 'AbortError' || e.name === 'APIUserAbortError'))) {
299
319
  abortRestore();
300
- return { completed: false, finalText: null };
320
+ traceStatus = 'aborted';
321
+ const mutation = getCurrentTurnMutationState();
322
+ return {
323
+ completed: false,
324
+ finalText: null,
325
+ validation: latestValidation,
326
+ changedFiles: mutation.changedFiles.map((item) => item.path),
327
+ };
301
328
  }
302
329
  throw e;
303
330
  }
@@ -315,6 +342,7 @@ export async function runAgentCore(opts) {
315
342
  // lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
316
343
  onContextUpdate?.();
317
344
  if (result.toolCalls.length > 0) {
345
+ toolCallCount += result.toolCalls.length;
318
346
  hadToolsThisTurn = true;
319
347
  // 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
320
348
  if (mode !== 'idle' && lastChar !== '\n')
@@ -329,104 +357,47 @@ export async function runAgentCore(opts) {
329
357
  function: { name: tc.name, arguments: tc.arguments },
330
358
  })),
331
359
  });
332
- // 工具分组执行(保 tool_calls 原顺序):连续的只读工具(READ_TOOL_NAMES)成组并发——先一次性
333
- // 渲染全部 header,让摘要在任何同步工具真正执行前立即可见;随后启动全部 executeTool
334
- // 再按原顺序逐个 await + 回灌结果。
335
- // mutation(write_file/edit_file)及 run_command/use_skill 各为单步串行屏障——mutation 串行保
336
- // recordMutation 调用序 = 回滚快照序(executeTool 内写前记 before 快照,同文件多次写需按序)。
337
- // 渲染与 history 回灌一律按原顺序;并发只影响执行时序,tool_call_id 仍按序配对。
338
- // executeTool 永不抛错(调度器 try/catch 返字符串),故 await 单个 promise 不会抛(永远 resolve 为字符串)。
360
+ // 工具分组执行(保 tool_calls 原顺序):连续且显式声明 parallel 的 safe 工具成组并发——先一次性
361
+ // 渲染全部 header,让摘要在任何同步工具真正执行前立即可见;随后启动全部 executeToolOutcome
362
+ // 再按原顺序逐个 await + 回灌结果。其余工具均为串行屏障;resource-locked write 保证
363
+ // rollback 快照顺序,未知扩展与共享工作区 task 也默认串行。
364
+ // 渲染与 history 回灌一律按原顺序;并发只影响执行时序,tool_call_id 仍按序配对。
365
+ // executeToolOutcome 永不抛错,失败通过结构化 status/code 返回。
339
366
  const calls = result.toolCalls;
340
367
  let i = 0;
341
368
  while (i < calls.length) {
342
- if (READ_TOOL_NAMES.has(calls[i].name)) {
369
+ const currentCall = calls[i];
370
+ if (getRuntimeDisabledTools().has(currentCall.name)) {
371
+ hooks.onToolHeader?.(currentCall);
372
+ const error = t('task.disabled');
373
+ hooks.onToolResult?.(currentCall, error, null, null, 1);
374
+ const hint = recordAndHint(currentCall.name, currentCall.arguments);
375
+ pushToolResult(history, currentCall, hint ? `${error}${hint}` : error, relprune, lifecycle, scheduler, runtimeContextState, false);
376
+ i++;
377
+ continue;
378
+ }
379
+ if (isParallelTool(currentCall.name)) {
343
380
  // 收集连续只读组(≥1),并发执行:先渲染所有 header,再一次性启动所有
344
381
  // (executeTool 调用即开始 I/O),最后按原顺序逐个 await + 回灌。
345
382
  // 必须先 header 后 execute:grep 等同步快速工具会在 executeTool 返回 Promise 前
346
383
  // 已经完成;若先 started.map,用户只能在工具完成后才看到摘要与其前面的换行。
347
384
  // 异步工具(web_fetch 等)并发跑、总耗时 ≈ 最慢一个;同步工具(glob/grep)map 时已顺序跑完,await 即返。
348
385
  let j = i;
349
- while (j < calls.length && READ_TOOL_NAMES.has(calls[j].name))
386
+ while (j < calls.length && isParallelTool(calls[j].name))
350
387
  j++;
351
388
  const batch = calls.slice(i, j);
352
389
  for (const tc of batch)
353
390
  hooks.onToolHeader?.(tc);
354
391
  hooks.onToolStart?.(batch[0].name);
355
- const started = batch.map((tc) => executeTool(tc.name, tc.arguments, signal, { dropContext }));
392
+ const started = batch.map((tc) => executeToolOutcome(tc.name, tc.arguments, signal, { dropContext }));
356
393
  for (let k = 0; k < batch.length; k++) {
357
394
  const tc = batch[k];
358
- const output = await started[k];
359
- hooks.onToolResult?.(tc, output, null, null, 1); // 只读工具无 diff
395
+ const outcome = await started[k];
396
+ const output = outcome.output;
397
+ hooks.onToolResult?.(tc, output, null, null, 1); // 并行工具无 diff
360
398
  // Thrashing:history 里附 hint(UI 已用干净 output 渲染,避免屏幕噪声)
361
399
  const hint = recordAndHint(tc.name, tc.arguments);
362
- pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
363
- }
364
- hooks.onToolDone?.();
365
- i = j;
366
- }
367
- else if (calls[i].name === 'task') {
368
- // task 并发组:连续的 task 调用成组并发(子 agent 并行跑,各自独立 history)。
369
- // 一次性启动全部(executeTool 即 spawnAgent,子 agent 开始跑),再并发 await + 渲染。
370
- // task 是长任务,并发 fan-out 总耗时 ≈ 最慢一个子 agent。
371
- // task 与 mutation/run_command 之间串行屏障(task 子 agent 可能有文件改动,不能和 write_file 乱序)。
372
- // 渲染:先批量打印所有 ● 头 + 启 spinner(让用户看到多个 task 同时在跑),再逐个 await 出结果。
373
- // (若像只读组那样「header → await → result」串行,长 task 的第二个 header 要等第一个跑完才出现,
374
- // 视觉上只有一个在跑——与并发事实不符。)
375
- //
376
- // plan 模式防御 backstop(与单步串行分支同语义):schema 已剔除 task,正常不会进这里;
377
- // 防后端幻觉调用——不执行(绝不派生子 agent,子 agent 可能有 mutation,违反只读),直接返错回灌。
378
- if (getAgentMode() === 'plan') {
379
- const tc = calls[i];
380
- hooks.onToolHeader?.(tc);
381
- const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
382
- hooks.onToolResult?.(tc, err, null, null, 1);
383
- // Thrashing:同上
384
- const hint = recordAndHint(tc.name, tc.arguments);
385
- pushToolResult(history, tc, hint ? `${err}${hint}` : err, relprune, lifecycle, scheduler);
386
- i++;
387
- continue;
388
- }
389
- let j = i;
390
- while (j < calls.length && calls[j].name === 'task')
391
- j++;
392
- const batch = calls.slice(i, j);
393
- // 权限预检查:逐个 task 弹确认面板(在启动子 agent 之前,体验:先问再执行)。
394
- // 拒绝的 task 直接跳过,不启动子 agent;放行的收集到 allowedBatch。
395
- const allowedBatch = [];
396
- for (const tc of batch) {
397
- const parsed = parseArgs(tc.arguments);
398
- const tool = tools.find((t) => t.name === tc.name);
399
- if (tool) {
400
- const perm = await checkPermission(tool, parsed ?? {}, signal);
401
- if (perm === 'deny') {
402
- hooks.onToolHeader?.(tc);
403
- const err = `错误:用户拒绝了工具 ${tc.name} 的执行。`;
404
- hooks.onToolResult?.(tc, err, null, null, 1);
405
- const hint = recordAndHint(tc.name, tc.arguments);
406
- pushToolResult(history, tc, hint ? `${err}${hint}` : err, relprune, lifecycle, scheduler);
407
- continue;
408
- }
409
- }
410
- allowedBatch.push(tc);
411
- }
412
- if (allowedBatch.length === 0) {
413
- i = j;
414
- continue; // 全部被拒绝,跳过执行
415
- }
416
- const started = allowedBatch.map((tc) => executeTool(tc.name, tc.arguments, signal, { dropContext }));
417
- // 先批量打印所有头 + 启 spinner(多 task 并发,spinner 只显一个,但 ● 头都打出来)
418
- for (const tc of allowedBatch) {
419
- hooks.onToolHeader?.(tc);
420
- }
421
- hooks.onToolStart?.(allowedBatch[0].name); // spinner:多 task 共用一个「执行 task…」
422
- // 逐个 await 出结果(按 tool_calls 原序,保 tool_call_id 配对);结果到即渲染 ↳
423
- for (let k = 0; k < allowedBatch.length; k++) {
424
- const tc = allowedBatch[k];
425
- const output = await started[k];
426
- hooks.onToolResult?.(tc, output, null, null, 1); // task 结果是摘要,无 diff
427
- // Thrashing:同上
428
- const hint = recordAndHint(tc.name, tc.arguments);
429
- pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
400
+ pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
430
401
  }
431
402
  hooks.onToolDone?.();
432
403
  i = j;
@@ -454,10 +425,10 @@ export async function runAgentCore(opts) {
454
425
  const perm = await checkPermission(tool, parsed ?? {}, signal);
455
426
  if (perm === 'deny') {
456
427
  hooks.onToolHeader?.(tc);
457
- const err = `错误:用户拒绝了工具 ${tc.name} 的执行。`;
458
- hooks.onToolResult?.(tc, err, null, null, 1);
428
+ const outcome = deniedOutcome(tc.name);
429
+ hooks.onToolResult?.(tc, outcome.output, null, null, 1);
459
430
  const hint = recordAndHint(tc.name, tc.arguments);
460
- pushToolResult(history, tc, hint ? `${err}${hint}` : err, relprune, lifecycle, scheduler);
431
+ pushToolResult(history, tc, hint ? `${outcome.output}${hint}` : outcome.output, relprune, lifecycle, scheduler, runtimeContextState, false);
461
432
  i++;
462
433
  continue;
463
434
  }
@@ -468,14 +439,15 @@ export async function runAgentCore(opts) {
468
439
  : null;
469
440
  const { preWriteOld, editStartLine } = readDiffContext(tc, mutationParsed);
470
441
  hooks.onToolStart?.(tc.name);
471
- const output = await executeTool(tc.name, tc.arguments, signal, { dropContext });
442
+ const outcome = await executeToolOutcome(tc.name, tc.arguments, signal, { dropContext });
443
+ const output = outcome.output;
472
444
  hooks.onToolDone?.();
473
445
  hooks.onToolResult?.(tc, output, mutationParsed, preWriteOld, editStartLine);
474
446
  // Thrashing:同上(history 附 hint,UI 干净)
475
447
  const hint = recordAndHint(tc.name, tc.arguments);
476
- pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
448
+ pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
477
449
  // 只有成功 mutation 才会使旧 read 失效;pruner 与 lifecycle 独立启停。
478
- if (isMutationTool(tc.name) && isToolResultSuccess(output)) {
450
+ if (isMutationTool(tc.name) && outcome.status === 'success') {
479
451
  const mp = mutationParsed?.path;
480
452
  if (typeof mp === 'string' && mp) {
481
453
  relprune?.observeMutation(history, mp);
@@ -509,18 +481,100 @@ export async function runAgentCore(opts) {
509
481
  });
510
482
  continue; // 带着提示再调一次 LLM
511
483
  }
512
- // 没有工具调用:流式正文即最终回复(已实时打印)
484
+ // 没有工具调用:候选正文已流式打印。若本轮有新的代码变更,先通过框架验证门;
485
+ // failed 作为 system observation 风格的 user 消息回灌,不能伪造无配对的 tool 消息。
513
486
  if (!gotText)
514
487
  hooks.onNoReply?.();
515
- history.push({ role: 'assistant', content: result.content });
488
+ const candidate = { role: 'assistant', content: result.content };
489
+ const mutationBeforeValidation = getCurrentTurnMutationState();
490
+ const shouldValidate = opts.autoValidate === true &&
491
+ getAgentMode() !== 'plan' &&
492
+ mutationBeforeValidation.version > validatedMutationVersion;
493
+ if (shouldValidate) {
494
+ history.push(candidate);
495
+ try {
496
+ latestValidation = await validator(signal, {
497
+ onCommandStart: (command) => hooks.onValidationStart?.(command),
498
+ });
499
+ }
500
+ catch (error) {
501
+ const mutation = getCurrentTurnMutationState();
502
+ latestValidation = {
503
+ status: signal?.aborted ? 'aborted' : 'failed',
504
+ output: `Automatic validation failed to run: ${error instanceof Error ? error.message : String(error)}`,
505
+ durationMs: 0,
506
+ changedFiles: mutation.changedFiles.map((item) => item.path),
507
+ mutationVersion: mutation.version,
508
+ };
509
+ }
510
+ hooks.onValidationResult?.(latestValidation);
511
+ validatedMutationVersion = latestValidation.mutationVersion;
512
+ if (signal?.aborted || latestValidation.status === 'aborted') {
513
+ abortRestore();
514
+ traceStatus = 'aborted';
515
+ const mutation = getCurrentTurnMutationState();
516
+ return {
517
+ completed: false,
518
+ finalText: null,
519
+ validation: latestValidation,
520
+ changedFiles: mutation.changedFiles.map((item) => item.path),
521
+ };
522
+ }
523
+ if (latestValidation.status === 'failed') {
524
+ history.push({
525
+ role: 'user',
526
+ content: '[System observation: automatic validation failed]\n' +
527
+ `Command: ${latestValidation.command ?? '(internal verifier)'}\n` +
528
+ `${latestValidation.output}\n\n` +
529
+ 'Fix the reported problem, then finish the task. Do not claim success until validation passes.',
530
+ });
531
+ // 验证及其失败观察均已完整落入 history;下一步中断时可安全保留。
532
+ savedHistory = history.slice();
533
+ continue;
534
+ }
535
+ }
536
+ else {
537
+ history.push(candidate);
538
+ }
539
+ const finalMutation = getCurrentTurnMutationState();
516
540
  done = true;
517
- return { completed: true, finalText: result.content, usage: turnUsage };
541
+ traceStatus = 'completed';
542
+ return {
543
+ completed: true,
544
+ finalText: result.content,
545
+ usage: turnUsage,
546
+ validation: latestValidation,
547
+ changedFiles: finalMutation.changedFiles.map((item) => item.path),
548
+ };
518
549
  }
519
550
  hooks.onMaxSteps?.();
520
551
  done = true;
521
- return { completed: true, finalText: null, usage: turnUsage };
552
+ traceStatus = 'max_steps';
553
+ const finalMutation = getCurrentTurnMutationState();
554
+ return {
555
+ completed: true,
556
+ finalText: null,
557
+ usage: turnUsage,
558
+ validation: latestValidation,
559
+ changedFiles: finalMutation.changedFiles.map((item) => item.path),
560
+ };
522
561
  }
523
562
  finally {
563
+ const finalMutation = getCurrentTurnMutationState();
564
+ try {
565
+ opts.onTrace?.({
566
+ ts: new Date().toISOString(),
567
+ status: traceStatus,
568
+ durationMs: Date.now() - t0,
569
+ toolCalls: toolCallCount,
570
+ changedFiles: finalMutation.changedFiles.map((item) => item.path),
571
+ usage: turnUsage,
572
+ validation: latestValidation,
573
+ });
574
+ }
575
+ catch {
576
+ // Trace is best-effort and must not change the turn result.
577
+ }
524
578
  // 跑完(正常 / 达上限)在回复末尾打耗时摘要行;中断 done=false 不打。
525
579
  if (done) {
526
580
  hooks.onDone?.(Date.now() - t0, turnUsage);
@@ -528,4 +582,4 @@ export async function runAgentCore(opts) {
528
582
  }
529
583
  }
530
584
  // ── 导出共享辅助(主 agent 的 TUI hooks 实现要用)──────────────────────────
531
- export { parseArgs, readDiffContext, isMutationTool, READ_TOOL_NAMES };
585
+ export { parseArgs, readDiffContext, isMutationTool, isParallelTool };
@@ -14,6 +14,7 @@ import { runAgentCore, isMutationTool, } from './core.js';
14
14
  import { createPetHooks } from '../pet/state.js';
15
15
  import { t } from '../i18n/index.js';
16
16
  import { isToolErrorOutput } from '../tools/result.js';
17
+ import { appendCurrentSessionTrace } from '../session/index.js';
17
18
  /** 当前 turn 的 batch id(runAgent 内闭包变量;一条 turn 一轮 tool batch 结束即清空)。 */
18
19
  let currentBatchId = null;
19
20
  /** 取 userInput 的首行:字符串直接 split;多模态 parts 找首个 text part 再 split。 */
@@ -187,6 +188,23 @@ onContextUpdate) {
187
188
  flushToolBatch();
188
189
  layout.contentWrite(`${ui.dim}${t('agent.aborted')}${ui.reset}\n`);
189
190
  },
191
+ onValidationStart: (command) => {
192
+ flushToolBatch();
193
+ spinner.start(t('agent.validating', { command }));
194
+ },
195
+ onValidationResult: (validation) => {
196
+ spinner.stop();
197
+ const color = validation.status === 'passed'
198
+ ? ui.green
199
+ : validation.status === 'failed'
200
+ ? ui.red
201
+ : ui.yellow;
202
+ const command = validation.command ?? t('agent.validationNoCommand');
203
+ const detail = validation.status === 'skipped' && validation.skipReason
204
+ ? `${validation.status}: ${validation.skipReason}`
205
+ : validation.status;
206
+ layout.contentWrite(` ${color}●${ui.reset} ${t('agent.validationResult', { command, status: detail })}\n`);
207
+ },
190
208
  onDone: (elapsedMs, usage) => {
191
209
  flushToolBatch();
192
210
  const tok = formatTurnTokens(usage);
@@ -210,6 +228,8 @@ onContextUpdate) {
210
228
  signal,
211
229
  onContextUpdate,
212
230
  hooks: combinedHooks,
231
+ autoValidate: config.autoValidate,
232
+ onTrace: appendCurrentSessionTrace,
213
233
  });
214
234
  }
215
235
  finally {
@@ -12,7 +12,7 @@
12
12
  // - 中断透传:opts.signal(主 agent 的 abort signal)透传给 runAgentCore → chat/executeTool,
13
13
  // 主 Ctrl+C 树杀子 agent(chat 流式 abort + run_command/web_fetch 即时取消)。
14
14
  import { chatTools } from '../llm/index.js';
15
- import { config, isMemoryEnabled } from '../config/index.js';
15
+ import { config, isMemoryEnabled, isSubAgentEnabled } from '../config/index.js';
16
16
  import { effectiveSystemPrompt } from '../skills/index.js';
17
17
  import { buildMemorySection, buildMemoryIndexSection } from '../memory/index.js';
18
18
  import { ui } from '../ui/theme.js';
@@ -40,6 +40,13 @@ You are a sub-agent spawned by the main agent to handle an isolated sub-task. Yo
40
40
  * 子 agent 跑在主 signal 下,主 abort 即子 abort;子 agent 的 abortRestore 还原子 history + 模式。
41
41
  */
42
42
  export async function spawnAgent(opts) {
43
+ if (!isSubAgentEnabled()) {
44
+ return {
45
+ summary: null,
46
+ completed: false,
47
+ transcript: 'Sub-agent execution is disabled. Enable it with /subagent on.',
48
+ };
49
+ }
43
50
  const maxSteps = opts.maxSteps ?? config.subAgentMaxSteps ?? 50;
44
51
  // 构造子 agent 系统提示:复用主 agent 组装链 + 子 agent 角色后缀 + 自定义后缀。
45
52
  // config.systemPrompt 是 getter(每次访问现拼 buildBasePrompt,反映 isMemoryEnabled),
@@ -124,6 +131,7 @@ export async function spawnAgent(opts) {
124
131
  maxSteps,
125
132
  toolsOverride,
126
133
  contextState: localContextState,
134
+ autoValidate: false, // 子 Agent 共享主轮工作区,由主 Agent 收尾统一验证
127
135
  });
128
136
  return {
129
137
  summary: result.finalText,
@@ -358,6 +358,7 @@ export const config = {
358
358
  compactThreshold: Number(process.env.COMPACT_THRESHOLD) || 0.85,
359
359
  includeUsage: process.env.LLM_STREAM_USAGE !== 'false',
360
360
  autoCompact: process.env.AUTO_COMPACT !== 'false',
361
+ autoValidate: process.env.MOCODE_AUTO_VALIDATE !== 'false',
361
362
  contextOptimize: process.env.MOCODE_CONTEXT_OPTIMIZE !== 'false',
362
363
  contextRelprune: process.env.MOCODE_CONTEXT_RELPRUNE !== 'false',
363
364
  contextLifecycle: process.env.MOCODE_LIFECYCLE !== 'false',
@@ -366,6 +367,7 @@ export const config = {
366
367
  memoryEnabled: process.env.MEMORY_ENABLED === 'true',
367
368
  reflectEveryN: Number(process.env.REFLECT_EVERY_N) || 5,
368
369
  maxSteps: Number(process.env.MAX_STEPS) || 200,
370
+ subAgentEnabled: process.env.MOCODE_SUBAGENT_ENABLED === 'true',
369
371
  subAgentMaxSteps: Number(process.env.SUB_AGENT_MAX_STEPS) || 50,
370
372
  sessionDir: path.join(process.cwd(), '.mocode', 'sessions'),
371
373
  searchApiKey: process.env.ANYSEARCH_API_KEY,
@@ -379,6 +381,7 @@ export const config = {
379
381
  llmKeysFromShell,
380
382
  projectSnapshotEnabled: process.env.MOCODE_PROJECT_SNAPSHOT !== 'false',
381
383
  permissionEnabled: process.env.MOCODE_PERMISSION !== 'false',
384
+ permissionNonInteractiveAllow: process.env.MOCODE_PERMISSION_NON_INTERACTIVE_ALLOW === 'true',
382
385
  projectSkillEnabled: process.env.MOCODE_PROJECT_SKILL === 'true',
383
386
  };
384
387
  /**
@@ -407,6 +410,15 @@ export function updateModelConfig(opts) {
407
410
  process.env.CONTEXT_WINDOW_TOKENS = String(opts.contextWindowTokens);
408
411
  }
409
412
  }
413
+ /** 子 Agent 总开关;默认 false,关闭时 task 不进入模型工具表。 */
414
+ export function isSubAgentEnabled() {
415
+ return config.subAgentEnabled;
416
+ }
417
+ /** 运行时切换子 Agent;工具 schema 刷新与持久化由 REPL 调用方完成。 */
418
+ export function updateSubAgentConfig(enabled) {
419
+ config.subAgentEnabled = enabled;
420
+ process.env.MOCODE_SUBAGENT_ENABLED = enabled ? 'true' : 'false';
421
+ }
410
422
  /**
411
423
  * 记忆子系统总开关:单一来源。/memory_switch、/memory_status、buildSystemPrompt、
412
424
  * tools/builtins/index.ts、tools/constants.ts 的 plan-mode 列表都从这里查。