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