"""智能体服务(AgentService)—— zcode 式"模型操作工作区文件"的网关侧封装。 职责: - OpenAICompatChat:OpenAI 兼容 /chat/completions 的工具调用客户端(ToolLoop 的 chat_fn), 支持 httpx transport/client 注入(测试用 MockTransport,对齐 D11 封闭性)。 - AgentService:运行一次智能体任务——事件逐条落盘 agent_runs/{id}/events.jsonl, 终态写 status.json;SSE 端点轮询事件文件增量推送(与 v3 workspace 监视同思路, 不侵入 router_system)。 - 模型来源:模型池 agent 角色(或显式 pool_id),否则回退经典 Architect 设置。 安全与护栏: - 文件操作被 WorkspaceTools 关押在工作区根目录内 - 轮数上限(agent.max_rounds)与 token 熔断(agent.token_cap)双护栏 """ from __future__ import annotations import asyncio import json import time import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional from router_system.tools import ToolLoop, WorkspaceTools # 运行目录(与 runs/ 平级) AGENT_RUNS_DIR = Path("agent_runs") STATE_RUNNING = "running" STATE_DONE = "done" STATE_FAILED = "failed" AGENT_SYSTEM_PROMPT = ( "你是端云协同 LLM 系统中的智能体(Agent),正在操作用户选择的**真实项目工作目录**。" "你拥有的工具:list_dir(列目录)、read_file(读文件)、write_file(写文件/新建)、" "edit_file(精确替换编辑:old_string 须唯一匹配)、search_files(跨文件搜索内容)、" "run_command(执行 shell 命令,仅当系统开启 allow_shell 时可用,否则不要尝试)。" "像编程助手一样工作:先列目录/搜索了解项目结构,读文件核对原文后再用 edit_file 小步修改" "(或 write_file 新建),需要时运行命令验证。任务完成或给出结论后," "直接输出给用户的最终答复(中文,不要再调用工具)。" ) # ── 两级智能体(D7):规划者(大模型)+ 执行者(本地小模型),交接走 handoff 文档 ── PLANNER_SYSTEM_PROMPT = ( "你是两级智能体中的**规划者**(大模型)。执行者是一个能力有限的本地小模型," "只能机械地使用工具。你的职责:把用户任务拆成执行者可照做的**具体指令**," "并在执行后审查其汇报。输出必须是合法 JSON 对象(不要 markdown 围栏)。" ) EXECUTOR_SYSTEM_PROMPT = ( "你是两级智能体中的**执行者**(本地小模型)。规划者已给你具体指令," "你只负责用工具完成指令并在最后**汇报**:做了什么、结果如何、有什么问题。" "严格遵守指令范围,不要自行扩大任务。汇报用中文,是给规划者看的," "要列出:修改的文件、关键命令输出、未完成项。" ) # 规划者首轮:产出指令(JSON) _PLAN_SCHEMA_HINT = { "instructions": "string(给执行者的具体步骤指令,<=600字)", "acceptance": "string(验收标准,<=200字)", } # 规划者审查轮:裁决(JSON) _REVIEW_SCHEMA_HINT = { "verdict": "enum(done|redo)", "reply_to_executor": "string(verdict=redo 时给执行者的补充指令;done 时可空)", "final_answer": "string(verdict=done 时给用户的最终答复)", } DEFAULT_MAX_HANDOFFS = 2 # 规划者<->执行者交接轮数上限 def _parse_json_loose(content: str) -> Dict[str, Any]: """宽松解析规划者的 JSON 输出(剥围栏/取首个对象);失败返回 {}。""" try: from router_system.architect import ArchitectClient return ArchitectClient._parse_json(content) except Exception: return {} # ───────────────────────────────────────────────────────────────────────────── # OpenAI 兼容工具调用客户端 # ───────────────────────────────────────────────────────────────────────────── class OpenAICompatChat: """ToolLoop.chat_fn 的 OpenAI 兼容实现(支持 tools 参数)。""" def __init__( self, base_url: str, api_key: Optional[str], model: str, temperature: float = 0.3, max_tokens: int = 4096, timeout_s: float = 120.0, transport: Any = None, _client: Any = None, ): self.base_url = base_url.rstrip("/") self.api_key = api_key self.model = model self.temperature = temperature self.max_tokens = max_tokens self.timeout_s = timeout_s self._transport = transport self._client = _client self._owns = _client is None def _get_client(self): if self._client is None: import httpx kwargs: Dict[str, Any] = {"timeout": self.timeout_s} if self._transport is not None: kwargs["transport"] = self._transport self._client = httpx.AsyncClient(**kwargs) return self._client async def aclose(self) -> None: if self._owns and self._client is not None: await self._client.aclose() self._client = None async def __call__(self, messages: List[Dict[str, Any]], tools_spec: List[Dict[str, Any]]) -> Dict[str, Any]: body: Dict[str, Any] = { "model": self.model, "messages": messages, "temperature": self.temperature, "max_tokens": self.max_tokens, } if tools_spec: body["tools"] = tools_spec body["tool_choice"] = "auto" headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} client = self._get_client() resp = await client.post(f"{self.base_url}/chat/completions", headers=headers, json=body) resp.raise_for_status() data = resp.json() msg = (data.get("choices") or [{}])[0].get("message") or {} # tool_calls 解析放这里(网关层),内核 tools.parse_tool_calls 供其他调用方复用 from router_system.tools import parse_tool_calls return { "content": msg.get("content"), "tool_calls": parse_tool_calls(msg), "usage": data.get("usage") or {}, } # ───────────────────────────────────────────────────────────────────────────── # 智能体服务 # ───────────────────────────────────────────────────────────────────────────── @dataclass class AgentRunInfo: """一次智能体运行的状态快照(内存 + status.json 双写)。""" request_id: str task: str = "" model: str = "" state: str = STATE_RUNNING started_at: float = 0.0 finished_at: float = 0.0 error: Optional[str] = None response: str = "" rounds: int = 0 prompt_tokens: int = 0 completion_tokens: int = 0 pool_id: str = "" workspace: str = "" # 本次运行使用的工作区根目录(绝对路径) executor_model: str = "" # 两级模式:执行者模型名(空 = 单模型模式) mode: str = "single" # single | dual tool_calls: int = 0 # 本次运行的工具调用步数 asyncio_task: Optional[asyncio.Task] = field(default=None, repr=False) def to_dict(self) -> Dict[str, Any]: return { "request_id": self.request_id, "task": self.task, "model": self.model, "state": self.state, "started_at": self.started_at, "finished_at": self.finished_at, "error": self.error, "response": self.response, "rounds": self.rounds, "prompt_tokens": self.prompt_tokens, "completion_tokens": self.completion_tokens, "pool_id": self.pool_id, "workspace": self.workspace, "executor_model": self.executor_model, "mode": self.mode, "tool_calls": self.tool_calls, } class AgentService: """智能体运行服务:事件落盘 + 状态管理。""" def __init__(self, run_dir: str | Path = AGENT_RUNS_DIR): self.run_dir = Path(run_dir) self._runs: Dict[str, AgentRunInfo] = {} self.max_running = 5 # ---------- 路径 ---------- def _dir(self, request_id: str) -> Path: return self.run_dir / request_id def events_path(self, request_id: str) -> Path: return self._dir(request_id) / "events.jsonl" def status_path(self, request_id: str) -> Path: return self._dir(request_id) / "status.json" # ---------- 注册与查询 ---------- def register(self, request_id: str, task: str, model: str, pool_id: str, workspace: str = "", executor_model: str = "", mode: str = "single") -> Optional[AgentRunInfo]: running = [r for r in self._runs.values() if r.state == STATE_RUNNING] if len(running) >= self.max_running: return None info = AgentRunInfo(request_id=request_id, task=task, model=model, pool_id=pool_id, workspace=workspace, executor_model=executor_model, mode=mode, started_at=time.time()) self._runs[request_id] = info self._dir(request_id).mkdir(parents=True, exist_ok=True) self._write_status(info) return info def get(self, request_id: str) -> Optional[AgentRunInfo]: return self._runs.get(request_id) # ---------- 执行 ---------- async def run(self, info: AgentRunInfo, chat: Any, workspace_dir: str | Path, max_rounds: int = 8, token_cap: int = 0, allow_shell: bool = False, shell_timeout_s: int = 20, executor_chat: Any = None, max_handoffs: int = DEFAULT_MAX_HANDOFFS, session: Optional["AgentSession"] = None) -> None: """执行智能体任务(由调用方包成后台协程)。 executor_chat 为空 = 单模型模式(chat 全程包办); 提供时进入两级模式:chat 作规划者,executor_chat 作执行者(D7)。 session 提供时:既往轮次作为对话上下文,完成后把本轮追加进会话。 """ history = self._history_from_session(session) try: if executor_chat is not None: result = await self.run_dual( info, chat, executor_chat, workspace_dir, max_rounds=max_rounds, token_cap=token_cap, allow_shell=allow_shell, shell_timeout_s=shell_timeout_s, max_handoffs=max_handoffs) else: tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell, shell_timeout_s=shell_timeout_s) loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap, on_event=self._make_event_writer(info)) result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT, history=history) self._apply_result(info, result) except Exception as exc: # pragma: no cover info.state = STATE_FAILED info.error = f"{type(exc).__name__}: {exc}" self._append_event(info, {"type": "final", "round": info.rounds, "reason": "error", "error": info.error}) finally: info.finished_at = time.time() self._write_status(info) if session is not None: session.data["turns"].append({ "request_id": info.request_id, "task": info.task, "response": info.response, "state": info.state, "tool_calls": info.tool_calls, "tokens": info.prompt_tokens + info.completion_tokens, "error": info.error, "ts": info.finished_at, }) get_session_store().save(session) @staticmethod def _history_from_session(session: Optional["AgentSession"], max_turns: int = 6, max_chars: int = 1500) -> List[Dict[str, Any]]: """把会话既往轮次折叠成对话上下文(不含工具细节)。""" if session is None: return [] turns = [t for t in session.data.get("turns", []) if t.get("state") == STATE_DONE and t.get("response")] out: List[Dict[str, Any]] = [] for t in turns[-max_turns:]: out.append({"role": "user", "content": str(t["task"])[:max_chars]}) out.append({"role": "assistant", "content": str(t["response"])[:max_chars]}) return out def _apply_result(self, info: AgentRunInfo, result: Dict[str, Any]) -> None: """把循环结果落到运行状态(单/两级模式共用)。""" info.response = result.get("response", "") info.rounds = int(result.get("rounds", 0)) info.prompt_tokens = int(result.get("prompt_tokens", 0)) info.completion_tokens = int(result.get("completion_tokens", 0)) if result.get("reason") == "error": info.state = STATE_FAILED info.error = result.get("error") elif result.get("reason") in ("token_cap", "max_rounds", "max_handoffs"): # 触顶属于护栏行为:结果仍交付,但标记部分完成信息 info.state = STATE_DONE info.error = result.get("error") else: info.state = STATE_DONE # ---------- 两级模式(D7):规划者 + 执行者 ---------- async def run_dual(self, info: AgentRunInfo, planner_chat: Any, executor_chat: Any, workspace_dir: str | Path, max_rounds: int = 8, token_cap: int = 0, allow_shell: bool = False, shell_timeout_s: int = 20, max_handoffs: int = DEFAULT_MAX_HANDOFFS) -> Dict[str, Any]: """大模型拆解/审查 + 小模型执行工具轮,交接状态写 handoff.json(智能体版交流文本)。""" info.mode = "dual" tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell, shell_timeout_s=shell_timeout_s) handoff: Dict[str, Any] = { "task": info.task, "planner_model": info.model, "executor_model": info.executor_model, "workspace": info.workspace, "instructions": "", "acceptance": "", "exchanges": [], } spent = {"in": 0, "out": 0} total_rounds = 0 def _account(usage: Dict[str, Any] | None) -> None: spent["in"] += int((usage or {}).get("prompt_tokens", 0)) spent["out"] += int((usage or {}).get("completion_tokens", 0)) def _save_handoff() -> None: try: (self._dir(info.request_id) / "handoff.json").write_text( json.dumps(handoff, ensure_ascii=False, indent=2), encoding="utf-8") except OSError: pass def _remaining_cap() -> int: return (token_cap - spent["in"] - spent["out"]) if token_cap else 1 async def _planner_json(user_msg: str) -> Dict[str, Any]: """调规划者并解析 JSON;解析失败回喂重试一次,再失败降级为 {}(禁止带病继续的软版本)。""" messages = [{"role": "system", "content": PLANNER_SYSTEM_PROMPT}, {"role": "user", "content": user_msg}] content = "" for attempt in (1, 2): resp = await planner_chat(messages, []) _account(resp.get("usage")) content = resp.get("content") or "" obj = _parse_json_loose(content) if obj: break if attempt == 1: messages += [{"role": "assistant", "content": content}, {"role": "user", "content": "你的输出不是合法 JSON。请重新只输出合法 JSON 对象。"}] self._append_event(info, {"type": "message", "role": "planner", "content": content[:2000]}) return obj try: # ---- 阶段 1:规划(大模型拆解为执行者指令) ---- self._append_event(info, {"type": "phase", "phase": "plan", "model": info.model}) plan = await _planner_json( f"用户任务:{info.task}\n\n" "请产出给执行者的指令,仅输出符合如下结构的 JSON:\n" + json.dumps(_PLAN_SCHEMA_HINT, ensure_ascii=False)) instructions = (plan.get("instructions") or info.task).strip() handoff["instructions"] = instructions handoff["acceptance"] = str(plan.get("acceptance", "")) _save_handoff() final_text = "" reason = "answer" error = None exec_rounds_total = 0 # ---- 阶段 2/3:执行 <-> 审查(有界交接) ---- for h in range(1, max_handoffs + 1): # 执行(本地小模型跑工具轮) self._append_event(info, {"type": "phase", "phase": "execute", "handoff": h, "model": info.executor_model}) loop = ToolLoop(tools, executor_chat, max_rounds=max_rounds, token_cap=max(1, _remaining_cap()), on_event=self._make_event_writer(info), emit_final=False) exec_result = await loop.run(instructions, system=EXECUTOR_SYSTEM_PROMPT) _account({"prompt_tokens": exec_result.get("prompt_tokens", 0), "completion_tokens": exec_result.get("completion_tokens", 0)}) exec_rounds_total += int(exec_result.get("rounds", 0)) report = exec_result.get("response", "") # 执行者汇报作为消息事件透出(前端可读) self._append_event(info, {"type": "message", "role": "executor", "handoff": h, "content": (report or "")[:4000]}) if exec_result.get("reason") == "error": reason, error = "error", exec_result.get("error") final_text = report break # 审查(大模型裁决) self._append_event(info, {"type": "phase", "phase": "review", "handoff": h, "model": info.model}) review = await _planner_json( f"用户任务:{info.task}\n你之前给出的指令:{instructions}\n" f"验收标准:{handoff['acceptance'] or '(未明确)'}\n\n" f"执行者第 {h} 轮汇报:\n{report[:4000]}\n\n" "请审查是否已按验收标准完成,仅输出符合如下结构的 JSON:\n" + json.dumps(_REVIEW_SCHEMA_HINT, ensure_ascii=False)) verdict = str(review.get("verdict", "done")).lower() handoff["exchanges"].append({ "handoff": h, "executor_report": report, "verdict": verdict, "reply_to_executor": str(review.get("reply_to_executor", "")), }) _save_handoff() if verdict == "done": final_text = str(review.get("final_answer") or report) break # redo:裁决意见作为下一轮执行者指令(带上一轮上下文) instructions = str(review.get("reply_to_executor") or instructions) if h == max_handoffs: reason = "max_handoffs" error = f"交接轮数达上限({max_handoffs}),以执行者汇报收尾" final_text = report else: final_text = final_text or "" self._append_event(info, {"type": "final", "round": total_rounds + exec_rounds_total, "reason": reason, "error": error}) return {"response": final_text, "rounds": total_rounds + exec_rounds_total, "reason": reason, "error": error, "prompt_tokens": spent["in"], "completion_tokens": spent["out"]} except Exception as exc: reason = "error" error = f"{type(exc).__name__}: {exc}" self._append_event(info, {"type": "final", "round": total_rounds, "reason": reason, "error": error}) return {"response": "", "rounds": total_rounds, "reason": reason, "error": error, "prompt_tokens": spent["in"], "completion_tokens": spent["out"]} # ---------- 事件 ---------- def _make_event_writer(self, info: AgentRunInfo): def _on_event(ev: Dict[str, Any]) -> None: if ev.get("type") == "tool_call": info.tool_calls += 1 # 工具步数统计(单/两级模式统一在此) self._append_event(info, ev) return _on_event def _append_event(self, info: AgentRunInfo, ev: Dict[str, Any]) -> None: ev = {"ts": time.time(), **ev} try: with self.events_path(info.request_id).open("a", encoding="utf-8") as f: f.write(json.dumps(ev, ensure_ascii=False) + "\n") except OSError: pass def read_events(self, request_id: str) -> List[Dict[str, Any]]: p = self.events_path(request_id) if not p.exists(): return [] out = [] for line in p.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue try: out.append(json.loads(line)) except json.JSONDecodeError: pass # 半行(正在写入)忽略 return out # ---------- 状态 ---------- def _write_status(self, info: AgentRunInfo) -> None: try: self.status_path(info.request_id).write_text( json.dumps(info.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8") except OSError: pass async def watch_events(self, request_id: str, cancel_event: asyncio.Event, poll_interval: float = 0.3, max_seconds: float = 900.0): """SSE 生成器:增量推送 events.jsonl 新行,直到终态/取消/超时。 从文件头开始回放(晚加入的订阅者也能看到完整过程)。 """ p = self.events_path(request_id) offset = 0 deadline = time.time() + max_seconds while not cancel_event.is_set() and time.time() < deadline: if p.exists(): try: size = p.stat().st_size if size > offset: with p.open("r", encoding="utf-8") as f: f.seek(offset) new_text = f.read() offset = f.tell() for line in new_text.splitlines(): line = line.strip() if not line: continue try: ev = json.loads(line) except json.JSONDecodeError: continue yield ev if ev.get("type") == "final": return except OSError: pass info = self.get(request_id) if info and info.state in (STATE_DONE, STATE_FAILED): # 终态兜底:状态已结束但可能没有 final 事件(如注册即失败) yield {"type": "final", "round": info.rounds, "reason": "answer" if info.state == STATE_DONE else "error", "error": info.error} return await asyncio.sleep(poll_interval) yield {"type": "final", "round": 0, "reason": "error", "error": "订阅超时"} # ---------- 全局单例 ---------- _service: Optional[AgentService] = None def get_agent_service() -> AgentService: global _service if _service is None: _service = AgentService() return _service def reset_agent_service() -> None: """测试用:重置全局智能体服务单例。""" global _service _service = None def new_request_id() -> str: return "ag" + uuid.uuid4().hex[:10] # ───────────────────────────────────────────────────────────────────────────── # 会话(dsh 式:工作区内多轮对话,持久化到磁盘) # ───────────────────────────────────────────────────────────────────────────── SESSIONS_DIR = Path("agent_runs") / "sessions" class AgentSession: """一个智能体会话:多轮任务 + 配置快照(磁盘持久化)。""" def __init__(self, data: Dict[str, Any]): self.data = data @classmethod def new(cls, sid: str, title: str, workspace: str, pool_id: str = "", executor_pool_id: str = "") -> "AgentSession": now = time.time() return cls({ "id": sid, "title": title[:24] or "新会话", "workspace": workspace, "pool_id": pool_id, "executor_pool_id": executor_pool_id, "created_at": now, "updated_at": now, "busy": False, "turns": [], # [{request_id, task, response, state, tool_calls, tokens}] }) def to_dict(self) -> Dict[str, Any]: return dict(self.data) def view(self, include_turns: bool = True) -> Dict[str, Any]: out = self.to_dict() if not include_turns: out["turns"] = len(self.data.get("turns", [])) return out class SessionStore: """会话注册表(内存索引 + sessions/{sid}.json 持久化)。""" def __init__(self, root: Path = SESSIONS_DIR): self.root = Path(root) self.root.mkdir(parents=True, exist_ok=True) self._cache: Dict[str, AgentSession] = {} def _path(self, sid: str) -> Path: return self.root / f"{sid}.json" def create(self, title: str, workspace: str, pool_id: str = "", executor_pool_id: str = "") -> AgentSession: sid = "as" + uuid.uuid4().hex[:10] sess = AgentSession.new(sid, title or "新会话", workspace, pool_id, executor_pool_id) self._cache[sid] = sess self._save(sess) return sess def get(self, sid: str) -> Optional[AgentSession]: if sid in self._cache: return self._cache[sid] p = self._path(sid) if not p.exists(): return None try: sess = AgentSession(json.loads(p.read_text(encoding="utf-8"))) self._cache[sid] = sess return sess except (json.JSONDecodeError, OSError): return None def list(self) -> List[Dict[str, Any]]: out = [] for p in sorted(self.root.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True): try: out.append(json.loads(p.read_text(encoding="utf-8"))) except (json.JSONDecodeError, OSError): continue return out def delete(self, sid: str) -> bool: self._cache.pop(sid, None) p = self._path(sid) if p.exists(): p.unlink() return True return False def save(self, sess: AgentSession) -> None: self._cache[sess.data["id"]] = sess self._save(sess) def _save(self, sess: AgentSession) -> None: sess.data["updated_at"] = time.time() try: self._path(sess.data["id"]).write_text( json.dumps(sess.data, ensure_ascii=False, indent=2), encoding="utf-8") except OSError: pass _session_store: Optional[SessionStore] = None def get_session_store() -> SessionStore: global _session_store if _session_store is None: _session_store = SessionStore() return _session_store def reset_session_store() -> None: """测试用。""" global _session_store _session_store = None