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
+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(