Skip to main content

bamboo_engine/runtime/execution/
agent_spawn.rs

1//! Core agent execution spawning logic.
2//!
3//! Provides [`spawn_session_execution`] which handles the full lifecycle of a
4//! background agent run: spawn task → execute → finalize runner → persist session.
5
6use std::collections::{BTreeSet, HashMap};
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11use tokio::sync::{mpsc, RwLock};
12use tokio_util::sync::CancellationToken;
13use tracing::Instrument;
14
15use bamboo_agent_core::tools::ToolExecutor;
16use bamboo_agent_core::{AgentError, AgentEvent, Session};
17use bamboo_domain::ReasoningEffort;
18use bamboo_llm::LLMProvider;
19
20use crate::runtime::config::{
21    AuxiliaryModelConfig, BashCompletionSink, BashResumeHook, GoldConfig, GuardianConfig,
22    GuardianSpawner, ImageFallbackConfig,
23};
24use crate::runtime::execution::child_completion::ChildCompletion;
25use crate::runtime::execution::runner_lifecycle::finalize_runner;
26use crate::runtime::execution::runner_state::AgentRunner;
27use crate::runtime::model_roster::ModelRoster;
28use crate::runtime::Agent;
29use crate::runtime::{ExecuteRequest, ExecuteRequestBuilder};
30
31/// Shared, per-session-locked session cache.
32///
33/// A `DashMap` gives each session id its own shard-level lock (so unrelated
34/// sessions never contend), and the inner `parking_lot::RwLock` is a *sync*
35/// lock held only to briefly clone-out or mutate-and-write-back a single
36/// `Session` — never across an `.await`. Using a sync lock makes "no guard
37/// across await" a compile-time guarantee in Send futures.
38pub type SessionCache = std::sync::Arc<
39    dashmap::DashMap<String, std::sync::Arc<parking_lot::RwLock<bamboo_agent_core::Session>>>,
40>;
41
42/// Read a session out of the in-memory cache, cloning it out from under the
43/// brief sync read-lock. Returns `None` on a cache miss.
44///
45/// This is the single canonical cache-read used everywhere a caller holds a
46/// `SessionCache` (HTTP handlers, server tools, the app-state loader). It
47/// replaced ~13 verbatim copies of the
48/// `cache.get(id).map(|e| e.value().clone()).map(|a| a.read().clone())` idiom.
49pub fn read_cached_session(cache: &SessionCache, id: &str) -> Option<bamboo_agent_core::Session> {
50    cache
51        .get(id)
52        .map(|e| e.value().clone())
53        .map(|a| a.read().clone())
54}
55
56const SKILL_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_SKILL_CONTEXT_START -->";
57const TOOL_GUIDE_START_MARKER: &str = "<!-- BAMBOO_TOOL_GUIDE_START -->";
58const EXTERNAL_MEMORY_START_MARKER: &str = "<!-- BAMBOO_EXTERNAL_MEMORY_START -->";
59const TASK_LIST_START_MARKER: &str = "<!-- BAMBOO_TASK_LIST_START -->";
60
61/// Outcome of an agent execution, handed to an optional
62/// [`SessionCompletionHook`].
63///
64/// Deliberately decoupled from the runtime's internal error type so the hook
65/// API stays stable across crates and callers don't need to match on engine
66/// error variants.
67pub struct SessionExecutionOutcome {
68    /// The run finished without error.
69    pub success: bool,
70    /// The run ended because it was cancelled (a non-success subset).
71    pub cancelled: bool,
72    /// Stringified error, present when `!success`.
73    pub error: Option<String>,
74}
75
76impl SessionExecutionOutcome {
77    fn from_result(result: &Result<(), AgentError>) -> Self {
78        match result {
79            Ok(()) => Self {
80                success: true,
81                cancelled: false,
82                error: None,
83            },
84            Err(error) => Self {
85                success: false,
86                cancelled: error.is_cancelled(),
87                error: Some(error.to_string()),
88            },
89        }
90    }
91}
92
93/// Optional post-execution hook for [`spawn_session_execution`].
94///
95/// Invoked after the runner is finalized but **before** the session is
96/// persisted, so a caller can record bespoke terminal bookkeeping (e.g. a
97/// scheduled-run status) and/or append a closing message that is then saved
98/// with the session. Receives the execution outcome plus a mutable handle to
99/// the session. This is how callers with extra finalization (the schedule
100/// manager) route through the single canonical execution path instead of
101/// forking their own spawn + `execute` + persist sequence.
102pub type SessionCompletionHook = Box<
103    dyn for<'a> FnOnce(
104            SessionExecutionOutcome,
105            &'a mut Session,
106        ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>
107        + Send,
108>;
109
110/// Arguments for spawning a background agent execution.
111///
112/// This is the crate-agnostic equivalent of the server's `SpawnAgentExecution`.
113/// It holds everything needed to run the agent loop and persist the result,
114/// without depending on HTTP types or `AppState`.
115pub struct SessionExecutionArgs {
116    // Core execution.
117    pub agent: Arc<Agent>,
118    pub session_id: String,
119    pub session: Session,
120
121    // Execution parameters.
122    pub tools_override: Option<Arc<dyn ToolExecutor>>,
123    pub provider_override: Option<Arc<dyn LLMProvider>>,
124    /// Cohesive primary + auxiliary model/provider selection. The primary
125    /// `model` is required for a spawn (see [`ModelRoster::model`]); the three
126    /// auxiliary roles default to their `Config::get_*` fallbacks when `None`.
127    pub model_roster: ModelRoster,
128    pub reasoning_effort: Option<ReasoningEffort>,
129    pub reasoning_effort_source: String,
130    pub auxiliary_model_resolver:
131        Option<Arc<dyn Fn() -> crate::runtime::config::AuxiliaryModelConfig + Send + Sync>>,
132    /// Optional per-round live resolver for the disabled tool/skill sets (#136).
133    /// When `None` the per-run snapshot is used (sub-agent spawns pass `None`, so
134    /// short-lived children keep the spawn-time snapshot — by design).
135    pub disabled_filter_resolver:
136        Option<Arc<dyn Fn() -> (BTreeSet<String>, BTreeSet<String>) + Send + Sync>>,
137    pub disabled_tools: Option<BTreeSet<String>>,
138    pub disabled_skill_ids: Option<BTreeSet<String>>,
139    pub selected_skill_ids: Option<Vec<String>>,
140    pub selected_skill_mode: Option<String>,
141    pub cancel_token: CancellationToken,
142    pub mpsc_tx: mpsc::Sender<AgentEvent>,
143    pub image_fallback: Option<ImageFallbackConfig>,
144    pub gold_config: Option<GoldConfig>,
145    /// Optional guardian adversarial-review gate configuration.
146    pub guardian_config: Option<GuardianConfig>,
147    /// Late-bound guardian reviewer spawner (server-provided; the runner cannot
148    /// construct a child directly).
149    pub guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
150    /// Late-bound bash self-resume hook (issue #84 Phase 2b).
151    pub bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
152    /// Late-bound bash completion sink (issue #84 Phase 2b follow-up).
153    pub bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
154    pub app_data_dir: Option<std::path::PathBuf>,
155    /// Per-run resource guardrail override (issue #221). `None` uses the
156    /// config-level `Config::run_budget` default unmodified.
157    pub run_budget: Option<bamboo_config::RunBudgetConfig>,
158
159    // Post-execution resources.
160    pub runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
161    pub sessions_cache: SessionCache,
162
163    /// Optional bespoke finalization, run after the runner is finalized and
164    /// before the session is persisted. See [`SessionCompletionHook`].
165    pub on_complete: Option<SessionCompletionHook>,
166
167    /// Child-completion publisher for CHILD sessions driven through this path
168    /// (issue #546). The canonical first run of a child goes through
169    /// `run_child_spawn`, which publishes its own terminal completion — but a
170    /// child RESUMED later (after an approval, a clarification answer, or a
171    /// nested child-parent woken by its own children) runs through
172    /// `spawn_session_execution` and previously published nothing, so the
173    /// waiting parent was never woken. When set and `session.kind == Child`
174    /// with a parent id, the terminal block invokes this handler after the
175    /// final persist + runner finalization. Non-terminal suspends publish the
176    /// non-terminal "suspended" status, which the coordinator's terminality
177    /// guard ignores.
178    pub child_completion_handler: Option<Arc<dyn super::ChildCompletionHandler>>,
179}
180
181/// The per-request parameter subset of [`SessionExecutionArgs`] — everything
182/// that maps onto an [`ExecuteRequest`], minus the three required positional
183/// fields (`initial_message`, `event_tx`, `cancel_token`) and the post-execution
184/// resources (runners, sessions cache, completion hook).
185///
186/// Grouping these here lets [`build_execute_request`] perform the
187/// `SessionExecutionArgs` → [`ExecuteRequest`] mapping through the canonical
188/// [`ExecuteRequestBuilder`] in one place, instead of a hand-written struct
189/// literal that must be kept field-aligned with [`ExecuteRequest`] by hand.
190struct ExecuteRequestParams {
191    tools: Option<Arc<dyn ToolExecutor>>,
192    provider_override: Option<Arc<dyn LLMProvider>>,
193    model_roster: ModelRoster,
194    reasoning_effort: Option<ReasoningEffort>,
195    auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
196    disabled_filter_resolver:
197        Option<Arc<dyn Fn() -> (BTreeSet<String>, BTreeSet<String>) + Send + Sync>>,
198    disabled_tools: Option<BTreeSet<String>>,
199    disabled_skill_ids: Option<BTreeSet<String>>,
200    selected_skill_ids: Option<Vec<String>>,
201    selected_skill_mode: Option<String>,
202    image_fallback: Option<ImageFallbackConfig>,
203    gold_config: Option<GoldConfig>,
204    guardian_config: Option<GuardianConfig>,
205    guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
206    bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
207    bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
208    app_data_dir: Option<std::path::PathBuf>,
209    run_budget: Option<bamboo_config::RunBudgetConfig>,
210}
211
212/// Assemble an [`ExecuteRequest`] from the resolved spawn parameters via the
213/// canonical [`ExecuteRequestBuilder`].
214///
215/// Centralizing this mapping keeps every optional field threaded with exactly
216/// the same value the old struct literal carried (the builder defaults each
217/// unset field to `None`), while removing the field-by-field duplication.
218fn build_execute_request(
219    initial_message: String,
220    event_tx: mpsc::Sender<AgentEvent>,
221    cancel_token: CancellationToken,
222    params: ExecuteRequestParams,
223) -> ExecuteRequest {
224    let ExecuteRequestParams {
225        tools,
226        provider_override,
227        model_roster,
228        reasoning_effort,
229        auxiliary_model_resolver,
230        disabled_filter_resolver,
231        disabled_tools,
232        disabled_skill_ids,
233        selected_skill_ids,
234        selected_skill_mode,
235        image_fallback,
236        gold_config,
237        guardian_config,
238        guardian_spawner,
239        bash_resume_hook,
240        bash_completion_sink,
241        app_data_dir,
242        run_budget,
243    } = params;
244
245    let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token)
246        .model_roster(model_roster)
247        .gold_config(gold_config)
248        .guardian_config(guardian_config)
249        .guardian_spawner(guardian_spawner)
250        .bash_resume_hook(bash_resume_hook)
251        .bash_completion_sink(bash_completion_sink);
252
253    if let Some(run_budget) = run_budget {
254        builder = builder.run_budget(run_budget);
255    }
256
257    if let Some(tools) = tools {
258        builder = builder.tools(tools);
259    }
260    if let Some(provider_override) = provider_override {
261        builder = builder.provider_override(provider_override);
262    }
263    if let Some(reasoning_effort) = reasoning_effort {
264        builder = builder.reasoning_effort(reasoning_effort);
265    }
266    if let Some(disabled_filter_resolver) = disabled_filter_resolver {
267        builder = builder.disabled_filter_resolver(disabled_filter_resolver);
268    }
269    if let Some(auxiliary_model_resolver) = auxiliary_model_resolver {
270        builder = builder.auxiliary_model_resolver(auxiliary_model_resolver);
271    }
272    if let Some(disabled_tools) = disabled_tools {
273        builder = builder.disabled_tools(disabled_tools);
274    }
275    if let Some(disabled_skill_ids) = disabled_skill_ids {
276        builder = builder.disabled_skill_ids(disabled_skill_ids);
277    }
278    if let Some(selected_skill_ids) = selected_skill_ids {
279        builder = builder.selected_skill_ids(selected_skill_ids);
280    }
281    if let Some(selected_skill_mode) = selected_skill_mode {
282        builder = builder.selected_skill_mode(selected_skill_mode);
283    }
284    if let Some(image_fallback) = image_fallback {
285        builder = builder.image_fallback(image_fallback);
286    }
287    if let Some(app_data_dir) = app_data_dir {
288        builder = builder.app_data_dir(app_data_dir);
289    }
290
291    builder.build()
292}
293
294/// Spawn a background agent execution task.
295///
296/// This function spawns a tokio task that:
297/// 1. Executes the agent loop via `agent.execute()`
298/// 2. Sends a terminal error event if the execution fails
299/// 3. Finalizes the runner status
300/// 4. Persists the session via merge-save (preserves concurrent UI title/pin edits)
301/// 5. Updates the in-memory session cache
302pub fn spawn_session_execution(args: SessionExecutionArgs) {
303    let span_session_id = args.session_id.clone();
304    let session_span = tracing::info_span!("agent_execution", session_id = %span_session_id);
305
306    tokio::spawn(
307        async move {
308            let SessionExecutionArgs {
309                agent,
310                session_id,
311                mut session,
312                tools_override,
313                provider_override,
314                model_roster,
315                reasoning_effort,
316                reasoning_effort_source,
317                auxiliary_model_resolver,
318                disabled_filter_resolver,
319                disabled_tools,
320                disabled_skill_ids,
321                selected_skill_ids,
322                selected_skill_mode,
323                cancel_token,
324                mpsc_tx,
325                image_fallback,
326                gold_config,
327                guardian_config,
328                guardian_spawner,
329                bash_resume_hook,
330                bash_completion_sink,
331                app_data_dir,
332                run_budget,
333                runners,
334                sessions_cache,
335                on_complete,
336                child_completion_handler,
337            } = args;
338
339            // The primary model is required for a spawn; the roster stores it as
340            // `Option<String>` for uniformity, so recover the owned String here
341            // for session attribution / logging (same value the caller set).
342            let model = model_roster.model.clone().unwrap_or_default();
343
344            let initial_message = initial_user_message_for_session(&session);
345            let selected_skill_ids =
346                selected_skill_ids.or_else(|| selected_skill_ids_for_session(&session));
347            let selected_skill_mode =
348                selected_skill_mode.or_else(|| selected_skill_mode_for_session(&session));
349
350            tracing::info!(
351                "[{}] Using resolved session model: {}, reasoning_effort={}, reasoning_source={}",
352                session_id,
353                model,
354                reasoning_effort
355                    .map(ReasoningEffort::as_str)
356                    .unwrap_or("none"),
357                reasoning_effort_source
358            );
359
360            // Set the resolved model via the single authoritative pre-execution
361            // mutation point. The caller already placed the system prompt on the
362            // session, so pass `None` for `system_prompt` (the subsequent
363            // `system_prompt_for_session` read below sees the caller's message).
364            // This must run before that read / logging so the observable
365            // sequence (model set, then prompt snapshot) is identical.
366            crate::session_app::execution_prep::prepare_session_for_execution(
367                &mut session,
368                None,
369                Some(&model),
370            );
371
372            let system_prompt = system_prompt_for_session(&session);
373            if let Some(prompt) = system_prompt.as_ref() {
374                log_base_system_prompt_snapshot(&session_id, prompt);
375            }
376
377            let execute_request = build_execute_request(
378                initial_message,
379                mpsc_tx.clone(),
380                cancel_token,
381                ExecuteRequestParams {
382                    tools: tools_override,
383                    provider_override,
384                    model_roster,
385                    reasoning_effort,
386                    auxiliary_model_resolver,
387                    disabled_filter_resolver,
388                    disabled_tools,
389                    disabled_skill_ids,
390                    selected_skill_ids,
391                    selected_skill_mode,
392                    image_fallback,
393                    gold_config,
394                    guardian_config,
395                    guardian_spawner,
396                    bash_resume_hook,
397                    bash_completion_sink,
398                    app_data_dir,
399                    run_budget,
400                },
401            );
402
403            // Panic containment (issue #546): everything below — the terminal
404            // status persist, finalize_runner, and the child-completion
405            // publish — only runs if this task survives execution. An
406            // unwinding panic would leave a zombie Running runner entry (which
407            // blinds liveness checks) and, for a child session, strand its
408            // waiting parent. Map a panic to a terminal error instead.
409            let result = {
410                use futures::FutureExt;
411                match std::panic::AssertUnwindSafe(agent.execute(&mut session, execute_request))
412                    .catch_unwind()
413                    .await
414                {
415                    Ok(result) => result,
416                    Err(panic) => {
417                        let message = panic
418                            .downcast_ref::<&str>()
419                            .map(|s| (*s).to_string())
420                            .or_else(|| panic.downcast_ref::<String>().cloned())
421                            .unwrap_or_else(|| "non-string panic payload".to_string());
422                        tracing::error!(
423                            "[{}] agent execution panicked; finalizing as terminal error: {}",
424                            session_id,
425                            message
426                        );
427                        Err(AgentError::LLM(format!(
428                            "agent execution panicked: {message}"
429                        )))
430                    }
431                }
432            };
433
434            // Send terminal event for all error cases (including cancellation).
435            if let Some(error_event) = terminal_error_event_for_result(&result) {
436                let _ = mpsc_tx.send(error_event).await;
437            }
438
439            // Record the terminal run status on the session BEFORE persisting so
440            // the session summary reports a real `last_run_status`. Top-level
441            // sessions otherwise never set it, so summaries show
442            // `last_run_status: null`; the frontend then cannot confirm the run
443            // finished and falls back on a ~5s optimistic-settle window, leaving
444            // a phantom "thinking" indicator after the reply is already done
445            // (notably on a session's first turn). Same Ok/cancelled/error
446            // mapping `status_from_execution_result` applies to the runner.
447            //
448            // A suspended run also returns `Ok(())` but is NOT terminal: it
449            // stamped `runtime.suspend_reason` (awaiting_clarification /
450            // waiting_for_children / waiting_for_bash / awaiting_parent_approval)
451            // and will resume later (which removes the reason — see respond.rs /
452            // child_completion_coordinator). Mark it "suspended" rather than
453            // "completed" so a session waiting on the user or on children isn't
454            // reported as finished (mirrors the child path in `sdk::spawn`).
455            let suspended_non_terminal = result.is_ok()
456                && session
457                    .metadata
458                    .get("runtime.suspend_reason")
459                    .is_some_and(|reason| !reason.trim().is_empty());
460            match &result {
461                Ok(()) if suspended_non_terminal => {
462                    session.set_last_run_status("suspended");
463                    session.clear_last_run_error();
464                }
465                Ok(()) => {
466                    session.set_last_run_status("completed");
467                    session.clear_last_run_error();
468                }
469                Err(error) if error.is_cancelled() => {
470                    session.set_last_run_status("cancelled");
471                    session.set_last_run_error(error.to_string());
472                }
473                Err(error) => {
474                    session.set_last_run_status("error");
475                    session.set_last_run_error(error.to_string());
476                }
477            }
478
479            // Bespoke terminal bookkeeping (e.g. a scheduled-run status) runs
480            // here — before persistence — so any closing message the hook
481            // appends is saved with the session below.
482            if let Some(on_complete) = on_complete {
483                on_complete(SessionExecutionOutcome::from_result(&result), &mut session).await;
484            }
485
486            // Save session via merge-save so any concurrent UI edits to
487            // title / pinned / title_version are preserved (the runtime is not
488            // an authoritative title writer).
489            if let Err(error) = agent.persistence().save_runtime_session(&mut session).await {
490                tracing::warn!("[{}] Failed to save session: {}", session_id, error);
491            }
492
493            // Flip the runner registry to a terminal status (which makes session
494            // summaries report `is_running: false`) ONLY AFTER the run status is
495            // persisted above, so `is_running` and `last_run_status` become
496            // visible together and the frontend settles immediately instead of
497            // lingering in its optimistic-settle window.
498            finalize_runner(&runners, &session_id, &result).await;
499
500            // A CHILD session finishing through this path (a resumed child, or
501            // a nested child-parent woken by its own children) must wake its
502            // waiting parent (issue #546). Publish AFTER the final persist and
503            // runner finalization so the coordinator reads the child's settled
504            // terminal state — the resume message folds in the child's final
505            // assistant content from storage. The status mirrors the
506            // `last_run_status` mapping above; a non-terminal "suspended" is
507            // published too and ignored by the coordinator's terminality guard.
508            let child_completion = child_completion_handler.filter(|_| {
509                session.kind == bamboo_agent_core::SessionKind::Child
510                    && session.parent_session_id.is_some()
511            });
512            let parent_session_id = session.parent_session_id.clone();
513            let child_status = session.last_run_status();
514            let child_error = session.last_run_error();
515
516            // Update memory cache.
517            sessions_cache.insert(
518                session_id.clone(),
519                Arc::new(parking_lot::RwLock::new(session)),
520            );
521
522            if let (Some(handler), Some(parent_session_id), Some(status)) =
523                (child_completion, parent_session_id, child_status)
524            {
525                use futures::FutureExt;
526                let completion = ChildCompletion {
527                    parent_session_id: parent_session_id.clone(),
528                    child_session_id: session_id.clone(),
529                    status,
530                    error: child_error,
531                    completed_at: chrono::Utc::now(),
532                };
533                if std::panic::AssertUnwindSafe(handler.on_child_completed(completion))
534                    .catch_unwind()
535                    .await
536                    .is_err()
537                {
538                    tracing::error!(
539                        %parent_session_id,
540                        child_session_id = %session_id,
541                        "child completion handler panicked on resumed-child terminal"
542                    );
543                }
544            }
545
546            tracing::info!("[{}] Agent execution completed", session_id);
547        }
548        .instrument(session_span),
549    );
550}
551
552/// Log a snapshot of the base system prompt for debugging.
553pub fn log_base_system_prompt_snapshot(session_id: &str, prompt: &str) {
554    tracing::info!(
555        "[{}] Base system prompt snapshot: len={} chars, has_skill={}, has_tool_guide={}, has_external_memory={}, has_task_list={}",
556        session_id,
557        prompt.len(),
558        prompt.contains(SKILL_CONTEXT_START_MARKER),
559        prompt.contains(TOOL_GUIDE_START_MARKER),
560        prompt.contains(EXTERNAL_MEMORY_START_MARKER),
561        prompt.contains(TASK_LIST_START_MARKER),
562    );
563
564    tracing::debug!(
565        "[{}] ========== BASE SYSTEM PROMPT SNAPSHOT ==========",
566        session_id
567    );
568    tracing::debug!("[{}] Snapshot length: {} chars", session_id, prompt.len());
569    tracing::debug!("[{}] -----------------------------------", session_id);
570    tracing::debug!("[{}] {}", session_id, prompt);
571    tracing::debug!(
572        "[{}] ========== END BASE SYSTEM PROMPT SNAPSHOT ==========",
573        session_id
574    );
575}
576
577/// Map an execution result to a terminal error event.
578pub fn terminal_error_event_for_result(result: &Result<(), AgentError>) -> Option<AgentEvent> {
579    match result {
580        Ok(_) => None,
581        Err(error) if error.is_cancelled() => Some(AgentEvent::Error {
582            message: "Agent execution cancelled by user".to_string(),
583        }),
584        Err(error) => Some(AgentEvent::Error {
585            message: error.to_string(),
586        }),
587    }
588}
589
590// Session metadata helpers (pure functions, no server dependency).
591
592fn system_prompt_for_session(session: &Session) -> Option<String> {
593    session
594        .messages
595        .iter()
596        .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
597        .map(|message| message.content.clone())
598}
599
600fn initial_user_message_for_session(session: &Session) -> String {
601    session
602        .messages
603        .last()
604        .filter(|message| matches!(message.role, bamboo_agent_core::Role::User))
605        .map(|message| message.content.clone())
606        .unwrap_or_default()
607}
608
609fn selected_skill_ids_for_session(session: &Session) -> Option<Vec<String>> {
610    session
611        .metadata
612        .get("selected_skill_ids")
613        .and_then(|raw| bamboo_skills::selection::parse_selected_skill_ids_metadata(raw))
614}
615
616fn selected_skill_mode_for_session(session: &Session) -> Option<String> {
617    let value = session
618        .metadata
619        .get("skill_mode")
620        .or_else(|| session.metadata.get("mode"))?;
621    let trimmed = value.trim();
622    if trimmed.is_empty() {
623        None
624    } else {
625        Some(trimmed.to_string())
626    }
627}