Skip to main content

lash_sansio/
mode.rs

1//! Mode-agnostic types and helpers used by protocol drivers. The
2//! concrete mode drivers and their prompts live in
3//! the mode crates crates; this module only
4//! exposes the shared surface:
5//!
6//! - [`ModeConfig`], [`ModePreamble`], [`ModeBuildInput`] — the
7//!   per-turn configuration driver-plugins populate.
8//! - A small helper layer (`normalized_response_parts`, `reasoning_part`,
9//!   `append_assistant_text_part`, `turn_limit_exhausted_message`) that
10//!   mode drivers reuse for building assistant
11//!   messages.
12
13use std::sync::Arc;
14
15use crate::PromptFingerprint;
16use crate::llm::types::{LlmOutputPart, LlmResponse, LlmToolSpec, ProviderReasoningReplay};
17use crate::sansio::{
18    ChatContextProjector, ContextProjector, ModeProtocol, ProtocolDriverHandle, UnitModeProtocol,
19};
20use crate::session_model::{Message, MessageRole, Part, PartKind, PruneState, shared_parts};
21use crate::{ExecutionMode, PromptContribution, ToolSurface};
22
23#[derive(Clone)]
24pub struct ModeConfig<M: ModeProtocol = UnitModeProtocol> {
25    pub protocol: Arc<dyn ProtocolDriverHandle<M>>,
26    pub projector: Arc<dyn ContextProjector<M>>,
27    pub sync_execution_surface: bool,
28}
29
30impl<M: ModeProtocol> ModeConfig<M> {
31    pub fn chat(protocol: Arc<dyn ProtocolDriverHandle<M>>, sync_execution_surface: bool) -> Self {
32        Self {
33            protocol,
34            projector: Arc::new(ChatContextProjector),
35            sync_execution_surface,
36        }
37    }
38}
39
40#[derive(Clone)]
41pub struct ModePreamble<M: ModeProtocol = UnitModeProtocol> {
42    pub config: ModeConfig<M>,
43    pub tool_specs: Arc<Vec<LlmToolSpec>>,
44    pub tool_names: Arc<Vec<String>>,
45    pub tool_names_fingerprint: PromptFingerprint,
46    pub omitted_tool_count: usize,
47    pub execution_prompt: Arc<str>,
48    pub prompt_contributions: Vec<PromptContribution>,
49}
50
51#[derive(Clone, Debug)]
52pub struct ModeBuildInput {
53    pub mode: ExecutionMode,
54    pub tool_surface: std::sync::Arc<ToolSurface>,
55    pub extra_prompt_contributions: Vec<PromptContribution>,
56}
57
58/// Convert a raw `LlmResponse` into a stream of `LlmOutputPart`s that
59/// downstream code can iterate. When the response only carries
60/// `full_text` (provider didn't populate `parts`), synthesize a single
61/// `Text` part.
62pub fn normalized_response_parts(llm_response: &LlmResponse) -> Vec<LlmOutputPart> {
63    if llm_response.parts.is_empty() && !llm_response.full_text.is_empty() {
64        vec![LlmOutputPart::Text {
65            text: llm_response.full_text.clone(),
66            response_meta: None,
67        }]
68    } else {
69        llm_response.parts.clone()
70    }
71}
72
73/// Build a Reasoning `Part` from a reasoning item. `meta` is Some when
74/// the item carries provider replay metadata; None for display-only
75/// summaries.
76pub fn reasoning_part(
77    asst_id: &str,
78    index: usize,
79    text: String,
80    meta: Option<ProviderReasoningReplay>,
81) -> Part {
82    Part {
83        id: format!("{asst_id}.p{index}"),
84        kind: PartKind::Reasoning,
85        content: text,
86        attachment: None,
87        tool_call_id: None,
88        tool_name: None,
89        tool_replay: None,
90        prune_state: PruneState::Intact,
91        reasoning_meta: meta,
92        response_meta: None,
93    }
94}
95
96/// Append a streamed text part to the running assistant text, inserting
97/// the right number of blank lines so consecutive parts don't glue
98/// together.
99pub fn append_assistant_text_part(out: &mut String, next: &str) {
100    if out.is_empty() {
101        out.push_str(next);
102        return;
103    }
104
105    let prev_trailing_newlines = out.chars().rev().take_while(|ch| *ch == '\n').count();
106    let next_leading_newlines = next.chars().take_while(|ch| *ch == '\n').count();
107    let total_boundary_newlines = prev_trailing_newlines + next_leading_newlines;
108    if total_boundary_newlines < 2 {
109        out.push_str(&"\n".repeat(2 - total_boundary_newlines));
110    }
111
112    out.push_str(next);
113}
114
115/// System-level "turn limit reached" message that both protocol
116/// drivers append when their mode_iteration count exceeds `max_turns`.
117pub fn turn_limit_exhausted_message(max_turns: usize) -> Message {
118    let id = crate::session_model::fresh_message_id();
119    Message {
120        id: id.clone(),
121        role: MessageRole::System,
122        parts: shared_parts(vec![Part {
123            id: format!("{id}.p0"),
124            kind: PartKind::Error,
125            content: format!("Turn limit reached ({max_turns}) before a final assistant response."),
126            attachment: None,
127            tool_call_id: None,
128            tool_name: None,
129            tool_replay: None,
130            prune_state: PruneState::Intact,
131            reasoning_meta: None,
132            response_meta: None,
133        }]),
134        origin: None,
135    }
136}