- 新增 byok_crypto.py,提供兼容 Java AES-256-GCM encryptedAk 解密工具 - 新增 nodes_byok.py,提供 AigcByokDecryptNode 节点支持工作流中 encryptedAk 解密 - 在 node.py 注册 AigcByokDecryptNode 节点及显示名称 - 扩展 nodes_template_compose.py 支持工具任务服务调用,新增 tool_base_url 等参数 - 支持按新版 templateWorkflow 协议(version 2)执行工作流 AI hook,处理依赖链及缓存 - 增加工作流协议解析与校验实现 template_workflow.py,处理图层索引、JSON Pointer 读写及依赖排序 - 优化模板校验,捕获 WorkflowProtocolError 并返回统一错误结构,完善日志打印 - 支持工具任务上下文解析,剔除保留字段保护 requestId 和 toolCall 不被覆盖 - 修改套版服务调用逻辑,Headers 支持合并 Bearer Token 和自定义头,优先使用 auth_token - 增加详细错误定位信息,方便调试复杂多节点 AI 工作流 - 更新.gitignore,添加 tests/ 目录忽略规则
237 lines
9.3 KiB
Python
237 lines
9.3 KiB
Python
"""Agent 工具任务调用适配器。
|
||
|
||
提交 `POST {base_url}/open/api/agent/v1/tool-tasks/submit` 得到 taskId,
|
||
再轮询 `GET {base_url}/open/api/agent/v1/tool-tasks/{taskId}` 直到终态,
|
||
返回 StandaloneToolCallResult。提交即返回终态时不再轮询。
|
||
"""
|
||
|
||
import copy
|
||
import json
|
||
import time
|
||
|
||
import requests
|
||
|
||
|
||
# 状态字段的取值暂未固定,按语义归类;未命中的状态按“仍在执行”处理
|
||
TERMINAL_SUCCESS_STATUSES = frozenset({"SUCCESS", "SUCCEEDED", "COMPLETED", "COMPLETE", "FINISHED", "DONE", "OK"})
|
||
TERMINAL_FAILURE_STATUSES = frozenset(
|
||
{"FAILED", "FAILURE", "ERROR", "CANCELLED", "CANCELED", "TIMEOUT", "TIMED_OUT", "REJECTED", "EXPIRED"}
|
||
)
|
||
|
||
_STAGE_DONE = "DONE"
|
||
_STAGE_FAILED = "FAILED"
|
||
_STAGE_PENDING = "PENDING"
|
||
|
||
# 服务端业务错误码 -> 套版工作流协议错误码,值为 (协议码, 是否可重试)
|
||
_BUSINESS_ERROR_CODES = {
|
||
"UNAUTHORIZED": ("WORKFLOW_FORBIDDEN", False),
|
||
"AUTH_FAILED": ("WORKFLOW_FORBIDDEN", False),
|
||
"SHARE_INVALID": ("WORKFLOW_FORBIDDEN", False),
|
||
"TOOL_NOT_BOUND": ("WORKFLOW_UNAVAILABLE", False),
|
||
"TOOL_NOT_FOUND": ("WORKFLOW_UNAVAILABLE", False),
|
||
"TASK_NOT_FOUND": ("WORKFLOW_UNAVAILABLE", False),
|
||
"INVALID_ARGUMENTS": ("PARAMETER_INVALID", False),
|
||
"INVALID_PARAM": ("PARAMETER_INVALID", False),
|
||
# 每用户每分钟有调用上限,批量场景下属于可恢复错误
|
||
"RATE_LIMITED": ("WORKFLOW_EXECUTION_FAILED", True),
|
||
"POINT_INSUFFICIENT": ("WORKFLOW_EXECUTION_FAILED", False),
|
||
}
|
||
|
||
|
||
def _map_business_error(code, retryable_hint=False):
|
||
mapped = _BUSINESS_ERROR_CODES.get((code or "").strip().upper())
|
||
if mapped is None:
|
||
return "WORKFLOW_EXECUTION_FAILED", bool(retryable_hint)
|
||
return mapped[0], mapped[1] or bool(retryable_hint)
|
||
|
||
|
||
class ToolTaskError(Exception):
|
||
"""工具任务调用失败,code 使用套版工作流协议的错误码"""
|
||
|
||
def __init__(self, code, message, retryable=False):
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.message = message
|
||
self.retryable = retryable
|
||
|
||
|
||
class ToolTaskClient:
|
||
SUBMIT_PATH = "/open/api/agent/v1/tool-tasks/submit"
|
||
QUERY_PATH = "/open/api/agent/v1/tool-tasks/{task_id}"
|
||
|
||
def __init__(
|
||
self,
|
||
base_url,
|
||
headers=None,
|
||
context=None,
|
||
poll_interval=5,
|
||
max_poll_time=600,
|
||
timeout=60,
|
||
log_prefix="[ToolTaskClient]",
|
||
):
|
||
self.base_url = (base_url or "").rstrip("/")
|
||
self.headers = dict(headers or {})
|
||
# 提交体中除 requestId / toolCall 之外的上下文由调用方整块透传
|
||
self.context = copy.deepcopy(context or {})
|
||
self.poll_interval = max(1, int(poll_interval or 5))
|
||
self.max_poll_time = max(1, int(max_poll_time or 600))
|
||
self.timeout = max(5, int(timeout or 60))
|
||
self.log_prefix = log_prefix
|
||
|
||
def call_tool(self, tool_name, arguments, request_id):
|
||
"""提交并等待工具任务完成,返回 StandaloneToolCallResult"""
|
||
body = copy.deepcopy(self.context)
|
||
body["requestId"] = request_id
|
||
body["toolCall"] = {"name": tool_name, "arguments": arguments, "id": request_id}
|
||
|
||
print(f"{self.log_prefix} 提交工具任务: {tool_name} (requestId: {request_id})")
|
||
result = self._request("POST", self.SUBMIT_PATH, json_body=body)
|
||
|
||
stage = self._stage(result)
|
||
if stage == _STAGE_DONE:
|
||
return result
|
||
if stage == _STAGE_FAILED:
|
||
raise self._result_error(result)
|
||
|
||
task_id = result.get("taskId")
|
||
if not task_id:
|
||
raise ToolTaskError("WORKFLOW_EXECUTION_FAILED", f"提交工具任务未返回 taskId: {_snippet(result)}")
|
||
return self._poll(task_id)
|
||
|
||
def _poll(self, task_id):
|
||
path = self.QUERY_PATH.format(task_id=task_id)
|
||
deadline = time.time() + self.max_poll_time
|
||
attempt = 0
|
||
|
||
while True:
|
||
attempt += 1
|
||
try:
|
||
result = self._request("GET", path)
|
||
except ToolTaskError as error:
|
||
# 查询接口的瞬时故障不算任务失败,超时前继续重试
|
||
if not error.retryable or time.time() >= deadline:
|
||
raise
|
||
print(f"{self.log_prefix} 第 {attempt} 次查询失败,稍后重试: {error.message}")
|
||
else:
|
||
stage = self._stage(result)
|
||
if stage == _STAGE_DONE:
|
||
print(f"{self.log_prefix} 任务完成: {task_id} (轮询 {attempt} 次)")
|
||
return result
|
||
if stage == _STAGE_FAILED:
|
||
raise self._result_error(result)
|
||
print(f"{self.log_prefix} 任务进行中: {task_id} status={result.get('status')!r}")
|
||
|
||
if time.time() >= deadline:
|
||
raise ToolTaskError(
|
||
"WORKFLOW_EXECUTION_FAILED",
|
||
f"工具任务轮询超时({self.max_poll_time} 秒未到终态): {task_id}",
|
||
)
|
||
time.sleep(self.poll_interval)
|
||
|
||
def _request(self, method, path, json_body=None):
|
||
if not self.base_url:
|
||
raise ToolTaskError("WORKFLOW_UNAVAILABLE", "未配置工具任务服务地址")
|
||
|
||
url = self.base_url + path
|
||
# 请求上下文随日志与错误信息一起给出,便于对照排查(headers 含 token,不打印)
|
||
where = f"{method} {url}"
|
||
if json_body is not None:
|
||
print(f"{self.log_prefix} 请求 {where} body={_snippet(json_body, 1000)}")
|
||
else:
|
||
print(f"{self.log_prefix} 请求 {where}")
|
||
|
||
try:
|
||
if method == "GET":
|
||
response = requests.get(url, headers=self.headers, timeout=self.timeout)
|
||
else:
|
||
response = requests.post(url, headers=self.headers, json=json_body, timeout=self.timeout)
|
||
except requests.RequestException as exc:
|
||
raise ToolTaskError("WORKFLOW_EXECUTION_FAILED", f"工具任务请求失败: {where} {exc}", retryable=True)
|
||
|
||
print(f"{self.log_prefix} 响应 {where} -> {response.status_code} {_snippet(response.text, 1000)}")
|
||
|
||
if response.status_code >= 400:
|
||
raise ToolTaskError(
|
||
self._http_error_code(response.status_code),
|
||
f"工具任务请求失败: {where} -> {response.status_code} {response.reason}, "
|
||
f"响应: {_snippet(response.text)}",
|
||
retryable=response.status_code >= 500,
|
||
)
|
||
|
||
try:
|
||
envelope = response.json()
|
||
except ValueError:
|
||
raise ToolTaskError(
|
||
"WORKFLOW_EXECUTION_FAILED",
|
||
f"工具任务返回非 JSON 内容: {where} -> {_snippet(response.text)}",
|
||
retryable=True,
|
||
)
|
||
|
||
if not isinstance(envelope, dict):
|
||
raise ToolTaskError(
|
||
"WORKFLOW_EXECUTION_FAILED", f"工具任务返回结构不支持: {where} -> {_snippet(envelope)}"
|
||
)
|
||
|
||
if envelope.get("success") is False:
|
||
code, retryable = _map_business_error(envelope.get("code"))
|
||
raise ToolTaskError(
|
||
code,
|
||
f"工具任务返回失败: {envelope.get('message') or envelope.get('code') or '未提供 message'}"
|
||
f"({where}, code: {envelope.get('code')}"
|
||
+ (f", 提交体: {_snippet(json_body, 400)}" if json_body is not None else "")
|
||
+ f", 响应: {_snippet(envelope, 400)})",
|
||
retryable=retryable,
|
||
)
|
||
|
||
result = envelope.get("data")
|
||
if not isinstance(result, dict):
|
||
raise ToolTaskError(
|
||
"WORKFLOW_EXECUTION_FAILED", f"工具任务未返回 data: {where} -> {_snippet(envelope)}"
|
||
)
|
||
return result
|
||
|
||
@staticmethod
|
||
def _http_error_code(status_code):
|
||
if status_code in (401, 403):
|
||
return "WORKFLOW_FORBIDDEN"
|
||
if status_code == 404:
|
||
return "WORKFLOW_UNAVAILABLE"
|
||
return "WORKFLOW_EXECUTION_FAILED"
|
||
|
||
@staticmethod
|
||
def _stage(result):
|
||
status = (result.get("status") or "").strip().upper()
|
||
if status in TERMINAL_SUCCESS_STATUSES:
|
||
return _STAGE_DONE
|
||
if status in TERMINAL_FAILURE_STATUSES:
|
||
return _STAGE_FAILED
|
||
if status:
|
||
return _STAGE_PENDING
|
||
|
||
# 没有 status 时按 success 与结果体判断,兼容同步返回
|
||
if result.get("success") is False or result.get("error"):
|
||
return _STAGE_FAILED
|
||
if result.get("success") is True and result.get("data"):
|
||
return _STAGE_DONE
|
||
return _STAGE_PENDING
|
||
|
||
@staticmethod
|
||
def _result_error(result):
|
||
error = result.get("error") if isinstance(result.get("error"), dict) else {}
|
||
message = error.get("message") or f"工具任务执行失败: status={result.get('status')!r}"
|
||
code, retryable = _map_business_error(error.get("code"), bool(error.get("retryable")))
|
||
return ToolTaskError(
|
||
code,
|
||
f"{message}(taskId: {result.get('taskId')}, code: {error.get('code')}, "
|
||
f"结果: {_snippet(result, 400)})",
|
||
retryable=retryable,
|
||
)
|
||
|
||
|
||
def _snippet(value, limit=500):
|
||
try:
|
||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
||
except Exception:
|
||
text = str(value)
|
||
return text[:limit] + ("...(已截断)" if len(text) > limit else "")
|