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}