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