Files
AgentLab/backend/app/engine/toolcalls.py
T
ThearchyHelios 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 的说法)。后端全量通过。
2026-10-19 22:48:23 +08:00

320 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""节点里调工具的公共部分:时限,以及「模型把工具调用写成了文字」的识别。
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))