From 42fe7bcbc9b32850982499b5f649dde4cf429642 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 21:56:08 +0800 Subject: [PATCH] feat(calendar): U3 agent calendar tool for ReAct integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CalendarTool implementing the Tool ABC so the ReAct engine can create, query, update, and delete events autonomously. Resolves event_type_name and tag_names (look up or create), sets source="agent" to distinguish agent-created events from manual ones. - src/agentkit/tools/calendar_tool.py — CalendarTool(Tool) - tests/unit/tools/test_calendar_tool.py — 13 tests covering all actions --- src/agentkit/tools/calendar_tool.py | 277 +++++++++++++++++++ tests/unit/tools/test_calendar_tool.py | 352 +++++++++++++++++++++++++ 2 files changed, 629 insertions(+) create mode 100644 src/agentkit/tools/calendar_tool.py create mode 100644 tests/unit/tools/test_calendar_tool.py diff --git a/src/agentkit/tools/calendar_tool.py b/src/agentkit/tools/calendar_tool.py new file mode 100644 index 0000000..cf81a8e --- /dev/null +++ b/src/agentkit/tools/calendar_tool.py @@ -0,0 +1,277 @@ +"""CalendarTool — Agent tool for calendar event CRUD via ReAct integration. + +Wraps CalendarService so the LLM can create, query, update, and delete +calendar events via function calling. The tool delegates all business logic +to CalendarService — it only handles input validation, name→id resolution +for event types and tags, and result formatting. + +The tool trusts the caller (the agent framework) to provide the correct +user_id; it does not perform auth (same pattern as DocumentTool). +""" + +from __future__ import annotations + +from typing import Any + +from agentkit.calendar.service import CalendarService +from agentkit.tools.base import Tool + + +class CalendarTool(Tool): + """Agent tool for calendar event management. + + Actions: create_event, query_events, update_event, delete_event. + """ + + def __init__(self, calendar_service: CalendarService): + super().__init__( + name="calendar", + description=( + "Create, query, update, and delete calendar events. " + "Actions: create_event, query_events, update_event, delete_event." + ), + input_schema={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "create_event", + "query_events", + "update_event", + "delete_event", + ], + "description": "Calendar operation to perform.", + }, + "user_id": { + "type": "string", + "description": "User ID owning the calendar events.", + }, + "event_id": { + "type": "string", + "description": "Event ID (for update_event and delete_event).", + }, + "title": { + "type": "string", + "description": "Event title (create_event, update_event).", + }, + "start_time": { + "type": "string", + "description": "Event start time, ISO 8601 UTC (create_event, update_event).", + }, + "end_time": { + "type": "string", + "description": "Event end time, ISO 8601 UTC (create_event, update_event).", + }, + "description": { + "type": "string", + "description": "Event description (create_event, update_event).", + }, + "location": { + "type": "string", + "description": "Event location (create_event, update_event).", + }, + "is_all_day": { + "type": "boolean", + "description": "Whether the event is all-day (create_event, update_event).", + }, + "event_type_name": { + "type": "string", + "description": "Event type name; looked up or created if missing (create_event).", + }, + "tag_names": { + "type": "array", + "items": {"type": "string"}, + "description": "Tag names; each looked up or created if missing (create_event).", + }, + "rrule": { + "type": "string", + "description": "RFC 5545 RRULE recurrence string, e.g. FREQ=WEEKLY;BYDAY=MO;COUNT=10 (create_event).", + }, + "conversation_id": { + "type": "string", + "description": "Conversation ID to associate with the event (create_event).", + }, + "start_date": { + "type": "string", + "description": "Range start, ISO 8601 UTC (query_events).", + }, + "end_date": { + "type": "string", + "description": "Range end, ISO 8601 UTC (query_events).", + }, + "limit": { + "type": "integer", + "description": "Maximum number of events to return (query_events).", + }, + }, + "required": ["action", "user_id"], + }, + ) + self._service = calendar_service + + async def execute(self, **kwargs) -> dict[str, Any]: + action = kwargs.get("action") + + if action == "create_event": + return await self._create_event(**kwargs) + if action == "query_events": + return await self._query_events(**kwargs) + if action == "update_event": + return await self._update_event(**kwargs) + if action == "delete_event": + return await self._delete_event(**kwargs) + return {"success": False, "error": f"Unknown action: {action!r}"} + + # ------------------------------------------------------------------ + # create_event + # ------------------------------------------------------------------ + + async def _create_event(self, **kwargs) -> dict[str, Any]: + user_id = kwargs.get("user_id") + title = kwargs.get("title") + start_time = kwargs.get("start_time") + end_time = kwargs.get("end_time") + + if not user_id: + return {"success": False, "error": "Missing required field: user_id"} + if not title: + return {"success": False, "error": "Missing required field: title"} + if not start_time: + return {"success": False, "error": "Missing required field: start_time"} + if not end_time: + return {"success": False, "error": "Missing required field: end_time"} + + description = kwargs.get("description", "") + location = kwargs.get("location", "") + is_all_day = kwargs.get("is_all_day", False) + rrule = kwargs.get("rrule") + conversation_id = kwargs.get("conversation_id") + + # Resolve event_type_name → event_type_id (look up or create) + event_type_id: str | None = None + event_type_name = kwargs.get("event_type_name") + if event_type_name: + event_type_id = await self._resolve_event_type_id(user_id, event_type_name) + + # Resolve tag_names → tag_ids (look up or create each) + tag_ids: list[str] | None = None + tag_names = kwargs.get("tag_names") + if tag_names: + tag_ids = await self._resolve_tag_ids(user_id, tag_names) + + try: + event = await self._service.create_event( + user_id=user_id, + title=title, + start_time=start_time, + end_time=end_time, + description=description, + location=location, + is_all_day=is_all_day, + event_type_id=event_type_id, + rrule=rrule, + source="agent", + conversation_id=conversation_id, + tag_ids=tag_ids, + ) + return {"success": True, "event": event.to_dict()} + except Exception as e: + return {"success": False, "error": f"create_event failed: {e}"} + + async def _resolve_event_type_id(self, user_id: str, name: str) -> str | None: + """Look up an event type by name for the user; create if not found.""" + existing = await self._service.list_event_types(user_id) + for et in existing: + if et.name == name: + return et.id + et = await self._service.create_event_type(user_id, name) + return et.id + + async def _resolve_tag_ids(self, user_id: str, names: list[str]) -> list[str]: + """Look up tags by name for the user; create each if not found.""" + existing = await self._service.list_tags(user_id) + existing_by_name = {t.name: t.id for t in existing} + tag_ids: list[str] = [] + for name in names: + if name in existing_by_name: + tag_ids.append(existing_by_name[name]) + else: + tag = await self._service.create_tag(user_id, name) + tag_ids.append(tag.id) + return tag_ids + + # ------------------------------------------------------------------ + # query_events + # ------------------------------------------------------------------ + + async def _query_events(self, **kwargs) -> dict[str, Any]: + user_id = kwargs.get("user_id") + if not user_id: + return {"success": False, "error": "Missing required field: user_id"} + + start = kwargs.get("start_date") + end = kwargs.get("end_date") + limit = kwargs.get("limit") + + try: + events = await self._service.list_events( + user_id=user_id, + start=start, + end=end, + ) + if limit is not None: + events = events[:limit] + return {"success": True, "events": [e.to_dict() for e in events]} + except Exception as e: + return {"success": False, "error": f"query_events failed: {e}"} + + # ------------------------------------------------------------------ + # update_event + # ------------------------------------------------------------------ + + async def _update_event(self, **kwargs) -> dict[str, Any]: + event_id = kwargs.get("event_id") + user_id = kwargs.get("user_id") + if not event_id: + return {"success": False, "error": "Missing required field: event_id"} + if not user_id: + return {"success": False, "error": "Missing required field: user_id"} + + # Build fields dict from updatable params (only those explicitly provided) + updatable = ["title", "description", "start_time", "end_time", "location", "is_all_day"] + fields: dict[str, Any] = {} + for key in updatable: + if key in kwargs and kwargs[key] is not None: + fields[key] = kwargs[key] + + if not fields: + return {"success": False, "error": "No fields to update"} + + try: + updated = await self._service.update_event(event_id, fields) + if not updated: + return {"success": False, "error": f"Event not found: {event_id}"} + return {"success": True} + except Exception as e: + return {"success": False, "error": f"update_event failed: {e}"} + + # ------------------------------------------------------------------ + # delete_event + # ------------------------------------------------------------------ + + async def _delete_event(self, **kwargs) -> dict[str, Any]: + event_id = kwargs.get("event_id") + user_id = kwargs.get("user_id") + if not event_id: + return {"success": False, "error": "Missing required field: event_id"} + if not user_id: + return {"success": False, "error": "Missing required field: user_id"} + + try: + deleted = await self._service.delete_event(event_id) + if not deleted: + return {"success": False, "error": f"Event not found: {event_id}"} + return {"success": True} + except Exception as e: + return {"success": False, "error": f"delete_event failed: {e}"} diff --git a/tests/unit/tools/test_calendar_tool.py b/tests/unit/tools/test_calendar_tool.py new file mode 100644 index 0000000..39f2468 --- /dev/null +++ b/tests/unit/tools/test_calendar_tool.py @@ -0,0 +1,352 @@ +"""Tests for CalendarTool — Agent tool wrapper for ReAct integration (U3).""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from agentkit.calendar.db import get_event_tags, init_calendar_db +from agentkit.calendar.service import CalendarService +from agentkit.tools.calendar_tool import CalendarTool + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def service(tmp_path: Path) -> CalendarService: + """Provide a CalendarService backed by a temp DB.""" + db_path = tmp_path / "test.db" + asyncio.run(init_calendar_db(db_path)) + return CalendarService(db_path=db_path) + + +@pytest.fixture +def tool(service: CalendarService) -> CalendarTool: + return CalendarTool(calendar_service=service) + + +# --------------------------------------------------------------------------- +# create_event +# --------------------------------------------------------------------------- + + +async def test_create_event_action_returns_success( + tool: CalendarTool, service: CalendarService +) -> None: + """create_event returns success and persists the event with source='agent'.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Sprint Planning", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + description="Bi-weekly sprint planning", + location="Room A", + ) + assert result["success"] is True + event_dict = result["event"] + assert event_dict["title"] == "Sprint Planning" + assert event_dict["source"] == "agent" + assert event_dict["user_id"] == "user-1" + + # Verify persisted in DB + fetched = await service.get_event(event_dict["id"]) + assert fetched is not None + assert fetched.title == "Sprint Planning" + assert fetched.source == "agent" + + +async def test_create_event_with_recurrence_sets_rrule( + tool: CalendarTool, service: CalendarService +) -> None: + """rrule param is stored correctly on the event.""" + rrule = "FREQ=WEEKLY;BYDAY=MO;COUNT=10" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Weekly Standup", + start_time="2026-07-06T09:00:00+00:00", + end_time="2026-07-06T09:30:00+00:00", + rrule=rrule, + ) + assert result["success"] is True + assert result["event"]["rrule"] == rrule + + fetched = await service.get_event(result["event"]["id"]) + assert fetched is not None + assert fetched.rrule == rrule + + +async def test_query_events_returns_list(tool: CalendarTool) -> None: + """create 2 events, query, verify both returned.""" + await tool.execute( + action="create_event", + user_id="user-1", + title="Event A", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + await tool.execute( + action="create_event", + user_id="user-1", + title="Event B", + start_time="2026-07-02T14:00:00+00:00", + end_time="2026-07-02T15:00:00+00:00", + ) + + result = await tool.execute( + action="query_events", + user_id="user-1", + ) + assert result["success"] is True + events = result["events"] + assert len(events) == 2 + titles = {e["title"] for e in events} + assert titles == {"Event A", "Event B"} + + +async def test_update_event_action_modifies_fields( + tool: CalendarTool, service: CalendarService +) -> None: + """create then update title; verify the field is modified.""" + create_result = await tool.execute( + action="create_event", + user_id="user-1", + title="Original Title", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + assert create_result["success"] is True + event_id = create_result["event"]["id"] + + update_result = await tool.execute( + action="update_event", + user_id="user-1", + event_id=event_id, + title="Updated Title", + ) + assert update_result["success"] is True + + fetched = await service.get_event(event_id) + assert fetched is not None + assert fetched.title == "Updated Title" + + +async def test_delete_event_action_removes_record( + tool: CalendarTool, service: CalendarService +) -> None: + """create then delete; verify the record is gone.""" + create_result = await tool.execute( + action="create_event", + user_id="user-1", + title="To Be Deleted", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + assert create_result["success"] is True + event_id = create_result["event"]["id"] + + delete_result = await tool.execute( + action="delete_event", + user_id="user-1", + event_id=event_id, + ) + assert delete_result["success"] is True + + fetched = await service.get_event(event_id) + assert fetched is None + + +# --------------------------------------------------------------------------- +# error paths +# --------------------------------------------------------------------------- + + +async def test_invalid_action_returns_error(tool: CalendarTool) -> None: + """Unknown action returns success=False with error message.""" + result = await tool.execute( + action="frobnicate", + user_id="user-1", + ) + assert result["success"] is False + assert "Unknown action" in result["error"] + + +async def test_missing_required_field_returns_error(tool: CalendarTool) -> None: + """create_event without title returns success=False.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + assert result["success"] is False + assert "Missing required field" in result["error"] + assert "title" in result["error"] + + +# --------------------------------------------------------------------------- +# conversation_id +# --------------------------------------------------------------------------- + + +async def test_created_event_has_conversation_id( + tool: CalendarTool, service: CalendarService +) -> None: + """conversation_id is set from context when provided.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Chat-Initiated Event", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + conversation_id="conv-abc-123", + ) + assert result["success"] is True + assert result["event"]["conversation_id"] == "conv-abc-123" + + fetched = await service.get_event(result["event"]["id"]) + assert fetched is not None + assert fetched.conversation_id == "conv-abc-123" + + +# --------------------------------------------------------------------------- +# event_type_name resolution +# --------------------------------------------------------------------------- + + +async def test_create_event_with_event_type_name( + tool: CalendarTool, service: CalendarService +) -> None: + """create event with event_type_name='Meeting' creates the type and links it.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Strategy Sync", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + event_type_name="Meeting", + ) + assert result["success"] is True + event_type_id = result["event"]["event_type_id"] + assert event_type_id is not None + + # Verify the event type was created + types = await service.list_event_types("user-1") + meeting_types = [t for t in types if t.name == "Meeting"] + assert len(meeting_types) == 1 + assert meeting_types[0].id == event_type_id + + # Verify the event references the type + fetched = await service.get_event(result["event"]["id"]) + assert fetched is not None + assert fetched.event_type_id == event_type_id + + +async def test_create_event_reuses_existing_event_type( + tool: CalendarTool, service: CalendarService +) -> None: + """If event_type_name matches an existing type, it is reused (not duplicated).""" + # Pre-create the type + existing = await service.create_event_type("user-1", "Meeting") + + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Second Meeting", + start_time="2026-07-02T10:00:00+00:00", + end_time="2026-07-02T11:00:00+00:00", + event_type_name="Meeting", + ) + assert result["success"] is True + assert result["event"]["event_type_id"] == existing.id + + # No duplicate type created + types = await service.list_event_types("user-1") + meeting_types = [t for t in types if t.name == "Meeting"] + assert len(meeting_types) == 1 + + +# --------------------------------------------------------------------------- +# tag_names resolution +# --------------------------------------------------------------------------- + + +async def test_create_event_with_tag_names( + tool: CalendarTool, service: CalendarService, tmp_path: Path +) -> None: + """create event with tag_names=['urgent', 'work'] creates tags and links them.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Urgent Work Task", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + tag_names=["urgent", "work"], + ) + assert result["success"] is True + event_id = result["event"]["id"] + + # Verify tags were created + tags = await service.list_tags("user-1") + tag_names = {t.name for t in tags} + assert "urgent" in tag_names + assert "work" in tag_names + + # Verify tags are linked to the event + linked_tags = await get_event_tags(event_id, service.db_path) + linked_names = {t.name for t in linked_tags} + assert linked_names == {"urgent", "work"} + + +async def test_create_event_reuses_existing_tags( + tool: CalendarTool, service: CalendarService +) -> None: + """If a tag name matches an existing tag, it is reused (not duplicated).""" + # Pre-create a tag + existing = await service.create_tag("user-1", "urgent") + + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Tagged Event", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + tag_names=["urgent", "new-tag"], + ) + assert result["success"] is True + + # 'urgent' should not be duplicated, 'new-tag' should be created + tags = await service.list_tags("user-1") + urgent_tags = [t for t in tags if t.name == "urgent"] + assert len(urgent_tags) == 1 + assert urgent_tags[0].id == existing.id + new_tags = [t for t in tags if t.name == "new-tag"] + assert len(new_tags) == 1 + + +# --------------------------------------------------------------------------- +# tool registration / schema +# --------------------------------------------------------------------------- + + +def test_tool_name_and_schema(tool: CalendarTool) -> None: + """Tool has correct name and input_schema.""" + assert tool.name == "calendar" + schema = tool.input_schema + assert schema["type"] == "object" + assert "action" in schema["properties"] + assert "user_id" in schema["properties"] + assert schema["properties"]["action"]["enum"] == [ + "create_event", + "query_events", + "update_event", + "delete_event", + ] + assert "action" in schema["required"] + assert "user_id" in schema["required"]