Source code for design_research_agents.tools._runtime
"""Toolbox runtime that merges core, MCP, script, and in-process tools."""
from __future__ import annotations
import os
from collections.abc import Mapping, Sequence
from types import TracebackType
from typing import Self
from design_research_agents._contracts._tools import (
ToolMetadata,
ToolResult,
ToolRuntime,
ToolSpec,
)
from ._config import (
CallableToolConfig,
CoreToolsConfig,
McpConfig,
MCPServerConfig,
ScriptToolConfig,
ScriptToolsConfig,
ToolRuntimeConfig,
)
from ._core import CoreToolSource
from ._policy import ToolPolicy, ToolPolicyConfig
from ._registry import ToolRegistry
from ._sources._inprocess_source import InProcessToolSource, ToolHandler
[docs]
class Toolbox(ToolRuntime):
"""Tool runtime that routes calls across enabled tool sources."""
def __init__(
self,
*,
workspace_root: str | os.PathLike[str] = ".",
enable_core_tools: bool = True,
script_tools: tuple[ScriptToolConfig, ...] | None = None,
callable_tools: tuple[CallableToolConfig, ...] | None = None,
mcp_servers: tuple[MCPServerConfig, ...] | None = None,
) -> None:
"""Initialize toolbox sources from ergonomic constructor arguments.
Args:
workspace_root: Root directory for tools that interact with the filesystem.
enable_core_tools: Whether to enable the built-in core tools.
script_tools: Optional tuple of ScriptToolConfig definitions to expose through a script tool
source.
callable_tools: Optional tuple of CallableToolConfig definitions to register as in-process
tools.
mcp_servers: Optional tuple of MCP server definitions to connect to and expose tools
from.
"""
normalized_workspace_root = os.fspath(workspace_root)
runtime_config = ToolRuntimeConfig(
core_tools=CoreToolsConfig(
enabled=enable_core_tools,
workspace_root=normalized_workspace_root,
),
mcp=McpConfig(
enabled=bool(mcp_servers),
servers=tuple(mcp_servers or ()),
),
script_tools=ScriptToolsConfig(
enabled=bool(script_tools),
tools=tuple(script_tools or ()),
),
)
# Normalize constructor shorthands into one config object so source initialization is centralized.
self._initialize_from_config(runtime_config)
for callable_tool in tuple(callable_tools or ()): # register after custom source exists.
self.register_callable_tool(callable_tool)
def _initialize_from_config(self, runtime_config: ToolRuntimeConfig) -> None:
"""Initialize runtime sources from a fully-resolved config object.
Args:
runtime_config: Fully-resolved runtime configuration object.
Returns:
None
"""
self._config = runtime_config
self._registry = ToolRegistry()
core_policy = ToolPolicy(
ToolPolicyConfig(
workspace_root=self._config.core_tools.workspace_root,
artifacts_dir=self._config.core_tools.artifacts_dir,
allow_writes_outside_artifacts=self._config.core_tools.allow_writes_outside_artifacts,
allow_network=self._config.core_tools.allow_network,
allowed_commands=self._config.core_tools.allowed_commands,
)
)
self._core_policy = core_policy
self._core_source: CoreToolSource | None = None
if self._config.core_tools.enabled:
self._core_source = CoreToolSource(policy=self._core_policy)
self._registry.add_source(self._core_source)
self._custom_source = InProcessToolSource(source_id="custom")
# Keep a dedicated in-process source always available for dynamic callable registration.
self._registry.add_source(self._custom_source)
self._mcp_source = None
if self._config.mcp.enabled and self._config.mcp.servers:
from ._sources._mcp_source import McpToolSource
self._mcp_source = McpToolSource(
mcp_config=self._config.mcp,
policy=self._core_policy,
)
self._registry.add_source(self._mcp_source)
self._script_source = None
if self._config.script_tools.enabled and self._config.script_tools.tools:
from ._sources._script_source import ScriptToolSource
script_policy = ToolPolicy(
ToolPolicyConfig(
workspace_root=self._config.core_tools.workspace_root,
artifacts_dir=self._config.core_tools.artifacts_dir,
allow_writes_outside_artifacts=self._config.core_tools.allow_writes_outside_artifacts,
allow_network=self._config.core_tools.allow_network,
allowed_commands=self._config.core_tools.allowed_commands,
default_timeout_s=30,
)
)
self._script_source = ScriptToolSource(
script_tools=self._config.script_tools.tools,
policy=script_policy,
)
self._registry.add_source(self._script_source)
@property
def registry(self) -> ToolRegistry:
"""Return the source-merging registry.
Returns:
Registry that owns source routing and invocation dispatch.
"""
return self._registry
@property
def config(self) -> ToolRuntimeConfig:
"""Return active runtime configuration.
Returns:
Fully resolved runtime configuration for this toolbox.
"""
return self._config
[docs]
def list_tools(self) -> Sequence[ToolSpec]:
"""List all tools currently exposed by enabled runtime sources.
Returns:
Sequence of ToolSpec objects representing all tools currently exposed by enabled runtime
sources, in no particular order.
"""
return self._registry.list_tools()
[docs]
def invoke(
self,
tool_name: str,
input: Mapping[str, object],
*,
request_id: str,
dependencies: Mapping[str, object],
) -> ToolResult:
"""Invoke one tool through the registry routing layer.
Args:
tool_name: Name of the tool to invoke. This will be normalized by stripping leading and
trailing whitespace before lookup.
input: Mapping of input values to provide for this tool invocation. This will be
validated against the tool's input schema before invocation.
request_id: Request ID to associate with this tool invocation, which will be passed
through to the underlying tool handler and can be used for logging, tracing, and
other purposes.
dependencies: Mapping of dependencies to provide for this tool invocation, which will
be passed through to the underlying tool handler and can be used to provide
additional context or resources needed for the tool execution.
Returns:
The result of the tool invocation, as returned by the underlying tool handler. This will
be validated against the tool's output schema before being returned to the caller.
"""
return self._registry.invoke(
tool_name,
input,
request_id=request_id,
dependencies=dependencies,
)
[docs]
def invoke_dict(
self,
tool_name: str,
input: Mapping[str, object],
*,
request_id: str,
dependencies: Mapping[str, object],
) -> dict[str, object]:
"""Invoke one tool and require a successful dictionary payload.
Args:
tool_name: Name of the tool to invoke.
input: Tool input payload mapping.
request_id: Request identifier associated with this invocation.
dependencies: Dependency payload mapping for this invocation.
Returns:
Tool result mapping.
Raises:
RuntimeError: If invocation fails or result payload is not a mapping.
"""
result = self.invoke(
tool_name,
input,
request_id=request_id,
dependencies=dependencies,
)
if not result.ok:
# Promote tool-level failures to runtime errors for call sites that require strict dict outputs.
message = result.error.message if result.error is not None else "unknown tool error"
raise RuntimeError(f"{tool_name} failed: {message}")
if not isinstance(result.result, Mapping):
result_type = type(result.result).__name__
raise RuntimeError(f"{tool_name} returned non-dict result ({result_type}).")
return {str(key): value for key, value in result.result.items()}
[docs]
def register_tool(self, *, spec: ToolSpec, handler: ToolHandler) -> None:
"""Register a custom in-process tool.
Args:
spec: ToolSpec defining the tool to register. The name field will be normalized by
stripping leading and trailing whitespace, and must be non-empty after
normalization.
handler: ToolHandler function to execute when this tool is invoked. The handler will be
wrapped to match the expected signature for in-process tools, which includes
additional parameters for request ID and dependencies that will be ignored by the
provided handler.
Returns:
None
"""
self._custom_source.register_tool(spec=spec, handler=handler)
[docs]
def register_callable_tool(self, callable_tool: CallableToolConfig) -> None:
"""Register one callable tool wrapper.
Args:
callable_tool: CallableToolConfig definition to register. The name field will be normalized by
stripping leading and trailing whitespace, and must be non-empty after
normalization.
Returns:
None
Raises:
Exception: Raised when this operation cannot complete.
"""
normalized_name = callable_tool.name.strip()
if not normalized_name:
raise ValueError("CallableToolConfig.name must be non-empty.")
spec = ToolSpec(
name=normalized_name,
description=callable_tool.description,
input_schema=dict(callable_tool.input_schema),
output_schema=dict(callable_tool.output_schema),
permissions=callable_tool.permissions,
metadata=ToolMetadata(source="custom", risky=callable_tool.risky),
)
def _handler(
input_dict: Mapping[str, object],
_request_id: str,
_dependencies: Mapping[str, object],
) -> object:
"""Invoke one user-registered callable tool handler.
Args:
input_dict: Tool input payload supplied by the runtime.
_request_id: Request id forwarded by the runtime.
_dependencies: Dependency bag forwarded by the runtime.
Returns:
Raw handler result before ``ToolResult`` wrapping.
"""
del _request_id, _dependencies
return callable_tool.handler(input_dict)
self._custom_source.register_tool(spec=spec, handler=_handler)
[docs]
def close(self) -> None:
"""Release external source resources."""
if self._mcp_source is not None and hasattr(self._mcp_source, "close"):
self._mcp_source.close()
def __enter__(self) -> Self:
"""Return this runtime for ``with``-statement usage."""
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
"""Close the runtime on ``with``-statement exit."""
del exc_type, exc, tb
self.close()
return None
def __del__(self) -> None: # pragma: no cover - defensive cleanup.
"""Best-effort cleanup for external sources during GC."""
self.close()
__all__ = ["Toolbox"]