feat(tools): refactor migrate OpenClaw from custom tool to builtin tool

Create OpenClawTool class inheriting BuiltinTool with dedicated config
Remove all x-openclaw special handling from CustomTool (~270 lines)
Add multi-operation support (print_task, device_query, image_understand, general)
Change ensure_builtin_tools_initialized to incremental mode for auto-provisioning
Fix OperationTool and LangchainAdapter to support OpenClaw operation routing
This commit is contained in:
miao
2026-04-09 18:13:21 +08:00
parent 562ca6c1f1
commit 55b2e05ba8
10 changed files with 503 additions and 475 deletions

View File

@@ -0,0 +1,298 @@
"""OpenClaw 远程 Agent 内置工具"""
import time
import base64
from io import BytesIO
from typing import List, Dict, Any, Optional
import aiohttp
from app.core.tools.builtin.base import BuiltinTool
from app.schemas.tool_schema import ToolParameter, ToolResult, ParameterType
from app.core.logging_config import get_business_logger
logger = get_business_logger()
class OpenClawTool(BuiltinTool):
"""OpenClaw 远程 Agent 工具 — 支持文本和图片多模态输入"""
def __init__(self, tool_id: str, config: Dict[str, Any]):
super().__init__(tool_id, config)
params = self.parameters_config
# 用户配置项(前端表单填写)
self._server_url = params.get("server_url", "")
self._api_key = params.get("api_key", "")
self._agent_id = params.get("agent_id", "main")
# 内部默认值
self._model = "openclaw"
self._session_strategy = "by_user"
self._timeout = 120
# 运行时上下文(通过 set_runtime_context 注入)
self._user_id = "anonymous"
self._conversation_id = None
self._uploaded_files = []
@property
def name(self) -> str:
return "openclaw_tool"
@property
def description(self) -> str:
return (
"OpenClaw 远程 Agent将任务委托给远程 OpenClaw Agent。"
"具备 3D 模型生成与打印控制、设备管理、文件处理、浏览器自动化、"
"Shell 命令执行、网络搜索等能力。支持文本和图片多模态交互。"
)
@property
def parameters(self) -> List[ToolParameter]:
return [
ToolParameter(
name="operation",
type=ParameterType.STRING,
description="任务类型",
required=True,
enum= ["print_task", "device_query", "image_understand", "general"]
),
ToolParameter(
name="message",
type=ParameterType.STRING,
description="发送给 OpenClaw Agent 的文本请求内容",
required=True
),
ToolParameter(
name="image_url",
type=ParameterType.STRING,
description="可选,附带的图片 URL 或 base64 data URIOpenClaw 支持图片输入)",
required=False
)
]
# ---------- 运行时上下文注入 ----------
def set_runtime_context(
self,
user_id: str = "anonymous",
conversation_id: Optional[str] = None,
uploaded_files: Optional[list] = None
):
"""注入运行时上下文(由 chat service 调用)"""
self._user_id = user_id
self._conversation_id = conversation_id
self._uploaded_files = uploaded_files or []
# ---------- 连接测试 ----------
async def test_connection(self) -> Dict[str, Any]:
"""测试 OpenClaw Gateway 连接"""
if not self._server_url:
return {"success": False, "message": "未配置 server_url"}
if not self._api_key:
return {"success": False, "message": "未配置 api_key"}
url = f"{self._server_url.rstrip('/')}/v1/responses"
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"x-openclaw-agent-id": self._agent_id
}
body = {
"model": self._model,
"user": "connection-test",
"input": "hi",
"stream": False
}
try:
timeout_cfg = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout_cfg) as session:
async with session.post(url, json=body, headers=headers) as resp:
if resp.status < 400:
return {"success": True, "message": "OpenClaw 连接成功"}
error_text = await resp.text()
return {
"success": False,
"message": f"OpenClaw HTTP {resp.status}: {error_text[:200]}"
}
except Exception as e:
return {"success": False, "message": f"OpenClaw 连接失败: {str(e)}"}
# ---------- 执行 ----------
async def execute(self, **kwargs) -> ToolResult:
"""执行 OpenClaw 调用"""
start_time = time.time()
try:
message = kwargs.get("message", "")
operation = kwargs.get("operation", "unknown")
if not message:
return ToolResult.error_result(
error="message 参数不能为空",
error_code="OPENCLAW_INVALID_INPUT",
execution_time=time.time() - start_time
)
# 提取图片优先从用户上传文件中获取LLM 传的 image_url 作为兜底
image_url = self._extract_image_from_uploads()
if not image_url:
image_url = kwargs.get("image_url")
if image_url and not image_url.startswith("data:"):
image_url = await self._download_and_encode_image(image_url)
# 构建请求
url = f"{self._server_url.rstrip('/')}/v1/responses"
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"x-openclaw-agent-id": self._agent_id
}
user_field = (
f"conv-{self._conversation_id}"
if self._session_strategy == "by_conversation" and self._conversation_id
else f"user-{self._user_id}"
)
input_field = self._build_input(message, image_url)
body = {
"model": self._model,
"user": user_field,
"input": input_field,
"stream": False
}
timeout_cfg = aiohttp.ClientTimeout(total=self._timeout)
# 打印请求日志(截断 base64 避免日志过大)
log_body = {**body}
if isinstance(log_body.get("input"), list):
log_body["input"] = "[multimodal input, truncated]"
elif isinstance(log_body.get("input"), str) and len(log_body["input"]) > 500:
log_body["input"] = log_body["input"][:500] + "..."
logger.info(
f"OpenClaw 请求: url={url}, agent_id={self._agent_id}, "
f"has_image={bool(image_url)}, body={log_body}"
)
async with aiohttp.ClientSession(timeout=timeout_cfg) as session:
async with session.post(url, json=body, headers=headers) as resp:
execution_time = time.time() - start_time
if resp.status >= 400:
error_text = await resp.text()
return ToolResult.error_result(
error=f"OpenClaw HTTP {resp.status}: {error_text[:500]}",
error_code="OPENCLAW_HTTP_ERROR",
execution_time=execution_time
)
data = await resp.json()
text = self._extract_response(data)
display_text = self._format_result(text)
return ToolResult.success_result(
data=display_text,
execution_time=execution_time
)
except aiohttp.ClientError as e:
return ToolResult.error_result(
error=f"OpenClaw 网络连接失败: {str(e)}",
error_code="OPENCLAW_NETWORK_ERROR",
execution_time=time.time() - start_time
)
except Exception as e:
return ToolResult.error_result(
error=f"OpenClaw 调用失败: {str(e)}",
error_code="OPENCLAW_EXECUTION_ERROR",
execution_time=time.time() - start_time
)
# ---------- 私有方法 ----------
def _extract_image_from_uploads(self) -> Optional[str]:
"""从用户上传文件中提取图片 URL"""
for f in self._uploaded_files:
f_type = f.get("type", "")
if f_type == "image":
source = f.get("source", {})
if source.get("type") == "base64":
media_type = source.get("media_type", "image/jpeg")
data = source.get("data", "")
return f"data:{media_type};base64,{data}"
elif f.get("image"):
return f.get("image")
elif f.get("url"):
return f.get("url")
elif f_type == "image_url":
return f.get("image_url", {}).get("url", "")
return None
async def _download_and_encode_image(self, image_url: str) -> str:
"""下载图片并转为 base64 data URI"""
try:
from PIL import Image
MAX_RAW_SIZE = 4 * 1024 * 1024
async with aiohttp.ClientSession() as session:
async with session.get(
image_url, allow_redirects=True,
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status != 200:
return image_url
content_type = resp.headers.get("Content-Type", "image/jpeg")
if not content_type.startswith("image/"):
return image_url
img_bytes = await resp.read()
if len(img_bytes) > MAX_RAW_SIZE:
img = Image.open(BytesIO(img_bytes))
if img.mode in ("RGBA", "P", "LA"):
img = img.convert("RGB")
if max(img.size) > 2048:
img.thumbnail((2048, 2048), Image.LANCZOS)
buf = BytesIO()
img.save(buf, format="JPEG", quality=75, optimize=True)
img_bytes = buf.getvalue()
content_type = "image/jpeg"
b64 = base64.b64encode(img_bytes).decode("utf-8")
return f"data:{content_type};base64,{b64}"
except Exception as e:
logger.warning(f"OpenClaw 下载图片失败,使用原始 URL: {e}")
return image_url
def _build_input(self, message: str, image_url: Optional[str] = None):
"""构造请求 input 字段:有图片则构造多模态结构,否则纯文本"""
if not image_url:
return message
content_parts = [{"type": "input_text", "text": message}]
if image_url.startswith("data:"):
try:
header, data = image_url.split(",", 1)
media_type = header.split(":")[1].split(";")[0]
content_parts.append({
"type": "input_image",
"source": {"type": "base64", "media_type": media_type, "data": data}
})
except (ValueError, IndexError):
return message
else:
content_parts.append({
"type": "input_image",
"source": {"type": "url", "url": image_url}
})
return [{"type": "message", "role": "user", "content": content_parts}]
def _extract_response(self, response_data: Dict[str, Any]) -> str:
"""从 OpenClaw 响应中提取文本内容
OpenClaw /v1/responses 只返回 output_text 类型的内容。
图片信息(如有)由 OpenClaw Skill 以 Markdown 链接形式嵌入文本中返回。
"""
output = response_data.get("output", [])
texts = []
for item in output:
if item.get("type") == "message":
for content in item.get("content", []):
if content.get("type") == "output_text" and content.get("text"):
texts.append(content["text"])
return "\n".join(texts) if texts else str(response_data)
@staticmethod
def _format_result(text: str) -> str:
"""格式化结果为 LLM 可读字符串"""
return text or "OpenClaw 返回了空内容)"

View File

@@ -32,6 +32,8 @@ class OperationTool(BaseTool):
return self._get_datetime_params()
elif self.base_tool.name == 'json_tool':
return self._get_json_params()
elif self.base_tool.name == 'openclaw_tool':
return self._get_openclaw_params()
else:
# 默认返回除operation外的所有参数
return [p for p in self.base_tool.parameters if p.name != "operation"]
@@ -209,6 +211,64 @@ class OperationTool(BaseTool):
else:
return base_params
def _get_openclaw_params(self) -> List[ToolParameter]:
"""获取 openclaw_tool 特定操作的参数"""
if self.operation == "print_task":
return [
ToolParameter(
name="message",
type=ParameterType.STRING,
description="发送给 OpenClaw 的打印任务描述,将用户的原始消息原封不动地传递给 OpenClaw禁止改写、补充或润色用户的原文",
required=True
),
ToolParameter(
name="image_url",
type=ParameterType.STRING,
description="可选附带的设计图片或参考图OpenClaw 可据此生成 3D 模型",
required=False
)
]
elif self.operation == "device_query":
return [
ToolParameter(
name="message",
type=ParameterType.STRING,
description="发送给 OpenClaw 的设备查询指令",
required=True
)
]
elif self.operation == "image_understand":
return [
ToolParameter(
name="message",
type=ParameterType.STRING,
description="发送给 OpenClaw 的图片理解任务,应描述需要对图片做什么(如描述内容、提取文字、分析信息)",
required=True
),
ToolParameter(
name="image_url",
type=ParameterType.STRING,
description="必须提供,要分析的图片 URL 或 base64 data URI",
required=True
)
]
else:
# general 及其他
return [
ToolParameter(
name="message",
type=ParameterType.STRING,
description="发送给 OpenClaw Agent 的任务描述,应包含完整的任务需求",
required=True
),
ToolParameter(
name="image_url",
type=ParameterType.STRING,
description="可选,附带的图片 URL 或 base64 data URI",
required=False
)
]
async def execute(self, **kwargs) -> ToolResult:
"""执行特定操作"""
# 添加operation参数

View File

@@ -0,0 +1,15 @@
{
"name": "openclaw_tool",
"description": "调用OpenClaw Agent远程服务",
"tool_class": "OpenClawTool",
"category": "agent",
"requires_config": true,
"version": "1.0.0",
"enabled": true,
"parameters": {
"server_url": "",
"api_key": "",
"agent_id": "main"
},
"tags": ["agent", "openclaw", "multimodal", "3d-printing", "builtin"]
}

View File

@@ -30,5 +30,18 @@
"parameters": {
"api_key": {"type": "string", "description": "百度搜索API密钥", "sensitive": true, "required": true}
}
},
"openclaw": {
"name": "OpenClaw远程Agent",
"description": "OpenClaw Agent远程服务",
"tool_class": "OpenClawTool",
"category": "agent",
"requires_config": true,
"version": "1.0.0",
"enabled": true,
"parameters": {
"server_url": {"type": "string", "description": "OpenClaw Gateway 地址", "required": true},
"api_key": {"type": "string", "description": "OpenClaw API Key", "sensitive": true, "required": true}
}
}
}

View File

@@ -31,7 +31,7 @@ class CustomTool(BaseTool):
self.base_url = config.get("base_url", "")
self.timeout = config.get("timeout", 30)
#===========OpenClaw特殊判断取到OpenClaw特殊配置==========
# 解析 schema
schema = self.schema_content
if isinstance(schema, str):
try:
@@ -39,51 +39,7 @@ class CustomTool(BaseTool):
self.schema_content = schema
except json.JSONDecodeError:
schema = {}
info = schema.get("info", {}) if isinstance(schema, dict) else {}
self._is_openclaw = info.get("x-openclaw", False)
if self._is_openclaw:
# 从扩展字段读取 OpenClaw 配置
self._openclaw_agent_id = info.get("x-openclaw-agent-id", "main")
self._openclaw_model = info.get("x-openclaw-default-model", "openclaw")
self._openclaw_session_strategy = info.get(
"x-openclaw-session-strategy", "by_user")
self._openclaw_timeout = info.get("x-openclaw-timeout", 60)
self._openclaw_input_mode = info.get("x-openclaw-input-mode", "text")
self._openclaw_output_mode = info.get("x-openclaw-output-mode", "text")
# 从 servers 读取 base_url
servers = schema.get("servers", [])
if servers:
self.base_url = servers[0].get("url", "")
# 从 auth_config 读取 token兼容 api_key 和 bearer_token 两种认证方式)
self._openclaw_token = (
self.auth_config.get("api_key") # api_key 认证方式
or self.auth_config.get("token") # bearer_token 认证方式
or ""
)
# 覆盖 timeout
self.timeout = self._openclaw_timeout
# 运行时上下文(后续注入)
self._user_id = "anonymous"
self._conversation_id = None
self._uploaded_files = [] # 新增:用户上传的文件
# 跳过 Schema 解析
self._parsed_operations = {}
logger.info(
f"检测到 OpenClaw 工具: agent_id={self._openclaw_agent_id}, "
f"base_url={self.base_url}, "
f"input_mode={self._openclaw_input_mode}, "
f"output_mode={self._openclaw_output_mode}")
else:
# 解析schema
self._parsed_operations = self._parse_openapi_schema()
self._parsed_operations = self._parse_openapi_schema()
@property
def name(self) -> str:
@@ -109,31 +65,6 @@ class CustomTool(BaseTool):
@property
def parameters(self) -> List[ToolParameter]:
"""工具参数定义"""
# ========== OpenClaw 特判 根据输入模式解析是否需要image_url ==========
if self._is_openclaw:
params = [
ToolParameter(
name="message",
type=ParameterType.STRING,
description="发送给 OpenClaw Agent 的文本请求内容",
required=True
)
]
# 多模态输入模式下,增加 image_url 参数
if self._openclaw_input_mode == "multimodal":
params.append(ToolParameter(
name="image_url",
type=ParameterType.STRING,
description=(
"可选附带的图片URL或base64 data URI"
"(如 data:image/png;base64,...)。"
"传入后 Agent 可以理解图片内容。"
),
required=False
))
return params
# ========== 特判结束 ==========
params = []
# 添加操作选择参数
@@ -166,10 +97,6 @@ class CustomTool(BaseTool):
async def execute(self, **kwargs) -> ToolResult:
"""执行自定义工具"""
# ========== OpenClaw 特判 ==========
if self._is_openclaw:
return await self._execute_openclaw(**kwargs)
# ========== 特判结束 ==========
start_time = time.time()
try:
@@ -210,275 +137,6 @@ class CustomTool(BaseTool):
execution_time=execution_time
)
#=============openclaw执行函数开始===============
async def _execute_openclaw(self, **kwargs) -> ToolResult:
"""OpenClaw 专属执行逻辑(支持多模态输入)"""
start_time = time.time()
try:
message = kwargs.get("message", "")
# 从用户实际上传的文件中提取图片 URL
image_url = None
if self._uploaded_files:
for f in self._uploaded_files:
f_type = f.get("type", "")
if f_type == "image":
# Bedrock/Anthropic 格式:{"type": "image", "source": {"type": "base64", ...}}
source = f.get("source", {})
if source.get("type") == "base64":
media_type = source.get("media_type", "image/jpeg")
data = source.get("data", "")
image_url = f"data:{media_type};base64,{data}"
elif f.get("image"):
# DashScope 格式:{"type": "image", "image": "url"}
image_url = f.get("image")
elif f.get("url"):
# 其他格式:{"type": "image", "url": "https://..."}
image_url = f.get("url")
break
elif f_type == "image_url":
# OpenAI/Volcano 格式:{"type": "image_url", "image_url": {"url": "..."}}
image_url = f.get("image_url", {}).get("url", "")
break
# 如果 image_url 是服务器中转 URL直接下载图片转 base64
# 避免 OSS 签名 URL 在重定向解析过程中被破坏
if image_url and not image_url.startswith("data:"):
try:
import base64
from io import BytesIO
from PIL import Image
MAX_RAW_SIZE = 4 * 1024 * 1024 # 超过 4MB 则压缩
async with aiohttp.ClientSession() as _session:
async with _session.get(image_url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=30)) as _resp:
if _resp.status == 200:
content_type = _resp.headers.get("Content-Type", "image/jpeg")
if content_type.startswith("image/"):
img_bytes = await _resp.read()
original_size = len(img_bytes)
logger.info(f"OpenClaw 下载图片: size={original_size} bytes, type={content_type}")
if original_size > MAX_RAW_SIZE:
img = Image.open(BytesIO(img_bytes))
if img.mode in ("RGBA", "P", "LA"):
img = img.convert("RGB")
max_side = 2048
if max(img.size) > max_side:
img.thumbnail((max_side, max_side), Image.LANCZOS)
buf = BytesIO()
img.save(buf, format="JPEG", quality=75, optimize=True)
img_bytes = buf.getvalue()
content_type = "image/jpeg"
logger.info(f"OpenClaw 图片已压缩: {original_size} -> {len(img_bytes)} bytes")
b64_data = base64.b64encode(img_bytes).decode("utf-8")
image_url = f"data:{content_type};base64,{b64_data}"
logger.info(f"OpenClaw 图片已转为 base64, size={len(img_bytes)} bytes")
else:
logger.warning(f"OpenClaw 图片 URL 返回非图片类型: {content_type}")
else:
logger.warning(f"OpenClaw 下载图片失败: HTTP {_resp.status}")
except Exception as e:
logger.warning(f"OpenClaw 下载图片失败,使用原始 URL: {e}")
if not message:
return ToolResult.error_result(
error="message 参数不能为空",
error_code="OPENCLAW_INVALID_INPUT",
execution_time=time.time() - start_time)
url = f"{self.base_url.rstrip('/')}/v1/responses"
#请求头
headers = {
"Authorization": f"Bearer {self._openclaw_token}",
"Content-Type": "application/json",
"x-openclaw-agent-id": self._openclaw_agent_id
}
# session 路由
if (self._openclaw_session_strategy == "by_conversation"
and self._conversation_id):
user_field = f"conv-{self._conversation_id}"
else:
user_field = f"user-{self._user_id}"
# 根据 input_mode 和是否有图片构造 input
input_field = self._build_openclaw_input(message, image_url)
#请求体
body = {
"model": self._openclaw_model,
"user": user_field,
"input": input_field,
"stream": False
}
logger.info(f"OpenClaw 请求体: {json.dumps(body, ensure_ascii=False)[:1000]}")
timeout_config = aiohttp.ClientTimeout(total=self.timeout)
#请求
async with aiohttp.ClientSession(timeout=timeout_config) as session:
async with session.post(url, json=body, headers=headers) as resp:
execution_time = time.time() - start_time
if resp.status >= 400:
error_text = await resp.text()
_img_preview2 = (image_url[:100] + "...") if image_url and len(image_url) > 100 else image_url
logger.error(
f"OpenClaw 调用失败: HTTP {resp.status}, "
f"url={url}, agent_id={self._openclaw_agent_id}, "
f"has_image={bool(image_url)}, image_url={_img_preview2}, "
f"input_type={'multimodal' if isinstance(input_field, list) else 'text'}, "
f"error_response={error_text[:1000]}"
)
return ToolResult.error_result(
error=f"OpenClaw HTTP {resp.status}: {error_text[:500]}",
error_code="OPENCLAW_HTTP_ERROR",
execution_time=execution_time)
data = await resp.json()
# 根据 output_mode 解析响应
result = self._extract_openclaw_response(
data, self._openclaw_output_mode)
display_text = self._format_openclaw_result(result)
logger.info(
"OpenClaw 调用成功",
extra={
"tool_id": self.tool_id,
"agent_id": self._openclaw_agent_id,
"has_images": len(result["images"]) > 0,
"execution_time": execution_time
})
return ToolResult.success_result(
data=display_text, execution_time=execution_time)
except aiohttp.ClientError as e:
return ToolResult.error_result(
error=f"OpenClaw 网络连接失败: {str(e)}",
error_code="OPENCLAW_NETWORK_ERROR",
execution_time=time.time() - start_time)
except Exception as e:
return ToolResult.error_result(
error=f"OpenClaw 调用失败: {str(e)}",
error_code="OPENCLAW_EXECUTION_ERROR",
execution_time=time.time() - start_time)
def _build_openclaw_input(self, message: str, image_url: str = None):
"""根据 input_mode 和是否有图片构造 OpenClaw input 字段
纯文本模式或无图片 → 返回字符串
多模态模式且有图片 → 返回结构化 item 数组
"""
if not image_url or self._openclaw_input_mode != "multimodal":
return message
# 构造多模态 content 数组
content_parts = [
{"type": "input_text", "text": message}
]
if image_url.startswith("data:"):
# base64 data URI: data:image/png;base64,iVBORw0KGgo...
try:
header, data = image_url.split(",", 1)
media_type = header.split(":")[1].split(";")[0]
content_parts.append({
"type": "input_image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data
}
})
except (ValueError, IndexError):
logger.warning("无法解析 base64 data URI回退为纯文本输入")
return message
else:
# URL 引用
content_parts.append({
"type": "input_image",
"source": {
"type": "url",
"url": image_url
}
})
return [{
"type": "message",
"role": "user",
"content": content_parts
}]
@staticmethod
def _extract_openclaw_response(response_data: Dict[str, Any],
output_mode: str = "text") -> Dict[str, Any]:
"""从 OpenClaw 响应中提取文本和图片
响应格式:
{"output": [{"type": "message", "content": [
{"type": "output_text", "text": "..."},
{"type": "output_image", "image_url": "..."}
]}]}
返回:
{"text": "文本内容", "images": [{"url": "...", "media_type": "image/png"}]}
"""
output = response_data.get("output", [])
texts = []
images = []
for item in output:
if item.get("type") == "message":
for content in item.get("content", []):
content_type = content.get("type")
if content_type == "output_text":
text = content.get("text", "")
if text:
texts.append(text)
elif content_type == "output_image" and output_mode == "multimodal":
image_url = content.get("image_url", "")
if image_url:
images.append({
"url": image_url,
"media_type": content.get("media_type", "image/png")
})
text_result = "\n".join(texts) if texts else ""
# text 模式下只返回文本(向后兼容)
if output_mode == "text":
return {"text": text_result or str(response_data), "images": []}
return {"text": text_result, "images": images}
@staticmethod
def _format_openclaw_result(result: Dict[str, Any]) -> str:
"""将解析结果格式化为返回给 LLM 的字符串
纯文本 → 直接返回
有图片 → 将图片以 Markdown 格式嵌入文本
"""
text = result.get("text", "")
images = result.get("images", [])
if not images:
return text or "OpenClaw 返回了空内容)"
parts = []
if text:
parts.append(text)
for i, img in enumerate(images):
parts.append(f"![OpenClaw 生成的图片 {i+1}]({img['url']})")
return "\n\n".join(parts)
#=============openclaw执行函数结束================
def _parse_openapi_schema(self) -> Dict[str, Any]:
"""解析OpenAPI schema"""
operations = {}

View File

@@ -131,7 +131,7 @@ class LangchainAdapter:
def _tool_supports_operations(tool: BaseTool) -> bool:
"""检查工具是否支持多操作"""
# 内置工具中支持操作的工具
builtin_operation_tools = ['datetime_tool', 'json_tool']
builtin_operation_tools = ['datetime_tool', 'json_tool', 'openclaw_tool']
# 检查内置工具
if tool.tool_type.value == "builtin" and tool.name in builtin_operation_tools:

View File

@@ -161,6 +161,17 @@ class BuiltinToolRepository:
BuiltinToolConfig.id == tool_id
).first()
@staticmethod
def get_existing_tool_classes(db: Session, tenant_id: uuid.UUID) -> set:
"""获取该租户已有的内置工具 tool_class 集合"""
rows = db.query(BuiltinToolConfig.tool_class).join(
ToolConfig, BuiltinToolConfig.id == ToolConfig.id
).filter(
ToolConfig.tenant_id == tenant_id,
ToolConfig.tool_type == ToolType.BUILTIN.value
).all()
return {row[0] for row in rows}
class CustomToolRepository:
"""自定义工具仓储类"""

View File

@@ -165,18 +165,14 @@ class AppChatService:
multimodal_service = MultimodalService(self.db, model_info)
processed_files = await multimodal_service.process_files(files)
logger.info(f"处理了 {len(processed_files)} 个文件")
#============为 OpenClaw 工具注入会话session======
# 为 OpenClaw 工具注入运行时上下文
# 为需要运行时上下文的工具注入上下文
for t in tools:
if hasattr(t, 'tool_instance') and hasattr(t.tool_instance, '_is_openclaw'):
if t.tool_instance._is_openclaw:
t.tool_instance._user_id = user_id or "anonymous"
t.tool_instance._conversation_id = (
str(conversation_id) if conversation_id else None)
# 注入用户上传的文件
if processed_files:
t.tool_instance._uploaded_files = processed_files
#============为 OpenClaw 工具注入会话session======
if hasattr(t, 'tool_instance') and hasattr(t.tool_instance, 'set_runtime_context'):
t.tool_instance.set_runtime_context(
user_id=user_id or "anonymous",
conversation_id=str(conversation_id) if conversation_id else None,
uploaded_files=processed_files or []
)
# 调用 Agent支持多模态
result = await agent.chat(
message=message,
@@ -424,16 +420,14 @@ class AppChatService:
processed_files = await multimodal_service.process_files(files)
logger.info(f"处理了 {len(processed_files)} 个文件")
#============为 OpenClaw 工具注入运行时上下文======
# 为需要运行时上下文的工具注入上下文
for t in tools:
if hasattr(t, 'tool_instance') and hasattr(t.tool_instance, '_is_openclaw'):
if t.tool_instance._is_openclaw:
t.tool_instance._user_id = user_id or "anonymous"
t.tool_instance._conversation_id = (
str(conversation_id) if conversation_id else None)
if processed_files:
t.tool_instance._uploaded_files = processed_files
#============为 OpenClaw 工具注入运行时上下文结束======
if hasattr(t, 'tool_instance') and hasattr(t.tool_instance, 'set_runtime_context'):
t.tool_instance.set_runtime_context(
user_id=user_id or "anonymous",
conversation_id=str(conversation_id) if conversation_id else None,
uploaded_files=processed_files or []
)
# 流式调用 Agent支持多模态同时并行启动 TTS
full_content = ""

View File

@@ -640,17 +640,14 @@ class AgentRunService:
multimodal_service = MultimodalService(self.db, model_info)
processed_files = await multimodal_service.process_files(files)
logger.info(f"处理了 {len(processed_files)} 个文件provider={provider}")
#================= 为 OpenClaw 工具注入运行时上下文==========
# 为需要运行时上下文的工具注入上下文
for t in tools:
if hasattr(t, 'tool_instance') and hasattr(t.tool_instance, '_is_openclaw'):
if t.tool_instance._is_openclaw:
t.tool_instance._user_id = user_id or "anonymous"
t.tool_instance._conversation_id = (
str(conversation_id) if conversation_id else None)
if processed_files:
t.tool_instance._uploaded_files = processed_files
logger.info(f"已注入 _uploaded_files, 数量: {len(processed_files)}")
#================= 为 OpenClaw 工具注入运行时上下文结束==========
if hasattr(t, 'tool_instance') and hasattr(t.tool_instance, 'set_runtime_context'):
t.tool_instance.set_runtime_context(
user_id=user_id or "anonymous",
conversation_id=str(conversation_id) if conversation_id else None,
uploaded_files=processed_files or []
)
# 7. 知识库检索
context = None
@@ -900,18 +897,14 @@ class AgentRunService:
multimodal_service = MultimodalService(self.db, model_info)
processed_files = await multimodal_service.process_files(files)
logger.info(f"处理了 {len(processed_files)} 个文件provider={provider}")
#============为 OpenClaw 工具注入会话session======
# 为 OpenClaw 工具注入运行时上下文
# 为需要运行时上下文的工具注入上下文
for t in tools:
if hasattr(t, 'tool_instance') and hasattr(t.tool_instance, '_is_openclaw'):
if t.tool_instance._is_openclaw:
t.tool_instance._user_id = user_id or "anonymous"
t.tool_instance._conversation_id = (
str(conversation_id) if conversation_id else None)
# 注入用户上传的文件
if processed_files:
t.tool_instance._uploaded_files = processed_files
#============为 OpenClaw 工具注入会话session======
if hasattr(t, 'tool_instance') and hasattr(t.tool_instance, 'set_runtime_context'):
t.tool_instance.set_runtime_context(
user_id=user_id or "anonymous",
conversation_id=str(conversation_id) if conversation_id else None,
uploaded_files=processed_files or []
)
# 7. 知识库检索
context = None

View File

@@ -34,7 +34,8 @@ BUILTIN_TOOLS = {
"JsonTool": "app.core.tools.builtin.json_tool",
"BaiduSearchTool": "app.core.tools.builtin.baidu_search_tool",
"MinerUTool": "app.core.tools.builtin.mineru_tool",
"TextInTool": "app.core.tools.builtin.textin_tool"
"TextInTool": "app.core.tools.builtin.textin_tool",
"OpenClawTool": "app.core.tools.builtin.openclaw_tool",
}
@@ -330,20 +331,6 @@ class ToolService:
if config.tool_type == ToolType.MCP.value:
return await self._test_mcp_connection(config)
elif config.tool_type == ToolType.CUSTOM.value:
# ========== 测试工具连接 OpenClaw 特判 ==========
custom_config = self.custom_repo.find_by_tool_id(self.db, config.id)
if custom_config and custom_config.schema_content:
schema = custom_config.schema_content
if isinstance(schema, str):
try:
schema = json.loads(schema)
except json.JSONDecodeError:
schema = {}
#请求头中包含OpenClaw字段
if isinstance(schema, dict) and schema.get("info", {}).get("x-openclaw"):
return await self._test_openclaw_connection(custom_config, schema)
# ========== OpenClaw 特判结束 ==========
#正常自定义工具逻辑
return await self._test_custom_connection(config)
elif config.tool_type == ToolType.BUILTIN.value:
return await self._test_builtin_connection(config)
@@ -353,62 +340,19 @@ class ToolService:
except Exception as e:
return {"success": False, "message": f"测试失败: {str(e)}"}
#=============测试openclaw连接 特判===============
async def _test_openclaw_connection(
self, custom_config: CustomToolConfig, schema: dict
) -> Dict[str, Any]:
"""测试 OpenClaw 连接"""
import aiohttp
try:
info = schema.get("info", {})
servers = schema.get("servers", [])
base_url = servers[0].get("url", "") if servers else ""
if not base_url:
return {"success": False, "message": "OpenClaw 未配置 server URL"}
auth = custom_config.auth_config or {}
token = auth.get("api_key") or auth.get("token") or ""
agent_id = info.get("x-openclaw-agent-id", "main")
model = info.get("x-openclaw-default-model", "openclaw")
url = f"{base_url.rstrip('/')}/v1/responses"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"x-openclaw-agent-id": agent_id
}
body = {
"model": model,
"user": "connection-test",
"input": "hi",
"stream": False
}
timeout_config = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout_config) as session:
async with session.post(url, json=body, headers=headers) as resp:
if resp.status < 400:
return {"success": True, "message": "OpenClaw 连接成功"}
error_text = await resp.text()
return {
"success": False,
"message": f"OpenClaw HTTP {resp.status}: {error_text[:200]}"
}
except Exception as e:
return {"success": False, "message": f"OpenClaw 连接失败: {str(e)}"}
#=============测试openclaw连接结束===========
def ensure_builtin_tools_initialized(self, tenant_id: uuid.UUID):
"""确保内置工具已初始化"""
existing = self.tool_repo.exists_builtin_for_tenant(self.db, tenant_id)
if existing:
"""确保内置工具已初始化(支持增量补充新工具)"""
builtin_config = self._load_builtin_config()
if not builtin_config:
return
# 从配置文件加载内置工具定义
builtin_config = self._load_builtin_config()
existing_classes = self.builtin_repo.get_existing_tool_classes(self.db, tenant_id)
added = False
for tool_key, tool_info in builtin_config.items():
if tool_info['tool_class'] in existing_classes:
continue
try:
# 创建工具配置
initial_status = self._determine_initial_status(tool_info)
tool_config = ToolConfig(
name=tool_info['name'],
@@ -424,7 +368,6 @@ class ToolService:
self.db.add(tool_config)
self.db.flush()
# 创建内置工具配置
builtin_config_obj = BuiltinToolConfig(
id=tool_config.id,
tool_class=tool_info['tool_class'],
@@ -432,12 +375,14 @@ class ToolService:
requires_config=tool_info.get('requires_config', False)
)
self.db.add(builtin_config_obj)
added = True
except Exception as e:
logger.error(f"初始化内置工具失败: {tool_key}, {e}")
self.db.commit()
logger.info(f"租户 {tenant_id} 内置工具初始化完成")
if added:
self.db.commit()
logger.info(f"租户 {tenant_id} 内置工具增量初始化完成")
async def get_tool_methods(self, tool_id: str, tenant_id: uuid.UUID) -> Optional[List[Dict[str, Any]]]:
"""获取工具的所有方法
@@ -515,6 +460,9 @@ class ToolService:
# 对于json_tool根据操作类型返回相关参数
elif hasattr(tool_instance, 'name') and tool_instance.name == 'json_tool':
return self._get_json_tool_params(operation)
# 对于openclaw_tool根据操作类型返回不同描述的参数
elif hasattr(tool_instance, 'name') and tool_instance.name == 'openclaw_tool':
return self._get_openclaw_tool_params(operation)
# 其他工具的默认处理返回除operation外的所有参数
return [{
@@ -744,6 +692,65 @@ class ToolService:
return base_params
@staticmethod
def _get_openclaw_tool_params(operation: str) -> List[Dict[str, Any]]:
"""获取 openclaw_tool 特定操作的参数"""
if operation == "print_task":
return [
{
"name": "message",
"type": "string",
"description": "发送给 OpenClaw 的打印任务描述,将用户的原始消息原封不动地传递给 OpenClaw禁止改写、补充或润色用户的原文",
"required": True
},
{
"name": "image_url",
"type": "string",
"description": "可选附带的设计图片或参考图OpenClaw 可据此生成 3D 模型",
"required": False
}
]
elif operation == "device_query":
return [
{
"name": "message",
"type": "string",
"description": "发送给 OpenClaw 的设备查询指令",
"required": True
}
]
elif operation == "image_understand":
return [
{
"name": "message",
"type": "string",
"description": "发送给 OpenClaw 的图片理解任务,应描述需要对图片做什么(如描述内容、提取文字、分析信息)",
"required": True
},
{
"name": "image_url",
"type": "string",
"description": "必须提供,要分析的图片 URL 或 base64 data URI",
"required": True
}
]
else:
# general 及其他
return [
{
"name": "message",
"type": "string",
"description": "发送给 OpenClaw Agent 的任务描述,应包含完整的任务需求",
"required": True
},
{
"name": "image_url",
"type": "string",
"description": "可选,附带的图片 URL 或 base64 data URI",
"required": False
}
]
async def _get_custom_tool_methods(self, config: ToolConfig) -> List[Dict[str, Any]]:
"""获取自定义工具的方法"""
custom_config = self.custom_repo.find_by_tool_id(self.db, config.id)
@@ -1196,27 +1203,6 @@ class ToolService:
custom_config = self.db.query(CustomToolConfig).filter(
CustomToolConfig.id == tool_config.id
).first()
# ========== 更新工具 OpenClaw 特判 ==========
if custom_config and custom_config.schema_content:
schema = custom_config.schema_content
if isinstance(schema, str):
try:
schema = json.loads(schema)
except json.JSONDecodeError:
schema = {}
info = schema.get("info", {}) if isinstance(schema, dict) else {}
if info.get("x-openclaw"):
servers = schema.get("servers", [])
has_url = bool(servers and servers[0].get("url"))
has_agent_id = bool(info.get("x-openclaw-agent-id"))
has_token = bool(custom_config.auth_config
and custom_config.auth_config.get("api_key"))
if has_url and has_agent_id and has_token:
tool_config.status = ToolStatus.AVAILABLE.value
else:
tool_config.status = ToolStatus.UNCONFIGURED.value
return
# ========== OpenClaw 特判结束 ==========
if custom_config and tool_config.name and (custom_config.schema_content or custom_config.schema_url):
tool_config.status = ToolStatus.AVAILABLE.value