CCAI-Demo / backend /app /services /resilience.py
NeonClary
perf(orchestrator): parallel phases, streaming, early table summaries
83add3c
Raw
History Blame Contribute Delete
17.7 kB
"""Participant-turn resilience helpers.
This module owns the logic for what happens after an AI participant's
LLM call fails. The orchestrator delegates each AI turn to
`run_resilient_turn`, which then decides whether to:
- return the original failure unchanged (when "Prioritize model
choice" is on; the user explicitly picked this model, so we don't
swap it out behind their back — they get the existing 2-attempt
HTTP retry from openai_compat and that's it),
- in Phase 1 only, retire the original participant and bring in an
alternate from the catalog,
- swap the backing LLM behind the participant's persona prompt by
walking the per-session substitution chain (gpt-5.4 →
gemini-2.5-flash → other externals → Neon "vanilla" models), or
- retry the same backing LLM once when the error looks transient
(5xx / 429 / timeout) in Phase 2+.
All of the post-failure behaviour is gated on
`settings.speed_priority`. The reasoning: under "Prioritize model
choice" the user has explicitly chosen a model and expects exactly
that model to speak (or the conversation to note the failure and
continue without lying about which model produced what). Under
"Prioritize conversation speed" the user has signalled they care
about the conversation flowing more than about which specific model
spoke — so we aggressively work around failures.
The orchestrator's existing `participant_error` SSE + auto-disable
threshold still apply as a final backstop: if every tier in this
module has been exhausted and the participant *still* can't speak,
the orchestrator emits `participant_error` and (in Phases 1/2)
auto-disables the participant after N consecutive failures.
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from app.config import settings
if TYPE_CHECKING:
# Avoid an import cycle: orchestrator imports this module, and
# this module only needs the types for annotations.
from app.services.models import Participant, Phase, Session
LOG = logging.getLogger(__name__)
# Substitution chain: head-of-list models we always try first because
# they're known to be widely-available and have generous context
# windows. The remainder of the chain is filled in dynamically from
# settings.providers at session-start.
PREFERRED_SUBSTITUTE_MODEL_IDS: list[str] = [
"gpt-5.4",
"gemini-2.5-flash",
]
# ---------------------------------------------------------------------------
# Result type returned to the orchestrator
# ---------------------------------------------------------------------------
@dataclass
class ResilientTurnResult:
"""Outcome of `run_resilient_turn` from the orchestrator's POV.
`speaker` is the Participant whose name + role_prompt produced the
response. Normally it's the same instance the orchestrator passed
in — but in Phase 1 we may swap an alternate participant in,
in which case `speaker` is the alternate. The orchestrator should
attribute the resulting message to `speaker`, not the original.
`sse_events` is a list of pre-encoded SSE chunks the caller should
yield *before* emitting the actual `message` event (roster
updates, substitution notices, etc.). They're returned rather than
yielded directly so the resilience layer doesn't have to be an
async generator — keeps the caller simple.
"""
speaker: "Participant"
text: str
elapsed: float
ok: bool
error_kind: str = ""
sse_events: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Substitution chain construction (called from api/chat.py at session start)
# ---------------------------------------------------------------------------
def build_substitution_chain(
neon_model_ids: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Resolve each model id we'd want to fall back to into a routing
dict the same shape as `settings.resolve_model()` returns.
Order:
1. PREFERRED_SUBSTITUTE_MODEL_IDS (gpt-5.4, gemini-2.5-flash)
2. Every other non-Neon model registered in settings.providers,
in provider order (deterministic).
3. Neon "vanilla" models (no persona overlay) as a last resort —
the caller supplies HANA model ids (e.g. "BrainForge/Engineer@2026.03.18")
which we expand into `neon:{id}:vanilla` participant ids.
Duplicates are dropped, so each model id appears at most once.
Models that fail to resolve (e.g. the API key isn't configured)
are silently skipped.
"""
chain: list[dict[str, Any]] = []
seen: set[str] = set()
def add(model_id: str) -> None:
if not model_id or model_id in seen:
return
resolved = settings.resolve_model(model_id)
if not resolved:
return
seen.add(model_id)
chain.append(resolved)
for mid in PREFERRED_SUBSTITUTE_MODEL_IDS:
add(mid)
for prov in settings.providers:
for m in prov.get("models", []):
mid = m.get("id")
if not mid:
continue
resolved = settings.resolve_model(mid)
if resolved and not resolved.get("is_neon"):
add(mid)
for hana_mid in neon_model_ids or []:
add(f"neon:{hana_mid}:vanilla")
return chain
# ---------------------------------------------------------------------------
# Core wrapper: run_resilient_turn
# ---------------------------------------------------------------------------
# The orchestrator passes in `call_participant` so this module doesn't
# have to import from orchestrator.py (avoids the cycle). Signature:
# async def call_participant(session, participant, user_prompt, label,
# max_tokens) -> tuple[str, float, bool, str]
CallParticipantFn = Callable[..., Awaitable[tuple[str, float, bool, str]]]
def _swap_participant_model_in_place(
participant: "Participant",
resolved: dict[str, Any],
) -> None:
"""Rewrite a Participant's backing-LLM fields with a resolved
substitute. Persona identity (participant_id, name, role_prompt,
kind) is left untouched, so transcript continuity is preserved —
only the speaker behind the persona changes.
Records the previous model_id on `substituted_from_model_id` so
api_log entries from before/after the swap can be cross-referenced.
"""
if not participant.substituted_from_model_id:
participant.substituted_from_model_id = participant.model_id
participant.model_id = resolved.get("model_id", participant.model_id)
participant.base_url = resolved.get("base_url", "")
participant.api_key = resolved.get("api_key", "")
participant.display_name = resolved.get("display_name", resolved.get("model_id", ""))
participant.is_neon = resolved.get("is_neon", False)
participant.hana_model_id = resolved.get("hana_model_id", "")
participant.persona_name = resolved.get("persona_name", "")
participant.neon_direct_vllm = resolved.get("neon_direct_vllm", False)
participant.vllm_base_url = resolved.get("vllm_base_url", "")
participant.vllm_api_key = resolved.get("vllm_api_key", "")
def _sse(event: str, data: dict[str, Any]) -> str:
"""Mirrors orchestrator._sse so we don't have to import it."""
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
async def _try_substitution_chain(
*,
session: "Session",
participant: "Participant",
user_prompt: str,
label: str,
max_tokens: int,
call_participant: CallParticipantFn,
) -> tuple[str, float, bool, str, list[str]]:
"""Walk session.substitution_chain trying each backing LLM in turn.
On the first success: mutate `participant` in place to point at the
successful substitute (so subsequent rounds also use it), emit a
`participant_substituted` SSE so the frontend can surface the
change, and return the result.
On exhaustion: return the last failure tuple unchanged.
"""
events: list[str] = []
last_text = ""
last_elapsed = 0.0
last_kind = ""
for resolved in session.substitution_chain:
candidate_id = resolved.get("model_id", "")
if not candidate_id or candidate_id == participant.model_id:
continue
original_model_id = participant.model_id
_swap_participant_model_in_place(participant, resolved)
LOG.info(
"Resilience: substituting backing LLM for %s (%s) — trying %s",
participant.name, participant.participant_id, candidate_id,
)
text, elapsed, ok, kind = await call_participant(
session=session,
participant=participant,
user_prompt=user_prompt,
label=f"{label}:sub:{candidate_id}",
max_tokens=max_tokens,
)
last_text, last_elapsed, last_kind = text, elapsed, kind
if ok and text.strip():
events.append(_sse("participant_substituted", {
"participant_id": participant.participant_id,
"name": participant.name,
"from_model_id": original_model_id,
"to_model_id": participant.model_id,
"to_model_display": participant.display_name,
"phase": session.phase.value,
}))
return text, elapsed, True, "", events
# Exhausted the chain. Caller will fall through to participant_error.
return last_text, last_elapsed, False, last_kind, events
async def _try_alternate_for_phase1(
*,
session: "Session",
original: "Participant",
user_prompt: str,
label: str,
max_tokens: int,
call_participant: CallParticipantFn,
) -> tuple["Participant | None", str, float, bool, str, list[str]]:
"""Phase 1 only: try alternate participants from the candidate pool.
Pops alternates one at a time off session.candidate_pool (so they
aren't tried twice across the chat), running the same initial-
opinion prompt against each in turn. On the first success the
alternate REPLACES `original` in session.participants (preserving
roster order), and a `participant_replaced` SSE is emitted so the
sidebar can re-render.
Returns (alternate, text, elapsed, ok, error_kind, sse_events).
If the pool is exhausted without a success, returns
(None, "", 0.0, False, "...", events).
"""
events: list[str] = []
last_text = ""
last_elapsed = 0.0
last_kind = ""
while session.candidate_pool:
alt = session.candidate_pool.pop(0)
if alt.kind == "human":
continue
LOG.info(
"Resilience: trying alternate participant %s (%s) in place of %s",
alt.name, alt.participant_id, original.name,
)
text, elapsed, ok, kind = await call_participant(
session=session,
participant=alt,
user_prompt=user_prompt,
label=f"{label}:alt:{alt.participant_id}",
max_tokens=max_tokens,
)
last_text, last_elapsed, last_kind = text, elapsed, kind
if ok and text.strip():
# Replace original in the roster at its original index so
# turn order is preserved for the rest of the chat.
try:
idx = session.participants.index(original)
except ValueError:
idx = len(session.participants)
session.participants.append(alt)
else:
session.participants[idx] = alt
events.append(_sse("participant_replaced", {
"original_participant_id": original.participant_id,
"original_name": original.name,
"new_participant_id": alt.participant_id,
"new_name": alt.name,
"new_model_id": alt.model_id,
"new_model_display": alt.display_name,
"new_kind": alt.kind,
"phase": session.phase.value,
"roster": [
{
"participant_id": p.participant_id,
"name": p.name,
"kind": p.kind,
"model_id": p.model_id,
"model_display": p.display_name,
}
for p in session.participants
],
}))
return alt, text, elapsed, True, "", events
return None, last_text, last_elapsed, False, last_kind, events
async def run_resilient_turn(
*,
session: "Session",
participant: "Participant",
user_prompt: str,
label: str,
max_tokens: int,
call_participant: CallParticipantFn,
stream_events: list[str] | None = None,
stream_message_id: str | None = None,
) -> ResilientTurnResult:
"""Phase-aware wrapper around a single participant LLM turn.
Always invokes `call_participant` once with the original
participant + model. If that succeeds (or speed-priority is off),
returns immediately — the existing 2-attempt HTTP retry in
`openai_compat` already serves as the "second try" for the
Prioritize-Model-Choice mode.
Otherwise, under speed-priority:
- Phase 1 (initial opinions): try an alternate participant from
the catalog; if that also fails, walk the LLM substitution
chain on the *original* persona.
- Phase 2+ : if the error looks transient (5xx/429/timeout)
retry the same model once; if it still fails, or the error
was permanent, walk the substitution chain.
"""
# Lazy phase import to avoid a circular dependency at module load.
from app.services.models import Phase # noqa: WPS433
t0 = time.time() # noqa: F841 — kept around in case future telemetry needs it
text, elapsed, ok, error_kind = await call_participant(
session=session,
participant=participant,
user_prompt=user_prompt,
label=label,
max_tokens=max_tokens,
stream_events=stream_events,
stream_message_id=stream_message_id,
)
if ok and text.strip():
return ResilientTurnResult(
speaker=participant, text=text, elapsed=elapsed,
ok=True, error_kind="", sse_events=[],
)
if not settings.speed_priority:
# "Prioritize model choice" — caller will emit participant_error
# and the conversation continues as today. The 2-attempt HTTP
# retry in openai_compat already satisfied "always a second try"
# per the resilience spec.
return ResilientTurnResult(
speaker=participant, text=text, elapsed=elapsed,
ok=False, error_kind=error_kind, sse_events=[],
)
events: list[str] = []
if session.phase == Phase.INITIAL_OPINIONS:
# Tier 2: alternate participant from catalog pool.
alt, alt_text, alt_elapsed, alt_ok, alt_kind, alt_events = await _try_alternate_for_phase1(
session=session,
original=participant,
user_prompt=user_prompt,
label=label,
max_tokens=max_tokens,
call_participant=call_participant,
)
events.extend(alt_events)
if alt_ok and alt is not None:
return ResilientTurnResult(
speaker=alt, text=alt_text, elapsed=alt_elapsed,
ok=True, error_kind="", sse_events=events,
)
# Tier 3: substitute the LLM behind the *original* persona.
sub_text, sub_elapsed, sub_ok, sub_kind, sub_events = await _try_substitution_chain(
session=session,
participant=participant,
user_prompt=user_prompt,
label=label,
max_tokens=max_tokens,
call_participant=call_participant,
)
events.extend(sub_events)
return ResilientTurnResult(
speaker=participant, text=sub_text, elapsed=sub_elapsed,
ok=sub_ok and bool(sub_text.strip()),
error_kind="" if sub_ok else (sub_kind or alt_kind or error_kind),
sse_events=events,
)
# Phase 2+ : retry-or-substitute fork.
if error_kind == "transient":
LOG.info(
"Resilience: transient failure on %s for %s — retrying same model",
participant.model_id, participant.name,
)
retry_text, retry_elapsed, retry_ok, retry_kind = await call_participant(
session=session,
participant=participant,
user_prompt=user_prompt,
label=f"{label}:retry",
max_tokens=max_tokens,
)
if retry_ok and retry_text.strip():
return ResilientTurnResult(
speaker=participant, text=retry_text, elapsed=retry_elapsed,
ok=True, error_kind="", sse_events=events,
)
text, elapsed, error_kind = retry_text, retry_elapsed, retry_kind
# Either the error was permanent, or the same-model retry also failed.
sub_text, sub_elapsed, sub_ok, sub_kind, sub_events = await _try_substitution_chain(
session=session,
participant=participant,
user_prompt=user_prompt,
label=label,
max_tokens=max_tokens,
call_participant=call_participant,
)
events.extend(sub_events)
return ResilientTurnResult(
speaker=participant, text=sub_text, elapsed=sub_elapsed,
ok=sub_ok and bool(sub_text.strip()),
error_kind="" if sub_ok else (sub_kind or error_kind),
sse_events=events,
)