Files
gogo-telefon/gogo/agent/loop.py
Senad Uka 0d0838e6e2 M2: agent logic in text mode
- LLMProvider interface: AnthropicLLM + ScriptedLLM test fake
- System prompt composition from super-admin template + structured tenant
  data only (§6.3); auto-generated greetings with recording disclosure,
  out-of-hours variant, chat variant
- Agent tools (§6.5): get_salon_info, check_availability (≤8 slots to model,
  agent offers ≤3), submit_booking_request (caller-ID fallback, hallucinated
  service-id guard), take_message
- Conversation loop with tool dispatch, round-limit guard, transcript capture,
  outcome classification (request_created/message_taken/info_only/abandoned)
- Terminal playground (python -m gogo.agent.cli) + demo seed (python -m gogo.seed)
- Deterministic loop tests (ScriptedLLM) + Appendix A golden-scenario
  acceptance tests against a real LLM (skipped without an API key)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:53:08 +02:00

149 lines
5.2 KiB
Python

"""Conversation loop shared by voice, chat and the playground (M2).
One AgentConversation per call/chat session. Each user turn may trigger several
LLM round-trips when the model calls tools; the loop executes them via
ToolExecutor and feeds tool_results back until the model produces text.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.agent.llm import LLMProvider, get_llm
from gogo.agent.prompt import compose_greeting, compose_system_prompt
from gogo.agent.tools import TOOL_DEFINITIONS, ToolExecutor
from gogo.domain import CallOutcome
from gogo.models import Tenant
log = logging.getLogger("gogo.agent.loop")
MAX_TOOL_ROUNDS_PER_TURN = 6 # guard against tool-call loops
@dataclass
class Turn:
role: str # "user" | "assistant"
text: str
tool_calls: list[tuple[str, dict]] = field(default_factory=list)
class AgentConversation:
def __init__(
self,
session: AsyncSession,
tenant: Tenant,
*,
channel: str, # "voice" | "chat"
caller_phone: str = "",
call_id=None,
chat_session_id=None,
llm: LLMProvider | None = None,
):
self.session = session
self.tenant = tenant
self.channel = channel
self.llm = llm or get_llm()
self.executor = ToolExecutor(
session,
tenant,
source=channel,
caller_phone=caller_phone,
call_id=call_id,
chat_session_id=chat_session_id,
)
self.messages: list[dict] = [] # Anthropic-format history
self.turns: list[Turn] = [] # human-readable transcript
self._system: str | None = None
async def greeting(self) -> str:
"""Opening line (spoken by TTS / shown in the chat widget)."""
text = compose_greeting(self.tenant, self.channel)
# seed history so the model knows it already greeted
self.messages.append({"role": "assistant", "content": text})
self.turns.append(Turn("assistant", text))
return text
async def user_turn(self, text: str) -> str:
"""Process one user utterance; returns the assistant's reply text."""
if self._system is None:
self._system = await compose_system_prompt(self.session, self.tenant, self.channel)
self.messages.append({"role": "user", "content": text})
self.turns.append(Turn("user", text))
reply_parts: list[str] = []
turn_tool_calls: list[tuple[str, dict]] = []
for _round in range(MAX_TOOL_ROUNDS_PER_TURN):
response = await self.llm.complete(
system=self._system,
messages=self.messages,
tools=TOOL_DEFINITIONS,
model=self.tenant.llm_model or None,
)
if response.text:
reply_parts.append(response.text)
if not response.tool_uses:
self.messages.append(
{"role": "assistant", "content": response.text or ""}
)
break
# record assistant blocks (text + tool_use) exactly as produced
content: list[dict] = []
if response.text:
content.append({"type": "text", "text": response.text})
for tu in response.tool_uses:
content.append(
{"type": "tool_use", "id": tu.id, "name": tu.name, "input": tu.input}
)
self.messages.append({"role": "assistant", "content": content})
results = []
for tu in response.tool_uses:
turn_tool_calls.append((tu.name, tu.input))
result = await self.executor.execute(tu.name, tu.input)
results.append(
{"type": "tool_result", "tool_use_id": tu.id, "content": result}
)
self.messages.append({"role": "user", "content": results})
else:
log.warning("tool-round limit hit (tenant %s)", self.tenant.slug)
reply = "\n".join(p for p in reply_parts if p).strip()
if not reply:
reply = "Izvinite, došlo je do tehničke greške. Salon će vas nazvati u najkraćem roku."
self.turns.append(Turn("assistant", reply, tool_calls=turn_tool_calls))
return reply
@property
def outcome(self) -> str:
"""Conversation outcome for call/chat history (§10)."""
if self.executor.created_request_id is not None:
return CallOutcome.request_created.value
if self.executor.took_message:
return CallOutcome.message_taken.value
if any(t.role == "user" for t in self.turns):
return CallOutcome.info_only.value
return CallOutcome.abandoned.value
@property
def transcript(self) -> list[dict]:
"""JSON-serializable transcript for calls.transcript / chat storage."""
return [
{
"role": t.role,
"text": t.text,
**(
{"tool_calls": [{"name": n, "input": i} for n, i in t.tool_calls]}
if t.tool_calls
else {}
),
}
for t in self.turns
]