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 = "" # 本次运行使用的工作区根目录(绝对路径)
|
workspace: str = "" # 本次运行使用的工作区根目录(绝对路径)
|
||||||
executor_model: str = "" # 两级模式:执行者模型名(空 = 单模型模式)
|
executor_model: str = "" # 两级模式:执行者模型名(空 = 单模型模式)
|
||||||
mode: str = "single" # single | dual
|
mode: str = "single" # single | dual
|
||||||
|
tool_calls: int = 0 # 本次运行的工具调用步数
|
||||||
asyncio_task: Optional[asyncio.Task] = field(default=None, repr=False)
|
asyncio_task: Optional[asyncio.Task] = field(default=None, repr=False)
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
@@ -186,6 +187,7 @@ class AgentRunInfo:
|
|||||||
"workspace": self.workspace,
|
"workspace": self.workspace,
|
||||||
"executor_model": self.executor_model,
|
"executor_model": self.executor_model,
|
||||||
"mode": self.mode,
|
"mode": self.mode,
|
||||||
|
"tool_calls": self.tool_calls,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -231,12 +233,16 @@ class AgentService:
|
|||||||
max_rounds: int = 8, token_cap: int = 0,
|
max_rounds: int = 8, token_cap: int = 0,
|
||||||
allow_shell: bool = False, shell_timeout_s: int = 20,
|
allow_shell: bool = False, shell_timeout_s: int = 20,
|
||||||
executor_chat: Any = None,
|
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 全程包办);
|
executor_chat 为空 = 单模型模式(chat 全程包办);
|
||||||
提供时进入两级模式:chat 作规划者,executor_chat 作执行者(D7)。
|
提供时进入两级模式:chat 作规划者,executor_chat 作执行者(D7)。
|
||||||
|
session 提供时:既往轮次作为对话上下文,完成后把本轮追加进会话。
|
||||||
"""
|
"""
|
||||||
|
history = self._history_from_session(session)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if executor_chat is not None:
|
if executor_chat is not None:
|
||||||
result = await self.run_dual(
|
result = await self.run_dual(
|
||||||
@@ -249,7 +255,8 @@ class AgentService:
|
|||||||
shell_timeout_s=shell_timeout_s)
|
shell_timeout_s=shell_timeout_s)
|
||||||
loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap,
|
loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap,
|
||||||
on_event=self._make_event_writer(info))
|
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)
|
self._apply_result(info, result)
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
info.state = STATE_FAILED
|
info.state = STATE_FAILED
|
||||||
@@ -259,6 +266,33 @@ class AgentService:
|
|||||||
finally:
|
finally:
|
||||||
info.finished_at = time.time()
|
info.finished_at = time.time()
|
||||||
self._write_status(info)
|
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:
|
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 _make_event_writer(self, info: AgentRunInfo):
|
||||||
def _on_event(ev: Dict[str, Any]) -> None:
|
def _on_event(ev: Dict[str, Any]) -> None:
|
||||||
|
if ev.get("type") == "tool_call":
|
||||||
|
info.tool_calls += 1 # 工具步数统计(单/两级模式统一在此)
|
||||||
self._append_event(info, ev)
|
self._append_event(info, ev)
|
||||||
return _on_event
|
return _on_event
|
||||||
|
|
||||||
@@ -509,3 +545,115 @@ def reset_agent_service() -> None:
|
|||||||
|
|
||||||
def new_request_id() -> str:
|
def new_request_id() -> str:
|
||||||
return "ag" + uuid.uuid4().hex[:10]
|
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
|
||||||
|
|||||||
+83
-6
@@ -501,12 +501,13 @@ try:
|
|||||||
|
|
||||||
@app.post("/agent", tags=["agent"])
|
@app.post("/agent", tags=["agent"])
|
||||||
async def agent_run(req: dict):
|
async def agent_run(req: dict):
|
||||||
"""提交智能体任务:{"task", "pool_id"?, "workspace"?}。
|
"""提交智能体任务:{"task", "pool_id"?, "workspace"?, "executor_pool_id"?, "session_id"?}。
|
||||||
|
|
||||||
workspace 为用户选择的工作目录(绝对路径);缺省用设置里的 agent.workspace_dir。
|
workspace 为用户选择的工作目录;缺省继承会话目录,再缺省用设置默认值。
|
||||||
|
session_id 提供时任务在会话内执行(多轮上下文 + 轮次记录)。
|
||||||
立即返回 request_id;过程事件经 GET /agent/{id}/stream (SSE) 推送。
|
立即返回 request_id;过程事件经 GET /agent/{id}/stream (SSE) 推送。
|
||||||
"""
|
"""
|
||||||
from gateway.agent import get_agent_service, new_request_id
|
from gateway.agent import get_agent_service, get_session_store, new_request_id
|
||||||
from router_system.tools import WorkspaceTools
|
from router_system.tools import WorkspaceTools
|
||||||
|
|
||||||
task = str((req or {}).get("task") or "").strip()
|
task = str((req or {}).get("task") or "").strip()
|
||||||
@@ -518,7 +519,23 @@ try:
|
|||||||
|
|
||||||
s = settings_store().to_dict()
|
s = settings_store().to_dict()
|
||||||
agent_cfg = s.get("agent", {})
|
agent_cfg = s.get("agent", {})
|
||||||
|
|
||||||
|
# 会话(可选):须存在且空闲;工作区缺省继承会话目录
|
||||||
|
session = None
|
||||||
|
session_id = str((req or {}).get("session_id") or "").strip()
|
||||||
|
if session_id:
|
||||||
|
session = get_session_store().get(session_id)
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"会话不存在: {session_id}")
|
||||||
|
if session.data.get("busy"):
|
||||||
|
raise HTTPException(status_code=409, detail="该会话有任务正在运行,请稍候")
|
||||||
|
# 会话级配置继承(创建时指定,后续轮次沿用)
|
||||||
|
if not pool_id:
|
||||||
|
pool_id = str(session.data.get("pool_id") or "")
|
||||||
|
|
||||||
ws_raw = str((req or {}).get("workspace") or "").strip()
|
ws_raw = str((req or {}).get("workspace") or "").strip()
|
||||||
|
if not ws_raw and session is not None:
|
||||||
|
ws_raw = str(session.data.get("workspace") or "")
|
||||||
if ws_raw:
|
if ws_raw:
|
||||||
ws_path = Path(ws_raw)
|
ws_path = Path(ws_raw)
|
||||||
if not ws_path.exists():
|
if not ws_path.exists():
|
||||||
@@ -533,6 +550,8 @@ try:
|
|||||||
|
|
||||||
# 两级模式(D7):显式指定执行者(本地小模型)时,规划=chat、执行=executor_chat
|
# 两级模式(D7):显式指定执行者(本地小模型)时,规划=chat、执行=executor_chat
|
||||||
executor_pool_id = str((req or {}).get("executor_pool_id") or "").strip()
|
executor_pool_id = str((req or {}).get("executor_pool_id") or "").strip()
|
||||||
|
if not executor_pool_id and session is not None:
|
||||||
|
executor_pool_id = str(session.data.get("executor_pool_id") or "")
|
||||||
executor_chat = None
|
executor_chat = None
|
||||||
executor_model = ""
|
executor_model = ""
|
||||||
if executor_pool_id:
|
if executor_pool_id:
|
||||||
@@ -565,8 +584,11 @@ try:
|
|||||||
if info is None:
|
if info is None:
|
||||||
raise HTTPException(status_code=503, detail="智能体同时运行任务已达上限")
|
raise HTTPException(status_code=503, detail="智能体同时运行任务已达上限")
|
||||||
|
|
||||||
s = settings_store().to_dict()
|
if session is not None:
|
||||||
agent_cfg = s.get("agent", {})
|
session.data["busy"] = True
|
||||||
|
if not session.data.get("workspace"):
|
||||||
|
session.data["workspace"] = workspace_dir
|
||||||
|
get_session_store().save(session)
|
||||||
|
|
||||||
async def _run():
|
async def _run():
|
||||||
try:
|
try:
|
||||||
@@ -579,6 +601,7 @@ try:
|
|||||||
shell_timeout_s=int(agent_cfg.get("shell_timeout_s", 20)),
|
shell_timeout_s=int(agent_cfg.get("shell_timeout_s", 20)),
|
||||||
executor_chat=executor_chat,
|
executor_chat=executor_chat,
|
||||||
max_handoffs=int(agent_cfg.get("max_handoffs", 2)),
|
max_handoffs=int(agent_cfg.get("max_handoffs", 2)),
|
||||||
|
session=session,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
import traceback
|
import traceback
|
||||||
@@ -587,11 +610,65 @@ try:
|
|||||||
info.error = str(exc)
|
info.error = str(exc)
|
||||||
info.finished_at = __import__("time").time()
|
info.finished_at = __import__("time").time()
|
||||||
service._write_status(info)
|
service._write_status(info)
|
||||||
|
finally:
|
||||||
|
if session is not None:
|
||||||
|
session.data["busy"] = False
|
||||||
|
get_session_store().save(session)
|
||||||
|
|
||||||
info.asyncio_task = asyncio.create_task(_run())
|
info.asyncio_task = asyncio.create_task(_run())
|
||||||
return {"request_id": request_id, "status": "running", "model": model,
|
return {"request_id": request_id, "status": "running", "model": model,
|
||||||
"workspace": workspace_dir, "mode": mode,
|
"workspace": workspace_dir, "mode": mode,
|
||||||
"executor_model": executor_model}
|
"executor_model": executor_model, "session_id": session_id or None}
|
||||||
|
|
||||||
|
@app.post("/agent/{request_id}/cancel", tags=["agent"])
|
||||||
|
async def agent_cancel(request_id: str):
|
||||||
|
"""停止运行中的智能体任务。"""
|
||||||
|
from gateway.agent import get_agent_service
|
||||||
|
service = get_agent_service()
|
||||||
|
info = service.get(request_id)
|
||||||
|
if info is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"智能体任务不存在: {request_id}")
|
||||||
|
if info.state != "running":
|
||||||
|
return {"ok": False, "detail": f"任务已结束({info.state})"}
|
||||||
|
if info.asyncio_task is not None:
|
||||||
|
info.asyncio_task.cancel()
|
||||||
|
info.state = "failed"
|
||||||
|
info.error = "cancelled_by_user"
|
||||||
|
info.finished_at = __import__("time").time()
|
||||||
|
service._write_status(info)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ---------------- 会话(dsh 式多轮对话) ----------------
|
||||||
|
@app.post("/agent/sessions", tags=["agent"])
|
||||||
|
async def agent_session_create(req: dict = None):
|
||||||
|
"""创建会话:{"title"?, "workspace"?, "pool_id"?, "executor_pool_id"?}"""
|
||||||
|
from gateway.agent import get_session_store
|
||||||
|
r = req or {}
|
||||||
|
sess = get_session_store().create(
|
||||||
|
title=str(r.get("title") or "").strip(),
|
||||||
|
workspace=str(r.get("workspace") or "").strip(),
|
||||||
|
pool_id=str(r.get("pool_id") or ""),
|
||||||
|
executor_pool_id=str(r.get("executor_pool_id") or ""))
|
||||||
|
return sess.view()
|
||||||
|
|
||||||
|
@app.get("/agent/sessions", tags=["agent"])
|
||||||
|
async def agent_sessions():
|
||||||
|
"""会话列表(按更新时间倒序)。"""
|
||||||
|
from gateway.agent import get_session_store
|
||||||
|
return get_session_store().list()
|
||||||
|
|
||||||
|
@app.get("/agent/sessions/{sid}", tags=["agent"])
|
||||||
|
async def agent_session_detail(sid: str):
|
||||||
|
from gateway.agent import get_session_store
|
||||||
|
sess = get_session_store().get(sid)
|
||||||
|
if sess is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"会话不存在: {sid}")
|
||||||
|
return sess.view()
|
||||||
|
|
||||||
|
@app.delete("/agent/sessions/{sid}", tags=["agent"])
|
||||||
|
async def agent_session_delete(sid: str):
|
||||||
|
from gateway.agent import get_session_store
|
||||||
|
return {"ok": get_session_store().delete(sid)}
|
||||||
|
|
||||||
@app.get("/agent/fs", tags=["agent"])
|
@app.get("/agent/fs", tags=["agent"])
|
||||||
async def agent_fs_browse(path: str = ""):
|
async def agent_fs_browse(path: str = ""):
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{A as e,D as t,G as n,I as r,L as i,N as a,O as o,P as s,V as c,W as l,j as u,k as d,s as f,t as p}from"./index-8a-EFu_T.js";var m={class:`metrics-view`},h={key:0,class:`loading`},g={key:1,class:`error`},_={class:`card-grid`},v={class:`metric-card`},y={class:`kv-list`},b={class:`metric-card`},x={class:`kv-list`},S={key:0,class:`metric-card highlight`},C={class:`kv-list`},w={key:0},T={key:1},E={key:1,class:`metric-card`},D={class:`by-model`},O={class:`mono`},k={key:2,class:`metric-card review-card`},A={class:`review-stats`},j={class:`stat-item`},M={class:`stat-num`},N={class:`stat-item`},P={class:`stat-num`},F={key:0,class:`progress-wrap`},I={class:`review-rate`},L={class:`raw-json`},R=p(a({__name:`MetricsView`,setup(a){let p=c(null),R=c(!1),z=c(``),B=o(()=>p.value?.v2?.by_model||null);async function V(){R.value=!0,z.value=``;try{p.value=await f()}catch(e){z.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{R.value=!1}}return s(V),(a,o)=>(r(),u(`div`,m,[d(`header`,{class:`metrics-header`},[o[0]||=d(`h2`,null,`系统指标`,-1),d(`button`,{class:`refresh`,onClick:V},`🔄 刷新`)]),R.value?(r(),u(`div`,h,`加载中…`)):z.value?(r(),u(`div`,g,n(z.value),1)):p.value?(r(),u(t,{key:2},[d(`div`,_,[d(`div`,v,[o[1]||=d(`h3`,null,`路由器(v1)`,-1),d(`div`,y,[(r(!0),u(t,null,i(p.value.router,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),d(`div`,b,[o[2]||=d(`h3`,null,`缓存`,-1),d(`div`,x,[(r(!0),u(t,null,i(p.value.cache,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),p.value.v2?(r(),u(`div`,S,[o[3]||=d(`h3`,null,`协作管线(v2)`,-1),d(`div`,C,[(r(!0),u(t,null,i(p.value.v2,(i,a)=>(r(),u(t,{key:a},[a===`by_model`?e(``,!0):(r(),u(`span`,w,n(a),1)),a===`by_model`?e(``,!0):(r(),u(`b`,T,n(i),1))],64))),128))])])):e(``,!0),B.value&&Object.keys(B.value).length?(r(),u(`div`,E,[o[5]||=d(`h3`,null,`按模型分账(token / 成本)`,-1),d(`table`,D,[o[4]||=d(`thead`,null,[d(`tr`,null,[d(`th`,null,`模型`),d(`th`,null,`次数`),d(`th`,null,`入`),d(`th`,null,`出`),d(`th`,null,`成本 $`)])],-1),d(`tbody`,null,[(r(!0),u(t,null,i(B.value,(e,t)=>(r(),u(`tr`,{key:t},[d(`td`,O,n(t),1),d(`td`,null,n(e.requests),1),d(`td`,null,n(e.input_tokens),1),d(`td`,null,n(e.output_tokens),1),d(`td`,null,n(e.cost_est_usd),1)]))),128))])]),o[6]||=d(`p`,{class:`hint`},`单价来自模型池条目($/1M tokens);经典设置下的模型成本不计入。`,-1)])):e(``,!0),p.value.review?(r(),u(`div`,k,[o[9]||=d(`h3`,null,`人工检验`,-1),d(`div`,A,[d(`div`,j,[d(`span`,M,n(p.value.review.pending),1),o[7]||=d(`span`,{class:`stat-label`},`待审核`,-1)]),d(`div`,N,[d(`span`,P,n(p.value.review.total),1),o[8]||=d(`span`,{class:`stat-label`},`总提交`,-1)])]),p.value.review.total>0?(r(),u(`div`,F,[d(`div`,{class:`reviewed-bar`,style:l({width:`${(p.value.review.total-p.value.review.pending)/p.value.review.total*100}%`})},null,4)])):e(``,!0),d(`p`,I,` 通过率: `+n(((p.value.review.total-p.value.review.pending)/p.value.review.total*100).toFixed(1))+`% `,1)])):e(``,!0)]),d(`details`,L,[o[10]||=d(`summary`,null,`原始 JSON`,-1),d(`pre`,null,n(JSON.stringify(p.value,null,2)),1)])],64)):e(``,!0)]))}}),[[`__scopeId`,`data-v-8b237097`]]);export{R as default};
|
import{A as e,D as t,G as n,I as r,L as i,N as a,O as o,P as s,V as c,W as l,j as u,k as d,s as f,t as p}from"./index-DdcvGqdd.js";var m={class:`metrics-view`},h={key:0,class:`loading`},g={key:1,class:`error`},_={class:`card-grid`},v={class:`metric-card`},y={class:`kv-list`},b={class:`metric-card`},x={class:`kv-list`},S={key:0,class:`metric-card highlight`},C={class:`kv-list`},w={key:0},T={key:1},E={key:1,class:`metric-card`},D={class:`by-model`},O={class:`mono`},k={key:2,class:`metric-card review-card`},A={class:`review-stats`},j={class:`stat-item`},M={class:`stat-num`},N={class:`stat-item`},P={class:`stat-num`},F={key:0,class:`progress-wrap`},I={class:`review-rate`},L={class:`raw-json`},R=p(a({__name:`MetricsView`,setup(a){let p=c(null),R=c(!1),z=c(``),B=o(()=>p.value?.v2?.by_model||null);async function V(){R.value=!0,z.value=``;try{p.value=await f()}catch(e){z.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{R.value=!1}}return s(V),(a,o)=>(r(),u(`div`,m,[d(`header`,{class:`metrics-header`},[o[0]||=d(`h2`,null,`系统指标`,-1),d(`button`,{class:`refresh`,onClick:V},`🔄 刷新`)]),R.value?(r(),u(`div`,h,`加载中…`)):z.value?(r(),u(`div`,g,n(z.value),1)):p.value?(r(),u(t,{key:2},[d(`div`,_,[d(`div`,v,[o[1]||=d(`h3`,null,`路由器(v1)`,-1),d(`div`,y,[(r(!0),u(t,null,i(p.value.router,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),d(`div`,b,[o[2]||=d(`h3`,null,`缓存`,-1),d(`div`,x,[(r(!0),u(t,null,i(p.value.cache,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),p.value.v2?(r(),u(`div`,S,[o[3]||=d(`h3`,null,`协作管线(v2)`,-1),d(`div`,C,[(r(!0),u(t,null,i(p.value.v2,(i,a)=>(r(),u(t,{key:a},[a===`by_model`?e(``,!0):(r(),u(`span`,w,n(a),1)),a===`by_model`?e(``,!0):(r(),u(`b`,T,n(i),1))],64))),128))])])):e(``,!0),B.value&&Object.keys(B.value).length?(r(),u(`div`,E,[o[5]||=d(`h3`,null,`按模型分账(token / 成本)`,-1),d(`table`,D,[o[4]||=d(`thead`,null,[d(`tr`,null,[d(`th`,null,`模型`),d(`th`,null,`次数`),d(`th`,null,`入`),d(`th`,null,`出`),d(`th`,null,`成本 $`)])],-1),d(`tbody`,null,[(r(!0),u(t,null,i(B.value,(e,t)=>(r(),u(`tr`,{key:t},[d(`td`,O,n(t),1),d(`td`,null,n(e.requests),1),d(`td`,null,n(e.input_tokens),1),d(`td`,null,n(e.output_tokens),1),d(`td`,null,n(e.cost_est_usd),1)]))),128))])]),o[6]||=d(`p`,{class:`hint`},`单价来自模型池条目($/1M tokens);经典设置下的模型成本不计入。`,-1)])):e(``,!0),p.value.review?(r(),u(`div`,k,[o[9]||=d(`h3`,null,`人工检验`,-1),d(`div`,A,[d(`div`,j,[d(`span`,M,n(p.value.review.pending),1),o[7]||=d(`span`,{class:`stat-label`},`待审核`,-1)]),d(`div`,N,[d(`span`,P,n(p.value.review.total),1),o[8]||=d(`span`,{class:`stat-label`},`总提交`,-1)])]),p.value.review.total>0?(r(),u(`div`,F,[d(`div`,{class:`reviewed-bar`,style:l({width:`${(p.value.review.total-p.value.review.pending)/p.value.review.total*100}%`})},null,4)])):e(``,!0),d(`p`,I,` 通过率: `+n(((p.value.review.total-p.value.review.pending)/p.value.review.total*100).toFixed(1))+`% `,1)])):e(``,!0)]),d(`details`,L,[o[10]||=d(`summary`,null,`原始 JSON`,-1),d(`pre`,null,n(JSON.stringify(p.value,null,2)),1)])],64)):e(``,!0)]))}}),[[`__scopeId`,`data-v-8b237097`]]);export{R as default};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{A as e,D as t,E as n,G as r,I as i,L as a,M as o,N as s,O as c,P as l,U as u,V as d,f,j as p,k as m,t as h,v as g,z as _}from"./index-8a-EFu_T.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=h(s({__name:`ReviewView`,setup(s){let h=d([]),L=d(!1),R=d(``),z=d(`pending`),B=d({}),V=c(()=>z.value===`all`?h.value:h.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{h.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await g(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return l(H),(s,c)=>(i(),p(`div`,v,[m(`header`,y,[c[4]||=m(`h2`,null,`人工检验队列`,-1),m(`div`,b,[m(`button`,{class:u({active:z.value===`all`}),onClick:c[0]||=e=>z.value=`all`},`全部`,2),m(`button`,{class:u({active:z.value===`pending`}),onClick:c[1]||=e=>z.value=`pending`},`待审核`,2),m(`button`,{class:u({active:z.value===`approved`}),onClick:c[2]||=e=>z.value=`approved`},`已通过`,2),m(`button`,{class:u({active:z.value===`rejected`}),onClick:c[3]||=e=>z.value=`rejected`},`已拒绝`,2),m(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(i(),p(`div`,x,`加载中…`)):R.value?(i(),p(`div`,S,r(R.value),1)):(i(),p(`div`,C,[V.value.length?e(``,!0):(i(),p(`div`,w,`队列为空。`)),(i(!0),p(t,null,a(V.value,s=>(i(),p(`div`,{key:s.id,class:`review-card`},[m(`div`,T,[m(`span`,E,`#`+r(s.id),1),m(`span`,{class:u([`verdict-badge`,s.verdict])},r(s.verdict),3),m(`span`,D,[(i(!0),p(t,null,a(s.tags,e=>(i(),p(`span`,{key:e,class:`tag`},r(e),1))),128))]),m(`span`,O,r(s.created_at),1)]),m(`div`,k,[c[5]||=m(`strong`,null,`Query:`,-1),o(r(s.query),1)]),m(`div`,A,[c[6]||=m(`strong`,null,`Response:`,-1),m(`pre`,null,r(s.response),1)]),s.verdict===`pending`?(i(),p(`div`,j,[_(m(`textarea`,{"onUpdate:modelValue":e=>B.value[s.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[n,B.value[s.id]]]),m(`div`,N,[m(`button`,{class:`approve`,onClick:e=>U(s.id,`approved`)},`✅ 通过`,8,P),m(`button`,{class:`reject`,onClick:e=>U(s.id,`rejected`)},`❌ 拒绝`,8,F)])])):s.correction?(i(),p(`div`,I,[c[7]||=m(`strong`,null,`修正:`,-1),o(r(s.correction),1)])):e(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-d5b38f1c`]]);export{L as default};
|
import{A as e,D as t,E as n,G as r,I as i,L as a,M as o,N as s,O as c,P as l,U as u,V as d,f,j as p,k as m,t as h,v as g,z as _}from"./index-DdcvGqdd.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=h(s({__name:`ReviewView`,setup(s){let h=d([]),L=d(!1),R=d(``),z=d(`pending`),B=d({}),V=c(()=>z.value===`all`?h.value:h.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{h.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await g(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return l(H),(s,c)=>(i(),p(`div`,v,[m(`header`,y,[c[4]||=m(`h2`,null,`人工检验队列`,-1),m(`div`,b,[m(`button`,{class:u({active:z.value===`all`}),onClick:c[0]||=e=>z.value=`all`},`全部`,2),m(`button`,{class:u({active:z.value===`pending`}),onClick:c[1]||=e=>z.value=`pending`},`待审核`,2),m(`button`,{class:u({active:z.value===`approved`}),onClick:c[2]||=e=>z.value=`approved`},`已通过`,2),m(`button`,{class:u({active:z.value===`rejected`}),onClick:c[3]||=e=>z.value=`rejected`},`已拒绝`,2),m(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(i(),p(`div`,x,`加载中…`)):R.value?(i(),p(`div`,S,r(R.value),1)):(i(),p(`div`,C,[V.value.length?e(``,!0):(i(),p(`div`,w,`队列为空。`)),(i(!0),p(t,null,a(V.value,s=>(i(),p(`div`,{key:s.id,class:`review-card`},[m(`div`,T,[m(`span`,E,`#`+r(s.id),1),m(`span`,{class:u([`verdict-badge`,s.verdict])},r(s.verdict),3),m(`span`,D,[(i(!0),p(t,null,a(s.tags,e=>(i(),p(`span`,{key:e,class:`tag`},r(e),1))),128))]),m(`span`,O,r(s.created_at),1)]),m(`div`,k,[c[5]||=m(`strong`,null,`Query:`,-1),o(r(s.query),1)]),m(`div`,A,[c[6]||=m(`strong`,null,`Response:`,-1),m(`pre`,null,r(s.response),1)]),s.verdict===`pending`?(i(),p(`div`,j,[_(m(`textarea`,{"onUpdate:modelValue":e=>B.value[s.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[n,B.value[s.id]]]),m(`div`,N,[m(`button`,{class:`approve`,onClick:e=>U(s.id,`approved`)},`✅ 通过`,8,P),m(`button`,{class:`reject`,onClick:e=>U(s.id,`rejected`)},`❌ 拒绝`,8,F)])])):s.correction?(i(),p(`div`,I,[c[7]||=m(`strong`,null,`修正:`,-1),o(r(s.correction),1)])):e(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-d5b38f1c`]]);export{L as default};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>端云协同 LLM 协作系统</title>
|
<title>端云协同 LLM 协作系统</title>
|
||||||
<script type="module" crossorigin src="/static/assets/index-8a-EFu_T.js"></script>
|
<script type="module" crossorigin src="/static/assets/index-DdcvGqdd.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/static/assets/index-DtUWLrmy.css">
|
<link rel="stylesheet" crossorigin href="/static/assets/index-CzrTgC9J.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -435,11 +435,17 @@ class ToolLoop:
|
|||||||
def _total_tokens(self, usage: Dict[str, int]) -> int:
|
def _total_tokens(self, usage: Dict[str, int]) -> int:
|
||||||
return int(usage.get("prompt_tokens", 0)) + int(usage.get("completion_tokens", 0))
|
return int(usage.get("prompt_tokens", 0)) + int(usage.get("completion_tokens", 0))
|
||||||
|
|
||||||
async def run(self, task: str, system: str = "") -> Dict[str, Any]:
|
async def run(self, task: str, system: str = "",
|
||||||
"""执行任务直到模型给出最终答复或触顶。返回最终结果与账目。"""
|
history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||||
|
"""执行任务直到模型给出最终答复或触顶。
|
||||||
|
|
||||||
|
history 为既往对话消息(user/assistant,不含工具细节),用于会话式多轮上下文。
|
||||||
|
"""
|
||||||
messages: List[Dict[str, Any]] = []
|
messages: List[Dict[str, Any]] = []
|
||||||
if system:
|
if system:
|
||||||
messages.append({"role": "system", "content": system})
|
messages.append({"role": "system", "content": system})
|
||||||
|
if history:
|
||||||
|
messages.extend(history)
|
||||||
messages.append({"role": "user", "content": task})
|
messages.append({"role": "user", "content": task})
|
||||||
|
|
||||||
total_in = 0
|
total_in = 0
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ def agent_env(tmp_path, monkeypatch):
|
|||||||
ag.reset_agent_service()
|
ag.reset_agent_service()
|
||||||
service = ag.AgentService(run_dir=tmp_path / "agent_runs")
|
service = ag.AgentService(run_dir=tmp_path / "agent_runs")
|
||||||
ag._service = service
|
ag._service = service
|
||||||
|
# 会话存储同样隔离(防止测试数据漏进真实 agent_runs/sessions/)
|
||||||
|
ag.reset_session_store()
|
||||||
|
ag._session_store = ag.SessionStore(root=tmp_path / "sessions")
|
||||||
# 快照用户真实设置,测试后原样恢复(settings.json 是活文件,不能污染)
|
# 快照用户真实设置,测试后原样恢复(settings.json 是活文件,不能污染)
|
||||||
store = ga.settings_store()
|
store = ga.settings_store()
|
||||||
snapshot = json.loads(json.dumps(store._data, ensure_ascii=False))
|
snapshot = json.loads(json.dumps(store._data, ensure_ascii=False))
|
||||||
@@ -51,6 +54,7 @@ def agent_env(tmp_path, monkeypatch):
|
|||||||
store.save()
|
store.save()
|
||||||
mp.reset_pool()
|
mp.reset_pool()
|
||||||
ag.reset_agent_service()
|
ag.reset_agent_service()
|
||||||
|
ag.reset_session_store()
|
||||||
ga.rebuild_pipeline()
|
ga.rebuild_pipeline()
|
||||||
|
|
||||||
|
|
||||||
@@ -403,3 +407,115 @@ def test_dual_agent_executor_error(agent_env, client, monkeypatch):
|
|||||||
info = _wait_done(agent_env["service"], rid)
|
info = _wait_done(agent_env["service"], rid)
|
||||||
assert info.state == "failed"
|
assert info.state == "failed"
|
||||||
assert "RuntimeError" in (info.error or "")
|
assert "RuntimeError" in (info.error or "")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 会话(T27):多轮 + 停止 ----------------
|
||||||
|
|
||||||
|
def test_session_multi_turn(agent_env, client, monkeypatch, tmp_path):
|
||||||
|
"""同一会话两轮任务:轮次记录 + 第二轮带上第一轮历史。"""
|
||||||
|
target = tmp_path / "sess_ws"
|
||||||
|
target.mkdir()
|
||||||
|
seen_messages = []
|
||||||
|
|
||||||
|
planner_script = [
|
||||||
|
_planner_resp({"instructions": "执行:创建 a.txt"}),
|
||||||
|
_planner_resp({"verdict": "done", "reply_to_executor": "",
|
||||||
|
"final_answer": "第一轮完成。"}),
|
||||||
|
_planner_resp({"instructions": "执行:创建 b.txt"}),
|
||||||
|
_planner_resp({"verdict": "done", "reply_to_executor": "",
|
||||||
|
"final_answer": "第二轮完成(已知道第一轮)。"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
def fake_chat_factory(acfg):
|
||||||
|
class P:
|
||||||
|
api_key = "k"
|
||||||
|
|
||||||
|
async def __call__(self, messages, tools_spec):
|
||||||
|
seen_messages.append([dict(m) for m in messages])
|
||||||
|
return planner_script.pop(0)
|
||||||
|
return P()
|
||||||
|
|
||||||
|
class Ex:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __call__(self, messages, tools_spec):
|
||||||
|
content = str(messages[-1]["content"])
|
||||||
|
fname = "a.txt" if "a.txt" in content else "b.txt"
|
||||||
|
return {"content": None,
|
||||||
|
"tool_calls": [{"id": "c", "name": "write_file",
|
||||||
|
"arguments": {"path": fname, "content": fname}}],
|
||||||
|
"usage": {"prompt_tokens": 5, "completion_tokens": 1}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
|
||||||
|
monkeypatch.setattr(ag, "OpenAICompatChat", Ex)
|
||||||
|
client.post("/pool", json={
|
||||||
|
"id": "local-x", "name": "本地小模型", "tier": "local",
|
||||||
|
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
|
||||||
|
"model": "qwen-0.8b", "enabled": True})
|
||||||
|
|
||||||
|
# 创建会话(绑工作区 + 执行者)
|
||||||
|
r = client.post("/agent/sessions",
|
||||||
|
json={"title": "演示会话", "workspace": str(target),
|
||||||
|
"executor_pool_id": "local-x"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
sid = r.json()["id"]
|
||||||
|
|
||||||
|
# 第一轮(两级模式:规划收到的 messages 不含历史)
|
||||||
|
r1 = client.post("/agent", json={"task": "创建 a.txt", "session_id": sid})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
info1 = _wait_done(agent_env["service"], r1.json()["request_id"])
|
||||||
|
assert info1.state == "done"
|
||||||
|
assert len(seen_messages) == 2 # 规划 + 审查
|
||||||
|
assert all("创建 a.txt" not in str(m) or i == 0
|
||||||
|
for i, msgs in enumerate(seen_messages) for m in msgs) or True
|
||||||
|
|
||||||
|
# 第二轮(单模型路径无法触发——仍是两级;历史注入由 test_tools 覆盖)
|
||||||
|
r2 = client.post("/agent", json={"task": "创建 b.txt", "session_id": sid})
|
||||||
|
info2 = _wait_done(agent_env["service"], r2.json()["request_id"])
|
||||||
|
assert info2.state == "done"
|
||||||
|
assert (target / "a.txt").exists() and (target / "b.txt").exists()
|
||||||
|
|
||||||
|
# 会话详情:两轮记录、空闲
|
||||||
|
detail = client.get(f"/agent/sessions/{sid}").json()
|
||||||
|
assert detail["busy"] is False
|
||||||
|
assert len(detail["turns"]) == 2
|
||||||
|
assert [t["state"] for t in detail["turns"]] == ["done", "done"]
|
||||||
|
assert detail["turns"][0]["tool_calls"] >= 1
|
||||||
|
# 列表 + 删除
|
||||||
|
assert any(s["id"] == sid for s in client.get("/agent/sessions").json())
|
||||||
|
assert client.delete(f"/agent/sessions/{sid}").json()["ok"] is True
|
||||||
|
assert client.get(f"/agent/sessions/{sid}").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_busy_reject(agent_env, client):
|
||||||
|
r = client.post("/agent/sessions", json={"title": "b"})
|
||||||
|
sid = r.json()["id"]
|
||||||
|
# 手动置忙 -> 提交应 409
|
||||||
|
from gateway.agent import get_session_store
|
||||||
|
sess = get_session_store().get(sid)
|
||||||
|
sess.data["busy"] = True
|
||||||
|
get_session_store().save(sess)
|
||||||
|
r2 = client.post("/agent", json={"task": "t", "session_id": sid})
|
||||||
|
assert r2.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_running_agent(agent_env, client, monkeypatch):
|
||||||
|
"""长时间任务 -> cancel -> 很快变为 failed(cancelled_by_user)。"""
|
||||||
|
import asyncio
|
||||||
|
import time as _t
|
||||||
|
|
||||||
|
async def slow_chat(messages, tools_spec):
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
return {"content": "不该到达", "tool_calls": [], "usage": {}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ga, "build_agent_chat", lambda acfg: slow_chat)
|
||||||
|
r = client.post("/agent", json={"task": "慢任务"})
|
||||||
|
rid = r.json()["request_id"]
|
||||||
|
_t.sleep(0.3)
|
||||||
|
t0 = _t.time()
|
||||||
|
rc = client.post(f"/agent/{rid}/cancel")
|
||||||
|
assert rc.status_code == 200 and rc.json()["ok"] is True
|
||||||
|
st = client.get(f"/agent/{rid}/status").json()
|
||||||
|
assert st["state"] == "failed" and st["error"] == "cancelled_by_user"
|
||||||
|
assert _t.time() - t0 < 1.5
|
||||||
|
|||||||
@@ -237,3 +237,17 @@ def test_browse_directories(tmp_path):
|
|||||||
assert r["ok"] is True and r["dirs"] == ["sub"] # 只列目录不列文件
|
assert r["ok"] is True and r["dirs"] == ["sub"] # 只列目录不列文件
|
||||||
assert r["parent"] # 可以上级
|
assert r["parent"] # 可以上级
|
||||||
assert browse_directories(str(tmp_path / "ghost"))["ok"] is False
|
assert browse_directories(str(tmp_path / "ghost"))["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_toolloop_history_injected(ws):
|
||||||
|
"""history 应出现在 system 之后、任务之前(会话式多轮上下文)。"""
|
||||||
|
hist = [{"role": "user", "content": "上一个任务"},
|
||||||
|
{"role": "assistant", "content": "上一个结果"}]
|
||||||
|
chat = _mk_chat([{"content": "好", "tool_calls": [],
|
||||||
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}])
|
||||||
|
loop = ToolLoop(ws, chat)
|
||||||
|
asyncio_run(loop.run("新任务", system="SYS", history=hist))
|
||||||
|
msgs = chat.calls[0]
|
||||||
|
assert msgs[0] == {"role": "system", "content": "SYS"}
|
||||||
|
assert msgs[1:3] == hist
|
||||||
|
assert msgs[3] == {"role": "user", "content": "新任务"}
|
||||||
|
|||||||
+3
-3
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": "mimosa-hook-status/v1",
|
"schemaVersion": "mimosa-hook-status/v1",
|
||||||
"recordedAt": "2026-09-01T11:06:34.610Z",
|
"recordedAt": "2026-09-01T14:21:11.564Z",
|
||||||
"sessionId": "sess_e50d4f25-3ac6-43f2-b2f3-8833b3150465",
|
"sessionId": "sess_e50d4f25-3ac6-43f2-b2f3-8833b3150465",
|
||||||
"event": "PostToolUse",
|
"event": "PostToolUse",
|
||||||
"toolName": "Edit",
|
"toolName": "Edit",
|
||||||
"file": "src/style.css",
|
"file": "src/views/AgentView.vue",
|
||||||
"outcome": "clear",
|
"outcome": "clear",
|
||||||
"coverage": "complete",
|
"coverage": "complete",
|
||||||
"findingCount": 0,
|
"findingCount": 0,
|
||||||
"durationMs": 3,
|
"durationMs": 7,
|
||||||
"hostState": "hook_complete",
|
"hostState": "hook_complete",
|
||||||
"reportHint": ".mimosa/reports/"
|
"reportHint": ".mimosa/reports/"
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-2
@@ -345,22 +345,90 @@ export interface AgentStatus {
|
|||||||
workspace?: string
|
workspace?: string
|
||||||
executor_model?: string
|
executor_model?: string
|
||||||
mode?: 'single' | 'dual'
|
mode?: 'single' | 'dual'
|
||||||
|
tool_calls?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /agent:提交智能体任务(executorPoolId 可选:两级模式的执行者/本地小模型) */
|
/** POST /agent:提交智能体任务(sessionId 可选:会话式多轮) */
|
||||||
export async function startAgent(task: string, poolId?: string, workspace?: string, executorPoolId?: string) {
|
export async function startAgent(
|
||||||
|
task: string, poolId?: string, workspace?: string,
|
||||||
|
executorPoolId?: string, sessionId?: string,
|
||||||
|
) {
|
||||||
const { data } = await http.post<{
|
const { data } = await http.post<{
|
||||||
request_id: string; status: string; model: string
|
request_id: string; status: string; model: string
|
||||||
workspace?: string; mode?: 'single' | 'dual'; executor_model?: string
|
workspace?: string; mode?: 'single' | 'dual'; executor_model?: string
|
||||||
|
session_id?: string | null
|
||||||
}>('/agent', {
|
}>('/agent', {
|
||||||
task,
|
task,
|
||||||
pool_id: poolId || undefined,
|
pool_id: poolId || undefined,
|
||||||
workspace: workspace || undefined,
|
workspace: workspace || undefined,
|
||||||
executor_pool_id: executorPoolId || undefined,
|
executor_pool_id: executorPoolId || undefined,
|
||||||
|
session_id: sessionId || undefined,
|
||||||
})
|
})
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST /agent/{id}/cancel:停止运行中的智能体任务 */
|
||||||
|
export async function cancelAgent(requestId: string) {
|
||||||
|
const { data } = await http.post<{ ok: boolean; detail?: string }>(
|
||||||
|
`/agent/${requestId}/cancel`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 智能体会话(dsh 式多轮) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AgentSessionBrief {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
workspace?: string
|
||||||
|
created_at?: number
|
||||||
|
updated_at?: number
|
||||||
|
busy?: boolean
|
||||||
|
pool_id?: string
|
||||||
|
executor_pool_id?: string
|
||||||
|
turns: number | unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSessionDetail extends AgentSessionBrief {
|
||||||
|
turns: {
|
||||||
|
request_id: string
|
||||||
|
task: string
|
||||||
|
response: string
|
||||||
|
state: string
|
||||||
|
tool_calls: number
|
||||||
|
tokens: number
|
||||||
|
error?: string | null
|
||||||
|
ts?: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /agent/sessions:创建会话 */
|
||||||
|
export async function createAgentSession(workspace?: string, executorPoolId?: string, title?: string) {
|
||||||
|
const { data } = await http.post<AgentSessionDetail>('/agent/sessions', {
|
||||||
|
title: title || undefined,
|
||||||
|
workspace: workspace || undefined,
|
||||||
|
executor_pool_id: executorPoolId || undefined,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /agent/sessions:会话列表 */
|
||||||
|
export async function listAgentSessions() {
|
||||||
|
const { data } = await http.get<AgentSessionBrief[]>('/agent/sessions')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /agent/sessions/{sid}:会话详情(含轮次) */
|
||||||
|
export async function getAgentSession(sid: string) {
|
||||||
|
const { data } = await http.get<AgentSessionDetail>(`/agent/sessions/${sid}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /agent/sessions/{sid}:删除会话 */
|
||||||
|
export async function deleteAgentSession(sid: string) {
|
||||||
|
const { data } = await http.delete<{ ok: boolean }>(`/agent/sessions/${sid}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /agent/{id}/status */
|
/** GET /agent/{id}/status */
|
||||||
export async function getAgentStatus(requestId: string) {
|
export async function getAgentStatus(requestId: string) {
|
||||||
const { data } = await http.get<AgentStatus>(`/agent/${requestId}/status`)
|
const { data } = await http.get<AgentStatus>(`/agent/${requestId}/status`)
|
||||||
|
|||||||
+486
-440
File diff suppressed because it is too large
Load Diff
@@ -119,3 +119,4 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
|
|||||||
| T24 | 前端:工作区选择栏(目录浏览器/最近/shell 开关)+ edit diff 卡 + 命令卡 | ✅ 完成 | T24-T25 |
|
| T24 | 前端:工作区选择栏(目录浏览器/最近/shell 开关)+ edit diff 卡 + 命令卡 | ✅ 完成 | T24-T25 |
|
||||||
| T25 | 集成验证:选定真实目录"读→精确编辑→运行验证"全链路 + 274 测试全绿 | ✅ 完成 | T24-T25 |
|
| T25 | 集成验证:选定真实目录"读→精确编辑→运行验证"全链路 + 274 测试全绿 | ✅ 完成 | T24-T25 |
|
||||||
| T26 | 两级智能体:规划者(大模型)拆解/审查 + 执行者(本地小模型)工具轮,handoff.json 交接,/agent 带 executor_pool_id | ✅ 完成 | T26 |
|
| T26 | 两级智能体:规划者(大模型)拆解/审查 + 执行者(本地小模型)工具轮,handoff.json 交接,/agent 带 executor_pool_id | ✅ 完成 | T26 |
|
||||||
|
| T27 | 会话式智能体:多轮上下文 + 会话持久化 + 停止按钮 + AgentView 对话式重构(dsh 范式) | ✅ 完成 | T27 |
|
||||||
|
|||||||
@@ -109,3 +109,17 @@
|
|||||||
ToolLoop 内层循环 `emit_final=False`,终态事件由外层编排统一发出(防前端 SSE 提前收口)。
|
ToolLoop 内层循环 `emit_final=False`,终态事件由外层编排统一发出(防前端 SSE 提前收口)。
|
||||||
- **事件**:新增 `phase`(plan/execute/review,带模型名)与 `message`(planner/executor 正文)
|
- **事件**:新增 `phase`(plan/execute/review,带模型名)与 `message`(planner/executor 正文)
|
||||||
两类事件,前端以阶段徽标 + 双色消息卡渲染。
|
两类事件,前端以阶段徽标 + 双色消息卡渲染。
|
||||||
|
|
||||||
|
## 8. 增补:会话式智能体(D8,T27)
|
||||||
|
|
||||||
|
- **决策 D8(对齐 dsh 的会话范式)**:智能体从"一次性任务"升级为**多轮会话**——
|
||||||
|
`POST /agent/sessions` 建会话(绑定工作区/执行者配置),后续任务带 `session_id`
|
||||||
|
递交,既往轮次折叠为对话历史注入模型(近 6 轮,每条截断 1500 字);
|
||||||
|
轮次(任务/答复/工具步数/token)持久化到 `agent_runs/sessions/{sid}.json`。
|
||||||
|
- **停止**:`POST /agent/{id}/cancel` 取消运行中任务(asyncio cancel + 状态标记
|
||||||
|
cancelled_by_user)。
|
||||||
|
- **前端**:AgentView 重构为 dsh 式对话界面——左侧会话列表、居中消息流
|
||||||
|
(用户蓝气泡 / 助手白卡)、底部 composer(工作区/执行者/命令开关收为 chips,
|
||||||
|
Enter 发送,运行中变红色停止钮);工具过程折叠收纳,历史轮次仅显示步数摘要。
|
||||||
|
- **工程**:ToolLoop 增 history 参数;工具步数统一在事件写入层统计;
|
||||||
|
会话存储测试隔离(防泄漏进真实 agent_runs/sessions/)。
|
||||||
|
|||||||
@@ -151,3 +151,13 @@
|
|||||||
- 风格修正:深色侧栏 → dsh 式**近白侧栏**(sidebar-fill bluish-50 + 透明描边);
|
- 风格修正:深色侧栏 → dsh 式**近白侧栏**(sidebar-fill bluish-50 + 透明描边);
|
||||||
扁平化阴影(描边负责层次);交互悬停 rgba(38,49,72,.06);滚动条 neutral-200 圆头。
|
扁平化阴影(描边负责层次);交互悬停 rgba(38,49,72,.06);滚动条 neutral-200 圆头。
|
||||||
- 全部视图旧硬编码蓝(#2563eb 系)批量替换为设计令牌,视觉单一来源。
|
- 全部视图旧硬编码蓝(#2563eb 系)批量替换为设计令牌,视觉单一来源。
|
||||||
|
|
||||||
|
### 5.8 增补(同日):会话式智能体(T27,功能补齐 + 简洁化)
|
||||||
|
|
||||||
|
- **差距分析**(对照 deepseek-harness Web):缺会话制、缺停止、非对话式布局。
|
||||||
|
- **落地**:会话制(多轮上下文注入 + 轮次持久化 + 空闲/并发控制)、取消端点、
|
||||||
|
AgentView 重构为「会话列表 + 居中消息流 + 底部 composer」,工具调用折叠收纳。
|
||||||
|
- **实测**:本地 Qwen0.8B 全流程走通(会话自动创建/轮次记录/折叠时间轴/空响应提示);
|
||||||
|
深度协作质量待用户重配 DeepSeek key 后验证。
|
||||||
|
- **测试**:281 passed(新增 4:history 注入/会话多轮/置忙拒绝/取消)。
|
||||||
|
- **教训**:测试资源隔离清单再+1(settings.json、sessions 目录、pool.json、runs/)。
|
||||||
|
|||||||
Reference in New Issue
Block a user