feat(v3): T26 两级智能体(大模型规划/审查 + 本地小模型执行,D7)
- run_dual 编排:规划者两阶段 JSON(plan/review,失败回喂重试一次再降级),
redo 时裁决意见回喂执行者,交接上限 agent.max_handoffs(默认 2)
- 交接文档 agent_runs/{id}/handoff.json(智能体版交流文本:instructions/acceptance/exchanges)
- /agent 新增 executor_pool_id;规划者==执行者条目拒绝;整体 token_cap 覆盖两级调用
- ToolLoop 增 emit_final 开关(内层循环不发终态,防前端 SSE 提前收口)
- 前端:执行者选择器 + phase/message 事件渲染(阶段徽标 + 双色消息卡)
- 测试 +3(done/redo/执行者故障),全量 277 passed
- fix(tests): test_config_get_put_reset 增加设置备份/恢复隔离,防止清掉用户真实配置
This commit is contained in:
+212
-18
@@ -41,6 +41,42 @@ AGENT_SYSTEM_PROMPT = (
|
|||||||
"直接输出给用户的最终答复(中文,不要再调用工具)。"
|
"直接输出给用户的最终答复(中文,不要再调用工具)。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── 两级智能体(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 兼容工具调用客户端
|
# OpenAI 兼容工具调用客户端
|
||||||
@@ -129,6 +165,8 @@ class AgentRunInfo:
|
|||||||
completion_tokens: int = 0
|
completion_tokens: int = 0
|
||||||
pool_id: str = ""
|
pool_id: str = ""
|
||||||
workspace: str = "" # 本次运行使用的工作区根目录(绝对路径)
|
workspace: str = "" # 本次运行使用的工作区根目录(绝对路径)
|
||||||
|
executor_model: str = "" # 两级模式:执行者模型名(空 = 单模型模式)
|
||||||
|
mode: str = "single" # single | dual
|
||||||
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]:
|
||||||
@@ -146,6 +184,8 @@ class AgentRunInfo:
|
|||||||
"completion_tokens": self.completion_tokens,
|
"completion_tokens": self.completion_tokens,
|
||||||
"pool_id": self.pool_id,
|
"pool_id": self.pool_id,
|
||||||
"workspace": self.workspace,
|
"workspace": self.workspace,
|
||||||
|
"executor_model": self.executor_model,
|
||||||
|
"mode": self.mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -169,12 +209,15 @@ class AgentService:
|
|||||||
|
|
||||||
# ---------- 注册与查询 ----------
|
# ---------- 注册与查询 ----------
|
||||||
def register(self, request_id: str, task: str, model: str, pool_id: str,
|
def register(self, request_id: str, task: str, model: str, pool_id: str,
|
||||||
workspace: str = "") -> Optional[AgentRunInfo]:
|
workspace: str = "", executor_model: str = "",
|
||||||
|
mode: str = "single") -> Optional[AgentRunInfo]:
|
||||||
running = [r for r in self._runs.values() if r.state == STATE_RUNNING]
|
running = [r for r in self._runs.values() if r.state == STATE_RUNNING]
|
||||||
if len(running) >= self.max_running:
|
if len(running) >= self.max_running:
|
||||||
return None
|
return None
|
||||||
info = AgentRunInfo(request_id=request_id, task=task, model=model,
|
info = AgentRunInfo(request_id=request_id, task=task, model=model,
|
||||||
pool_id=pool_id, workspace=workspace, started_at=time.time())
|
pool_id=pool_id, workspace=workspace,
|
||||||
|
executor_model=executor_model, mode=mode,
|
||||||
|
started_at=time.time())
|
||||||
self._runs[request_id] = info
|
self._runs[request_id] = info
|
||||||
self._dir(request_id).mkdir(parents=True, exist_ok=True)
|
self._dir(request_id).mkdir(parents=True, exist_ok=True)
|
||||||
self._write_status(info)
|
self._write_status(info)
|
||||||
@@ -186,27 +229,28 @@ class AgentService:
|
|||||||
# ---------- 执行 ----------
|
# ---------- 执行 ----------
|
||||||
async def run(self, info: AgentRunInfo, chat: Any, workspace_dir: str | Path,
|
async def run(self, info: AgentRunInfo, chat: Any, workspace_dir: str | Path,
|
||||||
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) -> None:
|
allow_shell: bool = False, shell_timeout_s: int = 20,
|
||||||
"""执行智能体任务(由调用方包成后台协程)。"""
|
executor_chat: Any = None,
|
||||||
|
max_handoffs: int = DEFAULT_MAX_HANDOFFS) -> None:
|
||||||
|
"""执行智能体任务(由调用方包成后台协程)。
|
||||||
|
|
||||||
|
executor_chat 为空 = 单模型模式(chat 全程包办);
|
||||||
|
提供时进入两级模式:chat 作规划者,executor_chat 作执行者(D7)。
|
||||||
|
"""
|
||||||
|
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,
|
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
|
||||||
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))
|
||||||
try:
|
|
||||||
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT)
|
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT)
|
||||||
info.response = result.get("response", "")
|
self._apply_result(info, result)
|
||||||
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"):
|
|
||||||
# 触顶属于护栏行为:结果仍交付,但标记部分完成信息
|
|
||||||
info.state = STATE_DONE
|
|
||||||
info.error = result.get("error")
|
|
||||||
else:
|
|
||||||
info.state = STATE_DONE
|
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
info.state = STATE_FAILED
|
info.state = STATE_FAILED
|
||||||
info.error = f"{type(exc).__name__}: {exc}"
|
info.error = f"{type(exc).__name__}: {exc}"
|
||||||
@@ -216,6 +260,156 @@ class AgentService:
|
|||||||
info.finished_at = time.time()
|
info.finished_at = time.time()
|
||||||
self._write_status(info)
|
self._write_status(info)
|
||||||
|
|
||||||
|
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 _make_event_writer(self, info: AgentRunInfo):
|
||||||
def _on_event(ev: Dict[str, Any]) -> None:
|
def _on_event(ev: Dict[str, Any]) -> None:
|
||||||
|
|||||||
+36
-2
@@ -530,13 +530,44 @@ try:
|
|||||||
workspace_dir = agent_cfg.get("workspace_dir", "agent_workspace")
|
workspace_dir = agent_cfg.get("workspace_dir", "agent_workspace")
|
||||||
|
|
||||||
chat, model, used_pool_id = _resolve_agent_chat(pool_id)
|
chat, model, used_pool_id = _resolve_agent_chat(pool_id)
|
||||||
|
|
||||||
|
# 两级模式(D7):显式指定执行者(本地小模型)时,规划=chat、执行=executor_chat
|
||||||
|
executor_pool_id = str((req or {}).get("executor_pool_id") or "").strip()
|
||||||
|
executor_chat = None
|
||||||
|
executor_model = ""
|
||||||
|
if executor_pool_id:
|
||||||
|
entry = get_pool().get(executor_pool_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail=f"执行者条目不存在: {executor_pool_id}")
|
||||||
|
if entry["backend"] == "mock":
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail="mock 模型不能担任执行者,请选择 llama_server 或 openai 条目")
|
||||||
|
from gateway.agent import OpenAICompatChat
|
||||||
|
executor_chat = OpenAICompatChat(
|
||||||
|
base_url=entry["base_url"] or "http://127.0.0.1:8901/v1",
|
||||||
|
api_key=entry.get("api_key") or None,
|
||||||
|
model=entry["model"],
|
||||||
|
temperature=float(entry.get("temperature", 0.3)),
|
||||||
|
max_tokens=int(entry.get("max_tokens", 4096)))
|
||||||
|
executor_model = f"{entry['name']}({entry['model']})"
|
||||||
|
|
||||||
|
if executor_chat is not None and executor_pool_id == used_pool_id:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail="规划者与执行者是同一个模型,两级模式无意义;请更换执行者条目")
|
||||||
|
|
||||||
service = get_agent_service()
|
service = get_agent_service()
|
||||||
request_id = new_request_id()
|
request_id = new_request_id()
|
||||||
|
mode = "dual" if executor_chat is not None else "single"
|
||||||
info = service.register(request_id, task, model, used_pool_id,
|
info = service.register(request_id, task, model, used_pool_id,
|
||||||
workspace=workspace_dir)
|
workspace=workspace_dir,
|
||||||
|
executor_model=executor_model, mode=mode)
|
||||||
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()
|
||||||
|
agent_cfg = s.get("agent", {})
|
||||||
|
|
||||||
async def _run():
|
async def _run():
|
||||||
try:
|
try:
|
||||||
await service.run(
|
await service.run(
|
||||||
@@ -546,6 +577,8 @@ try:
|
|||||||
token_cap=int(agent_cfg.get("token_cap", 20000)),
|
token_cap=int(agent_cfg.get("token_cap", 20000)),
|
||||||
allow_shell=bool(agent_cfg.get("allow_shell", False)),
|
allow_shell=bool(agent_cfg.get("allow_shell", False)),
|
||||||
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,
|
||||||
|
max_handoffs=int(agent_cfg.get("max_handoffs", 2)),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
import traceback
|
import traceback
|
||||||
@@ -557,7 +590,8 @@ try:
|
|||||||
|
|
||||||
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}
|
"workspace": workspace_dir, "mode": mode,
|
||||||
|
"executor_model": executor_model}
|
||||||
|
|
||||||
@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 = ""):
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ DEFAULTS: Dict[str, Any] = {
|
|||||||
"token_cap": 20000, # 单次智能体任务 token 熔断
|
"token_cap": 20000, # 单次智能体任务 token 熔断
|
||||||
"allow_shell": False, # 允许 run_command 执行 shell(默认关)
|
"allow_shell": False, # 允许 run_command 执行 shell(默认关)
|
||||||
"shell_timeout_s": 20, # shell 命令超时
|
"shell_timeout_s": 20, # shell 命令超时
|
||||||
|
"max_handoffs": 2, # 两级模式:规划者<->执行者交接轮数上限
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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-Bi4FCB5S.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-ba641559`]]);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-DtjeaX4S.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-ba641559`]]);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-Bi4FCB5S.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-19c16eff`]]);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-DtjeaX4S.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-19c16eff`]]);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>webapp</title>
|
<title>webapp</title>
|
||||||
<script type="module" crossorigin src="/static/assets/index-Bi4FCB5S.js"></script>
|
<script type="module" crossorigin src="/static/assets/index-DtjeaX4S.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/static/assets/index-zGyQjwum.css">
|
<link rel="stylesheet" crossorigin href="/static/assets/index-DPz6YNpx.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -413,6 +413,7 @@ class ToolLoop:
|
|||||||
token_cap: int = 0,
|
token_cap: int = 0,
|
||||||
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||||
result_preview_chars: int = MAX_RESULT_CHARS,
|
result_preview_chars: int = MAX_RESULT_CHARS,
|
||||||
|
emit_final: bool = True,
|
||||||
):
|
):
|
||||||
self.tools = tools
|
self.tools = tools
|
||||||
self.chat_fn = chat_fn
|
self.chat_fn = chat_fn
|
||||||
@@ -420,8 +421,11 @@ class ToolLoop:
|
|||||||
self.token_cap = int(token_cap) # 0 = 不限
|
self.token_cap = int(token_cap) # 0 = 不限
|
||||||
self.on_event = on_event
|
self.on_event = on_event
|
||||||
self.result_preview_chars = result_preview_chars
|
self.result_preview_chars = result_preview_chars
|
||||||
|
self.emit_final = emit_final # 两级模式内层循环置 False,由外层统一收尾
|
||||||
|
|
||||||
def _emit(self, ev: Dict[str, Any]) -> None:
|
def _emit(self, ev: Dict[str, Any]) -> None:
|
||||||
|
if ev.get("type") == "final" and not self.emit_final:
|
||||||
|
return # 内层循环不发终态事件(外层编排负责)
|
||||||
if self.on_event is not None:
|
if self.on_event is not None:
|
||||||
try:
|
try:
|
||||||
self.on_event(ev)
|
self.on_event(ev)
|
||||||
|
|||||||
@@ -244,3 +244,162 @@ def test_agent_workspace_and_file_accept_root(agent_env, client, tmp_path):
|
|||||||
# 非法 root -> 400
|
# 非法 root -> 400
|
||||||
r = client.get("/agent/workspace", params={"root": str(tmp_path / "nope")})
|
r = client.get("/agent/workspace", params={"root": str(tmp_path / "nope")})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 两级智能体(T26):规划者 + 执行者 ----------------
|
||||||
|
|
||||||
|
def _planner_resp(obj=None, raw=""):
|
||||||
|
content = raw or json.dumps(obj, ensure_ascii=False)
|
||||||
|
return {"content": content, "tool_calls": [],
|
||||||
|
"usage": {"prompt_tokens": 50, "completion_tokens": 20}}
|
||||||
|
|
||||||
|
|
||||||
|
def _install_dual(agent_env, monkeypatch, planner_script, executor_script):
|
||||||
|
"""注入假规划者(build_agent_chat)与假执行者(OpenAICompatChat)。"""
|
||||||
|
|
||||||
|
class FakePlanner:
|
||||||
|
api_key = "sk-fake"
|
||||||
|
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
self.script = list(planner_script)
|
||||||
|
|
||||||
|
async def __call__(self, messages, tools_spec):
|
||||||
|
if self.script:
|
||||||
|
return self.script.pop(0)
|
||||||
|
return _planner_resp({"verdict": "done", "final_answer": "(兜底)完成。"})
|
||||||
|
|
||||||
|
class FakeExecutorChat:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
self.script = list(executor_script)
|
||||||
|
|
||||||
|
async def __call__(self, messages, tools_spec):
|
||||||
|
if self.script:
|
||||||
|
return self.script.pop(0)
|
||||||
|
return {"content": "(执行者兜底)没有更多动作。", "tool_calls": [], "usage": {}}
|
||||||
|
|
||||||
|
def fake_chat_factory(acfg):
|
||||||
|
return FakePlanner()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
|
||||||
|
monkeypatch.setattr(ag, "OpenAICompatChat", FakeExecutorChat)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_agent_done_flow(agent_env, client, monkeypatch, tmp_path):
|
||||||
|
"""规划 -> 执行(写文件) -> 审查 done:事件/交接文档/状态全部落位。"""
|
||||||
|
_install_dual(
|
||||||
|
agent_env, monkeypatch,
|
||||||
|
planner_script=[
|
||||||
|
_planner_resp({"instructions": "在 data 目录创建 report.json",
|
||||||
|
"acceptance": "文件存在且内容为合法 JSON"}),
|
||||||
|
_planner_resp({"verdict": "done", "reply_to_executor": "",
|
||||||
|
"final_answer": "执行者已按指令创建数据文件,验收通过。"}),
|
||||||
|
],
|
||||||
|
executor_script=[
|
||||||
|
{"content": None,
|
||||||
|
"tool_calls": [{"id": "e1", "name": "write_file",
|
||||||
|
"arguments": {"path": "data/report.json",
|
||||||
|
"content": '{"ok": true}'}}],
|
||||||
|
"usage": {"prompt_tokens": 100, "completion_tokens": 10}},
|
||||||
|
{"content": "汇报:已创建 data/report.json,内容 {\"ok\": true}。",
|
||||||
|
"tool_calls": [], "usage": {"prompt_tokens": 120, "completion_tokens": 15}},
|
||||||
|
])
|
||||||
|
r = client.post("/agent", json={"task": "建数据文件", "executor_pool_id": "no-such"})
|
||||||
|
# 执行者条目不存在 -> 400
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
# 先放一个合法 llama_server 条目作为执行者
|
||||||
|
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})
|
||||||
|
r2 = client.post("/agent", json={"task": "建数据文件", "executor_pool_id": "local-x"})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["mode"] == "dual"
|
||||||
|
assert "本地小模型" in r2.json()["executor_model"]
|
||||||
|
|
||||||
|
rid = r2.json()["request_id"]
|
||||||
|
info = _wait_done(agent_env["service"], rid)
|
||||||
|
assert info.state == "done", info.error
|
||||||
|
assert info.mode == "dual"
|
||||||
|
assert info.response == "执行者已按指令创建数据文件,验收通过。"
|
||||||
|
|
||||||
|
# 事件序列:规划 -> 执行(含工具) -> 审查 -> final
|
||||||
|
evs = agent_env["service"].read_events(rid)
|
||||||
|
phases = [e["phase"] for e in evs if e["type"] == "phase"]
|
||||||
|
assert phases == ["plan", "execute", "review"]
|
||||||
|
kinds = [e["type"] for e in evs]
|
||||||
|
assert "message" in kinds and "tool_call" in kinds
|
||||||
|
# 交接文档(智能体版交流文本)
|
||||||
|
ho = json.loads((agent_env["service"]._dir(rid) / "handoff.json").read_text(encoding="utf-8"))
|
||||||
|
assert ho["instructions"]
|
||||||
|
assert ho["exchanges"][0]["verdict"] == "done"
|
||||||
|
assert ho["executor_model"] == "本地小模型(qwen-0.8b)"
|
||||||
|
|
||||||
|
st = client.get(f"/agent/{rid}/status").json()
|
||||||
|
assert st["mode"] == "dual" and st["executor_model"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_agent_redo_then_done(agent_env, client, monkeypatch):
|
||||||
|
"""第一轮裁决 redo -> 执行者带补充指令再跑 -> 第二轮 done。"""
|
||||||
|
_install_dual(
|
||||||
|
agent_env, monkeypatch,
|
||||||
|
planner_script=[
|
||||||
|
_planner_resp({"instructions": "写 hello.txt"}),
|
||||||
|
_planner_resp({"verdict": "redo", "reply_to_executor": "文件内容不对,请写入 DONE",
|
||||||
|
"final_answer": ""}),
|
||||||
|
_planner_resp({"verdict": "done", "reply_to_executor": "",
|
||||||
|
"final_answer": "第二轮通过。"}),
|
||||||
|
],
|
||||||
|
executor_script=[
|
||||||
|
{"content": "汇报:已写 hello.txt(内容空白)", "tool_calls": [],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5}},
|
||||||
|
{"content": None,
|
||||||
|
"tool_calls": [{"id": "e1", "name": "write_file",
|
||||||
|
"arguments": {"path": "hello.txt", "content": "DONE"}}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5}},
|
||||||
|
{"content": "汇报:已按补充指令重写 hello.txt 内容为 DONE",
|
||||||
|
"tool_calls": [], "usage": {"prompt_tokens": 10, "completion_tokens": 5}},
|
||||||
|
])
|
||||||
|
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", json={"task": "写 hello.txt", "executor_pool_id": "local-x"})
|
||||||
|
rid = r.json()["request_id"]
|
||||||
|
info = _wait_done(agent_env["service"], rid)
|
||||||
|
assert info.state == "done"
|
||||||
|
assert info.response == "第二轮通过。"
|
||||||
|
ho = json.loads((agent_env["service"]._dir(rid) / "handoff.json").read_text(encoding="utf-8"))
|
||||||
|
assert [x["verdict"] for x in ho["exchanges"]] == ["redo", "done"]
|
||||||
|
# 第二轮执行者应收到 redo 补充指令(消息历史含 reply_to_executor 内容)
|
||||||
|
evs = agent_env["service"].read_events(rid)
|
||||||
|
exec_phases = [e for e in evs if e["type"] == "phase" and e["phase"] == "execute"]
|
||||||
|
assert len(exec_phases) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_agent_executor_error(agent_env, client, monkeypatch):
|
||||||
|
"""执行者客户端异常 -> 任务 failed,错误透出。"""
|
||||||
|
class BoomChat:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __call__(self, messages, tools_spec):
|
||||||
|
raise RuntimeError("本地模型连不上")
|
||||||
|
|
||||||
|
class PlanOK:
|
||||||
|
api_key = "sk-fake"
|
||||||
|
|
||||||
|
async def __call__(self, messages, tools_spec):
|
||||||
|
return _planner_resp({"instructions": "随便执行"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(ga, "build_agent_chat", lambda acfg: PlanOK())
|
||||||
|
monkeypatch.setattr(ag, "OpenAICompatChat", BoomChat)
|
||||||
|
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", json={"task": "t", "executor_pool_id": "local-x"})
|
||||||
|
rid = r.json()["request_id"]
|
||||||
|
info = _wait_done(agent_env["service"], rid)
|
||||||
|
assert info.state == "failed"
|
||||||
|
assert "RuntimeError" in (info.error or "")
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ def test_metrics(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_config_get_put_reset(client):
|
def test_config_get_put_reset(client):
|
||||||
|
"""/config 端到端。settings.json 是活文件(用户真实配置),
|
||||||
|
测试前后必须备份/恢复,禁止把用户配置清掉。"""
|
||||||
|
import json
|
||||||
|
store = ga.settings_store()
|
||||||
|
snapshot = json.loads(json.dumps(store._data, ensure_ascii=False))
|
||||||
|
try:
|
||||||
# GET 默认
|
# GET 默认
|
||||||
r = client.get("/config")
|
r = client.get("/config")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -98,6 +104,10 @@ def test_config_get_put_reset(client):
|
|||||||
r3 = client.post("/config/reset")
|
r3 = client.post("/config/reset")
|
||||||
assert r3.status_code == 200
|
assert r3.status_code == 200
|
||||||
assert r3.json()["worker"]["backend"] == "llama_server"
|
assert r3.json()["worker"]["backend"] == "llama_server"
|
||||||
|
finally:
|
||||||
|
store._data = snapshot
|
||||||
|
store.save()
|
||||||
|
ga.rebuild_pipeline()
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_not_found(client):
|
def test_workspace_not_found(client):
|
||||||
|
|||||||
+20
-5
@@ -311,7 +311,7 @@ export async function listPoolModels(id: string) {
|
|||||||
// ── 智能体(工具调用) ───────────────────────────────────────────────────────
|
// ── 智能体(工具调用) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AgentEvent {
|
export interface AgentEvent {
|
||||||
type: 'round' | 'tool_call' | 'tool_result' | 'usage' | 'final'
|
type: 'round' | 'tool_call' | 'tool_result' | 'usage' | 'final' | 'phase' | 'message'
|
||||||
ts?: number
|
ts?: number
|
||||||
round?: number
|
round?: number
|
||||||
name?: string
|
name?: string
|
||||||
@@ -322,6 +322,12 @@ export interface AgentEvent {
|
|||||||
completion_tokens?: number
|
completion_tokens?: number
|
||||||
reason?: string
|
reason?: string
|
||||||
error?: string
|
error?: string
|
||||||
|
// 两级模式(D7)
|
||||||
|
phase?: 'plan' | 'execute' | 'review'
|
||||||
|
handoff?: number
|
||||||
|
model?: string
|
||||||
|
role?: 'planner' | 'executor'
|
||||||
|
content?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentStatus {
|
export interface AgentStatus {
|
||||||
@@ -337,12 +343,21 @@ export interface AgentStatus {
|
|||||||
started_at?: number
|
started_at?: number
|
||||||
finished_at?: number
|
finished_at?: number
|
||||||
workspace?: string
|
workspace?: string
|
||||||
|
executor_model?: string
|
||||||
|
mode?: 'single' | 'dual'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /agent:提交智能体任务(workspace 可选:选中工作目录) */
|
/** POST /agent:提交智能体任务(executorPoolId 可选:两级模式的执行者/本地小模型) */
|
||||||
export async function startAgent(task: string, poolId?: string, workspace?: string) {
|
export async function startAgent(task: string, poolId?: string, workspace?: string, executorPoolId?: string) {
|
||||||
const { data } = await http.post<{ request_id: string; status: string; model: string; workspace?: string }>(
|
const { data } = await http.post<{
|
||||||
'/agent', { task, pool_id: poolId || undefined, workspace: workspace || undefined })
|
request_id: string; status: string; model: string
|
||||||
|
workspace?: string; mode?: 'single' | 'dual'; executor_model?: string
|
||||||
|
}>('/agent', {
|
||||||
|
task,
|
||||||
|
pool_id: poolId || undefined,
|
||||||
|
workspace: workspace || undefined,
|
||||||
|
executor_pool_id: executorPoolId || undefined,
|
||||||
|
})
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,13 @@
|
|||||||
<option value="">默认模型(模型池 Agent 角色 / Architect 设置)</option>
|
<option value="">默认模型(模型池 Agent 角色 / Architect 设置)</option>
|
||||||
<option v-for="e in agentModels" :key="e.id" :value="e.id">{{ e.name }}({{ e.model }})</option>
|
<option v-for="e in agentModels" :key="e.id" :value="e.id">{{ e.name }}({{ e.model }})</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select v-model="selectedExecutorId" class="model-select executor-select"
|
||||||
|
title="两级模式:大模型拆解/审查,本地小模型执行工具轮">
|
||||||
|
<option value="">单模型模式(规划者全程包办)</option>
|
||||||
|
<option v-for="e in executorCandidates" :key="e.id" :value="e.id">
|
||||||
|
🔧 执行者:{{ e.name }}({{ e.model }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-bar">
|
<div class="task-bar">
|
||||||
<textarea v-model="task" class="task-input" rows="3"
|
<textarea v-model="task" class="task-input" rows="3"
|
||||||
@@ -66,6 +73,17 @@
|
|||||||
<template v-for="(ev, i) in events" :key="i">
|
<template v-for="(ev, i) in events" :key="i">
|
||||||
<div v-if="ev.type === 'round'" class="ev-round">— 第 {{ ev.round }} 轮 —</div>
|
<div v-if="ev.type === 'round'" class="ev-round">— 第 {{ ev.round }} 轮 —</div>
|
||||||
|
|
||||||
|
<div v-else-if="ev.type === 'phase'" class="ev-phase" :class="'ph-' + ev.phase">
|
||||||
|
{{ phaseLabel(ev) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="ev.type === 'message'" class="ev-msg">
|
||||||
|
<div class="ev-title">
|
||||||
|
{{ ev.role === 'planner' ? '🧠 规划者' : '🔧 执行者' }}{{ ev.handoff ? `(第 ${ev.handoff} 轮交接)` : '' }}
|
||||||
|
</div>
|
||||||
|
<pre class="ev-msgbody" :class="ev.role === 'planner' ? 'msg-planner' : 'msg-executor'">{{ ev.content }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="ev.type === 'tool_call' && ev.name === 'edit_file'" class="ev-card">
|
<div v-else-if="ev.type === 'tool_call' && ev.name === 'edit_file'" class="ev-card">
|
||||||
<div class="ev-title">✏️ 精确编辑 <b>{{ (ev.arguments as any)?.path }}</b></div>
|
<div class="ev-title">✏️ 精确编辑 <b>{{ (ev.arguments as any)?.path }}</b></div>
|
||||||
<pre class="diff-old">- {{ (ev.arguments as any)?.old_string }}</pre>
|
<pre class="diff-old">- {{ (ev.arguments as any)?.old_string }}</pre>
|
||||||
@@ -171,6 +189,9 @@ const manualPath = ref('')
|
|||||||
const createIfMissing = ref(false)
|
const createIfMissing = ref(false)
|
||||||
const allowShell = ref(false)
|
const allowShell = ref(false)
|
||||||
|
|
||||||
|
// 两级模式
|
||||||
|
const selectedExecutorId = ref('')
|
||||||
|
|
||||||
const wsFiles = ref<{ name: string; type: string; size?: number }[]>([])
|
const wsFiles = ref<{ name: string; type: string; size?: number }[]>([])
|
||||||
const loadingWs = ref(false)
|
const loadingWs = ref(false)
|
||||||
const filePreview = ref<{ path: string; content: string; truncated: boolean } | null>(null)
|
const filePreview = ref<{ path: string; content: string; truncated: boolean } | null>(null)
|
||||||
@@ -180,6 +201,18 @@ let _watch: ReturnType<typeof watchAgent> | null = null
|
|||||||
|
|
||||||
const agentModels = computed(() => poolEntries.value.filter(e => e.enabled))
|
const agentModels = computed(() => poolEntries.value.filter(e => e.enabled))
|
||||||
|
|
||||||
|
const executorCandidates = computed(() =>
|
||||||
|
poolEntries.value
|
||||||
|
.filter(e => e.enabled && e.backend !== 'mock')
|
||||||
|
.sort((a, b) => (a.backend === 'llama_server' ? -1 : 0) - (b.backend === 'llama_server' ? -1 : 0)))
|
||||||
|
|
||||||
|
function phaseLabel(ev: AgentEvent) {
|
||||||
|
const m = ev.model ? ` · ${ev.model}` : ''
|
||||||
|
if (ev.phase === 'plan') return `🧠 规划者 · 拆解任务${m}`
|
||||||
|
if (ev.phase === 'execute') return `🔧 执行者 · 工具执行(第 ${ev.handoff} 轮交接)${m}`
|
||||||
|
return `🔍 规划者 · 审查裁决${m}`
|
||||||
|
}
|
||||||
|
|
||||||
const finalResponse = computed(() => ((statusInfo.value as any)?.response || ''))
|
const finalResponse = computed(() => ((statusInfo.value as any)?.response || ''))
|
||||||
|
|
||||||
const statusLine = computed(() => {
|
const statusLine = computed(() => {
|
||||||
@@ -281,7 +314,8 @@ async function run() {
|
|||||||
events.value = []
|
events.value = []
|
||||||
statusInfo.value = null
|
statusInfo.value = null
|
||||||
try {
|
try {
|
||||||
const init = await startAgent(task.value.trim(), selectedPoolId.value, selectedRoot.value)
|
const init = await startAgent(task.value.trim(), selectedPoolId.value, selectedRoot.value,
|
||||||
|
selectedExecutorId.value)
|
||||||
if (init.workspace) selectedRoot.value = init.workspace
|
if (init.workspace) selectedRoot.value = init.workspace
|
||||||
_watch = watchAgent(init.request_id)
|
_watch = watchAgent(init.request_id)
|
||||||
_watch.subscribe({
|
_watch.subscribe({
|
||||||
@@ -481,6 +515,37 @@ onMounted(async () => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
|
.ev-phase {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px 14px;
|
||||||
|
margin: 12px auto 8px;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 90%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ph-plan { color: #1e40af; background: #dbeafe; }
|
||||||
|
.ph-execute { color: #166534; background: #dcfce7; }
|
||||||
|
.ph-review { color: #7c2d12; background: #ffedd5; }
|
||||||
|
.ev-msg { margin-bottom: 8px; }
|
||||||
|
.ev-msgbody {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.msg-planner { background: #eff6ff; color: #1e3a8a; border: 1px solid #bfdbfe; }
|
||||||
|
.msg-executor { background: #f0fdf4; color: #14532d; border: 1px solid #bbf7d0; }
|
||||||
|
.executor-select { flex: 1; max-width: 340px; }
|
||||||
.ev-card {
|
.ev-card {
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@@ -118,3 +118,4 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
|
|||||||
| T23 | 工作区选择:/agent/fs 目录浏览 + /agent/workspaces 最近列表 + /agent 带 workspace | ✅ 完成 | T23 |
|
| T23 | 工作区选择:/agent/fs 目录浏览 + /agent/workspaces 最近列表 + /agent 带 workspace | ✅ 完成 | T23 |
|
||||||
| 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 |
|
||||||
|
|||||||
@@ -95,3 +95,17 @@
|
|||||||
|
|
||||||
`config/model_pool.json`(池持久化)、`agent_runs/{id}/`(events.jsonl + status.json)、
|
`config/model_pool.json`(池持久化)、`agent_runs/{id}/`(events.jsonl + status.json)、
|
||||||
`agent_workspace/`(智能体默认工作区,可在设置 agent.workspace_dir 调整)。
|
`agent_workspace/`(智能体默认工作区,可在设置 agent.workspace_dir 调整)。
|
||||||
|
|
||||||
|
## 7. 增补:两级智能体(D7,T26)
|
||||||
|
|
||||||
|
- **决策 D7(价位协作延伸到智能体形态)**:`POST /agent` 支持 `executor_pool_id`——
|
||||||
|
规划者(大模型:agent 角色或经典 Architect 设置)负责拆解指令与审查裁决;
|
||||||
|
执行者(本地小模型:llama_server/openai 池条目)负责工具轮;中间状态写
|
||||||
|
`agent_runs/{id}/handoff.json`(智能体版"交流文本":instructions/acceptance/exchanges)。
|
||||||
|
- **协议**:规划者两阶段 JSON(plan: instructions+acceptance;review: verdict done|redo +
|
||||||
|
reply_to_executor + final_answer),解析失败回喂重试一次再降级(对齐仓库 JSON 纪律);
|
||||||
|
redo 时裁决意见作为执行者下一轮指令,交接上限 `agent.max_handoffs`(默认 2)。
|
||||||
|
- **护栏**:规划者与执行者不得为同一池条目;执行轮 token 与整体 token_cap 共享预算;
|
||||||
|
ToolLoop 内层循环 `emit_final=False`,终态事件由外层编排统一发出(防前端 SSE 提前收口)。
|
||||||
|
- **事件**:新增 `phase`(plan/execute/review,带模型名)与 `message`(planner/executor 正文)
|
||||||
|
两类事件,前端以阶段徽标 + 双色消息卡渲染。
|
||||||
|
|||||||
@@ -122,3 +122,15 @@
|
|||||||
- **测试**:全量 274 passed(新增 12:tools 扩展 7 + 工作区选择 5)。
|
- **测试**:全量 274 passed(新增 12:tools 扩展 7 + 工作区选择 5)。
|
||||||
- **安全边界(论文可写)**:文件操作相对所选根关押;shell 独立开关默认关;
|
- **安全边界(论文可写)**:文件操作相对所选根关押;shell 独立开关默认关;
|
||||||
唯一性约束防覆盖式误编辑——"能力开放 + 边界收窄"的设计权衡可作一节。
|
唯一性约束防覆盖式误编辑——"能力开放 + 边界收窄"的设计权衡可作一节。
|
||||||
|
|
||||||
|
### 5.5 增补(同日):两级智能体(T26,路线 B 落地)
|
||||||
|
|
||||||
|
- **形态**:大模型(规划者)拆解任务出指令 JSON → 本地小模型(执行者)跑工具轮并汇报 →
|
||||||
|
大模型审查裁决 done/redo(有界交接,默认 2 轮)→ 最终答复。交接状态落
|
||||||
|
`handoff.json`(智能体版交流文本),SSE 新增 phase/message 事件实时可视化。
|
||||||
|
- **测试**:+3 项(done 流 / redo 流 / 执行者故障),全量 277 passed。
|
||||||
|
- **事故与修复**:发现 `test_config_get_put_reset` 调 /config/reset 会清空真实
|
||||||
|
settings.json——此前全量跑测把用户在设置页配置的 API key 抹掉了。已修复测试隔离
|
||||||
|
(备份/恢复),并向用户说明需重配 key。
|
||||||
|
- **运维提醒**:网关用 taskkill /T 停止会连带杀掉其启动的 llama-server 子进程;
|
||||||
|
重启网关后需在设置页重新启动 llama-server。
|
||||||
|
|||||||
Reference in New Issue
Block a user