Files
projectAIpopular/gateway/agent.py
T
tzt 7d11ae2644 feat(v3): T27 会话式智能体(多轮上下文/停止/对话式 UI,对齐 dsh 范式)
- ToolLoop 增 history 参数(既往轮次折叠为对话上下文,近 6 轮)
- 会话制:AgentSession/SessionStore 持久化 agent_runs/sessions/{sid}.json,
  /agent/sessions CRUD + /agent 带 session_id(继承会话工作区与执行者配置,busy 并发控制)
- POST /agent/{id}/cancel:取消运行中任务
- AgentView 重构为对话式:会话列表 + 居中消息流(用户蓝气泡/助手白卡)+ 底部 composer
  (工作区/执行者/命令 chips,Enter 发送,运行中红色停止钮),工具过程折叠收纳
- 工具步数统计统一至事件写入层并透出 status.tool_calls
- fix(tests): 会话存储测试隔离,防测试数据泄漏进真实 sessions 目录
- 测试 +4,全量 281 passed
2026-09-01 22:30:26 +08:00

660 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""智能体服务(AgentService)—— zcode 式"模型操作工作区文件"的网关侧封装。
职责:
- OpenAICompatChatOpenAI 兼容 /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