chore: 添加虚拟环境到仓库
- 添加 backend_service/venv 虚拟环境 - 包含所有Python依赖包 - 注意:虚拟环境约393MB,包含12655个文件
This commit is contained in:
@@ -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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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>"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)}",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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)}",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
@@ -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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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))
|
||||
@@ -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}```""",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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.",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user