mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 23:07:14 +00:00
chore(merge): sync master into dev/4.11.x
This commit is contained in:
@@ -2,6 +2,7 @@ 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
|
||||
@@ -9,6 +10,8 @@ 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
|
||||
@@ -43,6 +46,36 @@ class ToolManager:
|
||||
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
|
||||
@@ -67,21 +100,24 @@ class ToolManager:
|
||||
|
||||
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] = []
|
||||
|
||||
all_functions.extend(await self.native_tool_loader.get_tools())
|
||||
# Skill tools (activate / register_skill) are exposed like native tools:
|
||||
# the SkillToolLoader gates itself on sandbox + skill_mgr availability, so
|
||||
# skill is just a group of authorized tools rather than a separate
|
||||
# capability-gated surface.
|
||||
all_functions.extend(await self.skill_tool_loader.get_tools())
|
||||
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,
|
||||
)
|
||||
@@ -91,11 +127,13 @@ class ToolManager:
|
||||
|
||||
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:
|
||||
@@ -111,13 +149,16 @@ class ToolManager:
|
||||
}
|
||||
)
|
||||
|
||||
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
|
||||
if include_skill_authoring:
|
||||
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,
|
||||
):
|
||||
@@ -127,6 +168,7 @@ class ToolManager:
|
||||
|
||||
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,
|
||||
@@ -140,6 +182,7 @@ class ToolManager:
|
||||
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,
|
||||
@@ -240,22 +283,28 @@ class ToolManager:
|
||||
'source_id': source_id if isinstance(source_id, str) and source_id else None,
|
||||
}
|
||||
|
||||
async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None:
|
||||
async def get_tool_by_name(self, context: TenantContext, name: str) -> tool_loader.ToolLookupResult | None:
|
||||
"""Get tool by name from any active loader."""
|
||||
for active_loader in (
|
||||
self.native_tool_loader,
|
||||
self.plugin_tool_loader,
|
||||
self.mcp_tool_loader,
|
||||
self.skill_tool_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 None
|
||||
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]:
|
||||
@@ -266,13 +315,18 @@ class ToolManager:
|
||||
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(name, source_ref) if source_ref else await self.get_tool_by_name(name)
|
||||
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:
|
||||
@@ -282,7 +336,11 @@ class ToolManager:
|
||||
{name, description, human_desc, parameters}. Returns None when the tool
|
||||
is not found.
|
||||
"""
|
||||
tool = await self.get_tool_by_source(name, source_ref) if source_ref else await self.get_tool_by_name(name)
|
||||
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 {
|
||||
@@ -294,6 +352,7 @@ class ToolManager:
|
||||
|
||||
async def get_tool_by_source(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
source_ref: ToolSourceRef,
|
||||
) -> tool_loader.ToolLookupResult | None:
|
||||
@@ -309,7 +368,9 @@ class ToolManager:
|
||||
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(name, source_id=source_id)
|
||||
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:
|
||||
@@ -360,6 +421,7 @@ class ToolManager:
|
||||
|
||||
try:
|
||||
await monitoring_service.record_tool_call(
|
||||
get_query_execution_context(query),
|
||||
tool_name=name,
|
||||
tool_source=source,
|
||||
duration=duration_ms,
|
||||
@@ -424,14 +486,23 @@ class ToolManager:
|
||||
|
||||
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)
|
||||
@@ -444,7 +515,11 @@ class ToolManager:
|
||||
loader = self.mcp_tool_loader
|
||||
telemetry_source = 'mcp'
|
||||
uses_source_id = True
|
||||
exists = await loader.has_tool(name, source_id=source_id)
|
||||
exists = await loader.has_tool(
|
||||
execution_context,
|
||||
name,
|
||||
source_id=source_id,
|
||||
)
|
||||
else:
|
||||
raise ToolNotFoundError(name)
|
||||
|
||||
@@ -452,6 +527,13 @@ class ToolManager:
|
||||
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)
|
||||
@@ -465,7 +547,10 @@ class ToolManager:
|
||||
invoke=invoke_selected_tool,
|
||||
)
|
||||
|
||||
if await self.native_tool_loader.has_tool(name):
|
||||
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',
|
||||
@@ -483,7 +568,7 @@ class ToolManager:
|
||||
query=query,
|
||||
invoke=lambda: self.plugin_tool_loader.invoke_tool(name, parameters, query),
|
||||
)
|
||||
if await self.mcp_tool_loader.has_tool(name):
|
||||
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',
|
||||
@@ -492,7 +577,7 @@ class ToolManager:
|
||||
query=query,
|
||||
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query),
|
||||
)
|
||||
if await self.skill_tool_loader.has_tool(name):
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user