Spaces:
Paused
Paused
| # -*- coding: utf-8 -*- | |
| # pylint: disable=unused-argument | |
| """The tool protocol in agentscope.""" | |
| import inspect | |
| import os | |
| from abc import abstractmethod, ABC | |
| from pathlib import Path | |
| from typing import AsyncGenerator, Any, Callable, List | |
| from pydantic import BaseModel | |
| from ._constants import DEFAULT_DANGEROUS_FILES, DEFAULT_DANGEROUS_DIRECTORIES | |
| from ..permission import ( | |
| PermissionContext, | |
| PermissionDecision, | |
| PermissionRule, | |
| PermissionBehavior, | |
| ) | |
| from ._response import ToolChunk | |
| from ._utils import _remove_title_field | |
| class ParamsBase(BaseModel): | |
| """A base class for tool parameters that remove the title field from the | |
| exported JSON schema. | |
| """ | |
| def model_json_schema(cls, *args: Any, **kwargs: Any) -> dict: | |
| """An override implementation to remove the title field from the | |
| exported schema. | |
| """ | |
| return _remove_title_field(super().model_json_schema(*args, **kwargs)) | |
| class ToolMiddlewareBase(ABC): | |
| """Base class for tool middlewares. | |
| A tool middleware wraps the execution of a tool in an onion fashion: the | |
| first registered middleware is the outermost layer and runs its pre-logic | |
| before any inner layer, then its post-logic after all inner layers have | |
| completed. Subclass this and implement :meth:`on_tool_call` — the signature | |
| is already spelled out, so second-party developers only need to fill in the | |
| body without reasoning about the wrapping protocol. | |
| Streaming and non-streaming tools are unified: ``next_handler`` always | |
| returns an async generator, so a middleware never needs to know whether the | |
| underlying tool yields a stream of chunks or returns a single chunk. | |
| Example: | |
| ```python | |
| class LoggingMiddleware(ToolMiddlewareBase): | |
| async def on_tool_call(self, tool, input_kwargs, next_handler): | |
| print(f"Calling {tool.name} with {input_kwargs}") | |
| async for chunk in next_handler(**input_kwargs): | |
| yield chunk | |
| print(f"Finished {tool.name}") | |
| tool = MyTool(middlewares=[LoggingMiddleware()]) | |
| ``` | |
| """ | |
| async def on_tool_call( | |
| self, | |
| tool: "ToolBase", | |
| input_kwargs: dict[str, Any], | |
| next_handler: Callable[..., AsyncGenerator[ToolChunk, None]], | |
| ) -> AsyncGenerator[ToolChunk, None]: | |
| """Intercept a single tool invocation. | |
| Add pre-/post-logic around ``next_handler``, rewrite the tool inputs by | |
| passing modified keyword arguments to ``next_handler``, or transform | |
| the yielded chunks. | |
| Args: | |
| tool (`ToolBase`): | |
| The tool instance being invoked. | |
| input_kwargs (`dict[str, Any]`): | |
| The tool's input arguments for this invocation. Pass them on | |
| via ``next_handler(**input_kwargs)``; mutate or replace them to | |
| change what the inner layers and the tool itself receive. | |
| next_handler (`Callable[..., AsyncGenerator[ToolChunk, None]]`): | |
| Call it as ``next_handler(**input_kwargs)`` to run the next | |
| layer. It always returns an async generator, regardless of | |
| whether the underlying tool is streaming or not. | |
| Yields: | |
| `ToolChunk`: | |
| The chunks produced by this tool invocation. | |
| """ | |
| class ToolBase(ABC): | |
| """The tool protocol.""" | |
| name: str | |
| """The name presented to the agent.""" | |
| description: str | |
| """The agent-oriented tool description.""" | |
| input_schema: dict[str, Any] | |
| """The input schema of the tool, following JSON schema format.""" | |
| is_concurrency_safe: bool | |
| """If this tool is concurrency safe.""" | |
| is_read_only: bool | |
| """If this tool is read-only, which will be used in the permission | |
| checking.""" | |
| is_external_tool: bool = False | |
| """If this tool is an external tool, which doesn't need to implement the | |
| __call__ method and the agent will yield the external tool call event.""" | |
| is_state_injected: bool = False | |
| """If this tool requires agent state to be injected when called. If `True`, | |
| the state will be injected by an argument named `_agent_state`. Note your | |
| tool should be able to accept such argument. | |
| """ | |
| is_mcp: bool = False | |
| """If this tool is an MCP tool, which will be used in the permission""" | |
| mcp_name: str | None = None | |
| """The name of the MCP server this tool belongs to, which is required if | |
| this tool is an MCP tool.""" | |
| # Class attributes for dangerous path checking | |
| dangerous_files: list[str] = DEFAULT_DANGEROUS_FILES | |
| """List of dangerous files that should be protected from auto-editing.""" | |
| dangerous_directories: list[str] = DEFAULT_DANGEROUS_DIRECTORIES | |
| """List of dangerous directories that should be protected from | |
| auto-editing.""" | |
| def __init__( | |
| self, | |
| middlewares: List["ToolMiddlewareBase"] | None = None, | |
| ) -> None: | |
| """Initialize the tool with optional middlewares. | |
| Args: | |
| middlewares (`List[ToolMiddlewareBase] | None`, optional): | |
| A list of :class:`ToolMiddlewareBase` instances wrapping the | |
| tool execution in an onion fashion. Defaults to an empty list. | |
| """ | |
| self._middlewares: List["ToolMiddlewareBase"] = ( | |
| middlewares if middlewares is not None else [] | |
| ) | |
| async def call( | |
| self, | |
| *args: Any, | |
| **kwargs: Any, | |
| ) -> ToolChunk | AsyncGenerator[ToolChunk, None]: | |
| """Execute the tool logic. | |
| This is the new override point for tool implementations. | |
| Subclasses should override this method instead of | |
| :meth:`__call__`. The base implementation raises | |
| :exc:`NotImplementedError` for non-external tools and | |
| :exc:`RuntimeError` for external tools. | |
| Args: | |
| **kwargs: Tool input arguments. | |
| Returns: | |
| `ToolChunk | AsyncGenerator[ToolChunk, None]`: | |
| A single :class:`~agentscope.tool.ToolChunk` or an | |
| async generator that yields them. | |
| """ | |
| if not self.is_external_tool: | |
| raise NotImplementedError( | |
| f"{self.__class__.__name__} does not implement call", | |
| ) | |
| raise RuntimeError( | |
| f"{self.__class__.__name__} is an external tool and should not " | |
| f"be called directly", | |
| ) | |
| async def __call__( | |
| self, | |
| *args: Any, | |
| **kwargs: Any, | |
| ) -> ToolChunk | AsyncGenerator[ToolChunk, None]: | |
| """Invoke the tool, layering any registered middlewares around | |
| :meth:`call`. | |
| Tools are always invoked with keyword arguments only. ``*args`` is | |
| accepted in the signature solely to stay Liskov-compatible with | |
| subclasses that override ``__call__`` with their own positional | |
| parameters; any positional argument actually passed here is rejected | |
| (raising :exc:`TypeError`) so it fails loudly instead of being silently | |
| dropped. | |
| Middlewares are applied in an onion fashion: the first registered | |
| middleware is the outermost layer and runs its pre-logic before | |
| any inner layers, then its post-logic after all inner layers | |
| have completed. | |
| """ | |
| if args: | |
| raise TypeError( | |
| f"{type(self).__name__} must be called with keyword arguments " | |
| f"only, but got {len(args)} positional argument(s).", | |
| ) | |
| # ``getattr`` with a default so the no-middleware path keeps working | |
| # even if a subclass overrides ``__init__`` without calling | |
| # ``super().__init__()``. | |
| middlewares = getattr(self, "_middlewares", []) | |
| if not middlewares: | |
| if inspect.isasyncgenfunction(self.call): | |
| return self.call(**kwargs) | |
| return await self.call(**kwargs) | |
| async def execute_chain( | |
| index: int = 0, | |
| **chain_kwargs: Any, | |
| ) -> AsyncGenerator[ToolChunk, None]: | |
| """Execute the tool middleware chain.""" | |
| if index >= len(middlewares): | |
| # Innermost layer: run the tool's own ``call``. ``call`` is | |
| # always async but comes in two shapes — an async generator | |
| # function (e.g. ``Bash``) or a coroutine returning a single | |
| # ``ToolChunk`` / an async generator (e.g. ``FunctionTool``). | |
| # Normalize both into a single stream so middlewares never have | |
| # to distinguish them. | |
| if inspect.isasyncgenfunction(self.call): | |
| async for chunk in self.call(**chain_kwargs): | |
| yield chunk | |
| else: | |
| result = await self.call(**chain_kwargs) | |
| if isinstance(result, AsyncGenerator): | |
| async for chunk in result: | |
| yield chunk | |
| else: | |
| yield result | |
| else: | |
| mw = middlewares[index] | |
| input_kwargs = dict(chain_kwargs) | |
| async def next_handler( | |
| **kw: Any, | |
| ) -> AsyncGenerator[ToolChunk, None]: | |
| async for chunk in execute_chain(index + 1, **kw): | |
| yield chunk | |
| async for chunk in mw.on_tool_call( | |
| tool=self, | |
| input_kwargs=input_kwargs, | |
| next_handler=next_handler, | |
| ): | |
| yield chunk | |
| return execute_chain(**kwargs) | |
| async def check_permissions( | |
| self, | |
| tool_input: dict[str, Any], | |
| context: PermissionContext, | |
| ) -> PermissionDecision: | |
| """Check permissions for the tool usage.""" | |
| async def check_read_only( | |
| self, | |
| tool_input: dict[str, Any], | |
| ) -> bool: | |
| """Decide whether this specific invocation is read-only. | |
| Returns the static :attr:`is_read_only` attribute by default. | |
| Subclasses with input-dependent semantics (e.g. ``Bash``) should | |
| override this to inspect ``tool_input`` — for example, ``Bash`` is | |
| statically marked as not read-only but ``ls -a`` is in fact read-only. | |
| Should be cheap — the permission engine may call this before the | |
| full :meth:`check_permissions` flow. | |
| Args: | |
| tool_input (`dict[str, Any]`): | |
| The tool input data for this invocation. | |
| Returns: | |
| `bool`: | |
| ``True`` if this invocation is read-only, ``False`` otherwise. | |
| """ | |
| return self.is_read_only | |
| async def match_rule( | |
| self, | |
| rule_content: str | None, | |
| tool_input: dict[str, Any], | |
| ) -> bool: | |
| """Check if a permission rule matches the tool input. | |
| .. note:: This is an optional method. A rule with no content (``None``) | |
| is a tool-name-level rule that matches every invocation; a rule | |
| with content requires the tool to override this method with its | |
| own matching logic, otherwise it returns ``False``. | |
| This means: | |
| - ``_FunctionTool`` and ``MCPTool`` (which do not override this) | |
| can still be controlled at the tool-name level via rules like | |
| ``{"tool_name": "my_tool", "rule_content": None}``. | |
| - Specific tools (Bash, Read, Write, Edit, Glob, Grep) override | |
| this method to support fine-grained pattern matching. | |
| Args: | |
| rule_content (`str | None`): | |
| The rule pattern to match. ``None`` means "match all | |
| invocations of this tool" (tool-name-level rule). | |
| tool_input (`dict[str, Any]`): | |
| The tool input data | |
| Returns: | |
| `bool`: | |
| True if the rule matches, False otherwise | |
| """ | |
| # None rule_content = tool-name-level rule, matches everything | |
| return rule_content is None | |
| async def generate_suggestions( | |
| self, | |
| tool_input: dict[str, Any], | |
| ) -> List[PermissionRule]: | |
| """Generate suggested permission rules for the tool input. | |
| .. note:: Suggest a single tool-name-level rule (``rule_content=None``) | |
| that allows all invocations of this tool. Tools can override this to | |
| provide finer-grained suggestions. | |
| For example: | |
| - File tools (Read/Write/Edit): suggest a glob pattern covering the | |
| parent directory (e.g., "src/main.py" -> "src/**") | |
| - Bash: suggest command prefix patterns (e.g., "git commit -m 'xxx'" | |
| -> "git commit:*") | |
| - Grep/Glob: suggest patterns based on search paths | |
| Args: | |
| tool_input (`dict[str, Any]`): | |
| The tool input data | |
| Returns: | |
| `List[PermissionRule]`: | |
| List of suggested permission rules (usually 1, max 5 for | |
| compound operations) | |
| """ | |
| return [ | |
| PermissionRule( | |
| tool_name=self.name, | |
| rule_content=None, | |
| behavior=PermissionBehavior.ALLOW, | |
| source="suggested", | |
| ), | |
| ] | |
| def _path_in_allowed_working_path( | |
| self, | |
| file_path: str, | |
| context: PermissionContext, | |
| ) -> bool: | |
| """Check if a file path is within any allowed working directory. | |
| A "working directory" is the process's current directory plus any | |
| entries in :attr:`PermissionContext.working_directories`. Paths | |
| are compared via :func:`os.path.realpath` so that aliases like | |
| macOS's ``/tmp`` → ``/private/tmp`` and symlinked working | |
| directories compare equal on both sides. | |
| Used by tools that conditionally auto-allow file operations in | |
| :attr:`PermissionMode.ACCEPT_EDITS` (e.g. Write, Edit, and the | |
| filesystem-command branch of Bash). | |
| Args: | |
| file_path (`str`): | |
| The file path to check. | |
| context (`PermissionContext`): | |
| The permission context containing the working directories. | |
| Returns: | |
| `bool`: | |
| True if ``file_path`` is within any allowed working | |
| directory. | |
| """ | |
| current_dir = os.getcwd() | |
| additional_dirs = list(context.working_directories.keys()) | |
| all_working_dirs = [current_dir] + additional_dirs | |
| abs_file_path = os.path.realpath(os.path.expanduser(file_path)) | |
| for working_dir in all_working_dirs: | |
| abs_working_dir = os.path.realpath( | |
| os.path.expanduser(working_dir), | |
| ) | |
| try: | |
| os.path.relpath(abs_file_path, abs_working_dir) | |
| if ( | |
| abs_file_path.startswith(abs_working_dir + os.sep) | |
| or abs_file_path == abs_working_dir | |
| ): | |
| return True | |
| except ValueError: | |
| # On Windows, relpath raises ValueError if paths are on | |
| # different drives. | |
| continue | |
| return False | |
| def _is_dangerous_path(self, file_path: str) -> bool: | |
| """Check if a file path is dangerous (sensitive file or directory). | |
| A path is considered dangerous if: | |
| 1. The filename matches a dangerous file (e.g., .bashrc, .gitconfig) | |
| 2. Any path segment matches a dangerous directory (e.g., .git, .ssh) | |
| Case-insensitive matching is used to prevent bypasses on | |
| case-insensitive filesystems (macOS, Windows). | |
| Args: | |
| file_path (`str`): | |
| The file path to check | |
| Returns: | |
| `bool`: | |
| True if the path is dangerous and should require explicit | |
| permission | |
| Example: | |
| >>> self._is_dangerous_path("/home/user/.bashrc") | |
| True | |
| >>> self._is_dangerous_path("/home/user/.git/config") | |
| True | |
| >>> self._is_dangerous_path("/home/user/project/main.py") | |
| False | |
| """ | |
| # Normalize path | |
| abs_path = os.path.abspath(os.path.expanduser(file_path)) | |
| # Split path into segments | |
| path_parts = Path(abs_path).parts | |
| path_parts_lower = [p.lower() for p in path_parts] | |
| # Check if filename matches dangerous files (case-insensitive) | |
| filename = os.path.basename(abs_path) | |
| filename_lower = filename.lower() | |
| for dangerous_file in self.dangerous_files: | |
| if filename_lower == dangerous_file.lower(): | |
| return True | |
| # Check if any path segment matches dangerous directories | |
| # (case-insensitive) | |
| for dangerous_dir in self.dangerous_directories: | |
| dangerous_dir_lower = dangerous_dir.lower() | |
| if dangerous_dir_lower in path_parts_lower: | |
| return True | |
| return False | |