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.
- package/bin/cli.js +1 -1
- package/dashboard/dist/assets/{icons-rnn04CvH.js → icons-Cq4-iQhP.js} +148 -88
- package/dashboard/dist/assets/index-DBxH7pVn.css +1 -0
- package/dashboard/dist/assets/index-Dw2F6qAS.js +197 -0
- package/dashboard/dist/assets/{react-markdown-CWxUbOf4.js → react-markdown-BA6FB2NP.js} +1 -1
- package/dashboard/dist/assets/{syntax-highlighter-CJ2drQQb.js → syntax-highlighter-CVLHn9O5.js} +1 -1
- package/dashboard/dist/assets/{vendor-f83ah6cm.js → vendor-BotF760a.js} +61 -61
- package/dashboard/dist/index.html +6 -6
- package/lib/bootstrap.js +1 -1
- package/lib/cli/SetupService.js +33 -8
- package/lib/cli/UpgradeService.js +139 -2
- package/lib/core/ast/ProjectGraph.js +599 -0
- package/lib/core/gateway/GatewayActionRegistry.js +2 -2
- package/lib/domain/recipe/Recipe.js +3 -0
- package/lib/external/ai/AiProvider.js +83 -20
- package/lib/external/ai/providers/ClaudeProvider.js +197 -0
- package/lib/external/ai/providers/GoogleGeminiProvider.js +235 -1
- package/lib/external/ai/providers/OpenAiProvider.js +131 -0
- package/lib/external/mcp/handlers/bootstrap/pipeline/dimension-context.js +216 -0
- package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +468 -0
- package/lib/external/mcp/handlers/bootstrap/pipeline/tier-scheduler.js +162 -0
- package/lib/external/mcp/handlers/bootstrap/skills.js +225 -0
- package/lib/external/mcp/handlers/bootstrap.js +151 -1634
- package/lib/external/mcp/handlers/browse.js +1 -1
- package/lib/external/mcp/handlers/candidate.js +1 -33
- package/lib/external/mcp/handlers/skill.js +54 -17
- package/lib/external/mcp/tools.js +4 -3
- package/lib/http/middleware/requestLogger.js +23 -4
- package/lib/http/routes/ai.js +3 -1
- package/lib/http/routes/auth.js +3 -2
- package/lib/http/routes/candidates.js +49 -25
- package/lib/http/routes/commands.js +0 -8
- package/lib/http/routes/guardRules.js +1 -16
- package/lib/http/routes/recipes.js +4 -17
- package/lib/http/routes/search.js +11 -19
- package/lib/http/routes/skills.js +2 -0
- package/lib/http/routes/snippets.js +0 -33
- package/lib/http/routes/spm.js +37 -63
- package/lib/http/utils/routeHelpers.js +31 -0
- package/lib/infrastructure/config/Paths.js +9 -0
- package/lib/infrastructure/logging/Logger.js +86 -3
- package/lib/infrastructure/realtime/RealtimeService.js +2 -5
- package/lib/infrastructure/vector/JsonVectorAdapter.js +24 -1
- package/lib/injection/ServiceContainer.js +55 -2
- package/lib/service/bootstrap/BootstrapTaskManager.js +400 -0
- package/lib/service/candidate/CandidateFileWriter.js +68 -27
- package/lib/service/candidate/CandidateService.js +156 -10
- package/lib/service/chat/AnalystAgent.js +216 -0
- package/lib/service/chat/CandidateGuardrail.js +134 -0
- package/lib/service/chat/ChatAgent.js +1036 -167
- package/lib/service/chat/ContextWindow.js +730 -0
- package/lib/service/chat/HandoffProtocol.js +180 -0
- package/lib/service/chat/ProducerAgent.js +240 -0
- package/lib/service/chat/ToolRegistry.js +149 -5
- package/lib/service/chat/tools.js +1397 -61
- package/lib/service/recipe/RecipeFileWriter.js +12 -1
- package/lib/service/skills/SignalCollector.js +31 -6
- package/lib/service/skills/SkillAdvisor.js +2 -1
- package/lib/service/skills/SkillHooks.js +13 -5
- package/lib/service/spm/SpmService.js +2 -2
- package/package.json +1 -1
- package/templates/copilot-instructions.md +20 -3
- package/templates/cursor-rules/autosnippet-conventions.mdc +21 -4
- package/templates/cursor-rules/autosnippet-skills.mdc +45 -0
- package/dashboard/dist/assets/index-BBKa3Dgi.js +0 -195
- package/dashboard/dist/assets/index-DLsECfzW.css +0 -1
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContextWindow — ChatAgent 的上下文窗口管理器
|
|
3
|
+
*
|
|
4
|
+
* 业界最佳实践:
|
|
5
|
+
* - OpenAI Compaction: 阈值触发自动压缩,保留关键上下文
|
|
6
|
+
* - LangChain trim_messages: 按 token 裁剪,保证消息合法性
|
|
7
|
+
* - Anthropic 长上下文: 长文档前置,查询后置
|
|
8
|
+
* - Gemini API: functionResponse 必须紧跟 functionCall
|
|
9
|
+
*
|
|
10
|
+
* 设计不变量:
|
|
11
|
+
* 1. messages[0] 始终是原始 user prompt(不可删除)
|
|
12
|
+
* 2. assistant(toolCalls) 与其 tool results 是原子单元(不可拆分)
|
|
13
|
+
* 3. 每次 AI 调用前自动压缩到 TOKEN_BUDGET 以内
|
|
14
|
+
* 4. 不通过追加 user 消息来控制 AI 行为(由 PhaseRouter 管理)
|
|
15
|
+
*
|
|
16
|
+
* 三级递进压缩:
|
|
17
|
+
* L1 (60-80%): 截断旧的 tool results 内容
|
|
18
|
+
* L2 (80-95%): 摘要历史轮次,保留最后 2 轮完整链
|
|
19
|
+
* L3 (>95%): 仅保留 prompt + 最后 1 轮 + 已提交列表
|
|
20
|
+
*
|
|
21
|
+
* @module ContextWindow
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import Logger from '../../infrastructure/logging/Logger.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 一组相关消息的原子单元:
|
|
28
|
+
* - assistant(toolCalls) + 所有后续 tool results
|
|
29
|
+
* - 或单独的 user/assistant 文本消息
|
|
30
|
+
*
|
|
31
|
+
* @typedef {Object} MessageRound
|
|
32
|
+
* @property {'tool-round'|'text'} type
|
|
33
|
+
* @property {Array<Object>} messages - 本轮包含的消息
|
|
34
|
+
* @property {number} estimatedTokens - 估算 token 数
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export class ContextWindow {
|
|
38
|
+
/** @type {Array<Object>} 统一格式消息 */
|
|
39
|
+
#messages = [];
|
|
40
|
+
/** @type {number} token 预算(默认 24000,约对应 Gemini 的安全阈值) */
|
|
41
|
+
#tokenBudget;
|
|
42
|
+
/** @type {Array<string>} 被压缩掉的轮次摘要(用于 digest 生成) */
|
|
43
|
+
#compactionLog = [];
|
|
44
|
+
/** @type {Set<string>} 被压缩前提取的已提交候选标题 */
|
|
45
|
+
#compactedSubmits = new Set();
|
|
46
|
+
/** @type {Object} 日志器 */
|
|
47
|
+
#logger;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {number} [tokenBudget=24000] — token 预算上限
|
|
51
|
+
*/
|
|
52
|
+
constructor(tokenBudget = 24000) {
|
|
53
|
+
this.#tokenBudget = tokenBudget;
|
|
54
|
+
this.#logger = Logger.getInstance();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── 消息添加 API ──────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 追加用户消息
|
|
61
|
+
* @param {string} content
|
|
62
|
+
*/
|
|
63
|
+
appendUserMessage(content) {
|
|
64
|
+
this.#messages.push({ role: 'user', content });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 追加阶段过渡引导消息 — 轻量级 user 消息,用于在 PhaseRouter 阶段转换时
|
|
69
|
+
* 向 AI 明确传达新阶段的行为期望。与 appendUserMessage 功能相同,
|
|
70
|
+
* 独立命名以便审计和搜索。
|
|
71
|
+
* @param {string} content
|
|
72
|
+
*/
|
|
73
|
+
appendUserNudge(content) {
|
|
74
|
+
this.#messages.push({ role: 'user', content });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 追加 assistant 消息(含工具调用)
|
|
79
|
+
* @param {string|null} text — assistant 文本
|
|
80
|
+
* @param {Array} toolCalls — [{id, name, args}]
|
|
81
|
+
*/
|
|
82
|
+
appendAssistantWithToolCalls(text, toolCalls) {
|
|
83
|
+
this.#messages.push({
|
|
84
|
+
role: 'assistant',
|
|
85
|
+
content: text || null,
|
|
86
|
+
toolCalls,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 追加工具结果(必须紧跟 assistant toolCalls 后)
|
|
92
|
+
* @param {string} toolCallId
|
|
93
|
+
* @param {string} name — 工具名
|
|
94
|
+
* @param {string} content — 工具返回内容(已经过 ToolResultLimiter 截断)
|
|
95
|
+
*/
|
|
96
|
+
appendToolResult(toolCallId, name, content) {
|
|
97
|
+
this.#messages.push({
|
|
98
|
+
role: 'tool',
|
|
99
|
+
toolCallId,
|
|
100
|
+
name,
|
|
101
|
+
content,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 追加 assistant 纯文本消息(无工具调用)
|
|
107
|
+
* @param {string} text
|
|
108
|
+
*/
|
|
109
|
+
appendAssistantText(text) {
|
|
110
|
+
this.#messages.push({
|
|
111
|
+
role: 'assistant',
|
|
112
|
+
content: text,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── 压缩 API ─────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 在每次 AI 调用前调用 — 根据 token 使用率执行分级压缩
|
|
120
|
+
*
|
|
121
|
+
* @returns {{ level: number, removed: number }} 压缩结果
|
|
122
|
+
*/
|
|
123
|
+
compactIfNeeded() {
|
|
124
|
+
const usage = this.getTokenUsageRatio();
|
|
125
|
+
|
|
126
|
+
if (usage < 0.6 || this.#messages.length <= 4) {
|
|
127
|
+
return { level: 0, removed: 0 };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (usage < 0.8) {
|
|
131
|
+
return this.#compactL1();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (usage < 0.95) {
|
|
135
|
+
return this.#compactL2();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return this.#compactL3();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* L1 压缩: 截断旧轮次的工具结果内容
|
|
143
|
+
* 仅缩短 text 长度,不删除消息
|
|
144
|
+
*/
|
|
145
|
+
#compactL1() {
|
|
146
|
+
const TRUNCATE_THRESHOLD = 2000; // 超过此长度的 tool result 截断
|
|
147
|
+
const TRUNCATE_TO = 500;
|
|
148
|
+
let truncated = 0;
|
|
149
|
+
|
|
150
|
+
// 找到最后一个 assistant-with-toolCalls 的位置
|
|
151
|
+
const lastRoundStart = this.#findLastToolRoundStart();
|
|
152
|
+
if (lastRoundStart < 0) return { level: 1, removed: 0 };
|
|
153
|
+
|
|
154
|
+
// 只截断 lastRoundStart 之前的 tool results
|
|
155
|
+
for (let i = 1; i < lastRoundStart; i++) {
|
|
156
|
+
const msg = this.#messages[i];
|
|
157
|
+
if (msg.role === 'tool' && msg.content && msg.content.length > TRUNCATE_THRESHOLD) {
|
|
158
|
+
msg.content = msg.content.substring(0, TRUNCATE_TO) + `\n... [truncated from ${msg.content.length} chars]`;
|
|
159
|
+
truncated++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (truncated > 0) {
|
|
164
|
+
this.#logger.info(`[ContextWindow] L1 compact: truncated ${truncated} tool results`);
|
|
165
|
+
}
|
|
166
|
+
return { level: 1, removed: truncated };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* L2 压缩: 删除历史轮次,保留 prompt + 摘要 + 最后 2 轮完整链
|
|
171
|
+
* 1. 找到倒数第 2 轮 assistant(toolCalls) 的起始位置
|
|
172
|
+
* 2. 提取 messages[1..start-1] 中的已提交候选
|
|
173
|
+
* 3. 用精简的摘要占位替换
|
|
174
|
+
*/
|
|
175
|
+
#compactL2() {
|
|
176
|
+
// 找到倒数第 2 个 tool round 的起始(保留最后 2 轮)
|
|
177
|
+
const roundStarts = this.#findAllToolRoundStarts();
|
|
178
|
+
if (roundStarts.length < 2) return { level: 2, removed: 0 };
|
|
179
|
+
|
|
180
|
+
const keepFrom = roundStarts[roundStarts.length - 2]; // 保留从倒数第 2 轮开始
|
|
181
|
+
if (keepFrom <= 1) return { level: 2, removed: 0 };
|
|
182
|
+
|
|
183
|
+
return this.#spliceAndSummarize(keepFrom, 2);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* L3 压缩: 激进模式 — 仅保留 prompt + 最后 1 轮
|
|
188
|
+
*/
|
|
189
|
+
#compactL3() {
|
|
190
|
+
const lastRoundStart = this.#findLastToolRoundStart();
|
|
191
|
+
if (lastRoundStart <= 1) {
|
|
192
|
+
// 没有 tool round,保留 prompt + 最后一条消息
|
|
193
|
+
if (this.#messages.length > 3) {
|
|
194
|
+
const removed = this.#messages.splice(1, this.#messages.length - 2);
|
|
195
|
+
this.#compactionLog.push(`L3: removed ${removed.length} messages (no tool rounds)`);
|
|
196
|
+
return { level: 3, removed: removed.length };
|
|
197
|
+
}
|
|
198
|
+
return { level: 3, removed: 0 };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return this.#spliceAndSummarize(lastRoundStart, 3);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 执行 splice + summarize(L2/L3 共用)
|
|
206
|
+
* @param {number} keepFrom — 保留的消息起始位置
|
|
207
|
+
* @param {number} level — 压缩级别
|
|
208
|
+
*
|
|
209
|
+
* ⚠ 注意:此方法在 messages[1] 插入 role='user' 摘要,
|
|
210
|
+
* 与 messages[0](也是 user)形成连续同角色消息。
|
|
211
|
+
* Provider 层(GoogleGeminiProvider / ClaudeProvider)的 #convertMessages
|
|
212
|
+
* 已通过 pushOrMerge 自动合并连续同角色消息来处理此情况。
|
|
213
|
+
*/
|
|
214
|
+
#spliceAndSummarize(keepFrom, level) {
|
|
215
|
+
const removed = this.#messages.slice(1, keepFrom);
|
|
216
|
+
|
|
217
|
+
// 从被移除的消息中提取已提交候选标题
|
|
218
|
+
for (const m of removed) {
|
|
219
|
+
if (m.role === 'assistant' && m.toolCalls) {
|
|
220
|
+
for (const tc of m.toolCalls) {
|
|
221
|
+
if (tc.name === 'submit_candidate' || tc.name === 'submit_with_check') {
|
|
222
|
+
this.#compactedSubmits.add(tc.args?.title || tc.args?.category || 'untitled');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 计算历史统计
|
|
229
|
+
const toolCallCount = removed.filter(m => m.role === 'assistant' && m.toolCalls).length;
|
|
230
|
+
const toolResultCount = removed.filter(m => m.role === 'tool').length;
|
|
231
|
+
|
|
232
|
+
// Splice: 移除 messages[1..keepFrom-1]
|
|
233
|
+
this.#messages.splice(1, keepFrom - 1);
|
|
234
|
+
|
|
235
|
+
// 插入精简摘要(不包含控制指令)
|
|
236
|
+
const summaryParts = [
|
|
237
|
+
`[Context compressed: ${toolCallCount} tool rounds, ${toolResultCount} results removed]`,
|
|
238
|
+
];
|
|
239
|
+
if (this.#compactedSubmits.size > 0) {
|
|
240
|
+
summaryParts.push(`[Submitted candidates: ${[...this.#compactedSubmits].join(', ')}]`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
this.#messages.splice(1, 0, {
|
|
244
|
+
role: 'user',
|
|
245
|
+
content: summaryParts.join('\n'),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const removedCount = keepFrom - 1;
|
|
249
|
+
this.#compactionLog.push(`L${level}: removed ${removedCount} messages (${toolCallCount} rounds)`);
|
|
250
|
+
this.#logger.info(`[ContextWindow] L${level} compact: removed ${removedCount} messages, kept last ${level === 2 ? 2 : 1} rounds`);
|
|
251
|
+
|
|
252
|
+
return { level, removed: removedCount };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── 查询 API ─────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 导出消息(供 AI Provider 使用)
|
|
259
|
+
* @returns {Array<Object>}
|
|
260
|
+
*/
|
|
261
|
+
toMessages() {
|
|
262
|
+
return this.#messages;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 获取消息数量
|
|
267
|
+
* @returns {number}
|
|
268
|
+
*/
|
|
269
|
+
get length() {
|
|
270
|
+
return this.#messages.length;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 获取 token 预算
|
|
275
|
+
* @returns {number}
|
|
276
|
+
*/
|
|
277
|
+
get tokenBudget() {
|
|
278
|
+
return this.#tokenBudget;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 估算当前 token 使用量
|
|
283
|
+
* @returns {number}
|
|
284
|
+
*/
|
|
285
|
+
estimateTokens() {
|
|
286
|
+
let total = 0;
|
|
287
|
+
for (const m of this.#messages) {
|
|
288
|
+
if (m.content) total += m.content.length / 3;
|
|
289
|
+
if (m.toolCalls) total += JSON.stringify(m.toolCalls).length / 3;
|
|
290
|
+
}
|
|
291
|
+
return Math.ceil(total);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 获取 token 使用率 (0-1)
|
|
296
|
+
* @returns {number}
|
|
297
|
+
*/
|
|
298
|
+
getTokenUsageRatio() {
|
|
299
|
+
return this.estimateTokens() / this.#tokenBudget;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 获取动态工具结果配额
|
|
304
|
+
* 根据当前 token 使用率返回工具结果的大小限制
|
|
305
|
+
* @returns {{ maxChars: number, maxMatches: number }}
|
|
306
|
+
*/
|
|
307
|
+
getToolResultQuota() {
|
|
308
|
+
const usage = this.getTokenUsageRatio();
|
|
309
|
+
if (usage < 0.4) return { maxChars: 6000, maxMatches: 15 };
|
|
310
|
+
if (usage < 0.6) return { maxChars: 3000, maxMatches: 8 };
|
|
311
|
+
if (usage < 0.8) return { maxChars: 1500, maxMatches: 5 };
|
|
312
|
+
return { maxChars: 800, maxMatches: 3 };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 获取压缩日志(用于调试)
|
|
317
|
+
* @returns {Array<string>}
|
|
318
|
+
*/
|
|
319
|
+
getCompactionLog() {
|
|
320
|
+
return [...this.#compactionLog];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 获取被压缩掉的已提交候选标题
|
|
325
|
+
* @returns {Set<string>}
|
|
326
|
+
*/
|
|
327
|
+
getCompactedSubmits() {
|
|
328
|
+
return new Set(this.#compactedSubmits);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 清空消息 — 仅保留首条 prompt
|
|
333
|
+
* 用于致命错误后的恢复
|
|
334
|
+
*/
|
|
335
|
+
resetToPromptOnly() {
|
|
336
|
+
if (this.#messages.length > 1) {
|
|
337
|
+
// 提取所有已提交候选
|
|
338
|
+
for (let i = 1; i < this.#messages.length; i++) {
|
|
339
|
+
const m = this.#messages[i];
|
|
340
|
+
if (m.role === 'assistant' && m.toolCalls) {
|
|
341
|
+
for (const tc of m.toolCalls) {
|
|
342
|
+
if (tc.name === 'submit_candidate' || tc.name === 'submit_with_check') {
|
|
343
|
+
this.#compactedSubmits.add(tc.args?.title || tc.args?.category || 'untitled');
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
this.#messages.length = 1;
|
|
349
|
+
this.#compactionLog.push(`RESET: cleared all messages except prompt`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─── 内部方法 ──────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 找到最后一个 assistant(toolCalls) 的位置
|
|
357
|
+
* @returns {number} 位置索引,-1 表示找不到
|
|
358
|
+
*/
|
|
359
|
+
#findLastToolRoundStart() {
|
|
360
|
+
for (let i = this.#messages.length - 1; i >= 1; i--) {
|
|
361
|
+
if (this.#messages[i].role === 'assistant' && this.#messages[i].toolCalls?.length > 0) {
|
|
362
|
+
return i;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return -1;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 找到所有 assistant(toolCalls) 的位置(按顺序)
|
|
370
|
+
* @returns {Array<number>}
|
|
371
|
+
*/
|
|
372
|
+
#findAllToolRoundStarts() {
|
|
373
|
+
const starts = [];
|
|
374
|
+
for (let i = 1; i < this.#messages.length; i++) {
|
|
375
|
+
if (this.#messages[i].role === 'assistant' && this.#messages[i].toolCalls?.length > 0) {
|
|
376
|
+
starts.push(i);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return starts;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── ToolResultLimiter ──────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 工具结果入口限制器 — 在工具结果进入 ContextWindow 前压缩
|
|
387
|
+
*
|
|
388
|
+
* @param {string} toolName — 工具名
|
|
389
|
+
* @param {*} result — 工具原始返回
|
|
390
|
+
* @param {{ maxChars: number, maxMatches: number }} quota — 动态配额
|
|
391
|
+
* @returns {string} 压缩后的结果字符串
|
|
392
|
+
*/
|
|
393
|
+
export function limitToolResult(toolName, result, quota) {
|
|
394
|
+
const { maxChars = 4000, maxMatches = 10 } = quota;
|
|
395
|
+
|
|
396
|
+
// submit_candidate / submit_with_check 结果很短,不截断
|
|
397
|
+
if (toolName === 'submit_candidate' || toolName === 'submit_with_check') {
|
|
398
|
+
const raw = typeof result === 'string' ? result : JSON.stringify(result);
|
|
399
|
+
return raw.length > 500 ? raw.substring(0, 500) : raw;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// search_project_code: 限制匹配数 + 截断上下文
|
|
403
|
+
if (toolName === 'search_project_code') {
|
|
404
|
+
return limitSearchResult(result, maxMatches, maxChars);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// read_project_file: 限制字符数
|
|
408
|
+
if (toolName === 'read_project_file') {
|
|
409
|
+
return limitFileContent(result, maxChars);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 通用: 按字符限制
|
|
413
|
+
const raw = typeof result === 'string' ? result : JSON.stringify(result);
|
|
414
|
+
if (raw.length > maxChars) {
|
|
415
|
+
return raw.substring(0, maxChars) + `\n... [truncated, ${raw.length} total chars]`;
|
|
416
|
+
}
|
|
417
|
+
return raw;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 限制搜索结果 — 只保留 topN 匹配,每个匹配的 context 截断
|
|
422
|
+
*
|
|
423
|
+
* search_project_code 返回格式:
|
|
424
|
+
* { matches: [{ file, line, code, context, score }], total, searchedFiles }
|
|
425
|
+
*/
|
|
426
|
+
function limitSearchResult(result, maxMatches, maxChars) {
|
|
427
|
+
if (typeof result === 'string') {
|
|
428
|
+
return result.length > maxChars
|
|
429
|
+
? result.substring(0, maxChars) + '\n... [truncated]'
|
|
430
|
+
: result;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!result || typeof result !== 'object') {
|
|
434
|
+
return JSON.stringify(result || {});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 深拷贝避免修改原对象
|
|
438
|
+
const limited = { ...result };
|
|
439
|
+
if (Array.isArray(limited.matches)) {
|
|
440
|
+
limited.matches = limited.matches.slice(0, maxMatches).map(m => {
|
|
441
|
+
const copy = { ...m };
|
|
442
|
+
// 截断每个匹配的 context 字段(多行文本)
|
|
443
|
+
if (copy.context && typeof copy.context === 'string') {
|
|
444
|
+
const contextLines = copy.context.split('\n');
|
|
445
|
+
if (contextLines.length > 7) {
|
|
446
|
+
copy.context = contextLines.slice(0, 7).join('\n') + '\n... [truncated]';
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// 兼容旧格式: 也处理 lines 数组
|
|
450
|
+
if (Array.isArray(copy.lines) && copy.lines.length > 5) {
|
|
451
|
+
copy.lines = copy.lines.slice(0, 5);
|
|
452
|
+
copy._truncated = true;
|
|
453
|
+
}
|
|
454
|
+
return copy;
|
|
455
|
+
});
|
|
456
|
+
if (result.matches.length > maxMatches) {
|
|
457
|
+
limited._note = `Showing ${maxMatches} of ${result.matches.length} matches`;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const str = JSON.stringify(limited);
|
|
462
|
+
if (str.length > maxChars) {
|
|
463
|
+
return str.substring(0, maxChars) + '\n... [truncated]';
|
|
464
|
+
}
|
|
465
|
+
return str;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* 限制文件内容 — 截断 content 字段
|
|
470
|
+
*/
|
|
471
|
+
function limitFileContent(result, maxChars) {
|
|
472
|
+
if (typeof result === 'string') {
|
|
473
|
+
return result.length > maxChars
|
|
474
|
+
? result.substring(0, maxChars) + '\n... [truncated]'
|
|
475
|
+
: result;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!result || typeof result !== 'object') {
|
|
479
|
+
return JSON.stringify(result || {});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const limited = { ...result };
|
|
483
|
+
if (limited.content && limited.content.length > maxChars) {
|
|
484
|
+
const lines = limited.content.split('\n');
|
|
485
|
+
let truncated = '';
|
|
486
|
+
for (const line of lines) {
|
|
487
|
+
if (truncated.length + line.length + 1 > maxChars) break;
|
|
488
|
+
truncated += line + '\n';
|
|
489
|
+
}
|
|
490
|
+
limited.content = truncated + `... [truncated at ${maxChars} chars, total ${result.content.length}]`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return JSON.stringify(limited);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ─── PhaseRouter ────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* 阶段路由器 — 管理 Agent ReAct 循环的阶段自动推进
|
|
500
|
+
*
|
|
501
|
+
* 取代散落在 while 循环中的多个状态变量:
|
|
502
|
+
* - wrapUpInjected → PRODUCE 阶段自动处理
|
|
503
|
+
* - searchOnlyRounds → EXPLORE 阶段自动计数
|
|
504
|
+
* - searchBudgetPromptInjected → EXPLORE→PRODUCE 自动转换
|
|
505
|
+
* - idleRoundsWithoutSubmit → PRODUCE 阶段自动计数
|
|
506
|
+
*
|
|
507
|
+
* 阶段流:
|
|
508
|
+
* candidate/dual: EXPLORE → PRODUCE → SUMMARIZE
|
|
509
|
+
* skill-only: EXPLORE → SUMMARIZE
|
|
510
|
+
*/
|
|
511
|
+
export class PhaseRouter {
|
|
512
|
+
/** @type {'EXPLORE'|'PRODUCE'|'SUMMARIZE'} */
|
|
513
|
+
#phase;
|
|
514
|
+
/** @type {number} 当前阶段内的轮次计数 */
|
|
515
|
+
#phaseRounds = 0;
|
|
516
|
+
/** @type {number} 连续无 submit 的轮次 */
|
|
517
|
+
#idleRounds = 0;
|
|
518
|
+
/** @type {number} 总迭代轮次 */
|
|
519
|
+
#totalIterations = 0;
|
|
520
|
+
/** @type {number} 总 submit 数 */
|
|
521
|
+
#totalSubmits = 0;
|
|
522
|
+
/** @type {Object} 预算配置 */
|
|
523
|
+
#budget;
|
|
524
|
+
/** @type {boolean} 是否为 skill-only 维度 */
|
|
525
|
+
#isSkillOnly;
|
|
526
|
+
/** @type {Object} 日志器 */
|
|
527
|
+
#logger;
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* @param {Object} budget — 预算配置
|
|
531
|
+
* @param {boolean} isSkillOnly — 是否为 skill-only 维度
|
|
532
|
+
*/
|
|
533
|
+
constructor(budget, isSkillOnly = false) {
|
|
534
|
+
this.#budget = budget;
|
|
535
|
+
this.#isSkillOnly = isSkillOnly;
|
|
536
|
+
this.#phase = 'EXPLORE';
|
|
537
|
+
this.#logger = Logger.getInstance();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* 获取当前阶段
|
|
542
|
+
* @returns {'EXPLORE'|'PRODUCE'|'SUMMARIZE'}
|
|
543
|
+
*/
|
|
544
|
+
get phase() {
|
|
545
|
+
return this.#phase;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* 获取总迭代次数
|
|
550
|
+
* @returns {number}
|
|
551
|
+
*/
|
|
552
|
+
get totalIterations() {
|
|
553
|
+
return this.#totalIterations;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* 获取总提交数
|
|
558
|
+
* @returns {number}
|
|
559
|
+
*/
|
|
560
|
+
get totalSubmits() {
|
|
561
|
+
return this.#totalSubmits;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* 获取当前阶段的 toolChoice
|
|
566
|
+
* @returns {'required'|'auto'|'none'}
|
|
567
|
+
*/
|
|
568
|
+
getToolChoice() {
|
|
569
|
+
switch (this.#phase) {
|
|
570
|
+
case 'EXPLORE':
|
|
571
|
+
// 最后一轮 EXPLORE 用 auto 保底
|
|
572
|
+
if (this.#phaseRounds >= this.#budget.searchBudget - 1) return 'auto';
|
|
573
|
+
return 'required';
|
|
574
|
+
case 'PRODUCE':
|
|
575
|
+
return 'auto';
|
|
576
|
+
case 'SUMMARIZE':
|
|
577
|
+
return 'none';
|
|
578
|
+
default:
|
|
579
|
+
return 'auto';
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* 是否应该退出主循环
|
|
585
|
+
* @returns {boolean}
|
|
586
|
+
*/
|
|
587
|
+
shouldExit() {
|
|
588
|
+
if (this.#totalIterations >= this.#budget.maxIterations) return true;
|
|
589
|
+
if (this.#phase === 'SUMMARIZE' && this.#phaseRounds >= 2) return true;
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 每轮迭代开始时调用 — 递增计数 + 检查阶段转换
|
|
595
|
+
*/
|
|
596
|
+
tick() {
|
|
597
|
+
this.#totalIterations++;
|
|
598
|
+
this.#phaseRounds++;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* 根据本轮工具调用结果更新阶段
|
|
603
|
+
*
|
|
604
|
+
* @param {Object} roundResult — 本轮结果
|
|
605
|
+
* @param {Array} roundResult.functionCalls — AI 返回的工具调用(null 表示纯文本)
|
|
606
|
+
* @param {number} roundResult.submitCount — 本轮提交的候选数
|
|
607
|
+
* @param {boolean} roundResult.isTextOnly — AI 返回纯文本(无工具调用)
|
|
608
|
+
* @returns {{ transitioned: boolean, newPhase: string, exitReason?: string }}
|
|
609
|
+
*/
|
|
610
|
+
update(roundResult) {
|
|
611
|
+
const { functionCalls, submitCount = 0, isTextOnly = false } = roundResult;
|
|
612
|
+
this.#totalSubmits += submitCount;
|
|
613
|
+
|
|
614
|
+
// ── EXPLORE → PRODUCE/SUMMARIZE ──
|
|
615
|
+
if (this.#phase === 'EXPLORE') {
|
|
616
|
+
// 有提交 → 直接进入 PRODUCE
|
|
617
|
+
if (submitCount > 0) {
|
|
618
|
+
return this.#transitionTo(this.#isSkillOnly ? 'SUMMARIZE' : 'PRODUCE');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// 搜索轮次耗尽 → 转换
|
|
622
|
+
if (this.#phaseRounds >= this.#budget.searchBudget) {
|
|
623
|
+
const next = this.#isSkillOnly ? 'SUMMARIZE' : 'PRODUCE';
|
|
624
|
+
this.#logger.info(`[PhaseRouter] search budget exhausted (${this.#phaseRounds}/${this.#budget.searchBudget}) → ${next}`);
|
|
625
|
+
return this.#transitionTo(next);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// 文本回复(非工具调用)→ AI 可能在总结 → 转换
|
|
629
|
+
if (isTextOnly) {
|
|
630
|
+
const next = this.#isSkillOnly ? 'SUMMARIZE' : 'PRODUCE';
|
|
631
|
+
return this.#transitionTo(next);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return { transitioned: false, newPhase: this.#phase };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ── PRODUCE → SUMMARIZE ──
|
|
638
|
+
if (this.#phase === 'PRODUCE') {
|
|
639
|
+
if (submitCount > 0) {
|
|
640
|
+
this.#idleRounds = 0;
|
|
641
|
+
} else {
|
|
642
|
+
this.#idleRounds++;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// 硬上限
|
|
646
|
+
if (this.#budget.maxSubmits > 0 && this.#totalSubmits >= this.#budget.maxSubmits) {
|
|
647
|
+
this.#logger.info(`[PhaseRouter] hard submit cap reached (${this.#totalSubmits}/${this.#budget.maxSubmits}) → SUMMARIZE`);
|
|
648
|
+
return this.#transitionTo('SUMMARIZE');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// 连续空转
|
|
652
|
+
if (this.#totalSubmits > 0 && this.#idleRounds >= this.#budget.idleRoundsToExit) {
|
|
653
|
+
this.#logger.info(`[PhaseRouter] idle rounds (${this.#idleRounds}) → SUMMARIZE`);
|
|
654
|
+
return this.#transitionTo('SUMMARIZE');
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// 文本回复 — AI 可能在提交间分析或被引导提交
|
|
658
|
+
// 只在以下情况下认为 AI 主动结束:
|
|
659
|
+
// 1. 已有至少 1 次提交 (softSubmitLimit < totalSubmits 说明 AI 认为足够)
|
|
660
|
+
// 2. 或已在 PRODUCE 阶段停留多轮但 0 提交 (通过 searchBudgetGrace 兜底)
|
|
661
|
+
// 否则视为 AI 的中间分析,不立即退出
|
|
662
|
+
if (isTextOnly) {
|
|
663
|
+
if (this.#totalSubmits >= this.#budget.softSubmitLimit) {
|
|
664
|
+
this.#logger.info(`[PhaseRouter] text reply after ${this.#totalSubmits} submits (≥ softLimit=${this.#budget.softSubmitLimit}) → SUMMARIZE`);
|
|
665
|
+
return this.#transitionTo('SUMMARIZE');
|
|
666
|
+
}
|
|
667
|
+
// 未达 softLimit — 空转已在上方通用分支计数,不再额外递增
|
|
668
|
+
this.#logger.info(`[PhaseRouter] text reply in PRODUCE — idleRounds=${this.#idleRounds}, totalSubmits=${this.#totalSubmits}, continuing`);
|
|
669
|
+
return { transitioned: false, newPhase: this.#phase };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// PRODUCE 阶段容忍期耗尽且 0 提交 → 强制进入 SUMMARIZE
|
|
673
|
+
// 使用 PRODUCE 阶段内的轮次计数,而非 totalIterations
|
|
674
|
+
if (this.#phaseRounds >= this.#budget.searchBudgetGrace && this.#totalSubmits === 0) {
|
|
675
|
+
this.#logger.info(`[PhaseRouter] PRODUCE grace exhausted (${this.#phaseRounds}/${this.#budget.searchBudgetGrace} rounds, 0 submits) → SUMMARIZE`);
|
|
676
|
+
return this.#transitionTo('SUMMARIZE');
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return { transitioned: false, newPhase: this.#phase };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// SUMMARIZE 阶段不转换
|
|
683
|
+
return { transitioned: false, newPhase: this.#phase };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* 获取当前阶段的系统提示(注入到 systemPrompt 中,而非追加 user 消息)
|
|
688
|
+
* @returns {string|null}
|
|
689
|
+
*/
|
|
690
|
+
getPhaseHint() {
|
|
691
|
+
switch (this.#phase) {
|
|
692
|
+
case 'EXPLORE':
|
|
693
|
+
if (this.#phaseRounds >= this.#budget.searchBudget - 2) {
|
|
694
|
+
return `搜索预算即将耗尽 (${this.#phaseRounds}/${this.#budget.searchBudget}),请准备提交候选或产出摘要。`;
|
|
695
|
+
}
|
|
696
|
+
return null;
|
|
697
|
+
|
|
698
|
+
case 'PRODUCE':
|
|
699
|
+
if (this.#totalSubmits === 0 && this.#phaseRounds >= 1) {
|
|
700
|
+
return this.#isSkillOnly
|
|
701
|
+
? '你已收集足够信息,请在回复中直接输出 dimensionDigest JSON。'
|
|
702
|
+
: '⚠️ 探索阶段已结束。你已收集了足够的项目信息,请 **立即** 调用 submit_candidate 提交候选。不要继续搜索,直接提交。';
|
|
703
|
+
}
|
|
704
|
+
if (this.#totalSubmits >= this.#budget.softSubmitLimit && this.#budget.softSubmitLimit > 0) {
|
|
705
|
+
const remaining = this.#budget.maxSubmits - this.#totalSubmits;
|
|
706
|
+
return `已提交 ${this.#totalSubmits} 个候选(上限 ${this.#budget.maxSubmits})。${remaining > 0 ? `还可提交 ${remaining} 个。` : ''}如果还有值得记录的发现可以继续提交,否则请产出 dimensionDigest 总结。\n⚠️ 如果还有未处理的信号,请在 dimensionDigest 的 remainingTasks 字段中标记,下次运行时会续传。`;
|
|
707
|
+
}
|
|
708
|
+
return null;
|
|
709
|
+
|
|
710
|
+
case 'SUMMARIZE':
|
|
711
|
+
return null; // SUMMARIZE 阶段不注入额外 hint
|
|
712
|
+
|
|
713
|
+
default:
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* 执行阶段转换
|
|
720
|
+
* @private
|
|
721
|
+
*/
|
|
722
|
+
#transitionTo(newPhase) {
|
|
723
|
+
const oldPhase = this.#phase;
|
|
724
|
+
this.#phase = newPhase;
|
|
725
|
+
this.#phaseRounds = 0;
|
|
726
|
+
this.#idleRounds = 0;
|
|
727
|
+
this.#logger.info(`[PhaseRouter] ${oldPhase} → ${newPhase} (iter=${this.#totalIterations}, submits=${this.#totalSubmits})`);
|
|
728
|
+
return { transitioned: true, newPhase };
|
|
729
|
+
}
|
|
730
|
+
}
|