feat: 增强日历功能并集成 AgentScope 代理服务

This commit is contained in:
qzl
2026-03-11 17:16:11 +08:00
parent e20e7d2a02
commit 85b314cf64
53 changed files with 3642 additions and 297 deletions
@@ -0,0 +1,14 @@
from core.agentscope.events.agui_codec import AgentScopeAgUiCodec, to_agui_wire_event
from core.agentscope.events.pipeline import AgentScopeEventPipeline
from core.agentscope.events.redis_bus import RedisStreamBus
from core.agentscope.events.sse import to_sse_event
from core.agentscope.events.store import NullEventStore
__all__ = [
"AgentScopeAgUiCodec",
"AgentScopeEventPipeline",
"RedisStreamBus",
"NullEventStore",
"to_agui_wire_event",
"to_sse_event",
]
@@ -0,0 +1,49 @@
from __future__ import annotations
from typing import Any, cast
_TYPE_MAP: dict[str, str] = {
"run.started": "RUN_STARTED",
"run.finished": "RUN_FINISHED",
"run.error": "RUN_ERROR",
"step.start": "STEP_STARTED",
"step.finish": "STEP_FINISHED",
"text.start": "TEXT_MESSAGE_START",
"text.delta": "TEXT_MESSAGE_CONTENT",
"text.end": "TEXT_MESSAGE_END",
"tool.start": "TOOL_CALL_START",
"tool.args": "TOOL_CALL_ARGS",
"tool.end": "TOOL_CALL_END",
"tool.result": "TOOL_CALL_RESULT",
"tool.error": "TOOL_CALL_ERROR",
"state.snapshot": "STATE_SNAPSHOT",
"messages.snapshot": "MESSAGES_SNAPSHOT",
}
def to_agui_wire_event(event: dict[str, Any]) -> dict[str, Any]:
event_type = str(event.get("type", "")).strip()
wire_type = _TYPE_MAP.get(event_type, event_type.upper().replace(".", "_"))
payload: dict[str, Any] = {
"type": wire_type,
}
thread_id = event.get("threadId")
run_id = event.get("runId")
if isinstance(thread_id, str) and thread_id:
payload["threadId"] = thread_id
if isinstance(run_id, str) and run_id:
payload["runId"] = run_id
data = event.get("data")
if isinstance(data, dict):
reserved = {"type", "threadId", "runId"}
data_map = cast(dict[str, Any], data)
payload.update({k: v for k, v in data_map.items() if k not in reserved})
return payload
class AgentScopeAgUiCodec:
def to_wire(self, event: dict[str, Any]) -> dict[str, Any]:
return to_agui_wire_event(event)
@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Any, Protocol
class CodecLike(Protocol):
def to_wire(self, event: dict[str, Any]) -> dict[str, Any]: ...
class StoreLike(Protocol):
async def persist(self, event: dict[str, Any]) -> None: ...
class BusLike(Protocol):
async def publish(self, *, session_id: str, event: dict[str, Any]) -> str: ...
class AgentScopeEventPipeline:
_codec: CodecLike
_store: StoreLike
_bus: BusLike
def __init__(self, *, codec: CodecLike, store: StoreLike, bus: BusLike) -> None:
self._codec = codec
self._store = store
self._bus = bus
async def emit(self, *, session_id: str, event: dict[str, Any]) -> str:
wire_event = self._codec.to_wire(event)
await self._store.persist(wire_event)
return await self._bus.publish(session_id=session_id, event=wire_event)
@@ -0,0 +1,91 @@
from __future__ import annotations
import inspect
import json
from typing import Any, Protocol, cast
class RedisStreamClient(Protocol):
def xadd(self, *args: Any, **kwargs: Any) -> Any: ...
def xread(self, *args: Any, **kwargs: Any) -> Any: ...
class RedisStreamBus:
_client: RedisStreamClient
_stream_prefix: str
_read_count: int
_block_ms: int
def __init__(
self,
*,
client: RedisStreamClient,
stream_prefix: str,
read_count: int = 100,
block_ms: int = 5000,
) -> None:
self._client = client
self._stream_prefix = stream_prefix
self._read_count = read_count
self._block_ms = block_ms
async def publish(self, *, session_id: str, event: dict[str, Any]) -> str:
payload = json.dumps(event, ensure_ascii=True, separators=(",", ":"))
result = self._client.xadd(self._stream_name(session_id), {"event": payload})
if inspect.isawaitable(result):
return str(await result)
return str(result)
async def read(
self,
*,
session_id: str,
last_event_id: str | None,
) -> list[dict[str, Any]]:
stream = self._stream_name(session_id)
start_id = "0-0" if last_event_id is None else last_event_id
raw = self._client.xread(
{stream: start_id},
count=self._read_count,
block=self._block_ms,
)
response = await raw if inspect.isawaitable(raw) else raw
if not response:
return []
first = response[0]
if (
not isinstance(first, tuple)
or len(first) != 2
or not isinstance(first[1], list)
):
return []
entries = cast(list[tuple[str, dict[str, Any]]], first[1])
rows: list[dict[str, Any]] = []
for entry in entries:
if (
not isinstance(entry, tuple)
or len(entry) != 2
or not isinstance(entry[0], str)
or not isinstance(entry[1], dict)
):
continue
payload_map = cast(dict[str, Any], entry[1])
event_payload = payload_map.get("event")
if isinstance(event_payload, bytes):
event_payload = event_payload.decode("utf-8", errors="replace")
if not isinstance(event_payload, str):
continue
try:
decoded = json.loads(event_payload)
except (TypeError, ValueError):
continue
if not isinstance(decoded, dict):
continue
rows.append({"id": entry[0], "event": decoded})
return rows
def _stream_name(self, session_id: str) -> str:
return f"{self._stream_prefix}:{session_id}"
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import json
import re
from typing import Any
from ag_ui.core.events import BaseEvent
from ag_ui.encoder.encoder import EventEncoder
_EVENT_TYPE_RE = re.compile(r"^[A-Z0-9_]+$")
_ENCODER = EventEncoder()
def to_sse_event(stream_id: str, event: dict[str, Any]) -> str:
safe_stream_id = str(stream_id).replace("\r", "").replace("\n", "")
try:
event_model = BaseEvent.model_validate(event)
event_type = event_model.type.value
encoded_data = _ENCODER.encode(event_model)
return f"id: {safe_stream_id}\nevent: {event_type}\n{encoded_data}"
except Exception: # noqa: BLE001
raw_event_type = (
str(event.get("type", "MESSAGE")).replace("\r", "").replace("\n", "")
)
event_type = (
raw_event_type if _EVENT_TYPE_RE.fullmatch(raw_event_type) else "MESSAGE"
)
payload = json.dumps(event, ensure_ascii=True, separators=(",", ":"))
return f"id: {safe_stream_id}\nevent: {event_type}\ndata: {payload}\n\n"
@@ -0,0 +1,12 @@
from __future__ import annotations
from typing import Any, Protocol
class EventStore(Protocol):
async def persist(self, event: dict[str, Any]) -> None: ...
class NullEventStore:
async def persist(self, event: dict[str, Any]) -> None:
del event
@@ -1,4 +1,9 @@
from core.agentscope.runtime.agent_route_runtime import AgentRouteRuntime
from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator
from core.agentscope.runtime.react_runner import AgentScopeReActRunner
__all__ = ["AgentScopeRuntimeOrchestrator", "AgentScopeReActRunner"]
__all__ = [
"AgentRouteRuntime",
"AgentScopeRuntimeOrchestrator",
"AgentScopeReActRunner",
]
@@ -0,0 +1,215 @@
from __future__ import annotations
from typing import Any, Protocol
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from core.agent.domain.user_context import UserAgentContext
from core.logging import get_logger
from core.agentscope.schemas import RuntimeOutput
from core.agentscope.schemas.agent_runtime import ResumeCommand, RunCommand
class OrchestratorLike(Protocol):
async def run(
self,
*,
session: AsyncSession,
owner_id: UUID,
user_token: str,
user_context: UserAgentContext,
user_input: str | list[dict[str, Any]],
) -> RuntimeOutput: ...
class PipelineLike(Protocol):
async def emit(self, *, session_id: str, event: dict[str, Any]) -> str: ...
class AgentRouteRuntime:
_orchestrator: OrchestratorLike
_pipeline: PipelineLike
_logger = get_logger("core.agentscope.runtime.agent_route_runtime")
def __init__(
self, *, orchestrator: OrchestratorLike, pipeline: PipelineLike
) -> None:
self._orchestrator = orchestrator
self._pipeline = pipeline
async def run(
self,
*,
command: RunCommand,
owner_id: UUID,
user_token: str,
user_context: UserAgentContext,
session: AsyncSession,
) -> RuntimeOutput:
return await self._execute(
command=command,
owner_id=owner_id,
user_token=user_token,
user_context=user_context,
session=session,
)
async def resume(
self,
*,
command: ResumeCommand,
owner_id: UUID,
user_token: str,
user_context: UserAgentContext,
session: AsyncSession,
) -> RuntimeOutput:
return await self._execute(
command=command,
owner_id=owner_id,
user_token=user_token,
user_context=user_context,
session=session,
)
async def _execute(
self,
*,
command: RunCommand,
owner_id: UUID,
user_token: str,
user_context: UserAgentContext,
session: AsyncSession,
) -> RuntimeOutput:
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "run.started",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {},
},
)
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "step.start",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {"stepName": "intent"},
},
)
try:
result = await self._orchestrator.run(
session=session,
owner_id=owner_id,
user_token=user_token,
user_context=user_context,
user_input=command.messages,
)
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "step.finish",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {"stepName": "intent"},
},
)
except Exception: # noqa: BLE001
self._logger.exception(
"agentscope runtime execution failed",
thread_id=command.thread_id,
run_id=command.run_id,
)
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "run.error",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {"message": "runtime execution failed"},
},
)
raise
if result.execution is not None:
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "step.start",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {"stepName": "execution"},
},
)
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "step.finish",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {"stepName": "execution"},
},
)
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "step.start",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {"stepName": "report"},
},
)
report_message_id = f"assistant-{command.run_id}"
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "text.start",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {"messageId": report_message_id, "role": "assistant"},
},
)
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "text.delta",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {
"messageId": report_message_id,
"delta": result.report.assistant_text,
},
},
)
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "text.end",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {"messageId": report_message_id},
},
)
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "step.finish",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {"stepName": "report"},
},
)
await self._pipeline.emit(
session_id=command.thread_id,
event={
"type": "run.finished",
"threadId": command.thread_id,
"runId": command.run_id,
"data": {},
},
)
return result
@@ -0,0 +1,138 @@
from __future__ import annotations
from typing import Any
from uuid import UUID
from core.agent.domain.user_context import UserAgentContext, parse_profile_settings
from core.agentscope.events import (
AgentScopeAgUiCodec,
AgentScopeEventPipeline,
NullEventStore,
RedisStreamBus,
)
from core.agentscope.runtime import AgentRouteRuntime, AgentScopeRuntimeOrchestrator
from core.agentscope.schemas.agent_runtime import ResumeCommand, RunCommand
from core.config.settings import config
from core.db.session import AsyncSessionLocal
from core.logging import get_logger
from core.taskiq.app import bulk_broker, critical_broker, default_broker
from services.base.redis import get_or_init_redis_client
logger = get_logger("core.agentscope.runtime.tasks")
def _build_user_context(*, owner_id: UUID, run_input: RunCommand) -> UserAgentContext:
forwarded = (
run_input.forwarded_props if isinstance(run_input.forwarded_props, dict) else {}
)
username = str(forwarded.get("username", "user")).strip() or "user"
bio_value = forwarded.get("bio")
bio = str(bio_value).strip() if isinstance(bio_value, str) else None
profile_settings = forwarded.get("profileSettings")
settings_raw = profile_settings if isinstance(profile_settings, dict) else None
return UserAgentContext(
user_id=owner_id,
username=username,
bio=bio,
settings=parse_profile_settings(settings_raw),
)
def _extract_user_token(
*, command: dict[str, Any], run_input: RunCommand
) -> str | None:
raw_token = command.get("user_token")
if isinstance(raw_token, str) and raw_token.strip():
return raw_token.strip()
forwarded = (
run_input.forwarded_props if isinstance(run_input.forwarded_props, dict) else {}
)
for key in ("accessToken", "userToken", "token"):
value = forwarded.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
command_type = str(command.get("command", "run")).strip().lower()
raw_run_input = command.get("run_input")
raw_owner_id = command.get("owner_id")
if not isinstance(raw_run_input, dict):
raise ValueError("run_input is required")
if not isinstance(raw_owner_id, str) or not raw_owner_id.strip():
raise ValueError("owner_id is required")
owner_id = UUID(raw_owner_id)
parsed_run_input = (
ResumeCommand.model_validate(raw_run_input)
if command_type == "resume"
else RunCommand.model_validate(raw_run_input)
)
user_context = _build_user_context(owner_id=owner_id, run_input=parsed_run_input)
user_token = _extract_user_token(command=command, run_input=parsed_run_input) or ""
redis_client = await get_or_init_redis_client()
bus = RedisStreamBus(
client=redis_client,
stream_prefix=config.agent_runtime.redis_stream_prefix,
read_count=config.agent_runtime.redis_stream_read_count,
block_ms=config.agent_runtime.redis_stream_block_ms,
)
pipeline = AgentScopeEventPipeline(
codec=AgentScopeAgUiCodec(),
store=NullEventStore(),
bus=bus,
)
runtime = AgentRouteRuntime(
orchestrator=AgentScopeRuntimeOrchestrator(),
pipeline=pipeline,
)
async with AsyncSessionLocal() as session:
if command_type == "resume":
await runtime.resume(
command=ResumeCommand.model_validate(raw_run_input),
owner_id=owner_id,
user_token=user_token,
user_context=user_context,
session=session,
)
elif command_type == "run":
await runtime.run(
command=RunCommand.model_validate(raw_run_input),
owner_id=owner_id,
user_token=user_token,
user_context=user_context,
session=session,
)
else:
raise ValueError("invalid command type")
logger.info(
"agentscope runtime task completed",
command_type=command_type,
thread_id=parsed_run_input.thread_id,
run_id=parsed_run_input.run_id,
)
return {
"thread_id": parsed_run_input.thread_id,
"run_id": parsed_run_input.run_id,
"status": "completed",
}
@default_broker.task(task_name="tasks.agentscope.run_command")
async def run_command_task(command: dict[str, Any]) -> dict[str, object]:
return await run_agentscope_task(command)
@critical_broker.task(task_name="tasks.agentscope.run_command.critical")
async def run_command_task_critical(command: dict[str, Any]) -> dict[str, object]:
return await run_agentscope_task(command)
@bulk_broker.task(task_name="tasks.agentscope.run_command.bulk")
async def run_command_task_bulk(command: dict[str, Any]) -> dict[str, object]:
return await run_agentscope_task(command)
@@ -1,13 +1,31 @@
from core.agentscope.schemas.agent_runtime import (
AcceptedTaskResponse,
AgUiWireEvent,
HistorySnapshotResponse,
InternalRuntimeEvent,
ResumeCommand,
RunCommand,
TaskAccepted,
TaskAcceptedResponse,
)
from core.agentscope.schemas.execution import ExecutionBatchOutput, ExecutionTaskOutput
from core.agentscope.schemas.intent import IntentOutput, IntentTask
from core.agentscope.schemas.report import ReportOutput
from core.agentscope.schemas.runtime import RuntimeOutput
__all__ = [
"AgUiWireEvent",
"AcceptedTaskResponse",
"ExecutionBatchOutput",
"ExecutionTaskOutput",
"HistorySnapshotResponse",
"IntentOutput",
"IntentTask",
"InternalRuntimeEvent",
"ReportOutput",
"ResumeCommand",
"RuntimeOutput",
"RunCommand",
"TaskAccepted",
"TaskAcceptedResponse",
]
@@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Any, ClassVar, Literal
from pydantic import BaseModel, ConfigDict, Field
class _AliasModel(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(
populate_by_name=True, serialize_by_alias=True, extra="forbid"
)
class AcceptedTaskResponse(_AliasModel):
task_id: str = Field(alias="taskId", min_length=1)
thread_id: str = Field(alias="threadId", min_length=1)
run_id: str = Field(alias="runId", min_length=1)
created: bool
class RunCommand(_AliasModel):
thread_id: str = Field(alias="threadId", min_length=1)
run_id: str = Field(alias="runId", min_length=1)
state: dict[str, Any] | None = None
messages: list[dict[str, Any]] = Field(default_factory=list)
tools: list[dict[str, Any]] = Field(default_factory=list)
context: dict[str, Any] = Field(default_factory=dict)
forwarded_props: dict[str, Any] = Field(
default_factory=dict, alias="forwardedProps"
)
class ResumeCommand(RunCommand):
pass
# Backward compatibility alias during migration.
TaskAcceptedResponse = AcceptedTaskResponse
TaskAccepted = AcceptedTaskResponse
class InternalRuntimeEvent(_AliasModel):
type: str = Field(min_length=1)
thread_id: str | None = Field(default=None, alias="threadId")
run_id: str | None = Field(default=None, alias="runId")
data: dict[str, Any] = Field(default_factory=dict)
class AgUiWireEvent(_AliasModel):
type: str = Field(min_length=1)
thread_id: str | None = Field(default=None, alias="threadId")
run_id: str | None = Field(default=None, alias="runId")
payload: Any = None
class HistorySnapshot(_AliasModel):
scope: Literal["history_day"] = "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[dict[str, Any]] = Field(default_factory=list)
class HistorySnapshotResponse(_AliasModel):
type: Literal["STATE_SNAPSHOT"] = "STATE_SNAPSHOT"
thread_id: str | None = Field(default=None, alias="threadId")
run_id: str | None = Field(default=None, alias="runId")
snapshot: HistorySnapshot
@@ -134,6 +134,14 @@ async def calendar_write(
str | None,
Field(description="Event color value, for example #4F46E5."),
] = None,
reminder_minutes: Annotated[
int | None,
Field(
description="Minutes before start time to trigger reminder (0-10080).",
ge=0,
le=10080,
),
] = None,
status: Annotated[
Literal["active", "completed", "canceled", "archived"] | None,
Field(description="Event status: active, completed, canceled, or archived."),
@@ -158,6 +166,7 @@ async def calendar_write(
timezone: Event timezone.
location: Event location.
color: Event color.
reminder_minutes: Reminder minutes before event start.
status: Event lifecycle status.
replace: Replace-strategy flag for conflict handling.
session: Runtime-injected database session.
@@ -193,6 +202,12 @@ async def calendar_write(
return build_tool_response(
_invalid_argument_response(message="timezone length must be <= 50")
)
if reminder_minutes is not None and (
reminder_minutes < 0 or reminder_minutes > 10080
):
return build_tool_response(
_invalid_argument_response(message="reminder_minutes must be 0..10080")
)
if session is None or owner_id is None:
raise ValueError("calendar.write missing runtime preset arguments")
@@ -221,6 +236,8 @@ async def calendar_write(
tool_args["location"] = location
if color is not None:
tool_args["color"] = color
if reminder_minutes is not None:
tool_args["reminderMinutes"] = reminder_minutes
if status is not None:
tool_args["status"] = status