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) prompt_memory_flags: PromptMemoryFlags,
1064 pub(crate) max_tool_calls_per_round: usize,
1066 pub(crate) max_consecutive_failures_per_tool: usize,
1068 pub(crate) per_tool_timeout_secs: u64,
1070 pub(crate) parallel_batch_timeout_secs: u64,
1072 pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
1075 pub(crate) upstream_retry: bamboo_llm::retry::UpstreamRetry,
1077 pub(crate) permission_mode: Option<PermissionMode>,
1079 pub(crate) gold_config: Option<GoldConfig>,
1084 pub(crate) guardian_config: Option<GuardianConfig>,
1087 pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
1091 pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
1097 pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
1106 pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
1110 pub(crate) hook_runner: Arc<HookRunner>,
1113 pub(crate) features_dynamic_model_routing: bool,
1117 pub(crate) features_final_evidence_check: bool,
1119 pub(crate) auxiliary_model_resolver:
1125 Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
1126 pub(crate) background_model_resolver: Option<BackgroundModelResolver>,
1128 pub(crate) resume_auxiliary_resolver: Option<ResumeAuxiliaryResolver>,
1130 pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
1137 pub(crate) mcp_tool_guidance: Option<String>,
1143 pub(crate) run_budget: bamboo_config::RunBudgetConfig,
1152 pub(crate) counted_loop: Option<super::counted_loop::CountedLoopConfig>,
1153 pub(crate) loop_control: Option<super::loop_control::LoopControlConfig>,
1154}
1155
1156impl AgentLoopConfig {
1157 pub(crate) fn effective_counted_loop(&self) -> Option<&super::counted_loop::CountedLoopConfig> {
1159 self.counted_loop
1160 .as_ref()
1161 .or_else(|| self.loop_control.as_ref().and_then(|c| c.active_counted()))
1162 }
1163
1164 pub fn initial_untrusted_inputs(&self) -> Option<&UntrustedExecutionInputs> {
1166 self.initial_untrusted_inputs.as_ref()
1167 }
1168}
1169
1170impl Default for AgentLoopConfig {
1171 fn default() -> Self {
1172 Self {
1173 freeze_tool_exposure_for_cache: true,
1174 ticket_worker_plan: None,
1175 system_prompt: None,
1176 disabled_skill_ids: BTreeSet::new(),
1177 selected_skill_ids: None,
1178 selected_skill_mode: None,
1179 initial_untrusted_inputs: None,
1180 sdk_skill_execution_host: None,
1181 workspace_execution_host: None,
1182 additional_tool_schemas: Vec::new(),
1183 tool_registry: Arc::new(ToolRegistry::new()),
1184 skill_manager: None,
1185 project_context_resolver: None,
1186 skip_initial_user_message: false,
1187 storage: None,
1188 persistence: None,
1189 guidance_active_run_id: None,
1190 session_inbox: None,
1191 live_bootstrap_host: None,
1192 gold_target_resolver: None,
1193 host_gold_run: None,
1194 session_activation_notifications: None,
1195 attachment_reader: None,
1196 metrics_collector: None,
1197 model_name: None,
1198 fast_model_name: None,
1199 fast_model_provider: None,
1200 auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
1201 background_model_name: None,
1202 planning_model_name: None,
1203 search_model_name: None,
1204 compression_instructions: None,
1205 summary_target_ratio: 0.20,
1206 context_management: bamboo_config::ContextManagementConfig::default(),
1207 summary_safe_window_percent: 80,
1208 summarization_model_name: None,
1209 background_model_provider: None,
1210 summarization_model_provider: None,
1211 provider_name: None,
1212 provider_type: None,
1213 reasoning_effort: None,
1214 app_data_dir: None,
1215 memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
1216 disabled_tools: BTreeSet::new(),
1217 token_budget: None,
1218 model_limits_section: None,
1219 legacy_model_limits: None,
1220 image_fallback: None,
1221 vision_model_resolver: None,
1222 legacy_vision_model_resolver: None,
1223 prompt_memory_flags: PromptMemoryFlags::default(),
1224 max_tool_calls_per_round: 80,
1225 max_consecutive_failures_per_tool: 3,
1226 per_tool_timeout_secs: 120,
1227 parallel_batch_timeout_secs: 300,
1228 stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
1229 upstream_retry: Default::default(),
1230 permission_mode: None,
1231 gold_config: None,
1232 guardian_config: None,
1233 guardian_spawner: None,
1234 bash_resume_hook: None,
1235 bash_completion_sink: None,
1236 approval_delegate: None,
1237 hook_runner: Arc::new(HookRunner::new()),
1238 features_dynamic_model_routing: false,
1239 features_final_evidence_check: false,
1240 auxiliary_model_resolver: None,
1241 background_model_resolver: None,
1242 resume_auxiliary_resolver: None,
1243 disabled_filter_resolver: None,
1244 mcp_tool_guidance: None,
1245 run_budget: bamboo_config::RunBudgetConfig::default(),
1246 counted_loop: None,
1247 loop_control: None,
1248 }
1249 }
1250}
1251
1252impl AgentLoopConfig {
1253 pub(crate) fn resolve_auxiliary_models(&self) -> AuxiliaryModelConfig {
1257 let mut models = self
1258 .auxiliary_model_resolver
1259 .as_ref()
1260 .map(|resolver| resolver())
1261 .unwrap_or_else(|| AuxiliaryModelConfig {
1262 fast_model_name: self.fast_model_name.clone(),
1263 fast_model_provider: self.fast_model_provider.clone(),
1264 background_model_name: self.background_model_name.clone(),
1265 planning_model_name: self.planning_model_name.clone(),
1266 search_model_name: self.search_model_name.clone(),
1267 summarization_model_name: self.summarization_model_name.clone(),
1268 background_model_provider: self.background_model_provider.clone(),
1269 summarization_model_provider: self.summarization_model_provider.clone(),
1270 summarization_model_error: None,
1271 });
1272 if let Some(resolve) = &self.background_model_resolver {
1273 let background = resolve(self.provider_name.as_deref().unwrap_or_default());
1274 models.background_model_name =
1275 background.as_ref().map(|model| model.model_name.clone());
1276 models.background_model_provider = background.map(|model| model.provider);
1277 }
1278 if let Some(resolve) = &self.resume_auxiliary_resolver {
1279 let (summary, background) = resolve();
1280 models.background_model_name =
1281 background.as_ref().map(|model| model.model_name.clone());
1282 models.background_model_provider = background.map(|model| model.provider);
1283 match summary {
1284 Ok(summary) => {
1285 models.summarization_model_name =
1286 summary.as_ref().map(|model| model.model_name.clone());
1287 models.summarization_model_provider = summary.map(|model| model.provider);
1288 models.summarization_model_error = None;
1289 }
1290 Err(error) => {
1291 models.summarization_model_name = None;
1292 models.summarization_model_provider = None;
1293 models.summarization_model_error = Some(error.to_string());
1294 }
1295 }
1296 }
1297 models
1298 }
1299
1300 pub fn bind_ticket_worker_plan(
1301 &mut self,
1302 plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
1303 ) {
1304 self.ticket_worker_plan = Some(plan);
1305 }
1306 pub(crate) fn resolve_disabled_filters(
1311 &self,
1312 ) -> (
1313 std::borrow::Cow<'_, BTreeSet<String>>,
1314 std::borrow::Cow<'_, BTreeSet<String>>,
1315 ) {
1316 match &self.disabled_filter_resolver {
1317 Some(resolver) => {
1318 let (tools, skills) = resolver();
1319 (
1320 std::borrow::Cow::Owned(tools),
1321 std::borrow::Cow::Owned(skills),
1322 )
1323 }
1324 None => (
1325 std::borrow::Cow::Borrowed(&self.disabled_tools),
1326 std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
1327 ),
1328 }
1329 }
1330
1331 pub fn active_goal(&self) -> Option<&str> {
1335 self.gold_config
1336 .as_ref()
1337 .filter(|cfg| cfg.enabled)
1338 .and_then(GoldConfig::effective_goal)
1339 }
1340
1341 pub fn goal_loop_active(&self) -> bool {
1349 self.gold_config.as_ref().is_some_and(|cfg| {
1350 cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
1351 })
1352 }
1353
1354 pub fn guardian_active(&self) -> bool {
1358 self.guardian_spawner.is_some()
1359 && self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
1360 }
1361
1362 pub fn guardian_max_reviews(&self) -> u32 {
1365 self.guardian_config
1366 .as_ref()
1367 .map_or(0, |cfg| cfg.max_reviews)
1368 }
1369
1370 pub fn guardian_model(&self) -> Option<&str> {
1372 self.guardian_config
1373 .as_ref()
1374 .and_then(|cfg| cfg.model_name.as_deref())
1375 }
1376
1377 pub fn delegation_active(&self) -> bool {
1379 self.approval_delegate.is_some()
1380 }
1381}
1382
1383#[cfg(test)]
1384mod tests;
1385
1386#[cfg(test)]
1387mod execution_input_tests {
1388 use super::*;
1389 fn request() -> bamboo_domain::SessionSkillRequest {
1390 bamboo_domain::SessionSkillRequest {
1391 selections: vec![bamboo_domain::SessionSkillSelection {
1392 id: "exact Case".into(),
1393 source: "builtin".into(),
1394 revision: 1,
1395 args: serde_json::json!({"raw":"保持原样"}),
1396 }],
1397 mode: None,
1398 }
1399 }
1400 #[test]
1401 fn execution_input_owned_bounds_and_old_config_default() {
1402 assert!(AgentLoopConfig::default()
1403 .initial_untrusted_inputs()
1404 .is_none());
1405 let request = request();
1406 let input = UntrustedInputObservation::new("exact-input", Some(&request)).unwrap();
1407 assert_eq!(input.input_id(), "exact-input");
1408 assert_eq!(input.request(), Some(&request));
1409 for id in [
1410 "",
1411 " leading",
1412 "trailing ",
1413 "unsafe/id",
1414 "unsafe\\id",
1415 "unsafe..id",
1416 ] {
1417 assert!(UntrustedInputObservation::new(id, None).is_none());
1418 }
1419 assert!(UntrustedInputObservation::new(&"a".repeat(257), None).is_none());
1420 assert!(UntrustedExecutionInputs::new(Vec::new()).is_none());
1421 let batch = UntrustedExecutionInputs::new(
1422 (0..128)
1423 .map(|i| {
1424 UntrustedInputObservation::new(&format!("input-{i}"), Some(&request)).unwrap()
1425 })
1426 .collect(),
1427 )
1428 .unwrap();
1429 assert_eq!(batch.observations().len(), 128);
1430 assert_eq!(batch.observations()[127].input_id(), "input-127");
1431 assert!(UntrustedExecutionInputs::new(
1432 (0..129)
1433 .map(|i| UntrustedInputObservation::new(&format!("input-{i}"), None).unwrap())
1434 .collect()
1435 )
1436 .is_none());
1437 assert!(UntrustedExecutionInputs::new(vec![
1438 UntrustedInputObservation::new("same", None).unwrap(),
1439 UntrustedInputObservation::new("same", None).unwrap()
1440 ])
1441 .is_none());
1442 let mut malformed = request;
1443 malformed.selections[0].args = (0..65).fold(serde_json::Value::Null, |v, _| {
1444 serde_json::Value::Array(vec![v])
1445 });
1446 assert!(UntrustedInputObservation::new("deep", Some(&malformed)).is_none());
1447 }
1448 #[test]
1449 fn execution_input_only_explicit_checked_commit_slice_copies_request_fields() {
1450 let request = request();
1451 let mut user = bamboo_domain::SessionMessageEnvelope::user_input("target", "ordinary");
1452 if let bamboo_domain::SessionMessageBody::Content(content) = &mut user.body {
1453 content.skill_request = Some(request.clone());
1454 }
1455 let id = user.id.to_string();
1456 let plain = user.to_provider_message().unwrap();
1457 let root = user
1458 .with_root_chat_prompt("system prompt is not retained".into())
1459 .unwrap()
1460 .to_provider_message()
1461 .unwrap();
1462 for message in [plain, root] {
1463 let batch = UntrustedExecutionInputs::from_committed_messages(&[message]).unwrap();
1464 assert_eq!(batch.observations().len(), 1);
1465 assert_eq!(batch.observations()[0].input_id(), id);
1466 assert_eq!(batch.observations()[0].request(), Some(&request));
1467 }
1468 let text = bamboo_agent_core::Message::user("{\"skill_request\":\"fragment\"}");
1469 assert!(UntrustedExecutionInputs::from_committed_messages(&[text]).is_none());
1470 assert!(UntrustedExecutionInputs::from_committed_messages(&[]).is_none());
1471 }
1472}
1473
1474#[cfg(test)]
1475mod sdk_acceptance_tests {
1476 use super::*;
1477 use crate::runtime::managers::lifecycle::BoundedInputRequestBatch;
1478 use bamboo_agent_core::{Message, Role, Session};
1479 use tokio_util::sync::CancellationToken;
1480
1481 struct Repository {
1482 stored: std::sync::Mutex<Option<Session>>,
1483 reject: bool,
1484 corrupt: bool,
1485 }
1486 #[async_trait::async_trait]
1487 impl RuntimeSessionPersistence for Repository {
1488 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1489 if self.reject {
1490 return Err(std::io::Error::other("checkpoint refused"));
1491 }
1492 let mut accepted = session.clone();
1493 if self.corrupt {
1494 accepted
1495 .messages
1496 .iter_mut()
1497 .find(|message| message.role == Role::User)
1498 .unwrap()
1499 .content
1500 .push_str(" changed");
1501 }
1502 *self.stored.lock().unwrap() = Some(accepted);
1503 Ok(())
1504 }
1505 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1506 self.save_runtime_session(session).await
1507 }
1508 async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1509 Ok(self.stored.lock().unwrap().clone())
1510 }
1511 }
1512 fn repository(reject: bool, corrupt: bool) -> Arc<dyn RuntimeSessionPersistence> {
1513 Arc::new(Repository {
1514 stored: Default::default(),
1515 reject,
1516 corrupt,
1517 })
1518 }
1519
1520 fn nonzero_conversation(session: &mut Session) {
1521 session.conversation_reset = Some(bamboo_domain::ConversationResetState {
1522 schema_version: 1,
1523 conversation_revision: 1,
1524 receipts: vec![bamboo_domain::ConversationResetReceipt {
1525 operation_id: format!("0:{}", uuid::Uuid::new_v4()),
1526 birth_token: session.root_mode_birth_token(),
1527 expected_snapshot_sha256: "a".repeat(64),
1528 previous_revision: 0,
1529 conversation_revision: 1,
1530 retained_system_messages: 0,
1531 reset_at: session.updated_at,
1532 }],
1533 });
1534 }
1535
1536 struct ConversationChangingRepository {
1537 stored: std::sync::Mutex<Option<Session>>,
1538 mutate_caller: bool,
1539 }
1540 #[async_trait::async_trait]
1541 impl RuntimeSessionPersistence for ConversationChangingRepository {
1542 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1543 let mut accepted = session.clone();
1544 if let Some(state) = accepted.conversation_reset.as_mut() {
1545 state.receipts[0].expected_snapshot_sha256 = "b".repeat(64);
1546 } else {
1547 nonzero_conversation(&mut accepted);
1548 }
1549 if self.mutate_caller {
1550 *session = accepted.clone();
1551 }
1552 *self.stored.lock().unwrap() = Some(accepted);
1553 Ok(())
1554 }
1555 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1556 self.save_runtime_session(session).await
1557 }
1558 async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1559 Ok(self.stored.lock().unwrap().clone())
1560 }
1561 }
1562
1563 #[async_trait::async_trait]
1564 impl bamboo_domain::Storage for ConversationChangingRepository {
1565 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
1566 *self.stored.lock().unwrap() = Some(session.clone());
1567 Ok(())
1568 }
1569 async fn load_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1570 Ok(self.stored.lock().unwrap().clone())
1571 }
1572 async fn delete_session(&self, _: &str) -> std::io::Result<bool> {
1573 Err(std::io::Error::other("fixture never deletes a Session"))
1574 }
1575 }
1576
1577 #[derive(Default)]
1578 struct SealLoopProbe {
1579 provider_calls: std::sync::atomic::AtomicUsize,
1580 tool_calls: std::sync::atomic::AtomicUsize,
1581 setup_calls: std::sync::atomic::AtomicUsize,
1582 }
1583 #[async_trait::async_trait]
1584 impl bamboo_llm::LLMProvider for SealLoopProbe {
1585 async fn chat_stream(
1586 &self,
1587 _: &[Message],
1588 _: &[bamboo_agent_core::tools::ToolSchema],
1589 _: Option<u32>,
1590 _: &str,
1591 ) -> bamboo_llm::provider::Result<bamboo_llm::LLMStream> {
1592 self.provider_calls
1593 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1594 Ok(Box::pin(futures::stream::iter(vec![
1595 Ok(bamboo_llm::LLMChunk::Token("synthetic complete".into())),
1596 Ok(bamboo_llm::LLMChunk::Done),
1597 ])))
1598 }
1599 }
1600 #[async_trait::async_trait]
1601 impl bamboo_agent_core::tools::ToolExecutor for SealLoopProbe {
1602 async fn execute(
1603 &self,
1604 _: &bamboo_agent_core::tools::ToolCall,
1605 ) -> Result<bamboo_agent_core::tools::ToolResult, bamboo_agent_core::tools::ToolError>
1606 {
1607 self.tool_calls
1608 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1609 Err(bamboo_agent_core::tools::ToolError::Execution(
1610 "no fixture tools".into(),
1611 ))
1612 }
1613 fn list_tools(&self) -> Vec<bamboo_agent_core::tools::ToolSchema> {
1614 vec![]
1615 }
1616 }
1617 #[async_trait::async_trait]
1618 impl bamboo_agent_core::AgentHook for SealLoopProbe {
1619 fn point(&self) -> bamboo_domain::AgentHookPoint {
1620 bamboo_domain::AgentHookPoint::AfterSessionSetup
1621 }
1622 async fn run(
1623 &self,
1624 _: bamboo_domain::AgentHookPoint,
1625 _: &bamboo_domain::HookPayload,
1626 _: &Session,
1627 ) -> bamboo_domain::HookResult {
1628 self.setup_calls
1629 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1630 bamboo_domain::HookResult::Continue
1631 }
1632 }
1633
1634 fn seal_loop_config(
1635 inputs: UntrustedExecutionInputs,
1636 persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
1637 storage: Option<Arc<dyn bamboo_domain::Storage>>,
1638 probe: &Arc<SealLoopProbe>,
1639 ) -> AgentLoopConfig {
1640 let mut hooks = crate::runtime::HookRunner::new();
1641 hooks.register(probe.clone());
1642 AgentLoopConfig {
1643 initial_untrusted_inputs: Some(inputs),
1644 persistence,
1645 storage,
1646 skip_initial_user_message: true,
1647 model_name: Some("synthetic-seal-model".into()),
1648 hook_runner: Arc::new(hooks),
1649 prompt_memory_flags: PromptMemoryFlags {
1650 project_prompt_injection: false,
1651 relevant_recall: false,
1652 relevant_recall_rerank: false,
1653 project_first_dream: false,
1654 ledger_agenda: false,
1655 },
1656 ..Default::default()
1657 }
1658 }
1659
1660 #[tokio::test]
1661 async fn full_loop_stops_changed_or_invalid_seal_before_hooks_provider_and_complete() {
1662 for mutate_caller in [false, true] {
1663 for generation in ["advance", "same-revision", "invalid"] {
1664 let mut session = Session::new("sdk-full-seal-refusal", "model");
1665 if generation == "same-revision" {
1666 nonzero_conversation(&mut session);
1667 }
1668 if generation == "invalid" {
1669 session.conversation_reset = Some(Default::default());
1670 }
1671 let user = Message::user("synthetic current input");
1672 let inputs = producer(&session, &user, true);
1673 session.add_message(user);
1674 let repository = Arc::new(ConversationChangingRepository {
1675 stored: std::sync::Mutex::new(Some(session.clone())),
1676 mutate_caller,
1677 });
1678 let probe = Arc::new(SealLoopProbe::default());
1679 let config = seal_loop_config(
1680 inputs,
1681 Some(repository.clone()),
1682 Some(repository.clone()),
1683 &probe,
1684 );
1685 let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
1686 let error = crate::runtime::runner::run_agent_loop_with_config(
1687 &mut session,
1688 "synthetic current input".into(),
1689 tx,
1690 probe.clone(),
1691 probe.clone(),
1692 CancellationToken::new(),
1693 config,
1694 )
1695 .await
1696 .unwrap_err();
1697 assert!(
1698 error.to_string().contains("session_conversation_changed:"),
1699 "{generation}: {error}"
1700 );
1701 assert_eq!(
1702 probe
1703 .provider_calls
1704 .load(std::sync::atomic::Ordering::SeqCst),
1705 0
1706 );
1707 assert_eq!(
1708 probe.tool_calls.load(std::sync::atomic::Ordering::SeqCst),
1709 0
1710 );
1711 assert_eq!(
1712 probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst),
1713 0
1714 );
1715 while let Ok(event) = rx.try_recv() {
1716 assert!(!matches!(
1717 event,
1718 bamboo_agent_core::AgentEvent::Complete { .. }
1719 ));
1720 }
1721 assert!(repository
1724 .stored
1725 .lock()
1726 .unwrap()
1727 .as_ref()
1728 .unwrap()
1729 .conversation_reset
1730 .is_some());
1731 }
1732 }
1733 }
1734
1735 #[tokio::test]
1736 async fn full_loop_preserves_ordinary_optional_seal_failure_behavior() {
1737 let mut session = Session::new("sdk-optional-seal", "model");
1738 let user = Message::user("synthetic optional input");
1739 let inputs = producer(&session, &user, true);
1740 session.add_message(user);
1741 let probe = Arc::new(SealLoopProbe::default());
1742 let config = seal_loop_config(inputs, None, None, &probe);
1743 let (tx, _rx) = tokio::sync::mpsc::channel(1024);
1744 crate::runtime::runner::run_agent_loop_with_config(
1745 &mut session,
1746 "synthetic optional input".into(),
1747 tx,
1748 probe.clone(),
1749 probe.clone(),
1750 CancellationToken::new(),
1751 config,
1752 )
1753 .await
1754 .unwrap();
1755 assert!(
1756 probe
1757 .provider_calls
1758 .load(std::sync::atomic::Ordering::SeqCst)
1759 > 0
1760 );
1761 assert!(probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst) > 0);
1762 }
1763
1764 #[tokio::test]
1765 async fn sdk_input_seal_freezes_generation_before_a_custom_checkpoint_and_readback() {
1766 for mutate_caller in [false, true] {
1767 for same_revision in [false, true] {
1768 let mut session = Session::new("sdk-generation-seal", "model");
1769 if same_revision {
1770 nonzero_conversation(&mut session);
1771 }
1772 let user = Message::user("exact id and digest still cannot cross conversation");
1773 let inputs = producer(&session, &user, true);
1774 session.add_message(user);
1775 let persistence: Arc<dyn RuntimeSessionPersistence> =
1776 Arc::new(ConversationChangingRepository {
1777 stored: Default::default(),
1778 mutate_caller,
1779 });
1780 let error = inputs
1781 .seal_sdk_input(&mut session, Some(&persistence))
1782 .await
1783 .unwrap_err();
1784 assert!(error.to_string().contains("session_conversation_changed:"));
1785 assert!(
1786 current(&inputs, &session).is_none(),
1787 "cannot publish New input from a different conversation"
1788 );
1789 }
1790 }
1791 }
1792 fn producer(session: &Session, user: &Message, typed: bool) -> UntrustedExecutionInputs {
1793 UntrustedExecutionInputs::new(vec![UntrustedInputObservation::new(&user.id, None).unwrap()])
1794 .unwrap()
1795 .bind_sdk_append(
1796 "actual-sdk-execution".into(),
1797 SdkPendingInput {
1798 session_id: session.id.clone(),
1799 input_id: user.id.clone(),
1800 header_digest: sdk_message_header_digest(user),
1801 final_digest: typed.then(|| sdk_message_digest(user)),
1802 },
1803 None,
1804 )
1805 }
1806 fn current(
1807 inputs: &UntrustedExecutionInputs,
1808 session: &Session,
1809 ) -> Option<BoundedInputRequestBatch> {
1810 let (execution_id, observation) = inputs.take_startup_observation(&session.id).unwrap();
1811 assert_eq!(execution_id, "actual-sdk-execution");
1812 let mut current = None;
1813 observation.update_current(&mut current, &session.id, &execution_id);
1814 assert!(inputs.take_startup_observation(&session.id).is_none());
1815 current
1816 }
1817 #[tokio::test]
1818 async fn sdk_startup_requires_exact_checkpoint_and_preserves_request_absence_as_new() {
1819 let mut session = Session::new("sdk-accept", "model");
1820 let user = Message::user("final prepared");
1821 let inputs = producer(&session, &user, true);
1822 session.add_message(user.clone());
1823 inputs
1824 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1825 .await
1826 .unwrap();
1827 let batch = current(&inputs, &session).unwrap();
1828 assert_eq!(batch.records().len(), 1);
1829 assert_eq!(batch.records()[0].input_id, user.id);
1830 assert_eq!(
1831 batch.records()[0].source,
1832 bamboo_domain::SessionMessageSource::User
1833 );
1834 assert!(batch.records()[0].request.is_none());
1835 }
1836 #[tokio::test]
1837 async fn sdk_startup_unknown_checkpoint_or_modified_final_user_stays_unavailable() {
1838 for (reject, corrupt, mutate, duplicate, wrong_session) in [
1839 (true, false, false, false, false),
1840 (false, true, false, false, false),
1841 (false, false, true, false, false),
1842 (false, false, false, true, false),
1843 (false, false, false, false, true),
1844 ] {
1845 let mut session = Session::new("sdk-deny", "model");
1846 let user = Message::user("prepared");
1847 let inputs = producer(&session, &user, true);
1848 session.add_message(user.clone());
1849 if mutate {
1850 session.messages[0].content.push_str(" changed");
1851 }
1852 if duplicate {
1853 session.add_message(user);
1854 }
1855 if wrong_session {
1856 session.id = "wrong".into();
1857 }
1858 assert!(inputs
1859 .seal_sdk_input(&mut session, Some(&repository(reject, corrupt)))
1860 .await
1861 .is_err());
1862 if wrong_session {
1863 assert!(inputs.take_startup_observation(&session.id).is_none());
1864 } else {
1865 assert!(current(&inputs, &session).is_none());
1866 }
1867 }
1868 }
1869 #[tokio::test]
1870 async fn sdk_legacy_hook_rewrite_is_bound_to_original_id_while_generic_history_is_unsealed() {
1871 let mut session = Session::new("sdk-string", "model");
1872 let user = Message::user("original");
1873 let inputs = producer(&session, &user, false);
1874 session.add_message(user.clone());
1875 session.messages[0].content = "real portable hook result".into();
1876 inputs
1877 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1878 .await
1879 .unwrap();
1880 assert_eq!(
1881 current(&inputs, &session).unwrap().records()[0].input_id,
1882 user.id
1883 );
1884 let generic =
1885 UntrustedExecutionInputs::new(vec![
1886 UntrustedInputObservation::new(&user.id, None).unwrap()
1887 ])
1888 .unwrap();
1889 generic
1890 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1891 .await
1892 .unwrap();
1893 assert!(generic.sdk_execution_id().is_none());
1894 assert!(generic.take_startup_observation(&session.id).is_none());
1895 }
1896 #[test]
1897 fn sdk_header_binding_accepts_only_the_canonical_prompt_changes() {
1898 let mut original = Message::user("canonical");
1899 original.content_parts = Some(vec![
1900 bamboo_domain::MessagePart::Text {
1901 text: "unrelated native text".into(),
1902 },
1903 bamboo_domain::MessagePart::Text {
1904 text: "canonical".into(),
1905 },
1906 ]);
1907 let digest = sdk_message_header_digest(&original);
1908 let mut prepared = original.clone();
1909 prepared.content = "canonical plus F".into();
1910 if let bamboo_domain::MessagePart::Text { text } =
1911 &mut prepared.content_parts.as_mut().unwrap()[1]
1912 {
1913 *text = prepared.content.clone();
1914 }
1915 assert_eq!(sdk_message_header_digest(&prepared), digest);
1916 prepared.id = "different".into();
1917 assert_ne!(sdk_message_header_digest(&prepared), digest);
1918 prepared = original;
1919 if let bamboo_domain::MessagePart::Text { text } =
1920 &mut prepared.content_parts.as_mut().unwrap()[0]
1921 {
1922 *text = "tampered".into();
1923 }
1924 assert_ne!(sdk_message_header_digest(&prepared), digest);
1925 }
1926}