feat(agent-runner): enforce 4.x host-owned execution

This commit is contained in:
huanghuoguoguo
2026-07-12 20:36:32 +08:00
parent 9aa71d54e3
commit d88d05e27c
170 changed files with 6952 additions and 5381 deletions
+9
View File
@@ -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}')
+65 -17
View File
@@ -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
+207 -5
View File
@@ -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(