CCAI-Demo / backend /app /services /orchestrator.py
NeonClary
Build credentials during Phase 1 and streamline human participant setup.
d4017c8
Raw
History Blame Contribute Delete
66.7 kB
"""CCAI orchestrator: six-phase state machine driving a multi-participant
group discussion to a consensus (or to a documented failure-to-consense).
Phase outline (matches the build plan):
1. Initial Opinions (independent, no peeking)
1.5. Credential Summary (built concurrently during Phase 1)
2. Critique x 2 rounds (full history visible)
3. Status Assessment (max 3 iterations of targeted follow-ups)
4. Opinion Finalization
5. Consensus Gathering (alliance-aware, addressed-to aware)
6. Closure (majority report, or unaddressed-factor probe + retry,
or failure report)
Two failsafes pause the loop until the user clicks "Continue":
- Participant-message cap: 60, then +20.
- Orchestrator-call cap: 100, then +50.
Every LLM response runs through `app.utils.sanitize.strip_thinking` on
its way into history, into the orchestrator's prompts, and into the
summarizer.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from dataclasses import asdict
from typing import Any, AsyncIterator
from app.clients.llm_router import chat_completion
from app.config import settings
from app.services import context_budget, human_io
from app.services.consensus import (
assess_consensus_status,
classify_addressed_to,
detect_alliances,
find_unaddressed_factor,
)
from app.services.context_budget import (
ContextSummary,
DEFAULT_REPLY_BUDGET,
KEEP_RECENT_MESSAGES,
build_compressed_transcript_block,
cap_max_tokens_for_window,
context_window_for,
estimate_messages_tokens,
replace_embedded_transcript,
run_summarize,
select_summarizer_model_id,
should_summarize,
)
from app.services.credential import (
assemble_credential_summary_list,
build_credential_for_participant,
credentials_to_block,
)
from app.services.json_calls import orchestrator_call
from app.services.orchestrator_speed import (
_AiTurnResult,
_AiTurnSpec,
compact_transcript_for_orchestrator,
orchestrator_fast_model_id,
run_initial_opinions_roster,
run_roster_ai_turns_parallel,
)
from app.services.resilience import run_resilient_turn
from app.services.models import (
DEFAULT_MAX_PARTICIPANTS,
MAX_MAX_PARTICIPANTS,
MIN_MAX_PARTICIPANTS,
Participant,
Phase,
Session,
)
from app.services.prompts import (
CONSENSUS_ALLIED_PROMPT,
CONSENSUS_SOLO_PROMPT,
CONTRIBUTION_SUMMARY_PROMPT,
CRITIQUE_PROMPT,
FINALIZATION_PROMPT,
INITIAL_OPINION_PROMPT,
MAJORITY_REPORT_PROMPT,
NO_CONSENSUS_REPORT_PROMPT,
NO_REASONING_DIRECTIVE,
PARTICIPANT_BASE_DIRECTIVE,
STATUS_ASSESSMENT_PROMPT,
TARGETED_FOLLOWUP_PROMPT,
TARGETED_FOLLOWUP_FROM_PARTICIPANT_PROMPT,
)
from app.utils.sanitize import strip_thinking
LOG = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Session registry
# ---------------------------------------------------------------------------
_sessions: dict[str, Session] = {}
def get_session(sid: str) -> Session | None:
return _sessions.get(sid)
def create_session() -> Session:
s = Session()
_sessions[s.session_id] = s
return s
# ---------------------------------------------------------------------------
# SSE helpers
# ---------------------------------------------------------------------------
def _sse(event: str, data: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _active_participants(session: Session) -> list[Participant]:
return [p for p in session.participants if p.enabled]
def _orchestrator_model_id(session: Session) -> str:
return session.orchestrator_model_id or settings.orchestrator_model
def _summarizer_model_id(session: Session) -> str:
return select_summarizer_model_id(
session.summarizer_model_id,
session.orchestrator_model_id,
)
def _format_history(
messages: list[dict[str, Any]],
*,
include_orchestrator: bool = True,
) -> str:
lines: list[str] = []
for m in messages:
if m.get("role") == "orchestrator" and not include_orchestrator:
continue
speaker = m.get("speaker_name") or m.get("speaker_id") or "(anon)"
if m.get("role") == "orchestrator":
speaker = "Orchestrator"
lines.append(f"{speaker}: {m.get('text', '')}")
return "\n".join(lines)
def _participant_roster_string(
speaker: Participant,
participants: list[Participant],
) -> str:
others = [p.name for p in participants if p.participant_id != speaker.participant_id]
return ", ".join(others) if others else "(no other participants)"
# Per-prompt cap on how much of a long pending-thread message we quote back
# to the speaker. The full message is still in the transcript above; this
# block is a "what specifically is owed to you" reminder, not a re-render.
_PENDING_TRUNCATE_CHARS = 600
def _pending_addressed_for(
session: Session,
speaker: Participant,
) -> list[tuple[str, str, str]]:
"""Return (asker_id, asker_name, message_text) for participant messages
that addressed `speaker` since `speaker`'s last own-message turn.
Used to (1) inject an "Open threads directed at you" block into per-
speaker prompt templates and (2) populate the `replying_to` field on
the speaker's outgoing message so the frontend can render a
"Replying to X, Y" pill above the bubble.
"""
last_own_idx = -1
for i, m in enumerate(session.messages):
if (
m.get("role") == "participant"
and m.get("speaker_id") == speaker.participant_id
):
last_own_idx = i
pending: list[tuple[str, str, str]] = []
for m in session.messages[last_own_idx + 1:]:
if m.get("role") != "participant":
continue
if m.get("addressed_to") != speaker.participant_id:
continue
asker_id = m.get("speaker_id") or "unknown"
asker_name = m.get("speaker_name") or "another participant"
text = (m.get("text") or "").strip()
if not text:
continue
pending.append((asker_id, asker_name, text))
return pending
def _format_pending_block(
pending: list[tuple[str, str, str]],
) -> str:
"""Render the open-threads section that gets interpolated into per-
speaker prompt templates. Always non-empty so templates read naturally;
we explicitly print "(none)" when there are no open threads.
"""
if not pending:
return (
"Open threads directed at you since your last turn: (none).\n\n"
)
lines = ["Open threads directed at you since your last turn:"]
for _asker_id, asker_name, text in pending:
snippet = text
if len(snippet) > _PENDING_TRUNCATE_CHARS:
snippet = snippet[:_PENDING_TRUNCATE_CHARS].rstrip() + "..."
lines.append(f' - {asker_name} said to you: "{snippet}"')
return "\n".join(lines) + "\n\n"
def _replying_to_ids(pending: list[tuple[str, str, str]]) -> list[str]:
"""Stable, de-duplicated list of asker participant_ids extracted from a
pending-thread list. Used to populate the message's `replying_to`
field so the frontend can render the "Replying to X, Y" pill.
"""
seen: set[str] = set()
out: list[str] = []
for asker_id, _name, _text in pending:
if asker_id in seen:
continue
seen.add(asker_id)
out.append(asker_id)
return out
# ---------------------------------------------------------------------------
# Failsafe checks
# ---------------------------------------------------------------------------
def _participant_msg_cap_hit(session: Session) -> bool:
return session.total_participant_messages >= session.participant_message_cap
def _orchestrator_cap_hit(session: Session) -> bool:
return session.orchestrator_call_count >= session.orchestrator_call_cap
def _bump_orchestrator_count(session: Session) -> None:
session.orchestrator_call_count += 1
async def _wait_for_continue(
session: Session,
reason: str,
) -> AsyncIterator[str]:
"""Pause the state machine until the user clicks Continue.
Increment values come from `session.limits`, which the user can
tune via the settings menu. Defaults match the historical
PARTICIPANT_MESSAGE_PAUSE_INC / ORCHESTRATOR_CALL_PAUSE_INC.
"""
session.paused_for_continue = True
session.pause_reason = reason
if reason == "messages":
bump_inc = session.limits.participant_message_pause_inc
msg = (
f"Conversation paused after {session.total_participant_messages} "
"participant messages. Click Continue to allow another "
f"{bump_inc} messages."
)
evt = "failsafe_pause"
else:
bump_inc = session.limits.orchestrator_call_pause_inc
msg = (
f"Conversation paused after {session.orchestrator_call_count} "
"orchestrator calls. Click Continue to allow another "
f"{bump_inc} orchestrator calls."
)
evt = "orchestrator_cap_pause"
yield _sse(evt, {
"reason": reason,
"message": msg,
"participant_messages": session.total_participant_messages,
"orchestrator_calls": session.orchestrator_call_count,
})
# Block until pending_continue is flipped by the API layer.
while session.paused_for_continue and not session.pending_continue:
await asyncio.sleep(0.25)
session.pending_continue = False
session.paused_for_continue = False
if reason == "messages":
session.participant_message_cap += bump_inc
else:
session.orchestrator_call_cap += bump_inc
session.pause_reason = None
for chunk in _orchestrator_banner_sse(session, "Resuming conversation..."):
yield chunk
# ---------------------------------------------------------------------------
# Human-participant turn
# ---------------------------------------------------------------------------
async def _wait_for_human_text(
session: Session,
participant: Participant,
*,
phase: Phase,
addressed_to: str | None = None,
asker_id: str | None = None,
asker_name: str | None = None,
prompt_context: str | None = None,
) -> AsyncIterator[str]:
"""Pause the orchestrator until the human types a response (or skips).
Yields a `human_turn_needed` SSE event with the metadata the
frontend needs to render the input slot and the lower-screen
"waiting for your input" cue, then polls the human_io slot until
the API layer's POST /human-response sets it, then yields a
`human_turn_cleared` event so the frontend can dismiss the cue.
The actual response text + skipped flag are NOT returned from this
generator (async gens can't return values cleanly). The caller
reads them via `human_io.slot_for(session.session_id)` AFTER the
iteration completes:
slot.response_text (str)
slot.skipped (bool)
slot.started_at (float) - subtract from now() for elapsed
slot.pending_snapshot (list) - pending threads at turn-start
Caller is expected to reset_slot after consuming the result.
"""
started = time.time()
pending = _pending_addressed_for(session, participant)
slot = human_io.slot_for(session.session_id)
slot.event.clear()
slot.response_text = ""
slot.skipped = False
slot.started_at = started
slot.pending_snapshot = pending
awaiting = {
"speaker_id": participant.participant_id,
"speaker_name": participant.name,
"phase": phase.value,
"addressed_to": addressed_to,
"asker_id": asker_id,
"asker_name": asker_name,
"prompt_context": prompt_context,
}
session.awaiting_human = awaiting
session.paused_for_continue = True
session.pause_reason = "human_turn"
yield _sse("human_turn_needed", awaiting)
try:
# Poll with the same 0.25s cadence as _wait_for_continue so
# SSE-stream cancellation propagates promptly to the user
# clicking Stop.
while not slot.event.is_set():
await asyncio.sleep(0.25)
finally:
session.paused_for_continue = False
session.pause_reason = None
session.awaiting_human = None
yield _sse("human_turn_cleared", {
"speaker_id": participant.participant_id,
})
async def _do_human_turn(
session: Session,
participant: Participant,
*,
phase: Phase,
actives: list[Participant],
addressed_to_target: str | None = None,
asker_id: str | None = None,
asker_name: str | None = None,
prompt_context: str | None = None,
classify_addressed: bool = False,
track_initial_opinion: bool = False,
track_final_opinion: bool = False,
addressed_state: dict[str, Any] | None = None,
) -> AsyncIterator[str]:
"""End-to-end human turn: emit human_turn_needed, await response,
emit human_turn_cleared, then either record a skip note or append a
participant message (with addressed-to classification when asked).
Yields SSE chunks throughout, then runs the failsafe-pause check.
`addressed_state`, when provided, is a caller-owned dict that gets
mutated with {"last_addressed": <participant_id|None>} after the
turn so the consensus phase can update its routing variable
without a return value sneaking out of the generator.
"""
async for chunk in _wait_for_human_text(
session, participant, phase=phase,
addressed_to=addressed_to_target,
asker_id=asker_id, asker_name=asker_name,
prompt_context=prompt_context,
):
yield chunk
slot = human_io.slot_for(session.session_id)
text = (slot.response_text or "").strip()
skipped = slot.skipped
elapsed = max(0.0, time.time() - slot.started_at)
pending = list(slot.pending_snapshot or [])
human_io.reset_slot(session.session_id)
if skipped or not text:
note = _add_orchestrator_message(
session,
f"{participant.name} declined to comment this turn.",
kind="status",
)
yield _sse("orchestrator", _msg_payload(note))
if addressed_state is not None:
addressed_state["last_addressed"] = None
return
addressed: str | None = None
if classify_addressed:
addressed = await classify_addressed_to(
orchestrator_model_id=orchestrator_fast_model_id(session),
participants=actives,
speaker_name=participant.name,
message=text,
api_log=session.api_log,
)
_bump_orchestrator_count(session)
msg = _add_participant_message(
session, participant, text,
phase=phase, elapsed=elapsed,
addressed_to=addressed,
replying_to=_replying_to_ids(pending),
)
if track_initial_opinion:
session.initial_opinions[participant.participant_id] = text
if track_final_opinion:
session.final_opinions[participant.participant_id] = text
if addressed_state is not None:
addressed_state["last_addressed"] = addressed
yield _sse("message", _msg_payload(msg))
if _participant_msg_cap_hit(session):
async for chunk in _wait_for_continue(session, "messages"):
yield chunk
if _orchestrator_cap_hit(session):
async for chunk in _wait_for_continue(session, "orchestrator"):
yield chunk
# ---------------------------------------------------------------------------
# Participant turn (with context budgeting + summarize-on-demand)
# ---------------------------------------------------------------------------
async def _maybe_summarize_for_participant(
session: Session,
participant: Participant,
api_messages: list[dict[str, Any]],
) -> None:
"""If this participant's input estimate exceeds the threshold, run a
summarize call against the configured summarizer model and update
`participant.summary` in place."""
needs_sum, _trim, _budget = should_summarize(
participant.model_id, api_messages, participant.summary,
)
if not needs_sum:
return
# Build a transcript that excludes orchestrator status banners (those
# don't add information value to a summary) but keeps everything the
# participant has said and heard.
summarizable_msgs = [
m for m in session.messages
if m.get("role") != "orchestrator_status"
]
if not summarizable_msgs:
return
transcript = _format_history(summarizable_msgs, include_orchestrator=False)
if not transcript.strip():
return
summarizer_id = _summarizer_model_id(session)
summary_text = await run_summarize(summarizer_id, transcript)
# The summarizer counts as an orchestrator-side call for cap purposes.
session.orchestrator_call_count += 1
if summary_text:
participant.summary.summary_text = summary_text
participant.summary.summarized_through_idx = len(session.messages) - 1
async def _call_participant(
*,
session: Session,
participant: Participant,
user_prompt: str,
label: str,
max_tokens: int = 600,
timeout: float = 45.0,
stream_events: list[str] | None = None,
stream_message_id: str | None = None,
) -> tuple[str, float, bool, str]:
"""Run one participant turn.
Returns ``(text, elapsed_seconds, ok, error_kind)``.
``error_kind`` is ``""`` on success. On failure it's one of:
* ``"transient"`` — HTTP 5xx, 429, timeout, connection error. The
same model is worth retrying.
* ``"permanent"`` — auth, invalid request, content filter, model
gone. Retrying the same model won't help.
* ``"empty"`` — call returned a 200 with an empty body. Treated
as transient by the resilience layer (retry once before
substituting).
* ``"unknown"`` — orchestrator-side exception we couldn't
classify.
The state-machine handles auto-disable on repeated failure; the
resilience layer (`services.resilience.run_resilient_turn`) handles
in-turn retry / alternate / substitution under speed-priority.
"""
others = _participant_roster_string(participant, _active_participants(session))
base_directive = PARTICIPANT_BASE_DIRECTIVE.format(
n_participants=len(_active_participants(session)),
other_participants=others,
)
system_text = (
f"{participant.role_prompt}\n\n{base_directive}\n\n{NO_REASONING_DIRECTIVE}"
)
api_messages: list[dict[str, Any]] = [
{"role": "system", "content": system_text},
{"role": "user", "content": user_prompt},
]
await _maybe_summarize_for_participant(session, participant, api_messages)
needs_sum, needs_trim, input_budget = should_summarize(
participant.model_id, api_messages, participant.summary,
)
# CCAI embeds the transcript inside the user prompt. When over budget,
# swap that block for summary + recent tail (AskJerry pattern).
if needs_sum or needs_trim:
recent_transcript = _format_history(
session.messages[-KEEP_RECENT_MESSAGES:],
include_orchestrator=False,
)
compressed_block = build_compressed_transcript_block(
participant.summary,
recent_transcript,
)
if compressed_block:
new_prompt = replace_embedded_transcript(user_prompt, compressed_block)
user_prompt = new_prompt
api_messages[1] = {"role": "user", "content": user_prompt}
elif len(user_prompt) > input_budget * 4:
# Last-resort: keep prompt head + tail if no transcript header matched.
keep = max(512, input_budget * 2)
user_prompt = (
user_prompt[:keep]
+ "\n\n[…middle truncated for context…]\n\n"
+ user_prompt[-keep:]
)
api_messages[1] = {"role": "user", "content": user_prompt}
max_tokens = cap_max_tokens_for_window(
participant.model_id, api_messages, max_tokens,
)
resolved = {
"model_id": participant.model_id,
"base_url": participant.base_url,
"api_key": participant.api_key,
"is_neon": participant.is_neon,
"hana_model_id": participant.hana_model_id,
"persona_name": participant.persona_name,
"neon_direct_vllm": participant.neon_direct_vllm,
"vllm_base_url": participant.vllm_base_url,
"vllm_api_key": participant.vllm_api_key,
}
log_entry: dict[str, Any] = {
"timestamp": time.time(),
"label": f"participant:{participant.participant_id}:{label}",
"model": participant.model_id,
"request": {"messages": api_messages, "max_tokens": max_tokens},
}
msg_id = stream_message_id or str(uuid.uuid4())
on_text_delta_cb = None
if stream_events is not None:
stream_events.append(_sse("message_stream_start", {
"message_id": msg_id,
"speaker_id": participant.participant_id,
"speaker_name": participant.name,
"kind": participant.kind,
"phase": session.phase.value,
"model_id": participant.model_id,
"model_display": participant.display_name,
}))
def on_text_delta_cb(piece: str) -> None:
stream_events.append(_sse("message_delta", {
"message_id": msg_id,
"delta": piece,
}))
try:
result = await chat_completion(
resolved=resolved,
messages=api_messages,
temperature=0.7,
max_tokens=max_tokens,
timeout=timeout,
on_text_delta=on_text_delta_cb,
)
except Exception as exc:
LOG.exception("Participant %s call failed: %s", participant.participant_id, exc)
log_entry["response"] = {"error": str(exc)}
session.api_log.append(log_entry)
participant.consecutive_failures += 1
return "", 0.0, False, "unknown"
log_entry["response"] = result
session.api_log.append(log_entry)
if result.get("error"):
participant.consecutive_failures += 1
return (
"",
result.get("elapsed_seconds", 0),
False,
result.get("error_kind") or "permanent",
)
text = strip_thinking(result.get("response", ""))
elapsed = float(result.get("elapsed_seconds", 0) or 0)
if not text.strip():
# 200 OK but the model returned nothing usable. Worth one
# retry / substitute attempt before we surface participant_error.
participant.consecutive_failures += 1
return "", elapsed, False, "empty"
participant.consecutive_failures = 0
return text, elapsed, True, ""
def _add_participant_message(
session: Session,
participant: Participant,
text: str,
*,
phase: Phase,
elapsed: float,
addressed_to: str | None = None,
replying_to: list[str] | None = None,
message_id: str | None = None,
) -> dict[str, Any]:
msg = {
"message_id": message_id or str(uuid.uuid4()),
"speaker_id": participant.participant_id,
"speaker_name": participant.name,
"role": "participant",
# `kind` lets the frontend distinguish a human participant's
# message ("human") from LLM messages ("neon" | "extra" |
# "expert") so the green left-edge accent can be applied
# independently of the rotating color palette.
"kind": participant.kind,
"text": text,
"phase": phase.value,
"timestamp": time.time(),
"elapsed_seconds": round(elapsed, 2),
"addressed_to": addressed_to,
# `replying_to` mirrors the pending-thread list we showed the
# speaker at turn-start: ordered, de-duplicated participant_ids of
# everyone whose questions this turn was supposed to address.
# Empty list when there were no open threads. The frontend renders
# this as a "Replying to X, Y" pill above the bubble.
"replying_to": list(replying_to) if replying_to else [],
"model_id": participant.model_id,
"model_display": participant.display_name,
}
session.messages.append(msg)
session.total_participant_messages += 1
return msg
def _add_orchestrator_message(
session: Session,
text: str,
*,
kind: str,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
msg = {
"speaker_id": "orchestrator",
"speaker_name": "Orchestrator",
"role": "orchestrator",
"kind": kind, # "status" | "factor" | "majority_report" | "no_consensus_report"
"text": text,
"phase": session.phase.value,
"timestamp": time.time(),
}
if extra:
msg.update(extra)
session.messages.append(msg)
return msg
def _msg_payload(msg: dict[str, Any]) -> dict[str, Any]:
"""Public payload for a message event over SSE."""
return msg
def _orchestrator_banner_sse(
session: Session,
text: str,
*,
kind: str = "status",
extra: dict[str, Any] | None = None,
) -> list[str]:
"""Append an orchestrator line to the transcript and emit chat + status SSE."""
msg = _add_orchestrator_message(session, text, kind=kind, extra=extra)
return [
_sse("orchestrator", _msg_payload(msg)),
_sse("status", {"message": text}),
]
def _participant_turn_failure_sse(
session: Session,
participant: Participant,
) -> list[str]:
"""Emit participant_error and auto-disable banner when threshold hit."""
out = [
_sse("participant_error", {
"participant_id": participant.participant_id,
"name": participant.name,
"phase": session.phase.value,
}),
]
if participant.consecutive_failures >= session.limits.auto_disable_failures:
participant.enabled = False
out.extend(_orchestrator_banner_sse(
session,
f"{participant.name} auto-disabled after "
f"{session.limits.auto_disable_failures} consecutive failures.",
))
return out
# ---------------------------------------------------------------------------
# Credential summary (concurrent with Phase 1)
# ---------------------------------------------------------------------------
async def _credential_build_runner(
session: Session,
participant: Participant,
initial_opinion: str,
) -> None:
"""Background task: one participant's credential row."""
try:
cred = await build_credential_for_participant(
orchestrator_model_id=_orchestrator_model_id(session),
question=session.question,
participant=participant,
initial_opinion=initial_opinion,
api_log=session.api_log,
)
session.credential_entries_by_pid[participant.participant_id] = cred
session.credential_model_by_pid[participant.participant_id] = (
participant.model_id
)
_bump_orchestrator_count(session)
except Exception as exc:
LOG.exception(
"Credential build failed for %s: %s",
participant.participant_id,
exc,
)
def _schedule_phase1_credential_build(
session: Session,
participant: Participant,
initial_opinion: str,
) -> None:
"""Start (or restart) a background credential build for one AI participant."""
if participant.kind == "human":
return
if not (initial_opinion or "").strip():
return
pid = participant.participant_id
existing = session.credential_build_tasks.get(pid)
if existing is not None and not existing.done():
existing.cancel()
session.credential_build_tasks[pid] = asyncio.create_task(
_credential_build_runner(session, participant, initial_opinion),
name=f"credential:{pid}",
)
def _sync_credential_summary_from_entries(session: Session) -> None:
session.credential_summary = assemble_credential_summary_list(
participants=_active_participants(session),
credential_entries_by_pid=session.credential_entries_by_pid,
human_credential=session.human_credential,
)
async def _await_phase1_credential_tasks(session: Session) -> None:
"""Wait for any in-flight per-participant credential builds."""
tasks = [
t for t in session.credential_build_tasks.values()
if t is not None and not t.done()
]
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
_sync_credential_summary_from_entries(session)
async def _rebuild_participant_credential_on_model_change(
session: Session,
participant: Participant,
) -> bool:
"""Rebuild one credential row when the backing LLM model changes."""
if participant.kind == "human":
return False
pid = participant.participant_id
opinion = (session.initial_opinions or {}).get(pid, "")
if not opinion.strip():
return False
prior_model = session.credential_model_by_pid.get(pid)
if not prior_model or prior_model == participant.model_id:
return False
cred = await build_credential_for_participant(
orchestrator_model_id=_orchestrator_model_id(session),
question=session.question,
participant=participant,
initial_opinion=opinion,
api_log=session.api_log,
)
_bump_orchestrator_count(session)
session.credential_entries_by_pid[pid] = cred
session.credential_model_by_pid[pid] = participant.model_id
_sync_credential_summary_from_entries(session)
return True
# ---------------------------------------------------------------------------
# Phase implementations
# ---------------------------------------------------------------------------
async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
session.phase = Phase.INITIAL_OPINIONS
for chunk in _orchestrator_banner_sse(
session, "Phase 1: collecting independent first opinions...",
):
yield chunk
actives = _active_participants(session)
async def _human_initial(p: Participant) -> AsyncIterator[str]:
async for chunk in _do_human_turn(
session, p, phase=session.phase, actives=actives,
track_initial_opinion=True,
prompt_context=(
"Share your initial opinion on the question. "
"You're speaking BEFORE seeing the other participants."
),
):
yield chunk
async def _post_initial(result: _AiTurnResult) -> dict[str, Any]:
speaker = result.turn.speaker
session.initial_opinions[speaker.participant_id] = result.turn.text
_schedule_phase1_credential_build(
session, speaker, result.turn.text,
)
return {}
def _build_initial_spec(p: Participant) -> _AiTurnSpec | None:
if p.kind == "human":
return None
return _AiTurnSpec(
participant=p,
user_prompt=INITIAL_OPINION_PROMPT.format(question=session.question),
label="initial_opinion",
max_tokens=700,
)
async for chunk in run_initial_opinions_roster(
session,
actives,
build_spec=_build_initial_spec,
call_participant=_call_participant,
on_human_turn=_human_initial,
post_process=_post_initial,
):
yield chunk
# Credential rows were built in parallel as each opinion landed;
# only wait here if any background task is still finishing.
await _await_phase1_credential_tasks(session)
yield _sse("credentials_updated", {
"stage": "built",
"credentials": session.credential_summary,
})
_CRITIQUE_PHASES = {
1: Phase.CRITIQUE_ROUND_1,
2: Phase.CRITIQUE_ROUND_2,
3: Phase.CRITIQUE_ROUND_3,
4: Phase.CRITIQUE_ROUND_4,
}
def _critique_phase_for(round_number: int) -> Phase:
"""Map a critique round number to the matching Phase enum value.
Falls back to CRITIQUE_ROUND_2 for unknown numbers - the API layer
clamps `critique_rounds` to the bounds, so this fallback is purely
defensive."""
return _CRITIQUE_PHASES.get(round_number, Phase.CRITIQUE_ROUND_2)
async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[str]:
session.phase = _critique_phase_for(round_number)
round_total = session.limits.critique_rounds
for chunk in _orchestrator_banner_sse(
session,
f"Phase 2: critique round {round_number} of {round_total}...",
):
yield chunk
cred_block = credentials_to_block(session.credential_summary)
actives = _active_participants(session)
# Freeze transcript + pending threads at round start so parallel
# turns see the same context and reply metadata stays consistent.
transcript_snapshot = _format_history(session.messages)
pending_snapshot = {
p.participant_id: _pending_addressed_for(session, p)
for p in actives
if p.kind != "human"
}
async def _human_critique(p: Participant) -> AsyncIterator[str]:
async for chunk in _do_human_turn(
session, p, phase=session.phase, actives=actives,
classify_addressed=True,
prompt_context=(
f"Critique round {round_number} of {round_total}. "
"Push back on, agree with, or build on what others "
"have said. Address other participants by name."
),
):
yield chunk
def _build_critique_spec(p: Participant) -> _AiTurnSpec | None:
if p.kind == "human":
return None
pending = pending_snapshot.get(p.participant_id, [])
pending_block = _format_pending_block(pending)
prompt = CRITIQUE_PROMPT.format(
round_number=round_number,
round_total=round_total,
question=session.question,
credential_summary=cred_block,
transcript=transcript_snapshot,
pending_block=pending_block,
)
return _AiTurnSpec(
participant=p,
user_prompt=prompt,
label=f"critique_round_{round_number}",
max_tokens=700,
)
async def _post_critique(result: _AiTurnResult) -> dict[str, Any]:
speaker = result.turn.speaker
addressed = await classify_addressed_to(
orchestrator_model_id=orchestrator_fast_model_id(session),
participants=_active_participants(session),
speaker_name=speaker.name,
message=result.turn.text,
api_log=session.api_log,
)
_bump_orchestrator_count(session)
return {
"addressed_to": addressed,
"replying_to": _replying_to_ids(result.pending),
}
async for chunk in run_roster_ai_turns_parallel(
session,
actives,
phase=session.phase,
build_spec=_build_critique_spec,
call_participant=_call_participant,
on_human_turn=_human_critique,
post_process=_post_critique,
):
yield chunk
async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
session.phase = Phase.STATUS_ASSESSMENT
for chunk in _orchestrator_banner_sse(
session, "Phase 3: assessing whether more questions are needed...",
):
yield chunk
cred_block = credentials_to_block(session.credential_summary)
for iteration in range(session.limits.status_assessment_max):
session.status_assessment_iterations = iteration + 1
transcript = await compact_transcript_for_orchestrator(
session,
orchestrator_model_id=orchestrator_fast_model_id(session),
)
prompt = STATUS_ASSESSMENT_PROMPT.format(
question=session.question,
credential_summary=cred_block,
transcript=transcript,
)
_raw, parsed = await orchestrator_call(
orchestrator_model_id=_orchestrator_model_id(session),
user_prompt=prompt,
label=f"status_assessment_{iteration + 1}",
api_log=session.api_log,
max_tokens=512,
)
_bump_orchestrator_count(session)
opinions_solidified = bool(
isinstance(parsed, dict) and parsed.get("opinions_solidified")
)
open_qs: list[dict[str, Any]] = []
if isinstance(parsed, dict):
open_qs = parsed.get("open_questions") or []
if opinions_solidified or not open_qs:
msg = _add_orchestrator_message(
session,
"Opinions appear solidified - moving to finalization.",
kind="status",
)
yield _sse("orchestrator", _msg_payload(msg))
return
# Otherwise run targeted follow-ups
active_ids = {p.participant_id for p in _active_participants(session)}
for oq in open_qs:
pid = oq.get("participant_id")
question_text = (oq.get("question") or "").strip()
if not pid or pid not in active_ids or not question_text:
continue
target = next(p for p in session.participants if p.participant_id == pid)
# Decide synthesized vs verbatim. Source of truth is
# asker_participant_id - if it resolves to a real, *different*,
# active participant we treat the question as verbatim from
# them. Otherwise we treat it as orchestrator-synthesized.
asker_id = (oq.get("asker_participant_id") or "").strip() or None
asker: Participant | None = None
if asker_id and asker_id in active_ids and asker_id != pid:
asker = next(
p for p in session.participants
if p.participant_id == asker_id
)
if asker is not None:
announce = (
f"{asker.name} raised a question earlier, to "
f"{target.name}: \"{question_text}\""
)
else:
announce = (
f"I have a follow-up question for {target.name}: "
f"\"{question_text}\""
)
announce_msg = _add_orchestrator_message(session, announce, kind="status")
yield _sse("orchestrator", _msg_payload(announce_msg))
if target.kind == "human":
async for chunk in _do_human_turn(
session, target, phase=session.phase,
actives=_active_participants(session),
asker_id=(asker.participant_id if asker else None),
asker_name=(asker.name if asker else None),
prompt_context=question_text,
):
yield chunk
continue
transcript = _format_history(session.messages)
if asker is not None:
prompt2 = TARGETED_FOLLOWUP_FROM_PARTICIPANT_PROMPT.format(
transcript=transcript,
credential_summary=cred_block,
targeted_question=question_text,
asker_name=asker.name,
)
else:
prompt2 = TARGETED_FOLLOWUP_PROMPT.format(
transcript=transcript,
credential_summary=cred_block,
targeted_question=question_text,
)
stream_events: list[str] = []
stream_msg_id = str(uuid.uuid4())
turn = await run_resilient_turn(
session=session, participant=target,
user_prompt=prompt2,
label="targeted_followup",
max_tokens=600,
call_participant=_call_participant,
stream_events=stream_events,
stream_message_id=stream_msg_id,
)
for ev in stream_events:
yield ev
for ev in turn.sse_events:
yield ev
if not turn.ok:
for chunk in _participant_turn_failure_sse(session, target):
yield chunk
continue
speaker = turn.speaker
text, elapsed = turn.text, turn.elapsed
# When the orchestrator is relaying a verbatim question from
# another participant, mark this turn as replying to that
# asker so the frontend can render the "Replying to X" pill.
replying_to = [asker.participant_id] if asker is not None else []
msg = _add_participant_message(
session, speaker, text, phase=session.phase, elapsed=elapsed,
replying_to=replying_to,
message_id=stream_msg_id,
)
yield _sse("message", _msg_payload(msg))
if _participant_msg_cap_hit(session):
async for chunk in _wait_for_continue(session, "messages"):
yield chunk
if _orchestrator_cap_hit(session):
async for chunk in _wait_for_continue(session, "orchestrator"):
yield chunk
msg = _add_orchestrator_message(
session,
"Moving to finalization.",
kind="status",
)
yield _sse("orchestrator", _msg_payload(msg))
async def _phase_finalization(session: Session) -> AsyncIterator[str]:
session.phase = Phase.FINALIZATION
for chunk in _orchestrator_banner_sse(session, "Phase 4: opinion finalization..."):
yield chunk
cred_block = credentials_to_block(session.credential_summary)
actives = _active_participants(session)
transcript_snapshot = _format_history(session.messages)
pending_snapshot = {
p.participant_id: _pending_addressed_for(session, p)
for p in actives
if p.kind != "human"
}
async def _human_final(p: Participant) -> AsyncIterator[str]:
async for chunk in _do_human_turn(
session, p, phase=session.phase, actives=actives,
track_final_opinion=True,
prompt_context=(
"Phase 4: state your final opinion on the question, "
"incorporating whatever you've learned in the discussion."
),
):
yield chunk
def _build_final_spec(p: Participant) -> _AiTurnSpec | None:
if p.kind == "human":
return None
pending = pending_snapshot.get(p.participant_id, [])
pending_block = _format_pending_block(pending)
prompt = FINALIZATION_PROMPT.format(
question=session.question,
credential_summary=cred_block,
transcript=transcript_snapshot,
pending_block=pending_block,
)
return _AiTurnSpec(
participant=p,
user_prompt=prompt,
label="finalization",
max_tokens=600,
)
async def _post_final(result: _AiTurnResult) -> dict[str, Any]:
session.final_opinions[result.participant.participant_id] = result.turn.text
return {"replying_to": _replying_to_ids(result.pending)}
async for chunk in run_roster_ai_turns_parallel(
session,
actives,
phase=session.phase,
build_spec=_build_final_spec,
call_participant=_call_participant,
on_human_turn=_human_final,
post_process=_post_final,
):
yield chunk
async def _phase_consensus(session: Session) -> AsyncIterator[str]:
session.phase = Phase.CONSENSUS
for chunk in _orchestrator_banner_sse(session, "Phase 5: consensus gathering..."):
yield chunk
cred_block = credentials_to_block(session.credential_summary)
actives = _active_participants(session)
# Initial alliance detection from the finalization-phase opinions
groups = await detect_alliances(
orchestrator_model_id=_orchestrator_model_id(session),
question=session.question,
participants=actives,
final_opinions=session.final_opinions,
api_log=session.api_log,
)
_bump_orchestrator_count(session)
session.alliance_groups = groups
# Render alliance group members using the same display names shown
# in the sidebar (Participant.name), not raw participant_ids.
id_to_name = {p.participant_id: p.name for p in actives}
alliance_prefix = (
"Updated alliance groups detected: "
if session.consensus_attempts > 0
else "Alliance groups detected: "
)
announce = alliance_prefix + "; ".join(
f"\"{g.get('stance', '')}\" -> ["
+ ", ".join(
id_to_name.get(m, m) for m in (g.get("members") or [])
)
+ "]"
for g in groups
)
msg = _add_orchestrator_message(session, announce, kind="status")
yield _sse("orchestrator", _msg_payload(msg))
# Round-robin among active participants, but yield to the addressed-to
# target whenever the previous message named one explicitly. To keep
# two participants from monopolizing the floor with an A->B->A->B
# loop, we cap consecutive addressed-to routings at the configured
# `dyad_cap`. After that many in a row, we force a round-robin pick.
queue: list[Participant] = list(actives)
last_addressed: str | None = None
dyad_run: int = 0
dyad_cap = session.limits.dyad_cap
# Hard backstop on this phase: if we make a lot of consensus turns
# without resolving, exit and let closure handle it. The orchestrator-
# call cap will usually hit before this, but it's a clean upper bound.
max_consensus_turns = (
session.limits.consensus_turns_per_participant * len(actives)
)
consensus_turns = 0
while consensus_turns < max_consensus_turns:
consensus_turns += 1
actives = _active_participants(session)
if len(actives) < 2:
break
queue = [p for p in queue if p.enabled]
# Pick speaker. Prefer the addressed-to target (dyadic exchange)
# only while we're under the consecutive-routing cap. Once the
# cap is hit, force a round-robin pick so a third voice can join.
if last_addressed and dyad_run < dyad_cap:
speaker = next(
(p for p in actives if p.participant_id == last_addressed),
None,
)
if speaker is None:
speaker = queue[0] if queue else actives[0]
dyad_run = 0
else:
queue = [p for p in queue if p.participant_id != speaker.participant_id]
dyad_run += 1
last_addressed = None
else:
if not queue:
queue = list(actives)
speaker = queue.pop(0)
dyad_run = 0
last_addressed = None
if speaker.kind == "human":
addressed_state: dict[str, Any] = {}
async for chunk in _do_human_turn(
session, speaker, phase=session.phase, actives=actives,
classify_addressed=True,
addressed_state=addressed_state,
prompt_context=(
"Phase 5: weigh in on whether you agree, disagree, "
"or want to refine. Address other participants by "
"name when you're responding to something specific "
"they said."
),
):
yield chunk
# Propagate addressed_to so dyad routing also works when the
# last speaker was the human.
last_addressed = addressed_state.get("last_addressed")
# Status check every full round (every len(actives) turns).
# Replicated here because the LLM-path code below also does
# it, and we need it on the human path too.
if consensus_turns % max(1, len(actives)) == 0:
terminal = await _consensus_status_terminal_sse(session, actives)
if terminal:
yield terminal
return
continue
# Decide allied vs solo prompt
speaker_group, other_groups = _find_speaker_group(speaker, session.alliance_groups)
prompt = _build_consensus_prompt(
session, speaker, speaker_group, other_groups,
actives, cred_block,
)
# Snapshot pending threads BEFORE the call so the outgoing
# message records who this turn was supposed to be replying to.
pending = _pending_addressed_for(session, speaker)
stream_events: list[str] = []
stream_msg_id = str(uuid.uuid4())
turn = await run_resilient_turn(
session=session, participant=speaker,
user_prompt=prompt,
label="consensus",
max_tokens=700,
call_participant=_call_participant,
stream_events=stream_events,
stream_message_id=stream_msg_id,
)
for ev in stream_events:
yield ev
for ev in turn.sse_events:
yield ev
if not turn.ok:
for chunk in _participant_turn_failure_sse(session, speaker):
yield chunk
continue
# Consensus phase doesn't swap participants, only LLMs behind
# them, so turn.speaker is the same instance as `speaker`. Use
# turn.speaker to stay consistent with other phases.
speaker = turn.speaker
text, elapsed = turn.text, turn.elapsed
addressed = await classify_addressed_to(
orchestrator_model_id=orchestrator_fast_model_id(session),
participants=actives,
speaker_name=speaker.name,
message=text,
api_log=session.api_log,
)
_bump_orchestrator_count(session)
last_addressed = addressed
msg = _add_participant_message(
session, speaker, text, phase=session.phase, elapsed=elapsed,
addressed_to=addressed,
replying_to=_replying_to_ids(pending),
message_id=stream_msg_id,
)
yield _sse("message", _msg_payload(msg))
if _participant_msg_cap_hit(session):
async for chunk in _wait_for_continue(session, "messages"):
yield chunk
if _orchestrator_cap_hit(session):
async for chunk in _wait_for_continue(session, "orchestrator"):
yield chunk
# Status check every full round (every len(actives) turns)
if consensus_turns % max(1, len(actives)) == 0:
terminal = await _consensus_status_terminal_sse(session, actives)
if terminal:
yield terminal
return
async def _consensus_status_terminal_sse(
session: Session,
actives: list[Participant],
) -> str | None:
"""Run a consensus status check. Returns an SSE chunk when the phase
should end (majority or unproductive), else None."""
transcript = await compact_transcript_for_orchestrator(
session,
orchestrator_model_id=orchestrator_fast_model_id(session),
)
status = await assess_consensus_status(
orchestrator_model_id=orchestrator_fast_model_id(session),
question=session.question,
transcript=transcript,
alliance_groups=session.alliance_groups,
api_log=session.api_log,
)
_bump_orchestrator_count(session)
if status.get("status") == "majority":
session.alliance_groups = await _refresh_alliance_groups(session, actives)
msg = _add_orchestrator_message(
session,
f"Majority reached. {status.get('rationale', '')}".strip(),
kind="status",
)
return _sse("orchestrator", _msg_payload(msg))
if status.get("status") == "unproductive":
msg = _add_orchestrator_message(
session,
f"Conversation no longer productive. {status.get('rationale', '')}".strip(),
kind="status",
)
return _sse("orchestrator", _msg_payload(msg))
return None
async def _refresh_alliance_groups(
session: Session,
actives: list[Participant],
) -> list[dict[str, Any]]:
"""Re-cluster after the consensus phase, treating the latest round of
consensus statements as each participant's current stance."""
latest_by_id: dict[str, str] = {}
for m in session.messages:
if m.get("role") != "participant":
continue
if m.get("phase") != Phase.CONSENSUS.value:
continue
latest_by_id[m["speaker_id"]] = m["text"]
# Fall back to finalization opinions for any participant who didn't
# speak in the consensus phase yet.
merged: dict[str, str] = dict(session.final_opinions)
merged.update(latest_by_id)
groups = await detect_alliances(
orchestrator_model_id=_orchestrator_model_id(session),
question=session.question,
participants=actives,
final_opinions=merged,
api_log=session.api_log,
)
_bump_orchestrator_count(session)
return groups
def _find_speaker_group(
speaker: Participant,
groups: list[dict[str, Any]],
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
speaker_group: dict[str, Any] | None = None
others: list[dict[str, Any]] = []
for g in groups:
if speaker.participant_id in (g.get("members") or []):
speaker_group = g
else:
others.append(g)
return speaker_group, others
def _build_consensus_prompt(
session: Session,
speaker: Participant,
speaker_group: dict[str, Any] | None,
other_groups: list[dict[str, Any]],
actives: list[Participant],
cred_block: str,
) -> str:
transcript = _format_history(session.messages)
pending_block = _format_pending_block(
_pending_addressed_for(session, speaker)
)
# NOTE: The "speaker is whoever was addressed last" routing happens in
# _phase_consensus, NOT here. This function only renders the prompt the
# speaker receives. Whatever needs answering shows up in pending_block,
# so a single unified allied/solo template handles both targeted and
# broadcast turns - the prompt's "FIRST address open threads" rule
# naturally focuses the speaker on whoever was just talking to them.
if speaker_group and len(speaker_group.get("members") or []) > 1:
members = ", ".join(
p.name for p in actives
if p.participant_id in (speaker_group.get("members") or [])
and p.participant_id != speaker.participant_id
) or "(no co-allies named)"
return CONSENSUS_ALLIED_PROMPT.format(
alliance_members=members,
alliance_stance=speaker_group.get("stance", "(unspecified)"),
question=session.question,
credential_summary=cred_block,
transcript=transcript,
pending_block=pending_block,
)
other_groups_block = "\n".join(
f" - \"{g.get('stance', '')}\" supported by " + ", ".join(
p.name for p in actives if p.participant_id in (g.get("members") or [])
)
for g in other_groups
) or "(no other groups)"
return CONSENSUS_SOLO_PROMPT.format(
your_stance=(speaker_group or {}).get("stance", "(unspecified)"),
other_groups_block=other_groups_block,
question=session.question,
credential_summary=cred_block,
transcript=transcript,
pending_block=pending_block,
)
# ---------------------------------------------------------------------------
# Closure
# ---------------------------------------------------------------------------
async def _phase_closure(session: Session) -> AsyncIterator[str]:
session.phase = Phase.CLOSURE
for chunk in _orchestrator_banner_sse(session, "Phase 6: closure..."):
yield chunk
cred_block = credentials_to_block(session.credential_summary)
transcript = await compact_transcript_for_orchestrator(
session,
orchestrator_model_id=orchestrator_fast_model_id(session),
)
status = await assess_consensus_status(
orchestrator_model_id=orchestrator_fast_model_id(session),
question=session.question,
transcript=transcript,
alliance_groups=session.alliance_groups,
api_log=session.api_log,
)
_bump_orchestrator_count(session)
actives = _active_participants(session)
if status.get("status") == "majority":
idx = status.get("majority_group_index")
majority_group = None
if isinstance(idx, int) and 0 <= idx < len(session.alliance_groups):
majority_group = session.alliance_groups[idx]
else:
# Fallback: largest group wins
if session.alliance_groups:
majority_group = max(
session.alliance_groups,
key=lambda g: len(g.get("members") or []),
)
if majority_group:
members_names = [
p.name for p in actives
if p.participant_id in (majority_group.get("members") or [])
]
stance = majority_group.get("stance", "")
prompt = MAJORITY_REPORT_PROMPT.format(
question=session.question,
credential_summary=cred_block,
majority_members=", ".join(members_names),
majority_stance=stance,
transcript=transcript,
)
raw, _ = await orchestrator_call(
orchestrator_model_id=_orchestrator_model_id(session),
user_prompt=prompt,
label="majority_report",
api_log=session.api_log,
expect_json=False,
max_tokens=900,
temperature=0.3,
)
_bump_orchestrator_count(session)
session.final_report = {
"kind": "majority",
"text": raw,
"majority_members": members_names,
"majority_stance": stance,
"alliance_groups": session.alliance_groups,
}
msg = _add_orchestrator_message(
session, raw, kind="majority_report",
extra={"majority_members": members_names, "majority_stance": stance},
)
yield _sse("orchestrator", _msg_payload(msg))
return
# Not productive / no majority. We may surface an unaddressed
# factor and re-run consensus up to `stall_recovery_attempts` times
# before giving up and emitting the no-consensus report.
if session.consensus_attempts < session.limits.stall_recovery_attempts:
session.consensus_attempts += 1
factor = await find_unaddressed_factor(
orchestrator_model_id=_orchestrator_model_id(session),
question=session.question,
credential_summary_block=cred_block,
transcript=transcript,
api_log=session.api_log,
)
_bump_orchestrator_count(session)
if factor and factor.get("factor"):
announce = (
f"The discussion has stalled. The orchestrator surfaces a new "
f"factor for the group to consider: {factor['factor']}"
)
msg = _add_orchestrator_message(
session, announce, kind="factor",
extra={"expected_to_shift": factor.get("expected_to_shift") or []},
)
yield _sse("orchestrator", _msg_payload(msg))
# Re-run the consensus phase once more
async for chunk in _phase_consensus(session):
yield chunk
async for chunk in _phase_closure(session):
yield chunk
return
# Failed twice (or no factor surfaced) -> emit no-consensus report
prompt = NO_CONSENSUS_REPORT_PROMPT.format(
question=session.question,
credential_summary=cred_block,
alliance_block="\n".join(
f" - \"{g.get('stance', '')}\": "
+ ", ".join(
p.name for p in actives
if p.participant_id in (g.get("members") or [])
)
for g in session.alliance_groups
),
transcript=transcript,
)
raw, _ = await orchestrator_call(
orchestrator_model_id=_orchestrator_model_id(session),
user_prompt=prompt,
label="no_consensus_report",
api_log=session.api_log,
expect_json=False,
max_tokens=900,
temperature=0.3,
)
_bump_orchestrator_count(session)
session.final_report = {
"kind": "no_consensus",
"text": raw,
"alliance_groups": session.alliance_groups,
}
msg = _add_orchestrator_message(session, raw, kind="no_consensus_report")
yield _sse("orchestrator", _msg_payload(msg))
# ---------------------------------------------------------------------------
# Public driver
# ---------------------------------------------------------------------------
async def run_conversation(session: Session) -> AsyncIterator[str]:
"""Drive the full conversation, yielding SSE chunks.
The flow is structure → decision: the chosen ConversationStructure
runs its phases, then hands a DecisionInput to the chosen
DecisionMethod which runs the decision phase(s). Both are
resolved from `session.conversation_structure_id` /
`session.decision_method_id` (defaults: collaborative + consensus,
which preserves the original CCAI behavior).
"""
# Lazy import so the conversation package can import orchestrator
# helpers without a circular module load.
from app.services.conversation import get_structure, get_decision
actives = _active_participants(session)
if len(actives) < 2:
yield _sse("error", {
"message": "Need at least 2 active participants to start.",
})
yield _sse("done", {})
return
if len(actives) > session.max_participants:
# Defense in depth - the API layer should have already enforced this.
for extra in actives[session.max_participants:]:
extra.enabled = False
structure_cls = get_structure(session.conversation_structure_id)
decision_cls = get_decision(session.decision_method_id)
structure = structure_cls(session)
try:
async for chunk in structure.run():
yield chunk
# Kick off contribution summaries in the background just before
# the decision phase. The Table View blocks on this task only if
# the user opens it before it finishes - usually it'll be done
# by then, so the table loads instantly.
_start_contribution_summary_task(session)
decision_input = structure.build_decision_input()
decision = decision_cls(session, decision_input)
async for chunk in decision.run():
yield chunk
except Exception as exc:
LOG.exception("Conversation crashed: %s", exc)
yield _sse("error", {"message": f"Internal error: {exc}"})
finally:
session.finished = True
session.phase = Phase.FINISHED
# Drop the human-input slot (if any) so its asyncio.Event
# doesn't outlive the session in the module-level registry.
human_io.drop_session(session.session_id)
yield _sse("system", {"text": "End of Chat", "phase": session.phase.value})
yield _sse("done", {})
def _start_contribution_summary_task(session: Session) -> None:
"""Schedule the contribution-summary build as a background task.
Idempotent: if a task is already in flight (or completed) we don't
start another one. Errors in the background task are swallowed and
logged - the Table View endpoint will fall back to a synchronous
build if needed.
"""
if session.contribution_summary_task is not None:
return
if any((session.contribution_summaries or {}).values()):
return
async def _runner() -> None:
try:
await _build_contribution_summaries(session)
except Exception as exc: # noqa: BLE001
LOG.warning(
"Background contribution_summaries failed for %s: %s",
session.session_id, exc,
)
try:
session.contribution_summary_task = asyncio.create_task(_runner())
except RuntimeError:
session.contribution_summary_task = None
async def ensure_contribution_summaries(session: Session) -> None:
"""Block on contribution summaries for the Table View.
Order of preference:
1. Cached - return immediately.
2. Background task in flight - await it.
3. Nothing started - build synchronously.
"""
if any((session.contribution_summaries or {}).values()):
return
task = session.contribution_summary_task
if task is not None and not task.done():
try:
await task
except Exception as exc: # noqa: BLE001
LOG.warning("contribution_summary_task await failed: %s", exc)
if any((session.contribution_summaries or {}).values()):
return
await _build_contribution_summaries(session)
async def _build_contribution_summaries(session: Session) -> None:
actives = _active_participants(session)
roster = "\n".join(
f"- id: {p.participant_id} | name: {p.name}" for p in actives
)
transcript = _format_history(session.messages)
prompt = CONTRIBUTION_SUMMARY_PROMPT.format(
roster_block=roster,
transcript=transcript,
)
_raw, parsed = await orchestrator_call(
orchestrator_model_id=_orchestrator_model_id(session),
user_prompt=prompt,
label="contribution_summaries",
api_log=session.api_log,
max_tokens=900,
)
session.orchestrator_call_count += 1
if isinstance(parsed, dict) and isinstance(parsed.get("contributions"), list):
for c in parsed["contributions"]:
pid = c.get("participant_id")
summary = (c.get("summary") or "").strip()
if pid and summary:
session.contribution_summaries[pid] = summary