Pular para conteúdo

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
uv run pytest tests/infrastructure/llm/tools/
git add src/amon_claw/infrastructure/llm/tools/ tests/infrastructure/llm/tools/
git commit -m "feat(llm): implement check_availability and book_appointment tools"