mocode-ai 1.2.6 → 1.2.7

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,6 +5,7 @@ import dotenv from 'dotenv';
5
5
  import { getCurrentSessionId } from '../session/state.js';
6
6
  import { getNotesFilePath, extractActiveNotesSections } from '../session/notes.js';
7
7
  import { buildWorkDisciplineSection, inferModelFamily } from '../agent/work-discipline.js';
8
+ import { buildValidationCommandsSection } from '../verification/prompt.js';
8
9
  import { detectLanguage, setLanguage, t, } from '../i18n/index.js';
9
10
  /**
10
11
  * 按优先级加载配置文件并回填 process.env:
@@ -40,11 +41,14 @@ export const languageFromShell = process.env.MOCODE_LANGUAGE !== undefined;
40
41
  // 在 loadEnvFiles 回填前捕获:哪些 LLM 键由 shell 设置(决定 /model 写文件是否下次启动生效)。
41
42
  // 仿 themeFromShell 模式:shell export 的环境变量在 loadEnvFiles 中不被回填(优先级最高),
42
43
  // 故 /model 写入 ~/.mocode/config 的同名键下次启动会被 shell 值覆盖——据此给 dim 警告。
43
- const LLM_ENV_KEYS = ['LLM_BASE_URL', 'LLM_API_KEY', 'LLM_MODEL', 'CONTEXT_WINDOW_TOKENS'];
44
+ const LLM_ENV_KEYS = ['LLM_PROVIDER', 'LLM_BASE_URL', 'LLM_API_KEY', 'LLM_MODEL', 'CONTEXT_WINDOW_TOKENS', 'ANTHROPIC_PROMPT_CACHE'];
44
45
  export const DEFAULT_CONTEXT_WINDOW_TOKENS = 256000;
45
46
  const llmKeysFromShell = LLM_ENV_KEYS.filter((k) => process.env[k] !== undefined);
46
47
  loadEnvFiles();
47
48
  setLanguage(detectLanguage(process.env.MOCODE_LANGUAGE));
49
+ export function normalizeLlmProvider(value) {
50
+ return typeof value === 'string' && value.toLowerCase() === 'anthropic' ? 'anthropic' : 'openai';
51
+ }
48
52
  /**
49
53
  * 取环境变量;缺则返回空字符串(不退出)。
50
54
  * 历史上缺 LLM_BASE_URL/LLM_API_KEY 会 process.exit(1),但 /model 命令已能在 REPL 内配置模型,
@@ -243,15 +247,43 @@ export function reinjectSessionNotesIntoSystem(history) {
243
247
  return true;
244
248
  }
245
249
  /**
246
- * 一次性重注入会话状态(plan 段 + 笔记段)到系统提示。调用点(core.ts compact 后 /
247
- * 本步改 notes.md、repl compact 时)统一用本函数替代 reinjectActivePlanIntoSystem,
248
- * 让笔记段享有与 plan 段完全对称的常驻 + 恢复时机。返回任一 marker 是否改动。
250
+ * 一次性重注入会话状态(plan 段 + 笔记段)到系统提示。返回任一 marker 是否改动。
251
+ *
252
+ * @deprecated 热路径已不再调用(#prompt-cache):往 history[0] 追加 plan/笔记会让
253
+ * 系统提示每次 plan_update / note_append 后变字节,前缀缓存整段失效(系统提示 6-8k token,
254
+ * 本轮后续每步全价重算)。现由 agent/core 每步在 requestHistory **末尾**注入
255
+ * {@link buildSessionStateReminder} 的 ephemeral system 消息:模型看到的信息等价,
256
+ * 但变动落在前缀末端。本函数仅留给外部集成 / 旧测试,新增调用点请勿使用。
249
257
  */
250
258
  export function reinjectSessionStateIntoSystem(history) {
251
259
  const planChanged = reinjectActivePlanIntoSystem(history);
252
260
  const notesChanged = reinjectSessionNotesIntoSystem(history);
253
261
  return planChanged || notesChanged;
254
262
  }
263
+ /**
264
+ * 构造"会话状态提醒"正文(活跃 `## Plan:` 段 + 活跃笔记段正文),供 agent/core 每步
265
+ * 拼进 requestHistory **末尾**的 ephemeral system 消息。
266
+ *
267
+ * 为什么在尾部而不是 history[0](prompt 缓存):plan_update / note_append 是设计上鼓励
268
+ * 高频调用的工具,一旦它们改写系统提示,支持自动前缀缓存的后端(OpenAI / DeepSeek /
269
+ * GLM / Qwen)就会从第一个 token 起全部 miss。放到历史末尾后,前面整段(系统提示 + 全部
270
+ * 已有对话)保持逐字节稳定,只有尾部这一小条随 notes.md 变化。
271
+ *
272
+ * 纯读函数:不改 history,也不写文件。notes.md 不存在 / 无活跃内容时返回 ''(零开销)。
273
+ */
274
+ export function buildSessionStateReminder(sessionId = getCurrentSessionId()) {
275
+ const plan = extractActivePlanSection(sessionId);
276
+ const notes = extractActiveNotesSections(undefined, sessionId);
277
+ if (!plan && !notes)
278
+ return '';
279
+ const parts = [
280
+ '## Session state (current, from notes.md)',
281
+ 'This block mirrors the live session notepad and is refreshed every step; treat it as the authoritative plan/notes state, and ignore any older copy earlier in this conversation.',
282
+ ...(plan ? [plan] : []),
283
+ ...(notes ? [notes] : []),
284
+ ];
285
+ return parts.join('\n\n');
286
+ }
255
287
  const SYSTEM_PROMPT_MEMORY_SECTION = `
256
288
  ## Memory (cross-session facts)
257
289
  - The prompt may contain a title/summary index; retrieve details with memory_search or inspect all with memory_list. memory_search also surfaces knowledge-graph facts (relations between entities) alongside entry bodies.
@@ -345,6 +377,7 @@ Complete programming tasks through an "analyze → call tool → observe result
345
377
  - Report: stop when done and give honest conclusions with path:line references (see Reporting).
346
378
  - Use web search only when freshness materially affects the answer.
347
379
  ${buildCodegraphSection()}
380
+ ${buildValidationCommandsSection()}
348
381
 
349
382
  ## Engineering principles
350
383
  ${buildWorkDisciplineSection(inferModelFamily(config.model))}
@@ -446,6 +479,7 @@ export function getPlanModeSuffix() {
446
479
  return buildPlanModeSuffix();
447
480
  }
448
481
  export const config = {
482
+ provider: normalizeLlmProvider(process.env.LLM_PROVIDER),
449
483
  baseURL: requireEnv('LLM_BASE_URL'),
450
484
  apiKey: requireEnv('LLM_API_KEY'),
451
485
  model: process.env.LLM_MODEL || 'gpt-4o-mini',
@@ -457,6 +491,7 @@ export const config = {
457
491
  },
458
492
  contextWindowTokens: Number(process.env.CONTEXT_WINDOW_TOKENS) || DEFAULT_CONTEXT_WINDOW_TOKENS,
459
493
  includeUsage: process.env.LLM_STREAM_USAGE !== 'false',
494
+ anthropicPromptCache: process.env.ANTHROPIC_PROMPT_CACHE !== 'false',
460
495
  autoCompact: process.env.AUTO_COMPACT !== 'false',
461
496
  contextOptimize: process.env.MOCODE_CONTEXT_OPTIMIZE === 'true',
462
497
  contextRelprune: process.env.MOCODE_CONTEXT_RELPRUNE === 'true',
@@ -482,6 +517,21 @@ export const config = {
482
517
  permissionEnabled: process.env.MOCODE_PERMISSION !== 'false',
483
518
  permissionNonInteractiveAllow: process.env.MOCODE_PERMISSION_NON_INTERACTIVE_ALLOW === 'true',
484
519
  };
520
+ /**
521
+ * 会话钉死模型:窗口/会话启动时由 pinSessionModel() 捕获一次。
522
+ * 运行中 agent 一律经 getActiveModel() 取模型,而非热切的 config.model——
523
+ * 这样某窗口 /model switch 改写全局 config 后,其它【已经打开】的窗口的
524
+ * 运行 agent 仍用各自启动时的模型,不会被影响;只有重启/新开窗口才会读全局 config。
525
+ */
526
+ let sessionModel = null;
527
+ /** 在 REPL 启动时调用一次,把当前模型钉成本会话的活跃模型。 */
528
+ export function pinSessionModel() {
529
+ sessionModel = config.model;
530
+ }
531
+ /** 运行中 agent 实际使用的模型:优先钉死值,未钉(极早路径)则回退 config.model。 */
532
+ export function getActiveModel() {
533
+ return sessionModel ?? config.model;
534
+ }
485
535
  /**
486
536
  * 运行时更新模型相关配置(/model 命令调)。
487
537
  * - 更新 config 对象字段(即时生效:chat() 读 config.model,reconfigureClient 读 config.baseURL/apiKey)。
@@ -491,8 +541,16 @@ export const config = {
491
541
  * 重建 OpenAI 客户端(baseURL/apiKey 是构造时固化的实例字段)由调用方走 reconfigureClient。
492
542
  */
493
543
  export function updateModelConfig(opts) {
544
+ if (opts.provider !== undefined) {
545
+ config.provider = opts.provider;
546
+ process.env.LLM_PROVIDER = opts.provider;
547
+ }
494
548
  if (opts.model !== undefined) {
495
549
  config.model = opts.model;
550
+ // 钉死值同步更新:本窗口显式 /model switch 立即对本窗口运行 agent 生效;
551
+ // 其它已开窗口的 sessionModel 不受影响(各自启动时钉死)。
552
+ if (sessionModel !== null)
553
+ sessionModel = opts.model;
496
554
  process.env.LLM_MODEL = opts.model;
497
555
  }
498
556
  if (opts.baseURL !== undefined) {
@@ -507,6 +565,10 @@ export function updateModelConfig(opts) {
507
565
  config.contextWindowTokens = opts.contextWindowTokens;
508
566
  process.env.CONTEXT_WINDOW_TOKENS = String(opts.contextWindowTokens);
509
567
  }
568
+ if (opts.anthropicPromptCache !== undefined) {
569
+ config.anthropicPromptCache = opts.anthropicPromptCache;
570
+ process.env.ANTHROPIC_PROMPT_CACHE = opts.anthropicPromptCache ? 'true' : 'false';
571
+ }
510
572
  }
511
573
  /** 子 Agent 总开关;默认 false,关闭时 sub-agent 不进入模型工具表。 */
512
574
  export function isSubAgentEnabled() {
@@ -31,8 +31,8 @@ function filePathFor(name) {
31
31
  }
32
32
  return path.join(MODELS_DIR, `${name}.json`);
33
33
  }
34
- /** 把磁盘上的 raw JSON 解析并校验为 ModelPreset;非法字段抛错。 */
35
- function parsePreset(raw) {
34
+ /** 把磁盘上的 raw JSON 解析并校验为 ModelPreset;旧预设缺 provider 时按 openai 读取。 */
35
+ export function parsePreset(raw) {
36
36
  const obj = JSON.parse(raw);
37
37
  const { name, baseURL, apiKey, model, contextWindow } = obj;
38
38
  if (typeof name !== 'string' || !isValidPresetName(name)) {
@@ -50,7 +50,17 @@ function parsePreset(raw) {
50
50
  if (typeof contextWindow !== 'number' || !Number.isFinite(contextWindow) || contextWindow <= 0) {
51
51
  throw new Error(`预设 ${name}: contextWindow 必须为正数`);
52
52
  }
53
- return { name, baseURL, apiKey, model, contextWindow: Math.floor(contextWindow) };
53
+ const provider = obj.provider === 'anthropic' ? 'anthropic' : 'openai';
54
+ const anthropicPromptCache = provider === 'anthropic' && obj.anthropicPromptCache !== false;
55
+ return {
56
+ name,
57
+ provider,
58
+ baseURL,
59
+ apiKey,
60
+ model,
61
+ contextWindow: Math.floor(contextWindow),
62
+ anthropicPromptCache,
63
+ };
54
64
  }
55
65
  /** 读单个预设;不存在抛错。 */
56
66
  export function getPreset(name) {
@@ -68,15 +78,21 @@ export function readPreset(name) {
68
78
  throw e;
69
79
  }
70
80
  }
71
- /** 写/覆盖一个预设(原子:写 tmp 再 rename) */
81
+ /** 写/覆盖一个预设(原子:写 tmp 再 rename)。旧调用缺 provider 时仍按 openai 保存。 */
72
82
  export function savePreset(preset) {
73
83
  if (!isValidPresetName(preset.name)) {
74
84
  throw new Error(`非法预设名: ${JSON.stringify(preset.name)}`);
75
85
  }
86
+ const provider = preset.provider ?? 'openai';
87
+ const normalized = {
88
+ ...preset,
89
+ provider,
90
+ anthropicPromptCache: provider === 'anthropic' && preset.anthropicPromptCache !== false,
91
+ };
76
92
  fs.mkdirSync(MODELS_DIR, { recursive: true });
77
93
  const dest = filePathFor(preset.name);
78
94
  const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`;
79
- fs.writeFileSync(tmp, JSON.stringify(preset, null, 2), 'utf8');
95
+ fs.writeFileSync(tmp, JSON.stringify(normalized, null, 2), 'utf8');
80
96
  fs.renameSync(tmp, dest);
81
97
  }
82
98
  /** 删除一个预设;不存在返回 false,成功返回 true。 */
@@ -156,11 +172,15 @@ export function migrateCurrentToPreset(input) {
156
172
  return null;
157
173
  if (!Number.isFinite(input.contextWindow) || input.contextWindow <= 0)
158
174
  return null;
175
+ const provider = input.provider ?? 'openai';
176
+ const anthropicPromptCache = provider === 'anthropic' && input.anthropicPromptCache !== false;
159
177
  const existing = listPresets();
160
- const dup = existing.find((p) => p.baseURL === input.baseURL &&
178
+ const dup = existing.find((p) => p.provider === provider &&
179
+ p.baseURL === input.baseURL &&
161
180
  p.apiKey === input.apiKey &&
162
181
  p.model === input.model &&
163
- p.contextWindow === input.contextWindow);
182
+ p.contextWindow === input.contextWindow &&
183
+ p.anthropicPromptCache === anthropicPromptCache);
164
184
  if (dup)
165
185
  return null;
166
186
  // 'default' 已被占 → 用户已显式起过预设,无需老数据迁入;返回 null 让调用方跳过即可。
@@ -168,10 +188,12 @@ export function migrateCurrentToPreset(input) {
168
188
  return null;
169
189
  savePreset({
170
190
  name: 'default',
191
+ provider,
171
192
  baseURL: input.baseURL,
172
193
  apiKey: input.apiKey,
173
194
  model: input.model,
174
195
  contextWindow: input.contextWindow,
196
+ anthropicPromptCache,
175
197
  });
176
198
  return 'default';
177
199
  }
@@ -131,6 +131,26 @@ export function recordArtifact(state, history, idx, output, succeeded) {
131
131
  stateFor(state).artifacts.set(id, artifact);
132
132
  updateStats(state, stateFor(state));
133
133
  }
134
+ /**
135
+ * 按读取时间倒序返回最近若干「仍新鲜的 read_file 目标」(path + hash)。
136
+ * 用途:文件编辑工具参数校验失败(如缺 path)时,把系统已知的候选直接回灌给模型照抄,
137
+ * 避免模型在长上下文里凭记忆复述出错、补一个字段丢另一个字段的乒乓重试。
138
+ * 只展示事实、不替模型填值——选哪个候选仍由模型判断。永不抛错。
139
+ */
140
+ export function knownEditTargets(state, limit = 3) {
141
+ const artifacts = stateFor(state).artifacts;
142
+ const targets = [];
143
+ for (const artifact of artifacts.values()) {
144
+ if (artifact.freshness !== 'fresh' || artifact.source.type !== 'read')
145
+ continue;
146
+ const dependency = artifact.dependencies[0];
147
+ if (!dependency || dependency.path === '*' || !dependency.hash)
148
+ continue;
149
+ targets.push({ path: dependency.path, hash: dependency.hash, version: artifact.version ?? 0 });
150
+ }
151
+ targets.sort((a, b) => b.version - a.version);
152
+ return targets.slice(0, Math.max(1, Math.floor(limit) || 3)).map(({ path, hash }) => ({ path, hash }));
153
+ }
134
154
  function affected(artifact, changed) {
135
155
  // '*' 依赖(无法解析出具体文件路径的诊断/搜索结果)不与任何具体写操作关联:
136
156
  // 任何文件写入都会作废全部 '*' artifact,等于每次 mutation 都销毁
@@ -54,8 +54,10 @@ export function userTurnBoundary(history, window) {
54
54
  /** 评估当前 history 的五区预算(纯函数,改不动 history)。
55
55
  * 传入 step 是当前所在 step 编号(agent 循环 step 变量),用于日志/调试。
56
56
  * correction:API 实测 / 估算的校正系数(默认 1);>1 表示粗估偏低,乘以系数后 actual 更接近真实值。
57
- * activeTools 必须与下一次 chat() 实际发送的工具集合一致,避免 plan/子 agent 误算 schema。 */
58
- export function evaluateBudget(history, window, step = 0, correction = 1, activeTools = chatTools) {
57
+ * activeTools 必须与下一次 chat() 实际发送的工具集合一致,避免 plan/子 agent 误算 schema。
58
+ * ephemeralTokens:本次请求会追加、但不在 history 里的尾部注入(会话状态提醒等)的裸 token,
59
+ * 必须传入,否则压力线看不见这部分开销(见 SystemCostBreakdown.ephemeral)。 */
60
+ export function evaluateBudget(history, window, step = 0, correction = 1, activeTools = chatTools, ephemeralTokens = 0) {
59
61
  const layers = {};
60
62
  for (const k of BUDGET_LAYERS) {
61
63
  const budget = Math.floor(BUDGET_RATIO[k] * window);
@@ -66,12 +68,17 @@ export function evaluateBudget(history, window, step = 0, correction = 1, active
66
68
  // 裸总量(不乘 correction):硬闸用它判断,防止 correction 折扣否决真实溢出。
67
69
  let rawTotal = 0;
68
70
  const sysMsg = history[0];
71
+ const safeEphemeral = Number.isFinite(ephemeralTokens)
72
+ ? Math.max(0, Math.round(ephemeralTokens))
73
+ : 0;
69
74
  const systemCosts = {
70
75
  prompt: sysMsg ? msgTokens(sysMsg) : 0,
71
76
  toolSchemas: estimateToolSchemaTokens(activeTools),
77
+ ephemeralInjection: safeEphemeral,
72
78
  };
73
- // 工具 schemasystem prompt 同属请求固定开销;必须计入总量才能可靠触发压缩。
74
- const systemRaw = systemCosts.prompt + systemCosts.toolSchemas;
79
+ // 工具 schemasystem prompt 与尾部 ephemeral 注入同属请求固定开销;
80
+ // 必须计入总量才能可靠触发压缩(尾部注入不在 history 里,只能由调用方传入)。
81
+ const systemRaw = systemCosts.prompt + systemCosts.toolSchemas + systemCosts.ephemeralInjection;
75
82
  layers.system.actual = adj(systemRaw);
76
83
  rawTotal += systemRaw;
77
84
  // Summary 检测:role:'system' 且不是 history[0] 的,视为摘要(compact.ts 摘要插 index 1)。
@@ -143,7 +150,7 @@ export function scheduleActions(report) {
143
150
  const actions = [];
144
151
  const { layers } = report;
145
152
  if (layers.system.overBudget) {
146
- const { prompt, toolSchemas } = report.systemCosts;
153
+ const { prompt, toolSchemas, ephemeralInjection } = report.systemCosts;
147
154
  const { actual, budget } = layers.system;
148
155
  const excess = actual - budget;
149
156
  const percent = ((actual / Math.max(budget, 1)) * 100).toFixed(0);
@@ -151,7 +158,9 @@ export function scheduleActions(report) {
151
158
  kind: 'warn',
152
159
  layer: 'system',
153
160
  reason: `固定开销 ${actual}/${budget} (+${excess}, ${percent}%);`
154
- + `提示 ${prompt} + 工具 ${toolSchemas},×${report.correction.toFixed(2)}。`,
161
+ + `提示 ${prompt} + 工具 ${toolSchemas}`
162
+ + (ephemeralInjection > 0 ? ` + 尾部注入 ${ephemeralInjection}` : '')
163
+ + `,×${report.correction.toFixed(2)}。`,
155
164
  });
156
165
  }
157
166
  const pressureLine = DEFAULT_BUDGET_POLICY.pressureTriggerRatio * report.window;
@@ -3,7 +3,7 @@
3
3
  // scheduler is the only automatic rewrite coordinator at real pressure.
4
4
  export { optimizeToolResult } from './pipeline.js';
5
5
  export { classify, knownToolKinds } from './classifier.js';
6
- export { recordArtifact, invalidateArtifacts, rehydrateArtifacts, refreshArtifactFreshness, pruneStaleArtifacts, collectArtifactRefs, formatArtifactTokenSources, } from './artifacts.js';
6
+ export { recordArtifact, invalidateArtifacts, rehydrateArtifacts, refreshArtifactFreshness, pruneStaleArtifacts, collectArtifactRefs, formatArtifactTokenSources, knownEditTargets, } from './artifacts.js';
7
7
  export { registerEncoder, registerAll, getEncoder, registeredKinds, } from './registry.js';
8
8
  // ── Context Budget Scheduler ───────────────────────────────────────────────
9
9
  export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, BUDGET_LAYERS, DEFAULT_BUDGET_POLICY, BUDGET_RATIO, HOT_TURN_WINDOW, } from './budget.js';
@@ -33,7 +33,12 @@ async function initializeRuntime() {
33
33
  await initializeAllMcp();
34
34
  registerToolsExtension('mcp', getMcpTools());
35
35
  refreshChatTools();
36
- emit('runtime_ready', { projectRoot: process.cwd(), warnings: getMcpWarnings() });
36
+ emit('runtime_ready', {
37
+ projectRoot: process.cwd(),
38
+ provider: config.provider,
39
+ promptCache: config.provider === 'anthropic' && config.anthropicPromptCache,
40
+ warnings: getMcpWarnings(),
41
+ });
37
42
  })();
38
43
  return initialized;
39
44
  }
@@ -117,6 +122,8 @@ async function run(command) {
117
122
  sessionId,
118
123
  projectRoot: process.cwd(),
119
124
  resumed,
125
+ provider: config.provider,
126
+ promptCache: config.provider === 'anthropic' && config.anthropicPromptCache,
120
127
  attachments: command.attachments?.map((attachment) => attachment.name) ?? [],
121
128
  }, command.id);
122
129
  const result = await runAgentCore({
@@ -134,6 +141,8 @@ async function run(command) {
134
141
  terminationReason: result.terminationReason,
135
142
  changedFiles: result.changedFiles ?? [],
136
143
  usage: result.usage,
144
+ provider: config.provider,
145
+ promptCache: config.provider === 'anthropic' && config.anthropicPromptCache,
137
146
  usagePercent: Math.round(contextUsagePercent() * 100),
138
147
  contextWindow: config.contextWindowTokens,
139
148
  }, command.id);
package/dist/llm/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import OpenAI from 'openai';
2
- import { config, isSubAgentEnabled, isFrontendToolsEnabled } from '../config/index.js';
2
+ import { config, getActiveModel, isSubAgentEnabled, isFrontendToolsEnabled } from '../config/index.js';
3
3
  import { tools } from '../tools/registry.js';
4
4
  import { getPlanDisabledTools, FRONTEND_TOOLS } from '../tools/constants.js';
5
5
  import { ThinkTagFilter } from './think-filter.js';
6
+ import { anthropicChatOnce } from './providers/anthropic.js';
6
7
  // 强制关闭第三方调试日志泄漏:openai SDK 在 process.env.DEBUG === 'true' 时用裸
7
8
  // console.log 把请求/响应直写 stdout,会污染 TUI 输入框(并泄露 headers/URL)。
8
9
  // 仅拦截 'true' 这一开关值——保留 namespace 形式的 DEBUG(如 DEBUG=express:*) 调试能力。
@@ -267,6 +268,9 @@ toolsOverride) {
267
268
  throw new DOMException('This operation was aborted', 'AbortError');
268
269
  }
269
270
  try {
271
+ if (config.provider === 'anthropic') {
272
+ return await anthropicChatOnce(messages, handlers, signal, toolsOverride ?? chatTools);
273
+ }
270
274
  return await chatOnce(messages, handlers, signal, toolsOverride);
271
275
  }
272
276
  catch (err) {
@@ -327,6 +331,22 @@ export function normalizeImageDetail(messages) {
327
331
  }
328
332
  /** 单次流式 LLM 请求(无重试);chat() 的内部实现,可被 __setChatCreateImpl 注入桩以做单测。 */
329
333
  async function chatOnce(messages, handlers, signal, toolsOverride) {
334
+ // 防御:messages 必须至少含一条非空 user 消息,否则 OpenAI/Anthropic 都会 400。
335
+ // compact force 分支曾把所有 user 丢进摘要 → 重建 history 无 user → 下一轮 400。
336
+ // 在 transport 边界拦住所有类似回归(compact / 外部注入 / resume 损坏)。
337
+ const hasNonEmptyUser = messages.some((m) => {
338
+ if (m.role !== 'user')
339
+ return false;
340
+ const c = m.content;
341
+ if (typeof c === 'string')
342
+ return c.length > 0;
343
+ if (Array.isArray(c))
344
+ return c.some((p) => p.type === 'text' && (p.text?.length ?? 0) > 0);
345
+ return false;
346
+ });
347
+ if (!hasNonEmptyUser) {
348
+ throw new Error('messages must contain at least one non-empty user message');
349
+ }
330
350
  // signal 透传给 SDK 第二参(RequestOptions);abort 后 for await 抛错,chat 不 catch,透传 runAgent 处理。
331
351
  // createImplOverride 的 body 类型故意宽成 Record(测试桩用),生产走 client 分支时由 OpenAI 自己的类型守门。
332
352
  const create = createImplOverride
@@ -334,7 +354,7 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
334
354
  : (body, opts) => client.chat.completions.create(body, opts);
335
355
  const activeTools = toolsOverride ?? chatTools;
336
356
  const stream = await create({
337
- model: config.model,
357
+ model: getActiveModel(),
338
358
  messages: normalizeImageDetail(messages),
339
359
  tools: activeTools,
340
360
  stream: true,