1use std::collections::BTreeSet;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use bamboo_agent_core::storage::AttachmentReader;
6use bamboo_agent_core::storage::Storage;
7use bamboo_agent_core::tools::ToolSchema;
8use bamboo_agent_core::GoldConfidence;
9use bamboo_compression::TokenBudget;
10use bamboo_config::MemoryConfig;
11use bamboo_config::PermissionMode;
12use bamboo_domain::ReasoningEffort;
13use bamboo_domain::RuntimeSessionPersistence;
14use bamboo_llm::LLMProvider;
15use bamboo_metrics::MetricsCollector;
16use bamboo_skills::SkillManager;
17use bamboo_tools::ToolRegistry;
18use serde::{Deserialize, Serialize};
19
20use super::hooks::HookRunner;
21
22pub(crate) const DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY: usize = 2;
25pub(crate) const MAX_AUXILIARY_EVALUATION_MAX_CONCURRENCY: usize = 1024;
29
30pub(crate) fn normalize_auxiliary_evaluation_max_concurrency(value: Option<u64>) -> usize {
31 value
32 .filter(|value| *value > 0)
33 .map(|value| value.min(MAX_AUXILIARY_EVALUATION_MAX_CONCURRENCY as u64) as usize)
34 .unwrap_or(DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY)
35}
36
37pub type DisabledFilterSets = (BTreeSet<String>, BTreeSet<String>);
39
40pub type DisabledFilterResolver = Arc<dyn Fn() -> DisabledFilterSets + Send + Sync>;
42
43#[doc(hidden)]
45pub type BackgroundModelResolver =
46 Arc<dyn Fn(&str) -> Option<bamboo_llm::ResolvedModel> + Send + Sync>;
47
48#[doc(hidden)]
50pub type ResumeAuxiliarySelection = (
51 Result<Option<bamboo_llm::ResolvedModel>, bamboo_llm::LLMError>,
52 Option<bamboo_llm::ResolvedModel>,
53);
54
55#[doc(hidden)]
57pub type ResumeAuxiliaryResolver = Arc<dyn Fn() -> ResumeAuxiliarySelection + Send + Sync>;
58
59#[doc(hidden)]
63pub type GoldTargetResolver =
64 Arc<dyn Fn(&str, Arc<dyn LLMProvider>) -> Arc<dyn LLMProvider> + Send + Sync>;
65
66#[derive(Clone, Default)]
67pub struct AuxiliaryModelConfig {
68 pub fast_model_name: Option<String>,
69 pub fast_model_provider: Option<Arc<dyn LLMProvider>>,
70 pub background_model_name: Option<String>,
71 pub planning_model_name: Option<String>,
72 pub search_model_name: Option<String>,
73 pub summarization_model_name: Option<String>,
74 pub background_model_provider: Option<Arc<dyn LLMProvider>>,
75 pub summarization_model_provider: Option<Arc<dyn LLMProvider>>,
76 pub summarization_model_error: Option<String>,
78}
79
80fn default_gold_max_output_tokens() -> u32 {
81 1024
82}
83
84fn default_gold_max_auto_continuations() -> u32 {
85 3
86}
87
88fn default_gold_min_confidence() -> GoldConfidence {
89 GoldConfidence::Medium
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93#[serde(default)]
94pub struct GoldConfig {
95 #[serde(default)]
96 pub recovery: super::goal_recovery::GoalRecoveryPolicy,
97 #[serde(default)]
99 pub enabled: bool,
100 #[serde(default)]
105 pub auto_answer_enabled: bool,
106 #[serde(default)]
112 pub auto_continue_enabled: bool,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub model_name: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub goal: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub evaluation_prompt: Option<String>,
129 #[serde(default = "default_gold_max_output_tokens")]
131 pub max_output_tokens: u32,
132 #[serde(default = "default_gold_max_auto_continuations")]
134 pub max_auto_continuations: u32,
135 #[serde(default = "default_gold_min_confidence")]
139 pub min_auto_continue_confidence: GoldConfidence,
140}
141
142impl Default for GoldConfig {
143 fn default() -> Self {
144 Self {
145 recovery: Default::default(),
146 enabled: false,
147 auto_answer_enabled: false,
148 auto_continue_enabled: false,
149 model_name: None,
150 goal: None,
151 evaluation_prompt: None,
152 max_output_tokens: default_gold_max_output_tokens(),
153 max_auto_continuations: default_gold_max_auto_continuations(),
154 min_auto_continue_confidence: default_gold_min_confidence(),
155 }
156 }
157}
158
159impl GoldConfig {
160 pub fn effective_goal(&self) -> Option<&str> {
165 self.goal
166 .as_deref()
167 .or(self.evaluation_prompt.as_deref())
168 .map(str::trim)
169 .filter(|value| !value.is_empty())
170 }
171}
172
173fn default_guardian_max_reviews() -> u32 {
174 2
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
183#[serde(default)]
184pub struct GuardianConfig {
185 #[serde(default)]
187 pub enabled: bool,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub model_name: Option<String>,
191 #[serde(default = "default_guardian_max_reviews")]
194 pub max_reviews: u32,
195}
196
197impl Default for GuardianConfig {
198 fn default() -> Self {
199 Self {
200 enabled: false,
201 model_name: None,
202 max_reviews: default_guardian_max_reviews(),
203 }
204 }
205}
206
207#[async_trait::async_trait]
217pub trait GuardianSpawner: Send + Sync {
218 async fn spawn_guardian_review(
222 &self,
223 parent_session: &bamboo_agent_core::Session,
224 review_prompt: String,
225 model: String,
226 disabled_tools: Option<BTreeSet<String>>,
227 ) -> Result<String, String>;
228}
229
230pub const BASH_COMPLETION_RESUME_KIND: &str = "bash_completion_resume";
236
237pub use bamboo_agent_core::BashCompletionSink;
241
242pub trait BashResumeHook: Send + Sync {
256 fn arrange_bash_self_resume(&self, session_id: String, bash_ids: Vec<String>);
259
260 fn supports_background_exec(&self) -> bool {
263 false
264 }
265
266 fn arrange_exec_self_resume(
269 &self,
270 _job: bamboo_tools::tools::exec_command::BackgroundExecJob,
271 _session_created_at: chrono::DateTime<chrono::Utc>,
272 ) {
273 }
274}
275
276#[derive(Debug, Clone)]
281pub struct ChildApprovalRequest {
282 pub child_session_id: String,
283 pub parent_session_id: String,
284 pub child_tool_call_id: String,
286 pub tool_name: String,
287 pub permission_type: String,
289 pub resource: String,
291 pub question: String,
293 pub approval_payload: serde_json::Value,
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum ChildApprovalOutcome {
301 Delegated,
303 AutoApproved,
305 AutoDenied,
307}
308
309#[async_trait::async_trait]
315pub trait ApprovalDelegate: Send + Sync {
316 async fn delegate_child_approval(
319 &self,
320 request: ChildApprovalRequest,
321 ) -> Result<ChildApprovalOutcome, String>;
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub enum ImageFallbackMode {
326 Placeholder,
327 Error,
328 Ocr,
329 Vision,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct ImageFallbackConfig {
337 pub mode: ImageFallbackMode,
338 pub vision_model: Option<String>,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub struct PromptMemoryFlags {
346 pub project_prompt_injection: bool,
347 pub relevant_recall: bool,
348 pub relevant_recall_rerank: bool,
349 pub project_first_dream: bool,
350 pub ledger_agenda: bool,
351}
352
353impl Default for PromptMemoryFlags {
354 fn default() -> Self {
355 Self {
356 project_prompt_injection: true,
357 relevant_recall: true,
358 relevant_recall_rerank: false,
359 project_first_dream: true,
360 ledger_agenda: true,
361 }
362 }
363}
364
365impl From<&MemoryConfig> for PromptMemoryFlags {
366 fn from(value: &MemoryConfig) -> Self {
367 Self {
368 project_prompt_injection: value.project_prompt_injection,
369 relevant_recall: value.relevant_recall,
370 relevant_recall_rerank: value.relevant_recall_rerank,
371 project_first_dream: value.project_first_dream,
372 ledger_agenda: value.ledger_agenda_injection,
373 }
374 }
375}
376
377#[async_trait::async_trait]
380pub trait SkillExecutionHost: Send + Sync {
381 fn observe_current_inputs(
382 &self,
383 session_id: &str,
384 execution_id: &str,
385 current: Option<&crate::runtime::managers::lifecycle::BoundedInputRequestBatch>,
386 ) -> Result<(), bamboo_agent_core::AgentError>;
387
388 async fn refresh_runtime_scope(
391 &self,
392 _session: &mut bamboo_agent_core::Session,
393 _execution_id: &str,
394 ) -> Result<(), bamboo_agent_core::AgentError> {
395 Ok(())
396 }
397
398 async fn render_skill_prompt(
399 &self,
400 session: &bamboo_agent_core::Session,
401 execution_id: &str,
402 ) -> Result<String, bamboo_agent_core::AgentError>;
403
404 fn finish(&self, session_id: &str, execution_id: &str);
405}
406
407pub use SkillExecutionHost as SdkSkillExecutionHost;
409
410#[async_trait::async_trait]
413pub trait WorkspaceExecutionHost: Send + Sync {
414 async fn refresh_runtime_scope(
415 &self,
416 session: &mut bamboo_agent_core::Session,
417 execution_id: &str,
418 persistence: &Arc<dyn bamboo_domain::RuntimeSessionPersistence>,
419 ) -> Result<(), bamboo_agent_core::AgentError>;
420
421 fn finish(&self);
423}
424
425#[derive(Debug, PartialEq)]
428pub struct UntrustedInputObservation {
429 input_id: bamboo_domain::SessionMessageId,
430 request: Option<bamboo_domain::SessionSkillRequest>,
431}
432
433impl UntrustedInputObservation {
434 pub fn new(
435 input_id: &str,
436 request: Option<&bamboo_domain::SessionSkillRequest>,
437 ) -> Option<Self> {
438 if input_id.is_empty() || input_id.len() > 256 {
441 return None;
442 }
443 let input_id = bamboo_domain::SessionMessageId::parse(input_id.to_owned()).ok()?;
444 if let Some(request) = request {
445 request.validate().ok()?;
446 }
447 Some(Self {
448 input_id,
449 request: request.cloned(),
450 })
451 }
452
453 pub fn input_id(&self) -> &str {
454 self.input_id.as_str()
455 }
456 pub fn request(&self) -> Option<&bamboo_domain::SessionSkillRequest> {
457 self.request.as_ref()
458 }
459}
460
461pub struct UntrustedExecutionInputs {
466 observations: Box<[UntrustedInputObservation]>,
467 startup: std::sync::Mutex<Option<StartupInputObservation>>,
468 sdk_execution_id: Option<String>,
469 sdk_hook_runner: Option<Arc<HookRunner>>,
470 sdk_pending: std::sync::Mutex<Option<SdkPendingInput>>,
471}
472
473#[derive(Debug)]
474pub(crate) struct SdkPendingInput {
475 pub session_id: String,
476 pub input_id: String,
477 pub header_digest: [u8; 32],
478 pub final_digest: Option<[u8; 32]>,
479}
480
481impl std::fmt::Debug for UntrustedExecutionInputs {
482 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483 f.debug_struct("UntrustedExecutionInputs")
484 .field("observations", &self.observations)
485 .field("sdk_execution_id", &self.sdk_execution_id)
486 .finish_non_exhaustive()
487 }
488}
489
490pub(crate) fn sdk_message_digest(message: &bamboo_agent_core::Message) -> [u8; 32] {
492 use sha2::{Digest, Sha256};
493 let bytes = serde_json::to_vec(message).expect("Message serialization is infallible");
494 Sha256::digest(bytes).into()
495}
496
497pub(crate) fn sdk_message_header_digest(message: &bamboo_agent_core::Message) -> [u8; 32] {
500 let mut header = message.clone();
501 header.content.clear();
502 if let Some(parts) = header.content_parts.as_mut() {
503 if let Some(bamboo_domain::MessagePart::Text { text }) =
504 parts.iter_mut().find(|part| matches!(part, bamboo_domain::MessagePart::Text { text } if text == &message.content))
505 {
506 text.clear();
507 }
508 }
509 sdk_message_digest(&header)
510}
511
512#[derive(Debug)]
513struct StartupInputObservation {
514 session_id: String,
515 execution_id: String,
516 observation: crate::runtime::managers::lifecycle::InputObservation,
517}
518
519impl UntrustedExecutionInputs {
520 pub fn new(observations: Vec<UntrustedInputObservation>) -> Option<Self> {
521 if observations.is_empty() || observations.len() > 128 {
522 return None;
523 }
524 let mut ids = std::collections::HashSet::with_capacity(observations.len());
525 if observations.iter().any(|item| !ids.insert(item.input_id())) {
526 return None;
527 }
528 Some(Self {
529 observations: observations.into_boxed_slice(),
530 startup: std::sync::Mutex::new(None),
531 sdk_execution_id: None,
532 sdk_hook_runner: None,
533 sdk_pending: std::sync::Mutex::new(None),
534 })
535 }
536
537 pub fn sdk_execution_id(&self) -> Option<&str> {
539 self.sdk_execution_id.as_deref()
540 }
541
542 pub(crate) fn bind_sdk_append(
543 mut self,
544 execution_id: String,
545 pending: SdkPendingInput,
546 hook_runner: Option<Arc<HookRunner>>,
547 ) -> Self {
548 self.startup = std::sync::Mutex::new(Some(StartupInputObservation {
551 session_id: pending.session_id.clone(),
552 execution_id: execution_id.clone(),
553 observation: Default::default(),
554 }));
555 self.sdk_execution_id = Some(execution_id);
556 self.sdk_hook_runner = hook_runner;
557 self.sdk_pending = std::sync::Mutex::new(Some(pending));
558 self
559 }
560
561 pub(crate) fn sdk_hook_runner(&self) -> Option<Arc<HookRunner>> {
562 self.sdk_hook_runner.clone()
563 }
564
565 pub(crate) async fn seal_sdk_input(
568 &self,
569 session: &mut bamboo_agent_core::Session,
570 persistence: Option<&Arc<dyn RuntimeSessionPersistence>>,
571 ) -> Result<(), bamboo_agent_core::AgentError> {
572 let pending = self
573 .sdk_pending
574 .lock()
575 .ok()
576 .and_then(|mut slot| slot.take());
577 let Some(pending) = pending else {
578 return Ok(());
579 };
580 let execution_id = self.sdk_execution_id.as_deref().unwrap_or("");
581 let invalid = || {
582 bamboo_agent_core::AgentError::Tool("SDK input acceptance could not be verified".into())
583 };
584 if session.id != pending.session_id || execution_id.is_empty() {
585 return Err(invalid());
586 }
587 let mut matching = session
588 .messages
589 .iter()
590 .filter(|message| message.id == pending.input_id);
591 let message = matching.next().ok_or_else(invalid)?;
592 if matching.next().is_some()
593 || message.role != bamboo_agent_core::Role::User
594 || sdk_message_header_digest(message) != pending.header_digest
595 || pending
596 .final_digest
597 .is_some_and(|digest| sdk_message_digest(message) != digest)
598 {
599 return Err(invalid());
600 }
601 let final_digest = sdk_message_digest(message);
602 let created_at = message.created_at;
603 session.validate_conversation_state().map_err(|error| {
604 bamboo_agent_core::AgentError::Tool(format!("session_conversation_changed: {error}"))
605 })?;
606 let requires_canonical_generation = session.conversation_reset.is_some();
607 let persistence = persistence.ok_or_else(|| {
608 if requires_canonical_generation {
609 bamboo_agent_core::AgentError::Tool(
610 "session_conversation_changed: canonical input persistence unavailable".into(),
611 )
612 } else {
613 invalid()
614 }
615 })?;
616 let original_generation = session.conversation_generation_observation();
617 let checkpoint = persistence.checkpoint_runtime_session(session).await;
618 original_generation
619 .check_canonical(session)
620 .map_err(|error| {
621 bamboo_agent_core::AgentError::Tool(format!(
622 "session_conversation_changed: {error}"
623 ))
624 })?;
625 checkpoint.map_err(|error| {
626 bamboo_agent_core::AgentError::Tool(format!("SDK input checkpoint failed: {error}"))
627 })?;
628 let accepted = persistence
629 .load_runtime_session(&session.id)
630 .await
631 .map_err(|error| {
632 bamboo_agent_core::AgentError::Tool(format!(
633 "SDK input verification failed: {error}"
634 ))
635 })?
636 .ok_or_else(|| {
637 if requires_canonical_generation {
638 bamboo_agent_core::AgentError::Tool(
639 "session_conversation_changed: canonical input readback unavailable".into(),
640 )
641 } else {
642 invalid()
643 }
644 })?;
645 original_generation
646 .check_canonical(&accepted)
647 .map_err(|error| {
648 bamboo_agent_core::AgentError::Tool(format!(
649 "session_conversation_changed: {error}"
650 ))
651 })?;
652 let mut matching = accepted
653 .messages
654 .iter()
655 .filter(|message| message.id == pending.input_id);
656 let accepted_user = matching.next().ok_or_else(invalid)?;
657 if matching.next().is_some()
658 || accepted_user.role != bamboo_agent_core::Role::User
659 || sdk_message_digest(accepted_user) != final_digest
660 {
661 return Err(invalid());
662 }
663 let observation = self
664 .observations
665 .iter()
666 .find(|item| item.input_id() == pending.input_id)
667 .ok_or_else(invalid)?;
668 let source = bamboo_domain::SessionMessageSource::User;
669 let record = crate::runtime::runner::state_bridge::BorrowedInputRequestRecord {
670 input_id: &pending.input_id,
671 source: &source,
672 kind: bamboo_domain::SessionMessageKind::UserInput,
673 wrapper: None,
674 created_at,
675 request: observation.request(),
676 };
677 let projection = crate::runtime::runner::state_bridge::project_input_request_batch(
678 &session.id,
679 execution_id,
680 &[record],
681 );
682 let observation =
683 crate::runtime::managers::lifecycle::InputObservation::projected(projection);
684 *self.startup.lock().map_err(|_| invalid())? = Some(StartupInputObservation {
685 session_id: session.id.clone(),
686 execution_id: execution_id.to_owned(),
687 observation,
688 });
689 Ok(())
690 }
691
692 pub fn observations(&self) -> &[UntrustedInputObservation] {
693 &self.observations
694 }
695
696 pub async fn admit_with_startup_observation(
700 session: &mut bamboo_agent_core::Session,
701 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
702 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
703 inbox: Option<&Arc<dyn bamboo_domain::SessionInboxPort>>,
704 ) -> (crate::runtime::runner::TurnBoundaryRefresh, Option<Self>) {
705 let execution_id = crate::runtime::runner::round_prelude::new_execution_id();
706 let (refresh, observation) =
707 crate::runtime::runner::state_bridge::refresh_turn_boundary_with_observation(
708 session,
709 storage,
710 persistence,
711 inbox,
712 None,
713 &execution_id,
714 )
715 .await;
716 let carrier = observation
720 .new_inputs()
721 .and_then(|batch| {
722 Self::new(
723 batch
724 .records()
725 .iter()
726 .map(|record| {
727 Some(UntrustedInputObservation {
728 input_id: bamboo_domain::SessionMessageId::parse(
729 record.input_id.clone(),
730 )
731 .ok()?,
732 request: record.request.clone(),
733 })
734 })
735 .collect::<Option<Vec<_>>>()?,
736 )
737 })
738 .map(|mut carrier| {
739 carrier.startup = std::sync::Mutex::new(Some(StartupInputObservation {
740 session_id: session.id.clone(),
741 execution_id,
742 observation,
743 }));
744 carrier
745 });
746 (refresh, carrier)
747 }
748
749 pub(crate) fn take_startup_observation(
750 &self,
751 session_id: &str,
752 ) -> Option<(
753 String,
754 crate::runtime::managers::lifecycle::InputObservation,
755 )> {
756 let startup = self.startup.lock().ok()?.take()?;
757 (startup.session_id == session_id
758 && (startup.observation.is_unavailable()
759 || startup.observation.matches_session(session_id)))
760 .then_some((startup.execution_id, startup.observation))
761 }
762
763 pub fn from_committed_messages(messages: &[bamboo_agent_core::Message]) -> Option<Self> {
767 if messages.len() > 128 {
768 return None;
769 }
770 let mut observations = Vec::with_capacity(messages.len());
771 for message in messages {
772 if message.role != bamboo_agent_core::Role::User {
773 continue;
774 }
775 let Some(proof) = message
776 .metadata
777 .as_ref()
778 .and_then(|metadata| metadata.get("session_message"))
779 else {
780 continue;
781 };
782 if proof.get("id")?.as_str()? != message.id {
783 return None;
784 }
785 let body = proof.get("body")?;
786 let request = if proof.get("source")?.get("type")?.as_str()? == "user"
787 && proof.get("kind")?.as_str()? == "user_input"
788 && body.get("type")?.as_str()? == "content"
789 {
790 body.get("skill_request")
791 } else if proof.get("source")?.get("type")?.as_str()? == "runtime"
792 && proof.get("source")?.get("subsystem")?.as_str()? == "chat"
793 && proof.get("kind")?.as_str()? == "runtime_instruction"
794 && body.get("instruction")?.as_str()? == "root_chat_turn_v1"
795 {
796 body.get("content")?.get("skill_request")
797 } else {
798 continue;
799 };
800 let request = request
801 .filter(|value| !value.is_null())
802 .map(copy_checked_request)
803 .transpose()
804 .ok()?;
805 if message.id.is_empty() || message.id.len() > 256 {
806 return None;
807 }
808 let input_id = bamboo_domain::SessionMessageId::parse(message.id.clone()).ok()?;
809 observations.push(UntrustedInputObservation { input_id, request });
810 }
811 Self::new(observations)
812 }
813}
814
815fn copy_checked_request(
818 value: &serde_json::Value,
819) -> Result<bamboo_domain::SessionSkillRequest, ()> {
820 let selections = value
821 .get("selections")
822 .and_then(serde_json::Value::as_array)
823 .ok_or(())?;
824 if selections.is_empty() || selections.len() > 32 {
825 return Err(());
826 }
827 let mode = match value.get("mode") {
828 None | Some(serde_json::Value::Null) => None,
829 Some(value) => {
830 let mode = value.as_str().ok_or(())?;
831 if mode.is_empty()
832 || mode.len() > 64
833 || !mode
834 .bytes()
835 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
836 {
837 return Err(());
838 }
839 Some(mode)
840 }
841 };
842 for selection in selections {
845 bamboo_domain::SessionSkillSelection::validate_borrowed(
846 selection
847 .get("id")
848 .and_then(serde_json::Value::as_str)
849 .ok_or(())?,
850 selection
851 .get("source")
852 .and_then(serde_json::Value::as_str)
853 .ok_or(())?,
854 selection
855 .get("revision")
856 .and_then(serde_json::Value::as_u64)
857 .ok_or(())?,
858 selection.get("args").unwrap_or(&serde_json::Value::Null),
859 )
860 .map_err(|_| ())?;
861 }
862 let request = bamboo_domain::SessionSkillRequest {
863 selections: selections
864 .iter()
865 .map(|selection| bamboo_domain::SessionSkillSelection {
866 id: selection["id"].as_str().unwrap().to_owned(),
867 source: selection["source"].as_str().unwrap().to_owned(),
868 revision: selection["revision"].as_u64().unwrap(),
869 args: selection.get("args").cloned().unwrap_or_default(),
870 })
871 .collect(),
872 mode: mode.map(str::to_owned),
873 };
874 request.validate().map_err(|_| ())?;
875 Ok(request)
876}
877
878#[async_trait::async_trait]
881pub trait LiveBootstrapHost: Send + Sync {
882 async fn register(
885 &self,
886 owner: bamboo_domain::RootActorRuntimeWrite,
887 proof: bamboo_domain::LiveBootstrapProof,
888 persistence: Arc<dyn RuntimeSessionPersistence>,
889 inbox: Arc<dyn bamboo_domain::SessionInboxPort>,
890 offer: bamboo_llm::retry::LiveBootstrapOffer,
891 ) -> std::io::Result<Box<dyn Send>>;
892}
893
894#[non_exhaustive]
916pub struct AgentLoopConfig {
917 pub freeze_tool_exposure_for_cache: bool,
919 pub(crate) ticket_worker_plan: Option<Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>>,
921 pub(crate) system_prompt: Option<String>,
922 pub(crate) disabled_skill_ids: BTreeSet<String>,
924 pub(crate) selected_skill_ids: Option<Vec<String>>,
927 pub(crate) selected_skill_mode: Option<String>,
932 pub(crate) initial_untrusted_inputs: Option<UntrustedExecutionInputs>,
934 pub(crate) sdk_skill_execution_host: Option<Arc<dyn SdkSkillExecutionHost>>,
935 pub(crate) workspace_execution_host: Option<Arc<dyn WorkspaceExecutionHost>>,
936 pub(crate) additional_tool_schemas: Vec<ToolSchema>,
937 pub(crate) tool_registry: Arc<ToolRegistry>,
938 pub(crate) skill_manager: Option<Arc<SkillManager>>,
939 pub(crate) project_context_resolver:
942 Option<Arc<crate::project_context::ProjectContextResolver>>,
943 pub(crate) skip_initial_user_message: bool,
945 pub(crate) storage: Option<Arc<dyn Storage>>,
947 pub(crate) persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
950 pub(crate) guidance_active_run_id: Option<String>,
951 pub(crate) session_inbox: Option<Arc<dyn bamboo_domain::SessionInboxPort>>,
953 pub(crate) live_bootstrap_host: Option<Arc<dyn LiveBootstrapHost>>,
954 pub(crate) gold_target_resolver: Option<GoldTargetResolver>,
955 pub(crate) host_gold_run: Option<Arc<crate::runtime::host_gold::HostGoldRun>>,
956 pub(crate) session_activation_notifications:
960 Option<Arc<parking_lot::Mutex<tokio::sync::watch::Receiver<u64>>>>,
961 pub(crate) attachment_reader: Option<Arc<dyn AttachmentReader>>,
964 pub(crate) metrics_collector: Option<MetricsCollector>,
966 pub(crate) model_name: Option<String>,
968 pub(crate) fast_model_name: Option<String>,
972 pub(crate) fast_model_provider: Option<Arc<dyn LLMProvider>>,
974 pub(crate) auxiliary_evaluation_max_concurrency: usize,
978 pub(crate) background_model_name: Option<String>,
982
983 pub(crate) planning_model_name: Option<String>,
986 pub(crate) search_model_name: Option<String>,
989 pub(crate) compression_instructions: Option<String>,
994 pub(crate) summary_target_ratio: f64,
997 pub(crate) context_management: bamboo_config::ContextManagementConfig,
1000 pub(crate) summary_safe_window_percent: u8,
1003 pub(crate) summarization_model_name: Option<String>,
1005 pub(crate) background_model_provider: Option<Arc<dyn LLMProvider>>,
1010 pub(crate) summarization_model_provider: Option<Arc<dyn LLMProvider>>,
1015 pub(crate) provider_name: Option<String>,
1019 pub(crate) provider_type: Option<String>,
1024 pub(crate) reasoning_effort: Option<ReasoningEffort>,
1026 pub(crate) app_data_dir: Option<PathBuf>,
1031 pub(crate) memory_store: bamboo_memory::memory_store::MemoryStore,
1034 pub(crate) disabled_tools: BTreeSet<String>,
1036 pub(crate) token_budget: Option<TokenBudget>,
1038 pub(crate) model_limits_section:
1042 Option<Arc<bamboo_config::LiveSection<bamboo_config::ModelLimitsSection>>>,
1043 pub(crate) legacy_model_limits: Option<serde_json::Value>,
1049 pub(crate) image_fallback: Option<ImageFallbackConfig>,
1054 pub(crate) vision_model_resolver: Option<super::runner::image_fallback::VisionModelResolver>,
1059 pub(crate) legacy_vision_model_resolver:
1061 Option<super::runner::image_fallback::LegacyVisionModelResolver>,
1062 pub(crate) vision_description_reuse:
1069 std::sync::Arc<super::runner::image_fallback::VisionDescriptionReuse>,
1070 pub(crate) prompt_memory_flags: PromptMemoryFlags,
1072 pub(crate) max_tool_calls_per_round: usize,
1074 pub(crate) max_consecutive_failures_per_tool: usize,
1076 pub(crate) per_tool_timeout_secs: u64,
1078 pub(crate) parallel_batch_timeout_secs: u64,
1080 pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
1083 pub(crate) upstream_retry: bamboo_llm::retry::UpstreamRetry,
1085 pub(crate) permission_mode: Option<PermissionMode>,
1087 pub(crate) gold_config: Option<GoldConfig>,
1092 pub(crate) guardian_config: Option<GuardianConfig>,
1095 pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
1099 pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
1105 pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
1114 pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
1118 pub(crate) hook_runner: Arc<HookRunner>,
1121 pub(crate) features_dynamic_model_routing: bool,
1125 pub(crate) features_final_evidence_check: bool,
1127 pub(crate) auxiliary_model_resolver:
1133 Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
1134 pub(crate) background_model_resolver: Option<BackgroundModelResolver>,
1136 pub(crate) resume_auxiliary_resolver: Option<ResumeAuxiliaryResolver>,
1138 pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
1145 pub(crate) mcp_tool_guidance: Option<String>,
1151 pub(crate) run_budget: bamboo_config::RunBudgetConfig,
1160 pub(crate) counted_loop: Option<super::counted_loop::CountedLoopConfig>,
1161 pub(crate) loop_control: Option<super::loop_control::LoopControlConfig>,
1162}
1163
1164impl AgentLoopConfig {
1165 pub(crate) fn effective_counted_loop(&self) -> Option<&super::counted_loop::CountedLoopConfig> {
1167 self.counted_loop
1168 .as_ref()
1169 .or_else(|| self.loop_control.as_ref().and_then(|c| c.active_counted()))
1170 }
1171
1172 pub fn initial_untrusted_inputs(&self) -> Option<&UntrustedExecutionInputs> {
1174 self.initial_untrusted_inputs.as_ref()
1175 }
1176}
1177
1178impl Default for AgentLoopConfig {
1179 fn default() -> Self {
1180 Self {
1181 freeze_tool_exposure_for_cache: true,
1182 ticket_worker_plan: None,
1183 system_prompt: None,
1184 disabled_skill_ids: BTreeSet::new(),
1185 selected_skill_ids: None,
1186 selected_skill_mode: None,
1187 initial_untrusted_inputs: None,
1188 sdk_skill_execution_host: None,
1189 workspace_execution_host: None,
1190 additional_tool_schemas: Vec::new(),
1191 tool_registry: Arc::new(ToolRegistry::new()),
1192 skill_manager: None,
1193 project_context_resolver: None,
1194 skip_initial_user_message: false,
1195 storage: None,
1196 persistence: None,
1197 guidance_active_run_id: None,
1198 session_inbox: None,
1199 live_bootstrap_host: None,
1200 gold_target_resolver: None,
1201 host_gold_run: None,
1202 session_activation_notifications: None,
1203 attachment_reader: None,
1204 metrics_collector: None,
1205 model_name: None,
1206 fast_model_name: None,
1207 fast_model_provider: None,
1208 auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
1209 background_model_name: None,
1210 planning_model_name: None,
1211 search_model_name: None,
1212 compression_instructions: None,
1213 summary_target_ratio: 0.20,
1214 context_management: bamboo_config::ContextManagementConfig::default(),
1215 summary_safe_window_percent: 80,
1216 summarization_model_name: None,
1217 background_model_provider: None,
1218 summarization_model_provider: None,
1219 provider_name: None,
1220 provider_type: None,
1221 reasoning_effort: None,
1222 app_data_dir: None,
1223 memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
1224 disabled_tools: BTreeSet::new(),
1225 token_budget: None,
1226 model_limits_section: None,
1227 legacy_model_limits: None,
1228 image_fallback: None,
1229 vision_model_resolver: None,
1230 legacy_vision_model_resolver: None,
1231 vision_description_reuse: Default::default(),
1232 prompt_memory_flags: PromptMemoryFlags::default(),
1233 max_tool_calls_per_round: 80,
1234 max_consecutive_failures_per_tool: 3,
1235 per_tool_timeout_secs: 120,
1236 parallel_batch_timeout_secs: 300,
1237 stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
1238 upstream_retry: Default::default(),
1239 permission_mode: None,
1240 gold_config: None,
1241 guardian_config: None,
1242 guardian_spawner: None,
1243 bash_resume_hook: None,
1244 bash_completion_sink: None,
1245 approval_delegate: None,
1246 hook_runner: Arc::new(HookRunner::new()),
1247 features_dynamic_model_routing: false,
1248 features_final_evidence_check: false,
1249 auxiliary_model_resolver: None,
1250 background_model_resolver: None,
1251 resume_auxiliary_resolver: None,
1252 disabled_filter_resolver: None,
1253 mcp_tool_guidance: None,
1254 run_budget: bamboo_config::RunBudgetConfig::default(),
1255 counted_loop: None,
1256 loop_control: None,
1257 }
1258 }
1259}
1260
1261impl AgentLoopConfig {
1262 pub(crate) fn resolve_auxiliary_models(&self) -> AuxiliaryModelConfig {
1266 let mut models = self
1267 .auxiliary_model_resolver
1268 .as_ref()
1269 .map(|resolver| resolver())
1270 .unwrap_or_else(|| AuxiliaryModelConfig {
1271 fast_model_name: self.fast_model_name.clone(),
1272 fast_model_provider: self.fast_model_provider.clone(),
1273 background_model_name: self.background_model_name.clone(),
1274 planning_model_name: self.planning_model_name.clone(),
1275 search_model_name: self.search_model_name.clone(),
1276 summarization_model_name: self.summarization_model_name.clone(),
1277 background_model_provider: self.background_model_provider.clone(),
1278 summarization_model_provider: self.summarization_model_provider.clone(),
1279 summarization_model_error: None,
1280 });
1281 if let Some(resolve) = &self.background_model_resolver {
1282 let background = resolve(self.provider_name.as_deref().unwrap_or_default());
1283 models.background_model_name =
1284 background.as_ref().map(|model| model.model_name.clone());
1285 models.background_model_provider = background.map(|model| model.provider);
1286 }
1287 if let Some(resolve) = &self.resume_auxiliary_resolver {
1288 let (summary, background) = resolve();
1289 models.background_model_name =
1290 background.as_ref().map(|model| model.model_name.clone());
1291 models.background_model_provider = background.map(|model| model.provider);
1292 match summary {
1293 Ok(summary) => {
1294 models.summarization_model_name =
1295 summary.as_ref().map(|model| model.model_name.clone());
1296 models.summarization_model_provider = summary.map(|model| model.provider);
1297 models.summarization_model_error = None;
1298 }
1299 Err(error) => {
1300 models.summarization_model_name = None;
1301 models.summarization_model_provider = None;
1302 models.summarization_model_error = Some(error.to_string());
1303 }
1304 }
1305 }
1306 models
1307 }
1308
1309 pub fn bind_ticket_worker_plan(
1310 &mut self,
1311 plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
1312 ) {
1313 self.ticket_worker_plan = Some(plan);
1314 }
1315 pub(crate) fn resolve_disabled_filters(
1320 &self,
1321 ) -> (
1322 std::borrow::Cow<'_, BTreeSet<String>>,
1323 std::borrow::Cow<'_, BTreeSet<String>>,
1324 ) {
1325 match &self.disabled_filter_resolver {
1326 Some(resolver) => {
1327 let (tools, skills) = resolver();
1328 (
1329 std::borrow::Cow::Owned(tools),
1330 std::borrow::Cow::Owned(skills),
1331 )
1332 }
1333 None => (
1334 std::borrow::Cow::Borrowed(&self.disabled_tools),
1335 std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
1336 ),
1337 }
1338 }
1339
1340 pub fn active_goal(&self) -> Option<&str> {
1344 self.gold_config
1345 .as_ref()
1346 .filter(|cfg| cfg.enabled)
1347 .and_then(GoldConfig::effective_goal)
1348 }
1349
1350 pub fn goal_loop_active(&self) -> bool {
1358 self.gold_config.as_ref().is_some_and(|cfg| {
1359 cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
1360 })
1361 }
1362
1363 pub fn guardian_active(&self) -> bool {
1367 self.guardian_spawner.is_some()
1368 && self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
1369 }
1370
1371 pub fn guardian_max_reviews(&self) -> u32 {
1374 self.guardian_config
1375 .as_ref()
1376 .map_or(0, |cfg| cfg.max_reviews)
1377 }
1378
1379 pub fn guardian_model(&self) -> Option<&str> {
1381 self.guardian_config
1382 .as_ref()
1383 .and_then(|cfg| cfg.model_name.as_deref())
1384 }
1385
1386 pub fn delegation_active(&self) -> bool {
1388 self.approval_delegate.is_some()
1389 }
1390}
1391
1392#[cfg(test)]
1393mod tests;
1394
1395#[cfg(test)]
1396mod execution_input_tests {
1397 use super::*;
1398 fn request() -> bamboo_domain::SessionSkillRequest {
1399 bamboo_domain::SessionSkillRequest {
1400 selections: vec![bamboo_domain::SessionSkillSelection {
1401 id: "exact Case".into(),
1402 source: "builtin".into(),
1403 revision: 1,
1404 args: serde_json::json!({"raw":"保持原样"}),
1405 }],
1406 mode: None,
1407 }
1408 }
1409 #[test]
1410 fn execution_input_owned_bounds_and_old_config_default() {
1411 assert!(AgentLoopConfig::default()
1412 .initial_untrusted_inputs()
1413 .is_none());
1414 let request = request();
1415 let input = UntrustedInputObservation::new("exact-input", Some(&request)).unwrap();
1416 assert_eq!(input.input_id(), "exact-input");
1417 assert_eq!(input.request(), Some(&request));
1418 for id in [
1419 "",
1420 " leading",
1421 "trailing ",
1422 "unsafe/id",
1423 "unsafe\\id",
1424 "unsafe..id",
1425 ] {
1426 assert!(UntrustedInputObservation::new(id, None).is_none());
1427 }
1428 assert!(UntrustedInputObservation::new(&"a".repeat(257), None).is_none());
1429 assert!(UntrustedExecutionInputs::new(Vec::new()).is_none());
1430 let batch = UntrustedExecutionInputs::new(
1431 (0..128)
1432 .map(|i| {
1433 UntrustedInputObservation::new(&format!("input-{i}"), Some(&request)).unwrap()
1434 })
1435 .collect(),
1436 )
1437 .unwrap();
1438 assert_eq!(batch.observations().len(), 128);
1439 assert_eq!(batch.observations()[127].input_id(), "input-127");
1440 assert!(UntrustedExecutionInputs::new(
1441 (0..129)
1442 .map(|i| UntrustedInputObservation::new(&format!("input-{i}"), None).unwrap())
1443 .collect()
1444 )
1445 .is_none());
1446 assert!(UntrustedExecutionInputs::new(vec![
1447 UntrustedInputObservation::new("same", None).unwrap(),
1448 UntrustedInputObservation::new("same", None).unwrap()
1449 ])
1450 .is_none());
1451 let mut malformed = request;
1452 malformed.selections[0].args = (0..65).fold(serde_json::Value::Null, |v, _| {
1453 serde_json::Value::Array(vec![v])
1454 });
1455 assert!(UntrustedInputObservation::new("deep", Some(&malformed)).is_none());
1456 }
1457 #[test]
1458 fn execution_input_only_explicit_checked_commit_slice_copies_request_fields() {
1459 let request = request();
1460 let mut user = bamboo_domain::SessionMessageEnvelope::user_input("target", "ordinary");
1461 if let bamboo_domain::SessionMessageBody::Content(content) = &mut user.body {
1462 content.skill_request = Some(request.clone());
1463 }
1464 let id = user.id.to_string();
1465 let plain = user.to_provider_message().unwrap();
1466 let root = user
1467 .with_root_chat_prompt("system prompt is not retained".into())
1468 .unwrap()
1469 .to_provider_message()
1470 .unwrap();
1471 for message in [plain, root] {
1472 let batch = UntrustedExecutionInputs::from_committed_messages(&[message]).unwrap();
1473 assert_eq!(batch.observations().len(), 1);
1474 assert_eq!(batch.observations()[0].input_id(), id);
1475 assert_eq!(batch.observations()[0].request(), Some(&request));
1476 }
1477 let text = bamboo_agent_core::Message::user("{\"skill_request\":\"fragment\"}");
1478 assert!(UntrustedExecutionInputs::from_committed_messages(&[text]).is_none());
1479 assert!(UntrustedExecutionInputs::from_committed_messages(&[]).is_none());
1480 }
1481}
1482
1483#[cfg(test)]
1484mod sdk_acceptance_tests {
1485 use super::*;
1486 use crate::runtime::managers::lifecycle::BoundedInputRequestBatch;
1487 use bamboo_agent_core::{Message, Role, Session};
1488 use tokio_util::sync::CancellationToken;
1489
1490 struct Repository {
1491 stored: std::sync::Mutex<Option<Session>>,
1492 reject: bool,
1493 corrupt: bool,
1494 }
1495 #[async_trait::async_trait]
1496 impl RuntimeSessionPersistence for Repository {
1497 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1498 if self.reject {
1499 return Err(std::io::Error::other("checkpoint refused"));
1500 }
1501 let mut accepted = session.clone();
1502 if self.corrupt {
1503 accepted
1504 .messages
1505 .iter_mut()
1506 .find(|message| message.role == Role::User)
1507 .unwrap()
1508 .content
1509 .push_str(" changed");
1510 }
1511 *self.stored.lock().unwrap() = Some(accepted);
1512 Ok(())
1513 }
1514 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1515 self.save_runtime_session(session).await
1516 }
1517 async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1518 Ok(self.stored.lock().unwrap().clone())
1519 }
1520 }
1521 fn repository(reject: bool, corrupt: bool) -> Arc<dyn RuntimeSessionPersistence> {
1522 Arc::new(Repository {
1523 stored: Default::default(),
1524 reject,
1525 corrupt,
1526 })
1527 }
1528
1529 fn nonzero_conversation(session: &mut Session) {
1530 session.conversation_reset = Some(bamboo_domain::ConversationResetState {
1531 schema_version: 1,
1532 conversation_revision: 1,
1533 receipts: vec![bamboo_domain::ConversationResetReceipt {
1534 operation_id: format!("0:{}", uuid::Uuid::new_v4()),
1535 birth_token: session.root_mode_birth_token(),
1536 expected_snapshot_sha256: "a".repeat(64),
1537 previous_revision: 0,
1538 conversation_revision: 1,
1539 retained_system_messages: 0,
1540 reset_at: session.updated_at,
1541 }],
1542 });
1543 }
1544
1545 struct ConversationChangingRepository {
1546 stored: std::sync::Mutex<Option<Session>>,
1547 mutate_caller: bool,
1548 }
1549 #[async_trait::async_trait]
1550 impl RuntimeSessionPersistence for ConversationChangingRepository {
1551 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1552 let mut accepted = session.clone();
1553 if let Some(state) = accepted.conversation_reset.as_mut() {
1554 state.receipts[0].expected_snapshot_sha256 = "b".repeat(64);
1555 } else {
1556 nonzero_conversation(&mut accepted);
1557 }
1558 if self.mutate_caller {
1559 *session = accepted.clone();
1560 }
1561 *self.stored.lock().unwrap() = Some(accepted);
1562 Ok(())
1563 }
1564 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1565 self.save_runtime_session(session).await
1566 }
1567 async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1568 Ok(self.stored.lock().unwrap().clone())
1569 }
1570 }
1571
1572 #[async_trait::async_trait]
1573 impl bamboo_domain::Storage for ConversationChangingRepository {
1574 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
1575 *self.stored.lock().unwrap() = Some(session.clone());
1576 Ok(())
1577 }
1578 async fn load_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1579 Ok(self.stored.lock().unwrap().clone())
1580 }
1581 async fn delete_session(&self, _: &str) -> std::io::Result<bool> {
1582 Err(std::io::Error::other("fixture never deletes a Session"))
1583 }
1584 }
1585
1586 #[derive(Default)]
1587 struct SealLoopProbe {
1588 provider_calls: std::sync::atomic::AtomicUsize,
1589 tool_calls: std::sync::atomic::AtomicUsize,
1590 setup_calls: std::sync::atomic::AtomicUsize,
1591 }
1592 #[async_trait::async_trait]
1593 impl bamboo_llm::LLMProvider for SealLoopProbe {
1594 async fn chat_stream(
1595 &self,
1596 _: &[Message],
1597 _: &[bamboo_agent_core::tools::ToolSchema],
1598 _: Option<u32>,
1599 _: &str,
1600 ) -> bamboo_llm::provider::Result<bamboo_llm::LLMStream> {
1601 self.provider_calls
1602 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1603 Ok(Box::pin(futures::stream::iter(vec![
1604 Ok(bamboo_llm::LLMChunk::Token("synthetic complete".into())),
1605 Ok(bamboo_llm::LLMChunk::Done),
1606 ])))
1607 }
1608 }
1609 #[async_trait::async_trait]
1610 impl bamboo_agent_core::tools::ToolExecutor for SealLoopProbe {
1611 async fn execute(
1612 &self,
1613 _: &bamboo_agent_core::tools::ToolCall,
1614 ) -> Result<bamboo_agent_core::tools::ToolResult, bamboo_agent_core::tools::ToolError>
1615 {
1616 self.tool_calls
1617 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1618 Err(bamboo_agent_core::tools::ToolError::Execution(
1619 "no fixture tools".into(),
1620 ))
1621 }
1622 fn list_tools(&self) -> Vec<bamboo_agent_core::tools::ToolSchema> {
1623 vec![]
1624 }
1625 }
1626 #[async_trait::async_trait]
1627 impl bamboo_agent_core::AgentHook for SealLoopProbe {
1628 fn point(&self) -> bamboo_domain::AgentHookPoint {
1629 bamboo_domain::AgentHookPoint::AfterSessionSetup
1630 }
1631 async fn run(
1632 &self,
1633 _: bamboo_domain::AgentHookPoint,
1634 _: &bamboo_domain::HookPayload,
1635 _: &Session,
1636 ) -> bamboo_domain::HookResult {
1637 self.setup_calls
1638 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1639 bamboo_domain::HookResult::Continue
1640 }
1641 }
1642
1643 fn seal_loop_config(
1644 inputs: UntrustedExecutionInputs,
1645 persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
1646 storage: Option<Arc<dyn bamboo_domain::Storage>>,
1647 probe: &Arc<SealLoopProbe>,
1648 ) -> AgentLoopConfig {
1649 let mut hooks = crate::runtime::HookRunner::new();
1650 hooks.register(probe.clone());
1651 AgentLoopConfig {
1652 initial_untrusted_inputs: Some(inputs),
1653 persistence,
1654 storage,
1655 skip_initial_user_message: true,
1656 model_name: Some("synthetic-seal-model".into()),
1657 hook_runner: Arc::new(hooks),
1658 prompt_memory_flags: PromptMemoryFlags {
1659 project_prompt_injection: false,
1660 relevant_recall: false,
1661 relevant_recall_rerank: false,
1662 project_first_dream: false,
1663 ledger_agenda: false,
1664 },
1665 ..Default::default()
1666 }
1667 }
1668
1669 #[tokio::test]
1670 async fn full_loop_stops_changed_or_invalid_seal_before_hooks_provider_and_complete() {
1671 for mutate_caller in [false, true] {
1672 for generation in ["advance", "same-revision", "invalid"] {
1673 let mut session = Session::new("sdk-full-seal-refusal", "model");
1674 if generation == "same-revision" {
1675 nonzero_conversation(&mut session);
1676 }
1677 if generation == "invalid" {
1678 session.conversation_reset = Some(Default::default());
1679 }
1680 let user = Message::user("synthetic current input");
1681 let inputs = producer(&session, &user, true);
1682 session.add_message(user);
1683 let repository = Arc::new(ConversationChangingRepository {
1684 stored: std::sync::Mutex::new(Some(session.clone())),
1685 mutate_caller,
1686 });
1687 let probe = Arc::new(SealLoopProbe::default());
1688 let config = seal_loop_config(
1689 inputs,
1690 Some(repository.clone()),
1691 Some(repository.clone()),
1692 &probe,
1693 );
1694 let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
1695 let error = crate::runtime::runner::run_agent_loop_with_config(
1696 &mut session,
1697 "synthetic current input".into(),
1698 tx,
1699 probe.clone(),
1700 probe.clone(),
1701 CancellationToken::new(),
1702 config,
1703 )
1704 .await
1705 .unwrap_err();
1706 assert!(
1707 error.to_string().contains("session_conversation_changed:"),
1708 "{generation}: {error}"
1709 );
1710 assert_eq!(
1711 probe
1712 .provider_calls
1713 .load(std::sync::atomic::Ordering::SeqCst),
1714 0
1715 );
1716 assert_eq!(
1717 probe.tool_calls.load(std::sync::atomic::Ordering::SeqCst),
1718 0
1719 );
1720 assert_eq!(
1721 probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst),
1722 0
1723 );
1724 while let Ok(event) = rx.try_recv() {
1725 assert!(!matches!(
1726 event,
1727 bamboo_agent_core::AgentEvent::Complete { .. }
1728 ));
1729 }
1730 assert!(repository
1733 .stored
1734 .lock()
1735 .unwrap()
1736 .as_ref()
1737 .unwrap()
1738 .conversation_reset
1739 .is_some());
1740 }
1741 }
1742 }
1743
1744 #[tokio::test]
1745 async fn full_loop_preserves_ordinary_optional_seal_failure_behavior() {
1746 let mut session = Session::new("sdk-optional-seal", "model");
1747 let user = Message::user("synthetic optional input");
1748 let inputs = producer(&session, &user, true);
1749 session.add_message(user);
1750 let probe = Arc::new(SealLoopProbe::default());
1751 let config = seal_loop_config(inputs, None, None, &probe);
1752 let (tx, _rx) = tokio::sync::mpsc::channel(1024);
1753 crate::runtime::runner::run_agent_loop_with_config(
1754 &mut session,
1755 "synthetic optional input".into(),
1756 tx,
1757 probe.clone(),
1758 probe.clone(),
1759 CancellationToken::new(),
1760 config,
1761 )
1762 .await
1763 .unwrap();
1764 assert!(
1765 probe
1766 .provider_calls
1767 .load(std::sync::atomic::Ordering::SeqCst)
1768 > 0
1769 );
1770 assert!(probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst) > 0);
1771 }
1772
1773 #[tokio::test]
1774 async fn sdk_input_seal_freezes_generation_before_a_custom_checkpoint_and_readback() {
1775 for mutate_caller in [false, true] {
1776 for same_revision in [false, true] {
1777 let mut session = Session::new("sdk-generation-seal", "model");
1778 if same_revision {
1779 nonzero_conversation(&mut session);
1780 }
1781 let user = Message::user("exact id and digest still cannot cross conversation");
1782 let inputs = producer(&session, &user, true);
1783 session.add_message(user);
1784 let persistence: Arc<dyn RuntimeSessionPersistence> =
1785 Arc::new(ConversationChangingRepository {
1786 stored: Default::default(),
1787 mutate_caller,
1788 });
1789 let error = inputs
1790 .seal_sdk_input(&mut session, Some(&persistence))
1791 .await
1792 .unwrap_err();
1793 assert!(error.to_string().contains("session_conversation_changed:"));
1794 assert!(
1795 current(&inputs, &session).is_none(),
1796 "cannot publish New input from a different conversation"
1797 );
1798 }
1799 }
1800 }
1801 fn producer(session: &Session, user: &Message, typed: bool) -> UntrustedExecutionInputs {
1802 UntrustedExecutionInputs::new(vec![UntrustedInputObservation::new(&user.id, None).unwrap()])
1803 .unwrap()
1804 .bind_sdk_append(
1805 "actual-sdk-execution".into(),
1806 SdkPendingInput {
1807 session_id: session.id.clone(),
1808 input_id: user.id.clone(),
1809 header_digest: sdk_message_header_digest(user),
1810 final_digest: typed.then(|| sdk_message_digest(user)),
1811 },
1812 None,
1813 )
1814 }
1815 fn current(
1816 inputs: &UntrustedExecutionInputs,
1817 session: &Session,
1818 ) -> Option<BoundedInputRequestBatch> {
1819 let (execution_id, observation) = inputs.take_startup_observation(&session.id).unwrap();
1820 assert_eq!(execution_id, "actual-sdk-execution");
1821 let mut current = None;
1822 observation.update_current(&mut current, &session.id, &execution_id);
1823 assert!(inputs.take_startup_observation(&session.id).is_none());
1824 current
1825 }
1826 #[tokio::test]
1827 async fn sdk_startup_requires_exact_checkpoint_and_preserves_request_absence_as_new() {
1828 let mut session = Session::new("sdk-accept", "model");
1829 let user = Message::user("final prepared");
1830 let inputs = producer(&session, &user, true);
1831 session.add_message(user.clone());
1832 inputs
1833 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1834 .await
1835 .unwrap();
1836 let batch = current(&inputs, &session).unwrap();
1837 assert_eq!(batch.records().len(), 1);
1838 assert_eq!(batch.records()[0].input_id, user.id);
1839 assert_eq!(
1840 batch.records()[0].source,
1841 bamboo_domain::SessionMessageSource::User
1842 );
1843 assert!(batch.records()[0].request.is_none());
1844 }
1845 #[tokio::test]
1846 async fn sdk_startup_unknown_checkpoint_or_modified_final_user_stays_unavailable() {
1847 for (reject, corrupt, mutate, duplicate, wrong_session) in [
1848 (true, false, false, false, false),
1849 (false, true, false, false, false),
1850 (false, false, true, false, false),
1851 (false, false, false, true, false),
1852 (false, false, false, false, true),
1853 ] {
1854 let mut session = Session::new("sdk-deny", "model");
1855 let user = Message::user("prepared");
1856 let inputs = producer(&session, &user, true);
1857 session.add_message(user.clone());
1858 if mutate {
1859 session.messages[0].content.push_str(" changed");
1860 }
1861 if duplicate {
1862 session.add_message(user);
1863 }
1864 if wrong_session {
1865 session.id = "wrong".into();
1866 }
1867 assert!(inputs
1868 .seal_sdk_input(&mut session, Some(&repository(reject, corrupt)))
1869 .await
1870 .is_err());
1871 if wrong_session {
1872 assert!(inputs.take_startup_observation(&session.id).is_none());
1873 } else {
1874 assert!(current(&inputs, &session).is_none());
1875 }
1876 }
1877 }
1878 #[tokio::test]
1879 async fn sdk_legacy_hook_rewrite_is_bound_to_original_id_while_generic_history_is_unsealed() {
1880 let mut session = Session::new("sdk-string", "model");
1881 let user = Message::user("original");
1882 let inputs = producer(&session, &user, false);
1883 session.add_message(user.clone());
1884 session.messages[0].content = "real portable hook result".into();
1885 inputs
1886 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1887 .await
1888 .unwrap();
1889 assert_eq!(
1890 current(&inputs, &session).unwrap().records()[0].input_id,
1891 user.id
1892 );
1893 let generic =
1894 UntrustedExecutionInputs::new(vec![
1895 UntrustedInputObservation::new(&user.id, None).unwrap()
1896 ])
1897 .unwrap();
1898 generic
1899 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1900 .await
1901 .unwrap();
1902 assert!(generic.sdk_execution_id().is_none());
1903 assert!(generic.take_startup_observation(&session.id).is_none());
1904 }
1905 #[test]
1906 fn sdk_header_binding_accepts_only_the_canonical_prompt_changes() {
1907 let mut original = Message::user("canonical");
1908 original.content_parts = Some(vec![
1909 bamboo_domain::MessagePart::Text {
1910 text: "unrelated native text".into(),
1911 },
1912 bamboo_domain::MessagePart::Text {
1913 text: "canonical".into(),
1914 },
1915 ]);
1916 let digest = sdk_message_header_digest(&original);
1917 let mut prepared = original.clone();
1918 prepared.content = "canonical plus F".into();
1919 if let bamboo_domain::MessagePart::Text { text } =
1920 &mut prepared.content_parts.as_mut().unwrap()[1]
1921 {
1922 *text = prepared.content.clone();
1923 }
1924 assert_eq!(sdk_message_header_digest(&prepared), digest);
1925 prepared.id = "different".into();
1926 assert_ne!(sdk_message_header_digest(&prepared), digest);
1927 prepared = original;
1928 if let bamboo_domain::MessagePart::Text { text } =
1929 &mut prepared.content_parts.as_mut().unwrap()[0]
1930 {
1931 *text = "tampered".into();
1932 }
1933 assert_ne!(sdk_message_header_digest(&prepared), digest);
1934 }
1935}