fix: run sub-agent tools inside container
This commit is contained in:
parent
f3179e2a97
commit
96d0e68347
@ -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"""
|
||||
|
||||
354
easyagent/src/tools/container_bridge.js
Normal file
354
easyagent/src/tools/container_bridge.js
Normal 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,
|
||||
};
|
||||
@ -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)) {
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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) };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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 # 是否静默工具禁用提示
|
||||
|
||||
Loading…
Reference in New Issue
Block a user