feat(calendar): U3 agent calendar tool for ReAct integration

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
This commit is contained in:
chiguyong 2026-06-23 21:56:08 +08:00
parent d36e45bbe7
commit 42fe7bcbc9
2 changed files with 629 additions and 0 deletions

View File

@ -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, nameid 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}"}

View File

@ -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"]