From 96d0e683474055089866cc9cdcaa630668b55169 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 14 Mar 2026 21:13:27 +0800 Subject: [PATCH] fix: run sub-agent tools inside container --- core/main_terminal.py | 8 +- easyagent/src/tools/container_bridge.js | 354 ++++++++++++++++++++++++ easyagent/src/tools/edit_file.js | 42 +++ easyagent/src/tools/read_file.js | 65 +++++ easyagent/src/tools/read_mediafile.js | 13 +- easyagent/src/tools/run_command.js | 77 +++++- easyagent/src/tools/search_workspace.js | 21 ++ modules/sub_agent_manager.py | 142 ++++++++-- sub_agent/core/main_terminal.py | 3 +- 9 files changed, 694 insertions(+), 31 deletions(-) create mode 100644 easyagent/src/tools/container_bridge.js diff --git a/core/main_terminal.py b/core/main_terminal.py index c7a4f5c..05a4c9e 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -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""" diff --git a/easyagent/src/tools/container_bridge.js b/easyagent/src/tools/container_bridge.js new file mode 100644 index 0000000..ca2c215 --- /dev/null +++ b/easyagent/src/tools/container_bridge.js @@ -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, +}; diff --git a/easyagent/src/tools/edit_file.js b/easyagent/src/tools/edit_file.js index 4a19244..d947320 100644 --- a/easyagent/src/tools/edit_file.js +++ b/easyagent/src/tools/edit_file.js @@ -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)) { diff --git a/easyagent/src/tools/read_file.js b/easyagent/src/tools/read_file.js index 41df509..828dfd9 100644 --- a/easyagent/src/tools/read_file.js +++ b/easyagent/src/tools/read_file.js @@ -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') { diff --git a/easyagent/src/tools/read_mediafile.js b/easyagent/src/tools/read_mediafile.js index ed6c007..bf8e4a0 100644 --- a/easyagent/src/tools/read_mediafile.js +++ b/easyagent/src/tools/read_mediafile.js @@ -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) }; } diff --git a/easyagent/src/tools/run_command.js b/easyagent/src/tools/run_command.js index 76d3ba1..4662a9d 100644 --- a/easyagent/src/tools/run_command.js +++ b/easyagent/src/tools/run_command.js @@ -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; diff --git a/easyagent/src/tools/search_workspace.js b/easyagent/src/tools/search_workspace.js index bb24ae5..c069ef9 100644 --- a/easyagent/src/tools/search_workspace.js +++ b/easyagent/src/tools/search_workspace.js @@ -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 }); diff --git a/modules/sub_agent_manager.py b/modules/sub_agent_manager.py index e21bcd2..977e248 100644 --- a/modules/sub_agent_manager.py +++ b/modules/sub_agent_manager.py @@ -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,14 +36,24 @@ 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批处理入口 - self.easyagent_batch = Path(__file__).parent.parent / "easyagent" / "src" / "batch" / "index.js" + # 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) self.state_file.parent.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} diff --git a/sub_agent/core/main_terminal.py b/sub_agent/core/main_terminal.py index ac0787d..100471e 100644 --- a/sub_agent/core/main_terminal.py +++ b/sub_agent/core/main_terminal.py @@ -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 # 是否静默工具禁用提示