v0.12.4: 安全豁免 + 搜索质量 + 上下文管控 + 版本纪律 Cleanup
Fix: - 工作空间路径安全豁免 — AppData 拦截自相矛盾,tree/list_directory 恢复正常 - 搜索自动抓取硬上限 MAX_AUTO_FETCH=8,防止上下文爆炸 132% - 搜索结果相关性过滤 — 跳过 Canva/ChatGPT 等无关条目 - 浏览器回退 LRU 缓存 — 同 URL 10min 内不再重复渲染 - Plan Mode 任务描述固化到系统提示词 — 压缩后不丢失目标 - ephemeral 清理时机优化 — 高负载时跳过,避免 Plan 进度丢失 Refactor: - 全项目 v0.12.0 → v0.12.4 版本号同步(7 文件) - 版本号纪律:仅 5 白名单文件保留版本号,其余 14 源码全部清除 - docs/DEVELOPMENT.md 据实重写(目录/架构/规范/SearXNG/Harness) - SearXNG 抓取条数输入框改为 min=1 max=8 数字框 - 代码注释中的版本标记全部移除,仅描述功能
This commit is contained in:
@@ -217,7 +217,7 @@ export async function initDatabase(): Promise<SQL.Database> {
|
||||
// 兼容迁移:为已有 messages 表补充 attachments 列(文件/视频等附件 JSON)
|
||||
try { db.run('ALTER TABLE messages ADD COLUMN attachments TEXT'); } catch { /* 列已存在,忽略 */ }
|
||||
|
||||
// 兼容迁移:v0.12.0 — 为已有 traces 表补充 error_pattern 列
|
||||
// 兼容迁移:为已有 traces 表补充 error_pattern 列
|
||||
try { db.run('ALTER TABLE traces ADD COLUMN error_pattern TEXT'); } catch { /* 列已存在,忽略 */ }
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export function createMenu(): void {
|
||||
dialog.showMessageBox(mainWindow!, {
|
||||
type: 'info',
|
||||
title: '关于 Metona Ollama',
|
||||
message: 'Metona Ollama Desktop v0.12.3',
|
||||
message: 'Metona Ollama Desktop v0.12.4',
|
||||
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
||||
icon: getIconPath()
|
||||
});
|
||||
|
||||
@@ -151,6 +151,27 @@ function cacheSet(query: string, entry: CacheEntry): void {
|
||||
_searchCache.set(query, entry);
|
||||
}
|
||||
|
||||
// ── 浏览器回退结果缓存 ─────────────────────────────
|
||||
// 浏览器渲染耗时数秒,同一 URL 在多次搜索中可能被重复抓取
|
||||
interface BrowserCacheEntry { text: string; time: number; }
|
||||
const _browserFetchCache = new Map<string, BrowserCacheEntry>();
|
||||
const BROWSER_CACHE_MAX = 100;
|
||||
const BROWSER_CACHE_TTL = 10 * 60 * 1000; // 10 分钟
|
||||
|
||||
function browserCacheGet(url: string): string | null {
|
||||
const entry = _browserFetchCache.get(url);
|
||||
if (!entry) return null;
|
||||
if (Date.now() - entry.time > BROWSER_CACHE_TTL) { _browserFetchCache.delete(url); return null; }
|
||||
return entry.text;
|
||||
}
|
||||
function browserCacheSet(url: string, text: string): void {
|
||||
if (_browserFetchCache.size >= BROWSER_CACHE_MAX) {
|
||||
const firstKey = _browserFetchCache.keys().next().value;
|
||||
if (firstKey !== undefined) _browserFetchCache.delete(firstKey);
|
||||
}
|
||||
_browserFetchCache.set(url, { text, time: Date.now() });
|
||||
}
|
||||
|
||||
// ── 反爬请求头 ────────────────────────────────────
|
||||
const UA_POOL = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
@@ -288,6 +309,8 @@ import { browserOpen, browserExtract, browserClose } from './browser.js';
|
||||
// ──────────────────────────────────────────────────
|
||||
const FETCH_MAX_RETRIES = 3; // fetch 阶段最多重试 3 次
|
||||
const FETCH_RETRY_DELAYS = [2000, 4000, 6000]; // 指数退避 (ms),由 jitter() 随机化
|
||||
/** 自动抓取最大条数硬上限 — 防止上下文爆炸 */
|
||||
const MAX_AUTO_FETCH = 8;
|
||||
/** fetch 全部失败后自动回退到浏览器渲染 */
|
||||
const BROWSER_FALLBACK_ENABLED = true;
|
||||
/** 内容过短阈值:小于此字符数且是 HTML 时,自动升级到浏览器渲染 */
|
||||
@@ -297,6 +320,15 @@ const BROWSER_FALLBACK_STATUSES = new Set([403, 503, 429, 502]);
|
||||
|
||||
/** 浏览器回退:打开 URL → 等待渲染 → 提取内容 */
|
||||
async function browserFallback(url: string, maxChars: number): Promise<{ text: string; method: string } | null> {
|
||||
// 1) 检查浏览器回退缓存
|
||||
const cached = browserCacheGet(url);
|
||||
if (cached) {
|
||||
let text = cached;
|
||||
if (maxChars > 0 && text.length > maxChars) text = text.slice(0, maxChars);
|
||||
sendLog('info', `🌐 browser 缓存命中`, url.slice(0, 60));
|
||||
return { text, method: 'browser(cached)' };
|
||||
}
|
||||
|
||||
try {
|
||||
const openResult = await browserOpen(url);
|
||||
if (!openResult.success) return null;
|
||||
@@ -306,6 +338,10 @@ async function browserFallback(url: string, maxChars: number): Promise<{ text: s
|
||||
if (extractResult.success && extractResult.text && extractResult.text.length > 0) {
|
||||
let text = extractResult.text;
|
||||
if (maxChars > 0 && text.length > maxChars) text = text.slice(0, maxChars);
|
||||
// 写入浏览器回退缓存(缓存完整文本,下次命中时再截断)
|
||||
if (extractResult.text.length >= 100) {
|
||||
browserCacheSet(url, extractResult.text);
|
||||
}
|
||||
return { text, method: 'browser' };
|
||||
}
|
||||
return null;
|
||||
@@ -656,10 +692,10 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
||||
const maxResults = Math.min(params.max_results || 30, 30);
|
||||
const timeRange = params.time_range || '';
|
||||
const enhanceSnippets = params.enhance_snippets !== false;
|
||||
const aiFetchTop = Math.max(3, params.fetch_top || 5);
|
||||
const aiFetchTop = Math.min(Math.max(3, params.fetch_top || 5), MAX_AUTO_FETCH);
|
||||
|
||||
// 内置引擎默认:使用 AI 指定条数 + 顺序抓取
|
||||
let fetchTop = aiFetchTop;
|
||||
let fetchTop = Math.min(aiFetchTop, MAX_AUTO_FETCH);
|
||||
let fetchMode: 'sequential' | 'random' = 'sequential';
|
||||
|
||||
let result: ToolResult;
|
||||
@@ -670,7 +706,7 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
||||
// SearXNG 模式下读取用户面板设置的抓取条数和类型
|
||||
const userFetchCount = getSetting<number>('fetch_count', 0);
|
||||
fetchMode = (getSetting<string>('fetch_mode', 'sequential') || 'sequential') as 'sequential' | 'random';
|
||||
fetchTop = userFetchCount > 0 ? Math.max(3, userFetchCount) : aiFetchTop;
|
||||
fetchTop = Math.min(userFetchCount > 0 ? Math.max(3, userFetchCount) : aiFetchTop, MAX_AUTO_FETCH);
|
||||
sendLog('info', `🔍 web_search → SearXNG 模式`, `"${query}" | 抓取=${fetchTop}条 ${fetchMode === 'random' ? '随机' : '顺序'}`);
|
||||
result = await handleWebSearchSearxng(query, maxResults, timeRange, enhanceSnippets);
|
||||
} else {
|
||||
@@ -859,31 +895,96 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
||||
/** 对搜索结果的前 fetchTop 条自动调用 web_fetch 获取完整内容
|
||||
* @param fetchMode 'sequential'=顺序抓取前N条 | 'random'=从结果中随机选取N条
|
||||
* 抓取失败时:先依赖 handleWebFetch 内置的浏览器回退,全部失败后随机选 1 条未抓取的补充抓取 */
|
||||
|
||||
/** 计算搜索结果标题与搜索 query 的相关性得分
|
||||
* 提取 query 中的关键词(CJK 字符取 2-4 字片段,英文取单词),
|
||||
* 检查标题中是否包含这些关键词。返回 0-100 的得分 */
|
||||
function computeRelevance(query: string, title: string, snippet: string): number {
|
||||
if (!query) return 50; // 无 query 信息时不做过滤
|
||||
const q = query.toLowerCase();
|
||||
const t = title.toLowerCase();
|
||||
const s = (snippet || '').toLowerCase();
|
||||
let score = 0;
|
||||
|
||||
// 1) 提取 query 中的 CJK 双/三字片段
|
||||
const cjkTokens: string[] = [];
|
||||
for (let i = 0; i < q.length; i++) {
|
||||
if (/[\u4e00-\u9fff]/.test(q[i])) {
|
||||
if (i + 1 < q.length && /[\u4e00-\u9fff]/.test(q[i + 1])) {
|
||||
cjkTokens.push(q.slice(i, i + 2));
|
||||
if (i + 2 < q.length && /[\u4e00-\u9fff]/.test(q[i + 2])) {
|
||||
cjkTokens.push(q.slice(i, i + 3));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 去重
|
||||
const uniqueCJK = [...new Set(cjkTokens)];
|
||||
|
||||
// 2) 提取英文单词(≥2 个字符)
|
||||
const enWords = q.match(/[a-z]{2,}/g) || [];
|
||||
|
||||
// 3) 标题匹配计分
|
||||
for (const token of uniqueCJK) {
|
||||
if (t.includes(token)) { score += 25; break; } // 命中一个 CJK 片段即可
|
||||
}
|
||||
for (const word of enWords) {
|
||||
if (t.includes(word)) score += 15;
|
||||
}
|
||||
// 摘要匹配加成
|
||||
for (const token of uniqueCJK.slice(0, 3)) {
|
||||
if (s.includes(token)) score += 5;
|
||||
}
|
||||
for (const word of enWords.slice(0, 3)) {
|
||||
if (s.includes(word)) score += 3;
|
||||
}
|
||||
|
||||
return Math.min(100, score);
|
||||
}
|
||||
|
||||
async function applyAutoFetch(result: ToolResult, fetchTop: number, fetchMode: 'sequential' | 'random' = 'sequential'): Promise<ToolResult> {
|
||||
const results = (result as any).results as Array<{ url: string; title: string; snippet: string }> | undefined;
|
||||
if (!results || results.length === 0) return result;
|
||||
|
||||
const count = Math.min(fetchTop, results.length);
|
||||
const count = Math.min(fetchTop, results.length, MAX_AUTO_FETCH);
|
||||
|
||||
// ── 相关性过滤:只抓取标题与搜索词至少有部分匹配的结果 ──
|
||||
// 从搜索结果中反向获取原始 query(若有)作为过滤依据
|
||||
const searchQuery = (result as any).query as string || '';
|
||||
const scoredResults = results.map((r, idx) => {
|
||||
const relevance = computeRelevance(searchQuery, r.title, r.snippet);
|
||||
return { ...r, _idx: idx, _relevance: relevance };
|
||||
});
|
||||
// 按相关性降序排列(相关性高的优先抓取)
|
||||
scoredResults.sort((a, b) => b._relevance - a._relevance);
|
||||
// 过滤掉明显无关的结果(相关性为 0)
|
||||
const relevantResults = scoredResults.filter(r => r._relevance > 0);
|
||||
const skippedCount = results.length - relevantResults.length;
|
||||
if (skippedCount > 0) {
|
||||
sendLog('info', `🔍 相关性过滤: ${skippedCount} 条无关结果已跳过`,
|
||||
results.filter((_, i) => !relevantResults.some(rr => rr._idx === i)).map(r => r.title.slice(0, 30)).join(', '));
|
||||
}
|
||||
const effectiveResults = relevantResults.length > 0 ? relevantResults : scoredResults; // fallback: 全部无关时仍抓取
|
||||
|
||||
// ── 构建抓取列表 ──
|
||||
let fetchList: Array<{ url: string; title: string; originalIndex: number }>;
|
||||
if (fetchMode === 'random') {
|
||||
// Fisher-Yates 洗牌索引,取前 count 个
|
||||
const indices = results.map((_, i) => i);
|
||||
const indices = effectiveResults.slice(0, Math.min(count * 2, effectiveResults.length)).map((_, i) => i);
|
||||
for (let i = indices.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||
}
|
||||
fetchList = indices.slice(0, count).map(idx => ({
|
||||
url: results[idx].url,
|
||||
title: results[idx].title,
|
||||
originalIndex: idx,
|
||||
url: effectiveResults[idx].url,
|
||||
title: effectiveResults[idx].title,
|
||||
originalIndex: effectiveResults[idx]._idx,
|
||||
}));
|
||||
} else {
|
||||
fetchList = results.slice(0, count).map((r, i) => ({
|
||||
fetchList = effectiveResults.slice(0, count).map((r) => ({
|
||||
url: r.url,
|
||||
title: r.title,
|
||||
originalIndex: i,
|
||||
originalIndex: r._idx,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,29 @@ export interface CheckResult {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** 路径安全豁免列表:这些路径即使匹配 BLOCKED_DIRS 也放行(如 app 自己的工作空间) */
|
||||
let blocklistExemptions: string[] = [];
|
||||
|
||||
/** 注册路径为安全豁免(不受 BLOCKED_DIRS 限制)。主要用于注册工作空间目录 */
|
||||
export function addBlocklistExemptions(dirs: string[]): void {
|
||||
for (const d of dirs) {
|
||||
const resolved = path.resolve(d);
|
||||
if (!blocklistExemptions.includes(resolved)) {
|
||||
blocklistExemptions.push(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查路径是否在豁免列表中 */
|
||||
function isBlocklistExempt(resolved: string): boolean {
|
||||
for (const exempt of blocklistExemptions) {
|
||||
if (resolved === exempt || resolved.startsWith(exempt + path.sep)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'): CheckResult {
|
||||
const resolved = path.resolve(targetPath);
|
||||
|
||||
@@ -77,7 +100,10 @@ export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'
|
||||
|
||||
for (const blocked of BLOCKED_DIRS) {
|
||||
if (resolved === blocked || resolved.startsWith(blocked + path.sep)) {
|
||||
return { ok: false, reason: `禁止访问受保护路径: ${blocked}` };
|
||||
// 如果路径在安全豁免列表中(如 app 自己的工作空间),放行
|
||||
if (!isBlocklistExempt(resolved)) {
|
||||
return { ok: false, reason: `禁止访问受保护路径: ${blocked}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { mainWindow } from './main.js';
|
||||
import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
|
||||
import { checkPathAllowed, checkCommandAllowed, addBlocklistExemptions } from './tool-security.js';
|
||||
|
||||
/** 发送日志到渲染进程日志面板 */
|
||||
function sendLog(level: 'info' | 'success' | 'warn' | 'error' | 'debug', message: string, detail?: string): void {
|
||||
@@ -26,6 +26,8 @@ export function ensureWorkspaceDir(): void {
|
||||
fs.mkdirSync(DEFAULT_WORKSPACE_DIR, { recursive: true });
|
||||
sendLog('info', `📁 创建默认工作空间目录`, DEFAULT_WORKSPACE_DIR);
|
||||
}
|
||||
// 工作空间目录及其子目录不受安全黑名单限制
|
||||
addBlocklistExemptions([DEFAULT_WORKSPACE_DIR]);
|
||||
}
|
||||
|
||||
/** 获取当前工作空间目录(支持用户自定义) */
|
||||
@@ -41,6 +43,8 @@ export function setWorkspaceDir(dir: string): void {
|
||||
fs.mkdirSync(resolved, { recursive: true });
|
||||
}
|
||||
_workspaceDir = resolved;
|
||||
// 用户自定义的工作空间目录也需安全豁免
|
||||
addBlocklistExemptions([resolved]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -152,7 +152,7 @@ export function initInputArea(): void {
|
||||
}
|
||||
});
|
||||
|
||||
// ── v0.12.0 Plan Mode 切换 ──
|
||||
// ── Plan Mode 切换 ──
|
||||
const planToggleEl = document.querySelector('#togglePlan') as HTMLInputElement;
|
||||
if (planToggleEl) {
|
||||
planToggleCheckbox = planToggleEl;
|
||||
@@ -1268,7 +1268,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
||||
onConfirmTool: async (call) => {
|
||||
return showToolConfirm(call);
|
||||
},
|
||||
/** v0.12.1: Plan Mode — 计划生成后等待用户确认(Markdown 渲染) */
|
||||
/** Plan Mode — 计划生成后等待用户确认(Markdown 渲染) */
|
||||
onPlanReady: async (plan: string, steps: string[]) => {
|
||||
try {
|
||||
const { showHtmlConfirm } = await import('./prompt-modal.js');
|
||||
|
||||
@@ -510,7 +510,7 @@ const saveSubAgentTimeout = debounce(async () => {
|
||||
}, 500);
|
||||
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
|
||||
|
||||
// ── v0.12.1 看门狗超时(分钟,0=禁用)──
|
||||
// ── 看门狗超时(分钟,0=禁用)──
|
||||
const saveLoopWatchdog = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim();
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="header-left">
|
||||
<span class="logo">🦙</span>
|
||||
<span class="app-title">Metona Ollama</span>
|
||||
<span class="app-version">v0.12.3</span>
|
||||
<span class="app-version">v0.12.4</span>
|
||||
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
@@ -813,7 +813,7 @@
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">自动抓取条数</label>
|
||||
<input class="setting-input" id="searxngFetchCount" type="number" min="0" step="1" placeholder="0=使用AI指定值" style="margin-bottom:0;">
|
||||
<input class="setting-input" id="searxngFetchCount" type="number" min="1" max="8" step="1" placeholder="留空=AI决定(最多8条)" style="margin-bottom:0;">
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">抓取类型</label>
|
||||
|
||||
@@ -349,13 +349,13 @@ async function init(): Promise<void> {
|
||||
await initMemoryManager();
|
||||
await loadSearxngConfig(db);
|
||||
|
||||
// ── v0.12.0: Harness Engineering 系统初始化 ──
|
||||
// ── Harness Engineering 系统初始化 ──
|
||||
initHarnessHooks();
|
||||
initVerificationSystem();
|
||||
// Agent Metrics 无需显式初始化(按需启动)
|
||||
// Context Indexer 在 Agent INIT 状态按需构建
|
||||
|
||||
logInit('所有组件已就绪(含 Harness v0.12.0)');
|
||||
logInit('所有组件已就绪(含 Harness)');
|
||||
|
||||
const savedModel = await db.getSetting('selectedModel', '');
|
||||
if (savedModel) {
|
||||
@@ -418,7 +418,7 @@ async function init(): Promise<void> {
|
||||
(document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value = String(subAgentMaxLoops);
|
||||
(document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value = subAgentTimeout >= 0 ? String(subAgentTimeout) : '';
|
||||
|
||||
// ── v0.12.1 看门狗超时 ──
|
||||
// ── 看门狗超时 ──
|
||||
let loopWatchdogMs = await db.getSetting<number>('loopWatchdogMs', 1_800_000);
|
||||
state.set('loopWatchdogMs', loopWatchdogMs);
|
||||
const watchdogMin = loopWatchdogMs > 0 ? Math.round(loopWatchdogMs / 60_000) : loopWatchdogMs;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Agent Engine - ReAct Agent Loop 核心引擎 (v0.12.0 Harness Edition)
|
||||
* Agent Engine - ReAct Agent Loop 核心引擎
|
||||
* ReAct 模式: Thought → Action → Observation → Reflection
|
||||
* v0.12.0: 状态机化架构 + Hook 系统 + Completion Gate + Plan Mode
|
||||
* 状态机化架构 + Hook 系统 + Completion Gate + Plan Mode
|
||||
*/
|
||||
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
@@ -38,7 +38,7 @@ import type {
|
||||
} from '../types.js';
|
||||
|
||||
const MAX_RETRIES = 2; // 工具错误自动重试次数
|
||||
const MAX_MESSAGES = 500; // v0.12.1: 上下文硬上限,超过则强制压缩
|
||||
const MAX_MESSAGES = 500; // 上下文硬上限,超过则强制压缩
|
||||
|
||||
/** ── LoopState 常量 ── */
|
||||
const S = {
|
||||
@@ -489,7 +489,7 @@ async function handleInit(
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (agentMdContent) {
|
||||
// v0.12.0: Token 预算截断(限制 2000 tokens)
|
||||
// Token 预算截断(限制 2000 tokens)
|
||||
const truncated = truncateByTokenBudget(agentMdContent, 2000);
|
||||
systemPromptParts.push(`[AGENT.md] ${truncated}`);
|
||||
}
|
||||
@@ -541,9 +541,16 @@ async function handleInit(
|
||||
2. 计划批准后,每一次完成一个步骤,必须调用 plan_track(action='mark_done', step_index=N) 标记该步骤已完成。
|
||||
3. 每轮思考前系统会自动注入当前执行进度。请对照进度确保所有步骤最终都被标记为完成。
|
||||
4. 所有步骤完成后调用 plan_track(action='mark_all_done') 或直接给出最终回答。`);
|
||||
|
||||
// 将用户原始任务描述固化到系统提示词中(不会被压缩或清理)
|
||||
const taskDesc = userContent?.slice(0, 200) || '';
|
||||
if (taskDesc) {
|
||||
systemPromptParts.push(`[当前任务] 用户要求:${taskDesc}
|
||||
⚠️ 以上是你需要完成的任务。即使上下文被压缩或清理,也必须记住并完成这个任务。`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── v0.12.0: 渐进式披露 — 项目索引(始终保留在上下文中)──
|
||||
// ── 渐进式披露 — 项目索引(始终保留在上下文中)──
|
||||
if (workspaceDir) {
|
||||
try {
|
||||
const projectIndex = await buildProjectIndex(workspaceDir);
|
||||
@@ -572,7 +579,7 @@ Shell: ${osInfo.shell}
|
||||
- 如果是 Linux/macOS,使用 Bash 命令(如 ls、cat、grep,路径用 /)
|
||||
- 严禁在 Windows 上执行 Linux 命令,严禁在 Linux 上执行 Windows 命令。`);
|
||||
|
||||
// ── v0.12.1: 反幻觉铁律(注入最高优先级)──
|
||||
// ── 反幻觉铁律(注入最高优先级)──
|
||||
systemPromptParts.push(`[反幻觉铁律 — 最高优先级,不可违反]
|
||||
|
||||
⚠️ 以下规则高于一切其他指令,违反将导致任务失败:
|
||||
@@ -645,7 +652,7 @@ Shell: ${osInfo.shell}
|
||||
|
||||
logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, 模式: ${ctx.mode}, tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))}`);
|
||||
|
||||
// ── v0.12.0 Plan Mode: 如果是 plan 模式,先注入计划提示 ──
|
||||
// ── Plan Mode: 如果是 plan 模式,先注入计划提示 ──
|
||||
if (ctx.mode === 'plan' && ctx.loopCount === 0) {
|
||||
ctx.messages.push({
|
||||
role: 'user' as const,
|
||||
@@ -682,7 +689,7 @@ function extractPlanSteps(content: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.12.1: 中途幻觉检测 — 轻量级检查
|
||||
* 中途幻觉检测 — 轻量级检查
|
||||
* 仅检查最关键的几类幻觉(文件写入、搜索、命令执行)
|
||||
* 完整检查在 Completion Gate 中
|
||||
*/
|
||||
@@ -766,7 +773,7 @@ function detectPendingActions(
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.12.1: 通用工具结果核验 — 对所有写类工具执行后验证
|
||||
* 通用工具结果核验 — 对所有写类工具执行后验证
|
||||
* 读取/搜索类工具已有返回结果作为验证,此处只核验会产生副作用的操作
|
||||
*/
|
||||
const TOOLS_NEED_VERIFY = new Set([
|
||||
@@ -903,7 +910,7 @@ async function handleThinking(
|
||||
}
|
||||
}
|
||||
|
||||
// ── v0.12.1: 任务感知 — 检测用户请求需要工具但模型尚未行动 ──
|
||||
// ── 任务感知 — 检测用户请求需要工具但模型尚未行动 ──
|
||||
if (ctx.loopCount === 2 && ctx.allToolRecords.length === 0) {
|
||||
// 从用户第一条消息中检测是否需要工具
|
||||
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
|
||||
@@ -1209,7 +1216,7 @@ async function handleExecuting(
|
||||
callbacks.onToolCallStart(call);
|
||||
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
|
||||
|
||||
// ── v0.12.0: pre_tool Hook ──
|
||||
// ── pre_tool Hook ──
|
||||
await executeHooks('pre_tool', ctx, { toolName: call.function.name, toolArgs: call.function.arguments });
|
||||
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
@@ -1284,11 +1291,11 @@ async function handleExecuting(
|
||||
if (cacheKey) toolResultCache.set(cacheKey, { result: record.result!, timestamp: Date.now() });
|
||||
// 记录度量
|
||||
recordToolCall(record.name, record.status, Date.now() - record.timestamp);
|
||||
// ── v0.12.1: 通用工具结果核验 — 所有写类工具执行后验证 ──
|
||||
// ── 通用工具结果核验 — 所有写类工具执行后验证 ──
|
||||
if (record.status === 'success') {
|
||||
verifyToolResult(record.name, record.arguments, record.result!);
|
||||
}
|
||||
// ── v0.12.0: post_tool Hook ──
|
||||
// ── post_tool Hook ──
|
||||
executeHooks('post_tool', ctx, { toolName: record.name, toolArgs: record.arguments, toolResult: record.result! });
|
||||
if (record.status === 'success') {
|
||||
callbacks.onToolCallResult(record.name, record.result!, batch.find(c => c.function.name === record.name)!);
|
||||
@@ -1350,7 +1357,7 @@ async function handleObserving(
|
||||
// 记录本轮迭代度量
|
||||
recordIteration(ctx);
|
||||
|
||||
// ── v0.12.0: post_iteration Hook ──
|
||||
// ── post_iteration Hook ──
|
||||
executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount });
|
||||
|
||||
// 增量工具结果截断 — 超过 10 轮的旧结果每 3 轮截断到 500 字符
|
||||
@@ -1364,7 +1371,7 @@ async function handleObserving(
|
||||
}
|
||||
}
|
||||
|
||||
// ── v0.12.1: 进度锚定 — 每 5 轮注入机器生成的工具调用摘要 ──
|
||||
// ── 进度锚定 — 每 5 轮注入机器生成的工具调用摘要 ──
|
||||
if (ctx.loopCount > 1 && ctx.loopCount % 5 === 0 && ctx.allToolRecords.length > 0) {
|
||||
const recentRecords = ctx.allToolRecords.slice(-10);
|
||||
const summary = recentRecords.map(r => {
|
||||
@@ -1380,7 +1387,7 @@ async function handleObserving(
|
||||
logInfo('进度锚点已注入', `第 ${ctx.loopCount} 轮, ${recentRecords.length} 条记录`);
|
||||
}
|
||||
|
||||
// ── v0.12.1: 中途幻觉检测 — 每轮检查模型是否在无工具调用时声称完成了操作 ──
|
||||
// ── 中途幻觉检测 — 每轮检查模型是否在无工具调用时声称完成了操作 ──
|
||||
const lastAssistantMsg = [...ctx.messages].reverse().find(m => m.role === 'assistant');
|
||||
if (lastAssistantMsg?.content) {
|
||||
const midHallucination = detectMidTaskHallucination(lastAssistantMsg.content, ctx.allToolRecords);
|
||||
@@ -1395,13 +1402,21 @@ async function handleObserving(
|
||||
}
|
||||
|
||||
// 每 10 轮清理累积的 ephemeral 消息
|
||||
// 但如果上下文使用率已过高(>70%),跳过清理——压缩即将触发,
|
||||
// 此时清理会导致压缩 LLM 看不到 Plan Mode 进度等关键状态信息
|
||||
if (ctx.loopCount > 1 && ctx.loopCount % 10 === 0) {
|
||||
let removed = 0;
|
||||
ctx.messages = ctx.messages.filter(m => {
|
||||
if (m.ephemeral) { removed++; return false; }
|
||||
return true;
|
||||
});
|
||||
if (removed > 0) logInfo(`ephemeral 清理: ${removed} 条临时消息已移除`);
|
||||
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
|
||||
const usageRatio = numCtx > 0 ? estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx : 0;
|
||||
if (usageRatio > 0.7) {
|
||||
logInfo(`ephemeral 清理跳过: 上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 压缩即将触发`);
|
||||
} else {
|
||||
let removed = 0;
|
||||
ctx.messages = ctx.messages.filter(m => {
|
||||
if (m.ephemeral) { removed++; return false; }
|
||||
return true;
|
||||
});
|
||||
if (removed > 0) logInfo(`ephemeral 清理: ${removed} 条临时消息已移除`);
|
||||
}
|
||||
}
|
||||
|
||||
transition(ctx, S.REFLECTING);
|
||||
@@ -1639,7 +1654,7 @@ export async function runAgentLoop(
|
||||
|
||||
// Phase 2-7: THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (COMPRESSING) → loop
|
||||
while (ctx.state !== S.TERMINATED) {
|
||||
// ── v0.12.1: 看门狗 — 全局超时熔断(可通过设置 loopWatchdogMs 配置,0=禁用)──
|
||||
// ── 看门狗 — 全局超时熔断(可通过设置 loopWatchdogMs 配置,0=禁用)──
|
||||
// 默认 30 分钟,用户强调不要随意加超时限制
|
||||
const WATCHDOG_MS = state.get<number>('loopWatchdogMs', 1_800_000);
|
||||
if (WATCHDOG_MS > 0 && Date.now() - ctx.startTime > WATCHDOG_MS) {
|
||||
@@ -1654,7 +1669,7 @@ export async function runAgentLoop(
|
||||
break;
|
||||
}
|
||||
|
||||
// ── v0.12.1: 上下文硬上限 — 消息数超阈值强制压缩 ──
|
||||
// ── 上下文硬上限 — 消息数超阈值强制压缩 ──
|
||||
if (ctx.messages.length > MAX_MESSAGES) {
|
||||
logWarn(`上下文硬上限触发: ${ctx.messages.length} 条消息 > ${MAX_MESSAGES},强制压缩`);
|
||||
transition(ctx, S.COMPRESSING);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Agent Metrics — 治理与度量模块 (v0.12.0)
|
||||
* Harness Engineering Phase 8: 度量驱动的优化循环
|
||||
* Agent Metrics — 治理与度量模块
|
||||
* Harness Engineering: 度量驱动的优化循环
|
||||
*
|
||||
* 功能:
|
||||
* - 会话级度量采集(迭代效率、工具成功率、完成质量)
|
||||
@@ -232,7 +232,7 @@ export function generateImprovementSuggestions(): ImprovementSuggestion[] {
|
||||
export function formatSuggestionsAsRules(suggestions: ImprovementSuggestion[]): string {
|
||||
if (suggestions.length === 0) return '';
|
||||
|
||||
let rules = '\n\n## 自动生成的改进规则 (v0.12.0)\n';
|
||||
let rules = '\n\n## 自动生成的改进规则\n';
|
||||
rules += '> 以下规则由 Agent Metrics 系统根据历史错误模式自动生成\n\n';
|
||||
|
||||
for (const s of suggestions) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Completion Gate — 完成门控模块 (v0.12.0)
|
||||
* Harness Engineering Phase 3: 在 Agent 输出最终答案前进行结构化检查
|
||||
* Completion Gate — 完成门控模块
|
||||
* Harness Engineering: 在 Agent 输出最终答案前进行结构化检查
|
||||
*
|
||||
* 设计理念:
|
||||
* - 计算性反馈优先(规则驱动、毫秒级、100% 可靠)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Context Indexer — 渐进式披露模块 (v0.12.0)
|
||||
* Harness Engineering Phase 6: 三级上下文管理
|
||||
* Context Indexer — 渐进式披露模块
|
||||
* Harness Engineering: 三级上下文管理
|
||||
*
|
||||
* 索引层 (Index) — 始终保留:项目结构树 + 入口文件地图 + 技术栈摘要
|
||||
* 接口层 (Interface) — 按需加载:模块 API 声明 + 类型定义 + 配置文件
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Harness Hook 系统 (v0.12.0)
|
||||
* Harness Engineering Phase 4: 可扩展的 Agent 执行钩子架构
|
||||
* Harness Hook 系统
|
||||
* Harness Engineering: 可扩展的 Agent 执行钩子架构
|
||||
*
|
||||
* 钩子阶段:
|
||||
* - pre_tool: 工具执行前(安全检查、缓存检查、权限验证)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Verification System — 验证系统模块 (v0.12.0)
|
||||
* Harness Engineering Phase 5: 计算性反馈管道
|
||||
* Verification System — 验证系统模块
|
||||
* Harness Engineering: 计算性反馈管道
|
||||
*
|
||||
* 在 Agent 修改文件后自动运行:
|
||||
* - Linter(代码风格检查)
|
||||
|
||||
@@ -631,7 +631,7 @@ html, body {
|
||||
background: var(--critical-bg);
|
||||
}
|
||||
|
||||
/* ── Plan 按钮 (v0.12.0) ── */
|
||||
/* ── Plan 按钮 ── */
|
||||
.plan-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Vendored
+1
-1
@@ -395,7 +395,7 @@ export interface ToolCallRecord {
|
||||
export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Harness Engineering: Agent Loop 状态机 (v0.12.0)
|
||||
// Harness Engineering: Agent Loop 状态机
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** Agent Loop 状态枚举 */
|
||||
|
||||
Reference in New Issue
Block a user