feat(agents): implement Code Commander module (Phases 1-5)

- Phase 1: Infrastructure (state, prompts, registry)
- Phase 2: Execution engine (AI adapters, security classifier, executors)
- Phase 3: Agent integration (graph nodes, routing)
- Phase 4: Streaming interaction (PTY terminal, WebSocket)
- Phase 5: Frontend integration (Vue components)
This commit is contained in:
2026-04-05 14:56:45 +08:00
parent 11160ec4d2
commit 5667190abe
22 changed files with 2641 additions and 347 deletions

View File

@@ -138,3 +138,12 @@ SUB_COMMANDER_TOOLSETS = {
"analyst_progress": ANALYST_PROGRESS_TOOLS,
"analyst_insights": ANALYST_INSIGHT_TOOLS,
}
# Code Commander toolset (tools implemented in later phases)
CODE_COMMANDER_TOOLSET_NAMES = [
"execute_code_task",
"get_execution_status",
"send_interactive_input",
"download_workspace",
"cleanup_workspace",
]

View File

@@ -0,0 +1,196 @@
"""
AI CLI Adapter - 统一接口适配不同 AI CLI (Claude/Gemini/Codex/OpenCode)
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
@dataclass
class CodeExecutionResult:
"""代码执行结果"""
success: bool
message: str
files_created: list[str] = field(default_factory=list)
output: str = ""
error: str | None = None
exit_code: int = 0
class AICLIAdapter(ABC):
"""AI CLI 适配器抽象基类"""
@property
@abstractmethod
def cli_name(self) -> str:
"""CLI 命令名称,如 'claude', 'gemini'"""
pass
@property
@abstractmethod
def requires_workspace(self) -> bool:
"""是否需要工作目录"""
pass
@property
def provider(self) -> Literal["claude", "gemini", "codex", "opencode"]:
"""AI 提供商标识"""
return self.cli_name
@abstractmethod
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
"""构建 CLI 命令"""
pass
@abstractmethod
def parse_output(self, output: str) -> CodeExecutionResult:
"""解析 CLI 输出"""
pass
@abstractmethod
def is_installed(self) -> bool:
"""检查 CLI 是否已安装"""
pass
class ClaudeAdapter(AICLIAdapter):
"""Claude CLI 适配器"""
cli_name = "claude"
requires_workspace = True
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
cmd = ["claude", "-p", prompt]
if workspace:
cmd.extend(["--output-format", "stream-json"])
cmd.append("--dangerously-skip-permissions")
return cmd
def parse_output(self, output: str) -> CodeExecutionResult:
# Claude CLI 输出可能是纯文本或 JSON
# 简化处理:直接返回输出
if not output.strip():
return CodeExecutionResult(
success=False,
message="No output from Claude CLI",
output=output,
)
return CodeExecutionResult(
success=True,
message="Execution completed",
output=output,
)
def is_installed(self) -> bool:
import shutil
return shutil.which("claude") is not None
class GeminiAdapter(AICLIAdapter):
"""Gemini CLI 适配器"""
cli_name = "gemini"
requires_workspace = False
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
cmd = ["gemini", "-p", prompt]
return cmd
def parse_output(self, output: str) -> CodeExecutionResult:
if not output.strip():
return CodeExecutionResult(
success=False,
message="No output from Gemini CLI",
output=output,
)
return CodeExecutionResult(
success=True,
message="Execution completed",
output=output,
)
def is_installed(self) -> bool:
import shutil
return shutil.which("gemini") is not None
class CodexAdapter(AICLIAdapter):
"""Codex CLI 适配器"""
cli_name = "codex"
requires_workspace = True
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
cmd = ["codex", "-p", prompt]
return cmd
def parse_output(self, output: str) -> CodeExecutionResult:
if not output.strip():
return CodeExecutionResult(
success=False,
message="No output from Codex CLI",
output=output,
)
return CodeExecutionResult(
success=True,
message="Execution completed",
output=output,
)
def is_installed(self) -> bool:
import shutil
return shutil.which("codex") is not None
class OpenCodeAdapter(AICLIAdapter):
"""OpenCode CLI 适配器"""
cli_name = "opencode"
requires_workspace = True
def build_command(self, prompt: str, workspace: Path | None) -> list[str]:
cmd = ["opencode", "-p", prompt]
return cmd
def parse_output(self, output: str) -> CodeExecutionResult:
if not output.strip():
return CodeExecutionResult(
success=False,
message="No output from OpenCode CLI",
output=output,
)
return CodeExecutionResult(
success=True,
message="Execution completed",
output=output,
)
def is_installed(self) -> bool:
import shutil
return shutil.which("opencode") is not None
# 提供商注册表
ADAPTER_REGISTRY: dict[str, AICLIAdapter] = {
"claude": ClaudeAdapter(),
"gemini": GeminiAdapter(),
"codex": CodexAdapter(),
"opencode": OpenCodeAdapter(),
}
def get_adapter(provider: str) -> AICLIAdapter:
"""获取指定提供商的适配器"""
adapter = ADAPTER_REGISTRY.get(provider.lower())
if adapter is None:
raise ValueError(
f"Unknown AI provider: {provider}. Available: {list(ADAPTER_REGISTRY.keys())}"
)
return adapter

View File

@@ -0,0 +1,112 @@
"""
Direct Executor - 直接执行器
用于低风险任务,直接执行不隔离
"""
import asyncio
import os
import shutil
import tempfile
from pathlib import Path
from typing import AsyncGenerator
from app.agents.tools.ai_adapter import AICLIAdapter
class ExecutionResult:
"""执行结果"""
def __init__(
self,
success: bool,
exit_code: int,
stdout: str,
stderr: str,
):
self.success = success
self.exit_code = exit_code
self.stdout = stdout
self.stderr = stderr
class DirectExecutor:
"""直接执行器(用于低风险任务)"""
def __init__(self, adapter: AICLIAdapter, timeout: int = 60):
self.adapter = adapter
self.timeout = timeout
async def execute(
self,
prompt: str,
) -> AsyncGenerator[str, None]:
"""
直接执行,不需要沙盒
Args:
prompt: 任务描述
Yields:
str: 实时输出
"""
# 1. 检查 CLI 是否安装
if not self.adapter.is_installed():
yield f"[ERROR] {self.adapter.cli_name} is not installed\n"
yield f"[ERROR] Please install {self.adapter.cli_name} first\n"
return
# 2. 构建命令
cmd = self.adapter.build_command(prompt, None)
# 3. 异步执行,实时 yield 输出
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, "TERM": "xterm-256color"},
)
# 4. 实时读取输出
stdout_lines = []
stderr_lines = []
while True:
try:
line_bytes = await asyncio.wait_for(
process.stdout.readline(),
timeout=self.timeout,
)
if not line_bytes:
break
line = line_bytes.decode("utf-8", errors="replace")
stdout_lines.append(line)
yield line
except asyncio.TimeoutError:
process.kill()
yield f"\n[ERROR] Execution timed out after {self.timeout}s\n"
break
# 5. 读取 stderr
stderr_bytes = await process.communicate()
if stderr_bytes[1]:
stderr = stderr_bytes[1].decode("utf-8", errors="replace")
stderr_lines.append(stderr)
yield f"\n[STDERR]\n{stderr}\n"
# 6. 完成标记
yield f"\n[EXIT_CODE] {process.returncode or 0}\n"
yield f"\n[COMPLETE] success={process.returncode == 0}\n"
async def execute_sync(self, prompt: str) -> ExecutionResult:
"""同步执行并返回完整结果"""
output_parts = []
async for line in self.execute(prompt):
output_parts.append(line)
output = "".join(output_parts)
return ExecutionResult(
success="[COMPLETE] success=True" in output,
exit_code=0,
stdout=output,
stderr="",
)

View File

@@ -0,0 +1,58 @@
"""
InteractiveInputHandler - 交互输入处理
"""
import asyncio
from typing import Any
from app.agents.tools.terminal_engine import PTYManager
class InteractiveInputHandler:
"""交互输入处理器"""
def __init__(self, pty_manager: PTYManager):
self.pty_manager = pty_manager
self._pending_inputs: dict[str, asyncio.Event] = {}
self._input_cache: dict[str, str] = {}
async def wait_for_input(self, session_id: str, prompt: str) -> str:
"""等待用户输入(如 "y" 确认)"""
event = asyncio.Event()
self._pending_inputs[session_id] = event
# 发送提示
from app.routers.terminal import manager
try:
await manager.send(session_id, f"\n{prompt}\n")
except Exception:
pass
# 等待输入完成
try:
await asyncio.wait_for(event.wait(), timeout=60.0)
except asyncio.TimeoutError:
del self._pending_inputs[session_id]
return self._input_cache.get(session_id, "")
del self._pending_inputs[session_id]
return self._input_cache.get(session_id, "")
async def send_input(self, session_id: str, data: str):
"""用户发送输入"""
self._input_cache[session_id] = data
if session_id in self._pending_inputs:
self._pending_inputs[session_id].set()
# 同时写入 PTY
await self.pty_manager.write(session_id, data + "\n")
def clear_input(self, session_id: str):
"""清除输入缓存"""
if session_id in self._input_cache:
del self._input_cache[session_id]
if session_id in self._pending_inputs:
self._pending_inputs[session_id].set() # 取消等待
del self._pending_inputs[session_id]

View File

@@ -0,0 +1,173 @@
"""
Sandbox Executor - 沙盒执行器
在高风险任务在隔离的临时目录中执行
"""
import asyncio
import os
import shutil
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import AsyncGenerator
from app.agents.tools.ai_adapter import AICLIAdapter, CodeExecutionResult
@dataclass
class SandboxEnvironment:
"""沙盒环境"""
workspace_path: Path
session_id: str
@staticmethod
async def create(prefix: str = "jarvis_code_") -> "SandboxEnvironment":
"""创建新的沙盒环境"""
temp_dir = tempfile.mkdtemp(prefix=prefix)
session_id = Path(temp_dir).name
return SandboxEnvironment(
workspace_path=Path(temp_dir),
session_id=session_id,
)
async def cleanup(self) -> None:
"""清理沙盒环境"""
if self.workspace_path.exists():
shutil.rmtree(self.workspace_path)
def list_created_files(self) -> list[str]:
"""列出沙盒中创建的文件"""
if not self.workspace_path.exists():
return []
files = []
for root, dirs, filenames in os.walk(self.workspace_path):
for filename in filenames:
full_path = os.path.join(root, filename)
rel_path = os.path.relpath(full_path, self.workspace_path)
files.append(rel_path)
return files
@dataclass
class ExecutionResult:
"""执行结果"""
success: bool
exit_code: int
stdout: str
stderr: str
files_created: list[str] = field(default_factory=list)
class SandboxExecutor:
"""沙盒执行器"""
def __init__(self, adapter: AICLIAdapter, timeout: int = 300):
self.adapter = adapter
self.timeout = timeout
self._sessions: dict[str, SandboxEnvironment] = {}
async def execute(
self,
prompt: str,
session_id: str | None = None,
) -> AsyncGenerator[str, None]:
"""
执行代码任务yield 实时输出
Args:
prompt: 任务描述
session_id: 会话 ID可选用于复用沙盒
Yields:
str: 实时输出
"""
# 1. 创建或复用沙盒环境
if session_id and session_id in self._sessions:
env = self._sessions[session_id]
else:
env = await SandboxEnvironment.create()
self._sessions[env.session_id] = env
session_id = env.session_id
# 2. 构建命令
cmd = self.adapter.build_command(prompt, env.workspace_path)
# 3. 异步执行,实时 yield 输出
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(env.workspace_path),
env={**os.environ, "TERM": "xterm-256color"},
)
# 4. 实时读取输出
stdout_lines = []
stderr_lines = []
while True:
try:
line_bytes = await asyncio.wait_for(
process.stdout.readline(),
timeout=self.timeout,
)
if not line_bytes:
break
line = line_bytes.decode("utf-8", errors="replace")
stdout_lines.append(line)
yield line
except asyncio.TimeoutError:
process.kill()
yield f"\n[ERROR] Execution timed out after {self.timeout}s\n"
break
# 5. 读取 stderr
stderr_bytes = await process.communicate()
if stderr_bytes[1]:
stderr = stderr_bytes[1].decode("utf-8", errors="replace")
stderr_lines.append(stderr)
yield f"\n[STDERR]\n{stderr}\n"
# 6. 返回结果(通过 yield 完成标记)
result = ExecutionResult(
success=process.returncode == 0,
exit_code=process.returncode or 0,
stdout="".join(stdout_lines),
stderr="".join(stderr_lines),
files_created=env.list_created_files(),
)
yield f"\n[EXIT_CODE] {result.exit_code}\n"
yield f"\n[COMPLETE] success={result.success}\n"
async def get_result(
self,
prompt: str,
session_id: str | None = None,
) -> ExecutionResult:
"""同步执行并返回完整结果"""
output_parts = []
async for line in self.execute(prompt, session_id):
output_parts.append(line)
# 解析最后的结果
# 实际使用中可能需要更复杂的结果收集
return ExecutionResult(
success="[COMPLETE] success=True" in "".join(output_parts),
exit_code=0,
stdout="".join(output_parts),
stderr="",
files_created=[],
)
async def cleanup_session(self, session_id: str) -> bool:
"""清理指定会话"""
if session_id in self._sessions:
await self._sessions[session_id].cleanup()
del self._sessions[session_id]
return True
return False
def get_session(self, session_id: str) -> SandboxEnvironment | None:
"""获取会话环境"""
return self._sessions.get(session_id)

View File

@@ -0,0 +1,129 @@
"""
Security Classifier - 安全分级判定
低风险任务直接执行,高风险任务沙盒执行
"""
from enum import Enum
class RiskLevel(Enum):
LOW = "low" # 直接执行
HIGH = "high" # 沙盒执行
class SecurityClassifier:
"""安全分级器"""
HIGH_RISK_KEYWORDS = [
# 路径/项目操作
"修改",
"编辑",
"删除",
"移动",
"重命名",
"Jarvis",
"backend",
"frontend",
"git",
"config",
".env",
"生产环境",
# 文件操作
"写入",
"创建文件在",
"移动到",
"提交",
"push",
"pull",
"merge",
# 系统操作
"sudo",
"rm ",
"chmod",
"chown",
]
LOW_RISK_KEYWORDS = [
# demo/示例类
"demo",
"示例",
"贪食蛇",
"俄罗斯方块",
"小游戏",
"独立项目",
"新项目",
"创建一个",
"写一个",
"帮我写一个",
# 明确无害的请求
"生成代码",
"代码示例",
"练习项目",
]
def classify(self, task_description: str, target_path: str | None = None) -> RiskLevel:
"""
判断任务风险等级
Args:
task_description: 任务描述
target_path: 目标路径(如果有)
Returns:
RiskLevel: LOW 或 HIGH
"""
# 1. 检查高风险关键词
task_lower = task_description.lower()
if any(kw.lower() in task_lower for kw in self.HIGH_RISK_KEYWORDS):
return RiskLevel.HIGH
# 2. 检查目标路径
if target_path and self._is_project_path(target_path):
return RiskLevel.HIGH
# 3. 检查低风险关键词
if any(kw.lower() in task_lower for kw in self.LOW_RISK_KEYWORDS):
return RiskLevel.LOW
# 4. 默认高风险(保守策略)
return RiskLevel.HIGH
def _is_project_path(self, path: str) -> bool:
"""
检查路径是否指向项目目录
Args:
path: 文件路径
Returns:
bool: 如果是项目路径返回 True
"""
path_lower = path.lower()
project_indicators = [
"jarvis",
"backend/app",
"frontend/src",
".git",
"package.json",
"pyproject.toml",
"requirements.txt",
]
return any(indicator in path_lower for indicator in project_indicators)
def get_risk_factors(
self, task_description: str, target_path: str | None = None
) -> dict[str, bool]:
"""
获取详细的风险因素分析
Returns:
dict: 各风险因素及其状态
"""
task_lower = task_description.lower()
return {
"has_high_risk_keywords": any(
kw.lower() in task_lower for kw in self.HIGH_RISK_KEYWORDS
),
"has_low_risk_keywords": any(kw.lower() in task_lower for kw in self.LOW_RISK_KEYWORDS),
"is_project_path": bool(target_path and self._is_project_path(target_path)),
}

View File

@@ -0,0 +1,86 @@
"""
StreamOutput - 流式输出封装
"""
import json
from datetime import datetime, timezone
from typing import Any, AsyncGenerator, Callable
from dataclasses import dataclass
@dataclass
class StreamEvent:
"""流式事件"""
type: str # "output" | "error" | "status" | "complete"
session_id: str
data: str
timestamp: str
class StreamOutput:
"""流式输出处理器"""
def __init__(
self,
session_id: str,
websocket_sender: Callable[[str, str], Any] | None = None,
):
self.session_id = session_id
self.websocket_sender = websocket_sender
self._listeners: list[Callable[[StreamEvent], Any]] = []
def add_listener(self, listener: Callable[[StreamEvent], Any]):
"""添加事件监听器"""
self._listeners.append(listener)
def remove_listener(self, listener: Callable[[StreamEvent], Any]):
"""移除事件监听器"""
if listener in self._listeners:
self._listeners.remove(listener)
async def push(self, event_type: str, data: str):
"""推送事件到 WebSocket 和监听器"""
event = StreamEvent(
type=event_type,
session_id=self.session_id,
data=data,
timestamp=datetime.now(timezone.utc).isoformat(),
)
# 发送到 WebSocket
if self.websocket_sender:
try:
await self.websocket_sender(self.session_id, json.dumps(event.__dict__))
except Exception:
pass
# 通知监听器
for listener in self._listeners:
try:
result = listener(event)
if hasattr(result, "__await__"):
await result
except Exception:
pass
async def stream_execution(
self,
executor,
prompt: str,
) -> AsyncGenerator[str, None]:
"""包装执行器,实现流式输出"""
async for line in executor.execute(prompt):
await self.push("output", line)
yield line
await self.push("complete", "")
async def push_status(self, status: str):
"""推送状态消息"""
await self.push("status", status)
async def push_error(self, error: str):
"""推送错误消息"""
await self.push("error", error)

View File

@@ -0,0 +1,160 @@
"""
PTY Terminal Engine - 跨平台 PTY 终端管理
"""
import asyncio
import os
from dataclasses import dataclass, field
from typing import AsyncGenerator
from uuid import uuid4
@dataclass
class PTYSession:
"""PTY 会话"""
session_id: str
process: asyncio.subprocess.Process
workspace_path: str
class PTYManager:
"""PTY 会话管理器"""
def __init__(self):
self._sessions: dict[str, PTYSession] = {}
self._output_queues: dict[str, asyncio.Queue] = {}
async def spawn(
self,
cli: str,
args: list[str],
cwd: str,
session_id: str | None = None,
env: dict | None = None,
) -> str:
"""启动 PTY 会话"""
if session_id is None:
session_id = f"pty_{uuid4().hex[:8]}"
# 构建环境变量
process_env = {**os.environ, "TERM": "xterm-256color"}
if env:
process_env.update(env)
# 创建 PTY 进程
process = await asyncio.create_subprocess_exec(
cli,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=process_env,
)
session = PTYSession(
session_id=session_id,
process=process,
workspace_path=cwd,
)
self._sessions[session_id] = session
self._output_queues[session_id] = asyncio.Queue()
# 启动输出读取协程
asyncio.create_task(self._read_output(session_id))
return session_id
async def _read_output(self, session_id: str):
"""读取 PTY 输出并放入队列"""
session = self._sessions.get(session_id)
if not session:
return
queue = self._output_queues[session_id]
try:
while True:
line = await session.process.stdout.readline()
if not line:
break
decoded_line = line.decode(errors="replace")
await queue.put(decoded_line)
# 广播到 WebSocket
await self._broadcast(session_id, decoded_line)
# 读取 stderr
stderr_line = await session.process.stderr.readline()
if stderr_line:
decoded_err = stderr_line.decode(errors="replace")
await queue.put(decoded_err)
await self._broadcast(session_id, decoded_err)
except Exception:
pass
finally:
await queue.put(None) # 结束标记
async def write(self, session_id: str, data: str):
"""写入 PTY用户输入"""
session = self._sessions.get(session_id)
if session and session.process.stdin:
session.process.stdin.write(data)
await session.process.stdin.drain()
async def read(self, session_id: str) -> AsyncGenerator[str, None]:
"""读取 PTY 输出"""
queue = self._output_queues.get(session_id)
if not queue:
return
while True:
line = await queue.get()
if line is None:
break
yield line
async def resize(self, session_id: str, rows: int, cols: int):
"""调整终端大小"""
# TODO: 实现 resize (需要平台特定实现)
pass
async def kill(self, session_id: str):
"""终止 PTY 会话"""
if session_id in self._sessions:
session = self._sessions[session_id]
try:
session.process.terminate()
await asyncio.wait_for(session.process.wait(), timeout=3.0)
except asyncio.TimeoutError:
session.process.kill()
await session.process.wait()
except Exception:
pass
finally:
del self._sessions[session_id]
if session_id in self._output_queues:
del self._output_queues[session_id]
async def _broadcast(self, session_id: str, data: str):
"""广播输出到 WebSocket"""
from app.routers.terminal import manager
try:
await manager.send(session_id, data)
except Exception:
pass
def get_session(self, session_id: str) -> PTYSession | None:
"""获取会话"""
return self._sessions.get(session_id)
def list_sessions(self) -> list[str]:
"""列出所有会话 ID"""
return list(self._sessions.keys())
# 全局单例
pty_manager = PTYManager()