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::collections::HashMap;
16use std::sync::Arc;
17
18use crate::ToolDefinition;
19use crate::llm::types::LlmToolSpec;
20use crate::plugin::{CheckpointKind, PluginMessage, PluginSurfaceEvent};
21use crate::{MessageOrigin, ToolCallRecord};
22
23#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
24#[allow(clippy::large_enum_variant)]
25pub enum SessionEventRecord<ME = ()> {
26    Conversation(ConversationRecord),
27    Tool(ToolEvent),
28    Mode(ME),
29    StateSnapshot(StateSnapshotEvent),
30}
31
32#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
33pub struct ConversationRecord {
34    pub id: String,
35    pub role: MessageRole,
36    pub parts: Arc<Vec<Part>>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub origin: Option<MessageOrigin>,
39}
40
41#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
42pub struct AcceptedInjectedTurnInput {
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub id: Option<String>,
45    pub message: PluginMessage,
46}
47
48impl ConversationRecord {
49    pub fn from_message(message: Message) -> Self {
50        Self {
51            id: message.id,
52            role: message.role,
53            parts: message.parts,
54            origin: message.origin,
55        }
56    }
57
58    pub fn to_message(&self) -> Message {
59        Message {
60            id: self.id.clone(),
61            role: self.role,
62            parts: Arc::clone(&self.parts),
63            origin: self.origin.clone(),
64        }
65    }
66}
67
68#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
69pub enum ToolEvent {
70    Invocation {
71        stable_key: String,
72        record: ToolCallRecord,
73    },
74}
75
76#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
77pub enum StateSnapshotEvent {
78    Lashlang {
79        version: u32,
80        data: String,
81        files: HashMap<String, String>,
82    },
83}
84
85/// Token usage statistics from an LLM call.
86#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
87pub struct TokenUsage {
88    pub input_tokens: i64,
89    pub output_tokens: i64,
90    pub cached_input_tokens: i64,
91    #[serde(default)]
92    pub reasoning_tokens: i64,
93}
94
95impl TokenUsage {
96    pub fn total(&self) -> i64 {
97        self.input_tokens + self.output_tokens + self.reasoning_tokens
98    }
99
100    pub fn add(&mut self, other: &TokenUsage) {
101        self.input_tokens += other.input_tokens;
102        self.output_tokens += other.output_tokens;
103        self.cached_input_tokens += other.cached_input_tokens;
104        self.reasoning_tokens += other.reasoning_tokens;
105    }
106}
107
108#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
109pub struct ErrorEnvelope {
110    pub kind: String,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub code: Option<String>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub terminal_reason: Option<crate::llm::types::LlmTerminalReason>,
115    pub user_message: String,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub raw: Option<String>,
118}
119
120#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
121#[serde(tag = "type")]
122#[allow(clippy::large_enum_variant)]
123pub enum SessionEvent {
124    #[serde(rename = "text_delta")]
125    TextDelta { content: String },
126    /// Streaming update for the model's reasoning summary ("thinking").
127    /// The UI renders these incrementally in a muted/italic style;
128    /// reasoning content is never fed back to the model on subsequent
129    /// turns.
130    #[serde(rename = "reasoning_delta")]
131    ReasoningDelta { content: String },
132    #[serde(rename = "tool_call")]
133    ToolCall {
134        #[serde(default, skip_serializing_if = "Option::is_none")]
135        call_id: Option<String>,
136        name: String,
137        args: serde_json::Value,
138        output: crate::ToolCallOutput,
139        duration_ms: u64,
140    },
141    #[serde(rename = "tool_call_start")]
142    ToolCallStart {
143        #[serde(default, skip_serializing_if = "Option::is_none")]
144        call_id: Option<String>,
145        name: String,
146        args: serde_json::Value,
147    },
148    #[serde(rename = "message")]
149    Message { text: String, kind: String },
150    #[serde(rename = "llm_request")]
151    LlmRequest {
152        mode_iteration: usize,
153        message_count: usize,
154        tool_list: String,
155    },
156    #[serde(rename = "llm_response")]
157    LlmResponse {
158        mode_iteration: usize,
159        content: String,
160        duration_ms: u64,
161    },
162    #[serde(rename = "token_usage")]
163    TokenUsage {
164        mode_iteration: usize,
165        usage: TokenUsage,
166        cumulative: TokenUsage,
167    },
168    #[serde(rename = "child_token_usage")]
169    ChildTokenUsage {
170        session_id: String,
171        source: String,
172        model: String,
173        mode_iteration: usize,
174        usage: TokenUsage,
175        cumulative: TokenUsage,
176    },
177    #[serde(rename = "retry_status")]
178    RetryStatus {
179        wait_seconds: u64,
180        attempt: usize,
181        max_attempts: usize,
182        reason: String,
183        #[serde(default, skip_serializing_if = "Option::is_none")]
184        envelope: Option<ErrorEnvelope>,
185    },
186    #[serde(rename = "injected_turn_input_accepted")]
187    InjectedTurnInputAccepted {
188        inputs: Vec<AcceptedInjectedTurnInput>,
189        checkpoint: CheckpointKind,
190    },
191    #[serde(rename = "injected_messages_committed")]
192    InjectedMessagesCommitted {
193        messages: Vec<PluginMessage>,
194        checkpoint: CheckpointKind,
195    },
196    #[serde(rename = "plugin_event")]
197    PluginEvent {
198        plugin_id: String,
199        event: PluginSurfaceEvent,
200    },
201    /// Semantic result for a completed turn. `Done` remains the machine
202    /// lifecycle marker emitted after this event.
203    #[serde(rename = "turn_outcome")]
204    TurnOutcome { outcome: TurnOutcome },
205    #[serde(rename = "done")]
206    Done,
207    #[serde(rename = "error")]
208    Error {
209        message: String,
210        #[serde(default, skip_serializing_if = "Option::is_none")]
211        envelope: Option<ErrorEnvelope>,
212    },
213}
214
215#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum TurnOutcome {
218    Finished(TurnFinish),
219    Handoff { session_id: String },
220    Stopped(TurnStop),
221}
222
223#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
224#[serde(rename_all = "snake_case")]
225pub enum TurnFinish {
226    AssistantMessage {
227        text: String,
228    },
229    SubmittedValue {
230        value: serde_json::Value,
231    },
232    ToolValue {
233        tool_name: String,
234        value: serde_json::Value,
235    },
236}
237
238#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum TurnStop {
241    Cancelled,
242    Incomplete,
243    InvalidInput,
244    MaxTurns,
245    ToolFailure,
246    ProviderError,
247    PluginAbort,
248    RuntimeError,
249    SubmittedError {
250        value: serde_json::Value,
251    },
252    ToolError {
253        tool_name: String,
254        value: serde_json::Value,
255    },
256}
257
258#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
259pub struct PromptRequest {
260    pub question: String,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub panel: Option<PromptPanel>,
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub options: Vec<String>,
265    #[serde(default)]
266    pub selection_mode: PromptSelectionMode,
267    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
268    pub allow_note: bool,
269}
270
271#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
272pub struct PromptPanel {
273    pub title: String,
274    pub markdown: String,
275}
276
277impl PromptRequest {
278    pub fn freeform(question: impl Into<String>) -> Self {
279        Self {
280            question: question.into(),
281            panel: None,
282            options: Vec::new(),
283            selection_mode: PromptSelectionMode::Single,
284            allow_note: false,
285        }
286    }
287
288    pub fn single(question: impl Into<String>, options: Vec<String>) -> Self {
289        Self {
290            question: question.into(),
291            panel: None,
292            options,
293            selection_mode: PromptSelectionMode::Single,
294            allow_note: false,
295        }
296    }
297
298    pub fn multi(question: impl Into<String>, options: Vec<String>) -> Self {
299        Self {
300            question: question.into(),
301            panel: None,
302            options,
303            selection_mode: PromptSelectionMode::Multi,
304            allow_note: false,
305        }
306    }
307
308    pub fn with_optional_note(mut self) -> Self {
309        self.allow_note = !self.is_freeform();
310        self
311    }
312
313    pub fn with_markdown_panel(
314        mut self,
315        title: impl Into<String>,
316        markdown: impl Into<String>,
317    ) -> Self {
318        self.panel = Some(PromptPanel {
319            title: title.into(),
320            markdown: markdown.into(),
321        });
322        self
323    }
324
325    pub fn is_freeform(&self) -> bool {
326        self.options.is_empty()
327    }
328
329    pub fn allows_note(&self) -> bool {
330        self.allow_note && !self.is_freeform()
331    }
332
333    pub fn empty_response(&self) -> PromptResponse {
334        if self.is_freeform() {
335            PromptResponse::Text {
336                text: String::new(),
337            }
338        } else {
339            match self.selection_mode {
340                PromptSelectionMode::Single => PromptResponse::Single {
341                    selection: String::new(),
342                    note: None,
343                },
344                PromptSelectionMode::Multi => PromptResponse::Multi {
345                    selections: Vec::new(),
346                    note: None,
347                },
348            }
349        }
350    }
351}
352
353#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
354#[serde(rename_all = "snake_case")]
355pub enum PromptSelectionMode {
356    #[default]
357    Single,
358    Multi,
359}
360
361#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
362#[serde(tag = "kind", rename_all = "snake_case")]
363pub enum PromptResponse {
364    Text {
365        text: String,
366    },
367    Single {
368        selection: String,
369        #[serde(default, skip_serializing_if = "Option::is_none")]
370        note: Option<String>,
371    },
372    Multi {
373        selections: Vec<String>,
374        #[serde(default, skip_serializing_if = "Option::is_none")]
375        note: Option<String>,
376    },
377}
378
379#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
380pub struct TurnTerminationPolicyState {
381    max_steps_final: bool,
382}
383
384impl Default for TurnTerminationPolicyState {
385    fn default() -> Self {
386        Self::new()
387    }
388}
389
390impl TurnTerminationPolicyState {
391    pub fn new() -> Self {
392        Self {
393            max_steps_final: false,
394        }
395    }
396
397    pub fn should_force_exit_after_grace_turn(&self) -> bool {
398        self.max_steps_final
399    }
400
401    pub fn maybe_schedule_turn_limit_final(
402        &mut self,
403        mode_iteration: usize,
404        mode_run_offset: usize,
405        max_turns: Option<usize>,
406        msgs: &mut Vec<Message>,
407    ) {
408        let Some(max) = max_turns else { return };
409        if mode_iteration < mode_run_offset + max {
410            return;
411        }
412        let sys_id = fresh_message_id();
413        msgs.push(Message {
414            id: sys_id.clone(),
415            role: MessageRole::System,
416            parts: Arc::new(vec![Part {
417                id: format!("{}.p0", sys_id),
418                kind: PartKind::Text,
419                content: format!(
420                    "Turn limit reached ({max}). You MUST reply in plain prose now containing:\n\
421                        1. Summary of what you accomplished\n\
422                        2. List of remaining tasks not yet completed\n\
423                        3. Recommended next steps\n\
424                        Do NOT make any more tool calls and do NOT emit a mode-specific tag."
425                ),
426                attachment: None,
427                tool_call_id: None,
428                tool_name: None,
429                tool_replay: None,
430                prune_state: PruneState::Intact,
431                reasoning_meta: None,
432                response_meta: None,
433            }]),
434            origin: None,
435        });
436        self.max_steps_final = true;
437    }
438}
439
440pub fn make_error_envelope(
441    kind: &str,
442    code: Option<&str>,
443    terminal_reason: Option<crate::llm::types::LlmTerminalReason>,
444    user_message: impl Into<String>,
445    raw: Option<String>,
446) -> ErrorEnvelope {
447    let user_message = user_message.into();
448    ErrorEnvelope {
449        kind: kind.to_string(),
450        code: code.map(str::to_string),
451        terminal_reason,
452        user_message,
453        raw: raw.map(|s| truncate_raw_error(s.trim())),
454    }
455}
456
457pub fn make_error_event(
458    kind: &str,
459    code: Option<&str>,
460    user_message: impl Into<String>,
461    raw: Option<String>,
462) -> SessionEvent {
463    let user_message = user_message.into();
464    SessionEvent::Error {
465        message: user_message.clone(),
466        envelope: Some(make_error_envelope(kind, code, None, user_message, raw)),
467    }
468}
469
470pub fn truncate_raw_error(s: &str) -> String {
471    const MAX_RAW: usize = 4000;
472    if s.len() <= MAX_RAW {
473        return s.to_string();
474    }
475    let keep = MAX_RAW / 2;
476    let omitted = s.len() - MAX_RAW;
477    format!(
478        "{}\n\n... ({omitted} chars omitted) ...\n\n{}",
479        &s[..keep],
480        &s[s.len() - keep..]
481    )
482}
483
484pub fn format_tool_result_content(success: bool, result: &serde_json::Value) -> String {
485    if success {
486        match result {
487            serde_json::Value::String(text) => text.clone(),
488            other => serde_json::to_string(other).unwrap_or_else(|_| "null".to_string()),
489        }
490    } else {
491        match result {
492            serde_json::Value::String(text) => {
493                if text.is_empty() {
494                    "[Tool execution failed]".to_string()
495                } else if text.starts_with("[Tool execution failed]") {
496                    text.clone()
497                } else {
498                    format!("[Tool execution failed]\n{text}")
499                }
500            }
501            other => serde_json::to_string(&serde_json::json!({ "error": other }))
502                .unwrap_or_else(|_| "{\"error\":\"tool execution failed\"}".to_string()),
503        }
504    }
505}
506
507pub fn format_tool_output_content(output: &crate::ToolCallOutput) -> String {
508    match &output.outcome {
509        crate::ToolCallOutcome::Success(value) => {
510            let value = value.to_json_value();
511            match value {
512                serde_json::Value::String(text) => text,
513                other => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
514            }
515        }
516        crate::ToolCallOutcome::Failure(failure) => {
517            if failure.message.is_empty() {
518                "[Tool execution failed]".to_string()
519            } else {
520                format!("[Tool execution failed]\n{}", failure.message)
521            }
522        }
523        crate::ToolCallOutcome::Cancelled(cancellation) => {
524            if cancellation.message.is_empty() {
525                "[Tool execution cancelled]".to_string()
526            } else {
527                format!("[Tool execution cancelled]\n{}", cancellation.message)
528            }
529        }
530    }
531}
532
533pub fn fresh_message_id() -> String {
534    format!("m{}", uuid::Uuid::new_v4().simple())
535}
536
537pub fn reassign_part_ids(message_id: &str, parts: &mut [Part]) {
538    for (idx, part) in parts.iter_mut().enumerate() {
539        part.id = format!("{message_id}.p{idx}");
540    }
541}
542
543pub fn model_tool_specs_iter<'a>(
544    tools: impl IntoIterator<Item = &'a ToolDefinition>,
545) -> Vec<LlmToolSpec> {
546    tools
547        .into_iter()
548        .map(|tool| {
549            let model_tool = tool.model_tool();
550            LlmToolSpec {
551                name: model_tool.name,
552                description: model_tool.description,
553                input_schema: model_tool.input_schema,
554                output_schema: model_tool.output_schema,
555                input_schema_projections: model_tool.input_schema_projections,
556                output_schema_projections: model_tool.output_schema_projections,
557            }
558        })
559        .collect()
560}
561
562pub fn model_tool_specs(tools: &[ToolDefinition]) -> Vec<LlmToolSpec> {
563    model_tool_specs_iter(tools.iter())
564}