autosnippet 2.6.0 → 2.7.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 (66) hide show
  1. package/bin/cli.js +1 -1
  2. package/dashboard/dist/assets/{icons-rnn04CvH.js → icons-Cq4-iQhP.js} +148 -88
  3. package/dashboard/dist/assets/index-DBxH7pVn.css +1 -0
  4. package/dashboard/dist/assets/index-Dw2F6qAS.js +197 -0
  5. package/dashboard/dist/assets/{react-markdown-CWxUbOf4.js → react-markdown-BA6FB2NP.js} +1 -1
  6. package/dashboard/dist/assets/{syntax-highlighter-CJ2drQQb.js → syntax-highlighter-CVLHn9O5.js} +1 -1
  7. package/dashboard/dist/assets/{vendor-f83ah6cm.js → vendor-BotF760a.js} +61 -61
  8. package/dashboard/dist/index.html +6 -6
  9. package/lib/bootstrap.js +1 -1
  10. package/lib/cli/SetupService.js +33 -8
  11. package/lib/cli/UpgradeService.js +139 -2
  12. package/lib/core/ast/ProjectGraph.js +599 -0
  13. package/lib/core/gateway/GatewayActionRegistry.js +2 -2
  14. package/lib/domain/recipe/Recipe.js +3 -0
  15. package/lib/external/ai/AiProvider.js +83 -20
  16. package/lib/external/ai/providers/ClaudeProvider.js +197 -0
  17. package/lib/external/ai/providers/GoogleGeminiProvider.js +235 -1
  18. package/lib/external/ai/providers/OpenAiProvider.js +131 -0
  19. package/lib/external/mcp/handlers/bootstrap/pipeline/dimension-context.js +216 -0
  20. package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +468 -0
  21. package/lib/external/mcp/handlers/bootstrap/pipeline/tier-scheduler.js +162 -0
  22. package/lib/external/mcp/handlers/bootstrap/skills.js +225 -0
  23. package/lib/external/mcp/handlers/bootstrap.js +151 -1634
  24. package/lib/external/mcp/handlers/browse.js +1 -1
  25. package/lib/external/mcp/handlers/candidate.js +1 -33
  26. package/lib/external/mcp/handlers/skill.js +54 -17
  27. package/lib/external/mcp/tools.js +4 -3
  28. package/lib/http/middleware/requestLogger.js +23 -4
  29. package/lib/http/routes/ai.js +3 -1
  30. package/lib/http/routes/auth.js +3 -2
  31. package/lib/http/routes/candidates.js +49 -25
  32. package/lib/http/routes/commands.js +0 -8
  33. package/lib/http/routes/guardRules.js +1 -16
  34. package/lib/http/routes/recipes.js +4 -17
  35. package/lib/http/routes/search.js +11 -19
  36. package/lib/http/routes/skills.js +2 -0
  37. package/lib/http/routes/snippets.js +0 -33
  38. package/lib/http/routes/spm.js +37 -63
  39. package/lib/http/utils/routeHelpers.js +31 -0
  40. package/lib/infrastructure/config/Paths.js +9 -0
  41. package/lib/infrastructure/logging/Logger.js +86 -3
  42. package/lib/infrastructure/realtime/RealtimeService.js +2 -5
  43. package/lib/infrastructure/vector/JsonVectorAdapter.js +24 -1
  44. package/lib/injection/ServiceContainer.js +55 -2
  45. package/lib/service/bootstrap/BootstrapTaskManager.js +400 -0
  46. package/lib/service/candidate/CandidateFileWriter.js +68 -27
  47. package/lib/service/candidate/CandidateService.js +156 -10
  48. package/lib/service/chat/AnalystAgent.js +216 -0
  49. package/lib/service/chat/CandidateGuardrail.js +134 -0
  50. package/lib/service/chat/ChatAgent.js +1036 -167
  51. package/lib/service/chat/ContextWindow.js +730 -0
  52. package/lib/service/chat/HandoffProtocol.js +180 -0
  53. package/lib/service/chat/ProducerAgent.js +240 -0
  54. package/lib/service/chat/ToolRegistry.js +149 -5
  55. package/lib/service/chat/tools.js +1397 -61
  56. package/lib/service/recipe/RecipeFileWriter.js +12 -1
  57. package/lib/service/skills/SignalCollector.js +31 -6
  58. package/lib/service/skills/SkillAdvisor.js +2 -1
  59. package/lib/service/skills/SkillHooks.js +13 -5
  60. package/lib/service/spm/SpmService.js +2 -2
  61. package/package.json +1 -1
  62. package/templates/copilot-instructions.md +20 -3
  63. package/templates/cursor-rules/autosnippet-conventions.mdc +21 -4
  64. package/templates/cursor-rules/autosnippet-skills.mdc +45 -0
  65. package/dashboard/dist/assets/index-BBKa3Dgi.js +0 -195
  66. package/dashboard/dist/assets/index-DLsECfzW.css +0 -1
@@ -29,12 +29,85 @@ import Logger from '../../infrastructure/logging/Logger.js';
29
29
  import { TaskPipeline } from './TaskPipeline.js';
30
30
  import { Memory } from './Memory.js';
31
31
  import { ConversationStore } from './ConversationStore.js';
32
+ import { ContextWindow, PhaseRouter, limitToolResult } from './ContextWindow.js';
32
33
 
33
34
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
35
  const PROJECT_ROOT = path.resolve(__dirname, '../../..');
35
36
  const SKILLS_DIR = path.resolve(PROJECT_ROOT, 'skills');
36
37
  const SOUL_PATH = path.resolve(PROJECT_ROOT, 'SOUL.md');
37
38
  const MAX_ITERATIONS = 6;
39
+ /** 系统调用 (如 bootstrap) 允许更多迭代,因为每维度需要多次 submit_candidate */
40
+ const MAX_ITERATIONS_SYSTEM = 30;
41
+ /** 原生函数调用模式下,已提交 ≥ MIN_SUBMITS_FOR_EARLY_EXIT 个候选后,连续 N 轮无新提交则提前退出 */
42
+ const MIN_SUBMITS_FOR_EARLY_EXIT = 1;
43
+ const IDLE_ROUNDS_TO_EXIT = 2;
44
+ /** 单个维度最多提交候选数量 — 超过后跳过提交返回提醒 */
45
+ const MAX_SUBMITS_PER_DIMENSION = 6;
46
+ /** 提交达到软上限后注入收尾提示的阈值 */
47
+ const SOFT_SUBMIT_LIMIT = 4;
48
+ /** 连续搜索/阅读轮次预算 — 超过后注入提交提示并切 auto */
49
+ const SEARCH_BUDGET = 8;
50
+ /** 搜索预算耗尽后,额外容忍的轮次 — 再未提交则强制退出 */
51
+ const SEARCH_BUDGET_GRACE = 4;
52
+
53
+ /** 默认预算配置 — 可通过 execute() 的 opts.budget 覆盖 */
54
+ const DEFAULT_BUDGET = Object.freeze({
55
+ maxIterations: MAX_ITERATIONS_SYSTEM,
56
+ searchBudget: SEARCH_BUDGET,
57
+ searchBudgetGrace: SEARCH_BUDGET_GRACE,
58
+ maxSubmits: MAX_SUBMITS_PER_DIMENSION,
59
+ softSubmitLimit: SOFT_SUBMIT_LIMIT,
60
+ idleRoundsToExit: IDLE_ROUNDS_TO_EXIT,
61
+ });
62
+
63
+ /**
64
+ * 系统调用续跑提示 — 当 AI 输出纯文本计划而未执行工具调用时注入
65
+ * 告诉 AI 不要只写文字描述,而要实际调用工具
66
+ */
67
+ const SYSTEM_CONTINUATION_PROMPT = `你的分析计划很好。但你需要 **实际执行工具调用** 来完成任务,而不是只写文字描述。
68
+
69
+ 请现在开始执行:
70
+ 1. 用 \`search_project_code\` 搜索项目代码获取真实示例
71
+ 2. 用 \`read_project_file\` 查看完整文件内容
72
+ 3. 对每个值得保留的信号,用 \`submit_candidate\` 提交候选
73
+
74
+ ⚡ 推荐使用 batch_actions 一次提交多条候选:
75
+ \`\`\`batch_actions
76
+ [
77
+ {"tool": "submit_candidate", "params": {"title": "[Bootstrap] xxx/子主题", "code": "# 标题 — 项目特写\\n\\n> 摘要...\\n\\n描述和代码交织...", "language": "objectivec", "category": "Service", "summary": "...", "tags": ["bootstrap"], "source": "bootstrap", "reasoning": {"whyStandard": "...", "sources": ["file1"], "confidence": 0.7}}},
78
+ {"tool": "submit_candidate", "params": {"title": "...", "code": "...", ...}}
79
+ ]
80
+ \`\`\`
81
+
82
+ 请立即开始执行,不要再输出分析文字。`;
83
+
84
+ /**
85
+ * 系统调用提交提示 — 当 AI 做了工具调用(search/read)、写了分析文本,但没调 submit_candidate 时注入
86
+ * 引导 AI 将已有分析转化为实际的 submit_candidate 调用
87
+ */
88
+ const SYSTEM_SUBMIT_PROMPT = `你的分析很好,已经获取了足够的项目信息。但你还没有调用 \`submit_candidate\` 提交任何候选。
89
+
90
+ **你的分析不能只停留在文字描述层面** — 必须通过工具调用将分析结果持久化。
91
+
92
+ 请根据你刚才的分析,立即使用 batch_actions 提交候选:
93
+
94
+ \`\`\`batch_actions
95
+ [
96
+ {"tool": "submit_candidate", "params": {
97
+ "title": "[Bootstrap] 维度/子主题",
98
+ "code": "# 标题 — 项目特写\\n\\n> 本项目使用 XX 模式, N 个文件采用此写法\\n\\n描述...\\n\\n\`\`\`objc\\n// 真实代码示例\\n\`\`\`\\n\\n要点说明...",
99
+ "language": "objectivec",
100
+ "category": "Tool",
101
+ "summary": "≤80字精准摘要,引用真实类名和数字",
102
+ "tags": ["bootstrap", "维度id"],
103
+ "source": "bootstrap",
104
+ "reasoning": {"whyStandard": "为什么值得保留", "sources": ["真实文件名"], "confidence": 0.7}
105
+ }},
106
+ {"tool": "submit_candidate", "params": {...}}
107
+ ]
108
+ \`\`\`
109
+
110
+ 将你上面分析出的每个有价值的发现都转化为一条 submit_candidate 调用。code 字段写「项目特写」风格: 描述和代码交织,用项目真实类名和代码。`;
38
111
 
39
112
  export class ChatAgent {
40
113
  #toolRegistry;
@@ -51,6 +124,10 @@ export class ChatAgent {
51
124
  #conversations = null;
52
125
  /** @type {string|null} 当前 execute 调用的 source — 'user' | 'system' */
53
126
  #currentSource = null;
127
+ /** @type {Array|null} 内存文件缓存(bootstrap 场景注入,search_project_code/read_project_file 优先使用) */
128
+ #fileCache = null;
129
+ /** @type {Set<string>} 跨维度已提交候选标题(bootstrap 全局去重) */
130
+ #globalSubmittedTitles = new Set();
54
131
 
55
132
  /**
56
133
  * @param {object} opts
@@ -67,6 +144,12 @@ export class ChatAgent {
67
144
  /** 是否有 AI Provider(只读) */
68
145
  this.hasAI = !!aiProvider;
69
146
 
147
+ /**
148
+ * 是否有真实(非 Mock)AI Provider
149
+ * MockProvider 不具备实际推理能力,bootstrap 编排时应视为 AI 不可用
150
+ */
151
+ this.hasRealAI = !!aiProvider && aiProvider.name !== 'mock';
152
+
70
153
  // 初始化跨对话记忆 + 对话持久化
71
154
  try {
72
155
  const projectRoot = container?.singletons?._projectRoot || process.cwd();
@@ -80,6 +163,22 @@ export class ChatAgent {
80
163
 
81
164
  // ─── 公共 API ─────────────────────────────────────────
82
165
 
166
+ /**
167
+ * 注入内存文件缓存(bootstrap 场景: allFiles 已在内存中,避免重复磁盘读取)
168
+ * 调用后 search_project_code / read_project_file 优先从缓存查找
169
+ * @param {Array|null} files — [{ relativePath, content, name }]
170
+ */
171
+ setFileCache(files) {
172
+ this.#fileCache = files;
173
+ }
174
+
175
+ /**
176
+ * 重置跨维度全局提交标题(新 bootstrap session 开始时调用)
177
+ */
178
+ resetGlobalSubmittedTitles() {
179
+ this.#globalSubmittedTitles.clear();
180
+ }
181
+
83
182
  /**
84
183
  * 交互式对话(Dashboard Chat 入口)
85
184
  * 自动带 ReAct 循环: LLM 可决定调用工具或直接回答
@@ -89,25 +188,516 @@ export class ChatAgent {
89
188
  * @param {Array} opts.history — 对话历史 [{role, content}]
90
189
  * @param {string} [opts.conversationId] — 对话 ID(启用持久化时)
91
190
  * @param {'user'|'system'} [opts.source='user'] — 调用来源(影响 Memory 隔离)
191
+ * @param {object} [opts.dimensionMeta] — Bootstrap 维度元数据 { id, outputType, allowedKnowledgeTypes }
92
192
  * @returns {Promise<{reply: string, toolCalls: Array, hasContext: boolean, conversationId?: string}>}
93
193
  */
94
- async execute(prompt, { history = [], conversationId, source = 'user' } = {}) {
194
+ async execute(prompt, { history = [], conversationId, source = 'user', budget: budgetOverrides, dimensionId, dimensionMeta,
195
+ // v3.0: Agent 分离选项
196
+ systemPromptOverride, // 覆盖默认 system prompt (Analyst/Producer 各自使用)
197
+ allowedTools, // 覆盖默认工具白名单 (string[])
198
+ disablePhaseRouter = false, // 禁用 PhaseRouter (Analyst 不需要阶段控制)
199
+ temperature: temperatureOverride, // 覆盖默认温度
200
+ } = {}) {
95
201
  this.#currentSource = source;
202
+ const execStartTime = Date.now();
203
+ const promptPreview = prompt.length > 80 ? prompt.substring(0, 80) + '…' : prompt;
204
+ this.#logger.info(`[ChatAgent] ▶ execute — source=${source}${dimensionMeta?.id ? ', dim=' + dimensionMeta.id + '(' + dimensionMeta.outputType + ')' : (dimensionId ? ', dim=' + dimensionId : '')}, prompt="${promptPreview}", historyLen=${history.length}${conversationId ? ', convId=' + conversationId.substring(0, 8) : ''}`);
205
+
206
+ // 合并预算配置: 默认值 + 外部覆盖
207
+ const budget = budgetOverrides
208
+ ? { ...DEFAULT_BUDGET, ...budgetOverrides }
209
+ : { ...DEFAULT_BUDGET };
96
210
 
97
211
  // 对话持久化: 如果传了 conversationId,从 ConversationStore 加载历史
98
212
  let effectiveHistory = history;
99
213
  if (conversationId && this.#conversations) {
100
214
  effectiveHistory = this.#conversations.load(conversationId);
215
+ this.#logger.info(`[ChatAgent] loaded ${effectiveHistory.length} messages from conversation store`);
101
216
  this.#conversations.append(conversationId, { role: 'user', content: prompt });
102
217
  }
103
218
 
104
219
  // 每次对话刷新项目概况(不是每轮 ReAct)
105
220
  this.#projectBriefingCache = await this.#buildProjectBriefing();
106
221
 
222
+ // ── 双模路由: 原生函数调用 vs 文本解析 ──
223
+ // 支持原生函数调用的 Provider (如 Gemini) 走结构化路径,
224
+ // 其他 Provider 走传统文本 ReAct 解析路径
225
+ let result;
226
+ if (this.#aiProvider.supportsNativeToolCalling) {
227
+ this.#logger.info(`[ChatAgent] ✨ using NATIVE tool calling mode (${this.#aiProvider.name})`);
228
+ result = await this.#executeWithNativeTools(prompt, {
229
+ effectiveHistory, conversationId, source, execStartTime, budget, dimensionMeta,
230
+ // v3.0 新增
231
+ systemPromptOverride, allowedTools, disablePhaseRouter, temperatureOverride,
232
+ });
233
+ } else {
234
+ this.#logger.info(`[ChatAgent] 📝 using TEXT parsing mode (${this.#aiProvider.name})`);
235
+ result = await this.#executeWithTextParsing(prompt, {
236
+ effectiveHistory, conversationId, source, execStartTime,
237
+ });
238
+ }
239
+
240
+ // 持久化 assistant 回复
241
+ if (conversationId && this.#conversations) {
242
+ this.#conversations.append(conversationId, { role: 'assistant', content: result.reply });
243
+ this.#autoSummarize(conversationId).catch(err => {
244
+ this.#logger.debug('[ChatAgent] autoSummarize failed', { conversationId, error: err.message });
245
+ });
246
+ }
247
+
248
+ this.#extractMemory(prompt, result.reply);
249
+
250
+ return { ...result, conversationId };
251
+ }
252
+
253
+ // ─── Native Tool Calling ReAct 循环 ──────────────────────
254
+
255
+ /**
256
+ * 原生结构化函数调用 ReAct 循环
257
+ *
258
+ * 三层架构:
259
+ * 1. ContextWindow — 消息生命周期 + 三级递进压缩
260
+ * 2. PhaseRouter — 阶段状态机 (EXPLORE→PRODUCE→SUMMARIZE)
261
+ * 3. ToolResultLimiter — 工具结果入口压缩 (动态配额)
262
+ *
263
+ * @param {string} prompt
264
+ * @param {object} opts
265
+ * @returns {Promise<{reply: string, toolCalls: Array, hasContext: boolean}>}
266
+ */
267
+ async #executeWithNativeTools(prompt, { effectiveHistory, conversationId, source, execStartTime, budget = DEFAULT_BUDGET, dimensionMeta,
268
+ // v3.0: Agent 分离新增选项
269
+ systemPromptOverride, allowedTools, disablePhaseRouter = false, temperatureOverride,
270
+ }) {
271
+ const isSystem = source === 'system';
272
+ const isSkillOnly = dimensionMeta?.outputType === 'skill';
273
+ const temperature = temperatureOverride ?? (isSystem ? 0.3 : 0.7);
274
+
275
+ // ── Layer 1: ContextWindow ──
276
+ // messages[0] = prompt(不可压缩),历史消息在前面
277
+ const ctx = new ContextWindow(isSystem ? 24000 : 16000);
278
+ for (const h of effectiveHistory) {
279
+ if (h.role === 'assistant') {
280
+ ctx.appendAssistantText(h.content);
281
+ } else {
282
+ ctx.appendUserMessage(h.content);
283
+ }
284
+ }
285
+ // prompt 作为最终 user message(Anthropic 最佳实践: 查询放在所有上下文之后)
286
+ ctx.appendUserMessage(prompt);
287
+
288
+ // ── P5: Pre-check — 首条 prompt 过大时预警 ──
289
+ const initialUsage = ctx.getTokenUsageRatio();
290
+ if (initialUsage > 0.7) {
291
+ this.#logger.warn(`[ChatAgent] ⚠ initial prompt already at ${(initialUsage * 100).toFixed(0)}% of token budget (${ctx.estimateTokens()}/${ctx.tokenBudget})`);
292
+ if (initialUsage > 0.9 && isSystem) {
293
+ // 仅 1 条消息时 compactIfNeeded 无法压缩(需 >4 条),
294
+ // 依赖 P0/P1 信号限制来控制 prompt 大小
295
+ this.#logger.warn(`[ChatAgent] ⚠ prompt exceeds 90% budget — P0/P1 signal limiting should have prevented this. Check PROMPT_LIMITS config.`);
296
+ }
297
+ }
298
+
299
+ // ── Layer 2: PhaseRouter (仅 system 源且未禁用时使用) ──
300
+ const phaseRouter = (isSystem && !disablePhaseRouter) ? new PhaseRouter(budget, isSkillOnly) : null;
301
+
302
+ // ── 系统提示词 (支持外部覆盖) ──
303
+ const baseSystemPrompt = systemPromptOverride || this.#buildNativeToolSystemPrompt(budget);
304
+
305
+ // Bootstrap 场景限制可用工具集 (支持外部覆盖)
306
+ const effectiveAllowedTools = allowedTools || (isSystem ? [
307
+ 'search_project_code', 'read_project_file',
308
+ 'submit_candidate', 'submit_with_check',
309
+ 'list_project_structure', 'get_file_summary', 'semantic_search_code',
310
+ // AST 结构化分析工具
311
+ 'get_project_overview', 'get_class_hierarchy', 'get_class_info',
312
+ 'get_protocol_info', 'get_method_overrides', 'get_category_map',
313
+ 'get_previous_analysis',
314
+ ] : null);
315
+ const toolSchemas = this.#toolRegistry.getToolSchemas(effectiveAllowedTools);
316
+
317
+ const toolCalls = [];
318
+ const maxIter = isSystem ? budget.maxIterations : MAX_ITERATIONS;
319
+ let consecutiveAiErrors = 0;
320
+ let consecutiveEmptyResponses = 0;
321
+ let iterationCount = 0; // v3: 独立迭代计数器
322
+ const submittedTitles = new Set(this.#globalSubmittedTitles);
323
+
324
+ // ── 主循环 ──
325
+ while (true) {
326
+ // PhaseRouter tick + 退出检查
327
+ if (phaseRouter) {
328
+ phaseRouter.tick();
329
+ if (phaseRouter.shouldExit()) {
330
+ this.#logger.info(`[ChatAgent] PhaseRouter exit: phase=${phaseRouter.phase}, iter=${phaseRouter.totalIterations}, submits=${phaseRouter.totalSubmits}`);
331
+ break;
332
+ }
333
+ } else if (isSystem && iterationCount >= maxIter) {
334
+ // v3: system 模式无 PhaseRouter 时用独立迭代计数器硬限制
335
+ this.#logger.info(`[ChatAgent] Iteration hard cap reached: ${iterationCount}/${maxIter}`);
336
+ break;
337
+ } else if (!isSystem && ctx.length > maxIter * 2 + 2) {
338
+ // 用户对话模式: 简单的消息数限制
339
+ break;
340
+ }
341
+ iterationCount++;
342
+
343
+ const iterStartTime = Date.now();
344
+ const currentIter = phaseRouter?.totalIterations || iterationCount;
345
+
346
+ // ── 动态 toolChoice (由 PhaseRouter 决定) ──
347
+ let currentChoice;
348
+ if (phaseRouter) {
349
+ currentChoice = phaseRouter.getToolChoice();
350
+ } else {
351
+ currentChoice = 'auto';
352
+ }
353
+
354
+ // ── 压缩检查 (每次 AI 调用前) ──
355
+ const compactResult = ctx.compactIfNeeded();
356
+ if (compactResult.level > 0) {
357
+ this.#logger.info(`[ChatAgent] context compacted: L${compactResult.level}, removed ${compactResult.removed} items`);
358
+ }
359
+
360
+ // ── 构建 systemPrompt (含阶段提示) ──
361
+ let systemPrompt = baseSystemPrompt;
362
+ if (phaseRouter) {
363
+ const hint = phaseRouter.getPhaseHint();
364
+ if (hint) {
365
+ systemPrompt += `\n\n## 当前状态\n${hint}`;
366
+ }
367
+ }
368
+
369
+ // ── AI 调用 ──
370
+ let aiResult;
371
+ try {
372
+ const messages = ctx.toMessages();
373
+ this.#logger.info(`[ChatAgent] 🔄 iteration ${currentIter}/${maxIter} — phase=${phaseRouter?.phase || 'user'}, ${messages.length} msgs, toolChoice=${currentChoice}, tokens~${ctx.estimateTokens()}`);
374
+
375
+ aiResult = await this.#aiProvider.chatWithTools(prompt, {
376
+ messages,
377
+ toolSchemas,
378
+ toolChoice: currentChoice,
379
+ systemPrompt,
380
+ temperature,
381
+ maxTokens: 8192,
382
+ });
383
+
384
+ const aiDuration = Date.now() - iterStartTime;
385
+ if (aiResult.functionCalls?.length > 0) {
386
+ this.#logger.info(`[ChatAgent] ✓ AI returned ${aiResult.functionCalls.length} function calls in ${aiDuration}ms: [${aiResult.functionCalls.map(fc => fc.name).join(', ')}]`);
387
+ } else {
388
+ const textPreview = (aiResult.text || '').substring(0, 120).replace(/\n/g, '↵');
389
+ this.#logger.info(`[ChatAgent] ✓ AI returned text in ${aiDuration}ms (${(aiResult.text || '').length} chars) — "${textPreview}…"`);
390
+ }
391
+ consecutiveAiErrors = 0;
392
+ } catch (aiErr) {
393
+ consecutiveAiErrors++;
394
+ this.#logger.warn(`[ChatAgent] AI call failed (attempt ${consecutiveAiErrors}): ${aiErr.message}`);
395
+
396
+ // 熔断器已打开时立即跳出 — 不要继续浪费重试、也避免失败计数加速累积
397
+ if (aiErr.code === 'CIRCUIT_OPEN') {
398
+ if (isSystem) {
399
+ this.#logger.warn(`[ChatAgent] 🛑 circuit breaker is OPEN — skipping to summary`);
400
+ break;
401
+ }
402
+ return {
403
+ reply: `抱歉,AI 服务暂时不可用(${aiErr.message})。请稍后重试,或检查 API 配置。`,
404
+ toolCalls,
405
+ hasContext: toolCalls.length > 0,
406
+ };
407
+ }
408
+
409
+ if (consecutiveAiErrors >= 2) {
410
+ if (isSystem) {
411
+ this.#logger.warn(`[ChatAgent] 🛑 2 consecutive AI errors — resetting context, breaking to summary`);
412
+ ctx.resetToPromptOnly();
413
+ break;
414
+ }
415
+ return {
416
+ reply: `抱歉,AI 服务暂时不可用(${aiErr.message})。请稍后重试,或检查 API 配置。`,
417
+ toolCalls,
418
+ hasContext: toolCalls.length > 0,
419
+ };
420
+ }
421
+ await new Promise(r => setTimeout(r, 2000));
422
+ continue;
423
+ }
424
+
425
+ // ── 处理 functionCalls ──
426
+ if (aiResult.functionCalls && aiResult.functionCalls.length > 0) {
427
+ // 限制单次工具调用数量(防上下文溢出)
428
+ const MAX_TOOL_CALLS_PER_ITER = 8;
429
+ let activeCalls = aiResult.functionCalls;
430
+ if (activeCalls.length > MAX_TOOL_CALLS_PER_ITER) {
431
+ this.#logger.warn(`[ChatAgent] ⚠ ${activeCalls.length} tool calls, capping to ${MAX_TOOL_CALLS_PER_ITER}`);
432
+ activeCalls = activeCalls.slice(0, MAX_TOOL_CALLS_PER_ITER);
433
+ }
434
+
435
+ // ContextWindow: 原子追加 assistant + tool results
436
+ ctx.appendAssistantWithToolCalls(aiResult.text || null, activeCalls);
437
+
438
+ let roundSubmitCount = 0;
439
+
440
+ for (const fc of activeCalls) {
441
+ const toolStartTime = Date.now();
442
+ this.#logger.info(`[ChatAgent] 🔧 ${fc.name}(${JSON.stringify(fc.args).substring(0, 100)})`);
443
+
444
+ let toolResult;
445
+ try {
446
+ toolResult = await this.#toolRegistry.execute(
447
+ fc.name,
448
+ fc.args,
449
+ this.#getToolContext({ _sessionToolCalls: toolCalls, _dimensionMeta: dimensionMeta, _submittedTitles: submittedTitles }),
450
+ );
451
+ const toolDuration = Date.now() - toolStartTime;
452
+ const resultSize = typeof toolResult === 'string' ? toolResult.length : JSON.stringify(toolResult).length;
453
+ this.#logger.info(`[ChatAgent] 🔧 done: ${fc.name} → ${resultSize} chars in ${toolDuration}ms`);
454
+ } catch (toolErr) {
455
+ this.#logger.warn(`[ChatAgent] 🔧 FAILED: ${fc.name} — ${toolErr.message}`);
456
+ toolResult = { error: `tool "${fc.name}" failed: ${toolErr.message}` };
457
+ }
458
+
459
+ // 记录到全局 toolCalls
460
+ const summarized = this.#summarizeResult(toolResult);
461
+ toolCalls.push({ tool: fc.name, params: fc.args, result: summarized });
462
+
463
+ // ── Layer 3: ToolResultLimiter — 动态配额压缩 ──
464
+ const quota = ctx.getToolResultQuota();
465
+ let resultStr = limitToolResult(fc.name, toolResult, quota);
466
+
467
+ // ── 重复提交 / 维度范围校验 ──
468
+ if (fc.name === 'submit_candidate' || fc.name === 'submit_with_check') {
469
+ const title = fc.args?.title || fc.args?.category || '';
470
+ const isRejected = typeof toolResult === 'object' && toolResult?.status === 'rejected';
471
+ const isError = typeof toolResult === 'object' && (toolResult?.error || toolResult?.status === 'error');
472
+
473
+ if (isRejected) {
474
+ this.#logger.info(`[ChatAgent] 🚫 off-topic rejected: "${title}"`);
475
+ } else if (isError) {
476
+ // 候选创建失败(如 validation error)— 不加入 submittedTitles,允许 AI 重试
477
+ this.#logger.info(`[ChatAgent] ⚠ submit error: "${title}" — ${toolResult.error || 'unknown'}`);
478
+ } else if (submittedTitles.has(title)) {
479
+ resultStr = `⚠ 重复提交: "${title}" 已存在。`;
480
+ this.#logger.info(`[ChatAgent] 🔁 duplicate: "${title}"`);
481
+ } else {
482
+ submittedTitles.add(title);
483
+ this.#globalSubmittedTitles.add(title);
484
+ roundSubmitCount++;
485
+ }
486
+ }
487
+
488
+ // ContextWindow: 追加 tool result(与 assistant 保持原子性)
489
+ ctx.appendToolResult(fc.id, fc.name, resultStr);
490
+ }
491
+
492
+ // ── PhaseRouter 更新 ──
493
+ if (phaseRouter) {
494
+ const transition = phaseRouter.update({
495
+ functionCalls: activeCalls,
496
+ submitCount: roundSubmitCount,
497
+ isTextOnly: false,
498
+ });
499
+
500
+ // ── EXPLORE→PRODUCE 阶段过渡: 注入提交引导消息 ──
501
+ // 原生工具调用模式下,仅靠 systemPrompt 附加 hint 不够显著,
502
+ // 需要一条 user 消息明确告知 AI 切换到提交模式
503
+ if (transition.transitioned && transition.newPhase === 'PRODUCE') {
504
+ ctx.appendUserNudge(
505
+ '你已充分探索了项目代码,现在请开始调用 submit_candidate 工具来提交你发现的知识候选。不要再搜索,直接提交。'
506
+ );
507
+ this.#logger.info('[ChatAgent] 📝 injected PRODUCE transition nudge');
508
+ }
509
+
510
+ // ── EXPLORE→SUMMARIZE / PRODUCE→SUMMARIZE 阶段过渡: 注入 digest 引导 ──
511
+ // skill-only 维度从 EXPLORE 直接进入 SUMMARIZE (跳过 PRODUCE),
512
+ // 需要明确告知 AI 输出 dimensionDigest JSON
513
+ if (transition.transitioned && transition.newPhase === 'SUMMARIZE') {
514
+ const submitCount = toolCalls.filter(tc => tc.tool === 'submit_candidate' || tc.tool === 'submit_with_check').length;
515
+ ctx.appendUserNudge(
516
+ `你已完成分析探索。请在回复中直接输出 dimensionDigest JSON(用 \`\`\`json 包裹),包含以下字段:\n\`\`\`json\n{"dimensionDigest":{"summary":"分析总结(100-200字)","candidateCount":${submitCount},"keyFindings":["关键发现"],"crossRefs":{},"gaps":["未覆盖方面"],"remainingTasks":[{"signal":"未处理的信号/主题","reason":"未完成原因(如:提交上限已达)","priority":"high|medium|low","searchHints":["建议搜索词"]}]}}\n\`\`\`\n> 如果所有信号都已覆盖,remainingTasks 留空数组 \`[]\`。如果有未来得及处理的信号,请在此标记,系统会在下次运行时续传。`
517
+ );
518
+ this.#logger.info('[ChatAgent] 📝 injected SUMMARIZE transition nudge');
519
+ }
520
+ }
521
+
522
+ continue;
523
+ }
524
+
525
+ // ── 文字回答 ──
526
+ // 空响应重试(Gemini 偶发)
527
+ if (!aiResult.text && isSystem && consecutiveEmptyResponses < 2) {
528
+ consecutiveEmptyResponses++;
529
+ this.#logger.warn(`[ChatAgent] ⚠ empty response from system source — retrying (${consecutiveEmptyResponses}/2)`);
530
+ await new Promise(r => setTimeout(r, 1500));
531
+ continue;
532
+ }
533
+ // 收到非空响应时重置空响应计数器
534
+ if (aiResult.text) {
535
+ consecutiveEmptyResponses = 0;
536
+ }
537
+
538
+ // PhaseRouter: 文字回答触发阶段转换
539
+ if (phaseRouter) {
540
+ const transition = phaseRouter.update({
541
+ functionCalls: null,
542
+ submitCount: 0,
543
+ isTextOnly: true,
544
+ });
545
+
546
+ // SUMMARIZE 阶段的文字回答 = 最终回答
547
+ if (phaseRouter.phase === 'SUMMARIZE') {
548
+ // 刚从 EXPLORE/PRODUCE 转入 SUMMARIZE 的文字回答可能不含 digest,
549
+ // 注入 nudge 让 AI 再输出一次 digest JSON
550
+ if (transition.transitioned) {
551
+ ctx.appendAssistantText(aiResult.text || '');
552
+ const submitCount = toolCalls.filter(tc => tc.tool === 'submit_candidate' || tc.tool === 'submit_with_check').length;
553
+ ctx.appendUserNudge(
554
+ `请在回复中直接输出 dimensionDigest JSON 总结(用 \`\`\`json 包裹):\n\`\`\`json\n{"dimensionDigest":{"summary":"分析总结","candidateCount":${submitCount},"keyFindings":["发现"],"crossRefs":{},"gaps":["缺口"],"remainingTasks":[{"signal":"未处理的信号","reason":"原因","priority":"high","searchHints":["搜索词"]}]}}\n\`\`\`\n> remainingTasks: 记录未来得及处理的信号。已全部覆盖则留空 \`[]\`。`
555
+ );
556
+ this.#logger.info('[ChatAgent] 📝 injected SUMMARIZE nudge (text-triggered transition)');
557
+ continue;
558
+ }
559
+ // 已在 SUMMARIZE 阶段 — 这就是最终回答
560
+ const reply = this.#cleanFinalAnswer(aiResult.text || '');
561
+ const totalDuration = Date.now() - execStartTime;
562
+ this.#logger.info(`[ChatAgent] ✅ final answer — ${reply.length} chars, ${phaseRouter.totalIterations} iters, ${toolCalls.length} tool calls, ${totalDuration}ms`);
563
+ return { reply, toolCalls, hasContext: toolCalls.length > 0 };
564
+ }
565
+
566
+ if (!transition.transitioned) {
567
+ // 在 EXPLORE/PRODUCE 阶段收到文本但未转阶段 — 可能是 AI 的中间分析
568
+ // 注入提交引导并继续循环,而非立即返回
569
+ if (phaseRouter.phase === 'EXPLORE' || phaseRouter.phase === 'PRODUCE') {
570
+ ctx.appendAssistantText(aiResult.text || '');
571
+ if (phaseRouter.phase === 'PRODUCE') {
572
+ ctx.appendUserNudge(
573
+ '你的分析很好。请继续调用 submit_candidate 提交你发现的知识候选,每个值得记录的模式/实践都应该提交。'
574
+ );
575
+ this.#logger.info('[ChatAgent] 📝 injected submit nudge (text in PRODUCE, not transitioning)');
576
+ }
577
+ continue;
578
+ }
579
+ // 非生产阶段的未转换文本 = 最终回答
580
+ const reply = this.#cleanFinalAnswer(aiResult.text || '');
581
+ const totalDuration = Date.now() - execStartTime;
582
+ this.#logger.info(`[ChatAgent] ✅ final answer — ${reply.length} chars, ${phaseRouter.totalIterations} iters, ${toolCalls.length} tool calls, ${totalDuration}ms`);
583
+ return { reply, toolCalls, hasContext: toolCalls.length > 0 };
584
+ }
585
+
586
+ // 其他阶段的文字回答 → 继续循环(PhaseRouter 已自动转换阶段)
587
+ ctx.appendAssistantText(aiResult.text || '');
588
+ continue;
589
+ }
590
+
591
+ // 用户对话: 文字回答即最终回答
592
+ const reply = this.#cleanFinalAnswer(aiResult.text || '');
593
+ const totalDuration = Date.now() - execStartTime;
594
+ this.#logger.info(`[ChatAgent] ✅ final answer — ${reply.length} chars, ${toolCalls.length} tool calls, ${totalDuration}ms`);
595
+ return { reply, toolCalls, hasContext: toolCalls.length > 0 };
596
+ }
597
+
598
+ // ── 循环退出: 产出 dimensionDigest 总结 ──
599
+ return this.#produceForcedSummary({
600
+ source, toolCalls, toolSchemas, ctx, phaseRouter, execStartTime,
601
+ });
602
+ }
603
+
604
+ /**
605
+ * 强制退出后的摘要生成 — 独立方法,避免主循环代码膨胀
606
+ * @private
607
+ */
608
+ async #produceForcedSummary({ source, toolCalls, toolSchemas, ctx, phaseRouter, execStartTime }) {
609
+ const iterations = phaseRouter?.totalIterations || 0;
610
+ this.#logger.info(`[ChatAgent] ⚠ producing forced summary (${iterations} iters, ${toolCalls.length} calls)`);
611
+
612
+ const candidateCount = toolCalls.filter(tc =>
613
+ tc.tool === 'submit_candidate' || tc.tool === 'submit_with_check'
614
+ ).length;
615
+
616
+ let finalReply;
617
+
618
+ // 如果熔断器已打开,跳过 AI 调用直接合成 digest(避免无用的失败 + 计数累积)
619
+ const isCircuitOpen = this.#aiProvider._circuitState === 'OPEN';
620
+ if (isCircuitOpen) {
621
+ this.#logger.warn(`[ChatAgent] circuit breaker is OPEN — skipping AI summary, using synthetic digest`);
622
+ }
623
+
624
+ try {
625
+ if (isCircuitOpen) throw new Error('circuit open — skip to synthetic digest');
626
+
627
+ const submitSummary = toolCalls
628
+ .filter(tc => tc.tool === 'submit_candidate' || tc.tool === 'submit_with_check')
629
+ .map((tc, i) => `${i + 1}. ${tc.params?.title || tc.params?.category || 'untitled'}`)
630
+ .join('\n');
631
+
632
+ const summaryPrompt = source === 'system'
633
+ ? `你已完成 ${iterations} 轮工具调用(共 ${toolCalls.length} 次),提交了 ${candidateCount} 个候选。
634
+ ${submitSummary ? `已提交候选:\n${submitSummary}\n` : ''}
635
+ **必须**输出 dimensionDigest JSON(用 \`\`\`json 包裹):
636
+ \`\`\`json
637
+ {
638
+ "dimensionDigest": {
639
+ "summary": "本维度分析总结",
640
+ "candidateCount": ${candidateCount},
641
+ "keyFindings": ["发现1", "发现2"],
642
+ "crossRefs": {},
643
+ "gaps": ["未覆盖方面"],
644
+ "remainingTasks": [
645
+ { "signal": "未处理信号名", "reason": "达到提交上限/时间限制", "priority": "high", "searchHints": ["搜索词"] }
646
+ ]
647
+ }
648
+ }
649
+ \`\`\`
650
+ > remainingTasks: 列出本次未来得及处理的信号/主题。已全部覆盖则留空 \`[]\`。`
651
+ : `Completed ${iterations} iterations with ${toolCalls.length} tool calls. Please summarize.`;
652
+
653
+ // 用空 messages 避免累积上下文导致 400
654
+ const summaryResult = await this.#aiProvider.chatWithTools(
655
+ summaryPrompt,
656
+ {
657
+ messages: [],
658
+ toolSchemas,
659
+ toolChoice: 'none',
660
+ systemPrompt: '直接输出 dimensionDigest JSON 总结,不要调用工具。',
661
+ temperature: 0.3,
662
+ maxTokens: 8192,
663
+ },
664
+ );
665
+ finalReply = this.#cleanFinalAnswer(summaryResult.text || '');
666
+ } catch (err) {
667
+ this.#logger.warn(`[ChatAgent] forced summary AI call failed: ${err.message}`);
668
+ // 合成 digest 兜底
669
+ const titles = toolCalls
670
+ .filter(tc => tc.tool === 'submit_candidate' || tc.tool === 'submit_with_check')
671
+ .map(tc => tc.params?.title || 'untitled');
672
+ finalReply = `\`\`\`json
673
+ {
674
+ "dimensionDigest": {
675
+ "summary": "通过 ${toolCalls.length} 次工具调用分析了项目代码,提交了 ${candidateCount} 个候选。",
676
+ "candidateCount": ${candidateCount},
677
+ "keyFindings": ${JSON.stringify(titles.slice(0, 5))},
678
+ "crossRefs": {},
679
+ "gaps": ["AI 服务异常,部分分析未完成"]
680
+ }
681
+ }
682
+ \`\`\``;
683
+ }
684
+
685
+ const totalDuration = Date.now() - execStartTime;
686
+ this.#logger.info(`[ChatAgent] ✅ forced summary — ${finalReply.length} chars, ${totalDuration}ms total`);
687
+ return { reply: finalReply, toolCalls, hasContext: toolCalls.length > 0 };
688
+ }
689
+
690
+ // ─── Text Parsing ReAct 循环 (legacy) ─────────────────
691
+
692
+ /**
693
+ * 文本解析 ReAct 循环 — 传统模式
694
+ * 适用于不支持原生函数调用的 Provider (DeepSeek, OpenAI 兼容等)
695
+ * AI 输出文本 → #parseActions() 正则解析 → 执行工具 → 循环
696
+ */
697
+ async #executeWithTextParsing(prompt, { effectiveHistory, conversationId, source, execStartTime }) {
107
698
  const toolSchemas = this.#toolRegistry.getToolSchemas();
108
699
  const systemPrompt = this.#buildSystemPrompt(toolSchemas);
109
700
 
110
- // 首次 LLM 调用
111
701
  const messages = [
112
702
  ...effectiveHistory,
113
703
  { role: 'user', content: prompt },
@@ -116,96 +706,138 @@ export class ChatAgent {
116
706
  const toolCalls = [];
117
707
  let iterations = 0;
118
708
  let currentPrompt = prompt;
119
-
120
709
  let consecutiveAiErrors = 0;
710
+ const maxIter = source === 'system' ? MAX_ITERATIONS_SYSTEM : MAX_ITERATIONS;
121
711
 
122
- while (iterations < MAX_ITERATIONS) {
712
+ while (iterations < maxIter) {
123
713
  iterations++;
714
+ const iterStartTime = Date.now();
124
715
 
125
716
  let response;
126
717
  try {
718
+ this.#logger.info(`[ChatAgent] 🔄 text iteration ${iterations}/${maxIter} — calling AI (${messages.length} messages)`);
127
719
  response = await this.#aiProvider.chat(currentPrompt, {
128
- history: messages.slice(0, -1), // 不含最新 user prompt
720
+ history: messages.slice(0, -1),
129
721
  systemPrompt,
130
722
  });
723
+ const aiDuration = Date.now() - iterStartTime;
724
+ const responsePreview = (response || '').substring(0, 120).replace(/\n/g, '↵');
725
+ this.#logger.info(`[ChatAgent] ✓ AI responded in ${aiDuration}ms (${(response || '').length} chars) — "${responsePreview}…"`);
131
726
  consecutiveAiErrors = 0;
132
727
  } catch (aiErr) {
133
728
  consecutiveAiErrors++;
134
729
  this.#logger.warn(`[ChatAgent] AI call failed (attempt ${consecutiveAiErrors}): ${aiErr.message}`);
135
730
 
136
- // 连续 2 次失败则降级返回错误提示
731
+ // 熔断器已打开 立即返回
732
+ if (aiErr.code === 'CIRCUIT_OPEN') {
733
+ return {
734
+ reply: `抱歉,AI 服务暂时不可用(${aiErr.message})。请稍后重试,或检查 API 配置。`,
735
+ toolCalls,
736
+ hasContext: toolCalls.length > 0,
737
+ };
738
+ }
739
+
137
740
  if (consecutiveAiErrors >= 2) {
138
- const errorReply = `抱歉,AI 服务暂时不可用(${aiErr.message})。请稍后重试,或检查 API 配置。`;
139
- if (conversationId && this.#conversations) {
140
- this.#conversations.append(conversationId, { role: 'assistant', content: errorReply });
141
- }
142
- return { reply: errorReply, toolCalls, hasContext: toolCalls.length > 0, conversationId };
741
+ return {
742
+ reply: `抱歉,AI 服务暂时不可用(${aiErr.message})。请稍后重试,或检查 API 配置。`,
743
+ toolCalls,
744
+ hasContext: toolCalls.length > 0,
745
+ };
143
746
  }
144
- // 首次失败:等待后重试本轮
145
747
  await new Promise(r => setTimeout(r, 2000));
146
748
  continue;
147
749
  }
148
750
 
149
- // 尝试解析 Action
150
- const action = this.#parseAction(response);
751
+ const actions = this.#parseActions(response);
752
+
753
+ if (!actions) {
754
+ // ── 系统调用自动续跑 ──
755
+ const hasSubmits = toolCalls.some(tc => tc.tool === 'submit_candidate' || tc.tool === 'submit_with_check');
756
+ if (source === 'system' && iterations < maxIter && !hasSubmits) {
757
+ if (this.#looksLikeIncompleteStep(response)) {
758
+ this.#logger.info(`[ChatAgent] 🔄 detected planning-only response at iteration ${iterations}, injecting continuation prompt`);
759
+ messages.push({ role: 'assistant', content: response });
760
+ currentPrompt = SYSTEM_CONTINUATION_PROMPT;
761
+ messages.push({ role: 'user', content: currentPrompt });
762
+ continue;
763
+ }
764
+ if (toolCalls.length > 0) {
765
+ this.#logger.info(`[ChatAgent] 🔄 detected analysis-without-submission at iteration ${iterations} (${toolCalls.length} tool calls, 0 submits), injecting submission prompt`);
766
+ messages.push({ role: 'assistant', content: response });
767
+ currentPrompt = SYSTEM_SUBMIT_PROMPT;
768
+ messages.push({ role: 'user', content: currentPrompt });
769
+ continue;
770
+ }
771
+ }
151
772
 
152
- if (!action) {
153
- // 没有 Action → 最终回答
154
773
  const reply = this.#cleanFinalAnswer(response);
155
- this.#extractMemory(prompt, reply);
156
-
157
- // 持久化 assistant 回复
158
- if (conversationId && this.#conversations) {
159
- this.#conversations.append(conversationId, { role: 'assistant', content: reply });
160
- // 消息过多时自动压缩
161
- this.#autoSummarize(conversationId).catch(() => {});
162
- }
774
+ const totalDuration = Date.now() - execStartTime;
775
+ this.#logger.info(`[ChatAgent] ✅ text final answer — ${reply.length} chars, ${iterations} iterations, ${toolCalls.length} tool calls, ${totalDuration}ms total`);
163
776
 
164
- return { reply, toolCalls, hasContext: toolCalls.length > 0, conversationId };
777
+ return { reply, toolCalls, hasContext: toolCalls.length > 0 };
165
778
  }
166
779
 
167
780
  // 执行工具
168
- this.#logger.info('ChatAgent tool call', {
169
- tool: action.tool,
170
- iteration: iterations,
171
- });
172
-
173
- let toolResult;
174
- try {
175
- toolResult = await this.#toolRegistry.execute(
176
- action.tool,
177
- action.params,
178
- this.#getToolContext(),
179
- );
180
- } catch (toolErr) {
181
- this.#logger.warn(`[ChatAgent] Tool "${action.tool}" failed: ${toolErr.message}`);
182
- // 将错误反馈给 LLM,让它尝试其他方法
183
- toolResult = `Error: tool "${action.tool}" failed — ${toolErr.message}. Try a different approach or provide your answer based on available information.`;
781
+ const isBatch = actions.length > 1;
782
+ if (isBatch) {
783
+ this.#logger.info(`[ChatAgent] 📦 batch tool call: ${actions.length} actions [${actions.map(a => a.tool).join(', ')}]`, { iteration: iterations });
184
784
  }
185
785
 
186
- toolCalls.push({
187
- tool: action.tool,
188
- params: action.params,
189
- result: this.#summarizeResult(toolResult),
190
- });
786
+ const batchResults = [];
787
+ for (const action of actions) {
788
+ this.#logger.info(`[ChatAgent] 🔧 tool call: ${action.tool}(${JSON.stringify(action.params).substring(0, 100)})`, {
789
+ iteration: iterations,
790
+ batch: isBatch,
791
+ });
792
+
793
+ let toolResult;
794
+ const toolStartTime = Date.now();
795
+ try {
796
+ toolResult = await this.#toolRegistry.execute(
797
+ action.tool,
798
+ action.params,
799
+ this.#getToolContext({ _sessionToolCalls: toolCalls }),
800
+ );
801
+ const toolDuration = Date.now() - toolStartTime;
802
+ const resultSize = typeof toolResult === 'string' ? toolResult.length : JSON.stringify(toolResult).length;
803
+ this.#logger.info(`[ChatAgent] 🔧 tool done: ${action.tool} → ${resultSize} chars in ${toolDuration}ms`);
804
+ } catch (toolErr) {
805
+ this.#logger.warn(`[ChatAgent] 🔧 tool FAILED: ${action.tool} — ${toolErr.message} (${Date.now() - toolStartTime}ms)`);
806
+ toolResult = `Error: tool "${action.tool}" failed — ${toolErr.message}. Try a different approach or provide your answer based on available information.`;
807
+ }
808
+
809
+ const summarized = this.#summarizeResult(toolResult);
810
+ toolCalls.push({
811
+ tool: action.tool,
812
+ params: action.params,
813
+ result: summarized,
814
+ });
815
+ batchResults.push({ tool: action.tool, result: toolResult });
816
+ }
191
817
 
192
818
  // 将工具结果注入为下一轮 prompt
193
- const observation = typeof toolResult === 'string'
194
- ? toolResult
195
- : JSON.stringify(toolResult, null, 2);
819
+ let observation;
820
+ if (batchResults.length === 1) {
821
+ const r = batchResults[0];
822
+ const obsText = typeof r.result === 'string' ? r.result : JSON.stringify(r.result, null, 2);
823
+ observation = `Observation from tool "${r.tool}":\n${this.#truncate(obsText, 4000)}`;
824
+ } else {
825
+ observation = `Batch observation (${batchResults.length} tools):\n` +
826
+ batchResults.map((r, i) => {
827
+ const obsText = typeof r.result === 'string' ? r.result : JSON.stringify(r.result, null, 2);
828
+ return `[${i + 1}] ${r.tool}: ${this.#truncate(obsText, 2000)}`;
829
+ }).join('\n\n');
830
+ }
196
831
 
197
- currentPrompt = `Observation from tool "${action.tool}":\n${this.#truncate(observation, 4000)}\n\nBased on the above observation, continue reasoning about the user's question: "${prompt}".\nIf you have enough information, provide your final answer directly (without Action block). Otherwise, call another tool.`;
832
+ currentPrompt = `${observation}\n\nBased on the above observation, continue reasoning about the user's question: "${prompt}".\nIf you have enough information, provide your final answer directly (without Action block). Otherwise, call another tool.`;
198
833
 
199
- // 追加到消息历史中以保持上下文
200
834
  messages.push({ role: 'assistant', content: response });
201
835
  messages.push({ role: 'user', content: currentPrompt });
202
836
 
203
- // ── Context Window 自动压缩(Cline AutoCondense 模式)──
204
- // 每轮 ReAct 后检测消息总 token,超过预算时压缩中段消息
205
837
  this.#condenseIfNeeded(messages);
206
838
  }
207
839
 
208
- // 达到最大迭代次数,要求 LLM 总结
840
+ // 达到最大迭代次数
209
841
  const summaryPrompt = `You have used ${iterations} tool calls. Summarize what you found and answer the user's original question: "${prompt}"`;
210
842
  let finalResponse;
211
843
  try {
@@ -215,27 +847,13 @@ export class ChatAgent {
215
847
  });
216
848
  } catch (err) {
217
849
  this.#logger.warn(`[ChatAgent] Final summary AI call failed: ${err.message}`);
218
- // 降级:用工具调用结果拼一个简单回复
219
850
  finalResponse = `根据 ${toolCalls.length} 次工具调用的结果,以下是收集到的信息:\n\n` +
220
851
  toolCalls.map(tc => `• ${tc.tool}: ${typeof tc.result === 'string' ? tc.result.substring(0, 200) : JSON.stringify(tc.result).substring(0, 200)}`).join('\n') +
221
852
  '\n\n(注:AI 总结服务暂时不可用,上述为原始工具输出摘要)';
222
853
  }
223
854
 
224
855
  const finalReply = this.#cleanFinalAnswer(finalResponse);
225
- this.#extractMemory(prompt, finalReply);
226
-
227
- // 持久化 assistant 回复
228
- if (conversationId && this.#conversations) {
229
- this.#conversations.append(conversationId, { role: 'assistant', content: finalReply });
230
- this.#autoSummarize(conversationId).catch(() => {});
231
- }
232
-
233
- return {
234
- reply: finalReply,
235
- toolCalls,
236
- hasContext: toolCalls.length > 0,
237
- conversationId,
238
- };
856
+ return { reply: finalReply, toolCalls, hasContext: toolCalls.length > 0 };
239
857
  }
240
858
 
241
859
  /**
@@ -588,20 +1206,16 @@ ${code.substring(0, 3000)}
588
1206
  /**
589
1207
  * 注册内置 DAG 管线
590
1208
  *
591
- * 设计原则: 项目内 AI 都走 ChatAgent + tool,DAG 编排 AI 步骤。
592
- * bootstrapKnowledge() 只做启发式 Phase 1-5,不调 AI。
593
- * AI 增强步骤由 ChatAgent DAG 编排:
594
- *
595
- * bootstrap_full_pipeline:
596
- * Phase 0: bootstrap(SPM 扫描 + Skill 增强维度 + 候选创建,纯启发式)
597
- * Phase 1: enrich(AI 结构补齐,依赖 bootstrap 产出的候选 ID)
598
- * Phase 1: loadSkill(并行加载语言参考 Skill,用于润色提示)
599
- * Phase 2: refine(AI 内容润色,依赖 enrich + loadSkill)
1209
+ * v6 变更:
1210
+ * - 移除旧的 4 步 DAG (bootstrap enrich loadSkill → refine)
1211
+ * - 冷启动 AI 增强现在通过 orchestrator.js 中的 ChatAgent per-dimension production 完成
1212
+ * - 保留简化版 bootstrap_full_pipeline: 只做 Phase 1-4 启发式
1213
+ * (Phase 5 ChatAgent 生产由 orchestrator.js 管理,不再走 DAG 编排)
600
1214
  */
601
1215
  #registerBuiltinPipelines() {
602
- const hasAI = !!this.#aiProvider;
603
-
604
- // ── bootstrap_full_pipeline (DAG) ──────────────────────
1216
+ // ── bootstrap_full_pipeline (v6 简化版) ──────────────────
1217
+ // 只做启发式 Phase 1-5.5 (含 ChatAgent per-dimension production)
1218
+ // 不再需要 enrich/refine 后置步骤
605
1219
  this.registerPipeline(new TaskPipeline('bootstrap_full_pipeline', [
606
1220
  {
607
1221
  name: 'bootstrap',
@@ -613,69 +1227,6 @@ ${code.substring(0, 3000)}
613
1227
  loadSkills: true,
614
1228
  },
615
1229
  },
616
- {
617
- name: 'enrich',
618
- tool: 'enrich_candidate',
619
- dependsOn: ['bootstrap'],
620
- params: {
621
- candidateIds: (ctx) => {
622
- const bc = ctx._results.bootstrap?.bootstrapCandidates;
623
- return bc?.ids || bc?.candidateIds || [];
624
- },
625
- },
626
- when: (ctx) => {
627
- const ids = ctx._results.bootstrap?.bootstrapCandidates?.ids
628
- || ctx._results.bootstrap?.bootstrapCandidates?.candidateIds;
629
- return Array.isArray(ids) && ids.length > 0;
630
- },
631
- errorStrategy: 'continue',
632
- },
633
- {
634
- name: 'loadSkill',
635
- tool: 'load_skill',
636
- dependsOn: ['bootstrap'],
637
- params: {
638
- skillName: (ctx) => {
639
- const loaded = ctx._results.bootstrap?.skillsLoaded || [];
640
- return loaded.find(s => s.startsWith('autosnippet-reference-')) || 'autosnippet-coldstart';
641
- },
642
- },
643
- when: (ctx) => {
644
- const autoRefine = ctx._inputs.autoRefine;
645
- return autoRefine !== false && hasAI;
646
- },
647
- errorStrategy: 'continue',
648
- },
649
- {
650
- name: 'refine',
651
- tool: 'refine_bootstrap_candidates',
652
- dependsOn: ['enrich', 'loadSkill'],
653
- params: {
654
- userPrompt: (ctx) => {
655
- const parts = [];
656
-
657
- // Skill 业界标准参考
658
- const skillContent = ctx._results.loadSkill?.content;
659
- if (skillContent) {
660
- parts.push(`请参考以下业界最佳实践标准润色候选,确保 summary 精准、tags 丰富、confidence 合理:\n${skillContent.substring(0, 3000)}`);
661
- }
662
-
663
- // AST 代码结构分析 — 帮助 AI 理解继承体系和设计模式
664
- const astCtx = ctx._results.bootstrap?.astContext;
665
- if (astCtx) {
666
- parts.push(`\n# 项目代码结构分析 (Tree-sitter AST)\n以下是项目的 AST 分析结果,请在润色时参考类继承关系、设计模式和代码质量指标:\n${astCtx.substring(0, 2000)}`);
667
- }
668
-
669
- return parts.length > 0 ? parts.join('\n\n') : ctx._inputs.refinePrompt;
670
- },
671
- },
672
- when: (ctx) => {
673
- const autoRefine = ctx._inputs.autoRefine;
674
- const created = ctx._results.bootstrap?.bootstrapCandidates?.created || 0;
675
- return autoRefine !== false && created > 0 && hasAI;
676
- },
677
- errorStrategy: 'continue',
678
- },
679
1230
  ]));
680
1231
  }
681
1232
 
@@ -687,17 +1238,20 @@ ${code.substring(0, 3000)}
687
1238
  * 工具注入策略(Lazy Tool Schema — 类似 Cline .clinerules 按需加载):
688
1239
  * - 首屏只注入工具名 + 一行描述(compact list)
689
1240
  * - 系统提示词中告知 LLM 可通过 get_tool_details 获取完整参数
690
- * - 少量核心工具(search_knowledge, submit_with_check, analyze_code,
691
- * bootstrap_knowledge, load_skill, suggest_skills)直接展开完整 schema
1241
+ * - 少量核心工具(search_project_code, read_project_file, search_knowledge,
1242
+ * submit_with_check, analyze_code, bootstrap_knowledge, load_skill,
1243
+ * suggest_skills)直接展开完整 schema
692
1244
  *
693
- * 效果: 39 个工具的 prompt 从 ~5000 tokens 降到 ~1500 tokens
1245
+ * 效果: 44 个工具的 prompt 从 ~5000 tokens 降到 ~1500 tokens
694
1246
  */
695
1247
  #buildSystemPrompt(toolSchemas) {
696
1248
  // 核心工具 — 使用最频繁,直接展示完整 schema
697
1249
  const coreTools = new Set([
698
- 'search_knowledge', 'submit_with_check', 'analyze_code',
1250
+ 'search_project_code', 'read_project_file',
1251
+ 'search_knowledge', 'submit_candidate', 'submit_with_check', 'analyze_code',
699
1252
  'bootstrap_knowledge', 'load_skill', 'suggest_skills',
700
1253
  'create_skill', 'knowledge_overview', 'get_tool_details',
1254
+ 'plan_task', 'review_my_output',
701
1255
  ]);
702
1256
 
703
1257
  const compactDescriptions = [];
@@ -746,7 +1300,15 @@ ${skillSection}
746
1300
  {"tool": "tool_name", "params": {"key": "value"}}
747
1301
  \`\`\`
748
1302
 
749
- 3. 每次只调用一个工具。
1303
+ 3. 当需要连续调用多个**同类工具**(如批量提交候选)时,可使用批量格式:
1304
+
1305
+ \`\`\`batch_actions
1306
+ [
1307
+ {"tool": "submit_candidate", "params": {"title": "...", "code": "..."}},
1308
+ {"tool": "submit_candidate", "params": {"title": "...", "code": "..."}}
1309
+ ]
1310
+ \`\`\`
1311
+
750
1312
  4. 如果不需要工具就能回答,直接回答,不要输出 action 块。
751
1313
  5. 回答时使用用户的语言(中文/英文)。
752
1314
  6. 回答要简洁、有依据(引用工具返回的数据)。
@@ -764,36 +1326,304 @@ ${skillSection}
764
1326
  }
765
1327
 
766
1328
  /**
767
- * 从 LLM 响应中解析 Action 块
768
- * 格式: ```action\n{"tool":"...", "params":{...}}\n```
1329
+ * 构建原生函数调用模式的系统提示词
1330
+ *
1331
+ * 设计原则:
1332
+ * - 精简: bootstrap 模式不注入 SOUL.md 人格(节省 ~500 token)
1333
+ * - 分层: 静态指令放 systemPrompt,动态上下文放 user prompt
1334
+ * - 控制通过 PhaseRouter 状态机实现,不通过追加 user 消息
1335
+ * - 工具描述已通过 functionDeclarations 传递,不重复
1336
+ */
1337
+ #buildNativeToolSystemPrompt(budget = DEFAULT_BUDGET) {
1338
+ // 用户对话模式: 完整提示词(含 SOUL、Memory、项目概况)
1339
+ if (this.#currentSource !== 'system') {
1340
+ let soulSection = '';
1341
+ try {
1342
+ if (fs.existsSync(SOUL_PATH)) {
1343
+ soulSection = '\n' + fs.readFileSync(SOUL_PATH, 'utf-8').trim() + '\n';
1344
+ }
1345
+ } catch { /* SOUL.md not available */ }
1346
+
1347
+ return `${soulSection}
1348
+ 你是 AutoSnippet 项目的统一 AI 中心。项目内所有 AI 推理和分析都通过你执行。
1349
+ ${this.#projectBriefingCache}${this.#memory?.toPromptSection({ source: 'user' }) || ''}
1350
+
1351
+ ## 使用规则
1352
+ 1. 当需要查询数据时,直接调用相应工具。
1353
+ 2. 工具参数严格按照工具声明中的 schema 传递。
1354
+ 3. 对于代码分析任务,先 search_project_code 搜索,再 read_project_file 读取。
1355
+ 4. 回答时使用用户的语言(中文/英文)。
1356
+ 5. 当工具返回错误时,尝试不同参数或方法。`;
1357
+ }
1358
+
1359
+ // Bootstrap 系统模式: LLM 以领域大脑的能力处理任务
1360
+ return `你以「领域大脑」的能力来处理任务 — 你对软件工程领域拥有深厚的专家知识。
1361
+ 你将分析一个真实项目,自主发现其中有价值的代码知识。
1362
+ ${this.#projectBriefingCache}
1363
+
1364
+ ## 你的能力定位
1365
+ 你具备深度技术洞察力,能够理解代码背后的设计意图。
1366
+ 你知道什么知识对开发团队最有价值 — 不是显而易见的样板代码,
1367
+ 而是体现项目独有设计决策、架构模式和工程智慧的知识。
1368
+
1369
+ ## 你的工作方式
1370
+ 1. **全局感知** → list_project_structure 了解项目结构
1371
+ 2. **定向探索** → get_file_summary 快速了解文件角色
1372
+ 3. **深入研读** → search_project_code / read_project_file 获取真实代码
1373
+ 4. **语义发现** → semantic_search_code 在知识库查找相关知识
1374
+ 5. **知识产出** → submit_candidate 提交有价值的发现
1375
+
1376
+ ## 「项目特写」= 基本用法 + 项目特征融合
1377
+ submit_candidate 的 code 字段必须是「项目特写」— 将技术的基本用法与本项目的特征融合为一体:
1378
+ 1. **项目选择了什么**: 采用了哪种写法/模式/约定
1379
+ 2. **为什么这样选**: 统计数据(N 个文件、占比 M%)
1380
+ 3. **项目禁止什么**: 被放弃的写法、反模式、显式禁用标记
1381
+ 4. **新代码怎么写**: 可直接复制使用的代码模板
1382
+
1383
+ ## 核心原则
1384
+ - 代码必须真实,来自工具返回,不可编造
1385
+ - 引用具体类名、方法名、数字,禁止「本模块」「该文件」等泛化描述
1386
+ - 质量优先于数量,证据不足宁可不提交
1387
+ - 高效利用步数 (≤${budget.maxIterations} 轮)`;
1388
+ }
1389
+
1390
+ /**
1391
+ * 从 LLM 响应中解析 Action 块(单条)
1392
+ *
1393
+ * 兼容多家 AI 服务商的工具调用格式:
1394
+ * 1. ```action {"tool":"...", "params":{...}} ``` — 标准格式
1395
+ * 2. ```tool_code tool_name(key="value") ``` — Gemini 常用
1396
+ * 3. ```python / ```javascript 围栏内函数调用 — 各家偶发
1397
+ * 4. Action: tool_name / Action Input: {...} — ReAct (GPT/DeepSeek)
1398
+ * 5. <tool_call>{"name":"...", "arguments":{...}}</tool_call> — 训练遗留 XML
1399
+ * 6. ```json {"name":"...", "arguments":{...}} ``` — GPT function_call 文本化
1400
+ * 7. {"tool":"...", "params":{...}} 裸 JSON — 通用降级
1401
+ * 8. response 末尾裸函数调用 tool_name(key="value") — 通用降级
769
1402
  */
770
1403
  #parseAction(response) {
771
1404
  if (!response) return null;
772
1405
 
773
- // 尝试匹配 ```action ... ``` 代码块
1406
+ // ── 1. 标准 ```action {...} ``` ──
774
1407
  const blockMatch = response.match(/```action\s*\n?([\s\S]*?)```/);
775
1408
  if (blockMatch) {
776
- try {
777
- const parsed = JSON.parse(blockMatch[1].trim());
778
- if (parsed.tool && this.#toolRegistry.has(parsed.tool)) {
779
- return { tool: parsed.tool, params: parsed.params || {} };
1409
+ const parsed = this.#tryParseToolJson(blockMatch[1].trim());
1410
+ if (parsed) return parsed;
1411
+ }
1412
+
1413
+ // ── 2. ```tool_code fn(k=v) ``` (Gemini 常用) ──
1414
+ const toolCodeMatch = response.match(/```tool_code\s*\n?([\s\S]*?)```/);
1415
+ if (toolCodeMatch) {
1416
+ const parsed = this.#parseToolCodeBlock(toolCodeMatch[1].trim());
1417
+ if (parsed) return parsed;
1418
+ }
1419
+
1420
+ // ── 3. ```python / ```javascript / ```js 围栏内函数调用 ──
1421
+ const langFenceMatch = response.match(/```(?:python|javascript|js|typescript|ts)\s*\n?([\s\S]*?)```/);
1422
+ if (langFenceMatch) {
1423
+ const inner = langFenceMatch[1].trim();
1424
+ const parsed = this.#parseToolCodeBlock(inner);
1425
+ if (parsed) return parsed;
1426
+ // JS 对象字面量: tool_name({key: "value"})
1427
+ const jsObjMatch = inner.match(/^(\w+)\(\s*(\{[\s\S]*\})\s*\)$/s);
1428
+ if (jsObjMatch) {
1429
+ const toolName = jsObjMatch[1];
1430
+ if (this.#toolRegistry.has(toolName)) {
1431
+ try {
1432
+ let params;
1433
+ try { params = JSON.parse(jsObjMatch[2]); } catch {
1434
+ const normalized = jsObjMatch[2]
1435
+ .replace(/,\s*([}\]])/g, '$1')
1436
+ .replace(/'/g, '"')
1437
+ .replace(/([{,]\s*)(\w+)\s*:/g, '$1"$2":');
1438
+ params = JSON.parse(normalized);
1439
+ }
1440
+ return { tool: toolName, params };
1441
+ } catch { /* parse failed */ }
1442
+ }
1443
+ }
1444
+ }
1445
+
1446
+ // ── 4. ReAct: Action: tool_name\nAction Input: {...} (GPT/DeepSeek) ──
1447
+ const reactMatch = response.match(/Action\s*:\s*(\w+)\s*\n+Action\s*Input\s*:\s*([\s\S]*?)(?:\n\s*(?:Thought|Observation|$))/i);
1448
+ if (reactMatch) {
1449
+ const toolName = reactMatch[1];
1450
+ if (this.#toolRegistry.has(toolName)) {
1451
+ try {
1452
+ return { tool: toolName, params: JSON.parse(reactMatch[2].trim()) };
1453
+ } catch {
1454
+ const parsed = this.#parseToolCodeBlock(`${toolName}(${reactMatch[2].trim()})`);
1455
+ if (parsed) return parsed;
780
1456
  }
781
- } catch { /* parse failed */ }
1457
+ }
1458
+ }
1459
+ // Action/Action Input 在末尾(无后续 Thought)
1460
+ const reactEndMatch = response.match(/Action\s*:\s*(\w+)\s*\n+Action\s*Input\s*:\s*(\{[\s\S]*\})\s*$/i);
1461
+ if (reactEndMatch) {
1462
+ const toolName = reactEndMatch[1];
1463
+ if (this.#toolRegistry.has(toolName)) {
1464
+ try { return { tool: toolName, params: JSON.parse(reactEndMatch[2].trim()) }; } catch { /* ignore */ }
1465
+ }
782
1466
  }
783
1467
 
784
- // 降级: 尝试匹配 JSON-like 结构 {"tool": "...", "params": {...}}
785
- const jsonMatch = response.match(/\{\s*"tool"\s*:\s*"([^"]+)"\s*,\s*"params"\s*:\s*(\{[\s\S]*?\})\s*\}/);
1468
+ // ── 5. XML: <tool_call>...</tool_call> / <function_call>...</function_call> ──
1469
+ const xmlMatch = response.match(/<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/);
1470
+ if (xmlMatch) {
1471
+ const parsed = this.#tryParseToolJson(xmlMatch[1].trim());
1472
+ if (parsed) return parsed;
1473
+ }
1474
+ const fcMatch = response.match(/<function_call>\s*([\s\S]*?)\s*<\/function_call>/);
1475
+ if (fcMatch) {
1476
+ const parsed = this.#tryParseToolJson(fcMatch[1].trim());
1477
+ if (parsed) return parsed;
1478
+ }
1479
+
1480
+ // ── 6. ```json {...} ``` 内的 function_call 格式 ──
1481
+ const jsonFenceMatch = response.match(/```json\s*\n?([\s\S]*?)```/);
1482
+ if (jsonFenceMatch) {
1483
+ const parsed = this.#tryParseToolJson(jsonFenceMatch[1].trim());
1484
+ if (parsed) return parsed;
1485
+ }
1486
+
1487
+ // ── 7. 裸 JSON: {"tool":"..."} 或 {"name":"..."} ──
1488
+ const jsonMatch = response.match(/\{\s*"(?:tool|name|function)"\s*:\s*"([^"]+)"[\s\S]*?\}/);
786
1489
  if (jsonMatch) {
1490
+ const parsed = this.#tryParseToolJson(jsonMatch[0]);
1491
+ if (parsed) return parsed;
1492
+ }
1493
+
1494
+ // ── 8. 末尾裸函数调用: tool_name(key="value") ──
1495
+ const trailingFnMatch = response.match(/\b(\w+)\(([^)]*)\)\s*$/);
1496
+ if (trailingFnMatch) {
1497
+ const parsed = this.#parseToolCodeBlock(`${trailingFnMatch[1]}(${trailingFnMatch[2]})`);
1498
+ if (parsed) return parsed;
1499
+ }
1500
+
1501
+ return null;
1502
+ }
1503
+
1504
+ /**
1505
+ * 尝试从 JSON 文本解析工具调用
1506
+ * 兼容多种 key 命名:
1507
+ * - {"tool": "x", "params": {...}} — 标准格式
1508
+ * - {"name": "x", "arguments": {...}} — OpenAI function_call
1509
+ * - {"function": "x", "parameters": {...}} — 变体
1510
+ * - {"tool": "x", "input": {...}} — Claude 变体
1511
+ */
1512
+ #tryParseToolJson(text) {
1513
+ if (!text) return null;
1514
+ try {
1515
+ const obj = JSON.parse(text);
1516
+ const toolName = obj.tool || obj.name || obj.function;
1517
+ if (!toolName || !this.#toolRegistry.has(toolName)) return null;
1518
+ const params = obj.params || obj.arguments || obj.parameters || obj.input || {};
1519
+ return { tool: toolName, params };
1520
+ } catch { return null; }
1521
+ }
1522
+
1523
+ /**
1524
+ * 解析 tool_code 函数调用格式
1525
+ * 支持三种参数格式:
1526
+ * 1. key=value: search_project_code(query="xxx", language="objc")
1527
+ * 2. JSON 对象: read_project_file({"file_path": "Code/X.m"})
1528
+ * 3. 单字符串: read_project_file("Code/X.m")
1529
+ */
1530
+ #parseToolCodeBlock(text) {
1531
+ if (!text) return null;
1532
+ const fnMatch = text.match(/^(\w+)\((.*)\)$/s);
1533
+ if (!fnMatch) return null;
1534
+
1535
+ const toolName = fnMatch[1];
1536
+ if (!this.#toolRegistry.has(toolName)) return null;
1537
+
1538
+ const argsStr = fnMatch[2].trim();
1539
+ if (!argsStr) return { tool: toolName, params: {} };
1540
+
1541
+ // 尝试 1: key=value 格式 (Python 风格)
1542
+ const params = {};
1543
+ const argRegex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^,\s]+))/g;
1544
+ let m;
1545
+ while ((m = argRegex.exec(argsStr)) !== null) {
1546
+ params[m[1]] = m[2] ?? m[3] ?? m[4];
1547
+ }
1548
+ if (Object.keys(params).length > 0) return { tool: toolName, params };
1549
+
1550
+ // 尝试 2: JSON 对象参数 — read_project_file({"file_path": "..."})
1551
+ if (argsStr.startsWith('{')) {
787
1552
  try {
788
- const tool = jsonMatch[1];
789
- const params = JSON.parse(jsonMatch[2]);
790
- if (this.#toolRegistry.has(tool)) {
791
- return { tool, params };
1553
+ const jsonParams = JSON.parse(argsStr);
1554
+ if (typeof jsonParams === 'object' && jsonParams !== null) {
1555
+ return { tool: toolName, params: jsonParams };
792
1556
  }
793
- } catch { /* parse failed */ }
1557
+ } catch { /* not valid JSON, fall through */ }
794
1558
  }
795
1559
 
796
- return null;
1560
+ // 尝试 3: 单字符串参数 — read_project_file("Code/X.m") → 映射到首个 required 参数
1561
+ const strMatch = argsStr.match(/^["'](.+?)["']$/);
1562
+ if (strMatch) {
1563
+ const toolDef = this.#toolRegistry.getToolSchemas().find(t => t.name === toolName);
1564
+ const firstRequired = toolDef?.parameters?.required?.[0];
1565
+ if (firstRequired) {
1566
+ return { tool: toolName, params: { [firstRequired]: strMatch[1] } };
1567
+ }
1568
+ }
1569
+
1570
+ return { tool: toolName, params };
1571
+ }
1572
+
1573
+ /**
1574
+ * 从 LLM 响应中解析 Action 块(支持批量)
1575
+ *
1576
+ * 优先匹配:
1577
+ * ```batch_actions [...]```
1578
+ * 降级匹配:
1579
+ * - 多个 <tool_call> XML 标签
1580
+ * - 多个 ReAct Action 块
1581
+ * - 单条 #parseAction()
1582
+ *
1583
+ * @returns {Array<{tool:string, params:object}>|null}
1584
+ */
1585
+ #parseActions(response) {
1586
+ if (!response) return null;
1587
+
1588
+ // 1. 优先尝试 ```batch_actions``` 块
1589
+ const batchMatch = response.match(/```batch_actions\s*\n?([\s\S]*?)```/);
1590
+ if (batchMatch) {
1591
+ try {
1592
+ const arr = JSON.parse(batchMatch[1].trim());
1593
+ if (Array.isArray(arr) && arr.length > 0) {
1594
+ const valid = arr.filter(a => a.tool && this.#toolRegistry.has(a.tool));
1595
+ if (valid.length > 0) {
1596
+ return valid.map(a => ({ tool: a.tool, params: a.params || {} }));
1597
+ }
1598
+ }
1599
+ } catch { /* batch parse failed, fall through */ }
1600
+ }
1601
+
1602
+ // 2. 多个 <tool_call> XML 块 (DeepSeek/Qwen)
1603
+ const xmlMatches = [...response.matchAll(/<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g)];
1604
+ if (xmlMatches.length > 1) {
1605
+ const results = xmlMatches
1606
+ .map(m => this.#tryParseToolJson(m[1].trim()))
1607
+ .filter(Boolean);
1608
+ if (results.length > 0) return results;
1609
+ }
1610
+
1611
+ // 3. 多个 ReAct Action 块
1612
+ const reactMatches = [...response.matchAll(/Action\s*:\s*(\w+)\s*\n+Action\s*Input\s*:\s*(\{[\s\S]*?\})/gi)];
1613
+ if (reactMatches.length > 1) {
1614
+ const results = reactMatches
1615
+ .map(m => {
1616
+ const toolName = m[1];
1617
+ if (!this.#toolRegistry.has(toolName)) return null;
1618
+ try { return { tool: toolName, params: JSON.parse(m[2].trim()) }; } catch { return null; }
1619
+ })
1620
+ .filter(Boolean);
1621
+ if (results.length > 0) return results;
1622
+ }
1623
+
1624
+ // 4. 降级到单 action
1625
+ const single = this.#parseAction(response);
1626
+ return single ? [single] : null;
797
1627
  }
798
1628
 
799
1629
  /**
@@ -808,22 +1638,61 @@ ${skillSection}
808
1638
  .trim();
809
1639
  }
810
1640
 
1641
+ /**
1642
+ * 检测 AI 回复是否为「未完成的中间步骤」— 输出分析/计划文本但未实际调用工具
1643
+ *
1644
+ * Gemini 常见行为: 收到 production prompt 后先输出一段纯文本的
1645
+ * "执行计划" 或 "信号审视" 而不包含任何 action/tool_code block,
1646
+ * 导致 #parseActions() 返回 null,被误判为 final answer。
1647
+ *
1648
+ * 检测策略: 回复包含计划/分析关键词 + 不包含 dimensionDigest JSON
1649
+ */
1650
+ #looksLikeIncompleteStep(response) {
1651
+ if (!response || response.length < 100) return false;
1652
+
1653
+ // 如果已包含 dimensionDigest → 是真正的最终回答
1654
+ if (response.includes('"dimensionDigest"') || response.includes('dimensionDigest')) return false;
1655
+
1656
+ // 计划/分析性关键词 (中文 Gemini 常用)
1657
+ const planningPatterns = [
1658
+ /制定执行计划/,
1659
+ /信号质量预判/,
1660
+ /执行计划/,
1661
+ /我将按照/,
1662
+ /开始分析/,
1663
+ /我将分析/,
1664
+ /接下来我将/,
1665
+ /我来分析/,
1666
+ /首先[,,]?\s*我/,
1667
+ /\*\*0\.\s*制定/,
1668
+ /\*\*Signal\s+\d+/, // 信号列表分析
1669
+ /质量[::]\s*(高|中|低)/, // 信号质量评估
1670
+ /保留[。;]|丢弃[。;]|跳过[。;]/, // 信号去留判断
1671
+ ];
1672
+
1673
+ const matchCount = planningPatterns.filter(p => p.test(response)).length;
1674
+ return matchCount >= 2; // 至少匹配 2 个模式才认为是计划性回复
1675
+ }
1676
+
811
1677
  /**
812
1678
  * 获取工具执行上下文
1679
+ * @param {object} [extras] — 额外注入到上下文的字段(如 _sessionToolCalls)
813
1680
  */
814
- #getToolContext() {
1681
+ #getToolContext(extras) {
815
1682
  return {
816
1683
  container: this.#container,
817
1684
  aiProvider: this.#aiProvider,
818
1685
  projectRoot: this.#container?.singletons?._projectRoot || process.cwd(),
819
1686
  logger: this.#logger,
820
1687
  source: this.#currentSource,
1688
+ fileCache: this.#fileCache || null,
1689
+ ...extras,
821
1690
  };
822
1691
  }
823
1692
 
824
1693
  /**
825
1694
  * 列出可用的 Skills 及其摘要(用于系统提示词)
826
- * 加载顺序: 内置 skills/ → 项目级 .autosnippet/skills/(同名覆盖)
1695
+ * 加载顺序: 内置 skills/ → 项目级 AutoSnippet/skills/(同名覆盖)
827
1696
  * @returns {{ name: string, summary: string }[]}
828
1697
  */
829
1698
  #listAvailableSkills() {