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,24 @@
# -*- coding: utf-8 -*-
"""The agent base class."""
from ._agent_base import AgentBase
from ._react_agent_base import ReActAgentBase
from ._react_agent import ReActAgent
from ._user_input import (
UserInputBase,
UserInputData,
TerminalUserInput,
StudioUserInput,
)
from ._user_agent import UserAgent
__all__ = [
"AgentBase",
"ReActAgentBase",
"ReActAgent",
"UserInputData",
"UserInputBase",
"TerminalUserInput",
"StudioUserInput",
"UserAgent",
]

View File

@@ -0,0 +1,703 @@
# -*- coding: utf-8 -*-
"""The agent base class in agentscope."""
import asyncio
import io
import json
from asyncio import Task, Queue
from collections import OrderedDict
from copy import deepcopy
from typing import Callable, Any
import base64
import shortuuid
import numpy as np
from typing_extensions import deprecated
from ._agent_meta import _AgentMeta
from .._logging import logger
from ..module import StateModule
from ..message import (
Msg,
AudioBlock,
ToolUseBlock,
ToolResultBlock,
ImageBlock,
VideoBlock,
)
from ..types import AgentHookTypes
class AgentBase(StateModule, metaclass=_AgentMeta):
"""Base class for asynchronous agents."""
id: str
"""The agent's unique identifier, generated using shortuuid."""
supported_hook_types: list[str] = [
"pre_reply",
"post_reply",
"pre_print",
"post_print",
"pre_observe",
"post_observe",
]
"""Supported hook types for the agent base class."""
_class_pre_reply_hooks: dict[
str,
Callable[
[
"AgentBase", # self
dict[str, Any], # kwargs
],
dict[str, Any] | None, # The modified kwargs or None
],
] = OrderedDict()
"""The class-level hook functions that will be called before the reply
function, taking `self` object, the input arguments as input, and
generating the modified arguments (if needed). Then input arguments of the
reply function will be re-organized into a keyword arguments dictionary.
If the one hook returns a new dictionary, the modified arguments will be
passed to the next hook or the original reply function."""
_class_post_reply_hooks: dict[
str,
Callable[
[
"AgentBase", # self
dict[str, Any], # kwargs
Msg, # output, the output message
],
Msg | None,
],
] = OrderedDict()
"""The class-level hook functions that will be called after the reply
function, which takes the `self` object and deep copied
positional and keyword arguments (args and kwargs), and the output message
as input. If the hook returns a message, the new message will be passed
to the next hook or the original reply function. Otherwise, the original
output will be passed instead."""
_class_pre_print_hooks: dict[
str,
Callable[
[
"AgentBase", # self
dict[str, Any], # kwargs
],
dict[str, Any] | None, # The modified kwargs or None
],
] = OrderedDict()
"""The class-level hook functions that will be called before printing,
which takes the `self` object, a deep copied arguments dictionary as input,
and output the modified arguments (if needed). """
_class_post_print_hooks: dict[
str,
Callable[
[
"AgentBase", # self
dict[str, Any], # kwargs
Any, # output, `None` if no output
],
Any,
],
] = OrderedDict()
"""The class-level hook functions that will be called after the speak
function, which takes the `self` object as input."""
_class_pre_observe_hooks: dict[
str,
Callable[
[
"AgentBase", # self
dict[str, Any], # kwargs
],
dict[str, Any] | None, # The modified kwargs or None
],
] = OrderedDict()
"""The class-level hook functions that will be called before the observe
function, which takes the `self` object and a deep copied input
arguments dictionary as input. To change the input arguments, the hook
function needs to output the modified arguments dictionary, which will be
used as the input of the next hook function or the original observe
function."""
_class_post_observe_hooks: dict[
str,
Callable[
[
"AgentBase", # self
dict[str, Any], # kwargs
None, # The output, `None` if no output
],
None,
],
] = OrderedDict()
"""The class-level hook functions that will be called after the observe
function, which takes the `self` object as input."""
def __init__(self) -> None:
"""Initialize the agent."""
super().__init__()
self.id = shortuuid.uuid()
# The replying task and identify of the current replying
self._reply_task: Task | None = None
self._reply_id: str | None = None
# Initialize the instance-level hooks
self._instance_pre_print_hooks = OrderedDict()
self._instance_post_print_hooks = OrderedDict()
self._instance_pre_reply_hooks = OrderedDict()
self._instance_post_reply_hooks = OrderedDict()
self._instance_pre_observe_hooks = OrderedDict()
self._instance_post_observe_hooks = OrderedDict()
# The prefix used in streaming printing, which will save the
# accumulated text and audio streaming data for each message id.
# e.g. {"text": "xxx", "audio": (stream_obj, "{base64_data}")}
self._stream_prefix = {}
# The subscribers that will receive the reply message by their
# `observe` method. The key is the MsgHub id, and the value is the
# list of agents.
self._subscribers: dict[str, list[AgentBase]] = {}
# We add this variable in case developers want to disable the console
# output of the agent, e.g., in a production environment.
self._disable_console_output: bool = False
# The streaming message queue used to export the messages as a
# generator
self._disable_msg_queue: bool = True
self.msg_queue = None
async def observe(self, msg: Msg | list[Msg] | None) -> None:
"""Receive the given message(s) without generating a reply.
Args:
msg (`Msg | list[Msg] | None`):
The message(s) to be observed.
"""
raise NotImplementedError(
f"The observe function is not implemented in"
f" {self.__class__.__name__} class.",
)
async def reply(self, *args: Any, **kwargs: Any) -> Msg:
"""The main logic of the agent, which generates a reply based on the
current state and input arguments."""
raise NotImplementedError(
"The reply function is not implemented in "
f"{self.__class__.__name__} class.",
)
async def print(self, msg: Msg, last: bool = True) -> None:
"""The function to display the message.
Args:
msg (`Msg`):
The message object to be printed.
last (`bool`, defaults to `True`):
Whether this is the last one in streaming messages. For
non-streaming message, this should always be `True`.
"""
if not self._disable_msg_queue:
await self.msg_queue.put((deepcopy(msg), last))
if self._disable_console_output:
return
# The accumulated textual content to print, including the text blocks
# and the thinking blocks
thinking_and_text_to_print = []
for block in msg.get_content_blocks():
if block["type"] == "audio":
self._process_audio_block(msg.id, block)
elif block["type"] == "text":
self._print_text_block(
msg.id,
name_prefix=msg.name,
text_content=block["text"],
thinking_and_text_to_print=thinking_and_text_to_print,
)
elif block["type"] == "thinking":
self._print_text_block(
msg.id,
name_prefix=f"{msg.name}(thinking)",
text_content=block["thinking"],
thinking_and_text_to_print=thinking_and_text_to_print,
)
elif last:
self._print_last_block(block, msg)
# Clean up resources if this is the last message in streaming
if last and msg.id in self._stream_prefix:
if "audio" in self._stream_prefix[msg.id]:
player, _ = self._stream_prefix[msg.id]["audio"]
# Close the miniaudio player
player.close()
stream_prefix = self._stream_prefix.pop(msg.id)
if "text" in stream_prefix and not stream_prefix["text"].endswith(
"\n",
):
print()
def _process_audio_block(
self,
msg_id: str,
audio_block: AudioBlock,
) -> None:
"""Process audio block content.
Args:
msg_id (`str`):
The unique identifier of the message
audio_block (`AudioBlock`):
The audio content block
"""
if "source" not in audio_block:
raise ValueError(
"The audio block must contain the 'source' field.",
)
if audio_block["source"]["type"] == "url":
import urllib.request
import wave
import sounddevice as sd
url = audio_block["source"]["url"]
try:
with urllib.request.urlopen(url) as response:
audio_data = response.read()
with wave.open(io.BytesIO(audio_data), "rb") as wf:
samplerate = wf.getframerate()
n_frames = wf.getnframes()
audio_frames = wf.readframes(n_frames)
# Convert byte data to numpy array
audio_np = np.frombuffer(audio_frames, dtype=np.int16)
# Play audio
sd.play(audio_np, samplerate)
sd.wait()
except Exception as e:
logger.error(
"Failed to play audio from url %s: %s",
url,
str(e),
)
elif audio_block["source"]["type"] == "base64":
data = audio_block["source"]["data"]
if msg_id not in self._stream_prefix:
self._stream_prefix[msg_id] = {}
audio_prefix = self._stream_prefix[msg_id].get("audio", None)
import sounddevice as sd
# The player and the prefix data is cached for streaming audio
if audio_prefix:
player, audio_prefix_data = audio_prefix
else:
player = sd.OutputStream(
samplerate=24000,
channels=1,
dtype=np.float32,
blocksize=1024,
latency="low",
)
player.start()
audio_prefix_data = ""
# play the audio data
new_audio_data = data[len(audio_prefix_data) :]
if new_audio_data:
audio_bytes = base64.b64decode(new_audio_data)
audio_np = np.frombuffer(audio_bytes, dtype=np.int16)
audio_float = audio_np.astype(np.float32) / 32768.0
# Write to the audio output stream
player.write(audio_float)
# save the player and the prefix data
self._stream_prefix[msg_id]["audio"] = (
player,
data,
)
else:
raise ValueError(
"Unsupported audio source type: "
f"{audio_block['source']['type']}",
)
def _print_text_block(
self,
msg_id: str,
name_prefix: str,
text_content: str,
thinking_and_text_to_print: list[str],
) -> None:
"""Print the text block and thinking block content.
Args:
msg_id (`str`):
The unique identifier of the message
name_prefix (`str`):
The prefix for the message, e.g. "{name}: " for text block and
"{name}(thinking): " for thinking block.
text_content (`str`):
The textual content to be printed.
thinking_and_text_to_print (`list[str]`):
A list of textual content to be printed together. Here we
gather the text and thinking blocks to print them together.
"""
thinking_and_text_to_print.append(
f"{name_prefix}: {text_content}",
)
# The accumulated text and thinking blocks to print
to_print = "\n".join(thinking_and_text_to_print)
# The text prefix that has been printed
if msg_id not in self._stream_prefix:
self._stream_prefix[msg_id] = {}
text_prefix = self._stream_prefix[msg_id].get("text", "")
# Only print when there is new text content
if len(to_print) > len(text_prefix):
print(to_print[len(text_prefix) :], end="")
# Save the printed text prefix
self._stream_prefix[msg_id]["text"] = to_print
def _print_last_block(
self,
block: ToolUseBlock | ToolResultBlock | ImageBlock | VideoBlock,
msg: Msg,
) -> None:
"""Process and print the last content block, and the block type
is not audio, text, or thinking.
Args:
block (`ToolUseBlock | ToolResultBlock | ImageBlock | VideoBlock`):
The content block to be printed
msg (`Msg`):
The message object
"""
text_prefix = self._stream_prefix.get(msg.id, {}).get("text", "")
if text_prefix:
# Add a newline to separate from previous text content
print_newline = "" if text_prefix.endswith("\n") else "\n"
print(
f"{print_newline}"
f"{json.dumps(block, indent=4, ensure_ascii=False)}",
)
else:
print(
f"{msg.name}:"
f" {json.dumps(block, indent=4, ensure_ascii=False)}",
)
async def __call__(self, *args: Any, **kwargs: Any) -> Msg:
"""Call the reply function with the given arguments."""
self._reply_id = shortuuid.uuid()
reply_msg: Msg | None = None
try:
self._reply_task = asyncio.current_task()
reply_msg = await self.reply(*args, **kwargs)
# The interruption is triggered by calling the interrupt method
except asyncio.CancelledError:
reply_msg = await self.handle_interrupt(*args, **kwargs)
finally:
# Broadcast the reply message to all subscribers
if reply_msg:
await self._broadcast_to_subscribers(reply_msg)
self._reply_task = None
return reply_msg
async def _broadcast_to_subscribers(
self,
msg: Msg | list[Msg] | None,
) -> None:
"""Broadcast the message to all subscribers."""
for subscribers in self._subscribers.values():
for subscriber in subscribers:
await subscriber.observe(msg)
async def handle_interrupt(
self,
*args: Any,
**kwargs: Any,
) -> Msg:
"""The post-processing logic when the reply is interrupted by the
user or something else."""
raise NotImplementedError(
f"The handle_interrupt function is not implemented in "
f"{self.__class__.__name__}",
)
async def interrupt(self, msg: Msg | list[Msg] | None = None) -> None:
"""Interrupt the current reply process."""
if self._reply_task and not self._reply_task.done():
self._reply_task.cancel(msg)
def register_instance_hook(
self,
hook_type: AgentHookTypes,
hook_name: str,
hook: Callable,
) -> None:
"""Register a hook to the agent instance, which only takes effect
for the current instance.
Args:
hook_type (`str`):
The type of the hook, indicating where the hook is to be
triggered.
hook_name (`str`):
The name of the hook. If the name is already registered, the
hook will be overwritten.
hook (`Callable`):
The hook function.
"""
if not isinstance(self, AgentBase):
raise TypeError(
"The register_instance_hook method should be called on an "
f"instance of AsyncAgentBase, but got {self} of "
f"type {type(self)}.",
)
hooks = getattr(self, f"_instance_{hook_type}_hooks")
hooks[hook_name] = hook
def remove_instance_hook(
self,
hook_type: AgentHookTypes,
hook_name: str,
) -> None:
"""Remove an instance-level hook from the agent instance.
Args:
hook_type (`AgentHookTypes`):
The type of the hook, indicating where the hook is to be
triggered.
hook_name (`str`):
The name of the hook to remove.
"""
if not isinstance(self, AgentBase):
raise TypeError(
"The remove_instance_hook method should be called on an "
f"instance of AsyncAgentBase, but got {self} of "
f"type {type(self)}.",
)
hooks = getattr(self, f"_instance_{hook_type}_hooks")
if hook_name in hooks:
del hooks[hook_name]
else:
raise ValueError(
f"Hook '{hook_name}' not found in '{hook_type}' hooks of "
f"{self.__class__.__name__} instance.",
)
@classmethod
def register_class_hook(
cls,
hook_type: AgentHookTypes,
hook_name: str,
hook: Callable,
) -> None:
"""The universal function to register a hook to the agent class, which
will take effect for all instances of the class.
Args:
hook_type (`AgentHookTypes`):
The type of the hook, indicating where the hook is to be
triggered.
hook_name (`str`):
The name of the hook. If the name is already registered, the
hook will be overwritten.
hook (`Callable`):
The hook function.
"""
assert (
hook_type in cls.supported_hook_types
), f"Invalid hook type: {hook_type}"
hooks = getattr(cls, f"_class_{hook_type}_hooks")
hooks[hook_name] = hook
@classmethod
def remove_class_hook(
cls,
hook_type: AgentHookTypes,
hook_name: str,
) -> None:
"""Remove a class-level hook from the agent class.
Args:
hook_type (`AgentHookTypes`):
The type of the hook, indicating where the hook is to be
triggered.
hook_name (`str`):
The name of the hook to remove.
"""
assert (
hook_type in cls.supported_hook_types
), f"Invalid hook type: {hook_type}"
hooks = getattr(cls, f"_class_{hook_type}_hooks")
if hook_name in hooks:
del hooks[hook_name]
else:
raise ValueError(
f"Hook '{hook_name}' not found in '{hook_type}' hooks of "
f"{cls.__name__} class.",
)
@classmethod
def clear_class_hooks(
cls,
hook_type: AgentHookTypes | None = None,
) -> None:
"""Clear all class-level hooks.
Args:
hook_type (`AgentHookTypes`, optional):
The type of the hook to clear. If not specified, all
class-level hooks will be cleared.
"""
if hook_type is None:
for typ in cls.supported_hook_types:
hooks = getattr(cls, f"_class_{typ}_hooks")
hooks.clear()
else:
assert (
hook_type in cls.supported_hook_types
), f"Invalid hook type: {hook_type}"
hooks = getattr(cls, f"_class_{hook_type}_hooks")
hooks.clear()
def clear_instance_hooks(
self,
hook_type: AgentHookTypes | None = None,
) -> None:
"""If `hook_type` is not specified, clear all instance-level hooks.
Otherwise, clear the specified type of instance-level hooks."""
if hook_type is None:
for typ in self.supported_hook_types:
if not hasattr(self, f"_instance_{typ}_hooks"):
raise ValueError(
f"Call super().__init__() in the constructor "
f"to initialize the instance-level hooks for "
f"{self.__class__.__name__}.",
)
hooks = getattr(self, f"_instance_{typ}_hooks")
hooks.clear()
else:
assert (
hook_type in self.supported_hook_types
), f"Invalid hook type: {hook_type}"
if not hasattr(self, f"_instance_{hook_type}_hooks"):
raise ValueError(
f"Call super().__init__() in the constructor "
f"to initialize the instance-level hooks for "
f"{self.__class__.__name__}.",
)
hooks = getattr(self, f"_instance_{hook_type}_hooks")
hooks.clear()
def reset_subscribers(
self,
msghub_name: str,
subscribers: list["AgentBase"],
) -> None:
"""Reset the subscribers of the agent.
Args:
msghub_name (`str`):
The name of the MsgHub that manages the subscribers.
subscribers (`list[AgentBase]`):
A list of agents that will receive the reply message from
this agent via their `observe` method.
"""
self._subscribers[msghub_name] = [_ for _ in subscribers if _ != self]
def remove_subscribers(self, msghub_name: str) -> None:
"""Remove the msghub subscribers by the given msg hub name.
Args:
msghub_name (`str`):
The name of the MsgHub that manages the subscribers.
"""
if msghub_name not in self._subscribers:
logger.warning(
"MsgHub named '%s' not found",
msghub_name,
)
else:
self._subscribers.pop(msghub_name)
@deprecated("Please use set_console_output_enabled() instead.")
def disable_console_output(self) -> None:
"""This function will disable the console output of the agent, e.g.
in a production environment to avoid messy logs."""
self._disable_console_output = True
def set_console_output_enabled(self, enabled: bool) -> None:
"""Enable or disable the console output of the agent. E.g. in a
production environment, you may want to disable the console output to
avoid messy logs.
Args:
enabled (`bool`):
If `True`, enable the console output. If `False`, disable
the console output.
"""
self._disable_console_output = not enabled
def set_msg_queue_enabled(
self,
enabled: bool,
queue: Queue | None = None,
) -> None:
"""Enable or disable the message queue for streaming outputs.
Args:
enabled (`bool`):
If `True`, enable the message queue to allow streaming
outputs. If `False`, disable the message queue.
queue (`Queue | None`, optional):
The queue instance that will be used to initialize the
message queue when `enable` is `True`.
"""
if enabled:
if queue is None:
if self.msg_queue is None:
self.msg_queue = asyncio.Queue(maxsize=100)
else:
self.msg_queue = queue
else:
self.msg_queue = None
self._disable_msg_queue = not enabled

View File

@@ -0,0 +1,180 @@
# -*- coding: utf-8 -*-
"""The metaclass for agents in agentscope."""
import inspect
from copy import deepcopy
from functools import wraps
from typing import (
Any,
Dict,
TYPE_CHECKING,
Callable,
)
from .._utils._common import _execute_async_or_sync_func
if TYPE_CHECKING:
from ._agent_base import AgentBase
else:
AgentBase = "AgentBase"
def _normalize_to_kwargs(
func: Callable,
self: Any,
*args: Any,
**kwargs: Any,
) -> dict:
"""Normalize the provided positional and keyword arguments into a
keyword arguments dictionary that matches the function signature."""
sig = inspect.signature(func)
try:
# Bind the provided arguments to the function signature
bound = sig.bind(self, *args, **kwargs)
# Apply the default values for parameters
bound.apply_defaults()
# Return the arguments in a dictionary format
res = dict(bound.arguments)
res.pop("self")
return res
except TypeError as e:
# If failed to bind, we raise a TypeError with more context
param_names = list(sig.parameters.keys())
provided_args = len(args)
provided_kwargs = list(kwargs.keys())
raise TypeError(
f"Failed to bind parameters for function '{func.__name__}': {e}\n"
f"Expected parameters: {param_names}\n"
f"Provided {provided_args} positional args and kwargs: "
f"{provided_kwargs}",
) from e
def _wrap_with_hooks(
original_func: Callable,
) -> Callable:
"""A decorator to wrap the original async function with pre- and post-hooks
Args:
original_func (`Callable`):
The original async function to be wrapped with hooks.
"""
func_name = original_func.__name__.replace("_", "")
@wraps(original_func)
async def async_wrapper(
self: AgentBase,
*args: Any,
**kwargs: Any,
) -> Any:
"""The wrapped function, which call the pre- and post-hooks before and
after the original function."""
# Unify all positional and keyword arguments into a keyword arguments
normalized_kwargs = _normalize_to_kwargs(
original_func,
self,
*args,
**kwargs,
)
current_normalized_kwargs = normalized_kwargs
assert (
hasattr(self, f"_instance_pre_{func_name}_hooks")
and hasattr(self, f"_instance_post_{func_name}_hooks")
and hasattr(self.__class__, f"_class_pre_{func_name}_hooks")
and hasattr(self.__class__, f"_class_post_{func_name}_hooks")
), f"Hooks for {func_name} not found in {self.__class__.__name__}"
# pre-hooks
pre_hooks = list(
getattr(self, f"_instance_pre_{func_name}_hooks").values(),
) + list(
getattr(self, f"_class_pre_{func_name}_hooks").values(),
)
for pre_hook in pre_hooks:
modified_keywords = await _execute_async_or_sync_func(
pre_hook,
self,
deepcopy(current_normalized_kwargs),
)
if modified_keywords is not None:
assert isinstance(modified_keywords, dict), (
f"Pre-hook must return a dict of keyword arguments, rather"
f" than {type(modified_keywords)} from hook "
f"{pre_hook.__name__}"
)
current_normalized_kwargs = modified_keywords
# original function
# handle positional and keyword arguments specifically
args = current_normalized_kwargs.get("args", [])
kwargs = current_normalized_kwargs.get("kwargs", {})
others = {
k: v
for k, v in current_normalized_kwargs.items()
if k not in ["args", "kwargs"]
}
current_output = await original_func(
self,
*args,
**others,
**kwargs,
)
# post_hooks
post_hooks = list(
getattr(self, f"_instance_post_{func_name}_hooks").values(),
) + list(
getattr(self, f"_class_post_{func_name}_hooks").values(),
)
for post_hook in post_hooks:
modified_output = await _execute_async_or_sync_func(
post_hook,
self,
deepcopy(current_normalized_kwargs),
deepcopy(current_output),
)
if modified_output is not None:
current_output = modified_output
return current_output
return async_wrapper
class _AgentMeta(type):
"""The agent metaclass that wraps the agent's reply, observe and print
functions with pre- and post-hooks."""
def __new__(mcs, name: Any, bases: Any, attrs: Dict) -> Any:
"""Wrap the agent's functions with hooks."""
for func_name in [
"reply",
"print",
"observe",
]:
if func_name in attrs:
attrs[func_name] = _wrap_with_hooks(attrs[func_name])
return super().__new__(mcs, name, bases, attrs)
class _ReActAgentMeta(_AgentMeta):
"""The ReAct metaclass that adds pre- and post-hooks for the _reasoning
and _acting functions."""
def __new__(mcs, name: Any, bases: Any, attrs: Dict) -> Any:
"""Wrap the ReAct agent's _reasoning and _acting functions with
hooks."""
for func_name in [
"_reasoning",
"_acting",
]:
if func_name in attrs:
attrs[func_name] = _wrap_with_hooks(attrs[func_name])
return super().__new__(mcs, name, bases, attrs)

View File

@@ -0,0 +1,767 @@
# -*- coding: utf-8 -*-
# pylint: disable=not-an-iterable
# mypy: disable-error-code="list-item"
"""ReAct agent class in agentscope."""
import asyncio
from typing import Type, Any, AsyncGenerator, Literal
import shortuuid
from pydantic import BaseModel, ValidationError, Field
from ._react_agent_base import ReActAgentBase
from .._logging import logger
from ..formatter import FormatterBase
from ..memory import MemoryBase, LongTermMemoryBase, InMemoryMemory
from ..message import Msg, ToolUseBlock, ToolResultBlock, TextBlock
from ..model import ChatModelBase
from ..rag import KnowledgeBase, Document
from ..plan import PlanNotebook
from ..tool import Toolkit, ToolResponse
from ..tracing import trace_reply
class _QueryRewriteModel(BaseModel):
"""The structured model used for query rewriting."""
rewritten_query: str = Field(
description=(
"The rewritten query, which should be specific and concise. "
),
)
def finish_function_pre_print_hook(
self: "ReActAgent",
kwargs: dict[str, Any],
) -> dict[str, Any] | None:
"""A pre-speak hook function that check if finish_function is called. If
so, it will wrap the response argument into a message and return it to
replace the original message. By this way, the calling of the finish
function will be displayed as a text reply instead of a tool call."""
msg = kwargs["msg"]
if isinstance(msg.content, str):
return None
if isinstance(msg.content, list):
for i, block in enumerate(msg.content):
if (
block["type"] == "tool_use"
and block["name"] == self.finish_function_name
):
# Convert the response argument into a text block for
# displaying
try:
msg.content[i] = TextBlock(
type="text",
text=block["input"].get("response", ""),
)
return kwargs
except Exception:
print("Error in block input", block["input"])
return None
class ReActAgent(ReActAgentBase):
"""A ReAct agent implementation in AgentScope, which supports
- Realtime steering
- API-based (parallel) tool calling
- Hooks around reasoning, acting, reply, observe and print functions
- Structured output generation
"""
finish_function_name: str = "generate_response"
"""The function name used to finish replying and return a response to
the user."""
def __init__(
self,
name: str,
sys_prompt: str,
model: ChatModelBase,
formatter: FormatterBase,
toolkit: Toolkit | None = None,
memory: MemoryBase | None = None,
long_term_memory: LongTermMemoryBase | None = None,
long_term_memory_mode: Literal[
"agent_control",
"static_control",
"both",
] = "both",
enable_meta_tool: bool = False,
parallel_tool_calls: bool = False,
knowledge: KnowledgeBase | list[KnowledgeBase] | None = None,
enable_rewrite_query: bool = True,
plan_notebook: PlanNotebook | None = None,
print_hint_msg: bool = False,
max_iters: int = 10,
) -> None:
"""Initialize the ReAct agent
Args:
name (`str`):
The name of the agent.
sys_prompt (`str`):
The system prompt of the agent.
model (`ChatModelBase`):
The chat model used by the agent.
formatter (`FormatterBase`):
The formatter used to format the messages into the required
format of the model API provider.
toolkit (`Toolkit | None`, optional):
A `Toolkit` object that contains the tool functions. If not
provided, a default empty `Toolkit` will be created.
memory (`MemoryBase | None`, optional):
The memory used to store the dialogue history. If not provided,
a default `InMemoryMemory` will be created, which stores
messages in a list in memory.
long_term_memory (`LongTermMemoryBase | None`, optional):
The optional long-term memory, which will provide two tool
functions: `retrieve_from_memory` and `record_to_memory`, and
will attach the retrieved information to the system prompt
before each reply.
enable_meta_tool (`bool`, defaults to `False`):
If `True`, a meta tool function `reset_equipped_tools` will be
added to the toolkit, which allows the agent to manage its
equipped tools dynamically.
long_term_memory_mode (`Literal['agent_control', 'static_control',\
'both']`, defaults to `both`):
The mode of the long-term memory. If `agent_control`, two
tool functions `retrieve_from_memory` and `record_to_memory`
will be registered in the toolkit to allow the agent to
manage the long-term memory. If `static_control`, retrieving
and recording will happen in the beginning and end of
each reply respectively.
parallel_tool_calls (`bool`, defaults to `False`):
When LLM generates multiple tool calls, whether to execute
them in parallel.
knowledge (`KnowledgeBase | list[KnowledgeBase] | None`, optional):
The knowledge object(s) used by the agent to retrieve
relevant documents at the beginning of each reply.
enable_rewrite_query (`bool`, defaults to `True`):
Whether ask the agent to rewrite the user input query before
retrieving from the knowledge base(s), e.g. rewrite "Who am I"
to "{user's name}" to get more relevant documents. Only works
when the knowledge base(s) is provided.
plan_notebook (`PlanNotebook | None`, optional):
The plan notebook instance, allow the agent to finish the
complex task by decomposing it into a sequence of subtasks.
print_hint_msg (`bool`, defaults to `False`):
Whether to print the hint messages, including the reasoning
hint from the plan notebook, the retrieved information from
the long-term memory and knowledge base(s).
max_iters (`int`, defaults to `10`):
The maximum number of iterations of the reasoning-acting loops.
"""
super().__init__()
assert long_term_memory_mode in [
"agent_control",
"static_control",
"both",
]
# Static variables in the agent
self.name = name
self._sys_prompt = sys_prompt
self.max_iters = max_iters
self.model = model
self.formatter = formatter
# -------------- Memory management --------------
# Record the dialogue history in the memory
self.memory = memory or InMemoryMemory()
# If provide the long-term memory, it will be used to retrieve info
# in the beginning of each reply, and the result will be added to the
# system prompt
self.long_term_memory = long_term_memory
# The long-term memory mode
self._static_control = long_term_memory and long_term_memory_mode in [
"static_control",
"both",
]
self._agent_control = long_term_memory and long_term_memory_mode in [
"agent_control",
"both",
]
# -------------- Tool management --------------
# If None, a default Toolkit will be created
self.toolkit = toolkit or Toolkit()
self.toolkit.register_tool_function(
getattr(self, self.finish_function_name),
)
if self._agent_control:
# Adding two tool functions into the toolkit to allow self-control
self.toolkit.register_tool_function(
long_term_memory.record_to_memory,
)
self.toolkit.register_tool_function(
long_term_memory.retrieve_from_memory,
)
# Add a meta tool function to allow agent-controlled tool management
if enable_meta_tool or plan_notebook:
self.toolkit.register_tool_function(
self.toolkit.reset_equipped_tools,
)
self.parallel_tool_calls = parallel_tool_calls
# -------------- RAG management --------------
# The knowledge base(s) used by the agent
if isinstance(knowledge, KnowledgeBase):
knowledge = [knowledge]
self.knowledge: list[KnowledgeBase] = knowledge or []
self.enable_rewrite_query = enable_rewrite_query
# -------------- Plan management --------------
# Equipped the plan-related tools provided by the plan notebook as
# a tool group named "plan_related". So that the agent can activate
# the plan tools by the meta tool function
self.plan_notebook = None
if plan_notebook:
self.plan_notebook = plan_notebook
# When enable_meta_tool is True, plan tools are in plan_related
# group and active by agent.
# Otherwise, plan tools in bassic group and always active.
if enable_meta_tool:
self.toolkit.create_tool_group(
"plan_related",
description=self.plan_notebook.description,
)
for tool in plan_notebook.list_tools():
self.toolkit.register_tool_function(
tool,
group_name="plan_related",
)
else:
for tool in plan_notebook.list_tools():
self.toolkit.register_tool_function(
tool,
)
# If print the reasoning hint messages
self.print_hint_msg = print_hint_msg
# The maximum number of iterations of the reasoning-acting loops
self.max_iters = max_iters
# The hint messages that will be attached to the prompt to guide the
# agent's behavior before each reasoning step, and cleared after
# each reasoning step, meaning the hint messages is one-time use only.
# We use an InMemoryMemory instance to store the hint messages
self._reasoning_hint_msgs = InMemoryMemory()
# Variables to record the intermediate state
# If required structured output model is provided
self._required_structured_model: Type[BaseModel] | None = None
# -------------- State registration and hooks --------------
# Register the status variables
self.register_state("name")
self.register_state("_sys_prompt")
self.register_instance_hook(
"pre_print",
"finish_function_pre_print_hook",
finish_function_pre_print_hook,
)
@property
def sys_prompt(self) -> str:
"""The dynamic system prompt of the agent."""
return self._sys_prompt
@trace_reply
async def reply(
self,
msg: Msg | list[Msg] | None = None,
structured_model: Type[BaseModel] | None = None,
) -> Msg:
"""Generate a reply based on the current state and input arguments.
Args:
msg (`Msg | list[Msg] | None`, optional):
The input message(s) to the agent.
structured_model (`Type[BaseModel] | None`, optional):
The required structured output model. If provided, the agent
is expected to generate structured output in the `metadata`
field of the output message.
Returns:
`Msg`:
The output message generated by the agent.
"""
# Record the input message(s) in the memory
await self.memory.add(msg)
# Retrieve relevant records from the long-term memory if activated
await self._retrieve_from_long_term_memory(msg)
# Retrieve relevant documents from the knowledge base(s) if any
await self._retrieve_from_knowledge(msg)
self._required_structured_model = structured_model
# Record structured output model if provided
if structured_model:
self.toolkit.set_extended_model(
self.finish_function_name,
structured_model,
)
# The reasoning-acting loop
reply_msg = None
for _ in range(self.max_iters):
msg_reasoning = await self._reasoning()
futures = [
self._acting(tool_call)
for tool_call in msg_reasoning.get_content_blocks(
"tool_use",
)
]
# Parallel tool calls or not
if self.parallel_tool_calls:
acting_responses = await asyncio.gather(*futures)
else:
# Sequential tool calls
acting_responses = [await _ for _ in futures]
# Find the first non-None replying message from the acting
for acting_msg in acting_responses:
reply_msg = reply_msg or acting_msg
if reply_msg:
break
# When the maximum iterations are reached
if reply_msg is None:
reply_msg = await self._summarizing()
# Post-process the memory, long-term memory
if self._static_control:
await self.long_term_memory.record(
[
*([*msg] if isinstance(msg, list) else [msg]),
*await self.memory.get_memory(),
reply_msg,
],
)
await self.memory.add(reply_msg)
return reply_msg
async def _reasoning(
self,
) -> Msg:
"""Perform the reasoning process."""
if self.plan_notebook:
# Insert the reasoning hint from the plan notebook
hint_msg = await self.plan_notebook.get_current_hint()
if self.print_hint_msg and hint_msg:
await self.print(hint_msg)
await self._reasoning_hint_msgs.add(hint_msg)
# Convert Msg objects into the required format of the model API
prompt = await self.formatter.format(
msgs=[
Msg("system", self.sys_prompt, "system"),
*await self.memory.get_memory(),
# The hint messages to guide the agent's behavior, maybe empty
*await self._reasoning_hint_msgs.get_memory(),
],
)
# Clear the hint messages after use
await self._reasoning_hint_msgs.clear()
res = await self.model(
prompt,
tools=self.toolkit.get_json_schemas(),
)
# handle output from the model
interrupted_by_user = False
msg = None
try:
if self.model.stream:
msg = Msg(self.name, [], "assistant")
async for content_chunk in res:
msg.content = content_chunk.content
await self.print(msg, False)
await self.print(msg, True)
# Add a tiny sleep to yield the last message object in the
# message queue
await asyncio.sleep(0.001)
else:
msg = Msg(self.name, list(res.content), "assistant")
await self.print(msg, True)
except asyncio.CancelledError as e:
interrupted_by_user = True
raise e from None
finally:
if msg and not msg.has_content_blocks("tool_use"):
# Turn plain text response into a tool call of the finish
# function
msg = Msg.from_dict(msg.to_dict())
msg.content = [
ToolUseBlock(
id=shortuuid.uuid(),
type="tool_use",
name=self.finish_function_name,
input={"response": msg.get_text_content()},
),
]
# None will be ignored by the memory
await self.memory.add(msg)
# Post-process for user interruption
if interrupted_by_user and msg:
# Fake tool results
tool_use_blocks: list = msg.get_content_blocks(
"tool_use",
)
for tool_call in tool_use_blocks:
msg_res = Msg(
"system",
[
ToolResultBlock(
type="tool_result",
id=tool_call["id"],
name=tool_call["name"],
output="The tool call has been interrupted "
"by the user.",
),
],
"system",
)
await self.memory.add(msg_res)
await self.print(msg_res, True)
return msg
async def _acting(self, tool_call: ToolUseBlock) -> Msg | None:
"""Perform the acting process.
Args:
tool_call (`ToolUseBlock`):
The tool use block to be executed.
Returns:
`Union[Msg, None]`:
Return a message to the user if the `finish_function` is
called, otherwise return `None`.
"""
tool_res_msg = Msg(
"system",
[
ToolResultBlock(
type="tool_result",
id=tool_call["id"],
name=tool_call["name"],
output=[],
),
],
"system",
)
try:
# Execute the tool call
tool_res = await self.toolkit.call_tool_function(tool_call)
response_msg = None
# Async generator handling
async for chunk in tool_res:
# Turn into a tool result block
tool_res_msg.content[0][ # type: ignore[index]
"output"
] = chunk.content
# Skip the printing of the finish function call
if (
tool_call["name"] != self.finish_function_name
or tool_call["name"] == self.finish_function_name
and (
chunk.metadata is None
or not chunk.metadata.get("success")
)
):
await self.print(tool_res_msg, chunk.is_last)
# Raise the CancelledError to handle the interruption in the
# handle_interrupt function
if chunk.is_interrupted:
raise asyncio.CancelledError()
# Return message if generate_response is called successfully
if (
tool_call["name"] == self.finish_function_name
and chunk.metadata
and chunk.metadata.get(
"success",
True,
)
):
response_msg = chunk.metadata.get("response_msg")
return response_msg
finally:
# Record the tool result message in the memory
await self.memory.add(tool_res_msg)
async def observe(self, msg: Msg | list[Msg] | None) -> None:
"""Receive observing message(s) without generating a reply.
Args:
msg (`Msg | list[Msg] | None`):
The message or messages to be observed.
"""
await self.memory.add(msg)
async def _summarizing(self) -> Msg:
"""Generate a response when the agent fails to solve the problem in
the maximum iterations."""
hint_msg = Msg(
"user",
"You have failed to generate response within the maximum "
"iterations. Now respond directly by summarizing the current "
"situation.",
role="user",
)
# Generate a reply by summarizing the current situation
prompt = await self.formatter.format(
[
Msg("system", self.sys_prompt, "system"),
*await self.memory.get_memory(),
hint_msg,
],
)
# TODO: handle the structured output here, maybe force calling the
# finish_function here
res = await self.model(prompt)
res_msg = Msg(self.name, [], "assistant")
if isinstance(res, AsyncGenerator):
async for chunk in res:
res_msg.content = chunk.content
await self.print(res_msg, False)
await self.print(res_msg, True)
else:
res_msg.content = res.content
await self.print(res_msg, True)
return res_msg
async def handle_interrupt(
self,
_msg: Msg | list[Msg] | None = None,
) -> Msg:
"""The post-processing logic when the reply is interrupted by the
user or something else."""
response_msg = Msg(
self.name,
"I noticed that you have interrupted me. What can I "
"do for you?",
"assistant",
metadata={
# Expose this field to indicate the interruption
"is_interrupted": True,
},
)
await self.print(response_msg, True)
await self.memory.add(response_msg)
return response_msg
def generate_response(
self,
response: str,
**kwargs: Any,
) -> ToolResponse:
"""Generate a response. Note only the input argument `response` is
visible to the others, you should include all the necessary
information in the `response` argument.
Args:
response (`str`):
Your response to the user.
"""
response_msg = Msg(
self.name,
response,
"assistant",
)
# Prepare structured output
if self._required_structured_model:
try:
# Use the metadata field of the message to store the
# structured output
response_msg.metadata = (
self._required_structured_model.model_validate(
kwargs,
).model_dump()
)
except ValidationError as e:
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"Arguments Validation Error: {e}",
),
],
metadata={
"success": False,
"response_msg": None,
},
)
return ToolResponse(
content=[
TextBlock(
type="text",
text="Successfully generated response.",
),
],
metadata={
"success": True,
"response_msg": response_msg,
},
is_last=True,
)
async def _retrieve_from_long_term_memory(
self,
msg: Msg | list[Msg] | None,
) -> None:
"""Insert the retrieved information from the long-term memory into
the short-term memory as a Msg object.
Args:
msg (`Msg | list[Msg] | None`):
The input message to the agent.
"""
if self._static_control and msg:
# Retrieve information from the long-term memory if available
retrieved_info = await self.long_term_memory.retrieve(msg)
if retrieved_info:
retrieved_msg = Msg(
name="long_term_memory",
content="<long_term_memory>The content below are "
"retrieved from long-term memory, which maybe "
f"useful:\n{retrieved_info}</long_term_memory>",
role="user",
)
if self.print_hint_msg:
await self.print(retrieved_msg, True)
await self.memory.add(retrieved_msg)
async def _retrieve_from_knowledge(
self,
msg: Msg | list[Msg] | None,
) -> None:
"""Insert the retrieved documents from the RAG knowledge base(s) if
available.
Args:
msg (`Msg | list[Msg] | None`):
The input message to the agent.
"""
if self.knowledge and msg:
# Prepare the user input query
query = None
if isinstance(msg, Msg):
query = msg.get_text_content()
elif isinstance(msg, list):
query = "\n".join(_.get_text_content() for _ in msg)
# Skip if the query is empty
if not query:
return
# Rewrite the query by the LLM if enabled
if self.enable_rewrite_query:
try:
rewrite_prompt = await self.formatter.format(
msgs=[
Msg("system", self.sys_prompt, "system"),
*await self.memory.get_memory(),
Msg(
"user",
"<system-hint>Now you need to rewrite "
"the above user query to be more specific and "
"concise for knowledge retrieval. For "
"example, rewrite the query 'what happened "
"last day' to 'what happened on 2023-10-01' "
"(assuming today is 2023-10-02)."
"</system-hint>",
"user",
),
],
)
stream_tmp = self.model.stream
self.model.stream = False
res = await self.model(
rewrite_prompt,
structured_model=_QueryRewriteModel,
)
self.model.stream = stream_tmp
if res.metadata and res.metadata.get("rewritten_query"):
query = res.metadata["rewritten_query"]
except Exception as e:
logger.warning(
"Skipping the query rewriting due to error: %s",
str(e),
)
docs: list[Document] = []
for kb in self.knowledge:
# retrieve the user input query
docs.extend(
await kb.retrieve(query=query),
)
if docs:
# Rerank by the relevance score
docs = sorted(
docs,
key=lambda doc: doc.score or 0.0,
reverse=True,
)
# Prepare the retrieved knowledge string
retrieved_msg = Msg(
name="user",
content=[
TextBlock(
type="text",
text=(
"<retrieved_knowledge>Use the following "
"content from the knowledge base(s) if it's "
"helpful:\n"
),
),
*[_.metadata.content for _ in docs],
TextBlock(
type="text",
text="</retrieved_knowledge>",
),
],
role="user",
)
if self.print_hint_msg:
await self.print(retrieved_msg, True)
await self.memory.add(retrieved_msg)

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""The base class for ReAct agent in agentscope."""
from abc import abstractmethod
from collections import OrderedDict
from typing import Callable, Any
from ._agent_base import AgentBase
from ._agent_meta import _ReActAgentMeta
from ..message import Msg
class ReActAgentBase(AgentBase, metaclass=_ReActAgentMeta):
"""The ReAct agent base class.
To support ReAct algorithm, this class extends the AgentBase class by
adding two abstract interfaces: reasoning and acting, while supporting
hook functions at four positions: pre-reasoning, post-reasoning,
pre-acting, and post-acting by the `_ReActAgentMeta` metaclass.
"""
supported_hook_types: list[str] = [
"pre_reply",
"post_reply",
"pre_print",
"post_print",
"pre_observe",
"post_observe",
"pre_reasoning",
"post_reasoning",
"pre_acting",
"post_acting",
]
"""Supported hook types for the agent base class."""
_class_pre_reasoning_hooks: dict[
str,
Callable[
[
"ReActAgentBase", # self
dict[str, Any], # kwargs
],
dict[str, Any] | None, # The modified kwargs or None
],
] = OrderedDict()
"""The class-level pre-reasoning hooks, taking `self` object, the input
arguments as input"""
_class_post_reasoning_hooks: dict[
str,
Callable[
[
"ReActAgentBase", # self
dict[str, Any], # kwargs
Any, # output
],
Msg | None, # the modified output message or None
],
] = OrderedDict()
"""The class-level post-reasoning hooks, taking `self` object, the input
arguments and the output message as input, and return the modified output
message or None if no modification is needed."""
_class_pre_acting_hooks: dict[
str,
Callable[
[
"ReActAgentBase", # self
dict[str, Any], # kwargs
],
dict[str, Any] | None, # The modified kwargs or None
],
] = OrderedDict()
"""The class-level pre-acting hooks, taking `self` object, the input
arguments as input, and return the modified input arguments or None if no
modification is needed."""
_class_post_acting_hooks: dict[
str,
Callable[
[
"ReActAgentBase", # self
dict[str, Any], # kwargs
Any, # output
],
Msg | None, # the modified output message or None
],
] = OrderedDict()
"""The class-level post-acting hooks, taking `self` object, the input
arguments and the output message as input, and return the modified output
message or None if no modification is needed."""
def __init__(
self,
) -> None:
"""Initialize the ReAct agent base class."""
super().__init__()
# Init reasoning and acting hooks
self._instance_pre_reasoning_hooks = OrderedDict()
self._instance_post_reasoning_hooks = OrderedDict()
self._instance_pre_acting_hooks = OrderedDict()
self._instance_post_acting_hooks = OrderedDict()
@abstractmethod
async def _reasoning(
self,
*args: Any,
**kwargs: Any,
) -> Any:
"""The reasoning process of the ReAct agent, which will be wrapped
with pre- and post-hooks."""
@abstractmethod
async def _acting(self, *args: Any, **kwargs: Any) -> Any:
"""The acting process of the ReAct agent, which will be wrapped with
pre- and post-hooks."""

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""The user agent class."""
from typing import Type, Any
from pydantic import BaseModel
from ._agent_base import AgentBase
from ._user_input import UserInputBase, TerminalUserInput
from ..message import Msg
class UserAgent(AgentBase):
"""The class for user interaction, allowing developers to handle the user
input from different sources, such as web UI, cli, and other interfaces.
"""
_input_method: UserInputBase = TerminalUserInput()
"""The user input method, can be overridden by calling the
`register_instance/class_input_method` function."""
def __init__(
self,
name: str,
) -> None:
"""Initialize the user agent with a name."""
super().__init__()
self.name = name
async def reply(
self,
msg: Msg | list[Msg] | None = None,
structured_model: Type[BaseModel] | None = None,
) -> Msg:
"""Receive input message(s) and generate a reply message from the user.
Args:
msg (`Msg | list[Msg] | None`, defaults to `None`):
The message(s) to be replied. If `None`, the agent will wait
for user input.
structured_model (`Type[BaseModel] | None`, defaults to `None`):
A child class of `pydantic.BaseModel` that defines the
structured output format. If provided, the user will be
prompted to fill in the required fields.
Returns:
`Msg`:
The reply message generated by the user.
"""
# Get the input from the specified input method.
input_data = await self._input_method(
agent_id=self.id,
agent_name=self.name,
structured_model=structured_model,
)
blocks_input = input_data.blocks_input
if (
blocks_input
and len(blocks_input) == 1
and blocks_input[0].get("type") == "text"
):
# Turn blocks_input into a string if only one text block exists
blocks_input = blocks_input[0].get("text")
msg = Msg(
self.name,
content=blocks_input,
role="user",
metadata=input_data.structured_input,
)
await self.print(msg)
return msg
def override_instance_input_method(
self,
input_method: UserInputBase,
) -> None:
"""Override the input method of the current UserAgent instance.
Args:
input_method (`UserInputBase`):
The callable input method, which should be an object of a
class that inherits from `UserInputBase`.
"""
if not isinstance(input_method, UserInputBase):
raise ValueError(
f"The input method should be an instance of the child class "
f"of `UserInputBase`, but got {type(input_method)} instead.",
)
self._input_method = input_method
@classmethod
def override_class_input_method(
cls,
input_method: UserInputBase,
) -> None:
"""Override the input method of the current UserAgent class.
Args:
input_method (`UserInputBase`):
The callable input method, which should be an object of a
class that inherits from `UserInputBase`.
"""
if not isinstance(input_method, UserInputBase):
raise ValueError(
f"The input method should be an instance of the child class "
f"of `UserInputBase`, but got {type(input_method)} instead.",
)
cls._input_method = input_method
async def handle_interrupt(
self,
*args: Any,
**kwargs: Any,
) -> Msg:
"""The post-processing logic when the reply is interrupted by the
user or something else."""
raise NotImplementedError(
f"The handle_interrupt function is not implemented in "
f"{self.__class__.__name__}",
)
async def observe(self, msg: Msg | list[Msg] | None) -> None:
"""Observe the message(s) from the other agents or the environment."""

View File

@@ -0,0 +1,411 @@
# -*- coding: utf-8 -*-
"""The user input related classes."""
import json.decoder
import time
from abc import abstractmethod
from dataclasses import dataclass
from queue import Queue
from threading import Event
from typing import Any, Type, List
import jsonschema
import requests
import shortuuid
import socketio
from pydantic import BaseModel
import json5
from .. import _config
from .._logging import logger
from ..message import (
TextBlock,
VideoBlock,
AudioBlock,
ImageBlock,
)
@dataclass
class UserInputData:
"""The user input data."""
blocks_input: List[TextBlock | ImageBlock | AudioBlock | VideoBlock] = None
"""The text input from the user"""
structured_input: dict[str, Any] | None = None
"""The structured input from the user"""
class UserInputBase:
"""The base class used to handle the user input from different sources."""
@abstractmethod
async def __call__(
self,
agent_id: str,
agent_name: str,
*args: Any,
structured_model: Type[BaseModel] | None = None,
**kwargs: Any,
) -> UserInputData:
"""The user input method, which returns the user input and the
required structured data.
Args:
agent_id (`str`):
The agent identifier.
agent_name (`str`):
The agent name.
structured_model (`Type[BaseModel] | None`, optional):
A base model class that defines the structured input format.
Returns:
`UserInputData`:
The user input data.
"""
class TerminalUserInput(UserInputBase):
"""The terminal user input."""
def __init__(self, input_hint: str = "User Input: ") -> None:
"""Initialize the terminal user input with a hint."""
self.input_hint = input_hint
async def __call__(
self,
agent_id: str,
agent_name: str,
*args: Any,
structured_model: Type[BaseModel] | None = None,
**kwargs: Any,
) -> UserInputData:
"""Handle the user input from the terminal.
Args:
agent_id (`str`):
The agent identifier.
agent_name (`str`):
The agent name.
structured_model (`Type[BaseModel] | None`, optional):
A base model class that defines the structured input format.
Returns:
`UserInputData`:
The user input data.
"""
text_input = input(self.input_hint)
structured_input = None
if structured_model is not None:
structured_input = {}
json_schema = structured_model.model_json_schema()
required = json_schema.get("required", [])
print("Structured input (press Enter to skip for optional):)")
for key, item in json_schema.get("properties").items():
requirements = {**item}
requirements.pop("title")
while True:
res = input(f"\t{key} ({requirements}): ")
if res == "":
if key in required:
print(f"Key {key} is required.")
continue
res = item.get("default", None)
if item.get("type").lower() == "integer":
try:
res = json5.loads(res)
except json.decoder.JSONDecodeError as e:
print(
"\033[31mInvalid input with error:\n"
"```\n"
f"{e}\n"
"```\033[0m",
)
continue
try:
jsonschema.validate(res, item)
structured_input[key] = res
break
except jsonschema.ValidationError as e:
print(
f"\033[31mValidation error:\n```\n{e}\n```\033[0m",
)
time.sleep(0.5)
return UserInputData(
blocks_input=[TextBlock(type="text", text=text_input)],
structured_input=structured_input,
)
class StudioUserInput(UserInputBase):
"""The class that host the user input on the AgentScope Studio."""
_websocket_namespace: str = "/python"
def __init__(
self,
studio_url: str,
run_id: str,
max_retries: int = 3,
reconnect_attempts: int = 3,
reconnection_delay: int = 1,
reconnection_delay_max: int = 5,
) -> None:
"""Initialize the StudioUserInput object.
Args:
studio_url (`str`):
The URL of the AgentScope Studio.
run_id (`str`):
The current run identity.
max_retries (`int`, defaults to `3`):
The maximum number of retries to get user input.
"""
self._is_connected = False
self._is_reconnecting = False
self.studio_url = studio_url
self.run_id = run_id
self.max_retries = max_retries
# Init Websocket
self.sio = socketio.Client(
reconnection=True,
reconnection_attempts=reconnect_attempts,
reconnection_delay=reconnection_delay,
reconnection_delay_max=reconnection_delay_max,
)
self.input_queues = {}
self.input_events = {}
@self.sio.on("connect", namespace=self._websocket_namespace)
def on_connect() -> None:
self._is_connected = True
logger.info(
'Connected to AgentScope Studio at "%s" with '
'run name "%s".',
self.studio_url,
run_id,
)
logger.info(
"View the run at: %s/dashboard/projects/%s",
self.studio_url,
_config.project,
)
@self.sio.on("disconnect", namespace=self._websocket_namespace)
def on_disconnect() -> None:
self._is_connected = False
logger.info(
"Disconnected from AgentScope Studio at %s",
self.studio_url,
)
@self.sio.on("reconnect", namespace=self._websocket_namespace)
def on_reconnect(attempt_number: int) -> None:
self._is_connected = True
self._is_reconnecting = False
logger.info(
"Reconnected to AgentScope Studio at %s with run_id %s after "
"%d attempts",
self.studio_url,
self.run_id,
attempt_number,
)
@self.sio.on("reconnect_attempt", namespace=self._websocket_namespace)
def on_reconnect_attempt(attempt_number: int) -> None:
self._is_reconnecting = True
logger.info(
"Attempting to reconnect to AgentScope Studio at %s "
"(attempt %d)",
self.studio_url,
attempt_number,
)
@self.sio.on("reconnect_failed", namespace=self._websocket_namespace)
def on_reconnect_failed() -> None:
self._is_reconnecting = False
logger.error(
"Failed to reconnect to AgentScope Studio at %s",
self.studio_url,
)
@self.sio.on("reconnect_error", namespace=self._websocket_namespace)
def on_reconnect_error(error: Any) -> None:
logger.error(
"Error while reconnecting to AgentScope Studio at %s: %s",
self.studio_url,
str(error),
)
# The AgentScope Studio backend send the "sendUserInput" event to
# the current python run
@self.sio.on("forwardUserInput", namespace=self._websocket_namespace)
def receive_user_input(
request_id: str,
blocks_input: List[
TextBlock | ImageBlock | AudioBlock | VideoBlock
],
structured_input: dict[str, Any],
) -> None:
if request_id in self.input_queues:
self.input_queues[request_id].put(
UserInputData(
blocks_input=blocks_input,
structured_input=structured_input,
),
)
self.input_events[request_id].set()
try:
self.sio.connect(
f"{self.studio_url}",
namespaces=["/python"],
auth={"run_id": self.run_id},
)
except Exception as e:
raise RuntimeError(
f"Failed to connect to AgentScope Studio at {self.studio_url}",
) from e
def _ensure_connected(
self,
timeout: float = 30.0,
check_interval: float = 5.0,
) -> None:
"""Ensure the connection is established or wait for reconnection.
Args:
timeout (`float`):
Maximum time to wait for reconnection in seconds. Defaults
to 30.0.
check_interval (`float`):
Interval between connection checks in seconds. Defaults to 1.0.
Raises:
`RuntimeError`:
If connection cannot be established within timeout.
"""
if self._is_connected:
return
if self._is_reconnecting:
start_time = time.time()
while self._is_reconnecting:
# Check timeout
elapsed_time = time.time() - start_time
if elapsed_time > timeout:
raise RuntimeError(
f"Reconnection timeout after {elapsed_time} seconds",
)
# Log status
logger.info(
"Waiting for reconnection... (%.1fs / %.1fs)",
elapsed_time,
timeout,
)
# Wait for next check
time.sleep(check_interval)
# After reconnection attempt completed, check final status
if self._is_connected:
return
# Not connected and not reconnecting
raise RuntimeError(
f"Not connected to AgentScope Studio at {self.studio_url}.",
)
async def __call__( # type: ignore[override]
self,
agent_id: str,
agent_name: str,
*args: Any,
structured_model: Type[BaseModel] | None = None,
) -> UserInputData:
"""Get the user input from AgentScope Studio.
Args:
agent_id (`str`):
The identity of the agent.
agent_name (`str`):
The name of the agent.
structured_model (`Type[BaseModel] | None`, optional):
The base model class of the structured input.
Raises:
`RuntimeError`:
Failed to get user input from AgentScope Studio.
Returns:
`UserInputData`:
The user input.
"""
self._ensure_connected()
request_id = shortuuid.uuid()
self.input_queues[request_id] = Queue()
self.input_events[request_id] = Event()
if structured_model is None:
structured_input = None
else:
structured_input = structured_model.model_json_schema()
n_retry = 0
while True:
try:
response = requests.post(
f"{self.studio_url}/trpc/requestUserInput",
json={
"requestId": request_id,
"runId": self.run_id,
"agentId": agent_id,
"agentName": agent_name,
"structuredInput": structured_input,
},
)
response.raise_for_status()
break
except Exception as e:
if n_retry < self.max_retries:
n_retry += 1
continue
raise RuntimeError(
"Failed to get user input from AgentScope Studio",
) from e
try:
self.input_events[request_id].wait()
response_data = self.input_queues[request_id].get()
return response_data
finally:
self.input_queues.pop(request_id, None)
self.input_events.pop(request_id, None)
def __del__(self) -> None:
"""Cleanup socket connection when object it destroyed"""
try:
self.sio.disconnect()
except Exception as e:
logger.error(
"Failed to disconnect from AgentScope Studio at %s: %s",
self.studio_url,
str(e),
)