feat: 实现 AgentScope ReAct Runner 两阶段执行并重构事件处理

This commit is contained in:
zl-q
2026-03-16 09:01:01 +08:00
parent 072c09d99d
commit dcceb48d84
51 changed files with 5015 additions and 5663 deletions
+6 -5
View File
@@ -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
+7
View File
@@ -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(),
}
+3 -2
View File
@@ -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,
+34
View File
@@ -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)
+45 -25
View File
@@ -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,
+149
View File
@@ -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