Skip to main content

lash_core/session_model/
mod.rs

1pub mod context;
2pub use lash_sansio::session_model::message;
3pub use lash_sansio::session_model::prompt;
4
5use std::sync::Arc;
6use tokio::sync::mpsc;
7
8use crate::ModelSpec;
9use crate::llm::types::{LlmEventSender, LlmStreamEvent};
10use crate::plugin::PluginMessage;
11use crate::provider::{ProviderBinding, ProviderHandle, ProviderResolutionError};
12
13pub use lash_sansio::format_tool_output_content;
14pub use lash_sansio::session_model::{
15    ConversationRecord, ErrorEnvelope, MAIN_AGENT_INTRO, Message, MessageRole, Part, PartKind,
16    PromptBuiltin, PromptSlot, PromptTemplate, PromptTemplateEntry, PromptTemplateSection,
17    ProtocolEvent, PruneState, SessionStreamEvent, TokenUsage, TurnTerminationPolicyState,
18    default_prompt_template, make_error_envelope, make_error_event, reassign_part_ids,
19    render_prompt, render_transcript_prompt, shared_parts,
20};
21
22pub fn fresh_message_id() -> String {
23    format!("m{}", uuid::Uuid::new_v4().simple())
24}
25
26pub type SessionHistoryRecord = lash_sansio::session_model::SessionHistoryRecord<ProtocolEvent>;
27
28pub const PLUGIN_RUNTIME_PROTOCOL_PLUGIN_ID: &str = "lash.plugin_runtime";
29
30#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
31pub struct PersistedPluginRuntimeEvent {
32    pub plugin_id: String,
33    pub event: crate::PluginRuntimeEvent,
34}
35
36pub fn plugin_runtime_protocol_event(
37    plugin_id: impl Into<String>,
38    event: crate::PluginRuntimeEvent,
39) -> Result<ProtocolEvent, serde_json::Error> {
40    ProtocolEvent::typed(
41        PLUGIN_RUNTIME_PROTOCOL_PLUGIN_ID,
42        PersistedPluginRuntimeEvent {
43            plugin_id: plugin_id.into(),
44            event,
45        },
46    )
47}
48
49pub fn plugin_runtime_event_from_protocol(
50    event: &ProtocolEvent,
51) -> Result<Option<PersistedPluginRuntimeEvent>, serde_json::Error> {
52    event.decode(PLUGIN_RUNTIME_PROTOCOL_PLUGIN_ID)
53}
54
55/// Send an event to the channel if it's still open.
56pub(crate) async fn send_event(tx: &mpsc::Sender<SessionStreamEvent>, event: SessionStreamEvent) {
57    if !tx.is_closed() {
58        let _ = tx.send(event).await;
59    }
60}
61
62pub(crate) fn plugin_message_to_message(plugin_message: &PluginMessage) -> Message {
63    let message_id = fresh_message_id();
64    let mut parts = if plugin_message.parts.is_empty() {
65        vec![Part {
66            id: format!("{message_id}.p0"),
67            kind: PartKind::Text,
68            content: plugin_message.content.clone(),
69            attachment: None,
70            tool_call_id: None,
71            tool_name: None,
72            tool_replay: None,
73            prune_state: PruneState::Intact,
74            reasoning_meta: None,
75            response_meta: None,
76        }]
77    } else {
78        plugin_message.parts.clone()
79    };
80    reassign_part_ids(&message_id, &mut parts);
81    Message {
82        id: message_id,
83        role: plugin_message.role,
84        parts: Arc::new(parts),
85        origin: plugin_message.origin.clone().or_else(|| {
86            Some(crate::MessageOrigin::Plugin {
87                plugin_id: "plugin".to_string(),
88                transient: false,
89            })
90        }),
91    }
92}
93
94#[derive(Clone, Debug, Default, PartialEq, Eq)]
95pub struct SessionPolicy {
96    pub model: ModelSpec,
97    pub provider_id: String,
98    pub session_id: Option<String>,
99    pub autonomous: bool,
100    pub max_turns: Option<usize>,
101    pub prompt: crate::PromptLayer,
102}
103
104impl SessionPolicy {
105    pub fn recorded_provider_id(&self) -> &str {
106        self.provider_id.trim()
107    }
108
109    pub fn model_id(&self) -> &str {
110        &self.model.id
111    }
112
113    pub fn model_variant(&self) -> &crate::ReasoningSelection {
114        &self.model.variant
115    }
116
117    pub fn context_window_tokens(&self) -> usize {
118        self.model.context_window_tokens()
119    }
120}
121
122impl serde::Serialize for SessionPolicy {
123    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
124    where
125        S: serde::Serializer,
126    {
127        use serde::ser::SerializeStruct;
128
129        let mut fields = 5;
130        if !self.prompt.is_empty() {
131            fields += 1;
132        }
133        let mut state = serializer.serialize_struct("SessionPolicy", fields)?;
134        state.serialize_field("model", &self.model)?;
135        state.serialize_field("provider_id", self.recorded_provider_id())?;
136        state.serialize_field("session_id", &self.session_id)?;
137        state.serialize_field("autonomous", &self.autonomous)?;
138        state.serialize_field("max_turns", &self.max_turns)?;
139        if !self.prompt.is_empty() {
140            state.serialize_field("prompt", &self.prompt)?;
141        }
142        state.end()
143    }
144}
145
146impl<'de> serde::Deserialize<'de> for SessionPolicy {
147    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
148    where
149        D: serde::Deserializer<'de>,
150    {
151        #[derive(serde::Deserialize)]
152        #[serde(deny_unknown_fields)]
153        struct Wire {
154            #[serde(default)]
155            model: ModelSpec,
156            #[serde(default)]
157            provider_id: String,
158            #[serde(default)]
159            session_id: Option<String>,
160            #[serde(default)]
161            autonomous: bool,
162            #[serde(default)]
163            max_turns: Option<usize>,
164            #[serde(default)]
165            prompt: crate::PromptLayer,
166        }
167
168        let value = serde_json::Value::deserialize(deserializer)?;
169        if value
170            .as_object()
171            .is_some_and(|object| object.contains_key("provider"))
172        {
173            return Err(serde::de::Error::custom(
174                "legacy serialized provider config is not supported in session state; persist provider_id only",
175            ));
176        }
177        let wire = Wire::deserialize(value).map_err(serde::de::Error::custom)?;
178        Ok(Self {
179            model: wire.model,
180            provider_id: wire.provider_id,
181            session_id: wire.session_id,
182            autonomous: wire.autonomous,
183            max_turns: wire.max_turns,
184            prompt: wire.prompt,
185        })
186    }
187}
188
189/// Runtime-only policy resolved against host-owned live dependencies.
190#[derive(Clone, Debug, Default, PartialEq, Eq)]
191pub struct RuntimeSessionPolicy {
192    pub policy: SessionPolicy,
193    pub binding: ProviderBinding,
194}
195
196impl RuntimeSessionPolicy {
197    pub fn new(policy: SessionPolicy, binding: ProviderBinding) -> Self {
198        Self { policy, binding }
199    }
200
201    pub fn from_provider(
202        policy: SessionPolicy,
203        provider: ProviderHandle,
204    ) -> Result<Self, ProviderResolutionError> {
205        let binding = ProviderBinding::new(policy.recorded_provider_id(), provider)?;
206        Ok(Self { policy, binding })
207    }
208
209    pub fn provider(&self) -> &ProviderHandle {
210        &self.binding.provider
211    }
212
213    pub fn into_policy(self) -> SessionPolicy {
214        self.policy
215    }
216}
217
218impl std::ops::Deref for RuntimeSessionPolicy {
219    type Target = SessionPolicy;
220
221    fn deref(&self) -> &Self::Target {
222        &self.policy
223    }
224}
225
226impl std::ops::DerefMut for RuntimeSessionPolicy {
227    fn deref_mut(&mut self) -> &mut Self::Target {
228        &mut self.policy
229    }
230}
231
232/// Reusable session configuration overlay.
233///
234/// `SessionSpec` is the public configuration shape for callers that want to
235/// describe either a root session or a child session without constructing the
236/// persisted [`SessionPolicy`] directly.
237#[derive(Clone, Debug, PartialEq, Eq)]
238pub struct SessionSpec {
239    inherit: bool,
240    pub provider_id: Option<String>,
241    pub model: Option<ModelSpec>,
242    pub max_turns: Option<Option<usize>>,
243    pub prompt: Option<crate::PromptLayer>,
244}
245
246impl SessionSpec {
247    /// Create an explicit root-style spec. Unset fields resolve from the
248    /// runtime's core defaults.
249    pub fn new() -> Self {
250        Self {
251            inherit: false,
252            provider_id: None,
253            model: None,
254            max_turns: None,
255            prompt: None,
256        }
257    }
258
259    /// Create a parent-relative spec. Unset fields inherit from the live
260    /// parent policy at resolution time.
261    pub fn inherit() -> Self {
262        Self {
263            inherit: true,
264            ..Self::new()
265        }
266    }
267
268    pub fn inherits(&self) -> bool {
269        self.inherit
270    }
271
272    pub fn provider_id(mut self, provider_id: impl Into<String>) -> Self {
273        self.provider_id = Some(provider_id.into());
274        self
275    }
276
277    pub fn model(mut self, model: ModelSpec) -> Self {
278        self.model = Some(model);
279        self
280    }
281
282    pub fn max_turns(mut self, max_turns: usize) -> Self {
283        self.max_turns = Some(Some(max_turns));
284        self
285    }
286
287    pub fn clear_max_turns(mut self) -> Self {
288        self.max_turns = Some(None);
289        self
290    }
291
292    pub fn prompt_layer(mut self, prompt: crate::PromptLayer) -> Self {
293        self.prompt = Some(prompt);
294        self
295    }
296
297    pub fn resolve_against(&self, base: &SessionPolicy) -> SessionPolicy {
298        let mut policy = base.clone();
299        if let Some(provider_id) = self.provider_id.as_ref() {
300            policy.provider_id = provider_id.clone();
301        }
302        if let Some(model) = self.model.as_ref() {
303            policy.model = model.clone();
304        }
305        if let Some(max_turns) = self.max_turns {
306            policy.max_turns = max_turns;
307        }
308        if let Some(prompt) = self.prompt.as_ref() {
309            policy.prompt = prompt.clone();
310        }
311        policy
312    }
313}
314
315impl Default for SessionSpec {
316    fn default() -> Self {
317        Self::new()
318    }
319}
320
321pub(crate) fn transport_stream_events(
322    provider: &ProviderHandle,
323    requested: Option<tokio::sync::mpsc::UnboundedSender<LlmStreamEvent>>,
324) -> Option<LlmEventSender> {
325    if let Some(requested) = requested {
326        return Some(make_stream_event_sender(requested));
327    }
328
329    if provider.requires_streaming() {
330        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<LlmStreamEvent>();
331        drop(rx);
332        Some(make_stream_event_sender(tx))
333    } else {
334        None
335    }
336}
337
338fn make_stream_event_sender(
339    tx: tokio::sync::mpsc::UnboundedSender<LlmStreamEvent>,
340) -> LlmEventSender {
341    LlmEventSender::new(move |event| {
342        let _ = tx.send(event);
343    })
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn protocol_event_writes_tagged_payload() {
352        let event = ProtocolEvent::typed("test_protocol", serde_json::json!({ "value": 42 }))
353            .expect("typed event");
354        let serialized = serde_json::to_value(event).expect("serialize");
355        assert_eq!(serialized["plugin_id"], "test_protocol");
356        assert!(serialized.get("payload").is_some());
357    }
358
359    #[test]
360    fn session_policy_rejects_legacy_provider_config() {
361        let err = serde_json::from_value::<SessionPolicy>(serde_json::json!({
362            "model": {},
363            "provider": {
364                "type": "openai",
365                "api_key": "must-not-load"
366            }
367        }))
368        .expect_err("legacy provider config must fail");
369
370        assert!(
371            err.to_string()
372                .contains("legacy serialized provider config is not supported")
373        );
374    }
375
376    #[test]
377    fn session_policy_serializes_provider_id_without_provider_handle() {
378        let policy = SessionPolicy {
379            provider_id: "mock-provider".to_string(),
380            model: ModelSpec::from_token_limits("mock-model", Default::default(), 200_000, None)
381                .expect("valid test model"),
382            ..SessionPolicy::default()
383        };
384
385        let value = serde_json::to_value(&policy).expect("serialize policy");
386
387        assert_eq!(value["provider_id"], "mock-provider");
388        assert!(value.get("provider").is_none());
389    }
390}