Spaces:
Paused
Paused
File size: 17,052 Bytes
9792ea7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 | # -*- 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.
"""
@classmethod
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()])
```
"""
@abstractmethod
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)
@abstractmethod
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
|