guanghulab/glada/step-executor.js
2026-05-10 13:12:44 +08:00

512 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
};