224 lines
7.7 KiB
Python
224 lines
7.7 KiB
Python
"""Beaver 工具系统的统一契约。
|
|
|
|
这一层的目标不是实现具体工具,而是把 runtime 真正依赖的最小接口定死。
|
|
|
|
我们需要统一回答 4 个问题:
|
|
1. 一个工具长什么样
|
|
2. tool schema 怎么导出给 provider
|
|
3. 工具执行结果长什么样
|
|
4. tool loop 执行时,可以把哪些运行时依赖传给工具
|
|
|
|
这层故意保持很薄:
|
|
- 不绑定 MCP
|
|
- 不绑定 memory/session
|
|
- 不绑定具体 provider
|
|
|
|
这样内建工具、MCP 工具、未来插件工具都可以收敛到同一套契约上。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
import inspect
|
|
import json
|
|
from typing import Any
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ToolSpec:
|
|
"""单个工具对外暴露的描述信息。
|
|
|
|
这份信息主要服务两个场景:
|
|
1. 以 MCP-style descriptor 作为统一事实来源
|
|
2. 导出给 provider 的 function schema
|
|
3. 在 registry 中做列出、查找、调试与 embedding 召回
|
|
"""
|
|
|
|
name: str
|
|
description: str
|
|
input_schema: dict[str, Any]
|
|
toolset: str = "core"
|
|
always_available: bool = False
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_mcp_descriptor(self) -> dict[str, Any]:
|
|
"""导出 MCP ListTools 风格的工具描述。
|
|
|
|
MCP 的基础字段是 `name`、`description`、`inputSchema`。
|
|
Beaver 内部额外的 toolset/always_available 不塞进这个对象,
|
|
避免未来对接真实 MCP server 时出现格式偏差。
|
|
"""
|
|
|
|
return {
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"inputSchema": self.input_schema,
|
|
}
|
|
|
|
def to_provider_schema(self) -> dict[str, Any]:
|
|
"""导出为 OpenAI-compatible function tool schema。"""
|
|
|
|
return {
|
|
"type": "function",
|
|
"function": {
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"parameters": self.input_schema,
|
|
},
|
|
}
|
|
|
|
def to_embedding_candidate(self) -> dict[str, str]:
|
|
"""导出给语义召回使用的轻量文本候选。"""
|
|
|
|
return {
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"input_schema": json.dumps(self.input_schema, ensure_ascii=False, sort_keys=True),
|
|
}
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ToolContext:
|
|
"""一次工具执行时可用的运行时上下文。
|
|
|
|
这不是“所有系统对象的大杂烩”,而是当前工具执行阶段最常用的公共入口。
|
|
后面主链接进来时,可以把 session manager / memory store / workspace 等从这里传入。
|
|
"""
|
|
|
|
workspace: str | None = None
|
|
session_id: str | None = None
|
|
user_id: str | None = None
|
|
services: dict[str, Any] = field(default_factory=dict)
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def get(self, key: str, default: Any = None) -> Any:
|
|
"""优先从 services 中取依赖,方便工具侧少写样板代码。"""
|
|
|
|
return self.services.get(key, default)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ToolResult:
|
|
"""标准化工具执行结果。
|
|
|
|
统一返回结构的意义是:
|
|
1. tool loop 更容易记录日志和失败信息
|
|
2. provider 回灌时可以稳定地拿到字符串内容
|
|
3. 后面要做工具审计时,数据结构已经固定
|
|
"""
|
|
|
|
success: bool
|
|
content: str
|
|
tool_name: str
|
|
error: str | None = None
|
|
raw_output: Any | None = None
|
|
|
|
|
|
class BaseTool(ABC):
|
|
"""所有工具实现都应遵守的抽象基类。"""
|
|
|
|
@property
|
|
@abstractmethod
|
|
def spec(self) -> ToolSpec:
|
|
"""返回工具元数据。"""
|
|
|
|
@abstractmethod
|
|
async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult:
|
|
"""执行工具调用。"""
|
|
|
|
|
|
class ObjectBackedTool(BaseTool):
|
|
"""把现有“轻量对象工具”适配到统一 BaseTool 契约。
|
|
|
|
目前 `MemoryTool` / `SessionSearchTool` 已经存在,但它们还不是统一的 BaseTool。
|
|
这个适配器的作用就是避免重写业务逻辑,只做接口收口。
|
|
"""
|
|
|
|
def __init__(self, backend: Any) -> None:
|
|
self.backend = backend
|
|
self._spec = ToolSpec(
|
|
name=str(getattr(backend, "name")),
|
|
description=str(getattr(backend, "description", "")),
|
|
input_schema=dict(getattr(backend, "parameters", {"type": "object", "properties": {}})),
|
|
toolset=str(getattr(backend, "toolset", "core")),
|
|
always_available=bool(getattr(backend, "always_available", False)),
|
|
)
|
|
|
|
@property
|
|
def spec(self) -> ToolSpec:
|
|
return self._spec
|
|
|
|
async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult:
|
|
try:
|
|
call_arguments = dict(arguments)
|
|
self._inject_runtime_context(call_arguments, context)
|
|
content = await self.backend.execute(**call_arguments)
|
|
result = self._normalize_output(content)
|
|
return ToolResult(
|
|
success=result["success"],
|
|
content=result["content"],
|
|
tool_name=self.spec.name,
|
|
error=result.get("error"),
|
|
raw_output=content,
|
|
)
|
|
except Exception as exc:
|
|
return ToolResult(
|
|
success=False,
|
|
content=f"Tool {self.spec.name} failed: {exc}",
|
|
tool_name=self.spec.name,
|
|
error=str(exc),
|
|
)
|
|
|
|
def _inject_runtime_context(self, arguments: dict[str, Any], context: ToolContext) -> None:
|
|
"""把少量 runtime 上下文注入到后端工具参数中。
|
|
|
|
当前只做最小注入:
|
|
- 只有当 backend 明确暴露对应字段时才注入
|
|
- 避免把 ToolContext 整个对象直接塞给现有 builtin 工具
|
|
"""
|
|
|
|
if "current_session_id" not in arguments and hasattr(self.backend, "current_session_id"):
|
|
arguments["current_session_id"] = context.session_id
|
|
if "workspace" not in arguments and (hasattr(self.backend, "workspace") or self._backend_accepts_argument("workspace")):
|
|
arguments["workspace"] = context.workspace
|
|
if "services" not in arguments and self._backend_accepts_argument("services"):
|
|
arguments["services"] = context.services
|
|
if "metadata" not in arguments and self._backend_accepts_argument("metadata"):
|
|
arguments["metadata"] = context.metadata
|
|
|
|
def _backend_accepts_argument(self, name: str) -> bool:
|
|
try:
|
|
signature = inspect.signature(self.backend.execute)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
for parameter in signature.parameters.values():
|
|
if parameter.kind == inspect.Parameter.VAR_KEYWORD:
|
|
return True
|
|
if parameter.name == name:
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def _normalize_output(content: Any) -> dict[str, Any]:
|
|
"""把后端工具返回值转成统一 success/content/error 语义。
|
|
|
|
对现有 builtin 工具最关键的是:
|
|
- 若返回的是 JSON 字符串,且包含 `success` 字段,就尊重它
|
|
- 否则默认视为普通成功文本
|
|
"""
|
|
|
|
if isinstance(content, str):
|
|
try:
|
|
parsed = json.loads(content)
|
|
except json.JSONDecodeError:
|
|
return {"success": True, "content": content}
|
|
if isinstance(parsed, dict) and "success" in parsed:
|
|
return {
|
|
"success": bool(parsed.get("success")),
|
|
"content": content,
|
|
"error": parsed.get("error"),
|
|
}
|
|
return {"success": True, "content": content}
|
|
return {"success": True, "content": str(content)}
|