Spaces:
Paused
Paused
| # -*- coding: utf-8 -*- | |
| """The toolkit class for tool calls in AgentScope.""" | |
| import asyncio | |
| import inspect | |
| from collections import OrderedDict | |
| from typing import ( | |
| AsyncGenerator, | |
| Type, | |
| Generator, | |
| Sequence, | |
| ) | |
| import mcp | |
| from jinja2 import Template | |
| from pydantic import ( | |
| BaseModel, | |
| Field, | |
| create_model, | |
| ) | |
| from ._builtin import ResetTools, SkillViewer | |
| from ._base import ToolBase | |
| from ._response import ToolResponse, ToolChunk | |
| from ..skill import SkillLoaderBase, Skill | |
| from ._types import RegisteredTool | |
| from .._utils._common import _json_loads_with_repair | |
| from ..exception import ( | |
| DeveloperOrientedException, | |
| ToolNotFoundError, | |
| ToolGroupInactiveError, | |
| ) | |
| from ..mcp import MCPClient | |
| from ..message import ( | |
| ToolCallBlock, | |
| TextBlock, | |
| ToolResultState, | |
| ) | |
| from ._tool_group import ToolGroup | |
| from .._logging import logger | |
| from ..state import AgentState | |
| # pylint: disable=line-too-long | |
| DEFAULT_META_TOOL_RESPONSE_TEMPLATE = """{% if groups | length == 0 %}All tool groups are currently deactivated.{% else %}The currently activated tool group(s): {{ groups | map(attribute='name') | join(', ') }}.{% if groups | selectattr('instructions', 'ne', None) | list | length > 0 %} | |
| <tool-instructions> | |
| The tool instructions are a collection of suggestions, rules and notifications about how to use the tools in the activated groups. | |
| {% for group in groups %}{% if group.instructions %}<group name="{{ group.name }}">{{ group.instructions }}</group>{% endif %}{% endfor %} | |
| </tool-instructions>{% endif %}{% endif %}""" # noqa: E501 | |
| DEFAULT_SKILL_INSTRUCTION = """<agent-skills> | |
| Skills are a collection of instructions, scripts, and resources to extend your capabilities. | |
| **IMPORTANT**: Skills are NOT tools, and you cannot call a skill directly. To use a skill, you MUST use the `{{ skill_viewer }}` tool to read the skill's full instructions, and then follow those instructions to use the tools and resources provided by the skill. | |
| # Available Skills:{% for skill in skills %} | |
| <skill> | |
| <name>{{ skill.name }}</name> | |
| <description>{{ skill.description }}</description> | |
| <dir>{{ skill.dir }}</dir> | |
| </skill>{% endfor %} | |
| </agent-skills> | |
| """ # noqa: E501 | |
| class Toolkit: | |
| """Toolkit is the core module to register, manage and delete tool | |
| functions, MCP clients, Agent skills in AgentScope. | |
| About tool functions: | |
| - Register and parse JSON schemas from their docstrings automatically. | |
| - Group-wise tools management, and agentic tools activation/deactivation. | |
| - Extend the tool function JSON schema dynamically with Pydantic BaseModel. | |
| - Tool function execution with unified streaming interface. | |
| About MCP clients: | |
| - Register tool functions from MCP clients directly. | |
| - Client-level tool functions removal. | |
| About Agent skills: | |
| - Register agent skills from the given directory. | |
| - Provide prompt for the registered skills to the agent. | |
| """ | |
| def __init__( | |
| self, | |
| tools: list[ToolBase] | None = None, | |
| skills_or_loaders: Sequence[str | Skill | SkillLoaderBase] | |
| | None = None, | |
| mcps: list[MCPClient] | None = None, | |
| tool_groups: list[ToolGroup] | None = None, | |
| meta_tool_response_template: str = DEFAULT_META_TOOL_RESPONSE_TEMPLATE, | |
| skill_instruction_template: str = DEFAULT_SKILL_INSTRUCTION, | |
| ) -> None: | |
| """Initialize the toolkit. | |
| Args: | |
| tools (`list[ToolBase] | None`, optional): | |
| The tool objects that belong to the "basic" tool group. | |
| skills_or_loaders (`list[str | Skill | SkillLoaderBase] | None`, \ | |
| optional): | |
| The agent skill directories to be registered in the "basic" | |
| tool group. | |
| mcps (`list[MCPClient] | None`, optional): | |
| The mcp clients to be registered in the "basic" tool group. | |
| tool_groups (`list[ToolGroup] | None`, optional): | |
| The tool groups to be registered. | |
| meta_tool_response_template (`str`, optional): | |
| The template for meta tool responses. | |
| skill_instruction_template (`str`): | |
| A Jinja2 template for generating the agent skill instruction. | |
| """ | |
| if tool_groups is not None and any( | |
| _.name == "basic" for _ in tool_groups | |
| ): | |
| raise ValueError( | |
| "The 'basic' tool group is reserved for the default tool " | |
| "group. Don't include 'basic' in the tool_groups argument " | |
| "when you also provide tools, skills or mcps in the " | |
| "constructor.", | |
| ) | |
| self.tool_groups = [ | |
| ToolGroup( | |
| name="basic", | |
| tools=tools or [], | |
| skills_or_loaders=skills_or_loaders or [], | |
| mcps=mcps or [], | |
| ), | |
| ] + (tool_groups or []) | |
| # Check name conflict for tool groups | |
| if len(set(_.name for _ in self.tool_groups)) != len( | |
| self.tool_groups, | |
| ): | |
| raise ValueError( | |
| "Tool groups must not contain duplicate tool groups.", | |
| ) | |
| # The stateful MCP clients should be initialized already | |
| for group in self.tool_groups: | |
| for client in group.mcps: | |
| if client.is_stateful and not client.is_connected: | |
| raise ValueError( | |
| f"The MCP client '{client.name}' is stateful, but " | |
| f"not connected.", | |
| ) | |
| self.meta_tool_response_template = meta_tool_response_template | |
| self.skill_instruction_template = skill_instruction_template | |
| self.builtin_meta_tool = RegisteredTool( | |
| tool=ResetTools( | |
| # An inference value for groups so that it can generate the | |
| # corresponding input schema. | |
| groups=self.tool_groups, | |
| response_template=meta_tool_response_template, | |
| ), | |
| ) | |
| self.builtin_skill_viewer = RegisteredTool( | |
| tool=SkillViewer( | |
| get_skills_method=self._get_available_skills, | |
| ), | |
| ) | |
| async def get_tool_schemas( | |
| self, | |
| groups: list[str] | None = None, | |
| ) -> list[dict]: | |
| """Get the JSON schemas of the currently available tool functions | |
| based on the given activated tool groups. | |
| .. note:: The preset keyword arguments is removed from the JSON | |
| schema, and the extended model is applied if it is set. | |
| Args: | |
| groups (`list[str] | None`, optional): | |
| A list of group names to filter the tool function. The "basic" | |
| group will always be included regardless of the filter. If not | |
| provided, only the "basic" group will be included. | |
| Example: | |
| .. code-block:: JSON | |
| :caption: Example of tool function JSON schemas | |
| [ | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "google_search", | |
| "description": "Search on Google.", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "query": { | |
| "type": "string", | |
| "description": "The search query." | |
| } | |
| }, | |
| "required": ["query"] | |
| } | |
| } | |
| }, | |
| ... | |
| ] | |
| Returns: | |
| `list[dict]`: | |
| A list of function JSON schemas. | |
| """ | |
| function_schemas = [] | |
| # Get all available tools | |
| tools_dict = await self._get_available_tools(groups) | |
| for tool in tools_dict.values(): | |
| function_schemas.append(tool.get_tool_schema()) | |
| return function_schemas | |
| async def call_tool( | |
| self, | |
| tool_call: ToolCallBlock, | |
| state: AgentState, | |
| ) -> AsyncGenerator[ToolChunk | ToolResponse, None]: | |
| """Call the tool function, return the incremental tool result in | |
| a ToolChunk stream, and finally return the complete tool result in a | |
| ToolResponse object. **Note the accumulation process occurs within this | |
| function, so the tool functions only need to return/yield the | |
| ToolChunk objects in an incremental manner.** | |
| Args: | |
| tool_call (`ToolCallBlock`): | |
| A tool call block. | |
| state (`AgentState`): | |
| The current agent state, used to state injection. | |
| Yields: | |
| `ToolChunk | ToolResponse`: | |
| The incremental tool result in a ToolChunk stream, and finally | |
| the complete tool result in a ToolResponse object. | |
| """ | |
| tool_response = ToolResponse(id=tool_call.id) | |
| # Check | |
| available_tools = await self._get_available_tools( | |
| state.tool_context.activated_groups, | |
| ) | |
| if tool_call.name not in available_tools: | |
| all_tools = await self._get_available_tools( | |
| groups=[_.name for _ in self.tool_groups], | |
| ) | |
| # Not activate | |
| if tool_call.name in all_tools: | |
| group_name = all_tools[tool_call.name].group | |
| chunk = ToolChunk( | |
| content=[ | |
| TextBlock( | |
| text=( | |
| "ToolGroupInactiveError: The tool " | |
| f"'{tool_call.name}' in group '{group_name}' " | |
| "is currently inactive. You should first " | |
| "activate the group by calling the " | |
| f"'{self.builtin_meta_tool.tool.name}' tool." | |
| ), | |
| ), | |
| ], | |
| state=ToolResultState.ERROR, | |
| ) | |
| yield chunk | |
| yield tool_response.append_chunk(chunk) | |
| return | |
| # Not exist | |
| chunk = ToolChunk( | |
| content=[ | |
| TextBlock( | |
| text=f"ToolNotFoundError: The tool named " | |
| f"'{tool_call.name}' doesn't exist.", | |
| ), | |
| ], | |
| state=ToolResultState.ERROR, | |
| ) | |
| yield chunk | |
| yield tool_response.append_chunk(chunk) | |
| return | |
| # Obtain the tool function | |
| tool_func = available_tools[tool_call.name].tool | |
| # Async function | |
| try: | |
| # Prepare keyword arguments | |
| kwargs = _json_loads_with_repair(tool_call.input) | |
| # State injection | |
| if ( | |
| tool_func.is_state_injected | |
| and not tool_func.is_mcp | |
| and not tool_func.is_external_tool | |
| ): | |
| kwargs["_agent_state"] = state | |
| if inspect.iscoroutinefunction(tool_func.__call__): | |
| res = await tool_func(**kwargs) | |
| else: | |
| # When `tool_func.original_func` is Async generator function or | |
| # Sync function | |
| res = tool_func(**kwargs) | |
| if isinstance(res, ToolChunk): | |
| yield res | |
| tool_response.append_chunk(res) | |
| # If return an async generator | |
| elif isinstance(res, AsyncGenerator): | |
| async for chunk in res: | |
| yield chunk | |
| tool_response.append_chunk(chunk) | |
| # If return a sync generator | |
| elif isinstance(res, Generator): | |
| for chunk in res: | |
| yield chunk | |
| tool_response.append_chunk(chunk) | |
| else: | |
| raise DeveloperOrientedException( | |
| "The tool function must return a ToolChunk object, or an " | |
| "AsyncGenerator/Generator of ToolChunk objects, " | |
| f"but got {type(res)}.", | |
| ) | |
| except mcp.shared.exceptions.McpError as e: | |
| chunk = ToolChunk( | |
| content=[ | |
| TextBlock( | |
| type="text", | |
| text=f"Error occurred when calling MCP tool: {e}", | |
| ), | |
| ], | |
| state=ToolResultState.ERROR, | |
| ) | |
| yield chunk | |
| tool_response.append_chunk(chunk) | |
| except Exception as e: | |
| # Raise the developer-oriented exception | |
| if isinstance(e, DeveloperOrientedException): | |
| raise e from None | |
| # The exceptions should be handled by the agent | |
| chunk = ToolChunk( | |
| content=[ | |
| TextBlock( | |
| type="text", | |
| text=str(e), | |
| ), | |
| ], | |
| state=ToolResultState.ERROR, | |
| ) | |
| yield chunk | |
| tool_response.append_chunk(chunk) | |
| except asyncio.CancelledError: | |
| chunk = ToolChunk( | |
| content=[ | |
| TextBlock( | |
| type="text", | |
| text="<system-reminder>" | |
| "The tool call has been interrupted " | |
| "by the user." | |
| "</system-reminder>", | |
| ), | |
| ], | |
| state=ToolResultState.INTERRUPTED, | |
| ) | |
| yield chunk | |
| tool_response.append_chunk(chunk) | |
| finally: | |
| # Finally, yield the complete tool response | |
| yield tool_response | |
| async def _get_available_skills( | |
| self, | |
| groups: list[str] | None = None, | |
| ) -> dict[str, Skill]: | |
| """A unified method to collect all skills from the registered skill | |
| loaders. | |
| Args: | |
| groups (`list[str] | None`, optional): | |
| A list of group names to filter the skill loaders. The "basic" | |
| group will always be included regardless of the filter. If not | |
| provided, only the "basic" group will be included. | |
| Returns: | |
| `dict[str, Skill]` | |
| A dictionary of skill name and their corresponding Skill | |
| objects. | |
| """ | |
| groups_filter = ["basic"] + (groups or []) | |
| skills = OrderedDict() | |
| for group in self.tool_groups: | |
| if group.name not in groups_filter: | |
| continue | |
| for skill in await group.list_skills(): | |
| if skill.name in skills: | |
| logger.warning( | |
| "Duplicate skill name '%s' found in group '%s', " | |
| "overwriting it.", | |
| skill.name, | |
| group.name, | |
| ) | |
| skills[skill.name] = skill | |
| return skills | |
| async def get_skill_instructions( | |
| self, | |
| activated_groups: list[str] | None = None, | |
| ) -> str | None: | |
| """Get the prompt for all registered agent skills, which can be | |
| attached to the system prompt for the agent. | |
| The prompt is consisted of an overall instruction and the detailed | |
| descriptions of each skill, including its name, description, and | |
| directory. | |
| .. note:: If no skill is registered, None will be returned. | |
| Args: | |
| activated_groups (`list[str] | None`, optional): | |
| The currently activated tool groups. If omitted, all groups | |
| will be included for backwards compatibility. | |
| Returns: | |
| `str | None`: | |
| The combined prompt for registered agent skills, or None | |
| if no skill is registered. | |
| """ | |
| if activated_groups is None: | |
| activated_groups = [_.name for _ in self.tool_groups] | |
| skills = await self._get_available_skills( | |
| activated_groups, | |
| ) | |
| # If no skills were collected, return None | |
| if len(skills) == 0: | |
| return None | |
| # Generate the skill instruction prompt with the template | |
| template = Template(self.skill_instruction_template) | |
| return template.render( | |
| skills=skills.values(), | |
| skill_viewer=self.builtin_skill_viewer.tool.name, | |
| ) | |
| async def _get_available_tools( | |
| self, | |
| groups: list[str] | None, | |
| ) -> dict[str, RegisteredTool]: | |
| """Return the currently available tools based on the given | |
| activated tool groups. Tools in the ``"basic"`` group are always | |
| included. When at least one tool group is registered, the built-in | |
| meta tool is also included. | |
| Args: | |
| groups (`list[str]`): | |
| The list of currently activated tool group names. | |
| Returns: | |
| `dict[str, RegisteredTool]`: | |
| The dictionary of available tool name and their corresponding | |
| RegisteredTool objects. | |
| """ | |
| available_tools = {} | |
| # Built-in skill viewers | |
| skills = await self._get_available_skills(groups) | |
| if len(skills): | |
| available_tools[ | |
| self.builtin_skill_viewer.tool.name | |
| ] = self.builtin_skill_viewer | |
| # Builtin meta tool is only included when there is at least one tool | |
| # group | |
| if ( | |
| len(self.tool_groups) == 1 | |
| and self.tool_groups[0].name != "basic" | |
| or len(self.tool_groups) > 1 | |
| ): | |
| available_tools[ | |
| self.builtin_meta_tool.tool.name | |
| ] = self.builtin_meta_tool | |
| # The tools in the activated groups and the "basic" group are included | |
| groups_filter = ["basic"] + (groups or []) | |
| for group in self.tool_groups: | |
| if group.name not in groups_filter: | |
| continue | |
| cache_tools = [] | |
| # Python tools | |
| for tool in group.tools: | |
| cache_tools.append(tool) | |
| # MCP tools | |
| for client in group.mcps: | |
| tools = await client.list_tools() | |
| cache_tools.extend(tools) | |
| # Append cached tools into the available tools and solve the name | |
| # conflict | |
| for tool in cache_tools: | |
| if tool.name in available_tools: | |
| logger.warning( | |
| "Duplicate tool name '%s' found in group '%s', " | |
| "overwriting it.", | |
| tool.name, | |
| group.name, | |
| ) | |
| available_tools[tool.name] = RegisteredTool( | |
| tool=tool, | |
| group=group.name, | |
| ) | |
| return available_tools | |
| async def check_tool_available( | |
| self, | |
| tool_name: str, | |
| activated_groups: list[str], | |
| ) -> ToolBase: | |
| """Check if the tool is available now. If not, raise the | |
| agent-oriented exception. | |
| Args: | |
| tool_name (`str`): | |
| The name of the tool to be checked. | |
| activated_groups (`list[str]`): | |
| The currently activated tool groups. | |
| Returns: | |
| `ToolBase`: | |
| If the tool is available, return the corresponding ToolBase | |
| object. Otherwise, raise the agent-oriented exception with the | |
| error message. | |
| """ | |
| tools = await self._get_available_tools(activated_groups) | |
| if tool_name not in tools: | |
| raise ToolNotFoundError( | |
| f"ToolNotFoundError: The tool named '{tool_name}' doesn't " | |
| f"exist.", | |
| ) | |
| group_name = tools[tool_name].group | |
| if group_name != "basic" and group_name not in activated_groups: | |
| raise ToolGroupInactiveError( | |
| f"ToolGroupInactiveError: The tool '{tool_name}' in group " | |
| f"'{group_name}' is currently inactive. " | |
| f"You should first activate the group by calling the " | |
| f"'{self.builtin_meta_tool.tool.name}' tool.", | |
| ) | |
| return tools[tool_name].tool | |
| async def get_tool(self, name: str) -> ToolBase | None: | |
| """Get tool instance by its name. | |
| Args: | |
| name (`str`): | |
| The name of the tool to be checked. | |
| Returns: | |
| `ToolBase | None`: | |
| The tool instance, or `None` if no tool is found. | |
| """ | |
| tools = await self._get_available_tools( | |
| [_.name for _ in self.tool_groups], | |
| ) | |
| registered_tool = tools.get(name, None) | |
| if registered_tool is None: | |
| return None | |
| return registered_tool.tool | |
| def _get_meta_tool_schema(self) -> Type[BaseModel]: | |
| """Get the meta tool schema based on the current tool groups.""" | |
| fields = {} | |
| for group in self.tool_groups: | |
| if group.name == "basic": | |
| continue | |
| fields[group.name] = ( | |
| bool, | |
| Field( | |
| default=False, | |
| description=group.description, | |
| ), | |
| ) | |
| return create_model("_DynamicModel", **fields) | |
| def clear(self) -> None: | |
| """Clear the registered tools, skills and MCPs.""" | |
| self.tool_groups.clear() | |