489 lines
16 KiB
JavaScript
489 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
/**
|
||
* easyagent 批处理模式
|
||
* 用于子智能体执行,不需要交互式 CLI
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { streamChat } = require('../model/client');
|
||
const { executeTool } = require('../tools/dispatcher');
|
||
const { getModelByKey } = require('../config');
|
||
const { applyUsage, normalizeUsagePayload } = require('../utils/token_usage');
|
||
|
||
// 解析命令行参数
|
||
function parseArgs() {
|
||
const args = process.argv.slice(2);
|
||
const config = {
|
||
workspace: process.cwd(),
|
||
taskFile: null,
|
||
systemPromptFile: null,
|
||
outputFile: null,
|
||
statsFile: null,
|
||
progressFile: null,
|
||
agentId: null,
|
||
modelKey: null,
|
||
thinkingMode: null,
|
||
timeout: 600,
|
||
};
|
||
|
||
for (let i = 0; i < args.length; i++) {
|
||
const arg = args[i];
|
||
if (arg === '--workspace' && i + 1 < args.length) {
|
||
config.workspace = args[++i];
|
||
} else if (arg === '--task-file' && i + 1 < args.length) {
|
||
config.taskFile = args[++i];
|
||
} else if (arg === '--system-prompt-file' && i + 1 < args.length) {
|
||
config.systemPromptFile = args[++i];
|
||
} else if (arg === '--output-file' && i + 1 < args.length) {
|
||
config.outputFile = args[++i];
|
||
} else if (arg === '--stats-file' && i + 1 < args.length) {
|
||
config.statsFile = args[++i];
|
||
} else if (arg === '--progress-file' && i + 1 < args.length) {
|
||
config.progressFile = args[++i];
|
||
} else if (arg === '--agent-id' && i + 1 < args.length) {
|
||
config.agentId = args[++i];
|
||
} else if (arg === '--model-key' && i + 1 < args.length) {
|
||
config.modelKey = args[++i];
|
||
} else if (arg === '--thinking-mode' && i + 1 < args.length) {
|
||
config.thinkingMode = String(args[++i] || '').trim().toLowerCase();
|
||
} else if (arg === '--timeout' && i + 1 < args.length) {
|
||
config.timeout = parseInt(args[++i], 10);
|
||
}
|
||
}
|
||
|
||
return config;
|
||
}
|
||
|
||
// 读取文件内容
|
||
function readFile(filePath) {
|
||
try {
|
||
return fs.readFileSync(filePath, 'utf8');
|
||
} catch (err) {
|
||
console.error(`读取文件失败: ${filePath}`, err);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// 写入输出文件
|
||
function writeOutput(filePath, data) {
|
||
try {
|
||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||
} catch (err) {
|
||
console.error(`写入输出文件失败: ${filePath}`, err);
|
||
}
|
||
}
|
||
|
||
// 更新统计文件
|
||
function updateStats(statsFile, stats) {
|
||
try {
|
||
fs.writeFileSync(statsFile, JSON.stringify(stats, null, 2), 'utf8');
|
||
} catch (err) {
|
||
console.error(`更新统计文件失败: ${statsFile}`, err);
|
||
}
|
||
}
|
||
|
||
function appendProgress(progressFile, entry) {
|
||
if (!progressFile) return;
|
||
try {
|
||
const dir = path.dirname(progressFile);
|
||
if (!fs.existsSync(dir)) {
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
}
|
||
fs.appendFileSync(progressFile, `${JSON.stringify(entry)}\n`, 'utf8');
|
||
} catch (err) {
|
||
console.error(`写入进度失败: ${progressFile}`, err);
|
||
}
|
||
}
|
||
|
||
// 主函数
|
||
async function main() {
|
||
const config = parseArgs();
|
||
|
||
if (!config.taskFile || !config.systemPromptFile || !config.outputFile) {
|
||
console.error('缺少必需参数: --task-file, --system-prompt-file, --output-file');
|
||
process.exit(1);
|
||
}
|
||
|
||
// 读取任务和系统提示
|
||
const taskMessage = readFile(config.taskFile);
|
||
const systemPrompt = readFile(config.systemPromptFile);
|
||
|
||
// 加载模型配置
|
||
const modelConfig = require('../config');
|
||
const ensuredConfig = modelConfig.ensureConfig();
|
||
|
||
if (!ensuredConfig.valid_models || ensuredConfig.valid_models.length === 0) {
|
||
writeOutput(config.outputFile, {
|
||
success: false,
|
||
summary: '未找到可用模型配置',
|
||
error: 'no_model_config',
|
||
});
|
||
process.exit(1);
|
||
}
|
||
|
||
// 使用指定模型或默认模型
|
||
const modelKey = config.modelKey || ensuredConfig.default_model_key;
|
||
const model = getModelByKey(ensuredConfig, modelKey);
|
||
|
||
if (!model) {
|
||
writeOutput(config.outputFile, {
|
||
success: false,
|
||
summary: `未找到模型: ${modelKey}`,
|
||
error: 'model_not_found',
|
||
});
|
||
process.exit(1);
|
||
}
|
||
|
||
// 加载工具定义
|
||
const tools = JSON.parse(fs.readFileSync(path.join(__dirname, '../../doc/tools.json'), 'utf8'));
|
||
|
||
// 添加 finish_task 工具
|
||
tools.push({
|
||
type: 'function',
|
||
function: {
|
||
name: 'finish_task',
|
||
description: '完成当前任务并退出。调用此工具表示你已经完成了分配的任务,所有交付文件已准备好。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
success: {
|
||
type: 'boolean',
|
||
description: '任务是否成功完成。true=成功完成所有要求;false=部分完成或遇到问题无法继续。',
|
||
},
|
||
summary: {
|
||
type: 'string',
|
||
description: '任务完成摘要(50-200字),说明完成了什么工作、生成了哪些文件、关键发现或结果。如果失败,说明原因和已完成的部分。',
|
||
},
|
||
},
|
||
required: ['success', 'summary'],
|
||
},
|
||
},
|
||
});
|
||
|
||
// 初始化对话
|
||
const messages = [
|
||
{ role: 'system', content: systemPrompt },
|
||
{ role: 'user', content: taskMessage },
|
||
];
|
||
|
||
// 统计信息
|
||
const stats = {
|
||
runtime_start: Date.now(),
|
||
runtime_seconds: 0,
|
||
files_read: 0,
|
||
edit_files: 0,
|
||
searches: 0,
|
||
web_pages: 0,
|
||
commands: 0,
|
||
api_calls: 0,
|
||
token_usage: { prompt: 0, completion: 0, total: 0 },
|
||
};
|
||
|
||
const startTime = Date.now();
|
||
const timeoutMs = config.timeout * 1000;
|
||
let turnCount = 0;
|
||
const maxTurns = 50; // 防止无限循环
|
||
|
||
try {
|
||
while (true) {
|
||
turnCount++;
|
||
stats.api_calls += 1;
|
||
stats.turn_count = turnCount;
|
||
|
||
// 检查超时
|
||
const elapsed = Date.now() - startTime;
|
||
if (elapsed > timeoutMs) {
|
||
writeOutput(config.outputFile, {
|
||
success: false,
|
||
summary: '任务超时未完成',
|
||
timeout: true,
|
||
stats: {
|
||
...stats,
|
||
runtime_seconds: Math.floor(elapsed / 1000),
|
||
},
|
||
});
|
||
process.exit(1);
|
||
}
|
||
|
||
// 检查轮次限制
|
||
if (turnCount > maxTurns) {
|
||
writeOutput(config.outputFile, {
|
||
success: false,
|
||
summary: `任务执行超过 ${maxTurns} 轮,可能陷入循环`,
|
||
max_turns_exceeded: true,
|
||
stats: {
|
||
...stats,
|
||
runtime_seconds: Math.floor(elapsed / 1000),
|
||
},
|
||
});
|
||
process.exit(1);
|
||
}
|
||
|
||
// 更新统计文件
|
||
if (config.statsFile) {
|
||
updateStats(config.statsFile, {
|
||
...stats,
|
||
runtime_seconds: Math.floor(elapsed / 1000),
|
||
turn_count: turnCount,
|
||
});
|
||
}
|
||
|
||
// 调用 API
|
||
let assistantMessage = { role: 'assistant', content: '', tool_calls: [] };
|
||
let reasoningBuffer = '';
|
||
let currentToolCall = null;
|
||
let usage = null;
|
||
|
||
try {
|
||
for await (const chunk of streamChat({
|
||
config: ensuredConfig,
|
||
modelKey,
|
||
messages,
|
||
tools,
|
||
thinkingMode: config.thinkingMode === 'thinking',
|
||
currentContextTokens: 0,
|
||
abortSignal: null,
|
||
})) {
|
||
const choice = chunk.choices?.[0];
|
||
const delta = choice?.delta;
|
||
if (!delta) continue;
|
||
|
||
// 处理内容
|
||
if (delta.content) {
|
||
assistantMessage.content += delta.content;
|
||
}
|
||
|
||
// 处理 reasoning_content(兼容 reasoning_details)
|
||
if (delta.reasoning_content || delta.reasoning_details || choice?.reasoning_details) {
|
||
let rc = '';
|
||
if (delta.reasoning_content) {
|
||
rc = delta.reasoning_content;
|
||
} else if (delta.reasoning_details) {
|
||
if (Array.isArray(delta.reasoning_details)) {
|
||
rc = delta.reasoning_details.map((d) => d.text || '').join('');
|
||
} else if (typeof delta.reasoning_details === 'string') {
|
||
rc = delta.reasoning_details;
|
||
} else if (delta.reasoning_details && typeof delta.reasoning_details.text === 'string') {
|
||
rc = delta.reasoning_details.text;
|
||
}
|
||
} else if (choice?.reasoning_details) {
|
||
if (Array.isArray(choice.reasoning_details)) {
|
||
rc = choice.reasoning_details.map((d) => d.text || '').join('');
|
||
} else if (typeof choice.reasoning_details === 'string') {
|
||
rc = choice.reasoning_details;
|
||
} else if (choice.reasoning_details && typeof choice.reasoning_details.text === 'string') {
|
||
rc = choice.reasoning_details.text;
|
||
}
|
||
}
|
||
if (rc) reasoningBuffer += rc;
|
||
}
|
||
|
||
// 处理工具调用
|
||
if (delta.tool_calls) {
|
||
for (const tc of delta.tool_calls) {
|
||
if (tc.index !== undefined) {
|
||
if (!assistantMessage.tool_calls[tc.index]) {
|
||
assistantMessage.tool_calls[tc.index] = {
|
||
id: tc.id || '',
|
||
type: 'function',
|
||
function: { name: '', arguments: '' },
|
||
};
|
||
currentToolCall = assistantMessage.tool_calls[tc.index];
|
||
} else {
|
||
currentToolCall = assistantMessage.tool_calls[tc.index];
|
||
}
|
||
|
||
if (tc.id) currentToolCall.id = tc.id;
|
||
if (tc.function?.name) currentToolCall.function.name += tc.function.name;
|
||
if (tc.function?.arguments) currentToolCall.function.arguments += tc.function.arguments;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理 usage
|
||
if (chunk.usage) {
|
||
usage = normalizeUsagePayload(chunk.usage);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
writeOutput(config.outputFile, {
|
||
success: false,
|
||
summary: `API 调用失败: ${err.message}`,
|
||
error: 'api_error',
|
||
stats: {
|
||
...stats,
|
||
runtime_seconds: Math.floor((Date.now() - startTime) / 1000),
|
||
},
|
||
});
|
||
process.exit(1);
|
||
}
|
||
|
||
// 更新 token 统计
|
||
if (usage) {
|
||
applyUsage(stats.token_usage, usage);
|
||
}
|
||
|
||
// 添加助手消息到历史(reasoning_content 放在 content 前)
|
||
const finalAssistantMessage = reasoningBuffer
|
||
? {
|
||
role: 'assistant',
|
||
reasoning_content: reasoningBuffer,
|
||
content: assistantMessage.content,
|
||
tool_calls: assistantMessage.tool_calls,
|
||
}
|
||
: assistantMessage;
|
||
messages.push(finalAssistantMessage);
|
||
|
||
// 如果没有工具调用,检查是否忘记调用 finish_task
|
||
if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
|
||
// 兜底机制:提醒调用 finish_task
|
||
messages.push({
|
||
role: 'user',
|
||
content: '如果你已经完成了任务,请调用 finish_task 工具提交完成报告。如果还没有完成,请继续执行任务。',
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// 执行工具调用
|
||
for (const toolCall of assistantMessage.tool_calls) {
|
||
const toolName = toolCall.function.name;
|
||
stats.tool_calls = (stats.tool_calls || 0) + 1;
|
||
|
||
// 检查是否是 finish_task
|
||
if (toolName === 'finish_task') {
|
||
let args = {};
|
||
try {
|
||
args = JSON.parse(toolCall.function.arguments || '{}');
|
||
} catch (err) {
|
||
writeOutput(config.outputFile, {
|
||
success: false,
|
||
summary: 'finish_task 参数解析失败',
|
||
error: 'invalid_finish_args',
|
||
stats: {
|
||
...stats,
|
||
runtime_seconds: Math.floor((Date.now() - startTime) / 1000),
|
||
},
|
||
});
|
||
process.exit(1);
|
||
}
|
||
|
||
// 保存对话记录到交付目录的 .subagent/ 子目录
|
||
try {
|
||
// 从任务文件路径推断交付目录
|
||
const taskFileDir = path.dirname(config.taskFile);
|
||
// 读取任务消息以获取交付目录路径
|
||
const taskContent = fs.readFileSync(config.taskFile, 'utf8');
|
||
const deliverablesDirMatch = taskContent.match(/\*\*交付目录\*\*:(.+)/);
|
||
if (deliverablesDirMatch) {
|
||
const deliverablesDirPath = deliverablesDirMatch[1].split('\n')[0].trim();
|
||
const subagentDir = path.join(deliverablesDirPath, '.subagent');
|
||
|
||
// 确保目录存在
|
||
if (!fs.existsSync(subagentDir)) {
|
||
fs.mkdirSync(subagentDir, { recursive: true });
|
||
}
|
||
|
||
// 保存对话记录
|
||
const conversationData = {
|
||
agent_id: config.agentId,
|
||
created_at: new Date(startTime).toISOString(),
|
||
completed_at: new Date().toISOString(),
|
||
success: args.success,
|
||
summary: args.summary,
|
||
messages: messages,
|
||
stats: {
|
||
...stats,
|
||
runtime_seconds: Math.floor((Date.now() - startTime) / 1000),
|
||
},
|
||
};
|
||
|
||
const conversationFile = path.join(subagentDir, 'conversation.json');
|
||
fs.writeFileSync(conversationFile, JSON.stringify(conversationData, null, 2), 'utf8');
|
||
}
|
||
} catch (err) {
|
||
// 保存对话记录失败不影响任务完成
|
||
console.error('保存对话记录失败:', err);
|
||
}
|
||
|
||
// 写入输出并退出
|
||
writeOutput(config.outputFile, {
|
||
success: args.success || false,
|
||
summary: args.summary || '任务完成',
|
||
stats: {
|
||
...stats,
|
||
runtime_seconds: Math.floor((Date.now() - startTime) / 1000),
|
||
},
|
||
});
|
||
process.exit(0);
|
||
}
|
||
|
||
let progressArgs = {};
|
||
try {
|
||
progressArgs = JSON.parse(toolCall.function.arguments || '{}');
|
||
} catch (err) {
|
||
progressArgs = { _raw: toolCall.function.arguments || '' };
|
||
}
|
||
const progressId = toolCall.id || `tool_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
||
appendProgress(config.progressFile, {
|
||
id: progressId,
|
||
tool: toolName,
|
||
status: 'running',
|
||
args: progressArgs,
|
||
ts: Date.now(),
|
||
});
|
||
|
||
// 执行其他工具
|
||
const result = await executeTool({
|
||
workspace: config.workspace,
|
||
config: ensuredConfig,
|
||
allowMode: 'full_access',
|
||
toolCall,
|
||
abortSignal: null,
|
||
});
|
||
|
||
appendProgress(config.progressFile, {
|
||
id: progressId,
|
||
tool: toolName,
|
||
status: result && result.success ? 'completed' : 'failed',
|
||
args: progressArgs,
|
||
ts: Date.now(),
|
||
});
|
||
|
||
// 更新统计
|
||
if (toolName === 'read_file') stats.files_read++;
|
||
else if (toolName === 'edit_file') stats.edit_files++;
|
||
else if (toolName === 'search_workspace') stats.searches++;
|
||
else if (toolName === 'web_search' || toolName === 'extract_webpage') stats.web_pages++;
|
||
else if (toolName === 'run_command') stats.commands++;
|
||
|
||
// 添加工具结果到历史
|
||
messages.push({
|
||
role: 'tool',
|
||
tool_call_id: toolCall.id,
|
||
content: result.formatted || '',
|
||
});
|
||
}
|
||
}
|
||
} catch (err) {
|
||
writeOutput(config.outputFile, {
|
||
success: false,
|
||
summary: `执行出错: ${err.message}`,
|
||
error: 'execution_error',
|
||
stats: {
|
||
...stats,
|
||
runtime_seconds: Math.floor((Date.now() - startTime) / 1000),
|
||
},
|
||
});
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// 运行
|
||
main().catch((err) => {
|
||
console.error('批处理模式执行失败:', err);
|
||
process.exit(1);
|
||
});
|