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

This commit is contained in:
qzl
2026-03-11 15:28:29 +08:00
parent e55e445906
commit e20e7d2a02
85 changed files with 5175 additions and 885 deletions
@@ -0,0 +1,3 @@
from core.agentscope.tools.toolkit import build_stage_toolkit, build_toolkit
__all__ = ["build_toolkit", "build_stage_toolkit"]
@@ -0,0 +1,3 @@
from core.agentscope.tools.custom.calendar import calendar_read, calendar_write
__all__ = ["calendar_read", "calendar_write"]
@@ -0,0 +1,232 @@
from typing import Annotated, Any, Literal, cast
from uuid import UUID
from pydantic import Field
from core.auth.jwt_verifier import JwtVerifier, TokenValidationError
from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import (
_execute_list_calendar_events,
_execute_mutate_calendar_event,
)
from core.config.settings import config
from core.agentscope.tools.response import build_tool_response
def _unauthorized_response() -> dict[str, object]:
return {
"type": "calendar_operation.v1",
"version": "v1",
"data": {
"ok": False,
"code": "UNAUTHORIZED",
"message": "calendar.write requires validated user token",
},
"actions": [],
}
def _invalid_argument_response(*, message: str) -> dict[str, object]:
return {
"type": "calendar_operation.v1",
"version": "v1",
"data": {
"ok": False,
"code": "INVALID_ARGUMENT",
"message": message,
},
"actions": [],
}
def _verify_user_token(*, user_token: str, owner_id: UUID) -> bool:
jwt_secret = config.supabase.jwt_secret
if jwt_secret is None:
return False
verifier = JwtVerifier(
issuer=str(config.supabase.jwt_issuer),
jwt_secret=jwt_secret.get_secret_value(),
jwt_algorithm=config.supabase.jwt_algorithm,
)
try:
payload = verifier.verify(user_token)
except TokenValidationError:
return False
subject = payload.get("sub")
return isinstance(subject, str) and subject == str(owner_id)
async def calendar_read(
query: Annotated[
str | None,
Field(description="Optional keyword to filter calendar events."),
] = None,
page: Annotated[
int,
Field(description="Page number, starting from 1.", ge=1),
] = 1,
page_size: Annotated[
int,
Field(description="Number of items per page (1-100).", ge=1, le=100),
] = 20,
session: Any = None,
owner_id: Any = None,
user_token: str | None = None,
) -> Any:
"""Read calendar events and return a structured paginated response.
Args:
query: Optional search keyword for event filtering.
page: Page index starting from 1.
page_size: Page size for pagination.
session: Runtime-injected database session.
owner_id: Runtime-injected user ID.
user_token: Runtime-injected user access token.
Returns:
A tool response payload containing a calendar event list.
"""
if session is None or owner_id is None:
raise ValueError("calendar.read missing runtime preset arguments")
if not isinstance(user_token, str) or not user_token.strip():
return build_tool_response(_unauthorized_response())
if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)):
return build_tool_response(_unauthorized_response())
result = await _execute_list_calendar_events(
session=cast(Any, session),
owner_id=cast(UUID, owner_id),
tool_args={"query": query, "page": page, "pageSize": page_size},
)
return build_tool_response(result)
async def calendar_write(
operation: Annotated[
Literal["create", "update", "delete"],
Field(description="Write operation: create, update, or delete."),
],
event_id: Annotated[
str | None,
Field(description="Required event ID for update/delete operations."),
] = None,
title: Annotated[
str | None,
Field(description="Event title.", max_length=255),
] = None,
description: Annotated[
str | None,
Field(description="Event description.", max_length=2000),
] = None,
start_at: Annotated[
str | None,
Field(description="Event start time in ISO 8601 format."),
] = None,
end_at: Annotated[
str | None,
Field(description="Event end time in ISO 8601 format."),
] = None,
timezone: Annotated[
str | None,
Field(description="IANA timezone name for the event.", max_length=50),
] = None,
location: Annotated[str | None, Field(description="Event location.")] = None,
color: Annotated[
str | None,
Field(description="Event color value, for example #4F46E5."),
] = None,
status: Annotated[
Literal["active", "completed", "canceled", "archived"] | None,
Field(description="Event status: active, completed, canceled, or archived."),
] = None,
replace: Annotated[
bool,
Field(description="Whether to use the replace strategy for conflicts."),
] = False,
session: Any = None,
owner_id: Any = None,
user_token: str | None = None,
) -> Any:
"""Execute calendar write operations with runtime authorization checks.
Args:
operation: Write operation type.
event_id: Target event ID.
title: Event title.
description: Event description.
start_at: Event start time in ISO 8601 format.
end_at: Event end time in ISO 8601 format.
timezone: Event timezone.
location: Event location.
color: Event color.
status: Event lifecycle status.
replace: Replace-strategy flag for conflict handling.
session: Runtime-injected database session.
owner_id: Runtime-injected user ID.
user_token: Runtime-injected user access token.
Returns:
A tool response payload describing the mutation result.
"""
if operation in ("update", "delete") and (
not isinstance(event_id, str) or not event_id.strip()
):
return build_tool_response(
_invalid_argument_response(
message="event_id is required for update and delete operations"
)
)
if operation == "create" and isinstance(event_id, str) and event_id.strip():
return build_tool_response(
_invalid_argument_response(
message="event_id must not be provided for create operation"
)
)
if isinstance(title, str) and len(title.strip()) > 255:
return build_tool_response(
_invalid_argument_response(message="title length must be <= 255")
)
if isinstance(description, str) and len(description.strip()) > 2000:
return build_tool_response(
_invalid_argument_response(message="description length must be <= 2000")
)
if isinstance(timezone, str) and len(timezone.strip()) > 50:
return build_tool_response(
_invalid_argument_response(message="timezone length must be <= 50")
)
if session is None or owner_id is None:
raise ValueError("calendar.write missing runtime preset arguments")
if not isinstance(user_token, str) or not user_token.strip():
return build_tool_response(_unauthorized_response())
if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)):
return build_tool_response(_unauthorized_response())
tool_args: dict[str, object] = {
"operation": operation,
"replace": replace,
}
if event_id is not None:
tool_args["eventId"] = event_id
if title is not None:
tool_args["title"] = title
if description is not None:
tool_args["description"] = description
if start_at is not None:
tool_args["startAt"] = start_at
if end_at is not None:
tool_args["endAt"] = end_at
if timezone is not None:
tool_args["timezone"] = timezone
if location is not None:
tool_args["location"] = location
if color is not None:
tool_args["color"] = color
if status is not None:
tool_args["status"] = status
result = await _execute_mutate_calendar_event(
session=cast(Any, session),
owner_id=cast(UUID, owner_id),
tool_args=tool_args,
)
return build_tool_response(result)
@@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Any, AsyncGenerator, Callable
from core.agentscope.tools.response import build_tool_response
from core.agentscope.tools.tool_meta import ToolMeta
def register_tool_middlewares(
*,
toolkit: Any,
meta_by_name: dict[str, ToolMeta],
) -> None:
toolkit.register_middleware(create_hitl_middleware(meta_by_name=meta_by_name))
def create_hitl_middleware(
*,
meta_by_name: dict[str, ToolMeta],
approval_resolver: Callable[[str, dict[str, Any]], str | None] | None = None,
) -> Callable[..., AsyncGenerator[Any, None]]:
async def hitl_middleware(
kwargs: dict[str, Any],
next_handler: Callable,
) -> AsyncGenerator[Any, None]:
tool_call = kwargs.get("tool_call")
if not isinstance(tool_call, dict):
async for response in await next_handler(**kwargs):
yield response
return
tool_name = tool_call.get("name")
if not isinstance(tool_name, str):
async for response in await next_handler(**kwargs):
yield response
return
meta = meta_by_name.get(tool_name)
if meta is None or not meta.requires_approval:
async for response in await next_handler(**kwargs):
yield response
return
tool_input = tool_call.get("input")
tool_args = tool_input if isinstance(tool_input, dict) else {}
decision = (
approval_resolver(tool_name, tool_args) if approval_resolver else None
)
if decision == "approved":
sanitized_args = {
key: value for key, value in tool_args.items() if key != "_hitl"
}
next_call = {**tool_call, "input": sanitized_args}
next_kwargs = {**kwargs, "tool_call": next_call}
async for response in await next_handler(**next_kwargs):
yield response
return
if decision == "rejected":
yield build_tool_response(
{
"type": "tool_approval.v1",
"version": "v1",
"data": {
"status": "rejected",
"tool": tool_name,
"ok": False,
"message": "tool call rejected by reviewer",
},
}
)
return
yield build_tool_response(
{
"type": "tool_approval.v1",
"version": "v1",
"data": {
"status": "pending",
"tool": tool_name,
"ok": False,
"message": "tool call requires approval",
},
}
)
return hitl_middleware
@@ -0,0 +1,18 @@
from __future__ import annotations
import json
from typing import Any
def build_tool_response(payload: dict[str, Any]):
from agentscope.message import TextBlock
from agentscope.tool import ToolResponse
return ToolResponse(
content=[
TextBlock(
type="text",
text=json.dumps(payload, ensure_ascii=True, separators=(",", ":")),
)
]
)
@@ -0,0 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
TOOL_APPROVAL_REQUIRED: dict[str, bool] = {
"calendar.read": False,
"calendar.write": False,
}
@dataclass(frozen=True)
class ToolMeta:
name: str
requires_approval: bool
TOOL_META: dict[str, ToolMeta] = {
tool_name: ToolMeta(name=tool_name, requires_approval=requires_approval)
for tool_name, requires_approval in TOOL_APPROVAL_REQUIRED.items()
}
@@ -0,0 +1,126 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, cast
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.tools.custom.calendar import calendar_read, calendar_write
from core.agentscope.tools.hitl_middleware import register_tool_middlewares
from core.agentscope.tools.tool_meta import TOOL_META
@dataclass(frozen=True)
class CustomToolBinding:
name: str
func: Any
preset_kwargs: dict[str, object]
@dataclass(frozen=True)
class ToolGroup:
stage: str
tool_names: frozenset[str]
TOOL_GROUPS: dict[str, ToolGroup] = {
"intent": ToolGroup(stage="intent", tool_names=frozenset({"calendar.read"})),
"execution": ToolGroup(
stage="execution",
tool_names=frozenset({"calendar.read", "calendar.write"}),
),
"report": ToolGroup(stage="report", tool_names=frozenset()),
}
def get_tool_group(stage: str) -> ToolGroup:
group = TOOL_GROUPS.get(stage)
if group is None:
raise ValueError(f"unknown tool group stage: {stage}")
return group
def _load_custom_tool_bindings(
*,
session: AsyncSession,
owner_id: UUID,
user_token: str | None,
) -> list[CustomToolBinding]:
return [
CustomToolBinding(
name="calendar.read",
func=calendar_read,
preset_kwargs={
"session": session,
"owner_id": owner_id,
"user_token": user_token or "",
},
),
CustomToolBinding(
name="calendar.write",
func=calendar_write,
preset_kwargs={
"session": session,
"owner_id": owner_id,
"user_token": user_token or "",
},
),
]
def build_toolkit(
*,
session: AsyncSession,
owner_id: UUID,
user_token: str | None = None,
enable_hitl: bool = True,
enabled_tool_names: set[str] | None = None,
):
from agentscope.tool import Toolkit
from agentscope.types import JSONSerializableObject
toolkit = Toolkit()
bindings = _load_custom_tool_bindings(
session=session,
owner_id=owner_id,
user_token=user_token,
)
registered_tool_names: set[str] = set()
for binding in bindings:
if enabled_tool_names is not None and binding.name not in enabled_tool_names:
continue
registered_tool_names.add(binding.name)
toolkit.register_tool_function(
binding.func,
func_name=binding.name,
preset_kwargs=cast(
dict[str, JSONSerializableObject],
binding.preset_kwargs,
),
)
if enabled_tool_names is not None:
missing = enabled_tool_names - registered_tool_names
if missing:
raise ValueError(f"unknown tools in enabled_tool_names: {sorted(missing)}")
if enable_hitl:
register_tool_middlewares(toolkit=toolkit, meta_by_name=TOOL_META)
return toolkit
def build_stage_toolkit(
*,
stage: str,
session: AsyncSession,
owner_id: UUID,
user_token: str | None = None,
enable_hitl: bool = True,
):
group = get_tool_group(stage)
return build_toolkit(
session=session,
owner_id=owner_id,
user_token=user_token,
enable_hitl=enable_hitl,
enabled_tool_names=set(group.tool_names),
)