Files
jinchan ce1d493769 feat(module): 添加 AIGC BYOK 密钥解密节点及批量套版 AI hook 功能
- 新增 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/ 目录忽略规则
2026-09-14 20:42:43 +08:00

237 lines
9.3 KiB
Python
Raw Permalink 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 工具任务调用适配器。
提交 `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 "")