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>
This commit is contained in:
148
gogo/agent/loop.py
Normal file
148
gogo/agent/loop.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""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
|
||||
]
|
||||
Reference in New Issue
Block a user