feat: 实现 AgentScope ReAct Runner 两阶段执行并重构事件处理
This commit is contained in:
@@ -8,11 +8,6 @@ from redis.asyncio import Redis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.agentscope.events import RedisStreamBus
|
||||
from core.agentscope.runtime.tasks import (
|
||||
run_command_task,
|
||||
run_command_task_bulk,
|
||||
run_command_task_critical,
|
||||
)
|
||||
from core.agentscope.tools.tool_result_storage import (
|
||||
create_tool_result_storage,
|
||||
)
|
||||
@@ -48,6 +43,12 @@ class TaskiqQueueClient:
|
||||
|
||||
@staticmethod
|
||||
def _select_queue_task(command: dict[str, object]) -> Any:
|
||||
from core.agentscope.runtime.tasks import (
|
||||
run_command_task,
|
||||
run_command_task_bulk,
|
||||
run_command_task_critical,
|
||||
)
|
||||
|
||||
queue = str(command.get("queue", "default")).strip().lower()
|
||||
if queue == "critical":
|
||||
return run_command_task_critical
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -218,6 +219,12 @@ class AgentRepository:
|
||||
"seq": int(message.seq),
|
||||
"role": role,
|
||||
"content": message.content,
|
||||
"model_code": message.model_code,
|
||||
"tool_name": message.tool_name,
|
||||
"input_tokens": int(message.input_tokens or 0),
|
||||
"output_tokens": int(message.output_tokens or 0),
|
||||
"cost": str(message.cost if message.cost is not None else Decimal("0")),
|
||||
"latency_ms": message.latency_ms,
|
||||
"metadata": message.metadata_json,
|
||||
"timestamp": message.created_at.astimezone(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ from v1.agent.schemas import (
|
||||
AttachmentReference,
|
||||
AttachmentSignedUrlResponse,
|
||||
AttachmentUploadResponse,
|
||||
HistorySnapshotResponse,
|
||||
TaskAcceptedResponse,
|
||||
)
|
||||
from v1.agent.service import AgentService, asr_service
|
||||
@@ -219,13 +220,13 @@ async def stream_events(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
@router.get("/history", response_model=HistorySnapshotResponse)
|
||||
async def get_user_history_snapshot(
|
||||
service: Annotated[AgentService, Depends(get_agent_service)],
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
thread_id: str | None = Query(default=None, alias="threadId"),
|
||||
before: date | None = Query(default=None),
|
||||
) -> dict[str, object]:
|
||||
) -> HistorySnapshotResponse:
|
||||
return await service.get_user_history_snapshot(
|
||||
current_user=current_user,
|
||||
thread_id=thread_id,
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from schemas.agent.ui_schema import UiSchemaRenderer
|
||||
|
||||
|
||||
class TaskAcceptedResponse(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
@@ -33,3 +35,35 @@ class AttachmentSignedUrlResponse(BaseModel):
|
||||
bucket: str
|
||||
path: str
|
||||
url: str
|
||||
|
||||
|
||||
class HistoryMessage(BaseModel):
|
||||
"""History message schema for /history endpoint response."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
id: str = Field(description="Message UUID")
|
||||
seq: int = Field(description="Message sequence number")
|
||||
role: str = Field(description="Message role: user | assistant | tool")
|
||||
content: str = Field(description="Message text content")
|
||||
url: str | None = Field(
|
||||
default=None,
|
||||
description="Temporary signed URL for user-attached images",
|
||||
)
|
||||
ui_schema: UiSchemaRenderer | None = Field(
|
||||
default=None,
|
||||
description="Compiled UI schema from worker/tool ui_hints for frontend rendering",
|
||||
)
|
||||
timestamp: str = Field(description="Message creation timestamp in ISO-8601 format")
|
||||
|
||||
|
||||
class HistorySnapshotResponse(BaseModel):
|
||||
"""Response schema for GET /api/v1/agent/history"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
scope: str = Field(default="history_day")
|
||||
thread_id: str | None = Field(default=None, alias="threadId")
|
||||
day: str | None = None
|
||||
has_more: bool = Field(default=False, alias="hasMore")
|
||||
messages: list[HistoryMessage] = Field(default_factory=list)
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, Protocol
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import dashscope
|
||||
from ag_ui.core import RunAgentInput, StateSnapshotEvent
|
||||
from ag_ui.core import RunAgentInput
|
||||
from dashscope.audio.asr import Recognition, RecognitionCallback
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -21,6 +21,7 @@ from schemas.messages.chat_message import (
|
||||
AgentChatMessageMetadata,
|
||||
UserMessageAttachments,
|
||||
)
|
||||
from v1.agent.schemas import HistorySnapshotResponse
|
||||
|
||||
logger = get_logger(__name__)
|
||||
_ALLOWED_ATTACHMENT_MIME_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
@@ -416,27 +417,48 @@ class AgentService:
|
||||
thread_id: str,
|
||||
before: date | None,
|
||||
current_user: CurrentUser,
|
||||
) -> dict[str, object]:
|
||||
) -> HistorySnapshotResponse:
|
||||
from schemas.messages.chat_message import AgentChatMessage
|
||||
from v1.agent.utils import convert_message_to_history
|
||||
from v1.agent.schemas import HistoryMessage
|
||||
|
||||
owner = await self._repository.get_session_owner(session_id=thread_id)
|
||||
ensure_session_owner(owner_id=owner, current_user=current_user)
|
||||
day_payload = await self._repository.get_history_day(
|
||||
session_id=thread_id,
|
||||
before=before,
|
||||
)
|
||||
snapshot = {
|
||||
"scope": "history_day",
|
||||
"threadId": thread_id,
|
||||
"day": day_payload["day"] if day_payload else None,
|
||||
"hasMore": day_payload["hasMore"] if day_payload else False,
|
||||
"messages": day_payload["messages"] if day_payload else [],
|
||||
}
|
||||
event = StateSnapshotEvent(snapshot=snapshot).model_dump(
|
||||
mode="json",
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
|
||||
messages: list[HistoryMessage] = []
|
||||
if day_payload:
|
||||
raw_messages = day_payload.get("messages") or []
|
||||
for msg_dict in raw_messages:
|
||||
msg = AgentChatMessage.model_validate(msg_dict)
|
||||
|
||||
signed_url: str | None = None
|
||||
if self._attachment_storage and msg.metadata:
|
||||
att = msg.metadata.user_message_attachments
|
||||
if att:
|
||||
signed_url = await self._attachment_storage.create_signed_url(
|
||||
bucket=att.bucket,
|
||||
path=att.path,
|
||||
expires_in_seconds=self._SIGNED_URL_EXPIRES_IN_SECONDS,
|
||||
)
|
||||
|
||||
converted = convert_message_to_history(msg, None)
|
||||
if signed_url:
|
||||
converted["url"] = signed_url
|
||||
messages.append(HistoryMessage.model_validate(converted))
|
||||
|
||||
return HistorySnapshotResponse(
|
||||
scope="history_day",
|
||||
threadId=thread_id,
|
||||
day=str(day_payload.get("day"))
|
||||
if day_payload and day_payload.get("day")
|
||||
else None,
|
||||
hasMore=bool(day_payload.get("hasMore")) if day_payload else False,
|
||||
messages=messages,
|
||||
)
|
||||
event["threadId"] = thread_id
|
||||
return event
|
||||
|
||||
async def get_user_history_snapshot(
|
||||
self,
|
||||
@@ -444,22 +466,20 @@ class AgentService:
|
||||
current_user: CurrentUser,
|
||||
thread_id: str | None,
|
||||
before: date | None,
|
||||
) -> dict[str, object]:
|
||||
) -> HistorySnapshotResponse:
|
||||
target_thread_id = thread_id
|
||||
if target_thread_id is None:
|
||||
target_thread_id = await self._repository.get_latest_session_id_for_user(
|
||||
user_id=str(current_user.id)
|
||||
)
|
||||
if target_thread_id is None:
|
||||
return StateSnapshotEvent(
|
||||
snapshot={
|
||||
"scope": "history_day",
|
||||
"threadId": None,
|
||||
"day": None,
|
||||
"hasMore": False,
|
||||
"messages": [],
|
||||
}
|
||||
).model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
return HistorySnapshotResponse(
|
||||
scope="history_day",
|
||||
threadId=None,
|
||||
day=None,
|
||||
hasMore=False,
|
||||
messages=[],
|
||||
)
|
||||
return await self.get_history_snapshot(
|
||||
thread_id=target_thread_id,
|
||||
before=before,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
历史消息转换工具函数
|
||||
|
||||
将数据库中的原始消息转换为 API 响应的数据结构
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from core.agentscope.runtime.ui_compiler import compile as compile_ui_hints
|
||||
from schemas.messages.chat_message import (
|
||||
AgentChatMessage,
|
||||
AgentChatMessageMetadata,
|
||||
UserMessageAttachments,
|
||||
)
|
||||
|
||||
|
||||
def convert_message_to_history(
|
||||
message: AgentChatMessage,
|
||||
get_signed_url_fn: Callable[[str, str], str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
将 AgentChatMessage 转换为 HistoryMessage 格式
|
||||
|
||||
转换规则:
|
||||
- role=user: 读取 metadata.user_message_attachments,将 bucket 转临时访问 url
|
||||
- role=tool: 读取 content 和 metadata.tool_agent_output.ui_hints,编译成 ui_schema
|
||||
- role=assistant: 读取 metadata.worker_agent_output.ui_hints,编译成 ui_schema
|
||||
"""
|
||||
role = message.role
|
||||
content = message.content
|
||||
metadata = message.metadata
|
||||
|
||||
url: str | None = None
|
||||
ui_schema: dict[str, Any] | None = None
|
||||
|
||||
if role == "user":
|
||||
url = _convert_user_attachments(metadata, get_signed_url_fn)
|
||||
|
||||
elif role == "tool":
|
||||
ui_schema = _compile_tool_ui_hints(metadata)
|
||||
|
||||
elif role == "assistant":
|
||||
ui_schema = _compile_worker_ui_hints(metadata)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"id": str(message.id),
|
||||
"seq": message.seq,
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": message.timestamp.isoformat(),
|
||||
}
|
||||
|
||||
if url:
|
||||
result["url"] = url
|
||||
|
||||
if ui_schema:
|
||||
result["uiSchema"] = ui_schema
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _convert_user_attachments(
|
||||
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
|
||||
get_signed_url_fn: Callable[[str, str], str] | None,
|
||||
) -> str | None:
|
||||
"""转换用户附件为临时访问 URL"""
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
if isinstance(metadata, AgentChatMessageMetadata):
|
||||
attachments = metadata.user_message_attachments
|
||||
else:
|
||||
attachments_data = metadata.get("user_message_attachments")
|
||||
if not attachments_data:
|
||||
return None
|
||||
attachments = UserMessageAttachments.model_validate(attachments_data)
|
||||
|
||||
if not attachments or not get_signed_url_fn:
|
||||
return None
|
||||
|
||||
try:
|
||||
return get_signed_url_fn(
|
||||
{"bucket": attachments.bucket, "path": attachments.path}
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _compile_tool_ui_hints(
|
||||
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""编译 tool 消息的 ui_hints"""
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
if isinstance(metadata, AgentChatMessageMetadata):
|
||||
tool_output = metadata.tool_agent_output
|
||||
else:
|
||||
tool_output_data = metadata.get("tool_agent_output")
|
||||
if not tool_output_data:
|
||||
return None
|
||||
from schemas.agent.runtime_models import ToolAgentOutput
|
||||
|
||||
tool_output = ToolAgentOutput.model_validate(tool_output_data)
|
||||
|
||||
if not tool_output:
|
||||
return None
|
||||
|
||||
ui_hints = tool_output.ui_hints
|
||||
if not ui_hints:
|
||||
return None
|
||||
|
||||
try:
|
||||
compiled = compile_ui_hints(ui_hints)
|
||||
return compiled
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _compile_worker_ui_hints(
|
||||
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""编译 assistant 消息的 worker ui_hints"""
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
if isinstance(metadata, AgentChatMessageMetadata):
|
||||
worker_output = metadata.worker_agent_output
|
||||
else:
|
||||
worker_output_data = metadata.get("worker_agent_output")
|
||||
if not worker_output_data:
|
||||
return None
|
||||
from schemas.agent.runtime_models import WorkerAgentOutputRich
|
||||
|
||||
worker_output = WorkerAgentOutputRich.model_validate(worker_output_data)
|
||||
|
||||
if not worker_output:
|
||||
return None
|
||||
|
||||
ui_hints = worker_output.ui_hints
|
||||
if not ui_hints:
|
||||
return None
|
||||
|
||||
try:
|
||||
compiled = compile_ui_hints(ui_hints)
|
||||
return compiled
|
||||
except Exception:
|
||||
return None
|
||||
Reference in New Issue
Block a user