NeonClary Cursor commited on
Commit
83add3c
·
1 Parent(s): de1db91

perf(orchestrator): parallel phases, streaming, early table summaries

Browse files

- Parallel participant turns in Phases 1/2/4 via orchestrator_speed.run_roster_ai_turns_parallel
- SSE token streaming on consensus + targeted follow-up turns (message_stream_start / message_delta)
- Parallel ballot collection in majority / ranked-choice / Robert's Rules votes
- Heuristic-first addressed_to classifier skips most LLM calls
- Compact rolling transcript for orchestrator judge prompts (cached per session)
- Fast orchestrator model (gemini-2.0-flash) for lightweight classifier calls
- Lazy contribution summaries kicked off after Phase 4 so Table View loads instantly
- Conditional Phase-3 credential refresh only when follow-ups are needed
- max_tokens 4x multiplier limited to thinking models

Co-authored-by: Cursor <cursoragent@cursor.com>

COMMIT_MSG.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ perf(orchestrator): parallel phases, streaming, early table summaries
2
+
3
+ - Parallel participant turns in Phases 1/2/4 via orchestrator_speed.run_roster_ai_turns_parallel
4
+ - SSE token streaming on consensus + targeted follow-up turns (message_stream_start / message_delta)
5
+ - Parallel ballot collection in majority / ranked-choice / Robert's Rules votes
6
+ - Heuristic-first addressed_to classifier skips most LLM calls
7
+ - Compact rolling transcript for orchestrator judge prompts (cached per session)
8
+ - Fast orchestrator model (gemini-2.0-flash) for lightweight classifier calls
9
+ - Lazy contribution summaries kicked off after Phase 4 so Table View loads instantly
10
+ - Conditional Phase-3 credential refresh only when follow-ups are needed
11
+ - max_tokens 4x multiplier limited to thinking models
backend/app/api/chat.py CHANGED
@@ -985,6 +985,13 @@ async def api_table_view(session_id: str):
985
  if not session:
986
  raise HTTPException(404, "Session not found")
987
 
 
 
 
 
 
 
 
988
  rows = []
989
  for p in session.participants:
990
  first = (session.initial_opinions or {}).get(p.participant_id, "")
 
985
  if not session:
986
  raise HTTPException(404, "Session not found")
987
 
988
+ from app.services.orchestrator import ensure_contribution_summaries
989
+
990
+ try:
991
+ await ensure_contribution_summaries(session)
992
+ except Exception as exc:
993
+ LOG.warning("Failed to build contribution summaries: %s", exc)
994
+
995
  rows = []
996
  for p in session.participants:
997
  first = (session.initial_opinions or {}).get(p.participant_id, "")
backend/app/clients/llm_router.py CHANGED
@@ -45,11 +45,19 @@ async def chat_completion(
45
  temperature: float = 0.7,
46
  max_tokens: int = 1024,
47
  timeout: float | None = None,
 
48
  ) -> dict[str, Any]:
49
  """Unified LLM call that routes Neon models through HANA and others through OpenAI-compat."""
50
  if resolved.get("is_neon"):
51
  return await _call_hana(resolved, messages, temperature, max_tokens)
52
 
 
 
 
 
 
 
 
53
  from app.config import settings
54
  if settings.speed_priority:
55
  return await _racing_openai(resolved, messages, temperature, max_tokens, timeout)
@@ -63,6 +71,7 @@ async def _plain_openai(
63
  temperature: float,
64
  max_tokens: int,
65
  timeout: float | None,
 
66
  ) -> dict[str, Any]:
67
  return await openai_chat_completion(
68
  base_url=resolved["base_url"],
@@ -72,6 +81,7 @@ async def _plain_openai(
72
  temperature=temperature,
73
  max_tokens=max_tokens,
74
  timeout=timeout,
 
75
  )
76
 
77
 
 
45
  temperature: float = 0.7,
46
  max_tokens: int = 1024,
47
  timeout: float | None = None,
48
+ on_text_delta: Any | None = None,
49
  ) -> dict[str, Any]:
50
  """Unified LLM call that routes Neon models through HANA and others through OpenAI-compat."""
51
  if resolved.get("is_neon"):
52
  return await _call_hana(resolved, messages, temperature, max_tokens)
53
 
54
+ # Streaming uses a single request path (no model racing).
55
+ if on_text_delta is not None:
56
+ return await _plain_openai(
57
+ resolved, messages, temperature, max_tokens, timeout,
58
+ on_text_delta=on_text_delta,
59
+ )
60
+
61
  from app.config import settings
62
  if settings.speed_priority:
63
  return await _racing_openai(resolved, messages, temperature, max_tokens, timeout)
 
71
  temperature: float,
72
  max_tokens: int,
73
  timeout: float | None,
74
+ on_text_delta: Any | None = None,
75
  ) -> dict[str, Any]:
76
  return await openai_chat_completion(
77
  base_url=resolved["base_url"],
 
81
  temperature=temperature,
82
  max_tokens=max_tokens,
83
  timeout=timeout,
84
+ on_text_delta=on_text_delta,
85
  )
86
 
87
 
backend/app/clients/openai_compat.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import asyncio
 
4
  import logging
5
  import time
6
  from typing import Any
@@ -19,6 +20,11 @@ _MAX_COMPLETION_TOKEN_MODELS = {
19
  }
20
  _NO_TEMPERATURE_MODELS = {"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini"}
21
 
 
 
 
 
 
22
  # Reserve a few tokens for chat-template framing the server tacks on (the
23
  # vLLM server and OpenAI both add a small overhead per request that our
24
  # input estimate doesn't account for).
@@ -68,6 +74,13 @@ def _estimate_input_tokens(messages: list[dict[str, str]]) -> int:
68
  return total
69
 
70
 
 
 
 
 
 
 
 
71
  def _resolve_effective_max(
72
  model: str, requested: int, messages: list[dict[str, str]],
73
  ) -> tuple[int, int, int]:
@@ -86,7 +99,8 @@ def _resolve_effective_max(
86
  window = context_window_for(model)
87
  input_estimate = _estimate_input_tokens(messages)
88
  headroom = max(_MIN_OUTPUT_TOKENS, window - input_estimate - _INPUT_SAFETY_MARGIN)
89
- effective_max = max(_MIN_OUTPUT_TOKENS, min(requested * 4, headroom))
 
90
  return effective_max, input_estimate, window
91
 
92
 
@@ -110,6 +124,7 @@ async def openai_chat_completion(
110
  temperature: float = 0.7,
111
  max_tokens: int = 1024,
112
  timeout: float | None = None,
 
113
  ) -> dict[str, Any]:
114
  """Send a chat completion request to any OpenAI-compatible endpoint."""
115
  url = f"{base_url.rstrip('/')}/chat/completions"
@@ -123,11 +138,12 @@ async def openai_chat_completion(
123
  effective_max, input_estimate, window = _resolve_effective_max(
124
  model, max_tokens, messages,
125
  )
126
- if effective_max < max_tokens * 4:
 
127
  LOG.info(
128
- "Capped max_tokens for %s: requested %d (x4=%d), input ~=%d, "
129
  "window=%d, sending %d",
130
- model, max_tokens, max_tokens * 4, input_estimate, window,
131
  effective_max,
132
  )
133
  effective_timeout = max(timeout * 2, 120) if timeout else timeout
@@ -146,6 +162,59 @@ async def openai_chat_completion(
146
  req_timeout = httpx.Timeout(effective_timeout) if effective_timeout else None
147
  client = _get_client()
148
  t0 = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  for attempt in range(2):
150
  try:
151
  resp = await client.post(url, json=body, headers=headers, timeout=req_timeout)
 
1
  from __future__ import annotations
2
 
3
  import asyncio
4
+ import json
5
  import logging
6
  import time
7
  from typing import Any
 
20
  }
21
  _NO_TEMPERATURE_MODELS = {"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini"}
22
 
23
+ # Models that emit long internal reasoning traces; allow a higher completion
24
+ # cap. Flash/mini/instruct models use the requested max_tokens as-is.
25
+ _THINKING_MULTIPLIER_MODEL_PREFIXES = _MAX_COMPLETION_TOKEN_MODELS
26
+ _THINKING_NAME_HINTS = ("thinking", "reasoning", "-think", "/think")
27
+
28
  # Reserve a few tokens for chat-template framing the server tacks on (the
29
  # vLLM server and OpenAI both add a small overhead per request that our
30
  # input estimate doesn't account for).
 
74
  return total
75
 
76
 
77
+ def _model_wants_thinking_multiplier(model: str) -> bool:
78
+ mid = (model or "").lower()
79
+ if any(mid.startswith(prefix) for prefix in _THINKING_MULTIPLIER_MODEL_PREFIXES):
80
+ return True
81
+ return any(hint in mid for hint in _THINKING_NAME_HINTS)
82
+
83
+
84
  def _resolve_effective_max(
85
  model: str, requested: int, messages: list[dict[str, str]],
86
  ) -> tuple[int, int, int]:
 
99
  window = context_window_for(model)
100
  input_estimate = _estimate_input_tokens(messages)
101
  headroom = max(_MIN_OUTPUT_TOKENS, window - input_estimate - _INPUT_SAFETY_MARGIN)
102
+ multiplier = 4 if _model_wants_thinking_multiplier(model) else 1
103
+ effective_max = max(_MIN_OUTPUT_TOKENS, min(requested * multiplier, headroom))
104
  return effective_max, input_estimate, window
105
 
106
 
 
124
  temperature: float = 0.7,
125
  max_tokens: int = 1024,
126
  timeout: float | None = None,
127
+ on_text_delta: Any | None = None,
128
  ) -> dict[str, Any]:
129
  """Send a chat completion request to any OpenAI-compatible endpoint."""
130
  url = f"{base_url.rstrip('/')}/chat/completions"
 
138
  effective_max, input_estimate, window = _resolve_effective_max(
139
  model, max_tokens, messages,
140
  )
141
+ mult = 4 if _model_wants_thinking_multiplier(model) else 1
142
+ if effective_max < max_tokens * mult:
143
  LOG.info(
144
+ "Capped max_tokens for %s: requested %d (x%d=%d), input ~=%d, "
145
  "window=%d, sending %d",
146
+ model, max_tokens, mult, max_tokens * mult, input_estimate, window,
147
  effective_max,
148
  )
149
  effective_timeout = max(timeout * 2, 120) if timeout else timeout
 
162
  req_timeout = httpx.Timeout(effective_timeout) if effective_timeout else None
163
  client = _get_client()
164
  t0 = time.time()
165
+
166
+ if on_text_delta is not None:
167
+ body["stream"] = True
168
+ try:
169
+ parts: list[str] = []
170
+ async with client.stream(
171
+ "POST", url, json=body, headers=headers, timeout=req_timeout,
172
+ ) as resp:
173
+ if resp.status_code >= 400:
174
+ detail = (await resp.aread()).decode("utf-8", errors="replace")[:300]
175
+ return {
176
+ "response": f"[Error {resp.status_code}]: {detail}",
177
+ "elapsed_seconds": round(time.time() - t0, 2),
178
+ "model": model,
179
+ "error": True,
180
+ "error_kind": _classify_http_status(resp.status_code),
181
+ "error_status": resp.status_code,
182
+ }
183
+ async for line in resp.aiter_lines():
184
+ if not line or not line.startswith("data:"):
185
+ continue
186
+ payload = line[5:].strip()
187
+ if payload == "[DONE]":
188
+ break
189
+ try:
190
+ chunk = json.loads(payload)
191
+ except json.JSONDecodeError:
192
+ continue
193
+ choices = chunk.get("choices") or []
194
+ if not choices:
195
+ continue
196
+ delta = choices[0].get("delta") or {}
197
+ piece = delta.get("content") or ""
198
+ if piece:
199
+ parts.append(piece)
200
+ on_text_delta(piece)
201
+ text = strip_thinking("".join(parts))
202
+ return {
203
+ "response": text.strip(),
204
+ "elapsed_seconds": round(time.time() - t0, 2),
205
+ "model": model,
206
+ "finish_reason": "stop",
207
+ }
208
+ except Exception as exc:
209
+ LOG.exception("OpenAI-compat stream failed: %s", exc)
210
+ return {
211
+ "response": f"[Error]: {exc}",
212
+ "elapsed_seconds": round(time.time() - t0, 2),
213
+ "model": model,
214
+ "error": True,
215
+ "error_kind": _classify_exception(exc),
216
+ }
217
+
218
  for attempt in range(2):
219
  try:
220
  resp = await client.post(url, json=body, headers=headers, timeout=req_timeout)
backend/app/config.py CHANGED
@@ -37,6 +37,9 @@ class Settings(BaseSettings):
37
  mistral_api_key: str = ""
38
 
39
  orchestrator_model: str = "gpt-4o-mini"
 
 
 
40
  speed_priority: bool = False
41
 
42
  cors_origins: str = "http://localhost:3000,http://localhost:3001,http://localhost:3002"
 
37
  mistral_api_key: str = ""
38
 
39
  orchestrator_model: str = "gpt-4o-mini"
40
+ # Lightweight model for addressed-to / status classifiers. Falls back
41
+ # to orchestrator_model when unset or unresolvable.
42
+ orchestrator_fast_model: str = "gemini-2.0-flash"
43
  speed_priority: bool = False
44
 
45
  cors_origins: str = "http://localhost:3000,http://localhost:3001,http://localhost:3002"
backend/app/services/consensus.py CHANGED
@@ -7,6 +7,7 @@ All four are short JSON-shaped orchestrator calls layered on top of
7
  from __future__ import annotations
8
 
9
  import logging
 
10
  from typing import Any
11
 
12
  from app.services.json_calls import orchestrator_call
@@ -99,6 +100,64 @@ def _normalize_groups(
99
  return out
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  async def classify_addressed_to(
103
  *,
104
  orchestrator_model_id: str,
@@ -107,6 +166,10 @@ async def classify_addressed_to(
107
  message: str,
108
  api_log: list[dict[str, Any]] | None = None,
109
  ) -> str | None:
 
 
 
 
110
  prompt = ADDRESSED_TO_PROMPT.format(
111
  roster_block=_format_roster_block(participants),
112
  speaker=speaker_name,
 
7
  from __future__ import annotations
8
 
9
  import logging
10
+ import re
11
  from typing import Any
12
 
13
  from app.services.json_calls import orchestrator_call
 
100
  return out
101
 
102
 
103
+ def _heuristic_addressed_to(
104
+ participants: list[Any],
105
+ speaker_name: str,
106
+ message: str,
107
+ ) -> str | None:
108
+ """Fast path when the addressee is obvious from the text.
109
+
110
+ Returns a participant_id when confidence is high; otherwise None
111
+ so the orchestrator LLM classifier runs.
112
+ """
113
+ if not message or not participants:
114
+ return None
115
+
116
+ others = [p for p in participants if p.name != speaker_name]
117
+ if len(others) == 1:
118
+ return others[0].participant_id
119
+
120
+ text = message.strip()
121
+ lower = text.lower()
122
+
123
+ # "@Name" or "Name:" at start of a sentence
124
+ for p in others:
125
+ name = p.name.strip()
126
+ if not name:
127
+ continue
128
+ nl = name.lower()
129
+ if re.search(rf"@{re.escape(nl)}\b", lower):
130
+ return p.participant_id
131
+ if re.search(
132
+ rf"(^|[.!?\n]\s*){re.escape(nl)}\s*[:,]",
133
+ lower,
134
+ ):
135
+ return p.participant_id
136
+
137
+ # "I agree with Name" / "Name, I think"
138
+ hits: list[str] = []
139
+ for p in others:
140
+ name = p.name.strip()
141
+ if not name:
142
+ continue
143
+ nl = re.escape(name.lower())
144
+ if re.search(
145
+ rf"\b(?:agree with|disagree with|respond to|reply to|"
146
+ rf"building on|thank you,?)\s+{nl}\b",
147
+ lower,
148
+ ):
149
+ hits.append(p.participant_id)
150
+ elif re.search(rf"\b{nl}\b[,:]?\s+(?:you|your|i think|i believe)\b", lower):
151
+ hits.append(p.participant_id)
152
+ elif re.search(rf"\b{nl}\b", lower) and len(name) >= 4:
153
+ hits.append(p.participant_id)
154
+
155
+ unique = list(dict.fromkeys(hits))
156
+ if len(unique) == 1:
157
+ return unique[0]
158
+ return None
159
+
160
+
161
  async def classify_addressed_to(
162
  *,
163
  orchestrator_model_id: str,
 
166
  message: str,
167
  api_log: list[dict[str, Any]] | None = None,
168
  ) -> str | None:
169
+ heuristic = _heuristic_addressed_to(participants, speaker_name, message)
170
+ if heuristic is not None:
171
+ return heuristic
172
+
173
  prompt = ADDRESSED_TO_PROMPT.format(
174
  roster_block=_format_roster_block(participants),
175
  speaker=speaker_name,
backend/app/services/conversation/decisions/majority.py CHANGED
@@ -28,6 +28,7 @@ from typing import Any, AsyncIterator
28
  from app.services.conversation.decisions.base import DecisionMethod
29
  from app.services.conversation.voting import (
30
  cast_vote_single,
 
31
  cast_vote_yesno,
32
  extract_candidate_options,
33
  tally_single_votes,
@@ -86,10 +87,13 @@ class MajorityRulesDecision(DecisionMethod):
86
  yield _sse("orchestrator", _msg_payload(msg))
87
 
88
  ballots: list[dict[str, Any]] = []
89
- for p in voters:
90
- result = await cast_vote_yesno(
91
- session=session, participant=p, motion=motion,
92
- )
 
 
 
93
  ballot = {
94
  "voter_id": p.participant_id,
95
  "voter_name": p.name,
@@ -162,11 +166,14 @@ class MajorityRulesDecision(DecisionMethod):
162
  yield _sse("orchestrator", _msg_payload(msg))
163
 
164
  ballots: list[dict[str, Any]] = []
165
- for p in voters:
166
- result = await cast_vote_single(
167
- session=session, participant=p, question=di.question,
168
- options=options,
169
- )
 
 
 
170
  ballot = {
171
  "voter_id": p.participant_id,
172
  "voter_name": p.name,
 
28
  from app.services.conversation.decisions.base import DecisionMethod
29
  from app.services.conversation.voting import (
30
  cast_vote_single,
31
+ gather_votes_parallel,
32
  cast_vote_yesno,
33
  extract_candidate_options,
34
  tally_single_votes,
 
87
  yield _sse("orchestrator", _msg_payload(msg))
88
 
89
  ballots: list[dict[str, Any]] = []
90
+ for p, result in await gather_votes_parallel(
91
+ voters,
92
+ cast_vote_yesno,
93
+ session=session,
94
+ default_mode="yesno",
95
+ motion=motion,
96
+ ):
97
  ballot = {
98
  "voter_id": p.participant_id,
99
  "voter_name": p.name,
 
166
  yield _sse("orchestrator", _msg_payload(msg))
167
 
168
  ballots: list[dict[str, Any]] = []
169
+ for p, result in await gather_votes_parallel(
170
+ voters,
171
+ cast_vote_single,
172
+ session=session,
173
+ default_mode="single",
174
+ question=di.question,
175
+ options=options,
176
+ ):
177
  ballot = {
178
  "voter_id": p.participant_id,
179
  "voter_name": p.name,
backend/app/services/conversation/decisions/ranked_choice.py CHANGED
@@ -26,6 +26,7 @@ from app.services.conversation.decisions.base import DecisionMethod
26
  from app.services.conversation.voting import (
27
  cast_vote_ranking,
28
  extract_candidate_options,
 
29
  run_irv,
30
  )
31
  from app.services.models import Phase
@@ -94,11 +95,14 @@ class RankedChoiceDecision(DecisionMethod):
94
 
95
  ballots: list[dict[str, Any]] = []
96
  rankings_only: list[list[int]] = []
97
- for p in voters:
98
- result = await cast_vote_ranking(
99
- session=session, participant=p,
100
- question=di.question, options=options,
101
- )
 
 
 
102
  ranking = result.get("ranking") or []
103
  ballots.append({
104
  "voter_id": p.participant_id,
 
26
  from app.services.conversation.voting import (
27
  cast_vote_ranking,
28
  extract_candidate_options,
29
+ gather_votes_parallel,
30
  run_irv,
31
  )
32
  from app.services.models import Phase
 
95
 
96
  ballots: list[dict[str, Any]] = []
97
  rankings_only: list[list[int]] = []
98
+ for p, result in await gather_votes_parallel(
99
+ voters,
100
+ cast_vote_ranking,
101
+ session=session,
102
+ default_mode="rank",
103
+ question=di.question,
104
+ options=options,
105
+ ):
106
  ranking = result.get("ranking") or []
107
  ballots.append({
108
  "voter_id": p.participant_id,
backend/app/services/conversation/decisions/roberts_rules_vote.py CHANGED
@@ -19,6 +19,7 @@ from typing import Any, AsyncIterator
19
  from app.services.conversation.decisions.base import DecisionMethod
20
  from app.services.conversation.voting import (
21
  cast_vote_yesno,
 
22
  tally_yesno_votes,
23
  )
24
  from app.services.json_calls import orchestrator_call
@@ -114,10 +115,13 @@ class RobertsRulesVote(DecisionMethod):
114
  yield _sse("orchestrator", _msg_payload(msg))
115
 
116
  ballots: list[dict[str, Any]] = []
117
- for p in voters:
118
- result = await cast_vote_yesno(
119
- session=session, participant=p, motion=motion,
120
- )
 
 
 
121
  ballot = {
122
  "voter_id": p.participant_id,
123
  "voter_name": p.name,
 
19
  from app.services.conversation.decisions.base import DecisionMethod
20
  from app.services.conversation.voting import (
21
  cast_vote_yesno,
22
+ gather_votes_parallel,
23
  tally_yesno_votes,
24
  )
25
  from app.services.json_calls import orchestrator_call
 
115
  yield _sse("orchestrator", _msg_payload(msg))
116
 
117
  ballots: list[dict[str, Any]] = []
118
+ for p, result in await gather_votes_parallel(
119
+ voters,
120
+ cast_vote_yesno,
121
+ session=session,
122
+ default_mode="yesno",
123
+ motion=motion,
124
+ ):
125
  ballot = {
126
  "voter_id": p.participant_id,
127
  "voter_name": p.name,
backend/app/services/conversation/voting.py CHANGED
@@ -19,10 +19,11 @@ method classes can compose them however they want.
19
  """
20
  from __future__ import annotations
21
 
 
22
  import json
23
  import logging
24
  import time
25
- from typing import Any
26
 
27
  from app.clients.llm_router import chat_completion
28
  from app.services.json_calls import (
@@ -194,6 +195,36 @@ async def extract_candidate_options(
194
  return options
195
 
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  async def cast_vote_single(
198
  *,
199
  session: Session,
 
19
  """
20
  from __future__ import annotations
21
 
22
+ import asyncio
23
  import json
24
  import logging
25
  import time
26
+ from typing import Any, Awaitable, Callable
27
 
28
  from app.clients.llm_router import chat_completion
29
  from app.services.json_calls import (
 
195
  return options
196
 
197
 
198
+ async def gather_votes_parallel(
199
+ voters: list[Participant],
200
+ cast_fn: Callable[..., Awaitable[dict[str, Any]]],
201
+ *,
202
+ session: Session,
203
+ default_mode: str = "single",
204
+ **cast_kwargs: Any,
205
+ ) -> list[tuple[Participant, dict[str, Any]]]:
206
+ """Run ballot calls concurrently; roster order is preserved."""
207
+ if not voters:
208
+ return []
209
+
210
+ async def _one(p: Participant) -> tuple[Participant, dict[str, Any]]:
211
+ result = await cast_fn(session=session, participant=p, **cast_kwargs)
212
+ return p, result
213
+
214
+ gathered = await asyncio.gather(
215
+ *[_one(p) for p in voters],
216
+ return_exceptions=True,
217
+ )
218
+ out: list[tuple[Participant, dict[str, Any]]] = []
219
+ for p, item in zip(voters, gathered):
220
+ if isinstance(item, BaseException):
221
+ LOG.exception("Parallel vote failed for %s: %s", p.participant_id, item)
222
+ out.append((p, _vote_default(default_mode)))
223
+ else:
224
+ out.append(item)
225
+ return out
226
+
227
+
228
  async def cast_vote_single(
229
  *,
230
  session: Session,
backend/app/services/models.py CHANGED
@@ -407,3 +407,15 @@ class Session:
407
  # on RR output.
408
  main_motion: str | None = None
409
  proposed_motions: list[dict[str, Any]] = field(default_factory=list)
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  # on RR output.
408
  main_motion: str | None = None
409
  proposed_motions: list[dict[str, Any]] = field(default_factory=list)
410
+
411
+ # Rolling summary for orchestrator judge prompts when the transcript
412
+ # grows past the compact-transcript char budget (see orchestrator_speed).
413
+ orchestrator_context_summary: str = ""
414
+ orchestrator_context_through_idx: int = -1
415
+
416
+ # Background task that builds per-participant contribution summaries
417
+ # for the Table View. Kicked off by run_conversation just before the
418
+ # decision phase so that by the time the user opens Table View the
419
+ # work is already done. `Any` rather than `asyncio.Task` to avoid the
420
+ # import-time dependency on a running loop.
421
+ contribution_summary_task: Any = None
backend/app/services/orchestrator.py CHANGED
@@ -26,6 +26,7 @@ import asyncio
26
  import json
27
  import logging
28
  import time
 
29
  from dataclasses import asdict
30
  from typing import Any, AsyncIterator
31
 
@@ -55,6 +56,13 @@ from app.services.credential import (
55
  refresh_credential_summary,
56
  )
57
  from app.services.json_calls import orchestrator_call
 
 
 
 
 
 
 
58
  from app.services.resilience import run_resilient_turn
59
  from app.services.models import (
60
  DEFAULT_MAX_PARTICIPANTS,
@@ -421,7 +429,7 @@ async def _do_human_turn(
421
  addressed: str | None = None
422
  if classify_addressed:
423
  addressed = await classify_addressed_to(
424
- orchestrator_model_id=_orchestrator_model_id(session),
425
  participants=actives,
426
  speaker_name=participant.name,
427
  message=text,
@@ -501,6 +509,8 @@ async def _call_participant(
501
  label: str,
502
  max_tokens: int = 600,
503
  timeout: float = 45.0,
 
 
504
  ) -> tuple[str, float, bool, str]:
505
  """Run one participant turn.
506
 
@@ -563,6 +573,25 @@ async def _call_participant(
563
  "request": {"messages": api_messages, "max_tokens": max_tokens},
564
  }
565
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  try:
567
  result = await chat_completion(
568
  resolved=resolved,
@@ -570,6 +599,7 @@ async def _call_participant(
570
  temperature=0.7,
571
  max_tokens=max_tokens,
572
  timeout=timeout,
 
573
  )
574
  except Exception as exc:
575
  LOG.exception("Participant %s call failed: %s", participant.participant_id, exc)
@@ -611,8 +641,10 @@ def _add_participant_message(
611
  elapsed: float,
612
  addressed_to: str | None = None,
613
  replying_to: list[str] | None = None,
 
614
  ) -> dict[str, Any]:
615
  msg = {
 
616
  "speaker_id": participant.participant_id,
617
  "speaker_name": participant.name,
618
  "role": "participant",
@@ -676,55 +708,42 @@ async def _phase_initial_opinions(session: Session) -> AsyncIterator[str]:
676
  yield _sse("status", {"message": "Phase 1: collecting independent first opinions..."})
677
 
678
  actives = _active_participants(session)
679
- for p in actives:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
  if p.kind == "human":
681
- async for chunk in _do_human_turn(
682
- session, p, phase=session.phase, actives=actives,
683
- track_initial_opinion=True,
684
- prompt_context=(
685
- "Share your initial opinion on the question. "
686
- "You're speaking BEFORE seeing the other participants."
687
- ),
688
- ):
689
- yield chunk
690
- continue
691
- # Phase 1 deliberately uses a *bare* prompt (no transcript) so each
692
- # participant's first opinion is independent of the others.
693
- prompt = INITIAL_OPINION_PROMPT.format(question=session.question)
694
- turn = await run_resilient_turn(
695
- session=session, participant=p,
696
- user_prompt=prompt,
697
  label="initial_opinion",
698
  max_tokens=700,
699
- call_participant=_call_participant,
700
  )
701
- for ev in turn.sse_events:
702
- yield ev
703
- if not turn.ok:
704
- yield _sse("participant_error", {
705
- "participant_id": p.participant_id,
706
- "name": p.name,
707
- "phase": session.phase.value,
708
- })
709
- if p.consecutive_failures >= session.limits.auto_disable_failures:
710
- p.enabled = False
711
- yield _sse("status", {
712
- "message": (
713
- f"{p.name} auto-disabled after "
714
- f"{session.limits.auto_disable_failures} failures."
715
- ),
716
- })
717
- continue
718
- speaker = turn.speaker
719
- msg = _add_participant_message(
720
- session, speaker, turn.text, phase=session.phase, elapsed=turn.elapsed,
721
- )
722
- session.initial_opinions[speaker.participant_id] = turn.text
723
- yield _sse("message", _msg_payload(msg))
724
 
725
- if _participant_msg_cap_hit(session):
726
- async for chunk in _wait_for_continue(session, "messages"):
727
- yield chunk
 
 
 
 
 
 
 
728
 
729
  yield _sse("status", {"message": "Building Credential Summary..."})
730
  creds = await build_credential_summary(
@@ -770,83 +789,72 @@ async def _phase_critique(session: Session, round_number: int) -> AsyncIterator[
770
  })
771
  cred_block = credentials_to_block(session.credential_summary)
772
  actives = _active_participants(session)
773
- for p in actives:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
774
  if p.kind == "human":
775
- async for chunk in _do_human_turn(
776
- session, p, phase=session.phase, actives=actives,
777
- classify_addressed=True,
778
- prompt_context=(
779
- f"Critique round {round_number} of {round_total}. "
780
- "Push back on, agree with, or build on what others "
781
- "have said. Address other participants by name."
782
- ),
783
- ):
784
- yield chunk
785
- continue
786
- transcript = _format_history(session.messages)
787
- # Snapshot pending threads BEFORE the call so we can both render
788
- # them in the prompt and stamp them onto the outgoing message as
789
- # `replying_to` (frontend pill).
790
- pending = _pending_addressed_for(session, p)
791
  pending_block = _format_pending_block(pending)
792
  prompt = CRITIQUE_PROMPT.format(
793
  round_number=round_number,
794
  round_total=round_total,
795
  question=session.question,
796
  credential_summary=cred_block,
797
- transcript=transcript,
798
  pending_block=pending_block,
799
  )
800
- turn = await run_resilient_turn(
801
- session=session, participant=p,
802
  user_prompt=prompt,
803
  label=f"critique_round_{round_number}",
804
  max_tokens=700,
805
- call_participant=_call_participant,
806
  )
807
- for ev in turn.sse_events:
808
- yield ev
809
- if not turn.ok:
810
- yield _sse("participant_error", {
811
- "participant_id": p.participant_id, "name": p.name,
812
- "phase": session.phase.value,
813
- })
814
- if p.consecutive_failures >= session.limits.auto_disable_failures:
815
- p.enabled = False
816
- yield _sse("status", {
817
- "message": (
818
- f"{p.name} auto-disabled after "
819
- f"{session.limits.auto_disable_failures} failures."
820
- ),
821
- })
822
- continue
823
- speaker = turn.speaker
824
- text, elapsed = turn.text, turn.elapsed
825
 
826
- # Detect addressed_to so the consensus phase's targeted-response
827
- # logic can also reuse it - cheap classification call.
828
  addressed = await classify_addressed_to(
829
- orchestrator_model_id=_orchestrator_model_id(session),
830
  participants=_active_participants(session),
831
  speaker_name=speaker.name,
832
- message=text,
833
  api_log=session.api_log,
834
  )
835
  _bump_orchestrator_count(session)
 
 
 
 
836
 
837
- msg = _add_participant_message(
838
- session, speaker, text, phase=session.phase, elapsed=elapsed,
839
- addressed_to=addressed,
840
- replying_to=_replying_to_ids(pending),
841
- )
842
- yield _sse("message", _msg_payload(msg))
843
-
844
- if _participant_msg_cap_hit(session):
845
- async for chunk in _wait_for_continue(session, "messages"):
846
- yield chunk
847
- if _orchestrator_cap_hit(session):
848
- async for chunk in _wait_for_continue(session, "orchestrator"):
849
- yield chunk
850
 
851
 
852
  async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
@@ -854,33 +862,18 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
854
  yield _sse("status", {"message": "Phase 3: assessing whether more questions are needed..."})
855
 
856
  cred_block = credentials_to_block(session.credential_summary)
857
-
858
- # Refresh Credential Summary once after Phase 2 critique - participants
859
- # have revealed a lot more about themselves through critique.
860
- transcript = _format_history(session.messages)
861
- refreshed = await refresh_credential_summary(
862
- orchestrator_model_id=_orchestrator_model_id(session),
863
- question=session.question,
864
- participants=_active_participants(session),
865
- existing=session.credential_summary,
866
- critique_transcript=transcript,
867
- api_log=session.api_log,
868
- )
869
- _bump_orchestrator_count(session)
870
- if refreshed != session.credential_summary:
871
- session.credential_summary = refreshed
872
- yield _sse("credentials_updated", {
873
- "stage": "refreshed",
874
- "credentials": session.credential_summary,
875
- })
876
- cred_block = credentials_to_block(session.credential_summary)
877
 
878
  for iteration in range(session.limits.status_assessment_max):
879
  session.status_assessment_iterations = iteration + 1
 
 
 
 
880
  prompt = STATUS_ASSESSMENT_PROMPT.format(
881
  question=session.question,
882
  credential_summary=cred_block,
883
- transcript=_format_history(session.messages),
884
  )
885
  _raw, parsed = await orchestrator_call(
886
  orchestrator_model_id=_orchestrator_model_id(session),
@@ -907,6 +900,27 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
907
  yield _sse("orchestrator", _msg_payload(msg))
908
  return
909
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
910
  # Otherwise run targeted follow-ups
911
  active_ids = {p.participant_id for p in _active_participants(session)}
912
  for oq in open_qs:
@@ -966,13 +980,19 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
966
  credential_summary=cred_block,
967
  targeted_question=question_text,
968
  )
 
 
969
  turn = await run_resilient_turn(
970
  session=session, participant=target,
971
  user_prompt=prompt2,
972
  label="targeted_followup",
973
  max_tokens=600,
974
  call_participant=_call_participant,
 
 
975
  )
 
 
976
  for ev in turn.sse_events:
977
  yield ev
978
  if not turn.ok:
@@ -990,6 +1010,7 @@ async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
990
  msg = _add_participant_message(
991
  session, speaker, text, phase=session.phase, elapsed=elapsed,
992
  replying_to=replying_to,
 
993
  )
994
  yield _sse("message", _msg_payload(msg))
995
 
@@ -1014,54 +1035,56 @@ async def _phase_finalization(session: Session) -> AsyncIterator[str]:
1014
 
1015
  cred_block = credentials_to_block(session.credential_summary)
1016
  actives = _active_participants(session)
1017
- for p in actives:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1018
  if p.kind == "human":
1019
- async for chunk in _do_human_turn(
1020
- session, p, phase=session.phase, actives=actives,
1021
- track_final_opinion=True,
1022
- prompt_context=(
1023
- "Phase 4: state your final opinion on the question, "
1024
- "incorporating whatever you've learned in the discussion."
1025
- ),
1026
- ):
1027
- yield chunk
1028
- continue
1029
- transcript = _format_history(session.messages)
1030
- pending = _pending_addressed_for(session, p)
1031
  pending_block = _format_pending_block(pending)
1032
  prompt = FINALIZATION_PROMPT.format(
1033
  question=session.question,
1034
  credential_summary=cred_block,
1035
- transcript=transcript,
1036
  pending_block=pending_block,
1037
  )
1038
- turn = await run_resilient_turn(
1039
- session=session, participant=p,
1040
  user_prompt=prompt,
1041
  label="finalization",
1042
  max_tokens=600,
1043
- call_participant=_call_participant,
1044
- )
1045
- for ev in turn.sse_events:
1046
- yield ev
1047
- if not turn.ok:
1048
- yield _sse("participant_error", {
1049
- "participant_id": p.participant_id, "name": p.name,
1050
- "phase": session.phase.value,
1051
- })
1052
- continue
1053
- speaker = turn.speaker
1054
- text, elapsed = turn.text, turn.elapsed
1055
- session.final_opinions[speaker.participant_id] = text
1056
- msg = _add_participant_message(
1057
- session, speaker, text, phase=session.phase, elapsed=elapsed,
1058
- replying_to=_replying_to_ids(pending),
1059
  )
1060
- yield _sse("message", _msg_payload(msg))
1061
 
1062
- if _participant_msg_cap_hit(session):
1063
- async for chunk in _wait_for_continue(session, "messages"):
1064
- yield chunk
 
 
 
 
 
 
 
 
 
 
 
1065
 
1066
 
1067
  async def _phase_consensus(session: Session) -> AsyncIterator[str]:
@@ -1160,31 +1183,9 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
1160
  # Replicated here because the LLM-path code below also does
1161
  # it, and we need it on the human path too.
1162
  if consensus_turns % max(1, len(actives)) == 0:
1163
- transcript = _format_history(session.messages)
1164
- status = await assess_consensus_status(
1165
- orchestrator_model_id=_orchestrator_model_id(session),
1166
- question=session.question,
1167
- transcript=transcript,
1168
- alliance_groups=session.alliance_groups,
1169
- api_log=session.api_log,
1170
- )
1171
- _bump_orchestrator_count(session)
1172
- if status.get("status") == "majority":
1173
- session.alliance_groups = await _refresh_alliance_groups(session, actives)
1174
- msg = _add_orchestrator_message(
1175
- session,
1176
- f"Majority reached. {status.get('rationale', '')}".strip(),
1177
- kind="status",
1178
- )
1179
- yield _sse("orchestrator", _msg_payload(msg))
1180
- return
1181
- if status.get("status") == "unproductive":
1182
- msg = _add_orchestrator_message(
1183
- session,
1184
- f"Conversation no longer productive. {status.get('rationale', '')}".strip(),
1185
- kind="status",
1186
- )
1187
- yield _sse("orchestrator", _msg_payload(msg))
1188
  return
1189
  continue
1190
 
@@ -1199,13 +1200,19 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
1199
  # message records who this turn was supposed to be replying to.
1200
  pending = _pending_addressed_for(session, speaker)
1201
 
 
 
1202
  turn = await run_resilient_turn(
1203
  session=session, participant=speaker,
1204
  user_prompt=prompt,
1205
  label="consensus",
1206
  max_tokens=700,
1207
  call_participant=_call_participant,
 
 
1208
  )
 
 
1209
  for ev in turn.sse_events:
1210
  yield ev
1211
  if not turn.ok:
@@ -1221,7 +1228,7 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
1221
  text, elapsed = turn.text, turn.elapsed
1222
 
1223
  addressed = await classify_addressed_to(
1224
- orchestrator_model_id=_orchestrator_model_id(session),
1225
  participants=actives,
1226
  speaker_name=speaker.name,
1227
  message=text,
@@ -1234,6 +1241,7 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
1234
  session, speaker, text, phase=session.phase, elapsed=elapsed,
1235
  addressed_to=addressed,
1236
  replying_to=_replying_to_ids(pending),
 
1237
  )
1238
  yield _sse("message", _msg_payload(msg))
1239
 
@@ -1246,33 +1254,46 @@ async def _phase_consensus(session: Session) -> AsyncIterator[str]:
1246
 
1247
  # Status check every full round (every len(actives) turns)
1248
  if consensus_turns % max(1, len(actives)) == 0:
1249
- transcript = _format_history(session.messages)
1250
- status = await assess_consensus_status(
1251
- orchestrator_model_id=_orchestrator_model_id(session),
1252
- question=session.question,
1253
- transcript=transcript,
1254
- alliance_groups=session.alliance_groups,
1255
- api_log=session.api_log,
1256
- )
1257
- _bump_orchestrator_count(session)
1258
- if status.get("status") == "majority":
1259
- session.alliance_groups = await _refresh_alliance_groups(session, actives)
1260
- msg = _add_orchestrator_message(
1261
- session,
1262
- f"Majority reached. {status.get('rationale', '')}".strip(),
1263
- kind="status",
1264
- )
1265
- yield _sse("orchestrator", _msg_payload(msg))
1266
- return
1267
- if status.get("status") == "unproductive":
1268
- msg = _add_orchestrator_message(
1269
- session,
1270
- f"Conversation no longer productive. {status.get('rationale', '')}".strip(),
1271
- kind="status",
1272
- )
1273
- yield _sse("orchestrator", _msg_payload(msg))
1274
  return
1275
- # else: productive - keep going
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1276
 
1277
 
1278
  async def _refresh_alliance_groups(
@@ -1376,10 +1397,13 @@ async def _phase_closure(session: Session) -> AsyncIterator[str]:
1376
  yield _sse("status", {"message": "Phase 6: closure..."})
1377
 
1378
  cred_block = credentials_to_block(session.credential_summary)
1379
- transcript = _format_history(session.messages)
 
 
 
1380
 
1381
  status = await assess_consensus_status(
1382
- orchestrator_model_id=_orchestrator_model_id(session),
1383
  question=session.question,
1384
  transcript=transcript,
1385
  alliance_groups=session.alliance_groups,
@@ -1538,6 +1562,12 @@ async def run_conversation(session: Session) -> AsyncIterator[str]:
1538
  async for chunk in structure.run():
1539
  yield chunk
1540
 
 
 
 
 
 
 
1541
  decision_input = structure.build_decision_input()
1542
  decision = decision_cls(session, decision_input)
1543
  async for chunk in decision.run():
@@ -1552,16 +1582,59 @@ async def run_conversation(session: Session) -> AsyncIterator[str]:
1552
  # doesn't outlive the session in the module-level registry.
1553
  human_io.drop_session(session.session_id)
1554
 
1555
- # Build per-participant contribution summaries for the table view.
1556
- try:
1557
- await _build_contribution_summaries(session)
1558
- except Exception as exc:
1559
- LOG.warning("Failed to build contribution summaries: %s", exc)
1560
-
1561
  yield _sse("system", {"text": "End of Chat", "phase": session.phase.value})
1562
  yield _sse("done", {})
1563
 
1564
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1565
  async def _build_contribution_summaries(session: Session) -> None:
1566
  actives = _active_participants(session)
1567
  roster = "\n".join(
 
26
  import json
27
  import logging
28
  import time
29
+ import uuid
30
  from dataclasses import asdict
31
  from typing import Any, AsyncIterator
32
 
 
56
  refresh_credential_summary,
57
  )
58
  from app.services.json_calls import orchestrator_call
59
+ from app.services.orchestrator_speed import (
60
+ _AiTurnResult,
61
+ _AiTurnSpec,
62
+ compact_transcript_for_orchestrator,
63
+ orchestrator_fast_model_id,
64
+ run_roster_ai_turns_parallel,
65
+ )
66
  from app.services.resilience import run_resilient_turn
67
  from app.services.models import (
68
  DEFAULT_MAX_PARTICIPANTS,
 
429
  addressed: str | None = None
430
  if classify_addressed:
431
  addressed = await classify_addressed_to(
432
+ orchestrator_model_id=orchestrator_fast_model_id(session),
433
  participants=actives,
434
  speaker_name=participant.name,
435
  message=text,
 
509
  label: str,
510
  max_tokens: int = 600,
511
  timeout: float = 45.0,
512
+ stream_events: list[str] | None = None,
513
+ stream_message_id: str | None = None,
514
  ) -> tuple[str, float, bool, str]:
515
  """Run one participant turn.
516
 
 
573
  "request": {"messages": api_messages, "max_tokens": max_tokens},
574
  }
575
 
576
+ msg_id = stream_message_id or str(uuid.uuid4())
577
+ on_text_delta_cb = None
578
+ if stream_events is not None:
579
+ stream_events.append(_sse("message_stream_start", {
580
+ "message_id": msg_id,
581
+ "speaker_id": participant.participant_id,
582
+ "speaker_name": participant.name,
583
+ "kind": participant.kind,
584
+ "phase": session.phase.value,
585
+ "model_id": participant.model_id,
586
+ "model_display": participant.display_name,
587
+ }))
588
+
589
+ def on_text_delta_cb(piece: str) -> None:
590
+ stream_events.append(_sse("message_delta", {
591
+ "message_id": msg_id,
592
+ "delta": piece,
593
+ }))
594
+
595
  try:
596
  result = await chat_completion(
597
  resolved=resolved,
 
599
  temperature=0.7,
600
  max_tokens=max_tokens,
601
  timeout=timeout,
602
+ on_text_delta=on_text_delta_cb,
603
  )
604
  except Exception as exc:
605
  LOG.exception("Participant %s call failed: %s", participant.participant_id, exc)
 
641
  elapsed: float,
642
  addressed_to: str | None = None,
643
  replying_to: list[str] | None = None,
644
+ message_id: str | None = None,
645
  ) -> dict[str, Any]:
646
  msg = {
647
+ "message_id": message_id or str(uuid.uuid4()),
648
  "speaker_id": participant.participant_id,
649
  "speaker_name": participant.name,
650
  "role": "participant",
 
708
  yield _sse("status", {"message": "Phase 1: collecting independent first opinions..."})
709
 
710
  actives = _active_participants(session)
711
+
712
+ async def _human_initial(p: Participant) -> AsyncIterator[str]:
713
+ async for chunk in _do_human_turn(
714
+ session, p, phase=session.phase, actives=actives,
715
+ track_initial_opinion=True,
716
+ prompt_context=(
717
+ "Share your initial opinion on the question. "
718
+ "You're speaking BEFORE seeing the other participants."
719
+ ),
720
+ ):
721
+ yield chunk
722
+
723
+ async def _post_initial(result: _AiTurnResult) -> dict[str, Any]:
724
+ session.initial_opinions[result.participant.participant_id] = result.turn.text
725
+ return {}
726
+
727
+ def _build_initial_spec(p: Participant) -> _AiTurnSpec | None:
728
  if p.kind == "human":
729
+ return None
730
+ return _AiTurnSpec(
731
+ participant=p,
732
+ user_prompt=INITIAL_OPINION_PROMPT.format(question=session.question),
 
 
 
 
 
 
 
 
 
 
 
 
733
  label="initial_opinion",
734
  max_tokens=700,
 
735
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736
 
737
+ async for chunk in run_roster_ai_turns_parallel(
738
+ session,
739
+ actives,
740
+ phase=session.phase,
741
+ build_spec=_build_initial_spec,
742
+ call_participant=_call_participant,
743
+ on_human_turn=_human_initial,
744
+ post_process=_post_initial,
745
+ ):
746
+ yield chunk
747
 
748
  yield _sse("status", {"message": "Building Credential Summary..."})
749
  creds = await build_credential_summary(
 
789
  })
790
  cred_block = credentials_to_block(session.credential_summary)
791
  actives = _active_participants(session)
792
+ # Freeze transcript + pending threads at round start so parallel
793
+ # turns see the same context and reply metadata stays consistent.
794
+ transcript_snapshot = _format_history(session.messages)
795
+ pending_snapshot = {
796
+ p.participant_id: _pending_addressed_for(session, p)
797
+ for p in actives
798
+ if p.kind != "human"
799
+ }
800
+
801
+ async def _human_critique(p: Participant) -> AsyncIterator[str]:
802
+ async for chunk in _do_human_turn(
803
+ session, p, phase=session.phase, actives=actives,
804
+ classify_addressed=True,
805
+ prompt_context=(
806
+ f"Critique round {round_number} of {round_total}. "
807
+ "Push back on, agree with, or build on what others "
808
+ "have said. Address other participants by name."
809
+ ),
810
+ ):
811
+ yield chunk
812
+
813
+ def _build_critique_spec(p: Participant) -> _AiTurnSpec | None:
814
  if p.kind == "human":
815
+ return None
816
+ pending = pending_snapshot.get(p.participant_id, [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
817
  pending_block = _format_pending_block(pending)
818
  prompt = CRITIQUE_PROMPT.format(
819
  round_number=round_number,
820
  round_total=round_total,
821
  question=session.question,
822
  credential_summary=cred_block,
823
+ transcript=transcript_snapshot,
824
  pending_block=pending_block,
825
  )
826
+ return _AiTurnSpec(
827
+ participant=p,
828
  user_prompt=prompt,
829
  label=f"critique_round_{round_number}",
830
  max_tokens=700,
 
831
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
832
 
833
+ async def _post_critique(result: _AiTurnResult) -> dict[str, Any]:
834
+ speaker = result.turn.speaker
835
  addressed = await classify_addressed_to(
836
+ orchestrator_model_id=orchestrator_fast_model_id(session),
837
  participants=_active_participants(session),
838
  speaker_name=speaker.name,
839
+ message=result.turn.text,
840
  api_log=session.api_log,
841
  )
842
  _bump_orchestrator_count(session)
843
+ return {
844
+ "addressed_to": addressed,
845
+ "replying_to": _replying_to_ids(result.pending),
846
+ }
847
 
848
+ async for chunk in run_roster_ai_turns_parallel(
849
+ session,
850
+ actives,
851
+ phase=session.phase,
852
+ build_spec=_build_critique_spec,
853
+ call_participant=_call_participant,
854
+ on_human_turn=_human_critique,
855
+ post_process=_post_critique,
856
+ ):
857
+ yield chunk
 
 
 
858
 
859
 
860
  async def _phase_status_assessment(session: Session) -> AsyncIterator[str]:
 
862
  yield _sse("status", {"message": "Phase 3: assessing whether more questions are needed..."})
863
 
864
  cred_block = credentials_to_block(session.credential_summary)
865
+ cred_refreshed = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
866
 
867
  for iteration in range(session.limits.status_assessment_max):
868
  session.status_assessment_iterations = iteration + 1
869
+ transcript = await compact_transcript_for_orchestrator(
870
+ session,
871
+ orchestrator_model_id=orchestrator_fast_model_id(session),
872
+ )
873
  prompt = STATUS_ASSESSMENT_PROMPT.format(
874
  question=session.question,
875
  credential_summary=cred_block,
876
+ transcript=transcript,
877
  )
878
  _raw, parsed = await orchestrator_call(
879
  orchestrator_model_id=_orchestrator_model_id(session),
 
900
  yield _sse("orchestrator", _msg_payload(msg))
901
  return
902
 
903
+ # Refresh credentials only when follow-ups are actually needed.
904
+ if not cred_refreshed:
905
+ cred_refreshed = True
906
+ full_transcript = _format_history(session.messages)
907
+ refreshed = await refresh_credential_summary(
908
+ orchestrator_model_id=_orchestrator_model_id(session),
909
+ question=session.question,
910
+ participants=_active_participants(session),
911
+ existing=session.credential_summary,
912
+ critique_transcript=full_transcript,
913
+ api_log=session.api_log,
914
+ )
915
+ _bump_orchestrator_count(session)
916
+ if refreshed != session.credential_summary:
917
+ session.credential_summary = refreshed
918
+ yield _sse("credentials_updated", {
919
+ "stage": "refreshed",
920
+ "credentials": session.credential_summary,
921
+ })
922
+ cred_block = credentials_to_block(session.credential_summary)
923
+
924
  # Otherwise run targeted follow-ups
925
  active_ids = {p.participant_id for p in _active_participants(session)}
926
  for oq in open_qs:
 
980
  credential_summary=cred_block,
981
  targeted_question=question_text,
982
  )
983
+ stream_events: list[str] = []
984
+ stream_msg_id = str(uuid.uuid4())
985
  turn = await run_resilient_turn(
986
  session=session, participant=target,
987
  user_prompt=prompt2,
988
  label="targeted_followup",
989
  max_tokens=600,
990
  call_participant=_call_participant,
991
+ stream_events=stream_events,
992
+ stream_message_id=stream_msg_id,
993
  )
994
+ for ev in stream_events:
995
+ yield ev
996
  for ev in turn.sse_events:
997
  yield ev
998
  if not turn.ok:
 
1010
  msg = _add_participant_message(
1011
  session, speaker, text, phase=session.phase, elapsed=elapsed,
1012
  replying_to=replying_to,
1013
+ message_id=stream_msg_id,
1014
  )
1015
  yield _sse("message", _msg_payload(msg))
1016
 
 
1035
 
1036
  cred_block = credentials_to_block(session.credential_summary)
1037
  actives = _active_participants(session)
1038
+ transcript_snapshot = _format_history(session.messages)
1039
+ pending_snapshot = {
1040
+ p.participant_id: _pending_addressed_for(session, p)
1041
+ for p in actives
1042
+ if p.kind != "human"
1043
+ }
1044
+
1045
+ async def _human_final(p: Participant) -> AsyncIterator[str]:
1046
+ async for chunk in _do_human_turn(
1047
+ session, p, phase=session.phase, actives=actives,
1048
+ track_final_opinion=True,
1049
+ prompt_context=(
1050
+ "Phase 4: state your final opinion on the question, "
1051
+ "incorporating whatever you've learned in the discussion."
1052
+ ),
1053
+ ):
1054
+ yield chunk
1055
+
1056
+ def _build_final_spec(p: Participant) -> _AiTurnSpec | None:
1057
  if p.kind == "human":
1058
+ return None
1059
+ pending = pending_snapshot.get(p.participant_id, [])
 
 
 
 
 
 
 
 
 
 
1060
  pending_block = _format_pending_block(pending)
1061
  prompt = FINALIZATION_PROMPT.format(
1062
  question=session.question,
1063
  credential_summary=cred_block,
1064
+ transcript=transcript_snapshot,
1065
  pending_block=pending_block,
1066
  )
1067
+ return _AiTurnSpec(
1068
+ participant=p,
1069
  user_prompt=prompt,
1070
  label="finalization",
1071
  max_tokens=600,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1072
  )
 
1073
 
1074
+ async def _post_final(result: _AiTurnResult) -> dict[str, Any]:
1075
+ session.final_opinions[result.participant.participant_id] = result.turn.text
1076
+ return {"replying_to": _replying_to_ids(result.pending)}
1077
+
1078
+ async for chunk in run_roster_ai_turns_parallel(
1079
+ session,
1080
+ actives,
1081
+ phase=session.phase,
1082
+ build_spec=_build_final_spec,
1083
+ call_participant=_call_participant,
1084
+ on_human_turn=_human_final,
1085
+ post_process=_post_final,
1086
+ ):
1087
+ yield chunk
1088
 
1089
 
1090
  async def _phase_consensus(session: Session) -> AsyncIterator[str]:
 
1183
  # Replicated here because the LLM-path code below also does
1184
  # it, and we need it on the human path too.
1185
  if consensus_turns % max(1, len(actives)) == 0:
1186
+ terminal = await _consensus_status_terminal_sse(session, actives)
1187
+ if terminal:
1188
+ yield terminal
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1189
  return
1190
  continue
1191
 
 
1200
  # message records who this turn was supposed to be replying to.
1201
  pending = _pending_addressed_for(session, speaker)
1202
 
1203
+ stream_events: list[str] = []
1204
+ stream_msg_id = str(uuid.uuid4())
1205
  turn = await run_resilient_turn(
1206
  session=session, participant=speaker,
1207
  user_prompt=prompt,
1208
  label="consensus",
1209
  max_tokens=700,
1210
  call_participant=_call_participant,
1211
+ stream_events=stream_events,
1212
+ stream_message_id=stream_msg_id,
1213
  )
1214
+ for ev in stream_events:
1215
+ yield ev
1216
  for ev in turn.sse_events:
1217
  yield ev
1218
  if not turn.ok:
 
1228
  text, elapsed = turn.text, turn.elapsed
1229
 
1230
  addressed = await classify_addressed_to(
1231
+ orchestrator_model_id=orchestrator_fast_model_id(session),
1232
  participants=actives,
1233
  speaker_name=speaker.name,
1234
  message=text,
 
1241
  session, speaker, text, phase=session.phase, elapsed=elapsed,
1242
  addressed_to=addressed,
1243
  replying_to=_replying_to_ids(pending),
1244
+ message_id=stream_msg_id,
1245
  )
1246
  yield _sse("message", _msg_payload(msg))
1247
 
 
1254
 
1255
  # Status check every full round (every len(actives) turns)
1256
  if consensus_turns % max(1, len(actives)) == 0:
1257
+ terminal = await _consensus_status_terminal_sse(session, actives)
1258
+ if terminal:
1259
+ yield terminal
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1260
  return
1261
+
1262
+
1263
+ async def _consensus_status_terminal_sse(
1264
+ session: Session,
1265
+ actives: list[Participant],
1266
+ ) -> str | None:
1267
+ """Run a consensus status check. Returns an SSE chunk when the phase
1268
+ should end (majority or unproductive), else None."""
1269
+ transcript = await compact_transcript_for_orchestrator(
1270
+ session,
1271
+ orchestrator_model_id=orchestrator_fast_model_id(session),
1272
+ )
1273
+ status = await assess_consensus_status(
1274
+ orchestrator_model_id=orchestrator_fast_model_id(session),
1275
+ question=session.question,
1276
+ transcript=transcript,
1277
+ alliance_groups=session.alliance_groups,
1278
+ api_log=session.api_log,
1279
+ )
1280
+ _bump_orchestrator_count(session)
1281
+ if status.get("status") == "majority":
1282
+ session.alliance_groups = await _refresh_alliance_groups(session, actives)
1283
+ msg = _add_orchestrator_message(
1284
+ session,
1285
+ f"Majority reached. {status.get('rationale', '')}".strip(),
1286
+ kind="status",
1287
+ )
1288
+ return _sse("orchestrator", _msg_payload(msg))
1289
+ if status.get("status") == "unproductive":
1290
+ msg = _add_orchestrator_message(
1291
+ session,
1292
+ f"Conversation no longer productive. {status.get('rationale', '')}".strip(),
1293
+ kind="status",
1294
+ )
1295
+ return _sse("orchestrator", _msg_payload(msg))
1296
+ return None
1297
 
1298
 
1299
  async def _refresh_alliance_groups(
 
1397
  yield _sse("status", {"message": "Phase 6: closure..."})
1398
 
1399
  cred_block = credentials_to_block(session.credential_summary)
1400
+ transcript = await compact_transcript_for_orchestrator(
1401
+ session,
1402
+ orchestrator_model_id=orchestrator_fast_model_id(session),
1403
+ )
1404
 
1405
  status = await assess_consensus_status(
1406
+ orchestrator_model_id=orchestrator_fast_model_id(session),
1407
  question=session.question,
1408
  transcript=transcript,
1409
  alliance_groups=session.alliance_groups,
 
1562
  async for chunk in structure.run():
1563
  yield chunk
1564
 
1565
+ # Kick off contribution summaries in the background just before
1566
+ # the decision phase. The Table View blocks on this task only if
1567
+ # the user opens it before it finishes - usually it'll be done
1568
+ # by then, so the table loads instantly.
1569
+ _start_contribution_summary_task(session)
1570
+
1571
  decision_input = structure.build_decision_input()
1572
  decision = decision_cls(session, decision_input)
1573
  async for chunk in decision.run():
 
1582
  # doesn't outlive the session in the module-level registry.
1583
  human_io.drop_session(session.session_id)
1584
 
 
 
 
 
 
 
1585
  yield _sse("system", {"text": "End of Chat", "phase": session.phase.value})
1586
  yield _sse("done", {})
1587
 
1588
 
1589
+ def _start_contribution_summary_task(session: Session) -> None:
1590
+ """Schedule the contribution-summary build as a background task.
1591
+
1592
+ Idempotent: if a task is already in flight (or completed) we don't
1593
+ start another one. Errors in the background task are swallowed and
1594
+ logged - the Table View endpoint will fall back to a synchronous
1595
+ build if needed.
1596
+ """
1597
+ if session.contribution_summary_task is not None:
1598
+ return
1599
+ if any((session.contribution_summaries or {}).values()):
1600
+ return
1601
+
1602
+ async def _runner() -> None:
1603
+ try:
1604
+ await _build_contribution_summaries(session)
1605
+ except Exception as exc: # noqa: BLE001
1606
+ LOG.warning(
1607
+ "Background contribution_summaries failed for %s: %s",
1608
+ session.session_id, exc,
1609
+ )
1610
+
1611
+ try:
1612
+ session.contribution_summary_task = asyncio.create_task(_runner())
1613
+ except RuntimeError:
1614
+ session.contribution_summary_task = None
1615
+
1616
+
1617
+ async def ensure_contribution_summaries(session: Session) -> None:
1618
+ """Block on contribution summaries for the Table View.
1619
+
1620
+ Order of preference:
1621
+ 1. Cached - return immediately.
1622
+ 2. Background task in flight - await it.
1623
+ 3. Nothing started - build synchronously.
1624
+ """
1625
+ if any((session.contribution_summaries or {}).values()):
1626
+ return
1627
+ task = session.contribution_summary_task
1628
+ if task is not None and not task.done():
1629
+ try:
1630
+ await task
1631
+ except Exception as exc: # noqa: BLE001
1632
+ LOG.warning("contribution_summary_task await failed: %s", exc)
1633
+ if any((session.contribution_summaries or {}).values()):
1634
+ return
1635
+ await _build_contribution_summaries(session)
1636
+
1637
+
1638
  async def _build_contribution_summaries(session: Session) -> None:
1639
  actives = _active_participants(session)
1640
  roster = "\n".join(
backend/app/services/orchestrator_speed.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Speed-oriented orchestration helpers.
2
+
3
+ Parallel participant turns, compact orchestrator context, and fast-
4
+ model routing for lightweight classifier calls. Keeps the same
5
+ visible message count while shortening wall-clock time.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, AsyncIterator, Awaitable, Callable, TYPE_CHECKING
14
+
15
+ from app.services.resilience import ResilientTurnResult, run_resilient_turn
16
+
17
+ if TYPE_CHECKING:
18
+ from app.services.models import Participant, Phase, Session
19
+
20
+ LOG = logging.getLogger(__name__)
21
+
22
+ # Rough char budget for orchestrator-side prompts that would otherwise
23
+ # resend the entire transcript every few turns.
24
+ ORCHESTRATOR_TRANSCRIPT_CHAR_BUDGET = 14_000
25
+ _RECENT_TAIL_MESSAGES = 24
26
+
27
+ CallParticipantFn = Callable[..., Awaitable[tuple[str, float, bool, str]]]
28
+
29
+
30
+ @dataclass
31
+ class _AiTurnSpec:
32
+ participant: "Participant"
33
+ user_prompt: str
34
+ label: str
35
+ max_tokens: int
36
+
37
+
38
+ @dataclass
39
+ class _AiTurnResult:
40
+ participant: "Participant"
41
+ turn: ResilientTurnResult
42
+ pending: list[tuple[str, str, str]] = field(default_factory=list)
43
+
44
+
45
+ def orchestrator_fast_model_id(session: "Session") -> str:
46
+ """Model for lightweight orchestrator classifiers (addressed-to, status)."""
47
+ from app.config import settings
48
+ from app.services.orchestrator import _orchestrator_model_id
49
+
50
+ fast = (getattr(settings, "orchestrator_fast_model", None) or "").strip()
51
+ if fast and settings.resolve_model(fast):
52
+ return fast
53
+ return _orchestrator_model_id(session)
54
+
55
+
56
+ def _format_history_for_orchestrator(
57
+ messages: list[dict[str, Any]],
58
+ *,
59
+ include_orchestrator: bool = True,
60
+ ) -> str:
61
+ from app.services.orchestrator import _format_history
62
+
63
+ return _format_history(messages, include_orchestrator=include_orchestrator)
64
+
65
+
66
+ async def compact_transcript_for_orchestrator(
67
+ session: "Session",
68
+ *,
69
+ orchestrator_model_id: str,
70
+ ) -> str:
71
+ """Return a transcript block sized for orchestrator judge prompts.
72
+
73
+ Uses a rolling summary + recent tail when the full history exceeds
74
+ the char budget. Summaries are built lazily (one extra orchestrator
75
+ call) and cached on the session.
76
+ """
77
+ from app.services.json_calls import orchestrator_call
78
+ from app.services.orchestrator import _bump_orchestrator_count
79
+
80
+ messages = session.messages
81
+ full = _format_history_for_orchestrator(messages)
82
+ if len(full) <= ORCHESTRATOR_TRANSCRIPT_CHAR_BUDGET:
83
+ return full
84
+
85
+ tail = messages[-_RECENT_TAIL_MESSAGES:]
86
+ tail_text = _format_history_for_orchestrator(tail)
87
+ through = len(messages) - len(tail)
88
+ if (
89
+ session.orchestrator_context_summary
90
+ and session.orchestrator_context_through_idx >= through - 2
91
+ ):
92
+ return (
93
+ "[Earlier discussion summary]\n"
94
+ f"{session.orchestrator_context_summary}\n\n"
95
+ "[Recent messages]\n"
96
+ f"{tail_text}"
97
+ )
98
+
99
+ prompt = (
100
+ "Summarize the following group discussion for an orchestrator that "
101
+ "will judge consensus and open questions. Preserve names, stances, "
102
+ "and unresolved disagreements. Be concise (under 400 words).\n\n"
103
+ f"{full}"
104
+ )
105
+ raw, _ = await orchestrator_call(
106
+ orchestrator_model_id=orchestrator_model_id,
107
+ user_prompt=prompt,
108
+ label="orchestrator_transcript_summary",
109
+ api_log=session.api_log,
110
+ expect_json=False,
111
+ max_tokens=700,
112
+ temperature=0.2,
113
+ )
114
+ _bump_orchestrator_count(session)
115
+ summary = (raw or "").strip() or full[-ORCHESTRATOR_TRANSCRIPT_CHAR_BUDGET:]
116
+ session.orchestrator_context_summary = summary
117
+ session.orchestrator_context_through_idx = through
118
+ return (
119
+ "[Earlier discussion summary]\n"
120
+ f"{summary}\n\n"
121
+ "[Recent messages]\n"
122
+ f"{tail_text}"
123
+ )
124
+
125
+
126
+ async def _execute_ai_turn(
127
+ session: "Session",
128
+ spec: _AiTurnSpec,
129
+ call_participant: CallParticipantFn,
130
+ ) -> _AiTurnResult:
131
+ from app.services.orchestrator import _pending_addressed_for
132
+
133
+ pending = _pending_addressed_for(session, spec.participant)
134
+ turn = await run_resilient_turn(
135
+ session=session,
136
+ participant=spec.participant,
137
+ user_prompt=spec.user_prompt,
138
+ label=spec.label,
139
+ max_tokens=spec.max_tokens,
140
+ call_participant=call_participant,
141
+ )
142
+ return _AiTurnResult(
143
+ participant=spec.participant,
144
+ turn=turn,
145
+ pending=pending,
146
+ )
147
+
148
+
149
+ PostProcessFn = Callable[
150
+ [_AiTurnResult],
151
+ Awaitable[dict[str, Any] | None],
152
+ ]
153
+
154
+
155
+ async def run_roster_ai_turns_parallel(
156
+ session: "Session",
157
+ actives: list["Participant"],
158
+ *,
159
+ phase: "Phase",
160
+ build_spec: Callable[["Participant"], _AiTurnSpec | None],
161
+ call_participant: CallParticipantFn,
162
+ on_human_turn: Callable[
163
+ ["Participant"],
164
+ AsyncIterator[str],
165
+ ],
166
+ post_process: PostProcessFn | None = None,
167
+ ) -> AsyncIterator[str]:
168
+ """Run participant turns: humans sequentially, AI in parallel batches.
169
+
170
+ Walks `actives` in roster order. Consecutive AI participants are
171
+ executed with ``asyncio.gather``; results are applied in roster
172
+ order so the message log stays deterministic. Humans are awaited
173
+ one at a time via ``on_human_turn``.
174
+
175
+ Yields orchestrator SSE strings (status, message, errors, etc.).
176
+ """
177
+ from app.services.orchestrator import (
178
+ _msg_payload,
179
+ _participant_msg_cap_hit,
180
+ _sse,
181
+ _wait_for_continue,
182
+ )
183
+
184
+ ai_batch: list[_AiTurnSpec] = []
185
+
186
+ async def flush_ai_batch() -> AsyncIterator[str]:
187
+ nonlocal ai_batch
188
+ if not ai_batch:
189
+ return
190
+ specs = ai_batch
191
+ ai_batch = []
192
+ results = await asyncio.gather(
193
+ *[
194
+ _execute_ai_turn(session, spec, call_participant)
195
+ for spec in specs
196
+ ],
197
+ return_exceptions=True,
198
+ )
199
+ for spec, item in zip(specs, results):
200
+ if isinstance(item, BaseException):
201
+ LOG.exception(
202
+ "Parallel turn failed for %s: %s",
203
+ spec.participant.participant_id,
204
+ item,
205
+ )
206
+ yield _sse("participant_error", {
207
+ "participant_id": spec.participant.participant_id,
208
+ "name": spec.participant.name,
209
+ "phase": phase.value,
210
+ })
211
+ continue
212
+ extra: dict[str, Any] | None = None
213
+ if post_process is not None:
214
+ extra = await post_process(item) or {}
215
+ async for chunk in _emit_ai_turn_result(
216
+ session, item, phase=phase, extra=extra,
217
+ ):
218
+ yield chunk
219
+ if _participant_msg_cap_hit(session):
220
+ async for chunk in _wait_for_continue(session, "messages"):
221
+ yield chunk
222
+
223
+ for p in actives:
224
+ if p.kind == "human":
225
+ async for chunk in flush_ai_batch():
226
+ yield chunk
227
+ async for chunk in on_human_turn(p):
228
+ yield chunk
229
+ continue
230
+
231
+ spec = build_spec(p)
232
+ if spec is None:
233
+ continue
234
+ ai_batch.append(spec)
235
+
236
+ async for chunk in flush_ai_batch():
237
+ yield chunk
238
+
239
+
240
+ async def _emit_ai_turn_result(
241
+ session: "Session",
242
+ result: _AiTurnResult,
243
+ *,
244
+ phase: "Phase",
245
+ extra: dict[str, Any] | None = None,
246
+ ) -> AsyncIterator[str]:
247
+ """Apply a completed AI turn to the session and yield SSE."""
248
+ from app.services.orchestrator import (
249
+ _add_participant_message,
250
+ _msg_payload,
251
+ _orchestrator_cap_hit,
252
+ _replying_to_ids,
253
+ _sse,
254
+ _wait_for_continue,
255
+ )
256
+
257
+ p = result.participant
258
+ turn = result.turn
259
+ for ev in turn.sse_events:
260
+ yield ev
261
+ if not turn.ok:
262
+ yield _sse("participant_error", {
263
+ "participant_id": p.participant_id,
264
+ "name": p.name,
265
+ "phase": phase.value,
266
+ })
267
+ if p.consecutive_failures >= session.limits.auto_disable_failures:
268
+ p.enabled = False
269
+ yield _sse("status", {
270
+ "message": (
271
+ f"{p.name} auto-disabled after "
272
+ f"{session.limits.auto_disable_failures} failures."
273
+ ),
274
+ })
275
+ return
276
+
277
+ speaker = turn.speaker
278
+ meta = extra or {}
279
+ msg = _add_participant_message(
280
+ session,
281
+ speaker,
282
+ turn.text,
283
+ phase=phase,
284
+ elapsed=turn.elapsed,
285
+ addressed_to=meta.get("addressed_to"),
286
+ replying_to=meta.get(
287
+ "replying_to",
288
+ _replying_to_ids(result.pending),
289
+ ),
290
+ message_id=meta.get("message_id"),
291
+ )
292
+ yield _sse("message", _msg_payload(msg))
293
+
294
+ if _orchestrator_cap_hit(session):
295
+ async for chunk in _wait_for_continue(session, "orchestrator"):
296
+ yield chunk
297
+
298
+
299
+ async def run_parallel_coroutines(
300
+ coros: list[Awaitable[Any]],
301
+ ) -> list[Any]:
302
+ """Gather with exception isolation (failed tasks become exceptions)."""
303
+ return await asyncio.gather(*coros, return_exceptions=True)
backend/app/services/resilience.py CHANGED
@@ -329,6 +329,8 @@ async def run_resilient_turn(
329
  label: str,
330
  max_tokens: int,
331
  call_participant: CallParticipantFn,
 
 
332
  ) -> ResilientTurnResult:
333
  """Phase-aware wrapper around a single participant LLM turn.
334
 
@@ -356,6 +358,8 @@ async def run_resilient_turn(
356
  user_prompt=user_prompt,
357
  label=label,
358
  max_tokens=max_tokens,
 
 
359
  )
360
 
361
  if ok and text.strip():
 
329
  label: str,
330
  max_tokens: int,
331
  call_participant: CallParticipantFn,
332
+ stream_events: list[str] | None = None,
333
+ stream_message_id: str | None = None,
334
  ) -> ResilientTurnResult:
335
  """Phase-aware wrapper around a single participant LLM turn.
336
 
 
358
  user_prompt=user_prompt,
359
  label=label,
360
  max_tokens=max_tokens,
361
+ stream_events=stream_events,
362
+ stream_message_id=stream_message_id,
363
  )
364
 
365
  if ok and text.strip():
frontend/src/App.js CHANGED
@@ -753,9 +753,38 @@ export default function App() {
753
  setSessionParticipants(data.participants || []);
754
  },
755
  onMessage: (data) => {
756
- setMessages(prev => [...prev, data]);
 
 
 
 
 
 
 
 
 
 
757
  setStatusText('Conversation in progress...');
758
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
759
  onOrchestrator: (data) => {
760
  // Orchestrator events with kind == "status" but no text are
761
  // status banners; bubble them into a message-style entry so
 
753
  setSessionParticipants(data.participants || []);
754
  },
755
  onMessage: (data) => {
756
+ setMessages(prev => {
757
+ const mid = data?.message_id;
758
+ if (!mid) return [...prev, data];
759
+ const idx = prev.findIndex(m => m.message_id === mid);
760
+ if (idx >= 0) {
761
+ const next = [...prev];
762
+ next[idx] = { ...next[idx], ...data, streaming: false };
763
+ return next;
764
+ }
765
+ return [...prev, data];
766
+ });
767
  setStatusText('Conversation in progress...');
768
  },
769
+ onMessageStreamStart: (data) => {
770
+ setMessages(prev => [...prev, {
771
+ ...data,
772
+ role: 'participant',
773
+ text: '',
774
+ streaming: true,
775
+ timestamp: Date.now() / 1000,
776
+ }]);
777
+ },
778
+ onMessageDelta: (data) => {
779
+ const mid = data?.message_id;
780
+ const delta = data?.delta || '';
781
+ if (!mid || !delta) return;
782
+ setMessages(prev => prev.map(m => (
783
+ m.message_id === mid
784
+ ? { ...m, text: `${m.text || ''}${delta}` }
785
+ : m
786
+ )));
787
+ },
788
  onOrchestrator: (data) => {
789
  // Orchestrator events with kind == "status" but no text are
790
  // status banners; bubble them into a message-style entry so
frontend/src/utils/api.js CHANGED
@@ -117,6 +117,8 @@ function eventHandlerKey(eventType) {
117
  switch (eventType) {
118
  case 'session': return 'onSession';
119
  case 'message': return 'onMessage';
 
 
120
  case 'orchestrator': return 'onOrchestrator';
121
  case 'system': return 'onSystem';
122
  case 'status': return 'onStatus';
 
117
  switch (eventType) {
118
  case 'session': return 'onSession';
119
  case 'message': return 'onMessage';
120
+ case 'message_stream_start': return 'onMessageStreamStart';
121
+ case 'message_delta': return 'onMessageDelta';
122
  case 'orchestrator': return 'onOrchestrator';
123
  case 'system': return 'onSystem';
124
  case 'status': return 'onStatus';