mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-23 04:46:07 +00:00
feat(agent-runner): enforce 4.x host-owned execution
This commit is contained in:
@@ -4,3 +4,12 @@ class ToolNotFoundError(ValueError):
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
super().__init__(f'Tool not found: {name}')
|
||||
|
||||
|
||||
class ToolExecutionDeniedError(PermissionError):
|
||||
"""Raised when a known tool is not allowed in the current execution context."""
|
||||
|
||||
def __init__(self, name: str, reason: str):
|
||||
self.name = name
|
||||
self.reason = reason
|
||||
super().__init__(f'Tool execution denied for {name}: {reason}')
|
||||
|
||||
@@ -22,6 +22,7 @@ from mcp.shared.exceptions import McpError
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from .. import loader
|
||||
from ..errors import ToolExecutionDeniedError
|
||||
from ....core import app
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
@@ -1558,35 +1559,67 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
return items
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
def _session_by_source_id(self, source_id: str) -> RuntimeMCPSession | None:
|
||||
return next(
|
||||
(session for session in self.sessions.values() if session.server_uuid == source_id),
|
||||
None,
|
||||
)
|
||||
|
||||
async def has_tool(self, name: str, source_id: str | None = None) -> bool:
|
||||
"""检查工具是否存在"""
|
||||
if name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE):
|
||||
if source_id is None and name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE):
|
||||
return bool(self._eligible_resource_sessions_for_bound(None))
|
||||
for session in self.sessions.values():
|
||||
sessions = [self._session_by_source_id(source_id)] if source_id is not None else list(self.sessions.values())
|
||||
for session in sessions:
|
||||
if session is None:
|
||||
continue
|
||||
for function in session.get_tools():
|
||||
if function.name == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_tool(self, name: str) -> resource_tool.LLMTool | None:
|
||||
for session in self.sessions.values():
|
||||
async def get_tool(
|
||||
self,
|
||||
name: str,
|
||||
source_id: str | None = None,
|
||||
) -> resource_tool.LLMTool | None:
|
||||
if source_id is None and name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE):
|
||||
if not self._eligible_resource_sessions_for_bound(None):
|
||||
return None
|
||||
return next(
|
||||
(tool for tool in self._mcp_synthetic_resource_tools() if tool.name == name),
|
||||
None,
|
||||
)
|
||||
sessions = [self._session_by_source_id(source_id)] if source_id is not None else list(self.sessions.values())
|
||||
for session in sessions:
|
||||
if session is None:
|
||||
continue
|
||||
for function in session.get_tools():
|
||||
if function.name == name:
|
||||
return function
|
||||
return None
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
async def invoke_tool(
|
||||
self,
|
||||
name: str,
|
||||
parameters: dict,
|
||||
query: pipeline_query.Query,
|
||||
source_id: str | None = None,
|
||||
) -> typing.Any:
|
||||
"""执行工具调用"""
|
||||
if name == MCP_TOOL_LIST_RESOURCES:
|
||||
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False:
|
||||
return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')]
|
||||
if source_id is None and name == MCP_TOOL_LIST_RESOURCES:
|
||||
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True:
|
||||
raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled')
|
||||
return await self._invoke_mcp_list_resources(parameters, query)
|
||||
if name == MCP_TOOL_READ_RESOURCE:
|
||||
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False:
|
||||
return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')]
|
||||
if source_id is None and name == MCP_TOOL_READ_RESOURCE:
|
||||
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True:
|
||||
raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled')
|
||||
return await self._invoke_mcp_read_resource(parameters, query)
|
||||
|
||||
for session in self.sessions.values():
|
||||
sessions = [self._session_by_source_id(source_id)] if source_id is not None else list(self.sessions.values())
|
||||
for session in sessions:
|
||||
if session is None:
|
||||
continue
|
||||
for function in session.get_tools():
|
||||
if function.name == name:
|
||||
self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}')
|
||||
@@ -1666,7 +1699,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
default_max_bytes: int = MCP_RESOURCE_CONTEXT_MAX_BYTES,
|
||||
) -> str:
|
||||
"""Build host-controlled MCP resource context for the current query."""
|
||||
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False:
|
||||
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True:
|
||||
return ''
|
||||
|
||||
attachments = (query.variables or {}).get('_pipeline_mcp_resource_attachments', [])
|
||||
@@ -1684,7 +1717,7 @@ class MCPLoader(loader.ToolLoader):
|
||||
for raw_attachment in attachments:
|
||||
if remaining_tokens <= 0:
|
||||
break
|
||||
if not isinstance(raw_attachment, dict) or raw_attachment.get('enabled') is False:
|
||||
if not isinstance(raw_attachment, dict) or raw_attachment.get('enabled', True) is not True:
|
||||
continue
|
||||
|
||||
attachment = raw_attachment.copy()
|
||||
@@ -1702,8 +1735,23 @@ class MCPLoader(loader.ToolLoader):
|
||||
if session.server_uuid not in eligible_by_uuid and session.server_name not in eligible_by_name:
|
||||
continue
|
||||
|
||||
max_tokens = min(int(attachment.get('max_tokens') or remaining_tokens), remaining_tokens)
|
||||
max_bytes = int(attachment.get('max_bytes') or default_max_bytes)
|
||||
raw_max_tokens = attachment.get('max_tokens')
|
||||
raw_max_bytes = attachment.get('max_bytes')
|
||||
if raw_max_tokens is None:
|
||||
max_tokens = remaining_tokens
|
||||
elif isinstance(raw_max_tokens, bool) or not isinstance(raw_max_tokens, int) or raw_max_tokens <= 0:
|
||||
self.ap.logger.warning(f'Ignoring MCP resource {uri!r}: max_tokens must be a positive integer')
|
||||
continue
|
||||
else:
|
||||
max_tokens = min(raw_max_tokens, remaining_tokens)
|
||||
|
||||
if raw_max_bytes is None:
|
||||
max_bytes = default_max_bytes
|
||||
elif isinstance(raw_max_bytes, bool) or not isinstance(raw_max_bytes, int) or raw_max_bytes <= 0:
|
||||
self.ap.logger.warning(f'Ignoring MCP resource {uri!r}: max_bytes must be a positive integer')
|
||||
continue
|
||||
else:
|
||||
max_bytes = raw_max_bytes
|
||||
|
||||
try:
|
||||
envelope = await session.read_resource_envelope(
|
||||
|
||||
@@ -50,17 +50,35 @@ class PluginToolLoader(loader.ToolLoader):
|
||||
|
||||
return catalog
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
async def get_tool(
|
||||
self,
|
||||
name: str,
|
||||
source_id: str | None = None,
|
||||
) -> resource_tool.LLMTool | None:
|
||||
tools = await self.get_tools([source_id] if source_id else None)
|
||||
return next((tool for tool in tools if tool.name == name), None)
|
||||
|
||||
async def has_tool(self, name: str, source_id: str | None = None) -> bool:
|
||||
"""检查工具是否存在"""
|
||||
for tool in await self.ap.plugin_connector.list_tools():
|
||||
for tool in await self.ap.plugin_connector.list_tools([source_id] if source_id else None):
|
||||
if tool.metadata.name == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
async def invoke_tool(
|
||||
self,
|
||||
name: str,
|
||||
parameters: dict,
|
||||
query: pipeline_query.Query,
|
||||
source_id: str | None = None,
|
||||
) -> typing.Any:
|
||||
try:
|
||||
return await self.ap.plugin_connector.call_tool(
|
||||
name, parameters, session=query.session, query_id=query.query_id
|
||||
name,
|
||||
parameters,
|
||||
session=query.session,
|
||||
query_id=query.query_id,
|
||||
bound_plugins=[source_id] if source_id else None,
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}')
|
||||
|
||||
@@ -134,7 +134,11 @@ async def persist_activated_skill(
|
||||
if not isinstance(session, dict):
|
||||
return
|
||||
|
||||
state_context = session.get('state_context')
|
||||
authorization = session.get('authorization')
|
||||
if not isinstance(authorization, dict):
|
||||
return
|
||||
|
||||
state_context = authorization.get('state_context')
|
||||
if not isinstance(state_context, dict):
|
||||
return
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@ if TYPE_CHECKING:
|
||||
)
|
||||
|
||||
|
||||
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工具管理器"""
|
||||
|
||||
@@ -115,6 +125,121 @@ class ToolManager:
|
||||
|
||||
return catalog
|
||||
|
||||
async def get_resolved_tool_catalog(
|
||||
self,
|
||||
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(
|
||||
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, name: str) -> tool_loader.ToolLookupResult | None:
|
||||
"""Get tool by name from any active loader."""
|
||||
for active_loader in (
|
||||
@@ -129,7 +254,11 @@ class ToolManager:
|
||||
|
||||
return None
|
||||
|
||||
async def get_tool_schema(self, name: str) -> tuple[str | None, dict | None]:
|
||||
async def get_tool_schema(
|
||||
self,
|
||||
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
|
||||
@@ -137,19 +266,23 @@ 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_name(name)
|
||||
tool = await self.get_tool_by_source(name, source_ref) if source_ref else await self.get_tool_by_name(name)
|
||||
if tool is None:
|
||||
return None, None
|
||||
return tool.description, (tool.parameters or None)
|
||||
|
||||
async def get_tool_detail(self, name: str) -> dict | None:
|
||||
async def get_tool_detail(
|
||||
self,
|
||||
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_name(name)
|
||||
tool = await self.get_tool_by_source(name, source_ref) if source_ref else await self.get_tool_by_name(name)
|
||||
if tool is None:
|
||||
return None
|
||||
return {
|
||||
@@ -159,6 +292,26 @@ class ToolManager:
|
||||
'parameters': tool.parameters or {},
|
||||
}
|
||||
|
||||
async def get_tool_by_source(
|
||||
self,
|
||||
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(name, source_id=source_id)
|
||||
return None
|
||||
|
||||
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
tools = []
|
||||
|
||||
@@ -260,9 +413,58 @@ class ToolManager:
|
||||
)
|
||||
return result
|
||||
|
||||
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
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:
|
||||
source = source_ref['source']
|
||||
source_id = source_ref.get('source_id')
|
||||
uses_source_id = False
|
||||
if source in {'builtin', 'native'}:
|
||||
loader = self.native_tool_loader
|
||||
telemetry_source = 'native'
|
||||
exists = await loader.has_tool(name)
|
||||
elif source == 'skill':
|
||||
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(name, source_id=source_id)
|
||||
else:
|
||||
raise ToolNotFoundError(name)
|
||||
|
||||
if not exists:
|
||||
raise ToolNotFoundError(name)
|
||||
|
||||
async def invoke_selected_tool() -> typing.Any:
|
||||
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,
|
||||
)
|
||||
|
||||
if await self.native_tool_loader.has_tool(name):
|
||||
telemetry_features.increment(query, 'tool_calls', 'native')
|
||||
return await self._invoke_tool_with_monitoring(
|
||||
|
||||
Reference in New Issue
Block a user