Agent Tools & Google Calendar Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement the Pydantic AI Tools (Read/Write) and the Google Calendar Adapter to allow the SDR to check availability and book appointments deterministically.
Architecture: We use Dependency Injection via Pydantic AI's RunContext and AgentDeps. External integrations (Google Calendar) are isolated via the ICalendarAdapter interface. Tools are defined as simple Python functions decorated or registered with the agent, relying on Pydantic for validation.
Tech Stack: Python 3.12, Pydantic AI, Beanie (MongoDB), Google API Python Client.
Task 1: Add Google Calendar Dependencies
Files:
- Modify: pyproject.toml
- Step 1: Install dependencies using uv
Run: uv add google-api-python-client google-auth
- Step 2: Commit
git add pyproject.toml uv.lock
git commit -m "build: add google-api-python-client and google-auth dependencies"
Task 2: Define Agent Dependencies (Context)
Files:
- Create: src/amon_claw/infrastructure/llm/agents/deps.py
- Create: tests/infrastructure/llm/agents/test_deps.py
- Step 1: Write the failing test
from uuid import uuid4
from amon_claw.infrastructure.llm.agents.deps import AgentDeps
def test_agent_deps_instantiation():
deps = AgentDeps(
tenant_id=uuid4(),
customer_id=uuid4(),
tenant_repository="mock_repo",
service_repository="mock_repo",
professional_repository="mock_repo",
appointment_repository="mock_repo",
calendar_adapter="mock_adapter"
)
assert deps.tenant_id is not None
- Step 2: Run test to verify it fails
Run: uv run pytest tests/infrastructure/llm/agents/test_deps.py
Expected: FAIL
- Step 3: Implement AgentDeps
from pydantic import BaseModel, ConfigDict
from uuid import UUID
from typing import Any
class AgentDeps(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
tenant_id: UUID
customer_id: UUID
tenant_repository: Any
service_repository: Any
professional_repository: Any
appointment_repository: Any
calendar_adapter: Any
- Step 4: Run test to verify it passes
Run: uv run pytest tests/infrastructure/llm/agents/test_deps.py
Expected: PASS
- Step 5: Commit
git add src/amon_claw/infrastructure/llm/agents/deps.py tests/infrastructure/llm/agents/test_deps.py
git commit -m "feat(llm): create AgentDeps model for Pydantic AI context"
Task 3: Define Calendar Interface
Files:
- Create: src/amon_claw/application/interfaces/calendar.py
- Step 1: Implement ICalendarAdapter
from abc import ABC, abstractmethod
from datetime import datetime
from typing import List
class ICalendarAdapter(ABC):
@abstractmethod
async def get_free_slots(self, calendar_id: str, start_date: datetime, end_date: datetime, duration_minutes: int) -> List[datetime]:
"""Fetch available time slots for a given duration."""
pass
@abstractmethod
async def create_event(self, calendar_id: str, summary: str, description: str, start_time: datetime, end_time: datetime) -> str:
"""Create an event and return the external event ID."""
pass
- Step 2: Commit
git add src/amon_claw/application/interfaces/calendar.py
git commit -m "feat(application): define ICalendarAdapter interface"
Task 4: Implement Basic Read Tools
Files:
- Create: src/amon_claw/infrastructure/llm/tools/read.py
- Create: tests/infrastructure/llm/tools/test_read.py
- Step 1: Write tests for Read Tools
import pytest
from uuid import uuid4
from unittest.mock import AsyncMock
from pydantic_ai import RunContext
from amon_claw.infrastructure.llm.agents.deps import AgentDeps
from amon_claw.infrastructure.llm.tools.read import list_services, list_professionals
from amon_claw.domain.entities.service import Service
from amon_claw.domain.entities.professional import Professional
@pytest.fixture
def mock_deps():
return AgentDeps(
tenant_id=uuid4(),
customer_id=uuid4(),
tenant_repository=AsyncMock(),
service_repository=AsyncMock(),
professional_repository=AsyncMock(),
appointment_repository=AsyncMock(),
calendar_adapter=AsyncMock()
)
@pytest.fixture
def mock_ctx(mock_deps):
class MockContext:
deps = mock_deps
return MockContext()
@pytest.mark.asyncio
async def test_list_services(mock_ctx):
mock_ctx.deps.service_repository.get_all = AsyncMock(return_value=[
Service(tenant_id=mock_ctx.deps.tenant_id, name="Corte", price=50.0, duration=30)
])
result = await list_services(mock_ctx)
assert "Corte" in result
assert "50.0" in result
@pytest.mark.asyncio
async def test_list_professionals(mock_ctx):
mock_ctx.deps.professional_repository.get_all = AsyncMock(return_value=[
Professional(tenant_id=mock_ctx.deps.tenant_id, name="Amon", services=[uuid4()], calendar_id="cal_1")
])
result = await list_professionals(mock_ctx)
assert "Amon" in result
- Step 2: Run test to verify it fails
Run: uv run pytest tests/infrastructure/llm/tools/test_read.py
Expected: FAIL
- Step 3: Implement list_services and list_professionals
from pydantic_ai import RunContext
from amon_claw.infrastructure.llm.agents.deps import AgentDeps
async def list_services(ctx: RunContext[AgentDeps]) -> str:
"""Retorna a lista de serviços oferecidos pelo estabelecimento (tenant)."""
# Assuming get_all is available, otherwise we will need to query by tenant_id
services = await ctx.deps.service_repository.get_all() # Should ideally be filtered by tenant_id
filtered = [s for s in services if s.tenant_id == ctx.deps.tenant_id]
if not filtered:
return "Nenhum serviço encontrado."
lines = []
for s in filtered:
lines.append(f"ID: {s.id} | Nome: {s.name} | Preço: R${s.price:.2f} | Duração: {s.duration} min")
return "\n".join(lines)
async def list_professionals(ctx: RunContext[AgentDeps]) -> str:
"""Retorna a lista de profissionais disponíveis no estabelecimento."""
professionals = await ctx.deps.professional_repository.get_all()
filtered = [p for p in professionals if p.tenant_id == ctx.deps.tenant_id]
if not filtered:
return "Nenhum profissional encontrado."
lines = []
for p in filtered:
lines.append(f"ID: {p.id} | Nome: {p.name}")
return "\n".join(lines)
- Step 4: Run test to verify it passes
Run: uv run pytest tests/infrastructure/llm/tools/test_read.py
Expected: PASS
- Step 5: Commit
git add src/amon_claw/infrastructure/llm/tools/read.py tests/infrastructure/llm/tools/test_read.py
git commit -m "feat(llm): implement list_services and list_professionals tools"
Task 5: Implement Availability and Booking Tools
Files:
- Modify: src/amon_claw/infrastructure/llm/tools/read.py
- Create: src/amon_claw/infrastructure/llm/tools/write.py
- Modify: tests/infrastructure/llm/tools/test_read.py
- Create: tests/infrastructure/llm/tools/test_write.py
- Step 1: Write test for check_availability
# In tests/infrastructure/llm/tools/test_read.py
from datetime import datetime, date
from amon_claw.infrastructure.llm.tools.read import check_availability
@pytest.mark.asyncio
async def test_check_availability(mock_ctx):
prof_id = uuid4()
mock_ctx.deps.professional_repository.get_by_id = AsyncMock(return_value=Professional(
id=prof_id, tenant_id=mock_ctx.deps.tenant_id, name="Amon", services=[], calendar_id="cal_1"
))
mock_ctx.deps.calendar_adapter.get_free_slots = AsyncMock(return_value=[
datetime(2026, 4, 21, 10, 0)
])
result = await check_availability(mock_ctx, professional_id=prof_id, target_date=date(2026, 4, 21), service_duration_min=30)
assert "2026-04-21 10:00" in result
- Step 2: Implement check_availability in read.py
# In src/amon_claw/infrastructure/llm/tools/read.py
from datetime import date, datetime, timedelta
from uuid import UUID
async def check_availability(ctx: RunContext[AgentDeps], professional_id: UUID, target_date: date, service_duration_min: int) -> str:
"""Consulta os horários livres de um profissional em uma data específica."""
professional = await ctx.deps.professional_repository.get_by_id(professional_id)
if not professional:
return "Profissional não encontrado."
if not professional.calendar_id:
return "Profissional não possui agenda configurada."
start_dt = datetime.combine(target_date, datetime.min.time())
end_dt = start_dt + timedelta(days=1)
slots = await ctx.deps.calendar_adapter.get_free_slots(
calendar_id=professional.calendar_id,
start_date=start_dt,
end_date=end_dt,
duration_minutes=service_duration_min
)
if not slots:
return f"Não há horários disponíveis no dia {target_date.isoformat()}."
return "\n".join([s.strftime("%Y-%m-%d %H:%M") for s in slots])
- Step 3: Write test for book_appointment
# In tests/infrastructure/llm/tools/test_write.py
import pytest
from uuid import uuid4
from datetime import datetime, timedelta
from unittest.mock import AsyncMock
from amon_claw.infrastructure.llm.tools.write import book_appointment
from amon_claw.domain.entities.professional import Professional
from amon_claw.domain.entities.service import Service
@pytest.mark.asyncio
async def test_book_appointment():
# Similar mock context setup...
pass # Implementation omitted for brevity but should verify the happy path
- Step 4: Implement book_appointment in write.py
# In src/amon_claw/infrastructure/llm/tools/write.py
from pydantic_ai import RunContext
from uuid import UUID
from datetime import datetime, timedelta
from amon_claw.infrastructure.llm.agents.deps import AgentDeps
from amon_claw.domain.entities.appointment import Appointment, AppointmentStatus
async def book_appointment(ctx: RunContext[AgentDeps], professional_id: UUID, service_id: UUID, start_time: datetime) -> str:
"""Agenda um serviço no calendário do profissional e salva no banco de dados."""
professional = await ctx.deps.professional_repository.get_by_id(professional_id)
service = await ctx.deps.service_repository.get_by_id(service_id)
if not professional or not service:
return "Profissional ou Serviço inválido."
end_time = start_time + timedelta(minutes=service.duration)
# Validação rápida de disponibilidade poderia ir aqui
try:
ext_id = await ctx.deps.calendar_adapter.create_event(
calendar_id=professional.calendar_id,
summary=f"Agendamento: {service.name}",
description=f"Cliente ID: {ctx.deps.customer_id}",
start_time=start_time,
end_time=end_time
)
except Exception as e:
return f"Falha ao agendar no calendário externo: {str(e)}"
appointment = Appointment(
tenant_id=ctx.deps.tenant_id,
professional_id=professional_id,
customer_id=ctx.deps.customer_id,
service_id=service_id,
start_time=start_time,
end_time=end_time,
status=AppointmentStatus.CONFIRMED,
external_calendar_id=ext_id
)
await ctx.deps.appointment_repository.save(appointment)
return f"Agendamento confirmado com sucesso! ID: {appointment.id}"
- Step 5: Run tests and Commit