UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

580 lines (579 loc) 23.4 kB
/** * ContextWindow — Agent 的上下文窗口管理器 * * 业界最佳实践: * - OpenAI Compaction: 阈值触发自动压缩,保留关键上下文 * - LangChain trim_messages: 按 token 裁剪,保证消息合法性 * - Anthropic 长上下文: 长文档前置,查询后置 * - Gemini API: functionResponse 必须紧跟 functionCall * * 设计不变量: * 1. messages[0] 始终是原始 user prompt(不可删除) * 2. assistant(toolCalls) 与其 tool results 是原子单元(不可拆分) * 3. 每次 AI 调用前自动压缩到 TOKEN_BUDGET 以内 * 4. 不通过追加 user 消息来控制 AI 行为(由 ExplorationTracker 管理) * * 三级递进压缩: * L1 (60-80%): 截断旧的 tool results 内容 * L2 (80-95%): 摘要历史轮次,保留最后 2 轮完整链 * L3 (>95%): 仅保留 prompt + 最后 1 轮 + 已提交列表 * * @module ContextWindow */ import Logger from '#infra/logging/Logger.js'; import { estimateTokensFast } from '#shared/token-utils.js'; /** * 一组相关消息的原子单元: * - assistant(toolCalls) + 所有后续 tool results * - 或单独的 user/assistant 文本消息 */ export class ContextWindow { /** 统一格式消息 */ #messages = []; /** token 预算(默认 24000,约对应 Gemini 的安全阈值) */ #tokenBudget; /** 被压缩掉的轮次摘要(用于 digest 生成) */ #compactionLog = []; /** 被压缩前提取的已提交候选标题 */ #compactedSubmits = new Set(); /** 日志器 */ #logger; /** * 模型名 → 上下文窗口大小映射(token 数)。 * 键为正则模式,按优先级从上到下匹配。 * 值为模型的原始上下文窗口上限。 */ static MODEL_CONTEXT_WINDOWS = [ // ── Google Gemini ── [/gemini-3/i, 1_000_000], [/gemini-2\.5/i, 1_000_000], [/gemini-2/i, 1_000_000], [/gemini-1\.5-pro/i, 1_000_000], [/gemini-1\.5-flash/i, 1_000_000], [/gemini-1\.0/i, 32_000], [/gemini/i, 1_000_000], // 未知版本回退 // ── OpenAI ── [/gpt-5\.4-(?:mini|nano)/i, 400_000], [/gpt-5/i, 1_000_000], [/gpt-4o/i, 128_000], [/gpt-4-turbo/i, 128_000], [/gpt-4-(?!turbo)/i, 8_192], [/gpt-3\.5-turbo-16k/i, 16_384], [/gpt-3\.5/i, 4_096], [/o1|o3|o4/i, 200_000], // OpenAI reasoning models // ── Anthropic ── [/claude-(?:opus|sonnet)-4[.-]6/i, 1_000_000], // Opus 4.6 / Sonnet 4.6 [/claude-.*sonnet-4/i, 200_000], [/claude-3[.-]5/i, 200_000], [/claude-3[.-]opus/i, 200_000], [/claude-3/i, 200_000], [/claude/i, 200_000], // 未知 claude 回退 // ── DeepSeek ── [/deepseek/i, 64_000], // ── 本地 Ollama ── [/llama3[.-]?[23]/i, 128_000], [/llama3/i, 8_192], [/llama/i, 4_096], [/mistral/i, 32_000], [/qwen/i, 128_000], [/phi/i, 128_000], // ── Mock(测试) ── [/mock/i, 32_000], ]; /** * 根据模型名称解析合适的 ContextWindow token 预算。 * * 策略: 取模型最大上下文窗口的一个安全分片, * - 超大窗口 (≥400k): 预算 48000(1M 级模型可容纳更多上下文) * - 大窗口 (≥200k): 预算 32000(tool schemas + system prompt 占显著空间) * - 中窗口 (≥64k): 预算 24000 * - 小窗口 (≥16k): 预算 12000 * - 微窗口 (<16k): 预算 = 窗口 × 0.7(留 30% 给 prompt/tool schema) * * @param modelName 模型名称,如 'gemini-3-flash-preview', 'gpt-5.4-mini' * @param [opts] - isSystem 为 true 时给予更高预算 * @returns 建议的 token 预算 */ static resolveTokenBudget(modelName, opts = {}) { const { isSystem = false } = opts; // 1. 查找模型上下文窗口大小 let contextSize = 32_000; // 默认回退值 if (modelName) { for (const [pattern, size] of ContextWindow.MODEL_CONTEXT_WINDOWS) { if (pattern.test(modelName)) { contextSize = size; break; } } } // 2. 按分级策略计算 token 预算 let budget; if (contextSize >= 400_000) { budget = isSystem ? 48_000 : 36_000; } else if (contextSize >= 200_000) { budget = isSystem ? 32_000 : 24_000; } else if (contextSize >= 64_000) { budget = isSystem ? 24_000 : 20_000; } else if (contextSize >= 16_000) { budget = isSystem ? 14_000 : 12_000; } else { budget = Math.floor(contextSize * (isSystem ? 0.75 : 0.65)); } return budget; } /** @param [tokenBudget=24000] token 预算上限 */ constructor(tokenBudget = 24000) { this.#tokenBudget = tokenBudget; this.#logger = Logger.getInstance(); } // ─── 消息添加 API ────────────────────────────────────── /** 追加用户消息 */ appendUserMessage(content) { this.#messages.push({ role: 'user', content }); } /** * 追加阶段过渡引导消息 — 轻量级 user 消息,用于在 ExplorationTracker 阶段转换时 * 向 AI 明确传达新阶段的行为期望。与 appendUserMessage 功能相同, * 独立命名以便审计和搜索。 */ appendUserNudge(content) { this.#messages.push({ role: 'user', content }); } /** * 追加 assistant 消息(含工具调用) * @param text assistant 文本 * @param toolCalls [{id, name, args}] */ appendAssistantWithToolCalls(text, toolCalls) { this.#messages.push({ role: 'assistant', content: text || null, toolCalls, }); } /** * 追加工具结果(必须紧跟 assistant toolCalls 后) * @param name 工具名 * @param content 工具返回内容(已经过 ToolResultLimiter 截断) */ appendToolResult(toolCallId, name, content) { this.#messages.push({ role: 'tool', toolCallId, name, content, }); } /** 追加 assistant 纯文本消息(无工具调用) */ appendAssistantText(text) { this.#messages.push({ role: 'assistant', content: text, }); } // ─── 压缩 API ───────────────────────────────────────── /** * 在每次 AI 调用前调用 — 根据 token 使用率执行分级压缩 * * @returns } 压缩结果 */ compactIfNeeded() { const usage = this.getTokenUsageRatio(); if (usage < 0.6 || this.#messages.length <= 4) { return { level: 0, removed: 0 }; } if (usage < 0.8) { return this.#compactL1(); } if (usage < 0.95) { return this.#compactL2(); } return this.#compactL3(); } /** * L1 压缩: 截断旧轮次的工具结果内容 * 仅缩短 text 长度,不删除消息 */ #compactL1() { const TRUNCATE_THRESHOLD = 2000; // 超过此长度的 tool result 截断 const TRUNCATE_TO = 500; let truncated = 0; // 找到最后一个 assistant-with-toolCalls 的位置 const lastRoundStart = this.#findLastToolRoundStart(); if (lastRoundStart < 0) { return { level: 1, removed: 0 }; } // 只截断 lastRoundStart 之前的 tool results for (let i = 1; i < lastRoundStart; i++) { const msg = this.#messages[i]; if (msg.role === 'tool' && msg.content && msg.content.length > TRUNCATE_THRESHOLD) { msg.content = `${msg.content.substring(0, TRUNCATE_TO)}\n... [truncated from ${msg.content.length} chars]`; truncated++; } } if (truncated > 0) { const afterTokens = this.estimateTokens(); const ratio = this.getTokenUsageRatio(); this.#logger.info(`[ContextWindow] L1 compact: truncated ${truncated} tool results | ` + `tokens≈${afterTokens}/${this.#tokenBudget} (${(ratio * 100).toFixed(1)}%)`); } return { level: 1, removed: truncated }; } /** * L2 压缩: 删除历史轮次,保留 prompt + 摘要 + 最后 2 轮完整链 * 1. 找到倒数第 2 轮 assistant(toolCalls) 的起始位置 * 2. 提取 messages[1..start-1] 中的已提交候选 * 3. 用精简的摘要占位替换 */ #compactL2() { // 找到倒数第 2 个 tool round 的起始(保留最后 2 轮) const roundStarts = this.#findAllToolRoundStarts(); if (roundStarts.length < 2) { return { level: 2, removed: 0 }; } const keepFrom = roundStarts[roundStarts.length - 2]; // 保留从倒数第 2 轮开始 if (keepFrom <= 1) { return { level: 2, removed: 0 }; } return this.#spliceAndSummarize(keepFrom, 2); } /** L3 压缩: 激进模式 — 仅保留 prompt + 最后 1 轮 */ #compactL3() { const lastRoundStart = this.#findLastToolRoundStart(); if (lastRoundStart <= 1) { // 没有 tool round,保留 prompt + 最后一条消息 if (this.#messages.length > 3) { const removed = this.#messages.splice(1, this.#messages.length - 2); this.#compactionLog.push(`L3: removed ${removed.length} messages (no tool rounds)`); return { level: 3, removed: removed.length }; } return { level: 3, removed: 0 }; } return this.#spliceAndSummarize(lastRoundStart, 3); } /** * 执行 splice + summarize(L2/L3 共用) * @param keepFrom 保留的消息起始位置 * @param level 压缩级别 * * ⚠ 注意:此方法在 messages[1] 插入 role='user' 摘要, * 与 messages[0](也是 user)形成连续同角色消息。 * Provider 层(GoogleGeminiProvider / ClaudeProvider)的 #convertMessages * 已通过 pushOrMerge 自动合并连续同角色消息来处理此情况。 */ #spliceAndSummarize(keepFrom, level) { const removed = this.#messages.slice(1, keepFrom); // 从被移除的消息中提取已提交候选标题 for (const m of removed) { if (m.role === 'assistant' && m.toolCalls) { for (const tc of m.toolCalls) { if (tc.name === 'submit_knowledge' || tc.name === 'submit_with_check') { this.#compactedSubmits.add(tc.args?.title || tc.args?.category || 'untitled'); } } } } // 计算历史统计 const toolCallCount = removed.filter((m) => m.role === 'assistant' && m.toolCalls).length; const toolResultCount = removed.filter((m) => m.role === 'tool').length; // Splice: 移除 messages[1..keepFrom-1] this.#messages.splice(1, keepFrom - 1); // 插入精简摘要(不包含控制指令) const summaryParts = [ `[Context compressed: ${toolCallCount} tool rounds, ${toolResultCount} results removed]`, ]; if (this.#compactedSubmits.size > 0) { summaryParts.push(`[Submitted candidates: ${[...this.#compactedSubmits].join(', ')}]`); } this.#messages.splice(1, 0, { role: 'user', content: summaryParts.join('\n'), }); const removedCount = keepFrom - 1; this.#compactionLog.push(`L${level}: removed ${removedCount} messages (${toolCallCount} rounds)`); const afterTokens = this.estimateTokens(); const ratio = this.getTokenUsageRatio(); this.#logger.info(`[ContextWindow] L${level} compact: removed ${removedCount} messages (${toolCallCount} rounds), ` + `kept last ${level === 2 ? 2 : 1} round(s) | ` + `tokens≈${afterTokens}/${this.#tokenBudget} (${(ratio * 100).toFixed(1)}%)`); return { level, removed: removedCount }; } // ─── 查询 API ───────────────────────────────────────── /** 导出消息(供 AI Provider 使用) */ toMessages() { return this.#messages; } /** 获取消息数量 */ get length() { return this.#messages.length; } /** 获取 token 预算 */ get tokenBudget() { return this.#tokenBudget; } /** 估算当前 token 使用量 */ estimateTokens() { let total = 0; for (const m of this.#messages) { if (m.content) { total += estimateTokensFast(m.content); } if (m.toolCalls) { total += estimateTokensFast(JSON.stringify(m.toolCalls)); } } return total; } /** 获取 token 使用率 (0-1) */ getTokenUsageRatio() { return this.estimateTokens() / this.#tokenBudget; } /** * 获取动态工具结果配额 * 根据当前 token 使用率返回工具结果的大小限制 * @returns } */ getToolResultQuota() { const usage = this.getTokenUsageRatio(); if (usage < 0.4) { return { maxChars: 6000, maxMatches: 15 }; } if (usage < 0.6) { return { maxChars: 3000, maxMatches: 8 }; } if (usage < 0.8) { return { maxChars: 1500, maxMatches: 5 }; } return { maxChars: 800, maxMatches: 3 }; } /** 获取压缩日志(用于调试) */ getCompactionLog() { return [...this.#compactionLog]; } /** 获取被压缩掉的已提交候选标题 */ getCompactedSubmits() { return new Set(this.#compactedSubmits); } /** * 清空消息 — 仅保留首条 prompt * 用于致命错误后的恢复 */ resetToPromptOnly() { if (this.#messages.length > 1) { // 提取所有已提交候选 this.#extractCompactedSubmits(1); this.#messages.length = 1; this.#compactionLog.push(`RESET: cleared all messages except prompt`); } } /** * Pipeline 阶段隔离 — 清空全部消息。 * * 用于 PipelineStrategy 在阶段间重置 ContextWindow: * analyze → (reset) → produce * * reactLoop 会将新阶段的 prompt 追加为 messages[0], * systemPrompt 通过 chatWithTools 参数独立传递,不受影响。 * * 保留 compactedSubmits 以支持跨阶段提交去重。 */ resetForNewStage() { this.#extractCompactedSubmits(0); this.#messages = []; this.#compactionLog.push('RESET_STAGE: cleared all messages for new pipeline stage'); } /** * 从消息中提取已提交候选到 compactedSubmits * @param fromIdx 从哪个索引开始扫描 */ #extractCompactedSubmits(fromIdx) { for (let i = fromIdx; i < this.#messages.length; i++) { const m = this.#messages[i]; if (m.role === 'assistant' && m.toolCalls) { for (const tc of m.toolCalls) { if (tc.name === 'submit_knowledge' || tc.name === 'submit_with_check') { this.#compactedSubmits.add(tc.args?.title || tc.args?.category || 'untitled'); } } } } } // ─── 内部方法 ────────────────────────────────────────── /** * 找到最后一个 assistant(toolCalls) 的位置 * @returns 位置索引,-1 表示找不到 */ #findLastToolRoundStart() { for (let i = this.#messages.length - 1; i >= 1; i--) { if (this.#messages[i].role === 'assistant' && (this.#messages[i].toolCalls?.length ?? 0) > 0) { return i; } } return -1; } /** 找到所有 assistant(toolCalls) 的位置(按顺序) */ #findAllToolRoundStarts() { const starts = []; for (let i = 1; i < this.#messages.length; i++) { if (this.#messages[i].role === 'assistant' && (this.#messages[i].toolCalls?.length ?? 0) > 0) { starts.push(i); } } return starts; } } // ─── ToolResultLimiter ────────────────────────────────── /** * 工具结果入口限制器 — 在工具结果进入 ContextWindow 前压缩 * * @param toolName 工具名 * @param result 工具原始返回 * @param quota 动态配额 * @returns 压缩后的结果字符串 */ export function limitToolResult(toolName, result, quota) { const { maxChars = 4000, maxMatches = 10 } = quota; // submit_knowledge / submit_with_check 结果很短,不截断 if (toolName === 'submit_knowledge' || toolName === 'submit_with_check') { const raw = typeof result === 'string' ? result : JSON.stringify(result); return raw.length > 500 ? raw.substring(0, 500) : raw; } // search_project_code: 限制匹配数 + 截断上下文(支持批量模式) if (toolName === 'search_project_code') { if (result && typeof result === 'object' && result.batchResults) { // 批量模式:对每个 pattern 的结果独立限制(直接操作对象,避免 stringify→parse 往返) const limited = { ...result }; const perKeyChars = Math.floor(maxChars / Object.keys(limited.batchResults).length); for (const [key, sub] of Object.entries(limited.batchResults)) { limited.batchResults[key] = limitSearchResultObj(sub, Math.min(maxMatches, 3), perKeyChars); } const raw = JSON.stringify(limited); return raw.length > maxChars ? `${raw.substring(0, maxChars)}\n... [batch truncated]` : raw; } return limitSearchResult(result, maxMatches, maxChars); } // read_project_file: 限制字符数(支持批量模式) if (toolName === 'read_project_file') { if (result && typeof result === 'object' && result.batchResults) { const raw = JSON.stringify(result); return raw.length > maxChars ? `${raw.substring(0, maxChars)}\n... [batch truncated]` : raw; } return limitFileContent(result, maxChars); } // 通用: 按字符限制 const raw = typeof result === 'string' ? result : JSON.stringify(result); if (raw.length > maxChars) { return `${raw.substring(0, maxChars)}\n... [truncated, ${raw.length} total chars]`; } return raw; } /** * 限制搜索结果 — 只保留 topN 匹配,每个匹配的 context 截断 * * search_project_code 返回格式: * { matches: [{ file, line, code, context, score }], total, searchedFiles } */ function limitSearchResult(result, maxMatches, maxChars) { if (typeof result === 'string') { return result.length > maxChars ? `${result.substring(0, maxChars)}\n... [truncated]` : result; } if (!result || typeof result !== 'object') { return JSON.stringify(result || {}); } // 深拷贝避免修改原对象 const src = result; const limited = { ...src }; if (Array.isArray(limited.matches)) { limited.matches = limited.matches.slice(0, maxMatches).map((m) => { const copy = { ...m }; // 截断每个匹配的 context 字段(多行文本) if (copy.context && typeof copy.context === 'string') { const contextLines = copy.context.split('\n'); if (contextLines.length > 7) { copy.context = `${contextLines.slice(0, 7).join('\n')}\n... [truncated]`; } } // 兼容旧格式: 也处理 lines 数组 if (Array.isArray(copy.lines) && copy.lines.length > 5) { copy.lines = copy.lines.slice(0, 5); copy._truncated = true; } return copy; }); if (src.matches.length > maxMatches) { limited._note = `Showing ${maxMatches} of ${src.matches.length} matches`; } } const str = JSON.stringify(limited); if (str.length > maxChars) { return `${str.substring(0, maxChars)}\n... [truncated]`; } return str; } /** * 限制搜索结果(返回对象) — 用于批量模式,避免 JSON.stringify → JSON.parse 往返 * 当源码含控制字符时,stringify→substring 截断会破坏 JSON 结构导致 parse 失败 */ function limitSearchResultObj(result, maxMatches, maxChars) { if (!result || typeof result !== 'object') { return (result || {}); } if (typeof result === 'string') { return { _raw: result.substring(0, maxChars) }; } const src = result; const limited = { ...src }; if (Array.isArray(limited.matches)) { limited.matches = limited.matches.slice(0, maxMatches).map((m) => { const copy = { ...m }; if (copy.context && typeof copy.context === 'string') { const contextLines = copy.context.split('\n'); if (contextLines.length > 7) { copy.context = `${contextLines.slice(0, 7).join('\n')}\n... [truncated]`; } // 按字符上限截断 context(防止单个代码块过大) if (copy.context.length > 500) { copy.context = `${copy.context.substring(0, 500)}\n... [truncated]`; } } if (Array.isArray(copy.lines) && copy.lines.length > 5) { copy.lines = copy.lines.slice(0, 5); copy._truncated = true; } return copy; }); if (src.matches.length > maxMatches) { limited._note = `Showing ${maxMatches} of ${src.matches.length} matches`; } } return limited; } /** 限制文件内容 — 截断 content 字段 */ function limitFileContent(result, maxChars) { if (typeof result === 'string') { return result.length > maxChars ? `${result.substring(0, maxChars)}\n... [truncated]` : result; } if (!result || typeof result !== 'object') { return JSON.stringify(result || {}); } const src = result; const limited = { ...src }; if (limited.content && limited.content.length > maxChars) { const lines = limited.content.split('\n'); let truncated = ''; for (const line of lines) { if (truncated.length + line.length + 1 > maxChars) { break; } truncated += `${line}\n`; } limited.content = `${truncated}... [truncated at ${maxChars} chars, total ${src.content.length}]`; } return JSON.stringify(limited); }