chore: 添加虚拟环境到仓库

- 添加 backend_service/venv 虚拟环境
- 包含所有Python依赖包
- 注意:虚拟环境约393MB,包含12655个文件
This commit is contained in:
2025-12-03 10:19:25 +08:00
parent a6c2027caa
commit c4f851d387
12655 changed files with 3009376 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""The tool module in agentscope."""
from ._response import ToolResponse
from ._coding import (
execute_python_code,
execute_shell_command,
)
from ._text_file import (
view_text_file,
write_text_file,
insert_text_file,
)
from ._multi_modality import (
dashscope_text_to_image,
dashscope_text_to_audio,
dashscope_image_to_text,
openai_text_to_image,
openai_text_to_audio,
openai_edit_image,
openai_create_image_variation,
openai_image_to_text,
openai_audio_to_text,
)
from ._toolkit import Toolkit
__all__ = [
"Toolkit",
"ToolResponse",
"execute_python_code",
"execute_shell_command",
"view_text_file",
"write_text_file",
"insert_text_file",
"dashscope_text_to_image",
"dashscope_text_to_audio",
"dashscope_image_to_text",
"openai_text_to_image",
"openai_text_to_audio",
"openai_edit_image",
"openai_create_image_variation",
"openai_image_to_text",
"openai_audio_to_text",
]

View File

@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""The functions that wrap object, sync generator, and async generator
into async generators.
TODO: handle the exception raised when yielding from async generator
into a normal ToolResponse instance.
"""
import asyncio
from typing import AsyncGenerator, Generator, Callable, Awaitable
from ._response import ToolResponse
from ..message import TextBlock
from .._utils._common import _execute_async_or_sync_func
async def _postprocess_tool_response(
tool_response: ToolResponse,
postprocess_func: (
Callable[[ToolResponse], ToolResponse | None]
| Callable[[ToolResponse], Awaitable[ToolResponse | None]]
)
| None,
) -> ToolResponse:
"""Post-process a ToolResponse object with the given function.
Supports both sync and async postprocess_func.
"""
if postprocess_func:
processed_response = await _execute_async_or_sync_func(
postprocess_func,
tool_response,
)
if processed_response:
return processed_response
return tool_response
async def _object_wrapper(
obj: ToolResponse,
postprocess_func: (
Callable[[ToolResponse], ToolResponse | None]
| Callable[[ToolResponse], Awaitable[ToolResponse | None]]
)
| None,
) -> AsyncGenerator[ToolResponse, None]:
"""Wrap a ToolResponse object to an async generator."""
yield await _postprocess_tool_response(obj, postprocess_func)
async def _sync_generator_wrapper(
sync_generator: Generator[ToolResponse, None, None],
postprocess_func: (
Callable[[ToolResponse], ToolResponse | None]
| Callable[[ToolResponse], Awaitable[ToolResponse | None]]
)
| None,
) -> AsyncGenerator[ToolResponse, None]:
"""Wrap a sync generator to an async generator."""
for chunk in sync_generator:
yield await _postprocess_tool_response(chunk, postprocess_func)
async def _async_generator_wrapper(
async_func: AsyncGenerator[ToolResponse, None],
postprocess_func: (
Callable[[ToolResponse], ToolResponse | None]
| Callable[[ToolResponse], Awaitable[ToolResponse | None]]
)
| None,
) -> AsyncGenerator[ToolResponse, None]:
"""When the function is interrupted during generating the tool
response, add an interrupted message to the response, and postpone
the CancelledError to the caller."""
last_chunk = None
try:
async for chunk in async_func:
processed_chunk = await _postprocess_tool_response(
chunk,
postprocess_func,
)
yield processed_chunk
last_chunk = processed_chunk
except asyncio.CancelledError:
interrupted_info = TextBlock(
type="text",
text="<system-info>"
"The tool call has been interrupted by the user."
"</system-info>",
)
if last_chunk:
last_chunk.content.append(interrupted_info)
last_chunk.is_interrupted = True
last_chunk.is_last = True
yield await _postprocess_tool_response(
last_chunk,
postprocess_func,
)
else:
yield await _postprocess_tool_response(
ToolResponse(
content=[interrupted_info],
is_interrupted=True,
is_last=True,
),
postprocess_func,
)

View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
"""The coding-related tools module in agentscope."""
from ._python import execute_python_code
from ._shell import execute_shell_command
__all__ = [
"execute_python_code",
"execute_shell_command",
]

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
# pylint: disable=unused-argument
"""The Python code execution tool in agentscope."""
import asyncio
import os
import sys
import tempfile
from typing import Any
import shortuuid
from ...message import TextBlock
from .._response import ToolResponse
async def execute_python_code(
code: str,
timeout: float = 300,
**kwargs: Any,
) -> ToolResponse:
"""Execute the given python code in a temp file and capture the return
code, standard output and error. Note you must `print` the output to get
the result, and the tmp file will be removed right after the execution.
Args:
code (`str`):
The Python code to be executed.
timeout (`float`, defaults to `300`):
The maximum time (in seconds) allowed for the code to run.
Returns:
`ToolResponse`:
The response containing the return code, standard output, and
standard error of the executed code.
"""
with tempfile.TemporaryDirectory() as temp_dir:
temp_file = os.path.join(temp_dir, f"tmp_{shortuuid.uuid()}.py")
with open(temp_file, "w", encoding="utf-8") as f:
f.write(code)
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-u",
temp_file,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
await asyncio.wait_for(proc.wait(), timeout=timeout)
stdout, stderr = await proc.communicate()
stdout_str = stdout.decode("utf-8")
stderr_str = stderr.decode("utf-8")
returncode = proc.returncode
except asyncio.TimeoutError:
stderr_suffix = (
f"TimeoutError: The code execution exceeded "
f"the timeout of {timeout} seconds."
)
returncode = -1
try:
proc.terminate()
stdout, stderr = await proc.communicate()
stdout_str = stdout.decode("utf-8")
stderr_str = stderr.decode("utf-8")
if stderr_str:
stderr_str += f"\n{stderr_suffix}"
else:
stderr_str = stderr_suffix
except ProcessLookupError:
stdout_str = ""
stderr_str = stderr_suffix
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"<returncode>{returncode}</returncode>"
f"<stdout>{stdout_str}</stdout>"
f"<stderr>{stderr_str}</stderr>",
),
],
)

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
# pylint: disable=unused-argument
"""The shell command tool in agentscope."""
import asyncio
from typing import Any
from .._response import ToolResponse
from ...message import TextBlock
async def execute_shell_command(
command: str,
timeout: int = 300,
**kwargs: Any,
) -> ToolResponse:
"""Execute given command and return the return code, standard output and
error within <returncode></returncode>, <stdout></stdout> and
<stderr></stderr> tags.
Args:
command (`str`):
The shell command to execute.
timeout (`float`, defaults to `300`):
The maximum time (in seconds) allowed for the command to run.
Returns:
`ToolResponse`:
The tool response containing the return code, standard output, and
standard error of the executed command.
"""
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
bufsize=0,
)
try:
await asyncio.wait_for(proc.wait(), timeout=timeout)
stdout, stderr = await proc.communicate()
stdout_str = stdout.decode("utf-8")
stderr_str = stderr.decode("utf-8")
returncode = proc.returncode
except asyncio.TimeoutError:
stderr_suffix = (
f"TimeoutError: The command execution exceeded "
f"the timeout of {timeout} seconds."
)
returncode = -1
try:
proc.terminate()
stdout, stderr = await proc.communicate()
stdout_str = stdout.decode("utf-8")
stderr_str = stderr.decode("utf-8")
if stderr_str:
stderr_str += f"\n{stderr_suffix}"
else:
stderr_str = stderr_suffix
except ProcessLookupError:
stdout_str = ""
stderr_str = stderr_suffix
return ToolResponse(
content=[
TextBlock(
type="text",
text=(
f"<returncode>{returncode}</returncode>"
f"<stdout>{stdout_str}</stdout>"
f"<stderr>{stderr_str}</stderr>"
),
),
],
)

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
"""The multi-modal-related tools module in agentscope."""
from ._dashscope_tools import (
dashscope_image_to_text,
dashscope_text_to_audio,
dashscope_text_to_image,
)
from ._openai_tools import (
openai_text_to_image,
openai_edit_image,
openai_text_to_audio,
openai_create_image_variation,
openai_image_to_text,
openai_audio_to_text,
)
__all__ = [
"dashscope_image_to_text",
"dashscope_text_to_audio",
"dashscope_text_to_image",
"openai_text_to_image",
"openai_text_to_audio",
"openai_edit_image",
"openai_create_image_variation",
"openai_image_to_text",
"openai_audio_to_text",
]

View File

@@ -0,0 +1,302 @@
# -*- coding: utf-8 -*-
"""Use DashScope API to generate images,
convert text to audio, and convert images to text.
Please refer to the `official documentation <https://dashscope.aliyun.com/>`_
for more details.
"""
import base64
from typing import Literal, Sequence
import os
from ..._utils._common import _get_bytes_from_web_url
from ...message import ImageBlock, TextBlock, AudioBlock
from ...tool import ToolResponse
def dashscope_text_to_image(
prompt: str,
api_key: str,
n: int = 1,
size: Literal["1024*1024", "720*1280", "1280*720"] = "1024*1024",
model: str = "wanx-v1",
use_base64: bool = False,
) -> ToolResponse:
"""Generate image(s) based on the given prompt, and return image url(s)
or base64 data.
Args:
prompt (`str`):
The text prompt to generate image.
api_key (`str`):
The api key for the dashscope api.
n (`int`, defaults to `1`):
The number of images to generate.
size (`Literal["1024*1024", "720*1280", "1280*720"]`, defaults to \
`"1024*1024"`):
Size of the image.
model (`str`, defaults to '"wanx-v1"'):
The model to use, such as "wanx-v1", "qwen-image",
"wan2.2-t2i-flash", etc.
use_base64 (`bool`, defaults to 'False'):
Whether to use base64 data for images.
Returns:
`ToolResponse`:
A ToolResponse containing the generated content
(ImageBlock/TextBlock/AudioBlock) or error information if the
operation failed.
"""
try:
import dashscope
response = dashscope.ImageSynthesis.call(
model=model,
prompt=prompt,
api_key=api_key,
n=n,
size=size,
)
images = response.output["results"]
urls = [_["url"] for _ in images]
image_blocks: list = []
if urls is not None:
for url in urls:
if use_base64:
extension = url.split(".")[-1].lower()
image_data = _get_bytes_from_web_url(url)
image_blocks.append(
ImageBlock(
type="image",
source={
"type": "base64",
"media_type": f"image/{extension}",
"data": image_data,
},
),
)
else:
image_blocks.append(
ImageBlock(
type="image",
source={
"type": "url",
"url": url,
},
),
)
return ToolResponse(
content=image_blocks,
)
else:
return ToolResponse(
[
TextBlock(
type="text",
text="Error: Failed to generate images",
),
],
)
except Exception as e:
return ToolResponse(
[
TextBlock(
type="text",
text=f"Failed to generate images: {str(e)}",
),
],
)
def dashscope_image_to_text(
image_urls: str | Sequence[str],
api_key: str,
prompt: str = "Describe the image",
model: str = "qwen-vl-plus",
) -> ToolResponse:
"""Generate text based on the given images.
Args:
image_urls (`str | Sequence[str]`):
The url of single or multiple images.
api_key (`str`):
The api key for the dashscope api.
prompt (`str`, defaults to 'Describe the image' ):
The text prompt.
model (`str`, defaults to 'qwen-vl-plus'):
The model to use in DashScope MultiModal API.
Returns:
`ToolResponse`:
A ToolResponse containing the generated content
(ImageBlock/TextBlock/AudioBlock) or error information if the
operation failed.
"""
if isinstance(image_urls, str):
image_urls = [image_urls]
# Check if the local url is valid
img_abs_urls = []
for url in image_urls:
if os.path.exists(url):
if os.path.isfile(url):
img_abs_urls.append(os.path.abspath(url))
else:
return ToolResponse(
[
TextBlock(
type="text",
text=f'Error: The input image url "{url}" is '
f"not a file.",
),
],
)
else:
# Maybe a web url or an invalid url, we leave it to the API
# to handle
img_abs_urls.append(url)
# Convert image paths according to the model requirements
contents = []
for url in img_abs_urls:
contents.append(
{
"image": url,
},
)
contents.append({"text": prompt})
# currently only support one round of conversation
# if multiple rounds of conversation are needed,
# it would be better to implement an Agent class
sys_message = {
"role": "system",
"content": [{"text": "You are a helpful assistant."}],
}
user_message = {
"role": "user",
"content": contents,
}
messages = [sys_message, user_message]
try:
import dashscope
response = dashscope.MultiModalConversation.call(
model=model,
messages=messages,
api_key=api_key,
)
content = response.output["choices"][0]["message"]["content"]
if isinstance(content, list):
content = content[0]["text"]
if content is not None:
return ToolResponse(
[
TextBlock(
type="text",
text=content,
),
],
)
else:
return ToolResponse(
[
TextBlock(
type="text",
text="Error: Failed to generate text",
),
],
)
except Exception as e:
return ToolResponse(
[
TextBlock(
type="text",
text=f"Failed to generate text: {str(e)}",
),
],
)
def dashscope_text_to_audio(
text: str,
api_key: str,
model: str = "sambert-zhichu-v1",
sample_rate: int = 48000,
) -> ToolResponse:
"""Convert the given text to audio.
Args:
text (`str`):
The text to be converted into audio.
api_key (`str`):
The api key for the dashscope API.
model (`str`, defaults to 'sambert-zhichu-v1'):
The model to use. Full model list can be found in the
`official document
<https://help.aliyun.com/zh/model-studio/sambert-python-sdk>`_.
sample_rate (`int`, defaults to 48000):
Sample rate of the audio.
Returns:
`ToolResponse`:
A ToolResponse containing the generated content
(ImageBlock/TextBlock/AudioBlock) or error information if the
operation failed.
"""
try:
import dashscope
dashscope.api_key = api_key
res = dashscope.audio.tts.SpeechSynthesizer.call(
model=model,
text=text,
sample_rate=sample_rate,
format="wav",
)
audio_data = res.get_audio_data()
if audio_data is not None:
audio_base64 = base64.b64encode(audio_data).decode("utf-8")
return ToolResponse(
[
AudioBlock(
type="audio",
source={
"type": "base64",
"media_type": "audio/wav",
"data": audio_base64,
},
),
],
)
else:
return ToolResponse(
[
TextBlock(
type="text",
text="Error: Failed to generate audio",
),
],
)
except Exception as e:
return ToolResponse(
[
TextBlock(
type="text",
text=f"Failed to generate audio: {str(e)}",
),
],
)

View File

@@ -0,0 +1,672 @@
# -*- coding: utf-8 -*-
"""
Wrap OpenAI API calls as tools. Refer the official
`OpenAI API documentation <https://platform.openai.com/docs/overview>`_ for
more details.
"""
import base64
from io import BytesIO
import os
from typing import Literal, IO
import requests
from .. import ToolResponse
from ...formatter._openai_formatter import _to_openai_image_url
from ...message import (
ImageBlock,
TextBlock,
Base64Source,
URLSource,
AudioBlock,
)
def _parse_url(url: str) -> BytesIO | IO[bytes]:
"""
If url is a local file path, return a BytesIO of the file content.
If url is a web URL, fetch the content and return as BytesIO.
"""
if url.startswith(("http://", "https://")):
response = requests.get(url)
response.raise_for_status() # Raise an exception for HTTP errors
return BytesIO(response.content)
else:
if not os.path.exists(url):
raise FileNotFoundError(f"File not found: {url}")
return open(os.path.abspath(url), "rb")
def openai_text_to_image(
prompt: str,
api_key: str,
n: int = 1,
model: Literal["dall-e-2", "dall-e-3", "gpt-image-1"] = "dall-e-2",
size: Literal[
"256x256",
"512x512",
"1024x1024",
"1792x1024",
"1024x1792",
] = "256x256",
quality: Literal[
"auto",
"standard",
"hd",
"high",
"medium",
"low",
] = "auto",
style: Literal["vivid", "natural"] = "vivid",
response_format: Literal["url", "b64_json"] = "url",
) -> ToolResponse:
"""
Generate image(s) based on the given prompt, and return image URL(s) or
base64 data.
Args:
prompt (`str`):
The text prompt to generate images.
api_key (`str`):
The API key for the OpenAI API.
n (`int`, defaults to `1`):
The number of images to generate.
model (`Literal["dall-e-2", "dall-e-3"]`, defaults to `"dall-e-2"`):
The model to use for image generation.
size (`Literal["256x256", "512x512", "1024x1024", "1792x1024", \
"1024x1792"]`, defaults to `"256x256"`):
The size of the generated images.
Must be one of 1024x1024, 1536x1024 (landscape), 1024x1536 (
portrait), or auto (default value) for gpt-image-1,
one of 256x256, 512x512, or 1024x1024 for dall-e-2,
and one of 1024x1024, 1792x1024, or 1024x1792 for dall-e-3.
quality (`Literal["auto", "standard", "hd", "high", "medium", \
"low"]`, defaults to `"auto"`):
The quality of the image that will be generated.
- `auto` (default value) will automatically select the best
quality for the given model.
- `high`, `medium` and `low` are supported for gpt-image-1.
- `hd` and `standard` are supported for dall-e-3.
- `standard` is the only option for dall-e-2.
style (`Literal["vivid", "natural"]`, defaults to `"vivid"`):
The style of the generated images.
This parameter is only supported for dall-e-3.
Must be one of `vivid` or `natural`.
- `Vivid` causes the model to lean towards generating hyper-real
and dramatic images.
- `Natural` causes the model to produce more natural,
less hyper-real looking images.
response_format (`Literal["url", "b64_json"]`, defaults to `"url"`):
The format in which generated images with dall-e-2 and dall-e-3
are returned.
- Must be one of "url" or "b64_json".
- URLs are only valid for 60 minutes after the image has been
generated.
- This parameter isn't supported for gpt-image-1 which will always
return base64-encoded images.
Returns:
`ToolResponse`:
A ToolResponse containing the generated content
(ImageBlock/TextBlock/AudioBlock) or error information if the
operation failed.
"""
kwargs = {
"model": model,
"prompt": prompt,
"n": n,
"size": size,
}
if model == "dall-e-3":
kwargs["style"] = style
if model != "dall-e-2":
kwargs["quality"] = quality
if model != "gpt-image-1":
kwargs["response_format"] = response_format
if model == "gpt-image-1":
response_format = "b64_json"
try:
import openai
client = openai.OpenAI(
api_key=api_key,
)
response = client.images.generate(
**kwargs,
)
image_blocks: list = []
if response_format == "url":
image_urls = [_.url for _ in response.data]
for image_url in image_urls:
image_blocks.append(
ImageBlock(
type="image",
source=URLSource(
type="url",
url=image_url,
),
),
)
else:
image_datas = [_.b64_json for _ in response.data]
for image_data in image_datas:
image_blocks.append(
ImageBlock(
type="image",
source=Base64Source(
type="base64",
media_type="image/png",
data=image_data,
),
),
)
return ToolResponse(
content=image_blocks,
)
except Exception as e:
return ToolResponse(
[
TextBlock(
type="text",
text=f"Failed to generate image: {str(e)}",
),
],
)
def openai_edit_image(
image_url: str,
prompt: str,
api_key: str,
model: Literal["dall-e-2", "gpt-image-1"] = "dall-e-2",
mask_url: str | None = None,
n: int = 1,
size: Literal[
"256x256",
"512x512",
"1024x1024",
] = "256x256",
response_format: Literal["url", "b64_json"] = "url",
) -> ToolResponse:
"""
Edit an image based on the provided mask and prompt, and return the edited
image URL(s) or base64 data.
Args:
image_url (`str`):
The file path or URL to the image that needs editing.
prompt (`str`):
The text prompt describing the edits to be made to the image.
api_key (`str`):
The API key for the OpenAI API.
model (`Literal["dall-e-2", "gpt-image-1"]`, defaults to `"dall-e-2"`):
The model to use for image generation.
mask_url (`str | None`, defaults to `None`):
The file path or URL to the mask image that specifies the regions
to be edited.
n (`int`, defaults to `1`):
The number of edited images to generate.
size (`Literal["256x256", "512x512", "1024x1024"]`, defaults to \
`"256x256"`):
The size of the edited images.
response_format (`Literal["url", "b64_json"]`, defaults to `"url"`):
The format in which generated images are returned.
- Must be one of "url" or "b64_json".
- URLs are only valid for 60 minutes after generation.
- This parameter isn't supported for gpt-image-1 which will
always return base64-encoded images.
Returns:
`ToolResponse`:
A ToolResponse containing the generated content
(ImageBlock/TextBlock/AudioBlock) or error information if the
operation failed.
"""
try:
import openai
client = openai.OpenAI(
api_key=api_key,
)
def prepare_image(url_or_path: str) -> BytesIO:
from PIL import Image
if url_or_path.startswith(("http://", "https://")):
response = requests.get(url_or_path)
response.raise_for_status()
img = Image.open(BytesIO(response.content))
else:
img = Image.open(url_or_path)
if img.mode != "RGBA":
img = img.convert("RGBA")
img_buffer = BytesIO()
img.save(img_buffer, format="PNG")
img_buffer.seek(0)
img_buffer.name = "image.png"
return img_buffer
image_file = prepare_image(image_url)
kwargs = {
"model": model,
"image": image_file,
"prompt": prompt,
"n": n,
"size": size,
}
if mask_url:
kwargs["mask"] = prepare_image(mask_url)
if model == "dall-e-2":
kwargs["response_format"] = response_format
else:
response_format = "b64_json"
response = client.images.edit(**kwargs)
if response_format == "url":
urls = [_.url for _ in response.data]
image_blocks: list = []
for url in urls:
image_blocks.append(
ImageBlock(
type="image",
source=URLSource(
type="url",
url=url,
),
),
)
return ToolResponse(
content=image_blocks,
)
elif response_format == "b64_json":
image_datas = [_.b64_json for _ in response.data]
image_blocks = []
for image_data in image_datas:
image_blocks.append(
ImageBlock(
type="image",
source=Base64Source(
type="base64",
media_type="image/png",
data=image_data,
),
),
)
return ToolResponse(
content=image_blocks,
)
except Exception as e:
return ToolResponse(
[
TextBlock(
type="text",
text=f"Failed to generate image: {str(e)}",
),
],
)
def openai_create_image_variation(
image_url: str,
api_key: str,
n: int = 1,
model: Literal["dall-e-2"] = "dall-e-2",
size: Literal[
"256x256",
"512x512",
"1024x1024",
] = "256x256",
response_format: Literal["url", "b64_json"] = "url",
) -> ToolResponse:
"""
Create variations of an image and return the image URL(s) or base64 data.
Args:
image_url (`str`):
The file path or URL to the image from which variations will be
generated.
api_key (`str`):
The API key for the OpenAI API.
n (`int`, defaults to `1`):
The number of image variations to generate.
model (` Literal["dall-e-2"]`, default to `dall-e-2`):
The model to use for image variation.
size (`Literal["256x256", "512x512", "1024x1024"]`, defaults to \
`"256x256"`):
The size of the generated image variations.
response_format (`Literal["url", "b64_json"]`, defaults to `"url"`):
The format in which generated images are returned.
- Must be one of url or b64_json.
- URLs are only valid for 60 minutes after the image has been
generated.
Returns:
`ToolResponse`:
A ToolResponse containing the generated content
(ImageBlock/TextBlock/AudioBlock) or error information if the
operation failed.
"""
# _parse_url handles both local and web URLs and returns BytesIO
image = _parse_url(image_url)
try:
import openai
client = openai.OpenAI(
api_key=api_key,
)
response = client.images.create_variation(
model=model,
image=image,
n=n,
size=size,
)
image_blocks: list = []
if response_format == "url":
urls = [_.url for _ in response.data]
for url in urls:
image_blocks.append(
ImageBlock(
type="image",
source=URLSource(
type="url",
url=url,
),
),
)
else:
image_datas = [_.b64_json for _ in response.data]
for image_data in image_datas:
image_blocks.append(
ImageBlock(
type="image",
source=Base64Source(
type="base64",
media_type="image/png",
data=image_data,
),
),
)
return ToolResponse(
content=image_blocks,
)
except Exception as e:
return ToolResponse(
[
TextBlock(
type="text",
text=f"Failed to generate image: {str(e)}",
),
],
)
def openai_image_to_text(
image_urls: str | list[str],
api_key: str,
prompt: str = "Describe the image",
model: str = "gpt-4o",
) -> ToolResponse:
"""
Generate descriptive text for given image(s) using a specified model, and
return the generated text.
Args:
image_urls (`str | list[str]`):
The URL or list of URLs pointing to the images that need to be
described.
api_key (`str`):
The API key for the OpenAI API.
prompt (`str`, defaults to `"Describe the image"`):
The prompt that instructs the model on how to describe
the image(s).
model (`str`, defaults to `"gpt-4o"`):
The model to use for generating the text descriptions.
Returns:
`ToolResponse`:
A ToolResponse containing the generated content
(ImageBlock/TextBlock/AudioBlock) or error information if the
operation failed.
"""
if isinstance(image_urls, str):
image_urls = [image_urls]
content = []
for url in image_urls:
content.append(
{
"type": "image_url",
"image_url": {
"url": _to_openai_image_url(url),
},
},
)
content.append(
{
"type": "text",
"text": prompt,
},
)
messages = [
{
"role": "user",
"content": content,
},
]
try:
import openai
client = openai.OpenAI(
api_key=api_key,
)
response = client.chat.completions.create(
messages=messages,
model=model,
)
return ToolResponse(
[
TextBlock(
type="text",
text=response.choices[0].message.content,
),
],
)
except Exception as e:
return ToolResponse(
[
TextBlock(
type="text",
text=f"Failed to generate text: {str(e)}",
),
],
)
def openai_text_to_audio(
text: str,
api_key: str,
model: Literal["tts-1", "tts-1-hd", "gpt-4o-mini-tts"] = "tts-1",
voice: Literal[
"alloy",
"ash",
"ballad",
"coral",
"echo",
"fable",
"nova",
"onyx",
"sage",
"shimmer",
] = "alloy",
speed: float = 1.0,
res_format: Literal[
"mp3",
"opus",
"aac",
"flac",
"wav",
"pcm",
] = "mp3",
) -> ToolResponse:
"""
Convert text to an audio file using a specified model and voice.
Args:
text (`str`):
The text to convert to audio.
api_key (`str`):
The API key for the OpenAI API.
model (`Literal["tts-1", "tts-1-hd"]`, defaults to `"tts-1"`):
The model to use for text-to-speech conversion.
voice (`Literal["alloy", "echo", "fable", "onyx", "nova", \
"shimmer"]`, defaults to `"alloy"`):
The voice to use for the audio output.
speed (`float`, defaults to `1.0`):
The speed of the audio playback. A value of 1.0 is normal speed.
res_format (`Literal["mp3", "wav", "opus", "aac", "flac", \
"wav", "pcm"]`, defaults to `"mp3"`):
The format of the audio file.
Returns:
`ToolResponse`:
A ToolResponse containing the generated content
(ImageBlock/TextBlock/AudioBlock) or error information if the
operation failed.
"""
try:
import openai
client = openai.OpenAI(
api_key=api_key,
)
response = client.audio.speech.create(
model=model,
voice=voice,
speed=speed,
input=text,
response_format=res_format,
)
audio_bytes = response.content
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
return ToolResponse(
[
AudioBlock(
type="audio",
source=Base64Source(
type="base64",
media_type=f"audio/{res_format}",
data=audio_base64,
),
),
],
)
except Exception as e:
return ToolResponse(
[
TextBlock(
type="text",
text=f"Error: Failed to generate audio. {str(e)}",
),
],
)
def openai_audio_to_text(
audio_file_url: str,
api_key: str,
language: str = "en",
temperature: float = 0.2,
) -> ToolResponse:
"""
Convert an audio file to text using OpenAI's transcription service.
Args:
audio_file_url (`str`):
The file path or URL to the audio file that needs to be
transcribed.
api_key (`str`):
The API key for the OpenAI API.
language (`str`, defaults to `"en"`):
The language of the input audio in
`ISO-639-1 format \
<https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes>`_
(e.g., "en", "zh", "fr"). Improves accuracy and latency.
temperature (`float`, defaults to `0.2`):
The temperature for the transcription, which affects the
randomness of the output.
Returns:
`ToolResponse`:
A ToolResponse containing the generated content
(ImageBlock/TextBlock/AudioBlock) or error information if the
operation failed.
"""
try:
import openai
client = openai.OpenAI(
api_key=api_key,
)
if audio_file_url.startswith(("http://", "https://")):
response = requests.get(audio_file_url)
response.raise_for_status()
audio_buffer = BytesIO(response.content)
import urllib.parse
from pathlib import Path
parsed_url = urllib.parse.urlparse(audio_file_url)
filename = Path(parsed_url.path).name or "audio.mp3"
audio_buffer.name = filename
audio_file = audio_buffer
transcription = client.audio.transcriptions.create(
model="whisper-1",
file=audio_file,
language=language,
temperature=temperature,
)
else:
if not os.path.exists(audio_file_url):
raise FileNotFoundError(f"File not found: {audio_file_url}")
with open(audio_file_url, "rb") as audio_file:
transcription = client.audio.transcriptions.create(
model="whisper-1",
file=audio_file,
language=language,
temperature=temperature,
)
return ToolResponse(
[
TextBlock(
type="text",
text=transcription.text,
),
],
)
except Exception as e:
return ToolResponse(
[
TextBlock(
type="text",
text=f"Error: Failed to transcribe audio: {str(e)}",
),
],
)

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""The data model for registered tool functions in AgentScope."""
from copy import deepcopy
from dataclasses import field, dataclass
from typing import Callable, Literal, Type, Awaitable
from pydantic import BaseModel
from ._response import ToolResponse
from .._utils._common import _remove_title_field
from ..message import ToolUseBlock
from ..types import ToolFunction, JSONSerializableObject
@dataclass
class RegisteredToolFunction:
"""The registered tool function class."""
name: str
"""The name of the tool function."""
group: str | Literal["basic"]
"""The belonging group of the tool function"""
source: Literal["function", "mcp_server", "function_group"]
""""The type of the tool function, can be `function` or `mcp_server`."""
original_func: ToolFunction
"""The original function"""
json_schema: dict
"""The JSON schema of the tool function, which is used to validate the """
preset_kwargs: dict[str, JSONSerializableObject] = field(
default_factory=dict,
)
"""The preset keyword arguments, which won't be presented in the JSON
schema and exposed to the user."""
extended_model: Type[BaseModel] | None = None
"""The base model used to extend the JSON schema of the original tool
function, so that we can dynamically adjust the tool function."""
mcp_name: str | None = None
"""The name of the MCP, if the tool function comes from an MCP server."""
postprocess_func: (
Callable[
[ToolUseBlock, ToolResponse],
ToolResponse | None,
]
| Callable[
[ToolUseBlock, ToolResponse],
Awaitable[ToolResponse | None],
]
) | None = None
"""The post-processing function that will be called after the tool
function is executed, taking the tool call block and tool
response as arguments. The function can be either sync or async. If it
returns `None`, the tool result will be returned as is. If it returns a
`ToolResponse`, the returned block will be used as the final tool
response."""
@property
def extended_json_schema(self) -> dict:
"""Get the JSON schema of the tool function, if an extended model is
set, the merged JSON schema will be returned."""
if self.extended_model is None:
return self.json_schema
# Merge the extended model with the original JSON schema
extended_schema = self.extended_model.model_json_schema()
merged_schema = deepcopy(self.json_schema)
_remove_title_field( # pylint: disable=protected-access
extended_schema,
)
for key, value in extended_schema["properties"].items():
if key in self.json_schema["function"]["parameters"]["properties"]:
raise ValueError(
f"The field `{key}` already exists in the original "
f"function schema of `{self.name}`. Try to use a "
"different name.",
)
merged_schema["function"]["parameters"]["properties"][key] = value
if key in extended_schema.get("required", []):
if "required" not in merged_schema["function"]["parameters"]:
merged_schema["function"]["parameters"]["required"] = []
merged_schema["function"]["parameters"]["required"].append(key)
return merged_schema

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""The tool response class."""
from dataclasses import dataclass, field
from typing import Optional, List
from .._utils._common import _get_timestamp
from ..message import AudioBlock, ImageBlock, TextBlock
@dataclass
class ToolResponse:
"""The result chunk of a tool call."""
content: List[TextBlock | ImageBlock | AudioBlock]
"""The execution output of the tool function."""
metadata: Optional[dict] = None
"""The metadata to be accessed within the agent, so that we don't need to
parse the tool result block."""
stream: bool = False
"""Whether the tool output is streamed."""
is_last: bool = True
"""Whether this is the last response in a stream tool execution."""
is_interrupted: bool = False
"""Whether the tool execution is interrupted."""
id: str = field(default_factory=lambda: _get_timestamp(True))
"""The identity of the tool response."""

View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""The text file tool module in agentscope."""
from ._view_text_file import view_text_file
from ._write_text_file import (
insert_text_file,
write_text_file,
)
__all__ = [
"insert_text_file",
"write_text_file",
"view_text_file",
]

View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
"""The utility functions for text file tools in agentscope."""
from ...exception import ToolInvalidArgumentsError
def _calculate_view_ranges(
old_n_lines: int,
new_n_lines: int,
start: int,
end: int,
extra_view_n_lines: int = 5,
) -> tuple[int, int]:
"""Calculate after writing the new content, the view ranges of the file.
Args:
old_n_lines (`int`):
The number of lines before writing the new content.
new_n_lines (`int`):
The number of lines after writing the new content.
start (`int`):
The start line of the writing range.
end (`int`):
The end line of the writing range.
extra_view_n_lines (`int`, optional):
The number of extra lines to view before and after the range.
"""
view_start = max(1, start - extra_view_n_lines)
delta_lines = new_n_lines - old_n_lines
view_end = min(end + delta_lines + extra_view_n_lines, new_n_lines)
return view_start, view_end
def _assert_ranges(
ranges: list[int],
) -> None:
"""Check if the ranges are valid.
Raises:
ToolInvalidArgumentsError: If the ranges are invalid.
"""
if (
isinstance(ranges, list)
and len(ranges) == 2
and all(isinstance(i, int) for i in ranges)
):
start, end = ranges
if start > end:
raise ToolInvalidArgumentsError(
f"InvalidArgumentError: The start line is greater than the "
f"end line in the given range {ranges}.",
)
else:
raise ToolInvalidArgumentsError(
f"InvalidArgumentError: Invalid range format. Expected a list of "
f"two integers, but got {ranges}.",
)
def _view_text_file(
file_path: str,
ranges: list[int] | None = None,
) -> str:
"""Return the file content in the specified range with line numbers."""
with open(file_path, "r", encoding="utf-8") as file:
lines = file.readlines()
if ranges:
_assert_ranges(ranges)
start, end = ranges
if start > len(lines):
raise ToolInvalidArgumentsError(
f"InvalidArgumentError: The range '{ranges}' is out of bounds "
f"for the file '{file_path}', which has only {len(lines)} "
f"lines.",
)
view_content = [
f"{index + start}: {line}"
for index, line in enumerate(lines[start - 1 : end])
]
return "".join(view_content)
return "".join(f"{index + 1}: {line}" for index, line in enumerate(lines))

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
# flake8: noqa: E501
# pylint: disable=line-too-long
"""The view text file tool in agentscope."""
import os
from ._write_text_file import _view_text_file
from .._response import ToolResponse
from ...exception import ToolInvalidArgumentsError
from ...message import TextBlock
async def view_text_file(
file_path: str,
ranges: list[int] | None = None,
) -> ToolResponse:
"""View the file content in the specified range with line numbers. If `ranges` is not provided, the entire file will be returned.
Args:
file_path (`str`):
The target file path.
ranges:
The range of lines to be viewed (e.g. lines 1 to 100: [1, 100]), inclusive. If not provided, the entire file will be returned. To view the last 100 lines, use [-100, -1].
Returns:
`ToolResponse`:
The tool response containing the file content or an error message.
"""
if not os.path.exists(file_path):
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Error: The file {file_path} does not exist.",
),
],
)
if not os.path.isfile(file_path):
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Error: The path {file_path} is not a file.",
),
],
)
try:
content = _view_text_file(file_path, ranges)
except ToolInvalidArgumentsError as e:
return ToolResponse(
content=[
TextBlock(
type="text",
text=e.message,
),
],
)
if ranges is None:
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"""The content of {file_path}:
```
{content}```""",
),
],
)
else:
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"""The content of {file_path} in {ranges} lines:
```
{content}```""",
),
],
)

View File

@@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
# flake8: noqa: E501
# pylint: disable=line-too-long
"""The text file tools in agentscope."""
import os
from ._utils import _calculate_view_ranges, _view_text_file
from .._response import ToolResponse
from ...message import TextBlock
async def insert_text_file(
file_path: str,
content: str,
line_number: int,
) -> ToolResponse:
"""Insert the content at the specified line number in a text file.
Args:
file_path (`str`):
The target file path.
content (`str`):
The content to be inserted.
line_number (`int`):
The line number at which the content should be inserted, starting
from 1. If exceeds the number of lines in the file, it will be
appended to the end of the file.
Returns:
`ToolResponse`:
The tool response containing the result of the insertion operation.
"""
if line_number <= 0:
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"InvalidArgumentsError: "
f"The line number {line_number} is invalid. ",
),
],
)
if not os.path.exists(file_path):
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"InvalidArgumentsError: The target file "
f"{file_path} does not exist. ",
),
],
)
with open(file_path, "r", encoding="utf-8") as file:
original_lines = file.readlines()
if line_number == len(original_lines) + 1:
new_lines = original_lines + ["\n" + content]
elif line_number < len(original_lines) + 1:
new_lines = (
original_lines[: line_number - 1]
+ [content + "\n"]
+ original_lines[line_number - 1 :]
)
else:
return ToolResponse(
content=[
TextBlock(
type="text",
text="InvalidArgumentsError: The given line_number "
f"({line_number}) is not in the valid range "
f"[1, {len(original_lines) + 1}].",
),
],
)
with open(file_path, "w", encoding="utf-8") as file:
file.writelines(new_lines)
with open(file_path, "r", encoding="utf-8") as file:
new_lines = file.readlines()
start, end = _calculate_view_ranges(
len(original_lines),
len(new_lines),
line_number,
line_number,
extra_view_n_lines=5,
)
show_content = _view_text_file(file_path, [start, end])
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Insert content into {file_path} at line "
f"{line_number} successfully. The new content "
f"between lines {start}-{end} is:\n"
f"```\n{show_content}```",
),
],
)
async def write_text_file(
file_path: str,
content: str,
ranges: None | list[int] = None,
) -> ToolResponse:
"""Create/Replace/Overwrite content in a text file. When `ranges` is provided, the content will be replaced in the specified range. Otherwise, the entire file (if exists) will be overwritten.
Args:
file_path (`str`):
The target file path.
content (`str`):
The content to be written.
ranges (`list[int] | None`, defaults to `None`):
The range of lines to be replaced. If `None`, the entire file will
be overwritten.
Returns:
`ToolResponse`:
The tool response containing the result of the writing operation.
"""
if not os.path.exists(file_path):
with open(file_path, "w", encoding="utf-8") as file:
file.write(content)
if ranges:
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Create and write {file_path} successfully. "
f"The ranges {ranges} is ignored because the "
f"file does not exist.",
),
],
)
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Create and write {file_path} successfully.",
),
],
)
with open(file_path, "r", encoding="utf-8") as file:
original_lines = file.readlines()
if ranges is not None:
if (
isinstance(ranges, list)
and len(ranges) == 2
and all(isinstance(i, int) for i in ranges)
):
# Replace content in the specified range
start, end = ranges
if start > len(original_lines):
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Error: The start line {start} is invalid. "
f"The file only has {len(original_lines)} "
f"lines.",
),
],
)
new_content = (
original_lines[: start - 1]
+ [
content,
]
+ original_lines[end:]
)
with open(file_path, "w", encoding="utf-8") as file:
file.write("".join(new_content))
# The written content may contain multiple "\n", to avoid mis
# counting the lines, we read the file again to get the new content
with open(file_path, "r", encoding="utf-8") as file:
new_lines = file.readlines()
view_start, view_end = _calculate_view_ranges(
len(original_lines),
len(new_lines),
start,
end,
)
content = "".join(
[
f"{index + view_start}: {line}"
for index, line in enumerate(
new_lines[view_start - 1 : view_end],
)
],
)
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"""Write {file_path} successfully. The new content snippet:
```
{content}```""",
),
],
)
else:
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Error: Invalid range format. Expected a list "
f"of two integers, but got {ranges}.",
),
],
)
with open(file_path, "w", encoding="utf-8") as file:
file.write(content)
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Overwrite {file_path} successfully.",
),
],
)

View File

@@ -0,0 +1,967 @@
# -*- coding: utf-8 -*-
"""The toolkit class for tool calls in agentscope."""
import asyncio
import inspect
from copy import deepcopy
from dataclasses import dataclass
from functools import partial
from typing import (
AsyncGenerator,
Literal,
Dict,
Any,
Type,
Generator,
Callable,
Awaitable,
)
from pydantic import (
BaseModel,
Field,
create_model,
ConfigDict,
)
from docstring_parser import parse
from ._async_wrapper import (
_async_generator_wrapper,
_object_wrapper,
_sync_generator_wrapper,
)
from ._registered_tool_function import RegisteredToolFunction
from ._response import ToolResponse
from .._utils._common import _remove_title_field
from ..mcp import (
MCPToolFunction,
MCPClientBase,
StatefulClientBase,
)
from ..message import (
ToolUseBlock,
TextBlock,
)
from ..module import StateModule
from ..types import (
JSONSerializableObject,
ToolFunction,
)
from ..tracing._trace import trace_toolkit
from .._logging import logger
@dataclass
class ToolGroup:
"""The tool group class"""
name: str
"""The group name, which will be used in the reset function as the group
identifier."""
active: bool
"""If the tool group is active, meaning the tool functions in this group
is included in the JSON schema"""
description: str
"""The description of the tool group to tell the agent what the tool
group is about."""
notes: str | None = None
"""The using notes of the tool group, to remind the agent how to use"""
class Toolkit(StateModule):
"""The class that supports both function- and group-level tool management.
Use the following methods to manage the tool functions:
- `register_tool_function`
- `remove_tool_function`
For group-level management:
- `create_tool_group`
- `update_tool_groups`
- `remove_tool_groups`
MCP related methods:
- `register_mcp_server`
- `remove_mcp_servers`
To run the tool functions or get the data from the activated tools:
- `call_tool_function`
- `get_json_schemas`
- `get_tool_group_notes`
"""
def __init__(self) -> None:
"""Initialize the toolkit."""
super().__init__()
self.tools: dict[str, RegisteredToolFunction] = {}
self.groups: dict[str, ToolGroup] = {}
def create_tool_group(
self,
group_name: str,
description: str,
active: bool = False,
notes: str | None = None,
) -> None:
"""Create a tool group to organize tool functions
Args:
group_name (`str`):
The name of the tool group.
description (`str`):
The description of the tool group.
active (`bool`, defaults to `False`):
If the group is active, meaning the tool functions in this
group are included in the JSON schema.
notes (`str | None`, optional):
The notes used to remind the agent how to use the tool
functions properly, which can be combined into the system
prompt.
"""
if group_name in self.groups or group_name == "basic":
raise ValueError(
f"Tool group '{group_name}' is already registered in the "
"toolkit.",
)
self.groups[group_name] = ToolGroup(
name=group_name,
description=description,
notes=notes,
active=active,
)
def update_tool_groups(self, group_names: list[str], active: bool) -> None:
"""Update the activation status of the given tool groups.
Args:
group_names (`list[str]`):
The list of tool group names to be updated.
active (`bool`):
If the tool groups should be activated or deactivated.
"""
for group_name in group_names:
if group_name == "basic":
logger.warning(
"The 'basic' tool group is always active, skipping it.",
)
if group_name in self.groups:
self.groups[group_name].active = active
def remove_tool_groups(self, group_names: str | list[str]) -> None:
"""Remove tool functions from the toolkit by their group names.
Args:
group_names (`str | list[str]`):
The group names to be removed from the toolkit.
"""
if isinstance(group_names, str):
group_names = [group_names]
if not isinstance(group_names, list) or not all(
isinstance(_, str) for _ in group_names
):
raise TypeError(
f"The group_names must be a list of strings, "
f"but got {type(group_names)}.",
)
if "basic" in group_names:
raise ValueError(
"Cannot remove the default 'basic' tool group.",
)
for group_name in group_names:
self.groups.pop(group_name, None)
# Remove the tool functions in the given groups
tool_names = deepcopy(list(self.tools.keys()))
for tool_name in tool_names:
if self.tools[tool_name].group in group_names:
self.tools.pop(tool_name)
def register_tool_function( # pylint: disable=too-many-branches
self,
tool_func: ToolFunction,
group_name: str | Literal["basic"] = "basic",
preset_kwargs: dict[str, JSONSerializableObject] | None = None,
func_description: str | None = None,
json_schema: dict | None = None,
include_long_description: bool = True,
include_var_positional: bool = False,
include_var_keyword: bool = False,
postprocess_func: (
Callable[
[ToolUseBlock, ToolResponse],
ToolResponse | None,
]
| Callable[
[ToolUseBlock, ToolResponse],
Awaitable[ToolResponse | None],
]
)
| None = None,
) -> None:
"""Register a tool function to the toolkit.
Args:
tool_func (`ToolFunction`):
The tool function, which can be async or sync, streaming or
not-streaming, but the response must be a `ToolResponse`
object.
group_name (`str | Literal["basic"]`, defaults to `"basic"`):
The belonging group of the tool function. Tools in "basic"
group is always included in the JSON schema, while the others
are only included when their group is active.
preset_kwargs (`dict[str, JSONSerializableObject] | None`, \
optional):
Preset arguments by the user, which will not be included in
the JSON schema, nor exposed to the agent.
func_description (`str | None`, optional):
The function description. If not provided, the description
will be extracted from the docstring automatically.
json_schema (`dict | None`, optional):
Manually provided JSON schema for the tool function, which
should be `{"type": "function", "function": {"name":
"function_name": "xx", "description": "xx",
"parameters": {...}}}`
include_long_description (`bool`, defaults to `True`):
When extracting function description from the docstring, if
the long description will be included.
include_var_positional (`bool`, defaults to `False`):
Whether to include the variable positional arguments (`*args`)
in the function schema.
include_var_keyword (`bool`, defaults to `False`):
Whether to include the variable keyword arguments (`**kwargs`)
in the function schema.
postprocess_func (`(Callable[[ToolUseBlock, ToolResponse], \
ToolResponse | None] | Callable[[ToolUseBlock, ToolResponse], \
Awaitable[ToolResponse | None]]) | None`, optional):
A post-processing function that will be called after the tool
function is executed, taking the tool call block and tool
response as arguments. The function can be either sync or
async. If it returns `None`, the tool result will be
returned as is. If it returns a `ToolResponse`,
the returned block will be used as the final tool result.
"""
# Arguments checking
if group_name not in self.groups and group_name != "basic":
raise ValueError(
f"Tool group '{group_name}' not found.",
)
# Check the manually provided JSON schema if provided
if json_schema:
assert (
isinstance(json_schema, dict)
and "type" in json_schema
and json_schema["type"] == "function"
and "function" in json_schema
and isinstance(json_schema["function"], dict)
), "Invalid JSON schema for the tool function."
# Handle MCP tool function and regular function respectively
mcp_name = None
if isinstance(tool_func, MCPToolFunction):
func_name = tool_func.name
original_func = tool_func.__call__
self._validate_tool_function(func_name)
json_schema = json_schema or tool_func.json_schema
mcp_name = tool_func.mcp_name
elif isinstance(tool_func, partial):
# partial function
kwargs = tool_func.keywords
# Turn args into keyword arguments
if tool_func.args:
param_names = list(
inspect.signature(tool_func.func).parameters.keys(),
)
for i, arg in enumerate(tool_func.args):
if i < len(param_names):
kwargs[param_names[i]] = arg
preset_kwargs = {
**kwargs,
**(preset_kwargs or {}),
}
func_name = tool_func.func.__name__
original_func = tool_func.func
self._validate_tool_function(func_name)
json_schema = json_schema or self._parse_tool_function(
tool_func.func,
include_long_description=include_long_description,
include_var_positional=include_var_positional,
include_var_keyword=include_var_keyword,
)
else:
# normal function
func_name = tool_func.__name__
original_func = tool_func
self._validate_tool_function(func_name)
json_schema = json_schema or self._parse_tool_function(
tool_func,
include_long_description=include_long_description,
include_var_positional=include_var_positional,
include_var_keyword=include_var_keyword,
)
# Override the description if provided
if func_description:
json_schema["function"]["description"] = func_description
# Remove the preset kwargs from the JSON schema
for arg_name in preset_kwargs or {}:
if arg_name in json_schema["function"]["parameters"]["properties"]:
json_schema["function"]["parameters"]["properties"].pop(
arg_name,
)
if "required" in json_schema["function"]["parameters"]:
for arg_name in preset_kwargs or {}:
if (
arg_name
in json_schema["function"]["parameters"]["required"]
):
json_schema["function"]["parameters"]["required"].remove(
arg_name,
)
# Remove the required field if it is empty
if len(json_schema["function"]["parameters"]["required"]) == 0:
json_schema["function"]["parameters"].pop("required", None)
func_obj = RegisteredToolFunction(
name=func_name,
group=group_name,
source="function",
original_func=original_func,
json_schema=json_schema,
preset_kwargs=preset_kwargs or {},
extended_model=None,
mcp_name=mcp_name,
postprocess_func=postprocess_func,
)
self.tools[func_name] = func_obj
def remove_tool_function(self, tool_name: str) -> None:
"""Remove tool function from the toolkit by its name.
Args:
tool_name (`str`):
The name of the tool function to be removed.
"""
if tool_name not in self.tools:
logger.warning(
"Skipping removing tool function '%s' as it does not exist.",
tool_name,
)
self.tools.pop(tool_name, None)
def get_json_schemas(
self,
) -> list[dict]:
"""Get the JSON schemas from the tool functions that belong to the
active groups.
.. note:: The preset keyword arguments is removed from the JSON
schema, and the extended model is applied if it is set.
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.
"""
# If meta tool is set here, update its extended model here
if "reset_equipped_tools" in self.tools:
fields = {}
for group_name, group in self.groups.items():
if group_name == "basic":
continue
fields[group_name] = (
bool,
Field(
default=False,
description=group.description,
),
)
extended_model = create_model("_DynamicModel", **fields)
self.set_extended_model(
"reset_equipped_tools",
extended_model,
)
return [
tool.extended_json_schema
for tool in self.tools.values()
if tool.group == "basic" or self.groups[tool.group].active
]
def set_extended_model(
self,
func_name: str,
model: Type[BaseModel] | None,
) -> None:
"""Set the extended model for a tool function, so that the original
JSON schema will be extended.
Args:
func_name (`str`):
The name of the tool function.
model (`Union[Type[BaseModel], None]`):
The extended model to be set.
"""
if model is not None and not issubclass(model, BaseModel):
raise TypeError(
"The extended model must be a child class of pydantic "
f"BaseModel, but got {type(model)}.",
)
if func_name in self.tools:
self.tools[func_name].extended_model = model
else:
raise ValueError(
f"Tool function '{func_name}' not found in the toolkit.",
)
async def remove_mcp_clients(
self,
client_names: list[str],
) -> None:
"""Remove tool functions from the MCP clients by their names.
Args:
client_names (`list[str]`):
The names of the MCP client, which used to initialize the
client instance.
"""
if isinstance(client_names, str):
client_names = [client_names]
if isinstance(client_names, list) and not all(
isinstance(_, str) for _ in client_names
):
raise TypeError(
f"The client_names must be a list of strings, "
f"but got {type(client_names)}.",
)
to_removed = []
func_names = deepcopy(list(self.tools.keys()))
for func_name in func_names:
if self.tools[func_name].mcp_name in client_names:
self.tools.pop(func_name)
to_removed.append(func_name)
logger.info(
"Removed %d tool functions from %d MCP: %s",
len(to_removed),
len(client_names),
", ".join(to_removed),
)
@trace_toolkit
async def call_tool_function(
self,
tool_call: ToolUseBlock,
) -> AsyncGenerator[ToolResponse, None]:
"""Execute the tool function by the `ToolUseBlock` and return the
tool response chunk in unified streaming mode, i.e. an async
generator of `ToolResponse` objects.
.. note:: The tool response chunk is **accumulated**.
Args:
tool_call (`ToolUseBlock`):
A tool call block.
Yields:
`ToolResponse`:
The tool response chunk, in accumulative manner.
"""
# Check
if tool_call["name"] not in self.tools:
return _object_wrapper(
ToolResponse(
content=[
TextBlock(
type="text",
text="FunctionNotFoundError: Cannot find the "
f"function named {tool_call['name']}",
),
],
),
None,
)
# Prepare function and keyword arguments
tool_func = self.tools[tool_call["name"]]
kwargs = {
**tool_func.preset_kwargs,
**(tool_call.get("input", {}) or {}),
}
# Prepare postprocess function
if tool_func.postprocess_func:
# Type: partial wraps the postprocess_func with tool_call bound,
# reducing it from (ToolUseBlock, ToolResponse) to (ToolResponse)
partial_postprocess_func: (
Callable[[ToolResponse], ToolResponse | None]
| Callable[[ToolResponse], Awaitable[ToolResponse | None]]
) | None = partial(
tool_func.postprocess_func,
tool_call,
)
else:
partial_postprocess_func = None
# Async function
try:
if inspect.iscoroutinefunction(tool_func.original_func):
try:
res = await tool_func.original_func(**kwargs)
except asyncio.CancelledError:
res = ToolResponse(
content=[
TextBlock(
type="text",
text="<system-info>"
"The tool call has been interrupted "
"by the user."
"</system-info>",
),
],
stream=True,
is_last=True,
is_interrupted=True,
)
else:
# When `tool_func.original_func` is Async generator function or
# Sync function
res = tool_func.original_func(**kwargs)
except Exception as e:
res = ToolResponse(
content=[
TextBlock(
type="text",
text=f"Error: {e}",
),
],
)
# Handle different return type
# If return an async generator
if isinstance(res, AsyncGenerator):
return _async_generator_wrapper(res, partial_postprocess_func)
# If return a sync generator
if isinstance(res, Generator):
return _sync_generator_wrapper(res, partial_postprocess_func)
if isinstance(res, ToolResponse):
return _object_wrapper(res, partial_postprocess_func)
raise TypeError(
"The tool function must return a ToolResponse object, or an "
"AsyncGenerator/Generator of ToolResponse objects, "
f"but got {type(res)}.",
)
async def register_mcp_client(
self,
mcp_client: MCPClientBase,
group_name: str = "basic",
enable_funcs: list[str] | None = None,
disable_funcs: list[str] | None = None,
preset_kwargs_mapping: dict[str, dict[str, Any]] | None = None,
postprocess_func: (
Callable[
[ToolUseBlock, ToolResponse],
ToolResponse | None,
]
| Callable[
[ToolUseBlock, ToolResponse],
Awaitable[ToolResponse | None],
]
)
| None = None,
) -> None:
"""Register tool functions from an MCP client.
Args:
mcp_client (`MCPClientBase`):
The MCP client instance to connect to the MCP server.
group_name (`str`, defaults to `"basic"`):
The group name that the tool functions will be added to.
enable_funcs (`list[str] | None`, optional):
The functions to be added into the toolkit. If `None`, all
tool functions within the MCP servers will be added.
disable_funcs (`list[str] | None`, optional):
The functions that will be filtered out. If `None`, no
tool functions will be filtered out.
preset_kwargs_mapping: (`Optional[dict[str, dict[str, Any]]]`, \
defaults to `None`):
The preset keyword arguments mapping, whose keys are the tool
function names and values are the preset keyword arguments.
postprocess_func (`(Callable[[ToolUseBlock, ToolResponse], \
ToolResponse | None] | Callable[[ToolUseBlock, ToolResponse], \
Awaitable[ToolResponse | None]]) | None`, optional):
A post-processing function that will be called after the tool
function is executed, taking the tool call block and tool
response as arguments. The function can be either sync or
async. If it returns `None`, the tool result will be
returned as is. If it returns a `ToolResponse`,
the returned block will be used as the final tool result.
"""
if (
isinstance(mcp_client, StatefulClientBase)
and not mcp_client.is_connected
):
raise RuntimeError(
"The MCP client is not connected to the server. Use the "
"`connect()` method first.",
)
# Check arguments for enable_funcs and disabled_funcs
if enable_funcs is not None and disable_funcs is not None:
assert isinstance(enable_funcs, list) and all(
isinstance(_, str) for _ in enable_funcs
), (
"Enable functions should be a list of strings, but got "
f"{enable_funcs}."
)
assert isinstance(disable_funcs, list) and all(
isinstance(_, str) for _ in disable_funcs
), (
"Disable functions should be a list of strings, but got "
f"{disable_funcs}."
)
intersection = set(enable_funcs).intersection(
set(disable_funcs),
)
assert len(intersection) == 0, (
f"The functions in enable_funcs and disable_funcs "
f"should not overlap, but got {intersection}."
)
if not (
preset_kwargs_mapping is None
or isinstance(preset_kwargs_mapping, dict)
):
raise TypeError(
f"The preset_kwargs_mapping must be a dictionary or None, "
f"but got {type(preset_kwargs_mapping)}.",
)
tool_names = []
for mcp_tool in await mcp_client.list_tools():
# Skip the functions that are not in the enable_funcs if
# enable_funcs is not None
if enable_funcs is not None and mcp_tool.name not in enable_funcs:
continue
# Skip the disabled functions
if disable_funcs is not None and mcp_tool.name in disable_funcs:
continue
tool_names.append(mcp_tool.name)
# Obtain callable function object
func_obj = await mcp_client.get_callable_function(
func_name=mcp_tool.name,
wrap_tool_result=True,
)
# Prepare preset kwargs
preset_kwargs = None
if preset_kwargs_mapping is not None:
preset_kwargs = preset_kwargs_mapping.get(mcp_tool.name, {})
# TODO: handle mcp_server_name
self.register_tool_function(
tool_func=func_obj,
group_name=group_name,
preset_kwargs=preset_kwargs,
postprocess_func=postprocess_func,
)
logger.info(
"Registered %d tool functions from MCP: %s.",
len(tool_names),
", ".join(tool_names),
)
def state_dict(self) -> dict[str, Any]:
"""Get the state dictionary of the toolkit.
Returns:
`dict[str, Any]`:
A dictionary containing the active tool group names.
"""
return {
"active_groups": [
name for name, group in self.groups.items() if group.active
],
}
def load_state_dict(
self,
state_dict: dict[str, Any],
strict: bool = True,
) -> None:
"""Load the state dictionary into the toolkit.
Args:
state_dict (`dict`):
The state dictionary to load, which should have "active_groups"
key and its value must be a list of group names.
strict (`bool`, defaults to `True`):
If `True`, raises an error if any key in the module is not
found in the state_dict. If `False`, skips missing keys.
"""
if (
not isinstance(state_dict, dict)
or "active_groups" not in state_dict
or not isinstance(state_dict["active_groups"], list)
):
raise ValueError(
"The state_dict for toolkit must be a dictionary with "
"active_groups key and its value must be a list, "
f"but got {type(state_dict)}.",
)
if strict and list(state_dict.keys()) != ["active_groups"]:
raise ValueError(
"Get additional keys in the state_dict: "
f'{list(state_dict.keys())}, but only "active_groups" '
"is expected.",
)
for group_name, group in self.groups.items():
if group_name in state_dict["active_groups"]:
group.active = True
else:
group.active = False
def get_activated_notes(self) -> str:
"""Get the notes from the active tool groups, which can be used to
construct the system prompt for the agent.
Returns:
`str`:
The combined notes from the active tool groups.
"""
collected_notes = []
for group_name, group in self.groups.items():
if group.active and group.notes:
collected_notes.append(
"\n".join(
[f"## About {group_name} Tools", group.notes],
),
)
return "\n".join(collected_notes)
def reset_equipped_tools(self, **kwargs: Any) -> ToolResponse:
"""Choose appropriate tools to equip yourself with, so that you can
finish your task. Each argument in this function represents a group
of related tools, and the value indicates whether to activate the
group or not. Besides, the tool response of this function will
contain the precaution notes for using them, which you
**MUST pay attention to and follow**. You can also reuse this function
to check the notes of the tool groups.
Note this function will `reset` the tools, so that the original tools
will be removed first.
"""
to_activate = []
for key, value in kwargs.items():
if not isinstance(value, bool):
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Invalid arguments: the argument {key} "
f"should be a bool value, but got {type(value)}.",
),
],
)
if value:
to_activate.append(key)
self.update_tool_groups(to_activate, active=True)
notes = self.get_activated_notes()
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Active tool groups successfully: {to_activate}. "
"You MUST follow these notes to use the tools:\n"
f"<notes>{notes}</notes>",
),
],
)
def clear(self) -> None:
"""Clear the toolkit, removing all tool functions and groups."""
self.tools.clear()
self.groups.clear()
def _validate_tool_function(self, func_name: str) -> None:
"""Check if the tool function already registered in the toolkit. If
so, raise a ValueError."""
if func_name in self.tools:
raise ValueError(
f"A function with name '{func_name} is already registered "
"in the toolkit.",
)
@staticmethod
def _parse_tool_function(
tool_func: ToolFunction,
include_long_description: bool,
include_var_positional: bool,
include_var_keyword: bool,
) -> dict:
"""Extract JSON schema from the tool function's docstring"""
docstring = parse(tool_func.__doc__)
params_docstring = {
_.arg_name: _.description for _ in docstring.params
}
# Function description
descriptions = []
if docstring.short_description is not None:
descriptions.append(docstring.short_description)
if include_long_description and docstring.long_description is not None:
descriptions.append(docstring.long_description)
func_description = "\n\n".join(descriptions)
# Create a dynamic model with the function signature
fields = {}
for name, param in inspect.signature(tool_func).parameters.items():
# Skip the `self` and `cls` parameters
if name in ["self", "cls"]:
continue
# Handle `**kwargs`
if param.kind == inspect.Parameter.VAR_KEYWORD:
if not include_var_keyword:
continue
fields[name] = (
Dict[str, Any]
if param.annotation == inspect.Parameter.empty
else Dict[str, param.annotation], # type: ignore
Field(
description=params_docstring.get(
f"**{name}",
params_docstring.get(name, None),
),
default={}
if param.default is param.empty
else param.default,
),
)
elif param.kind == inspect.Parameter.VAR_POSITIONAL:
if not include_var_positional:
continue
fields[name] = (
list[Any]
if param.annotation == inspect.Parameter.empty
else list[param.annotation], # type: ignore
Field(
description=params_docstring.get(
f"*{name}",
params_docstring.get(name, None),
),
default=[]
if param.default is param.empty
else param.default,
),
)
else:
fields[name] = (
Any
if param.annotation == inspect.Parameter.empty
else param.annotation,
Field(
description=params_docstring.get(name, None),
default=...
if param.default is param.empty
else param.default,
),
)
base_model = create_model(
"_StructuredOutputDynamicClass",
__config__=ConfigDict(arbitrary_types_allowed=True),
**fields,
)
params_json_schema = base_model.model_json_schema()
# Remove the title from the json schema
_remove_title_field(params_json_schema)
func_json_schema: dict = {
"type": "function",
"function": {
"name": tool_func.__name__,
"parameters": params_json_schema,
},
}
if func_description not in [None, ""]:
func_json_schema["function"]["description"] = func_description
return func_json_schema