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
55pub(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#[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#[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 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 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}