mocode-ai 1.7.11 → 1.8.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.
@@ -5,7 +5,7 @@
5
5
  import { ui } from '../ui/theme.js';
6
6
  import { Spinner } from '../ui/spinner.js';
7
7
  import { summarizeToolCall, summarizeToolResult, truncateDisplay, fmtElapsed } from '../ui/render.js';
8
- import { renderFileChange } from '../ui/diff.js';
8
+ import { renderFileChange, renderMultiFileChange } from '../ui/diff.js';
9
9
  import * as layout from '../ui/layout.js';
10
10
  import * as batch from '../ui/batch.js';
11
11
  import { config } from '../config/index.js';
@@ -150,7 +150,7 @@ function writeToolHeader(tc, toolRuntime) {
150
150
  }
151
151
  /** 渲染工具结果:mutation 成功走 diff 块(行号 + 语法高亮);其余走一行 preview。
152
152
  * 同 writeToolHeader,改为累积到 BatchRenderer(只缓存字符串,不写屏)。 */
153
- function writeToolResult(tc, output, parsed, preWriteOld, editStartLine, toolRuntime) {
153
+ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine, toolRuntime, multiHunks) {
154
154
  // 优先按 tool_call id 反查所属批(并行 / sub-agent 组时 currentBatchId 已不是它)。
155
155
  const batchId = batch.batchIdForCall(tc.id) ?? currentBatchId;
156
156
  if (!batchId)
@@ -173,19 +173,55 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine, toolRun
173
173
  }
174
174
  let diff = null;
175
175
  if ((tc.name === 'edit_file' || tc.name === 'write_file') && parsed && !isToolErrorOutput(output)) {
176
- const oldText = tc.name === 'edit_file' ? String(parsed.old_string ?? '') : preWriteOld;
177
- const newText = String((tc.name === 'edit_file' ? parsed.new_string : parsed.content) ?? '');
178
- diff = renderFileChange({
179
- path: String(parsed.path ?? ''),
180
- kind: tc.name === 'edit_file' ? 'edit' : 'write',
181
- oldStr: tc.name === 'edit_file' ? String(parsed.old_string ?? '') : preWriteOld,
182
- newStr: String((tc.name === 'edit_file' ? parsed.new_string : parsed.content) ?? ''),
183
- startLine: tc.name === 'edit_file' ? editStartLine : 1,
184
- });
176
+ const filePath = String(parsed.path ?? '');
177
+ const isEdit = tc.name === 'edit_file';
178
+ // 多替换模式:每个 old/new 段一个 hunk,渲染成一个 Update 块内的多个 hunk。
179
+ const multi = isEdit && multiHunks && multiHunks.length > 0 ? multiHunks : null;
180
+ const oldText = isEdit
181
+ ? multi
182
+ ? multi.map((h) => h.oldStr).join('\n')
183
+ : String(parsed.old_string ?? '')
184
+ : preWriteOld;
185
+ const newText = isEdit
186
+ ? multi
187
+ ? multi.map((h) => h.newStr).join('\n')
188
+ : String(parsed.new_string ?? '')
189
+ : String(parsed.content ?? '');
190
+ if (multi) {
191
+ diff = renderMultiFileChange({
192
+ path: filePath,
193
+ hunks: multi.map((h) => ({ oldStr: h.oldStr, newStr: h.newStr, startLine: h.startLine })),
194
+ });
195
+ // 渲染器对超大 diff 返回 null(无法做 LCS)时退回工具结果摘要。
196
+ if (!diff)
197
+ diff = renderFileChange({
198
+ path: filePath,
199
+ kind: 'edit',
200
+ oldStr: oldText,
201
+ newStr: newText,
202
+ startLine: editStartLine,
203
+ });
204
+ }
205
+ else {
206
+ diff = renderFileChange({
207
+ path: filePath,
208
+ kind: isEdit ? 'edit' : 'write',
209
+ oldStr: isEdit ? String(parsed.old_string ?? '') : preWriteOld,
210
+ newStr: String((isEdit ? parsed.new_string : parsed.content) ?? ''),
211
+ startLine: isEdit ? editStartLine : 1,
212
+ });
213
+ }
214
+ // 回合改动统计:多替换按各 hunk 独立计算后累加(拼接不相邻段再 diff 会把跨段行误算成增删)。
215
+ const delta = multi
216
+ ? multi.reduce((acc, h) => {
217
+ const d = lineDelta(h.oldStr, h.newStr);
218
+ return { added: acc.added + d.added, removed: acc.removed + d.removed };
219
+ }, { added: 0, removed: 0 })
220
+ : lineDelta(oldText, newText);
185
221
  turnFileChanges.push({
186
- path: String(parsed.path ?? ''),
222
+ path: filePath,
187
223
  kind: tc.name === 'write_file' && preWriteOld == null ? 'A' : 'M',
188
- ...lineDelta(oldText, newText),
224
+ ...delta,
189
225
  });
190
226
  }
191
227
  const preview = diff ? '' : summarizeToolResult(tc.name, output);
@@ -406,7 +442,7 @@ runtimeOrContext = defaultRuntime) {
406
442
  onToolStart: (name) => spinner.start(t('agent.executing', { tool: name })),
407
443
  onToolOutput: (tc, chunk) => batch.recordOutput(tc.id, chunk, layout),
408
444
  onToolDone: () => spinner.stop(),
409
- onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine, runtime.context.toolRuntime),
445
+ onToolResult: (tc, output, parsed, preWriteOld, editStartLine, multiHunks) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine, runtime.context.toolRuntime, multiHunks),
410
446
  onToolBatchEnd: () => {
411
447
  // 一次工具轮次结束不再切 UI batch;下一轮若仍无正文,继续复用 currentBatchId。
412
448
  },
@@ -290,7 +290,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
290
290
  break;
291
291
  }
292
292
  case 'result':
293
- hooks.onToolResult?.(event.call, event.outcome.output, event.parsed, event.diff.preWriteOld, event.diff.editStartLine);
293
+ hooks.onToolResult?.(event.call, event.outcome.output, event.parsed, event.diff.preWriteOld, event.diff.editStartLine, event.diff.multiHunks);
294
294
  if (event.call.name === 'ask_human' && event.outcome.status === 'success') {
295
295
  askHumanCountThisTurn += 1;
296
296
  emitTrace('ask_human_call', {
@@ -326,6 +326,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
326
326
  delegation: delegationForOrchestrator,
327
327
  readDedup,
328
328
  argumentErrorHint: (name) => argumentErrorHint(name, runtimeContextState),
329
+ ...(result.finishReason ? { finishReason: result.finishReason } : {}),
329
330
  onToolOutput: (call, chunk) => hooks.onToolOutput?.(call, chunk),
330
331
  ...(opts.toolPolicy
331
332
  ? {
@@ -401,6 +402,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
401
402
  allowedToolNames: currentAllowedToolNames(),
402
403
  delegation: delegationForOrchestrator(),
403
404
  readDedup,
405
+ ...(result.finishReason ? { finishReason: result.finishReason } : {}),
404
406
  });
405
407
  }
406
408
  }
@@ -572,6 +574,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
572
574
  allowedToolNames: currentAllowedToolNames(),
573
575
  delegation: delegationForOrchestrator(),
574
576
  readDedup,
577
+ ...(result.finishReason ? { finishReason: result.finishReason } : {}),
575
578
  }));
576
579
  // 完成即发:usage/onToolOutcome/trace 在各 outcome 落地瞬间发出(1s 的
577
580
  // grep 不再被 60s 的 web_fetch 卡住可见性);history 回灌严格按原序。
@@ -671,6 +674,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
671
674
  delegation: delegationForOrchestrator(),
672
675
  readDedup,
673
676
  ...(hint ? { argumentErrorHint: hint } : {}),
677
+ ...(result.finishReason ? { finishReason: result.finishReason } : {}),
674
678
  onLockAcquired: (lockedArgs) => {
675
679
  entry.diff = readDiffContext(entry.tc, lockedArgs, ctx.jailResolve);
676
680
  },
@@ -685,7 +689,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
685
689
  for (let offset = 0; offset < entries.length; offset++) {
686
690
  const entry = entries[offset];
687
691
  const outcome = await outcomes[offset];
688
- hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
692
+ hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine, entry.diff.multiHunks);
689
693
  pushToolResult(history, entry.tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
690
694
  const invalidatedFiles = [
691
695
  ...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])]),
@@ -760,6 +764,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
760
764
  delegation: delegationForOrchestrator(),
761
765
  readDedup,
762
766
  ...(hint ? { argumentErrorHint: hint } : {}),
767
+ ...(result.finishReason ? { finishReason: result.finishReason } : {}),
763
768
  onLockAcquired: (lockedArgs) => {
764
769
  entry.diff = readDiffContext(entry.tc, lockedArgs, ctx.jailResolve);
765
770
  },
@@ -776,7 +781,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
776
781
  for (let k = 0; k < entries.length; k++) {
777
782
  const entry = entries[k];
778
783
  const outcome = await started[k];
779
- hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
784
+ hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine, entry.diff.multiHunks);
780
785
  pushToolResult(history, entry.tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
781
786
  const invalidatedFiles = [
782
787
  ...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])]),
@@ -856,6 +861,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
856
861
  delegation: delegationForOrchestrator(),
857
862
  readDedup,
858
863
  ...(serialHint ? { argumentErrorHint: serialHint } : {}),
864
+ ...(result.finishReason ? { finishReason: result.finishReason } : {}),
859
865
  onLockAcquired: (lockedArgs) => {
860
866
  if (mutationParsed)
861
867
  diff = readDiffContext(tc, lockedArgs, ctx.jailResolve);
@@ -866,7 +872,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
866
872
  traceToolEnd(tc, i, outcome);
867
873
  const output = outcome.output;
868
874
  hooks.onToolDone?.();
869
- hooks.onToolResult?.(tc, output, mutationParsed, diff.preWriteOld, diff.editStartLine);
875
+ hooks.onToolResult?.(tc, output, mutationParsed, diff.preWriteOld, diff.editStartLine, diff.multiHunks);
870
876
  if (tc.name === 'ask_human' && outcome.status === 'success') {
871
877
  askHumanCountThisTurn += 1;
872
878
  emitTrace('ask_human_call', {
@@ -49,6 +49,7 @@ class LegacyCompatibleToolDispatcher {
49
49
  delegation: request.delegation(),
50
50
  readDedup: request.readDedup,
51
51
  ...(hint ? { argumentErrorHint: hint } : {}),
52
+ ...(request.finishReason ? { finishReason: request.finishReason } : {}),
52
53
  ...(onLockAcquired ? { onLockAcquired } : {}),
53
54
  });
54
55
  const executionEvents = (index, parsed, outcome) => {
@@ -128,31 +128,59 @@ export function unregisteredToolOutcome(name, toolRuntime) {
128
128
  /** mutation 执行前读旧内容供 diff:write_file 取整文件旧内容(不存在→null=新建),
129
129
  * edit_file 取 old_string 起始行号(供 diff 显示真实文件行号)。读不到则 diff 退化为相对行号。
130
130
  * 非 mutation 或参数非法返 { preWriteOld: null, editStartLine: 1 }。失败不阻断。 */
131
+ const toLf = (s) => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
131
132
  export function readDiffContext(tc, parsed, resolvePath = jailResolve) {
133
+ const empty = { preWriteOld: null, editStartLine: 1 };
132
134
  if (!parsed)
133
- return { preWriteOld: null, editStartLine: 1 };
135
+ return empty;
134
136
  const p = String(parsed.path ?? '');
135
137
  if (!p)
136
- return { preWriteOld: null, editStartLine: 1 };
138
+ return empty;
137
139
  if (tc.name === 'write_file') {
138
140
  try {
139
141
  // jailResolve:沙箱越界(../../、绝对外圈、软链出圈)抛错 → catch 兜底返 null,不泄露牢外内容(TOCTOU)
140
142
  return { preWriteOld: readFileSync(resolvePath(p), 'utf8'), editStartLine: 1 };
141
143
  }
142
144
  catch {
143
- return { preWriteOld: null, editStartLine: 1 }; // 文件不存在(新建)、不可读 或 沙箱越界(不泄露)
145
+ return empty; // 文件不存在(新建)、不可读 或 沙箱越界(不泄露)
144
146
  }
145
147
  }
146
148
  if (tc.name === 'edit_file') {
147
149
  // 行尾归一化:LLM 生成的 old_string 用 LF(\n),但 Windows 文件可能是 CRLF(\r\n),
148
150
  // 不统一则 indexOf 必败、editStartLine 恒为 1。与 edit-file.ts 保持一致归一化为 LF。
149
- const oldStr = String(parsed.old_string ?? '')
150
- .replace(/\r\n/g, '\n')
151
- .replace(/\r/g, '\n');
151
+ const replacements = Array.isArray(parsed.replacements) ? parsed.replacements : null;
152
152
  try {
153
153
  // jailResolve:同上,沙箱越界抛错 → catch 兜底,不泄露牢外内容
154
154
  const raw = readFileSync(resolvePath(p), 'utf8');
155
- const data = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
155
+ const data = toLf(raw);
156
+ // 多替换模式:在「编辑前」的原文上为每个 old_string 定位唯一命中的起始行,
157
+ // 供多 hunk diff 显示真实文件行号。任一项非唯一/找不到就不带 hunk(退回工具结果摘要),
158
+ // 真正的原子校验仍以 edit-file.ts execute 为准,这里只做展示、失败不阻断。
159
+ if (replacements) {
160
+ const hunks = [];
161
+ for (const item of replacements) {
162
+ if (!item || typeof item !== 'object')
163
+ return empty;
164
+ const rec = item;
165
+ if (typeof rec.old_string !== 'string' || typeof rec.new_string !== 'string')
166
+ return empty;
167
+ const oldStr = toLf(rec.old_string);
168
+ if (!oldStr)
169
+ return empty;
170
+ const first = data.indexOf(oldStr);
171
+ if (first < 0 || data.indexOf(oldStr, first + oldStr.length) !== -1)
172
+ return empty;
173
+ hunks.push({
174
+ oldStr: rec.old_string,
175
+ newStr: rec.new_string,
176
+ startLine: data.slice(0, first).split('\n').length,
177
+ });
178
+ }
179
+ if (hunks.length === 0)
180
+ return empty;
181
+ return { preWriteOld: null, editStartLine: hunks[0].startLine, multiHunks: hunks };
182
+ }
183
+ const oldStr = toLf(String(parsed.old_string ?? ''));
156
184
  const idx = oldStr ? data.indexOf(oldStr) : -1;
157
185
  return {
158
186
  preWriteOld: null,
@@ -160,10 +188,10 @@ export function readDiffContext(tc, parsed, resolvePath = jailResolve) {
160
188
  };
161
189
  }
162
190
  catch {
163
- return { preWriteOld: null, editStartLine: 1 }; // 读不到:diff 退化为相对行号(含沙箱越界)
191
+ return empty; // 读不到:diff 退化为相对行号(含沙箱越界)
164
192
  }
165
193
  }
166
- return { preWriteOld: null, editStartLine: 1 };
194
+ return empty;
167
195
  }
168
196
  /** 回灌 tool 结果到 history。
169
197
  * 正常路径只做单条 hard cap;原始 output 同时供 TUI 展示,因此用户与模型
@@ -22,11 +22,19 @@ export function readConfigFile() {
22
22
  }
23
23
  /**
24
24
  * 读-合并-写:把 entries 覆盖进 ~/.mocode/config,保留文件里其它键。
25
- * mkdirSync 兜底(首跑无 ~/.mocode)。不吞 I/O 异常——向导路径让其抛(与历史行为一致);
26
- * UI 偏好路径用 `updateConfigKey`(自带 try/catch 静默)。
25
+ * 值为 null 表示从配置文件删除该键(用于切到「不带某可选值」的预设时清除残留,
26
+ * 否则旧值会一直盖过新预设)。mkdirSync 兜底(首跑无 ~/.mocode)。不吞 I/O 异常——
27
+ * 向导路径让其抛(与历史行为一致);UI 偏好路径用 `updateConfigKey`(自带 try/catch 静默)。
27
28
  */
28
29
  export function writeConfigKeys(entries) {
29
- const merged = { ...readConfigFile(), ...entries };
30
+ const existing = readConfigFile();
31
+ const merged = { ...existing };
32
+ for (const [k, v] of Object.entries(entries)) {
33
+ if (v === null)
34
+ delete merged[k];
35
+ else
36
+ merged[k] = v;
37
+ }
30
38
  const body = Object.entries(merged)
31
39
  .map(([k, v]) => `${k}=${v}`)
32
40
  .join('\n');
@@ -57,6 +57,7 @@ const LLM_ENV_KEYS = [
57
57
  'LLM_API_KEY',
58
58
  'LLM_MODEL',
59
59
  'CONTEXT_WINDOW_TOKENS',
60
+ 'MAX_TOKENS',
60
61
  'ANTHROPIC_PROMPT_CACHE',
61
62
  'REASONING_EFFORT',
62
63
  ];
@@ -485,7 +486,7 @@ ${buildWorkDisciplineSection(inferModelFamily(config.model))}
485
486
  - Silent Execution: invoke tools directly without preamble. Output visible text ONLY for the final answer and critical mid-task findings. Strictly no step-by-step narration (no "let me…", "让我先…", "now checking…" between calls).
486
487
  - Go directly to a known path or symbol; use discovery tools only when the location is unknown.
487
488
  - Edit against a FRESH read: before editing, call read_file on the exact path and copy the exact target text. Never reconstruct old_string from a grep/summary/diff — those lose whitespace and indentation and cause edit failures.
488
- - Pick the right file tool: use edit_file for partial changes to an existing file (expected_hash is OPTIONAL in old_string mode, REQUIRED in line-range mode); use write_file only to create a file, to rewrite most of a file (replacing needs the latest read_file hash), or to append (append=true needs no read_file and no hash). Never write a throwaway script (tmp_patch*.cjs, fix_*.py, ...) to modify source files: scripts run outside ChangeSet tracking, so rollback and diff cannot see them.
489
+ - Pick the right file tool: for an EXISTING file, DEFAULT to edit_file — one old_string for a single change, replacements[] for many separate changes in one atomic call, or a line range for a big block. Do NOT overwrite an existing file with write_file just to tweak parts of it (re-sending the whole file wastes tokens and its long JSON is truncation-prone). Use write_file only to create a new file, to append (append=true needs no read_file and no hash), or to rewrite essentially the WHOLE file (a full rewrite needs the latest read_file hash). expected_hash is OPTIONAL in edit_file old_string/replacements mode, REQUIRED in line-range mode. Never write a throwaway script (tmp_patch*.cjs, fix_*.py, ...) to modify source files: scripts run outside ChangeSet tracking, so rollback and diff cannot see them.
489
490
  - A read_file hash from before a compaction, session resume, edit conflict, or external change is STALE and will be rejected if you pass it. In edit_file old_string mode just omit expected_hash; otherwise re-read rather than reuse an old hash.
490
491
  - Emit multiple independent tool calls in ONE assistant message so they run concurrently — e.g. several read_file regions, a grep plus a glob, or several web_fetch calls. One lookup per message wastes a full model round-trip each time. Place parallel-safe calls consecutively; keep any call that depends on their results (e.g. an edit) for the next message.
491
492
  - Never batch a read with an edit that depends on it; do not repeat overlapping reads or unchanged failed calls.
@@ -581,7 +582,8 @@ export const config = {
581
582
  model: llmKeysFromShell.includes('LLM_MODEL')
582
583
  ? process.env.LLM_MODEL || 'gpt-4o-mini'
583
584
  : (__activePreset?.model ?? process.env.LLM_MODEL ?? 'gpt-4o-mini'),
584
- maxTokens: process.env.MAX_TOKENS ? Number(process.env.MAX_TOKENS) : undefined,
585
+ // 优先级:shell env > 激活预设(目录添加时按 limit.output 预填)> undefined=不下发吃后端默认。
586
+ maxTokens: process.env.MAX_TOKENS ? Number(process.env.MAX_TOKENS) : (__activePreset?.maxTokens ?? undefined),
585
587
  // 用 getter 而非 buildBasePrompt() 立即求值:对象字面量初始化期间 buildBasePrompt 会读取
586
588
  // config.model 等字段,直接调用会触发 TDZ。Getter 也让语言、persona 等运行时变化在下一轮生效。
587
589
  get systemPrompt() {
@@ -727,6 +729,14 @@ export function updateModelConfig(opts) {
727
729
  config.contextWindowTokens = opts.contextWindowTokens;
728
730
  process.env.CONTEXT_WINDOW_TOKENS = String(opts.contextWindowTokens);
729
731
  }
732
+ if (opts.maxTokens !== undefined) {
733
+ // null = 显式清除(切到无该值的预设);数字 = 设置。同步 env 保证本窗口立即按新值/不发该字段。
734
+ config.maxTokens = opts.maxTokens === null ? undefined : opts.maxTokens;
735
+ if (opts.maxTokens === null)
736
+ delete process.env.MAX_TOKENS;
737
+ else
738
+ process.env.MAX_TOKENS = String(opts.maxTokens);
739
+ }
730
740
  if (opts.anthropicPromptCache !== undefined) {
731
741
  config.anthropicPromptCache = opts.anthropicPromptCache;
732
742
  process.env.ANTHROPIC_PROMPT_CACHE = opts.anthropicPromptCache ? 'true' : 'false';
@@ -88,6 +88,14 @@ export function parsePreset(raw) {
88
88
  if (typeof contextWindow !== 'number' || !Number.isFinite(contextWindow) || contextWindow <= 0) {
89
89
  throw new Error(`预设 ${name}: contextWindow 必须为正数`);
90
90
  }
91
+ // maxTokens 可选:缺省=不下发(吃后端默认);给了就必须是正整数。
92
+ let maxTokens;
93
+ if (obj.maxTokens !== undefined && obj.maxTokens !== null) {
94
+ if (typeof obj.maxTokens !== 'number' || !Number.isFinite(obj.maxTokens) || obj.maxTokens <= 0) {
95
+ throw new Error(`预设 ${name}: maxTokens 必须为正数`);
96
+ }
97
+ maxTokens = Math.floor(obj.maxTokens);
98
+ }
91
99
  const provider = normalizePresetProvider(obj.provider);
92
100
  const anthropicPromptCache = provider === 'anthropic' && obj.anthropicPromptCache !== false;
93
101
  // reasoningEffort 缺省 = auto;显式写了非法值按现有校验风格报错(坏文件由 listPresets 跳过)。
@@ -132,6 +140,7 @@ export function parsePreset(raw) {
132
140
  apiKey,
133
141
  model,
134
142
  contextWindow: Math.floor(contextWindow),
143
+ ...(maxTokens ? { maxTokens } : {}),
135
144
  anthropicPromptCache,
136
145
  ...(reasoningEffort ? { reasoningEffort } : {}),
137
146
  ...extra,
package/dist/llm/index.js CHANGED
@@ -740,6 +740,10 @@ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = def
740
740
  let consumedAny = false;
741
741
  const thinkFilter = new ThinkTagFilter();
742
742
  let usage;
743
+ // finish_reason 只在最后一个带 choice 的 chunk 出现;取最后一次非空值。
744
+ // 'length' 意味着输出在 max_tokens 处被硬切断——长 tool_call 参数此时必为残缺 JSON,
745
+ // 工具层据此把笼统的 INVALID_JSON 升级成截断恢复指引。
746
+ let finishReason;
743
747
  const toolAcc = new Map();
744
748
  const emitVisible = (text) => {
745
749
  if (!text)
@@ -784,7 +788,11 @@ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = def
784
788
  cachedTokens: usage.cachedTokens,
785
789
  });
786
790
  }
787
- const delta = chunk.choices?.[0]?.delta;
791
+ const choice0 = chunk.choices?.[0];
792
+ const delta = choice0?.delta;
793
+ if (typeof choice0?.finish_reason === 'string' && choice0.finish_reason) {
794
+ finishReason = choice0.finish_reason;
795
+ }
788
796
  if (!delta)
789
797
  continue; // 末尾 usage-only chunk 等无 delta
790
798
  if (delta.content) {
@@ -835,6 +843,7 @@ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = def
835
843
  content: consumedAny ? visibleContent : null,
836
844
  toolCalls,
837
845
  usage,
846
+ finishReason,
838
847
  };
839
848
  }
840
849
  // ── token 估算(自包含,不依赖 ui / 外部 tokenizer)──────────────────────
@@ -315,6 +315,8 @@ export async function anthropicChatOnce(messages, handlers, signal, tools, runti
315
315
  await throwResponseError(response);
316
316
  let content = '';
317
317
  let usage;
318
+ // message_delta 的 delta.stop_reason 给出停止原因;max_tokens 归一到 OpenAI 的 'length'。
319
+ let finishReason;
318
320
  const toolAcc = new Map();
319
321
  const live = { cjk: 0, other: 0 };
320
322
  const reportLive = () => {
@@ -391,6 +393,12 @@ export async function anthropicChatOnce(messages, handlers, signal, tools, runti
391
393
  continue;
392
394
  }
393
395
  if (type === 'message_delta') {
396
+ // stop_reason 位于 message_delta 的 delta 子对象(与 usage 同级事件)。
397
+ const d = event.delta;
398
+ if (typeof d?.stop_reason === 'string' && d.stop_reason) {
399
+ // Anthropic: end_turn / max_tokens / stop_sequence / tool_use / tool_use 等。
400
+ finishReason = d.stop_reason === 'max_tokens' ? 'length' : d.stop_reason;
401
+ }
394
402
  const raw = event.usage;
395
403
  if (raw) {
396
404
  const previous = usage ?? usageFromAnthropic({});
@@ -414,5 +422,5 @@ export async function anthropicChatOnce(messages, handlers, signal, tools, runti
414
422
  const toolCalls = [...toolAcc.entries()]
415
423
  .sort((a, b) => a[0] - b[0])
416
424
  .map(([, call]) => ({ ...call, arguments: call.arguments || '{}' }));
417
- return { content: content || null, toolCalls, usage };
425
+ return { content: content || null, toolCalls, usage, finishReason };
418
426
  }
@@ -407,5 +407,11 @@ export async function vertexChatOnce(messages, handlers, signal, tools, runtime,
407
407
  content += marker;
408
408
  handlers.onText?.(marker);
409
409
  }
410
- return { content: content || null, toolCalls, usage };
410
+ return {
411
+ content: content || null,
412
+ toolCalls,
413
+ usage,
414
+ // Gemini MAX_TOKENS 归一到 OpenAI 的 'length',供工具层给出截断恢复指引。
415
+ ...(finishReason ? { finishReason: finishReason === 'MAX_TOKENS' ? 'length' : finishReason } : {}),
416
+ };
411
417
  }
@@ -57,6 +57,9 @@ export function buildPreset(input) {
57
57
  const contextWindow = typeof model.limit?.context === 'number' && model.limit.context > 0
58
58
  ? Math.floor(model.limit.context)
59
59
  : FALLBACK_CONTEXT_WINDOW;
60
+ // 单次输出上限:目录有 limit.output 就带上(避免吃网关小默认导致长 write_file 被截断);
61
+ // 目录缺失则留 undefined=不下发,绝不硬编码大值(超过模型真实上限会被后端 400 拒整请求)。
62
+ const maxOutput = typeof model.limit?.output === 'number' && model.limit.output > 0 ? Math.floor(model.limit.output) : undefined;
60
63
  return {
61
64
  name: input.name ?? defaultPresetName(provider.id, model.id),
62
65
  provider: presetProvider,
@@ -64,6 +67,7 @@ export function buildPreset(input) {
64
67
  apiKey: input.apiKey,
65
68
  model: input.modelOverride ?? model.id,
66
69
  contextWindow,
70
+ ...(maxOutput ? { maxTokens: maxOutput } : {}),
67
71
  anthropicPromptCache: presetProvider === 'anthropic',
68
72
  };
69
73
  }
@@ -23,6 +23,8 @@ export function applyPresetConfig(ctx, p) {
23
23
  baseURL: p.baseURL,
24
24
  apiKey: p.apiKey,
25
25
  contextWindowTokens: p.contextWindow,
26
+ // 预设带值用值;不带显式清除(否则旧 MAX_TOKENS 残留会盖过新预设)。
27
+ maxTokens: p.maxTokens ?? null,
26
28
  anthropicPromptCache: p.anthropicPromptCache,
27
29
  ...(p.reasoningEffort ? { reasoningEffort: p.reasoningEffort } : {}),
28
30
  });
@@ -32,6 +34,7 @@ export function applyPresetConfig(ctx, p) {
32
34
  LLM_API_KEY: p.apiKey,
33
35
  LLM_MODEL: p.model,
34
36
  CONTEXT_WINDOW_TOKENS: String(p.contextWindow),
37
+ MAX_TOKENS: p.maxTokens ? String(p.maxTokens) : null,
35
38
  ANTHROPIC_PROMPT_CACHE: p.anthropicPromptCache ? 'true' : 'false',
36
39
  });
37
40
  // 关键不变量:应用预设即激活——必须同步 .active 指针。否则重启时旧激活预设的优先级
@@ -25,14 +25,15 @@ import { renderHistory } from '../message-format.js';
25
25
  import { MODEL_PRESETS, maskKey } from '../commands.js';
26
26
  import { unhandled, next } from './types.js';
27
27
  /** 决定自动存的预设名:协议、缓存配置和连接四元组都一致时不重复存。 */
28
- function uniquePresetName(desired, provider, baseURL, apiKey, model, contextWindow, anthropicPromptCache) {
28
+ function uniquePresetName(desired, provider, baseURL, apiKey, model, contextWindow, anthropicPromptCache, maxTokens) {
29
29
  const existing = listPresets();
30
30
  const sameEntry = existing.find((p) => p.provider === provider &&
31
31
  p.baseURL === baseURL &&
32
32
  p.apiKey === apiKey &&
33
33
  p.model === model &&
34
34
  p.contextWindow === contextWindow &&
35
- p.anthropicPromptCache === anthropicPromptCache);
35
+ p.anthropicPromptCache === anthropicPromptCache &&
36
+ (p.maxTokens ?? undefined) === maxTokens);
36
37
  if (sameEntry)
37
38
  return null;
38
39
  const sanitized = desired
@@ -73,6 +74,8 @@ export const modelCommands = [
73
74
  baseURL: target.baseURL,
74
75
  apiKey: target.apiKey,
75
76
  contextWindowTokens: target.contextWindow,
77
+ // 预设带值用值;不带则显式清除(否则本窗口/配置文件里的旧 MAX_TOKENS 会一直盖过新预设)。
78
+ maxTokens: target.maxTokens ?? null,
76
79
  anthropicPromptCache: target.anthropicPromptCache,
77
80
  ...(target.reasoningEffort ? { reasoningEffort: target.reasoningEffort } : {}),
78
81
  });
@@ -82,6 +85,8 @@ export const modelCommands = [
82
85
  LLM_API_KEY: target.apiKey,
83
86
  LLM_MODEL: target.model,
84
87
  CONTEXT_WINDOW_TOKENS: String(target.contextWindow),
88
+ // null = 从配置文件删除该键(写不下发=吃后端默认)。
89
+ MAX_TOKENS: target.maxTokens ? String(target.maxTokens) : null,
85
90
  ANTHROPIC_PROMPT_CACHE: target.anthropicPromptCache ? 'true' : 'false',
86
91
  ...(target.reasoningEffort ? { REASONING_EFFORT: target.reasoningEffort } : {}),
87
92
  });
@@ -185,6 +190,7 @@ export const modelCommands = [
185
190
  layout.contentWrite(` ${ui.accent}effort ${ui.reset} ${config.reasoningEffort}` +
186
191
  (recognized ? '\n' : `${ui.dim}(当前模型未识别,参数不会下发)${ui.reset}\n`));
187
192
  layout.contentWrite(` ${ui.accent}窗口 ${ui.reset} ${config.contextWindowTokens} tokens\n`);
193
+ layout.contentWrite(` ${ui.accent}输出上限${ui.reset} ${config.maxTokens ? `${config.maxTokens} tokens` : `${ui.dim}未设置(不下发,吃后端默认)${ui.reset}`}\n`);
188
194
  if (config.provider === 'anthropic') {
189
195
  layout.contentWrite(` ${ui.accent}缓存 ${ui.reset} Prompt Cache ${config.anthropicPromptCache ? 'on' : 'off'}\n`);
190
196
  }
@@ -284,11 +290,14 @@ export const modelCommands = [
284
290
  let apiKey;
285
291
  let model;
286
292
  let window;
293
+ // 单次输出上限;undefined = 不下发(吃后端默认)。quickApply 沿用当前值。
294
+ let maxTokens;
287
295
  if (quickApply) {
288
296
  baseURL = preset.baseURL;
289
297
  apiKey = config.apiKey;
290
298
  model = preset.model;
291
299
  window = preset.window;
300
+ maxTokens = config.maxTokens;
292
301
  }
293
302
  else {
294
303
  // baseURL
@@ -364,6 +373,29 @@ export const modelCommands = [
364
373
  window = Math.floor(n);
365
374
  }
366
375
  }
376
+ // maxTokens(可选):单次回复输出上限。回车采纳当前值;填 0/留空 = 不下发吃后端默认。
377
+ {
378
+ const res = await promptIntervention({
379
+ type: 'input',
380
+ title: 'MAX_TOKENS',
381
+ detail: '单次回复最大输出 token(思考+正文+工具参数)。留空=不下发吃后端默认;' +
382
+ '部分兼容网关默认仅 4k/8k,长文件写入会被截断,建议填该模型官方最大输出(常见 8k-64k)。回车采纳当前值。',
383
+ seed: config.maxTokens ? String(config.maxTokens) : '',
384
+ });
385
+ if (res.action === 'cancelled') {
386
+ return next();
387
+ }
388
+ const v = (res.value ?? '').trim();
389
+ if (v) {
390
+ const n = Number(v);
391
+ if (Number.isFinite(n) && n > 0)
392
+ maxTokens = Math.floor(n);
393
+ // 非法值不阻断:保持 undefined(不下发),与留空同义。
394
+ }
395
+ else {
396
+ maxTokens = undefined;
397
+ }
398
+ }
367
399
  }
368
400
  // 3) 应用协议、连接与缓存配置;Anthropic 原生协议无需重建 OpenAI client,但统一刷新无害。
369
401
  const provider = preset.provider;
@@ -374,6 +406,8 @@ export const modelCommands = [
374
406
  baseURL,
375
407
  apiKey,
376
408
  contextWindowTokens: window,
409
+ // 手填向导里留空 = undefined → 显式清除旧值,不下发 max_tokens。
410
+ maxTokens: maxTokens ?? null,
377
411
  anthropicPromptCache,
378
412
  });
379
413
  writeConfigKeys({
@@ -382,13 +416,14 @@ export const modelCommands = [
382
416
  LLM_API_KEY: apiKey,
383
417
  LLM_MODEL: model,
384
418
  CONTEXT_WINDOW_TOKENS: String(window),
419
+ MAX_TOKENS: maxTokens ? String(maxTokens) : null,
385
420
  ANTHROPIC_PROMPT_CACHE: anthropicPromptCache ? 'true' : 'false',
386
421
  });
387
422
  reconfigureClient();
388
423
  // 3.5) 自动存为命名预设;协议与缓存策略也是去重键的一部分。
389
424
  let savedName = null;
390
425
  try {
391
- const finalName = uniquePresetName(model, provider, baseURL, apiKey, model, window, anthropicPromptCache);
426
+ const finalName = uniquePresetName(model, provider, baseURL, apiKey, model, window, anthropicPromptCache, maxTokens);
392
427
  if (finalName) {
393
428
  savePreset({
394
429
  name: finalName,
@@ -397,6 +432,7 @@ export const modelCommands = [
397
432
  apiKey,
398
433
  model,
399
434
  contextWindow: window,
435
+ ...(maxTokens ? { maxTokens } : {}),
400
436
  anthropicPromptCache,
401
437
  });
402
438
  savedName = finalName;
@@ -16,10 +16,12 @@ function conflict(path, details) {
16
16
  }
17
17
  export const editFileTool = {
18
18
  name: 'edit_file',
19
- description: `Replace content in a file transactionally. Two modes:
19
+ description: `The DEFAULT tool for changing an EXISTING file: transactional, surgical edits — use this instead of re-writing the whole file with write_file. Pick ONE of three modes per call:
20
20
 
21
21
  **String replacement (default):** old_string must occur EXACTLY once; copy it verbatim from a fresh read_file output (see Tool policy — never reconstruct it from memory, summaries, or grep output). expected_hash is OPTIONAL in this mode: omit it and the tool checks the file itself (old_string must still match exactly once, and the commit is conflict-checked). If you do pass it, it must match the current file or the edit is rejected.
22
22
 
23
+ **Multiple replacements:** pass replacements: [{ old_string, new_string }, …] to apply MANY edits across the file in ONE atomic call. Each old_string must occur exactly once in the original file and the matches must not overlap; if ANY of them is missing, ambiguous, or overlapping, the whole call is rejected and nothing is written. Prefer this over write_file whenever you change several spots — it keeps each payload small and never touches unrelated content. Mutually exclusive with the single old_string and line-range modes.
24
+
23
25
  **Line-range:** line_start/line_end (1-based, inclusive) instead of old_string — for large blocks, repeated patterns, or hard-to-reproduce text. Line numbers drift when a file changes, so expected_hash (sha256 from the read_file artifact header) is REQUIRED here; if it is stale, re-read and retry with the new hash.`,
24
26
  risk: 'confirm',
25
27
  parameters: {
@@ -28,9 +30,25 @@ export const editFileTool = {
28
30
  path: { type: 'string', description: 'Absolute file path.' },
29
31
  old_string: {
30
32
  type: 'string',
31
- description: 'String replacement mode: the exact text to replace (must occur once). Mutually exclusive with line_start/line_end.',
33
+ description: 'Single-replacement mode: the exact text to replace (must occur once). Mutually exclusive with replacements[] and line_start/line_end.',
34
+ },
35
+ new_string: {
36
+ type: 'string',
37
+ description: 'Single-replacement / line-range mode: the replacement text.',
38
+ },
39
+ replacements: {
40
+ type: 'array',
41
+ minItems: 1,
42
+ description: 'Multi-replacement mode: many edits applied ATOMICALLY in one call. Each old_string must occur exactly once in the current file and matches must not overlap; if any item is missing/ambiguous/overlapping the whole call is rejected and nothing is written. Mutually exclusive with old_string and line_start/line_end.',
43
+ items: {
44
+ type: 'object',
45
+ properties: {
46
+ old_string: { type: 'string', description: 'Exact text to replace (must occur once).' },
47
+ new_string: { type: 'string', description: 'Its replacement.' },
48
+ },
49
+ required: ['old_string', 'new_string'],
50
+ },
32
51
  },
33
- new_string: { type: 'string', description: 'The replacement text.' },
34
52
  line_start: {
35
53
  type: 'integer',
36
54
  description: 'Line-range mode: start line (1-based, inclusive). Mutually exclusive with old_string.',
@@ -41,28 +59,64 @@ export const editFileTool = {
41
59
  },
42
60
  expected_hash: {
43
61
  type: 'string',
44
- description: 'sha256 hash from the latest read_file artifact header (sha256:<64 hex>). Optional for old_string mode; required for line-range mode.',
62
+ description: 'sha256 hash from the latest read_file artifact header (sha256:<64 hex>). Optional for old_string / replacements modes; required for line-range mode.',
45
63
  },
46
64
  },
47
- // 注意:expected_hash 故意不放进 schema 的 required。
48
- // 原因:old_string 模式下它是可选的(old_string 唯一命中本身就是基于内容的前置条件,
49
- // 提交时 ChangeSet 仍会比对读到的内容 hash 防并发改动);行号模式下必填,但缺失时若被
50
- // AJV 在 schema 层拦下,只会得到泛化的「缺少必填字段 expected_hash」,模型据此只补该字段、
51
- // 反而丢掉 path,形成乒乓失败。故只在 execute 内校验,缺失时返回富指导信息。
52
- required: ['path', 'new_string'],
65
+ // 注意:expected_hash 与模式专属字段(new_string / replacements / line_*)都不放进 schema required。
66
+ // 原因:三模式互斥,必填字段取决于所选模式;AJV 只能表达「静态必填」,在 schema 层拦下只会得到
67
+ // 泛化的「缺少必填字段 X」,模型据此只补该字段、反而丢掉 path,形成乒乓失败。故只在 execute 内
68
+ // 按模式校验,缺失/互斥时返回富指导信息。
69
+ required: ['path'],
53
70
  },
54
71
  async execute(args, ctx) {
55
72
  const file = String(args.path);
56
- const newString = String(args.new_string);
57
73
  const hasOldString = args.old_string !== undefined;
58
74
  const hasLineStart = args.line_start !== undefined;
59
75
  const hasLineEnd = args.line_end !== undefined;
60
- // 参数互斥检查
61
- if (hasOldString && (hasLineStart || hasLineEnd)) {
62
- return conflict(file, 'old_string 和 line_start/line_end 互斥,请选择一种模式。');
76
+ const hasReplacements = args.replacements !== undefined;
77
+ const hasLineRange = hasLineStart || hasLineEnd;
78
+ // 三模式互斥:old_string(single)/ replacements[](multi)/ line_start+line_end(line),一次恰好一种。
79
+ const modeCount = (hasOldString ? 1 : 0) + (hasReplacements ? 1 : 0) + (hasLineRange ? 1 : 0);
80
+ if (modeCount > 1) {
81
+ return conflict(file, 'old_string、replacements[]、line_start/line_end 三种模式互斥,一次只能用一种。');
82
+ }
83
+ if (hasLineStart !== hasLineEnd) {
84
+ return conflict(file, '行号模式必须同时提供 line_start 和 line_end。');
85
+ }
86
+ let mode;
87
+ if (hasOldString)
88
+ mode = 'single';
89
+ else if (hasReplacements)
90
+ mode = 'multi';
91
+ else if (hasLineStart && hasLineEnd)
92
+ mode = 'line';
93
+ else {
94
+ return conflict(file, '必须提供 old_string、replacements[] 或同时提供 line_start 和 line_end 三者之一。');
63
95
  }
64
- if (!hasOldString && (!hasLineStart || !hasLineEnd)) {
65
- return conflict(file, '必须提供 old_string 或同时提供 line_start 和 line_end。');
96
+ if ((mode === 'single' || mode === 'line') && typeof args.new_string !== 'string') {
97
+ return conflict(file, '必须提供字符串 new_string(要删除该段时传空串 "")。');
98
+ }
99
+ const toLf = (s) => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
100
+ // multi:数组形状校验(schema 已守,这里给富指导并归一化 CRLF)。
101
+ let multiEdits = [];
102
+ if (mode === 'multi') {
103
+ const arr = args.replacements;
104
+ if (!Array.isArray(arr) || arr.length === 0) {
105
+ return conflict(file, 'replacements 必须是至少含 1 个 {old_string,new_string} 的数组。');
106
+ }
107
+ for (let i = 0; i < arr.length; i++) {
108
+ const item = arr[i];
109
+ if (!item ||
110
+ typeof item !== 'object' ||
111
+ typeof item.old_string !== 'string' ||
112
+ typeof item.new_string !== 'string') {
113
+ return conflict(file, `replacements[${i}] 必须是含字符串字段 old_string/new_string 的对象。`);
114
+ }
115
+ const rec = item;
116
+ if (!rec.old_string)
117
+ return conflict(file, `replacements[${i}].old_string 不能为空。`);
118
+ multiEdits.push({ old: toLf(rec.old_string), next: toLf(rec.new_string) });
119
+ }
66
120
  }
67
121
  // expected_hash:缺省/null/空串都视为「未提供」。提供了就必须格式正确并严格校验。
68
122
  const rawHash = args.expected_hash;
@@ -70,7 +124,7 @@ export const editFileTool = {
70
124
  let expectedHash = hashProvided ? normalizeContentHash(String(rawHash)) : null;
71
125
  if (hashProvided && !expectedHash)
72
126
  return conflict(file, 'expected_hash 必须是 sha256:<64 hex>。');
73
- if (!hasOldString && !expectedHash) {
127
+ if (mode === 'line' && !expectedHash) {
74
128
  return conflict(file, '行号模式必须提供 expected_hash(行号会随文件变化漂移,需要内容版本锚点)。');
75
129
  }
76
130
  // 以 Buffer 读取并按 Buffer 算 hash,与 ChangeSet 内部口径一致(非法 UTF-8 时字符串 hash 会对不上)。
@@ -83,13 +137,12 @@ export const editFileTool = {
83
137
  if (!expectedHash)
84
138
  expectedHash = contentHash(buffer);
85
139
  const data = buffer.toString('utf8');
86
- const normalized = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
87
- const newNormalized = newString.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
140
+ const normalized = toLf(data);
88
141
  let updated;
89
- if (hasOldString) {
142
+ if (mode === 'single') {
90
143
  // String replacement mode
91
- const oldString = String(args.old_string);
92
- const oldNormalized = oldString.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
144
+ const newNormalized = toLf(String(args.new_string));
145
+ const oldNormalized = toLf(String(args.old_string));
93
146
  const count = normalized.split(oldNormalized).length - 1;
94
147
  if (count === 0) {
95
148
  return conflict(file, 'old_string 未找到;请重新 read_file 并复制最新内容。');
@@ -99,8 +152,43 @@ export const editFileTool = {
99
152
  }
100
153
  updated = normalized.replace(oldNormalized, () => newNormalized);
101
154
  }
155
+ else if (mode === 'multi') {
156
+ // Multiple replacements mode:所有 old_string 先在「原始文件」上一次性定位,
157
+ // 各自必须唯一命中、区间互不重叠;任何一项不满足整体拒绝、一条都不改(原子)。
158
+ const located = [];
159
+ for (let i = 0; i < multiEdits.length; i++) {
160
+ const edit = multiEdits[i];
161
+ const hits = [];
162
+ let from = 0;
163
+ let hit;
164
+ // 空串已在上面拒绝;indexOf 步进至少 1 不会死循环。
165
+ while ((hit = normalized.indexOf(edit.old, from)) !== -1) {
166
+ hits.push(hit);
167
+ from = hit + edit.old.length;
168
+ }
169
+ if (hits.length === 0) {
170
+ return conflict(file, `replacements[${i}].old_string 未找到;请重新 read_file 并复制最新内容。`);
171
+ }
172
+ if (hits.length > 1) {
173
+ return conflict(file, `replacements[${i}].old_string 出现 ${hits.length} 次;请增加上下文使其唯一。`);
174
+ }
175
+ located.push({ start: hits[0], end: hits[0] + edit.old.length, next: edit.next });
176
+ }
177
+ located.sort((a, b) => a.start - b.start || a.end - b.end);
178
+ for (let i = 1; i < located.length; i++) {
179
+ if (located[i].start < located[i - 1].end) {
180
+ return conflict(file, 'replacements[] 的命中区间互相重叠;请拆分或合并这些编辑后重试(本次未写入)。');
181
+ }
182
+ }
183
+ // 定位全部通过后才应用:从后向前按区间替换,偏移不会因前序改动而失效。
184
+ updated = normalized;
185
+ for (let i = located.length - 1; i >= 0; i--) {
186
+ updated = updated.slice(0, located[i].start) + located[i].next + updated.slice(located[i].end);
187
+ }
188
+ }
102
189
  else {
103
190
  // Line-range mode
191
+ const newNormalized = toLf(String(args.new_string));
104
192
  const lineStart = Number(args.line_start);
105
193
  const lineEnd = Number(args.line_end);
106
194
  if (!Number.isInteger(lineStart) || !Number.isInteger(lineEnd)) {
@@ -28,8 +28,10 @@ function invalid(path, message) {
28
28
  }
29
29
  export const writeFileTool = {
30
30
  name: 'write_file',
31
- description: 'Create or replace one file transactionally. expected_hash may be omitted (or null) only for create-only writes to a path that must not exist; overwriting requires the hash from a fresh read_file.\n' +
32
- 'To ADD to an existing file, pass append=true — only the new text goes in `content`; NO expected_hash and NO prior read_file needed (lock-protected; creates the file when missing). Appends VERBATIM: if the file does not end with a newline, start your content with "\\n" or the last line will merge with yours.',
31
+ description: 'Create a NEW file, APPEND to one, or rewrite essentially the WHOLE file — transactionally.\n' +
32
+ 'Do NOT use this for localized changes to an existing file, even when there are several separate changes: use edit_file (a single edit, one replacements[] batch covering many spots, or a line range). Re-sending an entire file just to tweak a few lines wastes tokens and the long JSON is prone to truncation. Reach for write_file on an existing path only when most of the file is actually being replaced.\n' +
33
+ 'expected_hash may be omitted (or null) only for create-only writes to a path that must not exist; overwriting requires the hash from a fresh read_file.\n' +
34
+ 'To ADD to an existing file, pass append=true — only the new text goes in `content`; NO expected_hash and NO prior read_file needed (lock-protected; creates the file when missing). For long generated output, write the first chunk normally then extend it with repeated append=true calls, keeping each call small. Appends VERBATIM: if the file does not end with a newline, start your content with "\\n" or the last line will merge with yours.',
33
35
  risk: 'confirm',
34
36
  parameters: {
35
37
  type: 'object',
@@ -8,6 +8,67 @@ const DEFAULT_CAPABILITIES = Object.freeze({
8
8
  effect: 'unknown',
9
9
  concurrency: 'serial',
10
10
  });
11
+ /** 长非法 JSON 回显时首尾各保留的字符数(尾部对判断截断最关键)。 */
12
+ const INVALID_JSON_SNIPPET = 150;
13
+ /** 按工具给出参数损坏后的可执行恢复路径。truncated=已确认输出被 token 上限截断。 */
14
+ function invalidJsonRecovery(name, truncated) {
15
+ if (truncated) {
16
+ if (name === 'write_file') {
17
+ return ('恢复方法(不要用相同参数重试):\n' +
18
+ '1) write_file 只写入首块内容,再用 write_file(append=true, content=…) 分多次续写,每段保持短小;\n' +
19
+ '2) 若只是修改已有文件的局部,改用 edit_file(只发 old_string/new_string,不要整篇重发);\n' +
20
+ '3) 确需一次生成,调大 MAX_TOKENS 后再试。');
21
+ }
22
+ if (name === 'edit_file') {
23
+ return '恢复方法:参数在 token 上限处被截断。缩短 old_string(只保留唯一命中所需的最小上下文),或调大 MAX_TOKENS 后再试。';
24
+ }
25
+ return '恢复方法:参数在 token 上限处被截断。把本次调用拆成更小的多个调用,或调大 MAX_TOKENS 后再试。';
26
+ }
27
+ if (name === 'write_file' || name === 'edit_file') {
28
+ return ('恢复方法:JSON 字符串值里的换行必须转义为 \\n、双引号转义为 \\"、反斜杠转义为 \\\\,不要在字符串中放裸换行。\n' +
29
+ '长内容优先 write_file 首块 + append=true 分段,或改用 edit_file 做局部替换。');
30
+ }
31
+ return '恢复方法:检查参数 JSON 的引号、反斜杠与换行转义后重试。';
32
+ }
33
+ /** 把 JSON.parse 的 SyntaxError 映射成可操作的中文死因。 */
34
+ function classifyJsonSyntaxError(message, knownTruncated) {
35
+ if (knownTruncated) {
36
+ return '死因:模型输出在 max_tokens 处被硬截断,tool_call 参数是不完整的 JSON(没有正常结尾)。';
37
+ }
38
+ if (/unterminated string/i.test(message)) {
39
+ return '死因:字符串未正常结束(Unterminated string)——参数可能在末尾被截断,或内容里的引号未转义。';
40
+ }
41
+ if (/bad control character|bad escaped/i.test(message)) {
42
+ return '死因:字符串值里出现了裸控制字符(最常见是裸换行)——JSON 字符串内换行必须写成 \\n。';
43
+ }
44
+ if (/unexpected token/i.test(message)) {
45
+ return '死因:出现非法字符(Unexpected token)——多为内容中的双引号/反斜杠没有转义,或参数被截断。';
46
+ }
47
+ if (/expected|end of (data|json)|unexpected end/i.test(message)) {
48
+ return '死因:JSON 提前结束或结构残缺——参数可能被截断。';
49
+ }
50
+ return `死因:${message}`;
51
+ }
52
+ /** argsRaw 的安全摘要:短则全量,长则只给长度+首尾片段,避免几万字符刷屏且被 TUI 二次截断。 */
53
+ function summarizeRawArgs(argsRaw) {
54
+ const len = argsRaw.length;
55
+ if (len <= INVALID_JSON_SNIPPET * 2 + 40)
56
+ return argsRaw;
57
+ const head = argsRaw.slice(0, INVALID_JSON_SNIPPET);
58
+ const tail = argsRaw.slice(len - INVALID_JSON_SNIPPET);
59
+ return `${head}\n…[中间省略 ${len - INVALID_JSON_SNIPPET * 2} 字符]…\n${tail}`;
60
+ }
61
+ function invalidJsonOutcome(name, argsRaw, parseError, opts, startedAt) {
62
+ const syntaxMessage = parseError instanceof Error ? parseError.message : String(parseError);
63
+ const knownTruncated = opts?.finishReason === 'length';
64
+ const looksTruncated = /end of (data|json)|unexpected end/i.test(syntaxMessage);
65
+ const output = `错误:工具 ${name} 的 arguments 不是合法 JSON(收到 ${argsRaw.length} 字符)。\n` +
66
+ `${classifyJsonSyntaxError(syntaxMessage, knownTruncated)}\n` +
67
+ `${invalidJsonRecovery(name, knownTruncated || looksTruncated)}\n` +
68
+ `解析器报错:${syntaxMessage}\n` +
69
+ `参数片段(首/尾):\n${summarizeRawArgs(argsRaw)}`;
70
+ return terminalOutcome('error', 'INVALID_JSON', output, startedAt);
71
+ }
11
72
  const DEFAULT_DEPENDENCIES = {
12
73
  enforceSandbox: defaultEnforceSandbox,
13
74
  resolveResourceLockRequests: defaultResolveResourceLockRequests,
@@ -194,8 +255,10 @@ export class ToolRuntime {
194
255
  try {
195
256
  parsed = argsRaw.trim() ? JSON.parse(argsRaw) : {};
196
257
  }
197
- catch {
198
- return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
258
+ catch (parseError) {
259
+ // 不再裸 catch:保留 SyntaxError 的具体死因(截断 vs 裸换行 vs 漏转义),
260
+ // 并结合 finish_reason=length 给出可执行恢复路径,替代旧的整段 argsRaw 刷屏。
261
+ return invalidJsonOutcome(name, argsRaw, parseError, opts, startedAt);
199
262
  }
200
263
  const validation = validateToolArguments(tool, parsed);
201
264
  if (!validation.valid) {
package/dist/ui/diff.js CHANGED
@@ -295,9 +295,16 @@ export function renderFileChange(opts) {
295
295
  const padW = Math.max(3, String(startLine + Math.max(oldLen, newLen) - 1).length);
296
296
  return renderBody(head, counts, compactCtx(ops), padW, startLine, lang);
297
297
  }
298
- /** 头行 + 计数行 + 正文(折叠 + 截断 + 行号),每行尾 \n。 */
299
- function renderBody(head, counts, items, padW, startLine, lang) {
300
- const lines = [head, counts];
298
+ /** Update(path) 头行(edit / 多 hunk 共用)。 */
299
+ function updateHead(pathDisp) {
300
+ return `${HEAD_INDENT}${ui.bold}${ui.accent}Update${ui.reset}${ui.gray}(${ui.reset}${ui.accent}${pathDisp}${ui.reset}${ui.gray})${ui.reset}`;
301
+ }
302
+ /**
303
+ * 单个 hunk 的正文行(折叠 + 截断 + 行号),不含头/计数行。
304
+ * 多替换模式对每个 {old_string,new_string} 各调一次,行号从该 hunk 的 startLine 独立起算。
305
+ */
306
+ function renderHunkBody(items, padW, startLine, lang) {
307
+ const lines = [];
301
308
  const metaPrefix = `${BODY_INDENT}`; // 与正文行左对齐(batch 展开时还会再加外层 indent,避免双重缩进导致提示行突兀)
302
309
  let oldLine = startLine;
303
310
  let newLine = startLine;
@@ -365,5 +372,62 @@ function renderBody(head, counts, items, padW, startLine, lang) {
365
372
  if (overflow > 0) {
366
373
  lines.push(`${metaPrefix}${ui.dim}…(还有 ${overflow} 行未显示)${ui.reset}`);
367
374
  }
368
- return lines.join('\n') + '\n';
375
+ return lines;
376
+ }
377
+ /** 单 hunk:头行 + 计数行 + 正文。 */
378
+ function renderBody(head, counts, items, padW, startLine, lang) {
379
+ return [head, counts, ...renderHunkBody(items, padW, startLine, lang)].join('\n') + '\n';
380
+ }
381
+ /** 单个 hunk 的增删行计数。 */
382
+ function countOps(ops) {
383
+ let add = 0;
384
+ let del = 0;
385
+ for (const o of ops) {
386
+ if (o.op === 'add')
387
+ add++;
388
+ else if (o.op === 'del')
389
+ del++;
390
+ }
391
+ return { add, del };
392
+ }
393
+ function countsLine(add, del) {
394
+ const parts = [];
395
+ if (add > 0)
396
+ parts.push(`${ui.dim}Added ${ui.reset}${ui.green}${add}${ui.reset}${ui.dim} ${lineWord(add)}`);
397
+ if (del > 0)
398
+ parts.push(`${ui.dim}removed ${ui.reset}${ui.red}${del}${ui.reset}${ui.dim} ${lineWord(del)}`);
399
+ return parts.length > 0
400
+ ? `${HEAD_INDENT} ${parts.join(`${ui.dim}, ${ui.reset}`)}${ui.reset}`
401
+ : `${HEAD_INDENT} ${ui.dim}No changes${ui.reset}`;
402
+ }
403
+ /**
404
+ * 多替换模式:一个文件里多个互不相邻的 hunk,共用一个 Update(path) 头,
405
+ * 每个 hunk 独立行号(由 readDiffContext 在原文定位其起始行)。
406
+ * 任一 hunk 过大无法做 LCS 时返回 null,调用方退回摘要展示(与 renderFileChange 同策略)。
407
+ */
408
+ export function renderMultiFileChange(opts) {
409
+ const lang = langForPath(opts.path);
410
+ const head = updateHead(opts.path);
411
+ const prepared = [];
412
+ let totalAdd = 0;
413
+ let totalDel = 0;
414
+ let maxLineNo = 1;
415
+ for (const h of opts.hunks) {
416
+ const ops = lineDiff(h.oldStr, h.newStr);
417
+ if (ops === null)
418
+ return null;
419
+ const { add, del } = countOps(ops);
420
+ totalAdd += add;
421
+ totalDel += del;
422
+ maxLineNo = Math.max(maxLineNo, h.startLine + Math.max(splitLines(h.oldStr).length, splitLines(h.newStr).length) - 1);
423
+ prepared.push({ ops, startLine: h.startLine, padW: 0 });
424
+ }
425
+ const padW = Math.max(3, String(maxLineNo).length);
426
+ const out = [head, countsLine(totalAdd, totalDel)];
427
+ prepared.forEach((p, idx) => {
428
+ if (idx > 0)
429
+ out.push(`${BODY_INDENT}${ui.dim}@@${ui.reset}`);
430
+ out.push(...renderHunkBody(compactCtx(p.ops), padW, p.startLine, lang));
431
+ });
432
+ return out.join('\n') + '\n';
369
433
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.7.11",
3
+ "version": "1.8.0",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 27 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {