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),正在操作用户选择的**真实项目工作目录**。"
|
||||
"你拥有的工具: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]]) -> 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] = {
|
||||
"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):
|
||||
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"))
|
||||
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 / 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:
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user