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 render_skill_prompt(
348 &self,
349 session: &bamboo_agent_core::Session,
350 execution_id: &str,
351 ) -> Result<String, bamboo_agent_core::AgentError>;
352
353 fn finish(&self, session_id: &str, execution_id: &str);
354}
355
356pub use SkillExecutionHost as SdkSkillExecutionHost;
358
359#[derive(Debug, PartialEq)]
362pub struct UntrustedInputObservation {
363 input_id: bamboo_domain::SessionMessageId,
364 request: Option<bamboo_domain::SessionSkillRequest>,
365}
366
367impl UntrustedInputObservation {
368 pub fn new(
369 input_id: &str,
370 request: Option<&bamboo_domain::SessionSkillRequest>,
371 ) -> Option<Self> {
372 if input_id.is_empty() || input_id.len() > 256 {
375 return None;
376 }
377 let input_id = bamboo_domain::SessionMessageId::parse(input_id.to_owned()).ok()?;
378 if let Some(request) = request {
379 request.validate().ok()?;
380 }
381 Some(Self {
382 input_id,
383 request: request.cloned(),
384 })
385 }
386
387 pub fn input_id(&self) -> &str {
388 self.input_id.as_str()
389 }
390 pub fn request(&self) -> Option<&bamboo_domain::SessionSkillRequest> {
391 self.request.as_ref()
392 }
393}
394
395pub struct UntrustedExecutionInputs {
400 observations: Box<[UntrustedInputObservation]>,
401 startup: std::sync::Mutex<Option<StartupInputObservation>>,
402 sdk_execution_id: Option<String>,
403 sdk_hook_runner: Option<Arc<HookRunner>>,
404 sdk_pending: std::sync::Mutex<Option<SdkPendingInput>>,
405}
406
407#[derive(Debug)]
408pub(crate) struct SdkPendingInput {
409 pub session_id: String,
410 pub input_id: String,
411 pub header_digest: [u8; 32],
412 pub final_digest: Option<[u8; 32]>,
413}
414
415impl std::fmt::Debug for UntrustedExecutionInputs {
416 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417 f.debug_struct("UntrustedExecutionInputs")
418 .field("observations", &self.observations)
419 .field("sdk_execution_id", &self.sdk_execution_id)
420 .finish_non_exhaustive()
421 }
422}
423
424pub(crate) fn sdk_message_digest(message: &bamboo_agent_core::Message) -> [u8; 32] {
426 use sha2::{Digest, Sha256};
427 let bytes = serde_json::to_vec(message).expect("Message serialization is infallible");
428 Sha256::digest(bytes).into()
429}
430
431pub(crate) fn sdk_message_header_digest(message: &bamboo_agent_core::Message) -> [u8; 32] {
434 let mut header = message.clone();
435 header.content.clear();
436 if let Some(parts) = header.content_parts.as_mut() {
437 if let Some(bamboo_domain::MessagePart::Text { text }) =
438 parts.iter_mut().find(|part| matches!(part, bamboo_domain::MessagePart::Text { text } if text == &message.content))
439 {
440 text.clear();
441 }
442 }
443 sdk_message_digest(&header)
444}
445
446#[derive(Debug)]
447struct StartupInputObservation {
448 session_id: String,
449 execution_id: String,
450 observation: crate::runtime::managers::lifecycle::InputObservation,
451}
452
453impl UntrustedExecutionInputs {
454 pub fn new(observations: Vec<UntrustedInputObservation>) -> Option<Self> {
455 if observations.is_empty() || observations.len() > 128 {
456 return None;
457 }
458 let mut ids = std::collections::HashSet::with_capacity(observations.len());
459 if observations.iter().any(|item| !ids.insert(item.input_id())) {
460 return None;
461 }
462 Some(Self {
463 observations: observations.into_boxed_slice(),
464 startup: std::sync::Mutex::new(None),
465 sdk_execution_id: None,
466 sdk_hook_runner: None,
467 sdk_pending: std::sync::Mutex::new(None),
468 })
469 }
470
471 pub fn sdk_execution_id(&self) -> Option<&str> {
473 self.sdk_execution_id.as_deref()
474 }
475
476 pub(crate) fn bind_sdk_append(
477 mut self,
478 execution_id: String,
479 pending: SdkPendingInput,
480 hook_runner: Option<Arc<HookRunner>>,
481 ) -> Self {
482 self.startup = std::sync::Mutex::new(Some(StartupInputObservation {
485 session_id: pending.session_id.clone(),
486 execution_id: execution_id.clone(),
487 observation: Default::default(),
488 }));
489 self.sdk_execution_id = Some(execution_id);
490 self.sdk_hook_runner = hook_runner;
491 self.sdk_pending = std::sync::Mutex::new(Some(pending));
492 self
493 }
494
495 pub(crate) fn sdk_hook_runner(&self) -> Option<Arc<HookRunner>> {
496 self.sdk_hook_runner.clone()
497 }
498
499 pub(crate) async fn seal_sdk_input(
502 &self,
503 session: &mut bamboo_agent_core::Session,
504 persistence: Option<&Arc<dyn RuntimeSessionPersistence>>,
505 ) -> Result<(), bamboo_agent_core::AgentError> {
506 let pending = self
507 .sdk_pending
508 .lock()
509 .ok()
510 .and_then(|mut slot| slot.take());
511 let Some(pending) = pending else {
512 return Ok(());
513 };
514 let execution_id = self.sdk_execution_id.as_deref().unwrap_or("");
515 let invalid = || {
516 bamboo_agent_core::AgentError::Tool("SDK input acceptance could not be verified".into())
517 };
518 if session.id != pending.session_id || execution_id.is_empty() {
519 return Err(invalid());
520 }
521 let mut matching = session
522 .messages
523 .iter()
524 .filter(|message| message.id == pending.input_id);
525 let message = matching.next().ok_or_else(invalid)?;
526 if matching.next().is_some()
527 || message.role != bamboo_agent_core::Role::User
528 || sdk_message_header_digest(message) != pending.header_digest
529 || pending
530 .final_digest
531 .is_some_and(|digest| sdk_message_digest(message) != digest)
532 {
533 return Err(invalid());
534 }
535 let final_digest = sdk_message_digest(message);
536 let created_at = message.created_at;
537 let persistence = persistence.ok_or_else(invalid)?;
538 persistence
539 .checkpoint_runtime_session(session)
540 .await
541 .map_err(|error| {
542 bamboo_agent_core::AgentError::Tool(format!("SDK input checkpoint failed: {error}"))
543 })?;
544 let accepted = persistence
545 .load_runtime_session(&session.id)
546 .await
547 .map_err(|error| {
548 bamboo_agent_core::AgentError::Tool(format!(
549 "SDK input verification failed: {error}"
550 ))
551 })?
552 .ok_or_else(invalid)?;
553 let mut matching = accepted
554 .messages
555 .iter()
556 .filter(|message| message.id == pending.input_id);
557 let accepted_user = matching.next().ok_or_else(invalid)?;
558 if matching.next().is_some()
559 || accepted_user.role != bamboo_agent_core::Role::User
560 || sdk_message_digest(accepted_user) != final_digest
561 {
562 return Err(invalid());
563 }
564 let observation = self
565 .observations
566 .iter()
567 .find(|item| item.input_id() == pending.input_id)
568 .ok_or_else(invalid)?;
569 let source = bamboo_domain::SessionMessageSource::User;
570 let record = crate::runtime::runner::state_bridge::BorrowedInputRequestRecord {
571 input_id: &pending.input_id,
572 source: &source,
573 kind: bamboo_domain::SessionMessageKind::UserInput,
574 wrapper: None,
575 created_at,
576 request: observation.request(),
577 };
578 let projection = crate::runtime::runner::state_bridge::project_input_request_batch(
579 &session.id,
580 execution_id,
581 &[record],
582 );
583 let observation =
584 crate::runtime::managers::lifecycle::InputObservation::projected(projection);
585 *self.startup.lock().map_err(|_| invalid())? = Some(StartupInputObservation {
586 session_id: session.id.clone(),
587 execution_id: execution_id.to_owned(),
588 observation,
589 });
590 Ok(())
591 }
592
593 pub fn observations(&self) -> &[UntrustedInputObservation] {
594 &self.observations
595 }
596
597 pub async fn admit_with_startup_observation(
601 session: &mut bamboo_agent_core::Session,
602 storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
603 persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
604 inbox: Option<&Arc<dyn bamboo_domain::SessionInboxPort>>,
605 ) -> (crate::runtime::runner::TurnBoundaryRefresh, Option<Self>) {
606 let execution_id = crate::runtime::runner::round_prelude::new_execution_id();
607 let (refresh, observation) =
608 crate::runtime::runner::state_bridge::refresh_turn_boundary_with_observation(
609 session,
610 storage,
611 persistence,
612 inbox,
613 None,
614 &execution_id,
615 )
616 .await;
617 let carrier = observation
621 .new_inputs()
622 .and_then(|batch| {
623 Self::new(
624 batch
625 .records()
626 .iter()
627 .map(|record| {
628 Some(UntrustedInputObservation {
629 input_id: bamboo_domain::SessionMessageId::parse(
630 record.input_id.clone(),
631 )
632 .ok()?,
633 request: record.request.clone(),
634 })
635 })
636 .collect::<Option<Vec<_>>>()?,
637 )
638 })
639 .map(|mut carrier| {
640 carrier.startup = std::sync::Mutex::new(Some(StartupInputObservation {
641 session_id: session.id.clone(),
642 execution_id,
643 observation,
644 }));
645 carrier
646 });
647 (refresh, carrier)
648 }
649
650 pub(crate) fn take_startup_observation(
651 &self,
652 session_id: &str,
653 ) -> Option<(
654 String,
655 crate::runtime::managers::lifecycle::InputObservation,
656 )> {
657 let startup = self.startup.lock().ok()?.take()?;
658 (startup.session_id == session_id
659 && (startup.observation.is_unavailable()
660 || startup.observation.matches_session(session_id)))
661 .then_some((startup.execution_id, startup.observation))
662 }
663
664 pub fn from_committed_messages(messages: &[bamboo_agent_core::Message]) -> Option<Self> {
668 if messages.len() > 128 {
669 return None;
670 }
671 let mut observations = Vec::with_capacity(messages.len());
672 for message in messages {
673 if message.role != bamboo_agent_core::Role::User {
674 continue;
675 }
676 let Some(proof) = message
677 .metadata
678 .as_ref()
679 .and_then(|metadata| metadata.get("session_message"))
680 else {
681 continue;
682 };
683 if proof.get("id")?.as_str()? != message.id {
684 return None;
685 }
686 let body = proof.get("body")?;
687 let request = if proof.get("source")?.get("type")?.as_str()? == "user"
688 && proof.get("kind")?.as_str()? == "user_input"
689 && body.get("type")?.as_str()? == "content"
690 {
691 body.get("skill_request")
692 } else if proof.get("source")?.get("type")?.as_str()? == "runtime"
693 && proof.get("source")?.get("subsystem")?.as_str()? == "chat"
694 && proof.get("kind")?.as_str()? == "runtime_instruction"
695 && body.get("instruction")?.as_str()? == "root_chat_turn_v1"
696 {
697 body.get("content")?.get("skill_request")
698 } else {
699 continue;
700 };
701 let request = request
702 .filter(|value| !value.is_null())
703 .map(copy_checked_request)
704 .transpose()
705 .ok()?;
706 if message.id.is_empty() || message.id.len() > 256 {
707 return None;
708 }
709 let input_id = bamboo_domain::SessionMessageId::parse(message.id.clone()).ok()?;
710 observations.push(UntrustedInputObservation { input_id, request });
711 }
712 Self::new(observations)
713 }
714}
715
716fn copy_checked_request(
719 value: &serde_json::Value,
720) -> Result<bamboo_domain::SessionSkillRequest, ()> {
721 let selections = value
722 .get("selections")
723 .and_then(serde_json::Value::as_array)
724 .ok_or(())?;
725 if selections.is_empty() || selections.len() > 32 {
726 return Err(());
727 }
728 let mode = match value.get("mode") {
729 None | Some(serde_json::Value::Null) => None,
730 Some(value) => {
731 let mode = value.as_str().ok_or(())?;
732 if mode.is_empty()
733 || mode.len() > 64
734 || !mode
735 .bytes()
736 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
737 {
738 return Err(());
739 }
740 Some(mode)
741 }
742 };
743 for selection in selections {
746 bamboo_domain::SessionSkillSelection::validate_borrowed(
747 selection
748 .get("id")
749 .and_then(serde_json::Value::as_str)
750 .ok_or(())?,
751 selection
752 .get("source")
753 .and_then(serde_json::Value::as_str)
754 .ok_or(())?,
755 selection
756 .get("revision")
757 .and_then(serde_json::Value::as_u64)
758 .ok_or(())?,
759 selection.get("args").unwrap_or(&serde_json::Value::Null),
760 )
761 .map_err(|_| ())?;
762 }
763 let request = bamboo_domain::SessionSkillRequest {
764 selections: selections
765 .iter()
766 .map(|selection| bamboo_domain::SessionSkillSelection {
767 id: selection["id"].as_str().unwrap().to_owned(),
768 source: selection["source"].as_str().unwrap().to_owned(),
769 revision: selection["revision"].as_u64().unwrap(),
770 args: selection.get("args").cloned().unwrap_or_default(),
771 })
772 .collect(),
773 mode: mode.map(str::to_owned),
774 };
775 request.validate().map_err(|_| ())?;
776 Ok(request)
777}
778
779#[non_exhaustive]
801pub struct AgentLoopConfig {
802 pub freeze_tool_exposure_for_cache: bool,
804 pub(crate) ticket_worker_plan: Option<Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>>,
806 pub(crate) system_prompt: Option<String>,
807 pub(crate) disabled_skill_ids: BTreeSet<String>,
809 pub(crate) selected_skill_ids: Option<Vec<String>>,
812 pub(crate) selected_skill_mode: Option<String>,
817 pub(crate) initial_untrusted_inputs: Option<UntrustedExecutionInputs>,
819 pub(crate) sdk_skill_execution_host: Option<Arc<dyn SdkSkillExecutionHost>>,
820 pub(crate) additional_tool_schemas: Vec<ToolSchema>,
821 pub(crate) tool_registry: Arc<ToolRegistry>,
822 pub(crate) skill_manager: Option<Arc<SkillManager>>,
823 pub(crate) project_context_resolver:
826 Option<Arc<crate::project_context::ProjectContextResolver>>,
827 pub(crate) skip_initial_user_message: bool,
829 pub(crate) storage: Option<Arc<dyn Storage>>,
831 pub(crate) persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
834 pub(crate) guidance_active_run_id: Option<String>,
835 pub(crate) session_inbox: Option<Arc<dyn bamboo_domain::SessionInboxPort>>,
837 pub(crate) session_activation_notifications:
841 Option<Arc<parking_lot::Mutex<tokio::sync::watch::Receiver<u64>>>>,
842 pub(crate) attachment_reader: Option<Arc<dyn AttachmentReader>>,
845 pub(crate) metrics_collector: Option<MetricsCollector>,
847 pub(crate) model_name: Option<String>,
849 pub(crate) fast_model_name: Option<String>,
853 pub(crate) fast_model_provider: Option<Arc<dyn LLMProvider>>,
855 pub(crate) auxiliary_evaluation_max_concurrency: usize,
859 pub(crate) background_model_name: Option<String>,
863
864 pub(crate) planning_model_name: Option<String>,
867 pub(crate) search_model_name: Option<String>,
870 pub(crate) compression_instructions: Option<String>,
875 pub(crate) summary_target_ratio: f64,
878 pub(crate) context_management: bamboo_config::ContextManagementConfig,
881 pub(crate) summary_safe_window_percent: u8,
884 pub(crate) summarization_model_name: Option<String>,
886 pub(crate) background_model_provider: Option<Arc<dyn LLMProvider>>,
891 pub(crate) summarization_model_provider: Option<Arc<dyn LLMProvider>>,
896 pub(crate) provider_name: Option<String>,
900 pub(crate) provider_type: Option<String>,
905 pub(crate) reasoning_effort: Option<ReasoningEffort>,
907 pub(crate) app_data_dir: Option<PathBuf>,
912 pub(crate) memory_store: bamboo_memory::memory_store::MemoryStore,
915 pub(crate) disabled_tools: BTreeSet<String>,
917 pub(crate) token_budget: Option<TokenBudget>,
919 pub(crate) model_limits_section:
923 Option<Arc<bamboo_config::LiveSection<bamboo_config::ModelLimitsSection>>>,
924 pub(crate) legacy_model_limits: Option<serde_json::Value>,
930 pub(crate) image_fallback: Option<ImageFallbackConfig>,
935 pub(crate) vision_model_resolver: Option<super::runner::image_fallback::VisionModelResolver>,
940 pub(crate) prompt_memory_flags: PromptMemoryFlags,
942 pub(crate) max_tool_calls_per_round: usize,
944 pub(crate) max_consecutive_failures_per_tool: usize,
946 pub(crate) per_tool_timeout_secs: u64,
948 pub(crate) parallel_batch_timeout_secs: u64,
950 pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
953 pub(crate) permission_mode: Option<PermissionMode>,
955 pub(crate) gold_config: Option<GoldConfig>,
960 pub(crate) guardian_config: Option<GuardianConfig>,
963 pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
967 pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
973 pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
982 pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
986 pub(crate) hook_runner: Arc<HookRunner>,
989 pub(crate) features_dynamic_model_routing: bool,
993 pub(crate) features_final_evidence_check: bool,
995 pub(crate) auxiliary_model_resolver:
1001 Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
1002 pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
1009 pub(crate) mcp_tool_guidance: Option<String>,
1015 pub(crate) run_budget: bamboo_config::RunBudgetConfig,
1024 pub(crate) counted_loop: Option<super::counted_loop::CountedLoopConfig>,
1025 pub(crate) loop_control: Option<super::loop_control::LoopControlConfig>,
1026}
1027
1028impl AgentLoopConfig {
1029 pub(crate) fn effective_counted_loop(&self) -> Option<&super::counted_loop::CountedLoopConfig> {
1031 self.counted_loop
1032 .as_ref()
1033 .or_else(|| self.loop_control.as_ref().and_then(|c| c.active_counted()))
1034 }
1035
1036 pub fn initial_untrusted_inputs(&self) -> Option<&UntrustedExecutionInputs> {
1038 self.initial_untrusted_inputs.as_ref()
1039 }
1040}
1041
1042impl Default for AgentLoopConfig {
1043 fn default() -> Self {
1044 Self {
1045 freeze_tool_exposure_for_cache: true,
1046 ticket_worker_plan: None,
1047 system_prompt: None,
1048 disabled_skill_ids: BTreeSet::new(),
1049 selected_skill_ids: None,
1050 selected_skill_mode: None,
1051 initial_untrusted_inputs: None,
1052 sdk_skill_execution_host: None,
1053 additional_tool_schemas: Vec::new(),
1054 tool_registry: Arc::new(ToolRegistry::new()),
1055 skill_manager: None,
1056 project_context_resolver: None,
1057 skip_initial_user_message: false,
1058 storage: None,
1059 persistence: None,
1060 guidance_active_run_id: None,
1061 session_inbox: None,
1062 session_activation_notifications: None,
1063 attachment_reader: None,
1064 metrics_collector: None,
1065 model_name: None,
1066 fast_model_name: None,
1067 fast_model_provider: None,
1068 auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
1069 background_model_name: None,
1070 planning_model_name: None,
1071 search_model_name: None,
1072 compression_instructions: None,
1073 summary_target_ratio: 0.20,
1074 context_management: bamboo_config::ContextManagementConfig::default(),
1075 summary_safe_window_percent: 80,
1076 summarization_model_name: None,
1077 background_model_provider: None,
1078 summarization_model_provider: None,
1079 provider_name: None,
1080 provider_type: None,
1081 reasoning_effort: None,
1082 app_data_dir: None,
1083 memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
1084 disabled_tools: BTreeSet::new(),
1085 token_budget: None,
1086 model_limits_section: None,
1087 legacy_model_limits: None,
1088 image_fallback: None,
1089 vision_model_resolver: None,
1090 prompt_memory_flags: PromptMemoryFlags::default(),
1091 max_tool_calls_per_round: 80,
1092 max_consecutive_failures_per_tool: 3,
1093 per_tool_timeout_secs: 120,
1094 parallel_batch_timeout_secs: 300,
1095 stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
1096 permission_mode: None,
1097 gold_config: None,
1098 guardian_config: None,
1099 guardian_spawner: None,
1100 bash_resume_hook: None,
1101 bash_completion_sink: None,
1102 approval_delegate: None,
1103 hook_runner: Arc::new(HookRunner::new()),
1104 features_dynamic_model_routing: false,
1105 features_final_evidence_check: false,
1106 auxiliary_model_resolver: None,
1107 disabled_filter_resolver: None,
1108 mcp_tool_guidance: None,
1109 run_budget: bamboo_config::RunBudgetConfig::default(),
1110 counted_loop: None,
1111 loop_control: None,
1112 }
1113 }
1114}
1115
1116impl AgentLoopConfig {
1117 pub fn bind_ticket_worker_plan(
1118 &mut self,
1119 plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
1120 ) {
1121 self.ticket_worker_plan = Some(plan);
1122 }
1123 pub(crate) fn resolve_disabled_filters(
1128 &self,
1129 ) -> (
1130 std::borrow::Cow<'_, BTreeSet<String>>,
1131 std::borrow::Cow<'_, BTreeSet<String>>,
1132 ) {
1133 match &self.disabled_filter_resolver {
1134 Some(resolver) => {
1135 let (tools, skills) = resolver();
1136 (
1137 std::borrow::Cow::Owned(tools),
1138 std::borrow::Cow::Owned(skills),
1139 )
1140 }
1141 None => (
1142 std::borrow::Cow::Borrowed(&self.disabled_tools),
1143 std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
1144 ),
1145 }
1146 }
1147
1148 pub fn active_goal(&self) -> Option<&str> {
1152 self.gold_config
1153 .as_ref()
1154 .filter(|cfg| cfg.enabled)
1155 .and_then(GoldConfig::effective_goal)
1156 }
1157
1158 pub fn goal_loop_active(&self) -> bool {
1166 self.gold_config.as_ref().is_some_and(|cfg| {
1167 cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
1168 })
1169 }
1170
1171 pub fn guardian_active(&self) -> bool {
1175 self.guardian_spawner.is_some()
1176 && self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
1177 }
1178
1179 pub fn guardian_max_reviews(&self) -> u32 {
1182 self.guardian_config
1183 .as_ref()
1184 .map_or(0, |cfg| cfg.max_reviews)
1185 }
1186
1187 pub fn guardian_model(&self) -> Option<&str> {
1189 self.guardian_config
1190 .as_ref()
1191 .and_then(|cfg| cfg.model_name.as_deref())
1192 }
1193
1194 pub fn delegation_active(&self) -> bool {
1196 self.approval_delegate.is_some()
1197 }
1198}
1199
1200#[cfg(test)]
1201mod tests;
1202
1203#[cfg(test)]
1204mod execution_input_tests {
1205 use super::*;
1206 fn request() -> bamboo_domain::SessionSkillRequest {
1207 bamboo_domain::SessionSkillRequest {
1208 selections: vec![bamboo_domain::SessionSkillSelection {
1209 id: "exact Case".into(),
1210 source: "builtin".into(),
1211 revision: 1,
1212 args: serde_json::json!({"raw":"保持原样"}),
1213 }],
1214 mode: None,
1215 }
1216 }
1217 #[test]
1218 fn execution_input_owned_bounds_and_old_config_default() {
1219 assert!(AgentLoopConfig::default()
1220 .initial_untrusted_inputs()
1221 .is_none());
1222 let request = request();
1223 let input = UntrustedInputObservation::new("exact-input", Some(&request)).unwrap();
1224 assert_eq!(input.input_id(), "exact-input");
1225 assert_eq!(input.request(), Some(&request));
1226 for id in [
1227 "",
1228 " leading",
1229 "trailing ",
1230 "unsafe/id",
1231 "unsafe\\id",
1232 "unsafe..id",
1233 ] {
1234 assert!(UntrustedInputObservation::new(id, None).is_none());
1235 }
1236 assert!(UntrustedInputObservation::new(&"a".repeat(257), None).is_none());
1237 assert!(UntrustedExecutionInputs::new(Vec::new()).is_none());
1238 let batch = UntrustedExecutionInputs::new(
1239 (0..128)
1240 .map(|i| {
1241 UntrustedInputObservation::new(&format!("input-{i}"), Some(&request)).unwrap()
1242 })
1243 .collect(),
1244 )
1245 .unwrap();
1246 assert_eq!(batch.observations().len(), 128);
1247 assert_eq!(batch.observations()[127].input_id(), "input-127");
1248 assert!(UntrustedExecutionInputs::new(
1249 (0..129)
1250 .map(|i| UntrustedInputObservation::new(&format!("input-{i}"), None).unwrap())
1251 .collect()
1252 )
1253 .is_none());
1254 assert!(UntrustedExecutionInputs::new(vec![
1255 UntrustedInputObservation::new("same", None).unwrap(),
1256 UntrustedInputObservation::new("same", None).unwrap()
1257 ])
1258 .is_none());
1259 let mut malformed = request;
1260 malformed.selections[0].args = (0..65).fold(serde_json::Value::Null, |v, _| {
1261 serde_json::Value::Array(vec![v])
1262 });
1263 assert!(UntrustedInputObservation::new("deep", Some(&malformed)).is_none());
1264 }
1265 #[test]
1266 fn execution_input_only_explicit_checked_commit_slice_copies_request_fields() {
1267 let request = request();
1268 let mut user = bamboo_domain::SessionMessageEnvelope::user_input("target", "ordinary");
1269 if let bamboo_domain::SessionMessageBody::Content(content) = &mut user.body {
1270 content.skill_request = Some(request.clone());
1271 }
1272 let id = user.id.to_string();
1273 let plain = user.to_provider_message().unwrap();
1274 let root = user
1275 .with_root_chat_prompt("system prompt is not retained".into())
1276 .unwrap()
1277 .to_provider_message()
1278 .unwrap();
1279 for message in [plain, root] {
1280 let batch = UntrustedExecutionInputs::from_committed_messages(&[message]).unwrap();
1281 assert_eq!(batch.observations().len(), 1);
1282 assert_eq!(batch.observations()[0].input_id(), id);
1283 assert_eq!(batch.observations()[0].request(), Some(&request));
1284 }
1285 let text = bamboo_agent_core::Message::user("{\"skill_request\":\"fragment\"}");
1286 assert!(UntrustedExecutionInputs::from_committed_messages(&[text]).is_none());
1287 assert!(UntrustedExecutionInputs::from_committed_messages(&[]).is_none());
1288 }
1289}
1290
1291#[cfg(test)]
1292mod sdk_acceptance_tests {
1293 use super::*;
1294 use crate::runtime::managers::lifecycle::BoundedInputRequestBatch;
1295 use bamboo_agent_core::{Message, Role, Session};
1296
1297 struct Repository {
1298 stored: std::sync::Mutex<Option<Session>>,
1299 reject: bool,
1300 corrupt: bool,
1301 }
1302 #[async_trait::async_trait]
1303 impl RuntimeSessionPersistence for Repository {
1304 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1305 if self.reject {
1306 return Err(std::io::Error::other("checkpoint refused"));
1307 }
1308 let mut accepted = session.clone();
1309 if self.corrupt {
1310 accepted
1311 .messages
1312 .iter_mut()
1313 .find(|message| message.role == Role::User)
1314 .unwrap()
1315 .content
1316 .push_str(" changed");
1317 }
1318 *self.stored.lock().unwrap() = Some(accepted);
1319 Ok(())
1320 }
1321 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1322 self.save_runtime_session(session).await
1323 }
1324 async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1325 Ok(self.stored.lock().unwrap().clone())
1326 }
1327 }
1328 fn repository(reject: bool, corrupt: bool) -> Arc<dyn RuntimeSessionPersistence> {
1329 Arc::new(Repository {
1330 stored: Default::default(),
1331 reject,
1332 corrupt,
1333 })
1334 }
1335 fn producer(session: &Session, user: &Message, typed: bool) -> UntrustedExecutionInputs {
1336 UntrustedExecutionInputs::new(vec![UntrustedInputObservation::new(&user.id, None).unwrap()])
1337 .unwrap()
1338 .bind_sdk_append(
1339 "actual-sdk-execution".into(),
1340 SdkPendingInput {
1341 session_id: session.id.clone(),
1342 input_id: user.id.clone(),
1343 header_digest: sdk_message_header_digest(user),
1344 final_digest: typed.then(|| sdk_message_digest(user)),
1345 },
1346 None,
1347 )
1348 }
1349 fn current(
1350 inputs: &UntrustedExecutionInputs,
1351 session: &Session,
1352 ) -> Option<BoundedInputRequestBatch> {
1353 let (execution_id, observation) = inputs.take_startup_observation(&session.id).unwrap();
1354 assert_eq!(execution_id, "actual-sdk-execution");
1355 let mut current = None;
1356 observation.update_current(&mut current, &session.id, &execution_id);
1357 assert!(inputs.take_startup_observation(&session.id).is_none());
1358 current
1359 }
1360 #[tokio::test]
1361 async fn sdk_startup_requires_exact_checkpoint_and_preserves_request_absence_as_new() {
1362 let mut session = Session::new("sdk-accept", "model");
1363 let user = Message::user("final prepared");
1364 let inputs = producer(&session, &user, true);
1365 session.add_message(user.clone());
1366 inputs
1367 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1368 .await
1369 .unwrap();
1370 let batch = current(&inputs, &session).unwrap();
1371 assert_eq!(batch.records().len(), 1);
1372 assert_eq!(batch.records()[0].input_id, user.id);
1373 assert_eq!(
1374 batch.records()[0].source,
1375 bamboo_domain::SessionMessageSource::User
1376 );
1377 assert!(batch.records()[0].request.is_none());
1378 }
1379 #[tokio::test]
1380 async fn sdk_startup_unknown_checkpoint_or_modified_final_user_stays_unavailable() {
1381 for (reject, corrupt, mutate, duplicate, wrong_session) in [
1382 (true, false, false, false, false),
1383 (false, true, false, false, false),
1384 (false, false, true, false, false),
1385 (false, false, false, true, false),
1386 (false, false, false, false, true),
1387 ] {
1388 let mut session = Session::new("sdk-deny", "model");
1389 let user = Message::user("prepared");
1390 let inputs = producer(&session, &user, true);
1391 session.add_message(user.clone());
1392 if mutate {
1393 session.messages[0].content.push_str(" changed");
1394 }
1395 if duplicate {
1396 session.add_message(user);
1397 }
1398 if wrong_session {
1399 session.id = "wrong".into();
1400 }
1401 assert!(inputs
1402 .seal_sdk_input(&mut session, Some(&repository(reject, corrupt)))
1403 .await
1404 .is_err());
1405 if wrong_session {
1406 assert!(inputs.take_startup_observation(&session.id).is_none());
1407 } else {
1408 assert!(current(&inputs, &session).is_none());
1409 }
1410 }
1411 }
1412 #[tokio::test]
1413 async fn sdk_legacy_hook_rewrite_is_bound_to_original_id_while_generic_history_is_unsealed() {
1414 let mut session = Session::new("sdk-string", "model");
1415 let user = Message::user("original");
1416 let inputs = producer(&session, &user, false);
1417 session.add_message(user.clone());
1418 session.messages[0].content = "real portable hook result".into();
1419 inputs
1420 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1421 .await
1422 .unwrap();
1423 assert_eq!(
1424 current(&inputs, &session).unwrap().records()[0].input_id,
1425 user.id
1426 );
1427 let generic =
1428 UntrustedExecutionInputs::new(vec![
1429 UntrustedInputObservation::new(&user.id, None).unwrap()
1430 ])
1431 .unwrap();
1432 generic
1433 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1434 .await
1435 .unwrap();
1436 assert!(generic.sdk_execution_id().is_none());
1437 assert!(generic.take_startup_observation(&session.id).is_none());
1438 }
1439 #[test]
1440 fn sdk_header_binding_accepts_only_the_canonical_prompt_changes() {
1441 let mut original = Message::user("canonical");
1442 original.content_parts = Some(vec![
1443 bamboo_domain::MessagePart::Text {
1444 text: "unrelated native text".into(),
1445 },
1446 bamboo_domain::MessagePart::Text {
1447 text: "canonical".into(),
1448 },
1449 ]);
1450 let digest = sdk_message_header_digest(&original);
1451 let mut prepared = original.clone();
1452 prepared.content = "canonical plus F".into();
1453 if let bamboo_domain::MessagePart::Text { text } =
1454 &mut prepared.content_parts.as_mut().unwrap()[1]
1455 {
1456 *text = prepared.content.clone();
1457 }
1458 assert_eq!(sdk_message_header_digest(&prepared), digest);
1459 prepared.id = "different".into();
1460 assert_ne!(sdk_message_header_digest(&prepared), digest);
1461 prepared = original;
1462 if let bamboo_domain::MessagePart::Text { text } =
1463 &mut prepared.content_parts.as_mut().unwrap()[0]
1464 {
1465 *text = "tampered".into();
1466 }
1467 assert_ne!(sdk_message_header_digest(&prepared), digest);
1468 }
1469}