autosnippet 2.5.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 (72) hide show
  1. package/bin/cli.js +35 -0
  2. package/dashboard/dist/assets/{icons-Dtm0E6DS.js → icons-Cq4-iQhP.js} +152 -87
  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/Gateway.js +19 -4
  14. package/lib/core/gateway/GatewayActionRegistry.js +2 -2
  15. package/lib/domain/recipe/Recipe.js +3 -0
  16. package/lib/external/ai/AiProvider.js +117 -10
  17. package/lib/external/ai/providers/ClaudeProvider.js +197 -0
  18. package/lib/external/ai/providers/GoogleGeminiProvider.js +235 -1
  19. package/lib/external/ai/providers/OpenAiProvider.js +131 -0
  20. package/lib/external/mcp/McpServer.js +2 -1
  21. package/lib/external/mcp/handlers/bootstrap/pipeline/dimension-context.js +216 -0
  22. package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +468 -0
  23. package/lib/external/mcp/handlers/bootstrap/pipeline/tier-scheduler.js +162 -0
  24. package/lib/external/mcp/handlers/bootstrap/skills.js +225 -0
  25. package/lib/external/mcp/handlers/bootstrap.js +151 -1634
  26. package/lib/external/mcp/handlers/browse.js +1 -1
  27. package/lib/external/mcp/handlers/candidate.js +1 -33
  28. package/lib/external/mcp/handlers/skill.js +126 -31
  29. package/lib/external/mcp/tools.js +25 -3
  30. package/lib/http/middleware/requestLogger.js +23 -4
  31. package/lib/http/routes/ai.js +3 -1
  32. package/lib/http/routes/auth.js +3 -2
  33. package/lib/http/routes/candidates.js +49 -25
  34. package/lib/http/routes/commands.js +0 -8
  35. package/lib/http/routes/guardRules.js +1 -16
  36. package/lib/http/routes/recipes.js +4 -17
  37. package/lib/http/routes/search.js +16 -22
  38. package/lib/http/routes/skills.js +40 -3
  39. package/lib/http/routes/snippets.js +0 -33
  40. package/lib/http/routes/spm.js +37 -63
  41. package/lib/http/utils/routeHelpers.js +31 -0
  42. package/lib/infrastructure/audit/AuditStore.js +18 -0
  43. package/lib/infrastructure/config/Paths.js +9 -0
  44. package/lib/infrastructure/logging/Logger.js +86 -3
  45. package/lib/infrastructure/realtime/RealtimeService.js +2 -5
  46. package/lib/infrastructure/vector/JsonVectorAdapter.js +24 -1
  47. package/lib/injection/ServiceContainer.js +62 -3
  48. package/lib/service/bootstrap/BootstrapTaskManager.js +400 -0
  49. package/lib/service/candidate/CandidateFileWriter.js +68 -27
  50. package/lib/service/candidate/CandidateService.js +156 -10
  51. package/lib/service/chat/AnalystAgent.js +216 -0
  52. package/lib/service/chat/CandidateGuardrail.js +134 -0
  53. package/lib/service/chat/ChatAgent.js +1272 -155
  54. package/lib/service/chat/ContextWindow.js +730 -0
  55. package/lib/service/chat/ConversationStore.js +377 -0
  56. package/lib/service/chat/HandoffProtocol.js +180 -0
  57. package/lib/service/chat/Memory.js +40 -10
  58. package/lib/service/chat/ProducerAgent.js +240 -0
  59. package/lib/service/chat/ToolRegistry.js +149 -5
  60. package/lib/service/chat/tools.js +1493 -60
  61. package/lib/service/recipe/RecipeFileWriter.js +12 -1
  62. package/lib/service/skills/EventAggregator.js +187 -0
  63. package/lib/service/skills/SignalCollector.js +549 -0
  64. package/lib/service/skills/SkillAdvisor.js +324 -0
  65. package/lib/service/skills/SkillHooks.js +13 -5
  66. package/lib/service/spm/SpmService.js +2 -2
  67. package/package.json +1 -1
  68. package/templates/copilot-instructions.md +20 -3
  69. package/templates/cursor-rules/autosnippet-conventions.mdc +21 -4
  70. package/templates/cursor-rules/autosnippet-skills.mdc +45 -0
  71. package/dashboard/dist/assets/index-B7VpZOCz.css +0 -1
  72. package/dashboard/dist/assets/index-D87IZTmZ.js +0 -187
@@ -28,12 +28,86 @@ import { fileURLToPath } from 'node:url';
28
28
  import Logger from '../../infrastructure/logging/Logger.js';
29
29
  import { TaskPipeline } from './TaskPipeline.js';
30
30
  import { Memory } from './Memory.js';
31
+ import { ConversationStore } from './ConversationStore.js';
32
+ import { ContextWindow, PhaseRouter, limitToolResult } from './ContextWindow.js';
31
33
 
32
34
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
33
35
  const PROJECT_ROOT = path.resolve(__dirname, '../../..');
34
36
  const SKILLS_DIR = path.resolve(PROJECT_ROOT, 'skills');
35
37
  const SOUL_PATH = path.resolve(PROJECT_ROOT, 'SOUL.md');
36
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 字段写「项目特写」风格: 描述和代码交织,用项目真实类名和代码。`;
37
111
 
38
112
  export class ChatAgent {
39
113
  #toolRegistry;
@@ -46,6 +120,14 @@ export class ChatAgent {
46
120
  #projectBriefingCache = '';
47
121
  /** @type {Memory|null} 跨对话轻量记忆 */
48
122
  #memory = null;
123
+ /** @type {ConversationStore|null} 对话持久化 */
124
+ #conversations = null;
125
+ /** @type {string|null} 当前 execute 调用的 source — 'user' | 'system' */
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();
49
131
 
50
132
  /**
51
133
  * @param {object} opts
@@ -62,11 +144,18 @@ export class ChatAgent {
62
144
  /** 是否有 AI Provider(只读) */
63
145
  this.hasAI = !!aiProvider;
64
146
 
65
- // 初始化跨对话记忆
147
+ /**
148
+ * 是否有真实(非 Mock)AI Provider
149
+ * MockProvider 不具备实际推理能力,bootstrap 编排时应视为 AI 不可用
150
+ */
151
+ this.hasRealAI = !!aiProvider && aiProvider.name !== 'mock';
152
+
153
+ // 初始化跨对话记忆 + 对话持久化
66
154
  try {
67
155
  const projectRoot = container?.singletons?._projectRoot || process.cwd();
68
156
  this.#memory = new Memory(projectRoot);
69
- } catch { /* Memory init failed, degrade silently */ }
157
+ this.#conversations = new ConversationStore(projectRoot);
158
+ } catch { /* Memory/ConversationStore init failed, degrade silently */ }
70
159
 
71
160
  // 注册内置 DAG 管线
72
161
  this.#registerBuiltinPipelines();
@@ -74,6 +163,22 @@ export class ChatAgent {
74
163
 
75
164
  // ─── 公共 API ─────────────────────────────────────────
76
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
+
77
182
  /**
78
183
  * 交互式对话(Dashboard Chat 入口)
79
184
  * 自动带 ReAct 循环: LLM 可决定调用工具或直接回答
@@ -81,88 +186,674 @@ export class ChatAgent {
81
186
  * @param {string} prompt — 用户消息
82
187
  * @param {object} opts
83
188
  * @param {Array} opts.history — 对话历史 [{role, content}]
84
- * @returns {Promise<{reply: string, toolCalls: Array, hasContext: boolean}>}
189
+ * @param {string} [opts.conversationId] 对话 ID(启用持久化时)
190
+ * @param {'user'|'system'} [opts.source='user'] — 调用来源(影响 Memory 隔离)
191
+ * @param {object} [opts.dimensionMeta] — Bootstrap 维度元数据 { id, outputType, allowedKnowledgeTypes }
192
+ * @returns {Promise<{reply: string, toolCalls: Array, hasContext: boolean, conversationId?: string}>}
85
193
  */
86
- async execute(prompt, { history = [] } = {}) {
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
+ } = {}) {
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 };
210
+
211
+ // 对话持久化: 如果传了 conversationId,从 ConversationStore 加载历史
212
+ let effectiveHistory = history;
213
+ if (conversationId && this.#conversations) {
214
+ effectiveHistory = this.#conversations.load(conversationId);
215
+ this.#logger.info(`[ChatAgent] loaded ${effectiveHistory.length} messages from conversation store`);
216
+ this.#conversations.append(conversationId, { role: 'user', content: prompt });
217
+ }
218
+
87
219
  // 每次对话刷新项目概况(不是每轮 ReAct)
88
220
  this.#projectBriefingCache = await this.#buildProjectBriefing();
89
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 }) {
90
698
  const toolSchemas = this.#toolRegistry.getToolSchemas();
91
699
  const systemPrompt = this.#buildSystemPrompt(toolSchemas);
92
700
 
93
- // 首次 LLM 调用
94
701
  const messages = [
95
- ...history,
702
+ ...effectiveHistory,
96
703
  { role: 'user', content: prompt },
97
704
  ];
98
705
 
99
706
  const toolCalls = [];
100
707
  let iterations = 0;
101
708
  let currentPrompt = prompt;
709
+ let consecutiveAiErrors = 0;
710
+ const maxIter = source === 'system' ? MAX_ITERATIONS_SYSTEM : MAX_ITERATIONS;
102
711
 
103
- while (iterations < MAX_ITERATIONS) {
712
+ while (iterations < maxIter) {
104
713
  iterations++;
714
+ const iterStartTime = Date.now();
105
715
 
106
- const response = await this.#aiProvider.chat(currentPrompt, {
107
- history: messages.slice(0, -1), // 不含最新 user prompt
108
- systemPrompt,
109
- });
716
+ let response;
717
+ try {
718
+ this.#logger.info(`[ChatAgent] 🔄 text iteration ${iterations}/${maxIter} — calling AI (${messages.length} messages)`);
719
+ response = await this.#aiProvider.chat(currentPrompt, {
720
+ history: messages.slice(0, -1),
721
+ systemPrompt,
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}…"`);
726
+ consecutiveAiErrors = 0;
727
+ } catch (aiErr) {
728
+ consecutiveAiErrors++;
729
+ this.#logger.warn(`[ChatAgent] AI call failed (attempt ${consecutiveAiErrors}): ${aiErr.message}`);
730
+
731
+ // 熔断器已打开 → 立即返回
732
+ if (aiErr.code === 'CIRCUIT_OPEN') {
733
+ return {
734
+ reply: `抱歉,AI 服务暂时不可用(${aiErr.message})。请稍后重试,或检查 API 配置。`,
735
+ toolCalls,
736
+ hasContext: toolCalls.length > 0,
737
+ };
738
+ }
110
739
 
111
- // 尝试解析 Action
112
- const action = this.#parseAction(response);
740
+ if (consecutiveAiErrors >= 2) {
741
+ return {
742
+ reply: `抱歉,AI 服务暂时不可用(${aiErr.message})。请稍后重试,或检查 API 配置。`,
743
+ toolCalls,
744
+ hasContext: toolCalls.length > 0,
745
+ };
746
+ }
747
+ await new Promise(r => setTimeout(r, 2000));
748
+ continue;
749
+ }
750
+
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
+ }
113
772
 
114
- if (!action) {
115
- // 没有 Action → 最终回答
116
773
  const reply = this.#cleanFinalAnswer(response);
117
- this.#extractMemory(prompt, reply);
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`);
776
+
118
777
  return { reply, toolCalls, hasContext: toolCalls.length > 0 };
119
778
  }
120
779
 
121
780
  // 执行工具
122
- this.#logger.info('ChatAgent tool call', {
123
- tool: action.tool,
124
- iteration: iterations,
125
- });
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 });
784
+ }
126
785
 
127
- const toolResult = await this.#toolRegistry.execute(
128
- action.tool,
129
- action.params,
130
- this.#getToolContext(),
131
- );
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
+ });
132
792
 
133
- toolCalls.push({
134
- tool: action.tool,
135
- params: action.params,
136
- result: this.#summarizeResult(toolResult),
137
- });
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
+ }
138
817
 
139
818
  // 将工具结果注入为下一轮 prompt
140
- const observation = typeof toolResult === 'string'
141
- ? toolResult
142
- : 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
+ }
143
831
 
144
- 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.`;
145
833
 
146
- // 追加到消息历史中以保持上下文
147
834
  messages.push({ role: 'assistant', content: response });
148
835
  messages.push({ role: 'user', content: currentPrompt });
836
+
837
+ this.#condenseIfNeeded(messages);
149
838
  }
150
839
 
151
- // 达到最大迭代次数,要求 LLM 总结
840
+ // 达到最大迭代次数
152
841
  const summaryPrompt = `You have used ${iterations} tool calls. Summarize what you found and answer the user's original question: "${prompt}"`;
153
- const finalResponse = await this.#aiProvider.chat(summaryPrompt, {
154
- history: messages,
155
- systemPrompt: '直接回答用户问题,不要再调用工具。',
156
- });
842
+ let finalResponse;
843
+ try {
844
+ finalResponse = await this.#aiProvider.chat(summaryPrompt, {
845
+ history: messages,
846
+ systemPrompt: '直接回答用户问题,不要再调用工具。',
847
+ });
848
+ } catch (err) {
849
+ this.#logger.warn(`[ChatAgent] Final summary AI call failed: ${err.message}`);
850
+ finalResponse = `根据 ${toolCalls.length} 次工具调用的结果,以下是收集到的信息:\n\n` +
851
+ toolCalls.map(tc => `• ${tc.tool}: ${typeof tc.result === 'string' ? tc.result.substring(0, 200) : JSON.stringify(tc.result).substring(0, 200)}`).join('\n') +
852
+ '\n\n(注:AI 总结服务暂时不可用,上述为原始工具输出摘要)';
853
+ }
157
854
 
158
855
  const finalReply = this.#cleanFinalAnswer(finalResponse);
159
- this.#extractMemory(prompt, finalReply);
160
-
161
- return {
162
- reply: finalReply,
163
- toolCalls,
164
- hasContext: toolCalls.length > 0,
165
- };
856
+ return { reply: finalReply, toolCalls, hasContext: toolCalls.length > 0 };
166
857
  }
167
858
 
168
859
  /**
@@ -177,6 +868,40 @@ export class ChatAgent {
177
868
  return this.#toolRegistry.execute(toolName, params, this.#getToolContext());
178
869
  }
179
870
 
871
+ // ─── 对话管理 API ──────────────────────────────────────
872
+
873
+ /**
874
+ * 创建新对话(用于 Dashboard 前端)
875
+ * @param {object} [opts]
876
+ * @param {'user'|'system'} [opts.category='user']
877
+ * @param {string} [opts.title]
878
+ * @returns {string} conversationId
879
+ */
880
+ createConversation({ category = 'user', title = '' } = {}) {
881
+ if (!this.#conversations) return null;
882
+ return this.#conversations.create({ category, title });
883
+ }
884
+
885
+ /**
886
+ * 获取对话列表
887
+ * @param {object} [opts]
888
+ * @param {'user'|'system'} [opts.category]
889
+ * @param {number} [opts.limit=20]
890
+ * @returns {Array}
891
+ */
892
+ getConversations({ category, limit = 20 } = {}) {
893
+ if (!this.#conversations) return [];
894
+ return this.#conversations.list({ category, limit });
895
+ }
896
+
897
+ /**
898
+ * 获取 ConversationStore 实例(供外部使用,如 HTTP 路由)
899
+ * @returns {ConversationStore|null}
900
+ */
901
+ getConversationStore() {
902
+ return this.#conversations;
903
+ }
904
+
180
905
  /**
181
906
  * 预定义任务流
182
907
  * 将常见多步骤操作封装为一个任务名。
@@ -481,20 +1206,16 @@ ${code.substring(0, 3000)}
481
1206
  /**
482
1207
  * 注册内置 DAG 管线
483
1208
  *
484
- * 设计原则: 项目内 AI 都走 ChatAgent + tool,DAG 编排 AI 步骤。
485
- * bootstrapKnowledge() 只做启发式 Phase 1-5,不调 AI。
486
- * AI 增强步骤由 ChatAgent DAG 编排:
487
- *
488
- * bootstrap_full_pipeline:
489
- * Phase 0: bootstrap(SPM 扫描 + Skill 增强维度 + 候选创建,纯启发式)
490
- * Phase 1: enrich(AI 结构补齐,依赖 bootstrap 产出的候选 ID)
491
- * Phase 1: loadSkill(并行加载语言参考 Skill,用于润色提示)
492
- * 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 编排)
493
1214
  */
494
1215
  #registerBuiltinPipelines() {
495
- const hasAI = !!this.#aiProvider;
496
-
497
- // ── bootstrap_full_pipeline (DAG) ──────────────────────
1216
+ // ── bootstrap_full_pipeline (v6 简化版) ──────────────────
1217
+ // 只做启发式 Phase 1-5.5 (含 ChatAgent per-dimension production)
1218
+ // 不再需要 enrich/refine 后置步骤
498
1219
  this.registerPipeline(new TaskPipeline('bootstrap_full_pipeline', [
499
1220
  {
500
1221
  name: 'bootstrap',
@@ -506,69 +1227,6 @@ ${code.substring(0, 3000)}
506
1227
  loadSkills: true,
507
1228
  },
508
1229
  },
509
- {
510
- name: 'enrich',
511
- tool: 'enrich_candidate',
512
- dependsOn: ['bootstrap'],
513
- params: {
514
- candidateIds: (ctx) => {
515
- const bc = ctx._results.bootstrap?.bootstrapCandidates;
516
- return bc?.ids || bc?.candidateIds || [];
517
- },
518
- },
519
- when: (ctx) => {
520
- const ids = ctx._results.bootstrap?.bootstrapCandidates?.ids
521
- || ctx._results.bootstrap?.bootstrapCandidates?.candidateIds;
522
- return Array.isArray(ids) && ids.length > 0;
523
- },
524
- errorStrategy: 'continue',
525
- },
526
- {
527
- name: 'loadSkill',
528
- tool: 'load_skill',
529
- dependsOn: ['bootstrap'],
530
- params: {
531
- skillName: (ctx) => {
532
- const loaded = ctx._results.bootstrap?.skillsLoaded || [];
533
- return loaded.find(s => s.startsWith('autosnippet-reference-')) || 'autosnippet-coldstart';
534
- },
535
- },
536
- when: (ctx) => {
537
- const autoRefine = ctx._inputs.autoRefine;
538
- return autoRefine !== false && hasAI;
539
- },
540
- errorStrategy: 'continue',
541
- },
542
- {
543
- name: 'refine',
544
- tool: 'refine_bootstrap_candidates',
545
- dependsOn: ['enrich', 'loadSkill'],
546
- params: {
547
- userPrompt: (ctx) => {
548
- const parts = [];
549
-
550
- // Skill 业界标准参考
551
- const skillContent = ctx._results.loadSkill?.content;
552
- if (skillContent) {
553
- parts.push(`请参考以下业界最佳实践标准润色候选,确保 summary 精准、tags 丰富、confidence 合理:\n${skillContent.substring(0, 3000)}`);
554
- }
555
-
556
- // AST 代码结构分析 — 帮助 AI 理解继承体系和设计模式
557
- const astCtx = ctx._results.bootstrap?.astContext;
558
- if (astCtx) {
559
- parts.push(`\n# 项目代码结构分析 (Tree-sitter AST)\n以下是项目的 AST 分析结果,请在润色时参考类继承关系、设计模式和代码质量指标:\n${astCtx.substring(0, 2000)}`);
560
- }
561
-
562
- return parts.length > 0 ? parts.join('\n\n') : ctx._inputs.refinePrompt;
563
- },
564
- },
565
- when: (ctx) => {
566
- const autoRefine = ctx._inputs.autoRefine;
567
- const created = ctx._results.bootstrap?.bootstrapCandidates?.created || 0;
568
- return autoRefine !== false && created > 0 && hasAI;
569
- },
570
- errorStrategy: 'continue',
571
- },
572
1230
  ]));
573
1231
  }
574
1232
 
@@ -576,14 +1234,41 @@ ${code.substring(0, 3000)}
576
1234
 
577
1235
  /**
578
1236
  * 构建系统提示词(含工具描述 + Skills 感知)
1237
+ *
1238
+ * 工具注入策略(Lazy Tool Schema — 类似 Cline .clinerules 按需加载):
1239
+ * - 首屏只注入工具名 + 一行描述(compact list)
1240
+ * - 系统提示词中告知 LLM 可通过 get_tool_details 获取完整参数
1241
+ * - 少量核心工具(search_project_code, read_project_file, search_knowledge,
1242
+ * submit_with_check, analyze_code, bootstrap_knowledge, load_skill,
1243
+ * suggest_skills)直接展开完整 schema
1244
+ *
1245
+ * 效果: 44 个工具的 prompt 从 ~5000 tokens 降到 ~1500 tokens
579
1246
  */
580
1247
  #buildSystemPrompt(toolSchemas) {
581
- const toolDescriptions = toolSchemas.map(t => {
582
- const paramsDesc = Object.entries(t.parameters.properties || {})
583
- .map(([k, v]) => ` - ${k} (${v.type}): ${v.description || ''}`)
584
- .join('\n');
585
- return `- **${t.name}**: ${t.description}\n Parameters:\n${paramsDesc || ' (none)'}`;
586
- }).join('\n\n');
1248
+ // 核心工具 使用最频繁,直接展示完整 schema
1249
+ const coreTools = new Set([
1250
+ 'search_project_code', 'read_project_file',
1251
+ 'search_knowledge', 'submit_candidate', 'submit_with_check', 'analyze_code',
1252
+ 'bootstrap_knowledge', 'load_skill', 'suggest_skills',
1253
+ 'create_skill', 'knowledge_overview', 'get_tool_details',
1254
+ 'plan_task', 'review_my_output',
1255
+ ]);
1256
+
1257
+ const compactDescriptions = [];
1258
+ const detailedDescriptions = [];
1259
+
1260
+ for (const t of toolSchemas) {
1261
+ if (coreTools.has(t.name)) {
1262
+ const paramsDesc = Object.entries(t.parameters.properties || {})
1263
+ .map(([k, v]) => ` - ${k} (${v.type}): ${v.description || ''}`)
1264
+ .join('\n');
1265
+ detailedDescriptions.push(`- **${t.name}**: ${t.description}\n Parameters:\n${paramsDesc || ' (none)'}`);
1266
+ } else {
1267
+ compactDescriptions.push(`- ${t.name}: ${t.description}`);
1268
+ }
1269
+ }
1270
+
1271
+ const toolDescriptions = `### 核心工具(完整参数)\n\n${detailedDescriptions.join('\n\n')}\n\n### 其他工具(调用 get_tool_details 获取参数详情)\n\n${compactDescriptions.join('\n')}`;
587
1272
 
588
1273
  // Skills 清单 — 让 LLM 知道有哪些领域知识可加载
589
1274
  const skillList = this.#listAvailableSkills();
@@ -602,7 +1287,7 @@ ${code.substring(0, 3000)}
602
1287
  return `${soulSection}
603
1288
  你是 AutoSnippet 项目的统一 AI 中心。项目内所有 AI 推理和分析都通过你执行。
604
1289
  你拥有 ${toolSchemas.length} 个工具覆盖知识库管理全链路:搜索、提交、审核、质量评估、Guard 检查、知识图谱、冷启动等。
605
- ${this.#projectBriefingCache}${this.#memory?.toPromptSection() || ''}
1290
+ ${this.#projectBriefingCache}${this.#memory?.toPromptSection({ source: this.#currentSource === 'system' ? undefined : 'user' }) || ''}
606
1291
  可用工具:
607
1292
 
608
1293
  ${toolDescriptions}
@@ -615,7 +1300,15 @@ ${skillSection}
615
1300
  {"tool": "tool_name", "params": {"key": "value"}}
616
1301
  \`\`\`
617
1302
 
618
- 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
+
619
1312
  4. 如果不需要工具就能回答,直接回答,不要输出 action 块。
620
1313
  5. 回答时使用用户的语言(中文/英文)。
621
1314
  6. 回答要简洁、有依据(引用工具返回的数据)。
@@ -627,68 +1320,379 @@ ${skillSection}
627
1320
  - 不确定做什么 → load_skill("autosnippet-intent")
628
1321
  8. 你可以组合多个工具完成复杂任务(如:查重 → 提交 → 质量评分 → 知识图谱关联)。
629
1322
  9. 当工具返回 _meta.confidence = "none" 时,告知用户无匹配并建议下一步,不要凭空编造。当 _meta.confidence = "low" 时,明确标注结果不确定性。
630
- 10. 优先使用组合工具(analyze_code, knowledge_overview, submit_with_check)减少调用轮次。`;
1323
+ 10. 优先使用组合工具(analyze_code, knowledge_overview, submit_with_check)减少调用轮次。
1324
+ 11. 当你发现用户在重复解释编码规范、操作约定或项目特有模式时,主动调用 suggest_skills 检查是否需要创建 Skill。如果有高优先级建议,向用户说明并在确认后调用 create_skill 创建。
1325
+ 12. 当对话中出现值得长期记忆的信息(用户偏好、项目规范、关键决策、技术栈事实),在回复中嵌入记忆标签:\`[MEMORY:type] 内容 [/MEMORY]\`,type 可选 preference/decision/context。这些标签会被自动提取并持久化,不会显示给用户。`;
631
1326
  }
632
1327
 
633
1328
  /**
634
- * 从 LLM 响应中解析 Action 块
635
- * 格式: ```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") — 通用降级
636
1402
  */
637
1403
  #parseAction(response) {
638
1404
  if (!response) return null;
639
1405
 
640
- // 尝试匹配 ```action ... ``` 代码块
1406
+ // ── 1. 标准 ```action {...} ``` ──
641
1407
  const blockMatch = response.match(/```action\s*\n?([\s\S]*?)```/);
642
1408
  if (blockMatch) {
643
- try {
644
- const parsed = JSON.parse(blockMatch[1].trim());
645
- if (parsed.tool && this.#toolRegistry.has(parsed.tool)) {
646
- 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 */ }
647
1442
  }
648
- } catch { /* parse failed */ }
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;
1456
+ }
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
+ }
1466
+ }
1467
+
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;
649
1478
  }
650
1479
 
651
- // 降级: 尝试匹配 JSON-like 结构 {"tool": "...", "params": {...}}
652
- const jsonMatch = response.match(/\{\s*"tool"\s*:\s*"([^"]+)"\s*,\s*"params"\s*:\s*(\{[\s\S]*?\})\s*\}/);
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]*?\}/);
653
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('{')) {
654
1552
  try {
655
- const tool = jsonMatch[1];
656
- const params = JSON.parse(jsonMatch[2]);
657
- if (this.#toolRegistry.has(tool)) {
658
- return { tool, params };
1553
+ const jsonParams = JSON.parse(argsStr);
1554
+ if (typeof jsonParams === 'object' && jsonParams !== null) {
1555
+ return { tool: toolName, params: jsonParams };
659
1556
  }
660
- } catch { /* parse failed */ }
1557
+ } catch { /* not valid JSON, fall through */ }
661
1558
  }
662
1559
 
663
- 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;
664
1627
  }
665
1628
 
666
1629
  /**
667
- * 清理最终回答(去除 Thought/preamble
1630
+ * 清理最终回答(去除 Thought/preamble + MEMORY 标签)
668
1631
  */
669
1632
  #cleanFinalAnswer(response) {
670
1633
  if (!response) return '';
671
- // 去除 "Final Answer:" 前缀
672
1634
  return response
673
1635
  .replace(/^(Final Answer|最终回答|Answer)\s*[::]\s*/i, '')
1636
+ .replace(/\[MEMORY:\w+\]\s*[\s\S]*?\s*\[\/MEMORY\]/g, '')
1637
+ .replace(/\n{3,}/g, '\n\n')
674
1638
  .trim();
675
1639
  }
676
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
+
677
1677
  /**
678
1678
  * 获取工具执行上下文
1679
+ * @param {object} [extras] — 额外注入到上下文的字段(如 _sessionToolCalls)
679
1680
  */
680
- #getToolContext() {
1681
+ #getToolContext(extras) {
681
1682
  return {
682
1683
  container: this.#container,
683
1684
  aiProvider: this.#aiProvider,
684
1685
  projectRoot: this.#container?.singletons?._projectRoot || process.cwd(),
685
1686
  logger: this.#logger,
1687
+ source: this.#currentSource,
1688
+ fileCache: this.#fileCache || null,
1689
+ ...extras,
686
1690
  };
687
1691
  }
688
1692
 
689
1693
  /**
690
1694
  * 列出可用的 Skills 及其摘要(用于系统提示词)
691
- * 加载顺序: 内置 skills/ → 项目级 .autosnippet/skills/(同名覆盖)
1695
+ * 加载顺序: 内置 skills/ → 项目级 AutoSnippet/skills/(同名覆盖)
692
1696
  * @returns {{ name: string, summary: string }[]}
693
1697
  */
694
1698
  #listAvailableSkills() {
@@ -772,27 +1776,92 @@ ${skillSection}
772
1776
  }
773
1777
 
774
1778
  /**
775
- * 从用户消息中提取偏好/决策写入 Memory
776
- * 使用正则匹配,不调 AI — 零延迟
1779
+ * 从对话中提取值得记忆的信息写入 Memory
1780
+ *
1781
+ * 双层策略:
1782
+ * 1. 规则快速匹配(零延迟,覆盖明确的中英文模式)
1783
+ * 2. AI 驱动提取(异步后台,从 reply 中提取 [MEMORY] 标签)
1784
+ *
1785
+ * source 隔离: 标记 memory 来源,避免系统分析污染用户记忆
777
1786
  */
778
- #extractMemory(prompt, _reply) {
1787
+ #extractMemory(prompt, reply) {
779
1788
  if (!this.#memory) return;
1789
+ const source = this.#currentSource || 'user';
1790
+
780
1791
  try {
1792
+ // ── 层 1: 规则快速匹配(中文 + 英文) ──
781
1793
  const prefPatterns = [
782
1794
  /我们(项目|团队)?(不用|不使用|禁止|避免|偏好|习惯|规范是)/,
783
1795
  /以后(都|请|要)/,
784
1796
  /记住/,
1797
+ /we\s+(don'?t|never|always|prefer|avoid)\s+use/i,
1798
+ /remember\s+(to|that)/i,
1799
+ /our\s+(convention|standard|rule)\s+is/i,
785
1800
  ];
786
1801
  if (prefPatterns.some(p => p.test(prompt))) {
787
1802
  this.#memory.append({
788
1803
  type: 'preference',
789
1804
  content: prompt.substring(0, 200),
1805
+ source,
790
1806
  ttl: 30,
791
1807
  });
792
1808
  }
1809
+
1810
+ const decisionPatterns = [
1811
+ /决定(了|用|采用|使用)/,
1812
+ /(确认|同意|通过)(了|这个方案|审核)/,
1813
+ /就(这样|这么)(做|定|办)/,
1814
+ /let'?s\s+(go\s+with|use|adopt)/i,
1815
+ /approved|confirmed|decided/i,
1816
+ ];
1817
+ if (decisionPatterns.some(p => p.test(prompt))) {
1818
+ this.#memory.append({
1819
+ type: 'decision',
1820
+ content: prompt.substring(0, 200),
1821
+ source,
1822
+ ttl: 60,
1823
+ });
1824
+ }
1825
+
1826
+ // ── 层 2: 从 AI reply 中提取 [MEMORY] 标签 ──
1827
+ // AI 可在回复中嵌入: [MEMORY:preference] 内容 [/MEMORY]
1828
+ if (reply) {
1829
+ const memoryTagRegex = /\[MEMORY:(\w+)\]\s*([\s\S]*?)\s*\[\/MEMORY\]/g;
1830
+ let match;
1831
+ while ((match = memoryTagRegex.exec(reply)) !== null) {
1832
+ const type = match[1]; // preference | decision | context
1833
+ const content = match[2].trim();
1834
+ if (content && ['preference', 'decision', 'context'].includes(type)) {
1835
+ this.#memory.append({
1836
+ type,
1837
+ content: content.substring(0, 200),
1838
+ source,
1839
+ ttl: type === 'context' ? 90 : type === 'decision' ? 60 : 30,
1840
+ });
1841
+ }
1842
+ }
1843
+ }
793
1844
  } catch { /* memory write failure is non-critical */ }
794
1845
  }
795
1846
 
1847
+ /**
1848
+ * 自动压缩过长的对话(异步后台执行)
1849
+ * 当对话消息数超过 12 条时触发 AI 摘要压缩
1850
+ */
1851
+ async #autoSummarize(conversationId) {
1852
+ if (!this.#conversations || !this.#aiProvider) return;
1853
+ try {
1854
+ const messages = this.#conversations.load(conversationId, { tokenBudget: Infinity });
1855
+ if (messages.length >= 12) {
1856
+ await this.#conversations.summarize(conversationId, {
1857
+ aiProvider: this.#aiProvider,
1858
+ });
1859
+ }
1860
+ } catch {
1861
+ // 摘要失败不影响主流程
1862
+ }
1863
+ }
1864
+
796
1865
  /**
797
1866
  * 事件驱动入口(P2 预留接口)
798
1867
  * @param {{ type: string, payload: object, source?: string }} event
@@ -800,7 +1869,7 @@ ${skillSection}
800
1869
  async executeEvent(event) {
801
1870
  const { type, payload } = event;
802
1871
  const prompt = this.#eventToPrompt(type, payload);
803
- return this.execute(prompt, { history: [] });
1872
+ return this.execute(prompt, { history: [], source: 'system' });
804
1873
  }
805
1874
 
806
1875
  #eventToPrompt(type, payload) {
@@ -816,6 +1885,54 @@ ${skillSection}
816
1885
  }
817
1886
  }
818
1887
 
1888
+ /**
1889
+ * Context Window 自动压缩(受 Cline AutoCondense 启发)
1890
+ *
1891
+ * 在 ReAct 循环中实时检测消息总 token 数。
1892
+ * 当超过 TOKEN_BUDGET 时,保留:
1893
+ * - 首条消息(可能是 system / 用户首问)
1894
+ * - 最后 4 条消息(当前推理上下文)
1895
+ * 中间消息压缩为一条摘要。
1896
+ *
1897
+ * 策略: 非阻塞、纯规则(不调 AI),避免 ReAct 循环内引入额外 AI 调用。
1898
+ */
1899
+ #condenseIfNeeded(messages, tokenBudget = 10000) {
1900
+ const estimateTokens = (text) => Math.ceil((text || '').length / 3.5);
1901
+
1902
+ let totalTokens = 0;
1903
+ for (const m of messages) totalTokens += estimateTokens(m.content);
1904
+
1905
+ if (totalTokens <= tokenBudget || messages.length <= 6) return;
1906
+
1907
+ // 保留首条 + 最后 4 条,压缩中间
1908
+ const keepTail = 4;
1909
+ const first = messages[0];
1910
+ const tail = messages.slice(-keepTail);
1911
+ const middle = messages.slice(1, -keepTail);
1912
+
1913
+ if (middle.length === 0) return;
1914
+
1915
+ // 生成摘要
1916
+ const toolCallSummary = middle
1917
+ .filter(m => m.role === 'user' && m.content.startsWith('Observation from tool'))
1918
+ .map(m => {
1919
+ const toolMatch = m.content.match(/Observation from tool "([^"]+)"/);
1920
+ return toolMatch ? toolMatch[1] : null;
1921
+ })
1922
+ .filter(Boolean);
1923
+
1924
+ const condensed = {
1925
+ role: 'system',
1926
+ content: `[上下文压缩] 省略了 ${middle.length} 条中间消息(含工具调用: ${toolCallSummary.join(', ') || '无'})。请基于最近的 observation 继续推理。`,
1927
+ };
1928
+
1929
+ // 原地修改数组
1930
+ messages.length = 0;
1931
+ messages.push(first, condensed, ...tail);
1932
+
1933
+ this.#logger.debug(`[ChatAgent] condensed ${middle.length} messages (${totalTokens} → ~${estimateTokens(first.content) + estimateTokens(condensed.content) + tail.reduce((s, m) => s + estimateTokens(m.content), 0)} tokens)`);
1934
+ }
1935
+
819
1936
  /**
820
1937
  * 截断长文本
821
1938
  */