guanghulab/glada/step-executor.js

512 lines
15 KiB
JavaScript
Raw Normal View History

/**
* GLADA · 步骤执行器 · step-executor.js
*
* 负责
* 1. 接收单个步骤的执行指令
* 2. 调用 LLM 生成代码/修改方案
* 3. 解析 LLM 输出提取文件变更
* 4. 应用文件变更到本地文件系统
* 5. 运行测试验证变更
* 6. 记录执行日志包含"为什么"
*
* 版权国作登字-2026-A-00037559
* 签发铸渊 · ICE-GL-ZY001
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const https = require('https');
const http = require('http');
const modelRouter = require('./model-router');
const ROOT = path.resolve(__dirname, '..');
// ── LLM 调用配置 ───────────────────────────────
function getLLMConfig() {
const apiKey = process.env.ZY_LLM_API_KEY || process.env.LLM_API_KEY || '';
const baseUrl = (process.env.ZY_LLM_BASE_URL || process.env.LLM_BASE_URL || '').replace(/\/+$/, '');
return { apiKey, baseUrl };
}
/**
* HTTP 请求工具
*/
function httpRequest(url, options, body) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:';
const mod = isHttps ? https : http;
const opts = {
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method: options.method || 'POST',
headers: options.headers || {},
timeout: options.timeout || 120000,
};
const req = mod.request(opts, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
resolve({ status: res.statusCode, body: data });
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timeout'));
});
if (body) {
req.write(body);
}
req.end();
});
}
/**
* 调用 LLM API带重试和指数退避
* @param {string} systemPrompt - 系统提示词
* @param {string} userMessage - 用户消息
* @param {Object} [options] - 选项
* @returns {Promise<string>} LLM 响应文本
*/
async function callLLM(systemPrompt, userMessage, options = {}) {
const config = getLLMConfig();
const model = options.model || 'deepseek-chat';
const maxTokens = options.maxTokens || 8192;
const maxRetries = options.maxRetries || 3;
const backoffMs = options.backoffMs || 5000;
if (!config.apiKey || !config.baseUrl) {
throw new Error('LLM API 未配置:请设置 ZY_LLM_API_KEY 和 ZY_LLM_BASE_URL');
}
const url = `${config.baseUrl}/chat/completions`;
const body = JSON.stringify({
model,
max_tokens: maxTokens,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage }
]
});
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await httpRequest(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`
},
timeout: 180000
}, body);
if (response.status !== 200) {
const errBody = String(response.body || '').substring(0, 500);
// 429 (Rate limit) or 5xx are retryable
if ((response.status === 429 || response.status >= 500) && attempt < maxRetries) {
const wait = backoffMs * Math.pow(2, attempt - 1);
console.warn(`[GLADA-LLM] ⚠️ API返回 ${response.status}${wait / 1000}s 后重试 (${attempt}/${maxRetries})`);
await new Promise(r => setTimeout(r, wait));
lastError = new Error(`LLM API 错误: ${response.status} - ${errBody}`);
continue;
}
throw new Error(`LLM API 错误: ${response.status} - ${errBody}`);
}
const result = JSON.parse(response.body);
return result.choices?.[0]?.message?.content || '';
} catch (err) {
lastError = err;
// Network errors are retryable
if (attempt < maxRetries && (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ECONNREFUSED' || err.message === 'Request timeout')) {
const wait = backoffMs * Math.pow(2, attempt - 1);
console.warn(`[GLADA-LLM] ⚠️ 网络错误: ${err.message}${wait / 1000}s 后重试 (${attempt}/${maxRetries})`);
await new Promise(r => setTimeout(r, wait));
continue;
}
throw err;
}
}
throw lastError || new Error('LLM API 调用失败:已达最大重试次数');
}
/**
* 解析 LLM 输出中的文件变更
*
* 期望的 LLM 输出格式
* ```json
* {
* "reasoning": "为什么这样修改的原因",
* "files": [
* {
* "path": "相对路径",
* "action": "create|modify|delete",
* "content": "完整文件内容create/modify时"
* }
* ],
* "summary": "变更摘要"
* }
* ```
*
* @param {string} llmOutput - LLM 的原始输出
* @returns {Object|null} 解析后的变更对象
*/
function parseFileChanges(llmOutput) {
// 尝试从输出中提取 JSON
const jsonMatch = llmOutput.match(/```json\s*\n([\s\S]*?)\n```/);
let parsed = null;
if (jsonMatch) {
try {
parsed = JSON.parse(jsonMatch[1]);
} catch {
// 继续尝试其他方式
}
}
if (!parsed) {
// 尝试直接解析整个输出
try {
parsed = JSON.parse(llmOutput);
} catch {
// 尝试提取第一个 { 到最后一个 } 之间的内容
const braceStart = llmOutput.indexOf('{');
const braceEnd = llmOutput.lastIndexOf('}');
if (braceStart !== -1 && braceEnd > braceStart) {
try {
parsed = JSON.parse(llmOutput.substring(braceStart, braceEnd + 1));
} catch {
return null;
}
}
}
}
if (!parsed || !parsed.files || !Array.isArray(parsed.files)) {
return null;
}
// 验证 files 格式
for (const file of parsed.files) {
if (!file.path || !file.action) {
return null;
}
if (!['create', 'modify', 'delete'].includes(file.action)) {
return null;
}
if ((file.action === 'create' || file.action === 'modify') && typeof file.content !== 'string') {
return null;
}
}
return parsed;
}
/**
* 拍摄文件快照用于回滚
* @param {string[]} filePaths - 要快照的文件路径
* @returns {Map<string, string|null>} 文件内容快照
*/
function snapshotFiles(filePaths) {
const snapshot = new Map();
for (const relPath of filePaths) {
const absPath = path.resolve(ROOT, relPath);
if (fs.existsSync(absPath)) {
snapshot.set(relPath, fs.readFileSync(absPath, 'utf-8'));
} else {
snapshot.set(relPath, null); // 文件不存在
}
}
return snapshot;
}
/**
* 从快照恢复文件
* @param {Map<string, string|null>} snapshot - 文件快照
*/
function restoreFromSnapshot(snapshot) {
for (const [relPath, content] of snapshot) {
const absPath = path.resolve(ROOT, relPath);
if (content === null) {
// 原来不存在的文件,删除
if (fs.existsSync(absPath)) {
fs.unlinkSync(absPath);
}
} else {
// 恢复原内容
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, content, 'utf-8');
}
}
}
/**
* 应用文件变更
* @param {Object[]} files - 变更文件列表
* @param {Object} constraints - 约束条件
* @returns {{ applied: string[], errors: string[] }}
*/
function applyFileChanges(files, constraints = {}) {
const applied = [];
const errors = [];
const noTouch = constraints.no_touch_files || [];
for (const file of files) {
// 检查约束
const isNoTouch = noTouch.some(nt => file.path.startsWith(nt));
if (isNoTouch) {
errors.push(`跳过受保护文件: ${file.path}`);
continue;
}
const absPath = path.resolve(ROOT, file.path);
try {
if (file.action === 'delete') {
if (fs.existsSync(absPath)) {
fs.unlinkSync(absPath);
applied.push(file.path);
}
} else {
// create 或 modify
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, file.content, 'utf-8');
applied.push(file.path);
}
} catch (err) {
errors.push(`文件操作失败 [${file.action}] ${file.path}: ${err.message}`);
}
}
return { applied, errors };
}
/**
* 运行测试
* @param {string} testCommand - 测试命令仅允许白名单中的命令
* @returns {{ success: boolean, output: string }}
*/
const ALLOWED_TEST_COMMANDS = [
'npm run test:smoke',
'npm run test:contract',
'npm run test',
'npm test',
'node glada/tests/glada-smoke.test.js'
];
function runTests(testCommand = 'npm run test:smoke') {
// 安全白名单校验,防止任意命令注入
if (!ALLOWED_TEST_COMMANDS.includes(testCommand)) {
return {
success: false,
output: `测试命令不在白名单中: ${testCommand}\n允许的命令: ${ALLOWED_TEST_COMMANDS.join(', ')}`
};
}
try {
const output = execSync(testCommand, {
cwd: ROOT,
encoding: 'utf-8',
timeout: 60000,
stdio: ['pipe', 'pipe', 'pipe']
});
return { success: true, output };
} catch (err) {
return {
success: false,
output: (err.stdout || '') + '\n' + (err.stderr || err.message || '')
};
}
}
/**
* 执行单个步骤
* @param {Object} step - 步骤信息 { step_id, description }
* @param {string} systemPrompt - 系统提示词来自 context-builder
* @param {Object} gladaTask - 完整的 GLADA 任务
* @param {Object} [options] - 执行选项
* @returns {Promise<Object>} 执行结果
*/
async function executeStep(step, systemPrompt, gladaTask, options = {}) {
const startTime = Date.now();
// 从 execution_plan 读取配置如果存在优先级options > execution_plan > defaults
const execPlan = gladaTask.execution_plan || {};
const retryPolicy = execPlan.retry_policy || {};
const result = {
step_id: step.step_id,
status: 'pending',
started_at: new Date().toISOString(),
completed_at: null,
reasoning: null,
files_changed: [],
test_result: null,
error: null,
duration_ms: 0
};
console.log(`[GLADA-Executor] 🔧 执行步骤 ${step.step_id}: ${step.description}`);
try {
// 1. 构建步骤执行指令
const userMessage = buildStepPrompt(step, gladaTask);
// 2. 调用 LLM使用模型路由器自动选择最佳模型
// 模型选择优先级: options.model > execPlan.model_preference > model-router 自动选择
const modelSelection = await modelRouter.selectModel(step.description, {
model: options.model || execPlan.model_preference || null,
taskType: options.taskType
});
console.log(`[GLADA-Executor] 🤖 调用 LLM · 模型: ${modelSelection.model} (${modelSelection.source})`);
const llmResponse = await callLLM(systemPrompt, userMessage, {
model: modelSelection.model,
maxTokens: options.maxTokens || 8192,
maxRetries: retryPolicy.max_retries || 3,
backoffMs: retryPolicy.backoff_ms || 5000
});
// 3. 解析文件变更
const changes = parseFileChanges(llmResponse);
if (!changes) {
result.status = 'failed';
result.error = 'LLM 输出格式无法解析为文件变更';
result.raw_output = llmResponse.substring(0, 2000);
result.duration_ms = Date.now() - startTime;
return result;
}
result.reasoning = changes.reasoning || '无推理记录';
// 4. 拍摄快照
const filePaths = changes.files.map(f => f.path);
const snapshot = snapshotFiles(filePaths);
// 5. 应用变更
console.log(`[GLADA-Executor] 📝 应用 ${changes.files.length} 个文件变更...`);
const { applied, errors } = applyFileChanges(changes.files, gladaTask.constraints);
if (errors.length > 0) {
console.warn(`[GLADA-Executor] ⚠️ 部分变更有错误:`, errors);
}
result.files_changed = applied;
// 6. 运行测试(如果启用)
if (gladaTask.constraints?.required_tests !== false && options.skipTests !== true) {
console.log(`[GLADA-Executor] 🧪 运行测试...`);
const testResult = runTests();
result.test_result = {
success: testResult.success,
output: testResult.output.substring(0, 1000)
};
// 测试失败则回滚
if (!testResult.success) {
console.warn(`[GLADA-Executor] ❌ 测试失败,回滚变更`);
restoreFromSnapshot(snapshot);
result.status = 'rolled_back';
result.error = '测试失败,已回滚: ' + testResult.output.substring(0, 500);
result.files_changed = [];
result.duration_ms = Date.now() - startTime;
return result;
}
}
result.status = 'completed';
result.summary = changes.summary || `完成步骤 ${step.step_id}`;
result.completed_at = new Date().toISOString();
result.duration_ms = Date.now() - startTime;
console.log(`[GLADA-Executor] ✅ 步骤 ${step.step_id} 完成 (${result.duration_ms}ms)`);
return result;
} catch (err) {
result.status = 'failed';
result.error = err.message;
result.duration_ms = Date.now() - startTime;
console.error(`[GLADA-Executor] ❌ 步骤 ${step.step_id} 失败: ${err.message}`);
return result;
}
}
/**
* 构建步骤执行提示词
* @param {Object} step - 步骤信息
* @param {Object} gladaTask - 完整任务
* @returns {string} 用户消息
*/
function buildStepPrompt(step, gladaTask) {
const parts = [
`## 当前步骤`,
`步骤 ${step.step_id}: ${step.description}`,
'',
`## 任务上下文`,
`任务: ${gladaTask.plan.title}`,
`总步骤数: ${gladaTask.plan.steps.length}`,
''
];
// 约束
if (gladaTask.constraints) {
parts.push('## 约束');
if (gladaTask.constraints.no_touch_files?.length > 0) {
parts.push(`禁止修改的文件: ${gladaTask.constraints.no_touch_files.join(', ')}`);
}
if (gladaTask.constraints.max_files_changed) {
parts.push(`最大修改文件数: ${gladaTask.constraints.max_files_changed}`);
}
parts.push('');
}
parts.push('## 输出要求');
parts.push('请以严格的 JSON 格式输出你的修改方案:');
parts.push('```json');
parts.push('{');
parts.push(' "reasoning": "为什么这样修改(必须详细解释因果链)",');
parts.push(' "files": [');
parts.push(' {');
parts.push(' "path": "相对于仓库根目录的文件路径",');
parts.push(' "action": "create|modify|delete",');
parts.push(' "content": "完整的文件内容create/modify时必须提供完整内容"');
parts.push(' }');
parts.push(' ],');
parts.push(' "summary": "变更摘要"');
parts.push('}');
parts.push('```');
parts.push('');
parts.push('重要:');
parts.push('1. content 必须是完整的文件内容,不要使用省略号或 "..." ');
parts.push('2. reasoning 必须解释"为什么"这样做,而不仅仅描述"做了什么"');
parts.push('3. 确保修改不会破坏现有功能');
parts.push('4. 只修改与当前步骤相关的文件');
return parts.join('\n');
}
module.exports = {
callLLM,
parseFileChanges,
snapshotFiles,
restoreFromSnapshot,
applyFileChanges,
runTests,
executeStep,
buildStepPrompt
};