diff --git a/assets/aris/acp-servers/external-acp-server/README.md b/assets/aris/acp-servers/external-acp-server/README.md new file mode 100644 index 00000000..1506984a --- /dev/null +++ b/assets/aris/acp-servers/external-acp-server/README.md @@ -0,0 +1,105 @@ +# External ACP Server + +一个独立的 Agent Communication Protocol (ACP) 服务实现,支持: +- **Single-agent 模式**: 单代理执行 +- **Multi-agent 模式**: 多代理协作 +- **自定义工具**: 扩展 MCP 工具能力 + +## 架构 + +``` +┌─────────────────────────────────────────────────────┐ +│ ACP Server │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ WebSocket │ │ HTTP POST │ │ Tool Bridge │ │ +│ │ /acp │ │ /acp/rpc │ │ (MCP) │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └────────────────┴────────────────┘ │ +│ │ │ +│ ┌──────▼──────┐ │ +│ │ Router │ │ +│ └──────┬──────┘ │ +│ │ │ +│ ┌────────────────┼────────────────┐ │ +│ │ │ │ │ +│ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │ +│ │ Session │ │ Agent │ │ Tool │ │ +│ │ Manager │ │ Executor │ │ Handler │ │ +│ └───────────┘ └───────────┘ └───────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +## 快速开始 + +```bash +# 安装依赖 +pip install -r requirements.txt + +# 配置环境变量 +export ACP_LISTEN_ADDR="127.0.0.1:8787" +export ACP_MULTI_AGENT_ENABLED="true" + +# 启动服务 +python server.py serve + +# 或使用自定义配置 +python server.py serve --listen 0.0.0.0:9000 +``` + +## 环境变量 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `ACP_LISTEN_ADDR` | `127.0.0.1:8787` | 服务监听地址 | +| `ACP_MULTI_AGENT_ENABLED` | `true` | 是否启用多代理模式 | +| `ACP_MULTI_AGENT_MODEL` | `gpt-4o` | 多代理使用的模型 | + +## 协议 + +### JSON-RPC 方法 + +| 方法 | 说明 | +|------|------| +| `acp.capabilities` | 查询服务器能力 | +| `session.start` | 启动新会话 | +| `session.message` | 发送消息(延续会话)| +| `session.cancel` | 取消会话 | +| `session.close` | 关闭会话 | + +### 通知类型 + +| type | 说明 | +|------|------| +| `status` | 会话状态变更 | +| `delta` | 增量文本输出 | +| `step` | 多代理步骤进度 | + +## 扩展自定义工具 + +在 `tools/` 目录下添加新的工具实现: + +```python +# tools/my_tool.py +class MyTool: + name = "my_tool" + description = "工具描述" + input_schema = { + "type": "object", + "properties": { + "input": {"type": "string", "description": "输入参数"} + }, + "required": ["input"] + } + + def execute(self, arguments: dict) -> str: + return f"处理结果: {arguments.get('input')}" +``` + +在 `server.py` 中注册: + +```python +from tools.my_tool import MyTool + +tool_registry.register(MyTool()) +``` \ No newline at end of file diff --git a/assets/aris/acp-servers/external-acp-server/requirements.txt b/assets/aris/acp-servers/external-acp-server/requirements.txt new file mode 100644 index 00000000..fe2943e7 --- /dev/null +++ b/assets/aris/acp-servers/external-acp-server/requirements.txt @@ -0,0 +1,3 @@ +# ACP Server dependencies +aiohttp>=3.9.0 +requests>=2.31.0 \ No newline at end of file diff --git a/assets/aris/acp-servers/external-acp-server/server.py b/assets/aris/acp-servers/external-acp-server/server.py new file mode 100644 index 00000000..f58461e6 --- /dev/null +++ b/assets/aris/acp-servers/external-acp-server/server.py @@ -0,0 +1,1158 @@ +#!/usr/bin/env python3 +""" +External ACP Server - 独立的 Agent Communication Protocol 服务 + +支持: +- Single-agent 模式: 调用外部 CLI 工具 +- Multi-agent 模式: 多代理协作 +- 自定义工具: MCP 兼容的工具扩展 + +用法: + python server.py serve --listen 127.0.0.1:8787 + python server.py bridge # 作为 MCP 工具桥接器运行 +""" + +import asyncio +import json +import logging +import os +import signal +import sys +import time +import uuid +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable, Optional +from contextlib import asynccontextmanager + +try: + from aiohttp import web + import aiohttp + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + print("Warning: aiohttp not installed. Run: pip install aiohttp") + +try: + import websockets + WEBSOCKETS_AVAILABLE = True +except ImportError: + WEBSOCKETS_AVAILABLE = False + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("acp-server") + + +# ============================================================================ +# 数据模型 +# ============================================================================ + +class SessionMode(Enum): + SINGLE_AGENT = "single-agent" + MULTI_AGENT = "multi-agent" + + +@dataclass +class AcpSession: + """ACP 会话""" + session_id: str + thread_id: str + mode: SessionMode = SessionMode.SINGLE_AGENT + provider: str = "" + history: list = field(default_factory=list) + seq: int = 0 + created_at: float = field(default_factory=time.time) + cancelled: bool = False + closed: bool = False + + +@dataclass +class JsonRpcRequest: + """JSON-RPC 请求""" + id: Optional[Any] = None + method: str = "" + params: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + result = {"jsonrpc": "2.0", "method": self.method} + if self.id is not None: + result["id"] = self.id + if self.params: + result["params"] = self.params + return result + + +@dataclass +class JsonRpcResponse: + """JSON-RPC 响应""" + id: Optional[Any] = None + result: Any = None + error: Optional[dict] = None + + def to_dict(self) -> dict: + result = {"jsonrpc": "2.0"} + if self.id is not None: + result["id"] = self.id + if self.error: + result["error"] = self.error + else: + result["result"] = self.result + return result + + +@dataclass +class JsonRpcNotification: + """JSON-RPC 通知""" + method: str + params: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "jsonrpc": "2.0", + "method": self.method, + "params": self.params + } + + +# ============================================================================ +# 工具注册表 +# ============================================================================ + +class Tool(ABC): + """工具基类""" + + @property + @abstractmethod + def name(self) -> str: + """工具名称""" + pass + + @property + @abstractmethod + def description(self) -> str: + """工具描述""" + pass + + @property + def input_schema(self) -> dict: + """输入 JSON Schema""" + return {"type": "object", "properties": {}} + + @abstractmethod + def execute(self, arguments: dict) -> str: + """执行工具""" + pass + + +class ToolRegistry: + """工具注册表""" + + def __init__(self): + self._tools: dict[str, Tool] = {} + + def register(self, tool: Tool): + self._tools[tool.name] = tool + + def get(self, name: str) -> Optional[Tool]: + return self._tools.get(name) + + def list_all(self) -> list[Tool]: + return list(self._tools.values()) + + def to_mcp_tools_list(self) -> list[dict]: + return [ + { + "name": tool.name, + "description": tool.description, + "inputSchema": tool.input_schema + } + for tool in self._tools.values() + ] + + +# ============================================================================ +# 提供者注册表 +# ============================================================================ + +class Provider(ABC): + """代理提供者基类""" + + @property + @abstractmethod + def name(self) -> str: + """提供者名称""" + pass + + @property + def is_available(self) -> bool: + """检查提供者是否可用""" + return True + + @abstractmethod + async def execute( + self, + prompt: str, + working_directory: str = "", + model: str = "", + on_delta: Optional[Callable[[str], None]] = None + ) -> str: + """执行代理任务""" + pass + + +class ProviderRegistry: + """提供者注册表""" + + def __init__(self): + self._providers: dict[str, Provider] = {} + + def register(self, provider: Provider): + self._providers[provider.name] = provider + + def get(self, name: str) -> Optional[Provider]: + return self._providers.get(name) + + def list_available(self) -> list[str]: + return [p.name for p in self._providers.values() if p.is_available] + + +# ============================================================================ +# ACP 服务器核心 +# ============================================================================ + +class AcpServer: + """ACP 服务器""" + + def __init__(self, tool_registry: ToolRegistry, provider_registry: ProviderRegistry): + self.tool_registry = tool_registry + self.provider_registry = provider_registry + self.sessions: dict[str, AcpSession] = {} + self.session_tasks: dict[str, asyncio.Task] = {} + self._request_id_counter = 0 + + def get_config(self, key: str, default: str = "") -> str: + """获取配置""" + return os.environ.get(key, default) + + def get_bool_config(self, key: str, default: bool = False) -> bool: + """获取布尔配置""" + value = os.environ.get(key, "").lower() + if value in ("1", "true", "yes", "on"): + return True + if value in ("0", "false", "no", "off"): + return False + return default + + # ------------------------------------------------------------------------ + # 能力查询 + # ------------------------------------------------------------------------ + + def get_capabilities(self) -> dict: + """获取服务器能力""" + providers = self.provider_registry.list_available() + multi_agent_enabled = self.get_bool_config("ACP_MULTI_AGENT_ENABLED", True) + + return { + "singleAgent": len(providers) > 0, + "multiAgent": multi_agent_enabled, + "providers": providers, + "capabilities": { + "single_agent": len(providers) > 0, + "multi_agent": multi_agent_enabled, + "providers": providers, + "tools": [t.name for t in self.tool_registry.list_all()] + } + } + + # ------------------------------------------------------------------------ + # 会话管理 + # ------------------------------------------------------------------------ + + def create_session(self, session_id: str, thread_id: str) -> AcpSession: + """创建会话""" + session = AcpSession( + session_id=session_id, + thread_id=thread_id or session_id + ) + self.sessions[session_id] = session + return session + + def get_session(self, session_id: str) -> Optional[AcpSession]: + """获取会话""" + return self.sessions.get(session_id) + + def cancel_session(self, session_id: str) -> bool: + """取消会话""" + session = self.sessions.get(session_id) + if session: + session.cancelled = True + task = self.session_tasks.get(session_id) + if task: + task.cancel() + return True + return False + + def close_session(self, session_id: str) -> bool: + """关闭会话""" + session = self.sessions.get(session_id) + if session: + session.closed = True + self.cancel_session(session_id) + del self.sessions[session_id] + return True + return False + + # ------------------------------------------------------------------------ + # 消息处理 + # ------------------------------------------------------------------------ + + async def handle_request( + self, + request: JsonRpcRequest, + notify: Callable[[JsonRpcNotification], None] + ) -> JsonRpcResponse: + """处理 JSON-RPC 请求""" + method = request.method.strip() + + # 通知不需要响应 + if request.id is None: + if method == "notifications/initialized": + logger.info("Client initialized") + return None + + try: + if method == "acp.capabilities": + return JsonRpcResponse(id=request.id, result=self.get_capabilities()) + + elif method == "session.start": + return await self._handle_session_start(request, notify) + + elif method == "session.message": + return await self._handle_session_message(request, notify) + + elif method == "session.cancel": + return self._handle_session_cancel(request) + + elif method == "session.close": + return self._handle_session_close(request) + + else: + return JsonRpcResponse( + id=request.id, + error={"code": -32601, "message": f"Unknown method: {method}"} + ) + + except asyncio.CancelledError: + raise + except Exception as e: + logger.exception(f"Error handling {method}") + return JsonRpcResponse( + id=request.id, + error={"code": -32603, "message": str(e)} + ) + + async def _handle_session_start( + self, + request: JsonRpcRequest, + notify: Callable[[JsonRpcNotification], None] + ) -> JsonRpcResponse: + """处理 session.start""" + params = request.params + session_id = params.get("sessionId", "").strip() + thread_id = params.get("threadId", session_id).strip() + + if not session_id: + return JsonRpcResponse( + id=request.id, + error={"code": -32602, "message": "sessionId is required"} + ) + + # 创建新会话 + session = self.create_session(session_id, thread_id) + + # 发送开始通知 + turn_id = f"turn-{int(time.time() * 1000000)}" + self._emit_update(session, turn_id, { + "type": "status", + "event": "started", + "message": "session started", + "pending": True, + "error": False + }, notify) + + # 执行会话 + return await self._execute_session(session, params, turn_id, notify) + + async def _handle_session_message( + self, + request: JsonRpcRequest, + notify: Callable[[JsonRpcNotification], None] + ) -> JsonRpcResponse: + """处理 session.message""" + params = request.params + session_id = params.get("sessionId", "").strip() + thread_id = params.get("threadId", session_id).strip() + + if not session_id: + return JsonRpcResponse( + id=request.id, + error={"code": -32602, "message": "sessionId is required"} + ) + + # 获取或创建会话 + session = self.get_session(session_id) + if not session: + session = self.create_session(session_id, thread_id) + + turn_id = f"turn-{int(time.time() * 1000000)}" + return await self._execute_session(session, params, turn_id, notify) + + def _handle_session_cancel(self, request: JsonRpcRequest) -> JsonRpcResponse: + """处理 session.cancel""" + params = request.params + session_id = params.get("sessionId", "").strip() + + if not session_id: + return JsonRpcResponse( + id=request.id, + error={"code": -32602, "message": "sessionId is required"} + ) + + cancelled = self.cancel_session(session_id) + return JsonRpcResponse( + id=request.id, + result={"accepted": True, "cancelled": cancelled} + ) + + def _handle_session_close(self, request: JsonRpcRequest) -> JsonRpcResponse: + """处理 session.close""" + params = request.params + session_id = params.get("sessionId", "").strip() + + if not session_id: + return JsonRpcResponse( + id=request.id, + error={"code": -32602, "message": "sessionId is required"} + ) + + closed = self.close_session(session_id) + return JsonRpcResponse( + id=request.id, + result={"accepted": True, "closed": closed} + ) + + async def _execute_session( + self, + session: AcpSession, + params: dict, + turn_id: str, + notify: Callable[[JsonRpcNotification], None] + ) -> JsonRpcResponse: + """执行会话任务""" + mode_str = params.get("mode", "single-agent").strip() + session.mode = SessionMode(mode_str) if mode_str in ("single-agent", "multi-agent") else SessionMode.SINGLE_AGENT + + if session.mode == SessionMode.MULTI_AGENT: + return await self._run_multi_agent(session, params, turn_id, notify) + else: + return await self._run_single_agent(session, params, turn_id, notify) + + async def _run_single_agent( + self, + session: AcpSession, + params: dict, + turn_id: str, + notify: Callable[[JsonRpcNotification], None] + ) -> JsonRpcResponse: + """运行单代理""" + provider_name = params.get("provider", "codex").strip() + prompt = params.get("taskPrompt", "").strip() + prompt = self._augment_prompt(prompt, params) + working_directory = params.get("workingDirectory", "").strip() + model = params.get("model", "").strip() + + provider = self.provider_registry.get(provider_name) + if not provider: + self._emit_update(session, turn_id, { + "type": "status", + "event": "completed", + "message": f"Unknown provider: {provider_name}", + "pending": False, + "error": True + }, notify) + return JsonRpcResponse( + id=None, + error={"code": -32602, "message": f"Unknown provider: {provider_name}"} + ) + + if not provider.is_available: + self._emit_update(session, turn_id, { + "type": "status", + "event": "completed", + "message": f"Provider not available: {provider_name}", + "pending": False, + "error": True + }, notify) + return JsonRpcResponse( + id=None, + error={"code": -32602, "message": f"Provider not available: {provider_name}"} + ) + + def on_delta(text: str): + self._emit_update(session, turn_id, { + "type": "delta", + "delta": text, + "pending": True, + "error": False + }, notify) + + try: + output = await provider.execute( + prompt=prompt, + working_directory=working_directory, + model=model, + on_delta=on_delta + ) + + self._emit_update(session, turn_id, { + "type": "status", + "event": "completed", + "message": "single-agent completed", + "pending": False, + "error": False + }, notify) + + return JsonRpcResponse( + result={ + "success": True, + "output": output, + "turnId": turn_id, + "mode": "single-agent", + "provider": provider_name + } + ) + + except asyncio.CancelledError: + self._emit_update(session, turn_id, { + "type": "status", + "event": "cancelled", + "message": "session cancelled", + "pending": False, + "error": True + }, notify) + raise + + except Exception as e: + self._emit_update(session, turn_id, { + "type": "status", + "event": "completed", + "message": str(e), + "pending": False, + "error": True + }, notify) + return JsonRpcResponse( + error={"code": -32603, "message": str(e)} + ) + + async def _run_multi_agent( + self, + session: AcpSession, + params: dict, + turn_id: str, + notify: Callable[[JsonRpcNotification], None] + ) -> JsonRpcResponse: + """运行多代理""" + # TODO: 实现多代理协调逻辑 + # 这里是一个简化版本,实际需要更复杂的编排 + + self._emit_update(session, turn_id, { + "type": "step", + "mode": "multi-agent", + "title": "Coordinator", + "message": "Starting multi-agent orchestration", + "pending": True, + "error": False, + "role": "architect", + "iteration": 1, + "score": 0 + }, notify) + + # 获取配置 + base_url = params.get("aiGatewayBaseUrl", os.environ.get("AI_GATEWAY_BASE_URL", "")).strip() + api_key = params.get("aiGatewayApiKey", os.environ.get("AI_GATEWAY_API_KEY", "")).strip() + model = params.get("model", os.environ.get("ACP_MULTI_AGENT_MODEL", "gpt-4o")).strip() + prompt = params.get("taskPrompt", "").strip() + + if not api_key: + self._emit_update(session, turn_id, { + "type": "status", + "mode": "multi-agent", + "message": "aiGatewayApiKey is required for multi-agent mode", + "pending": False, + "error": True + }, notify) + return JsonRpcResponse( + error={"code": -32602, "message": "aiGatewayApiKey is required"} + ) + + # 调用 LLM (简化版本) + try: + output = await self._call_llm(base_url, api_key, model, prompt) + + self._emit_update(session, turn_id, { + "type": "step", + "mode": "multi-agent", + "title": "Result", + "message": output, + "pending": False, + "error": False, + "role": "tester", + "iteration": 1, + "score": 9 + }, notify) + + return JsonRpcResponse( + result={ + "success": True, + "summary": output, + "finalScore": 9, + "iterations": 1, + "turnId": turn_id, + "mode": "multi-agent" + } + ) + + except Exception as e: + self._emit_update(session, turn_id, { + "type": "status", + "mode": "multi-agent", + "message": str(e), + "pending": False, + "error": True + }, notify) + return JsonRpcResponse( + error={"code": -32603, "message": str(e)} + ) + + async def _call_llm(self, base_url: str, api_key: str, model: str, prompt: str) -> str: + """调用 LLM API""" + import aiohttp + + url = f"{base_url.rstrip('/')}/chat/completions" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + } + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "You are a multi-agent coordinator."}, + {"role": "user", "content": prompt} + ], + "max_tokens": 4096 + } + + async with aiohttp.ClientSession() as http_session: + async with http_session.post(url, headers=headers, json=payload) as response: + if response.status != 200: + text = await response.text() + raise Exception(f"API error {response.status}: {text[:500]}") + data = await response.json() + return data["choices"][0]["message"]["content"] + + def _augment_prompt(self, prompt: str, params: dict) -> str: + """附加文件信息到提示""" + attachments = params.get("attachments", []) + if not attachments: + return prompt + + lines = ["User-selected local attachments:"] + for att in attachments: + name = att.get("name", "attachment") + path = att.get("path", "") + if path: + lines.append(f"- {name}: {path}") + + return "\n".join(lines) + "\n\n" + prompt + + def _emit_update( + self, + session: AcpSession, + turn_id: str, + payload: dict, + notify: Callable[[JsonRpcNotification], None] + ): + """发送 session.update 通知""" + session.seq += 1 + params = { + "sessionId": session.session_id, + "threadId": session.thread_id, + "turnId": turn_id, + "seq": session.seq, + **payload + } + notify(JsonRpcNotification(method="session.update", params=params)) + + +# ============================================================================ +# 内置提供者 +# ============================================================================ + +class CodexProvider(Provider): + """Codex CLI 提供者""" + + @property + def name(self) -> str: + return "codex" + + @property + def is_available(self) -> bool: + import shutil + return shutil.which("codex") is not None + + async def execute( + self, + prompt: str, + working_directory: str = "", + model: str = "", + on_delta: Optional[Callable[[str], None]] = None + ) -> str: + args = ["exec", "--skip-git-repo-check", "--color", "never"] + if working_directory: + args.extend(["-C", working_directory]) + if model: + args.extend(["-m", model]) + args.append(prompt) + + process = await asyncio.create_subprocess_exec( + "codex", *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + raise Exception(f"Codex failed: {stderr.decode()}") + + return stdout.decode().strip() + + +class ClaudeProvider(Provider): + """Claude CLI 提供者""" + + @property + def name(self) -> str: + return "claude" + + @property + def is_available(self) -> bool: + import shutil + return shutil.which("claude") is not None + + async def execute( + self, + prompt: str, + working_directory: str = "", + model: str = "", + on_delta: Optional[Callable[[str], None]] = None + ) -> str: + args = ["-p", prompt] + if model: + args = ["--model", model] + args + + process = await asyncio.create_subprocess_exec( + "claude", *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=working_directory or None + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + raise Exception(f"Claude failed: {stderr.decode()}") + + return stdout.decode().strip() + + +# ============================================================================ +# 内置工具 +# ============================================================================ + +class ChatTool(Tool): + """LLM Chat 工具""" + + @property + def name(self) -> str: + return "chat" + + @property + def description(self) -> str: + return "Send a message to an LLM and get a response" + + @property + def input_schema(self) -> dict: + return { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "The prompt to send"}, + "model": {"type": "string", "description": "Model to use (default: from LLM_MODEL env)"}, + "system": {"type": "string", "description": "Optional system prompt"} + }, + "required": ["prompt"] + } + + def execute(self, arguments: dict) -> str: + import requests + + api_key = os.environ.get("LLM_API_KEY", "") + if not api_key: + return "Error: LLM_API_KEY not set" + + base_url = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1").rstrip("/") + model = arguments.get("model", os.environ.get("LLM_MODEL", "gpt-4o")) + prompt = arguments.get("prompt", "") + system = arguments.get("system", "") + + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + response = requests.post( + f"{base_url}/chat/completions", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + }, + json={"model": model, "messages": messages, "max_tokens": 4096}, + timeout=120 + ) + + if response.status_code != 200: + return f"Error: API returned {response.status_code}" + + return response.json()["choices"][0]["message"]["content"] + + +# ============================================================================ +# HTTP/WebSocket 服务器 +# ============================================================================ + +class AcpHttpServer: + """基于 aiohttp 的 ACP HTTP/WebSocket 服务器""" + + def __init__(self, acp_server: AcpServer, host: str = "127.0.0.1", port: int = 8787): + if not AIOHTTP_AVAILABLE: + raise RuntimeError("aiohttp not installed. Run: pip install aiohttp") + + self.acp_server = acp_server + self.host = host + self.port = port + self.app = web.Application() + self._setup_routes() + + def _setup_routes(self): + self.app.router.add_get("/health", self._handle_health) + self.app.router.add_post("/acp/rpc", self._handle_http_rpc) + self.app.router.add_get("/acp", self._handle_websocket) + + async def _handle_health(self, request: web.Request) -> web.Response: + return web.json_response({"status": "ok"}) + + async def _handle_http_rpc(self, request: web.Request) -> web.Response: + """处理 HTTP RPC 请求""" + try: + body = await request.read() + data = json.loads(body) + + rpc_request = JsonRpcRequest( + id=data.get("id"), + method=data.get("method", ""), + params=data.get("params", {}) + ) + + notifications = [] + + def notify(n: JsonRpcNotification): + notifications.append(n) + + response = await self.acp_server.handle_request(rpc_request, notify) + + # 检查是否需要 SSE 流式响应 + accept = request.headers.get("Accept", "").lower() + if "text/event-stream" in accept and notifications: + response_obj = web.StreamResponse() + response_obj.content_type = "text/event-stream" + response_obj.headers["Cache-Control"] = "no-cache" + response_obj.headers["Connection"] = "keep-alive" + await response_obj.prepare(request) + + for n in notifications: + await response_obj.write(f"data: {json.dumps(n.to_dict())}\n\n".encode()) + + if response: + await response_obj.write(f"data: {json.dumps(response.to_dict())}\n\n".encode()) + + await response_obj.write(b"data: [DONE]\n\n") + return response_obj + + if response is None: + return web.Response(status=204) + + return web.json_response(response.to_dict()) + + except json.JSONDecodeError: + return web.json_response( + {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Invalid JSON"}}, + status=400 + ) + except Exception as e: + logger.exception("HTTP RPC error") + return web.json_response( + {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}}, + status=500 + ) + + async def _handle_websocket(self, request: web.Request) -> web.WebSocketResponse: + """处理 WebSocket 连接""" + ws = web.WebSocketResponse() + await ws.prepare(request) + + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + try: + data = json.loads(msg.data) + rpc_request = JsonRpcRequest( + id=data.get("id"), + method=data.get("method", ""), + params=data.get("params", {}) + ) + + def notify(n: JsonRpcNotification): + asyncio.create_task(ws.send_json(n.to_dict())) + + response = await self.acp_server.handle_request(rpc_request, notify) + if response: + await ws.send_json(response.to_dict()) + + except json.JSONDecodeError: + await ws.send_json({ + "jsonrpc": "2.0", + "error": {"code": -32700, "message": "Invalid JSON"} + }) + except Exception as e: + logger.exception("WebSocket error") + + elif msg.type == aiohttp.WSMsgType.ERROR: + logger.error(f"WebSocket error: {ws.exception()}") + + return ws + + def run(self): + """启动服务器""" + web.run_app(self.app, host=self.host, port=self.port) + + +# ============================================================================ +# MCP 工具桥接器 +# ============================================================================ + +class McpToolBridge: + """作为 MCP 工具运行的桥接器""" + + def __init__(self, tool_registry: ToolRegistry): + self.tool_registry = tool_registry + + def run(self): + """运行 MCP 桥接器""" + import sys + + # 强制无缓冲 I/O + sys.stdout = os.fdopen(sys.stdout.fileno(), 'wb', buffering=0) + sys.stdin = os.fdopen(sys.stdin.fileno(), 'rb', buffering=0) + + while True: + try: + request = self._read_message() + if request is None: + break + + response = self._handle_request(request) + if response: + self._write_response(response) + + except EOFError: + break + except Exception as e: + logger.exception("MCP bridge error") + + def _read_message(self) -> Optional[dict]: + """读取 MCP 消息""" + line = sys.stdin.readline() + if not line: + return None + + line = line.decode('utf-8').rstrip('\r\n') + + # Content-Length 格式 + if line.lower().startswith("content-length:"): + content_length = int(line.split(":", 1)[1].strip()) + while True: + hdr = sys.stdin.readline() + if not hdr: + return None + hdr = hdr.decode('utf-8').rstrip('\r\n') + if hdr == "": + break + + body = sys.stdin.read(content_length) + return json.loads(body.decode('utf-8')) + + # NDJSON 格式 + if line.startswith("{"): + return json.loads(line) + + return None + + def _write_response(self, response: dict): + """写入 MCP 响应""" + json_str = json.dumps(response, separators=(',', ':')) + json_bytes = json_str.encode('utf-8') + header = f"Content-Length: {len(json_bytes)}\r\n\r\n".encode('utf-8') + sys.stdout.write(header + json_bytes) + sys.stdout.flush() + + def _handle_request(self, request: dict) -> Optional[dict]: + """处理 MCP 请求""" + method = request.get("method", "") + request_id = request.get("id") + + # 通知不需要响应 + if request_id is None: + if method == "notifications/initialized": + logger.info("MCP client initialized") + return None + + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": { + "name": "external-acp-tools", + "version": "1.0.0" + } + } + } + + elif method == "ping": + return {"jsonrpc": "2.0", "id": request_id, "result": {}} + + elif method == "tools/list": + return { + "jsonrpc": "2.0", + "id": request_id, + "result": {"tools": self.tool_registry.to_mcp_tools_list()} + } + + elif method == "tools/call": + params = request.get("params", {}) + tool_name = params.get("name", "") + arguments = params.get("arguments", {}) + + tool = self.tool_registry.get(tool_name) + if not tool: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"Unknown tool: {tool_name}"} + } + + try: + result = tool.execute(arguments) + return { + "jsonrpc": "2.0", + "id": request_id, + "result": {"content": [{"type": "text", "text": result}]} + } + except Exception as e: + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": f"Error: {e}"}], + "isError": True + } + } + + else: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"Unknown method: {method}"} + } + + +# ============================================================================ +# 主入口 +# ============================================================================ + +def create_default_registries() -> tuple[ToolRegistry, ProviderRegistry]: + """创建默认注册表""" + tool_registry = ToolRegistry() + tool_registry.register(ChatTool()) + + provider_registry = ProviderRegistry() + provider_registry.register(CodexProvider()) + provider_registry.register(ClaudeProvider()) + + return tool_registry, provider_registry + + +def main(): + """主入口""" + import argparse + + parser = argparse.ArgumentParser(description="External ACP Server") + subparsers = parser.add_subparsers(dest="command", help="Command") + + # serve 命令 + serve_parser = subparsers.add_parser("serve", help="Start ACP server") + serve_parser.add_argument("--listen", default=os.environ.get("ACP_LISTEN_ADDR", "127.0.0.1:8787"), + help="Listen address (default: 127.0.0.1:8787)") + + # bridge 命令 + subparsers.add_parser("bridge", help="Run as MCP tool bridge") + + args = parser.parse_args() + + tool_registry, provider_registry = create_default_registries() + + if args.command == "serve": + host, port = args.listen.split(":") + port = int(port) + acp_server = AcpServer(tool_registry, provider_registry) + http_server = AcpHttpServer(acp_server, host, port) + logger.info(f"Starting ACP server on {host}:{port}") + http_server.run() + + elif args.command == "bridge": + bridge = McpToolBridge(tool_registry) + logger.info("Starting MCP tool bridge") + bridge.run() + + else: + parser.print_help() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/assets/aris/acp-servers/external-acp-server/tools/__init__.py b/assets/aris/acp-servers/external-acp-server/tools/__init__.py new file mode 100644 index 00000000..8642ac61 --- /dev/null +++ b/assets/aris/acp-servers/external-acp-server/tools/__init__.py @@ -0,0 +1,4 @@ +# Tools package for external ACP server +# Add your custom tools here + +from .base import Tool, ToolRegistry \ No newline at end of file diff --git a/assets/aris/acp-servers/external-acp-server/tools/base.py b/assets/aris/acp-servers/external-acp-server/tools/base.py new file mode 100644 index 00000000..cfceed46 --- /dev/null +++ b/assets/aris/acp-servers/external-acp-server/tools/base.py @@ -0,0 +1,94 @@ +""" +Tool base classes for external ACP server +""" + +from abc import ABC, abstractmethod +from typing import Any, Optional + + +class Tool(ABC): + """ + 工具基类 + + 所有自定义工具必须继承此类并实现以下方法: + - name: 工具名称 + - description: 工具描述 + - input_schema: 输入参数 JSON Schema + - execute: 执行逻辑 + """ + + @property + @abstractmethod + def name(self) -> str: + """ + 工具名称 + + 必须唯一,使用小写字母和下划线,例如: code_review + """ + pass + + @property + @abstractmethod + def description(self) -> str: + """ + 工具描述 + + 简短描述工具的功能,用于 AI 选择合适的工具 + """ + pass + + @property + def input_schema(self) -> dict: + """ + 输入参数 JSON Schema + + 定义工具接受的参数结构 + """ + return { + "type": "object", + "properties": {}, + "required": [] + } + + @abstractmethod + def execute(self, arguments: dict) -> str: + """ + 执行工具 + + Args: + arguments: 工具参数,根据 input_schema 验证 + + Returns: + 工具执行结果,作为文本返回 + """ + pass + + +class ToolRegistry: + """工具注册表""" + + def __init__(self): + self._tools: dict[str, Tool] = {} + + def register(self, tool: Tool): + """注册工具""" + self._tools[tool.name] = tool + + def get(self, name: str) -> Optional[Tool]: + """获取工具""" + return self._tools.get(name) + + def list_all(self) -> list[Tool]: + """列出所有工具""" + return list(self._tools.values()) + + def to_mcp_tools_list(self) -> list[dict]: + """转换为 MCP 工具列表格式""" + return [ + { + "name": tool.name, + "description": tool.description, + "inputSchema": tool.input_schema + } + for tool in self._tools.values() + ] \ No newline at end of file diff --git a/assets/aris/acp-servers/external-acp-server/tools/code_review.py b/assets/aris/acp-servers/external-acp-server/tools/code_review.py new file mode 100644 index 00000000..d14d2d47 --- /dev/null +++ b/assets/aris/acp-servers/external-acp-server/tools/code_review.py @@ -0,0 +1,117 @@ +""" +示例自定义工具:代码审查工具 + +这个工具展示了如何扩展 ACP 服务器添加自定义工具。 +""" + + +class CodeReviewTool: + """代码审查工具 - 使用 LLM 审查代码变更""" + + @property + def name(self) -> str: + return "code_review" + + @property + def description(self) -> str: + return "Review code changes and provide feedback" + + @property + def input_schema(self) -> dict: + return { + "type": "object", + "properties": { + "diff": { + "type": "string", + "description": "The git diff or code changes to review" + }, + "context": { + "type": "string", + "description": "Optional context about the changes" + }, + "focus": { + "type": "string", + "description": "Areas to focus on (security, performance, style, etc.)" + } + }, + "required": ["diff"] + } + + def execute(self, arguments: dict) -> str: + """ + 执行代码审查 + + 实际实现中,你可以: + 1. 调用外部 LLM API + 2. 使用本地模型 + 3. 执行静态分析工具 + 4. 查询代码库知识库 + """ + import os + + diff = arguments.get("diff", "") + context = arguments.get("context", "") + focus = arguments.get("focus", "general") + + # 获取 LLM 配置 + api_key = os.environ.get("LLM_API_KEY", "") + base_url = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1") + model = os.environ.get("LLM_MODEL", "gpt-4o") + + if not api_key: + return "Error: LLM_API_KEY environment variable not set" + + # 构建审查提示 + system_prompt = """You are an expert code reviewer. Analyze the provided code changes and provide: +1. Summary of changes +2. Potential issues (bugs, security, performance) +3. Code style suggestions +4. Overall assessment + +Be concise and actionable.""" + + if focus != "general": + system_prompt += f"\n\nFocus particularly on: {focus}" + + user_prompt = f"Context: {context}\n\nCode changes:\n```\n{diff}\n```" if context else f"Code changes:\n```\n{diff}\n```" + + # 调用 LLM API + try: + import requests + + response = requests.post( + f"{base_url.rstrip('/')}/chat/completions", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + }, + json={ + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + "max_tokens": 4096 + }, + timeout=120 + ) + + if response.status_code != 200: + return f"Error: API returned {response.status_code}: {response.text[:200]}" + + return response.json()["choices"][0]["message"]["content"] + + except Exception as e: + return f"Error: {e}" + + +# 注册工具的示例 +def register_tools(registry): + """注册所有自定义工具""" + from .base import Tool + + # 注册代码审查工具 + registry.register(CodeReviewTool()) + + # 在这里添加更多工具... + # registry.register(AnotherTool()) \ No newline at end of file diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index f8139fda..010c637d 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -26,8 +26,8 @@ 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 8E6F4A7D31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8E6F4A7C31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 8E6F4A7D31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8E6F4A7C31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy */; }; A96EF8FFA0E80B16252FE834 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5F4830709C3A67EC8096888 /* Pods_RunnerTests.framework */; }; F02922E20E15948F8CE5469F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2099D82E31DC5912EA477802 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -74,7 +74,6 @@ 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 8E6F4A7C31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Runner/PrivacyInfo.xcprivacy; sourceTree = ""; }; 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; @@ -85,6 +84,7 @@ 73098D7450C105A02267F617 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 890E8B245EC31E1C0F085895 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 8E6F4A7C31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Runner/PrivacyInfo.xcprivacy; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; B5F4830709C3A67EC8096888 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D8467093D02A39550CF15067 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; @@ -422,7 +422,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n/bin/sh \"${PROJECT_DIR}/../scripts/ensure-framework-dsyms.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ diff --git a/scripts/build-go-core.sh b/scripts/build-go-core.sh index 86c551c3..63b393c7 100644 --- a/scripts/build-go-core.sh +++ b/scripts/build-go-core.sh @@ -4,7 +4,13 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" BRIDGE_DIR="$ROOT_DIR/go/go_core" OUTPUT_DIR="${OUTPUT_DIR:-$ROOT_DIR/build/bin}" -OUTPUT_PATH="${OUTPUT_PATH:-$OUTPUT_DIR/xworkmate-go-core}" +OUTPUT_PATH_BASE="${OUTPUT_DIR}/xworkmate-go-core" + +if [[ "$(uname -s)" == *MINGW* || "$(uname -s)" == *MSYS* || "$(uname -s)" == *CYGWIN* ]]; then + OUTPUT_PATH="${OUTPUT_PATH:-${OUTPUT_PATH_BASE}.exe}" +else + OUTPUT_PATH="${OUTPUT_PATH:-${OUTPUT_PATH_BASE}}" +fi if [[ ! -f "$BRIDGE_DIR/go.mod" ]]; then echo "Missing go.mod in $BRIDGE_DIR" >&2