gglib_core/domain/council/events.rs
1//! SSE event types emitted during an orchestrator run.
2//!
3//! [`CouncilEvent`] is the **single source of truth** for the wire format
4//! shared by the Axum SSE handler, the CLI consumer, and the TypeScript
5//! frontend types.
6//!
7//! Serialisation uses the same `{"type":"variant_name", ...}` envelope as
8//! [`gglib_core::AgentEvent`] and `CouncilEvent` so frontend event handlers
9//! stay consistent.
10//!
11//! # Event lifecycle
12//!
13//! ```text
14//! PlanProposed → [PlanApproved | PlanRejected]
15//! → NodeStarted* → NodeTextDelta* → NodeToolCall* → NodeComplete*
16//! → SynthesisStart → SynthesisTextDelta* → SynthesisComplete
17//! → CouncilComplete
18//! ```
19//!
20//! Error paths emit [`CouncilEvent::NodeFailed`] or
21//! [`CouncilEvent::CouncilError`] and then the stream closes.
22
23use serde::{Deserialize, Serialize};
24
25use crate::domain::agent::{ToolCall, ToolResult};
26use crate::domain::council::role_catalog::RoleId;
27
28use super::task_graph::{GraphDiff, TaskGraph};
29
30/// Channel capacity for the orchestrator event sender.
31///
32/// Larger than the per-agent channel to accommodate bursts from multiple
33/// concurrent worker nodes plus orchestration bookkeeping events.
34pub const COUNCIL_EVENT_CHANNEL_CAPACITY: usize = 8_192;
35
36// =============================================================================
37// ApprovalKind
38// =============================================================================
39
40/// Describes what the human-in-the-loop gate is waiting for approval on.
41///
42/// Carried inside [`CouncilEvent::AwaitingApproval`].
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(tag = "kind", rename_all = "snake_case")]
45pub enum ApprovalKind {
46 /// Approval of the proposed [`TaskGraph`] plan before execution begins.
47 Plan,
48 /// Approval before a specific worker node starts executing.
49 Node {
50 /// The id of the node pending approval.
51 node_id: String,
52 },
53 /// Approval before a specific tool call within a worker node.
54 Tool {
55 /// The worker node that is about to make the tool call.
56 node_id: String,
57 /// The tool name being called.
58 tool_name: String,
59 },
60 /// Approval before dynamically spawning a sub-team from within a worker node.
61 SpawnSubteam {
62 /// The worker node that requested the spawn.
63 node_id: String,
64 /// Roles suggested by the requesting worker for the new team.
65 suggested_roles: Vec<String>,
66 },
67}
68
69// =============================================================================
70// AgentStance
71// =============================================================================
72
73/// How a debating agent's position changed over the course of a debate.
74///
75/// Carried inside [`CouncilEvent::DebateStanceMap`].
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum StanceOutcome {
79 /// The agent maintained its original position throughout all rounds.
80 Held,
81 /// The agent moved toward a different position by the final round.
82 Shifted,
83 /// The agent explicitly conceded to another agent's argument.
84 Conceded,
85}
86
87/// The final stance outcome for a single debating agent.
88///
89/// Carried inside [`CouncilEvent::DebateStanceMap`].
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct AgentStance {
92 /// Short id of the agent (matches [`DebateAgent::id`]).
93 ///
94 /// [`DebateAgent::id`]: super::task_graph::DebateAgent::id
95 pub agent_id: String,
96 /// Whether the agent held, shifted, or conceded.
97 pub outcome: StanceOutcome,
98}
99
100// =============================================================================
101// CouncilEvent
102// =============================================================================
103
104/// A single event in an orchestrator execution stream.
105///
106/// Consumers receive these over SSE (web) or an `mpsc` channel (CLI).
107/// Each variant is independently useful — the frontend can render
108/// progressively as events arrive without buffering the full stream.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(tag = "type", rename_all = "snake_case")]
111pub enum CouncilEvent {
112 // ── planning ─────────────────────────────────────────────────────────
113 /// The director has produced an initial task plan.
114 ///
115 /// If `hitl_mode` requires plan approval, the executor immediately
116 /// emits [`CouncilEvent::AwaitingApproval`] after this and
117 /// pauses until the frontend responds.
118 PlanProposed { graph: TaskGraph },
119
120 /// A re-planning attempt was triggered (e.g. because the user rejected
121 /// the initial plan or a node failed and the director proposes recovery).
122 ReplanAttempt {
123 /// 1-based retry count.
124 attempt: u32,
125 /// Human-readable reason for re-planning.
126 reason: String,
127 },
128
129 /// Warn-only cost estimate emitted immediately after
130 /// [`CouncilEvent::PlanProposed`].
131 ///
132 /// Never suppressed, never fatal — the run always proceeds. The
133 /// frontend may display a yellow warning banner when
134 /// `est_wall_seconds > 60` or `node_count` exceeds 80 % of the active
135 /// [`NodeBudget`] upper bound.
136 RunCostEstimate {
137 /// Total aggregate node count across all subgraphs.
138 node_count: usize,
139 /// Rough token estimate (input + output) for the entire run.
140 est_tokens: u64,
141 /// Estimated wall-clock seconds at 50 tokens / second.
142 est_wall_seconds: u64,
143 },
144
145 /// The plan was approved (by the user or automatically when
146 /// `hitl_mode == None`).
147 PlanApproved,
148
149 /// The plan was rejected by the user. The orchestrator will either
150 /// re-plan or stop, depending on the caller's retry policy.
151 PlanRejected {
152 /// Optional user-provided rejection reason / edit instructions.
153 #[serde(skip_serializing_if = "Option::is_none")]
154 reason: Option<String>,
155 },
156
157 // ── HITL gates ───────────────────────────────────────────────────────
158 /// The orchestrator is paused waiting for human approval.
159 ///
160 /// The frontend should display the `kind` payload and allow the user
161 /// to approve or reject. The executor resumes when it receives an
162 /// `ApprovalResponse` back-channel message.
163 AwaitingApproval {
164 /// Unique id for this approval request (correlates with the
165 /// back-channel `ApprovalResponse`).
166 approval_id: String,
167 /// What is being approved.
168 kind: ApprovalKind,
169 },
170
171 // ── node lifecycle ───────────────────────────────────────────────────
172 /// A worker node has started executing.
173 NodeStarted {
174 /// Unique id of the node.
175 node_id: String,
176 /// The worker's goal text.
177 goal: String,
178 },
179
180 /// Incremental text token from the currently-executing worker node.
181 NodeTextDelta {
182 /// Source node id.
183 node_id: String,
184 /// The new text fragment.
185 delta: String,
186 },
187
188 /// Incremental reasoning / chain-of-thought token from a worker node
189 /// (for models that expose `CoT`).
190 NodeReasoningDelta {
191 /// Source node id.
192 node_id: String,
193 /// The reasoning fragment.
194 delta: String,
195 },
196
197 /// Prompt-processing progress during a worker's LLM pre-fill phase.
198 NodeProgress {
199 /// Source node id.
200 node_id: String,
201 /// Tokens processed so far.
202 processed: u32,
203 /// Total tokens in the prompt.
204 total: u32,
205 /// Tokens served from the KV cache.
206 cached: u32,
207 /// Wall-clock time elapsed in milliseconds.
208 time_ms: u64,
209 },
210
211 /// A worker node has initiated a tool call.
212 NodeToolCallStart {
213 /// Source node id.
214 node_id: String,
215 /// The tool call details.
216 tool_call: ToolCall,
217 /// Human-readable display name for the tool.
218 display_name: String,
219 /// Optional one-line summary of the arguments for UI rendering.
220 #[serde(skip_serializing_if = "Option::is_none")]
221 args_summary: Option<String>,
222 },
223
224 /// A tool call by a worker node has completed.
225 NodeToolCallComplete {
226 /// Source node id.
227 node_id: String,
228 /// The name of the tool that ran.
229 tool_name: String,
230 /// The tool's result payload.
231 result: ToolResult,
232 /// Human-readable display name.
233 display_name: String,
234 /// Human-readable elapsed time (e.g. `"1.2s"`).
235 duration_display: String,
236 },
237
238 /// A non-fatal warning from a worker node's agent loop.
239 NodeSystemWarning {
240 /// Source node id.
241 node_id: String,
242 /// Warning message text.
243 message: String,
244 /// Optional actionable hint for the user.
245 #[serde(skip_serializing_if = "Option::is_none")]
246 suggested_action: Option<String>,
247 },
248
249 /// A worker node's output is being compacted before downstream
250 /// nodes receive it as context.
251 NodeCompacting {
252 /// Source node id.
253 node_id: String,
254 },
255
256 /// A worker node finished successfully.
257 NodeComplete {
258 /// Source node id.
259 node_id: String,
260 /// First ≤ 200 characters of the output (for UI preview).
261 output_preview: String,
262 },
263
264 /// A worker node failed with an unrecoverable error.
265 ///
266 /// The orchestrator will mark all downstream nodes as
267 /// [`NodeStatus::Skipped`](super::task_graph::NodeStatus::Skipped) and
268 /// then emit [`CouncilEvent::CouncilError`].
269 NodeFailed {
270 /// Source node id.
271 node_id: String,
272 /// Error description.
273 error: String,
274 },
275
276 // ── synthesis ────────────────────────────────────────────────────────
277 /// The synthesis phase has started.
278 ///
279 /// The synthesiser assembles all node outputs into a unified answer.
280 SynthesisStart,
281
282 /// Prompt-processing progress during the synthesis LLM call.
283 SynthesisProgress {
284 /// Tokens processed so far.
285 processed: u32,
286 /// Total tokens in the prompt.
287 total: u32,
288 /// Tokens served from the KV cache.
289 cached: u32,
290 /// Wall-clock elapsed time in milliseconds.
291 time_ms: u64,
292 },
293
294 /// Incremental text token from the synthesiser.
295 SynthesisTextDelta {
296 /// The new text fragment.
297 delta: String,
298 },
299
300 /// The synthesiser has finished.
301 SynthesisComplete {
302 /// Full synthesised answer.
303 content: String,
304 },
305
306 // ── terminal ─────────────────────────────────────────────────────────
307 /// The orchestrator run completed successfully.
308 ///
309 /// `answer` is the synthesiser's final output (same as the `content`
310 /// field of the preceding [`CouncilEvent::SynthesisComplete`]).
311 CouncilComplete { answer: String },
312
313 /// The orchestrator run failed with an unrecoverable error.
314 CouncilError { message: String },
315
316 // ── team (v2) ────────────────────────────────────────────────────────
317 /// A [`TaskNodeKind::Team`] node has started executing its nested subgraph.
318 ///
319 /// Emitted by the executor before it begins scheduling the first wave of
320 /// the team's subgraph. Paired with [`CouncilEvent::TeamSynthesized`]
321 /// when the subgraph completes.
322 TeamStarted {
323 /// The id of the `Team` node in the parent graph.
324 team_id: String,
325 /// The optional specialist role assigned to this team.
326 #[serde(skip_serializing_if = "Option::is_none")]
327 role: Option<RoleId>,
328 },
329
330 /// A [`TaskNodeKind::Team`] node's subgraph has completed and its output
331 /// has been compacted for passing to downstream nodes in the parent graph.
332 ///
333 /// The `compacted_output` is what downstream nodes receive as context from
334 /// this team node — identical in shape to a leaf node's `compacted_output`.
335 TeamSynthesized {
336 /// The id of the `Team` node in the parent graph.
337 team_id: String,
338 /// Compacted summary of the team's synthesised output.
339 compacted_output: String,
340 },
341
342 // ── spawn (Phase I) ──────────────────────────────────────────────────
343 /// A worker node requested dynamic team spawning and the executor approved
344 /// it; the child sub-team subgraph has been planned and is about to run.
345 ///
346 /// `parent_node_id` is the leaf node that triggered the spawn. The child
347 /// graph runs as a nested [`run_wave_loop`] inside the parent's wave.
348 SubteamSpawned {
349 /// The leaf node that triggered the spawn.
350 parent_node_id: String,
351 /// One-line summary of the child graph that was planned.
352 child_graph_summary: String,
353 },
354
355 // ── steering (Phase K) ───────────────────────────────────────────────
356 /// A [`GraphDiff`] was applied to the task graph at a wave boundary.
357 ///
358 /// Emitted after [`super::task_graph::TaskGraph::apply_diff`] succeeds.
359 /// Informational only — execution continues with the updated graph.
360 SteeringApplied {
361 /// The diff that was applied.
362 diff: GraphDiff,
363 /// Zero-based wave index at which the diff was applied.
364 applied_at_wave: u32,
365 },
366
367 // ── debate (Phase N) ─────────────────────────────────────────────────
368 /// A new debate round has started inside a [`TaskNodeKind::Debate`] node.
369 ///
370 /// Emitted once per round, before any agent turn events for that round.
371 ///
372 /// [`TaskNodeKind::Debate`]: super::task_graph::TaskNodeKind::Debate
373 DebateRoundStarted {
374 /// The id of the `Debate` node in the parent graph.
375 node_id: String,
376 /// 1-based round number.
377 round: u32,
378 },
379
380 /// An agent's turn within a debate round has started.
381 ///
382 /// Immediately followed by zero or more [`CouncilEvent::DebateAgentTextDelta`]
383 /// events for this agent and then [`CouncilEvent::DebateAgentTurnComplete`].
384 DebateAgentTurnStarted {
385 /// The id of the `Debate` node.
386 node_id: String,
387 /// Short id of the agent (matches [`DebateAgent::id`]).
388 ///
389 /// [`DebateAgent::id`]: super::task_graph::DebateAgent::id
390 agent_id: String,
391 /// Display name of the agent.
392 agent_name: String,
393 /// Hex colour code (`#rrggbb`) for this agent's text in the UI.
394 color: String,
395 /// 1-based round number.
396 round: u32,
397 /// Temperature the LLM was called with (mapped from `contentiousness`).
398 contentiousness: f32,
399 },
400
401 /// Incremental text token from a debating agent.
402 DebateAgentTextDelta {
403 /// The id of the `Debate` node.
404 node_id: String,
405 /// Short id of the agent.
406 agent_id: String,
407 /// The new text fragment.
408 delta: String,
409 },
410
411 /// Incremental reasoning / chain-of-thought token from a debating agent.
412 DebateAgentReasoningDelta {
413 /// The id of the `Debate` node.
414 node_id: String,
415 /// Short id of the agent.
416 agent_id: String,
417 /// The reasoning fragment.
418 delta: String,
419 },
420
421 /// A debating agent has initiated a tool call.
422 DebateAgentToolCallStart {
423 /// The id of the `Debate` node.
424 node_id: String,
425 /// Short id of the agent.
426 agent_id: String,
427 /// The tool call details.
428 tool_call: ToolCall,
429 /// Human-readable display name for the tool.
430 display_name: String,
431 /// Optional one-line summary of the arguments for UI rendering.
432 #[serde(skip_serializing_if = "Option::is_none")]
433 args_summary: Option<String>,
434 },
435
436 /// A tool call by a debating agent has completed.
437 DebateAgentToolCallComplete {
438 /// The id of the `Debate` node.
439 node_id: String,
440 /// Short id of the agent.
441 agent_id: String,
442 /// The tool's result payload.
443 result: ToolResult,
444 /// Human-readable display name.
445 display_name: String,
446 /// Human-readable elapsed time (e.g. `"1.2s"`).
447 duration_display: String,
448 },
449
450 /// An agent's turn within a debate round has finished.
451 DebateAgentTurnComplete {
452 /// The id of the `Debate` node.
453 node_id: String,
454 /// Short id of the agent.
455 agent_id: String,
456 /// 1-based round number.
457 round: u32,
458 /// The agent's complete response for this turn.
459 final_text: String,
460 },
461
462 /// The judge LLM call for a debate round has started.
463 ///
464 /// Only emitted when a [`DebateJudgeConfig`] is present.
465 ///
466 /// [`DebateJudgeConfig`]: super::task_graph::DebateJudgeConfig
467 DebateJudgeStarted {
468 /// The id of the `Debate` node.
469 node_id: String,
470 /// 1-based round number being judged.
471 round: u32,
472 },
473
474 /// Incremental text token from the debate judge.
475 DebateJudgeTextDelta {
476 /// The id of the `Debate` node.
477 node_id: String,
478 /// The new text fragment.
479 delta: String,
480 },
481
482 /// The judge has finished assessing a debate round.
483 DebateJudgeSummary {
484 /// The id of the `Debate` node.
485 node_id: String,
486 /// 1-based round number that was judged.
487 round: u32,
488 /// Whether the judge determined that consensus has been reached.
489 consensus_reached: bool,
490 /// Whether the judge recommends stopping early (only acted on if
491 /// `round >= judge.min_rounds_before_stop`).
492 early_stop_recommended: bool,
493 /// The judge's full written assessment.
494 assessment_text: String,
495 },
496
497 /// A debate round's transcript has been compacted to reduce context pressure.
498 ///
499 /// Only emitted when the running context window would otherwise overflow.
500 DebateRoundCompacted {
501 /// The id of the `Debate` node.
502 node_id: String,
503 /// 1-based round number that was compacted.
504 round: u32,
505 /// Compressed summary replacing the full round transcript.
506 summary: String,
507 },
508
509 /// Final stance outcomes for all agents after all rounds complete.
510 ///
511 /// Emitted once after the last debate round, before synthesis starts.
512 DebateStanceMap {
513 /// The id of the `Debate` node.
514 node_id: String,
515 /// Per-agent stance outcomes.
516 stances: Vec<AgentStance>,
517 },
518
519 /// The debate synthesis phase has started.
520 ///
521 /// The synthesiser assembles the full round history into a verdict.
522 DebateSynthesisStarted {
523 /// The id of the `Debate` node.
524 node_id: String,
525 },
526
527 /// Incremental text token from the debate synthesiser.
528 DebateSynthesisTextDelta {
529 /// The id of the `Debate` node.
530 node_id: String,
531 /// The new text fragment.
532 delta: String,
533 },
534
535 /// The debate synthesis has finished.
536 ///
537 /// `final_text` becomes the node's `output` and is passed to the
538 /// compaction step before downstream nodes receive it as context.
539 DebateSynthesisComplete {
540 /// The id of the `Debate` node.
541 node_id: String,
542 /// The synthesiser's complete verdict text.
543 final_text: String,
544 },
545
546 // ── rewind (Phase M) ─────────────────────────────────────────────────
547 /// All nodes in a topological wave have completed.
548 ///
549 /// Emitted once at the end of each wave (depth 0 only). The frontend
550 /// uses these events as scrubber waypoints so the user can rewind to any
551 /// completed wave.
552 WaveCompleted {
553 /// Zero-based index of the wave that just finished.
554 wave_index: u32,
555 /// Number of nodes that completed in this wave.
556 node_count: usize,
557 },
558}