"""Agent tools (§6.5) — identical for voice and chat. Definitions follow the Anthropic tool-use schema; ToolExecutor binds them to a tenant/session and records side effects (created requests, messages) so the caller can classify the conversation outcome (§10 call history). """ from __future__ import annotations import json import logging import uuid from datetime import date, datetime, timedelta from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from gogo.domain import Slot from gogo.hours import DEFAULT_WORKING_HOURS, hours_summary_bs from gogo.i18n import fmt_price, fmt_slot from gogo.models import MessageForOwner, Service, Tenant from gogo.proposals import engine as proposal_engine from gogo.scheduling.base import get_provider log = logging.getLogger("gogo.agent.tools") MAX_SLOTS_TO_LLM = 8 # enough context to match preferences; agent offers ≤3 TOOL_DEFINITIONS: list[dict] = [ { "name": "get_salon_info", "description": ( "Working hours, address and the full services list (with ids, durations, " "prices and notes) of the salon." ), "input_schema": {"type": "object", "properties": {}, "additionalProperties": False}, }, { "name": "check_availability", "description": ( "Real free slots for a service in a date range. Returns at most " f"{MAX_SLOTS_TO_LLM} slots; offer the caller AT MOST 3 that best match " "their stated preference. Dates are ISO YYYY-MM-DD, inclusive." ), "input_schema": { "type": "object", "properties": { "service_id": {"type": "string", "description": "id from the services table"}, "date_from": {"type": "string", "description": "ISO date, first day to check"}, "date_to": {"type": "string", "description": "ISO date, last day to check"}, "home_visit": {"type": "boolean", "default": False}, }, "required": ["service_id", "date_from", "date_to"], }, }, { "name": "submit_booking_request", "description": ( "Create the booking request (proposal) for the salon owner. Call this once " "you have: the service, the client's name, a contact phone number and " "either concrete slots the client accepted (max 3, ordered by preference) " "or a free-text time preference (e.g. for home visits). This does NOT " "confirm the appointment — the salon will contact the client." ), "input_schema": { "type": "object", "properties": { "service_id": { "type": "string", "description": "id from the services table; omit if no clear match", }, "service_name_raw": { "type": "string", "description": "the service in the client's own words", }, "client_name": {"type": "string"}, "client_phone": {"type": "string"}, "slots": { "type": "array", "maxItems": 3, "items": { "type": "object", "properties": { "start": {"type": "string", "description": "ISO 8601 with offset"}, "end": {"type": "string", "description": "ISO 8601 with offset"}, }, "required": ["start", "end"], }, "description": "accepted slots from check_availability, best first", }, "time_preference_text": { "type": "string", "description": "client's time preference in words, when no exact slot", }, "home_visit": {"type": "boolean", "default": False}, "address": {"type": "string", "description": "address/area for home visits"}, "summary": { "type": "string", "description": ( "short Bosnian summary of what the client wants / their problem, " "for the owner" ), }, }, "required": ["client_name", "client_phone", "summary"], }, }, { "name": "take_message", "description": ( "Leave a message for the salon owner (cancellations, reschedules, questions " "you cannot answer, unintelligible calls). The owner will call the client back." ), "input_schema": { "type": "object", "properties": { "text": {"type": "string", "description": "the message, in Bosnian"}, "client_name": {"type": "string"}, "phone": {"type": "string", "description": "callback number"}, }, "required": ["text"], }, }, ] class ToolExecutor: """Executes tool calls for one conversation.""" def __init__( self, session: AsyncSession, tenant: Tenant, *, source: str, # "voice" | "chat" caller_phone: str = "", call_id: uuid.UUID | None = None, chat_session_id: uuid.UUID | None = None, ): self.session = session self.tenant = tenant self.source = source self.caller_phone = caller_phone self.call_id = call_id self.chat_session_id = chat_session_id # side effects for outcome classification self.created_request_id: uuid.UUID | None = None self.took_message: bool = False self.tool_calls: list[tuple[str, dict]] = [] # (name, input) log for the trace async def execute(self, name: str, tool_input: dict) -> str: """Run one tool; always returns a JSON string for the tool_result block.""" self.tool_calls.append((name, tool_input)) try: handler = { "get_salon_info": self._get_salon_info, "check_availability": self._check_availability, "submit_booking_request": self._submit_booking_request, "take_message": self._take_message, }.get(name) if handler is None: return json.dumps({"error": f"unknown tool: {name}"}) result = await handler(tool_input) return json.dumps(result, ensure_ascii=False) except Exception as e: # noqa: BLE001 — the model must get a usable error log.exception("tool %s failed (tenant %s)", name, self.tenant.slug) return json.dumps( {"error": "internal_error", "message": f"Tool failed: {type(e).__name__}"}, ensure_ascii=False, ) async def _get_salon_info(self, _: dict) -> dict: services = ( ( await self.session.execute( select(Service).where( Service.tenant_id == self.tenant.id, Service.active.is_(True) ) ) ) .scalars() .all() ) return { "name": self.tenant.name, "address": self.tenant.address, "city": self.tenant.city, "working_hours": hours_summary_bs(self.tenant.working_hours or DEFAULT_WORKING_HOURS), "services": [ { "id": str(s.id), "name": s.name, "duration_min": s.duration_min, "price": fmt_price(s.price_min, s.price_max), "home_visit": s.home_visit, "note": s.agent_note, } for s in services ], "notes": self.tenant.agent_notes, } async def _check_availability(self, tool_input: dict) -> dict: service_id = str(tool_input["service_id"]) try: date_from = date.fromisoformat(str(tool_input["date_from"])) date_to = date.fromisoformat(str(tool_input["date_to"])) except ValueError: return {"error": "bad_date", "message": "Dates must be ISO YYYY-MM-DD"} if date_to < date_from: date_from, date_to = date_to, date_from # guard against runaway ranges — provider horizon also applies if (date_to - date_from) > timedelta(days=31): date_to = date_from + timedelta(days=31) provider = await get_provider(self.session, self.tenant) slots = await provider.get_availability( service_id, date_from, date_to, home_visit=bool(tool_input.get("home_visit")) ) return { "slots": [ { "start": s.start.isoformat(), "end": s.end.isoformat(), "label": fmt_slot(s.start, self.tenant.timezone), **({"staff_name": s.staff_name} if s.staff_name else {}), } for s in slots[:MAX_SLOTS_TO_LLM] ], "note": ( "No free slots in this range — offer the client to leave a preference " "or check other days." if not slots else "Offer the client AT MOST 3 of these that match their preference." ), } async def _submit_booking_request(self, tool_input: dict) -> dict: slots: list[Slot] = [] for s in (tool_input.get("slots") or [])[:3]: try: slots.append( Slot( start=datetime.fromisoformat(s["start"]), end=datetime.fromisoformat(s["end"]), ) ) except (KeyError, ValueError): continue service_id = tool_input.get("service_id") or None if service_id: # tolerate hallucinated ids: verify it belongs to this tenant try: svc = ( await self.session.execute( select(Service).where( Service.id == uuid.UUID(str(service_id)), Service.tenant_id == self.tenant.id, ) ) ).scalar_one_or_none() except ValueError: svc = None if svc is None: service_id = None client_phone = (tool_input.get("client_phone") or "").strip() or self.caller_phone if not client_phone: return { "error": "missing_phone", "message": "A contact phone number is required before submitting.", } req, result = await proposal_engine.create_booking_request( self.session, self.tenant, source=self.source, client_name=str(tool_input.get("client_name", "")).strip(), client_phone=client_phone, service_id=str(service_id) if service_id else None, service_name_raw=str(tool_input.get("service_name_raw", "")), slots=slots, time_preference_text=str(tool_input.get("time_preference_text", "")), home_visit=bool(tool_input.get("home_visit")), address=(tool_input.get("address") or None), summary=str(tool_input.get("summary", "")), call_id=self.call_id, chat_session_id=self.chat_session_id, ) self.created_request_id = req.id return { "ok": True, "request_id": str(req.id), "delivered": result.ok, "note": ( "Request forwarded to the salon. Tell the client the salon will contact " "them to confirm — do NOT present the appointment as booked." ), } async def _take_message(self, tool_input: dict) -> dict: msg = MessageForOwner( tenant_id=self.tenant.id, client_name=str(tool_input.get("client_name", "")), client_phone=str(tool_input.get("phone", "") or self.caller_phone), text=str(tool_input["text"]), source=self.source, ) self.session.add(msg) await self.session.flush() self.took_message = True return {"ok": True, "note": "Message saved for the owner."}