"""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 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"): arguments["workspace"] = context.workspace if "metadata" not in arguments: arguments["metadata"] = context.metadata @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)}