feat: 增强日历功能并集成 AgentScope 代理服务
This commit is contained in:
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user