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