Skip to main content

starweaver_stream/
raw.rs

1//! Typed raw agent stream protocol.
2
3use std::sync::{Arc, Mutex, PoisonError};
4
5use serde::{Deserialize, Serialize};
6use starweaver_core::{
7    AgentEvent, AgentExecutionNode, AgentId, ConversationId, Metadata, RunId,
8    RunLifecycle as RunStatus, TaskId,
9};
10use starweaver_model::{ModelResponse, ModelResponseStreamEvent, ToolCallPart, ToolReturnPart};
11
12/// Stable category for context sideband events that are bridged into the runtime stream.
13#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
14#[serde(rename_all = "snake_case")]
15pub enum AgentSidebandEventCategory {
16    /// Run lifecycle and status events.
17    Run,
18    /// Model retry or model-side diagnostics.
19    Model,
20    /// Tool availability or direct tool lifecycle events.
21    Tool,
22    /// Dynamic tool-search discovery, loading, initialization, or refresh events.
23    ToolSearch,
24    /// Human-in-the-loop approval or deferred-tool decision events.
25    Hitl,
26    /// Skill scan, activation, or reload events.
27    Skill,
28    /// Task list or task state events.
29    Task,
30    /// Note state events.
31    Note,
32    /// File state or file-operation events.
33    File,
34    /// Media state or media-operation events.
35    Media,
36    /// Host adapter events.
37    HostEvent,
38    /// Subagent lifecycle events.
39    Subagent,
40    /// Message bus events.
41    Message,
42    /// Usage snapshot or usage-limit events.
43    Usage,
44    /// Context compaction lifecycle events.
45    Compact,
46    /// User steering events.
47    Steering,
48    /// Runtime goal-mode events.
49    Goal,
50    /// Background process or background shell events.
51    Background,
52    /// Capability-specific events that do not fit a narrower category.
53    Capability,
54}
55
56/// Typed view of an application sideband event carried by [`AgentStreamEvent::Custom`].
57#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
58pub struct AgentSidebandEvent {
59    /// Stable event category.
60    pub category: AgentSidebandEventCategory,
61    /// Event type.
62    pub kind: String,
63    /// Event payload.
64    #[serde(default)]
65    pub payload: serde_json::Value,
66    /// Event metadata.
67    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
68    pub metadata: Metadata,
69}
70
71impl AgentSidebandEvent {
72    /// Build a typed sideband event when the context event kind belongs to the stable taxonomy.
73    #[must_use]
74    pub fn from_agent_event(event: &AgentEvent) -> Option<Self> {
75        Self::category_for_kind(&event.kind).map(|category| Self {
76            category,
77            kind: event.kind.clone(),
78            payload: event.payload.clone(),
79            metadata: event.metadata.clone(),
80        })
81    }
82
83    /// Classify a context event kind into the stable sideband taxonomy.
84    #[must_use]
85    pub fn category_for_kind(kind: &str) -> Option<AgentSidebandEventCategory> {
86        match kind {
87            "run_start" | "run_complete" | "run_failed" | "run_waiting" | "run_cancelled" => {
88                Some(AgentSidebandEventCategory::Run)
89            }
90            "model_error_retry"
91            | "model_stream_resume"
92            | "model_transport_selected"
93            | "model_transport_fallback" => Some(AgentSidebandEventCategory::Model),
94            "tools_unavailable"
95            | "toolset_initialized"
96            | "toolset_unavailable"
97            | "toolset_failed"
98            | "toolset_refreshed"
99            | "toolset_closed" => Some(AgentSidebandEventCategory::Tool),
100            "tool_search_loaded" | "tool_search_initialized" | "tool_search_refreshed" => {
101                Some(AgentSidebandEventCategory::ToolSearch)
102            }
103            "approval_requested"
104            | "approval_resolved"
105            | "deferred_requested"
106            | "deferred_completed"
107            | "deferred_failed"
108            | "deferred_cancelled"
109            | "hitl_resolved"
110            | "hitl_decision_diagnostic" => Some(AgentSidebandEventCategory::Hitl),
111            "skills_scanned" | "skill_activated" | "skills_reloaded" => {
112                Some(AgentSidebandEventCategory::Skill)
113            }
114            "task_snapshot" => Some(AgentSidebandEventCategory::Task),
115            "usage_snapshot" => Some(AgentSidebandEventCategory::Usage),
116            "compact_start" | "compact_failed" | "compact_complete" => {
117                Some(AgentSidebandEventCategory::Compact)
118            }
119            "steering_received" | "steering_submitted" => {
120                Some(AgentSidebandEventCategory::Steering)
121            }
122            "goal_iteration" | "goal_complete" => Some(AgentSidebandEventCategory::Goal),
123            "background_shell_complete" => Some(AgentSidebandEventCategory::Background),
124            "message_received" => Some(AgentSidebandEventCategory::Message),
125            "subagent_started" | "subagent_completed" | "subagent_failed" => {
126                Some(AgentSidebandEventCategory::Subagent)
127            }
128            _ if kind.starts_with("model_") => Some(AgentSidebandEventCategory::Model),
129            _ if kind.starts_with("task_") => Some(AgentSidebandEventCategory::Task),
130            _ if kind.starts_with("note_") => Some(AgentSidebandEventCategory::Note),
131            _ if kind.starts_with("file_") => Some(AgentSidebandEventCategory::File),
132            _ if kind.starts_with("media_") => Some(AgentSidebandEventCategory::Media),
133            _ if kind.starts_with("host_") => Some(AgentSidebandEventCategory::HostEvent),
134            _ if kind.starts_with("tool_search_") => Some(AgentSidebandEventCategory::ToolSearch),
135            _ if kind.starts_with("tool_") || kind.starts_with("toolset_") => {
136                Some(AgentSidebandEventCategory::Tool)
137            }
138            _ if kind.starts_with("approval_")
139                || kind.starts_with("deferred_")
140                || kind.starts_with("hitl_") =>
141            {
142                Some(AgentSidebandEventCategory::Hitl)
143            }
144            _ if kind.starts_with("skill_") || kind.starts_with("skills_") => {
145                Some(AgentSidebandEventCategory::Skill)
146            }
147            _ if kind.starts_with("subagent_") => Some(AgentSidebandEventCategory::Subagent),
148            _ if kind.starts_with("message_") => Some(AgentSidebandEventCategory::Message),
149            _ if kind.starts_with("usage_") => Some(AgentSidebandEventCategory::Usage),
150            _ if kind.starts_with("compact_") => Some(AgentSidebandEventCategory::Compact),
151            _ if kind.starts_with("steering_") => Some(AgentSidebandEventCategory::Steering),
152            _ if kind.starts_with("goal_") => Some(AgentSidebandEventCategory::Goal),
153            _ if kind.starts_with("background_") => Some(AgentSidebandEventCategory::Background),
154            _ if kind.starts_with("capability_") => Some(AgentSidebandEventCategory::Capability),
155            _ => None,
156        }
157    }
158}
159
160/// Typed event emitted by the agent runtime while a run progresses.
161#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
162#[serde(tag = "kind", rename_all = "snake_case")]
163pub enum AgentStreamEvent {
164    /// A run started.
165    RunStart {
166        /// Run identifier.
167        run_id: RunId,
168        /// Conversation identifier.
169        conversation_id: ConversationId,
170    },
171    /// Runtime execution entered a durable node boundary.
172    NodeStart {
173        /// Execution boundary being entered.
174        node: AgentExecutionNode,
175        /// Completed run step at this boundary.
176        step: usize,
177        /// Current run status at this boundary.
178        status: RunStatus,
179    },
180    /// Runtime execution completed a durable node boundary.
181    NodeComplete {
182        /// Execution boundary that completed.
183        node: AgentExecutionNode,
184        /// Completed run step at this boundary.
185        step: usize,
186        /// Current run status after this boundary.
187        status: RunStatus,
188    },
189    /// A context sideband event was published during the run.
190    Custom {
191        /// Application or capability event.
192        event: AgentEvent,
193    },
194    /// A model request was prepared for a loop step.
195    ModelRequest {
196        /// Completed run step before sending the request.
197        step: usize,
198    },
199    /// A model response stream event was received.
200    ModelStream {
201        /// Completed run step for the active model request.
202        step: usize,
203        /// Canonical model stream event.
204        event: ModelResponseStreamEvent,
205    },
206    /// A model response was received.
207    ModelResponse {
208        /// Completed run step after receiving the response.
209        step: usize,
210        /// Canonical model response.
211        response: ModelResponse,
212    },
213    /// A durable execution checkpoint was persisted or inspected.
214    Checkpoint {
215        /// Execution boundary.
216        node: AgentExecutionNode,
217        /// Completed run step at this boundary.
218        step: usize,
219    },
220    /// Execution was suspended at a durable checkpoint.
221    Suspended {
222        /// Execution boundary that requested suspension.
223        node: AgentExecutionNode,
224        /// Human-readable suspend reason.
225        reason: String,
226    },
227    /// A model requested a function tool call.
228    ToolCall {
229        /// Current run step.
230        step: usize,
231        /// Tool call part.
232        call: ToolCallPart,
233    },
234    /// A function tool returned a result or structured control-flow error.
235    ToolReturn {
236        /// Current run step.
237        step: usize,
238        /// Tool return part.
239        tool_return: ToolReturnPart,
240    },
241    /// Output validation or output function validation requested another model turn.
242    OutputRetry {
243        /// Retry count after this retry was scheduled.
244        retries: usize,
245        /// Retry prompt sent to the model.
246        prompt: String,
247    },
248    /// Pending user steering requested another model turn before finalization.
249    SteeringGuard {
250        /// Current run step.
251        step: usize,
252        /// Control prompt sent to the model before finalization.
253        prompt: String,
254    },
255    /// A run completed successfully.
256    RunComplete {
257        /// Run identifier.
258        run_id: RunId,
259        /// Final output text.
260        output: String,
261    },
262    /// A run was cooperatively cancelled after preserving recoverable context state.
263    RunCancelled {
264        /// Run identifier.
265        run_id: RunId,
266        /// Human-readable cancellation reason.
267        reason: String,
268    },
269    /// A run failed after preserving recoverable context state.
270    RunFailed {
271        /// Run identifier.
272        run_id: RunId,
273        /// Failure kind.
274        error_kind: String,
275        /// Human-readable error message.
276        message: String,
277    },
278}
279
280impl AgentStreamEvent {
281    /// Return a typed sideband event view for known context events carried by `Custom`.
282    #[must_use]
283    pub fn sideband_event(&self) -> Option<AgentSidebandEvent> {
284        match self {
285            Self::Custom { event } => AgentSidebandEvent::from_agent_event(event),
286            _ => None,
287        }
288    }
289}
290
291/// Origin type for a stream record emitted by a nested runtime component.
292#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
293#[serde(rename_all = "snake_case")]
294pub enum AgentStreamSourceKind {
295    /// Record emitted by a delegated subagent run.
296    Subagent,
297}
298
299/// Source attribution for records merged from nested agent runs.
300#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
301pub struct AgentStreamSource {
302    /// Source category.
303    pub kind: AgentStreamSourceKind,
304    /// Source agent identifier.
305    pub agent_id: AgentId,
306    /// Human-readable source agent name.
307    pub agent_name: String,
308    /// Delegated task identifier when the source is task scoped.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub task_id: Option<TaskId>,
311    /// Source run identifier when known.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub run_id: Option<RunId>,
314    /// Parent run identifier when known.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub parent_run_id: Option<RunId>,
317    /// Original sequence number in the source run before parent stream rebasing.
318    pub source_sequence: usize,
319}
320
321impl AgentStreamSource {
322    /// Build subagent source attribution.
323    #[must_use]
324    pub fn subagent(
325        agent_id: AgentId,
326        agent_name: impl Into<String>,
327        task_id: TaskId,
328        run_id: Option<RunId>,
329        parent_run_id: Option<RunId>,
330        source_sequence: usize,
331    ) -> Self {
332        Self {
333            kind: AgentStreamSourceKind::Subagent,
334            agent_id,
335            agent_name: agent_name.into(),
336            task_id: Some(task_id),
337            run_id,
338            parent_run_id,
339            source_sequence,
340        }
341    }
342}
343
344/// In-memory sink used by tools that need to merge child stream records into the parent stream.
345#[derive(Clone, Debug, Default)]
346pub struct AgentStreamSink {
347    records: Arc<Mutex<Vec<AgentStreamRecord>>>,
348}
349
350impl AgentStreamSink {
351    /// Add one record to the sink.
352    pub fn push(&self, record: AgentStreamRecord) {
353        self.records
354            .lock()
355            .unwrap_or_else(PoisonError::into_inner)
356            .push(record);
357    }
358
359    /// Add several records to the sink.
360    pub fn extend(&self, records: impl IntoIterator<Item = AgentStreamRecord>) {
361        self.records
362            .lock()
363            .unwrap_or_else(PoisonError::into_inner)
364            .extend(records);
365    }
366
367    /// Drain all currently buffered records.
368    #[must_use]
369    pub fn drain(&self) -> Vec<AgentStreamRecord> {
370        self.records
371            .lock()
372            .unwrap_or_else(PoisonError::into_inner)
373            .drain(..)
374            .collect()
375    }
376
377    /// Return whether no records are buffered.
378    #[must_use]
379    pub fn is_empty(&self) -> bool {
380        self.records
381            .lock()
382            .unwrap_or_else(PoisonError::into_inner)
383            .is_empty()
384    }
385}
386
387/// Sequenced stream event record.
388#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
389pub struct AgentStreamRecord {
390    /// Monotonic event sequence number within one run.
391    pub sequence: usize,
392    /// Source attribution for records merged from nested runs.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub source: Option<AgentStreamSource>,
395    /// Typed event payload.
396    pub event: AgentStreamEvent,
397}
398
399impl starweaver_core::VersionedRecord for AgentStreamRecord {
400    const SCHEMA: &'static str = "starweaver.runtime.stream_record";
401    const ALLOW_BARE_V0: bool = true;
402}
403
404impl AgentStreamRecord {
405    /// Create a sequenced stream record.
406    #[must_use]
407    pub const fn new(sequence: usize, event: AgentStreamEvent) -> Self {
408        Self {
409            sequence,
410            source: None,
411            event,
412        }
413    }
414
415    /// Returns true when this is a steering event emitted by a delegated subagent.
416    ///
417    /// Raw records retain these events for durable replay and trace evidence. Display projections
418    /// use source attribution to keep child steering internals out of user-facing streams without
419    /// discarding the canonical record. Main-agent steering remains visible.
420    #[must_use]
421    pub fn is_subagent_steering_event(&self) -> bool {
422        if !matches!(
423            self.source.as_ref().map(|source| &source.kind),
424            Some(AgentStreamSourceKind::Subagent)
425        ) {
426            return false;
427        }
428        match &self.event {
429            AgentStreamEvent::SteeringGuard { .. } => true,
430            AgentStreamEvent::Custom { event } => {
431                let normalized = event.kind.to_ascii_lowercase().replace(['.', '-'], "_");
432                [
433                    "steering_submitted",
434                    "steer_submitted",
435                    "steering_received",
436                    "steer_received",
437                    "steering_ack",
438                    "steer_ack",
439                ]
440                .iter()
441                .any(|candidate| {
442                    normalized == *candidate || normalized.ends_with(&format!("_{candidate}"))
443                })
444            }
445            _ => false,
446        }
447    }
448
449    /// Attach source attribution.
450    #[must_use]
451    pub fn with_source(mut self, source: AgentStreamSource) -> Self {
452        self.source = Some(source);
453        self
454    }
455
456    /// Replace the parent stream sequence after merging into another stream.
457    #[must_use]
458    pub const fn with_sequence(mut self, sequence: usize) -> Self {
459        self.sequence = sequence;
460        self
461    }
462
463    /// Project this raw runtime stream record into its JSON representation.
464    ///
465    /// # Errors
466    ///
467    /// Returns a serialization error if a nested event payload cannot be encoded.
468    pub fn to_raw_json(&self) -> serde_json::Result<serde_json::Value> {
469        serde_json::to_value(self)
470    }
471}