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:
+189
-12
@@ -35,10 +35,12 @@ AGENT_SYSTEM_PROMPT = (
|
|||||||
"你是端云协同 LLM 系统中的智能体(Agent),正在操作用户选择的**真实项目工作目录**。"
|
"你是端云协同 LLM 系统中的智能体(Agent),正在操作用户选择的**真实项目工作目录**。"
|
||||||
"你拥有的工具:list_dir(列目录)、read_file(读文件)、write_file(写文件/新建)、"
|
"你拥有的工具:list_dir(列目录)、read_file(读文件)、write_file(写文件/新建)、"
|
||||||
"edit_file(精确替换编辑:old_string 须唯一匹配)、search_files(跨文件搜索内容)、"
|
"edit_file(精确替换编辑:old_string 须唯一匹配)、search_files(跨文件搜索内容)、"
|
||||||
"run_command(执行 shell 命令,仅当系统开启 allow_shell 时可用,否则不要尝试)。"
|
"run_command(执行 shell 命令,仅当系统开启 allow_shell 时可用,否则不要尝试)、"
|
||||||
|
"web_fetch(抓取公网 http/https 文档页面,私网地址会被拒绝)。"
|
||||||
"像编程助手一样工作:先列目录/搜索了解项目结构,读文件核对原文后再用 edit_file 小步修改"
|
"像编程助手一样工作:先列目录/搜索了解项目结构,读文件核对原文后再用 edit_file 小步修改"
|
||||||
"(或 write_file 新建),需要时运行命令验证。任务完成或给出结论后,"
|
"(或 write_file 新建),需要查外部资料时用 web_fetch,需要时运行命令验证。"
|
||||||
"直接输出给用户的最终答复(中文,不要再调用工具)。"
|
"任务完成或给出结论后,直接输出给用户的最终答复(中文,不要再调用工具)。"
|
||||||
|
"注意:不要反复以完全相同的参数调用同一工具——那不会带来新信息。"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── 两级智能体(D7):规划者(大模型)+ 执行者(本地小模型),交接走 handoff 文档 ──
|
# ── 两级智能体(D7):规划者(大模型)+ 执行者(本地小模型),交接走 handoff 文档 ──
|
||||||
@@ -92,6 +94,9 @@ class OpenAICompatChat:
|
|||||||
temperature: float = 0.3,
|
temperature: float = 0.3,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
timeout_s: float = 120.0,
|
timeout_s: float = 120.0,
|
||||||
|
stream: bool = True,
|
||||||
|
max_retries: int = 2,
|
||||||
|
retry_delay_s: float = 1.0,
|
||||||
transport: Any = None,
|
transport: Any = None,
|
||||||
_client: Any = None,
|
_client: Any = None,
|
||||||
):
|
):
|
||||||
@@ -101,6 +106,9 @@ class OpenAICompatChat:
|
|||||||
self.temperature = temperature
|
self.temperature = temperature
|
||||||
self.max_tokens = max_tokens
|
self.max_tokens = max_tokens
|
||||||
self.timeout_s = timeout_s
|
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._transport = transport
|
||||||
self._client = _client
|
self._client = _client
|
||||||
self._owns = _client is None
|
self._owns = _client is None
|
||||||
@@ -120,7 +128,114 @@ class OpenAICompatChat:
|
|||||||
self._client = None
|
self._client = None
|
||||||
|
|
||||||
async def __call__(self, messages: List[Dict[str, Any]],
|
async def __call__(self, messages: List[Dict[str, Any]],
|
||||||
tools_spec: List[Dict[str, Any]]) -> 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 / 5xx(dsh 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] = {
|
body: Dict[str, Any] = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
@@ -137,7 +252,6 @@ class OpenAICompatChat:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
msg = (data.get("choices") or [{}])[0].get("message") or {}
|
msg = (data.get("choices") or [{}])[0].get("message") or {}
|
||||||
# tool_calls 解析放这里(网关层),内核 tools.parse_tool_calls 供其他调用方复用
|
|
||||||
from router_system.tools import parse_tool_calls
|
from router_system.tools import parse_tool_calls
|
||||||
return {
|
return {
|
||||||
"content": msg.get("content"),
|
"content": msg.get("content"),
|
||||||
@@ -232,6 +346,7 @@ 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,
|
allow_shell: bool = False, shell_timeout_s: int = 20,
|
||||||
|
allow_net: bool = True,
|
||||||
executor_chat: Any = None,
|
executor_chat: Any = None,
|
||||||
max_handoffs: int = DEFAULT_MAX_HANDOFFS,
|
max_handoffs: int = DEFAULT_MAX_HANDOFFS,
|
||||||
session: Optional["AgentSession"] = None,
|
session: Optional["AgentSession"] = None,
|
||||||
@@ -246,6 +361,7 @@ class AgentService:
|
|||||||
history = self._history_from_session(session)
|
history = self._history_from_session(session)
|
||||||
approval_mgr = ApprovalManager()
|
approval_mgr = ApprovalManager()
|
||||||
info._approval_manager = approval_mgr # 供 /approve 端点裁决(瞬态属性)
|
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:
|
async def approval_hook(name: str, args: Dict[str, Any]) -> bool:
|
||||||
"""按策略判定;需审批则挂起等用户裁决,超时 fail-closed。"""
|
"""按策略判定;需审批则挂起等用户裁决,超时 fail-closed。"""
|
||||||
@@ -281,16 +397,20 @@ class AgentService:
|
|||||||
info, chat, executor_chat, workspace_dir,
|
info, chat, executor_chat, workspace_dir,
|
||||||
max_rounds=max_rounds, token_cap=token_cap,
|
max_rounds=max_rounds, token_cap=token_cap,
|
||||||
allow_shell=allow_shell, shell_timeout_s=shell_timeout_s,
|
allow_shell=allow_shell, shell_timeout_s=shell_timeout_s,
|
||||||
|
allow_net=allow_net,
|
||||||
max_handoffs=max_handoffs,
|
max_handoffs=max_handoffs,
|
||||||
approval_hook=approval_hook)
|
approval_hook=approval_hook)
|
||||||
else:
|
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,
|
||||||
|
allow_net=allow_net)
|
||||||
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),
|
||||||
approval_hook=approval_hook)
|
approval_hook=approval_hook,
|
||||||
|
on_delta=throttle.make_cb("executor"))
|
||||||
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT,
|
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT,
|
||||||
history=history)
|
history=history)
|
||||||
|
throttle.flush("executor")
|
||||||
self._apply_result(info, result)
|
self._apply_result(info, result)
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
info.state = STATE_FAILED
|
info.state = STATE_FAILED
|
||||||
@@ -348,14 +468,15 @@ class AgentService:
|
|||||||
async def run_dual(self, info: AgentRunInfo, planner_chat: Any, executor_chat: Any,
|
async def run_dual(self, info: AgentRunInfo, planner_chat: Any, executor_chat: Any,
|
||||||
workspace_dir: str | Path, max_rounds: int = 8,
|
workspace_dir: str | Path, max_rounds: int = 8,
|
||||||
token_cap: int = 0, allow_shell: bool = False,
|
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,
|
max_handoffs: int = DEFAULT_MAX_HANDOFFS,
|
||||||
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None
|
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""大模型拆解/审查 + 小模型执行工具轮,交接状态写 handoff.json(智能体版交流文本)。"""
|
"""大模型拆解/审查 + 小模型执行工具轮,交接状态写 handoff.json(智能体版交流文本)。"""
|
||||||
info.mode = "dual"
|
info.mode = "dual"
|
||||||
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, allow_net=allow_net)
|
||||||
|
throttle = DeltaThrottle(lambda ev: self._append_event(info, ev))
|
||||||
handoff: Dict[str, Any] = {
|
handoff: Dict[str, Any] = {
|
||||||
"task": info.task, "planner_model": info.model,
|
"task": info.task, "planner_model": info.model,
|
||||||
"executor_model": info.executor_model, "workspace": info.workspace,
|
"executor_model": info.executor_model, "workspace": info.workspace,
|
||||||
@@ -380,13 +501,23 @@ class AgentService:
|
|||||||
|
|
||||||
async def _planner_json(user_msg: str) -> Dict[str, Any]:
|
async def _planner_json(user_msg: str) -> Dict[str, Any]:
|
||||||
"""调规划者并解析 JSON;解析失败回喂重试一次,再失败降级为 {}(禁止带病继续的软版本)。"""
|
"""调规划者并解析 JSON;解析失败回喂重试一次,再失败降级为 {}(禁止带病继续的软版本)。"""
|
||||||
|
import inspect
|
||||||
messages = [{"role": "system", "content": PLANNER_SYSTEM_PROMPT},
|
messages = [{"role": "system", "content": PLANNER_SYSTEM_PROMPT},
|
||||||
{"role": "user", "content": user_msg}]
|
{"role": "user", "content": user_msg}]
|
||||||
content = ""
|
content = ""
|
||||||
for attempt in (1, 2):
|
for attempt in (1, 2):
|
||||||
resp = await planner_chat(messages, [])
|
# 规划者同样流式(前端弱化展示其 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"))
|
_account(resp.get("usage"))
|
||||||
content = resp.get("content") or ""
|
content = resp.get("content") or ""
|
||||||
|
throttle.flush("planner")
|
||||||
obj = _parse_json_loose(content)
|
obj = _parse_json_loose(content)
|
||||||
if obj:
|
if obj:
|
||||||
break
|
break
|
||||||
@@ -424,7 +555,8 @@ class AgentService:
|
|||||||
token_cap=max(1, _remaining_cap()),
|
token_cap=max(1, _remaining_cap()),
|
||||||
on_event=self._make_event_writer(info),
|
on_event=self._make_event_writer(info),
|
||||||
emit_final=False,
|
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)
|
exec_result = await loop.run(instructions, system=EXECUTOR_SYSTEM_PROMPT)
|
||||||
_account({"prompt_tokens": exec_result.get("prompt_tokens", 0),
|
_account({"prompt_tokens": exec_result.get("prompt_tokens", 0),
|
||||||
"completion_tokens": exec_result.get("completion_tokens", 0)})
|
"completion_tokens": exec_result.get("completion_tokens", 0)})
|
||||||
@@ -587,7 +719,7 @@ def new_request_id() -> str:
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 审批流(D9):dsh 式 allow-once / deny,fail-closed
|
# 审批流(D9):dsh 式 allow-once / deny,fail-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:
|
def needs_approval(policy: str, tool_name: str) -> bool:
|
||||||
@@ -599,6 +731,39 @@ def needs_approval(policy: str, tool_name: str) -> bool:
|
|||||||
return False
|
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:
|
class ApprovalManager:
|
||||||
"""单次智能体运行内的审批挂起/裁决(asyncio Event 实现,dsh 式 allow-once)。"""
|
"""单次智能体运行内的审批挂起/裁决(asyncio Event 实现,dsh 式 allow-once)。"""
|
||||||
|
|
||||||
@@ -705,6 +870,18 @@ class SessionStore:
|
|||||||
return True
|
return True
|
||||||
return False
|
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:
|
def save(self, sess: AgentSession) -> None:
|
||||||
self._cache[sess.data["id"]] = sess
|
self._cache[sess.data["id"]] = sess
|
||||||
self._save(sess)
|
self._save(sess)
|
||||||
|
|||||||
+80
-5
@@ -8,7 +8,8 @@ v2 端点(《实现方案_v2》5.3):
|
|||||||
GET /review/queue、POST /review/{id} 人工检验
|
GET /review/queue、POST /review/{id} 人工检验
|
||||||
GET /metrics 含 v2 token/快路径统计
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ if _dotenv_path.exists():
|
|||||||
load_dotenv(_dotenv_path)
|
load_dotenv(_dotenv_path)
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
@@ -38,6 +40,10 @@ from gateway.model_pool import (
|
|||||||
get_pool,
|
get_pool,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 路径参数 ID 白名单(runs/agent/sessions 的 ID 都由此系统生成;
|
||||||
|
# 拒绝任意其他字符可一并杀灭 Windows 反斜杠穿越 ../..%5C 等变体)
|
||||||
|
_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
||||||
|
|
||||||
# ---- v2 依赖(惰性导入,缺依赖时降级提示) ----
|
# ---- v2 依赖(惰性导入,缺依赖时降级提示) ----
|
||||||
try:
|
try:
|
||||||
from router_system.architect import build_architect
|
from router_system.architect import build_architect
|
||||||
@@ -179,6 +185,19 @@ try:
|
|||||||
version="2.0.0",
|
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 路由)
|
# Vue SPA 静态资源(html=True:对不存在的路径 fallback 到 index.html,支持 SPA 路由)
|
||||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static")
|
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(_INDEX_PATH.read_text(encoding="utf-8"))
|
||||||
return HTMLResponse("<h1>端云协同 LLM 系统</h1><p>请先构建前端:cd webapp && npm run build</p>")
|
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"])
|
@app.get("/health", response_model=HealthResponse, tags=["system"])
|
||||||
async def health():
|
async def health():
|
||||||
return get_router().health()
|
return get_router().health()
|
||||||
@@ -265,6 +290,7 @@ try:
|
|||||||
@app.get("/runs/{request_id}/status", tags=["v2"])
|
@app.get("/runs/{request_id}/status", tags=["v2"])
|
||||||
async def get_run_status(request_id: str):
|
async def get_run_status(request_id: str):
|
||||||
"""查询任务当前状态(pending / running / done / failed)。"""
|
"""查询任务当前状态(pending / running / done / failed)。"""
|
||||||
|
_check_id(request_id, "request_id")
|
||||||
info = get_job_store().get(request_id)
|
info = get_job_store().get(request_id)
|
||||||
if info is None:
|
if info is None:
|
||||||
raise HTTPException(status_code=404, detail=f"任务 {request_id} 不存在")
|
raise HTTPException(status_code=404, detail=f"任务 {request_id} 不存在")
|
||||||
@@ -302,6 +328,7 @@ try:
|
|||||||
@app.get("/runs/{request_id}/stream", tags=["v2"])
|
@app.get("/runs/{request_id}/stream", tags=["v2"])
|
||||||
async def stream_run(request_id: str):
|
async def stream_run(request_id: str):
|
||||||
"""SSE 端点:实时推送 workspace.json 状态变化(供前端协作可视化)。"""
|
"""SSE 端点:实时推送 workspace.json 状态变化(供前端协作可视化)。"""
|
||||||
|
_check_id(request_id, "request_id")
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
@@ -354,6 +381,7 @@ try:
|
|||||||
|
|
||||||
@app.get("/traces/{request_id}", tags=["system"])
|
@app.get("/traces/{request_id}", tags=["system"])
|
||||||
async def get_trace(request_id: str):
|
async def get_trace(request_id: str):
|
||||||
|
_check_id(request_id, "request_id")
|
||||||
trace = get_router().trace_store.get(request_id)
|
trace = get_router().trace_store.get(request_id)
|
||||||
if trace is None:
|
if trace is None:
|
||||||
raise HTTPException(status_code=404, detail=f"未找到请求 {request_id} 的推理链")
|
raise HTTPException(status_code=404, detail=f"未找到请求 {request_id} 的推理链")
|
||||||
@@ -362,6 +390,7 @@ try:
|
|||||||
# ---------------- v2:workspace / artifacts ----------------
|
# ---------------- v2:workspace / artifacts ----------------
|
||||||
@app.get("/runs/{request_id}/workspace", tags=["v2"])
|
@app.get("/runs/{request_id}/workspace", tags=["v2"])
|
||||||
async def get_workspace(request_id: str):
|
async def get_workspace(request_id: str):
|
||||||
|
_check_id(request_id, "request_id")
|
||||||
p = Path("runs") / request_id / "workspace.json"
|
p = Path("runs") / request_id / "workspace.json"
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"未找到运行 {request_id}")
|
raise HTTPException(status_code=404, detail=f"未找到运行 {request_id}")
|
||||||
@@ -370,7 +399,12 @@ try:
|
|||||||
|
|
||||||
@app.get("/runs/{request_id}/artifacts/{name}", tags=["v2"])
|
@app.get("/runs/{request_id}/artifacts/{name}", tags=["v2"])
|
||||||
async def get_artifact(request_id: str, name: str):
|
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():
|
if not p.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"未找到工件 {name}")
|
raise HTTPException(status_code=404, detail=f"未找到工件 {name}")
|
||||||
return {"name": name, "content": p.read_text(encoding="utf-8")}
|
return {"name": name, "content": p.read_text(encoding="utf-8")}
|
||||||
@@ -599,6 +633,7 @@ 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)),
|
||||||
|
allow_net=bool(agent_cfg.get("allow_net", True)),
|
||||||
executor_chat=executor_chat,
|
executor_chat=executor_chat,
|
||||||
max_handoffs=int(agent_cfg.get("max_handoffs", 2)),
|
max_handoffs=int(agent_cfg.get("max_handoffs", 2)),
|
||||||
session=session,
|
session=session,
|
||||||
@@ -625,6 +660,7 @@ try:
|
|||||||
@app.post("/agent/{request_id}/cancel", tags=["agent"])
|
@app.post("/agent/{request_id}/cancel", tags=["agent"])
|
||||||
async def agent_cancel(request_id: str):
|
async def agent_cancel(request_id: str):
|
||||||
"""停止运行中的智能体任务。"""
|
"""停止运行中的智能体任务。"""
|
||||||
|
_check_id(request_id, "request_id")
|
||||||
from gateway.agent import get_agent_service
|
from gateway.agent import get_agent_service
|
||||||
service = get_agent_service()
|
service = get_agent_service()
|
||||||
info = service.get(request_id)
|
info = service.get(request_id)
|
||||||
@@ -643,6 +679,7 @@ try:
|
|||||||
@app.post("/agent/{request_id}/approve", tags=["agent"])
|
@app.post("/agent/{request_id}/approve", tags=["agent"])
|
||||||
async def agent_approve(request_id: str, req: dict):
|
async def agent_approve(request_id: str, req: dict):
|
||||||
"""裁决待审批操作:{"approval_id", "allowed"}(dsh 式 allow-once / deny)。"""
|
"""裁决待审批操作:{"approval_id", "allowed"}(dsh 式 allow-once / deny)。"""
|
||||||
|
_check_id(request_id, "request_id")
|
||||||
from gateway.agent import get_agent_service
|
from gateway.agent import get_agent_service
|
||||||
info = get_agent_service().get(request_id)
|
info = get_agent_service().get(request_id)
|
||||||
if info is None:
|
if info is None:
|
||||||
@@ -678,14 +715,30 @@ try:
|
|||||||
|
|
||||||
@app.get("/agent/sessions/{sid}", tags=["agent"])
|
@app.get("/agent/sessions/{sid}", tags=["agent"])
|
||||||
async def agent_session_detail(sid: str):
|
async def agent_session_detail(sid: str):
|
||||||
|
"""会话详情(含轮次)。"""
|
||||||
|
_check_id(sid, "会话 ID")
|
||||||
from gateway.agent import get_session_store
|
from gateway.agent import get_session_store
|
||||||
sess = get_session_store().get(sid)
|
sess = get_session_store().get(sid)
|
||||||
if sess is None:
|
if sess is None:
|
||||||
raise HTTPException(status_code=404, detail=f"会话不存在: {sid}")
|
raise HTTPException(status_code=404, detail=f"会话不存在: {sid}")
|
||||||
return sess.view()
|
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"])
|
@app.delete("/agent/sessions/{sid}", tags=["agent"])
|
||||||
async def agent_session_delete(sid: str):
|
async def agent_session_delete(sid: str):
|
||||||
|
_check_id(sid, "会话 ID")
|
||||||
from gateway.agent import get_session_store
|
from gateway.agent import get_session_store
|
||||||
return {"ok": get_session_store().delete(sid)}
|
return {"ok": get_session_store().delete(sid)}
|
||||||
|
|
||||||
@@ -733,6 +786,7 @@ try:
|
|||||||
|
|
||||||
@app.get("/agent/{request_id}/status", tags=["agent"])
|
@app.get("/agent/{request_id}/status", tags=["agent"])
|
||||||
async def agent_status(request_id: str):
|
async def agent_status(request_id: str):
|
||||||
|
_check_id(request_id, "request_id")
|
||||||
from gateway.agent import get_agent_service
|
from gateway.agent import get_agent_service
|
||||||
info = get_agent_service().get(request_id)
|
info = get_agent_service().get(request_id)
|
||||||
if info is None:
|
if info is None:
|
||||||
@@ -747,12 +801,14 @@ try:
|
|||||||
@app.get("/agent/{request_id}/events", tags=["agent"])
|
@app.get("/agent/{request_id}/events", tags=["agent"])
|
||||||
async def agent_events(request_id: str):
|
async def agent_events(request_id: str):
|
||||||
"""完整事件列表(JSON,刷新后恢复用)。"""
|
"""完整事件列表(JSON,刷新后恢复用)。"""
|
||||||
|
_check_id(request_id, "request_id")
|
||||||
from gateway.agent import get_agent_service
|
from gateway.agent import get_agent_service
|
||||||
return get_agent_service().read_events(request_id)
|
return get_agent_service().read_events(request_id)
|
||||||
|
|
||||||
@app.get("/agent/{request_id}/stream", tags=["agent"])
|
@app.get("/agent/{request_id}/stream", tags=["agent"])
|
||||||
async def agent_stream(request_id: str):
|
async def agent_stream(request_id: str):
|
||||||
"""SSE:实时推送智能体过程事件(round/tool_call/tool_result/usage/final)。"""
|
"""SSE:实时推送智能体过程事件(round/tool_call/tool_result/usage/final)。"""
|
||||||
|
_check_id(request_id, "request_id")
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from gateway.agent import get_agent_service
|
from gateway.agent import get_agent_service
|
||||||
|
|
||||||
@@ -811,19 +867,36 @@ try:
|
|||||||
# ---------------- 模型设置(用户可调整) ----------------
|
# ---------------- 模型设置(用户可调整) ----------------
|
||||||
@app.get("/config", tags=["settings"])
|
@app.get("/config", tags=["settings"])
|
||||||
async def get_config():
|
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"])
|
@app.put("/config", tags=["settings"])
|
||||||
async def put_config(patch: dict):
|
async def put_config(patch: dict):
|
||||||
"""部分更新设置并重建管线。示例:
|
"""部分更新设置并重建管线。示例:
|
||||||
{"worker": {"backend": "openai", "base_url": "http://127.0.0.1:11434/v1", "temperature": 0.4}}
|
{"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:
|
try:
|
||||||
merged = settings_store().update(patch)
|
merged = settings_store().update(patch)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"设置非法: {e}")
|
raise HTTPException(status_code=400, detail=f"设置非法: {e}")
|
||||||
rebuild_pipeline()
|
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
|
return merged
|
||||||
|
|
||||||
@app.post("/config/reset", tags=["settings"])
|
@app.post("/config/reset", tags=["settings"])
|
||||||
@@ -1110,4 +1183,6 @@ def _maybe_enqueue(result) -> None:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
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)
|
||||||
|
|||||||
@@ -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 命令超时
|
||||||
|
"allow_net": True, # 允许 web_fetch 抓取公网页面(SSRF 防护内置)
|
||||||
"max_handoffs": 2, # 两级模式:规划者<->执行者交接轮数上限
|
"max_handoffs": 2, # 两级模式:规划者<->执行者交接轮数上限
|
||||||
"approval_policy": "dangerous", # 审批策略:off | dangerous(写/编辑/命令询问)| all
|
"approval_policy": "dangerous", # 审批策略:off | dangerous(写/编辑/命令询问)| all
|
||||||
"approval_timeout_s": 120, # 审批等待超时(超时自动拒绝)
|
"approval_timeout_s": 120, # 审批等待超时(超时自动拒绝)
|
||||||
|
|||||||
+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-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
@@ -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};
|
||||||
+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
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>端云协同 LLM 协作系统</title>
|
<title>端云协同 LLM 协作系统</title>
|
||||||
<script type="module" crossorigin src="/static/assets/index-C8Za1808.js"></script>
|
<script type="module" crossorigin src="/static/assets/index-C4nTxB0s.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/static/assets/index-wqjFJqzj.css">
|
<link rel="stylesheet" crossorigin href="/static/assets/index-rGGK-sk_.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+277
-33
@@ -1,8 +1,10 @@
|
|||||||
"""工具调用内核 —— 让 LLM 以 OpenAI function-calling 协议操作工作区文件。
|
"""工具调用内核 —— 让 LLM 以 OpenAI function-calling 协议操作工作区文件。
|
||||||
|
|
||||||
组成(对齐《实现方案_v4_模型池与工具智能体.md》D4):
|
组成(对齐《实现方案_v4_模型池与工具智能体.md》D4):
|
||||||
- TOOLS_SPEC:list_dir / read_file / write_file 三个工具的 OpenAI tools 声明
|
- TOOLS_SPEC:list_dir / read_file / write_file / edit_file / search_files /
|
||||||
- WorkspaceTools:被"关押"在根目录内的文件工具(路径越界一律拒绝,Windows pathlib)
|
run_command / web_fetch 七个工具的 OpenAI tools 声明
|
||||||
|
- WorkspaceTools:被"关押"在根目录内的文件工具(路径越界一律拒绝,Windows pathlib);
|
||||||
|
web_fetch 带公网 SSRF 防护(dsh web_fetch 同款),run_command 带危险命令拦截
|
||||||
- parse_tool_calls:解析 OpenAI 响应里的 tool_calls(arguments 容错为 {})
|
- parse_tool_calls:解析 OpenAI 响应里的 tool_calls(arguments 容错为 {})
|
||||||
- ToolLoop:通用智能体循环。chat_fn 注入(网关传 OpenAI 兼容客户端,测试传假实现),
|
- ToolLoop:通用智能体循环。chat_fn 注入(网关传 OpenAI 兼容客户端,测试传假实现),
|
||||||
本模块只负责循环编排:调用 -> 执行工具 -> 回喂结果 -> 直到模型给出最终答复。
|
本模块只负责循环编排:调用 -> 执行工具 -> 回喂结果 -> 直到模型给出最终答复。
|
||||||
@@ -11,9 +13,13 @@
|
|||||||
- 纯标准库(router_system 零第三方依赖不变)
|
- 纯标准库(router_system 零第三方依赖不变)
|
||||||
- 工具结果回喂前截断(防止上下文爆炸),轮数与 token 双上限(金额护栏)
|
- 工具结果回喂前截断(防止上下文爆炸),轮数与 token 双上限(金额护栏)
|
||||||
- 事件回调 on_event 逐条产出过程事件(供 SSE 透出"智能体在做什么")
|
- 事件回调 on_event 逐条产出过程事件(供 SSE 透出"智能体在做什么")
|
||||||
|
- 工具在线程池执行(asyncio.to_thread),长命令/大搜索不阻塞事件循环
|
||||||
|
- 重复同参调用达到阈值回喂警语(防模型原地打转,dsh repeat-reminder 同款)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
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 上限
|
# run_command 上限
|
||||||
SHELL_OUTPUT_CHARS = 4000
|
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 Prefix(dsh 同款拒绝项)
|
||||||
|
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
||||||
|
|
||||||
|
# 重复调用提醒阈值(同一工具 + 同一参数第 N 次起提示模型换策略)
|
||||||
|
REPEAT_CALL_WARN_AT = 3
|
||||||
|
|
||||||
# 默认循环上限
|
# 默认循环上限
|
||||||
DEFAULT_MAX_ROUNDS = 8
|
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": "要抓取的完整 URL(http/https)"},
|
||||||
|
},
|
||||||
|
"required": ["url"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
TOOL_NAMES = {t["function"]["name"] for t in TOOLS_SPEC}
|
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:
|
class WorkspaceTools:
|
||||||
"""被限制在根目录内的文件工具(智能体的"手")。
|
"""被限制在根目录内的文件工具(智能体的"手")。
|
||||||
|
|
||||||
@@ -175,11 +272,13 @@ class WorkspaceTools:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, root: str | Path,
|
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 = Path(root).resolve()
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
self.allow_shell = bool(allow_shell)
|
self.allow_shell = bool(allow_shell)
|
||||||
self.shell_timeout_s = max(1, int(shell_timeout_s))
|
self.shell_timeout_s = max(1, int(shell_timeout_s))
|
||||||
|
self.allow_net = bool(allow_net)
|
||||||
|
|
||||||
# ---------- 路径关押 ----------
|
# ---------- 路径关押 ----------
|
||||||
def resolve(self, rel_path: str) -> Path:
|
def resolve(self, rel_path: str) -> Path:
|
||||||
@@ -234,7 +333,7 @@ class WorkspaceTools:
|
|||||||
return {"ok": False, "error": f"内容过长(>{MAX_WRITE_CHARS} 字符),拒绝写入"}
|
return {"ok": False, "error": f"内容过长(>{MAX_WRITE_CHARS} 字符),拒绝写入"}
|
||||||
p = self.resolve(rel_path)
|
p = self.resolve(rel_path)
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
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"))}
|
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]:
|
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,
|
return {"ok": False,
|
||||||
"error": f"old_string 出现 {count} 次(要求唯一);请扩大上下文使其唯一"}
|
"error": f"old_string 出现 {count} 次(要求唯一);请扩大上下文使其唯一"}
|
||||||
new_text = text.replace(old_string, new_string, 1)
|
new_text = text.replace(old_string, new_string, 1)
|
||||||
p.write_text(new_text, encoding="utf-8")
|
_atomic_write_text(p, new_text)
|
||||||
return {
|
return {
|
||||||
"ok": True, "path": rel_path,
|
"ok": True, "path": rel_path,
|
||||||
"replaced": 1,
|
"replaced": 1,
|
||||||
@@ -265,7 +364,8 @@ class WorkspaceTools:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def search_files(self, query: str, rel_path: str = "") -> Dict[str, Any]:
|
def search_files(self, query: str, rel_path: str = "") -> Dict[str, Any]:
|
||||||
"""跨文件文本搜索(跳过依赖/构建目录与二进制大文件,限量返回)。"""
|
"""跨文件文本搜索(os.walk 修剪依赖/构建目录,限量返回,不跟随符号链接)。"""
|
||||||
|
import os
|
||||||
if not query:
|
if not query:
|
||||||
return {"ok": False, "error": "query 不能为空"}
|
return {"ok": False, "error": "query 不能为空"}
|
||||||
base = self.resolve(rel_path or "")
|
base = self.resolve(rel_path or "")
|
||||||
@@ -274,40 +374,61 @@ class WorkspaceTools:
|
|||||||
matches: List[Dict[str, Any]] = []
|
matches: List[Dict[str, Any]] = []
|
||||||
scanned = 0
|
scanned = 0
|
||||||
truncated = False
|
truncated = False
|
||||||
for p in sorted(base.rglob("*")):
|
|
||||||
if len(matches) >= SEARCH_MAX_MATCHES:
|
def _match_file(p: Path) -> bool:
|
||||||
truncated = True
|
"""在单文件内找匹配(找到即 True)。"""
|
||||||
break
|
nonlocal matches, truncated
|
||||||
if not p.is_file():
|
for lineno, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
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):
|
|
||||||
if query in line:
|
if query in line:
|
||||||
rel = Path(*p.relative_to(self.root).parts).as_posix()
|
rel = p.relative_to(self.root).as_posix()
|
||||||
matches.append({
|
matches.append({
|
||||||
"file": rel, "line": lineno,
|
"file": rel, "line": lineno,
|
||||||
"text": line.strip()[:300],
|
"text": line.strip()[:300],
|
||||||
})
|
})
|
||||||
if len(matches) >= SEARCH_MAX_MATCHES:
|
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
|
truncated = True
|
||||||
break
|
break
|
||||||
|
if _match_file(fpath):
|
||||||
|
break
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
continue # 二进制/不可读/并发删除,跳过
|
||||||
|
if len(matches) >= SEARCH_MAX_MATCHES:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
return {"ok": True, "query": query, "matches": matches,
|
return {"ok": True, "query": query, "matches": matches,
|
||||||
"scanned_files": scanned, "truncated": truncated}
|
"scanned_files": scanned, "truncated": truncated}
|
||||||
|
|
||||||
def run_command(self, command: str) -> Dict[str, Any]:
|
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:
|
if not self.allow_shell:
|
||||||
return {"ok": False,
|
return {"ok": False,
|
||||||
"error": "run_command 未启用(系统设置 allow_shell 为关)。"
|
"error": "run_command 未启用(系统设置 allow_shell 为关)。"
|
||||||
@@ -315,11 +436,17 @@ class WorkspaceTools:
|
|||||||
command = (command or "").strip()
|
command = (command or "").strip()
|
||||||
if not command:
|
if not command:
|
||||||
return {"ok": False, "error": "command 不能为空"}
|
return {"ok": False, "error": "command 不能为空"}
|
||||||
import subprocess
|
blocked = _dangerous_command_reason(command)
|
||||||
creationflags = 0x08000000 if __import__("os").name == "nt" else 0 # CREATE_NO_WINDOW
|
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:
|
try:
|
||||||
proc = subprocess.run(
|
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,
|
timeout=self.shell_timeout_s, creationflags=creationflags,
|
||||||
)
|
)
|
||||||
out = (proc.stdout or b"").decode("utf-8", errors="replace")
|
out = (proc.stdout or b"").decode("utf-8", errors="replace")
|
||||||
@@ -334,6 +461,72 @@ class WorkspaceTools:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
return {"ok": False, "error": f"命令执行失败: {type(e).__name__}: {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]:
|
def execute(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""按名字执行工具;任何异常折叠为 {"ok": False, "error": ...}。"""
|
"""按名字执行工具;任何异常折叠为 {"ok": False, "error": ...}。"""
|
||||||
@@ -355,6 +548,8 @@ class WorkspaceTools:
|
|||||||
str(arguments.get("query", "")), str(arguments.get("path", "")))
|
str(arguments.get("query", "")), str(arguments.get("path", "")))
|
||||||
if name == "run_command":
|
if name == "run_command":
|
||||||
return self.run_command(str(arguments.get("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}"}
|
return {"ok": False, "error": f"未知工具: {name}"}
|
||||||
except ToolError as e:
|
except ToolError as e:
|
||||||
return {"ok": False, "error": str(e)}
|
return {"ok": False, "error": str(e)}
|
||||||
@@ -362,6 +557,28 @@ class WorkspaceTools:
|
|||||||
return {"ok": False, "error": f"文件系统错误: {type(e).__name__}: {e}"}
|
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]]:
|
def parse_tool_calls(message: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
"""从 OpenAI 响应的 message 解析 tool_calls。
|
"""从 OpenAI 响应的 message 解析 tool_calls。
|
||||||
|
|
||||||
@@ -415,6 +632,7 @@ class ToolLoop:
|
|||||||
result_preview_chars: int = MAX_RESULT_CHARS,
|
result_preview_chars: int = MAX_RESULT_CHARS,
|
||||||
emit_final: bool = True,
|
emit_final: bool = True,
|
||||||
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None,
|
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None,
|
||||||
|
on_delta: Optional[Callable[[str], None]] = None,
|
||||||
):
|
):
|
||||||
self.tools = tools
|
self.tools = tools
|
||||||
self.chat_fn = chat_fn
|
self.chat_fn = chat_fn
|
||||||
@@ -425,6 +643,11 @@ class ToolLoop:
|
|||||||
self.emit_final = emit_final # 两级模式内层循环置 False,由外层统一收尾
|
self.emit_final = emit_final # 两级模式内层循环置 False,由外层统一收尾
|
||||||
# 审批门卫(D9):执行工具前调用,返回 False = 用户拒绝(可选;缺省跳过审批)
|
# 审批门卫(D9):执行工具前调用,返回 False = 用户拒绝(可选;缺省跳过审批)
|
||||||
self.approval_hook = approval_hook
|
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:
|
def _emit(self, ev: Dict[str, Any]) -> None:
|
||||||
if ev.get("type") == "final" and not self.emit_final:
|
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:
|
def _total_tokens(self, usage: Dict[str, int]) -> int:
|
||||||
return int(usage.get("prompt_tokens", 0)) + int(usage.get("completion_tokens", 0))
|
return int(usage.get("prompt_tokens", 0)) + int(usage.get("completion_tokens", 0))
|
||||||
|
|
||||||
|
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 = "",
|
async def run(self, task: str, system: str = "",
|
||||||
history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
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):
|
for round_no in range(1, self.max_rounds + 1):
|
||||||
self._emit({"type": "round", "round": round_no})
|
self._emit({"type": "round", "round": round_no})
|
||||||
try:
|
try:
|
||||||
resp = await self.chat_fn(messages, TOOLS_SPEC)
|
resp = await self._invoke_chat(messages)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._emit({"type": "final", "round": round_no, "reason": "error",
|
self._emit({"type": "final", "round": round_no, "reason": "error",
|
||||||
"error": f"{type(e).__name__}: {e}"})
|
"error": f"{type(e).__name__}: {e}"})
|
||||||
@@ -521,10 +756,19 @@ class ToolLoop:
|
|||||||
messages.append({"role": "tool", "tool_call_id": c["id"],
|
messages.append({"role": "tool", "tool_call_id": c["id"],
|
||||||
"content": preview})
|
"content": preview})
|
||||||
continue
|
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)
|
preview = json.dumps(result, ensure_ascii=False)
|
||||||
if len(preview) > self.result_preview_chars:
|
if len(preview) > self.result_preview_chars:
|
||||||
preview = 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,
|
self._emit({"type": "tool_result", "round": round_no,
|
||||||
"name": c["name"], "ok": bool(result.get("ok")),
|
"name": c["name"], "ok": bool(result.get("ok")),
|
||||||
"preview": preview})
|
"preview": preview})
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -360,8 +360,9 @@ async function send() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
messages.value.push({ role: 'user', content: task })
|
messages.value.push({ role: 'user', content: task })
|
||||||
const aiMsg: ChatMsg = { role: 'assistant', content: '', liveEvents: [] }
|
messages.value.push({ role: 'assistant', content: '', liveEvents: [] })
|
||||||
messages.value.push(aiMsg)
|
// 关键:取响应式代理对象(闭包持原始对象会丢失响应式,过程事件不渲染)
|
||||||
|
const aiMsg = messages.value[messages.value.length - 1] as ChatMsg
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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/D10,T28/T29)
|
||||||
|
|
||||||
|
- **决策 D9(操作审批,对齐 dsh)**:`agent.approval_policy` = off | dangerous(默认:
|
||||||
|
写/编辑/命令询问,只读自动放行)| all;工具执行前经 approval_hook 挂起等待,
|
||||||
|
UI 两按钮(拒绝 / 允许一次),超时 `approval_timeout_s`(默认 120s)自动拒绝
|
||||||
|
(fail-closed);拒绝结果回喂模型可改道;approval_request/decided 事件对入审计。
|
||||||
|
- **决策 D10(token 级流式)**:OpenAICompatChat 默认 stream=True(SSE 逐段解析,
|
||||||
|
tool_calls 碎片按 index 组装、不在正文展示;include_usage 计量);流式失败自动
|
||||||
|
回退非流式一次(已有部分增量输出时如实抛出);ToolLoop 经 on_delta 转发,
|
||||||
|
DeltaThrottle ≥48 字符节流落 delta 事件;前端打字机式实时渲染 + 光标。
|
||||||
|
- **配套修复**:Vue 响应式丢失 bug——闭包持有 push 前原始对象导致过程事件不渲染,
|
||||||
|
改取响应式代理对象。
|
||||||
@@ -120,3 +120,5 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
|
|||||||
| T25 | 集成验证:选定真实目录"读→精确编辑→运行验证"全链路 + 274 测试全绿 | ✅ 完成 | T24-T25 |
|
| T25 | 集成验证:选定真实目录"读→精确编辑→运行验证"全链路 + 274 测试全绿 | ✅ 完成 | T24-T25 |
|
||||||
| T26 | 两级智能体:规划者(大模型)拆解/审查 + 执行者(本地小模型)工具轮,handoff.json 交接,/agent 带 executor_pool_id | ✅ 完成 | T26 |
|
| T26 | 两级智能体:规划者(大模型)拆解/审查 + 执行者(本地小模型)工具轮,handoff.json 交接,/agent 带 executor_pool_id | ✅ 完成 | T26 |
|
||||||
| T27 | 会话式智能体:多轮上下文 + 会话持久化 + 停止按钮 + AgentView 对话式重构(dsh 范式) | ✅ 完成 | T27 |
|
| T27 | 会话式智能体:多轮上下文 + 会话持久化 + 停止按钮 + AgentView 对话式重构(dsh 范式) | ✅ 完成 | T27 |
|
||||||
|
| T28 | 操作审批流:approval_policy(off/dangerous/all) + 挂起/裁决/超时 + /approve + 审批卡 | ✅ 完成 | T28 |
|
||||||
|
| T29 | token 级流式:SSE 解析/tool_calls 碎片组装/回退 + DeltaThrottle + 打字机渲染 | ✅ 完成 | T29 |
|
||||||
|
|||||||
@@ -123,3 +123,16 @@
|
|||||||
Enter 发送,运行中变红色停止钮);工具过程折叠收纳,历史轮次仅显示步数摘要。
|
Enter 发送,运行中变红色停止钮);工具过程折叠收纳,历史轮次仅显示步数摘要。
|
||||||
- **工程**:ToolLoop 增 history 参数;工具步数统一在事件写入层统计;
|
- **工程**:ToolLoop 增 history 参数;工具步数统一在事件写入层统计;
|
||||||
会话存储测试隔离(防泄漏进真实 agent_runs/sessions/)。
|
会话存储测试隔离(防泄漏进真实 agent_runs/sessions/)。
|
||||||
|
|
||||||
|
## 9. 增补:审批流与流式输出(D9/D10,T28/T29)
|
||||||
|
|
||||||
|
- **决策 D9(操作审批,对齐 dsh)**:`agent.approval_policy` = off | dangerous(默认:
|
||||||
|
写/编辑/命令询问,只读自动放行)| all;工具执行前经 approval_hook 挂起等待,
|
||||||
|
UI 两按钮(拒绝 / 允许一次),超时 `approval_timeout_s`(默认 120s)自动拒绝
|
||||||
|
(fail-closed);拒绝结果回喂模型可改道;approval_request/decided 事件对入审计。
|
||||||
|
- **决策 D10(token 级流式)**:OpenAICompatChat 默认 stream=True(SSE 逐段解析,
|
||||||
|
tool_calls 碎片按 index 组装、不在正文展示;include_usage 计量);流式失败自动
|
||||||
|
回退非流式一次(已有部分增量输出时如实抛出);ToolLoop 经 on_delta 转发,
|
||||||
|
DeltaThrottle ≥48 字符节流落 delta 事件;前端打字机式实时渲染 + 光标。
|
||||||
|
- **配套修复**:Vue 响应式丢失 bug——闭包持有 push 前原始对象导致过程事件不渲染,
|
||||||
|
改取响应式代理对象。
|
||||||
|
|||||||
Reference in New Issue
Block a user