mocode-ai 0.3.0 → 0.4.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/dist/llm/index.js CHANGED
@@ -17,6 +17,14 @@ const RETRY_MAX_ATTEMPTS = 4;
17
17
  const RETRY_BASE_MS = 1000;
18
18
  const RETRY_MAX_MS = 30000;
19
19
  const RETRY_JITTER = 0.2;
20
+ /**
21
+ * 流式响应里出现的推理模型自创 `think` 标签(DeepSeek R1 / Qwen3 / 部分自训模型):
22
+ * 与 OpenAI 兼容协议的独立 `reasoning_content` 字段不同,这些模型把 thinking 直接嵌进 content
23
+ * 字符串,期间不调 onText(spinner 持续转 ⠹ 思考中…),也不写入可见 content(history 不被思考段污染)。
24
+ */
25
+ // 用 \u003c 表示 <,绕开本工具对 < 的处理(直接写 '<\u003cthink\u003e' 里 < 会被吃掉)。
26
+ const THINK_OPEN = '<think>';
27
+ const THINK_CLOSE = '</think>';
20
28
  let client = new OpenAI({
21
29
  baseURL: config.baseURL,
22
30
  apiKey: config.apiKey,
@@ -199,8 +207,13 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
199
207
  ...(config.maxTokens ? { max_tokens: config.maxTokens } : {}),
200
208
  ...(config.includeUsage ? { stream_options: { include_usage: true } } : {}),
201
209
  }, signal ? { signal } : undefined);
202
- let content = '';
203
- let hasContent = false;
210
+ // 流式 start end 标签过滤(见模块顶部 THINK_OPEN/CLOSE)。
211
+ // chunk 切分防御:buf 累积跨 chunk 边界,indexOf 扫描;为避免把跨 chunk 标签误判为
212
+ // 普通字符,buf 末尾为当前态保留 (label.length - 1) 个字符给下一 chunk 看。
213
+ let visibleContent = '';
214
+ let consumedAny = false;
215
+ let inThink = false;
216
+ let buf = '';
204
217
  let usage;
205
218
  const toolAcc = new Map();
206
219
  for await (const chunk of stream) {
@@ -216,9 +229,57 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
216
229
  if (!delta)
217
230
  continue; // 末尾 usage-only chunk 等无 delta
218
231
  if (delta.content) {
219
- content += delta.content;
220
- hasContent = true;
221
- handlers.onText?.(delta.content);
232
+ // 状态机切分 delta.content:inThink 外输出到 visibleContent + onText;
233
+ // inThink 内丢弃;标签起始/闭合用 indexOf 在 buf 里扫描。
234
+ // 末尾预留 (label.length - 1) 字符给下一 chunk 防切分误判。
235
+ buf += delta.content;
236
+ let i = 0;
237
+ while (true) {
238
+ if (inThink) {
239
+ // 思考段内,扫描 THINK_CLOSE;末尾预留 THINK_CLOSE.length - 1 防跨 chunk 切分
240
+ const endIdx = buf.indexOf(THINK_CLOSE, i);
241
+ if (endIdx === -1) {
242
+ // 思考段内未找到闭合;buf 短到不可能包含 </think> 时全丢(都是思考段内容),
243
+ // 否则留 (THINK_CLOSE.length - 1) 给下一 chunk 防跨边界切分。
244
+ const safeLen = buf.length >= THINK_CLOSE.length
245
+ ? buf.length - (THINK_CLOSE.length - 1)
246
+ : buf.length;
247
+ i = safeLen;
248
+ break;
249
+ }
250
+ inThink = false;
251
+ i = endIdx + THINK_CLOSE.length;
252
+ }
253
+ else {
254
+ // 普通段,扫描 THINK_OPEN;末尾预留 THINK_OPEN.length - 1 防跨 chunk 切分
255
+ const startIdx = buf.indexOf(THINK_OPEN, i);
256
+ if (startIdx === -1) {
257
+ // buf 短到不可能包含 <think> 时全输出(无 think 标签的普通模型不受影响);
258
+ // 否则留 (THINK_OPEN.length - 1) 给下一 chunk 防跨边界切分误判。
259
+ const safeLen = buf.length >= THINK_OPEN.length
260
+ ? buf.length - (THINK_OPEN.length - 1)
261
+ : buf.length;
262
+ const seg = buf.slice(i, safeLen);
263
+ if (seg) {
264
+ visibleContent += seg;
265
+ handlers.onText?.(seg);
266
+ consumedAny = true;
267
+ }
268
+ i = safeLen;
269
+ break;
270
+ }
271
+ // THINK_OPEN 之前的普通段:输出
272
+ if (startIdx > i) {
273
+ const seg = buf.slice(i, startIdx);
274
+ visibleContent += seg;
275
+ handlers.onText?.(seg);
276
+ consumedAny = true;
277
+ }
278
+ inThink = true;
279
+ i = startIdx + THINK_OPEN.length;
280
+ }
281
+ }
282
+ buf = buf.slice(i);
222
283
  }
223
284
  if (delta.tool_calls) {
224
285
  for (const tc of delta.tool_calls) {
@@ -241,6 +302,16 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
241
302
  }
242
303
  }
243
304
  }
305
+ // 防御:循环内 buf.slice 已把可确认部分消费;此处覆盖流末尾的"安全尾":
306
+ // - 普通段(stream 已结束,标签不会再出现):作为可见内容追加到 visibleContent(不再调 onText)
307
+ // - 思考段未闭合:丢弃,防 thinking 文本泄漏到 history
308
+ if (buf) {
309
+ if (!inThink) {
310
+ visibleContent += buf;
311
+ consumedAny = true;
312
+ }
313
+ buf = '';
314
+ }
244
315
  const toolCalls = [...toolAcc.entries()]
245
316
  .sort((a, b) => a[0] - b[0])
246
317
  .map(([, e]) => ({
@@ -249,7 +320,7 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
249
320
  arguments: e.arguments,
250
321
  }));
251
322
  return {
252
- content: hasContent ? content : null,
323
+ content: consumedAny ? visibleContent : null,
253
324
  toolCalls,
254
325
  usage,
255
326
  };
@@ -16,7 +16,7 @@ import { promptIntervention } from '../ui/intervention.js';
16
16
  import { tools } from '../tools/registry.js';
17
17
  import { estimateMessagesTokens, reconfigureClient, } from '../llm/index.js';
18
18
  import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
19
- import { compactHistory, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
19
+ import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
20
20
  import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
21
21
  import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
22
22
  import { buildMemorySection, buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, loadAll, } from '../memory/index.js';
@@ -867,16 +867,69 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
867
867
  continue;
868
868
  }
869
869
  if (line === '/compact' || line.startsWith('/compact ')) {
870
- const focus = line.startsWith('/compact ')
871
- ? line.slice('/compact '.length).trim()
872
- : undefined;
873
- const r = await compactHistory(history, {
874
- window: config.contextWindowTokens,
875
- threshold: config.compactThreshold,
876
- focus,
877
- });
878
- if (r.reason === 'noop') {
879
- layout.contentWrite(`${ui.dim}(无需压缩:没有可压缩的旧消息)${ui.reset}\n`);
870
+ // /compact 可选语法:/compact [focus] 或 /compact --force [focus]
871
+ // --force:即便 oldGroups 空(history 全在保护区)也强行把早期消息降级压一次。
872
+ const rest = line.slice('/compact'.length).trim();
873
+ let force = false;
874
+ let focus;
875
+ if (rest === '--force')
876
+ force = true;
877
+ else if (rest.startsWith('--force ')) {
878
+ force = true;
879
+ focus = rest.slice('--force '.length).trim() || undefined;
880
+ }
881
+ else if (rest)
882
+ focus = rest;
883
+ // 走调度器路径:与自动每步压缩完全一致——五区按 ROI 压(cold tools 优先 → history 摘要最后)。
884
+ // focus 透传到 compact_history action 的 LLM 摘要 prompt。
885
+ // 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
886
+ const log = await manualCompact(history, focus, { force });
887
+ const d = log.compactDetail;
888
+ if (!d) {
889
+ // 兜底(旧调用):只显示 old 文案
890
+ if (!log.compactHistoryCalled) {
891
+ layout.contentWrite(`${ui.dim}(无需压缩:没有可压缩的旧消息)${ui.reset}\n`);
892
+ }
893
+ else if (focus) {
894
+ layout.contentWrite(`${ui.dim}(带焦点压缩:${focus})${ui.reset}\n`);
895
+ }
896
+ continue;
897
+ }
898
+ // 详细文案:按 reason 分类
899
+ const reason = d.reason;
900
+ const before = d.estimateBefore;
901
+ const after = d.estimateAfter;
902
+ const proto = d.protectedRatio !== undefined ? `保护区占比 ${(d.protectedRatio * 100).toFixed(0)}%` : '';
903
+ const oldCt = d.oldGroupCount !== undefined ? `旧区组数 ${d.oldGroupCount}` : '';
904
+ const focusNote = focus ? `焦点:${focus}` : '';
905
+ const stats = [proto, oldCt].filter(Boolean).join(' · ');
906
+ if (reason === 'microcompact') {
907
+ layout.contentWrite(`${ui.cyan}✓ 微压缩:${ui.reset} ${before} → ${after} tokens${stats ? ` (${ui.dim}${stats}${ui.reset})` : ''}\n`);
908
+ }
909
+ else if (reason === 'summarize') {
910
+ layout.contentWrite(`${ui.cyan}✓ LLM 摘要:${ui.reset} ${before} → ${after} tokens${focusNote ? ` (${ui.dim}${focusNote}${ui.reset})` : ''}\n`);
911
+ }
912
+ else if (reason === 'noop-empty') {
913
+ layout.contentWrite(`${ui.dim}(history 太短,只有 system 提示,无可压旧区)${ui.reset}\n`);
914
+ }
915
+ else if (reason === 'noop-protected') {
916
+ layout.contentWrite(`${ui.dim}(无可压旧区:全部在保护区 system + 当前轮)${ui.reset}${stats ? ` ${ui.dim}(${stats})${ui.reset}` : ''}\n`);
917
+ layout.contentWrite(`${ui.dim}提示:/compact --force 强行把早期对话压成摘要${ui.reset}\n`);
918
+ }
919
+ else if (reason === 'noop-ml-only') {
920
+ layout.contentWrite(`${ui.dim}(LLM 摘要失败,且无超大单条可微压;可能是后端不可用)${ui.reset}\n`);
921
+ layout.contentWrite(`${ui.dim}回退:只跑了 keep-current 结构,history 未变${ui.reset}\n`);
922
+ }
923
+ else if (reason === 'noop-shrunk-too-large') {
924
+ layout.contentWrite(`${ui.yellow}● 上下文已超阈但无可压缩项(全在保护区),建议 /clear 或缩短输入。${ui.reset}\n`);
925
+ if (stats)
926
+ layout.contentWrite(`${ui.dim}(${stats})${ui.reset}\n`);
927
+ }
928
+ else if (reason === 'noop-noold-noop') {
929
+ layout.contentWrite(`${ui.dim}(无需压缩:没有可压缩的旧消息,且不在手动触发)${ui.reset}\n`);
930
+ }
931
+ else {
932
+ layout.contentWrite(`${ui.dim}(reason=${reason},${before} → ${after} tokens)${ui.reset}\n`);
880
933
  }
881
934
  continue;
882
935
  }
@@ -5,7 +5,8 @@ import { ui } from '../ui/theme.js';
5
5
  import { Spinner } from '../ui/spinner.js';
6
6
  import * as layout from '../ui/layout.js';
7
7
  import { pruneAfterCompaction } from '../rollback/index.js';
8
- /** 跨模块共享的上下文状态:agent 写 lastUsage,compact 写 lastEstimate,repl 的 /context 读。 */
8
+ /** 跨模块共享的上下文状态:agent 写 lastUsage,compact 写 lastEstimate,repl 的 /context 读。
9
+ * scheduler.ts 写最近一次调度日志(可选,repl 可读不到时 no-op)。 */
9
10
  export const contextState = {
10
11
  lastEstimate: 0,
11
12
  };
@@ -268,15 +269,83 @@ export async function compactHistory(history, opts) {
268
269
  summarized: false,
269
270
  estimateBefore,
270
271
  estimateAfter: estimateBefore,
271
- reason: 'noop',
272
+ reason: 'noop-noold-noop',
273
+ protectedRatio: history.length > 0 ? kept.length / history.length : 0,
274
+ oldGroupCount: oldGroups.length,
272
275
  };
273
276
  if (oldGroups.length === 0) {
274
277
  // 没有旧区可压缩
278
+ const protectedRatio = history.length > 0 ? kept.length / history.length : 0;
279
+ if (opts.force && history.length > 2) {
280
+ // 真·强制压:把降 keepBudget 当 old group 强行创建一组可压区
281
+ // 取最早的 user/assistant/tool 当 old,后一段当 kept
282
+ const mid = Math.max(1, Math.floor(history.length / 2));
283
+ const older = history.slice(1, mid);
284
+ const keptAfter = history.slice(mid);
285
+ // 走仅微压缩(LLM 可能不可用,不强行 summarize)
286
+ let microcompactDone2 = false;
287
+ for (const m of older) {
288
+ const c = m.content;
289
+ if (typeof c === 'string' && c.length > MAX_OLD_TOOL_STUB) {
290
+ m.content = truncateMid(c, MAX_OLD_TOOL_STUB);
291
+ microcompactDone2 = true;
292
+ }
293
+ const as = m;
294
+ if (as.role === 'assistant') {
295
+ if (typeof as.content === 'string' && as.content.length > MAX_OLD_TOOL_STUB) {
296
+ as.content = truncateMid(as.content, MAX_OLD_TOOL_STUB);
297
+ microcompactDone2 = true;
298
+ }
299
+ if (Array.isArray(as.tool_calls)) {
300
+ for (const tc of as.tool_calls) {
301
+ const args = tc?.function?.arguments;
302
+ if (typeof args !== 'string' || args.length <= MAX_OLD_TOOL_STUB)
303
+ continue;
304
+ const stubbed = stubToolCallArguments(args);
305
+ if (stubbed !== args) {
306
+ tc.function.arguments = stubbed;
307
+ microcompactDone2 = true;
308
+ }
309
+ }
310
+ }
311
+ }
312
+ }
313
+ const summaryMsg = {
314
+ role: 'system',
315
+ content: older.length > 0
316
+ ? `# 会话摘要(force)\n被跳过的早期对话 ${older.length} 条已微压缩(token 数减少)。`
317
+ : `# 会话摘要(force)\n无内容。`,
318
+ };
319
+ const rebuilt = [history[0], summaryMsg, ...keptAfter];
320
+ history.length = 0;
321
+ history.push(...rebuilt);
322
+ pruneAfterCompaction(history);
323
+ const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
324
+ contextState.lastEstimate = estimateAfter;
325
+ contextState.lastUsage = undefined;
326
+ layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}强制压缩(focus on early history)${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
327
+ return {
328
+ compacted: true,
329
+ summarized: false,
330
+ estimateBefore,
331
+ estimateAfter,
332
+ reason: microcompactDone2 ? 'microcompact' : 'summarize',
333
+ protectedRatio,
334
+ oldGroupCount: 0,
335
+ };
336
+ }
337
+ // 细分 noop 类型,供 repl 文案
338
+ const isEmpty = history.length <= 1; // 只有 system 提示
339
+ if (isEmpty) {
340
+ // 整段 history 太短,没有可压内容
341
+ return { ...noop, reason: 'noop-empty', protectedRatio };
342
+ }
343
+ // history 有内容但全在保护区(系统 + 当前轮)
275
344
  if (estimateBefore >= opts.threshold * opts.window) {
276
- layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}上下文已超阈但无可压缩项,建议 /clear 或缩短输入。${ui.reset}\n`);
277
- return { ...noop, reason: 'too-large' };
345
+ layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}上下文已超阈但无可压缩项(全在保护区),建议 /clear 或缩短输入。${ui.reset}\n`);
346
+ return { ...noop, reason: 'noop-shrunk-too-large', protectedRatio };
278
347
  }
279
- return noop;
348
+ return { ...noop, reason: 'noop-protected', protectedRatio };
280
349
  }
281
350
  // 第一层:微压缩——旧区原地截短(保 tool_call_id,无 LLM 调用)
282
351
  // 覆盖三类大字段,均只裁模型/工具产物,不动 user 原话与 system(摘要):
@@ -365,24 +434,63 @@ export async function compactHistory(history, opts) {
365
434
  estimateBefore,
366
435
  estimateAfter,
367
436
  reason: 'microcompact',
437
+ protectedRatio: history.length > 0 ? kept.length / history.length : 0,
438
+ oldGroupCount: oldGroups.length,
368
439
  };
369
440
  }
370
- return noop;
441
+ // 摘要失败 + 无超大单条可微压 = 真 noop
442
+ return {
443
+ ...noop,
444
+ reason: 'noop-ml-only',
445
+ protectedRatio: history.length > 0 ? kept.length / history.length : 0,
446
+ oldGroupCount: oldGroups.length,
447
+ };
371
448
  }
372
449
  /**
373
450
  * 自动压缩门槛:agent 每步调 chat() 前调用。
374
451
  * 用全量启发式估算(始终可用、安全侧、无 stale-usage 问题);超阈则压缩。
452
+ *
453
+ * 升级到 Budget Scheduler:可传 `report: BudgetReport`。传了就**按 ROI 调度**:
454
+ * - 只有当 `report.layers.history.overBudget` 或 `report.totalOver` 时才压;
455
+ * - 冷工具超(Cold Tool ROI 最低)→ 不压 history,留给调度器的 cold tools 路径处理。
456
+ * 这样 cold tools 路径(L1 中截 / L2 relevance / L3 age stub)能先动,history
457
+ * 摘要(最贵)只在 cold tools 解不开时才触发。
458
+ *
459
+ * 不传 report 时退化为原行为:仅看总占用是否超 `compactThreshold * window`——
460
+ * 兼容老调用方(子 agent / 测试直接调时),零行为变化。
461
+ *
462
+ * manual 选项(repl /compact 用):true 时旁路 autoCompact 开关与 ROI 阈,
463
+ * 强制走 compactHistory(manual/force 参数透传)。返 CompactResult 给 caller 文案展示。
464
+ * 默认 manual=false 自动路径完全不变。
375
465
  */
376
- export async function maybeCompact(history) {
466
+ export async function maybeCompact(history, report, manualOpts) {
377
467
  const schemaTokens = estimateToolSchemaTokens();
378
468
  const est = estimateMessagesTokens(history) + schemaTokens;
379
469
  contextState.lastEstimate = est;
380
- if (!config.autoCompact)
381
- return;
382
- if (est < config.compactThreshold * config.contextWindowTokens)
383
- return;
384
- await compactHistory(history, {
470
+ const isManual = manualOpts?.manual === true;
471
+ // 手动路径:旁路 autoCompact / report / 总阈三重门
472
+ if (!isManual) {
473
+ if (!config.autoCompact)
474
+ return;
475
+ if (report) {
476
+ // 调度模式:只压 history 层超或兜底总超
477
+ const needsCompact = report.layers.history.overBudget || report.totalOver;
478
+ if (!needsCompact)
479
+ return;
480
+ }
481
+ else {
482
+ // 老路径:总占用超阈才压
483
+ if (est < config.compactThreshold * config.contextWindowTokens)
484
+ return;
485
+ }
486
+ }
487
+ const r = await compactHistory(history, {
385
488
  window: config.contextWindowTokens,
386
489
  threshold: config.compactThreshold,
490
+ focus: manualOpts?.focus,
491
+ manual: isManual,
492
+ force: manualOpts?.force,
387
493
  });
494
+ if (isManual)
495
+ return r;
388
496
  }
@@ -5,5 +5,10 @@
5
5
  * 依赖方向:session → {llm(摘要复用 chat), config, ui};llm 不反向依赖 session。
6
6
  */
7
7
  export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, contextState, } from './compact.js';
8
+ // ── Context Budget Scheduler 接缝 ────────────────────────────────────────
9
+ // agent/core.ts 步前调 runScheduler(history, step):评估五区预算 → 按 ROI 调度
10
+ // shrink_cold_tools / cap_hot_tools / compact_history。开关关闭时退化为 maybeCompact。
11
+ // repl /compact 命令调 manualCompact(history, focus?):与自动路径完全一致,focus 透传摘要 prompt。
12
+ export { runScheduler, manualCompact, createBudgetScheduler, } from './scheduler.js';
8
13
  export { dropContextFromHistory, formatDropResult, } from './drop.js';
9
14
  export { newSessionId, saveSession, loadSession, listSessions, sessionDir, } from './persist.js';
@@ -0,0 +1,172 @@
1
+ // Context Budget Scheduler(执行层):把 scheduleActions() 产生的动作落到既有闸上。
2
+ //
3
+ // 关系图:
4
+ //
5
+ // agent/core.ts (步前) repl /compact 命令(手动)
6
+ // ↓ runScheduler(history, step) ↓ manualCompact(history, focus?)
7
+ // session/scheduler.ts(本文件)
8
+ // ├─ evaluateBudget(history, window, step) → BudgetReport
9
+ // │ ↓
10
+ // ├─ scheduleActions(report) → ScheduleAction[]
11
+ // │ ↓
12
+ // └─ 执行 actions:
13
+ // - warn: 仅写日志
14
+ // - shrink_cold_tools L1: 由 push-time cap.ts(MAX_HISTORY_RESULT)自动处理;此处 no-op
15
+ // - shrink_cold_tools L2: 由 pruner.observePush(relevance.ts)自动处理;此处 no-op
16
+ // - shrink_cold_tools L3: 由 lifecycle.pushTool(lifecycle.ts)自动处理;此处 no-op
17
+ // - cap_hot_tools: Hot 区只 cap,实际仍由 push-time cap 走;此处 no-op + 记日志
18
+ // - compact_history: 调 maybeCompact(history, report)── report 路由到 ROI 调度
19
+ //
20
+ // 设计意图:
21
+ // - **L1/L2/L3 不重复实现**——push-time 已经自动跑过这三级。再在调度器做一遍是 Double-Action
22
+ // 且破坏「调度器永不抛错 + 幂等」契约。调度器只负责"决策时点",push-time 闸负责"执行"。
23
+ // - **hotBoundary** 仍由调度器算出来供 lifecycle 内部用(将来可演进成「仅 Cold 区跑 age stub」)——
24
+ // 现版本先全面暴露给 report,暂不传参给 lifecycle。
25
+ // - **actionLog**:每次执行的决策落进 contextState.schedulerLog,供 /context 命令与调试用。
26
+ // - **manualCompact**:手动入口(用户敲 /compact),与 runScheduler 共享决策路径;唯一差别是
27
+ // 即使 history 不超预算也强制产 compact_history(focus 透传)。对齐用户拍板的方案 A。
28
+ //
29
+ // 开关:
30
+ // - config.contextBudget !== false(默认 true):agent 调 runScheduler
31
+ // - 关时 agent 仍走原 maybeCompact(history)无 report 路径,零行为变化
32
+ // - 手动 /compact 走 manualCompact;关时退化直接调 compactHistory(history, { focus })
33
+ import { evaluateBudget, scheduleActions, formatReport, } from '../context/budget.js';
34
+ import { config } from '../config/index.js';
35
+ import { maybeCompact, contextState } from './compact.js';
36
+ import * as layout from '../ui/layout.js';
37
+ import { ui } from '../ui/theme.js';
38
+ /** 创建 runAgentCore 闭包持有的 scheduler(每次 agent 启动一个新实例)。
39
+ * observePush 当前只是占位:真正 L1/L2/L3 已由 cap / pruner / lifecycle 在 push 时跑;
40
+ * 保留接口为后续「调度器注入 hotBoundary 给 lifecycle」演进留接缝。 */
41
+ export function createBudgetScheduler() {
42
+ const obs = {
43
+ lastRunLog: null,
44
+ observePush(_history, _idx) {
45
+ // 占位:push-time 三闸(cap / pruner / lifecycle)已自动跑;此接缝供将来演进。
46
+ },
47
+ async runStep(history, step) {
48
+ const report = evaluateBudget(history, config.contextWindowTokens, step);
49
+ const actions = scheduleActions(report);
50
+ let compactHistoryCalled = false;
51
+ for (const a of actions) {
52
+ if (a.kind === 'warn') {
53
+ // system 超:写一行提示(配置漂移应由用户处理,不是调度器压)
54
+ layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}调度器警告:${a.layer} ${a.reason}${ui.reset}\n`);
55
+ }
56
+ else if (a.kind === 'compact_history') {
57
+ // 路由到 maybeCompact(history, report)——按 ROI 调度(只有 history 超 / totalOver 才真压)
58
+ await maybeCompact(history, report);
59
+ compactHistoryCalled = true;
60
+ }
61
+ // shrink_cold_tools L1/L2/L3 与 cap_hot_tools:已由 push-time 闸在每次 push 自动跑
62
+ // (cap = MAX_HISTORY_RESULT;pruner = same-path 新旧替换;lifecycle = age stub)。
63
+ // 调度器不重复,只把决策记录下来供调试。
64
+ }
65
+ const log = {
66
+ step,
67
+ report,
68
+ actions,
69
+ compactHistoryCalled,
70
+ ts: Date.now(),
71
+ };
72
+ obs.lastRunLog = log;
73
+ // 暴露给 /context 共享读(repl / context 命令)
74
+ contextState.schedulerLog = log;
75
+ },
76
+ };
77
+ return obs;
78
+ }
79
+ /** 便捷:agent/core.ts 不需要每次 createBudgetScheduler,直接 runScheduler(history, step)。 */
80
+ export async function runScheduler(history, step) {
81
+ const s = createBudgetScheduler();
82
+ await s.runStep(history, step);
83
+ }
84
+ /** 手动 /compact 入口(repl):与自动路径完全一致——五区 ROI 调度,但 history 摘要强制执行。
85
+ * 即便 layers.history.overBudget=false 或 totalOver=false,manual 仍产 compact_history action
86
+ * 把 focus 透传给 LLM 摘要 prompt。其它 ROI 决策(cold tools / cap hot / warn)按 scheduleActions 走。
87
+ *
88
+ * 关系:runScheduler 是「自动触发」,manualCompact 是「用户显式触发」,二者共享 scheduleActions。
89
+ *
90
+ * force=true:即便 oldGroups 空(history 全在保护区)也强行把早期消息降级压一次。
91
+ * 适合"history 太长,自动阈值从未触发,但用户想强制压"的场景。
92
+ *
93
+ * 退化:config.contextBudget === false 时直接调 compactHistory(history, { focus }),与改造前等价。
94
+ * 返回 SchedulerRunLog + compactDetail 字段,供 repl 文案展示"为什么没压"。 */
95
+ export async function manualCompact(history, focus, opts) {
96
+ if (config.contextBudget === false) {
97
+ // 退化:不经调度器,直压 history(等价于改造前,但带 manual/force 透传)
98
+ const r = await import('./compact.js').then(({ compactHistory }) => compactHistory(history, {
99
+ window: config.contextWindowTokens,
100
+ threshold: config.compactThreshold,
101
+ focus,
102
+ manual: true,
103
+ force: opts?.force,
104
+ }));
105
+ return {
106
+ step: -1,
107
+ report: evaluateBudget(history, config.contextWindowTokens, -1),
108
+ actions: [{ kind: 'compact_history', focus }],
109
+ compactHistoryCalled: true,
110
+ ts: Date.now(),
111
+ compactDetail: {
112
+ reason: r.reason,
113
+ estimateBefore: r.estimateBefore,
114
+ estimateAfter: r.estimateAfter,
115
+ protectedRatio: r.protectedRatio,
116
+ oldGroupCount: r.oldGroupCount,
117
+ focus,
118
+ },
119
+ };
120
+ }
121
+ const report = evaluateBudget(history, config.contextWindowTokens, -1);
122
+ let actions = scheduleActions(report);
123
+ // 用户显式说「要压」:即使 report 不含 history 触发,仍追加 compact_history
124
+ const hasCompact = actions.some(a => a.kind === 'compact_history');
125
+ if (!hasCompact) {
126
+ actions = [...actions, { kind: 'compact_history', focus }];
127
+ }
128
+ else if (focus) {
129
+ // 已有 compact_history(action 由 scheduleActions 产,无 focus)— 注入 focus
130
+ actions = actions.map(a => a.kind === 'compact_history' ? { ...a, focus } : a);
131
+ }
132
+ const s = createBudgetScheduler();
133
+ // 直接驱动 runStep 的执行逻辑(action list 我们已自己生成)
134
+ let compactHistoryCalled = false;
135
+ let compactDetail;
136
+ for (const a of actions) {
137
+ if (a.kind === 'warn') {
138
+ // 复用现有 contentWrite 路径(经 scheduler.runStep);这里略,只记录到 log
139
+ }
140
+ else if (a.kind === 'compact_history') {
141
+ const r = await maybeCompact(history, report, {
142
+ manual: true,
143
+ force: opts?.force,
144
+ focus: a.focus,
145
+ });
146
+ compactHistoryCalled = true;
147
+ if (r) {
148
+ compactDetail = {
149
+ reason: r.reason,
150
+ estimateBefore: r.estimateBefore,
151
+ estimateAfter: r.estimateAfter,
152
+ protectedRatio: r.protectedRatio,
153
+ oldGroupCount: r.oldGroupCount,
154
+ focus: a.focus,
155
+ };
156
+ }
157
+ }
158
+ }
159
+ const log = {
160
+ step: -1,
161
+ report,
162
+ actions,
163
+ compactHistoryCalled,
164
+ ts: Date.now(),
165
+ compactDetail,
166
+ };
167
+ s.lastRunLog = log;
168
+ contextState.schedulerLog = log;
169
+ return log;
170
+ }
171
+ // ── 调试导出:用于 scripts/check-budget.ts 把报告打到 stdout 调试 ────────
172
+ export { evaluateBudget, scheduleActions, formatReport };
@@ -1,5 +1,43 @@
1
1
  import { promptIntervention } from '../../ui/intervention.js';
2
2
  import { sendState } from '../../pet/bridge.js';
3
+ /** 公开以便 check-ask-human-options.ts 单元测试。 */
4
+ export function coerceOptions(raw) {
5
+ // 路径 1:本身就是数组,map 成字符串。
6
+ if (Array.isArray(raw)) {
7
+ // 子路径 1a:LLM 把真数组包成字符串塞在单元素里(本次 bug 现场)
8
+ if (raw.length === 1 && typeof raw[0] === 'string') {
9
+ const t = raw[0].trim();
10
+ if (t.startsWith('[') && t.endsWith(']')) {
11
+ try {
12
+ const parsed = JSON.parse(t);
13
+ if (Array.isArray(parsed))
14
+ return parsed.map((o) => String(o));
15
+ }
16
+ catch {
17
+ // 不是合法 JSON 数组,降级原值
18
+ }
19
+ }
20
+ }
21
+ return raw.map((o) => (typeof o === 'string' ? o : String(o)));
22
+ }
23
+ // 路径 2:LLM 直接把整个数组 stringify 成单字符串塞 options 字段(JSON.parse 出来是字符串)
24
+ // 例如 GLM 系经常这么做,arg h['options']='["A","B"]' → args.options='["A","B"]'
25
+ // 这里解开成真数组再转。
26
+ if (typeof raw === 'string') {
27
+ const t = raw.trim();
28
+ if (t.startsWith('[') && t.endsWith(']')) {
29
+ try {
30
+ const parsed = JSON.parse(t);
31
+ if (Array.isArray(parsed))
32
+ return parsed.map((o) => String(o));
33
+ }
34
+ catch {
35
+ // 不是合法 JSON,保留为单元素数组(对应 input 模式)
36
+ }
37
+ }
38
+ }
39
+ return [];
40
+ }
3
41
  // ---------- ask_human ----------
4
42
  export const askHumanTool = {
5
43
  name: 'ask_human',
@@ -30,9 +68,10 @@ export const askHumanTool = {
30
68
  },
31
69
  async execute(args) {
32
70
  const question = String(args.question ?? '');
33
- const options = Array.isArray(args.options)
34
- ? args.options.map((o) => String(o))
35
- : [];
71
+ // 容错:部分 LLM(尤其 GLM 系)把数组/对象 stringify 后塞进来,这里识别「长得很像 JSON
72
+ // 数组的单字符串元素」并解开,避免菜单只剩一行 [object Object]、逼用户手动输入。
73
+ // 任何一步失败 / 解出非数组:降级 input,与原代码语义一致。
74
+ const options = coerceOptions(args.options);
36
75
  const context = args.context ? String(args.context) : undefined;
37
76
  // 桌宠:面板弹出期间广播 waiting_human(红灯闪烁,提示需要人工介入);拿到响应后 sendState 会被
38
77
  // 下一个 hook 事件(如 onToolDone→tool_call)覆盖,这里不用手动切回——与其它工具状态转移逻辑一致。