fix: run sub-agent tools inside container

This commit is contained in:
JOJO 2026-03-14 21:13:27 +08:00
parent f3179e2a97
commit 96d0e68347
9 changed files with 694 additions and 31 deletions

View File

@ -124,7 +124,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
self.todo_manager = TodoManager(self.context_manager)
self.sub_agent_manager = SubAgentManager(
project_path=self.project_path,
data_dir=str(self.data_dir)
data_dir=str(self.data_dir),
container_session=container_session,
)
self.easter_egg_manager = EasterEggManager()
self._announced_sub_agent_tasks = set()
@ -245,6 +246,11 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
self.terminal_ops.set_container_session(session)
if getattr(self, "file_manager", None):
self.file_manager.set_container_session(session)
if getattr(self, "sub_agent_manager", None):
try:
self.sub_agent_manager.set_container_session(session)
except Exception:
pass
def _ensure_conversation(self):
"""确保CLI模式下存在可用的对话ID"""

View File

@ -0,0 +1,354 @@
'use strict';
const path = require('path');
const { spawnSync } = require('child_process');
function getContainerContext() {
const name = process.env.EASYAGENT_CONTAINER_NAME;
if (!name) return null;
return {
containerName: name,
dockerBin: process.env.EASYAGENT_DOCKER_BIN || 'docker',
mountPath: (process.env.EASYAGENT_CONTAINER_MOUNT_PATH || '/workspace').replace(/\/+$/, '') || '/workspace',
containerVenv: process.env.EASYAGENT_CONTAINER_VENV || '/opt/agent-venv',
containerPython: process.env.EASYAGENT_CONTAINER_PYTHON || '/opt/agent-venv/bin/python3',
};
}
function normalizePosix(p) {
return p.split(path.sep).join('/');
}
function resolveContainerPath(workspace, inputPath, ctx) {
const hostRoot = path.resolve(workspace);
const containerRoot = ctx.mountPath;
let hostPath = hostRoot;
let containerPath = containerRoot;
if (inputPath) {
if (path.isAbsolute(inputPath)) {
if (inputPath.startsWith(containerRoot)) {
containerPath = inputPath;
const rel = path.posix.relative(containerRoot, inputPath);
hostPath = path.join(hostRoot, ...rel.split('/'));
} else if (inputPath.startsWith(hostRoot)) {
hostPath = inputPath;
const rel = path.relative(hostRoot, inputPath);
containerPath = path.posix.join(containerRoot, normalizePosix(rel));
} else {
return { error: '路径必须位于工作区内' };
}
} else {
hostPath = path.resolve(hostRoot, inputPath);
if (!hostPath.startsWith(hostRoot)) {
return { error: '路径必须位于工作区内' };
}
const rel = path.relative(hostRoot, hostPath);
containerPath = path.posix.join(containerRoot, normalizePosix(rel));
}
}
const relContainer = path.posix.relative(containerRoot, containerPath);
return {
hostRoot,
hostPath,
containerPath,
relativePath: relContainer || '.',
};
}
const CONTAINER_SCRIPT = `
import json
import sys
import pathlib
import shutil
import os
import re
import fnmatch
def _resolve(root: pathlib.Path, rel: str) -> pathlib.Path:
base = root.resolve()
target = (base / rel).resolve()
if not str(target).startswith(str(base)):
raise ValueError("路径越界: %s" % rel)
return target
def _read_text(target: pathlib.Path):
with target.open('r', encoding='utf-8', errors='ignore') as fh:
data = fh.read()
lines = data.splitlines(keepends=True)
return data, lines
def _ensure_file(target: pathlib.Path):
if not target.exists():
return {"success": False, "error": "文件不存在"}
if not target.is_file():
return {"success": False, "error": "不是文件"}
return None
def _read_text_segment(root, payload):
rel = payload.get("path")
start = payload.get("start_line")
end = payload.get("end_line")
target = _resolve(root, rel)
err = _ensure_file(target)
if err:
return err
data, lines = _read_text(target)
total = len(lines)
line_start = start if start and start > 0 else 1
line_end = end if end and end >= line_start else total
if line_start > total:
return {"success": False, "error": "起始行超出文件长度"}
line_end = min(line_end, total)
snippet = "".join(lines[line_start - 1 : line_end])
return {
"success": True,
"path": rel,
"content": snippet,
"line_start": line_start,
"line_end": line_end,
"total_lines": total
}
def _read_file(root, payload):
rel = payload.get("path")
target = _resolve(root, rel)
err = _ensure_file(target)
if err:
return err
with target.open('r', encoding='utf-8', errors='ignore') as fh:
content = fh.read()
return {"success": True, "path": rel, "content": content, "size": len(content)}
def _write_file(root, payload):
rel = payload.get("path")
content = payload.get("content") or ""
mode = payload.get("mode") or "w"
target = _resolve(root, rel)
target.parent.mkdir(parents=True, exist_ok=True)
with target.open(mode, encoding='utf-8') as fh:
fh.write(content)
return {"success": True, "path": rel, "size": len(content), "mode": mode}
def _search_text(root, payload):
rel = payload.get("path")
target = _resolve(root, rel)
err = _ensure_file(target)
if err:
return err
data, lines = _read_text(target)
total = len(lines)
query = payload.get("query") or ""
if not query:
return {"success": False, "error": "缺少搜索关键词"}
max_matches = payload.get("max_matches") or 20
before = payload.get("context_before") or 0
after = payload.get("context_after") or 0
case_sensitive = bool(payload.get("case_sensitive"))
query_cmp = query if case_sensitive else query.lower()
def contains(text):
text_cmp = text if case_sensitive else text.lower()
return query_cmp in text_cmp
matches = []
for idx, line in enumerate(lines, start=1):
if contains(line):
win_start = max(1, idx - before)
win_end = min(total, idx + after)
if matches and win_start <= matches[-1]["line_end"]:
matches[-1]["line_end"] = max(matches[-1]["line_end"], win_end)
matches[-1]["hits"].append(idx)
else:
if len(matches) >= max_matches:
break
matches.append({
"line_start": win_start,
"line_end": win_end,
"hits": [idx]
})
for window in matches:
snippet_lines = lines[window["line_start"] - 1 : window["line_end"]]
window["snippet"] = "".join(snippet_lines)
return {"success": True, "path": rel, "matches": matches}
def _extract_segments(root, payload):
rel = payload.get("path")
target = _resolve(root, rel)
err = _ensure_file(target)
if err:
return err
segments = payload.get("segments") or []
if not segments:
return {"success": False, "error": "缺少要提取的行区间"}
_, lines = _read_text(target)
total = len(lines)
extracted = []
for seg in segments:
start = max(1, int(seg.get("start_line") or 1))
end = max(start, int(seg.get("end_line") or start))
end = min(end, total)
extracted.append({
"label": seg.get("label") or "segment",
"line_start": start,
"line_end": end,
"content": "".join(lines[start - 1:end])
})
return {"success": True, "path": rel, "segments": extracted}
def _matches_glob(rel_path, patterns):
if not patterns:
return True
for pat in patterns:
if fnmatch.fnmatch(rel_path, pat):
return True
return False
def _search_workspace(root, payload):
mode = payload.get("mode")
query = payload.get("query") or ""
use_regex = bool(payload.get("use_regex"))
case_sensitive = bool(payload.get("case_sensitive"))
max_results = payload.get("max_results") or 20
max_matches_per_file = payload.get("max_matches_per_file") or 3
include_glob = payload.get("include_glob") or ["**/*"]
exclude_glob = payload.get("exclude_glob") or []
max_file_size = payload.get("max_file_size")
matcher = re.compile(query, 0 if case_sensitive else re.IGNORECASE) if use_regex else None
results = []
matches = []
root = root.resolve()
for dirpath, _, filenames in os.walk(root):
for filename in filenames:
full = pathlib.Path(dirpath) / filename
try:
rel = full.relative_to(root).as_posix()
except Exception:
continue
if not _matches_glob(rel, include_glob):
continue
if exclude_glob and _matches_glob(rel, exclude_glob):
continue
if mode == "file":
hay = filename if case_sensitive else filename.lower()
needle = query if case_sensitive else query.lower()
ok = matcher.search(filename) if matcher else (needle in hay)
if ok:
matches.append(str(full))
if len(matches) >= max_results:
return {"success": True, "mode": "file", "root": str(root), "query": query, "matches": matches}
continue
if mode != "content":
continue
if max_file_size:
try:
if full.stat().st_size > max_file_size:
continue
except Exception:
continue
try:
with full.open('r', encoding='utf-8', errors='ignore') as fh:
lines = fh.readlines()
except Exception:
continue
file_matches = []
for idx, line in enumerate(lines, start=1):
hay = line if case_sensitive else line.lower()
needle = query if case_sensitive else query.lower()
ok = matcher.search(line) if matcher else (needle in hay)
if ok:
file_matches.append({"line": idx, "snippet": line.strip()})
if len(file_matches) >= max_matches_per_file:
break
if file_matches:
results.append({"file": str(full), "matches": file_matches})
if len(results) >= max_results:
return {"success": True, "mode": "content", "root": str(root), "query": query, "results": results}
if mode == "file":
return {"success": True, "mode": "file", "root": str(root), "query": query, "matches": matches}
if mode == "content":
return {"success": True, "mode": "content", "root": str(root), "query": query, "results": results}
return {"success": False, "error": "未知 search_workspace mode"}
ACTION_MAP = {
"read_text_segment": _read_text_segment,
"read_file": _read_file,
"write_file": _write_file,
"search_text": _search_text,
"extract_segments": _extract_segments,
"search_workspace": _search_workspace,
}
def main():
raw = sys.stdin.read()
payload = json.loads(raw or "{}")
action = payload.get("action")
root = pathlib.Path(payload.get("root") or "/workspace")
handler = ACTION_MAP.get(action)
if not handler:
sys.stdout.write(json.dumps({"success": False, "error": "未知操作"}, ensure_ascii=False))
return
try:
result = handler(root, payload.get("payload") or {})
except Exception as exc:
result = {"success": False, "error": str(exc)}
sys.stdout.write(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
`;
function buildEnvArgs(ctx) {
const venv = ctx.containerVenv || '/opt/agent-venv';
const basePath = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
const pathValue = `${venv}/bin:${basePath}`;
return ['-e', `VIRTUAL_ENV=${venv}`, '-e', `PATH=${pathValue}`];
}
function execContainerAction(ctx, action, payload, timeoutMs, rootOverride) {
const request = { action, root: rootOverride || ctx.mountPath, payload };
const args = [ctx.dockerBin, 'exec', '-i'];
const workdir = rootOverride || ctx.mountPath;
if (workdir) {
args.push('-w', workdir);
}
args.push(...buildEnvArgs(ctx));
args.push(ctx.containerName, ctx.containerPython || 'python3', '-c', CONTAINER_SCRIPT);
const result = spawnSync(args[0], args.slice(1), {
input: JSON.stringify(request),
encoding: 'utf8',
timeout: timeoutMs || 60000,
maxBuffer: 10 * 1024 * 1024,
});
if (result.error) {
return { success: false, error: result.error.message || String(result.error) };
}
if (result.status !== 0) {
const stderr = (result.stderr || '').trim();
const stdout = (result.stdout || '').trim();
return { success: false, error: stderr || stdout || '容器返回错误' };
}
const output = (result.stdout || '').trim();
if (!output) {
return { success: false, error: '容器未返回结果' };
}
try {
return JSON.parse(output);
} catch (err) {
return { success: false, error: `容器响应无法解析: ${output.slice(0, 200)}` };
}
}
module.exports = {
getContainerContext,
resolveContainerPath,
execContainerAction,
buildEnvArgs,
};

View File

@ -3,6 +3,7 @@
const fs = require('fs');
const path = require('path');
const { structuredPatch } = require('diff');
const { getContainerContext, resolveContainerPath, execContainerAction } = require('./container_bridge');
function resolvePath(workspace, p) {
if (path.isAbsolute(p)) return p;
@ -44,6 +45,47 @@ function editFileTool(workspace, args) {
const target = resolvePath(workspace, args.file_path);
const oldString = args.old_string ?? '';
const newString = args.new_string ?? '';
const ctx = getContainerContext();
if (ctx) {
const resolved = resolveContainerPath(workspace, args.file_path || '.', ctx);
if (resolved.error) return { success: false, error: resolved.error };
const rel = resolved.relativePath;
if (!oldString) {
const writeResp = execContainerAction(ctx, 'write_file', {
path: rel,
content: newString || '',
mode: 'w',
});
if (!writeResp.success) return { success: false, error: writeResp.error || '写入失败' };
return {
success: true,
path: resolved.containerPath,
replacements: newString ? 1 : 0,
};
}
const readResp = execContainerAction(ctx, 'read_file', { path: rel });
if (!readResp.success) return { success: false, error: readResp.error || '读取失败' };
const original = readResp.content || '';
if (!original.includes(oldString)) {
return { success: false, error: 'old_string 未匹配到内容' };
}
const updated = original.split(oldString).join(newString);
const replacements = original.split(oldString).length - 1;
const writeResp = execContainerAction(ctx, 'write_file', {
path: rel,
content: updated,
mode: 'w',
});
if (!writeResp.success) return { success: false, error: writeResp.error || '写入失败' };
const diff = diffSummary(original, updated);
return {
success: true,
path: resolved.containerPath,
replacements,
diff,
};
}
try {
let creating = false;
if (!fs.existsSync(target)) {

View File

@ -2,6 +2,7 @@
const fs = require('fs');
const path = require('path');
const { getContainerContext, resolveContainerPath, execContainerAction } = require('./container_bridge');
function resolvePath(workspace, p) {
if (path.isAbsolute(p)) return p;
@ -20,6 +21,70 @@ function applyMaxChars(text, maxChars) {
function readFileTool(workspace, args) {
const target = resolvePath(workspace, args.path);
const type = args.type || 'read';
const ctx = getContainerContext();
if (ctx) {
const resolved = resolveContainerPath(workspace, args.path || '.', ctx);
if (resolved.error) return { success: false, error: resolved.error };
const rel = resolved.relativePath;
if (type === 'read') {
const resp = execContainerAction(ctx, 'read_text_segment', {
path: rel,
start_line: args.start_line,
end_line: args.end_line,
});
if (!resp.success) return { success: false, error: resp.error || '读取失败' };
const capped = applyMaxChars(resp.content || '', args.max_chars);
return {
success: true,
type: 'read',
path: resolved.containerPath,
line_start: resp.line_start,
line_end: resp.line_end,
content: capped.text,
truncated: capped.truncated,
};
}
if (type === 'search') {
const resp = execContainerAction(ctx, 'search_text', {
path: rel,
query: args.query,
case_sensitive: !!args.case_sensitive,
max_matches: args.max_matches || 20,
context_before: args.context_before || 0,
context_after: args.context_after || 0,
});
if (!resp.success) return { success: false, error: resp.error || '搜索失败' };
const matches = (resp.matches || []).map((item, idx) => ({
id: `match_${idx + 1}`,
line_start: item.line_start,
line_end: item.line_end,
hits: item.hits || [],
snippet: item.snippet || '',
}));
return {
success: true,
type: 'search',
path: resolved.containerPath,
query: args.query || '',
case_sensitive: !!args.case_sensitive,
matches,
};
}
if (type === 'extract') {
const resp = execContainerAction(ctx, 'extract_segments', {
path: rel,
segments: args.segments || [],
});
if (!resp.success) return { success: false, error: resp.error || '提取失败' };
return {
success: true,
type: 'extract',
path: resolved.containerPath,
segments: resp.segments || [],
};
}
return { success: false, error: '未知 read_file type' };
}
try {
const raw = fs.readFileSync(target, 'utf8');
if (type === 'read') {

View File

@ -3,6 +3,7 @@
const fs = require('fs');
const path = require('path');
const mime = require('mime-types');
const { getContainerContext, resolveContainerPath } = require('./container_bridge');
function resolvePath(workspace, p) {
if (path.isAbsolute(p)) return p;
@ -10,7 +11,15 @@ function resolvePath(workspace, p) {
}
function readMediafileTool(workspace, args) {
const target = resolvePath(workspace, args.path);
const ctx = getContainerContext();
let target = resolvePath(workspace, args.path);
let displayPath = target;
if (ctx) {
const resolved = resolveContainerPath(workspace, args.path || '.', ctx);
if (resolved.error) return { success: false, error: resolved.error };
target = resolved.hostPath;
displayPath = resolved.containerPath;
}
try {
const stat = fs.statSync(target);
if (!stat.isFile()) return { success: false, error: '不是文件' };
@ -22,7 +31,7 @@ function readMediafileTool(workspace, args) {
const data = fs.readFileSync(target);
const b64 = data.toString('base64');
const type = mt.startsWith('image/') ? 'image' : 'video';
return { success: true, path: target, mime: mt, type, b64 };
return { success: true, path: displayPath, mime: mt, type, b64 };
} catch (err) {
return { success: false, error: err.message || String(err) };
}

View File

@ -1,7 +1,8 @@
'use strict';
const { exec } = require('child_process');
const { exec, spawn } = require('child_process');
const path = require('path');
const { getContainerContext, resolveContainerPath, buildEnvArgs } = require('./container_bridge');
function resolvePath(workspace, p) {
if (!p) return workspace;
@ -14,12 +15,86 @@ function runCommandTool(workspace, args, abortSignal) {
const cmd = args.command;
const timeoutSec = Number(args.timeout || 0);
const cwd = resolvePath(workspace, args.working_dir || '.');
const ctx = getContainerContext();
if (!cmd) return resolve({ success: false, error: 'command 不能为空' });
if (abortSignal && abortSignal.aborted) {
return resolve({ success: false, error: '任务被用户取消', cancelled: true });
}
const timeoutMs = Math.max(0, timeoutSec) * 1000;
let finished = false;
if (ctx) {
const resolved = resolveContainerPath(workspace, args.working_dir || '.', ctx);
if (resolved.error) {
return resolve({ success: false, error: resolved.error });
}
const workdir = resolved.containerPath || ctx.mountPath;
const execArgs = [ 'exec', '-i' ];
if (workdir) execArgs.push('-w', workdir);
execArgs.push(...buildEnvArgs(ctx));
const venv = ctx.containerVenv || '/opt/agent-venv';
const basePath = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
const exportPrefix = `export VIRTUAL_ENV=${venv}; export PATH=${venv}/bin:${basePath}; `;
const wrappedCmd = `${exportPrefix}${cmd}`;
execArgs.push(ctx.containerName, 'sh', '-c', wrappedCmd);
const child = spawn(ctx.dockerBin, execArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
const onData = (chunk, buffer) => buffer.push(chunk);
const stdoutBuf = [];
const stderrBuf = [];
child.stdout.on('data', (chunk) => onData(chunk, stdoutBuf));
child.stderr.on('data', (chunk) => onData(chunk, stderrBuf));
const done = (result) => {
if (finished) return;
finished = true;
if (abortSignal) abortSignal.removeEventListener('abort', onAbort);
return resolve(result);
};
const onAbort = () => {
if (finished) return;
finished = true;
try {
child.kill('SIGTERM');
} catch (_) {}
return resolve({ success: false, error: '任务被用户取消', cancelled: true });
};
if (abortSignal) abortSignal.addEventListener('abort', onAbort, { once: true });
let timer = null;
if (timeoutMs) {
timer = setTimeout(() => {
try {
child.kill('SIGTERM');
} catch (_) {}
const output = Buffer.concat(stdoutBuf).toString() + Buffer.concat(stderrBuf).toString();
done({
success: false,
status: 'timeout',
error: '命令超时',
return_code: null,
output,
timeout: timeoutSec,
});
}, timeoutMs);
}
child.on('close', (code) => {
if (timer) clearTimeout(timer);
const output = Buffer.concat(stdoutBuf).toString() + Buffer.concat(stderrBuf).toString();
if (finished) return;
if (code && code !== 0) {
return done({
success: false,
status: 'error',
error: `exit ${code}`,
return_code: code,
output,
});
}
return done({ success: true, status: 'ok', output });
});
return;
}
const child = exec(cmd, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, shell: true }, (err, stdout, stderr) => {
if (finished) return;
finished = true;

View File

@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path');
const fg = require('fast-glob');
const { execSync } = require('child_process');
const { getContainerContext, resolveContainerPath, execContainerAction } = require('./container_bridge');
function resolvePath(workspace, p) {
if (!p) return workspace;
@ -31,6 +32,26 @@ async function searchWorkspaceTool(workspace, args) {
const includeGlob = Array.isArray(args.include_glob) && args.include_glob.length ? args.include_glob : ['**/*'];
const excludeGlob = Array.isArray(args.exclude_glob) ? args.exclude_glob : [];
const maxFileSize = args.max_file_size || null;
const ctx = getContainerContext();
if (ctx) {
const resolved = resolveContainerPath(workspace, args.root || '.', ctx);
if (resolved.error) return { success: false, error: resolved.error };
const resp = execContainerAction(ctx, 'search_workspace', {
mode,
query,
use_regex: useRegex,
case_sensitive: caseSensitive,
max_results: maxResults,
max_matches_per_file: maxMatchesPerFile,
include_glob: includeGlob,
exclude_glob: excludeGlob,
max_file_size: maxFileSize,
root: resolved.containerPath,
}, null, resolved.containerPath);
if (!resp.success) return { success: false, error: resp.error || '搜索失败' };
return resp;
}
if (mode === 'file') {
const files = await fg(includeGlob, { cwd: root, dot: true, ignore: excludeGlob, onlyFiles: true, absolute: true });

View File

@ -4,8 +4,10 @@ import json
import subprocess
import time
import uuid
from pathlib import Path
from typing import Dict, List, Optional, Any, Tuple
import os
import shutil
from pathlib import Path, PurePosixPath
from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING
from config import (
OUTPUT_FORMATS,
@ -18,6 +20,9 @@ from config import (
from utils.logger import setup_logger
import logging
if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle
# 静音子智能体日志(交由前端提示/brief_log处理
logger = setup_logger(__name__)
logger.setLevel(logging.CRITICAL)
@ -31,13 +36,23 @@ TERMINAL_STATUSES = {"completed", "failed", "timeout"}
class SubAgentManager:
"""负责主智能体与子智能体的任务调度(子进程模式)。"""
def __init__(self, project_path: str, data_dir: str):
def __init__(
self,
project_path: str,
data_dir: str,
container_session: Optional["ContainerHandle"] = None,
):
self.project_path = Path(project_path).resolve()
self.data_dir = Path(data_dir).resolve()
self.base_dir = Path(SUB_AGENT_TASKS_BASE_DIR).resolve()
self.state_file = Path(SUB_AGENT_STATE_FILE).resolve()
self.container_session: Optional["ContainerHandle"] = container_session
# easyagent批处理入口
# easyagent批处理入口优先使用项目目录内的副本
candidate_batch = self.project_path / "easyagent" / "src" / "batch" / "index.js"
if candidate_batch.exists():
self.easyagent_batch = candidate_batch
else:
self.easyagent_batch = Path(__file__).parent.parent / "easyagent" / "src" / "batch" / "index.js"
self.base_dir.mkdir(parents=True, exist_ok=True)
@ -111,31 +126,51 @@ class SubAgentManager:
progress_file = task_root / "progress.jsonl"
# 构建用户消息
user_message = self._build_user_message(agent_id, summary, task, deliverables_path, timeout_seconds)
prompt_workspace = self._get_runtime_path(self.project_path)
deliverables_display = self._get_runtime_path(deliverables_path)
user_message = self._build_user_message(agent_id, summary, task, deliverables_display, timeout_seconds)
task_file.write_text(user_message, encoding="utf-8")
# 构建系统提示
system_prompt = self._build_system_prompt()
system_prompt = self._build_system_prompt(prompt_workspace)
system_prompt_file.write_text(system_prompt, encoding="utf-8")
# 启动子进程
timeout_seconds = timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT
cmd = [
"node",
str(self.easyagent_batch),
"--workspace", str(self.project_path),
"--task-file", str(task_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", str(agent_id),
"--timeout", str(timeout_seconds),
]
if model_key:
cmd.extend(["--model-key", model_key])
if thinking_mode:
cmd.extend(["--thinking-mode", thinking_mode])
cmd = self._build_sub_agent_command(
batch_path=str(self.easyagent_batch),
workspace_path=str(self.project_path),
task_file=str(task_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,
timeout_seconds=timeout_seconds,
model_key=model_key,
thinking_mode=thinking_mode,
)
execution_mode = "host"
container_name = None
env = os.environ.copy()
if self._should_use_container():
container = self.container_session
container_name = getattr(container, "container_name", None) if container else None
docker_bin = (getattr(container, "sandbox_bin", None) if container else None) or shutil.which("docker")
if not container_name:
return {"success": False, "error": "容器模式下缺少 container_name无法启动子智能体。"}
if not docker_bin:
return {"success": False, "error": "容器模式下未找到 docker可执行子智能体失败。"}
env["EASYAGENT_CONTAINER_NAME"] = container_name
env["EASYAGENT_CONTAINER_MOUNT_PATH"] = (
getattr(container, "mount_path", None) or "/workspace"
)
env["EASYAGENT_DOCKER_BIN"] = docker_bin
env["EASYAGENT_CONTAINER_VENV"] = os.environ.get("CONTAINER_PYTHON_VENV", "/opt/agent-venv")
env["EASYAGENT_CONTAINER_PYTHON"] = os.environ.get("CONTAINER_PYTHON_CMD", "/opt/agent-venv/bin/python3")
env["EASYAGENT_CONTAINER_MODE"] = "1"
execution_mode = "docker"
try:
process = subprocess.Popen(
@ -143,6 +178,7 @@ class SubAgentManager:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=str(self.project_path),
env=env,
)
except Exception as exc:
return {"success": False, "error": f"启动子智能体失败: {exc}"}
@ -166,6 +202,8 @@ class SubAgentManager:
"stats_file": str(stats_file),
"progress_file": str(progress_file),
"pid": process.pid,
"execution_mode": execution_mode,
"container_name": container_name,
}
self.tasks[task_id] = task_record
self.processes[task_id] = process
@ -261,9 +299,61 @@ class SubAgentManager:
"system_message": f"🛑 子智能体{task.get('agent_id')} 已被手动关闭。",
}
def set_container_session(self, session: Optional["ContainerHandle"]):
"""更新容器会话信息。"""
self.container_session = session
# ------------------------------------------------------------------
# 内部工具方法
# ------------------------------------------------------------------
def _should_use_container(self) -> bool:
return bool(self.container_session and getattr(self.container_session, "mode", None) == "docker")
def _get_runtime_path(self, host_path: Path) -> str:
"""将宿主机路径映射为容器内路径(仅用于提示展示)。"""
if not self._should_use_container():
return str(host_path)
mount_path = (getattr(self.container_session, "mount_path", None) or "/workspace").rstrip("/") or "/workspace"
try:
relative = host_path.resolve().relative_to(self.project_path)
except Exception:
return mount_path
if str(relative) in {"", "."}:
return mount_path
return str(PurePosixPath(mount_path) / PurePosixPath(relative.as_posix()))
def _build_sub_agent_command(
self,
*,
batch_path: str,
workspace_path: str,
task_file: str,
system_prompt_file: str,
output_file: str,
stats_file: str,
progress_file: str,
agent_id: int,
timeout_seconds: int,
model_key: Optional[str],
thinking_mode: Optional[str],
) -> List[str]:
cmd = [
"node",
batch_path,
"--workspace", workspace_path,
"--task-file", task_file,
"--system-prompt-file", system_prompt_file,
"--output-file", output_file,
"--stats-file", stats_file,
"--progress-file", progress_file,
"--agent-id", str(agent_id),
"--timeout", str(timeout_seconds),
]
if model_key:
cmd.extend(["--model-key", model_key])
if thinking_mode:
cmd.extend(["--thinking-mode", thinking_mode])
return cmd
def _check_task_status(self, task: Dict) -> Dict:
"""检查任务状态,如果完成则解析输出。"""
task_id = task["task_id"]
@ -429,7 +519,7 @@ class SubAgentManager:
agent_id: int,
summary: str,
task: str,
deliverables_path: Path,
deliverables_path: str,
timeout_seconds: Optional[int],
) -> str:
"""构建发送给子智能体的用户消息。"""
@ -448,7 +538,7 @@ class SubAgentManager:
完成任务后请调用 finish_task 工具提交完成报告"""
def _build_system_prompt(self) -> str:
def _build_system_prompt(self, workspace_path: str) -> str:
"""构建子智能体的系统提示。"""
import platform
from datetime import datetime
@ -527,7 +617,7 @@ class SubAgentManager:
# 当前环境
- 工作区路径: {self.project_path}
- 工作区路径: {workspace_path}
- 系统: {system_info}
- 当前时间: {current_time}

View File

@ -95,7 +95,8 @@ class MainTerminal:
self.todo_manager = TodoManager(self.context_manager)
self.sub_agent_manager = SubAgentManager(
project_path=self.project_path,
data_dir=str(self.data_dir)
data_dir=str(self.data_dir),
container_session=None,
)
self._announced_sub_agent_tasks = set()
self.silent_tool_disable = False # 是否静默工具禁用提示