mocode-ai 0.7.2 → 1.0.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
@@ -8,13 +8,67 @@ A terminal coding agent: give it a goal, and it **completes it autonomously**
8
8
 
9
9
  MoCode explores your code, reads/writes/edits files, runs shell commands, and searches the web on its own, driving the task forward through a loop of "think → call a tool → observe the result → think again." It works with any OpenAI-compatible endpoint (GLM, DeepSeek, Qwen, local Ollama / vLLM, etc.), runs as a full-screen TUI with streaming output and visible reasoning.
10
10
 
11
+ ## Architecture
12
+
13
+ MoCode is organized as a layered runtime: the terminal experience drives an autonomous core, the core reaches capabilities through a guarded execution plane, and a persistent intelligence layer keeps long-running work coherent.
14
+
15
+ <p align="center"><img src="./assets/architecture/system-overview.svg" alt="MoCode layered system architecture" width="100%"></p>
16
+
17
+ ### Autonomous execution loop
18
+
19
+ Each model response is one step in a closed loop. Tool calls are classified by declared capabilities, safe reads can run in parallel, writes acquire canonical resource locks, observations are encoded before returning to context, and code changes pass through automatic validation.
20
+
21
+ <p align="center"><img src="./assets/architecture/agent-loop.svg" alt="MoCode autonomous agent execution loop" width="100%"></p>
22
+
23
+ ### Context that ages instead of exploding
24
+
25
+ Tool output does not accumulate as an undifferentiated transcript. Typed encoders, relevance pruning, an observation lifecycle, age-aware compression, and a five-zone budget scheduler continuously reshape the active working set while sessions, snapshots, skills, notes, and memory retain durable knowledge.
26
+
27
+ <p align="center"><img src="./assets/architecture/context-engine.svg" alt="MoCode context engineering and durable memory architecture" width="100%"></p>
28
+
29
+ ### Multi-agent work without unsafe shared writes
30
+
31
+ Read-only sub-agents fan out concurrently. Writer agents work inside private filesystem overlays and return structured ChangeSets; the coordinator checks expected hashes, acquires canonical locks, performs conflict-safe merges, and runs one unified verification gate in the main workspace.
32
+
33
+ <p align="center"><img src="./assets/architecture/multi-agent.svg" alt="MoCode multi-agent overlay and ChangeSet coordination" width="100%"></p>
34
+
35
+ ### Controlled execution: permission gates and capability scheduling
36
+
37
+ Every mutating tool calls into a permission layer before it runs. Tools are classified `safe` / `confirm` / `dangerous`, scopes can be `once` / `session` / `project` / global-tool, fingerprints are stable hashes (command, path, or args), and the persistent record lives in `~/.mocode/permissions.json` (v3 schema, with v2 resource grants still loaded). Piped or CI environments default to deny until you opt in.
38
+
39
+ <p align="center"><img src="./assets/architecture/permission-model.svg" alt="MoCode permission model: tool classes, four-tier grants, fingerprinting, durable storage" width="100%"></p>
40
+
41
+ ### Verification cascade: cheap checks first, expensive checks only on demand
42
+
43
+ Code changes go through V0 (file post-conditions) → V1 (scoped tsc/eslint markers) → V2 (targeted unit tests) → V3 (affected package scripts). The first actionable failure stops the cascade and is fed back to the agent as a fresh observation; a SHA-256 content cache skips repeated work on unchanged files.
44
+
45
+ <p align="center"><img src="./assets/architecture/verification-cascade.svg" alt="MoCode verification cascade V0 to V3 with content fingerprint cache" width="100%"></p>
46
+
47
+ ### Rollback timeline: per-mutation snapshots, restore by turn
48
+
49
+ A clean undo point is saved before every mutating tool. `/rollback <turnId>` restores file buffers in reverse-chronological order under canonical resource locks, then reruns V0+V1 to confirm a clean state — never re-runs the model. Read tools, network effects, and binary changes are explicitly out of scope, kept honest in the contract.
50
+
51
+ <p align="center"><img src="./assets/architecture/rollback-flow.svg" alt="MoCode rollback timeline and per-turn snapshot flow" width="100%"></p>
52
+
53
+ ### Context controls: five independent dials, not one big toggle
54
+
55
+ `autoCompact` / `contextOptimize` / `contextRelprune` / `contextLifecycle` / `contextBudget` each gate a different knob (push-time compression, encoders, superseded-read pruning, observation lifecycle, five-zone scheduler). Each is independently killable via a `MOCODE_*=false` env var; the observation lifecycle runs even with all toggles off, so context still ages instead of exploding. An EWMA self-calibrates the token estimator against real provider usage.
56
+
57
+ <p align="center"><img src="./assets/architecture/context-controls.svg" alt="MoCode context controls: five independent toggles, observation lifecycle, token self-calibration" width="100%"></p>
58
+
59
+ ### Desktop pet: a passive mirror over WebSocket
60
+
61
+ The optional Electron sub-package (`packages/pet-app`) shows a stateful floating character that mirrors agent activity via a one-way WebSocket stream. Quit with `/pet quit`. The renderer owns no business logic; the agent loop is unchanged regardless of whether the pet is running.
62
+
63
+ <p align="center"><img src="./assets/architecture/pet-bridge.svg" alt="MoCode desktop pet bridge: hooks, frames, Electron client" width="100%"></p>
64
+
11
65
  ## Why MoCode
12
66
 
13
67
  MoCode isn't a chat box with a coat of paint — it's an agent that actually gets things done:
14
68
 
15
69
  - **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
70
  - **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. 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.
71
+ - **Sub-agents divide and conquer** — Complex tasks can spawn independent sub-agents with isolated histories and scoped toolsets. Read-only workers can fan out concurrently; writer workers run in private filesystem overlays and return ChangeSets that are merged under expected-hash checks and canonical resource locks. Only structured findings return to the main thread.
18
72
  - **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
73
  - **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
74
  - **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.
@@ -112,8 +166,8 @@ Common backend `base_url` values:
112
166
  | `ANYSEARCH_BASE_URL` | Search API endpoint | `https://api.anysearch.com` |
113
167
  | `SKILLS_DIRS` | Override the default skill scan directories (platform path separator) | three default directories |
114
168
  | `MOCODE_CONTEXT_OPTIMIZE` | Typed encoding of tool results before they reach the LLM (tree/search/log…); disable for raw passthrough (length trimming only) | `true` |
115
- | `MAX_STEPS` | Max agent loop steps per turn (prevents infinite loops) | `200` |
116
- | `SUB_AGENT_MAX_STEPS` | Default step cap for sub-agents (spawned via the `task` tool) | `50` |
169
+ | `MAX_STEPS` | Max agent loop steps per turn (infinite-loop safety only) | `1000` |
170
+ | `SUB_AGENT_MAX_STEPS` | Sub-agent loop safety ceiling; defaults to the main-agent value | `1000` |
117
171
  | `SANDBOX_ROOT` | Sandbox root directory (file operation boundary; falls back to cwd if unset) | none |
118
172
  | `MOCODE_THEME` | Color theme (default/dark/light…; shell env takes precedence over file) | `default` |
119
173
 
@@ -149,7 +203,7 @@ The agent operates in **the working directory it was launched from** — to have
149
203
  | `ask_human` | Pop up a Q&A panel at decision points; user picks a preset or types freely (blocks until answered) |
150
204
  | `switch_mode` | Switch between `plan` (read-only planning) and `auto` (full execution); the agent can call this itself to explore before acting |
151
205
  | `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) |
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 |
206
+ | `sub-agent` | Spawn a capable isolated worker; read tasks can run concurrently and writes use overlay + ChangeSet safe merge |
153
207
 
154
208
  | `memory_save` | Save a piece of cross-session long-term memory (title indexed, body fetched on demand) |
155
209
  | `memory_search` | Search memory bodies by keyword; hits boost the recall count (affects forgetting decay) |
package/README.zh-CN.md CHANGED
@@ -8,13 +8,67 @@
8
8
 
9
9
  mocode 自己探索代码、读写改文件、执行命令、联网查资料,以「思考 → 调用工具 → 观察结果 → 再思考」的循环一步步把任务推进到完成。接任意 OpenAI 兼容接口(GLM、DeepSeek、Qwen、本地 Ollama / vLLM 等),全屏 TUI 交互,流式输出、思考过程可见。
10
10
 
11
+ ## 架构
12
+
13
+ MoCode 是一个分层的自治运行时:终端交互层驱动 Agent 内核,内核通过受控能力平面执行真实操作,持久化认知层则让长任务和跨会话工作保持连贯。
14
+
15
+ <p align="center"><img src="./assets/architecture/system-overview-zh-CN.svg" alt="MoCode 分层系统架构" width="100%"></p>
16
+
17
+ ### 自治执行循环
18
+
19
+ 每次模型响应都是闭环中的一步。工具调用按能力声明分类,安全读取可以并行,写操作获取规范化资源锁,观察结果编码后才回到上下文,代码改动最终经过自动验证门。
20
+
21
+ <p align="center"><img src="./assets/architecture/agent-loop-zh-CN.svg" alt="MoCode 自治 Agent 执行循环" width="100%"></p>
22
+
23
+ ### 会衰减、不会膨胀的上下文
24
+
25
+ 工具输出不会作为无差别日志无限堆积。类型化编码、相关性裁剪、观察生命周期、年龄感知压缩和五区预算调度持续重塑活跃工作集;会话、Snapshot、Skill、notes.md 与长期记忆负责保留耐久知识。
26
+
27
+ <p align="center"><img src="./assets/architecture/context-engine-zh-CN.svg" alt="MoCode 上下文工程与持久化记忆架构" width="100%"></p>
28
+
29
+ ### 多 Agent 并行,但不冒险共享写入
30
+
31
+ 只读子 Agent 可以并行扇出;写任务在私有文件系统 overlay 中完成并返回结构化 ChangeSet。协调器校验 expected hash、获取规范化资源锁、安全合并冲突,最后由主工作区统一执行验证。
32
+
33
+ <p align="center"><img src="./assets/architecture/multi-agent-zh-CN.svg" alt="MoCode 多 Agent overlay 与 ChangeSet 协调" width="100%"></p>
34
+
35
+ ### 受控执行:权限门 + 能力调度
36
+
37
+ 每个写入工具在执行前都会经过权限层。工具分为 `safe` / `confirm` / `dangerous` 三档,授权粒度支持 `once` / `session` / `project` / 全局工具四档,指纹使用稳定哈希(命令、路径或参数),持久化记录落在 `~/.mocode/permissions.json`(v3 格式,会自动加载 v2 资源授权)。管道/CI 环境默认拒绝所有需确认的操作,除非显式开启。
38
+
39
+ <p align="center"><img src="./assets/architecture/permission-model-zh-CN.svg" alt="MoCode 权限模型:工具分级、四档授权、指纹、持久化" width="100%"></p>
40
+
41
+ ### 验证瀑布:便宜检查先做,贵检查按需上场
42
+
43
+ 代码改动按 V0(文件级后置条件)→ V1(限范围的 tsc/eslint)→ V2(定向单元测试)→ V3(受影响 package 的脚本)由低到高执行。首个可操作失败立即停止,作为新的观察反馈给 Agent;SHA-256 文件指纹缓存避免对未改动文件重复劳动。
44
+
45
+ <p align="center"><img src="./assets/architecture/verification-cascade-zh-CN.svg" alt="MoCode 自动验证瀑布 V0 到 V3,带文件指纹缓存" width="100%"></p>
46
+
47
+ ### 回滚时间线:每次写入都留干净撤销点
48
+
49
+ 每次写入工具执行前先存一份 undo 快照。`/rollback <turnId>` 按时间逆序在 canonical 资源锁下恢复文件缓冲,然后重跑 V0+V1 验证状态干净——完全不重跑模型。读取类工具、网络副作用、二进制改动明确不在截图范围,契约里写死。
50
+
51
+ <p align="center"><img src="./assets/architecture/rollback-flow-zh-CN.svg" alt="MoCode 回滚时间线和每轮快照流" width="100%"></p>
52
+
53
+ ### 上下文控制:五个独立开关,不是一锅端
54
+
55
+ `autoCompact` / `contextOptimize` / `contextRelprune` / `contextLifecycle` / `contextBudget` 各自把控一个旋钮(push 压缩、编码器、被取代读取的剪裁、观察生命周期、五区调度器)。每个都能用 `MOCODE_*=false` 单独关;即使五个全关,观察结果仍按生命周期老化。token 估算带 EWMA 自动校准真实 provider 用量。
56
+
57
+ <p align="center"><img src="./assets/architecture/context-controls-zh-CN.svg" alt="MoCode 上下文控制:五个独立开关、观察生命周期、token 自校准" width="100%"></p>
58
+
59
+ ### 桌宠:WebSocket 上的被动镜像
60
+
61
+ 可选 Electron 子包(`packages/pet-app`)用一个悬浮小角色镜像 agent 状态:单向 WebSocket 推送事件帧,`/pet quit` 完全关闭。渲染层零业务逻辑,无论桌宠是否在跑,主 agent 循环一字不改。
62
+
63
+ <p align="center"><img src="./assets/architecture/pet-bridge-zh-CN.svg" alt="MoCode 桌宠桥:hooks、事件帧、Electron 客户端" width="100%"></p>
64
+
11
65
  ## 为什么用 mocode
12
66
 
13
67
  mocode 不是一个套壳聊天框,而是一个能真正动手干活的 agent:
14
68
 
15
69
  - **自主多步推进** — 一次对话里连续多步:读代码、改代码、跑测试、根据报错再改……agent 自己决定下一步,中途不用你反复催。遇到卡点会调 `ask_human` 弹面板问你(阻塞到回应)。
16
70
  - **只读工具并行执行** — 一轮里连续的只读操作(读文件、grep、glob、codegraph、联网搜索/抓取)自动并发跑,总耗时 ≈ 最慢一个,而不是逐个排队。写文件 / 改文件这类有副作用的操作仍串行,保快照顺序与数据安全。
17
- - **子 agent 分而治之** — 复杂任务可派生独立子 agent:各自有自己的对话历史(不污染主线),可限定工具集和步数上限。共享主工作区期间多个 task 串行执行,避免并发写冲突;每个子任务最后只把摘要回灌主线。
71
+ - **子 agent 分而治之** — 复杂任务可派生拥有独立历史与受限工具集的子 agent。只读 worker 可并行扇出;写 worker 在私有文件系统 overlay 中运行,返回的 ChangeSet 经过 expected hash 校验与规范化资源锁后才合并。主线只接收结构化发现,不接收过程噪声。
18
72
  - **计划 / 执行双模式** — `plan` 模式下只读探查(读代码、查索引、搜索,绝不写盘、不跑命令、不派生子 agent),产出计划;`auto` 模式全量工具放开。agent 还能在两者间自切换——先把陌生代码库摸清,再动手改。
19
73
  - **上下文自动压缩** — 接近窗口上限时三层压缩(单条结果裁剪 → 旧工具结果原地微压缩 → 旧对话摘要),长会话也不爆窗口;`/context` 实时显示 token 用量,`/compact` 可手动压缩(能带焦点指令聚焦保留)。
20
74
  - **跨会话长期记忆** — agent 能把项目架构、约定、踩过的坑存成长期记忆,下次会话自动加载;后台还会定期从对话里反思挖掘值得记住的事。记忆可增删改、带召回衰减。
@@ -110,8 +164,8 @@ LLM_MODEL=glm-4.6 # 换成你的模型名
110
164
  | `ANYSEARCH_BASE_URL` | 搜索 API 端点 | `https://api.anysearch.com` |
111
165
  | `SKILLS_DIRS` | 覆盖默认 skill 扫描目录(平台分隔符) | 三目录自动扫描 |
112
166
  | `MOCODE_CONTEXT_OPTIMIZE` | 工具结果进 LLM 前的类型化编码(树/搜索/日志…),关掉则原样进(仅长度裁剪) | `true` |
113
- | `MAX_STEPS` | 每轮 agent 循环最大步数(防无限循环) | `200` |
114
- | `SUB_AGENT_MAX_STEPS` | 子 agent(task 工具派生)默认步数上限 | `50` |
167
+ | `MAX_STEPS` | 每轮 Agent 循环最大步数(仅防无限循环) | `1000` |
168
+ | `SUB_AGENT_MAX_STEPS` | 子 Agent 循环安全上限,默认与主 Agent 一致 | `1000` |
115
169
  | `SANDBOX_ROOT` | 沙箱根目录(文件操作边界;未配则用 cwd 兜底) | 无 |
116
170
  | `MOCODE_THEME` | 颜色主题(default/dark/light…;shell 设置优先于文件) | `default` |
117
171
 
@@ -147,7 +201,7 @@ agent 工作在**启动时所在的工作目录**——想让它操作某个项
147
201
  | `ask_human` | 决策点弹终端问答面板,用户选预设项或自由输入(阻塞至回应) |
148
202
  | `switch_mode` | 在 `plan`(只读规划)与 `auto`(全量执行)间切换;agent 可自行调用,先探查再动手 |
149
203
  | `drop_context` | 把历史里无关的旧工具结果替换为存根释放上下文(保 tool_call_id 配对,不动 system 与当前轮;幂等) |
150
- | `task` | 派生子 agent 执行独立子任务(独立历史、可受限工具集、可设步数上限);共享工作区期间串行执行,只回摘要 |
204
+ | `sub-agent` | 派生具备完整能力的隔离子 Agent;只读任务可并发,写任务通过 overlay + ChangeSet 安全合并 |
151
205
 
152
206
  | `memory_save` | 存一条跨会话长期记忆(标题进索引,正文按需取) |
153
207
  | `memory_search` | 按关键词搜记忆正文,命中即提升召回计数(影响遗忘衰减) |
@@ -8,11 +8,12 @@ import { readFileSync } from 'node:fs';
8
8
  import { chat, estimatePromptTokens, planChatTools, chatTools, } from '../llm/index.js';
9
9
  import { executeToolOutcome, getToolCapabilities, isFileMutationTool, tools, } from '../tools/registry.js';
10
10
  import { checkPermission } from '../permissions/index.js';
11
+ import { validateToolArguments } from '../tools/validation.js';
11
12
  import { getPlanDisabledTools, getRuntimeDisabledTools } from '../tools/constants.js';
12
13
  import { getAgentMode, setAgentMode } from './mode.js';
13
14
  import { maybeCompact, contextState, dropContextFromHistory, createTraceEvent, summarizeToolArguments, safeProviderId, } from '../session/index.js';
14
15
  import { createBudgetScheduler } from '../session/scheduler.js';
15
- import { optimizeToolResult, HOT_TURN_WINDOW, userTurnBoundary } from '../context/index.js';
16
+ import { optimizeToolResult, HOT_TURN_WINDOW, userTurnBoundary, recordArtifact, invalidateArtifacts, rehydrateArtifacts, } from '../context/index.js';
16
17
  import { createAgeAwareEncodingState, } from '../context/age-aware.js';
17
18
  import { createRelevancePruner } from '../context/relevance.js';
18
19
  import { isToolResultSuccess } from '../context/utils.js';
@@ -50,7 +51,7 @@ function parseArgs(raw) {
50
51
  * (直接拼,不哈希——避免热路径开销;args 长度本身有限,内存压力可忽略)。
51
52
  * null 表示未触发,不污染输出。
52
53
  */
53
- const THRASH_THRESHOLD = 3;
54
+ const THRASH_THRESHOLD = 2;
54
55
  function thrashHint(name, args, count) {
55
56
  if (count < THRASH_THRESHOLD)
56
57
  return null;
@@ -58,9 +59,34 @@ function thrashHint(name, args, count) {
58
59
  'either failing or returning the same content. STOP retrying and switch strategy:\n' +
59
60
  '- read_file / glob → path likely wrong; call `glob` to discover paths, or `ask_human`\n' +
60
61
  '- run_command → Windows path-escaping issue; use `read_file` / `glob` with absolute paths instead\n' +
61
- '- edit_file → old_string mismatch; re-read the file to find the exact text\n' +
62
+ '- write_file / edit_file CHANGE_CONFLICT do not resend; read_file the same path and use its latest hash (use null only when read_file says the path is missing)\n' +
63
+ '- edit_file old_string mismatch → re-read the exact region and copy it verbatim\n' +
62
64
  '- otherwise → re-read the tool description; the argument shape may be wrong');
63
65
  }
66
+ /**
67
+ * Detect only a strict streak of identical failures. Successful calls, or a
68
+ * different tool/argument pair, end the streak. This keeps intentional phased
69
+ * read_file/glob calls from being mislabeled after the underlying files change.
70
+ */
71
+ export function createThrashTracker() {
72
+ let lastFailedFingerprint = null;
73
+ let consecutiveFailures = 0;
74
+ return (name, args, succeeded) => {
75
+ if (succeeded) {
76
+ lastFailedFingerprint = null;
77
+ consecutiveFailures = 0;
78
+ return null;
79
+ }
80
+ const fingerprint = `${name}\x00${args}`;
81
+ if (fingerprint === lastFailedFingerprint)
82
+ consecutiveFailures += 1;
83
+ else {
84
+ lastFailedFingerprint = fingerprint;
85
+ consecutiveFailures = 1;
86
+ }
87
+ return thrashHint(name, args, consecutiveFailures);
88
+ };
89
+ }
64
90
  /** 只有显式声明 parallel 且无需权限确认的工具才进入普通并发组。 */
65
91
  function isParallelTool(name) {
66
92
  const tool = tools.find((candidate) => candidate.name === name);
@@ -72,6 +98,15 @@ function isResourceLockedTool(name) {
72
98
  const tool = tools.find((candidate) => candidate.name === name);
73
99
  return !!tool && getToolCapabilities(tool).concurrency === 'resource-locked';
74
100
  }
101
+ function isResourceLockedCall(call) {
102
+ if (!isResourceLockedTool(call.name))
103
+ return false;
104
+ if (call.name !== 'sub-agent')
105
+ return true;
106
+ const args = parseArgs(call.arguments);
107
+ // Unknown write sets stay on the serial path. Read tasks and known disjoint write sets may batch.
108
+ return args?.mode !== 'write' || (Array.isArray(args.writeSet) && args.writeSet.length > 0);
109
+ }
75
110
  /** 文件 mutation 由 capability metadata 判定,供 diff、回滚与上下文失效共用。 */
76
111
  const isMutationTool = (name) => isFileMutationTool(name);
77
112
  function deniedOutcome(name) {
@@ -141,11 +176,13 @@ function pushToolResult(history, tc, output, pruner, lifecycle, _scheduler, runt
141
176
  content: optimizeToolResult(tc.name, output, tc.arguments, encodingContext),
142
177
  };
143
178
  history.push(msg);
179
+ const messageIndex = history.length - 1;
180
+ recordArtifact(runtimeContextState, history, messageIndex, output, succeeded);
144
181
  // 失败 read 不得淘汰旧 read;失败 consumer 也不能改变 lifecycle 上游状态。
145
182
  if (pruner)
146
183
  pruner.observePush(history, msg, succeeded);
147
184
  if (lifecycle)
148
- lifecycle.pushTool(history, history.length - 1, succeeded);
185
+ lifecycle.pushTool(history, messageIndex, succeeded);
149
186
  runtimeContextState.lifecycleStats = lifecycle?.stats();
150
187
  }
151
188
  /**
@@ -218,15 +255,10 @@ export async function runAgentCore(opts) {
218
255
  }
219
256
  : u;
220
257
  };
221
- // Thrashing 检测:本轮内同 (name, args) 累计次数。≥3 在工具结果尾部追加 hint(见 thrashHint)
222
- // 只在 runAgentCore 内,turn 结束自然 GC;不跨 turn 持久(下一轮重新计数,避免误把历史判为 thrashing)。
223
- const recentToolCalls = new Map();
224
- const recordAndHint = (name, args) => {
225
- const fp = `${name}\x00${args}`;
226
- const c = (recentToolCalls.get(fp) ?? 0) + 1;
227
- recentToolCalls.set(fp, c);
228
- return thrashHint(name, args, c);
229
- };
258
+ const addToolUsage = (outcome) => addUsage(outcome.usage);
259
+ // Only consecutive identical failures are thrashing. Any success (including
260
+ // a mutation between two reads) or different call resets the streak.
261
+ const recordAndHint = createThrashTracker();
230
262
  history.push({ role: 'user', content: userInput });
231
263
  // 中断回滚快照:push 用户消息后整段浅拷贝。abort 时 length=0;push(...saved) 还原。
232
264
  // 这样中断时至少保留用户消息(及之前的历史);每步工具全部执行完毕后刷新快照,
@@ -249,6 +281,7 @@ export async function runAgentCore(opts) {
249
281
  ? createLifecycleEngine(history)
250
282
  : null;
251
283
  runtimeContextState.lifecycleStats = lifecycle?.stats();
284
+ rehydrateArtifacts(runtimeContextState, history);
252
285
  // 预算调度器:每个 runAgentCore 实例一个,在 age-aware sweep 后评估并执行 warn / compact。
253
286
  // contextBudget 开关关闭时为 null。
254
287
  const scheduler = config.contextBudget !== false
@@ -309,6 +342,7 @@ export async function runAgentCore(opts) {
309
342
  completed: false,
310
343
  terminationReason: 'aborted',
311
344
  finalText: null,
345
+ usage: turnUsage,
312
346
  validation: latestValidation,
313
347
  changedFiles: mutation.changedFiles.map((item) => item.path),
314
348
  };
@@ -361,6 +395,7 @@ export async function runAgentCore(opts) {
361
395
  runtimeContextState.lifecycleStats = lifecycle.stats();
362
396
  }
363
397
  ageAware?.rehydrate(history);
398
+ rehydrateArtifacts(runtimeContextState, history);
364
399
  }
365
400
  hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
366
401
  mode = 'idle';
@@ -409,6 +444,7 @@ export async function runAgentCore(opts) {
409
444
  completed: false,
410
445
  terminationReason: 'aborted',
411
446
  finalText: null,
447
+ usage: turnUsage,
412
448
  validation: latestValidation,
413
449
  changedFiles: mutation.changedFiles.map((item) => item.path),
414
450
  };
@@ -479,6 +515,20 @@ export async function runAgentCore(opts) {
479
515
  ...(tc.id ? { providerToolCallId: tc.id } : {}),
480
516
  });
481
517
  }
518
+ const traceToolRetry = (tc, index, retry) => {
519
+ const traceCall = tracedCalls[index];
520
+ emitTrace('tool_retry', {
521
+ tool: tc.name,
522
+ argumentHash: traceCall.args.sha256,
523
+ attempt: retry.attempt,
524
+ nextAttempt: retry.nextAttempt,
525
+ waitMs: retry.waitMs,
526
+ code: retry.code,
527
+ }, {
528
+ toolCallId: traceCall.toolCallId,
529
+ ...(tc.id ? { providerToolCallId: tc.id } : {}),
530
+ });
531
+ };
482
532
  const traceToolEnd = (tc, index, outcome) => {
483
533
  const traceCall = tracedCalls[index];
484
534
  emitTrace('tool_call_end', {
@@ -488,8 +538,13 @@ export async function runAgentCore(opts) {
488
538
  code: outcome.code,
489
539
  retryable: outcome.retryable,
490
540
  durationMs: outcome.durationMs ?? 0,
491
- retry: 0,
541
+ attempt: outcome.attempts ?? 1,
542
+ retry: Math.max(0, (outcome.attempts ?? 1) - 1),
543
+ retryDelayMs: outcome.retryDelayMs ?? 0,
492
544
  changedFiles: outcome.changedFiles ?? [],
545
+ staleFiles: outcome.staleFiles ?? [],
546
+ ...(outcome.changeSet ? { changeSet: outcome.changeSet } : {}),
547
+ ...(outcome.usage ? { nestedUsage: outcome.usage } : {}),
493
548
  }, {
494
549
  toolCallId: traceCall.toolCallId,
495
550
  ...(tc.id ? { providerToolCallId: tc.id } : {}),
@@ -510,7 +565,7 @@ export async function runAgentCore(opts) {
510
565
  durationMs: 0,
511
566
  };
512
567
  hooks.onToolResult?.(currentCall, error, null, null, 1);
513
- const hint = recordAndHint(currentCall.name, currentCall.arguments);
568
+ const hint = recordAndHint(currentCall.name, currentCall.arguments, false);
514
569
  pushToolResult(history, currentCall, hint ? `${error}${hint}` : error, relprune, lifecycle, scheduler, runtimeContextState, false);
515
570
  traceToolEnd(currentCall, i, outcome);
516
571
  i++;
@@ -529,27 +584,32 @@ export async function runAgentCore(opts) {
529
584
  for (const tc of batch)
530
585
  hooks.onToolHeader?.(tc);
531
586
  hooks.onToolStart?.(batch[0].name);
532
- const started = batch.map((tc) => executeToolOutcome(tc.name, tc.arguments, signal, { dropContext }));
587
+ const started = batch.map((tc, offset) => executeToolOutcome(tc.name, tc.arguments, signal, {
588
+ dropContext,
589
+ onRetry: (retry) => traceToolRetry(tc, i + offset, retry),
590
+ }));
533
591
  for (let k = 0; k < batch.length; k++) {
534
592
  const tc = batch[k];
535
593
  const outcome = await started[k];
594
+ addToolUsage(outcome);
595
+ opts.onToolOutcome?.(tc.name, parseArgs(tc.arguments) ?? {}, outcome);
536
596
  traceToolEnd(tc, i + k, outcome);
537
597
  const output = outcome.output;
538
598
  hooks.onToolResult?.(tc, output, null, null, 1); // 并行工具无 diff
539
599
  // Thrashing:history 里附 hint(UI 已用干净 output 渲染,避免屏幕噪声)
540
- const hint = recordAndHint(tc.name, tc.arguments);
600
+ const hint = recordAndHint(tc.name, tc.arguments, outcome.status === 'success');
541
601
  pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
542
602
  }
543
603
  hooks.onToolDone?.();
544
604
  i = j;
545
605
  }
546
- else if (isResourceLockedTool(currentCall.name) &&
606
+ else if (isResourceLockedCall(currentCall) &&
547
607
  !(getAgentMode() === 'plan' && getPlanDisabledTools().has(currentCall.name))) {
548
608
  // 连续文件 mutation:权限确认仍严格按原序进行;全部 preflight 完成后再启动。
549
609
  // 每个执行在 registry 内按 canonical path 获取锁,不同文件可并发,同文件别名会排队。
550
610
  let j = i;
551
611
  while (j < calls.length &&
552
- isResourceLockedTool(calls[j].name) &&
612
+ isResourceLockedCall(calls[j]) &&
553
613
  !getRuntimeDisabledTools().has(calls[j].name) &&
554
614
  !(getAgentMode() === 'plan' && getPlanDisabledTools().has(calls[j].name)))
555
615
  j++;
@@ -559,8 +619,11 @@ export async function runAgentCore(opts) {
559
619
  const tc = batch[k];
560
620
  const parsed = parseArgs(tc.arguments);
561
621
  const tool = tools.find((candidate) => candidate.name === tc.name);
622
+ const argumentsValid = tool && parsed !== null
623
+ ? validateToolArguments(tool, parsed).valid
624
+ : false;
562
625
  let denied;
563
- if (tool) {
626
+ if (tool && argumentsValid) {
564
627
  const perm = await checkPermission(tool, parsed ?? {}, signal);
565
628
  emitTrace('permission', {
566
629
  source: 'agent_tool',
@@ -586,28 +649,35 @@ export async function runAgentCore(opts) {
586
649
  const firstAllowed = entries.find((entry) => !entry.denied);
587
650
  if (firstAllowed)
588
651
  hooks.onToolStart?.(firstAllowed.tc.name);
589
- const started = entries.map((entry) => entry.denied
652
+ const started = entries.map((entry, offset) => entry.denied
590
653
  ? Promise.resolve(entry.denied)
591
654
  : executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
592
655
  dropContext,
593
656
  onLockAcquired: (lockedArgs) => {
594
657
  entry.diff = readDiffContext(entry.tc, lockedArgs);
595
658
  },
659
+ onRetry: (retry) => traceToolRetry(entry.tc, i + offset, retry),
596
660
  }));
597
661
  for (let k = 0; k < entries.length; k++) {
598
662
  const entry = entries[k];
599
663
  const outcome = await started[k];
664
+ addToolUsage(outcome);
665
+ opts.onToolOutcome?.(entry.tc.name, entry.parsed ?? {}, outcome);
600
666
  traceToolEnd(entry.tc, i + k, outcome);
601
667
  hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
602
- const hint = recordAndHint(entry.tc.name, entry.tc.arguments);
668
+ const hint = recordAndHint(entry.tc.name, entry.tc.arguments, outcome.status === 'success');
603
669
  pushToolResult(history, entry.tc, hint ? `${outcome.output}${hint}` : outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
604
- if (isMutationTool(entry.tc.name) && outcome.status === 'success') {
605
- const mutationPath = entry.parsed?.path;
606
- if (typeof mutationPath === 'string' && mutationPath) {
607
- relprune?.observeMutation(history, mutationPath);
608
- lifecycle?.pushMutation(history, history.length - 1, mutationPath);
609
- runtimeContextState.lifecycleStats = lifecycle?.stats();
670
+ const invalidatedFiles = [...new Set([
671
+ ...(outcome.changedFiles ?? []),
672
+ ...(outcome.staleFiles ?? []),
673
+ ])];
674
+ if (invalidatedFiles.length > 0) {
675
+ for (const changedFile of invalidatedFiles) {
676
+ relprune?.observeMutation(history, changedFile);
677
+ lifecycle?.pushMutation(history, history.length - 1, changedFile);
610
678
  }
679
+ invalidateArtifacts(runtimeContextState, history, invalidatedFiles);
680
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
611
681
  }
612
682
  }
613
683
  if (firstAllowed)
@@ -632,7 +702,7 @@ export async function runAgentCore(opts) {
632
702
  };
633
703
  hooks.onToolResult?.(tc, err, null, null, 1);
634
704
  // Thrashing:同上
635
- const hint = recordAndHint(tc.name, tc.arguments);
705
+ const hint = recordAndHint(tc.name, tc.arguments, false);
636
706
  pushToolResult(history, tc, hint ? `${err}${hint}` : err, relprune, lifecycle, scheduler);
637
707
  traceToolEnd(tc, i, outcome);
638
708
  i++;
@@ -642,7 +712,10 @@ export async function runAgentCore(opts) {
642
712
  // 拒绝时只渲染拒绝结果,不渲染执行头;放行则继续走 header → start → executeTool 流程。
643
713
  const parsed = parseArgs(tc.arguments);
644
714
  const tool = tools.find((t) => t.name === tc.name);
645
- if (tool) {
715
+ const argumentsValid = tool && parsed !== null
716
+ ? validateToolArguments(tool, parsed).valid
717
+ : false;
718
+ if (tool && argumentsValid) {
646
719
  const perm = await checkPermission(tool, parsed ?? {}, signal);
647
720
  emitTrace('permission', {
648
721
  source: 'agent_tool',
@@ -657,7 +730,7 @@ export async function runAgentCore(opts) {
657
730
  hooks.onToolHeader?.(tc);
658
731
  const outcome = deniedOutcome(tc.name);
659
732
  hooks.onToolResult?.(tc, outcome.output, null, null, 1);
660
- const hint = recordAndHint(tc.name, tc.arguments);
733
+ const hint = recordAndHint(tc.name, tc.arguments, false);
661
734
  pushToolResult(history, tc, hint ? `${outcome.output}${hint}` : outcome.output, relprune, lifecycle, scheduler, runtimeContextState, false);
662
735
  traceToolEnd(tc, i, outcome);
663
736
  i++;
@@ -676,22 +749,28 @@ export async function runAgentCore(opts) {
676
749
  if (mutationParsed)
677
750
  diff = readDiffContext(tc, lockedArgs);
678
751
  },
752
+ onRetry: (retry) => traceToolRetry(tc, i, retry),
679
753
  });
754
+ addToolUsage(outcome);
755
+ opts.onToolOutcome?.(tc.name, parsed ?? {}, outcome);
680
756
  traceToolEnd(tc, i, outcome);
681
757
  const output = outcome.output;
682
758
  hooks.onToolDone?.();
683
759
  hooks.onToolResult?.(tc, output, mutationParsed, diff.preWriteOld, diff.editStartLine);
684
760
  // Thrashing:同上(history 附 hint,UI 干净)
685
- const hint = recordAndHint(tc.name, tc.arguments);
761
+ const hint = recordAndHint(tc.name, tc.arguments, outcome.status === 'success');
686
762
  pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
687
- // 只有成功 mutation 才会使旧 read 失效;pruner 与 lifecycle 独立启停。
688
- if (isMutationTool(tc.name) && outcome.status === 'success') {
689
- const mp = mutationParsed?.path;
690
- if (typeof mp === 'string' && mp) {
691
- relprune?.observeMutation(history, mp);
692
- lifecycle?.pushMutation(history, history.length - 1, mp);
693
- runtimeContextState.lifecycleStats = lifecycle?.stats();
763
+ const invalidatedFiles = [...new Set([
764
+ ...(outcome.changedFiles ?? []),
765
+ ...(outcome.staleFiles ?? []),
766
+ ])];
767
+ if (invalidatedFiles.length > 0) {
768
+ for (const changedFile of invalidatedFiles) {
769
+ relprune?.observeMutation(history, changedFile);
770
+ lifecycle?.pushMutation(history, history.length - 1, changedFile);
694
771
  }
772
+ invalidateArtifacts(runtimeContextState, history, invalidatedFiles);
773
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
695
774
  }
696
775
  i++;
697
776
  }
@@ -801,6 +880,7 @@ export async function runAgentCore(opts) {
801
880
  completed: false,
802
881
  terminationReason: 'aborted',
803
882
  finalText: null,
883
+ usage: turnUsage,
804
884
  validation: latestValidation,
805
885
  changedFiles: mutation.changedFiles.map((item) => item.path),
806
886
  };