- 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
141 lines
4.3 KiB
Python
141 lines
4.3 KiB
Python
"""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",
|
|
)
|