Skip to main content

bamboo_engine/session_app/
execution_prep.rs

1//! Single authoritative pre-execution session mutation point.
2//!
3//! Historically three execution entry points each duplicated — with subtly
4//! different logic — the work of (a) placing the authoritative leading System
5//! message and (b) setting `session.model` before handing the session to the
6//! agent loop. This module consolidates that into [`prepare_session_for_execution`]
7//! so there is exactly one place that defines the pre-execution mutation.
8//!
9//! The three callers are:
10//! - the SDK facade (`bamboo_sdk::agent::Agent::execute_internal`), which owns a
11//!   configured instruction and model and passes both;
12//! - the server spawn path (`runtime::execution::agent_spawn::spawn_session_execution`),
13//!   whose caller has already placed the system prompt, so it passes `None` for
14//!   `system_prompt` and only the resolved model;
15//! - the child spawn path (`sdk::spawn::run_child_spawn`), likewise `None` for the
16//!   system prompt and only the child model.
17
18use std::path::Path;
19
20use bamboo_agent_core::{AgentError, Message, Role, Session};
21
22use crate::project_context::{
23    ProjectContextResolver, SessionProjectIdentity, WorkspaceBindingStatus, WorkspaceSource,
24    WORKSPACE_BINDING_STATUS_METADATA_KEY, WORKSPACE_SOURCE_METADATA_KEY,
25};
26
27/// A workspace that an adapter has already validated for one execution.
28///
29/// Keeping path, provenance, and Project-binding status in one value prevents
30/// Schedule/Connect factories from publishing a path and taking their first
31/// prompt snapshot before the two authority fields are present.
32#[derive(Debug, Clone, Copy)]
33pub struct ResolvedExecutionWorkspace<'a> {
34    pub path: &'a Path,
35    pub source: WorkspaceSource,
36    pub binding_status: WorkspaceBindingStatus,
37}
38
39/// Publish an adapter-validated workspace and its typed provenance as one
40/// pre-snapshot operation.
41///
42/// The supplied [`bamboo_agent_core::workspace_state::WorkspaceResolver`] must
43/// be the same instance that performed validation. Publication can materialize
44/// a resolver-owned fallback, so the returned path is the only path persisted
45/// on the session.
46pub fn publish_resolved_workspace_for_execution(
47    session: &mut Session,
48    workspace: ResolvedExecutionWorkspace<'_>,
49    workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
50    publication_source: &str,
51) {
52    let final_workspace = workspace_resolver.publish_resolved_workspace(
53        &session.id,
54        workspace.path.to_path_buf(),
55        publication_source,
56    );
57    session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
58        &final_workspace,
59    ));
60    session.metadata.insert(
61        WORKSPACE_SOURCE_METADATA_KEY.to_string(),
62        workspace.source.as_str().to_string(),
63    );
64    session.metadata.insert(
65        WORKSPACE_BINDING_STATUS_METADATA_KEY.to_string(),
66        workspace.binding_status.as_str().to_string(),
67    );
68}
69
70/// Prepare a caller-owned session before any approved-tool replay or provider
71/// execution.
72///
73/// This is the engine-owned external handoff seam used by the SDK. It is
74/// deliberately idempotent and runs before a configured System prompt replaces
75/// the caller's leading message:
76///
77/// - validate Project identity and required resolver authority without mutation;
78/// - recover and remove legacy host-owned prompt blocks;
79/// - validate Project workspace ownership when a resolver exists;
80/// - fail closed for an assigned Project when no resolver authority exists;
81/// - publish the exact runtime workspace and typed path/source/binding metadata;
82/// - refresh the typed prompt snapshot without ever persisting host context in
83///   System text.
84pub async fn prepare_external_session_for_execution(
85    session: &mut Session,
86    project_context_resolver: Option<&ProjectContextResolver>,
87) -> Result<(), AgentError> {
88    // Establish that this process has the authority required for the session
89    // before migrating any retryable legacy state. In particular, an assigned
90    // session without a Project resolver must remain byte-for-byte retryable:
91    // even a marker-only System message cannot be consumed on this error path.
92    match ProjectContextResolver::session_project_identity(session) {
93        SessionProjectIdentity::Invalid { raw, message } => {
94            return Err(AgentError::ProjectContext(format!(
95                "session carries an invalid Project identity '{raw}': {message}"
96            )));
97        }
98        SessionProjectIdentity::Assigned(project_id) if project_context_resolver.is_none() => {
99            return Err(AgentError::ProjectContext(format!(
100                "assigned Project '{project_id}' requires a ProjectContextResolver before external execution"
101            )));
102        }
103        SessionProjectIdentity::Assigned(_) | SessionProjectIdentity::Unassigned => {}
104    }
105
106    // Once authority is known to be sufficient, recover a legacy workspace
107    // before resolution: its host-owned System block may be the sole source of
108    // the path that the resolver must validate and publish.
109    crate::runtime::runner::session_setup::migrate_legacy_workspace_prompt(session);
110
111    if let Some(resolver) = project_context_resolver {
112        resolver
113            .refresh_session_prompt(session)
114            .await
115            .map_err(|error| AgentError::ProjectContext(error.to_string()))?;
116        return Ok(());
117    }
118
119    // An unassigned embedding has no Project authority to consult, but it must
120    // still hand the resolved workspace to tools before replay/provider work.
121    // The process-global resolver is the compatibility authority for this SDK
122    // shape. Unassigned workspaces are necessarily unregistered.
123    let workspace = ProjectContextResolver::resolve_workspace_candidate(session, None)
124        .map_err(|error| AgentError::ProjectContext(error.to_string()))?;
125    if let Some(workspace) = workspace.as_deref() {
126        let source = WorkspaceSource::from_metadata(
127            session
128                .metadata
129                .get(WORKSPACE_SOURCE_METADATA_KEY)
130                .map(String::as_str),
131        );
132        publish_resolved_workspace_for_execution(
133            session,
134            ResolvedExecutionWorkspace {
135                path: workspace,
136                source,
137                binding_status: WorkspaceBindingStatus::Unregistered,
138            },
139            &bamboo_agent_core::workspace_state::WorkspaceResolver::from_process_globals(),
140            "external_execution_prep",
141        );
142    } else {
143        session.metadata.remove(WORKSPACE_SOURCE_METADATA_KEY);
144        session
145            .metadata
146            .remove(WORKSPACE_BINDING_STATUS_METADATA_KEY);
147    }
148    crate::runner::refresh_prompt_snapshot(session);
149    Ok(())
150}
151
152/// Apply the authoritative pre-execution mutations to `session`.
153///
154/// This encodes the single, authoritative behavior that every execution entry
155/// point must share:
156///
157/// - If `system_prompt` is `Some`, it is applied as the session's **leading**
158///   System message. The supplied prompt is authoritative: if the first message
159///   is already a [`Role::System`] message it is *replaced* (never duplicated),
160///   otherwise a System message is *inserted* at index 0. This guarantees a
161///   caller-supplied session can't silently shadow the configured instruction.
162/// - If `model` is `Some`, `session.model` is set to it.
163///
164/// Call sites that don't supply one of these inputs (e.g. the spawn paths, whose
165/// caller already placed the system prompt) pass `None` for that parameter, so
166/// behavior is identical to the previous inline logic.
167pub fn prepare_session_for_execution(
168    session: &mut Session,
169    system_prompt: Option<&str>,
170    model: Option<&str>,
171) {
172    if let Some(prompt) = system_prompt {
173        match session.messages.first() {
174            Some(first) if matches!(first.role, Role::System) => {
175                session.messages[0] = Message::system(prompt.to_string());
176            }
177            _ => session
178                .messages
179                .insert(0, Message::system(prompt.to_string())),
180        }
181    }
182
183    if let Some(model) = model {
184        session.model = model.to_string();
185    }
186
187    // A configured prompt may itself have been copied from a legacy Bamboo
188    // snapshot. Strip only recognized host-owned blocks after replacement;
189    // recovered typed metadata remains authoritative. Every caller observes a
190    // snapshot whose System bytes and typed workspace context agree.
191    crate::runtime::runner::session_setup::migrate_legacy_workspace_prompt(session);
192    crate::runner::refresh_prompt_snapshot(session);
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use bamboo_agent_core::Message;
199    use std::sync::Arc;
200
201    struct EmptyProjectSource;
202
203    #[async_trait::async_trait]
204    impl crate::project_context::ProjectContextSource for EmptyProjectSource {
205        async fn find_project(
206            &self,
207            _project_id: &bamboo_domain::ProjectId,
208        ) -> Result<
209            Option<crate::project_context::ProjectDescriptor>,
210            crate::project_context::ProjectContextError,
211        > {
212            Ok(None)
213        }
214    }
215
216    fn session_with(messages: Vec<Message>) -> Session {
217        let mut s = Session::new("test-session", "old-model");
218        s.messages = messages;
219        s
220    }
221
222    #[test]
223    fn empty_session_with_prompt_inserts_at_index_zero() {
224        let mut session = session_with(vec![Message::user("hello")]);
225
226        prepare_session_for_execution(&mut session, Some("you are helpful"), None);
227
228        assert_eq!(session.messages.len(), 2);
229        assert!(matches!(session.messages[0].role, Role::System));
230        assert_eq!(session.messages[0].content, "you are helpful");
231        assert!(matches!(session.messages[1].role, Role::User));
232    }
233
234    #[test]
235    fn leading_system_is_replaced_not_duplicated() {
236        let mut session = session_with(vec![
237            Message::system("stale prompt"),
238            Message::user("hello"),
239        ]);
240
241        prepare_session_for_execution(&mut session, Some("authoritative prompt"), None);
242
243        // Replaced in place, no duplicate System message.
244        assert_eq!(session.messages.len(), 2);
245        assert!(matches!(session.messages[0].role, Role::System));
246        assert_eq!(session.messages[0].content, "authoritative prompt");
247        assert!(matches!(session.messages[1].role, Role::User));
248    }
249
250    #[test]
251    fn leading_non_system_gets_prompt_inserted_at_zero() {
252        let mut session =
253            session_with(vec![Message::user("hello"), Message::assistant("hi", None)]);
254
255        prepare_session_for_execution(&mut session, Some("you are helpful"), None);
256
257        assert_eq!(session.messages.len(), 3);
258        assert!(matches!(session.messages[0].role, Role::System));
259        assert_eq!(session.messages[0].content, "you are helpful");
260        assert!(matches!(session.messages[1].role, Role::User));
261        assert!(matches!(session.messages[2].role, Role::Assistant));
262    }
263
264    #[test]
265    fn model_is_set_when_some() {
266        let mut session = session_with(vec![Message::user("hello")]);
267
268        prepare_session_for_execution(&mut session, None, Some("new-model"));
269
270        assert_eq!(session.model, "new-model");
271        // No system prompt supplied → message list untouched.
272        assert_eq!(session.messages.len(), 1);
273        assert!(matches!(session.messages[0].role, Role::User));
274    }
275
276    #[test]
277    fn none_inputs_leave_session_untouched() {
278        let mut session = session_with(vec![Message::user("hello")]);
279
280        prepare_session_for_execution(&mut session, None, None);
281
282        assert_eq!(session.model, "old-model");
283        assert_eq!(session.messages.len(), 1);
284        assert!(session.prompt_snapshot.is_some());
285    }
286
287    #[tokio::test]
288    async fn external_prep_migrates_serialized_legacy_workspace_idempotently_before_configured_prompt(
289    ) {
290        let root = tempfile::tempdir().expect("workspace root");
291        let workspace = root.path().join("legacy-workspace");
292        std::fs::create_dir_all(&workspace).expect("legacy workspace");
293        let canonical = workspace.canonicalize().expect("canonical workspace");
294        let display = bamboo_config::paths::path_to_display_string(&canonical);
295        let legacy_block = crate::runtime::context::build_workspace_prompt_context(&display)
296            .expect("legacy Workspace block");
297
298        let mut legacy = Session::new("external-legacy", "old-model");
299        legacy.add_message(Message::system(legacy_block));
300        legacy.metadata.insert(
301            "runtime_prompt_snapshot".to_string(),
302            "stale-snapshot".to_string(),
303        );
304        let serialized = serde_json::to_vec(&legacy).expect("serialize legacy session");
305        let mut session: Session =
306            serde_json::from_slice(&serialized).expect("deserialize legacy session");
307
308        let resolver = ProjectContextResolver::new_with_workspace_resolver(
309            Arc::new(EmptyProjectSource),
310            bamboo_agent_core::workspace_state::WorkspaceResolver::new(|| None, {
311                let root = root.path().to_path_buf();
312                move || bamboo_agent_core::workspace_state::WorkspaceRootConfig {
313                    root: root.clone(),
314                    confine: false,
315                }
316            }),
317        );
318
319        prepare_external_session_for_execution(&mut session, Some(&resolver))
320            .await
321            .expect("first external prep");
322        assert!(
323            session
324                .messages
325                .iter()
326                .all(|message| !matches!(message.role, Role::System)),
327            "a marker-only legacy System message must be removed"
328        );
329        assert_eq!(
330            session.workspace_path_meta().as_deref(),
331            Some(display.as_str())
332        );
333        assert_eq!(
334            session
335                .metadata
336                .get(WORKSPACE_SOURCE_METADATA_KEY)
337                .map(String::as_str),
338            Some(WorkspaceSource::Session.as_str())
339        );
340        assert_eq!(
341            session
342                .metadata
343                .get(WORKSPACE_BINDING_STATUS_METADATA_KEY)
344                .map(String::as_str),
345            Some(WorkspaceBindingStatus::Unregistered.as_str())
346        );
347        assert!(!session.metadata.contains_key("runtime_prompt_snapshot"));
348        assert_eq!(
349            bamboo_agent_core::workspace_state::get_workspace(&session.id),
350            Some(canonical.clone())
351        );
352
353        let once = serde_json::to_value(&session).expect("first prepared session");
354        prepare_external_session_for_execution(&mut session, Some(&resolver))
355            .await
356            .expect("second external prep");
357        assert_eq!(
358            serde_json::to_value(&session).expect("second prepared session"),
359            once,
360            "external prep must be an exact session-state no-op after the first handoff"
361        );
362
363        prepare_session_for_execution(&mut session, Some("configured System"), Some("new-model"));
364        let systems = session
365            .messages
366            .iter()
367            .filter(|message| matches!(message.role, Role::System))
368            .collect::<Vec<_>>();
369        assert_eq!(systems.len(), 1);
370        assert_eq!(systems[0].content, "configured System");
371        assert!(!systems[0].content.contains(&display));
372        assert!(!systems[0].content.contains("BAMBOO_WORKSPACE_CONTEXT"));
373        assert_eq!(session.model, "new-model");
374        assert_eq!(
375            bamboo_agent_core::workspace_state::get_workspace(&session.id),
376            Some(canonical)
377        );
378        let snapshot = session
379            .prompt_snapshot
380            .as_ref()
381            .expect("configured snapshot");
382        assert_eq!(snapshot.effective_system_prompt, "configured System");
383        let workspace_context = snapshot
384            .workspace_context
385            .as_deref()
386            .expect("typed Workspace context");
387        assert!(workspace_context.contains(&display));
388        assert!(workspace_context.contains("Workspace source: session"));
389        assert!(workspace_context.contains("Binding status: unregistered"));
390    }
391
392    #[tokio::test]
393    async fn assigned_external_prep_without_resolver_fails_closed_before_mutating_retry_state() {
394        let workspace = tempfile::tempdir().expect("legacy Workspace");
395        let display = bamboo_config::paths::path_to_display_string(workspace.path());
396        let legacy_block = crate::runtime::context::build_workspace_prompt_context(&display)
397            .expect("legacy Workspace block");
398        let mut session = session_with(vec![Message::system(format!(
399            "caller System\n\n{legacy_block}"
400        ))]);
401        session.set_project_id_meta("project-external-prep");
402        session.metadata.insert(
403            crate::session_app::respond::PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
404            "retryable-tool-call".to_string(),
405        );
406        session.prompt_snapshot = Some(
407            serde_json::from_value(serde_json::json!({
408                "base_system_prompt": "stale caller snapshot",
409                "effective_system_prompt": "stale caller snapshot"
410            }))
411            .expect("synthetic stale prompt snapshot"),
412        );
413        let before = serde_json::to_vec(&session).expect("serialize retryable legacy state");
414
415        let error = prepare_external_session_for_execution(&mut session, None)
416            .await
417            .expect_err("assigned external execution requires resolver authority");
418
419        assert!(matches!(error, AgentError::ProjectContext(_)));
420        assert_eq!(
421            serde_json::to_vec(&session).expect("serialize failed legacy state"),
422            before,
423            "missing resolver must leave the complete serialized Session state unchanged"
424        );
425        assert!(session.messages[0].content.contains(&display));
426        assert!(session.messages[0]
427            .content
428            .contains("BAMBOO_WORKSPACE_CONTEXT"));
429        assert_eq!(
430            session
431                .metadata
432                .get(crate::session_app::respond::PERMISSION_REEXECUTE_METADATA_KEY)
433                .map(String::as_str),
434            Some("retryable-tool-call")
435        );
436        assert!(session.prompt_snapshot.is_some());
437    }
438}