mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:31:59 +00:00
6b68961c65
部署上画布助手连着几轮报「助手本轮未完成:模型没有给出任何修改,也没有说明原因」,又试了一次才成。本地用同一网关、同一模型、同一底图和需求重放复现:模型思考里写着「先输出 look」,正文却是一段 DeepSeek 的 DSML 工具调用(invoke name="look",参数 need、targets),意图和参数都对,只是不是约定的单行 JSON。读流只认 JSON 和原生工具调用,于是一个操作都没认出、整轮报错;画布不处理 next=retry,点重试原样再发,几率不变。提示词里「输出这一行就停下,系统把结果交回来」很像工具调用,却从没说这里没有工具调用接口。
- engine/toolcalls:take_leaked_calls 读写成文字的工具调用(DSML 竖线一到两个、带不带空格,<function_calls><invoke>,<tool_call>{name, arguments},DeepSeek 特殊 token 两种排法),按出现先后交出已写完的调用和没写完的那一段;final 时参数写完整、只差结尾的补上结尾再读,写到一半的不硬读。_MARKUP 补认 DSML 结尾标签和 function_calls / tool_calls 包装(agent 节点的识别跟着受益)。has_markup 只认标记本身
- copilot_gen._iter_ops:从有标记的那一行起攒,一次调用写完就按名字读成操作(名字是操作名的才认,规矩同原生工具调用 _call_op;op 写在最前,接着写时交回模型的是约定写法),读到 look 照样停下去查看;读不出的记「工具调用标记的名字不认识 / 参数解析不了 / 没写完」;raw 交出正文开头一段
- Generation:起草时一次调用读完、到这时还没有能落地的操作(plan、look 不算——查看完接着写什么都没认出的整轮照样是空的),把原话交回去、附 StreamDiagnosis.correction 那句纠正,当场重写一次(FORMAT_NUDGE_PROMPT,每轮一次);什么都没写的把纠正接在最后那条消息后面。诊断按次收(absorb),记 nudged。写成标记被读出、当场纠正都打 info 日志
- 报错说法:Generation.empty 默认用 StreamDiagnosis.headline(写成工具调用格式 / 只写了思考 / 什么都没有输出 / 格式不对 / 只说了打算),不再一律「也没有说明原因」;摘要注明当场纠正过
- 提示词:look 用法(四个入口共用)和画布、应用、蓝图的输出格式都写明这里没有工具调用接口
- 问数据失败原因 meta.cause(kind=copilot)带上 code、diagnostics
验证:本地真模型重放,修后 DSML 没再出现、都正常交付;把第一次调用换成读不出的 DSML 后交给真模型,都被当场纠正救回来(先 look 再出操作);原先抓到的真实 DSML 原文离线读成 look。测试:新增 tests/test_copilot_markup_ops.py(各写法、流式切碎、和 JSON 行混写、读不出的诊断、DSML look 驱动真查看、说明文字 / 只写思考 / 只写 plan / 查看后空续写的纠正、只纠正一次、画布流不报错、报错说法、提示词);空生成相关的旧测试按「一次生成先纠正一次」更新(基线夹具 no_ops 的说法)。后端全量通过。
320 lines
15 KiB
Python
320 lines
15 KiB
Python
"""节点里调工具的公共部分:时限,以及「模型把工具调用写成了文字」的识别。
|
||
|
||
agent 节点、协作成员、工具节点三处都在调工具。以前时限只有数据层自己那一道
|
||
wait_for,而 wait_for 取消之后要等被取消的那一方收完尾:驱动关连接、回滚要排在
|
||
数据库那条语句后面。真实运行里一次「查询超过 30s 被中断」实际等了 90 秒——时限
|
||
到了、取消也发了,控制权却迟迟回不来,报错写的还是 30。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import re
|
||
from dataclasses import dataclass
|
||
from typing import Any, Awaitable
|
||
|
||
QUERY_PREFIX = "db_query__"
|
||
|
||
#: 到了工具自己的时限之后再宽限多久才放弃。工具自己的超时报错(「查询超过 30s
|
||
#: 被中断,加上 WHERE…」)比这里的说法更具体,先让它到;收尾卡住了才由这里兜底
|
||
GRACE_S = 1.0
|
||
|
||
#: 沙箱代码工具的时限来自参数(缺省 30 秒),由沙箱自己执行
|
||
_SANDBOX_TOOLS = {"python_exec", "shell_exec", "run_code"}
|
||
_HTTP_TOOLS = {"http_request", "web_fetch"}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Limit:
|
||
"""一次工具调用的时限。seconds 随 tool.start 发给界面,界面据此说「已超出 30s 上限」。"""
|
||
|
||
seconds: float
|
||
#: 到点由引擎收回控制权。只对「时限到了、收尾却可能卡住」的类型开:沙箱的时限
|
||
#: 里还含着起虚拟机、装依赖,按它掐会误杀冷启动;HTTP 客户端自己掐得准
|
||
enforce: bool
|
||
#: query | declared | sandbox | http
|
||
kind: str
|
||
|
||
@property
|
||
def self_timed(self) -> bool:
|
||
"""工具自己也按这个时限掐、并且自己收尾(数据层的查询)。引擎放弃它时只撒手、
|
||
不再取消:它那时多半正在收尾,再取消一次会把关连接那一步打断,连接就再也回不到池里。"""
|
||
return self.kind == "query"
|
||
|
||
|
||
def limit_of(tool: Any, name: str, args: dict[str, Any]) -> Limit | None:
|
||
"""这次调用的时限。说不出来就是 None——界面上不写上限,引擎也不掐。
|
||
|
||
工具自己在 metadata["timeout_s"] 里声明的优先(数字,或者按参数算的函数)。
|
||
"""
|
||
meta = (getattr(tool, "metadata", None) or {}) if tool is not None else {}
|
||
declared = meta.get("timeout_s")
|
||
if callable(declared):
|
||
declared = declared(args)
|
||
if not (isinstance(declared, (int, float)) and not isinstance(declared, bool) and declared > 0):
|
||
declared = None
|
||
if name.startswith(QUERY_PREFIX):
|
||
# 数据层自己按这个时限掐、自己收尾。数据源声明了自己的时限就用它,
|
||
# 否则按模块属性现取数据层的缺省——那个数只有这一处定义
|
||
from app.data import guard
|
||
|
||
return Limit(float(declared or guard.QueryLimits().timeout_seconds), True, "query")
|
||
if declared:
|
||
return Limit(float(declared), True, "declared")
|
||
if name in _SANDBOX_TOOLS:
|
||
try:
|
||
seconds = float(args.get("timeout") or 30)
|
||
except (TypeError, ValueError):
|
||
seconds = 30.0
|
||
return Limit(seconds, False, "sandbox")
|
||
if name in _HTTP_TOOLS:
|
||
from app.core.config import settings
|
||
|
||
return Limit(float(settings.http_tool_timeout), False, "http")
|
||
return None
|
||
|
||
|
||
def limit_fields(limit: Limit | None) -> dict[str, Any]:
|
||
"""tool.start 上附带的时限字段。没有时限就什么都不带,老客户端照旧。"""
|
||
if limit is None:
|
||
return {}
|
||
return {"timeout_s": int(limit.seconds) if limit.seconds.is_integer() else limit.seconds}
|
||
|
||
|
||
def _seconds(value: float) -> str:
|
||
return str(int(value)) if float(value).is_integer() else f"{value:g}"
|
||
|
||
|
||
class ToolTimeout(TimeoutError):
|
||
"""引擎等到时限就放弃了。消息直接给模型和人看:等了多久、为什么放弃、怎么办。"""
|
||
|
||
def __init__(self, name: str, limit: Limit) -> None:
|
||
self.name = name
|
||
self.limit = limit
|
||
seconds = _seconds(limit.seconds)
|
||
if limit.kind == "query":
|
||
text = (f"查询超过 {seconds} 秒没有返回,已停止等待(数据库可能仍在执行,连接会在后台"
|
||
"回收)。请添加 WHERE 条件或 LIMIT 缩小范围后重试")
|
||
else:
|
||
text = f"工具 {name} 超过 {seconds} 秒没有返回,已停止等待"
|
||
super().__init__(text)
|
||
|
||
|
||
async def run_bounded(call: Awaitable[Any], limit: Limit | None, name: str) -> Any:
|
||
"""跑一次工具调用;到了时限(加一点宽限)就收回控制权,不等它收尾。
|
||
|
||
被放弃的那次调用照样被取消,只是它的收尾(关连接、回滚)挪到后台去做完。
|
||
"""
|
||
if limit is None or not limit.enforce:
|
||
return await call
|
||
job = asyncio.ensure_future(call)
|
||
try:
|
||
done, _ = await asyncio.wait({job}, timeout=limit.seconds + GRACE_S)
|
||
except asyncio.CancelledError:
|
||
# 整次运行被取消(用户停止、服务关停):底下那次调用也不该接着跑
|
||
_abandon(job)
|
||
raise
|
||
if job in done:
|
||
return job.result()
|
||
_abandon(job, cancel=not limit.self_timed)
|
||
raise ToolTimeout(name, limit)
|
||
|
||
|
||
def _abandon(job: asyncio.Future[Any], *, cancel: bool = True) -> None:
|
||
if cancel:
|
||
job.cancel()
|
||
# 结果没人要了;把异常取走,免得事件循环报 "exception was never retrieved"
|
||
job.add_done_callback(lambda f: f.cancelled() or f.exception())
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 模型把工具调用写成了文字
|
||
#
|
||
# 有的模型(或它前面那层网关)不认工具调用接口、或者节点根本没绑工具,模型就把自己
|
||
# 训练时那套调用格式当正文吐出来。这一步没有任何真实调用,数据一次都没查到,而文字
|
||
# 看上去像是「在查」。认出来之后先纠正一次,仍然如此就判失败。
|
||
# --------------------------------------------------------------------------
|
||
|
||
_MARKUP = re.compile(
|
||
r"</?\s*[||]{1,2}\s*DSML\s*[||]{1,2}" # DeepSeek 的 DSML
|
||
r"|<[||]\s*tool[▁_ ]calls?[▁_ ]begin\s*[||]>" # DeepSeek 的特殊 token
|
||
r"|<\s*(?:tool_calls?|function_calls?)\s*>"
|
||
r"|<\s*invoke\s+name\s*=",
|
||
re.IGNORECASE,
|
||
)
|
||
# 只有一段 {"tool": …, "sql": …} 不算:正文里引用一段 JSON 很正常。
|
||
# 得同时自称是「假设」调的,才是在编
|
||
_TOOL_JSON = re.compile(r'\{\s*"tool"\s*:')
|
||
_SQL_KEY = re.compile(r'"sql"\s*:')
|
||
_PRETEND = re.compile(r"假设(?:调用|使用|执行)了?(?:工具|查询)|假装调用|模拟调用(?:了)?工具")
|
||
|
||
TOOL_MARKUP_ERROR = (
|
||
"模型以文本形式输出了工具调用的原始标记,未实际调用工具,这一步没有查询到任何数据。"
|
||
"常见原因:节点未绑定工具,或模型、服务不支持工具调用。"
|
||
"请在画布中为该节点绑定所需工具;如已绑定仍出现此问题,请换用支持工具调用的模型"
|
||
)
|
||
|
||
TOOL_MARKUP_NUDGE = (
|
||
"你上一条回复把工具调用写成了文字,系统没有执行它,也就没有拿到任何数据。"
|
||
"要用工具,请通过工具调用接口发起,不要把调用格式写进正文;手上没有能用的工具,"
|
||
"就直接说明查不到、缺什么,不要假设调用的结果。"
|
||
)
|
||
|
||
|
||
def leaked_markup(text: str | None) -> str | None:
|
||
"""正文里露出来的工具调用标记(截一小段,给警告用)。没有就是 None。"""
|
||
if not text:
|
||
return None
|
||
if m := _MARKUP.search(text):
|
||
return m.group(0)
|
||
if _TOOL_JSON.search(text) and _SQL_KEY.search(text) and (m := _PRETEND.search(text)):
|
||
return m.group(0)
|
||
return None
|
||
|
||
|
||
def markup_warning(snippet: str, who: str = "模型") -> str:
|
||
return f"{who}以文本形式输出了工具调用({snippet[:40]}…),未实际调用工具"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 把写成文字的工具调用读出来
|
||
#
|
||
# 助手(app/api/copilot_gen)不走工具调用接口:操作(look、add_node……)是正文里一行一行的 JSON。可模型偶尔还是按自己训练时
|
||
# 那套调用格式写(部署上撞过:DeepSeek 想先 look 看表,思考里写着「先输出 look」,正文却是一段 DSML),意图和参数都对,
|
||
# 只是格式不对。助手按名字把它们读成操作;节点里的 agent 仍然只认、不读(真要调工具得走接口,见上面的纠正)。
|
||
#
|
||
# 认得的几种写法:
|
||
# DSML(DeepSeek) <|DSML|invoke name="look"><|DSML|parameter name="need" string="true">…</|DSML|parameter>
|
||
# </|DSML|invoke>,外面可能包一层 function_calls / calls;竖线一到两个、中间可能有空格
|
||
# <invoke> 同上去掉 DSML 前缀(<function_calls><invoke name="…"><parameter name="…">…)
|
||
# <tool_call> <tool_call>{"name": "look", "arguments": {…}}</tool_call>(function_call 同)
|
||
# DeepSeek 特殊 token <|tool▁call▁begin|>function<|tool▁sep|>look ```json {…}```<|tool▁call▁end|>,
|
||
# 或者 <|tool▁call▁begin|>look<|tool▁sep|>{…}<|tool▁call▁end|>
|
||
# --------------------------------------------------------------------------
|
||
|
||
_DSML_PREFIX = re.compile(r"<(/?)\s*[||]{1,2}\s*DSML\s*[||]{1,2}\s*", re.IGNORECASE)
|
||
_INVOKE = re.compile(r"<invoke\s+name\s*=\s*[\"']([^\"']*)[\"']\s*>(.*?)</invoke\s*>", re.DOTALL | re.IGNORECASE)
|
||
_PARAMETER = re.compile(r"<parameter\s+name\s*=\s*[\"']([^\"']*)[\"']([^>]*)>(.*?)</parameter\s*>",
|
||
re.DOTALL | re.IGNORECASE)
|
||
_PARAMETER_OPEN = re.compile(r"<parameter\b", re.IGNORECASE)
|
||
_PARAMETER_CLOSE = re.compile(r"</parameter\s*>", re.IGNORECASE)
|
||
_STRING_ATTR = re.compile(r"string\s*=\s*[\"'](true|false)[\"']", re.IGNORECASE)
|
||
_JSON_CALL = re.compile(r"<(tool_call|function_call)\s*>(.*?)</\1\s*>", re.DOTALL | re.IGNORECASE)
|
||
_TOKEN_CALL = re.compile(r"<[||]\s*tool[▁_ ]call[▁_ ]begin\s*[||]>(.*?)<[||]\s*tool[▁_ ]sep\s*[||]>(.*?)"
|
||
r"<[||]\s*tool[▁_ ]call[▁_ ]end\s*[||]>", re.DOTALL | re.IGNORECASE)
|
||
#: 一次调用的开头:在它之后还没等到结尾的,留着等后面的行
|
||
_CALL_OPENER = re.compile(r"<invoke\s+name\s*=|<(?:tool_call|function_call)\s*>"
|
||
r"|<[||]\s*tool[▁_ ]call[▁_ ]begin\s*[||]>", re.IGNORECASE)
|
||
_FENCE = re.compile(r"^`{3,}[A-Za-z0-9_-]*\s*|\s*`{3,}$")
|
||
#: 参数值像 JSON 的(没写 string 属性时按 JSON 读)
|
||
_JSONISH = re.compile(r"^\s*(?:[\[{\"]|-?\d|true\b|false\b|null\b)")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LeakedCall:
|
||
"""写成文字的一次工具调用。args 是 None:参数读不出来(名字照样交出去,由调用方记进诊断)。"""
|
||
|
||
name: str
|
||
args: dict[str, Any] | None
|
||
|
||
|
||
def _parameter_value(raw: str, attrs: str) -> Any:
|
||
"""DSML 的 string="true" 是原样的字符串,string="false" 是 JSON;没写的看着像 JSON 就按 JSON 读,读不成照原样。"""
|
||
value = raw.strip()
|
||
flag = _STRING_ATTR.search(attrs)
|
||
if flag and flag.group(1).lower() == "true":
|
||
return value
|
||
if flag or _JSONISH.match(value):
|
||
try:
|
||
return json.loads(value)
|
||
except ValueError:
|
||
return value
|
||
return value
|
||
|
||
|
||
def _invoke_call(name: str, body: str) -> LeakedCall:
|
||
return LeakedCall(name.strip(), {key.strip(): _parameter_value(raw, attrs)
|
||
for key, attrs, raw in _PARAMETER.findall(body)})
|
||
|
||
|
||
def _json_call(body: str) -> LeakedCall:
|
||
"""<tool_call>{"name": …, "arguments": {…}}</tool_call>:arguments 也可能是一段 JSON 字符串。"""
|
||
try:
|
||
obj = json.loads(body.strip())
|
||
except ValueError:
|
||
return LeakedCall("", None)
|
||
if not isinstance(obj, dict):
|
||
return LeakedCall("", None)
|
||
function = obj.get("function") if isinstance(obj.get("function"), dict) else {}
|
||
name = str(obj.get("name") or function.get("name") or "")
|
||
args = next((v for v in (obj.get("arguments"), obj.get("parameters"), obj.get("args"),
|
||
function.get("arguments")) if v is not None), {})
|
||
if isinstance(args, str):
|
||
try:
|
||
args = json.loads(args) if args.strip() else {}
|
||
except ValueError:
|
||
args = None
|
||
return LeakedCall(name, args if isinstance(args, dict) else None)
|
||
|
||
|
||
def _token_call(head: str, body: str) -> LeakedCall:
|
||
"""DeepSeek 特殊 token 的两种排法:sep 前是 function、名字在 sep 后第一行;或者 sep 前就是名字。参数可能套着围栏。"""
|
||
head, body = head.strip(), body.strip()
|
||
if head.lower() in ("", "function"):
|
||
name, _, body = body.partition("\n")
|
||
else:
|
||
name = head
|
||
body = _FENCE.sub("", body.strip()).strip()
|
||
try:
|
||
args = json.loads(body) if body else {}
|
||
except ValueError:
|
||
args = None
|
||
return LeakedCall(name.strip(), args if isinstance(args, dict) else None)
|
||
|
||
|
||
#: 流读完了、最后一次调用还差结尾(模型写完参数就停了)时补上的结尾,按开头的写法
|
||
_CLOSERS = (("<invoke", "</invoke>"), ("<tool_call", "</tool_call>"), ("<function_call", "</function_call>"),
|
||
("<", "<|tool▁call▁end|>"))
|
||
|
||
|
||
def take_leaked_calls(text: str, *, final: bool = False) -> tuple[list[LeakedCall], str]:
|
||
"""从一段写成文字的工具调用里取出已经写完的调用(按出现的先后),交回 (调用, 还没写完的那一段)。还没写完的从最后一个
|
||
没等到结尾的调用开头算起;外层的包装(function_calls、DSML 的 calls)、调用之间的说明文字不留——没有没写完的调用时交回
|
||
空串。流式读的时候一行一行攒进来,每攒一行取一次。
|
||
|
||
final:后面不会再来了。最后一次调用只差结尾的(参数都写完整了,模型写完就停了)补上结尾再读一次;参数写到一半的、
|
||
补上也读不出名字和参数的,照样交回——拿半截参数当一次调用,比照实说没写完更糟。"""
|
||
calls, rest = _take(text)
|
||
if final and rest and len(_PARAMETER_OPEN.findall(rest)) == len(_PARAMETER_CLOSE.findall(rest)):
|
||
closer = next(c for prefix, c in _CLOSERS if rest.lower().startswith(prefix))
|
||
more, left = _take(rest + closer)
|
||
if more and not left and all(c.name and c.args is not None for c in more):
|
||
return calls + more, ""
|
||
return calls, rest
|
||
|
||
|
||
def _take(text: str) -> tuple[list[LeakedCall], str]:
|
||
text = _DSML_PREFIX.sub(r"<\1", text)
|
||
found: list[tuple[int, int, LeakedCall]] = []
|
||
for m in _INVOKE.finditer(text):
|
||
found.append((m.start(), m.end(), _invoke_call(m.group(1), m.group(2))))
|
||
for m in _JSON_CALL.finditer(text):
|
||
found.append((m.start(), m.end(), _json_call(m.group(2))))
|
||
for m in _TOKEN_CALL.finditer(text):
|
||
found.append((m.start(), m.end(), _token_call(m.group(1), m.group(2))))
|
||
found.sort(key=lambda f: f[0])
|
||
calls: list[LeakedCall] = []
|
||
end = 0
|
||
for start, stop, call in found:
|
||
if start < end: # 套在别的调用里面的不另算
|
||
continue
|
||
calls.append(call)
|
||
end = stop
|
||
opener = _CALL_OPENER.search(text, end)
|
||
return calls, text[opener.start():] if opener else ""
|
||
|
||
|
||
def has_markup(text: str) -> bool:
|
||
"""这一段里有没有工具调用标记(只认标记本身,不认 leaked_markup 那种「假设调用了」的说法):流式读的时候据此开始攒。"""
|
||
return bool(_MARKUP.search(text))
|