feat: 升级至 v0.3.2 — 工具体系扩展至 26 个 + Context Window 可配置化
## 主要变更 ### 1. Context Window 可配置化(v0.3.1 延续) - DeepSeek/Agnes contextWindow 不再写死 1M,可在设置中配置(min 4096) - 修复 Engine 128K vs Adapter 1M 不一致 bug,Engine 从 adapter.getContextWindow() 读取 - 热重载 configSig 加入 contextWindow,配置变化即时生效 ### 2. 新增 11 个工具(15 → 26 个) - Git 工具集(4):git_status, git_diff, git_log, git_commit - 开发工具集(3):lint_code, run_tests, project_info - HTTP 请求(1):http_request(Node 18+ fetch + AbortController) - TODO 管理(1):todo_write(会话级内存 + LRU 淘汰) - 结构化思考(1):think(无副作用思考空间) - 图片查看(1):view_image(base64 data URL,多模态 LLM 支持) ### 3. 审计修复(2 FAIL + 14 WARN) - FAIL-1: registry.ts truncateResult 添加 dataUrl 白名单(图片不被截断) - FAIL-2: todo.ts 添加 LRU 策略 + clearSession 静态方法 - WARN-1: run_tests filter 字符白名单校验,防 cmd 元字符注入 - WARN-2: dataUrl 检测前置到 stringify 之前,避免大图片无意义序列化 - WARN-3: todo.ts 实现真正 LRU(访问刷新位置,非 FIFO) - WARN-4: git_diff maxBuffer 提升至 5MB,支持超大变更集 - WARN-5: log.info 移至 DelegateTaskTool 注册后,输出正确的 26 - WARN-6: riskColors 添加 critical: 'error' 键 - WARN-7: 所有 execFileAsync 显式设置 encoding: 'utf-8' - WARN-8: git_log --author 拆分为独立参数 - WARN-9: todo_write 权限从 WRITE 改为 READ - WARN-10: description 区分与 task_manager 的不同用途 ### 4. PolicyEngine 策略 - 新增 11 条策略,26 个工具 + mcp_* 全覆盖 - git_commit: WRITE + requireConfirmation - todo_write: READ(内存操作)
This commit is contained in:
@@ -116,10 +116,17 @@ export class AgnesAdapter extends BaseAdapter {
|
|||||||
/**
|
/**
|
||||||
* H-2 修复: 获取上下文窗口大小(规范要求)
|
* H-2 修复: 获取上下文窗口大小(规范要求)
|
||||||
*
|
*
|
||||||
* Agnes 模型统一 1M 上下文窗口。
|
* v0.3.1: 优先使用配置注入的 contextWindow,回退到 MODEL_INFO 默认值。
|
||||||
|
* Agnes OpenAI 兼容 API 不支持 context_window 参数,此值仅用于
|
||||||
|
* Engine 压缩判断和前端 UI 显示。
|
||||||
* 注意:Agnes API 未提供 /models 端点,listModels 使用基类默认实现。
|
* 注意:Agnes API 未提供 /models 端点,listModels 使用基类默认实现。
|
||||||
*/
|
*/
|
||||||
override getContextWindow(): number {
|
override getContextWindow(): number {
|
||||||
|
// v0.3.1: 优先使用配置注入的 contextWindow
|
||||||
|
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
|
||||||
|
return this.config.contextWindow;
|
||||||
|
}
|
||||||
|
// 回退到 MODEL_INFO
|
||||||
const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel];
|
const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel];
|
||||||
return modelInfo?.contextWindow ?? 1_000_000;
|
return modelInfo?.contextWindow ?? 1_000_000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,9 +149,16 @@ export class DeepSeekAdapter extends BaseAdapter {
|
|||||||
/**
|
/**
|
||||||
* H-2 修复: 获取上下文窗口大小(规范要求)
|
* H-2 修复: 获取上下文窗口大小(规范要求)
|
||||||
*
|
*
|
||||||
* DeepSeek 模型统一 1M 上下文窗口。
|
* v0.3.1: 优先使用配置注入的 contextWindow,回退到 MODEL_INFO 默认值。
|
||||||
|
* DeepSeek OpenAI 兼容 API 不支持 context_window 参数,此值仅用于
|
||||||
|
* Engine 压缩判断和前端 UI 显示。
|
||||||
*/
|
*/
|
||||||
override getContextWindow(): number {
|
override getContextWindow(): number {
|
||||||
|
// v0.3.1: 优先使用配置注入的 contextWindow
|
||||||
|
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
|
||||||
|
return this.config.contextWindow;
|
||||||
|
}
|
||||||
|
// 回退到 MODEL_INFO
|
||||||
const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel];
|
const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel];
|
||||||
return modelInfo?.contextWindow ?? 1_000_000;
|
return modelInfo?.contextWindow ?? 1_000_000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,30 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
|
|||||||
// 无法预先配置精确策略,使用 mcp_* 通配符匹配所有 MCP 工具
|
// 无法预先配置精确策略,使用 mcp_* 通配符匹配所有 MCP 工具
|
||||||
// @see project_memory.md — All tools must have a configured policy in DEFAULT_POLICIES
|
// @see project_memory.md — All tools must have a configured policy in DEFAULT_POLICIES
|
||||||
{ toolName: 'mcp_*', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 },
|
{ toolName: 'mcp_*', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 },
|
||||||
|
|
||||||
|
// v0.3.1: Git 工具集(4 个)
|
||||||
|
{ toolName: 'git_status', requiredLevel: PermissionLevel.READ },
|
||||||
|
{ toolName: 'git_diff', requiredLevel: PermissionLevel.READ },
|
||||||
|
{ toolName: 'git_log', requiredLevel: PermissionLevel.READ },
|
||||||
|
{ toolName: 'git_commit', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 10 },
|
||||||
|
|
||||||
|
// v0.3.1: 开发工具集(3 个)
|
||||||
|
{ toolName: 'lint_code', requiredLevel: PermissionLevel.READ },
|
||||||
|
{ toolName: 'run_tests', requiredLevel: PermissionLevel.READ },
|
||||||
|
{ toolName: 'project_info', requiredLevel: PermissionLevel.READ },
|
||||||
|
|
||||||
|
// v0.3.1: HTTP 请求工具(1 个)
|
||||||
|
{ toolName: 'http_request', requiredLevel: PermissionLevel.READ, maxFrequency: 20 },
|
||||||
|
|
||||||
|
// v0.3.1: TODO 管理工具(1 个)— 内存存储,无持久化副作用
|
||||||
|
// v0.3.1 修复 WARN-9: 改为 READ(内存操作,非数据库)
|
||||||
|
{ toolName: 'todo_write', requiredLevel: PermissionLevel.READ },
|
||||||
|
|
||||||
|
// v0.3.1: 结构化思考工具(1 个)— 无副作用
|
||||||
|
{ toolName: 'think', requiredLevel: PermissionLevel.READ },
|
||||||
|
|
||||||
|
// v0.3.1: 图片查看工具(1 个)— 只读
|
||||||
|
{ toolName: 'view_image', requiredLevel: PermissionLevel.READ },
|
||||||
];
|
];
|
||||||
|
|
||||||
export class PolicyEngine {
|
export class PolicyEngine {
|
||||||
|
|||||||
@@ -0,0 +1,528 @@
|
|||||||
|
/**
|
||||||
|
* 开发工具集(3 个)
|
||||||
|
*
|
||||||
|
* lint_code, run_tests, project_info
|
||||||
|
*
|
||||||
|
* 用于代码检查、测试运行和项目结构分析。
|
||||||
|
* 使用 execFile 执行命令,防止命令注入。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { readFile, readdir } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/** 输出最大长度(50KB) */
|
||||||
|
const MAX_OUTPUT_LENGTH = 50 * 1024;
|
||||||
|
|
||||||
|
/** Windows 平台检测 */
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
|
/** ESLint 配置文件列表 */
|
||||||
|
const ESLINT_CONFIG_FILES = [
|
||||||
|
'.eslintrc',
|
||||||
|
'.eslintrc.js',
|
||||||
|
'.eslintrc.json',
|
||||||
|
'.eslintrc.yml',
|
||||||
|
'.eslintrc.yaml',
|
||||||
|
'.eslintrc.cjs',
|
||||||
|
'eslint.config.js',
|
||||||
|
'eslint.config.mjs',
|
||||||
|
'eslint.config.cjs',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 检测工作空间是否存在 ESLint 配置 */
|
||||||
|
function hasEslintConfig(workspacePath: string): boolean {
|
||||||
|
return ESLINT_CONFIG_FILES.some((f) => existsSync(join(workspacePath, f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取并解析 package.json */
|
||||||
|
async function readPackageJson(workspacePath: string): Promise<Record<string, unknown> | null> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(join(workspacePath, 'package.json'), 'utf-8');
|
||||||
|
return JSON.parse(content) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 截断输出到 50KB */
|
||||||
|
function truncateOutput(output: string): { output: string; truncated: boolean } {
|
||||||
|
if (output.length > MAX_OUTPUT_LENGTH) {
|
||||||
|
return { output: output.slice(0, MAX_OUTPUT_LENGTH), truncated: true };
|
||||||
|
}
|
||||||
|
return { output, truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 execFile 错误对象中提取 stdout/stderr 字符串 */
|
||||||
|
function extractOutput(err: { stdout?: string | Buffer; stderr?: string | Buffer }): { stdout: string; stderr: string } {
|
||||||
|
return {
|
||||||
|
stdout: typeof err.stdout === 'string' ? err.stdout : '',
|
||||||
|
stderr: typeof err.stderr === 'string' ? err.stderr : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 1. lint_code =====
|
||||||
|
|
||||||
|
export class LintCodeTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'lint_code',
|
||||||
|
description:
|
||||||
|
'Run code linting: TypeScript type checking (tsc) or ESLint code style checking. Auto-detects project configuration when type is "auto".',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Check type: "tsc" (TypeScript type check), "eslint" (ESLint), or "auto" (auto-detect, prioritizes tsc then eslint). Defaults to "tsc".',
|
||||||
|
enum: ['tsc', 'eslint', 'auto'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.CODE_EXECUTION,
|
||||||
|
riskLevel: MetonaRiskLevel.SAFE,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
const type = (args.type as string) ?? 'tsc';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let actualType: 'tsc' | 'eslint';
|
||||||
|
|
||||||
|
if (type === 'auto') {
|
||||||
|
// 自动检测:优先 tsc,然后 eslint
|
||||||
|
if (existsSync(join(context.workspacePath, 'tsconfig.json'))) {
|
||||||
|
actualType = 'tsc';
|
||||||
|
} else if (hasEslintConfig(context.workspacePath)) {
|
||||||
|
actualType = 'eslint';
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: 'none',
|
||||||
|
success: true,
|
||||||
|
errorCount: 0,
|
||||||
|
warningCount: 0,
|
||||||
|
output: 'No TypeScript or ESLint configuration found in workspace.',
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (type === 'tsc') {
|
||||||
|
if (!existsSync(join(context.workspacePath, 'tsconfig.json'))) {
|
||||||
|
return {
|
||||||
|
type: 'tsc',
|
||||||
|
success: true,
|
||||||
|
errorCount: 0,
|
||||||
|
warningCount: 0,
|
||||||
|
output: 'No tsconfig.json found, skipping TypeScript type check.',
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
actualType = 'tsc';
|
||||||
|
} else {
|
||||||
|
// eslint
|
||||||
|
if (!hasEslintConfig(context.workspacePath)) {
|
||||||
|
return {
|
||||||
|
type: 'eslint',
|
||||||
|
success: true,
|
||||||
|
errorCount: 0,
|
||||||
|
warningCount: 0,
|
||||||
|
output: 'No ESLint configuration found, skipping ESLint check.',
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
actualType = 'eslint';
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let exitCode = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (actualType === 'tsc') {
|
||||||
|
const result = await execFileAsync('npx', ['tsc', '--noEmit'], {
|
||||||
|
cwd: context.workspacePath,
|
||||||
|
maxBuffer: 5 * 1024 * 1024,
|
||||||
|
timeout: 60_000,
|
||||||
|
shell: isWindows,
|
||||||
|
encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码
|
||||||
|
});
|
||||||
|
stdout = result.stdout;
|
||||||
|
stderr = result.stderr;
|
||||||
|
} else {
|
||||||
|
const result = await execFileAsync('npx', ['eslint', '--ext', '.ts,.tsx', '.'], {
|
||||||
|
cwd: context.workspacePath,
|
||||||
|
maxBuffer: 5 * 1024 * 1024,
|
||||||
|
timeout: 60_000,
|
||||||
|
shell: isWindows,
|
||||||
|
encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码
|
||||||
|
});
|
||||||
|
stdout = result.stdout;
|
||||||
|
stderr = result.stderr;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// lint 命令退出码非 0 是正常情况(代码有错误时),需要捕获输出用于解析
|
||||||
|
const err = error as { stdout?: string | Buffer; stderr?: string | Buffer; code?: number };
|
||||||
|
const extracted = extractOutput(err);
|
||||||
|
stdout = extracted.stdout;
|
||||||
|
stderr = extracted.stderr;
|
||||||
|
exitCode = err.code ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = (stdout + (stderr ? '\n' + stderr : '')).trim();
|
||||||
|
const { errorCount, warningCount } = this.parseCounts(output, actualType);
|
||||||
|
const { output: finalOutput, truncated } = truncateOutput(output);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: actualType,
|
||||||
|
success: exitCode === 0,
|
||||||
|
errorCount,
|
||||||
|
warningCount,
|
||||||
|
output: finalOutput,
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return { error: errMsg, success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 lint 输出中的错误和警告数量 */
|
||||||
|
private parseCounts(output: string, type: 'tsc' | 'eslint'): { errorCount: number; warningCount: number } {
|
||||||
|
if (type === 'tsc') {
|
||||||
|
// tsc 输出格式: "file.ts(line,col): error TS1234: message"
|
||||||
|
const errorMatches = output.match(/error TS\d+:/g);
|
||||||
|
return { errorCount: errorMatches?.length ?? 0, warningCount: 0 };
|
||||||
|
}
|
||||||
|
// eslint 输出末尾汇总: "✖ X problems (Y errors, Z warnings)"
|
||||||
|
const match = output.match(/(\d+)\s+problems?\s*\((\d+)\s+errors?,\s*(\d+)\s+warnings?\)/);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
errorCount: parseInt(match[2], 10),
|
||||||
|
warningCount: parseInt(match[3], 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { errorCount: 0, warningCount: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 2. run_tests =====
|
||||||
|
|
||||||
|
export class RunTestsTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'run_tests',
|
||||||
|
description:
|
||||||
|
'Run the project test suite. Auto-detects the test command from package.json scripts, or uses a custom command. Supports test file filtering.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
filter: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Test file filter pattern (e.g., "auth" or "utils/*"). Passed to the test runner.',
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Watch mode (not applicable in this context, ignored).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.CODE_EXECUTION,
|
||||||
|
riskLevel: MetonaRiskLevel.LOW,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
const filter = args.filter as string | undefined;
|
||||||
|
// v0.3.1 修复 WARN-2: 移除 customCommand — LLM 输出不可信,shell:true 有注入风险
|
||||||
|
// watch 参数不适用,忽略
|
||||||
|
|
||||||
|
// v0.3.1 修复 WARN-1: filter 字符白名单校验,防止 Windows shell:true 下的 cmd 元字符注入
|
||||||
|
// 允许字母/数字/连字符/下划线/斜杠/星号/点(常见测试文件路径模式)
|
||||||
|
const FILTER_ALLOWED_RE = /^[A-Za-z0-9_\-/. *]+$/;
|
||||||
|
if (filter && !FILTER_ALLOWED_RE.test(filter)) {
|
||||||
|
return {
|
||||||
|
command: 'npm test',
|
||||||
|
success: false,
|
||||||
|
passed: 0,
|
||||||
|
failed: 0,
|
||||||
|
duration: '0s',
|
||||||
|
output: `Invalid filter: contains disallowed characters. Only letters, digits, '-', '_', '/', '.', '*', ' ' are allowed.`,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let actualCommand: string;
|
||||||
|
let execCmd: string;
|
||||||
|
let execArgs: string[];
|
||||||
|
|
||||||
|
// 检测 package.json 的 scripts.test
|
||||||
|
const pkg = await readPackageJson(context.workspacePath);
|
||||||
|
const scripts = (pkg?.scripts as Record<string, string> | undefined) ?? {};
|
||||||
|
const testScript = scripts.test;
|
||||||
|
|
||||||
|
if (!testScript || testScript === 'no test specified') {
|
||||||
|
return {
|
||||||
|
command: 'npm test',
|
||||||
|
success: false,
|
||||||
|
passed: 0,
|
||||||
|
failed: 0,
|
||||||
|
duration: '0s',
|
||||||
|
output: 'No test script found in package.json.',
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
actualCommand = filter ? `npm test -- ${filter}` : 'npm test';
|
||||||
|
execCmd = 'npm';
|
||||||
|
execArgs = filter ? ['test', '--', filter] : ['test'];
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let exitCode = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await execFileAsync(execCmd, execArgs, {
|
||||||
|
cwd: context.workspacePath,
|
||||||
|
maxBuffer: 5 * 1024 * 1024,
|
||||||
|
timeout: 120_000,
|
||||||
|
shell: isWindows,
|
||||||
|
encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码
|
||||||
|
});
|
||||||
|
stdout = result.stdout;
|
||||||
|
stderr = result.stderr;
|
||||||
|
} catch (error) {
|
||||||
|
// 测试失败(exitCode !== 0)是正常情况,需要捕获输出解析结果
|
||||||
|
const err = error as { stdout?: string | Buffer; stderr?: string | Buffer; code?: number };
|
||||||
|
const extracted = extractOutput(err);
|
||||||
|
stdout = extracted.stdout;
|
||||||
|
stderr = extracted.stderr;
|
||||||
|
exitCode = err.code ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = (stdout + (stderr ? '\n' + stderr : '')).trim();
|
||||||
|
const { passed, failed, duration } = this.parseTestResults(output);
|
||||||
|
const { output: finalOutput, truncated } = truncateOutput(output);
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: actualCommand,
|
||||||
|
success: exitCode === 0,
|
||||||
|
passed,
|
||||||
|
failed,
|
||||||
|
duration,
|
||||||
|
output: finalOutput,
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return { error: errMsg, success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从测试输出中解析通过/失败数量和耗时(支持 jest/vitest/mocha 格式) */
|
||||||
|
private parseTestResults(output: string): { passed: number; failed: number; duration: string } {
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let duration = '0s';
|
||||||
|
|
||||||
|
// jest/vitest 格式: "Tests: 5 passed, 2 failed, 7 total"
|
||||||
|
const jestMatch = output.match(/(\d+)\s+passed(?:,\s*(\d+)\s+failed)?/);
|
||||||
|
if (jestMatch) {
|
||||||
|
passed = parseInt(jestMatch[1], 10);
|
||||||
|
failed = jestMatch[2] ? parseInt(jestMatch[2], 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mocha 格式: "X passing (Ys)" / "X failing"
|
||||||
|
const mochaPass = output.match(/(\d+)\s+passing/);
|
||||||
|
const mochaFail = output.match(/(\d+)\s+failing/);
|
||||||
|
if (mochaPass) passed = parseInt(mochaPass[1], 10);
|
||||||
|
if (mochaFail) failed = parseInt(mochaFail[1], 10);
|
||||||
|
|
||||||
|
// 耗时解析: "Time: 3.5 s" / "Duration: 120ms" / "(3.5s)"
|
||||||
|
const timeMatch = output.match(/(?:Time|Duration|耗时)[:\s]+([\d.]+\s*(?:ms|s|m|h|seconds?|minutes?|hours?)?)/i);
|
||||||
|
if (timeMatch) {
|
||||||
|
duration = timeMatch[1];
|
||||||
|
} else {
|
||||||
|
const parenTime = output.match(/\(([\d.]+\s*(?:ms|s|m|h))\)/);
|
||||||
|
if (parenTime) duration = parenTime[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed, failed, duration };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 3. project_info =====
|
||||||
|
|
||||||
|
export class ProjectInfoTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'project_info',
|
||||||
|
description:
|
||||||
|
'Analyze project structure and dependencies. Returns package.json overview, full dependency list, npm scripts, or directory structure (2 levels deep).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
detail: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Detail level: "overview" (project summary), "dependencies" (full dependency list), "scripts" (npm scripts), or "structure" (directory tree, 2 levels). Defaults to "overview".',
|
||||||
|
enum: ['overview', 'dependencies', 'scripts', 'structure'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.FILESYSTEM,
|
||||||
|
riskLevel: MetonaRiskLevel.SAFE,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
const detail = (args.detail as string) ?? 'overview';
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (detail) {
|
||||||
|
case 'dependencies':
|
||||||
|
return await this.getDependencies(context.workspacePath);
|
||||||
|
case 'scripts':
|
||||||
|
return await this.getScripts(context.workspacePath);
|
||||||
|
case 'structure':
|
||||||
|
return await this.getStructure(context.workspacePath);
|
||||||
|
default:
|
||||||
|
return await this.getOverview(context.workspacePath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return { error: errMsg, success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOverview(workspacePath: string): Promise<unknown> {
|
||||||
|
const pkg = await readPackageJson(workspacePath);
|
||||||
|
if (!pkg) {
|
||||||
|
return { error: 'package.json not found', success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deps = (pkg.dependencies as Record<string, string> | undefined) ?? {};
|
||||||
|
const devDeps = (pkg.devDependencies as Record<string, string> | undefined) ?? {};
|
||||||
|
const scripts = (pkg.scripts as Record<string, string> | undefined) ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: (pkg.name as string) ?? 'unknown',
|
||||||
|
version: (pkg.version as string) ?? '0.0.0',
|
||||||
|
description: (pkg.description as string) ?? '',
|
||||||
|
type: pkg.type === 'module' ? 'module' : 'commonjs',
|
||||||
|
mainEntry: (pkg.module as string) ?? (pkg.main as string) ?? 'index.js',
|
||||||
|
dependencyCount: Object.keys(deps).length,
|
||||||
|
devDependencyCount: Object.keys(devDeps).length,
|
||||||
|
scriptCount: Object.keys(scripts).length,
|
||||||
|
hasTypeScript: existsSync(join(workspacePath, 'tsconfig.json')),
|
||||||
|
hasEslint: hasEslintConfig(workspacePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDependencies(workspacePath: string): Promise<unknown> {
|
||||||
|
const pkg = await readPackageJson(workspacePath);
|
||||||
|
if (!pkg) {
|
||||||
|
return { error: 'package.json not found', success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deps = (pkg.dependencies as Record<string, string> | undefined) ?? {};
|
||||||
|
const devDeps = (pkg.devDependencies as Record<string, string> | undefined) ?? {};
|
||||||
|
|
||||||
|
const dependencies = Object.entries(deps).map(([name, version]) => ({ name, version }));
|
||||||
|
const devDependencies = Object.entries(devDeps).map(([name, version]) => ({ name, version }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
dependencies,
|
||||||
|
devDependencies,
|
||||||
|
total: dependencies.length + devDependencies.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getScripts(workspacePath: string): Promise<unknown> {
|
||||||
|
const pkg = await readPackageJson(workspacePath);
|
||||||
|
if (!pkg) {
|
||||||
|
return { error: 'package.json not found', success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scripts = (pkg.scripts as Record<string, string> | undefined) ?? {};
|
||||||
|
const scriptList = Object.entries(scripts).map(([name, command]) => ({ name, command }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
scripts: scriptList,
|
||||||
|
count: scriptList.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStructure(workspacePath: string): Promise<unknown> {
|
||||||
|
const directories = await this.listDirectory(workspacePath, 0, 2);
|
||||||
|
return {
|
||||||
|
root: workspacePath,
|
||||||
|
directories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归列出目录(最多 maxDepth 层),跳过 node_modules/.git/dist */
|
||||||
|
private async listDirectory(
|
||||||
|
dirPath: string,
|
||||||
|
currentDepth: number,
|
||||||
|
maxDepth: number,
|
||||||
|
): Promise<Array<{ name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> }>> {
|
||||||
|
const results: Array<{ name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await readdir(dirPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (this.shouldSkip(entry.name)) continue;
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const item: { name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> } = {
|
||||||
|
name: entry.name,
|
||||||
|
type: 'dir',
|
||||||
|
};
|
||||||
|
if (currentDepth < maxDepth - 1) {
|
||||||
|
item.children = await this.listChildren(join(dirPath, entry.name));
|
||||||
|
}
|
||||||
|
results.push(item);
|
||||||
|
} else {
|
||||||
|
results.push({ name: entry.name, type: 'file' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 目录读取失败,跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出子目录内容(仅 1 层,不再递归) */
|
||||||
|
private async listChildren(dirPath: string): Promise<Array<{ name: string; type: string }>> {
|
||||||
|
const results: Array<{ name: string; type: string }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await readdir(dirPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (this.shouldSkip(entry.name)) continue;
|
||||||
|
results.push({ name: entry.name, type: entry.isDirectory() ? 'dir' : 'file' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 目录读取失败,跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断目录/文件是否应跳过(node_modules/.git/dist 不递归) */
|
||||||
|
private shouldSkip(name: string): boolean {
|
||||||
|
return ['node_modules', '.git', 'dist'].includes(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
/**
|
||||||
|
* Git 工具集(4 个)
|
||||||
|
*
|
||||||
|
* git_status, git_diff, git_log, git_commit
|
||||||
|
*
|
||||||
|
* 使用 execFile 执行 git 命令,防止命令注入。
|
||||||
|
* 所有命令在 context.workspacePath 下执行。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { isPathWithinWorkspace } from './file-guard';
|
||||||
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/** diff 输出截断阈值(50KB) */
|
||||||
|
const MAX_DIFF_SIZE = 50 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 git 命令的统一入口
|
||||||
|
*
|
||||||
|
* 使用 execFile(非 exec)+ 参数数组,从底层杜绝命令注入。
|
||||||
|
* 显式设置 encoding: 'utf-8',git 默认输出 UTF-8。
|
||||||
|
*
|
||||||
|
* v0.3.1 修复 WARN-4: 允许调用方覆盖 maxBuffer,默认 1MB;
|
||||||
|
* git_diff 传 5MB 以支持超大变更集(避免 maxBuffer exceeded 报错而非截断返回)。
|
||||||
|
*/
|
||||||
|
async function runGit(
|
||||||
|
args: string[],
|
||||||
|
cwd: string,
|
||||||
|
timeout = 10_000,
|
||||||
|
maxBuffer = 1024 * 1024, // 默认 1MB
|
||||||
|
): Promise<string> {
|
||||||
|
const { stdout } = await execFileAsync('git', args, {
|
||||||
|
cwd,
|
||||||
|
maxBuffer,
|
||||||
|
timeout,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断错误是否为 "不是 git 仓库" 或 git 不可用 */
|
||||||
|
function isNotGitRepoError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const stderr = (error as Error & { stderr?: string }).stderr ?? '';
|
||||||
|
const msg = `${error.message} ${stderr}`;
|
||||||
|
return /not a git repository/i.test(msg) || /git: not found|command not found/i.test(msg);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取错误信息字符串 */
|
||||||
|
function extractErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const stderr = (error as Error & { stderr?: string }).stderr ?? '';
|
||||||
|
return stderr ? `${error.message}\n${stderr}` : error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前分支名(失败时返回 'unknown',不抛错) */
|
||||||
|
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)).trim();
|
||||||
|
return branch;
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理 porcelain v1 重命名格式 "old -> new" → 返回 new */
|
||||||
|
function extractRenamedFile(file: string): string {
|
||||||
|
const idx = file.indexOf(' -> ');
|
||||||
|
return idx >= 0 ? file.slice(idx + 4) : file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 1. git_status =====
|
||||||
|
|
||||||
|
export class GitStatusTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'git_status',
|
||||||
|
description: 'Show the working tree status. Returns current branch, ahead/behind counts, and lists of staged, unstaged, and untracked files.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/" or "package.json"' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.FILESYSTEM,
|
||||||
|
riskLevel: MetonaRiskLevel.SAFE,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
const pathspec = args.pathspec as string | undefined;
|
||||||
|
const gitArgs = ['status', '--porcelain=v1', '-b'];
|
||||||
|
if (pathspec) {
|
||||||
|
gitArgs.push('--', pathspec);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await runGit(gitArgs, context.workspacePath);
|
||||||
|
return this.parseStatus(output);
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotGitRepoError(error)) {
|
||||||
|
return { error: 'Not a git repository or git not available', success: false };
|
||||||
|
}
|
||||||
|
return { error: extractErrorMessage(error), success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 `git status --porcelain=v1 -b` 输出
|
||||||
|
*
|
||||||
|
* 分支行格式: `## <branch>...<upstream> [ahead N, behind M]`
|
||||||
|
* 文件行格式: `XY <space> <file>` (X=暂存区状态, Y=工作区状态)
|
||||||
|
*/
|
||||||
|
private parseStatus(output: string): unknown {
|
||||||
|
const lines = output.split('\n').filter((l) => l.length > 0);
|
||||||
|
const staged: Array<{ status: string; file: string }> = [];
|
||||||
|
const unstaged: Array<{ status: string; file: string }> = [];
|
||||||
|
const untracked: string[] = [];
|
||||||
|
|
||||||
|
let branch = '';
|
||||||
|
let ahead = 0;
|
||||||
|
let behind = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// 分支行: ## main...origin/main [ahead 2, behind 1]
|
||||||
|
if (line.startsWith('## ')) {
|
||||||
|
const header = line.slice(3);
|
||||||
|
const bracketIdx = header.indexOf('[');
|
||||||
|
const branchPart = bracketIdx >= 0 ? header.slice(0, bracketIdx).trim() : header.trim();
|
||||||
|
// branchPart 形如 "main...origin/main" / "main" / "HEAD (no branch)"
|
||||||
|
const dotIdx = branchPart.indexOf('...');
|
||||||
|
branch = dotIdx >= 0 ? branchPart.slice(0, dotIdx) : branchPart.split(' ')[0];
|
||||||
|
|
||||||
|
if (bracketIdx >= 0) {
|
||||||
|
const meta = header.slice(bracketIdx + 1, header.lastIndexOf(']'));
|
||||||
|
const aheadMatch = meta.match(/ahead (\d+)/);
|
||||||
|
const behindMatch = meta.match(/behind (\d+)/);
|
||||||
|
if (aheadMatch) ahead = parseInt(aheadMatch[1], 10);
|
||||||
|
if (behindMatch) behind = parseInt(behindMatch[1], 10);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// porcelain v1 文件行: XY <space> <file>
|
||||||
|
const x = line[0];
|
||||||
|
const y = line[1];
|
||||||
|
const file = line.slice(3);
|
||||||
|
|
||||||
|
// 未跟踪: ??
|
||||||
|
if (x === '?' && y === '?') {
|
||||||
|
untracked.push(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暂存区变更 (X 非 ' ' 且非 '?')
|
||||||
|
if (x !== ' ' && x !== '?') {
|
||||||
|
staged.push({ status: x, file: extractRenamedFile(file) });
|
||||||
|
}
|
||||||
|
// 工作区变更 (Y 非 ' ' 且非 '?')
|
||||||
|
if (y !== ' ' && y !== '?') {
|
||||||
|
unstaged.push({ status: y, file: extractRenamedFile(file) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = staged.length === 0 && unstaged.length === 0 && untracked.length === 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
branch,
|
||||||
|
ahead,
|
||||||
|
behind,
|
||||||
|
staged,
|
||||||
|
unstaged,
|
||||||
|
untracked,
|
||||||
|
clean,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 2. git_diff =====
|
||||||
|
|
||||||
|
export class GitDiffTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'git_diff',
|
||||||
|
description: 'Show changes between the working tree and the index (or staged changes). Returns unified diff text. Output is truncated at 50KB.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
cached: { type: 'boolean', description: 'Show only staged (cached) changes (default false)' },
|
||||||
|
pathspec: { type: 'string', description: 'Limit to a path scope, e.g. "src/"' },
|
||||||
|
contextLines: { type: 'number', description: 'Number of context lines (default 3)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.FILESYSTEM,
|
||||||
|
riskLevel: MetonaRiskLevel.SAFE,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
const cached = (args.cached as boolean) ?? false;
|
||||||
|
const pathspec = args.pathspec as string | undefined;
|
||||||
|
const contextLines = Math.max(0, (args.contextLines as number) ?? 3);
|
||||||
|
|
||||||
|
const gitArgs = ['diff'];
|
||||||
|
if (cached) gitArgs.push('--cached');
|
||||||
|
gitArgs.push(`--unified=${contextLines}`);
|
||||||
|
if (pathspec) gitArgs.push('--', pathspec);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// v0.3.1 修复 WARN-4: 使用 5MB maxBuffer 支持超大 diff,避免 maxBuffer exceeded 报错
|
||||||
|
// 工具内部仍会截断到 50KB 返回,避免 LLM 上下文溢出
|
||||||
|
const diff = await runGit(gitArgs, context.workspacePath, 10_000, 5 * 1024 * 1024);
|
||||||
|
const truncated = diff.length > MAX_DIFF_SIZE;
|
||||||
|
const truncatedDiff = truncated ? diff.slice(0, MAX_DIFF_SIZE) : diff;
|
||||||
|
const filesChanged = this.countFilesChanged(diff);
|
||||||
|
|
||||||
|
return {
|
||||||
|
diff: truncatedDiff,
|
||||||
|
filesChanged,
|
||||||
|
truncated,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotGitRepoError(error)) {
|
||||||
|
return { error: 'Not a git repository or git not available', success: false };
|
||||||
|
}
|
||||||
|
return { error: extractErrorMessage(error), success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计 diff 中变更的文件数(每变更文件一个 `diff --git` 头) */
|
||||||
|
private countFilesChanged(diff: string): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const line of diff.split('\n')) {
|
||||||
|
if (line.startsWith('diff --git ')) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 3. git_log =====
|
||||||
|
|
||||||
|
export class GitLogTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'git_log',
|
||||||
|
description: 'Show commit history. Returns commits with hash, message, and optionally author/date. Supports filtering by author and path.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'number', description: 'Number of entries to return (default 20, max 100)' },
|
||||||
|
oneline: { type: 'boolean', description: 'Use compact one-line format (default true)' },
|
||||||
|
pathspec: { type: 'string', description: 'Limit to a path scope' },
|
||||||
|
author: { type: 'string', description: 'Filter by author name/email' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.FILESYSTEM,
|
||||||
|
riskLevel: MetonaRiskLevel.SAFE,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
const limit = Math.min(100, Math.max(1, (args.limit as number) ?? 20));
|
||||||
|
const oneline = (args.oneline as boolean) ?? true;
|
||||||
|
const pathspec = args.pathspec as string | undefined;
|
||||||
|
const author = args.author as string | undefined;
|
||||||
|
|
||||||
|
const gitArgs = ['log', `-n${limit}`];
|
||||||
|
if (oneline) {
|
||||||
|
gitArgs.push('--oneline');
|
||||||
|
} else {
|
||||||
|
// 用 NULL 字节分隔字段,避免 author/message 含 '|' 导致解析错误
|
||||||
|
gitArgs.push('--format=%H%x00%an%x00%ad%x00%s');
|
||||||
|
}
|
||||||
|
// v0.3.1 修复 WARN-8: 拆分为独立参数,避免特殊字符干扰 git 解析
|
||||||
|
if (author) gitArgs.push('--author', author);
|
||||||
|
if (pathspec) gitArgs.push('--', pathspec);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 并行获取日志和当前分支名
|
||||||
|
const [output, branch] = await Promise.all([
|
||||||
|
runGit(gitArgs, context.workspacePath),
|
||||||
|
getCurrentBranch(context.workspacePath),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const commits = this.parseLog(output, oneline);
|
||||||
|
return { commits, count: commits.length, branch };
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotGitRepoError(error)) {
|
||||||
|
return { error: 'Not a git repository or git not available', success: false };
|
||||||
|
}
|
||||||
|
return { error: extractErrorMessage(error), success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLog(
|
||||||
|
output: string,
|
||||||
|
oneline: boolean,
|
||||||
|
): Array<{ hash: string; author?: string; date?: string; message: string }> {
|
||||||
|
const lines = output.split('\n').filter((l) => l.length > 0);
|
||||||
|
const commits: Array<{ hash: string; author?: string; date?: string; message: string }> = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (oneline) {
|
||||||
|
// <short-hash> <space> <message>
|
||||||
|
const spaceIdx = line.indexOf(' ');
|
||||||
|
if (spaceIdx < 0) continue;
|
||||||
|
commits.push({
|
||||||
|
hash: line.slice(0, spaceIdx),
|
||||||
|
message: line.slice(spaceIdx + 1),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// <hash>\0<author>\0<date>\0<message>
|
||||||
|
const parts = line.split('\0');
|
||||||
|
if (parts.length < 4) continue;
|
||||||
|
commits.push({
|
||||||
|
hash: parts[0],
|
||||||
|
author: parts[1],
|
||||||
|
date: parts[2],
|
||||||
|
message: parts[3],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 4. git_commit =====
|
||||||
|
|
||||||
|
export class GitCommitTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'git_commit',
|
||||||
|
description: 'Stage files and create a git commit. Supports amending the previous commit. Requires user confirmation.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
message: { type: 'string', description: 'Commit message' },
|
||||||
|
files: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', description: 'File path to stage' },
|
||||||
|
description: 'Files to stage (default: all changes via git add -A)',
|
||||||
|
},
|
||||||
|
amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one (default false)' },
|
||||||
|
},
|
||||||
|
required: ['message'],
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.FILESYSTEM,
|
||||||
|
riskLevel: MetonaRiskLevel.MEDIUM,
|
||||||
|
requiresPermission: true,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
const message = args.message as string;
|
||||||
|
const files = (args.files as string[] | undefined) ?? [];
|
||||||
|
const amend = (args.amend as boolean) ?? false;
|
||||||
|
|
||||||
|
if (!message || message.trim().length === 0) {
|
||||||
|
return { success: false, error: 'Commit message is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 暂存文件(指定文件 → git add -- <files>;否则 git add -A)
|
||||||
|
// v0.3.1 修复 WARN-1: 校验每个 file 路径在工作空间内
|
||||||
|
if (files.length > 0) {
|
||||||
|
for (const f of files) {
|
||||||
|
const resolved = resolve(context.workspacePath, f);
|
||||||
|
if (!isPathWithinWorkspace(resolved, context.workspacePath)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `File path outside workspace: ${f}`,
|
||||||
|
commit: '', branch: '', message: '', filesChanged: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await runGit(['add', '--', ...files], context.workspacePath);
|
||||||
|
} else {
|
||||||
|
await runGit(['add', '-A'], context.workspacePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 统计暂存文件数(commit 前取样)
|
||||||
|
const stagedOutput = await runGit(['diff', '--cached', '--name-only'], context.workspacePath);
|
||||||
|
const filesChanged = stagedOutput.split('\n').filter((l) => l.length > 0).length;
|
||||||
|
|
||||||
|
// 3. 提交(commit 可能触发 hooks,给予更长超时)
|
||||||
|
const commitArgs = ['commit'];
|
||||||
|
if (amend) commitArgs.push('--amend');
|
||||||
|
commitArgs.push('-m', message);
|
||||||
|
await runGit(commitArgs, context.workspacePath, 20_000);
|
||||||
|
|
||||||
|
// 4. 获取提交 hash 和当前分支
|
||||||
|
const [commit, branch] = await Promise.all([
|
||||||
|
runGit(['rev-parse', 'HEAD'], context.workspacePath).then((s) => s.trim()),
|
||||||
|
getCurrentBranch(context.workspacePath),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
commit,
|
||||||
|
branch,
|
||||||
|
message,
|
||||||
|
filesChanged,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotGitRepoError(error)) {
|
||||||
|
return { error: 'Not a git repository or git not available', success: false };
|
||||||
|
}
|
||||||
|
return { success: false, error: extractErrorMessage(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* HTTP 请求工具(1 个)
|
||||||
|
*
|
||||||
|
* http_request — 发送 HTTP/REST API 请求
|
||||||
|
*
|
||||||
|
* 使用 Node.js 18+ 内置 fetch API。
|
||||||
|
* 响应体截断到 50KB 防止结果过大。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
|
|
||||||
|
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const;
|
||||||
|
const MAX_BODY_BYTES = 50 * 1024; // 50KB
|
||||||
|
|
||||||
|
export class HttpRequestTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'http_request',
|
||||||
|
description: 'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string', description: 'Request URL (must start with http:// or https://)' },
|
||||||
|
method: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'HTTP method (default GET)',
|
||||||
|
enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'],
|
||||||
|
},
|
||||||
|
headers: { type: 'object', description: 'Request headers as key-value pairs' },
|
||||||
|
body: { type: 'string', description: 'Request body (string)' },
|
||||||
|
timeout: { type: 'number', description: 'Timeout in milliseconds (default 30000, max 60000)' },
|
||||||
|
},
|
||||||
|
required: ['url'],
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.NETWORK,
|
||||||
|
riskLevel: MetonaRiskLevel.LOW,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const url = args.url as string;
|
||||||
|
const method = ((args.method as string) ?? 'GET').toUpperCase();
|
||||||
|
const headers = (args.headers as Record<string, string> | undefined) ?? undefined;
|
||||||
|
const body = args.body as string | undefined;
|
||||||
|
const timeout = Math.min(60_000, Math.max(1, (args.timeout as number) ?? 30_000));
|
||||||
|
|
||||||
|
// 校验 URL
|
||||||
|
if (!url || !/^https?:\/\//i.test(url)) {
|
||||||
|
return { error: 'Invalid URL', success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 method
|
||||||
|
if (!(ALLOWED_METHODS as readonly string[]).includes(method)) {
|
||||||
|
return {
|
||||||
|
error: `Invalid method: ${method}. Must be one of: ${ALLOWED_METHODS.join(', ')}`,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时控制
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
redirect: 'follow',
|
||||||
|
};
|
||||||
|
// GET/HEAD 不应携带 body
|
||||||
|
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||||
|
fetchOptions.body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
// 截断到 50KB
|
||||||
|
const truncated = text.length > MAX_BODY_BYTES;
|
||||||
|
const safeBody = truncated ? text.slice(0, MAX_BODY_BYTES) : text;
|
||||||
|
|
||||||
|
// 只返回 content-type 和 content-length
|
||||||
|
const filteredHeaders: Record<string, string> = {};
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType) filteredHeaders['content-type'] = contentType;
|
||||||
|
const contentLength = response.headers.get('content-length');
|
||||||
|
if (contentLength) filteredHeaders['content-length'] = contentLength;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: filteredHeaders,
|
||||||
|
body: safeBody,
|
||||||
|
truncated,
|
||||||
|
ok: response.ok,
|
||||||
|
success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 区分超时(AbortError)与其他网络错误
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return { error: 'Request timeout', success: false };
|
||||||
|
}
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return { error: errMsg, success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 内置工具导出
|
* 内置工具导出
|
||||||
*
|
*
|
||||||
|
* v0.3.1: 从 15 个工具扩展到 26 个工具
|
||||||
|
* 新增:git_status, git_diff, git_log, git_commit, lint_code, run_tests,
|
||||||
|
* project_info, http_request, todo_write, think, view_image
|
||||||
|
*
|
||||||
* v0.2.0: 从 11 个工具扩展到 15 个工具
|
* v0.2.0: 从 11 个工具扩展到 15 个工具
|
||||||
* 新增:file_editor, code_search, task_manager, diff_viewer
|
* 新增:file_editor, code_search, task_manager, diff_viewer
|
||||||
*
|
*
|
||||||
@@ -34,3 +38,21 @@ export { DelegateTaskTool } from './delegate-task';
|
|||||||
// v0.2.0: 任务管理工具(1 个)
|
// v0.2.0: 任务管理工具(1 个)
|
||||||
export { TaskManagerTool } from './task-manager';
|
export { TaskManagerTool } from './task-manager';
|
||||||
|
|
||||||
|
// v0.3.1: Git 工具集(4 个)
|
||||||
|
export { GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool } from './git';
|
||||||
|
|
||||||
|
// v0.3.1: 开发工具集(3 个)
|
||||||
|
export { LintCodeTool, RunTestsTool, ProjectInfoTool } from './dev-tools';
|
||||||
|
|
||||||
|
// v0.3.1: HTTP 请求工具(1 个)
|
||||||
|
export { HttpRequestTool } from './http-request';
|
||||||
|
|
||||||
|
// v0.3.1: TODO 管理工具(1 个)
|
||||||
|
export { TodoWriteTool } from './todo';
|
||||||
|
|
||||||
|
// v0.3.1: 结构化思考工具(1 个)
|
||||||
|
export { ThinkTool } from './think';
|
||||||
|
|
||||||
|
// v0.3.1: 图片查看工具(1 个)
|
||||||
|
export { ViewImageTool } from './view-image';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 思考工具(1 个)
|
||||||
|
*
|
||||||
|
* think — 结构化思考空间(无副作用)
|
||||||
|
*
|
||||||
|
* 参考 Claude Code 的 think 工具设计。
|
||||||
|
* 帮助 LLM 在工具调用上下文中组织思路。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
|
|
||||||
|
export class ThinkTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'think',
|
||||||
|
description:
|
||||||
|
'Use this tool for structured thinking. It helps you organize your thoughts before complex actions. This tool has no side effects — it simply returns your input for your own reference. Use it when: 1) Breaking down complex tasks into steps, 2) Evaluating multiple approaches, 3) Planning before writing code, 4) Reviewing your work before completing.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
thought: { type: 'string', description: 'The thought content' },
|
||||||
|
nextAction: { type: 'string', description: 'Next action plan' },
|
||||||
|
reasoning: { type: 'string', description: 'Reasoning process' },
|
||||||
|
},
|
||||||
|
required: ['thought'],
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.CUSTOM,
|
||||||
|
riskLevel: MetonaRiskLevel.SAFE,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const thought = args.thought as string;
|
||||||
|
const nextAction = (args.nextAction as string) ?? null;
|
||||||
|
const reasoning = (args.reasoning as string) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
thought,
|
||||||
|
nextAction,
|
||||||
|
reasoning,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return { error: errMsg, success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* TODO 管理工具(1 个)
|
||||||
|
*
|
||||||
|
* todo_write — 会话级 TODO 列表管理
|
||||||
|
*
|
||||||
|
* 使用内存 Map 存储,按 sessionId 隔离。
|
||||||
|
* 与 task_manager 区别:todo_write 用于临时思考拆解(内存,会话级),
|
||||||
|
* task_manager 用于持久化任务跟踪(数据库,跨会话)。
|
||||||
|
*
|
||||||
|
* v0.3.1 修复 FAIL-2: 添加 LRU 策略,限制最大会话数 50,
|
||||||
|
* 超出时淘汰最久未访问的会话。提供 clearSession 静态方法供外部清理。
|
||||||
|
*
|
||||||
|
* v0.3.1 修复 WARN-3: 真正的 LRU 实现 — 访问命中时通过 delete + re-insert
|
||||||
|
* 刷新会话位置,确保活跃会话不被淘汰,仅淘汰最久未访问的会话。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
|
import log from 'electron-log';
|
||||||
|
|
||||||
|
type TodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||||
|
type TodoPriority = 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
|
interface TodoItem {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
status: TodoStatus;
|
||||||
|
priority: TodoPriority;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_STATUSES: TodoStatus[] = ['pending', 'in_progress', 'completed'];
|
||||||
|
const VALID_PRIORITIES: TodoPriority[] = ['high', 'medium', 'low'];
|
||||||
|
|
||||||
|
// v0.3.1 修复 FAIL-2: LRU 策略限制最大会话数,防止内存泄漏
|
||||||
|
const MAX_SESSIONS = 50;
|
||||||
|
|
||||||
|
export class TodoWriteTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'todo_write',
|
||||||
|
description: 'Manage a session-level TODO list in memory. Actions: create, update, list, complete, clear. Unlike task_manager (persistent DB), todo_write is for temporary task breakdown within a session and does not persist across sessions.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
action: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Operation type',
|
||||||
|
enum: ['create', 'update', 'list', 'complete', 'clear'],
|
||||||
|
},
|
||||||
|
id: { type: 'string', description: 'TODO item ID (required for update/complete)' },
|
||||||
|
content: { type: 'string', description: 'TODO content (required for create/update)' },
|
||||||
|
status: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Status (for update)',
|
||||||
|
enum: ['pending', 'in_progress', 'completed'],
|
||||||
|
},
|
||||||
|
priority: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Priority (for create, default medium)',
|
||||||
|
enum: ['high', 'medium', 'low'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['action'],
|
||||||
|
},
|
||||||
|
// v0.3.1 修复 WARN-9: 改为 CUSTOM(内存操作,非数据库)
|
||||||
|
category: MetonaToolCategory.CUSTOM,
|
||||||
|
riskLevel: MetonaRiskLevel.SAFE,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// v0.3.1 修复 FAIL-2: 会话级 TODO 存储 + LRU 淘汰
|
||||||
|
private static todosBySession = new Map<string, Map<string, TodoItem>>();
|
||||||
|
private static counter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.3.1 修复 FAIL-2: 清理指定会话的 TODO(供外部调用,如会话结束时)
|
||||||
|
*/
|
||||||
|
static clearSession(sessionId: string): void {
|
||||||
|
const removed = TodoWriteTool.todosBySession.delete(sessionId);
|
||||||
|
if (removed) {
|
||||||
|
log.debug(`[TodoWriteTool] Cleared TODOs for session ${sessionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.3.1 修复 FAIL-2: LRU 淘汰 — 超过 MAX_SESSIONS 时删除最久未访问的会话
|
||||||
|
* Map 的迭代顺序按插入顺序,第一个即最久未访问
|
||||||
|
*
|
||||||
|
* v0.3.1 修复 WARN-3: 配合 getSessionMap 的访问刷新位置,实现真正的 LRU。
|
||||||
|
* 活跃会话因访问而刷新到 Map 末尾,不会被淘汰。
|
||||||
|
*/
|
||||||
|
private static evictIfFull(): void {
|
||||||
|
while (TodoWriteTool.todosBySession.size >= MAX_SESSIONS) {
|
||||||
|
const oldest = TodoWriteTool.todosBySession.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
TodoWriteTool.todosBySession.delete(oldest);
|
||||||
|
log.debug(`[TodoWriteTool] LRU evicted session ${oldest}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const action = args.action as string;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'create':
|
||||||
|
return this.create(args, context);
|
||||||
|
case 'update':
|
||||||
|
return this.update(args, context);
|
||||||
|
case 'list':
|
||||||
|
return this.list(context);
|
||||||
|
case 'complete':
|
||||||
|
return this.complete(args, context);
|
||||||
|
case 'clear':
|
||||||
|
return this.clear(context);
|
||||||
|
default:
|
||||||
|
return { success: false, error: `Unknown action: ${action}` };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return { error: errMsg, success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定会话的 TODO Map
|
||||||
|
*
|
||||||
|
* v0.3.1 修复 WARN-3: 真正的 LRU — 命中已有会话时通过 delete + re-insert
|
||||||
|
* 将会话移到 Map 末尾(最近使用位置),防止活跃会话被淘汰。
|
||||||
|
* 新会话插入前触发 evictIfFull。
|
||||||
|
*/
|
||||||
|
private getSessionMap(context: ToolExecutionContext): Map<string, TodoItem> {
|
||||||
|
const existing = TodoWriteTool.todosBySession.get(context.sessionId);
|
||||||
|
if (existing) {
|
||||||
|
// LRU 刷新位置:delete + re-insert → 移到 Map 末尾
|
||||||
|
TodoWriteTool.todosBySession.delete(context.sessionId);
|
||||||
|
TodoWriteTool.todosBySession.set(context.sessionId, existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
// 新会话插入前检查 LRU 淘汰
|
||||||
|
TodoWriteTool.evictIfFull();
|
||||||
|
const map = new Map<string, TodoItem>();
|
||||||
|
TodoWriteTool.todosBySession.set(context.sessionId, map);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private create(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||||||
|
const content = args.content as string;
|
||||||
|
if (!content) {
|
||||||
|
return { success: false, id: '', todo: null, error: 'content is required for create action' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = (args.priority as TodoPriority) ?? 'medium';
|
||||||
|
if (!VALID_PRIORITIES.includes(priority)) {
|
||||||
|
return { success: false, id: '', todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const id = `todo_${now}_${TodoWriteTool.counter++}`;
|
||||||
|
const todo: TodoItem = {
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
status: 'pending',
|
||||||
|
priority,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
this.getSessionMap(context).set(id, todo);
|
||||||
|
|
||||||
|
return { success: true, id, todo };
|
||||||
|
}
|
||||||
|
|
||||||
|
private update(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||||||
|
const id = args.id as string;
|
||||||
|
if (!id) {
|
||||||
|
return { success: false, id: '', todo: null, error: 'id is required for update action' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = this.getSessionMap(context);
|
||||||
|
const todo = map.get(id);
|
||||||
|
if (!todo) {
|
||||||
|
return { success: false, id, todo: null, error: `TODO not found: ${id}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.content !== undefined) {
|
||||||
|
todo.content = args.content as string;
|
||||||
|
}
|
||||||
|
if (args.status !== undefined) {
|
||||||
|
const status = args.status as TodoStatus;
|
||||||
|
if (!VALID_STATUSES.includes(status)) {
|
||||||
|
return { success: false, id, todo: null, error: `Invalid status: ${status}. Must be one of: ${VALID_STATUSES.join(', ')}` };
|
||||||
|
}
|
||||||
|
todo.status = status;
|
||||||
|
}
|
||||||
|
if (args.priority !== undefined) {
|
||||||
|
const priority = args.priority as TodoPriority;
|
||||||
|
if (!VALID_PRIORITIES.includes(priority)) {
|
||||||
|
return { success: false, id, todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
|
||||||
|
}
|
||||||
|
todo.priority = priority;
|
||||||
|
}
|
||||||
|
todo.updatedAt = Date.now();
|
||||||
|
|
||||||
|
return { success: true, id, todo };
|
||||||
|
}
|
||||||
|
|
||||||
|
private list(context: ToolExecutionContext): unknown {
|
||||||
|
const map = this.getSessionMap(context);
|
||||||
|
const todos = Array.from(map.values()).sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
return {
|
||||||
|
todos,
|
||||||
|
count: todos.length,
|
||||||
|
pending: todos.filter((t) => t.status === 'pending').length,
|
||||||
|
completed: todos.filter((t) => t.status === 'completed').length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private complete(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||||||
|
const id = args.id as string;
|
||||||
|
if (!id) {
|
||||||
|
return { success: false, id: '', todo: null, error: 'id is required for complete action' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = this.getSessionMap(context);
|
||||||
|
const todo = map.get(id);
|
||||||
|
if (!todo) {
|
||||||
|
return { success: false, id, todo: null, error: `TODO not found: ${id}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
todo.status = 'completed';
|
||||||
|
todo.updatedAt = Date.now();
|
||||||
|
return { success: true, id, todo };
|
||||||
|
}
|
||||||
|
|
||||||
|
private clear(context: ToolExecutionContext): unknown {
|
||||||
|
const map = this.getSessionMap(context);
|
||||||
|
const cleared = map.size;
|
||||||
|
map.clear();
|
||||||
|
return { success: true, cleared };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* 图片查看工具(1 个)
|
||||||
|
*
|
||||||
|
* view_image — 读取图片文件并返回 base64 data URL
|
||||||
|
*
|
||||||
|
* 支持格式:png, jpg, jpeg, gif, webp, bmp, svg
|
||||||
|
* 文件大小限制:5MB
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, stat } from 'fs/promises';
|
||||||
|
import { resolve, extname } from 'path';
|
||||||
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
|
import { isPathWithinWorkspace } from './file-guard';
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
|
/** 扩展名 → MIME 映射 */
|
||||||
|
const SUPPORTED_FORMATS: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUPPORTED_EXTENSIONS = Object.keys(SUPPORTED_FORMATS);
|
||||||
|
|
||||||
|
export class ViewImageTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'view_image',
|
||||||
|
description:
|
||||||
|
'Read an image file and return its content as a base64 data URL. Supports png, jpg, jpeg, gif, webp, bmp, svg. File size limit: 5MB. Path must be within the workspace.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string', description: 'Image file path (relative to workspace or absolute)' },
|
||||||
|
},
|
||||||
|
required: ['path'],
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.FILESYSTEM,
|
||||||
|
riskLevel: MetonaRiskLevel.SAFE,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const path = args.path as string;
|
||||||
|
if (!path) {
|
||||||
|
return { error: 'path is required', success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全校验:路径必须在 workspace 内(防范路径遍历)
|
||||||
|
if (!isPathWithinWorkspace(path, context.workspacePath)) {
|
||||||
|
return { error: 'Path outside workspace', path, success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展名校验
|
||||||
|
const ext = extname(path).toLowerCase();
|
||||||
|
const mimeType = SUPPORTED_FORMATS[ext];
|
||||||
|
if (!mimeType) {
|
||||||
|
return {
|
||||||
|
error: 'Unsupported image format',
|
||||||
|
path,
|
||||||
|
supportedFormats: SUPPORTED_EXTENSIONS,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = resolve(context.workspacePath, path);
|
||||||
|
|
||||||
|
// 文件存在性 + 大小检查(先 stat 再读取,避免读取超大文件)
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = await stat(resolvedPath);
|
||||||
|
} catch {
|
||||||
|
return { error: 'File not found', path, success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.size > MAX_IMAGE_BYTES) {
|
||||||
|
return { error: 'Image too large (max 5MB)', size: stats.size, success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件并编码为 base64 data URL
|
||||||
|
const buffer = await readFile(resolvedPath);
|
||||||
|
const dataUrl = `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||||
|
|
||||||
|
// v0.3.1 修复 WARN-3: 成功路径添加 success: true
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
size: stats.size,
|
||||||
|
mimeType,
|
||||||
|
dataUrl,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return { error: errMsg, success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,8 +139,23 @@ export class ToolRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 截断过大的工具返回值,防止 LLM 上下文溢出 */
|
/**
|
||||||
|
* 截断过大的工具返回值,防止 LLM 上下文溢出
|
||||||
|
*
|
||||||
|
* v0.3.1 修复 FAIL-1: view_image 返回的 dataUrl 需完整传输给多模态 LLM,
|
||||||
|
* 截断会导致 base64 损坏、图片无法显示。对含 dataUrl 字段的结果跳过截断。
|
||||||
|
* view_image 已在工具内部限制文件大小 5MB,base64 后约 6.7MB,
|
||||||
|
* 多模态 LLM 能处理此量级数据。
|
||||||
|
*
|
||||||
|
* v0.3.1 修复 WARN-2: dataUrl 检测前置到 stringify 之前,
|
||||||
|
* 避免对 5MB+ 的图片对象做无意义的 JSON.stringify(约 6.7MB 字符串)。
|
||||||
|
*/
|
||||||
private truncateResult(result: unknown): unknown {
|
private truncateResult(result: unknown): unknown {
|
||||||
|
// 先检测 dataUrl 白名单:图片类结果跳过截断(避免 base64 损坏 + 避免无意义的序列化)
|
||||||
|
if (typeof result === 'object' && result !== null && 'dataUrl' in result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const str = typeof result === 'string' ? result : JSON.stringify(result);
|
const str = typeof result === 'string' ? result : JSON.stringify(result);
|
||||||
if (str.length <= MAX_RESULT_CHARS) return result;
|
if (str.length <= MAX_RESULT_CHARS) return result;
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ export interface AdapterConfig {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
/** 自定义请求头 */
|
/** 自定义请求头 */
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* 上下文窗口大小(用于 Engine 压缩判断和 UI 显示)
|
||||||
|
*
|
||||||
|
* v0.3.1 新增:DeepSeek/Agnes 不再写死 1M,可通过配置注入。
|
||||||
|
* - Ollama: 不使用此字段(使用 contextLength/num_ctx 传给 API)
|
||||||
|
* - DeepSeek/Agnes: OpenAI 兼容 API 不支持 context_window 参数,
|
||||||
|
* 此值仅用于 Engine 压缩判断和前端显示,不传给 API
|
||||||
|
*/
|
||||||
|
contextWindow?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -667,7 +667,9 @@ export function registerAllIPCHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// LLM 相关配置变更时热重载 Adapter
|
// LLM 相关配置变更时热重载 Adapter
|
||||||
if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx'].includes(key)) {
|
// v0.3.1: 加入 deepseek.contextWindow / agnes.contextWindow,使上下文窗口配置变化也触发热重载
|
||||||
|
if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx',
|
||||||
|
'deepseek.contextWindow', 'agnes.contextWindow'].includes(key)) {
|
||||||
// C-1 修复: reloadAdapter 返回 false 时表示加载失败,需要通知前端
|
// C-1 修复: reloadAdapter 返回 false 时表示加载失败,需要通知前端
|
||||||
const reloadSuccess = reloadAdapter();
|
const reloadSuccess = reloadAdapter();
|
||||||
if (!reloadSuccess) {
|
if (!reloadSuccess) {
|
||||||
@@ -698,6 +700,13 @@ export function registerAllIPCHandlers(
|
|||||||
agentLoop.updateConfig({ contextLength: (value as number) || undefined });
|
agentLoop.updateConfig({ contextLength: (value as number) || undefined });
|
||||||
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
|
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
|
||||||
log.info(`[CONFIG] Agent contextLength updated to ${value}`);
|
log.info(`[CONFIG] Agent contextLength updated to ${value}`);
|
||||||
|
} else if (key === 'deepseek.contextWindow' || key === 'agnes.contextWindow') {
|
||||||
|
// v0.3.1: DeepSeek/Agnes contextWindow 变更,同步到 Engine 和 Orchestrator
|
||||||
|
// reloadAdapter 已重建 adapter,此处确保 Engine contextWindow 同步(兜底)
|
||||||
|
const ctxWindow = (value as number) || undefined;
|
||||||
|
agentLoop.updateConfig({ contextWindow: ctxWindow });
|
||||||
|
orchestrator.updateDefaultConfig({ contextWindow: ctxWindow });
|
||||||
|
log.info(`[CONFIG] ${key} updated to ${value}`);
|
||||||
} else if (key === 'agent.confirmationTimeoutMs') {
|
} else if (key === 'agent.confirmationTimeoutMs') {
|
||||||
// 工具确认超时变更,即时应用到 ConfirmationHook
|
// 工具确认超时变更,即时应用到 ConfirmationHook
|
||||||
confirmationHook.setConfirmationTimeout(value as number);
|
confirmationHook.setConfirmationTimeout(value as number);
|
||||||
|
|||||||
+62
-8
@@ -49,6 +49,13 @@ import {
|
|||||||
CodeSearchTool,
|
CodeSearchTool,
|
||||||
TaskManagerTool,
|
TaskManagerTool,
|
||||||
DiffViewerTool,
|
DiffViewerTool,
|
||||||
|
// v0.3.1 新增工具(11 个)
|
||||||
|
GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool,
|
||||||
|
LintCodeTool, RunTestsTool, ProjectInfoTool,
|
||||||
|
HttpRequestTool,
|
||||||
|
TodoWriteTool,
|
||||||
|
ThinkTool,
|
||||||
|
ViewImageTool,
|
||||||
} from './harness/tools/built-in';
|
} from './harness/tools/built-in';
|
||||||
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
||||||
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
|
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
|
||||||
@@ -144,7 +151,12 @@ async function initialize(): Promise<void> {
|
|||||||
throw new Error(`API key is required for provider "${provider || 'unknown'}". Please set it in Settings.`);
|
throw new Error(`API key is required for provider "${provider || 'unknown'}". Please set it in Settings.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapterConfig = { provider, baseURL, apiKey, defaultModel: model };
|
// v0.3.1: 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段)
|
||||||
|
const contextWindow = provider !== 'ollama'
|
||||||
|
? configService.get<number>(`${provider}.contextWindow`) ?? undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case 'agnes': return new AgnesAdapter(adapterConfig);
|
case 'agnes': return new AgnesAdapter(adapterConfig);
|
||||||
case 'ollama': return new OllamaAdapter(adapterConfig);
|
case 'ollama': return new OllamaAdapter(adapterConfig);
|
||||||
@@ -200,7 +212,26 @@ async function initialize(): Promise<void> {
|
|||||||
|
|
||||||
// 注册 Web Browser 统一浏览器工具
|
// 注册 Web Browser 统一浏览器工具
|
||||||
toolRegistry.registerBuiltin(new WebBrowserTool());
|
toolRegistry.registerBuiltin(new WebBrowserTool());
|
||||||
log.info(`Registered ${toolRegistry.size} built-in tools`);
|
|
||||||
|
// v0.3.1: Git 工具集(4 个)
|
||||||
|
toolRegistry.registerBuiltin(new GitStatusTool());
|
||||||
|
toolRegistry.registerBuiltin(new GitDiffTool());
|
||||||
|
toolRegistry.registerBuiltin(new GitLogTool());
|
||||||
|
toolRegistry.registerBuiltin(new GitCommitTool());
|
||||||
|
|
||||||
|
// v0.3.1: 开发工具集(3 个)
|
||||||
|
toolRegistry.registerBuiltin(new LintCodeTool());
|
||||||
|
toolRegistry.registerBuiltin(new RunTestsTool());
|
||||||
|
toolRegistry.registerBuiltin(new ProjectInfoTool());
|
||||||
|
|
||||||
|
// v0.3.1: 独立工具(4 个)
|
||||||
|
toolRegistry.registerBuiltin(new HttpRequestTool());
|
||||||
|
toolRegistry.registerBuiltin(new TodoWriteTool());
|
||||||
|
toolRegistry.registerBuiltin(new ThinkTool());
|
||||||
|
toolRegistry.registerBuiltin(new ViewImageTool());
|
||||||
|
|
||||||
|
// v0.3.1 修复 WARN-5: DelegateTaskTool 注册后再输出总数(此时才是完整的 26 个)
|
||||||
|
// log.info 移至 DelegateTaskTool 注册后
|
||||||
|
|
||||||
// ===== MCP Manager =====
|
// ===== MCP Manager =====
|
||||||
const mcpManager = new MCPManager(() => db, toolRegistry);
|
const mcpManager = new MCPManager(() => db, toolRegistry);
|
||||||
@@ -236,6 +267,8 @@ async function initialize(): Promise<void> {
|
|||||||
maxIterations: agentMaxIter ?? 20,
|
maxIterations: agentMaxIter ?? 20,
|
||||||
totalTimeoutMs: agentTimeout ?? 600_000,
|
totalTimeoutMs: agentTimeout ?? 600_000,
|
||||||
contextLength: ollamaNumCtx ?? undefined,
|
contextLength: ollamaNumCtx ?? undefined,
|
||||||
|
// v0.3.1: 从 adapter.getContextWindow() 读取,修复 Engine 128K vs Adapter 1M 不一致 bug
|
||||||
|
contextWindow: adapter.getContextWindow(),
|
||||||
thinkingEnabled: agentThinkingEnabled ?? true,
|
thinkingEnabled: agentThinkingEnabled ?? true,
|
||||||
thinkingEffort: agentThinkingEffort ?? 'high',
|
thinkingEffort: agentThinkingEffort ?? 'high',
|
||||||
toolExecutionTimeoutMs: toolExecTimeout ?? 120_000,
|
toolExecutionTimeoutMs: toolExecTimeout ?? 120_000,
|
||||||
@@ -255,6 +288,8 @@ async function initialize(): Promise<void> {
|
|||||||
thinkingEnabled: agentThinkingEnabled ?? true,
|
thinkingEnabled: agentThinkingEnabled ?? true,
|
||||||
thinkingEffort: agentThinkingEffort ?? 'high',
|
thinkingEffort: agentThinkingEffort ?? 'high',
|
||||||
contextLength: ollamaNumCtx ?? undefined,
|
contextLength: ollamaNumCtx ?? undefined,
|
||||||
|
// v0.3.1: 同步 contextWindow 到 SubAgent 配置
|
||||||
|
contextWindow: adapter.getContextWindow(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator));
|
toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator));
|
||||||
@@ -262,16 +297,27 @@ async function initialize(): Promise<void> {
|
|||||||
agentLoop.setTools(toolRegistry.listTools());
|
agentLoop.setTools(toolRegistry.listTools());
|
||||||
// v0.2.0: 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存
|
// v0.2.0: 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存
|
||||||
confirmationHook.setToolDefs(toolRegistry.listAllTools());
|
confirmationHook.setToolDefs(toolRegistry.listAllTools());
|
||||||
|
// v0.3.1: 所有工具(含 DelegateTaskTool)注册完成后输出总数,确保日志显示 26 而非 25
|
||||||
|
log.info(`Registered ${toolRegistry.size} built-in tools`);
|
||||||
|
|
||||||
// ===== 热重载 Adapter 回调(设置变更时触发)=====
|
// ===== 热重载 Adapter 回调(设置变更时触发)=====
|
||||||
// 记录上次创建 adapter 时的配置快照,用于检测配置是否真的变化
|
// 记录上次创建 adapter 时的配置快照,用于检测配置是否真的变化
|
||||||
let lastProvider = configService.get<string>('llm.provider') ?? '';
|
let lastProvider = configService.get<string>('llm.provider') ?? '';
|
||||||
let lastConfigSig = JSON.stringify({
|
// v0.3.1: configSig 加入 contextWindow 配置,使 contextWindow 变化也能触发热重载
|
||||||
provider: configService.get<string>('llm.provider') ?? '',
|
const buildConfigSig = () => {
|
||||||
|
const provider = configService.get<string>('llm.provider') ?? '';
|
||||||
|
return JSON.stringify({
|
||||||
|
provider,
|
||||||
model: configService.get<string>('llm.model') ?? '',
|
model: configService.get<string>('llm.model') ?? '',
|
||||||
apiKey: configService.get<string>('llm.apiKey') ?? '',
|
apiKey: configService.get<string>('llm.apiKey') ?? '',
|
||||||
baseURL: configService.get<string>('llm.baseURL') ?? '',
|
baseURL: configService.get<string>('llm.baseURL') ?? '',
|
||||||
|
// v0.3.1: 加入 contextWindow 配置(Ollama 用 numCtx,其他 Provider 用 ${provider}.contextWindow)
|
||||||
|
contextWindow: provider === 'ollama'
|
||||||
|
? configService.get<number>('ollama.numCtx')
|
||||||
|
: configService.get<number>(`${provider}.contextWindow`),
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
let lastConfigSig = buildConfigSig();
|
||||||
const reloadAdapter = (): boolean => {
|
const reloadAdapter = (): boolean => {
|
||||||
try {
|
try {
|
||||||
// 检测配置是否真的变化(避免每次发消息都重建 adapter 和弹 toast)
|
// 检测配置是否真的变化(避免每次发消息都重建 adapter 和弹 toast)
|
||||||
@@ -281,7 +327,7 @@ async function initialize(): Promise<void> {
|
|||||||
apiKey: configService.get<string>('llm.apiKey') ?? '',
|
apiKey: configService.get<string>('llm.apiKey') ?? '',
|
||||||
baseURL: configService.get<string>('llm.baseURL') ?? '',
|
baseURL: configService.get<string>('llm.baseURL') ?? '',
|
||||||
};
|
};
|
||||||
const currentSig = JSON.stringify(currentConfig);
|
const currentSig = buildConfigSig();
|
||||||
|
|
||||||
// 配置未变化 — 幂等返回成功,不重建 adapter,不发通知
|
// 配置未变化 — 幂等返回成功,不重建 adapter,不发通知
|
||||||
if (currentSig === lastConfigSig) {
|
if (currentSig === lastConfigSig) {
|
||||||
@@ -292,13 +338,21 @@ async function initialize(): Promise<void> {
|
|||||||
const newAdapter = createAdapter();
|
const newAdapter = createAdapter();
|
||||||
agentLoop.setAdapter(newAdapter);
|
agentLoop.setAdapter(newAdapter);
|
||||||
memoryConsolidator.setAdapter(newAdapter);
|
memoryConsolidator.setAdapter(newAdapter);
|
||||||
// Provider 切换时同步 contextLength:仅 Ollama 使用 numCtx 作为有效上下文窗口
|
// Provider 切换时同步 contextLength 和 contextWindow
|
||||||
const provider = currentConfig.provider;
|
const provider = currentConfig.provider;
|
||||||
if (provider === 'ollama') {
|
if (provider === 'ollama') {
|
||||||
const numCtx = configService.get<number>('ollama.numCtx');
|
const numCtx = configService.get<number>('ollama.numCtx');
|
||||||
agentLoop.updateConfig({ contextLength: numCtx ?? undefined });
|
agentLoop.updateConfig({
|
||||||
|
contextLength: numCtx ?? undefined,
|
||||||
|
// v0.3.1: 同步 contextWindow(Ollama 的 getContextWindow 返回固定 4096)
|
||||||
|
contextWindow: newAdapter.getContextWindow(),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
agentLoop.updateConfig({ contextLength: undefined });
|
// v0.3.1: DeepSeek/Agnes 无 contextLength,但同步 contextWindow
|
||||||
|
agentLoop.updateConfig({
|
||||||
|
contextLength: undefined,
|
||||||
|
contextWindow: newAdapter.getContextWindow(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
log.info(`[CONFIG] Adapter reloaded: provider=${provider}`);
|
log.info(`[CONFIG] Adapter reloaded: provider=${provider}`);
|
||||||
// 仅在 Provider 真正变化时通知渲染进程
|
// 仅在 Provider 真正变化时通知渲染进程
|
||||||
|
|||||||
@@ -374,6 +374,11 @@ export class DatabaseService {
|
|||||||
// Ollama 配置
|
// Ollama 配置
|
||||||
{ key: 'ollama.numCtx', value: 'null', category: 'ollama' },
|
{ key: 'ollama.numCtx', value: 'null', category: 'ollama' },
|
||||||
|
|
||||||
|
// Provider 上下文窗口配置(用于 Engine 压缩判断和 UI 显示)
|
||||||
|
// v0.3.1: DeepSeek/Agnes 改为可配置,不再写死 1M
|
||||||
|
{ key: 'deepseek.contextWindow', value: '1000000', category: 'deepseek' },
|
||||||
|
{ key: 'agnes.contextWindow', value: '1000000', category: 'agnes' },
|
||||||
|
|
||||||
// Onboarding
|
// Onboarding
|
||||||
{ key: 'onboarding.completed', value: 'false', category: 'general' },
|
{ key: 'onboarding.completed', value: 'false', category: 'general' },
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
|
|||||||
@@ -290,6 +290,9 @@ function LLMSettings() {
|
|||||||
const [apiKey, setApiKey] = useConfig('llm.apiKey', '');
|
const [apiKey, setApiKey] = useConfig('llm.apiKey', '');
|
||||||
const [baseURL, setBaseURL] = useConfig('llm.baseURL', '');
|
const [baseURL, setBaseURL] = useConfig('llm.baseURL', '');
|
||||||
const [numCtx, setNumCtx] = useConfig('ollama.numCtx', null as number | null);
|
const [numCtx, setNumCtx] = useConfig('ollama.numCtx', null as number | null);
|
||||||
|
// v0.3.1: DeepSeek/Agnes contextWindow 可配置(不再写死 1M)
|
||||||
|
const [dsCtxWindow, setDsCtxWindow] = useConfig('deepseek.contextWindow', 1000000);
|
||||||
|
const [agnesCtxWindow, setAgnesCtxWindow] = useConfig('agnes.contextWindow', 1000000);
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
|
||||||
// 同步 Provider/Model 到 Agent Store(含 contextWindow)
|
// 同步 Provider/Model 到 Agent Store(含 contextWindow)
|
||||||
@@ -297,12 +300,16 @@ function LLMSettings() {
|
|||||||
useAgentStore.getState().setProvider(provider, model);
|
useAgentStore.getState().setProvider(provider, model);
|
||||||
}, [provider, model]);
|
}, [provider, model]);
|
||||||
|
|
||||||
// Ollama numCtx 变化时同步 contextWindow
|
// v0.3.1: contextWindow 变化时同步到 Agent Store(支持所有 Provider)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (provider === 'ollama' && numCtx != null && numCtx > 0) {
|
if (provider === 'ollama') {
|
||||||
useAgentStore.setState({ contextWindow: numCtx });
|
if (numCtx != null && numCtx > 0) useAgentStore.setState({ contextWindow: numCtx });
|
||||||
|
} else if (provider === 'deepseek') {
|
||||||
|
if (dsCtxWindow != null && dsCtxWindow > 0) useAgentStore.setState({ contextWindow: dsCtxWindow });
|
||||||
|
} else if (provider === 'agnes') {
|
||||||
|
if (agnesCtxWindow != null && agnesCtxWindow > 0) useAgentStore.setState({ contextWindow: agnesCtxWindow });
|
||||||
}
|
}
|
||||||
}, [provider, numCtx]);
|
}, [provider, numCtx, dsCtxWindow, agnesCtxWindow]);
|
||||||
|
|
||||||
// 切换 Provider 时自动填充默认 URL + 清空 apiKey(不同 Provider 的 key 不通用)
|
// 切换 Provider 时自动填充默认 URL + 清空 apiKey(不同 Provider 的 key 不通用)
|
||||||
const handleProviderChange = (newProvider: string) => {
|
const handleProviderChange = (newProvider: string) => {
|
||||||
@@ -359,6 +366,31 @@ function LLMSettings() {
|
|||||||
slotProps={{ htmlInput: { min: 512, step: 512 } }}
|
slotProps={{ htmlInput: { min: 512, step: 512 } }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */}
|
||||||
|
{provider === 'deepseek' && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="上下文窗口 (contextWindow)"
|
||||||
|
type="number"
|
||||||
|
value={dsCtxWindow ?? 1000000}
|
||||||
|
onChange={(e) => setDsCtxWindow(Number(e.target.value) || 1000000)}
|
||||||
|
placeholder="如 64000、128000、1000000"
|
||||||
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
|
helperText="用于上下文压缩判断,不传给 API"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{provider === 'agnes' && (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="上下文窗口 (contextWindow)"
|
||||||
|
type="number"
|
||||||
|
value={agnesCtxWindow ?? 1000000}
|
||||||
|
onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)}
|
||||||
|
placeholder="如 64000、128000、1000000"
|
||||||
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
|
helperText="用于上下文压缩判断,不传给 API"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -466,7 +498,8 @@ function ToolsSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = { safe: 'success', low: 'info', medium: 'warning', high: 'error' };
|
// v0.3.1: 添加 critical 键,防止 critical 级别工具 Chip 渲染为 undefined color
|
||||||
|
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = { safe: 'success', low: 'info', medium: 'warning', high: 'error', critical: 'error' };
|
||||||
|
|
||||||
// 需要确认的工具(high/critical 或 requiresPermission)
|
// 需要确认的工具(high/critical 或 requiresPermission)
|
||||||
const needsConfirmTools = tools.filter((t) =>
|
const needsConfirmTools = tools.filter((t) =>
|
||||||
|
|||||||
@@ -375,12 +375,14 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
|
|
||||||
setProvider: (provider, model) => {
|
setProvider: (provider, model) => {
|
||||||
// L-16 修复: 使用命名常量替代魔法数字
|
// L-16 修复: 使用命名常量替代魔法数字
|
||||||
// DeepSeek/Agnes 固定 1M,Ollama 从配置读取
|
// v0.3.1: DeepSeek/Agnes 不再固定 1M,从配置读取
|
||||||
const ollamaCtx = provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW;
|
const initialCtx = provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW;
|
||||||
set({ provider, model, contextWindow: ollamaCtx });
|
set({ provider, model, contextWindow: initialCtx });
|
||||||
// 异步读取 Ollama 实际配置
|
// v0.3.1: 异步读取实际配置(所有 Provider)
|
||||||
if (provider === 'ollama' && window.metona?.config?.get) {
|
if (window.metona?.config?.get) {
|
||||||
window.metona.config.get('ollama.numCtx').then((v) => {
|
// Ollama 用 ollama.numCtx,DeepSeek 用 deepseek.contextWindow,Agnes 用 agnes.contextWindow
|
||||||
|
const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`;
|
||||||
|
window.metona.config.get(configKey).then((v) => {
|
||||||
if (v != null && typeof v === 'number' && v > 0) {
|
if (v != null && typeof v === 'number' && v > 0) {
|
||||||
set({ contextWindow: v });
|
set({ contextWindow: v });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user