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
This commit is contained in:
tzt
2026-09-01 22:30:26 +08:00
parent 943eecc5ef
commit 7d11ae2644
20 changed files with 973 additions and 473 deletions
+150 -2
View File
@@ -167,6 +167,7 @@ class AgentRunInfo:
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]:
@@ -186,6 +187,7 @@ class AgentRunInfo:
"workspace": self.workspace,
"executor_model": self.executor_model,
"mode": self.mode,
"tool_calls": self.tool_calls,
}
@@ -231,12 +233,16 @@ class AgentService:
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) -> 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(
@@ -249,7 +255,8 @@ class AgentService:
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)
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
@@ -259,6 +266,33 @@ class AgentService:
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:
"""把循环结果落到运行状态(单/两级模式共用)。"""
@@ -413,6 +447,8 @@ class AgentService:
# ---------- 事件 ----------
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
@@ -509,3 +545,115 @@ def reset_agent_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