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:
+150
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user