Files
LangBot/src/langbot/pkg/provider/tools/toolmgr.py
T
Hyu e631da0073 fix(runner): align SDK pin and complete real runtime verification (#2525)
* fix(runner): align SDK pin and workspace-aware integration fixtures

* fix(ci): format sources and resolve current migration head

* test(persistence): align standalone migration fixtures with current models

* test(web): align smoke fixtures with current processor UI

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 12:57:29 +08:00

592 lines
22 KiB
Python

from __future__ import annotations
import typing
import time
import inspect
from typing import TYPE_CHECKING
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from langbot_plugin.api.entities.events import pipeline_query
from . import loader as tool_loader
from .errors import ToolNotFoundError
from ...pipeline.pool import get_query_execution_context
from ...api.http.service.tenant import TenantContext
if TYPE_CHECKING:
from ...core import app
from langbot.pkg.provider.tools.loaders import (
mcp as mcp_loader,
native as native_loader,
plugin as plugin_loader,
skill_authoring as skill_authoring_loader,
)
TOOL_SOURCE_REFS_QUERY_KEY = '_host_tool_source_refs'
class ToolSourceRef(typing.TypedDict):
"""Stable Host-side identity for one tool implementation."""
source: str
source_id: str | None
class ToolManager:
"""LLM工具管理器"""
ap: app.Application
native_tool_loader: native_loader.NativeToolLoader
plugin_tool_loader: plugin_loader.PluginToolLoader
mcp_tool_loader: mcp_loader.MCPLoader
skill_tool_loader: skill_authoring_loader.SkillToolLoader
def __init__(self, ap: app.Application):
self.ap = ap
async def _bind_plugin_workspace(self, context: TenantContext) -> None:
"""Select the tenant before any plugin catalog lookup.
Tool discovery happens before invocation, so relying on ``call_tool``
to bind the Workspace is too late and can expose another task's
catalog in a shared Runtime.
"""
connector = getattr(self.ap, 'plugin_connector', None)
require_context = getattr(connector, 'require_workspace_context', None)
if require_context is None:
return
result = require_context(context)
if inspect.isawaitable(result):
await result
async def _workspace_sandbox_available(self, context: TenantContext) -> bool:
"""Resolve the Workspace capability before exposing sandbox tools."""
box_service = getattr(self.ap, 'box_service', None)
checker = getattr(box_service, 'is_workspace_sandbox_available', None)
if not callable(checker):
# Compatibility for OSS embedders and isolated manager tests. The
# BoxService execution path remains the final authority.
return True
try:
return bool(await checker(context))
except Exception:
return False
async def initialize(self):
from langbot.pkg.utils import importutil
from langbot.pkg.provider.tools import loaders
from langbot.pkg.provider.tools.loaders import (
mcp as mcp_loader,
native as native_loader,
plugin as plugin_loader,
skill_authoring as skill_authoring_loader,
)
importutil.import_modules_in_pkg(loaders)
self.native_tool_loader = native_loader.NativeToolLoader(self.ap)
await self.native_tool_loader.initialize()
self.plugin_tool_loader = plugin_loader.PluginToolLoader(self.ap)
await self.plugin_tool_loader.initialize()
self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap)
await self.mcp_tool_loader.initialize()
self.skill_tool_loader = skill_authoring_loader.SkillToolLoader(self.ap)
await self.skill_tool_loader.initialize()
async def get_all_tools(
self,
context: TenantContext,
bound_plugins: list[str] | None = None,
bound_mcp_servers: list[str] | None = None,
include_skill_authoring: bool = False,
include_mcp_resource_tools: bool = True,
) -> list[resource_tool.LLMTool]:
await self._bind_plugin_workspace(context)
all_functions: list[resource_tool.LLMTool] = []
sandbox_available = await self._workspace_sandbox_available(context)
if sandbox_available:
all_functions.extend(await self.native_tool_loader.get_tools())
if include_skill_authoring and sandbox_available:
all_functions.extend(await self.skill_tool_loader.get_tools())
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
all_functions.extend(
await self.mcp_tool_loader.get_tools(
context,
bound_mcp_servers,
include_resource_tools=include_mcp_resource_tools,
)
)
return all_functions
async def get_tool_catalog(
self,
context: TenantContext,
bound_plugins: list[str] | None = None,
bound_mcp_servers: list[str] | None = None,
include_skill_authoring: bool = False,
include_mcp_resource_tools: bool = False,
) -> list[dict[str, typing.Any]]:
await self._bind_plugin_workspace(context)
catalog: list[dict[str, typing.Any]] = []
def append_tools(source: str, source_name: str, tools: list[resource_tool.LLMTool]) -> None:
for tool in tools:
catalog.append(
{
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
'source': source,
'source_name': source_name,
}
)
sandbox_available = await self._workspace_sandbox_available(context)
if sandbox_available:
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
if include_skill_authoring and sandbox_available:
append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools())
catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins))
if self.mcp_tool_loader:
for item in await self.mcp_tool_loader.get_tool_catalog(
context,
bound_mcp_servers,
include_resource_tools=include_mcp_resource_tools,
):
catalog.append(item)
return catalog
async def get_resolved_tool_catalog(
self,
context: TenantContext,
bound_plugins: list[str] | None = None,
bound_mcp_servers: list[str] | None = None,
include_skill_authoring: bool = True,
include_mcp_resource_tools: bool = False,
) -> list[dict[str, typing.Any]]:
"""Return scoped tools with one unambiguous implementation per name.
LLM tool calls only carry a function name. If two implementations with
the same name remain inside the current Host scope, choosing one by
loader or registration order would authorize one resource and execute
another. Such names are therefore omitted until the scope is narrowed.
"""
catalog = await self.get_tool_catalog(
context,
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
include_mcp_resource_tools=include_mcp_resource_tools,
)
tools_by_name: dict[str, list[dict[str, typing.Any]]] = {}
for item in catalog:
name = item.get('name')
if isinstance(name, str) and name:
tools_by_name.setdefault(name, []).append(item)
resolved: list[dict[str, typing.Any]] = []
for name, candidates in tools_by_name.items():
implementations = {
(str(item.get('source') or ''), self._normalize_source_id(item.get('source_id'))) for item in candidates
}
if len(implementations) != 1:
self.ap.logger.warning(
f'Tool {name} is hidden because multiple implementations are visible: '
f'{sorted(implementations, key=lambda item: (item[0], item[1] or ""))}'
)
continue
resolved.append(candidates[0])
return resolved
@staticmethod
def _normalize_source_id(source_id: typing.Any) -> str | None:
return source_id if isinstance(source_id, str) and source_id else None
@classmethod
def source_ref_from_catalog_item(cls, item: dict[str, typing.Any]) -> ToolSourceRef | None:
source = item.get('source')
if not isinstance(source, str) or not source:
return None
return {
'source': source,
'source_id': cls._normalize_source_id(item.get('source_id')),
}
@classmethod
def source_refs_from_catalog(
cls,
catalog: typing.Iterable[dict[str, typing.Any]],
) -> dict[str, ToolSourceRef]:
refs: dict[str, ToolSourceRef] = {}
for item in catalog:
name = item.get('name')
ref = cls.source_ref_from_catalog_item(item)
if isinstance(name, str) and name and ref is not None:
refs[name] = ref
return refs
@staticmethod
def tools_from_catalog(
catalog: typing.Iterable[dict[str, typing.Any]],
) -> list[resource_tool.LLMTool]:
"""Materialize LLM schemas from an already authorized Host catalog."""
return [
resource_tool.LLMTool(
name=item['name'],
human_desc=item.get('human_desc') or item.get('description') or item['name'],
description=item.get('description') or '',
parameters=item.get('parameters') or {},
func=lambda parameters: {},
)
for item in catalog
]
@classmethod
def bind_query_tool_sources(
cls,
query: pipeline_query.Query,
catalog: typing.Iterable[dict[str, typing.Any]],
) -> None:
query.variables = query.variables or {}
query.variables[TOOL_SOURCE_REFS_QUERY_KEY] = cls.source_refs_from_catalog(catalog)
@staticmethod
def get_query_tool_source(
query: pipeline_query.Query,
name: str,
) -> ToolSourceRef | None:
variables = getattr(query, 'variables', None)
if not isinstance(variables, dict):
return None
refs = variables.get(TOOL_SOURCE_REFS_QUERY_KEY)
if not isinstance(refs, dict):
return None
ref = refs.get(name)
if not isinstance(ref, dict):
return None
source = ref.get('source')
if not isinstance(source, str) or not source:
return None
source_id = ref.get('source_id')
return {
'source': source,
'source_id': source_id if isinstance(source_id, str) and source_id else None,
}
async def get_tool_by_name(self, context: TenantContext, name: str) -> tool_loader.ToolLookupResult | None:
"""Get tool by name from any active loader."""
await self._bind_plugin_workspace(context)
sandbox_available = await self._workspace_sandbox_available(context)
if sandbox_available:
tool = await self.native_tool_loader.get_tool(name)
if tool:
return tool
for active_loader in (self.plugin_tool_loader,):
tool = await active_loader.get_tool(name)
if tool:
return tool
if sandbox_available:
tool = await self.skill_tool_loader.get_tool(name)
if tool:
return tool
return await self.mcp_tool_loader.get_tool(context, name)
async def get_tool_schema(
self,
context: TenantContext,
name: str,
source_ref: ToolSourceRef | None = None,
) -> tuple[str | None, dict | None]:
"""Return (description, parameters JSON schema) for a tool by name.
Used by the host to prefill ToolResource so a runner can build LLM tool
definitions without a separate get_tool_detail round-trip. All loaders
return resource_tool.LLMTool, so no per-shape branching is needed.
Returns (None, None) when the tool is not found.
"""
tool = (
await self.get_tool_by_source(context, name, source_ref)
if source_ref
else await self.get_tool_by_name(context, name)
)
if tool is None:
return None, None
return tool.description, (tool.parameters or None)
async def get_tool_detail(
self,
context: TenantContext,
name: str,
source_ref: ToolSourceRef | None = None,
) -> dict | None:
"""Return the host-level tool detail shape for a tool by name.
All loaders return resource_tool.LLMTool, so the shape is uniform:
{name, description, human_desc, parameters}. Returns None when the tool
is not found.
"""
tool = (
await self.get_tool_by_source(context, name, source_ref)
if source_ref
else await self.get_tool_by_name(context, name)
)
if tool is None:
return None
return {
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters or {},
}
async def get_tool_by_source(
self,
context: TenantContext,
name: str,
source_ref: ToolSourceRef,
) -> tool_loader.ToolLookupResult | None:
"""Resolve a tool only from the implementation frozen at authorization."""
source = source_ref['source']
source_id = source_ref.get('source_id')
if source in {'builtin', 'native'}:
return await self.native_tool_loader.get_tool(name)
if source == 'skill':
return await self.skill_tool_loader.get_tool(name)
if source == 'plugin':
if not source_id:
return None
return await self.plugin_tool_loader.get_tool(name, source_id=source_id)
if source == 'mcp':
return await self.mcp_tool_loader.get_tool(context, name, source_id=source_id)
return None
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
tools = []
for function in use_funcs:
function_schema = {
'type': 'function',
'function': {
'name': function.name,
'description': function.description,
'parameters': function.parameters,
},
}
tools.append(function_schema)
return tools
def _get_query_session_id(self, query: pipeline_query.Query) -> str | None:
launcher_type = getattr(query, 'launcher_type', None)
launcher_id = getattr(query, 'launcher_id', None)
if launcher_type is None or launcher_id is None:
return None
launcher_type_value = launcher_type.value if hasattr(launcher_type, 'value') else launcher_type
return f'{launcher_type_value}_{launcher_id}'
async def _record_tool_call(
self,
*,
name: str,
source: str,
parameters: dict,
query: pipeline_query.Query,
duration_ms: int,
status: str,
result: typing.Any = None,
error_message: str | None = None,
) -> None:
monitoring_service = getattr(self.ap, 'monitoring_service', None)
if not monitoring_service:
return
variables = getattr(query, 'variables', {}) or {}
message_id = variables.get('_monitoring_message_id') if isinstance(variables, dict) else None
bot_name = variables.get('_monitoring_bot_name') if isinstance(variables, dict) else None
pipeline_name = variables.get('_monitoring_pipeline_name') if isinstance(variables, dict) else None
try:
await monitoring_service.record_tool_call(
get_query_execution_context(query),
tool_name=name,
tool_source=source,
duration=duration_ms,
status=status,
bot_id=getattr(query, 'bot_uuid', None),
bot_name=bot_name,
pipeline_name=pipeline_name,
session_id=self._get_query_session_id(query),
message_id=message_id,
arguments=parameters,
result=result,
error_message=error_message,
)
except Exception as e:
self.ap.logger.warning(f'Failed to record tool call: {e}')
async def _invoke_tool_with_monitoring(
self,
*,
source: str,
name: str,
parameters: dict,
query: pipeline_query.Query,
invoke: typing.Callable[[], typing.Awaitable[typing.Any]],
) -> typing.Any:
start_time = time.perf_counter()
try:
result = await invoke()
except Exception as e:
duration_ms = int((time.perf_counter() - start_time) * 1000)
await self._record_tool_call(
name=name,
source=source,
parameters=parameters,
query=query,
duration_ms=duration_ms,
status='error',
error_message=str(e),
)
raise
duration_ms = int((time.perf_counter() - start_time) * 1000)
await self._record_tool_call(
name=name,
source=source,
parameters=parameters,
query=query,
duration_ms=duration_ms,
status='success',
result=result,
)
return result
async def execute_func_call(
self,
name: str,
parameters: dict,
query: pipeline_query.Query,
source_ref: ToolSourceRef | None = None,
) -> typing.Any:
from langbot.pkg.telemetry import features as telemetry_features
source_ref = source_ref or self.get_query_tool_source(query, name)
if source_ref is not None:
execution_context = get_query_execution_context(query)
await self._bind_plugin_workspace(execution_context)
sandbox_available = await self._workspace_sandbox_available(execution_context)
source = source_ref['source']
source_id = source_ref.get('source_id')
uses_source_id = False
if source in {'builtin', 'native'}:
if not sandbox_available:
raise ToolNotFoundError(name)
loader = self.native_tool_loader
telemetry_source = 'native'
exists = await loader.has_tool(name)
elif source == 'skill':
if not sandbox_available:
raise ToolNotFoundError(name)
loader = self.skill_tool_loader
telemetry_source = 'skill'
exists = await loader.has_tool(name)
elif source == 'plugin' and source_id:
loader = self.plugin_tool_loader
telemetry_source = 'plugin'
uses_source_id = True
exists = await loader.has_tool(name, source_id=source_id)
elif source == 'mcp':
loader = self.mcp_tool_loader
telemetry_source = 'mcp'
uses_source_id = True
exists = await loader.has_tool(
execution_context,
name,
source_id=source_id,
)
else:
raise ToolNotFoundError(name)
if not exists:
raise ToolNotFoundError(name)
async def invoke_selected_tool() -> typing.Any:
if source == 'mcp':
return await loader.invoke_tool(
name,
parameters,
query,
source_id=source_id,
)
if uses_source_id:
return await loader.invoke_tool(name, parameters, query, source_id=source_id)
return await loader.invoke_tool(name, parameters, query)
telemetry_features.increment(query, 'tool_calls', telemetry_source)
return await self._invoke_tool_with_monitoring(
source=telemetry_source,
name=name,
parameters=parameters,
query=query,
invoke=invoke_selected_tool,
)
execution_context = get_query_execution_context(query)
await self._bind_plugin_workspace(execution_context)
sandbox_available = await self._workspace_sandbox_available(execution_context)
if sandbox_available and await self.native_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'native')
return await self._invoke_tool_with_monitoring(
source='native',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.native_tool_loader.invoke_tool(name, parameters, query),
)
if await self.plugin_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'plugin')
return await self._invoke_tool_with_monitoring(
source='plugin',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.plugin_tool_loader.invoke_tool(name, parameters, query),
)
if await self.mcp_tool_loader.has_tool(execution_context, name):
telemetry_features.increment(query, 'tool_calls', 'mcp')
return await self._invoke_tool_with_monitoring(
source='mcp',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query),
)
if sandbox_available and await self.skill_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'skill')
return await self._invoke_tool_with_monitoring(
source='skill',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query),
)
raise ToolNotFoundError(name)
async def shutdown(self):
await self.native_tool_loader.shutdown()
await self.plugin_tool_loader.shutdown()
await self.mcp_tool_loader.shutdown()
await self.skill_tool_loader.shutdown()