Skip to main content

lash_core/runtime/
turn_loop.rs

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    /// Run a single turn and stream events to the host sink.
586    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    /// Enforce the durable-first wiring invariant at a turn-scope boundary: when
859    /// the host wired a durable effect host, every store reachable from this
860    /// scope must also be durable. A durable host running against any ephemeral
861    /// store fails loudly here rather than silently degrading.
862    ///
863    /// Inline controllers (the default tier) impose no requirement, so
864    /// inline/in-memory hosts pass unchanged.
865    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    /// Stream one logical host turn, following foreground AgentFrame switches
983    /// until a terminal outcome is reached.
984    ///
985    /// A protocol continuation creates a new frame in the same session. Hosts
986    /// that only care about the benchmark/app answer should not need to
987    /// special-case that intermediate outcome; this helper keeps driving the
988    /// same session through each frame's task with the normal runtime turn
989    /// guards.
990    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        // Release the read-view's graph clone before the rest of the turn
1346        // runs. Keeping it alive into `stream_prepared_turn` forces the
1347        // post-turn `append_active_read_delta` to deep-clone the session
1348        // graph (Arc::make_mut with refcount > 1).
1349        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    /// Run a single turn and return only the assembled terminal result.
1389    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    /// Run a turn using host-prepared message history.
1400    #[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        // Block-scope the pinned future so it (and its captured
1509        // `SessionReadView` clone of the session graph) drops before the
1510        // post-turn `append_active_read_delta` mutation. Keeping it alive
1511        // across the turn forces `Arc::make_mut` to deep-clone
1512        // `SessionGraphData`.
1513        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        // Durable controllers, like Restate, own in-flight replay. Writing
1618        // progress checkpoints directly to the shared store would make handler
1619        // replay observe a newer partial turn and change effect replay keys.
1620        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
1821/// Pump the turn driver's event channel into the host sinks while the run
1822/// future executes, then drain any events emitted between completion and the
1823/// sender dropping.
1824///
1825/// Both the fresh and resumed turn entry points construct a
1826/// `RuntimeTurnDriver`, kick off its run future, and need identical
1827/// event-pump/drain behavior before tearing the driver down. Only the driver
1828/// construction and post-run teardown differ, so each caller owns those and
1829/// shares this loop.
1830async 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}