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#[derive(Clone, Default)]
44pub struct AuxiliaryModelConfig {
45 pub fast_model_name: Option<String>,
46 pub fast_model_provider: Option<Arc<dyn LLMProvider>>,
47 pub background_model_name: Option<String>,
48 pub planning_model_name: Option<String>,
49 pub search_model_name: Option<String>,
50 pub summarization_model_name: Option<String>,
51 pub background_model_provider: Option<Arc<dyn LLMProvider>>,
52 pub summarization_model_provider: Option<Arc<dyn LLMProvider>>,
53 pub summarization_model_error: Option<String>,
55}
56
57fn default_gold_max_output_tokens() -> u32 {
58 1024
59}
60
61fn default_gold_max_auto_continuations() -> u32 {
62 3
63}
64
65fn default_gold_min_confidence() -> GoldConfidence {
66 GoldConfidence::Medium
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(default)]
71pub struct GoldConfig {
72 #[serde(default)]
73 pub recovery: super::goal_recovery::GoalRecoveryPolicy,
74 #[serde(default)]
76 pub enabled: bool,
77 #[serde(default)]
82 pub auto_answer_enabled: bool,
83 #[serde(default)]
89 pub auto_continue_enabled: bool,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub model_name: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub goal: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub evaluation_prompt: Option<String>,
106 #[serde(default = "default_gold_max_output_tokens")]
108 pub max_output_tokens: u32,
109 #[serde(default = "default_gold_max_auto_continuations")]
111 pub max_auto_continuations: u32,
112 #[serde(default = "default_gold_min_confidence")]
116 pub min_auto_continue_confidence: GoldConfidence,
117}
118
119impl Default for GoldConfig {
120 fn default() -> Self {
121 Self {
122 recovery: Default::default(),
123 enabled: false,
124 auto_answer_enabled: false,
125 auto_continue_enabled: false,
126 model_name: None,
127 goal: None,
128 evaluation_prompt: None,
129 max_output_tokens: default_gold_max_output_tokens(),
130 max_auto_continuations: default_gold_max_auto_continuations(),
131 min_auto_continue_confidence: default_gold_min_confidence(),
132 }
133 }
134}
135
136impl GoldConfig {
137 pub fn effective_goal(&self) -> Option<&str> {
142 self.goal
143 .as_deref()
144 .or(self.evaluation_prompt.as_deref())
145 .map(str::trim)
146 .filter(|value| !value.is_empty())
147 }
148}
149
150fn default_guardian_max_reviews() -> u32 {
151 2
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
160#[serde(default)]
161pub struct GuardianConfig {
162 #[serde(default)]
164 pub enabled: bool,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub model_name: Option<String>,
168 #[serde(default = "default_guardian_max_reviews")]
171 pub max_reviews: u32,
172}
173
174impl Default for GuardianConfig {
175 fn default() -> Self {
176 Self {
177 enabled: false,
178 model_name: None,
179 max_reviews: default_guardian_max_reviews(),
180 }
181 }
182}
183
184#[async_trait::async_trait]
194pub trait GuardianSpawner: Send + Sync {
195 async fn spawn_guardian_review(
199 &self,
200 parent_session: &bamboo_agent_core::Session,
201 review_prompt: String,
202 model: String,
203 disabled_tools: Option<BTreeSet<String>>,
204 ) -> Result<String, String>;
205}
206
207pub const BASH_COMPLETION_RESUME_KIND: &str = "bash_completion_resume";
213
214pub use bamboo_agent_core::BashCompletionSink;
218
219pub trait BashResumeHook: Send + Sync {
233 fn arrange_bash_self_resume(&self, session_id: String, bash_ids: Vec<String>);
236
237 fn supports_background_exec(&self) -> bool {
240 false
241 }
242
243 fn arrange_exec_self_resume(
246 &self,
247 _job: bamboo_tools::tools::exec_command::BackgroundExecJob,
248 _session_created_at: chrono::DateTime<chrono::Utc>,
249 ) {
250 }
251}
252
253#[derive(Debug, Clone)]
258pub struct ChildApprovalRequest {
259 pub child_session_id: String,
260 pub parent_session_id: String,
261 pub child_tool_call_id: String,
263 pub tool_name: String,
264 pub permission_type: String,
266 pub resource: String,
268 pub question: String,
270 pub approval_payload: serde_json::Value,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum ChildApprovalOutcome {
278 Delegated,
280 AutoApproved,
282 AutoDenied,
284}
285
286#[async_trait::async_trait]
292pub trait ApprovalDelegate: Send + Sync {
293 async fn delegate_child_approval(
296 &self,
297 request: ChildApprovalRequest,
298 ) -> Result<ChildApprovalOutcome, String>;
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum ImageFallbackMode {
303 Placeholder,
304 Error,
305 Ocr,
306 Vision,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct ImageFallbackConfig {
314 pub mode: ImageFallbackMode,
315 pub vision_model: Option<String>,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub struct PromptMemoryFlags {
322 pub project_prompt_injection: bool,
323 pub relevant_recall: bool,
324 pub relevant_recall_rerank: bool,
325 pub project_first_dream: bool,
326 pub ledger_agenda: bool,
327}
328
329impl Default for PromptMemoryFlags {
330 fn default() -> Self {
331 Self {
332 project_prompt_injection: true,
333 relevant_recall: true,
334 relevant_recall_rerank: false,
335 project_first_dream: true,
336 ledger_agenda: true,
337 }
338 }
339}
340
341impl From<&MemoryConfig> for PromptMemoryFlags {
342 fn from(value: &MemoryConfig) -> Self {
343 Self {
344 project_prompt_injection: value.project_prompt_injection,
345 relevant_recall: value.relevant_recall,
346 relevant_recall_rerank: value.relevant_recall_rerank,
347 project_first_dream: value.project_first_dream,
348 ledger_agenda: value.ledger_agenda_injection,
349 }
350 }
351}
352
353#[async_trait::async_trait]
356pub trait SkillExecutionHost: Send + Sync {
357 fn observe_current_inputs(
358 &self,
359 session_id: &str,
360 execution_id: &str,
361 current: Option<&crate::runtime::managers::lifecycle::BoundedInputRequestBatch>,
362 ) -> Result<(), bamboo_agent_core::AgentError>;
363
364 async fn refresh_runtime_scope(
367 &self,
368 _session: &mut bamboo_agent_core::Session,
369 _execution_id: &str,
370 ) -> Result<(), bamboo_agent_core::AgentError> {
371 Ok(())
372 }
373
374 async fn render_skill_prompt(
375 &self,
376 session: &bamboo_agent_core::Session,
377 execution_id: &str,
378 ) -> Result<String, bamboo_agent_core::AgentError>;
379
380 fn finish(&self, session_id: &str, execution_id: &str);
381}
382
383pub use SkillExecutionHost as SdkSkillExecutionHost;
385
386#[async_trait::async_trait]
389pub trait WorkspaceExecutionHost: Send + Sync {
390 async fn refresh_runtime_scope(
391 &self,
392 session: &mut bamboo_agent_core::Session,
393 execution_id: &str,
394 persistence: &Arc<dyn bamboo_domain::RuntimeSessionPersistence>,
395 ) -> Result<(), bamboo_agent_core::AgentError>;
396
397 fn finish(&self);
399}
400
401#[derive(Debug, PartialEq)]
404pub struct UntrustedInputObservation {
405 input_id: bamboo_domain::SessionMessageId,
406 request: Option<bamboo_domain::SessionSkillRequest>,
407}
408
409impl UntrustedInputObservation {
410 pub fn new(
411 input_id: &str,
412 request: Option<&bamboo_domain::SessionSkillRequest>,
413 ) -> Option<Self> {
414 if input_id.is_empty() || input_id.len() > 256 {
417 return None;
418 }
419 let input_id = bamboo_domain::SessionMessageId::parse(input_id.to_owned()).ok()?;
420 if let Some(request) = request {
421 request.validate().ok()?;
422 }
423 Some(Self {
424 input_id,
425 request: request.cloned(),
426 })
427 }
428
429 pub fn input_id(&self) -> &str {
430 self.input_id.as_str()
431 }
432 pub fn request(&self) -> Option<&bamboo_domain::SessionSkillRequest> {
433 self.request.as_ref()
434 }
435}
436
437pub struct UntrustedExecutionInputs {
442 observations: Box<[UntrustedInputObservation]>,
443 startup: std::sync::Mutex<Option<StartupInputObservation>>,
444 sdk_execution_id: Option<String>,
445 sdk_hook_runner: Option<Arc<HookRunner>>,
446 sdk_pending: std::sync::Mutex<Option<SdkPendingInput>>,
447}
448
449#[derive(Debug)]
450pub(crate) struct SdkPendingInput {
451 pub session_id: String,
452 pub input_id: String,
453 pub header_digest: [u8; 32],
454 pub final_digest: Option<[u8; 32]>,
455}
456
457impl std::fmt::Debug for UntrustedExecutionInputs {
458 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459 f.debug_struct("UntrustedExecutionInputs")
460 .field("observations", &self.observations)
461 .field("sdk_execution_id", &self.sdk_execution_id)
462 .finish_non_exhaustive()
463 }
464}
465
466pub(crate) fn sdk_message_digest(message: &bamboo_agent_core::Message) -> [u8; 32] {
468 use sha2::{Digest, Sha256};
469 let bytes = serde_json::to_vec(message).expect("Message serialization is infallible");
470 Sha256::digest(bytes).into()
471}
472
473pub(crate) fn sdk_message_header_digest(message: &bamboo_agent_core::Message) -> [u8; 32] {
476 let mut header = message.clone();
477 header.content.clear();
478 if let Some(parts) = header.content_parts.as_mut() {
479 if let Some(bamboo_domain::MessagePart::Text { text }) =
480 parts.iter_mut().find(|part| matches!(part, bamboo_domain::MessagePart::Text { text } if text == &message.content))
481 {
482 text.clear();
483 }
484 }
485 sdk_message_digest(&header)
486}
487
488#[derive(Debug)]
489struct StartupInputObservation {
490 session_id: String,
491 execution_id: String,
492 observation: crate::runtime::managers::lifecycle::InputObservation,
493}
494
495impl UntrustedExecutionInputs {
496 pub fn new(observations: Vec<UntrustedInputObservation>) -> Option<Self> {
497 if observations.is_empty() || observations.len() > 128 {
498 return None;
499 }
500 let mut ids = std::collections::HashSet::with_capacity(observations.len());
501 if observations.iter().any(|item| !ids.insert(item.input_id())) {
502 return None;
503 }
504 Some(Self {
505 observations: observations.into_boxed_slice(),
506 startup: std::sync::Mutex::new(None),
507 sdk_execution_id: None,
508 sdk_hook_runner: None,
509 sdk_pending: std::sync::Mutex::new(None),
510 })
511 }
512
513 pub fn sdk_execution_id(&self) -> Option<&str> {
515 self.sdk_execution_id.as_deref()
516 }
517
518 pub(crate) fn bind_sdk_append(
519 mut self,
520 execution_id: String,
521 pending: SdkPendingInput,
522 hook_runner: Option<Arc<HookRunner>>,
523 ) -> Self {
524 self.startup = std::sync::Mutex::new(Some(StartupInputObservation {
527 session_id: pending.session_id.clone(),
528 execution_id: execution_id.clone(),
529 observation: Default::default(),
530 }));
531 self.sdk_execution_id = Some(execution_id);
532 self.sdk_hook_runner = hook_runner;
533 self.sdk_pending = std::sync::Mutex::new(Some(pending));
534 self
535 }
536
537 pub(crate) fn sdk_hook_runner(&self) -> Option<Arc<HookRunner>> {
538 self.sdk_hook_runner.clone()
539 }
540
541 pub(crate) async fn seal_sdk_input(
544 &self,
545 session: &mut bamboo_agent_core::Session,
546 persistence: Option<&Arc<dyn RuntimeSessionPersistence>>,
547 ) -> Result<(), bamboo_agent_core::AgentError> {
548 let pending = self
549 .sdk_pending
550 .lock()
551 .ok()
552 .and_then(|mut slot| slot.take());
553 let Some(pending) = pending else {
554 return Ok(());
555 };
556 let execution_id = self.sdk_execution_id.as_deref().unwrap_or("");
557 let invalid = || {
558 bamboo_agent_core::AgentError::Tool("SDK input acceptance could not be verified".into())
559 };
560 if session.id != pending.session_id || execution_id.is_empty() {
561 return Err(invalid());
562 }
563 let mut matching = session
564 .messages
565 .iter()
566 .filter(|message| message.id == pending.input_id);
567 let message = matching.next().ok_or_else(invalid)?;
568 if matching.next().is_some()
569 || message.role != bamboo_agent_core::Role::User
570 || sdk_message_header_digest(message) != pending.header_digest
571 || pending
572 .final_digest
573 .is_some_and(|digest| sdk_message_digest(message) != digest)
574 {
575 return Err(invalid());
576 }
577 let final_digest = sdk_message_digest(message);
578 let created_at = message.created_at;
579 session.validate_conversation_state().map_err(|error| {
580 bamboo_agent_core::AgentError::Tool(format!("session_conversation_changed: {error}"))
581 })?;
582 let requires_canonical_generation = session.conversation_reset.is_some();
583 let persistence = persistence.ok_or_else(|| {
584 if requires_canonical_generation {
585 bamboo_agent_core::AgentError::Tool(
586 "session_conversation_changed: canonical input persistence unavailable".into(),
587 )
588 } else {
589 invalid()
590 }
591 })?;
592 let original_generation = session.conversation_generation_observation();
593 let checkpoint = persistence.checkpoint_runtime_session(session).await;
594 original_generation
595 .check_canonical(session)
596 .map_err(|error| {
597 bamboo_agent_core::AgentError::Tool(format!(
598 "session_conversation_changed: {error}"
599 ))
600 })?;
601 checkpoint.map_err(|error| {
602 bamboo_agent_core::AgentError::Tool(format!("SDK input checkpoint failed: {error}"))
603 })?;
604 let accepted = persistence
605 .load_runtime_session(&session.id)
606 .await
607 .map_err(|error| {
608 bamboo_agent_core::AgentError::Tool(format!(
609 "SDK input verification failed: {error}"
610 ))
611 })?
612 .ok_or_else(|| {
613 if requires_canonical_generation {
614 bamboo_agent_core::AgentError::Tool(
615 "session_conversation_changed: canonical input readback unavailable".into(),
616 )
617 } else {
618 invalid()
619 }
620 })?;
621 original_generation
622 .check_canonical(&accepted)
623 .map_err(|error| {
624 bamboo_agent_core::AgentError::Tool(format!(
625 "session_conversation_changed: {error}"
626 ))
627 })?;
628 let mut matching = accepted
629 .messages
630 .iter()
631 .filter(|message| message.id == pending.input_id);
632 let accepted_user = matching.next().ok_or_else(invalid)?;
633 if matching.next().is_some()
634 || accepted_user.role != bamboo_agent_core::Role::User
635 || sdk_message_digest(accepted_user) != final_digest
636 {
637 return Err(invalid());
638 }
639 let observation = self
640 .observations
641 .iter()
642 .find(|item| item.input_id() == pending.input_id)
643 .ok_or_else(invalid)?;
644 let source = bamboo_domain::SessionMessageSource::User;
645 let record = crate::runtime::runner::state_bridge::BorrowedInputRequestRecord {
646 input_id: &pending.input_id,
647 source: &source,
648 kind: bamboo_domain::SessionMessageKind::UserInput,
649 wrapper: None,
650 created_at,
651 request: observation.request(),
652 };
653 let projection = crate::runtime::runner::state_bridge::project_input_request_batch(
654 &session.id,
655 execution_id,
656 &[record],
657 );
658 let observation =
659 crate::runtime::managers::lifecycle::InputObservation::projected(projection);
660 *self.startup.lock().map_err(|_| invalid())? = Some(StartupInputObservation {
661 session_id: session.id.clone(),
662 execution_id: execution_id.to_owned(),
663 observation,
664 });
665 Ok(())
666 }
667
668 pub fn observations(&self) -> &[UntrustedInputObservation] {
669 &self.observations
670 }
671
672 pub async fn admit_with_startup_observation(
676 session: &mut bamboo_agent_core::Session,
677 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
678 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
679 inbox: Option<&Arc<dyn bamboo_domain::SessionInboxPort>>,
680 ) -> (crate::runtime::runner::TurnBoundaryRefresh, Option<Self>) {
681 let execution_id = crate::runtime::runner::round_prelude::new_execution_id();
682 let (refresh, observation) =
683 crate::runtime::runner::state_bridge::refresh_turn_boundary_with_observation(
684 session,
685 storage,
686 persistence,
687 inbox,
688 None,
689 &execution_id,
690 )
691 .await;
692 let carrier = observation
696 .new_inputs()
697 .and_then(|batch| {
698 Self::new(
699 batch
700 .records()
701 .iter()
702 .map(|record| {
703 Some(UntrustedInputObservation {
704 input_id: bamboo_domain::SessionMessageId::parse(
705 record.input_id.clone(),
706 )
707 .ok()?,
708 request: record.request.clone(),
709 })
710 })
711 .collect::<Option<Vec<_>>>()?,
712 )
713 })
714 .map(|mut carrier| {
715 carrier.startup = std::sync::Mutex::new(Some(StartupInputObservation {
716 session_id: session.id.clone(),
717 execution_id,
718 observation,
719 }));
720 carrier
721 });
722 (refresh, carrier)
723 }
724
725 pub(crate) fn take_startup_observation(
726 &self,
727 session_id: &str,
728 ) -> Option<(
729 String,
730 crate::runtime::managers::lifecycle::InputObservation,
731 )> {
732 let startup = self.startup.lock().ok()?.take()?;
733 (startup.session_id == session_id
734 && (startup.observation.is_unavailable()
735 || startup.observation.matches_session(session_id)))
736 .then_some((startup.execution_id, startup.observation))
737 }
738
739 pub fn from_committed_messages(messages: &[bamboo_agent_core::Message]) -> Option<Self> {
743 if messages.len() > 128 {
744 return None;
745 }
746 let mut observations = Vec::with_capacity(messages.len());
747 for message in messages {
748 if message.role != bamboo_agent_core::Role::User {
749 continue;
750 }
751 let Some(proof) = message
752 .metadata
753 .as_ref()
754 .and_then(|metadata| metadata.get("session_message"))
755 else {
756 continue;
757 };
758 if proof.get("id")?.as_str()? != message.id {
759 return None;
760 }
761 let body = proof.get("body")?;
762 let request = if proof.get("source")?.get("type")?.as_str()? == "user"
763 && proof.get("kind")?.as_str()? == "user_input"
764 && body.get("type")?.as_str()? == "content"
765 {
766 body.get("skill_request")
767 } else if proof.get("source")?.get("type")?.as_str()? == "runtime"
768 && proof.get("source")?.get("subsystem")?.as_str()? == "chat"
769 && proof.get("kind")?.as_str()? == "runtime_instruction"
770 && body.get("instruction")?.as_str()? == "root_chat_turn_v1"
771 {
772 body.get("content")?.get("skill_request")
773 } else {
774 continue;
775 };
776 let request = request
777 .filter(|value| !value.is_null())
778 .map(copy_checked_request)
779 .transpose()
780 .ok()?;
781 if message.id.is_empty() || message.id.len() > 256 {
782 return None;
783 }
784 let input_id = bamboo_domain::SessionMessageId::parse(message.id.clone()).ok()?;
785 observations.push(UntrustedInputObservation { input_id, request });
786 }
787 Self::new(observations)
788 }
789}
790
791fn copy_checked_request(
794 value: &serde_json::Value,
795) -> Result<bamboo_domain::SessionSkillRequest, ()> {
796 let selections = value
797 .get("selections")
798 .and_then(serde_json::Value::as_array)
799 .ok_or(())?;
800 if selections.is_empty() || selections.len() > 32 {
801 return Err(());
802 }
803 let mode = match value.get("mode") {
804 None | Some(serde_json::Value::Null) => None,
805 Some(value) => {
806 let mode = value.as_str().ok_or(())?;
807 if mode.is_empty()
808 || mode.len() > 64
809 || !mode
810 .bytes()
811 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
812 {
813 return Err(());
814 }
815 Some(mode)
816 }
817 };
818 for selection in selections {
821 bamboo_domain::SessionSkillSelection::validate_borrowed(
822 selection
823 .get("id")
824 .and_then(serde_json::Value::as_str)
825 .ok_or(())?,
826 selection
827 .get("source")
828 .and_then(serde_json::Value::as_str)
829 .ok_or(())?,
830 selection
831 .get("revision")
832 .and_then(serde_json::Value::as_u64)
833 .ok_or(())?,
834 selection.get("args").unwrap_or(&serde_json::Value::Null),
835 )
836 .map_err(|_| ())?;
837 }
838 let request = bamboo_domain::SessionSkillRequest {
839 selections: selections
840 .iter()
841 .map(|selection| bamboo_domain::SessionSkillSelection {
842 id: selection["id"].as_str().unwrap().to_owned(),
843 source: selection["source"].as_str().unwrap().to_owned(),
844 revision: selection["revision"].as_u64().unwrap(),
845 args: selection.get("args").cloned().unwrap_or_default(),
846 })
847 .collect(),
848 mode: mode.map(str::to_owned),
849 };
850 request.validate().map_err(|_| ())?;
851 Ok(request)
852}
853
854#[non_exhaustive]
876pub struct AgentLoopConfig {
877 pub freeze_tool_exposure_for_cache: bool,
879 pub(crate) ticket_worker_plan: Option<Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>>,
881 pub(crate) system_prompt: Option<String>,
882 pub(crate) disabled_skill_ids: BTreeSet<String>,
884 pub(crate) selected_skill_ids: Option<Vec<String>>,
887 pub(crate) selected_skill_mode: Option<String>,
892 pub(crate) initial_untrusted_inputs: Option<UntrustedExecutionInputs>,
894 pub(crate) sdk_skill_execution_host: Option<Arc<dyn SdkSkillExecutionHost>>,
895 pub(crate) workspace_execution_host: Option<Arc<dyn WorkspaceExecutionHost>>,
896 pub(crate) additional_tool_schemas: Vec<ToolSchema>,
897 pub(crate) tool_registry: Arc<ToolRegistry>,
898 pub(crate) skill_manager: Option<Arc<SkillManager>>,
899 pub(crate) project_context_resolver:
902 Option<Arc<crate::project_context::ProjectContextResolver>>,
903 pub(crate) skip_initial_user_message: bool,
905 pub(crate) storage: Option<Arc<dyn Storage>>,
907 pub(crate) persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
910 pub(crate) guidance_active_run_id: Option<String>,
911 pub(crate) session_inbox: Option<Arc<dyn bamboo_domain::SessionInboxPort>>,
913 pub(crate) session_activation_notifications:
917 Option<Arc<parking_lot::Mutex<tokio::sync::watch::Receiver<u64>>>>,
918 pub(crate) attachment_reader: Option<Arc<dyn AttachmentReader>>,
921 pub(crate) metrics_collector: Option<MetricsCollector>,
923 pub(crate) model_name: Option<String>,
925 pub(crate) fast_model_name: Option<String>,
929 pub(crate) fast_model_provider: Option<Arc<dyn LLMProvider>>,
931 pub(crate) auxiliary_evaluation_max_concurrency: usize,
935 pub(crate) background_model_name: Option<String>,
939
940 pub(crate) planning_model_name: Option<String>,
943 pub(crate) search_model_name: Option<String>,
946 pub(crate) compression_instructions: Option<String>,
951 pub(crate) summary_target_ratio: f64,
954 pub(crate) context_management: bamboo_config::ContextManagementConfig,
957 pub(crate) summary_safe_window_percent: u8,
960 pub(crate) summarization_model_name: Option<String>,
962 pub(crate) background_model_provider: Option<Arc<dyn LLMProvider>>,
967 pub(crate) summarization_model_provider: Option<Arc<dyn LLMProvider>>,
972 pub(crate) provider_name: Option<String>,
976 pub(crate) provider_type: Option<String>,
981 pub(crate) reasoning_effort: Option<ReasoningEffort>,
983 pub(crate) app_data_dir: Option<PathBuf>,
988 pub(crate) memory_store: bamboo_memory::memory_store::MemoryStore,
991 pub(crate) disabled_tools: BTreeSet<String>,
993 pub(crate) token_budget: Option<TokenBudget>,
995 pub(crate) model_limits_section:
999 Option<Arc<bamboo_config::LiveSection<bamboo_config::ModelLimitsSection>>>,
1000 pub(crate) legacy_model_limits: Option<serde_json::Value>,
1006 pub(crate) image_fallback: Option<ImageFallbackConfig>,
1011 pub(crate) vision_model_resolver: Option<super::runner::image_fallback::VisionModelResolver>,
1016 pub(crate) prompt_memory_flags: PromptMemoryFlags,
1018 pub(crate) max_tool_calls_per_round: usize,
1020 pub(crate) max_consecutive_failures_per_tool: usize,
1022 pub(crate) per_tool_timeout_secs: u64,
1024 pub(crate) parallel_batch_timeout_secs: u64,
1026 pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
1029 pub(crate) permission_mode: Option<PermissionMode>,
1031 pub(crate) gold_config: Option<GoldConfig>,
1036 pub(crate) guardian_config: Option<GuardianConfig>,
1039 pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
1043 pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
1049 pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
1058 pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
1062 pub(crate) hook_runner: Arc<HookRunner>,
1065 pub(crate) features_dynamic_model_routing: bool,
1069 pub(crate) features_final_evidence_check: bool,
1071 pub(crate) auxiliary_model_resolver:
1077 Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
1078 pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
1085 pub(crate) mcp_tool_guidance: Option<String>,
1091 pub(crate) run_budget: bamboo_config::RunBudgetConfig,
1100 pub(crate) counted_loop: Option<super::counted_loop::CountedLoopConfig>,
1101 pub(crate) loop_control: Option<super::loop_control::LoopControlConfig>,
1102}
1103
1104impl AgentLoopConfig {
1105 pub(crate) fn effective_counted_loop(&self) -> Option<&super::counted_loop::CountedLoopConfig> {
1107 self.counted_loop
1108 .as_ref()
1109 .or_else(|| self.loop_control.as_ref().and_then(|c| c.active_counted()))
1110 }
1111
1112 pub fn initial_untrusted_inputs(&self) -> Option<&UntrustedExecutionInputs> {
1114 self.initial_untrusted_inputs.as_ref()
1115 }
1116}
1117
1118impl Default for AgentLoopConfig {
1119 fn default() -> Self {
1120 Self {
1121 freeze_tool_exposure_for_cache: true,
1122 ticket_worker_plan: None,
1123 system_prompt: None,
1124 disabled_skill_ids: BTreeSet::new(),
1125 selected_skill_ids: None,
1126 selected_skill_mode: None,
1127 initial_untrusted_inputs: None,
1128 sdk_skill_execution_host: None,
1129 workspace_execution_host: None,
1130 additional_tool_schemas: Vec::new(),
1131 tool_registry: Arc::new(ToolRegistry::new()),
1132 skill_manager: None,
1133 project_context_resolver: None,
1134 skip_initial_user_message: false,
1135 storage: None,
1136 persistence: None,
1137 guidance_active_run_id: None,
1138 session_inbox: None,
1139 session_activation_notifications: None,
1140 attachment_reader: None,
1141 metrics_collector: None,
1142 model_name: None,
1143 fast_model_name: None,
1144 fast_model_provider: None,
1145 auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
1146 background_model_name: None,
1147 planning_model_name: None,
1148 search_model_name: None,
1149 compression_instructions: None,
1150 summary_target_ratio: 0.20,
1151 context_management: bamboo_config::ContextManagementConfig::default(),
1152 summary_safe_window_percent: 80,
1153 summarization_model_name: None,
1154 background_model_provider: None,
1155 summarization_model_provider: None,
1156 provider_name: None,
1157 provider_type: None,
1158 reasoning_effort: None,
1159 app_data_dir: None,
1160 memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
1161 disabled_tools: BTreeSet::new(),
1162 token_budget: None,
1163 model_limits_section: None,
1164 legacy_model_limits: None,
1165 image_fallback: None,
1166 vision_model_resolver: None,
1167 prompt_memory_flags: PromptMemoryFlags::default(),
1168 max_tool_calls_per_round: 80,
1169 max_consecutive_failures_per_tool: 3,
1170 per_tool_timeout_secs: 120,
1171 parallel_batch_timeout_secs: 300,
1172 stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
1173 permission_mode: None,
1174 gold_config: None,
1175 guardian_config: None,
1176 guardian_spawner: None,
1177 bash_resume_hook: None,
1178 bash_completion_sink: None,
1179 approval_delegate: None,
1180 hook_runner: Arc::new(HookRunner::new()),
1181 features_dynamic_model_routing: false,
1182 features_final_evidence_check: false,
1183 auxiliary_model_resolver: None,
1184 disabled_filter_resolver: None,
1185 mcp_tool_guidance: None,
1186 run_budget: bamboo_config::RunBudgetConfig::default(),
1187 counted_loop: None,
1188 loop_control: None,
1189 }
1190 }
1191}
1192
1193impl AgentLoopConfig {
1194 pub(crate) fn resolve_auxiliary_models(&self) -> AuxiliaryModelConfig {
1198 self.auxiliary_model_resolver
1199 .as_ref()
1200 .map(|resolver| resolver())
1201 .unwrap_or_else(|| AuxiliaryModelConfig {
1202 fast_model_name: self.fast_model_name.clone(),
1203 fast_model_provider: self.fast_model_provider.clone(),
1204 background_model_name: self.background_model_name.clone(),
1205 planning_model_name: self.planning_model_name.clone(),
1206 search_model_name: self.search_model_name.clone(),
1207 summarization_model_name: self.summarization_model_name.clone(),
1208 background_model_provider: self.background_model_provider.clone(),
1209 summarization_model_provider: self.summarization_model_provider.clone(),
1210 summarization_model_error: None,
1211 })
1212 }
1213
1214 pub fn bind_ticket_worker_plan(
1215 &mut self,
1216 plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
1217 ) {
1218 self.ticket_worker_plan = Some(plan);
1219 }
1220 pub(crate) fn resolve_disabled_filters(
1225 &self,
1226 ) -> (
1227 std::borrow::Cow<'_, BTreeSet<String>>,
1228 std::borrow::Cow<'_, BTreeSet<String>>,
1229 ) {
1230 match &self.disabled_filter_resolver {
1231 Some(resolver) => {
1232 let (tools, skills) = resolver();
1233 (
1234 std::borrow::Cow::Owned(tools),
1235 std::borrow::Cow::Owned(skills),
1236 )
1237 }
1238 None => (
1239 std::borrow::Cow::Borrowed(&self.disabled_tools),
1240 std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
1241 ),
1242 }
1243 }
1244
1245 pub fn active_goal(&self) -> Option<&str> {
1249 self.gold_config
1250 .as_ref()
1251 .filter(|cfg| cfg.enabled)
1252 .and_then(GoldConfig::effective_goal)
1253 }
1254
1255 pub fn goal_loop_active(&self) -> bool {
1263 self.gold_config.as_ref().is_some_and(|cfg| {
1264 cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
1265 })
1266 }
1267
1268 pub fn guardian_active(&self) -> bool {
1272 self.guardian_spawner.is_some()
1273 && self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
1274 }
1275
1276 pub fn guardian_max_reviews(&self) -> u32 {
1279 self.guardian_config
1280 .as_ref()
1281 .map_or(0, |cfg| cfg.max_reviews)
1282 }
1283
1284 pub fn guardian_model(&self) -> Option<&str> {
1286 self.guardian_config
1287 .as_ref()
1288 .and_then(|cfg| cfg.model_name.as_deref())
1289 }
1290
1291 pub fn delegation_active(&self) -> bool {
1293 self.approval_delegate.is_some()
1294 }
1295}
1296
1297#[cfg(test)]
1298mod tests;
1299
1300#[cfg(test)]
1301mod execution_input_tests {
1302 use super::*;
1303 fn request() -> bamboo_domain::SessionSkillRequest {
1304 bamboo_domain::SessionSkillRequest {
1305 selections: vec![bamboo_domain::SessionSkillSelection {
1306 id: "exact Case".into(),
1307 source: "builtin".into(),
1308 revision: 1,
1309 args: serde_json::json!({"raw":"保持原样"}),
1310 }],
1311 mode: None,
1312 }
1313 }
1314 #[test]
1315 fn execution_input_owned_bounds_and_old_config_default() {
1316 assert!(AgentLoopConfig::default()
1317 .initial_untrusted_inputs()
1318 .is_none());
1319 let request = request();
1320 let input = UntrustedInputObservation::new("exact-input", Some(&request)).unwrap();
1321 assert_eq!(input.input_id(), "exact-input");
1322 assert_eq!(input.request(), Some(&request));
1323 for id in [
1324 "",
1325 " leading",
1326 "trailing ",
1327 "unsafe/id",
1328 "unsafe\\id",
1329 "unsafe..id",
1330 ] {
1331 assert!(UntrustedInputObservation::new(id, None).is_none());
1332 }
1333 assert!(UntrustedInputObservation::new(&"a".repeat(257), None).is_none());
1334 assert!(UntrustedExecutionInputs::new(Vec::new()).is_none());
1335 let batch = UntrustedExecutionInputs::new(
1336 (0..128)
1337 .map(|i| {
1338 UntrustedInputObservation::new(&format!("input-{i}"), Some(&request)).unwrap()
1339 })
1340 .collect(),
1341 )
1342 .unwrap();
1343 assert_eq!(batch.observations().len(), 128);
1344 assert_eq!(batch.observations()[127].input_id(), "input-127");
1345 assert!(UntrustedExecutionInputs::new(
1346 (0..129)
1347 .map(|i| UntrustedInputObservation::new(&format!("input-{i}"), None).unwrap())
1348 .collect()
1349 )
1350 .is_none());
1351 assert!(UntrustedExecutionInputs::new(vec![
1352 UntrustedInputObservation::new("same", None).unwrap(),
1353 UntrustedInputObservation::new("same", None).unwrap()
1354 ])
1355 .is_none());
1356 let mut malformed = request;
1357 malformed.selections[0].args = (0..65).fold(serde_json::Value::Null, |v, _| {
1358 serde_json::Value::Array(vec![v])
1359 });
1360 assert!(UntrustedInputObservation::new("deep", Some(&malformed)).is_none());
1361 }
1362 #[test]
1363 fn execution_input_only_explicit_checked_commit_slice_copies_request_fields() {
1364 let request = request();
1365 let mut user = bamboo_domain::SessionMessageEnvelope::user_input("target", "ordinary");
1366 if let bamboo_domain::SessionMessageBody::Content(content) = &mut user.body {
1367 content.skill_request = Some(request.clone());
1368 }
1369 let id = user.id.to_string();
1370 let plain = user.to_provider_message().unwrap();
1371 let root = user
1372 .with_root_chat_prompt("system prompt is not retained".into())
1373 .unwrap()
1374 .to_provider_message()
1375 .unwrap();
1376 for message in [plain, root] {
1377 let batch = UntrustedExecutionInputs::from_committed_messages(&[message]).unwrap();
1378 assert_eq!(batch.observations().len(), 1);
1379 assert_eq!(batch.observations()[0].input_id(), id);
1380 assert_eq!(batch.observations()[0].request(), Some(&request));
1381 }
1382 let text = bamboo_agent_core::Message::user("{\"skill_request\":\"fragment\"}");
1383 assert!(UntrustedExecutionInputs::from_committed_messages(&[text]).is_none());
1384 assert!(UntrustedExecutionInputs::from_committed_messages(&[]).is_none());
1385 }
1386}
1387
1388#[cfg(test)]
1389mod sdk_acceptance_tests {
1390 use super::*;
1391 use crate::runtime::managers::lifecycle::BoundedInputRequestBatch;
1392 use bamboo_agent_core::{Message, Role, Session};
1393 use tokio_util::sync::CancellationToken;
1394
1395 struct Repository {
1396 stored: std::sync::Mutex<Option<Session>>,
1397 reject: bool,
1398 corrupt: bool,
1399 }
1400 #[async_trait::async_trait]
1401 impl RuntimeSessionPersistence for Repository {
1402 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1403 if self.reject {
1404 return Err(std::io::Error::other("checkpoint refused"));
1405 }
1406 let mut accepted = session.clone();
1407 if self.corrupt {
1408 accepted
1409 .messages
1410 .iter_mut()
1411 .find(|message| message.role == Role::User)
1412 .unwrap()
1413 .content
1414 .push_str(" changed");
1415 }
1416 *self.stored.lock().unwrap() = Some(accepted);
1417 Ok(())
1418 }
1419 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1420 self.save_runtime_session(session).await
1421 }
1422 async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1423 Ok(self.stored.lock().unwrap().clone())
1424 }
1425 }
1426 fn repository(reject: bool, corrupt: bool) -> Arc<dyn RuntimeSessionPersistence> {
1427 Arc::new(Repository {
1428 stored: Default::default(),
1429 reject,
1430 corrupt,
1431 })
1432 }
1433
1434 fn nonzero_conversation(session: &mut Session) {
1435 session.conversation_reset = Some(bamboo_domain::ConversationResetState {
1436 schema_version: 1,
1437 conversation_revision: 1,
1438 receipts: vec![bamboo_domain::ConversationResetReceipt {
1439 operation_id: format!("0:{}", uuid::Uuid::new_v4()),
1440 birth_token: session.root_mode_birth_token(),
1441 expected_snapshot_sha256: "a".repeat(64),
1442 previous_revision: 0,
1443 conversation_revision: 1,
1444 retained_system_messages: 0,
1445 reset_at: session.updated_at,
1446 }],
1447 });
1448 }
1449
1450 struct ConversationChangingRepository {
1451 stored: std::sync::Mutex<Option<Session>>,
1452 mutate_caller: bool,
1453 }
1454 #[async_trait::async_trait]
1455 impl RuntimeSessionPersistence for ConversationChangingRepository {
1456 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1457 let mut accepted = session.clone();
1458 if let Some(state) = accepted.conversation_reset.as_mut() {
1459 state.receipts[0].expected_snapshot_sha256 = "b".repeat(64);
1460 } else {
1461 nonzero_conversation(&mut accepted);
1462 }
1463 if self.mutate_caller {
1464 *session = accepted.clone();
1465 }
1466 *self.stored.lock().unwrap() = Some(accepted);
1467 Ok(())
1468 }
1469 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1470 self.save_runtime_session(session).await
1471 }
1472 async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1473 Ok(self.stored.lock().unwrap().clone())
1474 }
1475 }
1476
1477 #[async_trait::async_trait]
1478 impl bamboo_domain::Storage for ConversationChangingRepository {
1479 async fn save_session(&self, session: &Session) -> std::io::Result<()> {
1480 *self.stored.lock().unwrap() = Some(session.clone());
1481 Ok(())
1482 }
1483 async fn load_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1484 Ok(self.stored.lock().unwrap().clone())
1485 }
1486 async fn delete_session(&self, _: &str) -> std::io::Result<bool> {
1487 Err(std::io::Error::other("fixture never deletes a Session"))
1488 }
1489 }
1490
1491 #[derive(Default)]
1492 struct SealLoopProbe {
1493 provider_calls: std::sync::atomic::AtomicUsize,
1494 tool_calls: std::sync::atomic::AtomicUsize,
1495 setup_calls: std::sync::atomic::AtomicUsize,
1496 }
1497 #[async_trait::async_trait]
1498 impl bamboo_llm::LLMProvider for SealLoopProbe {
1499 async fn chat_stream(
1500 &self,
1501 _: &[Message],
1502 _: &[bamboo_agent_core::tools::ToolSchema],
1503 _: Option<u32>,
1504 _: &str,
1505 ) -> bamboo_llm::provider::Result<bamboo_llm::LLMStream> {
1506 self.provider_calls
1507 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1508 Ok(Box::pin(futures::stream::iter(vec![
1509 Ok(bamboo_llm::LLMChunk::Token("synthetic complete".into())),
1510 Ok(bamboo_llm::LLMChunk::Done),
1511 ])))
1512 }
1513 }
1514 #[async_trait::async_trait]
1515 impl bamboo_agent_core::tools::ToolExecutor for SealLoopProbe {
1516 async fn execute(
1517 &self,
1518 _: &bamboo_agent_core::tools::ToolCall,
1519 ) -> Result<bamboo_agent_core::tools::ToolResult, bamboo_agent_core::tools::ToolError>
1520 {
1521 self.tool_calls
1522 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1523 Err(bamboo_agent_core::tools::ToolError::Execution(
1524 "no fixture tools".into(),
1525 ))
1526 }
1527 fn list_tools(&self) -> Vec<bamboo_agent_core::tools::ToolSchema> {
1528 vec![]
1529 }
1530 }
1531 #[async_trait::async_trait]
1532 impl bamboo_agent_core::AgentHook for SealLoopProbe {
1533 fn point(&self) -> bamboo_domain::AgentHookPoint {
1534 bamboo_domain::AgentHookPoint::AfterSessionSetup
1535 }
1536 async fn run(
1537 &self,
1538 _: bamboo_domain::AgentHookPoint,
1539 _: &bamboo_domain::HookPayload,
1540 _: &Session,
1541 ) -> bamboo_domain::HookResult {
1542 self.setup_calls
1543 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1544 bamboo_domain::HookResult::Continue
1545 }
1546 }
1547
1548 fn seal_loop_config(
1549 inputs: UntrustedExecutionInputs,
1550 persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
1551 storage: Option<Arc<dyn bamboo_domain::Storage>>,
1552 probe: &Arc<SealLoopProbe>,
1553 ) -> AgentLoopConfig {
1554 let mut hooks = crate::runtime::HookRunner::new();
1555 hooks.register(probe.clone());
1556 AgentLoopConfig {
1557 initial_untrusted_inputs: Some(inputs),
1558 persistence,
1559 storage,
1560 skip_initial_user_message: true,
1561 model_name: Some("synthetic-seal-model".into()),
1562 hook_runner: Arc::new(hooks),
1563 prompt_memory_flags: PromptMemoryFlags {
1564 project_prompt_injection: false,
1565 relevant_recall: false,
1566 relevant_recall_rerank: false,
1567 project_first_dream: false,
1568 ledger_agenda: false,
1569 },
1570 ..Default::default()
1571 }
1572 }
1573
1574 #[tokio::test]
1575 async fn full_loop_stops_changed_or_invalid_seal_before_hooks_provider_and_complete() {
1576 for mutate_caller in [false, true] {
1577 for generation in ["advance", "same-revision", "invalid"] {
1578 let mut session = Session::new("sdk-full-seal-refusal", "model");
1579 if generation == "same-revision" {
1580 nonzero_conversation(&mut session);
1581 }
1582 if generation == "invalid" {
1583 session.conversation_reset = Some(Default::default());
1584 }
1585 let user = Message::user("synthetic current input");
1586 let inputs = producer(&session, &user, true);
1587 session.add_message(user);
1588 let repository = Arc::new(ConversationChangingRepository {
1589 stored: std::sync::Mutex::new(Some(session.clone())),
1590 mutate_caller,
1591 });
1592 let probe = Arc::new(SealLoopProbe::default());
1593 let config = seal_loop_config(
1594 inputs,
1595 Some(repository.clone()),
1596 Some(repository.clone()),
1597 &probe,
1598 );
1599 let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
1600 let error = crate::runtime::runner::run_agent_loop_with_config(
1601 &mut session,
1602 "synthetic current input".into(),
1603 tx,
1604 probe.clone(),
1605 probe.clone(),
1606 CancellationToken::new(),
1607 config,
1608 )
1609 .await
1610 .unwrap_err();
1611 assert!(
1612 error.to_string().contains("session_conversation_changed:"),
1613 "{generation}: {error}"
1614 );
1615 assert_eq!(
1616 probe
1617 .provider_calls
1618 .load(std::sync::atomic::Ordering::SeqCst),
1619 0
1620 );
1621 assert_eq!(
1622 probe.tool_calls.load(std::sync::atomic::Ordering::SeqCst),
1623 0
1624 );
1625 assert_eq!(
1626 probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst),
1627 0
1628 );
1629 while let Ok(event) = rx.try_recv() {
1630 assert!(!matches!(
1631 event,
1632 bamboo_agent_core::AgentEvent::Complete { .. }
1633 ));
1634 }
1635 assert!(repository
1638 .stored
1639 .lock()
1640 .unwrap()
1641 .as_ref()
1642 .unwrap()
1643 .conversation_reset
1644 .is_some());
1645 }
1646 }
1647 }
1648
1649 #[tokio::test]
1650 async fn full_loop_preserves_ordinary_optional_seal_failure_behavior() {
1651 let mut session = Session::new("sdk-optional-seal", "model");
1652 let user = Message::user("synthetic optional input");
1653 let inputs = producer(&session, &user, true);
1654 session.add_message(user);
1655 let probe = Arc::new(SealLoopProbe::default());
1656 let config = seal_loop_config(inputs, None, None, &probe);
1657 let (tx, _rx) = tokio::sync::mpsc::channel(1024);
1658 crate::runtime::runner::run_agent_loop_with_config(
1659 &mut session,
1660 "synthetic optional input".into(),
1661 tx,
1662 probe.clone(),
1663 probe.clone(),
1664 CancellationToken::new(),
1665 config,
1666 )
1667 .await
1668 .unwrap();
1669 assert!(
1670 probe
1671 .provider_calls
1672 .load(std::sync::atomic::Ordering::SeqCst)
1673 > 0
1674 );
1675 assert!(probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst) > 0);
1676 }
1677
1678 #[tokio::test]
1679 async fn sdk_input_seal_freezes_generation_before_a_custom_checkpoint_and_readback() {
1680 for mutate_caller in [false, true] {
1681 for same_revision in [false, true] {
1682 let mut session = Session::new("sdk-generation-seal", "model");
1683 if same_revision {
1684 nonzero_conversation(&mut session);
1685 }
1686 let user = Message::user("exact id and digest still cannot cross conversation");
1687 let inputs = producer(&session, &user, true);
1688 session.add_message(user);
1689 let persistence: Arc<dyn RuntimeSessionPersistence> =
1690 Arc::new(ConversationChangingRepository {
1691 stored: Default::default(),
1692 mutate_caller,
1693 });
1694 let error = inputs
1695 .seal_sdk_input(&mut session, Some(&persistence))
1696 .await
1697 .unwrap_err();
1698 assert!(error.to_string().contains("session_conversation_changed:"));
1699 assert!(
1700 current(&inputs, &session).is_none(),
1701 "cannot publish New input from a different conversation"
1702 );
1703 }
1704 }
1705 }
1706 fn producer(session: &Session, user: &Message, typed: bool) -> UntrustedExecutionInputs {
1707 UntrustedExecutionInputs::new(vec![UntrustedInputObservation::new(&user.id, None).unwrap()])
1708 .unwrap()
1709 .bind_sdk_append(
1710 "actual-sdk-execution".into(),
1711 SdkPendingInput {
1712 session_id: session.id.clone(),
1713 input_id: user.id.clone(),
1714 header_digest: sdk_message_header_digest(user),
1715 final_digest: typed.then(|| sdk_message_digest(user)),
1716 },
1717 None,
1718 )
1719 }
1720 fn current(
1721 inputs: &UntrustedExecutionInputs,
1722 session: &Session,
1723 ) -> Option<BoundedInputRequestBatch> {
1724 let (execution_id, observation) = inputs.take_startup_observation(&session.id).unwrap();
1725 assert_eq!(execution_id, "actual-sdk-execution");
1726 let mut current = None;
1727 observation.update_current(&mut current, &session.id, &execution_id);
1728 assert!(inputs.take_startup_observation(&session.id).is_none());
1729 current
1730 }
1731 #[tokio::test]
1732 async fn sdk_startup_requires_exact_checkpoint_and_preserves_request_absence_as_new() {
1733 let mut session = Session::new("sdk-accept", "model");
1734 let user = Message::user("final prepared");
1735 let inputs = producer(&session, &user, true);
1736 session.add_message(user.clone());
1737 inputs
1738 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1739 .await
1740 .unwrap();
1741 let batch = current(&inputs, &session).unwrap();
1742 assert_eq!(batch.records().len(), 1);
1743 assert_eq!(batch.records()[0].input_id, user.id);
1744 assert_eq!(
1745 batch.records()[0].source,
1746 bamboo_domain::SessionMessageSource::User
1747 );
1748 assert!(batch.records()[0].request.is_none());
1749 }
1750 #[tokio::test]
1751 async fn sdk_startup_unknown_checkpoint_or_modified_final_user_stays_unavailable() {
1752 for (reject, corrupt, mutate, duplicate, wrong_session) in [
1753 (true, false, false, false, false),
1754 (false, true, false, false, false),
1755 (false, false, true, false, false),
1756 (false, false, false, true, false),
1757 (false, false, false, false, true),
1758 ] {
1759 let mut session = Session::new("sdk-deny", "model");
1760 let user = Message::user("prepared");
1761 let inputs = producer(&session, &user, true);
1762 session.add_message(user.clone());
1763 if mutate {
1764 session.messages[0].content.push_str(" changed");
1765 }
1766 if duplicate {
1767 session.add_message(user);
1768 }
1769 if wrong_session {
1770 session.id = "wrong".into();
1771 }
1772 assert!(inputs
1773 .seal_sdk_input(&mut session, Some(&repository(reject, corrupt)))
1774 .await
1775 .is_err());
1776 if wrong_session {
1777 assert!(inputs.take_startup_observation(&session.id).is_none());
1778 } else {
1779 assert!(current(&inputs, &session).is_none());
1780 }
1781 }
1782 }
1783 #[tokio::test]
1784 async fn sdk_legacy_hook_rewrite_is_bound_to_original_id_while_generic_history_is_unsealed() {
1785 let mut session = Session::new("sdk-string", "model");
1786 let user = Message::user("original");
1787 let inputs = producer(&session, &user, false);
1788 session.add_message(user.clone());
1789 session.messages[0].content = "real portable hook result".into();
1790 inputs
1791 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1792 .await
1793 .unwrap();
1794 assert_eq!(
1795 current(&inputs, &session).unwrap().records()[0].input_id,
1796 user.id
1797 );
1798 let generic =
1799 UntrustedExecutionInputs::new(vec![
1800 UntrustedInputObservation::new(&user.id, None).unwrap()
1801 ])
1802 .unwrap();
1803 generic
1804 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1805 .await
1806 .unwrap();
1807 assert!(generic.sdk_execution_id().is_none());
1808 assert!(generic.take_startup_observation(&session.id).is_none());
1809 }
1810 #[test]
1811 fn sdk_header_binding_accepts_only_the_canonical_prompt_changes() {
1812 let mut original = Message::user("canonical");
1813 original.content_parts = Some(vec![
1814 bamboo_domain::MessagePart::Text {
1815 text: "unrelated native text".into(),
1816 },
1817 bamboo_domain::MessagePart::Text {
1818 text: "canonical".into(),
1819 },
1820 ]);
1821 let digest = sdk_message_header_digest(&original);
1822 let mut prepared = original.clone();
1823 prepared.content = "canonical plus F".into();
1824 if let bamboo_domain::MessagePart::Text { text } =
1825 &mut prepared.content_parts.as_mut().unwrap()[1]
1826 {
1827 *text = prepared.content.clone();
1828 }
1829 assert_eq!(sdk_message_header_digest(&prepared), digest);
1830 prepared.id = "different".into();
1831 assert_ne!(sdk_message_header_digest(&prepared), digest);
1832 prepared = original;
1833 if let bamboo_domain::MessagePart::Text { text } =
1834 &mut prepared.content_parts.as_mut().unwrap()[0]
1835 {
1836 *text = "tampered".into();
1837 }
1838 assert_ne!(sdk_message_header_digest(&prepared), digest);
1839 }
1840}