c0de-agent 1.0.0 → 1.2.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.
Files changed (46) hide show
  1. package/dist/cli/deps.d.ts +16 -5
  2. package/dist/cli/deps.js +18 -5
  3. package/dist/cli/index.js +8 -2
  4. package/dist/core/agent.js +5 -0
  5. package/dist/core/config.js +1 -0
  6. package/dist/core/index.d.ts +1 -0
  7. package/dist/core/index.js +1 -0
  8. package/dist/core/loop.d.ts +10 -0
  9. package/dist/core/loop.js +559 -295
  10. package/dist/core/slash.js +3 -4
  11. package/dist/core/title.js +3 -2
  12. package/dist/core/types.d.ts +2 -0
  13. package/dist/core/workflow.d.ts +25 -0
  14. package/dist/core/workflow.js +98 -0
  15. package/dist/core/worktree.js +6 -4
  16. package/dist/dap/session.js +3 -3
  17. package/dist/llm/provider.js +5 -1
  18. package/dist/plugins/loader.js +3 -2
  19. package/dist/server/agent-manager.d.ts +2 -0
  20. package/dist/server/agent-manager.js +8 -0
  21. package/dist/server/app.js +3 -0
  22. package/dist/server/context.js +2 -0
  23. package/dist/server/dev.d.ts +4 -1
  24. package/dist/server/dev.js +92 -27
  25. package/dist/server/permission/store.d.ts +2 -0
  26. package/dist/server/permission/store.js +9 -0
  27. package/dist/server/routes/chat.js +44 -1
  28. package/dist/server/routes/session.js +75 -1
  29. package/dist/server/routes/terminal.d.ts +5 -0
  30. package/dist/server/routes/terminal.js +66 -0
  31. package/dist/server/server.d.ts +14 -1
  32. package/dist/server/server.js +100 -28
  33. package/dist/server/terminal/pty-manager.d.ts +53 -0
  34. package/dist/server/terminal/pty-manager.js +160 -0
  35. package/dist/server/types.d.ts +3 -0
  36. package/dist/session/archive.d.ts +1 -1
  37. package/dist/session/compaction.d.ts +8 -2
  38. package/dist/session/compaction.js +85 -16
  39. package/dist/session/shake.d.ts +66 -0
  40. package/dist/session/shake.js +304 -0
  41. package/dist/session/types.d.ts +1 -1
  42. package/dist/shared/types/agent.d.ts +27 -0
  43. package/dist/shared/types/config.d.ts +10 -0
  44. package/dist/shared/types/llm.d.ts +1 -0
  45. package/dist/shared/types/tool.d.ts +3 -0
  46. package/package.json +11 -3
package/dist/core/loop.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { chatStream as llmChatStream } from '../llm/provider.js';
2
+ import { isContextOverflowFailure } from '../llm/provider-error.js';
2
3
  import { resolveRoute } from '../llm/registry.js';
3
4
  import { isLLMError } from '../llm/schema/errors.js';
4
5
  import { detectProjectInfo } from '../project/detect.js';
@@ -9,13 +10,46 @@ import { estimateTokens } from '../session/token.js';
9
10
  import { generateId } from '../shared/index.js';
10
11
  import { createAgent, runAgent } from './agent.js';
11
12
  import { createSummarizer, runCompaction } from './compact.js';
12
- import { calibrateEstimate, estimateBudget, shouldCompact } from './context.js';
13
+ import { calibrateEstimate, createTokenBudget, estimateBudget, shouldCompact } from './context.js';
13
14
  import { DEFAULT_EDIT_MODE, getToolMetrics, inferToolMode, recordToolMetrics, selectBestMode, } from './metrics.js';
14
15
  import { buildDynamicPrompt, createPromptRegistry, registerPromptSection, } from './prompt-registry.js';
15
16
  import { drainSteering } from './steering.js';
16
17
  import { executeToolCalls } from './tool-exec.js';
17
18
  import { applyPatchToParent, captureBaseline, captureDeltaPatch, createWorktree, removeWorktree, } from './worktree.js';
18
19
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
+ /** provider 未声明 contextWindow 时回填的保守下限(避免估计过大不压缩、过小过度压缩)。 */
21
+ const FALLBACK_CONTEXT_WINDOW = 32_000;
22
+ /** 每个 agent 已解析的有效 contextWindow 缓存(真实值或保守估计)。
23
+ * 避免每轮重复解析/告警,并防止估计值随每轮 input token 抖动导致预算基准漂移。 */
24
+ const effectiveContextWindowCache = new WeakMap();
25
+ /**
26
+ * 解析本轮有效 contextWindow,确保 shouldCompact 始终有合理基准:
27
+ * - provider 已声明(capabilities.contextWindow 为正数)→ 原样采用,不改变正常配置行为。
28
+ * - 缺失(provider 未注册 / 模型未知 / 未声明 contextWindow)→ 以本轮真实 input token
29
+ * 为下限回填保守估计 max(inputTokens × 2, FALLBACK_CONTEXT_WINDOW),并在首次回填时
30
+ * console.warn 告警(非致命——provider 未配置 contextWindow 不是致命错误)。
31
+ *
32
+ * 结果按 agent 缓存:首轮回填后后续轮次复用同一估计值,告警仅触发一次,
33
+ * 使 contextWindow 缺失时不再静默退化压缩阈值。
34
+ */
35
+ function resolveEffectiveContextWindow(state, raw, inputTokens, provider, model) {
36
+ // provider 已声明有效 contextWindow → 直接采用并缓存
37
+ if (typeof raw === 'number' && raw > 0) {
38
+ effectiveContextWindowCache.set(state, raw);
39
+ return raw;
40
+ }
41
+ // 已缓存(首轮已回填的估计值)→ 复用,避免重复告警与基准抖动
42
+ const cached = effectiveContextWindowCache.get(state);
43
+ if (cached !== undefined)
44
+ return cached;
45
+ // 缺失:以本轮真实 input token 作下限回填保守估计
46
+ const estimated = Math.max(inputTokens * 2, FALLBACK_CONTEXT_WINDOW);
47
+ console.warn(`[loop] provider "${provider}" / model "${model}" 未声明 contextWindow,` +
48
+ `使用保守估算值 ${estimated}(基于本轮 input token)。压缩阈值可能不准——` +
49
+ `建议在 provider 配置中声明 contextWindow。`);
50
+ effectiveContextWindowCache.set(state, estimated);
51
+ return estimated;
52
+ }
19
53
  async function waitForResume(state) {
20
54
  while (true) {
21
55
  await sleep(100);
@@ -194,19 +228,479 @@ function isParseErrorInput(input) {
194
228
  const obj = input;
195
229
  return '_parseError' in obj || '_raw' in obj;
196
230
  }
231
+ /**
232
+ * 执行会话压缩并刷新 token 预算。
233
+ *
234
+ * 自动压缩(agentLoop 阈值触发)与手动 /compact 共用此逻辑:复用
235
+ * createSummarizer + runCompaction,压缩改写消息历史后标记下一段 trigger='compaction'。
236
+ *
237
+ * 成功时 yield 一条 text_delta 通知:手动 /compact 透传给用户;自动压缩由调用方
238
+ * 静默消费。失败时抛错,由调用方决定是否记录(自动压缩非致命,仅 console.warn)。
239
+ */
240
+ export async function* compactContext(state, deps) {
241
+ // summarizer 优先用 compactionModel 覆盖,否则回退当前会话 provider/model。
242
+ const cm = state.compactionModel;
243
+ const summarizer = createSummarizer(deps.llmRegistry, cm ? cm.provider : state.config.provider, cm ? cm.model : state.config.model, { signal: state.abortController.signal });
244
+ const result = await runCompaction(deps.db, state.session.id, summarizer, {
245
+ keepRecentTokens: deps.config.compaction.keepRecentTokens,
246
+ });
247
+ // 压缩改写了消息历史 → 标记下一轮强制开新段(段边界)
248
+ state.pendingSegmentTrigger = 'compaction';
249
+ state.tokenBudget.used = estimateBudget(await getMessages(deps.db, state.session.id), state.calibrationFactor);
250
+ yield {
251
+ _tag: 'text_delta',
252
+ text: result.compacted
253
+ ? `Context compacted: ${result.compactedCount} messages summarized.`
254
+ : 'Nothing to compact yet.',
255
+ };
256
+ // 压缩成功后分发事件(spec: plugin-hooks `session:compact`):
257
+ // - session:compact hook(broadcast,插件可订阅;before=压缩条数,after=保留条数)
258
+ // - compaction_done AgentEvent(前端/UI 可观测,携带 summary/archiveId)
259
+ // 仅在实际发生压缩时触发(nothing_to_compact 无 before/after 语义)。
260
+ if (result.compacted) {
261
+ if (deps.hookRunner) {
262
+ await deps.hookRunner.fireHooks('session:compact', {
263
+ before: result.compactedCount,
264
+ after: result.keptCount,
265
+ });
266
+ }
267
+ yield {
268
+ _tag: 'compaction_done',
269
+ summary: result.summary,
270
+ archiveId: result.archiveId,
271
+ compactedCount: result.compactedCount,
272
+ keptCount: result.keptCount,
273
+ };
274
+ // 压缩成功后初始化退化监测器:监测接下来 5 轮 assistant 回复,
275
+ // 若连续产生空回复(无实质文本且无 tool_call)则发出警告(不中断循环)。
276
+ state.postCompactionMonitor = { remaining: 5, noTextStreak: 0 };
277
+ }
278
+ }
279
+ /**
280
+ * 响应式溢出恢复(reactive overflow compaction):context-overflow 时压缩
281
+ * 历史并刷新内存消息,使下一轮重建出更小的上下文。仅在 assistant 尚未开始
282
+ * 输出(无 text delta、无 tool_call)时由调用方判定后调用。
283
+ *
284
+ * 成功返回 true(已压缩、已重载 messages,调用方 `continue` 重试本轮);
285
+ * 压缩失败返回 false,由调用方回退到原始错误处理(透传 error 并停止)。
286
+ */
287
+ async function recoverFromOverflow(state, deps) {
288
+ try {
289
+ for await (const _ev of compactContext(state, deps)) {
290
+ // 静默消费压缩通知(与自动压缩一致,不透传给用户)
291
+ }
292
+ state.messages = await getMessages(deps.db, state.session.id);
293
+ return true;
294
+ }
295
+ catch (e) {
296
+ console.warn('[overflow-recovery] compaction failed:', e instanceof Error ? e.message : String(e));
297
+ return false;
298
+ }
299
+ }
300
+ /** 解析 tool_call_end 的入参:JSON.parse + 容错标记。
301
+ * 解析成功返回原值;失败返回 { _parseError, _raw } 容错标记(与协议层 finishAll 一致),
302
+ * 供 isParseErrorInput 识别——这类入参只反馈给模型重试,绝不持久化或渲染。 */
303
+ function parseToolCallArgs(finalArgs) {
304
+ try {
305
+ return JSON.parse(finalArgs);
306
+ }
307
+ catch (e) {
308
+ return {
309
+ _parseError: e instanceof Error ? e.message : String(e),
310
+ _raw: finalArgs,
311
+ };
312
+ }
313
+ }
314
+ /** 流式收集 LLM 输出:遍历 streamFn 的 chunk,分类收集 text/thinking/tool_call/usage/done,
315
+ * 实时透传 text_delta/thinking/usage/error 给 agentLoop 的消费者(通过 yield)。
316
+ *
317
+ * 内部处理响应式溢出恢复:context-overflow 且尚未开始输出(无 text、无 tool_call)
318
+ * 时压缩历史,成功则置 overflowRecovered=true 由调用方 `continue turnLoop` 重试本轮。
319
+ * 致命错误(abort / provider 抛错)置 fatalError=true 由调用方终止 loop。
320
+ *
321
+ * 注意:tool_call_start 仅登记、不发射 AgentEvent——入参尚未到达,过早发射空入参
322
+ * 会让前端渲染半成品卡。tool_call_start 由 persistAssistantAndTools 在入参解析完成后统一发射。 */
323
+ async function* collectStreamChunks(state, deps, streamFn, request, overflowCompacted) {
324
+ const chunks = [];
325
+ const text = [];
326
+ const thinking = [];
327
+ const toolCalls = new Map();
328
+ const toolCallArgs = new Map();
329
+ let usage = null;
330
+ let hadError = false;
331
+ // 非正常停止原因(length=被 max_tokens 截断, content_filter=被内容过滤)。
332
+ // 若在无 tool_call 的完成分支里仍非 null,说明回答被截断/过滤而非正常说完。
333
+ let truncated = null;
334
+ let firstTokenTime = null;
335
+ let recovered = overflowCompacted;
336
+ const finish = (over) => ({
337
+ chunks,
338
+ text,
339
+ thinking,
340
+ toolCalls,
341
+ usage,
342
+ truncated,
343
+ hadError,
344
+ firstTokenTime,
345
+ overflowCompacted: recovered,
346
+ overflowRecovered: false,
347
+ fatalError: false,
348
+ ...over,
349
+ });
350
+ try {
351
+ for await (const chunk of streamFn({
352
+ registry: deps.llmRegistry,
353
+ signal: state.abortController.signal,
354
+ }, request, { provider: state.config.provider, model: state.config.model })) {
355
+ if (firstTokenTime === null && chunk._tag !== 'done') {
356
+ firstTokenTime = Date.now();
357
+ }
358
+ chunks.push(chunk);
359
+ if (state.abortController.signal.aborted) {
360
+ yield { _tag: 'error', error: { _tag: 'aborted' } };
361
+ return finish({ fatalError: true });
362
+ }
363
+ switch (chunk._tag) {
364
+ case 'text':
365
+ text.push(chunk.text);
366
+ yield { _tag: 'text_delta', text: chunk.text };
367
+ break;
368
+ case 'tool_call_start':
369
+ // 仅登记,不立即发射 AgentEvent。此时入参尚未到达(流式 delta 累积中),
370
+ // 过早发射空入参的 tool_call_start 会让前端渲染 "Glob · " 等半成品卡,
371
+ // 且该 part 的入参之后也不会被纠正。tool_call_start 改在入参解析完成后、
372
+ // 携带真实入参时统一发射(见 persistAssistantAndTools)。
373
+ toolCalls.set(chunk.id, {
374
+ id: chunk.id,
375
+ tool: chunk.name,
376
+ input: {},
377
+ });
378
+ toolCallArgs.set(chunk.id, '');
379
+ break;
380
+ case 'tool_call_delta': {
381
+ const existing = toolCallArgs.get(chunk.id) ?? '';
382
+ toolCallArgs.set(chunk.id, existing + chunk.argumentsDelta);
383
+ break;
384
+ }
385
+ case 'tool_call_end': {
386
+ const finalArgs = chunk.argumentsFinal ?? toolCallArgs.get(chunk.id) ?? '{}';
387
+ const tc = toolCalls.get(chunk.id);
388
+ if (tc)
389
+ tc.input = parseToolCallArgs(finalArgs);
390
+ break;
391
+ }
392
+ case 'thinking':
393
+ thinking.push(chunk.text);
394
+ yield { _tag: 'thinking', text: chunk.text };
395
+ break;
396
+ case 'usage':
397
+ usage = {
398
+ inputTokens: chunk.inputTokens,
399
+ outputTokens: chunk.outputTokens,
400
+ cacheRead: chunk.cacheRead,
401
+ };
402
+ yield {
403
+ _tag: 'usage',
404
+ input: chunk.inputTokens,
405
+ output: chunk.outputTokens,
406
+ cacheRead: chunk.cacheRead,
407
+ };
408
+ break;
409
+ case 'done':
410
+ if (chunk.finishReason === 'length' || chunk.finishReason === 'content-filter') {
411
+ truncated = chunk.finishReason;
412
+ }
413
+ break;
414
+ case 'error': {
415
+ // 响应式溢出恢复:仅当尚未开始输出且未重试过时,压缩历史并重试本轮
416
+ const isOverflow = chunk.error.classification === 'context-overflow';
417
+ if (isOverflow && !recovered && text.length === 0 && toolCalls.size === 0) {
418
+ recovered = true;
419
+ if (await recoverFromOverflow(state, deps)) {
420
+ return finish({ overflowRecovered: true });
421
+ }
422
+ }
423
+ yield {
424
+ _tag: 'error',
425
+ error: {
426
+ _tag: 'provider',
427
+ message: chunk.error.message,
428
+ retryable: chunk.error.retryable ?? false,
429
+ },
430
+ };
431
+ hadError = true;
432
+ break;
433
+ }
434
+ }
435
+ }
436
+ }
437
+ catch (err) {
438
+ // 响应式溢出恢复:provider 抛出的 context-overflow 是生产环境的实际路径
439
+ // (httpPost 在非 2xx 时抛出已分类的 LLMError),与上方 error chunk 分支同理。
440
+ if (isContextOverflowFailure(err) && !recovered && text.length === 0 && toolCalls.size === 0) {
441
+ recovered = true;
442
+ if (await recoverFromOverflow(state, deps)) {
443
+ return finish({ overflowRecovered: true });
444
+ }
445
+ }
446
+ const message = err instanceof Error
447
+ ? err.message
448
+ : isLLMError(err)
449
+ ? err.message
450
+ : typeof err === 'object' && err !== null && 'message' in err
451
+ ? String(err.message)
452
+ : String(err);
453
+ yield {
454
+ _tag: 'error',
455
+ error: {
456
+ _tag: 'unexpected',
457
+ message,
458
+ },
459
+ };
460
+ state.status = {
461
+ _tag: 'stopped',
462
+ reason: 'error',
463
+ error: {
464
+ _tag: 'unexpected',
465
+ message,
466
+ },
467
+ };
468
+ return finish({ fatalError: true });
469
+ }
470
+ return finish({});
471
+ }
472
+ /** 段管理:判断段边界(pending trigger / model / systemPrompt / tools 变化),
473
+ * 构造本轮 LLMCall 并 push 到当前段,持久化 segments。直接修改 state.segments(引用语义)。
474
+ * trigger 判定用于新段元数据;needSegment 决定是否真正开新段(否则复用 activeSeg)。 */
475
+ async function manageSegment(state, deps, systemPrompt, tools, requestStartTime, totalLatency, collectedUsage, collectedText, collectedThinking, truncated, firstTokenTime, contextWindow, computedCost) {
476
+ // —— 段管理:判断是否开新段 ——
477
+ const fp = segmentFingerprint(systemPrompt, tools);
478
+ const activeSeg = state.segments[state.segments.length - 1];
479
+ const pendingTrigger = state.pendingSegmentTrigger;
480
+ let trigger;
481
+ if (pendingTrigger) {
482
+ trigger = pendingTrigger;
483
+ state.pendingSegmentTrigger = undefined;
484
+ }
485
+ else if (!activeSeg) {
486
+ trigger = 'initial';
487
+ }
488
+ else if (activeSeg.model !== state.config.model) {
489
+ trigger = 'model_change';
490
+ }
491
+ else if (activeSeg.fingerprint !== fp) {
492
+ trigger = activeSeg.systemPrompt !== systemPrompt ? 'system_prompt_change' : 'tools_change';
493
+ }
494
+ else {
495
+ trigger = 'user_confirmed'; // 占位,实际不会开段
496
+ }
497
+ const needSegment = !!pendingTrigger ||
498
+ !activeSeg ||
499
+ activeSeg.model !== state.config.model ||
500
+ activeSeg.fingerprint !== fp;
501
+ let currentSeg;
502
+ if (!needSegment && activeSeg) {
503
+ currentSeg = activeSeg;
504
+ }
505
+ else {
506
+ currentSeg = {
507
+ id: generateId(),
508
+ fingerprint: fp,
509
+ provider: state.config.provider,
510
+ model: state.config.model,
511
+ systemPrompt,
512
+ tools,
513
+ startedAt: requestStartTime,
514
+ trigger,
515
+ agentName: state.config.agentName,
516
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
517
+ calls: [],
518
+ };
519
+ state.segments.push(currentSeg);
520
+ }
521
+ // —— 段内轻量 call ——
522
+ const call = {
523
+ id: generateId(),
524
+ timestamp: requestStartTime,
525
+ usage: {
526
+ input: collectedUsage?.inputTokens ?? 0,
527
+ output: collectedUsage?.outputTokens ?? 0,
528
+ ...(collectedUsage?.cacheRead !== undefined ? { cacheRead: collectedUsage.cacheRead } : {}),
529
+ },
530
+ latency: {
531
+ firstToken: firstTokenTime ? firstTokenTime - requestStartTime : totalLatency,
532
+ total: totalLatency,
533
+ },
534
+ cost: computedCost,
535
+ ...(collectedThinking.length > 0 ? { thinking: collectedThinking.join('') } : {}),
536
+ responseText: collectedText.join(''),
537
+ ...(truncated ? { finishReason: truncated } : {}),
538
+ };
539
+ currentSeg.calls.push(call);
540
+ await saveLLMSegments(deps.db, state.session.id, state.segments);
541
+ }
542
+ /** 持久化 assistant 消息 + 执行工具调用 + 持久化 tool result + 记录 metrics,
543
+ * 并透传 tool_call_start/tool_call_end/subagent 事件。
544
+ *
545
+ * - 仅持久化/执行解析成功的调用(isParseErrorInput 过滤容错标记,避免 _raw/_parseError 落库)。
546
+ * - tool_call_start 仅在入参解析完成后、携带真实入参时发射(前端拿到的第一帧即完整入参)。
547
+ * - subagent 事件缓冲(runSubAgent → eventSink)在工具执行后 yield 出去(spec §4.5 step 7)。
548
+ * - metrics 记录为 fire-and-forget,失败绝不阻塞 agent 主流程。 */
549
+ async function* persistAssistantAndTools(state, deps, collectedText, validCalls) {
550
+ // subagent 事件缓冲:runSubAgent 通过 sink 推入事件,executeToolCalls 后 yield 出去
551
+ const subagentEvents = [];
552
+ const eventSink = (ev) => {
553
+ subagentEvents.push(ev);
554
+ };
555
+ const assistantContent = [];
556
+ if (collectedText.length > 0) {
557
+ assistantContent.push({ _tag: 'text', text: collectedText.join('') });
558
+ }
559
+ // 仅持久化解析成功的调用(携带真实入参)。解析失败的入参是容错标记,不能落库。
560
+ for (const tc of validCalls) {
561
+ assistantContent.push({
562
+ _tag: 'tool_call',
563
+ id: tc.id,
564
+ tool: tc.tool,
565
+ input: tc.input,
566
+ });
567
+ }
568
+ if (assistantContent.length > 0) {
569
+ const savedMsg = await appendMessage(deps.db, state.session.id, {
570
+ role: 'assistant',
571
+ content: assistantContent,
572
+ });
573
+ if (deps.hookRunner) {
574
+ await deps.hookRunner.fireHooks('message:after', { message: savedMsg });
575
+ }
576
+ }
577
+ // 工具调用卡只在入参解析完成后、携带真实入参时才向前端发射 tool_call_start。
578
+ // 这样前端拿到的第一帧就是可渲染的完整入参,不再出现空 pattern 半成品卡。
579
+ // 解析失败的调用不发射 start,其 tool_call_end 在前端无匹配 part 会被忽略,
580
+ // 因此解析失败在 UI 中不可见(模型会立即重试),错误仅反馈给模型并落库。
581
+ for (const tc of validCalls) {
582
+ yield { _tag: 'tool_call_start', id: tc.id, tool: tc.tool, input: tc.input };
583
+ }
584
+ // 仅执行解析成功的工具调用。解析失败的调用(isParseErrorInput 为真)对系统完全透明:
585
+ // 不执行、不持久化 tool result、不发 tool_call_start/end。其入参是 _parseError/_raw
586
+ // 容错标记,既不能执行也不能落库;若持久化为 orphan tool 消息(无对应 assistant
587
+ // tool_call),context.ts 的 sanitizeToolPairs 会在重建上下文时将其丢弃——即模型
588
+ // 永远收不到这个"错误反馈",徒增一次注定被忽略的 DB 写。故让 parse-error 对模型
589
+ // 不可见:模型下轮基于已持久化的 assistant 文本/有效 tool_call 重新生成,通常能
590
+ // 修正一次性的流截断错误。
591
+ if (validCalls.length > 0) {
592
+ const toolExecStart = Date.now();
593
+ const results = await executeToolCalls(deps.toolRegistry, deps.permission, {
594
+ cwd: deps.cwd,
595
+ session: { id: state.session.id, cwd: deps.cwd },
596
+ abort: state.abortController.signal,
597
+ ...(deps.urlRegistry ? { urlRegistry: deps.urlRegistry } : {}),
598
+ ...(deps.debugSpawn ? { debugSpawn: deps.debugSpawn } : {}),
599
+ runSubAgent: (req) => runSubAgent({ ...deps, _subagentEventSink: eventSink }, state, req),
600
+ ...(deps._subagentYieldCollector ? { collectYield: deps._subagentYieldCollector } : {}),
601
+ }, validCalls, deps.hookRunner);
602
+ const toolLatency = Date.now() - toolExecStart;
603
+ const metricsEnabled = deps.config.toolMetrics.enabled;
604
+ for (const { id, result } of results) {
605
+ yield { _tag: 'tool_call_end', id, result };
606
+ const tc = validCalls.find((c) => c.id === id);
607
+ if (tc) {
608
+ await appendMessage(deps.db, state.session.id, {
609
+ role: 'tool',
610
+ content: toolResultToContent(id, tc.tool, result),
611
+ });
612
+ // spec §16.5:记录工具执行结果供后续模式评估。
613
+ // fire-and-forget:记录失败绝不阻塞 agent 主流程。
614
+ if (metricsEnabled) {
615
+ const mode = inferToolMode(tc.tool, tc.input);
616
+ const success = result._tag === 'success' || result._tag === 'truncated';
617
+ void recordToolMetrics(deps.db, state.config.model, tc.tool, mode, success, toolLatency).catch(() => { });
618
+ }
619
+ }
620
+ }
621
+ }
622
+ // yield 在本轮工具执行中收集的 subagent 事件(subagent_start/end)
623
+ for (const ev of subagentEvents) {
624
+ yield ev;
625
+ }
626
+ }
627
+ /** 轮末压缩:turn-end 自动压缩(含死锁检测)+ mid-turn 压缩,复用 compactContext。
628
+ *
629
+ * - turn-end:shouldCompact 触发时静默压缩;压缩后仍超阈值(典型成因 keepRecentTokens
630
+ * 本身已超 historyBudget)则标记死锁暂停后续自动压缩,并发出非致命警告(不中断循环)。
631
+ * 死锁在下一轮用户输入(agentLoop 重入)时重置。
632
+ * - mid-turn:midTurnEnabled 单独 opt-in(默认关闭)时按 enabled:true 阈值静默压缩,
633
+ * 复用 shouldCompact 逻辑但不发 error/warning,压缩后刷新内存消息视图。
634
+ * 两者都静默消费 compactContext 的 text_delta 通知(不透传给用户)。 */
635
+ async function* runCompactionIfNeeded(state, deps, latestMessages) {
636
+ // 死锁时跳过自动压缩:上一轮压缩已证明无法释放足够空间,重试只会无限循环。
637
+ if (!state.compactionDeadEnd &&
638
+ shouldCompact(latestMessages, state.tokenBudget, deps.config.compaction)) {
639
+ // 自动压缩:复用 compactContext;失败非致命,仅记录警告不中断主循环。
640
+ try {
641
+ for await (const _ev of compactContext(state, deps)) {
642
+ // 静默消费压缩通知事件,不透传给用户(保持自动压缩的原有静默语义)
643
+ }
644
+ // 进度保护:compactContext 已重算 state.tokenBudget.used。若压缩后仍超阈值
645
+ // (典型成因:keepRecentTokens 本身已超 historyBudget),压缩无法再释放足够
646
+ // 空间 → 标记死锁暂停后续自动压缩,并发出非致命警告(不中断循环)。
647
+ // 死锁在下一轮用户输入(agentLoop 重入)时重置。
648
+ const postMessages = await getMessages(deps.db, state.session.id);
649
+ if (shouldCompact(postMessages, state.tokenBudget, deps.config.compaction)) {
650
+ state.compactionDeadEnd = true;
651
+ yield {
652
+ _tag: 'error',
653
+ error: {
654
+ _tag: 'unexpected',
655
+ message: 'Compaction freed too little context to make progress (keepRecentTokens may exceed the threshold). Auto-compaction paused until the next user message.',
656
+ },
657
+ };
658
+ }
659
+ }
660
+ catch (e) {
661
+ console.warn('[compaction] failed:', e instanceof Error ? e.message : String(e));
662
+ }
663
+ }
664
+ // —— 中轮压缩(mid-run compaction)——
665
+ // 单个 turn 内工具结果可能剧增 token(如长 bash 输出),在下一次 LLM 请求前
666
+ // 按 midTurnEnabled 检查阈值并静默压缩。与上方 turn-end 自动压缩独立:
667
+ // midTurnEnabled 是单独的 opt-in 闸门(默认关闭),以它为条件复用 shouldCompact
668
+ // 的阈值逻辑(传入 enabled:true 使阈值判定不受 compaction.enabled 影响),
669
+ // 便于在保留 turn-end 自动压缩行为的同时精确控制中轮压缩。
670
+ // 此时 state.tokenBudget.used 已是最新值(上方已重算;若 turn-end 压缩已触发,
671
+ // compactContext 内部也已重算),shouldCompact 只读取 budget,不依赖 messages。
672
+ if (deps.config.compaction.midTurnEnabled === true &&
673
+ !state.compactionDeadEnd &&
674
+ shouldCompact(latestMessages, state.tokenBudget, {
675
+ ...deps.config.compaction,
676
+ enabled: true,
677
+ })) {
678
+ // 中轮压缩静默执行:compactContext 内部已发 text_delta 通知,
679
+ // 不再 yield error/warning(与 turn-end 自动压缩的进度保护语义不同)。
680
+ try {
681
+ for await (const _ev of compactContext(state, deps)) {
682
+ // 静默消费压缩通知,不透传给用户
683
+ }
684
+ // 压缩改写了消息历史 → 刷新内存视图,供下一轮上下文重建使用
685
+ state.messages = await getMessages(deps.db, state.session.id);
686
+ }
687
+ catch (e) {
688
+ console.warn('[mid-turn-compaction] failed:', e instanceof Error ? e.message : String(e));
689
+ }
690
+ }
691
+ }
197
692
  export async function* agentLoop(state, deps) {
198
693
  const maxTurns = state.config.maxTurns ?? 50;
199
694
  const streamFn = deps.chatStream ?? llmChatStream;
695
+ // 响应式溢出恢复:context-overflow 时压缩后重试,最多 1 次(防无限循环)
696
+ let overflowCompacted = false;
697
+ // 新一轮用户输入:重置压缩死锁标记(新消息改变上下文,压缩或许能再次生效)
698
+ state.compactionDeadEnd = false;
200
699
  for (let turn = 0; turn < maxTurns; turn++) {
201
700
  if (state.abortController.signal.aborted) {
202
701
  yield { _tag: 'error', error: { _tag: 'aborted' } };
203
702
  return;
204
703
  }
205
- // subagent 事件缓冲:runSubAgent 通过 sink 推入事件,executeToolCalls 后 yield 出去
206
- const subagentEvents = [];
207
- const eventSink = (ev) => {
208
- subagentEvents.push(ev);
209
- };
210
704
  if (state.status._tag === 'paused') {
211
705
  state.status = { _tag: 'running', turnCount: turn };
212
706
  yield { _tag: 'status_change', status: state.status };
@@ -297,137 +791,18 @@ export async function* agentLoop(state, deps) {
297
791
  }
298
792
  request = hookResult.request;
299
793
  }
300
- // 总是收集完整 chunk 序列:既供 hookRunner 触发 provider:after,
301
- // 也是 LLMDetail.responseChunks 的来源(用于调用详情展示)。
302
- const collectedChunks = [];
303
- const collectedText = [];
304
- const collectedThinking = [];
305
- const collectedToolCalls = new Map();
306
- const toolCallArgs = new Map();
307
- let collectedUsage = null;
308
- let hadError = false;
309
- // 非正常停止原因(length=被 max_tokens 截断, content_filter=被内容过滤)。
310
- // 若在无 tool_call 的完成分支里仍非 null,说明回答被截断/过滤而非正常说完。
311
- let truncated = null;
312
794
  const requestStartTime = Date.now();
313
- let firstTokenTime = null;
314
- try {
315
- for await (const chunk of streamFn({
316
- registry: deps.llmRegistry,
317
- signal: state.abortController.signal,
318
- }, request, { provider: state.config.provider, model: state.config.model })) {
319
- if (firstTokenTime === null && chunk._tag !== 'done') {
320
- firstTokenTime = Date.now();
321
- }
322
- collectedChunks.push(chunk);
323
- if (state.abortController.signal.aborted) {
324
- yield { _tag: 'error', error: { _tag: 'aborted' } };
325
- return;
326
- }
327
- switch (chunk._tag) {
328
- case 'text':
329
- collectedText.push(chunk.text);
330
- yield { _tag: 'text_delta', text: chunk.text };
331
- break;
332
- case 'tool_call_start':
333
- // 仅登记,不立即发射 AgentEvent。此时入参尚未到达(流式 delta 累积中),
334
- // 过早发射空入参的 tool_call_start 会让前端渲染 "Glob · " 等半成品卡,
335
- // 且该 part 的入参之后也不会被纠正。tool_call_start 改在入参解析完成后、
336
- // 携带真实入参时统一发射(见本轮流结束后的处理)。
337
- collectedToolCalls.set(chunk.id, {
338
- id: chunk.id,
339
- tool: chunk.name,
340
- input: {},
341
- });
342
- toolCallArgs.set(chunk.id, '');
343
- break;
344
- case 'tool_call_delta': {
345
- const existing = toolCallArgs.get(chunk.id) ?? '';
346
- toolCallArgs.set(chunk.id, existing + chunk.argumentsDelta);
347
- break;
348
- }
349
- case 'tool_call_end': {
350
- const id = chunk.id;
351
- const finalArgs = chunk.argumentsFinal ?? toolCallArgs.get(id) ?? '{}';
352
- let parsed = {};
353
- try {
354
- parsed = JSON.parse(finalArgs);
355
- }
356
- catch (e) {
357
- // 与协议层 finishAll 的容错标记保持一致:同时带 _parseError(可读)与 _raw。
358
- parsed = {
359
- _parseError: e instanceof Error ? e.message : String(e),
360
- _raw: finalArgs,
361
- };
362
- }
363
- const tc = collectedToolCalls.get(id);
364
- if (tc)
365
- tc.input = parsed;
366
- break;
367
- }
368
- case 'thinking':
369
- collectedThinking.push(chunk.text);
370
- yield { _tag: 'thinking', text: chunk.text };
371
- break;
372
- case 'usage':
373
- collectedUsage = {
374
- inputTokens: chunk.inputTokens,
375
- outputTokens: chunk.outputTokens,
376
- cacheRead: chunk.cacheRead,
377
- };
378
- yield {
379
- _tag: 'usage',
380
- input: chunk.inputTokens,
381
- output: chunk.outputTokens,
382
- cacheRead: chunk.cacheRead,
383
- };
384
- break;
385
- case 'done':
386
- if (chunk.finishReason === 'length' || chunk.finishReason === 'content-filter') {
387
- truncated = chunk.finishReason;
388
- }
389
- break;
390
- case 'error':
391
- yield {
392
- _tag: 'error',
393
- error: {
394
- _tag: 'provider',
395
- message: chunk.error.message,
396
- retryable: chunk.error.retryable ?? false,
397
- },
398
- };
399
- hadError = true;
400
- break;
401
- }
402
- }
403
- }
404
- catch (err) {
405
- const message = err instanceof Error
406
- ? err.message
407
- : isLLMError(err)
408
- ? err.message
409
- : typeof err === 'object' && err !== null && 'message' in err
410
- ? String(err.message)
411
- : String(err);
412
- yield {
413
- _tag: 'error',
414
- error: {
415
- _tag: 'unexpected',
416
- message,
417
- },
418
- };
419
- state.status = {
420
- _tag: 'stopped',
421
- reason: 'error',
422
- error: {
423
- _tag: 'unexpected',
424
- message,
425
- },
426
- };
795
+ // —— 流式收集 LLM 输出(透传 text_delta/thinking/usage/error,内部处理溢出恢复)——
796
+ const collected = yield* collectStreamChunks(state, deps, streamFn, request, overflowCompacted);
797
+ overflowCompacted = collected.overflowCompacted;
798
+ // 溢出恢复成功 → 压缩历史后重试本轮(collectStreamChunks 已 reload messages)
799
+ if (collected.overflowRecovered)
800
+ continue;
801
+ // 致命错误(abort / provider 抛错)→ collectStreamChunks yield error 并设状态,直接终止
802
+ if (collected.fatalError)
427
803
  return;
428
- }
429
804
  if (deps.hookRunner) {
430
- await deps.hookRunner.fireHooks('provider:after', { request, chunks: collectedChunks });
805
+ await deps.hookRunner.fireHooks('provider:after', { request, chunks: collected.chunks });
431
806
  }
432
807
  const totalLatency = Date.now() - requestStartTime;
433
808
  let contextWindow;
@@ -435,8 +810,8 @@ export async function* agentLoop(state, deps) {
435
810
  try {
436
811
  const { capabilities } = resolveRoute(deps.llmRegistry, state.config.provider, state.config.model);
437
812
  contextWindow = capabilities.contextWindow;
438
- const inputTokens = collectedUsage?.inputTokens ?? 0;
439
- const outputTokens = collectedUsage?.outputTokens ?? 0;
813
+ const inputTokens = collected.usage?.inputTokens ?? 0;
814
+ const outputTokens = collected.usage?.outputTokens ?? 0;
440
815
  computedCost =
441
816
  (inputTokens / 1000) * capabilities.costPer1kInput +
442
817
  (outputTokens / 1000) * capabilities.costPer1kOutput;
@@ -444,73 +819,21 @@ export async function* agentLoop(state, deps) {
444
819
  catch {
445
820
  // provider 未注册或模型未知:保留 contextWindow=undefined、cost=0
446
821
  }
447
- // —— 段管理:判断是否开新段 ——
448
- const fp = segmentFingerprint(systemPrompt, tools);
449
- const activeSeg = state.segments[state.segments.length - 1];
450
- const pendingTrigger = state.pendingSegmentTrigger;
451
- let trigger;
452
- if (pendingTrigger) {
453
- trigger = pendingTrigger;
454
- state.pendingSegmentTrigger = undefined;
455
- }
456
- else if (!activeSeg) {
457
- trigger = 'initial';
458
- }
459
- else if (activeSeg.model !== state.config.model) {
460
- trigger = 'model_change';
461
- }
462
- else if (activeSeg.fingerprint !== fp) {
463
- trigger = activeSeg.systemPrompt !== systemPrompt ? 'system_prompt_change' : 'tools_change';
464
- }
465
- else {
466
- trigger = 'user_confirmed'; // 占位,实际不会开段
467
- }
468
- const needSegment = !!pendingTrigger ||
469
- !activeSeg ||
470
- activeSeg.model !== state.config.model ||
471
- activeSeg.fingerprint !== fp;
472
- let currentSeg;
473
- if (!needSegment && activeSeg) {
474
- currentSeg = activeSeg;
475
- }
476
- else {
477
- currentSeg = {
478
- id: generateId(),
479
- fingerprint: fp,
480
- provider: state.config.provider,
481
- model: state.config.model,
482
- systemPrompt,
483
- tools,
484
- startedAt: requestStartTime,
485
- trigger,
486
- agentName: state.config.agentName,
487
- ...(contextWindow !== undefined ? { contextWindow } : {}),
488
- calls: [],
489
- };
490
- state.segments.push(currentSeg);
491
- }
492
- // —— 段内轻量 call ——
493
- const call = {
494
- id: generateId(),
495
- timestamp: requestStartTime,
496
- usage: {
497
- input: collectedUsage?.inputTokens ?? 0,
498
- output: collectedUsage?.outputTokens ?? 0,
499
- ...(collectedUsage?.cacheRead !== undefined ? { cacheRead: collectedUsage.cacheRead } : {}),
500
- },
501
- latency: {
502
- firstToken: firstTokenTime ? firstTokenTime - requestStartTime : totalLatency,
503
- total: totalLatency,
504
- },
505
- cost: computedCost,
506
- ...(collectedThinking.length > 0 ? { thinking: collectedThinking.join('') } : {}),
507
- responseText: collectedText.join(''),
508
- ...(truncated ? { finishReason: truncated } : {}),
509
- };
510
- currentSeg.calls.push(call);
511
- await saveLLMSegments(deps.db, state.session.id, state.segments);
822
+ // —— contextWindow 兜底 + token 预算同步 ——
823
+ // provider 未声明 contextWindow 时回填保守估计(见 resolveEffectiveContextWindow),
824
+ // 并把 token 预算同步到真实/估计 contextWindow(总数变化时重算,保留 used),
825
+ // 使后续 shouldCompact 判断有合理基准,避免压缩阈值静默退化。
826
+ const effectiveWindow = resolveEffectiveContextWindow(state, contextWindow, collected.usage?.inputTokens ?? 0, state.config.provider, state.config.model);
827
+ contextWindow = effectiveWindow;
828
+ if (state.tokenBudget.total !== effectiveWindow) {
829
+ const used = state.tokenBudget.used;
830
+ state.tokenBudget = createTokenBudget(effectiveWindow);
831
+ state.tokenBudget.used = used;
832
+ }
833
+ // —— 段管理:判断段边界 + 构造 LLMCall + 持久化 ——
834
+ await manageSegment(state, deps, systemPrompt, tools, requestStartTime, totalLatency, collected.usage, collected.text, collected.thinking, collected.truncated, collected.firstTokenTime, contextWindow, computedCost);
512
835
  yield { _tag: 'llm_detail' };
513
- if (hadError) {
836
+ if (collected.hadError) {
514
837
  state.status = { _tag: 'stopped', reason: 'error' };
515
838
  return;
516
839
  }
@@ -518,87 +841,44 @@ export async function* agentLoop(state, deps) {
518
841
  // 会把单个 tool call 的 arguments 流式片段拆成多个独立 delta,每片
519
842
  // id/name 为空——这些碎片无法执行,且其空 id 在下一轮发回 provider 时
520
843
  // 触发 "invalid tool_call_id"。这里在持久化/执行前将其丢弃。
521
- const validToolCalls = Array.from(collectedToolCalls.values()).filter((c) => c.id.length > 0 && c.tool.length > 0);
844
+ const validToolCalls = Array.from(collected.toolCalls.values()).filter((c) => c.id.length > 0 && c.tool.length > 0);
522
845
  // 区分入参解析成功 / 失败的调用。解析失败(模型输出不完整 JSON,常见于流被截断)
523
- // 的调用对系统完全透明:不执行、不持久化、不发前端事件(见下方工具执行块说明),
846
+ // 的调用对系统完全透明:不执行、不持久化、不发前端事件(见 persistAssistantAndTools),
524
847
  // 仅保留解析成功的调用供执行与持久化,避免 _raw/_parseError 容错标记泄漏。
525
848
  const validCalls = validToolCalls.filter((tc) => !isParseErrorInput(tc.input));
526
- const assistantContent = [];
527
- if (collectedText.length > 0) {
528
- assistantContent.push({ _tag: 'text', text: collectedText.join('') });
529
- }
530
- // 仅持久化解析成功的调用(携带真实入参)。解析失败的入参是容错标记,不能落库。
531
- for (const tc of validCalls) {
532
- assistantContent.push({
533
- _tag: 'tool_call',
534
- id: tc.id,
535
- tool: tc.tool,
536
- input: tc.input,
537
- });
538
- }
539
- if (assistantContent.length > 0) {
540
- const savedMsg = await appendMessage(deps.db, state.session.id, {
541
- role: 'assistant',
542
- content: assistantContent,
543
- });
544
- if (deps.hookRunner) {
545
- await deps.hookRunner.fireHooks('message:after', { message: savedMsg });
849
+ // —— 压缩退化监测(spec: degradation-monitor)——
850
+ // 压缩成功后监测接下来若干轮 assistant 回复:连续产生空回复(无实质文本且
851
+ // tool_call)说明 agent 可能在"沉默退化"。仅发警告、不中断循环、不设
852
+ // hadError;remaining 耗尽即清除监测器。有 tool_call 的轮次不算退化
853
+ // (正常的工具调用轮次不是"沉默")。参考 progress guard 的非致命 error 模式。
854
+ const monitor = state.postCompactionMonitor;
855
+ if (monitor && monitor.remaining > 0) {
856
+ const replyText = collected.text.join('').trim();
857
+ const noMeaningfulOutput = replyText.length < 10 && validCalls.length === 0;
858
+ if (noMeaningfulOutput) {
859
+ monitor.noTextStreak += 1;
546
860
  }
547
- }
548
- // 工具调用卡只在入参解析完成后、携带真实入参时才向前端发射 tool_call_start。
549
- // 这样前端拿到的第一帧就是可渲染的完整入参,不再出现空 pattern 半成品卡。
550
- // 解析失败的调用不发射 start,其 tool_call_end 在前端无匹配 part 会被忽略,
551
- // 因此解析失败在 UI 中不可见(模型会立即重试),错误仅反馈给模型并落库。
552
- for (const tc of validCalls) {
553
- yield { _tag: 'tool_call_start', id: tc.id, tool: tc.tool, input: tc.input };
554
- }
555
- // 仅执行解析成功的工具调用。解析失败的调用(isParseErrorInput 为真)对系统完全透明:
556
- // 不执行、不持久化 tool result、不发 tool_call_start/end。其入参是 _parseError/_raw
557
- // 容错标记,既不能执行也不能落库;若持久化为 orphan tool 消息(无对应 assistant
558
- // tool_call),context.ts sanitizeToolPairs 会在重建上下文时将其丢弃——即模型
559
- // 永远收不到这个"错误反馈",徒增一次注定被忽略的 DB 写。故让 parse-error 对模型
560
- // 不可见:模型下轮基于已持久化的 assistant 文本/有效 tool_call 重新生成,通常能
561
- // 修正一次性的流截断错误。
562
- if (validCalls.length > 0) {
563
- const toolExecStart = Date.now();
564
- const results = await executeToolCalls(deps.toolRegistry, deps.permission, {
565
- cwd: deps.cwd,
566
- session: { id: state.session.id, cwd: deps.cwd },
567
- abort: state.abortController.signal,
568
- ...(deps.urlRegistry ? { urlRegistry: deps.urlRegistry } : {}),
569
- ...(deps.debugSpawn ? { debugSpawn: deps.debugSpawn } : {}),
570
- runSubAgent: (req) => runSubAgent({ ...deps, _subagentEventSink: eventSink }, state, req),
571
- ...(deps._subagentYieldCollector ? { collectYield: deps._subagentYieldCollector } : {}),
572
- }, validCalls, deps.hookRunner);
573
- const toolLatency = Date.now() - toolExecStart;
574
- const metricsEnabled = deps.config.toolMetrics.enabled;
575
- for (const { id, result } of results) {
576
- yield { _tag: 'tool_call_end', id, result };
577
- const tc = validCalls.find((c) => c.id === id);
578
- if (tc) {
579
- await appendMessage(deps.db, state.session.id, {
580
- role: 'tool',
581
- content: toolResultToContent(id, tc.tool, result),
582
- });
583
- // spec §16.5:记录工具执行结果供后续模式评估。
584
- // fire-and-forget:记录失败绝不阻塞 agent 主流程。
585
- if (metricsEnabled) {
586
- const mode = inferToolMode(tc.tool, tc.input);
587
- const success = result._tag === 'success' || result._tag === 'truncated';
588
- void recordToolMetrics(deps.db, state.config.model, tc.tool, mode, success, toolLatency).catch(() => { });
589
- }
590
- }
861
+ monitor.remaining -= 1;
862
+ if (monitor.noTextStreak >= 3) {
863
+ yield {
864
+ _tag: 'error',
865
+ error: {
866
+ _tag: 'unexpected',
867
+ message: 'Agent may be degraded after compaction (no meaningful output for 3 consecutive turns)',
868
+ },
869
+ };
870
+ }
871
+ if (monitor.remaining === 0) {
872
+ state.postCompactionMonitor = undefined;
591
873
  }
592
874
  }
593
- // yield 在本轮工具执行中收集的 subagent 事件(subagent_start/end)
594
- for (const ev of subagentEvents) {
595
- yield ev;
596
- }
875
+ // —— 持久化 assistant + 执行工具 + 透传 tool_call/subagent 事件 ——
876
+ yield* persistAssistantAndTools(state, deps, collected.text, validCalls);
597
877
  if (validToolCalls.length === 0) {
598
878
  // finish_reason=length/content_filter 表示响应被截断或被内容过滤,而非正常说完。
599
- // 若当作 completed,被截断的半截回答会静默成功(用户看到“中断但无报错”)。
600
- if (truncated !== null) {
601
- const message = truncated === 'length'
879
+ // 若当作 completed,被截断的半截回答会静默成功(用户看到"中断但无报错")。
880
+ if (collected.truncated !== null) {
881
+ const message = collected.truncated === 'length'
602
882
  ? 'Response truncated: the model hit max_tokens before finishing (finish_reason=length)'
603
883
  : 'Response filtered by content policy (finish_reason=content_filter)';
604
884
  yield { _tag: 'error', error: { _tag: 'unexpected', message } };
@@ -612,32 +892,16 @@ export async function* agentLoop(state, deps) {
612
892
  const latestMessages = await getMessages(deps.db, state.session.id);
613
893
  // 校准估算系数:本轮真实 input token(system prompt + 工具描述 + 消息序列全口径)
614
894
  // 反算 estimateTokens 的系统性系数,反哺 used/fitToBudget 的裁剪与压缩判断。
615
- if (collectedUsage && collectedUsage.inputTokens > 0) {
895
+ if (collected.usage && collected.usage.inputTokens > 0) {
616
896
  const estimatedRequest = estimateTokens(systemPrompt) +
617
897
  tools.reduce((sum, t) => sum + estimateTokens(JSON.stringify(t)), 0) +
618
898
  estimateTokens(JSON.stringify(chatMessages));
619
- state.calibrationFactor = calibrateEstimate(state.calibrationFactor, estimatedRequest, collectedUsage.inputTokens);
899
+ state.calibrationFactor = calibrateEstimate(state.calibrationFactor, estimatedRequest, collected.usage.inputTokens);
620
900
  }
621
901
  state.tokenBudget.used = estimateBudget(latestMessages, state.calibrationFactor);
622
902
  state.messages = latestMessages;
623
- if (shouldCompact(latestMessages, state.tokenBudget, deps.config.compaction)) {
624
- const summarizer = state.compactionModel
625
- ? createSummarizer(deps.llmRegistry, state.compactionModel.provider, state.compactionModel.model, { signal: state.abortController.signal })
626
- : createSummarizer(deps.llmRegistry, state.config.provider, state.config.model, {
627
- signal: state.abortController.signal,
628
- });
629
- try {
630
- await runCompaction(deps.db, state.session.id, summarizer, {
631
- keepRecentTokens: deps.config.compaction.keepRecentTokens,
632
- });
633
- // 压缩改写了消息历史 → 标记下一轮强制开新段(段边界)
634
- state.pendingSegmentTrigger = 'compaction';
635
- state.tokenBudget.used = estimateBudget(await getMessages(deps.db, state.session.id), state.calibrationFactor);
636
- }
637
- catch {
638
- // Compaction failure is non-fatal
639
- }
640
- }
903
+ // —— 轮末压缩(turn-end 自动压缩 + mid-turn 压缩)——
904
+ yield* runCompactionIfNeeded(state, deps, latestMessages);
641
905
  }
642
906
  yield {
643
907
  _tag: 'error',