autosnippet 2.8.2 → 2.9.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 (32) hide show
  1. package/README.md +1 -1
  2. package/dashboard/dist/assets/{icons-B_Xg4B-s.js → icons-CH-H9x0E.js} +1 -1
  3. package/dashboard/dist/assets/index-CqJRvYRL.js +197 -0
  4. package/dashboard/dist/assets/index-DICm9PNa.css +1 -0
  5. package/dashboard/dist/index.html +3 -3
  6. package/lib/cli/SetupService.js +1 -1
  7. package/lib/core/ast/ProjectGraph.js +160 -0
  8. package/lib/external/ai/providers/GoogleGeminiProvider.js +12 -3
  9. package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +1 -0
  10. package/lib/external/mcp/handlers/bootstrap.js +33 -36
  11. package/lib/external/mcp/handlers/skill.js +4 -2
  12. package/lib/external/mcp/handlers/system.js +3 -3
  13. package/lib/http/middleware/requestLogger.js +3 -3
  14. package/lib/http/routes/ai.js +17 -1
  15. package/lib/http/routes/skills.js +44 -1
  16. package/lib/infrastructure/cache/GraphCache.js +143 -0
  17. package/lib/infrastructure/database/migrations/015_create_token_usage.js +27 -0
  18. package/lib/infrastructure/realtime/RealtimeService.js +14 -2
  19. package/lib/injection/ServiceContainer.js +114 -2
  20. package/lib/repository/token/TokenUsageStore.js +162 -0
  21. package/lib/service/candidate/CandidateService.js +28 -0
  22. package/lib/service/chat/AnalystAgent.js +25 -14
  23. package/lib/service/chat/ChatAgent.js +237 -6
  24. package/lib/service/chat/ContextWindow.js +87 -3
  25. package/lib/service/chat/HandoffProtocol.js +26 -1
  26. package/lib/service/chat/ProducerAgent.js +4 -2
  27. package/lib/service/chat/tools.js +168 -71
  28. package/lib/service/skills/SignalCollector.js +3 -2
  29. package/lib/service/spm/SpmService.js +119 -18
  30. package/package.json +1 -1
  31. package/dashboard/dist/assets/index-CkIih2CC.css +0 -1
  32. package/dashboard/dist/assets/index-Duc8Qk-c.js +0 -197
@@ -19,20 +19,31 @@ import Logger from '../../infrastructure/logging/Logger.js';
19
19
  // System Prompt — Analyst 专用 (~100 tokens)
20
20
  // ──────────────────────────────────────────────────────────────────
21
21
 
22
- const ANALYST_SYSTEM_PROMPT = `你是一位高级软件架构师,正在深度分析一个真实项目。
22
+ const ANALYST_SYSTEM_PROMPT = `你是一位高级软件架构师,正在深度分析一个真实项目的某个维度。
23
23
 
24
- ## 工具使用优先级
25
- 1. **结构化查询优先** — get_project_overview → get_class_hierarchy → get_class_info
26
- 这些工具返回精确的类继承、协议实现、方法签名信息,比文本搜索更高效。
27
- 2. **文本搜索补充** — search_project_code 用于查找特定模式/关键字
28
- 3. **文件阅读验证** — read_project_file 用于确认具体实现细节
24
+ ## 执行计划
25
+ 你有 **N 轮**工具调用机会(系统会告知具体数字)。请严格按以下节奏分配:
29
26
 
30
- > ⚠️ 避免反复调用 search_project_code 搜索不同关键词。
31
- > 如果需要了解类结构,直接用 get_class_info 查询。
27
+ | 阶段 | 轮次占比 | 目标 |
28
+ |------|---------|------|
29
+ | 1. 全局扫描 | 第 1-3 轮 | get_project_overview + list_project_structure 了解项目结构 |
30
+ | 2. 结构化探索 | 第 4-N×60% 轮 | get_class_hierarchy / get_class_info 理解核心类;search_project_code 批量搜索关键模式 |
31
+ | 3. 深度验证 | 第 N×60%-N×80% 轮 | read_project_file 阅读关键实现,确认细节 |
32
+ | 4. 输出总结 | 最后 20% | **停止调用工具**,直接输出你的分析文本 |
32
33
 
34
+ ## 关键规则
35
+ - **到达 80% 轮次时必须开始写总结**,不要等系统提醒
36
+ - 每一轮都必须调用工具获取新信息,不要花轮次在纯文本思考上
37
+ - 不要重复搜索相同关键词或读取相同文件(系统会返回缓存并扣轮次)
38
+
39
+ ## 工具效率
40
+ - **批量搜索**: search_project_code({ patterns: ["keywordA", "keywordB", "keywordC"] }) — 一次搜 3-5 个
41
+ - **批量读文件**: read_project_file({ filePaths: ["a.m", "b.m", "c.m"] }) — 一次读 3-5 个
42
+ - **结构化查询优先**: get_class_hierarchy / get_class_info 比文本搜索更精确高效
43
+
44
+ ## 输出要求
33
45
  输出你的分析发现,包括具体的文件路径和代码位置。
34
- 不需要任何特定格式,用自然语言描述你的理解即可。
35
- 尽可能多地使用工具来获取准确信息,不要猜测。`;
46
+ 用自然语言描述你的理解,不需要特定格式。`;
36
47
 
37
48
  // ──────────────────────────────────────────────────────────────────
38
49
  // Analyst 可用工具白名单 — 只做探索,不做提交
@@ -61,12 +72,12 @@ const ANALYST_TOOLS = [
61
72
  // ──────────────────────────────────────────────────────────────────
62
73
 
63
74
  const ANALYST_BUDGET = {
64
- maxIterations: 12, // was 20多数维度 5-9 轮完成
65
- searchBudget: 10, // was 15探索为主,精简预算
66
- searchBudgetGrace: 6, // was 10
75
+ maxIterations: 24, // was 18大项目维度需要充足探索轮次
76
+ searchBudget: 18, // was 14匹配更大探索空间
77
+ searchBudgetGrace: 10, // was 8
67
78
  maxSubmits: 0, // Analyst 不提交候选
68
79
  softSubmitLimit: 0,
69
- idleRoundsToExit: 2, // was 3 — 减少空转
80
+ idleRoundsToExit: 2, // 减少空转
70
81
  };
71
82
 
72
83
  // ──────────────────────────────────────────────────────────────────
@@ -253,6 +253,31 @@ export class ChatAgent {
253
253
  // 附加 token 用量统计
254
254
  result.tokenUsage = { ...this.#currentTokenUsage };
255
255
 
256
+ // 持久化 token 消耗到数据库(fire-and-forget)
257
+ try {
258
+ const tokenStore = this.#container?.get?.('tokenUsageStore');
259
+ if (tokenStore) {
260
+ const aiProvider = this.#aiProvider;
261
+ tokenStore.record({
262
+ source: source || 'unknown',
263
+ dimension: dimensionId || dimensionMeta?.id || null,
264
+ provider: aiProvider?.name || null,
265
+ model: aiProvider?.model || null,
266
+ inputTokens: this.#currentTokenUsage.input,
267
+ outputTokens: this.#currentTokenUsage.output,
268
+ durationMs: Date.now() - execStartTime,
269
+ toolCalls: result.toolCalls?.length || 0,
270
+ sessionId: conversationId || null,
271
+ });
272
+
273
+ // 通知前端 token 用量变化
274
+ try {
275
+ const realtime = this.#container?.get?.('realtimeService');
276
+ realtime?.broadcastTokenUsageUpdated?.();
277
+ } catch { /* optional */ }
278
+ }
279
+ } catch { /* token logging should never break execution */ }
280
+
256
281
  return { ...result, conversationId };
257
282
  }
258
283
 
@@ -306,7 +331,13 @@ export class ChatAgent {
306
331
  const phaseRouter = (isSystem && !disablePhaseRouter) ? new PhaseRouter(budget, isSkillOnly) : null;
307
332
 
308
333
  // ── 系统提示词 (支持外部覆盖) ──
309
- const baseSystemPrompt = systemPromptOverride || this.#buildNativeToolSystemPrompt(budget);
334
+ let baseSystemPrompt = systemPromptOverride || this.#buildNativeToolSystemPrompt(budget);
335
+ // 统一注入轮次预算,确保 AI 始终知道具体数字和节奏
336
+ if (isSystem && !baseSystemPrompt.includes('轮次预算')) {
337
+ const exploreEnd = Math.floor(budget.maxIterations * 0.6);
338
+ const verifyEnd = Math.floor(budget.maxIterations * 0.8);
339
+ baseSystemPrompt += `\n\n## 轮次预算\n- 总轮次: **${budget.maxIterations} 轮**\n- 探索阶段: 第 1-${exploreEnd} 轮(搜索和结构化查询)\n- 验证阶段: 第 ${exploreEnd + 1}-${verifyEnd} 轮(读取关键文件确认细节)\n- 总结阶段: 第 ${verifyEnd + 1}-${budget.maxIterations} 轮(**停止工具调用,输出分析文本**)\n\n到达第 ${verifyEnd} 轮时你必须开始输出总结,不要继续搜索。`;
340
+ }
310
341
 
311
342
  // Bootstrap 场景限制可用工具集 (支持外部覆盖)
312
343
  const effectiveAllowedTools = allowedTools || (isSystem ? [
@@ -328,6 +359,20 @@ export class ChatAgent {
328
359
  const submittedTitles = new Set(this.#globalSubmittedTitles);
329
360
  const sharedState = {}; // P2.2: 跨工具调用共享状态(搜索计数器等)
330
361
 
362
+ // ── 进度感知收敛 (Analyst 专用: disablePhaseRouter=true) ──
363
+ // 追踪搜索/读取新信息的效率,当探索饱和时提前引导 AI 收敛
364
+ const explorationMetrics = {
365
+ uniqueFiles: new Set(), // 已读取的唯一文件
366
+ uniquePatterns: new Set(), // 已搜索的唯一 pattern
367
+ uniqueQueries: new Set(), // 已查询的唯一类名/目录(AST + list_project_structure)
368
+ totalToolCalls: 0, // 总工具调用数
369
+ newInfoRounds: 0, // 最近 N 轮中获取到新信息的轮次
370
+ staleRounds: 0, // 连续未获取新信息的轮次
371
+ convergenceNudged: false, // 是否已注入收敛 nudge
372
+ };
373
+ const MIN_EXPLORE_ITERS = 16; // 最少探索轮次(冷启动质量保障)
374
+ const STALE_THRESHOLD = 3; // 连续无新信息轮次触发收敛
375
+
331
376
  // ── 主循环 ──
332
377
  while (true) {
333
378
  // PhaseRouter tick + 退出检查
@@ -337,9 +382,53 @@ export class ChatAgent {
337
382
  this.#logger.info(`[ChatAgent] PhaseRouter exit: phase=${phaseRouter.phase}, iter=${phaseRouter.totalIterations}, submits=${phaseRouter.totalSubmits}`);
338
383
  break;
339
384
  }
340
- } else if (isSystem && iterationCount >= maxIter) {
341
- // v3: system 模式无 PhaseRouter 时用独立迭代计数器硬限制
342
- this.#logger.info(`[ChatAgent] Iteration hard cap reached: ${iterationCount}/${maxIter}`);
385
+ // PhaseRouter maxIterations 强制转入 SUMMARIZE 注入收尾 nudge
386
+ if (phaseRouter.consumeForcedSummarize()) {
387
+ const submitCount = toolCalls.filter(tc => tc.tool === 'submit_candidate' || tc.tool === 'submit_with_check').length;
388
+ ctx.appendUserNudge(
389
+ `⚠️ 轮次即将耗尽 (${phaseRouter.totalIterations}/${budget.maxIterations}),**必须立即结束**。请在回复中直接输出 dimensionDigest JSON 总结(用 \`\`\`json 包裹),不要再调用任何工具。\n` +
390
+ `\`\`\`json\n{"dimensionDigest":{"summary":"分析总结","candidateCount":${submitCount},"keyFindings":["发现"],"crossRefs":{},"gaps":["缺口"],"remainingTasks":[{"signal":"未处理信号","reason":"轮次耗尽","priority":"high","searchHints":["搜索词"]}]}}\n\`\`\`\n> remainingTasks: 列出未来得及处理的信号。已覆盖则留空 \`[]\`。`
391
+ );
392
+ this.#logger.info('[ChatAgent] 📝 injected forced SUMMARIZE nudge (maxIterations reached)');
393
+ }
394
+ } else if (isSystem && !phaseRouter && !ctx.__gracefulExitInjected && !explorationMetrics.convergenceNudged
395
+ && iterationCount >= MIN_EXPLORE_ITERS && explorationMetrics.staleRounds >= STALE_THRESHOLD) {
396
+ // ── 进度感知收敛:探索已饱和,提前引导 AI 总结 ──
397
+ this.#logger.info(
398
+ `[ChatAgent] 📊 Exploration saturated at iter ${iterationCount}/${maxIter} — ` +
399
+ `files=${explorationMetrics.uniqueFiles.size}, patterns=${explorationMetrics.uniquePatterns.size}, ` +
400
+ `queries=${explorationMetrics.uniqueQueries.size}, staleRounds=${explorationMetrics.staleRounds} — nudging convergence`
401
+ );
402
+ ctx.appendUserNudge(
403
+ `你已经充分探索了项目代码(${explorationMetrics.uniqueFiles.size} 个文件,${explorationMetrics.uniquePatterns.size} 次不同搜索,${explorationMetrics.uniqueQueries.size} 次结构化查询)。` +
404
+ `最近 ${explorationMetrics.staleRounds} 轮没有发现新信息,建议开始撰写分析总结。\n` +
405
+ `如果你确信还有重要方面未覆盖,可以继续探索(剩余 ${maxIter - iterationCount} 轮);否则请直接输出你的分析发现。`
406
+ );
407
+ explorationMetrics.convergenceNudged = true;
408
+ // 不 break,不设 toolChoice=none — 这是软 nudge,AI 仍可继续探索
409
+ } else if (isSystem && !phaseRouter && !ctx.__budgetWarningInjected
410
+ && iterationCount >= Math.floor(maxIter * 0.75)) {
411
+ // ── 预算感知提醒:75% 预算消耗时轻量提示 ──
412
+ ctx.appendUserNudge(
413
+ `📌 进度提醒:你已使用 ${iterationCount}/${maxIter} 轮次(${Math.round(iterationCount / maxIter * 100)}%)。` +
414
+ `请确保核心方面已覆盖,开始准备总结。剩余 ${maxIter - iterationCount} 轮,优先填补最重要的分析空白。`
415
+ );
416
+ ctx.__budgetWarningInjected = true;
417
+ this.#logger.info(`[ChatAgent] 📌 Budget warning at ${iterationCount}/${maxIter} (${Math.round(iterationCount / maxIter * 100)}%)`);
418
+ } else if (isSystem && iterationCount >= maxIter && !ctx.__gracefulExitInjected) {
419
+ // 达到上限 → 注入收尾消息让 AI 快速总结(而非硬中断)
420
+ this.#logger.info(`[ChatAgent] Iteration cap reached (${iterationCount}/${maxIter}) — injecting graceful exit nudge`);
421
+ const submitCount = toolCalls.filter(tc => tc.tool === 'submit_candidate' || tc.tool === 'submit_with_check').length;
422
+ ctx.appendUserNudge(
423
+ `⚠️ 你已使用 ${iterationCount}/${maxIter} 轮次,**必须立即结束**。请在回复中直接输出 dimensionDigest JSON 总结(用 \`\`\`json 包裹),不要再调用任何工具。\n` +
424
+ `\`\`\`json\n{"dimensionDigest":{"summary":"分析总结","candidateCount":${submitCount},"keyFindings":["发现"],"crossRefs":{},"gaps":["缺口"],"remainingTasks":[{"signal":"未处理信号","reason":"轮次耗尽","priority":"high","searchHints":["搜索词"]}]}}\n\`\`\`\n> remainingTasks: 列出未来得及处理的信号。已覆盖则留空 \`[]\`。`
425
+ );
426
+ ctx.__gracefulExitInjected = true;
427
+ // 不 break,给 AI 2 轮 grace 来产出总结
428
+ // 继续 while 循环
429
+ } else if (isSystem && iterationCount >= maxIter + 2) {
430
+ // 硬上限兜底:grace 轮次也耗尽
431
+ this.#logger.info(`[ChatAgent] Hard cap reached: ${iterationCount}/${maxIter + 2}`);
343
432
  break;
344
433
  } else if (!isSystem && ctx.length > maxIter * 2 + 2) {
345
434
  // 用户对话模式: 简单的消息数限制
@@ -352,7 +441,10 @@ export class ChatAgent {
352
441
 
353
442
  // ── 动态 toolChoice (由 PhaseRouter 决定) ──
354
443
  let currentChoice;
355
- if (phaseRouter) {
444
+ if (ctx.__gracefulExitInjected) {
445
+ // graceful exit: 禁止工具调用,强制 AI 输出文本总结
446
+ currentChoice = 'none';
447
+ } else if (phaseRouter) {
356
448
  currentChoice = phaseRouter.getToolChoice();
357
449
  } else {
358
450
  currentChoice = 'auto';
@@ -371,6 +463,19 @@ export class ChatAgent {
371
463
  if (hint) {
372
464
  systemPrompt += `\n\n## 当前状态\n${hint}`;
373
465
  }
466
+ } else if (isSystem) {
467
+ // 非 PhaseRouter 路径:注入实时轮次计数器,让 AI 始终感知进度
468
+ const verifyStart = Math.floor(maxIter * 0.8);
469
+ const remaining = maxIter - iterationCount;
470
+ let phaseLabel;
471
+ if (iterationCount <= Math.floor(maxIter * 0.6)) {
472
+ phaseLabel = '探索阶段';
473
+ } else if (iterationCount <= verifyStart) {
474
+ phaseLabel = '验证阶段';
475
+ } else {
476
+ phaseLabel = '⚠ 总结阶段 — 请停止工具调用,直接输出分析文本';
477
+ }
478
+ systemPrompt += `\n\n## 当前进度\n第 ${iterationCount}/${maxIter} 轮 | ${phaseLabel} | 剩余 ${remaining} 轮`;
374
479
  }
375
480
 
376
481
  // ── AI 调用 ──
@@ -438,6 +543,20 @@ export class ChatAgent {
438
543
 
439
544
  // ── 处理 functionCalls ──
440
545
  if (aiResult.functionCalls && aiResult.functionCalls.length > 0) {
546
+ // ── Graceful exit 保护: Gemini 有时会无视 toolChoice='none' 继续返回工具调用
547
+ // 强制忽略工具调用,将附带文本视为最终回复
548
+ if (ctx.__gracefulExitInjected) {
549
+ this.#logger.warn(`[ChatAgent] ⚠ AI returned ${aiResult.functionCalls.length} tool calls despite toolChoice=none (graceful exit) — ignoring tools, treating as text`);
550
+ if (aiResult.text) {
551
+ const reply = this.#cleanFinalAnswer(aiResult.text);
552
+ const totalDuration = Date.now() - execStartTime;
553
+ this.#logger.info(`[ChatAgent] ✅ final answer (graceful exit, forced) — ${reply.length} chars, ${toolCalls.length} tool calls, ${totalDuration}ms`);
554
+ return { reply, toolCalls, hasContext: toolCalls.length > 0 };
555
+ }
556
+ // 无文本时继续循环,下一轮硬上限兜底
557
+ continue;
558
+ }
559
+
441
560
  // 限制单次工具调用数量(防上下文溢出)
442
561
  const MAX_TOOL_CALLS_PER_ITER = 8;
443
562
  let activeCalls = aiResult.functionCalls;
@@ -450,6 +569,7 @@ export class ChatAgent {
450
569
  ctx.appendAssistantWithToolCalls(aiResult.text || null, activeCalls);
451
570
 
452
571
  let roundSubmitCount = 0;
572
+ let roundHasNewInfo = false; // 本轮是否获取到新信息
453
573
 
454
574
  for (const fc of activeCalls) {
455
575
  const toolStartTime = Date.now();
@@ -474,6 +594,99 @@ export class ChatAgent {
474
594
  const summarized = this.#summarizeResult(toolResult);
475
595
  toolCalls.push({ tool: fc.name, params: fc.args, result: summarized });
476
596
 
597
+ // ── 探索进度追踪 (非 PhaseRouter 路径) ──
598
+ if (!phaseRouter && isSystem) {
599
+ explorationMetrics.totalToolCalls++;
600
+ let foundNewInfo = false;
601
+
602
+ if (fc.name === 'search_project_code') {
603
+ const pattern = fc.args?.pattern || '';
604
+ const patterns = fc.args?.patterns || [];
605
+ // 单模式
606
+ if (pattern && !explorationMetrics.uniquePatterns.has(pattern)) {
607
+ explorationMetrics.uniquePatterns.add(pattern);
608
+ foundNewInfo = true;
609
+ }
610
+ // 批量模式
611
+ for (const p of patterns) {
612
+ if (!explorationMetrics.uniquePatterns.has(p)) {
613
+ explorationMetrics.uniquePatterns.add(p);
614
+ foundNewInfo = true;
615
+ }
616
+ }
617
+ // 检查搜索结果是否有新文件
618
+ if (toolResult && typeof toolResult === 'object') {
619
+ const matches = toolResult.matches || [];
620
+ const batchResults = toolResult.batchResults || {};
621
+ for (const m of matches) {
622
+ if (m.file && !explorationMetrics.uniqueFiles.has(m.file)) {
623
+ explorationMetrics.uniqueFiles.add(m.file);
624
+ foundNewInfo = true;
625
+ }
626
+ }
627
+ for (const sub of Object.values(batchResults)) {
628
+ for (const m of (sub.matches || [])) {
629
+ if (m.file && !explorationMetrics.uniqueFiles.has(m.file)) {
630
+ explorationMetrics.uniqueFiles.add(m.file);
631
+ foundNewInfo = true;
632
+ }
633
+ }
634
+ }
635
+ }
636
+ } else if (fc.name === 'read_project_file') {
637
+ const fp = fc.args?.filePath || '';
638
+ const fps = fc.args?.filePaths || [];
639
+ if (fp && !explorationMetrics.uniqueFiles.has(fp)) {
640
+ explorationMetrics.uniqueFiles.add(fp);
641
+ foundNewInfo = true;
642
+ }
643
+ for (const f of fps) {
644
+ if (!explorationMetrics.uniqueFiles.has(f)) {
645
+ explorationMetrics.uniqueFiles.add(f);
646
+ foundNewInfo = true;
647
+ }
648
+ }
649
+ } else if (fc.name === 'list_project_structure') {
650
+ // 目录结构:同一目录只算一次新信息
651
+ const dir = fc.args?.directory || '/';
652
+ const qKey = `list:${dir}`;
653
+ if (!explorationMetrics.uniqueQueries.has(qKey)) {
654
+ explorationMetrics.uniqueQueries.add(qKey);
655
+ foundNewInfo = true;
656
+ }
657
+ } else if (fc.name === 'get_class_info' || fc.name === 'get_class_hierarchy'
658
+ || fc.name === 'get_protocol_info' || fc.name === 'get_method_overrides'
659
+ || fc.name === 'get_category_map') {
660
+ // AST 结构化查询:同一类名/协议名只算一次新信息
661
+ const queryTarget = fc.args?.className || fc.args?.protocolName || fc.args?.name || '';
662
+ const qKey = `${fc.name}:${queryTarget}`;
663
+ if (!explorationMetrics.uniqueQueries.has(qKey)) {
664
+ explorationMetrics.uniqueQueries.add(qKey);
665
+ foundNewInfo = true;
666
+ }
667
+ } else if (fc.name === 'get_project_overview') {
668
+ // 项目概览只需调一次
669
+ const qKey = 'overview';
670
+ if (!explorationMetrics.uniqueQueries.has(qKey)) {
671
+ explorationMetrics.uniqueQueries.add(qKey);
672
+ foundNewInfo = true;
673
+ }
674
+ } else if (fc.name !== 'submit_candidate' && fc.name !== 'submit_with_check') {
675
+ // 其他未分类工具 — 首次算新信息,之后同工具名+同参数去重
676
+ const qKey = `${fc.name}:${JSON.stringify(fc.args || {}).substring(0, 80)}`;
677
+ if (!explorationMetrics.uniqueQueries.has(qKey)) {
678
+ explorationMetrics.uniqueQueries.add(qKey);
679
+ foundNewInfo = true;
680
+ }
681
+ }
682
+
683
+ if (foundNewInfo) {
684
+ explorationMetrics.staleRounds = 0;
685
+ explorationMetrics.newInfoRounds++;
686
+ roundHasNewInfo = true;
687
+ }
688
+ }
689
+
477
690
  // ── Layer 3: ToolResultLimiter — 动态配额压缩 ──
478
691
  const quota = ctx.getToolResultQuota();
479
692
  let resultStr = limitToolResult(fc.name, toolResult, quota);
@@ -503,6 +716,11 @@ export class ChatAgent {
503
716
  ctx.appendToolResult(fc.id, fc.name, resultStr);
504
717
  }
505
718
 
719
+ // ── 探索饱和度更新 (非 PhaseRouter 路径) ──
720
+ if (!phaseRouter && isSystem && !roundHasNewInfo) {
721
+ explorationMetrics.staleRounds++;
722
+ }
723
+
506
724
  // ── PhaseRouter 更新 ──
507
725
  if (phaseRouter) {
508
726
  const transition = phaseRouter.update({
@@ -602,7 +820,15 @@ export class ChatAgent {
602
820
  continue;
603
821
  }
604
822
 
605
- // 用户对话: 文字回答即最终回答
823
+ // 用户对话 / graceful exit: 文字回答即最终回答
824
+ if (ctx.__gracefulExitInjected || !isSystem) {
825
+ const reply = this.#cleanFinalAnswer(aiResult.text || '');
826
+ const totalDuration = Date.now() - execStartTime;
827
+ this.#logger.info(`[ChatAgent] ✅ final answer (${ctx.__gracefulExitInjected ? 'graceful exit' : 'user'}) — ${reply.length} chars, ${toolCalls.length} tool calls, ${totalDuration}ms`);
828
+ return { reply, toolCalls, hasContext: toolCalls.length > 0 };
829
+ }
830
+
831
+ // system 模式非 graceful exit 的文字回答(理论上不应到这里)
606
832
  const reply = this.#cleanFinalAnswer(aiResult.text || '');
607
833
  const totalDuration = Date.now() - execStartTime;
608
834
  this.#logger.info(`[ChatAgent] ✅ final answer — ${reply.length} chars, ${toolCalls.length} tool calls, ${totalDuration}ms`);
@@ -1392,6 +1618,11 @@ ${this.#projectBriefingCache}
1392
1618
  4. **语义发现** → semantic_search_code 在知识库查找相关知识
1393
1619
  5. **知识产出** → submit_candidate 提交有价值的发现
1394
1620
 
1621
+ ## 高效使用工具(节省轮次)
1622
+ - **批量搜索**: search_project_code({ patterns: ["keywordA", "keywordB", "keywordC"] })
1623
+ - **批量读文件**: read_project_file({ filePaths: ["path/a.m", "path/b.m"] })
1624
+ - 合并同类请求为一次调用,避免逐个搜索/读取浪费轮次。
1625
+
1395
1626
  ## 「项目特写」= 基本用法 + 项目特征融合
1396
1627
  submit_candidate 的 code 字段必须是「项目特写」— 将技术的基本用法与本项目的特征融合为一体:
1397
1628
  1. **项目选择了什么**: 采用了哪种写法/模式/约定
@@ -399,13 +399,27 @@ export function limitToolResult(toolName, result, quota) {
399
399
  return raw.length > 500 ? raw.substring(0, 500) : raw;
400
400
  }
401
401
 
402
- // search_project_code: 限制匹配数 + 截断上下文
402
+ // search_project_code: 限制匹配数 + 截断上下文(支持批量模式)
403
403
  if (toolName === 'search_project_code') {
404
+ if (result && typeof result === 'object' && result.batchResults) {
405
+ // 批量模式:对每个 pattern 的结果独立限制(直接操作对象,避免 stringify→parse 往返)
406
+ const limited = { ...result };
407
+ const perKeyChars = Math.floor(maxChars / Object.keys(limited.batchResults).length);
408
+ for (const [key, sub] of Object.entries(limited.batchResults)) {
409
+ limited.batchResults[key] = limitSearchResultObj(sub, Math.min(maxMatches, 3), perKeyChars);
410
+ }
411
+ const raw = JSON.stringify(limited);
412
+ return raw.length > maxChars ? raw.substring(0, maxChars) + '\n... [batch truncated]' : raw;
413
+ }
404
414
  return limitSearchResult(result, maxMatches, maxChars);
405
415
  }
406
416
 
407
- // read_project_file: 限制字符数
417
+ // read_project_file: 限制字符数(支持批量模式)
408
418
  if (toolName === 'read_project_file') {
419
+ if (result && typeof result === 'object' && result.batchResults) {
420
+ const raw = JSON.stringify(result);
421
+ return raw.length > maxChars ? raw.substring(0, maxChars) + '\n... [batch truncated]' : raw;
422
+ }
409
423
  return limitFileContent(result, maxChars);
410
424
  }
411
425
 
@@ -465,6 +479,41 @@ function limitSearchResult(result, maxMatches, maxChars) {
465
479
  return str;
466
480
  }
467
481
 
482
+ /**
483
+ * 限制搜索结果(返回对象) — 用于批量模式,避免 JSON.stringify → JSON.parse 往返
484
+ * 当源码含控制字符时,stringify→substring 截断会破坏 JSON 结构导致 parse 失败
485
+ */
486
+ function limitSearchResultObj(result, maxMatches, maxChars) {
487
+ if (!result || typeof result !== 'object') return result || {};
488
+ if (typeof result === 'string') return { _raw: result.substring(0, maxChars) };
489
+
490
+ const limited = { ...result };
491
+ if (Array.isArray(limited.matches)) {
492
+ limited.matches = limited.matches.slice(0, maxMatches).map(m => {
493
+ const copy = { ...m };
494
+ if (copy.context && typeof copy.context === 'string') {
495
+ const contextLines = copy.context.split('\n');
496
+ if (contextLines.length > 7) {
497
+ copy.context = contextLines.slice(0, 7).join('\n') + '\n... [truncated]';
498
+ }
499
+ // 按字符上限截断 context(防止单个代码块过大)
500
+ if (copy.context.length > 500) {
501
+ copy.context = copy.context.substring(0, 500) + '\n... [truncated]';
502
+ }
503
+ }
504
+ if (Array.isArray(copy.lines) && copy.lines.length > 5) {
505
+ copy.lines = copy.lines.slice(0, 5);
506
+ copy._truncated = true;
507
+ }
508
+ return copy;
509
+ });
510
+ if (result.matches.length > maxMatches) {
511
+ limited._note = `Showing ${maxMatches} of ${result.matches.length} matches`;
512
+ }
513
+ }
514
+ return limited;
515
+ }
516
+
468
517
  /**
469
518
  * 限制文件内容 — 截断 content 字段
470
519
  */
@@ -526,6 +575,9 @@ export class PhaseRouter {
526
575
  /** @type {Object} 日志器 */
527
576
  #logger;
528
577
 
578
+ /** @type {boolean} 是否因 maxIterations 强制进入 SUMMARIZE */
579
+ #forcedSummarize = false;
580
+
529
581
  /**
530
582
  * @param {Object} budget — 预算配置
531
583
  * @param {boolean} isSkillOnly — 是否为 skill-only 维度
@@ -561,6 +613,19 @@ export class PhaseRouter {
561
613
  return this.#totalSubmits;
562
614
  }
563
615
 
616
+ /**
617
+ * 是否因 maxIterations 触顶而强制进入 SUMMARIZE(一次性标记)
618
+ * 调用后自动复位
619
+ * @returns {boolean}
620
+ */
621
+ consumeForcedSummarize() {
622
+ if (this.#forcedSummarize) {
623
+ this.#forcedSummarize = false;
624
+ return true;
625
+ }
626
+ return false;
627
+ }
628
+
564
629
  /**
565
630
  * 获取当前阶段的 toolChoice
566
631
  * @returns {'required'|'auto'|'none'}
@@ -585,8 +650,21 @@ export class PhaseRouter {
585
650
  * @returns {boolean}
586
651
  */
587
652
  shouldExit() {
588
- if (this.#totalIterations >= this.#budget.maxIterations) return true;
653
+ // 已在 SUMMARIZE 阶段 给 2 轮输出总结后退出
589
654
  if (this.#phase === 'SUMMARIZE' && this.#phaseRounds >= 2) return true;
655
+
656
+ // 达到 maxIterations → 不硬退出,而是强制转入 SUMMARIZE 让 AI 在完整上下文中收尾
657
+ if (this.#totalIterations >= this.#budget.maxIterations && this.#phase !== 'SUMMARIZE') {
658
+ this.#logger.info(`[PhaseRouter] maxIterations reached (${this.#totalIterations}/${this.#budget.maxIterations}), forcing → SUMMARIZE for graceful exit`);
659
+ this.#forcedSummarize = true;
660
+ this.#transitionTo('SUMMARIZE');
661
+ // 返回 false 让主循环继续运行 SUMMARIZE 阶段的 2 轮收尾
662
+ return false;
663
+ }
664
+
665
+ // SUMMARIZE 阶段超限兜底(maxIterations + 2 轮 grace)
666
+ if (this.#totalIterations >= this.#budget.maxIterations + 2) return true;
667
+
590
668
  return false;
591
669
  }
592
670
 
@@ -688,6 +766,12 @@ export class PhaseRouter {
688
766
  * @returns {string|null}
689
767
  */
690
768
  getPhaseHint() {
769
+ // 接近 maxIterations 上限时,无论处于哪个阶段都警告 AI
770
+ const remaining = this.#budget.maxIterations - this.#totalIterations;
771
+ if (remaining <= 2 && remaining > 0 && this.#phase !== 'SUMMARIZE') {
772
+ return `⚠️ 仅剩 ${remaining} 轮次即达上限,请尽快完成当前工作并准备输出总结。`;
773
+ }
774
+
691
775
  switch (this.#phase) {
692
776
  case 'EXPLORE':
693
777
  if (this.#phaseRounds >= this.#budget.searchBudget - 2) {
@@ -13,6 +13,31 @@
13
13
  // AnalysisReport 构建
14
14
  // ──────────────────────────────────────────────────────────────────
15
15
 
16
+ /**
17
+ * 清理 Analyst 分析文本中可能泄漏的系统 nudge / graceful exit 指令。
18
+ * 这些内容如果传给 Producer,会干扰其正常工作流。
19
+ */
20
+ function sanitizeAnalysisText(text) {
21
+ if (!text) return '';
22
+ // 移除 graceful exit nudge 及 digest 模板指令
23
+ const patterns = [
24
+ /\*{0,2}⚠️?\s*(?:你已使用|轮次即将耗尽|仅剩|请立即停止|必须立即结束)[^\n]*\n?/gi,
25
+ /\*{0,2}请立即停止所有工具调用[^\n]*\*{0,2}\n?/gi,
26
+ /请在回复中直接输出\s*dimensionDigest\s*JSON[^\n]*\n?/gi,
27
+ /> ?(?:remainingTasks|如果所有信号都已覆盖)[^\n]*\n?/gi,
28
+ /> ?⚠️ 严禁输出任何非 JSON 内容[^\n]*\n?/gi,
29
+ // 移除 AI 回显的 dimensionDigest JSON 块(对 Producer 无价值且会干扰)
30
+ /```json\s*\n\s*\{\s*"dimensionDigest"\s*:[\s\S]*?\n```/g,
31
+ ];
32
+ let cleaned = text;
33
+ for (const pat of patterns) {
34
+ cleaned = cleaned.replace(pat, '');
35
+ }
36
+ // 移除可能残留的空行堆积
37
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim();
38
+ return cleaned;
39
+ }
40
+
16
41
  /**
17
42
  * 从 Analyst 的执行结果构建 AnalysisReport
18
43
  *
@@ -73,7 +98,7 @@ export function buildAnalysisReport(analystResult, dimensionId, projectGraph = n
73
98
  }
74
99
 
75
100
  // 从分析文本中提取文件路径
76
- const text = analystResult.reply || '';
101
+ const text = sanitizeAnalysisText(analystResult.reply || '');
77
102
  const textFileRefs = text.match(/[\w/.-]+\.[mhswift]+/g);
78
103
  if (textFileRefs) {
79
104
  for (const f of textFileRefs) {
@@ -31,12 +31,14 @@ const PRODUCER_SYSTEM_PROMPT = `你是知识管理专家。你会收到一段代
31
31
 
32
32
  工作流程:
33
33
  1. 阅读分析文本,识别每个独立的知识点/发现
34
- 2. 对每个知识点,用 read_project_file 获取关键代码片段(读取 30-80 行,不要只读 5 行头部)
34
+ 2. read_project_file 批量获取关键代码片段:
35
+ read_project_file({ filePaths: ["FileA.m", "FileB.m"], maxLines: 80 })
35
36
  3. 立刻调用 submit_candidate 提交
36
37
  4. 重复直到分析中的所有知识点都已提交
37
38
 
38
39
  关键规则:
39
40
  - 分析中的每个要点/段落都应转化为至少一个候选
41
+ - read_project_file 支持 filePaths 数组批量读取多个文件,一次调用完成
40
42
  - read_project_file 时读取足够多的行数(startLine + maxLines 至少 30 行)
41
43
  - reasoning.sources 必须是非空数组,填写相关文件路径如 ["FileName.m"]
42
44
  - 如果分析提到了 3 个模式,就应该提交 3 个候选,不要合并
@@ -63,7 +65,7 @@ const PRODUCER_TOOLS = [
63
65
  // ──────────────────────────────────────────────────────────────────
64
66
 
65
67
  const PRODUCER_BUDGET = {
66
- maxIterations: 15,
68
+ maxIterations: 24, // was 18 — 与 Analyst 统一
67
69
  searchBudget: 4,
68
70
  searchBudgetGrace: 3,
69
71
  maxSubmits: 10,