Skip to main content

lash_sansio/session_model/
mod.rs

1pub mod message;
2pub mod prompt;
3
4pub use message::{
5    BaseRenderCache, Message, MessageRole, MessageSequence, Part, PartAttachment, PartKind,
6    PruneState, RenderedPrompt, append_rendered_prompt, messages_are_prompt_resume_safe,
7    render_prompt, render_transcript_prompt, shared_parts,
8};
9pub use prompt::{
10    MAIN_AGENT_INTRO, PromptBuiltin, PromptLayer, PromptSlot, PromptSlotLayer, PromptTemplate,
11    PromptTemplateEntry, PromptTemplateSection, ResolvedPromptLayer, default_prompt_template,
12    resolve_prompt_layers,
13};
14
15use std::sync::Arc;
16
17use crate::MessageOrigin;
18use crate::ToolDefinition;
19use crate::llm::types::LlmToolSpec;
20use crate::plugin::{CheckpointKind, PluginMessage, PluginRuntimeEvent};
21
22/// Durable protocol payload stored in session history.
23#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
24pub struct ProtocolEvent {
25    pub plugin_id: String,
26    pub payload: serde_json::Value,
27}
28
29impl ProtocolEvent {
30    pub fn typed<T>(plugin_id: impl Into<String>, event: T) -> Result<Self, serde_json::Error>
31    where
32        T: serde::Serialize,
33    {
34        Ok(Self {
35            plugin_id: plugin_id.into(),
36            payload: serde_json::to_value(event)?,
37        })
38    }
39
40    pub fn decode<T>(&self, expected_plugin_id: &str) -> Result<Option<T>, serde_json::Error>
41    where
42        T: for<'de> serde::Deserialize<'de>,
43    {
44        if self.plugin_id != expected_plugin_id {
45            return Ok(None);
46        }
47        serde_json::from_value(self.payload.clone()).map(Some)
48    }
49}
50
51/// Typed node accepted at session-graph append boundaries.
52#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
53#[serde(tag = "kind", rename_all = "snake_case")]
54#[allow(clippy::large_enum_variant)]
55pub enum SessionAppendNode {
56    Message {
57        message: PluginMessage,
58        #[serde(default, skip_serializing_if = "Option::is_none")]
59        caused_by: Option<crate::CausalRef>,
60    },
61    ProtocolEvent {
62        event: ProtocolEvent,
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        caused_by: Option<crate::CausalRef>,
65    },
66    Plugin {
67        plugin_type: String,
68        #[serde(default)]
69        body: serde_json::Value,
70        #[serde(default, skip_serializing_if = "Option::is_none")]
71        caused_by: Option<crate::CausalRef>,
72    },
73}
74
75impl SessionAppendNode {
76    pub fn message(message: PluginMessage) -> Self {
77        Self::Message {
78            message,
79            caused_by: None,
80        }
81    }
82
83    pub fn plugin(plugin_type: impl Into<String>, body: serde_json::Value) -> Self {
84        Self::Plugin {
85            plugin_type: plugin_type.into(),
86            body,
87            caused_by: None,
88        }
89    }
90
91    pub fn protocol_event(event: ProtocolEvent) -> Self {
92        Self::ProtocolEvent {
93            event,
94            caused_by: None,
95        }
96    }
97
98    pub fn with_caused_by(mut self, caused_by: crate::CausalRef) -> Self {
99        match &mut self {
100            Self::Message {
101                caused_by: cause, ..
102            }
103            | Self::ProtocolEvent {
104                caused_by: cause, ..
105            }
106            | Self::Plugin {
107                caused_by: cause, ..
108            } => *cause = Some(caused_by),
109        }
110        self
111    }
112}
113
114/// Durable semantic history stored in the session graph and replayed into
115/// future prompts. Unlike [`SessionStreamEvent`], these records are committed
116/// state rather than transient UI/progress signals.
117#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
118#[allow(clippy::large_enum_variant)]
119pub enum SessionHistoryRecord<PE = ()> {
120    Conversation(ConversationRecord),
121    Protocol(PE),
122}
123
124#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
125pub struct ConversationRecord {
126    pub id: String,
127    pub role: MessageRole,
128    pub parts: Arc<Vec<Part>>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub origin: Option<MessageOrigin>,
131}
132
133#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
134pub struct AcceptedInjectedTurnInput {
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub id: Option<String>,
137    pub message: PluginMessage,
138}
139
140impl ConversationRecord {
141    pub fn from_message(message: Message) -> Self {
142        Self {
143            id: message.id,
144            role: message.role,
145            parts: message.parts,
146            origin: message.origin,
147        }
148    }
149
150    pub fn to_message(&self) -> Message {
151        Message {
152            id: self.id.clone(),
153            role: self.role,
154            parts: Arc::clone(&self.parts),
155            origin: self.origin.clone(),
156        }
157    }
158}
159
160/// Token usage statistics from an LLM call.
161#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
162pub struct TokenUsage {
163    pub input_tokens: i64,
164    pub output_tokens: i64,
165    pub cache_read_input_tokens: i64,
166    pub cache_write_input_tokens: i64,
167    pub reasoning_output_tokens: i64,
168}
169
170impl TokenUsage {
171    pub fn total(&self) -> i64 {
172        self.input_tokens
173            + self.output_tokens
174            + self.cache_read_input_tokens
175            + self.cache_write_input_tokens
176    }
177
178    pub fn input_total(&self) -> i64 {
179        self.input_tokens + self.cache_read_input_tokens + self.cache_write_input_tokens
180    }
181
182    pub fn add(&mut self, other: &TokenUsage) {
183        self.input_tokens += other.input_tokens;
184        self.output_tokens += other.output_tokens;
185        self.cache_read_input_tokens += other.cache_read_input_tokens;
186        self.cache_write_input_tokens += other.cache_write_input_tokens;
187        self.reasoning_output_tokens += other.reasoning_output_tokens;
188    }
189}
190
191/// Structured error payload carried on [`SessionStreamEvent::Error`] (and
192/// [`SessionStreamEvent::RetryStatus`]).
193///
194/// Durability: this type appears inside persisted session snapshots and turn
195/// checkpoints, so every field added after the initial shape must stay
196/// additive — `#[serde(default)]` on decode and
197/// `#[serde(skip_serializing_if = "Option::is_none")]` on encode — to keep
198/// old snapshots decodable and new snapshots readable by older readers.
199/// Transient runtime-stream signal for live consumers.
200///
201/// These events may be partial, duplicated, or display-only and are not proof
202/// of durable session history. Persisted semantic history uses
203/// [`SessionHistoryRecord`].
204#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
205pub struct ErrorEnvelope {
206    pub kind: String,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub code: Option<String>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub terminal_reason: Option<crate::llm::types::LlmTerminalReason>,
211    pub user_message: String,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub raw: Option<String>,
214    /// Whether the failing operation is safe to retry, when the source
215    /// carries a typed signal (provider transports classify retryability).
216    /// `None` means the source did not know.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub retryable: Option<bool>,
219    /// Typed provider-failure classification, set only when the error came
220    /// from an LLM provider/transport failure whose kind was classified
221    /// (an unclassified `Unknown` kind is surfaced as `None`).
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub provider_failure_kind: Option<crate::llm::types::ProviderFailureKind>,
224}
225
226#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
227#[serde(tag = "type")]
228#[allow(clippy::large_enum_variant)]
229pub enum SessionStreamEvent {
230    #[serde(rename = "text_delta")]
231    TextDelta { content: String },
232    /// Streaming update for the model's reasoning summary ("thinking").
233    /// The UI renders these incrementally in a muted/italic style;
234    /// reasoning content is never fed back to the model on subsequent
235    /// turns.
236    #[serde(rename = "reasoning_delta")]
237    ReasoningDelta { content: String },
238    #[serde(rename = "tool_call")]
239    ToolCall {
240        #[serde(default, skip_serializing_if = "Option::is_none")]
241        call_id: Option<String>,
242        name: String,
243        args: serde_json::Value,
244        output: crate::ToolCallOutput,
245        duration_ms: u64,
246    },
247    #[serde(rename = "tool_call_start")]
248    ToolCallStart {
249        #[serde(default, skip_serializing_if = "Option::is_none")]
250        call_id: Option<String>,
251        name: String,
252        args: serde_json::Value,
253    },
254    #[serde(rename = "message")]
255    Message { text: String, kind: String },
256    #[serde(rename = "llm_request")]
257    LlmRequest {
258        protocol_iteration: usize,
259        message_count: usize,
260        tool_list: String,
261    },
262    #[serde(rename = "llm_response")]
263    LlmResponse {
264        protocol_iteration: usize,
265        content: String,
266        duration_ms: u64,
267    },
268    #[serde(rename = "token_usage")]
269    TokenUsage {
270        protocol_iteration: usize,
271        usage: TokenUsage,
272        cumulative: TokenUsage,
273    },
274    #[serde(rename = "child_token_usage")]
275    ChildTokenUsage {
276        session_id: String,
277        source: String,
278        model: String,
279        protocol_iteration: usize,
280        usage: TokenUsage,
281        cumulative: TokenUsage,
282    },
283    #[serde(rename = "retry_status")]
284    RetryStatus {
285        wait_seconds: u64,
286        attempt: usize,
287        max_attempts: usize,
288        reason: String,
289        #[serde(default, skip_serializing_if = "Option::is_none")]
290        envelope: Option<ErrorEnvelope>,
291    },
292    #[serde(rename = "injected_turn_input_accepted")]
293    InjectedTurnInputAccepted {
294        inputs: Vec<AcceptedInjectedTurnInput>,
295        checkpoint: CheckpointKind,
296    },
297    #[serde(rename = "injected_messages_committed")]
298    InjectedMessagesCommitted {
299        messages: Vec<PluginMessage>,
300        checkpoint: CheckpointKind,
301    },
302    #[serde(rename = "plugin_event")]
303    PluginEvent {
304        plugin_id: String,
305        event: PluginRuntimeEvent,
306    },
307    /// Semantic result for a completed turn. `Done` remains the machine
308    /// lifecycle marker emitted after this event.
309    #[serde(rename = "turn_outcome")]
310    TurnOutcome { outcome: TurnOutcome },
311    #[serde(rename = "done")]
312    Done,
313    #[serde(rename = "error")]
314    Error {
315        message: String,
316        #[serde(default, skip_serializing_if = "Option::is_none")]
317        envelope: Option<ErrorEnvelope>,
318    },
319}
320
321#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
322#[serde(rename_all = "snake_case")]
323pub enum TurnOutcome {
324    Finished(TurnFinish),
325    AgentFrameSwitch {
326        frame_id: String,
327        task: String,
328        #[serde(default, skip_serializing_if = "Vec::is_empty")]
329        initial_nodes: Vec<SessionAppendNode>,
330    },
331    Stopped(TurnStop),
332}
333
334#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
335#[serde(rename_all = "snake_case")]
336pub enum TurnFinish {
337    AssistantMessage {
338        text: String,
339    },
340    FinalValue {
341        value: serde_json::Value,
342    },
343    ToolValue {
344        tool_name: String,
345        value: serde_json::Value,
346    },
347}
348
349#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub enum TurnStop {
352    Cancelled,
353    Incomplete,
354    InvalidInput,
355    MaxTurns,
356    ToolFailure,
357    ProviderError,
358    PluginAbort,
359    RuntimeError,
360    SubmittedError {
361        value: serde_json::Value,
362    },
363    ToolError {
364        tool_name: String,
365        value: serde_json::Value,
366    },
367}
368
369#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
370pub struct TurnTerminationPolicyState {
371    turn_limit_final_scheduled: bool,
372}
373
374impl Default for TurnTerminationPolicyState {
375    fn default() -> Self {
376        Self::new()
377    }
378}
379
380impl TurnTerminationPolicyState {
381    pub fn new() -> Self {
382        Self {
383            turn_limit_final_scheduled: false,
384        }
385    }
386
387    pub fn should_force_exit_after_grace_turn(&self) -> bool {
388        self.turn_limit_final_scheduled
389    }
390
391    pub fn turn_limit_final_to_schedule(
392        &self,
393        protocol_iteration: usize,
394        protocol_run_offset: usize,
395        max_turns: Option<usize>,
396    ) -> Option<usize> {
397        if self.turn_limit_final_scheduled {
398            return None;
399        }
400        let max = max_turns?;
401        if protocol_iteration < protocol_run_offset + max {
402            return None;
403        }
404        Some(max)
405    }
406
407    pub fn mark_turn_limit_final_scheduled(&mut self) {
408        self.turn_limit_final_scheduled = true;
409    }
410}
411
412pub fn make_error_envelope(
413    kind: &str,
414    code: Option<&str>,
415    terminal_reason: Option<crate::llm::types::LlmTerminalReason>,
416    user_message: impl Into<String>,
417    raw: Option<String>,
418) -> ErrorEnvelope {
419    let user_message = user_message.into();
420    ErrorEnvelope {
421        kind: kind.to_string(),
422        code: code.map(str::to_string),
423        terminal_reason,
424        user_message,
425        raw: raw.map(|s| truncate_raw_error(s.trim())),
426        retryable: None,
427        provider_failure_kind: None,
428    }
429}
430
431pub fn make_error_event(
432    kind: &str,
433    code: Option<&str>,
434    user_message: impl Into<String>,
435    raw: Option<String>,
436) -> SessionStreamEvent {
437    let user_message = user_message.into();
438    SessionStreamEvent::Error {
439        message: user_message.clone(),
440        envelope: Some(make_error_envelope(kind, code, None, user_message, raw)),
441    }
442}
443
444pub fn truncate_raw_error(s: &str) -> String {
445    const MAX_RAW: usize = 4000;
446    let raw_len = s.chars().count();
447    if raw_len <= MAX_RAW {
448        return s.to_string();
449    }
450    let keep = MAX_RAW / 2;
451    let head = s.chars().take(keep).collect::<String>();
452    let tail = s
453        .chars()
454        .rev()
455        .take(keep)
456        .collect::<Vec<_>>()
457        .into_iter()
458        .rev()
459        .collect::<String>();
460    let omitted = raw_len.saturating_sub(keep * 2);
461    format!("{head}\n\n... ({omitted} chars omitted) ...\n\n{tail}")
462}
463
464pub fn reassign_part_ids(message_id: &str, parts: &mut [Part]) {
465    for (idx, part) in parts.iter_mut().enumerate() {
466        part.id = format!("{message_id}.p{idx}");
467    }
468}
469
470pub fn model_tool_specs_iter<'a>(
471    tools: impl IntoIterator<Item = &'a ToolDefinition>,
472) -> Vec<LlmToolSpec> {
473    tools
474        .into_iter()
475        .map(|tool| {
476            let model_tool = tool.model_tool();
477            LlmToolSpec {
478                name: model_tool.name,
479                description: model_tool.description,
480                input_schema: model_tool.input_schema,
481                output_schema: model_tool.output_schema,
482            }
483        })
484        .collect()
485}
486
487pub fn model_tool_specs(tools: &[ToolDefinition]) -> Vec<LlmToolSpec> {
488    model_tool_specs_iter(tools.iter())
489}
490
491#[cfg(test)]
492mod tests {
493    use super::{ErrorEnvelope, SessionStreamEvent, TurnOutcome};
494    use crate::llm::types::{LlmTerminalReason, ProviderFailureKind};
495
496    // ─── ErrorEnvelope durable-snapshot compatibility ──────────────────
497    //
498    // `ErrorEnvelope` is persisted inside session snapshots and turn
499    // checkpoints. The retryability fields added after the initial shape
500    // must decode from legacy JSON (absent fields → `None`) and must not
501    // appear on the wire when unset, so old readers keep decoding new
502    // snapshots too.
503
504    #[test]
505    fn error_envelope_decodes_legacy_snapshot_without_retryability_fields() {
506        let legacy = r#"{
507            "kind":"llm_provider",
508            "code":"429",
509            "terminal_reason":"provider_error",
510            "user_message":"LLM error: rate limited",
511            "raw":"{\"error\":\"rate_limited\"}"
512        }"#;
513        let envelope: ErrorEnvelope = serde_json::from_str(legacy).expect("legacy envelope");
514        assert_eq!(envelope.kind, "llm_provider");
515        assert_eq!(envelope.retryable, None);
516        assert_eq!(envelope.provider_failure_kind, None);
517
518        // The legacy shape embedded in a persisted `SessionStreamEvent::Error`
519        // record decodes the same way.
520        let legacy_event = r#"{
521            "type":"error",
522            "message":"LLM error: rate limited",
523            "envelope":{"kind":"llm_provider","user_message":"LLM error: rate limited"}
524        }"#;
525        let event: SessionStreamEvent = serde_json::from_str(legacy_event).expect("legacy event");
526        match event {
527            SessionStreamEvent::Error { envelope, .. } => {
528                let envelope = envelope.expect("envelope");
529                assert_eq!(envelope.retryable, None);
530                assert_eq!(envelope.provider_failure_kind, None);
531            }
532            other => panic!("expected error event, got {other:?}"),
533        }
534    }
535
536    #[test]
537    fn error_envelope_roundtrips_retryability_fields() {
538        let envelope = ErrorEnvelope {
539            kind: "llm_provider".to_string(),
540            code: Some("429".to_string()),
541            terminal_reason: Some(LlmTerminalReason::ProviderError),
542            user_message: "LLM error: rate limited".to_string(),
543            raw: None,
544            retryable: Some(true),
545            provider_failure_kind: Some(ProviderFailureKind::Quota),
546        };
547        let json = serde_json::to_value(&envelope).expect("serialize envelope");
548        assert_eq!(json["retryable"], serde_json::json!(true));
549        assert_eq!(json["provider_failure_kind"], serde_json::json!("quota"));
550        let decoded: ErrorEnvelope = serde_json::from_value(json).expect("decode envelope");
551        assert_eq!(decoded.retryable, Some(true));
552        assert_eq!(
553            decoded.provider_failure_kind,
554            Some(ProviderFailureKind::Quota)
555        );
556    }
557
558    #[test]
559    fn error_envelope_omits_unset_retryability_fields_on_the_wire() {
560        let envelope = ErrorEnvelope {
561            kind: "plugin".to_string(),
562            code: Some("plugin_abort".to_string()),
563            terminal_reason: None,
564            user_message: "stopped".to_string(),
565            raw: None,
566            retryable: None,
567            provider_failure_kind: None,
568        };
569        let json = serde_json::to_value(&envelope).expect("serialize envelope");
570        let object = json.as_object().expect("object");
571        assert!(!object.contains_key("retryable"));
572        assert!(!object.contains_key("provider_failure_kind"));
573    }
574
575    #[test]
576    fn provider_failure_kind_decodes_unknown_future_codes() {
577        // Forward compatibility: a snapshot written by a newer runtime with a
578        // kind this build does not know decodes as `Unknown`.
579        let decoded: ProviderFailureKind =
580            serde_json::from_value(serde_json::json!("some_future_kind")).expect("future kind");
581        assert_eq!(decoded, ProviderFailureKind::Unknown);
582        for kind in [
583            ProviderFailureKind::Transport,
584            ProviderFailureKind::Timeout,
585            ProviderFailureKind::Http,
586            ProviderFailureKind::Stream,
587            ProviderFailureKind::Auth,
588            ProviderFailureKind::Validation,
589            ProviderFailureKind::Quota,
590            ProviderFailureKind::Unsupported,
591            ProviderFailureKind::Unknown,
592        ] {
593            let json = serde_json::to_value(kind).expect("serialize kind");
594            assert_eq!(json, serde_json::json!(kind.code()));
595            let round: ProviderFailureKind = serde_json::from_value(json).expect("decode kind");
596            assert_eq!(round, kind);
597        }
598    }
599
600    #[test]
601    fn agent_frame_switch_decodes_legacy_event_without_initial_nodes() {
602        let legacy = r#"{
603            "type":"turn_outcome",
604            "outcome":{
605                "agent_frame_switch":{
606                    "frame_id":"frame-2",
607                    "task":"continue"
608                }
609            }
610        }"#;
611        let event: SessionStreamEvent =
612            serde_json::from_str(legacy).expect("legacy frame switch event");
613        match event {
614            SessionStreamEvent::TurnOutcome {
615                outcome:
616                    TurnOutcome::AgentFrameSwitch {
617                        frame_id,
618                        task,
619                        initial_nodes,
620                    },
621            } => {
622                assert_eq!(frame_id, "frame-2");
623                assert_eq!(task, "continue");
624                assert!(initial_nodes.is_empty());
625            }
626            other => panic!("expected agent-frame switch event, got {other:?}"),
627        }
628    }
629}