chore(merge): sync master into dev/4.11.x

This commit is contained in:
huanghuoguoguo
2026-07-31 19:29:38 +08:00
502 changed files with 77975 additions and 12729 deletions
+108 -23
View File
@@ -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',