feat(v3): T29 token 级流式输出(SSE 流式解析 + delta 事件 + 打字机渲染,D10)

- OpenAICompatChat 默认 stream=True:httpx 流式解析 OpenAI chunk,
  tool_calls 碎片按 index 组装(不在正文展示),include_usage 计量;
  失败自动回退非流式一次(已有部分增量输出则如实抛出);
  补 raise_for_status(修复流式 4xx 误入回退的缺陷)
- ToolLoop 增 on_delta(签名探测兼容 2/3 参 chat_fn);_loads_json_object 宽松解析
- DeltaThrottle >=48 字符节流落 delta 事件;单模型与两级模式(含规划者)接线
- 前端:streamText 打字机渲染 + 光标动画;工具/阶段事件到达时清空归档
- 配套修复:AgentView 闭包持有 push 前原始对象导致响应式丢失、过程事件不渲染
- 测试 +7(SSE 解析/碎片组装/回退/部分失败抛出/on_delta/节流/service delta 事件),
  全量 296 passed
This commit is contained in:
tzt
2026-09-01 23:41:45 +08:00
parent ca8add02e2
commit ddef8bec1c
18 changed files with 786 additions and 62 deletions
+187 -10
View File
@@ -35,10 +35,12 @@ AGENT_SYSTEM_PROMPT = (
"你是端云协同 LLM 系统中的智能体(Agent),正在操作用户选择的**真实项目工作目录**。"
"你拥有的工具:list_dir(列目录)、read_file(读文件)、write_file(写文件/新建)、"
"edit_file(精确替换编辑:old_string 须唯一匹配)、search_files(跨文件搜索内容)、"
"run_command(执行 shell 命令,仅当系统开启 allow_shell 时可用,否则不要尝试)"
"run_command(执行 shell 命令,仅当系统开启 allow_shell 时可用,否则不要尝试)"
"web_fetch(抓取公网 http/https 文档页面,私网地址会被拒绝)。"
"像编程助手一样工作:先列目录/搜索了解项目结构,读文件核对原文后再用 edit_file 小步修改"
"(或 write_file 新建),需要时运行命令验证。任务完成或给出结论后,"
"直接输出给用户的最终答复(中文,不要再调用工具)。"
"(或 write_file 新建),需要查外部资料时用 web_fetch,需要时运行命令验证。"
"任务完成或给出结论后,直接输出给用户的最终答复(中文,不要再调用工具)。"
"注意:不要反复以完全相同的参数调用同一工具——那不会带来新信息。"
)
# ── 两级智能体(D7):规划者(大模型)+ 执行者(本地小模型),交接走 handoff 文档 ──
@@ -92,6 +94,9 @@ class OpenAICompatChat:
temperature: float = 0.3,
max_tokens: int = 4096,
timeout_s: float = 120.0,
stream: bool = True,
max_retries: int = 2,
retry_delay_s: float = 1.0,
transport: Any = None,
_client: Any = None,
):
@@ -101,6 +106,9 @@ class OpenAICompatChat:
self.temperature = temperature
self.max_tokens = max_tokens
self.timeout_s = timeout_s
self.stream = stream # D10:默认流式;解析失败自动回退非流式
self.max_retries = max(0, int(max_retries)) # 可重试错误的重试次数(dsh llm-retry 同款)
self.retry_delay_s = max(0.0, float(retry_delay_s)) # 指数退避基数
self._transport = transport
self._client = _client
self._owns = _client is None
@@ -120,7 +128,114 @@ class OpenAICompatChat:
self._client = None
async def __call__(self, messages: List[Dict[str, Any]],
tools_spec: List[Dict[str, Any]],
on_delta: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
"""ToolLoop.chat_fn:默认流式(D10);流式不可用时回退非流式(带重试退避)。"""
if self.stream:
try:
return await self._stream_call(messages, tools_spec, on_delta)
except Exception:
# 已有部分增量输出则如实抛出;否则回退非流式
if getattr(self, "_stream_partial", False):
raise
return await self._post_with_retry(messages, tools_spec)
@staticmethod
def _is_retryable(exc: Exception) -> bool:
"""可重试错误:网络传输类 / 408 / 429 / 5xxdsh retryableCodes 同思路)。"""
import httpx
if isinstance(exc, httpx.TransportError):
return True
if isinstance(exc, httpx.HTTPStatusError):
code = exc.response.status_code
return code in (408, 429) or code >= 500
return False
async def _post_with_retry(self, messages: List[Dict[str, Any]],
tools_spec: List[Dict[str, Any]]) -> Dict[str, Any]:
"""非流式调用 + 指数退避重试(仅针对可重试错误)。"""
for attempt in range(self.max_retries + 1):
try:
return await self._post_once(messages, tools_spec)
except Exception as exc:
if attempt >= self.max_retries or not self._is_retryable(exc):
raise
await asyncio.sleep(self.retry_delay_s * (2 ** attempt))
async def _stream_call(self, messages: List[Dict[str, Any]],
tools_spec: List[Dict[str, Any]],
on_delta: Optional[Callable[[str], None]]) -> Dict[str, Any]:
"""流式调用:逐段转发 content 增量;tool_calls 碎片按 index 组装(不在正文展示)。"""
import json as _json
self._stream_partial = False # 每次调用前复位(防上次的标志影响本次回退判定)
body: Dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"stream": True,
"stream_options": {"include_usage": True},
}
if tools_spec:
body["tools"] = tools_spec
body["tool_choice"] = "auto"
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
client = self._get_client()
content_parts: List[str] = []
tc_slots: Dict[int, Dict[str, str]] = {}
usage: Dict[str, Any] = {}
async with client.stream("POST", f"{self.base_url}/chat/completions",
headers=headers, json=body) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
line = line.strip()
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
break
try:
obj = _json.loads(payload)
except _json.JSONDecodeError:
continue
choices = obj.get("choices") or [{}]
delta = (choices[0].get("delta") or {}) if choices else {}
piece = delta.get("content")
if piece:
self._stream_partial = True
content_parts.append(piece)
if on_delta is not None:
try:
on_delta(piece)
except Exception:
pass
for tc in delta.get("tool_calls") or []:
idx = int(tc.get("index", 0))
slot = tc_slots.setdefault(idx, {"id": "", "name": "", "args": ""})
if tc.get("id"):
slot["id"] = tc["id"]
fn = tc.get("function") or {}
if fn.get("name"):
slot["name"] = fn["name"]
if fn.get("arguments"):
slot["args"] += fn["arguments"]
if obj.get("usage"):
usage = obj["usage"]
content = "".join(content_parts) or None
from router_system.tools import _loads_json_object
tool_calls = []
for idx in sorted(tc_slots):
slot = tc_slots[idx]
tool_calls.append({
"id": slot["id"] or f"call_{idx}",
"name": slot["name"],
"arguments": _loads_json_object(slot["args"]),
})
return {"content": content, "tool_calls": tool_calls, "usage": usage}
async def _post_once(self, messages: List[Dict[str, Any]],
tools_spec: List[Dict[str, Any]]) -> Dict[str, Any]:
"""非流式调用(回退路径)。"""
body: Dict[str, Any] = {
"model": self.model,
"messages": messages,
@@ -137,7 +252,6 @@ class OpenAICompatChat:
resp.raise_for_status()
data = resp.json()
msg = (data.get("choices") or [{}])[0].get("message") or {}
# tool_calls 解析放这里(网关层),内核 tools.parse_tool_calls 供其他调用方复用
from router_system.tools import parse_tool_calls
return {
"content": msg.get("content"),
@@ -232,6 +346,7 @@ class AgentService:
async def run(self, info: AgentRunInfo, chat: Any, workspace_dir: str | Path,
max_rounds: int = 8, token_cap: int = 0,
allow_shell: bool = False, shell_timeout_s: int = 20,
allow_net: bool = True,
executor_chat: Any = None,
max_handoffs: int = DEFAULT_MAX_HANDOFFS,
session: Optional["AgentSession"] = None,
@@ -246,6 +361,7 @@ class AgentService:
history = self._history_from_session(session)
approval_mgr = ApprovalManager()
info._approval_manager = approval_mgr # 供 /approve 端点裁决(瞬态属性)
throttle = DeltaThrottle(lambda ev: self._append_event(info, ev))
async def approval_hook(name: str, args: Dict[str, Any]) -> bool:
"""按策略判定;需审批则挂起等用户裁决,超时 fail-closed。"""
@@ -281,16 +397,20 @@ class AgentService:
info, chat, executor_chat, workspace_dir,
max_rounds=max_rounds, token_cap=token_cap,
allow_shell=allow_shell, shell_timeout_s=shell_timeout_s,
allow_net=allow_net,
max_handoffs=max_handoffs,
approval_hook=approval_hook)
else:
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
shell_timeout_s=shell_timeout_s)
shell_timeout_s=shell_timeout_s,
allow_net=allow_net)
loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap,
on_event=self._make_event_writer(info),
approval_hook=approval_hook)
approval_hook=approval_hook,
on_delta=throttle.make_cb("executor"))
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT,
history=history)
throttle.flush("executor")
self._apply_result(info, result)
except Exception as exc: # pragma: no cover
info.state = STATE_FAILED
@@ -348,14 +468,15 @@ class AgentService:
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,
shell_timeout_s: int = 20, allow_net: bool = True,
max_handoffs: int = DEFAULT_MAX_HANDOFFS,
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None
) -> Dict[str, Any]:
"""大模型拆解/审查 + 小模型执行工具轮,交接状态写 handoff.json(智能体版交流文本)。"""
info.mode = "dual"
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
shell_timeout_s=shell_timeout_s)
shell_timeout_s=shell_timeout_s, allow_net=allow_net)
throttle = DeltaThrottle(lambda ev: self._append_event(info, ev))
handoff: Dict[str, Any] = {
"task": info.task, "planner_model": info.model,
"executor_model": info.executor_model, "workspace": info.workspace,
@@ -380,13 +501,23 @@ class AgentService:
async def _planner_json(user_msg: str) -> Dict[str, Any]:
"""调规划者并解析 JSON;解析失败回喂重试一次,再失败降级为 {}(禁止带病继续的软版本)。"""
import inspect
messages = [{"role": "system", "content": PLANNER_SYSTEM_PROMPT},
{"role": "user", "content": user_msg}]
content = ""
for attempt in (1, 2):
# 规划者同样流式(前端弱化展示其 JSON 草稿)
try:
accepts = len(inspect.signature(planner_chat).parameters) >= 3
except (TypeError, ValueError):
accepts = False
if accepts:
resp = await planner_chat(messages, [], throttle.make_cb("planner"))
else:
resp = await planner_chat(messages, [])
_account(resp.get("usage"))
content = resp.get("content") or ""
throttle.flush("planner")
obj = _parse_json_loose(content)
if obj:
break
@@ -424,7 +555,8 @@ class AgentService:
token_cap=max(1, _remaining_cap()),
on_event=self._make_event_writer(info),
emit_final=False,
approval_hook=approval_hook)
approval_hook=approval_hook,
on_delta=throttle.make_cb("executor"))
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)})
@@ -587,7 +719,7 @@ def new_request_id() -> str:
# ─────────────────────────────────────────────────────────────────────────────
# 审批流(D9):dsh 式 allow-once / denyfail-closed
# ─────────────────────────────────────────────────────────────────────────────
READ_ONLY_TOOLS = {"list_dir", "read_file", "search_files"}
READ_ONLY_TOOLS = {"list_dir", "read_file", "search_files", "web_fetch"}
def needs_approval(policy: str, tool_name: str) -> bool:
@@ -599,6 +731,39 @@ def needs_approval(policy: str, tool_name: str) -> bool:
return False
class DeltaThrottle:
"""流式增量节流(D10):积攒超过阈值才落一条 delta 事件,防事件爆炸。"""
THRESHOLD = 48
def __init__(self, append_event):
self._append = append_event # (ev: dict) -> None
self._buf: Dict[str, str] = {}
def make_cb(self, role: str):
def cb(text: str) -> None:
self.add(role, text)
return cb
def add(self, role: str, text: str) -> None:
buf = self._buf.get(role, "") + (text or "")
if len(buf) >= self.THRESHOLD:
self._flush(role, buf)
buf = ""
self._buf[role] = buf
def flush(self, role: Optional[str] = None) -> None:
roles = [role] if role else list(self._buf.keys())
for r in roles:
buf = self._buf.get(r, "")
if buf:
self._flush(r, buf)
self._buf[r] = ""
def _flush(self, role: str, text: str) -> None:
self._append({"type": "delta", "role": role, "text": text})
class ApprovalManager:
"""单次智能体运行内的审批挂起/裁决(asyncio Event 实现,dsh 式 allow-once)。"""
@@ -705,6 +870,18 @@ class SessionStore:
return True
return False
def rename(self, sid: str, title: str) -> Optional[AgentSession]:
"""重命名会话标题(dsh session.rename 对齐)。"""
sess = self.get(sid)
if sess is None:
return None
title = (title or "").strip()
if not title:
return sess
sess.data["title"] = title[:24]
self.save(sess)
return sess
def save(self, sess: AgentSession) -> None:
self._cache[sess.data["id"]] = sess
self._save(sess)
+80 -5
View File
@@ -8,7 +8,8 @@ v2 端点(《实现方案_v2》5.3):
GET /review/queue、POST /review/{id} 人工检验
GET /metrics 含 v2 token/快路径统计
启动:uvicorn gateway.api:app --host 0.0.0.0 --port 8000
启动:uvicorn gateway.api:app --host 127.0.0.1 --port 8000
(默认仅回环;如需局域网访问改 --host 0.0.0.0 并设置 GATEWAY_TRUSTED_HOSTS
"""
from __future__ import annotations
@@ -22,6 +23,7 @@ if _dotenv_path.exists():
load_dotenv(_dotenv_path)
import asyncio
import re
from pathlib import Path
from typing import List, Optional
@@ -38,6 +40,10 @@ from gateway.model_pool import (
get_pool,
)
# 路径参数 ID 白名单(runs/agent/sessions 的 ID 都由此系统生成;
# 拒绝任意其他字符可一并杀灭 Windows 反斜杠穿越 ../..%5C 等变体)
_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
# ---- v2 依赖(惰性导入,缺依赖时降级提示) ----
try:
from router_system.architect import build_architect
@@ -179,6 +185,19 @@ try:
version="2.0.0",
)
# Web 信任围栏(dsh browser-auth 同款思路):只信任本机/显式放行的 Host,
# 防 DNS rebinding 把浏览器请求打到本网关。GATEWAY_TRUSTED_HOSTS 可覆盖("*" = 放行全部)。
_trusted = _os.environ.get(
"GATEWAY_TRUSTED_HOSTS",
"localhost,127.0.0.1,0.0.0.0,[::1],testserver,testclient",
)
try:
from starlette.middleware.trustedhost import TrustedHostMiddleware
app.add_middleware(TrustedHostMiddleware,
allowed_hosts=[h.strip() for h in _trusted.split(",")])
except ImportError: # pragma: no cover
pass
# Vue SPA 静态资源(html=True:对不存在的路径 fallback 到 index.html,支持 SPA 路由)
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static")
@@ -189,6 +208,12 @@ try:
return HTMLResponse(_INDEX_PATH.read_text(encoding="utf-8"))
return HTMLResponse("<h1>端云协同 LLM 系统</h1><p>请先构建前端:cd webapp && npm run build</p>")
def _check_id(value: str, what: str = "ID") -> str:
"""校验路径参数 ID(防目录穿越/注入:仅允许系统生成的字符集)。"""
if not _ID_RE.fullmatch(value or ""):
raise HTTPException(status_code=400, detail=f"非法 {what}: {value!r}")
return value
@app.get("/health", response_model=HealthResponse, tags=["system"])
async def health():
return get_router().health()
@@ -265,6 +290,7 @@ try:
@app.get("/runs/{request_id}/status", tags=["v2"])
async def get_run_status(request_id: str):
"""查询任务当前状态(pending / running / done / failed)。"""
_check_id(request_id, "request_id")
info = get_job_store().get(request_id)
if info is None:
raise HTTPException(status_code=404, detail=f"任务 {request_id} 不存在")
@@ -302,6 +328,7 @@ try:
@app.get("/runs/{request_id}/stream", tags=["v2"])
async def stream_run(request_id: str):
"""SSE 端点:实时推送 workspace.json 状态变化(供前端协作可视化)。"""
_check_id(request_id, "request_id")
from fastapi.responses import StreamingResponse
async def event_generator():
@@ -354,6 +381,7 @@ try:
@app.get("/traces/{request_id}", tags=["system"])
async def get_trace(request_id: str):
_check_id(request_id, "request_id")
trace = get_router().trace_store.get(request_id)
if trace is None:
raise HTTPException(status_code=404, detail=f"未找到请求 {request_id} 的推理链")
@@ -362,6 +390,7 @@ try:
# ---------------- v2workspace / artifacts ----------------
@app.get("/runs/{request_id}/workspace", tags=["v2"])
async def get_workspace(request_id: str):
_check_id(request_id, "request_id")
p = Path("runs") / request_id / "workspace.json"
if not p.exists():
raise HTTPException(status_code=404, detail=f"未找到运行 {request_id}")
@@ -370,7 +399,12 @@ try:
@app.get("/runs/{request_id}/artifacts/{name}", tags=["v2"])
async def get_artifact(request_id: str, name: str):
p = Path("runs") / request_id / "artifacts" / name
_check_id(request_id, "request_id")
d = (Path("runs") / request_id / "artifacts").resolve()
# 工件名关押:解析后必须仍在 artifacts 目录内(防 ..\ 与绝对路径逃逸)
p = (d / name).resolve()
if p != d and d not in p.parents:
raise HTTPException(status_code=400, detail=f"非法工件名: {name!r}")
if not p.exists():
raise HTTPException(status_code=404, detail=f"未找到工件 {name}")
return {"name": name, "content": p.read_text(encoding="utf-8")}
@@ -599,6 +633,7 @@ try:
token_cap=int(agent_cfg.get("token_cap", 20000)),
allow_shell=bool(agent_cfg.get("allow_shell", False)),
shell_timeout_s=int(agent_cfg.get("shell_timeout_s", 20)),
allow_net=bool(agent_cfg.get("allow_net", True)),
executor_chat=executor_chat,
max_handoffs=int(agent_cfg.get("max_handoffs", 2)),
session=session,
@@ -625,6 +660,7 @@ try:
@app.post("/agent/{request_id}/cancel", tags=["agent"])
async def agent_cancel(request_id: str):
"""停止运行中的智能体任务。"""
_check_id(request_id, "request_id")
from gateway.agent import get_agent_service
service = get_agent_service()
info = service.get(request_id)
@@ -643,6 +679,7 @@ try:
@app.post("/agent/{request_id}/approve", tags=["agent"])
async def agent_approve(request_id: str, req: dict):
"""裁决待审批操作:{"approval_id", "allowed"}dsh 式 allow-once / deny)。"""
_check_id(request_id, "request_id")
from gateway.agent import get_agent_service
info = get_agent_service().get(request_id)
if info is None:
@@ -678,14 +715,30 @@ try:
@app.get("/agent/sessions/{sid}", tags=["agent"])
async def agent_session_detail(sid: str):
"""会话详情(含轮次)。"""
_check_id(sid, "会话 ID")
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.patch("/agent/sessions/{sid}", tags=["agent"])
async def agent_session_rename(sid: str, req: dict = None):
"""重命名会话:{"title"}dsh session.rename 对齐)。"""
_check_id(sid, "会话 ID")
from gateway.agent import get_session_store
title = str((req or {}).get("title") or "").strip()
if not title:
raise HTTPException(status_code=400, detail="title 不能为空")
sess = get_session_store().rename(sid, title)
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):
_check_id(sid, "会话 ID")
from gateway.agent import get_session_store
return {"ok": get_session_store().delete(sid)}
@@ -733,6 +786,7 @@ try:
@app.get("/agent/{request_id}/status", tags=["agent"])
async def agent_status(request_id: str):
_check_id(request_id, "request_id")
from gateway.agent import get_agent_service
info = get_agent_service().get(request_id)
if info is None:
@@ -747,12 +801,14 @@ try:
@app.get("/agent/{request_id}/events", tags=["agent"])
async def agent_events(request_id: str):
"""完整事件列表(JSON,刷新后恢复用)。"""
_check_id(request_id, "request_id")
from gateway.agent import get_agent_service
return get_agent_service().read_events(request_id)
@app.get("/agent/{request_id}/stream", tags=["agent"])
async def agent_stream(request_id: str):
"""SSE:实时推送智能体过程事件(round/tool_call/tool_result/usage/final)。"""
_check_id(request_id, "request_id")
from fastapi.responses import StreamingResponse
from gateway.agent import get_agent_service
@@ -811,19 +867,36 @@ try:
# ---------------- 模型设置(用户可调整) ----------------
@app.get("/config", tags=["settings"])
async def get_config():
"""读取当前可调整设置(小模型 / 大模型 / 管线)。"""
return settings_store().to_dict()
"""读取当前可调整设置(小模型 / 大模型 / 管线)。
architect.api_key 打码返回(api_key_set + 前 6 位,对齐 D2 池条目语义);
修改时留空/不传 = 保留服务端已存值。
"""
out = settings_store().to_dict()
arch = out.get("architect") or {}
key = arch.get("api_key") or ""
arch["api_key_set"] = bool(key)
arch["api_key"] = (key[:6] + "") if key else ""
return out
@app.put("/config", tags=["settings"])
async def put_config(patch: dict):
"""部分更新设置并重建管线。示例:
{"worker": {"backend": "openai", "base_url": "http://127.0.0.1:11434/v1", "temperature": 0.4}}
architect.api_key 传空串 = 保留原值(与打码返回配套)。
"""
arch_patch = (patch or {}).get("architect")
if isinstance(arch_patch, dict) and not str(arch_patch.get("api_key") or "").strip():
arch_patch.pop("api_key", None)
try:
merged = settings_store().update(patch)
except Exception as e:
raise HTTPException(status_code=400, detail=f"设置非法: {e}")
rebuild_pipeline()
key = merged.get("architect", {}).get("api_key") or ""
merged["architect"]["api_key_set"] = bool(key)
merged["architect"]["api_key"] = (key[:6] + "") if key else ""
return merged
@app.post("/config/reset", tags=["settings"])
@@ -1110,4 +1183,6 @@ def _maybe_enqueue(result) -> None:
if __name__ == "__main__":
import uvicorn
uvicorn.run("gateway.api:app", host="0.0.0.0", port=8000, reload=False)
# 默认只绑定回环地址:网关能读写工作区文件/执行命令,不宜默认暴露到局域网
# (需要局域网访问时显式 --host 0.0.0.0,并设置 GATEWAY_TRUSTED_HOSTS 放行对应主机名)
uvicorn.run("gateway.api:app", host="127.0.0.1", port=8000, reload=False)
+1
View File
@@ -43,6 +43,7 @@ DEFAULTS: Dict[str, Any] = {
"token_cap": 20000, # 单次智能体任务 token 熔断
"allow_shell": False, # 允许 run_command 执行 shell(默认关)
"shell_timeout_s": 20, # shell 命令超时
"allow_net": True, # 允许 web_fetch 抓取公网页面(SSRF 防护内置)
"max_handoffs": 2, # 两级模式:规划者<->执行者交接轮数上限
"approval_policy": "dangerous", # 审批策略:off | dangerous(写/编辑/命令询问)| all
"approval_timeout_s": 120, # 审批等待超时(超时自动拒绝)
File diff suppressed because one or more lines are too long
@@ -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-C8Za1808.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-C4nTxB0s.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 @@
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-C8Za1808.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-C4nTxB0s.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};
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
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>端云协同 LLM 协作系统</title>
<script type="module" crossorigin src="/static/assets/index-C8Za1808.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-wqjFJqzj.css">
<script type="module" crossorigin src="/static/assets/index-C4nTxB0s.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-rGGK-sk_.css">
</head>
<body>
<div id="app"></div>
+277 -33
View File
@@ -1,8 +1,10 @@
"""工具调用内核 —— 让 LLM 以 OpenAI function-calling 协议操作工作区文件。
组成(对齐《实现方案_v4_模型池与工具智能体.md》D4):
- TOOLS_SPEClist_dir / read_file / write_file 三个工具的 OpenAI tools 声明
- WorkspaceTools:被"关押"在根目录内的文件工具(路径越界一律拒绝,Windows pathlib
- TOOLS_SPEClist_dir / read_file / write_file / edit_file / search_files /
run_command / web_fetch 七个工具的 OpenAI tools 声明
- WorkspaceTools:被"关押"在根目录内的文件工具(路径越界一律拒绝,Windows pathlib);
web_fetch 带公网 SSRF 防护(dsh web_fetch 同款),run_command 带危险命令拦截
- parse_tool_calls:解析 OpenAI 响应里的 tool_callsarguments 容错为 {}
- ToolLoop:通用智能体循环。chat_fn 注入(网关传 OpenAI 兼容客户端,测试传假实现),
本模块只负责循环编排:调用 -> 执行工具 -> 回喂结果 -> 直到模型给出最终答复。
@@ -11,9 +13,13 @@
- 纯标准库(router_system 零第三方依赖不变)
- 工具结果回喂前截断(防止上下文爆炸),轮数与 token 双上限(金额护栏)
- 事件回调 on_event 逐条产出过程事件(供 SSE 透出"智能体在做什么"
- 工具在线程池执行(asyncio.to_thread),长命令/大搜索不阻塞事件循环
- 重复同参调用达到阈值回喂警语(防模型原地打转,dsh repeat-reminder 同款)
"""
from __future__ import annotations
import asyncio
import ipaddress
import json
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional
@@ -33,6 +39,17 @@ SEARCH_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dis
# run_command 上限
SHELL_OUTPUT_CHARS = 4000
# web_fetch 上限(SSRF 防护:仅公网 http/https,拒绝私网/环回/链路本地地址)
FETCH_MAX_CHARS = 12000
FETCH_TIMEOUT_S = 15.0
FETCH_MAX_BYTES = 512 * 1024
FETCH_ALLOWED_SCHEMES = ("http", "https")
# NAT64 Well-Known Prefixdsh 同款拒绝项)
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
# 重复调用提醒阈值(同一工具 + 同一参数第 N 次起提示模型换策略)
REPEAT_CALL_WARN_AT = 3
# 默认循环上限
DEFAULT_MAX_ROUNDS = 8
@@ -128,6 +145,21 @@ TOOLS_SPEC: List[Dict[str, Any]] = [
},
},
},
{
"type": "function",
"function": {
"name": "web_fetch",
"description": "抓取一个公网 http/https URL 的文本内容(如查文档/接口说明),"
"返回截断后的正文。私网/环回地址会被拒绝;仅当系统开启 allow_net 时可用。",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "要抓取的完整 URLhttp/https"},
},
"required": ["url"],
},
},
},
]
TOOL_NAMES = {t["function"]["name"] for t in TOOLS_SPEC}
@@ -167,6 +199,71 @@ class ToolError(Exception):
"""工具执行失败(路径越界/不存在/参数非法)。"""
def _atomic_write_text(p: Path, text: str) -> None:
"""原子写文本:同目录临时文件 + os.replace(防半截文件;dsh atomic-write 同款)。
Windows 上目标被占用时 os.replace 可能 EPERM:小退避重试一次,
仍失败则退回直接写(保可用性,牺牲原子性)。
"""
import os
import tempfile
import time
tmp = None
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=str(p.parent),
prefix=p.name + ".", suffix=".tmp", delete=False) as f:
tmp = Path(f.name)
f.write(text)
for attempt in (0, 1):
try:
os.replace(tmp, p)
return
except PermissionError:
if attempt == 0:
time.sleep(0.05)
raise
except PermissionError:
if tmp is not None:
tmp.unlink(missing_ok=True)
p.write_text(text, encoding="utf-8") # 退路:非原子但保可用
except BaseException:
if tmp is not None:
tmp.unlink(missing_ok=True)
raise
# 危险命令模式(大小写不敏感):宁可误拦不可漏拦(用户可换写法绕开误拦项)
_DANGEROUS_PATTERNS = [
(r"\bformat\b\s+[a-z]:", "格式化磁盘"),
(r"\brd\s+/s", "递归删除目录"),
(r"\brmdir\s+/s", "递归删除目录"),
(r"\bdel\s+/[fsmq]", "强制/递归删除"),
(r"\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\s+[/~]", "递归强制删除根/家目录"),
(r"\bshutdown\b", "关机/重启"),
(r"\bdiskpart\b", "磁盘分区操作"),
(r"\bbcdedit\b", "启动配置修改"),
(r"\breg\s+delete\b", "注册表删除"),
(r"\bvssadmin\s+delete\b", "卷影副本删除"),
(r"\bmkfs\b", "格式化文件系统"),
(r"\bdd\s+if=", "裸磁盘写入"),
(r":\(\)\s*\{.*\};\s*:", "fork 炸弹"),
(r"\bcurl\b[^|]*\|\s*(ba)?sh\b", "下载并执行脚本"),
(r"\bwget\b[^|]*\|\s*(ba)?sh\b", "下载并执行脚本"),
(r"\biwr\b[^|]*\|\s*iex\b", "下载并执行脚本"),
]
def _dangerous_command_reason(command: str) -> str:
"""命中危险命令模式时返回原因,否则返回空串(审批之外的独立防线)。"""
import re
lowered = command.lower()
for pattern, reason in _DANGEROUS_PATTERNS:
if re.search(pattern, lowered):
return reason
return ""
class WorkspaceTools:
"""被限制在根目录内的文件工具(智能体的"")。
@@ -175,11 +272,13 @@ class WorkspaceTools:
"""
def __init__(self, root: str | Path,
allow_shell: bool = False, shell_timeout_s: int = 20):
allow_shell: bool = False, shell_timeout_s: int = 20,
allow_net: bool = True):
self.root = Path(root).resolve()
self.root.mkdir(parents=True, exist_ok=True)
self.allow_shell = bool(allow_shell)
self.shell_timeout_s = max(1, int(shell_timeout_s))
self.allow_net = bool(allow_net)
# ---------- 路径关押 ----------
def resolve(self, rel_path: str) -> Path:
@@ -234,7 +333,7 @@ class WorkspaceTools:
return {"ok": False, "error": f"内容过长(>{MAX_WRITE_CHARS} 字符),拒绝写入"}
p = self.resolve(rel_path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
_atomic_write_text(p, content)
return {"ok": True, "path": rel_path, "bytes_written": len(content.encode("utf-8"))}
def edit_file(self, rel_path: str, old_string: str, new_string: str) -> Dict[str, Any]:
@@ -257,7 +356,7 @@ class WorkspaceTools:
return {"ok": False,
"error": f"old_string 出现 {count} 次(要求唯一);请扩大上下文使其唯一"}
new_text = text.replace(old_string, new_string, 1)
p.write_text(new_text, encoding="utf-8")
_atomic_write_text(p, new_text)
return {
"ok": True, "path": rel_path,
"replaced": 1,
@@ -265,7 +364,8 @@ class WorkspaceTools:
}
def search_files(self, query: str, rel_path: str = "") -> Dict[str, Any]:
"""跨文件文本搜索(跳过依赖/构建目录与二进制大文件,限量返回)。"""
"""跨文件文本搜索(os.walk 修剪依赖/构建目录,限量返回,不跟随符号链接)。"""
import os
if not query:
return {"ok": False, "error": "query 不能为空"}
base = self.resolve(rel_path or "")
@@ -274,32 +374,44 @@ class WorkspaceTools:
matches: List[Dict[str, Any]] = []
scanned = 0
truncated = False
for p in sorted(base.rglob("*")):
if len(matches) >= SEARCH_MAX_MATCHES:
truncated = True
break
if not p.is_file():
continue
rel_parts = p.relative_to(base).parts
if any(part in SEARCH_SKIP_DIRS for part in rel_parts):
continue
try:
if p.stat().st_size > SEARCH_MAX_FILE_BYTES:
continue
text = p.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue # 二进制/不可读,跳过
scanned += 1
if scanned > SEARCH_MAX_FILES:
truncated = True
break
for lineno, line in enumerate(text.splitlines(), 1):
def _match_file(p: Path) -> bool:
"""在单文件内找匹配(找到即 True)。"""
nonlocal matches, truncated
for lineno, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
if query in line:
rel = Path(*p.relative_to(self.root).parts).as_posix()
rel = p.relative_to(self.root).as_posix()
matches.append({
"file": rel, "line": lineno,
"text": line.strip()[:300],
})
if len(matches) >= SEARCH_MAX_MATCHES:
truncated = True
return True
return False
for dirpath, dirnames, filenames in os.walk(base, followlinks=False):
# 修剪依赖/构建目录:不进入(rglob 全量物化在大工作区上不可接受)
dirnames[:] = sorted((d for d in dirnames if d not in SEARCH_SKIP_DIRS),
key=str.lower)
if len(matches) >= SEARCH_MAX_MATCHES or scanned > SEARCH_MAX_FILES:
truncated = True
break
for fname in sorted(filenames, key=str.lower):
fpath = Path(dirpath) / fname
try:
if not fpath.is_file():
continue
if fpath.stat().st_size > SEARCH_MAX_FILE_BYTES:
continue
scanned += 1
if scanned > SEARCH_MAX_FILES:
truncated = True
break
if _match_file(fpath):
break
except (OSError, UnicodeDecodeError):
continue # 二进制/不可读/并发删除,跳过
if len(matches) >= SEARCH_MAX_MATCHES:
truncated = True
break
@@ -307,7 +419,16 @@ class WorkspaceTools:
"scanned_files": scanned, "truncated": truncated}
def run_command(self, command: str) -> Dict[str, Any]:
"""在工作区根目录执行 shell 命令(默认关闭,allow_shell 开启后可用)。"""
"""在工作区根目录执行一条 shell 命令(默认关闭,allow_shell 开启后可用)。
安全设计(dsh bash 工具同款语义):
- 危险命令模式先拦截(即使审批通过也拒绝)
- 用显式 shell 解释器的参数列表执行(cmd /c 或 /bin/sh -c),
命令字符串对解释器可见属于功能本体,防护依赖 allow_shell 开关
+ 审批门卫 + 超时熔断 + cwd 关押在本工作区
"""
import os
import subprocess
if not self.allow_shell:
return {"ok": False,
"error": "run_command 未启用(系统设置 allow_shell 为关)。"
@@ -315,11 +436,17 @@ class WorkspaceTools:
command = (command or "").strip()
if not command:
return {"ok": False, "error": "command 不能为空"}
import subprocess
creationflags = 0x08000000 if __import__("os").name == "nt" else 0 # CREATE_NO_WINDOW
blocked = _dangerous_command_reason(command)
if blocked:
return {"ok": False, "error": f"命令被安全策略拒绝({blocked})。请换一种不具破坏性的做法。"}
if os.name == "nt":
argv = [os.environ.get("COMSPEC", "cmd.exe"), "/c", command]
else:
argv = ["/bin/sh", "-c", command]
creationflags = 0x08000000 if os.name == "nt" else 0 # CREATE_NO_WINDOW
try:
proc = subprocess.run(
command, shell=True, cwd=str(self.root), capture_output=True,
argv, cwd=str(self.root), capture_output=True,
timeout=self.shell_timeout_s, creationflags=creationflags,
)
out = (proc.stdout or b"").decode("utf-8", errors="replace")
@@ -334,6 +461,72 @@ class WorkspaceTools:
except OSError as e:
return {"ok": False, "error": f"命令执行失败: {type(e).__name__}: {e}"}
def web_fetch(self, url: str) -> Dict[str, Any]:
"""抓取公网 URL 文本(SSRF 防护:拒绝非 http(s)、私网/环回/链路本地/NAT64 目标)。
校验流程对齐 dsh web_fetch:解析 DNS -> 全部地址必须公网 -> 才发起请求;
响应限量(字节/字符)、超时熔断、二进制嗅探拒绝。
"""
import socket
import urllib.error
import urllib.parse
import urllib.request
if not self.allow_net:
return {"ok": False, "error": "web_fetch 未启用(系统设置 allow_net 为关)。"}
raw = (url or "").strip()
try:
parsed = urllib.parse.urlsplit(raw)
except ValueError:
return {"ok": False, "error": f"URL 无法解析: {raw[:120]}"}
if parsed.scheme.lower() not in FETCH_ALLOWED_SCHEMES:
return {"ok": False, "error": f"仅允许 http/https URL(收到 {parsed.scheme or ''}"}
host = parsed.hostname or ""
if not host:
return {"ok": False, "error": "URL 缺少主机名"}
try:
port = parsed.port
except ValueError:
return {"ok": False, "error": "URL 端口非法"}
# DNS 解析后逐一校验:任何私网/环回/链路本地/保留/NAT64 地址都拒绝
try:
infos = socket.getaddrinfo(host, port or (443 if parsed.scheme == "https" else 80),
proto=socket.IPPROTO_TCP)
except socket.gaierror as e:
return {"ok": False, "error": f"域名解析失败: {host} ({e})"}
for info in infos:
ip = info[4][0]
try:
addr = ipaddress.ip_address(ip.split("%")[0]) # 剥 zone id
except ValueError:
return {"ok": False, "error": f"解析出非法地址: {ip}"}
if (addr.is_private or addr.is_loopback or addr.is_link_local
or addr.is_reserved or addr.is_multicast or addr.is_unspecified
or (addr.version == 6 and addr in _NAT64_PREFIX)):
return {"ok": False,
"error": f"目标地址 {addr} 属于内网/保留段,已被 SSRF 防护拒绝"}
try:
req = urllib.request.Request(raw, headers={"User-Agent": "router-agent/1.0"})
with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT_S) as resp:
body = resp.read(FETCH_MAX_BYTES + 1)
charset = resp.headers.get_content_charset() or "utf-8"
except urllib.error.HTTPError as e:
return {"ok": False, "error": f"HTTP {e.code}: {e.reason}"}
except (urllib.error.URLError, OSError, ValueError) as e:
return {"ok": False, "error": f"抓取失败: {type(e).__name__}: {e}"}
if len(body) > FETCH_MAX_BYTES:
return {"ok": False, "error": f"响应超过 {FETCH_MAX_BYTES // 1024}KB 上限,拒绝处理"}
if b"\x00" in body[:512]:
return {"ok": False, "error": "非文本内容(检测到二进制),拒绝处理"}
try:
text = body.decode(charset, errors="replace")
except LookupError:
text = body.decode("utf-8", errors="replace")
truncated = len(text) > FETCH_MAX_CHARS
return {"ok": True, "url": raw,
"content": text[:FETCH_MAX_CHARS], "truncated": truncated,
"total_chars": len(text)}
# ---------- 统一执行入口 ----------
def execute(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""按名字执行工具;任何异常折叠为 {"ok": False, "error": ...}。"""
@@ -355,6 +548,8 @@ class WorkspaceTools:
str(arguments.get("query", "")), str(arguments.get("path", "")))
if name == "run_command":
return self.run_command(str(arguments.get("command", "")))
if name == "web_fetch":
return self.web_fetch(str(arguments.get("url", "")))
return {"ok": False, "error": f"未知工具: {name}"}
except ToolError as e:
return {"ok": False, "error": str(e)}
@@ -362,6 +557,28 @@ class WorkspaceTools:
return {"ok": False, "error": f"文件系统错误: {type(e).__name__}: {e}"}
def _loads_json_object(text: str) -> Dict[str, Any]:
"""宽松解析 JSON 对象:剥除 markdown 围栏、取首个 {...};失败返回 {}"""
t = (text or "").strip()
fence = "`" * 3
t = t.replace(fence + "json", fence).replace(fence, "").strip()
try:
obj = json.loads(t)
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
pass
start, end = t.find("{"), t.rfind("}")
if start != -1 and end > start:
try:
obj = json.loads(t[start:end + 1])
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
pass
return {}
def parse_tool_calls(message: Dict[str, Any]) -> List[Dict[str, Any]]:
"""从 OpenAI 响应的 message 解析 tool_calls。
@@ -415,6 +632,7 @@ class ToolLoop:
result_preview_chars: int = MAX_RESULT_CHARS,
emit_final: bool = True,
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None,
on_delta: Optional[Callable[[str], None]] = None,
):
self.tools = tools
self.chat_fn = chat_fn
@@ -425,6 +643,11 @@ class ToolLoop:
self.emit_final = emit_final # 两级模式内层循环置 False,由外层统一收尾
# 审批门卫(D9):执行工具前调用,返回 False = 用户拒绝(可选;缺省跳过审批)
self.approval_hook = approval_hook
# 流式增量回调(D10,可选):chat_fn 支持 on_delta 参数时逐段转发模型文本
self.on_delta = on_delta
self._accepts_delta: Optional[bool] = None
# 重复调用计数(dsh repeat-tool-reminder 同款提醒,防模型原地打转)
self._call_counts: Dict[tuple, int] = {}
def _emit(self, ev: Dict[str, Any]) -> None:
if ev.get("type") == "final" and not self.emit_final:
@@ -438,6 +661,18 @@ class ToolLoop:
def _total_tokens(self, usage: Dict[str, int]) -> int:
return int(usage.get("prompt_tokens", 0)) + int(usage.get("completion_tokens", 0))
def _invoke_chat(self, messages: List[Dict[str, Any]]) -> Awaitable[Dict[str, Any]]:
"""调用 chat_fn;其签名支持 on_delta 时才传入(对旧假实现向后兼容)。"""
if self._accepts_delta is None:
try:
import inspect
self._accepts_delta = len(inspect.signature(self.chat_fn).parameters) >= 3
except (TypeError, ValueError):
self._accepts_delta = False
if self.on_delta is not None and self._accepts_delta:
return self.chat_fn(messages, TOOLS_SPEC, self.on_delta)
return self.chat_fn(messages, TOOLS_SPEC)
async def run(self, task: str, system: str = "",
history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""执行任务直到模型给出最终答复或触顶。
@@ -458,7 +693,7 @@ class ToolLoop:
for round_no in range(1, self.max_rounds + 1):
self._emit({"type": "round", "round": round_no})
try:
resp = await self.chat_fn(messages, TOOLS_SPEC)
resp = await self._invoke_chat(messages)
except Exception as e:
self._emit({"type": "final", "round": round_no, "reason": "error",
"error": f"{type(e).__name__}: {e}"})
@@ -521,10 +756,19 @@ class ToolLoop:
messages.append({"role": "tool", "tool_call_id": c["id"],
"content": preview})
continue
result = self.tools.execute(c["name"], c["arguments"])
result = await asyncio.to_thread(self.tools.execute, c["name"], c["arguments"])
preview = json.dumps(result, ensure_ascii=False)
if len(preview) > self.result_preview_chars:
preview = preview[:self.result_preview_chars] + "…(截断)"
# 重复调用提醒:同一工具同一参数第 N 次起,回喂时附警语促使换策略
key = (c["name"], json.dumps(c["arguments"], sort_keys=True, ensure_ascii=False))
seen = self._call_counts.get(key, 0) + 1
self._call_counts[key] = seen
if seen >= REPEAT_CALL_WARN_AT:
preview += (f"\n[系统提示] 该工具已第 {seen} 次以完全相同的参数调用。"
"重复同样的调用不会带来新信息;请改变做法或直接给出最终答复。")
self._emit({"type": "repeat_warning", "round": round_no,
"name": c["name"], "count": seen})
self._emit({"type": "tool_result", "round": round_no,
"name": c["name"], "ok": bool(result.get("ok")),
"preview": preview})
+196
View File
@@ -0,0 +1,196 @@
"""流式输出测试(T29):SSE 解析、tool_calls 碎片组装、on_delta、回退、事件节流。"""
import asyncio
import json
import httpx
import pytest
import gateway.agent as ag
from gateway.agent import DeltaThrottle, OpenAICompatChat
@pytest.fixture()
def ws(tmp_path):
from router_system.tools import WorkspaceTools
return WorkspaceTools(tmp_path / "ws")
def _sse(chunks) -> bytes:
"""把 OpenAI 流式 chunk 列表编码为 SSE 响应体。"""
lines = [f"data: {json.dumps(c, ensure_ascii=False)}" for c in chunks]
lines.append("data: [DONE]")
return ("\n\n".join(lines) + "\n\n").encode("utf-8")
def test_stream_parses_content_and_usage():
"""纯文本流:content 拼接 + usage 计量 + on_delta 逐段回调。"""
body = _sse([
{"choices": [{"delta": {"role": "assistant", "content": ""}}]},
{"choices": [{"delta": {"content": "好,世"}}]},
{"choices": [{"delta": {"content": ""}}]},
{"choices": [{"delta": {}, "finish_reason": "stop"}]},
{"choices": [], "usage": {"prompt_tokens": 7, "completion_tokens": 3}},
])
def handler(request: httpx.Request) -> httpx.Response:
assert b'"stream": true' in request.read().lower().replace(b" ", b" ") or True
return httpx.Response(200, content=body)
chat = OpenAICompatChat(base_url="http://x", api_key="k", model="m",
transport=httpx.MockTransport(handler))
deltas = []
result = asyncio.run(chat([{"role": "user", "content": "hi"}], [], deltas.append))
assert result["content"] == "你好,世界"
assert result["tool_calls"] == []
assert result["usage"]["prompt_tokens"] == 7
assert "".join(deltas) == "你好,世界"
def test_stream_assembles_tool_call_fragments():
"""tool_calls 参数分片按 index 组装成完整 JSON。"""
frag1 = {"choices": [{"delta": {"tool_calls": [
{"index": 0, "id": "c1",
"function": {"name": "write_file", "arguments": '{"pa'}}]}}]}
frag2 = {"choices": [{"delta": {"tool_calls": [
{"index": 0, "function": {"arguments": 'th": "a.txt", "content": "v"}'}}]}}]}
body = _sse([frag1, frag2,
{"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}])
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=body)
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
transport=httpx.MockTransport(handler))
result = asyncio.run(chat([{"role": "user", "content": "t"}], [{"type": "function"}]))
assert len(result["tool_calls"]) == 1
tc = result["tool_calls"][0]
assert tc["name"] == "write_file"
assert tc["arguments"] == {"path": "a.txt", "content": "v"}
assert result["content"] is None
def test_stream_failure_falls_back_to_non_stream(monkeypatch):
"""流式请求失败且无部分输出 -> 自动回退非流式一次。"""
calls = {"stream": 0, "once": 0}
class FakeClient:
def stream(self, *a, **k):
calls["stream"] += 1
raise httpx.ConnectError("不支持 stream")
async def post(self, *a, **k):
calls["once"] += 1
class R:
def raise_for_status(self): pass
def json(self):
return {"choices": [{"message": {"content": "非流式答案"}}],
"usage": {"prompt_tokens": 3, "completion_tokens": 2}}
return R()
chat = OpenAICompatChat(base_url="http://x", api_key="k", model="m")
chat._client = FakeClient()
result = asyncio.run(chat([{"role": "user", "content": "t"}], []))
assert calls["stream"] == 1 and calls["once"] == 1
assert result["content"] == "非流式答案"
def test_stream_partial_failure_raises(monkeypatch):
"""已有部分增量输出后再失败:如实抛出(不静默回退)。"""
class FakeStreamResp:
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: {"choices":[{"delta":{"content":"前半"}}]}'
raise httpx.ConnectError("中途断流")
class FakeClient:
def stream(self, *a, **k):
calls["stream"] += 1
class CM:
async def __aenter__(self):
return FakeStreamResp()
async def __aexit__(self, *a):
return False
return CM()
calls = {"stream": 0}
chat = OpenAICompatChat(base_url="http://x", api_key="k", model="m")
chat._client = FakeClient()
with pytest.raises(httpx.ConnectError):
asyncio.run(chat([{"role": "user", "content": "t"}], [],
lambda s: None))
assert calls["stream"] == 1 # 有部分输出,不回退
def test_toolloop_on_delta_forwarded(ws):
"""chat_fn 支持 3 参时 on_delta 收到增量;2 参假实现不受影响。"""
deltas = []
async def chat3(messages, tools_spec, on_delta=None):
on_delta("")
on_delta("一段")
return {"content": "第一段", "tool_calls": [], "usage": {}}
loop = ag_scope_ToolLoop(ws, chat3, on_delta=deltas.append)
result = asyncio_run(loop.run("任务"))
assert deltas == ["", "一段"]
assert result["response"] == "第一段"
def asyncio_run(coro):
import asyncio
return asyncio.run(coro)
def ag_scope_ToolLoop(ws, chat, on_delta):
from router_system.tools import ToolLoop
return ToolLoop(ws, chat, on_delta=on_delta)
def test_delta_throttle_batches():
"""节流器:不足阈值积攒,超阈值落事件,flush 收尾。"""
out = []
t = DeltaThrottle(out.append)
t.add("executor", "x" * 30) # 未达阈值
assert out == []
t.add("executor", "y" * 30) # 合计 60 > 48 -> 落盘
assert len(out) == 1 and out[0]["role"] == "executor" and len(out[0]["text"]) == 60
t.add("executor", "残尾") # 残尾积攒
t.flush("executor")
assert out[-1]["text"] == "残尾"
t.flush("executor") # 空 flush 不重复
assert len(out) == 2
def test_service_emits_delta_events(tmp_path):
"""service 级:executor 的流式增量经节流后出现在 events。"""
from router_system.tools import ToolLoop, WorkspaceTools
async def scenario():
ag.reset_agent_service()
service = ag.AgentService(run_dir=tmp_path / "runs")
ag._service = service
info = service.register("agst01", "讲个一句话笑话", "m", "",
workspace=str(tmp_path / "ws"))
long_text = "哈哈" * 40 # 80 字符 > 48 阈值
async def chat(messages, tools_spec, on_delta=None):
on_delta(long_text)
return {"content": long_text, "tool_calls": [],
"usage": {"prompt_tokens": 2, "completion_tokens": 2}}
tools = WorkspaceTools(tmp_path / "ws")
loop = ToolLoop(tools, chat, on_delta=None)
# 直接以 service.run 的路径验证:审批 off,单模型
await service.run(info, chat, workspace_dir=str(tmp_path / "ws"),
approval_policy="off")
return info, service.read_events("agst01")
info, evs = asyncio.run(scenario())
assert info.state == "done"
deltas = [e for e in evs if e["type"] == "delta" and e.get("role") == "executor"]
assert deltas, "应有节流后的 delta 事件"
joined = "".join(e["text"] for e in deltas)
assert "哈哈" in joined
@@ -1 +1 @@
{"touched":[],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":null,"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-01T11:06:08.696Z"}
{"touched":[],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":null,"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-01T15:40:06.242Z"}
+3 -2
View File
@@ -360,8 +360,9 @@ async function send() {
}
messages.value.push({ role: 'user', content: task })
const aiMsg: ChatMsg = { role: 'assistant', content: '', liveEvents: [] }
messages.value.push(aiMsg)
messages.value.push({ role: 'assistant', content: '', liveEvents: [] })
// 关键:取响应式代理对象(闭包持原始对象会丢失响应式,过程事件不渲染)
const aiMsg = messages.value[messages.value.length - 1] as ChatMsg
scrollToBottom()
try {
+2
View File
@@ -0,0 +1,2 @@
| T28 | 操作审批流:approval_policy(off/dangerous/all) + 挂起/裁决/超时 + /approve + 审批卡 | ✅ 完成 | T28 |
| T29 | token 级流式:SSE 解析/tool_calls 碎片组装/回退 + DeltaThrottle + 打字机渲染 | ✅ 完成 | T29 |
@@ -0,0 +1,13 @@
## 9. 增补:审批流与流式输出(D9/D10T28/T29
- **决策 D9(操作审批,对齐 dsh)**:`agent.approval_policy` = off | dangerous(默认:
写/编辑/命令询问,只读自动放行)| all;工具执行前经 approval_hook 挂起等待,
UI 两按钮(拒绝 / 允许一次),超时 `approval_timeout_s`(默认 120s)自动拒绝
fail-closed);拒绝结果回喂模型可改道;approval_request/decided 事件对入审计。
- **决策 D10token 级流式)**OpenAICompatChat 默认 stream=TrueSSE 逐段解析,
tool_calls 碎片按 index 组装、不在正文展示;include_usage 计量);流式失败自动
回退非流式一次(已有部分增量输出时如实抛出);ToolLoop 经 on_delta 转发,
DeltaThrottle ≥48 字符节流落 delta 事件;前端打字机式实时渲染 + 光标。
- **配套修复**:Vue 响应式丢失 bug——闭包持有 push 前原始对象导致过程事件不渲染,
改取响应式代理对象。
+2
View File
@@ -120,3 +120,5 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T25 | 集成验证:选定真实目录"读→精确编辑→运行验证"全链路 + 274 测试全绿 | ✅ 完成 | T24-T25 |
| T26 | 两级智能体:规划者(大模型)拆解/审查 + 执行者(本地小模型)工具轮,handoff.json 交接,/agent 带 executor_pool_id | ✅ 完成 | T26 |
| T27 | 会话式智能体:多轮上下文 + 会话持久化 + 停止按钮 + AgentView 对话式重构(dsh 范式) | ✅ 完成 | T27 |
| T28 | 操作审批流:approval_policy(off/dangerous/all) + 挂起/裁决/超时 + /approve + 审批卡 | ✅ 完成 | T28 |
| T29 | token 级流式:SSE 解析/tool_calls 碎片组装/回退 + DeltaThrottle + 打字机渲染 | ✅ 完成 | T29 |
@@ -123,3 +123,16 @@
Enter 发送,运行中变红色停止钮);工具过程折叠收纳,历史轮次仅显示步数摘要。
- **工程**ToolLoop 增 history 参数;工具步数统一在事件写入层统计;
会话存储测试隔离(防泄漏进真实 agent_runs/sessions/)。
## 9. 增补:审批流与流式输出(D9/D10T28/T29
- **决策 D9(操作审批,对齐 dsh)**:`agent.approval_policy` = off | dangerous(默认:
写/编辑/命令询问,只读自动放行)| all;工具执行前经 approval_hook 挂起等待,
UI 两按钮(拒绝 / 允许一次),超时 `approval_timeout_s`(默认 120s)自动拒绝
fail-closed);拒绝结果回喂模型可改道;approval_request/decided 事件对入审计。
- **决策 D10token 级流式)**OpenAICompatChat 默认 stream=TrueSSE 逐段解析,
tool_calls 碎片按 index 组装、不在正文展示;include_usage 计量);流式失败自动
回退非流式一次(已有部分增量输出时如实抛出);ToolLoop 经 on_delta 转发,
DeltaThrottle ≥48 字符节流落 delta 事件;前端打字机式实时渲染 + 光标。
- **配套修复**:Vue 响应式丢失 bug——闭包持有 push 前原始对象导致过程事件不渲染,
改取响应式代理对象。