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