feat: complete phase 2 -- multi-agent routing, interrupt TTL, escalation, templates
- Intent classification with LLM structured output (single/multi/ambiguous) - Discount agent with apply_discount and generate_coupon tools - Interrupt manager with 30-min TTL auto-expiration and retry prompts - Webhook escalation module with exponential backoff retry (max 3) - Three vertical industry templates (e-commerce, SaaS, fintech) - Template loading in AgentRegistry - Enhanced supervisor prompt with dynamic agent descriptions - 153 tests passing, 90.18% coverage
This commit is contained in:
140
backend/app/escalation.py
Normal file
140
backend/app/escalation.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Webhook escalation module -- HTTP POST with exponential backoff retry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EscalationPayload(BaseModel, frozen=True):
|
||||
"""Immutable payload sent to the escalation webhook."""
|
||||
|
||||
thread_id: str
|
||||
reason: str
|
||||
conversation_summary: str
|
||||
metadata: dict = {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EscalationResult:
|
||||
"""Immutable result of an escalation attempt."""
|
||||
|
||||
success: bool
|
||||
status_code: int | None
|
||||
attempts: int
|
||||
error: str | None
|
||||
|
||||
|
||||
class EscalationService(Protocol):
|
||||
"""Protocol for escalation implementations."""
|
||||
|
||||
async def escalate(self, payload: EscalationPayload) -> EscalationResult: ...
|
||||
|
||||
|
||||
class WebhookEscalator:
|
||||
"""Sends escalation requests via HTTP POST with exponential backoff retry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
timeout_seconds: int = 10,
|
||||
max_retries: int = 3,
|
||||
) -> None:
|
||||
self._url = url
|
||||
self._timeout = timeout_seconds
|
||||
self._max_retries = max_retries
|
||||
|
||||
async def escalate(self, payload: EscalationPayload) -> EscalationResult:
|
||||
"""POST the escalation payload to the configured webhook URL."""
|
||||
if not self._url:
|
||||
return EscalationResult(
|
||||
success=False,
|
||||
status_code=None,
|
||||
attempts=0,
|
||||
error="Webhook URL not configured",
|
||||
)
|
||||
|
||||
last_error: str | None = None
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
for attempt in range(1, self._max_retries + 1):
|
||||
try:
|
||||
response = await client.post(
|
||||
self._url,
|
||||
json=payload.model_dump(),
|
||||
)
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
logger.info(
|
||||
"Escalation succeeded for thread %s (attempt %d)",
|
||||
payload.thread_id,
|
||||
attempt,
|
||||
)
|
||||
return EscalationResult(
|
||||
success=True,
|
||||
status_code=response.status_code,
|
||||
attempts=attempt,
|
||||
error=None,
|
||||
)
|
||||
|
||||
last_error = f"HTTP {response.status_code}"
|
||||
logger.warning(
|
||||
"Escalation attempt %d/%d failed: %s",
|
||||
attempt,
|
||||
self._max_retries,
|
||||
last_error,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
last_error = "Request timed out"
|
||||
logger.warning(
|
||||
"Escalation attempt %d/%d timed out",
|
||||
attempt,
|
||||
self._max_retries,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
last_error = str(exc)
|
||||
logger.warning(
|
||||
"Escalation attempt %d/%d error: %s",
|
||||
attempt,
|
||||
self._max_retries,
|
||||
last_error,
|
||||
)
|
||||
|
||||
# Exponential backoff: skip delay after last attempt
|
||||
if attempt < self._max_retries:
|
||||
delay = 2**attempt
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
logger.error(
|
||||
"Escalation failed for thread %s after %d attempts: %s",
|
||||
payload.thread_id,
|
||||
self._max_retries,
|
||||
last_error,
|
||||
)
|
||||
return EscalationResult(
|
||||
success=False,
|
||||
status_code=None,
|
||||
attempts=self._max_retries,
|
||||
error=last_error,
|
||||
)
|
||||
|
||||
|
||||
class NoOpEscalator:
|
||||
"""Escalator that does nothing -- used when webhook URL is not configured."""
|
||||
|
||||
async def escalate(self, payload: EscalationPayload) -> EscalationResult:
|
||||
logger.info("Escalation disabled (no webhook URL). Thread: %s", payload.thread_id)
|
||||
return EscalationResult(
|
||||
success=False,
|
||||
status_code=None,
|
||||
attempts=0,
|
||||
error="Escalation disabled",
|
||||
)
|
||||
Reference in New Issue
Block a user