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