1#[cfg(test)]
2use super::logical_turn::agent_frame_follow_turn_id;
3use super::logical_turn::{
4 LogicalTurnClaims, LogicalTurnStart, PhysicalTurnExecution, PreparedLogicalTurn,
5};
6use super::*;
7
8fn trace_fields_from_outcome(
9 outcome: &TurnOutcome,
10) -> (
11 &'static str,
12 &'static str,
13 Option<lash_trace::TraceAgentFrameSwitch>,
14) {
15 match outcome {
16 TurnOutcome::Finished(TurnFinish::AssistantMessage { .. }) => {
17 ("completed", "assistant_message", None)
18 }
19 TurnOutcome::Finished(TurnFinish::FinalValue { .. }) => ("completed", "final_value", None),
20 TurnOutcome::Finished(TurnFinish::ToolValue { .. }) => ("completed", "tool_value", None),
21 TurnOutcome::AgentFrameSwitch { frame_id, .. } => (
22 "completed",
23 "agent_frame_switch",
24 Some(lash_trace::TraceAgentFrameSwitch {
25 frame_id: frame_id.clone(),
26 }),
27 ),
28 TurnOutcome::Stopped(stop) => ("failed", trace_stop_reason(stop), None),
29 }
30}
31
32fn trace_stop_reason(stop: &TurnStop) -> &'static str {
33 match stop {
34 TurnStop::Cancelled => "cancelled",
35 TurnStop::Incomplete => "incomplete",
36 TurnStop::InvalidInput => "invalid_input",
37 TurnStop::MaxTurns => "max_turns",
38 TurnStop::ToolFailure => "tool_failure",
39 TurnStop::ProviderError => "provider_error",
40 TurnStop::PluginAbort => "plugin_abort",
41 TurnStop::RuntimeError => "runtime_error",
42 TurnStop::SubmittedError { .. } => "submitted_error",
43 TurnStop::ToolError { .. } => "tool_error",
44 }
45}
46
47fn session_head_refresh_error(err: SessionError) -> RuntimeError {
48 RuntimeError::new(
49 RuntimeErrorCode::Other("session_head_refresh".to_string()),
50 err.to_string(),
51 )
52}
53
54#[derive(Clone, Copy)]
55pub(super) enum SessionExecutionLeaseReleasePolicy {
56 KeepOnAgentFrameSwitch,
57}
58
59impl SessionExecutionLeaseReleasePolicy {
60 fn should_release(self, outcome: &TurnOutcome) -> bool {
61 match self {
62 Self::KeepOnAgentFrameSwitch => {
63 !matches!(outcome, TurnOutcome::AgentFrameSwitch { .. })
64 }
65 }
66 }
67}
68
69fn queued_work_payload_type(payload: &crate::QueuedWorkPayload) -> &'static str {
70 match payload {
71 crate::QueuedWorkPayload::ProcessWake { .. } => "process_wake",
72 crate::QueuedWorkPayload::AgentFrameTask { .. } => "agent_frame_task",
73 crate::QueuedWorkPayload::SessionCommand { command } => command.kind(),
74 }
75}
76
77fn queued_work_batch_ids(claim: &crate::QueuedWorkClaim) -> Vec<String> {
78 claim
79 .batches
80 .iter()
81 .map(|batch| batch.batch_id.clone())
82 .collect()
83}
84
85#[derive(Clone, Copy)]
95pub(super) struct TurnStopwatch {
96 started: std::time::Instant,
97 started_at_ms: u64,
98}
99
100impl TurnStopwatch {
101 pub(super) fn start(clock: &dyn crate::Clock) -> Self {
102 Self {
103 started: clock.now(),
104 started_at_ms: clock.timestamp_ms(),
105 }
106 }
107
108 pub(super) fn stamp(&self, turn: &mut AssembledTurn, clock: &dyn crate::Clock) {
109 turn.execution.started_at_ms = self.started_at_ms;
110 turn.execution.duration_ms = clock
111 .now()
112 .saturating_duration_since(self.started)
113 .as_millis() as u64;
114 }
115}
116
117fn turn_phase_id(parent_turn_id: &str, phase: &str) -> String {
118 format!("{parent_turn_id}:{phase}")
119}
120
121fn scoped_child_turn_controller<'run>(
122 scoped_effect_controller: &'run ScopedEffectController<'_>,
123 session_id: &str,
124 turn_id: &str,
125) -> Result<ScopedEffectController<'run>, RuntimeError> {
126 ScopedEffectController::borrowed(
127 scoped_effect_controller.controller(),
128 ExecutionScope::turn(session_id, turn_id),
129 )
130}
131
132pub(in crate::runtime) fn queued_work_trace_payload(
133 boundary: crate::QueuedWorkClaimBoundary,
134 claim: &crate::QueuedWorkClaim,
135 causes: &[crate::TurnCause],
136) -> serde_json::Value {
137 serde_json::json!({
138 "boundary": boundary,
139 "claim_id": claim.claim_id,
140 "owner_id": claim.owner.owner_id,
141 "incarnation_id": claim.owner.incarnation_id,
142 "batch_ids": queued_work_batch_ids(claim),
143 "payload_types": claim.batches.iter()
144 .flat_map(|batch| batch.items.iter())
145 .map(|item| queued_work_payload_type(&item.payload))
146 .collect::<Vec<_>>(),
147 "causes": causes,
148 })
149}
150
151pub(in crate::runtime) fn queued_work_completion_trace_payload(
152 completions: &[crate::QueuedWorkCompletion],
153) -> serde_json::Value {
154 serde_json::json!({
155 "claims": completions.iter().map(|completion| {
156 serde_json::json!({
157 "session_id": completion.session_id,
158 "claim_id": completion.claim_id,
159 "batch_ids": completion.batch_ids,
160 })
161 }).collect::<Vec<_>>(),
162 })
163}
164
165pub(in crate::runtime) fn turn_input_completion_trace_payload(
166 completions: &[crate::TurnInputCompletion],
167) -> serde_json::Value {
168 serde_json::json!({
169 "claims": completions.iter().map(|completion| {
170 serde_json::json!({
171 "session_id": completion.session_id,
172 "claim_id": completion.claim_id,
173 "input_ids": completion.input_ids,
174 })
175 }).collect::<Vec<_>>(),
176 })
177}
178
179async fn emit_queued_work_started_to_sink(
180 events: &dyn TurnActivitySink,
181 boundary: crate::QueuedWorkClaimBoundary,
182 claim: &crate::QueuedWorkClaim,
183 causes: Vec<crate::TurnCause>,
184) {
185 emit_turn_activity_to_sink(
186 events,
187 TurnActivity::independent(TurnEvent::QueuedWorkStarted {
188 boundary,
189 batch_ids: queued_work_batch_ids(claim),
190 causes,
191 }),
192 )
193 .await;
194}
195
196pub(in crate::runtime) async fn send_queued_work_started_event(
197 event_tx: &mpsc::Sender<RuntimeStreamEvent>,
198 boundary: crate::QueuedWorkClaimBoundary,
199 claim: &crate::QueuedWorkClaim,
200 causes: Vec<crate::TurnCause>,
201) {
202 send_turn_activity(
203 event_tx,
204 TurnActivityId::fresh(),
205 TurnEvent::QueuedWorkStarted {
206 boundary,
207 batch_ids: queued_work_batch_ids(claim),
208 causes,
209 },
210 )
211 .await;
212}
213
214struct TurnFinishInput {
215 turn_pipeline: TurnBoundary,
216 assembler: TurnAssembler,
217 new_messages: crate::MessageSequence,
218 policy: RuntimeSessionPolicy,
219 turn_index: usize,
220 queued_work_claims: Vec<crate::QueuedWorkClaim>,
221 turn_input_claims: Vec<crate::TurnInputClaim>,
222 trace_turn_id: String,
223}
224
225impl LashRuntime {
226 fn max_context_tokens(&self) -> usize {
227 self.state.effective_policy().context_window_tokens()
228 }
229
230 async fn claim_session_execution_lease(
231 &self,
232 cancel: CancellationToken,
233 busy_is_error: bool,
234 ) -> Result<Option<SessionExecutionLeaseGuard>, RuntimeError> {
235 let Some(store) = self
236 .session
237 .as_ref()
238 .and_then(|session| session.history_store())
239 else {
240 return Ok(None);
241 };
242 match SessionExecutionLeaseGuard::try_acquire(
243 store,
244 &self.state.session_id,
245 &self.runtime_lease_owner,
246 self.host.core.control.lease_timings,
247 Arc::clone(&self.host.core.clock),
248 cancel,
249 )
250 .await
251 .map_err(|err| RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string()))?
252 {
253 Some(lease) => Ok(Some(lease)),
254 None if busy_is_error => Err(RuntimeError::new(
255 RuntimeErrorCode::SessionExecutionBusy,
256 format!(
257 "session `{}` is already executing on another runtime owner",
258 self.state.session_id
259 ),
260 )),
261 None => Ok(None),
262 }
263 }
264
265 async fn settle_session_execution_lease<T>(
266 &self,
267 guard: Option<&SessionExecutionLeaseGuard>,
268 result: Result<T, RuntimeError>,
269 ) -> Result<T, RuntimeError> {
270 match result {
271 Ok(value) => {
272 if let Some(guard) = guard {
273 guard.release_if_live().await.map_err(|err| {
274 RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string())
275 })?;
276 }
277 Ok(value)
278 }
279 Err(err) => {
280 if err.code != RuntimeErrorCode::StoreCommitFailed
281 && let Some(guard) = guard
282 && let Err(release_err) = guard.release_if_live().await
283 {
284 tracing::warn!(
285 error = %release_err,
286 "failed to release session execution lease after runtime error"
287 );
288 }
289 Err(err)
290 }
291 }
292 }
293
294 async fn ensure_session_execution_lease_live(
295 &self,
296 guard: Option<&SessionExecutionLeaseGuard>,
297 ) -> Result<(), RuntimeError> {
298 let Some(guard) = guard else {
299 return Ok(());
300 };
301 guard.refresh_or_mark_lost().await.map_err(|err| {
302 RuntimeError::new(
303 RuntimeErrorCode::SessionExecutionLeaseLost,
304 format!(
305 "session execution lease for session `{}` was lost before commit: {err}",
306 self.state.session_id
307 ),
308 )
309 })
310 }
311
312 async fn abandon_queued_work_claims_after_lease_loss(
319 &self,
320 err: &RuntimeError,
321 claims: &[crate::QueuedWorkClaim],
322 ) {
323 if err.code != RuntimeErrorCode::SessionExecutionLeaseLost || claims.is_empty() {
324 return;
325 }
326 let Some(store) = self
327 .session
328 .as_ref()
329 .and_then(|session| session.history_store())
330 else {
331 return;
332 };
333 for claim in claims {
334 if let Err(abandon_err) = store.abandon_queued_work_claim(claim).await {
335 tracing::warn!(
336 error = %abandon_err,
337 session_id = %claim.session_id,
338 claim_id = %claim.claim_id,
339 "failed to abandon queued work claim after session execution lease loss"
340 );
341 }
342 }
343 }
344
345 async fn abandon_turn_input_claims_after_lease_loss(
346 &self,
347 err: &RuntimeError,
348 claims: &[crate::TurnInputClaim],
349 ) {
350 if err.code != RuntimeErrorCode::SessionExecutionLeaseLost || claims.is_empty() {
351 return;
352 }
353 let Some(store) = self
354 .session
355 .as_ref()
356 .and_then(|session| session.history_store())
357 else {
358 return;
359 };
360 for claim in claims {
361 if let Err(abandon_err) = store.abandon_turn_input_claim(claim).await {
362 tracing::warn!(
363 error = %abandon_err,
364 session_id = %claim.session_id,
365 claim_id = %claim.claim_id,
366 "failed to abandon turn input claim after session execution lease loss"
367 );
368 }
369 }
370 }
371
372 #[doc(hidden)]
373 pub fn set_turn_phase_probe(&mut self, probe: Arc<dyn RuntimeTurnPhaseProbe>) {
374 self.turn_phase_probe = Some(probe);
375 }
376
377 fn mark_phase_begin(&self, phase: RuntimeTurnPhase) {
378 if let Some(probe) = self.turn_phase_probe.as_ref() {
379 probe.begin(phase);
380 }
381 }
382
383 fn mark_phase_end(&self, phase: RuntimeTurnPhase) {
384 if let Some(probe) = self.turn_phase_probe.as_ref() {
385 probe.end(phase);
386 }
387 }
388
389 async fn finish_turn(
390 &mut self,
391 finish: TurnFinishInput,
392 events: &dyn EventSink,
393 scoped_effect_controller: &ScopedEffectController<'_>,
394 cancel_state: &CancellationToken,
395 session_execution_lease: Option<&SessionExecutionLeaseGuard>,
396 session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
397 ) -> Result<PhysicalTurnExecution, RuntimeError> {
398 let TurnFinishInput {
399 mut turn_pipeline,
400 assembler,
401 new_messages,
402 policy,
403 turn_index,
404 queued_work_claims,
405 turn_input_claims,
406 trace_turn_id,
407 } = finish;
408 self.policy = self.state.effective_policy().clone();
409 turn_pipeline.state_mut().policy = self.policy.clone();
410 turn_pipeline.state_mut().turn_index = turn_index;
411
412 let mut turn_usage_delta = {
413 let mut ledger = self.shared_token_ledger.lock().expect("token ledger lock");
414 std::mem::take(&mut *ledger)
415 };
416 if assembler.token_usage.total() > 0 {
417 turn_usage_delta.push(TokenLedgerEntry {
418 source: "turn".to_string(),
419 model: policy.model.id.clone(),
420 usage: assembler.token_usage.clone(),
421 });
422 }
423 let turn_usage_delta = merge_usage_delta_entries(turn_usage_delta);
424
425 turn_pipeline.finalize_turn_read_state(new_messages, cancel_state.is_cancelled());
426 if assembler.token_usage.total() > 0 {
427 turn_pipeline.state_mut().token_usage = assembler.token_usage.clone();
428 }
429
430 let last_prompt_usage = assembler.last_llm_usage().and_then(normalize_prompt_usage);
431 turn_pipeline.state_mut().last_prompt_usage = last_prompt_usage;
432 let assembled_state = turn_pipeline.export_state_for_assembly();
433 let assembled = assembler.finish(
434 assembled_state,
435 cancel_state.is_cancelled(),
436 None,
437 &self.host.core.control.termination,
438 );
439
440 let Some(session) = self.session.as_ref() else {
441 self.state.apply_snapshot(&assembled.state);
442 self.emit_completed_turn_trace(&assembled.state, &assembled.outcome, &trace_turn_id);
443 return Ok(PhysicalTurnExecution {
444 turn: assembled,
445 enqueued_queue_batches: Vec::new(),
446 });
447 };
448 self.ensure_session_execution_lease_live(session_execution_lease)
449 .await?;
450
451 let plugins = Arc::clone(session.plugins());
452 let manager = match self.runtime_session_services_for_turn(None) {
453 Ok(manager) => manager,
454 Err(err) => {
455 return Err(RuntimeError::new(
456 RuntimeErrorCode::PluginSessionManager,
457 err.to_string(),
458 ));
459 }
460 };
461
462 self.mark_phase_begin(RuntimeTurnPhase::FinalizeTurn);
463 let finalized = match plugins
464 .finalize_turn_with_phase_probe(
465 assembled,
466 manager.state_service(),
467 manager.lifecycle_service(),
468 manager.graph_service(),
469 self.turn_phase_probe.clone(),
470 )
471 .await
472 {
473 Ok(finalized) => finalized,
474 Err(err) => {
475 self.mark_phase_end(RuntimeTurnPhase::FinalizeTurn);
476 return Err(RuntimeError::new(
477 RuntimeErrorCode::PluginFinalizeTurn,
478 err.to_string(),
479 ));
480 }
481 };
482 self.mark_phase_end(RuntimeTurnPhase::FinalizeTurn);
483 self.ensure_session_execution_lease_live(session_execution_lease)
484 .await?;
485
486 let mut returned_turn = finalized.turn;
487 let release_session_execution_lease =
488 session_execution_lease_release_policy.should_release(&returned_turn.outcome);
489 let commit_effects = LogicalTurnClaims::new(queued_work_claims, turn_input_claims)
490 .into_commit_effects(
491 &returned_turn.outcome,
492 &self.state.session_id,
493 &trace_turn_id,
494 Some(self.state.effective_protocol_turn_options().clone()),
495 );
496 self.mark_phase_begin(RuntimeTurnPhase::PersistTurn);
497 self.mark_phase_begin(RuntimeTurnPhase::FinalCommit);
498 let queued_work_completion_trace = commit_effects.completed_queue_claims.clone();
499 let turn_input_completion_trace = commit_effects.completed_turn_input_claims.clone();
500 let pending_attachment_ids = self
501 .host
502 .core
503 .durability
504 .attachment_store
505 .pending_manifest_commit_ids();
506 let enqueued_queue_batches = match turn_pipeline
507 .final_commit(
508 &mut returned_turn,
509 self.session.as_mut(),
510 &turn_usage_delta,
511 Some(&trace_turn_id),
512 commit_effects.originating_queue_claims,
513 commit_effects.originating_turn_input_claims,
514 commit_effects.completed_queue_claims,
515 commit_effects.completed_turn_input_claims,
516 commit_effects.enqueued_queue_batches,
517 cancel_state.is_cancelled().then(|| trace_turn_id.clone()),
518 pending_attachment_ids.clone(),
519 release_session_execution_lease
520 .then(|| session_execution_lease.map(SessionExecutionLeaseGuard::completion))
521 .flatten(),
522 )
523 .await
524 {
525 Ok(batches) => batches,
526 Err(err) => {
527 self.mark_phase_end(RuntimeTurnPhase::FinalCommit);
528 self.mark_phase_end(RuntimeTurnPhase::PersistTurn);
529 return Err(err);
530 }
531 };
532 if release_session_execution_lease && let Some(lease) = session_execution_lease {
533 lease.mark_released();
534 }
535 self.host
536 .core
537 .durability
538 .attachment_store
539 .mark_manifest_committed(&pending_attachment_ids);
540 self.mark_phase_end(RuntimeTurnPhase::FinalCommit);
541
542 emit_session_events_to_sink(events, finalized.events).await;
543 self.state = turn_pipeline.into_final_state();
544 if matches!(returned_turn.outcome, TurnOutcome::AgentFrameSwitch { .. })
545 && let Some(session) = self.session.as_mut()
546 {
547 let protocol_session = Arc::clone(session.plugins().protocol_session());
548 let session_id = self.state.session_id.clone();
549 protocol_session
550 .restore_session(
551 crate::plugin::ProtocolSessionContext::new(session, &session_id),
552 &self.state,
553 )
554 .await
555 .map_err(|err| {
556 RuntimeError::new(
557 RuntimeErrorCode::Other("protocol_restore_session".to_string()),
558 err.to_string(),
559 )
560 })?;
561 }
562 if !queued_work_completion_trace.is_empty() {
563 crate::trace::emit_trace(
564 &self.host.core.tracing.trace_sink,
565 &self.host.core.tracing.trace_context,
566 lash_trace::TraceContext::default()
567 .for_session(returned_turn.state.session_id.clone())
568 .for_turn_index(returned_turn.state.turn_index)
569 .for_turn(trace_turn_id.clone()),
570 lash_trace::TraceEvent::Custom {
571 name: "queued_work.completed".to_string(),
572 payload: queued_work_completion_trace_payload(&queued_work_completion_trace),
573 },
574 self.host.core.clock.as_ref(),
575 );
576 }
577 if !turn_input_completion_trace.is_empty() {
578 crate::trace::emit_trace(
579 &self.host.core.tracing.trace_sink,
580 &self.host.core.tracing.trace_context,
581 lash_trace::TraceContext::default()
582 .for_session(returned_turn.state.session_id.clone())
583 .for_turn_index(returned_turn.state.turn_index)
584 .for_turn(trace_turn_id.clone()),
585 lash_trace::TraceEvent::Custom {
586 name: "turn_input.completed".to_string(),
587 payload: turn_input_completion_trace_payload(&turn_input_completion_trace),
588 },
589 self.host.core.clock.as_ref(),
590 );
591 }
592 self.mark_phase_begin(RuntimeTurnPhase::PostPersistHooks);
593 self.emit_turn_persisted_event(&returned_turn, scoped_effect_controller, &trace_turn_id)
594 .await?;
595 self.mark_phase_end(RuntimeTurnPhase::PostPersistHooks);
596 self.mark_phase_end(RuntimeTurnPhase::PersistTurn);
597
598 self.emit_completed_turn_trace(
599 &returned_turn.state,
600 &returned_turn.outcome,
601 &trace_turn_id,
602 );
603 Ok(PhysicalTurnExecution {
604 turn: returned_turn,
605 enqueued_queue_batches,
606 })
607 }
608
609 fn emit_completed_turn_trace(
610 &self,
611 state: &SessionSnapshot,
612 outcome: &TurnOutcome,
613 trace_turn_id: &str,
614 ) {
615 if self.host.core.tracing.trace_sink.is_none() {
616 return;
617 }
618
619 let (status, done_reason, agent_frame_switch) = trace_fields_from_outcome(outcome);
620 crate::trace::emit_trace(
621 &self.host.core.tracing.trace_sink,
622 &self.host.core.tracing.trace_context,
623 lash_trace::TraceContext::default()
624 .for_session(state.session_id.clone())
625 .for_turn_index(state.turn_index)
626 .for_turn(trace_turn_id.to_string()),
627 lash_trace::TraceEvent::TurnCompleted {
628 status: status.to_string(),
629 done_reason: done_reason.to_string(),
630 agent_frame_switch,
631 },
632 self.host.core.clock.as_ref(),
633 );
634 }
635
636 #[allow(clippy::too_many_arguments)]
637 pub(super) async fn finish_logical_turn_error(
638 &mut self,
639 message: String,
640 trace_turn_id: String,
641 events: &dyn EventSink,
642 turn_events: &dyn TurnActivitySink,
643 scoped_effect_controller: ScopedEffectController<'_>,
644 cancel: CancellationToken,
645 claims: LogicalTurnClaims,
646 session_execution_lease: Option<&SessionExecutionLeaseGuard>,
647 ) -> Result<PhysicalTurnExecution, RuntimeError> {
648 let mut assembler = TurnAssembler::default();
649 let error_event = SessionStreamEvent::Error {
650 message: message.clone(),
651 envelope: Some(crate::session_model::ErrorEnvelope {
652 kind: "runtime".to_string(),
653 code: Some("agent_frame_switch_limit".to_string()),
654 terminal_reason: None,
655 user_message: message.clone(),
656 raw: None,
657 retryable: Some(false),
658 provider_failure_kind: None,
659 }),
660 };
661 assembler.push(&error_event);
662 emit_turn_activity_to_sink(
663 turn_events,
664 TurnActivity::independent(TurnEvent::Error {
665 message: message.clone(),
666 }),
667 )
668 .await;
669 emit_session_event_to_sink(events, error_event).await;
670 let outcome_event = SessionStreamEvent::TurnOutcome {
671 outcome: TurnOutcome::Stopped(TurnStop::RuntimeError),
672 };
673 assembler.push(&outcome_event);
674 emit_session_event_to_sink(events, outcome_event).await;
675 assembler.push(&SessionStreamEvent::Done);
676 emit_session_event_to_sink(events, SessionStreamEvent::Done).await;
677
678 let messages = crate::MessageSequence::from_base(self.state.read_model().messages);
679 let mut turn_pipeline = TurnBoundary::from_state_with_clock(
680 self.state.clone(),
681 Arc::clone(&self.host.core.clock),
682 )
683 .with_session_execution_lease(
684 session_execution_lease.map(SessionExecutionLeaseGuard::fence),
685 );
686 turn_pipeline.apply_prepared_messages(&messages);
687 self.finish_turn(
688 TurnFinishInput {
689 turn_pipeline,
690 assembler,
691 new_messages: messages,
692 policy: RuntimeSessionPolicy::new(
693 self.state.effective_policy().clone(),
694 Default::default(),
695 ),
696 turn_index: self.state.turn_index + 1,
697 queued_work_claims: claims.queued,
698 turn_input_claims: claims.turn_inputs,
699 trace_turn_id,
700 },
701 events,
702 &scoped_effect_controller,
703 &cancel,
704 session_execution_lease,
705 SessionExecutionLeaseReleasePolicy::KeepOnAgentFrameSwitch,
706 )
707 .await
708 }
709
710 async fn emit_turn_persisted_event(
711 &self,
712 returned_turn: &AssembledTurn,
713 scoped_effect_controller: &ScopedEffectController<'_>,
714 trace_turn_id: &str,
715 ) -> Result<(), RuntimeError> {
716 let Some(session) = self.session.as_ref() else {
717 return Ok(());
718 };
719 let Ok(manager) = self.runtime_session_services() else {
720 return Ok(());
721 };
722 let phase_turn_id = turn_phase_id(trace_turn_id, "turn-persisted");
723 let phase_controller = scoped_child_turn_controller(
724 scoped_effect_controller,
725 &self.state.session_id,
726 &phase_turn_id,
727 )?;
728 let direct_completions = manager.direct_completion_client(
729 RuntimeEffectControllerHandle::borrowed(phase_controller),
730 Some(phase_turn_id),
731 );
732
733 session
734 .plugins()
735 .emit_runtime_event_with_phase_probe(
736 crate::PluginLifecycleEvent::TurnPersisted(Box::new(
737 crate::SessionStateChangedContext {
738 session_id: self.state.session_id.clone(),
739 state: crate::SessionReadView::from_snapshot(&returned_turn.state),
740 sessions: manager.state_service(),
741 session_graph: manager.graph_service(),
742 direct_completions,
743 },
744 )),
745 self.turn_phase_probe.clone(),
746 )
747 .await;
748 Ok(())
749 }
750
751 pub async fn stream_turn(
753 &mut self,
754 input: TurnInput,
755 opts: TurnOptions<'_>,
756 ) -> Result<AssembledTurn, RuntimeError> {
757 let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
758 let cancel = opts.cancel.clone();
759 let session_execution_lease = self
760 .claim_session_execution_lease(cancel.clone(), true)
761 .await?;
762 let scoped_effect_controller = opts.scoped_effect_controller();
763 let result = Box::pin(self.drive_logical_turn(
764 LogicalTurnStart::Input(input),
765 opts.events_or_noop(),
766 opts.turn_events_or_noop(),
767 scoped_effect_controller,
768 cancel,
769 LogicalTurnClaims::new(Vec::new(), Vec::new()),
770 session_execution_lease.as_ref(),
771 stopwatch,
772 ))
773 .await
774 .map(|run| {
775 run.into_final_turn()
776 .expect("logical turn always contains a terminal physical turn")
777 });
778 self.settle_session_execution_lease(session_execution_lease.as_ref(), result)
779 .await
780 }
781
782 pub async fn stream_next_queued_work(
783 &mut self,
784 opts: TurnOptions<'_>,
785 ) -> Result<Option<AssembledTurn>, RuntimeError> {
786 self.stream_queued_work(opts, None).await
787 }
788
789 pub async fn stream_selected_queued_work(
790 &mut self,
791 opts: TurnOptions<'_>,
792 batch_ids: &[String],
793 ) -> Result<Option<AssembledTurn>, RuntimeError> {
794 self.stream_queued_work(opts, Some(batch_ids)).await
795 }
796
797 async fn stream_queued_work(
798 &mut self,
799 opts: TurnOptions<'_>,
800 selected_batch_ids: Option<&[String]>,
801 ) -> Result<Option<AssembledTurn>, RuntimeError> {
802 let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
803 let cancel = opts.cancel.clone();
804 let Some(session_execution_lease) = self
805 .claim_session_execution_lease(cancel.clone(), false)
806 .await?
807 else {
808 return Ok(None);
809 };
810 let session_execution_fence = session_execution_lease.fence();
811 let Some(store) = self
812 .session
813 .as_ref()
814 .and_then(|session| session.history_store())
815 else {
816 session_execution_lease
817 .release_if_live()
818 .await
819 .map_err(|err| {
820 RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string())
821 })?;
822 return Ok(None);
823 };
824 let drain_commands_before_turn_input = if selected_batch_ids.is_some() {
825 true
826 } else {
827 self.session_commands_precede_pending_turn_input(store.as_ref())
828 .await?
829 };
830 if drain_commands_before_turn_input {
831 loop {
832 match self
833 .drain_next_session_command(&session_execution_fence)
834 .await
835 {
836 Ok(Some(_)) => {}
837 Ok(None) => break,
838 Err(err) => {
839 let _ = session_execution_lease.release_if_live().await;
840 return Err(err);
841 }
842 }
843 }
844 }
845 if selected_batch_ids.is_none() {
846 let input_claim = store
847 .claim_next_turn_inputs(
848 &self.state.session_id,
849 &session_execution_fence,
850 &self.runtime_lease_owner,
851 64,
852 )
853 .await
854 .map_err(super::runtime_error_from_store_commit)?;
855 if let Some(input_claim) = input_claim {
856 let mut input = input_claim.materialize_for_turn();
857 let turn_id = input
858 .trace_turn_id
859 .clone()
860 .or_else(|| Some(opts.execution_scope_id().to_owned()))
861 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
862 input.trace_turn_id = Some(turn_id.clone());
863 crate::trace::emit_trace(
864 &self.host.core.tracing.trace_sink,
865 &self.host.core.tracing.trace_context,
866 lash_trace::TraceContext::default()
867 .for_session(self.state.session_id.clone())
868 .for_turn_index(self.state.turn_index + 1)
869 .for_turn(turn_id.clone()),
870 lash_trace::TraceEvent::Custom {
871 name: "turn_input.claimed".to_string(),
872 payload: serde_json::json!({
873 "claim_id": &input_claim.claim_id,
874 "input_ids": input_claim.inputs.iter().map(|input| input.input_id.clone()).collect::<Vec<_>>(),
875 }),
876 },
877 self.host.core.clock.as_ref(),
878 );
879 let claim_for_abandon = input_claim.clone();
880 let scoped_effect_controller = opts.scoped_effect_controller();
881 let result = Box::pin(self.drive_logical_turn(
882 LogicalTurnStart::Input(input),
883 opts.events_or_noop(),
884 opts.turn_events_or_noop(),
885 scoped_effect_controller,
886 cancel,
887 LogicalTurnClaims::new(Vec::new(), vec![input_claim]),
888 Some(&session_execution_lease),
889 stopwatch,
890 ))
891 .await
892 .map(AgentFrameRun::into_final_turn);
893 if let Err(err) = &result {
894 self.abandon_turn_input_claims_after_lease_loss(
895 err,
896 std::slice::from_ref(&claim_for_abandon),
897 )
898 .await;
899 }
900 return self
901 .settle_session_execution_lease(Some(&session_execution_lease), result)
902 .await;
903 }
904 }
905 let claim = if let Some(batch_ids) = selected_batch_ids {
906 store
907 .claim_ready_queued_work_by_batch_ids(
908 &self.state.session_id,
909 &session_execution_fence,
910 &self.runtime_lease_owner,
911 crate::QueuedWorkClaimBoundary::Idle,
912 batch_ids,
913 )
914 .await
915 } else {
916 store
917 .claim_ready_queued_work(
918 &self.state.session_id,
919 &session_execution_fence,
920 &self.runtime_lease_owner,
921 crate::QueuedWorkClaimBoundary::Idle,
922 64,
923 )
924 .await
925 }
926 .map_err(super::runtime_error_from_store_commit)?;
927 let Some(claim) = claim else {
928 session_execution_lease
929 .release_if_live()
930 .await
931 .map_err(|err| {
932 RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string())
933 })?;
934 return Ok(None);
935 };
936 let mut work = claim.materialize_for_turn();
937 let turn_id = work
938 .input
939 .trace_turn_id
940 .clone()
941 .or_else(|| Some(opts.execution_scope_id().to_owned()))
942 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
943 work.input.trace_turn_id = Some(turn_id.clone());
944 let causes = work.turn_causes.clone();
945 emit_queued_work_started_to_sink(
946 opts.turn_events_or_noop(),
947 crate::QueuedWorkClaimBoundary::Idle,
948 &claim,
949 causes.clone(),
950 )
951 .await;
952 crate::trace::emit_trace(
953 &self.host.core.tracing.trace_sink,
954 &self.host.core.tracing.trace_context,
955 lash_trace::TraceContext::default()
956 .for_session(self.state.session_id.clone())
957 .for_turn_index(self.state.turn_index + 1)
958 .for_turn(turn_id.clone()),
959 lash_trace::TraceEvent::Custom {
960 name: "queued_work.claimed".to_string(),
961 payload: queued_work_trace_payload(
962 crate::QueuedWorkClaimBoundary::Idle,
963 &claim,
964 &causes,
965 ),
966 },
967 self.host.core.clock.as_ref(),
968 );
969 let claim_for_abandon = claim.clone();
970 let scoped_effect_controller = opts.scoped_effect_controller();
971 let result = Box::pin(self.drive_logical_turn(
972 LogicalTurnStart::Input(work.input),
973 opts.events_or_noop(),
974 opts.turn_events_or_noop(),
975 scoped_effect_controller,
976 cancel,
977 LogicalTurnClaims::new(vec![claim], Vec::new()),
978 Some(&session_execution_lease),
979 stopwatch,
980 ))
981 .await
982 .map(AgentFrameRun::into_final_turn);
983 if let Err(err) = &result {
984 self.abandon_queued_work_claims_after_lease_loss(
985 err,
986 std::slice::from_ref(&claim_for_abandon),
987 )
988 .await;
989 }
990 self.settle_session_execution_lease(Some(&session_execution_lease), result)
991 .await
992 }
993
994 async fn session_commands_precede_pending_turn_input(
995 &self,
996 store: &dyn crate::RuntimePersistence,
997 ) -> Result<bool, RuntimeError> {
998 let pending_inputs = store
999 .list_pending_turn_inputs(&self.state.session_id)
1000 .await
1001 .map_err(super::runtime_error_from_store_commit)?;
1002 let earliest_input = pending_inputs
1003 .iter()
1004 .filter(|input| input.state.is_next_turn_pending())
1005 .min_by_key(|input| (input.enqueued_at_ms, input.enqueue_seq));
1006 let queued_work = store
1007 .list_pending_queued_work(&self.state.session_id)
1008 .await
1009 .map_err(super::runtime_error_from_store_commit)?;
1010 let earliest_command = queued_work
1011 .iter()
1012 .filter(|batch| batch.is_session_command_work())
1013 .min_by_key(|batch| (batch.enqueued_at_ms, batch.enqueue_seq));
1014 Ok(match (earliest_command, earliest_input) {
1015 (Some(command), Some(input)) => command.enqueued_at_ms < input.enqueued_at_ms,
1016 (Some(_), None) => true,
1017 _ => false,
1018 })
1019 }
1020
1021 fn ensure_durable_store_facets_for_scope(
1029 &self,
1030 scoped_effect_controller: &ScopedEffectController<'_>,
1031 ) -> Result<(), RuntimeError> {
1032 if scoped_effect_controller.controller().durability_tier() != crate::DurabilityTier::Durable
1033 {
1034 return Ok(());
1035 }
1036 if self
1037 .host
1038 .core
1039 .durability
1040 .attachment_store
1041 .persistence()
1042 .durability_tier()
1043 != crate::DurabilityTier::Durable
1044 {
1045 return Err(RuntimeError::durable_store_required(
1046 crate::DurableStoreFacet::AttachmentStore,
1047 ));
1048 }
1049 if self
1050 .host
1051 .core
1052 .durability
1053 .process_env_store
1054 .durability_tier()
1055 != crate::DurabilityTier::Durable
1056 {
1057 return Err(RuntimeError::durable_store_required(
1058 crate::DurableStoreFacet::ProcessEnvStore,
1059 ));
1060 }
1061 if let Some(store) = self
1062 .session
1063 .as_ref()
1064 .and_then(|session| session.history_store())
1065 && store.durability_tier() != crate::DurabilityTier::Durable
1066 {
1067 return Err(RuntimeError::durable_store_required(
1068 crate::DurableStoreFacet::SessionStore,
1069 ));
1070 }
1071 if let Some(process_registry) = self.host.process_registry.as_ref()
1072 && process_registry.durability_tier() != crate::DurabilityTier::Durable
1073 {
1074 return Err(RuntimeError::durable_store_required(
1075 crate::DurableStoreFacet::ProcessRegistry,
1076 ));
1077 }
1078 Ok(())
1079 }
1080
1081 #[allow(clippy::too_many_arguments)]
1082 pub(super) async fn stream_turn_with_scoped_effect_controller_inner(
1083 &mut self,
1084 mut input: TurnInput,
1085 events: &dyn EventSink,
1086 turn_events: &dyn TurnActivitySink,
1087 scoped_effect_controller: ScopedEffectController<'_>,
1088 cancel: CancellationToken,
1089 queued_claims: Vec<crate::QueuedWorkClaim>,
1090 turn_input_claims: Vec<crate::TurnInputClaim>,
1091 materialize_initial_claims: bool,
1092 session_execution_lease: Option<&SessionExecutionLeaseGuard>,
1093 session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
1094 ) -> Result<PhysicalTurnExecution, RuntimeError> {
1095 if queued_claims.is_empty() && turn_input_claims.is_empty() {
1096 if let Some(lease) = session_execution_lease {
1097 while self
1098 .drain_next_session_command(&lease.fence())
1099 .await?
1100 .is_some()
1101 {}
1102 } else if self
1103 .session
1104 .as_ref()
1105 .and_then(|session| session.history_store())
1106 .is_some()
1107 {
1108 return Err(RuntimeError::new(
1109 RuntimeErrorCode::StoreCommitFailed,
1110 "session command drain requires a session execution lease",
1111 ));
1112 }
1113 }
1114 if let Some(input_turn_id) = input.trace_turn_id.as_deref()
1115 && scoped_effect_controller
1116 .execution_scope()
1117 .validates_turn_trace_id()
1118 && input_turn_id != scoped_effect_controller.scope_id()
1119 {
1120 return Err(RuntimeError::new(
1121 RuntimeErrorCode::ExecutionScopeTurnIdMismatch,
1122 format!(
1123 "input trace_turn_id `{input_turn_id}` does not match execution scope id `{}`",
1124 scoped_effect_controller.scope_id()
1125 ),
1126 ));
1127 }
1128 self.ensure_durable_store_facets_for_scope(&scoped_effect_controller)?;
1129 input
1130 .trace_turn_id
1131 .get_or_insert_with(|| scoped_effect_controller.scope_id().to_string());
1132 self.stream_turn_inner(
1133 input.clone(),
1134 events,
1135 turn_events,
1136 scoped_effect_controller,
1137 cancel.clone(),
1138 queued_claims,
1139 turn_input_claims,
1140 materialize_initial_claims,
1141 session_execution_lease,
1142 session_execution_lease_release_policy,
1143 )
1144 .await
1145 }
1146
1147 pub async fn stream_turn_with_agent_frames(
1156 &mut self,
1157 input: TurnInput,
1158 opts: TurnOptions<'_>,
1159 ) -> Result<AgentFrameRun, RuntimeError> {
1160 let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
1161 let cancel = opts.cancel.clone();
1162 let session_execution_lease = self
1163 .claim_session_execution_lease(cancel.clone(), true)
1164 .await?;
1165 let scoped_effect_controller = opts.scoped_effect_controller();
1166 let result = Box::pin(self.drive_logical_turn(
1167 LogicalTurnStart::Input(input),
1168 opts.events_or_noop(),
1169 opts.turn_events_or_noop(),
1170 scoped_effect_controller,
1171 cancel,
1172 LogicalTurnClaims::new(Vec::new(), Vec::new()),
1173 session_execution_lease.as_ref(),
1174 stopwatch,
1175 ))
1176 .await;
1177 self.settle_session_execution_lease(session_execution_lease.as_ref(), result)
1178 .await
1179 }
1180
1181 #[allow(clippy::too_many_arguments)]
1182 async fn stream_turn_inner(
1183 &mut self,
1184 mut input: TurnInput,
1185 events: &dyn EventSink,
1186 turn_events: &dyn TurnActivitySink,
1187 scoped_effect_controller: ScopedEffectController<'_>,
1188 cancel: CancellationToken,
1189 queued_claims: Vec<crate::QueuedWorkClaim>,
1190 turn_input_claims: Vec<crate::TurnInputClaim>,
1191 materialize_initial_claims: bool,
1192 session_execution_lease: Option<&SessionExecutionLeaseGuard>,
1193 session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
1194 ) -> Result<PhysicalTurnExecution, RuntimeError> {
1195 self.refresh_session_graph_from_store()
1196 .await
1197 .map_err(session_head_refresh_error)?;
1198 let input_trace_turn_id = input.trace_turn_id.clone();
1199 let queued_turn_work = materialize_initial_claims
1200 .then(|| queued_claims.first())
1201 .flatten()
1202 .map(crate::QueuedWorkClaim::materialize_for_turn);
1203 let pending_turn_input = materialize_initial_claims
1204 .then(|| turn_input_claims.first())
1205 .flatten()
1206 .map(crate::TurnInputClaim::materialize_for_turn);
1207 if let Some(work) = pending_turn_input.as_ref()
1208 && input.items.is_empty()
1209 && input.image_blobs.is_empty()
1210 {
1211 input = work.clone();
1212 if input.trace_turn_id.is_none() {
1213 input.trace_turn_id = input_trace_turn_id.clone();
1214 }
1215 }
1216 if let Some(work) = queued_turn_work.as_ref()
1217 && input.items.is_empty()
1218 && input.image_blobs.is_empty()
1219 {
1220 input = work.input.clone();
1221 if input.trace_turn_id.is_none() {
1222 input.trace_turn_id = input_trace_turn_id;
1223 }
1224 }
1225 if self
1226 .session
1227 .as_ref()
1228 .and_then(|session| session.history_store())
1229 .is_some()
1230 {
1231 ensure_durable_effect_input(&input)?;
1232 }
1233 if let Some(extension) = &input.protocol_extension
1234 && let Some(session) = self.session.as_ref()
1235 {
1236 let protocol_session = std::sync::Arc::clone(session.plugins().protocol_session());
1237 protocol_session
1238 .validate_turn_extension(extension)
1239 .await
1240 .map_err(|err| {
1241 RuntimeError::new(RuntimeErrorCode::ProtocolTurnExtension, err.to_string())
1242 })?;
1243 }
1244 let previous_prompt_usage = self.state.last_prompt_usage.clone();
1245 let normalized = match self
1246 .normalize_input_items(&input.items, &input.image_blobs)
1247 .await
1248 {
1249 Ok(items) => items,
1250 Err(e) => {
1251 self.state.last_prompt_usage = None;
1252 let mut assembler = TurnAssembler::default();
1253 let error_event = SessionStreamEvent::Error {
1254 message: e.clone(),
1255 envelope: Some(crate::session_model::ErrorEnvelope {
1256 kind: "input_validation".to_string(),
1257 code: Some("invalid_turn_input".to_string()),
1258 terminal_reason: None,
1259 user_message: e.clone(),
1260 raw: None,
1261 retryable: Some(false),
1262 provider_failure_kind: None,
1263 }),
1264 };
1265 assembler.push(&error_event);
1266 emit_turn_activity_to_sink(
1267 turn_events,
1268 TurnActivity::independent(TurnEvent::Error { message: e }),
1269 )
1270 .await;
1271 emit_session_event_to_sink(events, error_event).await;
1272 let outcome_event = SessionStreamEvent::TurnOutcome {
1273 outcome: TurnOutcome::Stopped(TurnStop::InvalidInput),
1274 };
1275 assembler.push(&outcome_event);
1276 emit_session_event_to_sink(events, outcome_event).await;
1277 assembler.push(&SessionStreamEvent::Done);
1278 emit_session_event_to_sink(events, SessionStreamEvent::Done).await;
1279 let turn_index = self.state.turn_index + 1;
1280 let trace_turn_id = input
1281 .trace_turn_id
1282 .clone()
1283 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1284 let messages = crate::MessageSequence::from_base(self.state.read_model().messages);
1285 let mut turn_pipeline = TurnBoundary::from_state_with_clock(
1286 self.state.clone(),
1287 Arc::clone(&self.host.core.clock),
1288 )
1289 .with_session_execution_lease(
1290 session_execution_lease.map(SessionExecutionLeaseGuard::fence),
1291 );
1292 turn_pipeline.apply_prepared_messages(&messages);
1293 return self
1294 .finish_turn(
1295 TurnFinishInput {
1296 turn_pipeline,
1297 assembler,
1298 new_messages: messages,
1299 policy: RuntimeSessionPolicy::new(
1300 self.state.effective_policy().clone(),
1301 Default::default(),
1302 ),
1303 turn_index,
1304 queued_work_claims: queued_claims,
1305 turn_input_claims,
1306 trace_turn_id,
1307 },
1308 events,
1309 &scoped_effect_controller,
1310 &cancel,
1311 session_execution_lease,
1312 session_execution_lease_release_policy,
1313 )
1314 .await;
1315 }
1316 };
1317 let turn_index = self.state.turn_index + 1;
1318 let trace_turn_id = input
1319 .trace_turn_id
1320 .clone()
1321 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1322 if self.host.core.tracing.trace_sink.is_some() {
1323 let mut trace_metadata = std::collections::BTreeMap::new();
1324 trace_metadata.insert(
1325 "input_item_count".to_string(),
1326 serde_json::json!(normalized.len()),
1327 );
1328 crate::trace::emit_trace(
1329 &self.host.core.tracing.trace_sink,
1330 &self.host.core.tracing.trace_context,
1331 lash_trace::TraceContext::default()
1332 .for_session(self.state.session_id.clone())
1333 .for_turn_index(turn_index)
1334 .for_turn(trace_turn_id.clone()),
1335 lash_trace::TraceEvent::TurnStarted {
1336 metadata: trace_metadata,
1337 },
1338 self.host.core.clock.as_ref(),
1339 );
1340 }
1341
1342 let base_read_model = self.state.read_model();
1343 let base_messages = base_read_model.messages;
1344 let base_render_cache = base_read_model.prompt_render_cache;
1345 let mut turn_delta = Vec::new();
1346 let initial_turn_causes = queued_turn_work
1347 .as_ref()
1348 .map(|work| work.turn_causes.clone())
1349 .unwrap_or_default();
1350 turn_delta.extend(
1351 initial_turn_causes
1352 .iter()
1353 .map(crate::TurnCause::to_event_message),
1354 );
1355
1356 let user_id = fresh_message_id();
1357 let mut user_parts: Vec<Part> = Vec::new();
1358 for item in normalized {
1359 match item {
1360 NormalizedItem::Text(text) => {
1361 if text.is_empty() {
1362 continue;
1363 }
1364 user_parts.push(Part {
1365 id: format!("{}.p{}", user_id, user_parts.len()),
1366 kind: PartKind::Text,
1367 content: text,
1368 attachment: None,
1369 tool_call_id: None,
1370 tool_name: None,
1371 tool_replay: None,
1372 prune_state: PruneState::Intact,
1373 reasoning_meta: None,
1374 response_meta: None,
1375 });
1376 }
1377 NormalizedItem::Image(reference) => {
1378 user_parts.push(Part {
1379 id: format!("{}.p{}", user_id, user_parts.len()),
1380 kind: PartKind::Image,
1381 content: String::new(),
1382 attachment: Some(crate::session_model::message::PartAttachment {
1383 reference,
1384 }),
1385 tool_call_id: None,
1386 tool_name: None,
1387 tool_replay: None,
1388 prune_state: PruneState::Intact,
1389 reasoning_meta: None,
1390 response_meta: None,
1391 });
1392 }
1393 }
1394 }
1395 if user_parts.is_empty() && initial_turn_causes.is_empty() {
1396 user_parts.push(Part {
1397 id: format!("{}.p0", user_id),
1398 kind: PartKind::Text,
1399 content: String::new(),
1400 attachment: None,
1401 tool_call_id: None,
1402 tool_name: None,
1403 tool_replay: None,
1404 prune_state: PruneState::Intact,
1405 reasoning_meta: None,
1406 response_meta: None,
1407 });
1408 }
1409 if !user_parts.is_empty() {
1410 reassign_part_ids(&user_id, &mut user_parts);
1411 turn_delta.push(Message {
1412 id: user_id.clone(),
1413 role: MessageRole::User,
1414 parts: shared_parts(user_parts),
1415 origin: None,
1416 });
1417 }
1418
1419 let manager = self
1420 .runtime_session_services_for_turn(None)
1421 .map_err(|err| {
1422 RuntimeError::new(RuntimeErrorCode::PluginSessionManager, err.to_string())
1423 })?;
1424 let plugin_session = self
1425 .session
1426 .as_ref()
1427 .map(|s| Arc::clone(s.plugins()))
1428 .ok_or_else(|| {
1429 RuntimeError::new(
1430 RuntimeErrorCode::ContextPrepareTurn,
1431 "runtime session not available",
1432 )
1433 })?;
1434 let prepare_phase_turn_id = turn_phase_id(&trace_turn_id, "prepare-turn");
1435 let prepare_phase_controller = scoped_child_turn_controller(
1436 &scoped_effect_controller,
1437 &self.state.session_id,
1438 &prepare_phase_turn_id,
1439 )?;
1440 let turn_ctx = crate::TurnTransformContext {
1441 session_id: self.state.session_id.clone(),
1442 state: self.read_view(),
1443 prompt_usage: previous_prompt_usage.clone(),
1444 max_context_tokens: Some(LashRuntime::max_context_tokens(self)),
1445 sessions: manager.state_service(),
1446 session_lifecycle: manager.lifecycle_service(),
1447 session_graph: manager.graph_service(),
1448 scoped_effect_controller: scoped_effect_controller.clone(),
1449 direct_completions: manager.direct_completion_client(
1450 RuntimeEffectControllerHandle::borrowed(prepare_phase_controller),
1451 Some(prepare_phase_turn_id),
1452 ),
1453 };
1454 self.mark_phase_begin(RuntimeTurnPhase::ContextTransform);
1455 let prepared_context = plugin_session
1456 .prepare_turn_context(
1457 &turn_ctx,
1458 crate::session_model::context::PreparedContext {
1459 messages: crate::MessageSequence::from_base_and_delta(
1460 base_messages,
1461 turn_delta,
1462 )
1463 .with_base_render_cache(base_render_cache),
1464 ..Default::default()
1465 },
1466 self.turn_phase_probe.clone(),
1467 )
1468 .await
1469 .map_err(|err| {
1470 RuntimeError::new(RuntimeErrorCode::ContextPrepareTurn, err.to_string())
1471 })?;
1472 self.mark_phase_end(RuntimeTurnPhase::ContextTransform);
1473 drop(turn_ctx);
1478 let messages = prepared_context.messages;
1479 if let Some(session) = self.session.as_mut() {
1480 session
1481 .set_context_overlay(
1482 prepared_context.tool_providers,
1483 prepared_context.prompt_contributions,
1484 prepared_context.include_base_tools,
1485 )
1486 .map_err(|err| {
1487 RuntimeError::new(
1488 RuntimeErrorCode::Other("session_tool_registry".to_string()),
1489 err.to_string(),
1490 )
1491 })?;
1492 }
1493
1494 self.state.last_prompt_usage = None;
1495 Box::pin(self.stream_prepared_turn_inner(
1496 messages,
1497 previous_prompt_usage,
1498 input.protocol_turn_options.clone(),
1499 input.protocol_extension.clone(),
1500 input.turn_context.clone(),
1501 initial_turn_causes,
1502 trace_turn_id,
1503 turn_index,
1504 events,
1505 turn_events,
1506 scoped_effect_controller,
1507 cancel,
1508 queued_claims,
1509 turn_input_claims,
1510 session_execution_lease,
1511 session_execution_lease_release_policy,
1512 ))
1513 .await
1514 }
1515
1516 pub async fn run_turn_assembled(
1518 &mut self,
1519 input: TurnInput,
1520 cancel: CancellationToken,
1521 scoped_effect_controller: ScopedEffectController<'_>,
1522 ) -> Result<AssembledTurn, RuntimeError> {
1523 self.stream_turn(input, TurnOptions::new(cancel, scoped_effect_controller))
1524 .await
1525 }
1526
1527 #[allow(clippy::too_many_arguments)]
1529 pub async fn stream_prepared_turn(
1530 &mut self,
1531 messages: crate::MessageSequence,
1532 previous_prompt_usage: Option<PromptUsage>,
1533 protocol_turn_options: Option<crate::ProtocolTurnOptions>,
1534 protocol_extension: Option<crate::ProtocolTurnExtensionHandle>,
1535 turn_context: crate::TurnContext,
1536 initial_turn_causes: Vec<crate::TurnCause>,
1537 trace_turn_id: String,
1538 turn_index: usize,
1539 events: &dyn EventSink,
1540 turn_events: &dyn TurnActivitySink,
1541 scoped_effect_controller: ScopedEffectController<'_>,
1542 cancel: CancellationToken,
1543 initial_queue_claim: Option<crate::QueuedWorkClaim>,
1544 initial_turn_input_claim: Option<crate::TurnInputClaim>,
1545 ) -> Result<AssembledTurn, RuntimeError> {
1546 let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
1547 let session_execution_lease = self
1548 .claim_session_execution_lease(cancel.clone(), true)
1549 .await?;
1550 let result = Box::pin(self.drive_logical_turn(
1551 LogicalTurnStart::Prepared(PreparedLogicalTurn {
1552 messages,
1553 previous_prompt_usage,
1554 protocol_turn_options,
1555 protocol_extension,
1556 turn_context,
1557 initial_turn_causes,
1558 trace_turn_id,
1559 turn_index,
1560 }),
1561 events,
1562 turn_events,
1563 scoped_effect_controller,
1564 cancel,
1565 LogicalTurnClaims::new(
1566 initial_queue_claim.into_iter().collect(),
1567 initial_turn_input_claim.into_iter().collect(),
1568 ),
1569 session_execution_lease.as_ref(),
1570 stopwatch,
1571 ))
1572 .await
1573 .map(|run| {
1574 run.into_final_turn()
1575 .expect("logical turn always contains a terminal physical turn")
1576 });
1577 self.settle_session_execution_lease(session_execution_lease.as_ref(), result)
1578 .await
1579 }
1580
1581 #[allow(clippy::too_many_arguments)]
1582 pub(super) async fn stream_prepared_turn_inner(
1583 &mut self,
1584 messages: crate::MessageSequence,
1585 _previous_prompt_usage: Option<PromptUsage>,
1586 protocol_turn_options: Option<crate::ProtocolTurnOptions>,
1587 protocol_extension: Option<crate::ProtocolTurnExtensionHandle>,
1588 turn_context: crate::TurnContext,
1589 initial_turn_causes: Vec<crate::TurnCause>,
1590 trace_turn_id: String,
1591 turn_index: usize,
1592 events: &dyn EventSink,
1593 turn_events: &dyn TurnActivitySink,
1594 scoped_effect_controller: ScopedEffectController<'_>,
1595 cancel: CancellationToken,
1596 initial_queue_claims: Vec<crate::QueuedWorkClaim>,
1597 initial_turn_input_claims: Vec<crate::TurnInputClaim>,
1598 session_execution_lease: Option<&SessionExecutionLeaseGuard>,
1599 session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
1600 ) -> Result<PhysicalTurnExecution, RuntimeError> {
1601 if session_execution_lease.is_none()
1602 && self
1603 .session
1604 .as_ref()
1605 .and_then(|session| session.history_store())
1606 .is_some()
1607 {
1608 return Err(RuntimeError::new(
1609 RuntimeErrorCode::StoreCommitFailed,
1610 "prepared turn requires a session execution lease",
1611 ));
1612 }
1613 let session_execution_fence =
1614 session_execution_lease.map(SessionExecutionLeaseGuard::fence);
1615 let (event_tx, mut event_rx) = mpsc::channel::<RuntimeStreamEvent>(100);
1616 let child_usage_event_relay = ChildUsageEventRelay::new(event_tx.clone());
1617 let mut turn_policy = self.state.effective_policy().clone();
1618 let turn_provider_override = turn_context.provider().cloned();
1619 if let Some(provider) = turn_provider_override.as_ref() {
1620 turn_policy.provider_id = provider.kind().to_string();
1621 }
1622 let session_protocol_turn_options = self.state.effective_protocol_turn_options().clone();
1623 let effective_protocol_turn_options = protocol_turn_options
1624 .clone()
1625 .map(|options| session_protocol_turn_options.merged_with_override(&options))
1626 .unwrap_or(session_protocol_turn_options);
1627 let manager = self
1628 .runtime_session_services_for_turn(Some(child_usage_event_relay.clone()))
1629 .map_err(|err| {
1630 RuntimeError::new(RuntimeErrorCode::PluginSessionManager, err.to_string())
1631 })?;
1632 let plugins = {
1633 let session = self
1634 .session
1635 .as_ref()
1636 .expect("lash runtime session must be available");
1637 Arc::clone(session.plugins())
1638 };
1639 let mut assembler = TurnAssembler::new();
1640 self.mark_phase_begin(RuntimeTurnPhase::BeforeTurnHooks);
1641 let prepared = {
1647 let prepare_turn = plugins.prepare_turn_with_phase_probe(
1648 PrepareTurnRequest {
1649 session_id: self.state.session_id.clone(),
1650 state: crate::SessionReadView::from_runtime_state(
1651 &self.state,
1652 turn_policy.clone(),
1653 effective_protocol_turn_options.clone(),
1654 ),
1655 messages,
1656 sessions: manager.state_service(),
1657 session_lifecycle: manager.lifecycle_service(),
1658 session_graph: manager.graph_service(),
1659 turn_context: turn_context.clone(),
1660 },
1661 self.turn_phase_probe.clone(),
1662 );
1663 let mut prepare_turn = Box::pin(prepare_turn);
1664
1665 loop {
1666 tokio::select! {
1667 prepared = prepare_turn.as_mut() => {
1668 let prepared = prepared.map_err(|err| {
1669 RuntimeError::new(RuntimeErrorCode::PluginPrepareTurn, err.to_string())
1670 })?;
1671 self.mark_phase_end(RuntimeTurnPhase::BeforeTurnHooks);
1672 break prepared;
1673 }
1674 maybe_event = event_rx.recv() => {
1675 if let Some(event) = maybe_event {
1676 emit_runtime_stream_event_to_sinks(
1677 events,
1678 turn_events,
1679 event,
1680 &mut assembler,
1681 )
1682 .await;
1683 }
1684 }
1685 }
1686 }
1687 };
1688 for event in &prepared.events {
1689 assembler.push(event);
1690 }
1691 emit_session_events_to_sink(events, prepared.events).await;
1692 if let Some(abort) = prepared.abort {
1693 drop(event_tx);
1694
1695 let mut turn_pipeline = TurnBoundary::from_state_with_clock(
1696 self.state.clone(),
1697 Arc::clone(&self.host.core.clock),
1698 )
1699 .with_session_execution_lease(session_execution_fence.clone());
1700 turn_pipeline.apply_prepared_messages(&prepared.messages);
1701 let issue = TurnIssue {
1702 kind: "plugin".to_string(),
1703 code: Some(abort.code),
1704 terminal_reason: None,
1705 message: abort.message.clone(),
1706 raw: None,
1707 retryable: None,
1708 provider_failure_kind: None,
1709 };
1710 let error_event = SessionStreamEvent::Error {
1711 message: abort.message,
1712 envelope: Some(crate::session_model::ErrorEnvelope {
1713 kind: "plugin".to_string(),
1714 code: issue.code.clone(),
1715 terminal_reason: None,
1716 user_message: issue.message.clone(),
1717 raw: None,
1718 retryable: None,
1719 provider_failure_kind: None,
1720 }),
1721 };
1722 assembler.push(&error_event);
1723 emit_turn_activity_to_sink(
1724 turn_events,
1725 TurnActivity::independent(TurnEvent::Error {
1726 message: issue.message.clone(),
1727 }),
1728 )
1729 .await;
1730 emit_session_event_to_sink(events, error_event).await;
1731 let outcome_event = SessionStreamEvent::TurnOutcome {
1732 outcome: TurnOutcome::Stopped(TurnStop::PluginAbort),
1733 };
1734 assembler.push(&outcome_event);
1735 emit_session_event_to_sink(events, outcome_event).await;
1736 assembler.push(&SessionStreamEvent::Done);
1737 emit_session_event_to_sink(events, SessionStreamEvent::Done).await;
1738 return self
1739 .finish_turn(
1740 TurnFinishInput {
1741 turn_pipeline,
1742 assembler,
1743 new_messages: prepared.messages,
1744 policy: RuntimeSessionPolicy::new(
1745 self.state.effective_policy().clone(),
1746 Default::default(),
1747 ),
1748 turn_index,
1749 queued_work_claims: initial_queue_claims,
1750 turn_input_claims: initial_turn_input_claims,
1751 trace_turn_id,
1752 },
1753 events,
1754 &scoped_effect_controller,
1755 &cancel,
1756 session_execution_lease,
1757 session_execution_lease_release_policy,
1758 )
1759 .await;
1760 }
1761 let mut turn_pipeline = TurnBoundary::from_state_with_clock(
1762 self.state.clone(),
1763 Arc::clone(&self.host.core.clock),
1764 )
1765 .with_session_execution_lease(session_execution_fence.clone());
1766 let store = self
1767 .session
1768 .as_ref()
1769 .and_then(|session| session.history_store());
1770 let progress_store = if scoped_effect_controller.controller().durability_tier()
1774 == crate::DurabilityTier::Durable
1775 {
1776 None
1777 } else {
1778 store.as_ref().map(|store| store.as_ref())
1779 };
1780 turn_pipeline
1781 .prepared_checkpoint(
1782 progress_store,
1783 turn_policy.clone(),
1784 turn_index,
1785 &prepared.messages,
1786 self.session.as_mut(),
1787 )
1788 .await
1789 .map_err(super::runtime_error_from_store_commit)?;
1790 let resolved_turn_policy = if let Some(provider) = turn_provider_override {
1791 RuntimeSessionPolicy::from_provider(
1792 turn_policy.clone(),
1793 provider.with_clock(Arc::clone(&self.host.core.clock)),
1794 )
1795 .map_err(|err| RuntimeError::new("llm_provider", err.to_string()))?
1796 } else {
1797 self.host
1798 .resolve_session_policy(&self.state.session_id, turn_policy.clone())
1799 .map_err(|err| RuntimeError::new("llm_provider", err.to_string()))?
1800 };
1801 let manager = self
1802 .runtime_session_services_for_turn(Some(child_usage_event_relay.clone()))
1803 .map_err(|err| {
1804 RuntimeError::new(RuntimeErrorCode::PluginSessionManager, err.to_string())
1805 })?;
1806 let cancel_state = cancel.clone();
1807 let finish_scoped_effect_controller = scoped_effect_controller.clone();
1808 let session = self
1809 .session
1810 .take()
1811 .expect("lash runtime session must be available");
1812 let mut driver = RuntimeTurnDriver {
1813 session,
1814 policy: resolved_turn_policy,
1815 host: self.host.clone(),
1816 turn_id: scoped_effect_controller.scope_id().to_string(),
1817 scoped_effect_controller,
1818 session_id: self.state.session_id.clone(),
1819 turn_index,
1820 turn_pipeline,
1821 llm_stream_summaries: HashMap::new(),
1822 llm_calls: Vec::new(),
1823 next_llm_ordinal: 0,
1824 session_services: manager,
1825 protocol_turn_options: effective_protocol_turn_options,
1826 protocol_extension,
1827 turn_context,
1828 turn_causes: initial_turn_causes,
1829 pending_queue_claims: initial_queue_claims,
1830 pending_turn_input_claims: initial_turn_input_claims,
1831 checkpoint_messages: crate::tool_dispatch::CheckpointMessageBuffer::default(),
1832 session_execution_lease: session_execution_fence,
1833 runtime_lease_owner: self.runtime_lease_owner.clone(),
1834 turn_phase_probe: self.turn_phase_probe.clone(),
1835 };
1836 let protocol_run_offset = 0;
1837 self.mark_phase_begin(RuntimeTurnPhase::EffectLoop);
1838 let run_result = drive_turn_to_completion(
1839 driver.run(prepared.messages, event_tx, cancel, protocol_run_offset),
1840 &mut event_rx,
1841 &mut assembler,
1842 &child_usage_event_relay,
1843 events,
1844 turn_events,
1845 )
1846 .await;
1847 let (new_messages, _new_protocol_iteration) = match run_result {
1848 Ok(result) => result,
1849 Err(err) => {
1850 self.mark_phase_end(RuntimeTurnPhase::EffectLoop);
1851 let RuntimeTurnDriver {
1852 session,
1853 pending_queue_claims,
1854 pending_turn_input_claims,
1855 ..
1856 } = driver;
1857 self.session = Some(session);
1858 self.abandon_queued_work_claims_after_lease_loss(&err, &pending_queue_claims)
1859 .await;
1860 self.abandon_turn_input_claims_after_lease_loss(&err, &pending_turn_input_claims)
1861 .await;
1862 return Err(err);
1863 }
1864 };
1865 self.mark_phase_end(RuntimeTurnPhase::EffectLoop);
1866 tracing::debug!(
1867 new_message_count = new_messages.len(),
1868 tool_call_count = assembler.tool_calls.len(),
1869 "runtime post-run_task"
1870 );
1871
1872 let RuntimeTurnDriver {
1873 session,
1874 policy,
1875 turn_pipeline,
1876 llm_calls,
1877 pending_queue_claims,
1878 pending_turn_input_claims,
1879 ..
1880 } = driver;
1881 self.session = Some(session);
1882 let pending_queue_claims_for_abandon = pending_queue_claims.clone();
1883 let pending_turn_input_claims_for_abandon = pending_turn_input_claims.clone();
1884 let finish_result = self
1885 .finish_turn(
1886 TurnFinishInput {
1887 turn_pipeline,
1888 assembler: assembler.with_llm_calls(llm_calls),
1889 new_messages,
1890 policy,
1891 turn_index,
1892 queued_work_claims: pending_queue_claims,
1893 turn_input_claims: pending_turn_input_claims,
1894 trace_turn_id,
1895 },
1896 events,
1897 &finish_scoped_effect_controller,
1898 &cancel_state,
1899 session_execution_lease,
1900 session_execution_lease_release_policy,
1901 )
1902 .await;
1903 if let Err(err) = &finish_result {
1904 self.abandon_queued_work_claims_after_lease_loss(
1905 err,
1906 &pending_queue_claims_for_abandon,
1907 )
1908 .await;
1909 self.abandon_turn_input_claims_after_lease_loss(
1910 err,
1911 &pending_turn_input_claims_for_abandon,
1912 )
1913 .await;
1914 }
1915 finish_result
1916 }
1917 async fn normalize_input_items(
1918 &self,
1919 items: &[InputItem],
1920 image_blobs: &HashMap<String, Vec<u8>>,
1921 ) -> Result<Vec<NormalizedItem>, String> {
1922 normalize_input_items(
1923 items,
1924 image_blobs,
1925 self.host.core.durability.attachment_store.as_ref(),
1926 )
1927 .await
1928 }
1929}
1930
1931pub fn ensure_durable_effect_input(input: &TurnInput) -> Result<(), RuntimeError> {
1932 if input.protocol_extension.is_some() {
1933 return Err(RuntimeError::new(
1934 RuntimeErrorCode::DurableEffectLiveProtocolExtension,
1935 "durable effect hosts do not support live protocol_extension inputs; encode replayable data in protocol_turn_options or persisted plugin state",
1936 ));
1937 }
1938 input
1939 .turn_context
1940 .live_plugin_inputs()
1941 .durable_effect_rejection()?;
1942 Ok(())
1943}
1944
1945async fn emit_turn_activity_to_sink(events: &dyn TurnActivitySink, activity: TurnActivity) {
1946 if !events.is_noop() {
1947 events.emit(activity).await;
1948 }
1949}
1950
1951async fn drive_turn_to_completion<F>(
1961 run_future: F,
1962 event_rx: &mut mpsc::Receiver<RuntimeStreamEvent>,
1963 assembler: &mut TurnAssembler,
1964 child_usage_event_relay: &ChildUsageEventRelay,
1965 events: &dyn EventSink,
1966 turn_events: &dyn TurnActivitySink,
1967) -> Result<(crate::MessageSequence, usize), RuntimeError>
1968where
1969 F: std::future::Future<Output = Result<(crate::MessageSequence, usize), RuntimeError>>,
1970{
1971 let run_result = {
1972 let mut run_future = Box::pin(run_future);
1973 loop {
1974 tokio::select! {
1975 maybe_event = event_rx.recv() => {
1976 if let Some(event) = maybe_event {
1977 emit_runtime_stream_event_to_sinks(
1978 events,
1979 turn_events,
1980 event,
1981 assembler,
1982 )
1983 .await;
1984 }
1985 }
1986 completed = run_future.as_mut() => {
1987 child_usage_event_relay.clear();
1988 break completed;
1989 }
1990 }
1991 }
1992 };
1993 while let Some(event) = event_rx.recv().await {
1994 emit_runtime_stream_event_to_sinks(events, turn_events, event, assembler).await;
1995 }
1996 run_result
1997}
1998
1999async fn emit_runtime_stream_event_to_sinks(
2000 events: &dyn EventSink,
2001 turn_events: &dyn TurnActivitySink,
2002 event: RuntimeStreamEvent,
2003 assembler: &mut TurnAssembler,
2004) {
2005 match event {
2006 RuntimeStreamEvent::Session(event) => {
2007 assembler.push(&event);
2008 emit_session_event_to_sink(events, event).await;
2009 }
2010 RuntimeStreamEvent::Turn(activity) => {
2011 emit_turn_activity_to_sink(turn_events, activity).await;
2012 }
2013 }
2014}
2015
2016#[cfg(test)]
2017mod tests {
2018 use super::agent_frame_follow_turn_id;
2019
2020 #[test]
2021 fn agent_frame_follow_turn_ids_are_distinct_and_deterministic() {
2022 assert_eq!(agent_frame_follow_turn_id("root-turn", 0), "root-turn");
2023 assert_eq!(
2024 agent_frame_follow_turn_id("root-turn", 1),
2025 "root-turn:agent-frame:1"
2026 );
2027 assert_eq!(
2028 agent_frame_follow_turn_id("root-turn", 2),
2029 "root-turn:agent-frame:2"
2030 );
2031 }
2032}