mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-19 02:12:46 +08:00
feat: add conversation management features and enhance message handling in the API
This commit is contained in:
@@ -6,10 +6,13 @@ readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aiohttp>=3.13.2",
|
||||
"anthropic>=0.72.1",
|
||||
"certifi>=2025.10.5",
|
||||
"click>=8.3.0",
|
||||
"docstring-parser>=0.17.0",
|
||||
"google-genai>=1.50.0",
|
||||
"loguru>=0.7.3",
|
||||
"openai>=2.7.2",
|
||||
"pydantic>=2.12.3",
|
||||
"pyyaml>=6.0.3",
|
||||
]
|
||||
|
||||
168
src/astr_agent_sdk/message.py
Normal file
168
src/astr_agent_sdk/message.py
Normal file
@@ -0,0 +1,168 @@
|
||||
# Inspired by MoonshotAI/kosong, credits to MoonshotAI/kosong authors for the original implementation.
|
||||
# License: Apache License 2.0
|
||||
|
||||
from typing import Any, ClassVar, Literal, cast
|
||||
|
||||
from pydantic import BaseModel, GetCoreSchemaHandler
|
||||
from pydantic_core import core_schema
|
||||
|
||||
|
||||
class ContentPart(BaseModel):
|
||||
"""A part of the content in a message."""
|
||||
|
||||
__content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {}
|
||||
|
||||
type: str
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
invalid_subclass_error_msg = f"ContentPart subclass {cls.__name__} must have a `type` field of type `str`"
|
||||
|
||||
type_value = getattr(cls, "type", None)
|
||||
if type_value is None or not isinstance(type_value, str):
|
||||
raise ValueError(invalid_subclass_error_msg)
|
||||
|
||||
cls.__content_part_registry[type_value] = cls
|
||||
|
||||
@classmethod
|
||||
def __get_pydantic_core_schema__(
|
||||
cls, source_type: Any, handler: GetCoreSchemaHandler
|
||||
) -> core_schema.CoreSchema:
|
||||
# If we're dealing with the base ContentPart class, use custom validation
|
||||
if cls.__name__ == "ContentPart":
|
||||
|
||||
def validate_content_part(value: Any) -> Any:
|
||||
# if it's already an instance of a ContentPart subclass, return it
|
||||
if hasattr(value, "__class__") and issubclass(value.__class__, cls):
|
||||
return value
|
||||
|
||||
# if it's a dict with a type field, dispatch to the appropriate subclass
|
||||
if isinstance(value, dict) and "type" in value:
|
||||
type_value: Any | None = cast(dict[str, Any], value).get("type")
|
||||
if not isinstance(type_value, str):
|
||||
raise ValueError(f"Cannot validate {value} as ContentPart")
|
||||
target_class = cls.__content_part_registry[type_value]
|
||||
return target_class.model_validate(value)
|
||||
|
||||
raise ValueError(f"Cannot validate {value} as ContentPart")
|
||||
|
||||
return core_schema.no_info_plain_validator_function(validate_content_part)
|
||||
|
||||
# for subclasses, use the default schema
|
||||
return handler(source_type)
|
||||
|
||||
|
||||
class TextPart(ContentPart):
|
||||
"""
|
||||
>>> TextPart(text="Hello, world!").model_dump()
|
||||
{'type': 'text', 'text': 'Hello, world!'}
|
||||
"""
|
||||
|
||||
type: str = "text"
|
||||
text: str
|
||||
|
||||
|
||||
class ImageURLPart(ContentPart):
|
||||
"""
|
||||
>>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
|
||||
{'type': 'image_url', 'image_url': 'http://example.com/image.jpg'}
|
||||
"""
|
||||
|
||||
class ImageURL(BaseModel):
|
||||
url: str
|
||||
"""The URL of the image, can be data URI scheme like `data:image/png;base64,...`."""
|
||||
id: str | None = None
|
||||
"""The ID of the image, to allow LLMs to distinguish different images."""
|
||||
|
||||
type: str = "image_url"
|
||||
image_url: str
|
||||
|
||||
|
||||
class AudioURLPart(ContentPart):
|
||||
"""
|
||||
>>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
|
||||
{'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}
|
||||
"""
|
||||
|
||||
class AudioURL(BaseModel):
|
||||
url: str
|
||||
"""The URL of the audio, can be data URI scheme like `data:audio/aac;base64,...`."""
|
||||
id: str | None = None
|
||||
"""The ID of the audio, to allow LLMs to distinguish different audios."""
|
||||
|
||||
type: str = "audio_url"
|
||||
audio_url: AudioURL
|
||||
|
||||
|
||||
class ToolCall(BaseModel):
|
||||
"""
|
||||
A tool call requested by the assistant.
|
||||
|
||||
>>> ToolCall(
|
||||
... id="123",
|
||||
... function=ToolCall.FunctionBody(
|
||||
... name="function",
|
||||
... arguments="{}"
|
||||
... ),
|
||||
... ).model_dump()
|
||||
{'type': 'function', 'id': '123', 'function': {'name': 'function', 'arguments': '{}'}}
|
||||
"""
|
||||
|
||||
class FunctionBody(BaseModel):
|
||||
name: str
|
||||
arguments: str | None
|
||||
|
||||
type: Literal["function"] = "function"
|
||||
|
||||
id: str
|
||||
"""The ID of the tool call."""
|
||||
function: FunctionBody
|
||||
"""The function body of the tool call."""
|
||||
|
||||
|
||||
class ToolCallPart(BaseModel):
|
||||
"""A part of the tool call."""
|
||||
|
||||
arguments_part: str | None = None
|
||||
"""A part of the arguments of the tool call."""
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""A message in a conversation."""
|
||||
|
||||
role: Literal[
|
||||
"system",
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
]
|
||||
|
||||
content: str | list[ContentPart]
|
||||
"""The content of the message."""
|
||||
|
||||
|
||||
class AssistantMessageSegment(Message):
|
||||
"""A message segment from the assistant."""
|
||||
|
||||
role: Literal["assistant"] = "assistant"
|
||||
tool_calls: list[ToolCall] | list[dict] | None = None
|
||||
|
||||
|
||||
class ToolCallMessageSegment(Message):
|
||||
"""A message segment representing a tool call."""
|
||||
|
||||
role: Literal["tool"] = "tool"
|
||||
tool_call_id: str
|
||||
|
||||
|
||||
class UserMessageSegment(Message):
|
||||
"""A message segment from the user."""
|
||||
|
||||
role: Literal["user"] = "user"
|
||||
|
||||
|
||||
class SystemMessageSegment(Message):
|
||||
"""A message segment from the system."""
|
||||
|
||||
role: Literal["system"] = "system"
|
||||
17
src/astr_agent_sdk/run_context.py
Normal file
17
src/astr_agent_sdk/run_context.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic
|
||||
|
||||
from typing_extensions import TypeVar
|
||||
|
||||
TContext = TypeVar("TContext", default=Any)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextWrapper(Generic[TContext]):
|
||||
"""A context for running an agent, which can be used to pass additional data or state."""
|
||||
|
||||
context: TContext
|
||||
tool_call_timeout: int = 60 # Default tool call timeout in seconds
|
||||
|
||||
|
||||
NoContext = ContextWrapper[None]
|
||||
286
src/astr_agent_sdk/tool.py
Normal file
286
src/astr_agent_sdk/tool.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Generic
|
||||
|
||||
import jsonschema
|
||||
import mcp
|
||||
from deprecated import deprecated
|
||||
from pydantic import model_validator
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
from .run_context import ContextWrapper, TContext
|
||||
|
||||
ParametersType = dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSchema:
|
||||
"""A class representing the schema of a tool for function calling."""
|
||||
|
||||
name: str
|
||||
"""The name of the tool."""
|
||||
|
||||
description: str
|
||||
"""The description of the tool."""
|
||||
|
||||
parameters: ParametersType
|
||||
"""The parameters of the tool, in JSON Schema format."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_parameters(self) -> "ToolSchema":
|
||||
jsonschema.validate(
|
||||
self.parameters, jsonschema.Draft202012Validator.META_SCHEMA
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionTool(ToolSchema, Generic[TContext]):
|
||||
"""A callable tool, for function calling."""
|
||||
|
||||
handler: Callable[..., Awaitable[Any]] | None = None
|
||||
"""a callable that implements the tool's functionality. It should be an async function."""
|
||||
|
||||
handler_module_path: str | None = None
|
||||
"""
|
||||
The module path of the handler function. This is empty when the origin is mcp.
|
||||
This field must be retained, as the handler will be wrapped in functools.partial during initialization,
|
||||
causing the handler's __module__ to be functools
|
||||
"""
|
||||
active: bool = True
|
||||
"""
|
||||
Whether the tool is active. This field is a special field for AstrBot.
|
||||
You can ignore it when integrating with other frameworks.
|
||||
"""
|
||||
|
||||
def __repr__(self):
|
||||
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[TContext], **kwargs
|
||||
) -> str | mcp.types.CallToolResult:
|
||||
"""Run the tool with the given arguments. The handler field has priority."""
|
||||
raise NotImplementedError(
|
||||
"FunctionTool.call() must be implemented by subclasses or set a handler."
|
||||
)
|
||||
|
||||
|
||||
class ToolSet:
|
||||
"""A set of function tools that can be used in function calling.
|
||||
|
||||
This class provides methods to add, remove, and retrieve tools, as well as
|
||||
convert the tools to different API formats (OpenAI, Anthropic, Google GenAI).
|
||||
"""
|
||||
|
||||
def __init__(self, tools: list[FunctionTool] | None = None):
|
||||
self.tools: list[FunctionTool] = tools or []
|
||||
|
||||
def empty(self) -> bool:
|
||||
"""Check if the tool set is empty."""
|
||||
return len(self.tools) == 0
|
||||
|
||||
def add_tool(self, tool: FunctionTool):
|
||||
"""Add a tool to the set."""
|
||||
# 检查是否已存在同名工具
|
||||
for i, existing_tool in enumerate(self.tools):
|
||||
if existing_tool.name == tool.name:
|
||||
self.tools[i] = tool
|
||||
return
|
||||
self.tools.append(tool)
|
||||
|
||||
def remove_tool(self, name: str):
|
||||
"""Remove a tool by its name."""
|
||||
self.tools = [tool for tool in self.tools if tool.name != name]
|
||||
|
||||
def get_tool(self, name: str) -> FunctionTool | None:
|
||||
"""Get a tool by its name."""
|
||||
for tool in self.tools:
|
||||
if tool.name == name:
|
||||
return tool
|
||||
return None
|
||||
|
||||
@deprecated(reason="Use add_tool() instead", version="4.0.0")
|
||||
def add_func(
|
||||
self,
|
||||
name: str,
|
||||
func_args: list,
|
||||
desc: str,
|
||||
handler: Callable[..., Awaitable[Any]],
|
||||
):
|
||||
"""Add a function tool to the set."""
|
||||
params = {
|
||||
"type": "object", # hard-coded here
|
||||
"properties": {},
|
||||
}
|
||||
for param in func_args:
|
||||
params["properties"][param["name"]] = {
|
||||
"type": param["type"],
|
||||
"description": param["description"],
|
||||
}
|
||||
_func = FunctionTool(
|
||||
name=name,
|
||||
parameters=params,
|
||||
description=desc,
|
||||
handler=handler,
|
||||
)
|
||||
self.add_tool(_func)
|
||||
|
||||
@deprecated(reason="Use remove_tool() instead", version="4.0.0")
|
||||
def remove_func(self, name: str):
|
||||
"""Remove a function tool by its name."""
|
||||
self.remove_tool(name)
|
||||
|
||||
@deprecated(reason="Use get_tool() instead", version="4.0.0")
|
||||
def get_func(self, name: str) -> FunctionTool | None:
|
||||
"""Get all function tools."""
|
||||
return self.get_tool(name)
|
||||
|
||||
@property
|
||||
def func_list(self) -> list[FunctionTool]:
|
||||
"""Get the list of function tools."""
|
||||
return self.tools
|
||||
|
||||
def openai_schema(self, omit_empty_parameter_field: bool = False) -> list[dict]:
|
||||
"""Convert tools to OpenAI API function calling schema format."""
|
||||
result = []
|
||||
for tool in self.tools:
|
||||
func_def = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
},
|
||||
}
|
||||
|
||||
if (
|
||||
tool.parameters and tool.parameters.get("properties")
|
||||
) or not omit_empty_parameter_field:
|
||||
func_def["function"]["parameters"] = tool.parameters
|
||||
|
||||
result.append(func_def)
|
||||
return result
|
||||
|
||||
def anthropic_schema(self) -> list[dict]:
|
||||
"""Convert tools to Anthropic API format."""
|
||||
result = []
|
||||
for tool in self.tools:
|
||||
input_schema = {"type": "object"}
|
||||
if tool.parameters:
|
||||
input_schema["properties"] = tool.parameters.get("properties", {})
|
||||
input_schema["required"] = tool.parameters.get("required", [])
|
||||
tool_def = {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"input_schema": input_schema,
|
||||
}
|
||||
result.append(tool_def)
|
||||
return result
|
||||
|
||||
def google_schema(self) -> dict:
|
||||
"""Convert tools to Google GenAI API format."""
|
||||
|
||||
def convert_schema(schema: dict) -> dict:
|
||||
"""Convert schema to Gemini API format."""
|
||||
supported_types = {
|
||||
"string",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"array",
|
||||
"object",
|
||||
"null",
|
||||
}
|
||||
supported_formats = {
|
||||
"string": {"enum", "date-time"},
|
||||
"integer": {"int32", "int64"},
|
||||
"number": {"float", "double"},
|
||||
}
|
||||
|
||||
if "anyOf" in schema:
|
||||
return {"anyOf": [convert_schema(s) for s in schema["anyOf"]]}
|
||||
|
||||
result = {}
|
||||
|
||||
if "type" in schema and schema["type"] in supported_types:
|
||||
result["type"] = schema["type"]
|
||||
if "format" in schema and schema["format"] in supported_formats.get(
|
||||
result["type"],
|
||||
set(),
|
||||
):
|
||||
result["format"] = schema["format"]
|
||||
else:
|
||||
result["type"] = "null"
|
||||
|
||||
support_fields = {
|
||||
"title",
|
||||
"description",
|
||||
"enum",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"maxItems",
|
||||
"minItems",
|
||||
"nullable",
|
||||
"required",
|
||||
}
|
||||
result.update({k: schema[k] for k in support_fields if k in schema})
|
||||
|
||||
if "properties" in schema:
|
||||
properties = {}
|
||||
for key, value in schema["properties"].items():
|
||||
prop_value = convert_schema(value)
|
||||
if "default" in prop_value:
|
||||
del prop_value["default"]
|
||||
properties[key] = prop_value
|
||||
|
||||
if properties:
|
||||
result["properties"] = properties
|
||||
|
||||
if "items" in schema:
|
||||
result["items"] = convert_schema(schema["items"])
|
||||
|
||||
return result
|
||||
|
||||
tools = []
|
||||
for tool in self.tools:
|
||||
d: dict[str, Any] = {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
}
|
||||
if tool.parameters:
|
||||
d["parameters"] = convert_schema(tool.parameters)
|
||||
tools.append(d)
|
||||
|
||||
declarations = {}
|
||||
if tools:
|
||||
declarations["function_declarations"] = tools
|
||||
return declarations
|
||||
|
||||
@deprecated(reason="Use openai_schema() instead", version="4.0.0")
|
||||
def get_func_desc_openai_style(self, omit_empty_parameter_field: bool = False):
|
||||
return self.openai_schema(omit_empty_parameter_field)
|
||||
|
||||
@deprecated(reason="Use anthropic_schema() instead", version="4.0.0")
|
||||
def get_func_desc_anthropic_style(self):
|
||||
return self.anthropic_schema()
|
||||
|
||||
@deprecated(reason="Use google_schema() instead", version="4.0.0")
|
||||
def get_func_desc_google_genai_style(self):
|
||||
return self.google_schema()
|
||||
|
||||
def names(self) -> list[str]:
|
||||
"""获取所有工具的名称列表"""
|
||||
return [tool.name for tool in self.tools]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.tools)
|
||||
|
||||
def __bool__(self):
|
||||
return len(self.tools) > 0
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.tools)
|
||||
|
||||
def __repr__(self):
|
||||
return f"ToolSet(tools={self.tools})"
|
||||
|
||||
def __str__(self):
|
||||
return f"ToolSet(tools={self.tools})"
|
||||
@@ -1,4 +1,5 @@
|
||||
# from astr_agent_sdk.message import AssistantMessageSegment, UserMessageSegment
|
||||
from astr_agent_sdk.message import AssistantMessageSegment, UserMessageSegment
|
||||
from ...api.basic.entities import Conversation
|
||||
|
||||
|
||||
class BaseConversationManager:
|
||||
@@ -31,7 +32,9 @@ class BaseConversationManager:
|
||||
"""
|
||||
...
|
||||
|
||||
async def switch_conversation(self, unified_msg_origin: str, conversation_id: str):
|
||||
async def switch_conversation(
|
||||
self, unified_msg_origin: str, conversation_id: str
|
||||
) -> None:
|
||||
"""切换会话的对话
|
||||
|
||||
Args:
|
||||
@@ -75,60 +78,60 @@ class BaseConversationManager:
|
||||
"""
|
||||
...
|
||||
|
||||
# async def get_conversation(
|
||||
# self,
|
||||
# unified_msg_origin: str,
|
||||
# conversation_id: str,
|
||||
# create_if_not_exists: bool = False,
|
||||
# ) -> Conversation | None:
|
||||
# """获取会话的对话.
|
||||
async def get_conversation(
|
||||
self,
|
||||
unified_msg_origin: str,
|
||||
conversation_id: str,
|
||||
create_if_not_exists: bool = False,
|
||||
) -> Conversation | None:
|
||||
"""获取会话的对话.
|
||||
|
||||
# Args:
|
||||
# unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
# conversation_id (str): 对话 ID, 是 uuid 格式的字符串
|
||||
# create_if_not_exists (bool): 如果对话不存在,是否创建一个新的对话
|
||||
# Returns:
|
||||
# conversation (Conversation): 对话对象
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
|
||||
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
|
||||
create_if_not_exists (bool): 如果对话不存在,是否创建一个新的对话
|
||||
Returns:
|
||||
conversation (Conversation): 对话对象
|
||||
|
||||
# """
|
||||
# ...
|
||||
"""
|
||||
...
|
||||
|
||||
# async def get_conversations(
|
||||
# self,
|
||||
# unified_msg_origin: str | None = None,
|
||||
# platform_id: str | None = None,
|
||||
# ) -> list[Conversation]:
|
||||
# """获取对话列表.
|
||||
async def get_conversations(
|
||||
self,
|
||||
unified_msg_origin: str | None = None,
|
||||
platform_id: str | None = None,
|
||||
) -> list[Conversation]:
|
||||
"""获取对话列表.
|
||||
|
||||
# Args:
|
||||
# unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id,可选
|
||||
# platform_id (str): 平台 ID, 可选参数, 用于过滤对话
|
||||
# Returns:
|
||||
# conversations (List[Conversation]): 对话对象列表
|
||||
Args:
|
||||
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id,可选
|
||||
platform_id (str): 平台 ID, 可选参数, 用于过滤对话
|
||||
Returns:
|
||||
conversations (List[Conversation]): 对话对象列表
|
||||
|
||||
# """
|
||||
# ...
|
||||
"""
|
||||
...
|
||||
|
||||
# async def get_filtered_conversations(
|
||||
# self,
|
||||
# page: int = 1,
|
||||
# page_size: int = 20,
|
||||
# platform_ids: list[str] | None = None,
|
||||
# search_query: str = "",
|
||||
# **kwargs,
|
||||
# ) -> tuple[list[Conversation], int]:
|
||||
# """获取过滤后的对话列表.
|
||||
async def get_filtered_conversations(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
platform_ids: list[str] | None = None,
|
||||
search_query: str = "",
|
||||
**kwargs,
|
||||
) -> tuple[list[Conversation], int]:
|
||||
"""获取过滤后的对话列表.
|
||||
|
||||
# Args:
|
||||
# page (int): 页码, 默认为 1
|
||||
# page_size (int): 每页大小, 默认为 20
|
||||
# platform_ids (list[str]): 平台 ID 列表, 可选
|
||||
# search_query (str): 搜索查询字符串, 可选
|
||||
# Returns:
|
||||
# conversations (list[Conversation]): 对话对象列表
|
||||
Args:
|
||||
page (int): 页码, 默认为 1
|
||||
page_size (int): 每页大小, 默认为 20
|
||||
platform_ids (list[str]): 平台 ID 列表, 可选
|
||||
search_query (str): 搜索查询字符串, 可选
|
||||
Returns:
|
||||
conversations (list[Conversation]): 对话对象列表
|
||||
|
||||
# """
|
||||
# ...
|
||||
"""
|
||||
...
|
||||
|
||||
async def update_conversation(
|
||||
self,
|
||||
@@ -184,23 +187,23 @@ class BaseConversationManager:
|
||||
"""
|
||||
...
|
||||
|
||||
# async def add_message_pair(
|
||||
# self,
|
||||
# cid: str,
|
||||
# user_message: UserMessageSegment | dict,
|
||||
# assistant_message: AssistantMessageSegment | dict,
|
||||
# ) -> None:
|
||||
# """Add a user-assistant message pair to the conversation history.
|
||||
async def add_message_pair(
|
||||
self,
|
||||
cid: str,
|
||||
user_message: UserMessageSegment | dict,
|
||||
assistant_message: AssistantMessageSegment | dict,
|
||||
) -> None:
|
||||
"""Add a user-assistant message pair to the conversation history.
|
||||
|
||||
# Args:
|
||||
# cid (str): Conversation ID
|
||||
# user_message (UserMessageSegment | dict): OpenAI-format user message object or dict
|
||||
# assistant_message (AssistantMessageSegment | dict): OpenAI-format assistant message object or dict
|
||||
Args:
|
||||
cid (str): Conversation ID
|
||||
user_message (UserMessageSegment | dict): OpenAI-format user message object or dict
|
||||
assistant_message (AssistantMessageSegment | dict): OpenAI-format assistant message object or dict
|
||||
|
||||
# Raises:
|
||||
# Exception: If the conversation with the given ID is not found
|
||||
# """
|
||||
# ...
|
||||
Raises:
|
||||
Exception: If the conversation with the given ID is not found
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_human_readable_context(
|
||||
self,
|
||||
|
||||
23
src/astrbot_sdk/api/basic/entities.py
Normal file
23
src/astrbot_sdk/api/basic/entities.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Conversation:
|
||||
"""The conversation entity representing a chat session."""
|
||||
|
||||
platform_id: str
|
||||
"""The platform ID in AstrBot"""
|
||||
user_id: str
|
||||
"""The user ID associated with the conversation."""
|
||||
cid: str
|
||||
"""The conversation ID, in UUID format."""
|
||||
history: str = ""
|
||||
"""The conversation history as a string."""
|
||||
title: str | None = ""
|
||||
"""The title of the conversation. For now, it's only used in WebChat."""
|
||||
persona_id: str | None = ""
|
||||
"""The persona ID associated with the conversation."""
|
||||
created_at: int = 0
|
||||
"""The timestamp when the conversation was created."""
|
||||
updated_at: int = 0
|
||||
"""The timestamp when the conversation was last updated."""
|
||||
126
src/astrbot_sdk/api/provider/entities.py
Normal file
126
src/astrbot_sdk/api/provider/entities.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from anthropic.types import Message as AnthropicMessage
|
||||
from google.genai.types import GenerateContentResponse
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from dataclasses import dataclass, field
|
||||
from ..message.chain import MessageChain
|
||||
from ..message import components as Comp
|
||||
from typing import Any
|
||||
from astr_agent_sdk.message import ToolCall
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
role: str
|
||||
"""角色, assistant, tool, err"""
|
||||
result_chain: MessageChain | None = None
|
||||
"""返回的消息链"""
|
||||
tools_call_args: list[dict[str, Any]] = field(default_factory=list)
|
||||
"""工具调用参数"""
|
||||
tools_call_name: list[str] = field(default_factory=list)
|
||||
"""工具调用名称"""
|
||||
tools_call_ids: list[str] = field(default_factory=list)
|
||||
"""工具调用 ID"""
|
||||
|
||||
raw_completion: (
|
||||
ChatCompletion | GenerateContentResponse | AnthropicMessage | None
|
||||
) = None
|
||||
_new_record: dict[str, Any] | None = None
|
||||
|
||||
_completion_text: str = ""
|
||||
|
||||
is_chunk: bool = False
|
||||
"""是否是流式输出的单个 Chunk"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
role: str,
|
||||
completion_text: str = "",
|
||||
result_chain: MessageChain | None = None,
|
||||
tools_call_args: list[dict[str, Any]] | None = None,
|
||||
tools_call_name: list[str] | None = None,
|
||||
tools_call_ids: list[str] | None = None,
|
||||
raw_completion: ChatCompletion
|
||||
| GenerateContentResponse
|
||||
| AnthropicMessage
|
||||
| None = None,
|
||||
_new_record: dict[str, Any] | None = None,
|
||||
is_chunk: bool = False,
|
||||
):
|
||||
"""初始化 LLMResponse
|
||||
|
||||
Args:
|
||||
role (str): 角色, assistant, tool, err
|
||||
completion_text (str, optional): 返回的结果文本,已经过时,推荐使用 result_chain. Defaults to "".
|
||||
result_chain (MessageChain, optional): 返回的消息链. Defaults to None.
|
||||
tools_call_args (List[Dict[str, any]], optional): 工具调用参数. Defaults to None.
|
||||
tools_call_name (List[str], optional): 工具调用名称. Defaults to None.
|
||||
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
|
||||
|
||||
"""
|
||||
if tools_call_args is None:
|
||||
tools_call_args = []
|
||||
if tools_call_name is None:
|
||||
tools_call_name = []
|
||||
if tools_call_ids is None:
|
||||
tools_call_ids = []
|
||||
|
||||
self.role = role
|
||||
self.completion_text = completion_text
|
||||
self.result_chain = result_chain
|
||||
self.tools_call_args = tools_call_args
|
||||
self.tools_call_name = tools_call_name
|
||||
self.tools_call_ids = tools_call_ids
|
||||
self.raw_completion = raw_completion
|
||||
self._new_record = _new_record
|
||||
self.is_chunk = is_chunk
|
||||
|
||||
@property
|
||||
def completion_text(self):
|
||||
if self.result_chain:
|
||||
return self.result_chain.get_plain_text()
|
||||
return self._completion_text
|
||||
|
||||
@completion_text.setter
|
||||
def completion_text(self, value):
|
||||
if self.result_chain:
|
||||
self.result_chain.chain = [
|
||||
comp
|
||||
for comp in self.result_chain.chain
|
||||
if not isinstance(comp, Comp.Plain)
|
||||
] # 清空 Plain 组件
|
||||
self.result_chain.chain.insert(0, Comp.Plain(text=value))
|
||||
else:
|
||||
self._completion_text = value
|
||||
|
||||
def to_openai_tool_calls(self) -> list[dict]:
|
||||
"""Convert to OpenAI tool calls format. Deprecated, use to_openai_to_calls_model instead."""
|
||||
ret = []
|
||||
for idx, tool_call_arg in enumerate(self.tools_call_args):
|
||||
ret.append(
|
||||
{
|
||||
"id": self.tools_call_ids[idx],
|
||||
"function": {
|
||||
"name": self.tools_call_name[idx],
|
||||
"arguments": json.dumps(tool_call_arg),
|
||||
},
|
||||
"type": "function",
|
||||
},
|
||||
)
|
||||
return ret
|
||||
|
||||
def to_openai_to_calls_model(self) -> list[ToolCall]:
|
||||
"""The same as to_openai_tool_calls but return pydantic model."""
|
||||
ret = []
|
||||
for idx, tool_call_arg in enumerate(self.tools_call_args):
|
||||
ret.append(
|
||||
ToolCall(
|
||||
id=self.tools_call_ids[idx],
|
||||
function=ToolCall.FunctionBody(
|
||||
name=self.tools_call_name[idx],
|
||||
arguments=json.dumps(tool_call_arg),
|
||||
),
|
||||
),
|
||||
)
|
||||
return ret
|
||||
@@ -1,16 +1,21 @@
|
||||
from abc import ABC
|
||||
from typing import Any, Callable
|
||||
from ..basic.conversation_mgr import BaseConversationManager
|
||||
from astr_agent_sdk.tool import ToolSet, FunctionTool
|
||||
from astr_agent_sdk.message import Message
|
||||
from ..provider.entities import LLMResponse
|
||||
from ..message.chain import MessageChain
|
||||
|
||||
|
||||
class Context(ABC):
|
||||
conversation_manager: BaseConversationManager
|
||||
persona_manager: Any
|
||||
|
||||
def __init__(self):
|
||||
self._registered_managers: dict[str, Any] = {}
|
||||
self._registered_functions: dict[str, Callable] = {}
|
||||
|
||||
def register_component(self, *components: Any) -> None:
|
||||
def _register_component(self, *components: Any) -> None:
|
||||
"""Register a components instance and its public methods.
|
||||
|
||||
This allows the components's methods to be called via RPC using the pattern:
|
||||
@@ -31,22 +36,117 @@ class Context(ABC):
|
||||
full_name = f"{class_name}.{attr_name}"
|
||||
self._registered_functions[full_name] = attr
|
||||
|
||||
def get_registered_function(self, full_name: str) -> Callable | None:
|
||||
"""Get a registered function by its full name.
|
||||
async def llm_generate(
|
||||
self,
|
||||
chat_provider_id: str,
|
||||
prompt: str | None = None,
|
||||
image_urls: list[str] | None = None,
|
||||
tools: ToolSet | None = None,
|
||||
system_prompt: str | None = None,
|
||||
contexts: list[Message] | list[dict] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> LLMResponse:
|
||||
"""Call the LLM to generate a response. The method will not automatically execute tool calls. If you want to use tool calls, please use `tool_loop_agent()`.
|
||||
|
||||
Args:
|
||||
full_name: Full name in format "ComponentClassName.method_name"
|
||||
chat_provider_id: The chat provider ID to use.
|
||||
prompt: The prompt to send to the LLM, if `contexts` and `prompt` are both provided, `prompt` will be appended as the last user message
|
||||
image_urls: List of image URLs to include in the prompt, if `contexts` and `prompt` are both provided, `image_urls` will be appended to the last user message
|
||||
tools: ToolSet of tools available to the LLM
|
||||
system_prompt: System prompt to guide the LLM's behavior, if provided, it will always insert as the first system message in the context
|
||||
contexts: context messages for the LLM
|
||||
**kwargs: Additional keyword arguments for LLM generation, OpenAI compatible
|
||||
|
||||
Raises:
|
||||
ChatProviderNotFoundError: If the specified chat provider ID is not found
|
||||
Exception: For other errors during LLM generation
|
||||
"""
|
||||
...
|
||||
|
||||
async def tool_loop_agent(
|
||||
self,
|
||||
chat_provider_id: str,
|
||||
prompt: str | None = None,
|
||||
image_urls: list[str] | None = None,
|
||||
tools: ToolSet | None = None,
|
||||
system_prompt: str | None = None,
|
||||
contexts: list[Message] | list[dict] | None = None,
|
||||
max_steps: int = 30,
|
||||
**kwargs: Any,
|
||||
) -> LLMResponse:
|
||||
"""Run an agent loop that allows the LLM to call tools iteratively until a final answer is produced.
|
||||
|
||||
Args:
|
||||
chat_provider_id: The chat provider ID to use.
|
||||
prompt: The prompt to send to the LLM, if `contexts` and `prompt` are both provided, `prompt` will be appended as the last user message
|
||||
image_urls: List of image URLs to include in the prompt, if `contexts` and `prompt` are both provided, `image_urls` will be appended to the last user message
|
||||
tools: ToolSet of tools available to the LLM
|
||||
system_prompt: System prompt to guide the LLM's behavior, if provided, it will always insert as the first system message in the context
|
||||
contexts: context messages for the LLM
|
||||
max_steps: Maximum number of tool calls before stopping the loop
|
||||
**kwargs: Additional keyword arguments for LLM generation, OpenAI compatible
|
||||
|
||||
Returns:
|
||||
The callable function or None if not found
|
||||
The final LLMResponse after tool calls are completed.
|
||||
|
||||
Raises:
|
||||
ChatProviderNotFoundError: If the specified chat provider ID is not found
|
||||
Exception: For other errors during LLM generation
|
||||
"""
|
||||
...
|
||||
|
||||
return self._registered_functions.get(full_name)
|
||||
async def send_message(
|
||||
self,
|
||||
session: str,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
"""Send a message to a user or group.
|
||||
|
||||
def list_registered_functions(self) -> list[str]:
|
||||
"""List all registered function names.
|
||||
Args:
|
||||
session: unified message origin(umo), this can represent a user or group in a specific platform instance
|
||||
message_chain: The MessageChain to send
|
||||
|
||||
Raises:
|
||||
Exception: If sending the message fails
|
||||
"""
|
||||
...
|
||||
|
||||
async def add_llm_tools(self, *tools: FunctionTool) -> None:
|
||||
"""Add tools to the LLM's toolset.
|
||||
|
||||
Args:
|
||||
tools: The FunctionTool instances to add
|
||||
"""
|
||||
...
|
||||
|
||||
async def put_kv_data(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Insert a key-value pair data. The data will permanently stored in AstrBot unless user explicitly deleted.
|
||||
|
||||
Args:
|
||||
key: The key to insert
|
||||
value: The value to insert
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_kv_data(self, key: str) -> str | None:
|
||||
"""Get a value by key from the key-value store.
|
||||
|
||||
Args:
|
||||
key: The key to retrieve
|
||||
|
||||
Returns:
|
||||
List of full function names in format "ComponentClassName.method_name"
|
||||
The value associated with the key, or None if not found
|
||||
"""
|
||||
return list(self._registered_functions.keys())
|
||||
...
|
||||
|
||||
async def delete_kv_data(self, key: str) -> None:
|
||||
"""Delete a key-value pair by key.
|
||||
|
||||
Args:
|
||||
key: The key to delete
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from astr_agent_sdk.message import AssistantMessageSegment, UserMessageSegment
|
||||
from astrbot_sdk.api.basic.entities import Conversation
|
||||
from ...api.basic.conversation_mgr import BaseConversationManager
|
||||
from ..star_runner import StarRunner
|
||||
|
||||
@@ -30,3 +32,109 @@ class ConversationManager(BaseConversationManager):
|
||||
},
|
||||
)
|
||||
return result["data"]
|
||||
|
||||
async def switch_conversation(
|
||||
self, unified_msg_origin: str, conversation_id: str
|
||||
) -> None:
|
||||
await self.runner.call_context_function(
|
||||
f"{self.__class__.__name__}.{self.switch_conversation.__name__}",
|
||||
{
|
||||
"unified_msg_origin": unified_msg_origin,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
async def delete_conversation(
|
||||
self,
|
||||
unified_msg_origin: str,
|
||||
conversation_id: str | None = None,
|
||||
) -> None:
|
||||
await self.runner.call_context_function(
|
||||
f"{self.__class__.__name__}.{self.delete_conversation.__name__}",
|
||||
{
|
||||
"unified_msg_origin": unified_msg_origin,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
async def delete_conversations_by_user_id(self, unified_msg_origin: str) -> None:
|
||||
await self.runner.call_context_function(
|
||||
f"{self.__class__.__name__}.{self.delete_conversations_by_user_id.__name__}",
|
||||
{
|
||||
"unified_msg_origin": unified_msg_origin,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_curr_conversation_id(self, unified_msg_origin: str) -> str | None:
|
||||
result = await self.runner.call_context_function(
|
||||
f"{self.__class__.__name__}.{self.get_curr_conversation_id.__name__}",
|
||||
{
|
||||
"unified_msg_origin": unified_msg_origin,
|
||||
},
|
||||
)
|
||||
return result["data"]
|
||||
|
||||
async def get_conversation(
|
||||
self,
|
||||
unified_msg_origin: str,
|
||||
conversation_id: str,
|
||||
create_if_not_exists: bool = False,
|
||||
) -> Conversation | None:
|
||||
result = await self.runner.call_context_function(
|
||||
f"{self.__class__.__name__}.{self.get_conversation.__name__}",
|
||||
{
|
||||
"unified_msg_origin": unified_msg_origin,
|
||||
"conversation_id": conversation_id,
|
||||
"create_if_not_exists": create_if_not_exists,
|
||||
},
|
||||
)
|
||||
return Conversation(**result["data"]) if result["data"] else None
|
||||
|
||||
async def get_conversations(
|
||||
self, unified_msg_origin: str | None = None, platform_id: str | None = None
|
||||
) -> list[Conversation]:
|
||||
result = await self.runner.call_context_function(
|
||||
f"{self.__class__.__name__}.{self.get_conversations.__name__}",
|
||||
{
|
||||
"unified_msg_origin": unified_msg_origin,
|
||||
"platform_id": platform_id,
|
||||
},
|
||||
)
|
||||
return [Conversation(**conv) for conv in result["data"]]
|
||||
|
||||
async def update_conversation(
|
||||
self,
|
||||
unified_msg_origin: str,
|
||||
conversation_id: str | None = None,
|
||||
history: list[dict] | None = None,
|
||||
title: str | None = None,
|
||||
persona_id: str | None = None,
|
||||
) -> None:
|
||||
await self.runner.call_context_function(
|
||||
f"{self.__class__.__name__}.{self.update_conversation.__name__}",
|
||||
{
|
||||
"unified_msg_origin": unified_msg_origin,
|
||||
"conversation_id": conversation_id,
|
||||
"history": history,
|
||||
"title": title,
|
||||
"persona_id": persona_id,
|
||||
},
|
||||
)
|
||||
|
||||
async def add_message_pair(
|
||||
self,
|
||||
cid: str,
|
||||
user_message: UserMessageSegment | dict,
|
||||
assistant_message: AssistantMessageSegment | dict,
|
||||
) -> None:
|
||||
"""Add a user-assistant message pair to the conversation history.
|
||||
|
||||
Args:
|
||||
cid (str): Conversation ID
|
||||
user_message (UserMessageSegment | dict): OpenAI-format user message object or dict
|
||||
assistant_message (AssistantMessageSegment | dict): OpenAI-format assistant message object or dict
|
||||
|
||||
Raises:
|
||||
Exception: If the conversation with the given ID is not found
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -5,6 +5,7 @@ from loguru import logger
|
||||
|
||||
from ...api.event.astr_message_event import AstrMessageEvent, AstrMessageEventModel
|
||||
from ...api.star.star import StarMetadata
|
||||
from ...api.star.context import Context
|
||||
from ..rpc.client import JSONRPCClient
|
||||
from ..rpc.request_helper import RPCRequestHelper
|
||||
from ..rpc.jsonrpc import (
|
||||
@@ -181,7 +182,7 @@ class ClientHandshakeHandler:
|
||||
class PluginRequestHandler:
|
||||
"""Handles JSON-RPC requests from plugins calling core methods."""
|
||||
|
||||
def __init__(self, context: Any):
|
||||
def __init__(self, context: Context):
|
||||
"""Initialize the plugin request handler.
|
||||
|
||||
Args:
|
||||
@@ -251,7 +252,7 @@ class PluginRequestHandler:
|
||||
)
|
||||
|
||||
# Get the registered function from context
|
||||
func = self._context.get_registered_function(func_full_name)
|
||||
func = self._context._registered_functions.get(func_full_name)
|
||||
if func is None:
|
||||
raise ValueError(f"Function not found: {func_full_name}")
|
||||
|
||||
|
||||
@@ -12,3 +12,6 @@ class HelloCommand(CommandComponent):
|
||||
ret = await self.context.conversation_manager.new_conversation("hello")
|
||||
print(f"New conversation created: {ret}")
|
||||
yield event.plain_result(f"Hello, Astrbot! Created conversation ID: {ret}")
|
||||
yield event.plain_result("Hello, Astrbot!")
|
||||
yield event.plain_result("Hello again, Astrbot!")
|
||||
yield event.plain_result("Goodbye, Astrbot!")
|
||||
|
||||
Reference in New Issue
Block a user