fix(sub-agent): stdout output, real-time progress, config externalization

- config/sub_agent: 子智能体模型配置走 deploy_config_path 直指,无回退
- easyagent/config: ensureConfig 文件不存在时 throw 而非自动创建
- easyagent/batch: 所有输出走 stdout JSONL (emit),不写文件;conversation 走 emit 不写工作区
- sub_agent_manager: 新增 conversation_file;运行期间 select 非阻塞 drain stdout 即时写 progress
This commit is contained in:
JOJO 2026-06-10 03:09:31 +08:00
parent 6255efc594
commit 960d087db0
4 changed files with 217 additions and 259 deletions

View File

@ -2,7 +2,12 @@
import os import os
from .paths import _resolve_repo_path, DATA_DIR, DEFAULT_PROJECT_PATH from .paths import (
_resolve_repo_path,
DATA_DIR,
DEFAULT_PROJECT_PATH,
deploy_config_path,
)
# 子智能体服务 # 子智能体服务
SUB_AGENT_SERVICE_BASE_URL = os.environ.get("SUB_AGENT_SERVICE_URL", "http://127.0.0.1:8092") SUB_AGENT_SERVICE_BASE_URL = os.environ.get("SUB_AGENT_SERVICE_URL", "http://127.0.0.1:8092")
@ -17,6 +22,9 @@ SUB_AGENT_STATE_FILE = _resolve_repo_path(os.environ.get("SUB_AGENT_STATE_FILE",
SUB_AGENT_PROJECT_RESULTS_DIR = _resolve_repo_path(os.environ.get("SUB_AGENT_PROJECT_RESULTS_DIR", ""), f"{DEFAULT_PROJECT_PATH}/sub_agent_results") SUB_AGENT_PROJECT_RESULTS_DIR = _resolve_repo_path(os.environ.get("SUB_AGENT_PROJECT_RESULTS_DIR", ""), f"{DEFAULT_PROJECT_PATH}/sub_agent_results")
SUB_AGENT_MAX_ACTIVE = int(os.environ.get("SUB_AGENT_MAX_ACTIVE", "5")) SUB_AGENT_MAX_ACTIVE = int(os.environ.get("SUB_AGENT_MAX_ACTIVE", "5"))
# 子智能体模型配置文件(仅从部署目录读取,读不到就报错)
SUB_AGENT_MODELS_CONFIG_FILE = os.environ.get("SUB_AGENT_MODELS_CONFIG_FILE", "") or deploy_config_path("sub_agent_models.json")
__all__ = [ __all__ = [
"SUB_AGENT_SERVICE_BASE_URL", "SUB_AGENT_SERVICE_BASE_URL",
"SUB_AGENT_DEFAULT_TIMEOUT", "SUB_AGENT_DEFAULT_TIMEOUT",
@ -25,4 +33,5 @@ __all__ = [
"SUB_AGENT_PROJECT_RESULTS_DIR", "SUB_AGENT_PROJECT_RESULTS_DIR",
"SUB_AGENT_STATE_FILE", "SUB_AGENT_STATE_FILE",
"SUB_AGENT_MAX_ACTIVE", "SUB_AGENT_MAX_ACTIVE",
"SUB_AGENT_MODELS_CONFIG_FILE",
] ]

View File

@ -3,7 +3,8 @@
/** /**
* easyagent 批处理模式 * easyagent 批处理模式
* 用于子智能体执行不需要交互式 CLI * 所有输出走 stdoutJSONL不写文件
* Python 侧捕获 stdout 后写入正式存储目录
*/ */
const fs = require('fs'); const fs = require('fs');
@ -13,19 +14,23 @@ const { executeTool } = require('../tools/dispatcher');
const { getModelByKey } = require('../config'); const { getModelByKey } = require('../config');
const { applyUsage, normalizeUsagePayload } = require('../utils/token_usage'); const { applyUsage, normalizeUsagePayload } = require('../utils/token_usage');
// 解析命令行参数 // ── stdout 输出 ──
function emit(type, data) {
const line = JSON.stringify({ type, ...data });
process.stdout.write(line + '\n');
}
// ── 解析命令行参数 ──
function parseArgs() { function parseArgs() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const config = { const config = {
workspace: process.cwd(), workspace: process.cwd(),
taskFile: null, taskFile: null,
systemPromptFile: null, systemPromptFile: null,
outputFile: null,
statsFile: null,
progressFile: null,
agentId: null, agentId: null,
modelKey: null, modelKey: null,
thinkingMode: null, thinkingMode: null,
configFile: null,
timeout: 600, timeout: 600,
}; };
@ -37,12 +42,6 @@ function parseArgs() {
config.taskFile = args[++i]; config.taskFile = args[++i];
} else if (arg === '--system-prompt-file' && i + 1 < args.length) { } else if (arg === '--system-prompt-file' && i + 1 < args.length) {
config.systemPromptFile = args[++i]; 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) { } else if (arg === '--agent-id' && i + 1 < args.length) {
config.agentId = args[++i]; config.agentId = args[++i];
} else if (arg === '--model-key' && i + 1 < args.length) { } else if (arg === '--model-key' && i + 1 < args.length) {
@ -51,13 +50,15 @@ function parseArgs() {
config.thinkingMode = String(args[++i] || '').trim().toLowerCase(); config.thinkingMode = String(args[++i] || '').trim().toLowerCase();
} else if (arg === '--timeout' && i + 1 < args.length) { } else if (arg === '--timeout' && i + 1 < args.length) {
config.timeout = parseInt(args[++i], 10); config.timeout = parseInt(args[++i], 10);
} else if (arg === '--config-file' && i + 1 < args.length) {
config.configFile = args[++i];
} }
} }
return config; return config;
} }
// 读取文件内容 // ── 读取文件 ──
function readFile(filePath) { function readFile(filePath) {
try { try {
return fs.readFileSync(filePath, 'utf8'); return fs.readFileSync(filePath, 'utf8');
@ -67,80 +68,44 @@ function readFile(filePath) {
} }
} }
// 写入输出文件 // ── 主函数 ──
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() { async function main() {
const config = parseArgs(); const config = parseArgs();
if (!config.taskFile || !config.systemPromptFile || !config.outputFile) { if (!config.taskFile || !config.systemPromptFile) {
console.error('缺少必需参数: --task-file, --system-prompt-file, --output-file'); console.error('缺少必需参数: --task-file, --system-prompt-file');
process.exit(1); process.exit(1);
} }
// 读取任务和系统提示 // 读取任务
const taskMessage = readFile(config.taskFile); const taskMessage = readFile(config.taskFile);
const systemPrompt = readFile(config.systemPromptFile); const systemPrompt = readFile(config.systemPromptFile);
// 加载模型配置 // 加载模型配置
const modelConfig = require('../config'); const modelConfig = require('../config');
const ensuredConfig = modelConfig.ensureConfig(); let ensuredConfig;
try {
ensuredConfig = modelConfig.ensureConfig(config.configFile);
} catch (e) {
emit('output', { success: false, summary: '模型配置加载失败: ' + e.message, error: 'config_load_error' });
process.exit(1);
}
if (!ensuredConfig.valid_models || ensuredConfig.valid_models.length === 0) { if (!ensuredConfig.valid_models || ensuredConfig.valid_models.length === 0) {
writeOutput(config.outputFile, { emit('output', { success: false, summary: '未找到可用模型配置', error: 'no_model_config' });
success: false,
summary: '未找到可用模型配置',
error: 'no_model_config',
});
process.exit(1); process.exit(1);
} }
// 使用指定模型或默认模型 // 选择模型
const modelKey = config.modelKey || ensuredConfig.default_model_key; const modelKey = config.modelKey || ensuredConfig.default_model_key;
const model = getModelByKey(ensuredConfig, modelKey); const model = getModelByKey(ensuredConfig, modelKey);
if (!model) { if (!model) {
writeOutput(config.outputFile, { emit('output', { success: false, summary: '未找到模型: ' + modelKey, error: 'model_not_found' });
success: false,
summary: `未找到模型: ${modelKey}`,
error: 'model_not_found',
});
process.exit(1); process.exit(1);
} }
// 加载工具定义 // 加载工具
const tools = JSON.parse(fs.readFileSync(path.join(__dirname, '../../doc/tools.json'), 'utf8')); const tools = JSON.parse(fs.readFileSync(path.join(__dirname, '../../doc/tools.json'), 'utf8'));
// 添加 finish_task 工具
tools.push({ tools.push({
type: 'function', type: 'function',
function: { function: {
@ -149,14 +114,8 @@ async function main() {
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
success: { success: { type: 'boolean', description: '任务是否成功完成。' },
type: 'boolean', summary: { type: 'string', description: '任务完成摘要50-200字。' },
description: '任务是否成功完成。true=成功完成所有要求false=部分完成或遇到问题无法继续。',
},
summary: {
type: 'string',
description: '任务完成摘要50-200字说明完成了什么工作、生成了哪些文件、关键发现或结果。如果失败说明原因和已完成的部分。',
},
}, },
required: ['success', 'summary'], required: ['success', 'summary'],
}, },
@ -169,7 +128,7 @@ async function main() {
{ role: 'user', content: taskMessage }, { role: 'user', content: taskMessage },
]; ];
// 统计信息 // 统计
const stats = { const stats = {
runtime_start: Date.now(), runtime_start: Date.now(),
runtime_seconds: 0, runtime_seconds: 0,
@ -185,7 +144,7 @@ async function main() {
const startTime = Date.now(); const startTime = Date.now();
const timeoutMs = config.timeout * 1000; const timeoutMs = config.timeout * 1000;
let turnCount = 0; let turnCount = 0;
const maxTurns = 50; // 防止无限循环 const maxTurns = 50;
try { try {
while (true) { while (true) {
@ -193,48 +152,33 @@ async function main() {
stats.api_calls += 1; stats.api_calls += 1;
stats.turn_count = turnCount; stats.turn_count = turnCount;
// 检查超时
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
if (elapsed > timeoutMs) { if (elapsed > timeoutMs) {
writeOutput(config.outputFile, { emit('output', {
success: false, success: false,
summary: '任务超时未完成', summary: '任务超时未完成',
timeout: true, timeout: true,
stats: { stats: { ...stats, runtime_seconds: Math.floor(elapsed / 1000) },
...stats,
runtime_seconds: Math.floor(elapsed / 1000),
},
}); });
process.exit(1); process.exit(1);
} }
// 检查轮次限制
if (turnCount > maxTurns) { if (turnCount > maxTurns) {
writeOutput(config.outputFile, { emit('output', {
success: false, success: false,
summary: `任务执行超过 ${maxTurns} 轮,可能陷入循环`, summary: '任务执行超过 ' + maxTurns + ' 轮,可能陷入循环',
max_turns_exceeded: true, max_turns_exceeded: true,
stats: { stats: { ...stats, runtime_seconds: Math.floor(elapsed / 1000) },
...stats,
runtime_seconds: Math.floor(elapsed / 1000),
},
}); });
process.exit(1); process.exit(1);
} }
// 更新统计文件 // 发送统计更新
if (config.statsFile) { emit('stats', { ...stats, runtime_seconds: Math.floor(elapsed / 1000), turn_count: turnCount });
updateStats(config.statsFile, {
...stats,
runtime_seconds: Math.floor(elapsed / 1000),
turn_count: turnCount,
});
}
// 调用 API // 调用 API
let assistantMessage = { role: 'assistant', content: '', tool_calls: [] }; let assistantMessage = { role: 'assistant', content: '', tool_calls: [] };
let reasoningBuffer = ''; let reasoningBuffer = '';
let currentToolCall = null;
let usage = null; let usage = null;
try { try {
@ -251,37 +195,19 @@ async function main() {
const delta = choice?.delta; const delta = choice?.delta;
if (!delta) continue; if (!delta) continue;
// 处理内容
if (delta.content) { if (delta.content) {
assistantMessage.content += delta.content; assistantMessage.content += delta.content;
} }
// 处理 reasoning_content兼容 reasoning_details if (delta.reasoning_content) {
if (delta.reasoning_content || delta.reasoning_details || choice?.reasoning_details) { reasoningBuffer += delta.reasoning_content;
let rc = ''; } else if (delta.reasoning_details) {
if (delta.reasoning_content) { const rd = delta.reasoning_details;
rc = delta.reasoning_content; if (Array.isArray(rd)) reasoningBuffer += rd.map(d => d.text || '').join('');
} else if (delta.reasoning_details) { else if (typeof rd === 'string') reasoningBuffer += rd;
if (Array.isArray(delta.reasoning_details)) { else if (rd && typeof rd.text === 'string') reasoningBuffer += rd.text;
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) { if (delta.tool_calls) {
for (const tc of delta.tool_calls) { for (const tc of delta.tool_calls) {
if (tc.index !== undefined) { if (tc.index !== undefined) {
@ -291,55 +217,37 @@ async function main() {
type: 'function', type: 'function',
function: { name: '', arguments: '' }, function: { name: '', arguments: '' },
}; };
currentToolCall = assistantMessage.tool_calls[tc.index];
} else {
currentToolCall = assistantMessage.tool_calls[tc.index];
} }
const ct = assistantMessage.tool_calls[tc.index];
if (tc.id) currentToolCall.id = tc.id; if (tc.id) ct.id = tc.id;
if (tc.function?.name) currentToolCall.function.name += tc.function.name; if (tc.function?.name) ct.function.name += tc.function.name;
if (tc.function?.arguments) currentToolCall.function.arguments += tc.function.arguments; if (tc.function?.arguments) ct.function.arguments += tc.function.arguments;
} }
} }
} }
// 处理 usage
if (chunk.usage) { if (chunk.usage) {
usage = normalizeUsagePayload(chunk.usage); usage = normalizeUsagePayload(chunk.usage);
} }
} }
} catch (err) { } catch (err) {
writeOutput(config.outputFile, { emit('output', {
success: false, success: false,
summary: `API 调用失败: ${err.message}`, summary: 'API 调用失败: ' + err.message,
error: 'api_error', error: 'api_error',
stats: { stats: { ...stats, runtime_seconds: Math.floor((Date.now() - startTime) / 1000) },
...stats,
runtime_seconds: Math.floor((Date.now() - startTime) / 1000),
},
}); });
process.exit(1); process.exit(1);
} }
// 更新 token 统计 if (usage) applyUsage(stats.token_usage, usage);
if (usage) {
applyUsage(stats.token_usage, usage);
}
// 添加助手消息到历史reasoning_content 放在 content 前)
const finalAssistantMessage = reasoningBuffer const finalAssistantMessage = reasoningBuffer
? { ? { role: 'assistant', reasoning_content: reasoningBuffer, content: assistantMessage.content, tool_calls: assistantMessage.tool_calls }
role: 'assistant',
reasoning_content: reasoningBuffer,
content: assistantMessage.content,
tool_calls: assistantMessage.tool_calls,
}
: assistantMessage; : assistantMessage;
messages.push(finalAssistantMessage); messages.push(finalAssistantMessage);
// 如果没有工具调用,检查是否忘记调用 finish_task
if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) { if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
// 兜底机制:提醒调用 finish_task
messages.push({ messages.push({
role: 'user', role: 'user',
content: '如果你已经完成了任务,请调用 finish_task 工具提交完成报告。如果还没有完成,请继续执行任务。', content: '如果你已经完成了任务,请调用 finish_task 工具提交完成报告。如果还没有完成,请继续执行任务。',
@ -347,95 +255,50 @@ async function main() {
continue; continue;
} }
// 执行工具调用
for (const toolCall of assistantMessage.tool_calls) { for (const toolCall of assistantMessage.tool_calls) {
const toolName = toolCall.function.name; const toolName = toolCall.function.name;
stats.tool_calls = (stats.tool_calls || 0) + 1; stats.tool_calls = (stats.tool_calls || 0) + 1;
// 检查是否是 finish_task
if (toolName === 'finish_task') { if (toolName === 'finish_task') {
let args = {}; let args = {};
try { try {
args = JSON.parse(toolCall.function.arguments || '{}'); args = JSON.parse(toolCall.function.arguments || '{}');
} catch (err) { } catch (err) {
writeOutput(config.outputFile, { emit('output', {
success: false, success: false,
summary: 'finish_task 参数解析失败', summary: 'finish_task 参数解析失败',
error: 'invalid_finish_args', error: 'invalid_finish_args',
stats: { stats: { ...stats, runtime_seconds: Math.floor((Date.now() - startTime) / 1000) },
...stats,
runtime_seconds: Math.floor((Date.now() - startTime) / 1000),
},
}); });
process.exit(1); process.exit(1);
} }
// 保存对话记录到交付目录的 .subagent/ 子目录 // 输出对话记录到 stdout由 Python 管理程序写入正式目录
try { emit('conversation', {
// 从任务文件路径推断交付目录 agent_id: config.agentId,
const taskFileDir = path.dirname(config.taskFile); created_at: new Date(startTime).toISOString(),
// 读取任务消息以获取交付目录路径 completed_at: new Date().toISOString(),
const taskContent = fs.readFileSync(config.taskFile, 'utf8'); success: args.success,
const deliverablesDirMatch = taskContent.match(/\*\*交付目录\*\*(.+)/); summary: args.summary,
if (deliverablesDirMatch) { messages: messages,
const deliverablesDirPath = deliverablesDirMatch[1].split('\n')[0].trim(); stats: { ...stats, runtime_seconds: Math.floor((Date.now() - startTime) / 1000) },
const subagentDir = path.join(deliverablesDirPath, '.subagent'); });
// 确保目录存在 // 最终输出
if (!fs.existsSync(subagentDir)) { emit('output', {
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, success: args.success || false,
summary: args.summary || '任务完成', summary: args.summary || '任务完成',
stats: { stats: { ...stats, runtime_seconds: Math.floor((Date.now() - startTime) / 1000) },
...stats,
runtime_seconds: Math.floor((Date.now() - startTime) / 1000),
},
}); });
process.exit(0); process.exit(0);
} }
let progressArgs = {}; let progressArgs = {};
try { try { progressArgs = JSON.parse(toolCall.function.arguments || '{}'); } catch (e) { progressArgs = { _raw: toolCall.function.arguments || '' }; }
progressArgs = JSON.parse(toolCall.function.arguments || '{}'); const progressId = toolCall.id || 'tool_' + Date.now() + '_' + Math.random().toString(16).slice(2, 8);
} catch (err) {
progressArgs = { _raw: toolCall.function.arguments || '' }; emit('progress', { id: progressId, tool: toolName, status: 'running', args: progressArgs, ts: Date.now() });
}
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({ const result = await executeTool({
workspace: config.workspace, workspace: config.workspace,
config: ensuredConfig, config: ensuredConfig,
@ -444,44 +307,28 @@ async function main() {
abortSignal: null, abortSignal: null,
}); });
appendProgress(config.progressFile, { emit('progress', { id: progressId, tool: toolName, status: result && result.success ? 'completed' : 'failed', args: progressArgs, ts: Date.now() });
id: progressId,
tool: toolName,
status: result && result.success ? 'completed' : 'failed',
args: progressArgs,
ts: Date.now(),
});
// 更新统计
if (toolName === 'read_file') stats.files_read++; if (toolName === 'read_file') stats.files_read++;
else if (toolName === 'edit_file') stats.edit_files++; else if (toolName === 'edit_file') stats.edit_files++;
else if (toolName === 'search_workspace') stats.searches++; else if (toolName === 'search_workspace') stats.searches++;
else if (toolName === 'web_search' || toolName === 'extract_webpage') stats.web_pages++; else if (toolName === 'web_search' || toolName === 'extract_webpage') stats.web_pages++;
else if (toolName === 'run_command') stats.commands++; else if (toolName === 'run_command') stats.commands++;
// 添加工具结果到历史 messages.push({ role: 'tool', tool_call_id: toolCall.id, content: result.formatted || '' });
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result.formatted || '',
});
} }
} }
} catch (err) { } catch (err) {
writeOutput(config.outputFile, { emit('output', {
success: false, success: false,
summary: `执行出错: ${err.message}`, summary: '执行出错: ' + err.message,
error: 'execution_error', error: 'execution_error',
stats: { stats: { ...stats, runtime_seconds: Math.floor((Date.now() - startTime) / 1000) },
...stats,
runtime_seconds: Math.floor((Date.now() - startTime) / 1000),
},
}); });
process.exit(1); process.exit(1);
} }
} }
// 运行
main().catch((err) => { main().catch((err) => {
console.error('批处理模式执行失败:', err); console.error('批处理模式执行失败:', err);
process.exit(1); process.exit(1);

View File

@ -98,15 +98,10 @@ function buildConfig(raw, filePath) {
}; };
} }
function ensureConfig() { function ensureConfig(customPath) {
const file = DEFAULT_CONFIG_PATH; const file = customPath || DEFAULT_CONFIG_PATH;
if (!fs.existsSync(file)) { if (!fs.existsSync(file)) {
const template = { throw new Error(`子智能体模型配置文件不存在: ${file}`);
tavily_api_key: '',
default_model: '',
models: [],
};
fs.writeFileSync(file, JSON.stringify(template, null, 2), 'utf8');
} }
const content = fs.readFileSync(file, 'utf8'); const content = fs.readFileSync(file, 'utf8');
const raw = JSON.parse(content); const raw = JSON.parse(content);

View File

@ -9,6 +9,7 @@ import shlex
import platform import platform
import shutil import shutil
import signal import signal
import select
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING
@ -16,6 +17,7 @@ from config import (
OUTPUT_FORMATS, OUTPUT_FORMATS,
SUB_AGENT_DEFAULT_TIMEOUT, SUB_AGENT_DEFAULT_TIMEOUT,
SUB_AGENT_MAX_ACTIVE, SUB_AGENT_MAX_ACTIVE,
SUB_AGENT_MODELS_CONFIG_FILE,
SUB_AGENT_STATE_FILE, SUB_AGENT_STATE_FILE,
SUB_AGENT_STATUS_POLL_INTERVAL, SUB_AGENT_STATUS_POLL_INTERVAL,
SUB_AGENT_TASKS_BASE_DIR, SUB_AGENT_TASKS_BASE_DIR,
@ -124,16 +126,13 @@ class SubAgentManager:
except ValueError as exc: except ValueError as exc:
return {"success": False, "error": str(exc)} return {"success": False, "error": str(exc)}
# 创建.subagent目录用于存储对话历史
subagent_dir = deliverables_path / ".subagent"
subagent_dir.mkdir(parents=True, exist_ok=True)
# 准备文件路径 # 准备文件路径
task_file = task_root / "task.txt" task_file = task_root / "task.txt"
system_prompt_file = task_root / "system_prompt.txt" system_prompt_file = task_root / "system_prompt.txt"
output_file = task_root / "output.json" output_file = task_root / "output.json"
stats_file = task_root / "stats.json" stats_file = task_root / "stats.json"
progress_file = task_root / "progress.jsonl" progress_file = task_root / "progress.jsonl"
conversation_file = task_root / "conversation.json"
# 构建用户消息 # 构建用户消息
prompt_workspace = self._get_runtime_path(self.project_path) prompt_workspace = self._get_runtime_path(self.project_path)
@ -152,9 +151,6 @@ class SubAgentManager:
workspace_path=str(self.project_path), workspace_path=str(self.project_path),
task_file=str(task_file), task_file=str(task_file),
system_prompt_file=str(system_prompt_file), system_prompt_file=str(system_prompt_file),
output_file=str(output_file),
stats_file=str(stats_file),
progress_file=str(progress_file),
agent_id=agent_id, agent_id=agent_id,
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
model_key=model_key, model_key=model_key,
@ -206,6 +202,9 @@ class SubAgentManager:
return {"success": False, "error": f"构建宿主机沙箱启动命令失败:{exc}"} return {"success": False, "error": f"构建宿主机沙箱启动命令失败:{exc}"}
try: try:
logger.debug(f"[SubAgent] 启动进程 task={task_id} execution_mode={execution_mode}")
logger.debug(f"[SubAgent] cmd: {self._join_shell_words(launch_cmd)[:500]}")
logger.debug(f"[SubAgent] cwd: {launch_cwd}")
process = subprocess.Popen( process = subprocess.Popen(
launch_cmd, launch_cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@ -214,6 +213,7 @@ class SubAgentManager:
env=env, env=env,
pass_fds=pass_fds, pass_fds=pass_fds,
) )
logger.debug(f"[SubAgent] 进程已启动 task={task_id} pid={process.pid}")
except Exception as exc: except Exception as exc:
return {"success": False, "error": f"启动子智能体失败: {exc}"} return {"success": False, "error": f"启动子智能体失败: {exc}"}
finally: finally:
@ -231,7 +231,6 @@ class SubAgentManager:
"task": task, "task": task,
"status": "running", "status": "running",
"deliverables_dir": str(deliverables_path), "deliverables_dir": str(deliverables_path),
"subagent_dir": str(subagent_dir),
"timeout_seconds": timeout_seconds, "timeout_seconds": timeout_seconds,
"thinking_mode": thinking_mode, "thinking_mode": thinking_mode,
"created_at": time.time(), "created_at": time.time(),
@ -241,6 +240,7 @@ class SubAgentManager:
"output_file": str(output_file), "output_file": str(output_file),
"stats_file": str(stats_file), "stats_file": str(stats_file),
"progress_file": str(progress_file), "progress_file": str(progress_file),
"conversation_file": str(conversation_file),
"pid": process.pid, "pid": process.pid,
"execution_mode": execution_mode, "execution_mode": execution_mode,
"container_name": container_name, "container_name": container_name,
@ -374,9 +374,6 @@ class SubAgentManager:
workspace_path: str, workspace_path: str,
task_file: str, task_file: str,
system_prompt_file: str, system_prompt_file: str,
output_file: str,
stats_file: str,
progress_file: str,
agent_id: int, agent_id: int,
timeout_seconds: int, timeout_seconds: int,
model_key: Optional[str], model_key: Optional[str],
@ -388,11 +385,9 @@ class SubAgentManager:
"--workspace", workspace_path, "--workspace", workspace_path,
"--task-file", task_file, "--task-file", task_file,
"--system-prompt-file", system_prompt_file, "--system-prompt-file", system_prompt_file,
"--output-file", output_file,
"--stats-file", stats_file,
"--progress-file", progress_file,
"--agent-id", str(agent_id), "--agent-id", str(agent_id),
"--timeout", str(timeout_seconds), "--timeout", str(timeout_seconds),
"--config-file", SUB_AGENT_MODELS_CONFIG_FILE,
] ]
if model_key: if model_key:
cmd.extend(["--model-key", model_key]) cmd.extend(["--model-key", model_key])
@ -417,19 +412,131 @@ class SubAgentManager:
cmd = [str(fd_num) if token == "__SECCOMP_FD__" else token for token in plan_command] cmd = [str(fd_num) if token == "__SECCOMP_FD__" else token for token in plan_command]
return cmd, (fd_num,), seccomp_fd return cmd, (fd_num,), seccomp_fd
def _check_task_status(self, task: Dict) -> Dict: def _check_task_status(self, task: Dict) -> Dict:
"""检查任务状态,如果完成则解析输出。""" """检查任务状态,如果完成则从 stdout 解析输出并写入正式目录"""
task_id = task["task_id"] task_id = task["task_id"]
process = self.processes.get(task_id) process = self.processes.get(task_id)
# 检查进程是否结束 # 检查进程是否结束
if process: if process:
# 先非阻塞读取 stdout 中已有的行(运行中和已结束都需要)
stdout_lines = task.get("_stdout_lines", [])
progress_file = Path(task.get("progress_file", ""))
new_lines_count = 0
while True:
try:
ready, _, _ = select.select([process.stdout], [], [], 0)
except (ValueError, OSError):
break # fd 已关闭
if not ready:
break
line = process.stdout.readline()
if not line:
break
line_text = line.decode('utf-8', errors='replace') if isinstance(line, bytes) else line
line_text = line_text.strip()
if line_text:
stdout_lines.append(line_text)
new_lines_count += 1
if new_lines_count:
task["_stdout_lines"] = stdout_lines
# 即时写入所有 progress 行到文件(全量重写,保证与内存一致)
try:
progress_file.parent.mkdir(parents=True, exist_ok=True)
with open(str(progress_file), "w", encoding="utf-8") as f:
for line_text in stdout_lines:
try:
entry = json.loads(line_text)
if entry.get("type") == "progress":
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass
except Exception:
pass
returncode = process.poll() returncode = process.poll()
if returncode is None: if returncode is None:
# 进程还在运行
return {"status": "running", "task_id": task_id} return {"status": "running", "task_id": task_id}
# 进程已结束,读取输出文件 # 进程已结束,读取 stderr
stderr_text = ""
try:
stderr_data = process.stderr.read()
stderr_text = stderr_data.decode('utf-8', errors='replace') if isinstance(stderr_data, bytes) and stderr_data else ""
except Exception:
pass
logger.debug(
f"[SubAgent] 进程结束 task={task_id} pid={process.pid} "
f"rc={returncode} stdout_lines={len(stdout_lines)} stderr_len={len(stderr_text)}"
)
if stderr_text:
logger.debug(f"[SubAgent] stderr: {stderr_text[:2000]}")
# 解析累积的 stdout 行,将输出写入正式目录
output_data = None
progress_entries = []
stats_data = None
conversation_data = None
for line_text in stdout_lines:
if not line_text:
continue
try:
entry = json.loads(line_text)
except Exception:
continue
entry_type = entry.get("type", "")
if entry_type == "output":
output_data = entry
elif entry_type == "progress":
progress_entries.append(entry)
elif entry_type == "stats":
stats_data = entry
elif entry_type == "conversation":
conversation_data = entry
# 写入正式目录
output_file = Path(task.get("output_file", ""))
stats_file = Path(task.get("stats_file", ""))
progress_file = Path(task.get("progress_file", ""))
conversation_file = Path(task.get("conversation_file", ""))
if output_data:
try:
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(json.dumps(output_data, ensure_ascii=False), encoding="utf-8")
logger.debug(f"[SubAgent] output 已写入: {output_file}")
except Exception as exc:
logger.debug(f"[SubAgent] 写入 output 失败: {exc}")
if stats_data:
try:
stats_file.parent.mkdir(parents=True, exist_ok=True)
stats_file.write_text(json.dumps(stats_data, ensure_ascii=False), encoding="utf-8")
except Exception:
pass
if progress_entries:
try:
progress_file.parent.mkdir(parents=True, exist_ok=True)
with open(str(progress_file), "w", encoding="utf-8") as f:
for entry in progress_entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass
if conversation_data:
try:
conversation_file.parent.mkdir(parents=True, exist_ok=True)
conversation_file.write_text(json.dumps(conversation_data, ensure_ascii=False), encoding="utf-8")
logger.debug(f"[SubAgent] conversation 已写入: {conversation_file}")
except Exception as exc:
logger.debug(f"[SubAgent] 写入 conversation 失败: {exc}")
# 读取输出文件
output_file = Path(task.get("output_file", "")) output_file = Path(task.get("output_file", ""))
logger.debug(f"[SubAgent] 检查输出文件: {output_file} exists={output_file.exists()}")
if not output_file.exists(): if not output_file.exists():
# 输出文件不存在,可能是异常退出 # 输出文件不存在,可能是异常退出
task["status"] = "failed" task["status"] = "failed"
@ -594,7 +701,7 @@ class SubAgentManager:
{task} {task}
**交付目录**{deliverables_path} **交付目录**{deliverables_path}
请将所有生成的文件保存到此目录对话历史会自动保存到 {deliverables_path}/.subagent/ 目录 请将所有生成的文件保存到此目录
**超时时间**{timeout_seconds} **超时时间**{timeout_seconds}