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>,
939 pub(crate) prompt_memory_flags: PromptMemoryFlags,
941 pub(crate) max_tool_calls_per_round: usize,
943 pub(crate) max_consecutive_failures_per_tool: usize,
945 pub(crate) per_tool_timeout_secs: u64,
947 pub(crate) parallel_batch_timeout_secs: u64,
949 pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
952 pub(crate) permission_mode: Option<PermissionMode>,
954 pub(crate) gold_config: Option<GoldConfig>,
959 pub(crate) guardian_config: Option<GuardianConfig>,
962 pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
966 pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
972 pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
981 pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
985 pub(crate) hook_runner: Arc<HookRunner>,
988 pub(crate) features_dynamic_model_routing: bool,
992 pub(crate) features_final_evidence_check: bool,
994 pub(crate) auxiliary_model_resolver:
1000 Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
1001 pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
1008 pub(crate) mcp_tool_guidance: Option<String>,
1014 pub(crate) run_budget: bamboo_config::RunBudgetConfig,
1023 pub(crate) counted_loop: Option<super::counted_loop::CountedLoopConfig>,
1024 pub(crate) loop_control: Option<super::loop_control::LoopControlConfig>,
1025}
1026
1027impl AgentLoopConfig {
1028 pub(crate) fn effective_counted_loop(&self) -> Option<&super::counted_loop::CountedLoopConfig> {
1030 self.counted_loop
1031 .as_ref()
1032 .or_else(|| self.loop_control.as_ref().and_then(|c| c.active_counted()))
1033 }
1034
1035 pub fn initial_untrusted_inputs(&self) -> Option<&UntrustedExecutionInputs> {
1037 self.initial_untrusted_inputs.as_ref()
1038 }
1039}
1040
1041impl Default for AgentLoopConfig {
1042 fn default() -> Self {
1043 Self {
1044 freeze_tool_exposure_for_cache: true,
1045 ticket_worker_plan: None,
1046 system_prompt: None,
1047 disabled_skill_ids: BTreeSet::new(),
1048 selected_skill_ids: None,
1049 selected_skill_mode: None,
1050 initial_untrusted_inputs: None,
1051 sdk_skill_execution_host: None,
1052 additional_tool_schemas: Vec::new(),
1053 tool_registry: Arc::new(ToolRegistry::new()),
1054 skill_manager: None,
1055 project_context_resolver: None,
1056 skip_initial_user_message: false,
1057 storage: None,
1058 persistence: None,
1059 guidance_active_run_id: None,
1060 session_inbox: None,
1061 session_activation_notifications: None,
1062 attachment_reader: None,
1063 metrics_collector: None,
1064 model_name: None,
1065 fast_model_name: None,
1066 fast_model_provider: None,
1067 auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
1068 background_model_name: None,
1069 planning_model_name: None,
1070 search_model_name: None,
1071 compression_instructions: None,
1072 summary_target_ratio: 0.20,
1073 context_management: bamboo_config::ContextManagementConfig::default(),
1074 summary_safe_window_percent: 80,
1075 summarization_model_name: None,
1076 background_model_provider: None,
1077 summarization_model_provider: None,
1078 provider_name: None,
1079 provider_type: None,
1080 reasoning_effort: None,
1081 app_data_dir: None,
1082 memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
1083 disabled_tools: BTreeSet::new(),
1084 token_budget: None,
1085 model_limits_section: None,
1086 legacy_model_limits: None,
1087 image_fallback: None,
1088 vision_model_resolver: None,
1089 prompt_memory_flags: PromptMemoryFlags::default(),
1090 max_tool_calls_per_round: 80,
1091 max_consecutive_failures_per_tool: 3,
1092 per_tool_timeout_secs: 120,
1093 parallel_batch_timeout_secs: 300,
1094 stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
1095 permission_mode: None,
1096 gold_config: None,
1097 guardian_config: None,
1098 guardian_spawner: None,
1099 bash_resume_hook: None,
1100 bash_completion_sink: None,
1101 approval_delegate: None,
1102 hook_runner: Arc::new(HookRunner::new()),
1103 features_dynamic_model_routing: false,
1104 features_final_evidence_check: false,
1105 auxiliary_model_resolver: None,
1106 disabled_filter_resolver: None,
1107 mcp_tool_guidance: None,
1108 run_budget: bamboo_config::RunBudgetConfig::default(),
1109 counted_loop: None,
1110 loop_control: None,
1111 }
1112 }
1113}
1114
1115impl AgentLoopConfig {
1116 pub fn bind_ticket_worker_plan(
1117 &mut self,
1118 plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
1119 ) {
1120 self.ticket_worker_plan = Some(plan);
1121 }
1122 pub(crate) fn resolve_disabled_filters(
1127 &self,
1128 ) -> (
1129 std::borrow::Cow<'_, BTreeSet<String>>,
1130 std::borrow::Cow<'_, BTreeSet<String>>,
1131 ) {
1132 match &self.disabled_filter_resolver {
1133 Some(resolver) => {
1134 let (tools, skills) = resolver();
1135 (
1136 std::borrow::Cow::Owned(tools),
1137 std::borrow::Cow::Owned(skills),
1138 )
1139 }
1140 None => (
1141 std::borrow::Cow::Borrowed(&self.disabled_tools),
1142 std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
1143 ),
1144 }
1145 }
1146
1147 pub fn active_goal(&self) -> Option<&str> {
1151 self.gold_config
1152 .as_ref()
1153 .filter(|cfg| cfg.enabled)
1154 .and_then(GoldConfig::effective_goal)
1155 }
1156
1157 pub fn goal_loop_active(&self) -> bool {
1165 self.gold_config.as_ref().is_some_and(|cfg| {
1166 cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
1167 })
1168 }
1169
1170 pub fn guardian_active(&self) -> bool {
1174 self.guardian_spawner.is_some()
1175 && self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
1176 }
1177
1178 pub fn guardian_max_reviews(&self) -> u32 {
1181 self.guardian_config
1182 .as_ref()
1183 .map_or(0, |cfg| cfg.max_reviews)
1184 }
1185
1186 pub fn guardian_model(&self) -> Option<&str> {
1188 self.guardian_config
1189 .as_ref()
1190 .and_then(|cfg| cfg.model_name.as_deref())
1191 }
1192
1193 pub fn delegation_active(&self) -> bool {
1195 self.approval_delegate.is_some()
1196 }
1197}
1198
1199#[cfg(test)]
1200mod tests;
1201
1202#[cfg(test)]
1203mod execution_input_tests {
1204 use super::*;
1205 fn request() -> bamboo_domain::SessionSkillRequest {
1206 bamboo_domain::SessionSkillRequest {
1207 selections: vec![bamboo_domain::SessionSkillSelection {
1208 id: "exact Case".into(),
1209 source: "builtin".into(),
1210 revision: 1,
1211 args: serde_json::json!({"raw":"保持原样"}),
1212 }],
1213 mode: None,
1214 }
1215 }
1216 #[test]
1217 fn execution_input_owned_bounds_and_old_config_default() {
1218 assert!(AgentLoopConfig::default()
1219 .initial_untrusted_inputs()
1220 .is_none());
1221 let request = request();
1222 let input = UntrustedInputObservation::new("exact-input", Some(&request)).unwrap();
1223 assert_eq!(input.input_id(), "exact-input");
1224 assert_eq!(input.request(), Some(&request));
1225 for id in [
1226 "",
1227 " leading",
1228 "trailing ",
1229 "unsafe/id",
1230 "unsafe\\id",
1231 "unsafe..id",
1232 ] {
1233 assert!(UntrustedInputObservation::new(id, None).is_none());
1234 }
1235 assert!(UntrustedInputObservation::new(&"a".repeat(257), None).is_none());
1236 assert!(UntrustedExecutionInputs::new(Vec::new()).is_none());
1237 let batch = UntrustedExecutionInputs::new(
1238 (0..128)
1239 .map(|i| {
1240 UntrustedInputObservation::new(&format!("input-{i}"), Some(&request)).unwrap()
1241 })
1242 .collect(),
1243 )
1244 .unwrap();
1245 assert_eq!(batch.observations().len(), 128);
1246 assert_eq!(batch.observations()[127].input_id(), "input-127");
1247 assert!(UntrustedExecutionInputs::new(
1248 (0..129)
1249 .map(|i| UntrustedInputObservation::new(&format!("input-{i}"), None).unwrap())
1250 .collect()
1251 )
1252 .is_none());
1253 assert!(UntrustedExecutionInputs::new(vec![
1254 UntrustedInputObservation::new("same", None).unwrap(),
1255 UntrustedInputObservation::new("same", None).unwrap()
1256 ])
1257 .is_none());
1258 let mut malformed = request;
1259 malformed.selections[0].args = (0..65).fold(serde_json::Value::Null, |v, _| {
1260 serde_json::Value::Array(vec![v])
1261 });
1262 assert!(UntrustedInputObservation::new("deep", Some(&malformed)).is_none());
1263 }
1264 #[test]
1265 fn execution_input_only_explicit_checked_commit_slice_copies_request_fields() {
1266 let request = request();
1267 let mut user = bamboo_domain::SessionMessageEnvelope::user_input("target", "ordinary");
1268 if let bamboo_domain::SessionMessageBody::Content(content) = &mut user.body {
1269 content.skill_request = Some(request.clone());
1270 }
1271 let id = user.id.to_string();
1272 let plain = user.to_provider_message().unwrap();
1273 let root = user
1274 .with_root_chat_prompt("system prompt is not retained".into())
1275 .unwrap()
1276 .to_provider_message()
1277 .unwrap();
1278 for message in [plain, root] {
1279 let batch = UntrustedExecutionInputs::from_committed_messages(&[message]).unwrap();
1280 assert_eq!(batch.observations().len(), 1);
1281 assert_eq!(batch.observations()[0].input_id(), id);
1282 assert_eq!(batch.observations()[0].request(), Some(&request));
1283 }
1284 let text = bamboo_agent_core::Message::user("{\"skill_request\":\"fragment\"}");
1285 assert!(UntrustedExecutionInputs::from_committed_messages(&[text]).is_none());
1286 assert!(UntrustedExecutionInputs::from_committed_messages(&[]).is_none());
1287 }
1288}
1289
1290#[cfg(test)]
1291mod sdk_acceptance_tests {
1292 use super::*;
1293 use crate::runtime::managers::lifecycle::BoundedInputRequestBatch;
1294 use bamboo_agent_core::{Message, Role, Session};
1295
1296 struct Repository {
1297 stored: std::sync::Mutex<Option<Session>>,
1298 reject: bool,
1299 corrupt: bool,
1300 }
1301 #[async_trait::async_trait]
1302 impl RuntimeSessionPersistence for Repository {
1303 async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1304 if self.reject {
1305 return Err(std::io::Error::other("checkpoint refused"));
1306 }
1307 let mut accepted = session.clone();
1308 if self.corrupt {
1309 accepted
1310 .messages
1311 .iter_mut()
1312 .find(|message| message.role == Role::User)
1313 .unwrap()
1314 .content
1315 .push_str(" changed");
1316 }
1317 *self.stored.lock().unwrap() = Some(accepted);
1318 Ok(())
1319 }
1320 async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1321 self.save_runtime_session(session).await
1322 }
1323 async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1324 Ok(self.stored.lock().unwrap().clone())
1325 }
1326 }
1327 fn repository(reject: bool, corrupt: bool) -> Arc<dyn RuntimeSessionPersistence> {
1328 Arc::new(Repository {
1329 stored: Default::default(),
1330 reject,
1331 corrupt,
1332 })
1333 }
1334 fn producer(session: &Session, user: &Message, typed: bool) -> UntrustedExecutionInputs {
1335 UntrustedExecutionInputs::new(vec![UntrustedInputObservation::new(&user.id, None).unwrap()])
1336 .unwrap()
1337 .bind_sdk_append(
1338 "actual-sdk-execution".into(),
1339 SdkPendingInput {
1340 session_id: session.id.clone(),
1341 input_id: user.id.clone(),
1342 header_digest: sdk_message_header_digest(user),
1343 final_digest: typed.then(|| sdk_message_digest(user)),
1344 },
1345 None,
1346 )
1347 }
1348 fn current(
1349 inputs: &UntrustedExecutionInputs,
1350 session: &Session,
1351 ) -> Option<BoundedInputRequestBatch> {
1352 let (execution_id, observation) = inputs.take_startup_observation(&session.id).unwrap();
1353 assert_eq!(execution_id, "actual-sdk-execution");
1354 let mut current = None;
1355 observation.update_current(&mut current, &session.id, &execution_id);
1356 assert!(inputs.take_startup_observation(&session.id).is_none());
1357 current
1358 }
1359 #[tokio::test]
1360 async fn sdk_startup_requires_exact_checkpoint_and_preserves_request_absence_as_new() {
1361 let mut session = Session::new("sdk-accept", "model");
1362 let user = Message::user("final prepared");
1363 let inputs = producer(&session, &user, true);
1364 session.add_message(user.clone());
1365 inputs
1366 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1367 .await
1368 .unwrap();
1369 let batch = current(&inputs, &session).unwrap();
1370 assert_eq!(batch.records().len(), 1);
1371 assert_eq!(batch.records()[0].input_id, user.id);
1372 assert_eq!(
1373 batch.records()[0].source,
1374 bamboo_domain::SessionMessageSource::User
1375 );
1376 assert!(batch.records()[0].request.is_none());
1377 }
1378 #[tokio::test]
1379 async fn sdk_startup_unknown_checkpoint_or_modified_final_user_stays_unavailable() {
1380 for (reject, corrupt, mutate, duplicate, wrong_session) in [
1381 (true, false, false, false, false),
1382 (false, true, false, false, false),
1383 (false, false, true, false, false),
1384 (false, false, false, true, false),
1385 (false, false, false, false, true),
1386 ] {
1387 let mut session = Session::new("sdk-deny", "model");
1388 let user = Message::user("prepared");
1389 let inputs = producer(&session, &user, true);
1390 session.add_message(user.clone());
1391 if mutate {
1392 session.messages[0].content.push_str(" changed");
1393 }
1394 if duplicate {
1395 session.add_message(user);
1396 }
1397 if wrong_session {
1398 session.id = "wrong".into();
1399 }
1400 assert!(inputs
1401 .seal_sdk_input(&mut session, Some(&repository(reject, corrupt)))
1402 .await
1403 .is_err());
1404 if wrong_session {
1405 assert!(inputs.take_startup_observation(&session.id).is_none());
1406 } else {
1407 assert!(current(&inputs, &session).is_none());
1408 }
1409 }
1410 }
1411 #[tokio::test]
1412 async fn sdk_legacy_hook_rewrite_is_bound_to_original_id_while_generic_history_is_unsealed() {
1413 let mut session = Session::new("sdk-string", "model");
1414 let user = Message::user("original");
1415 let inputs = producer(&session, &user, false);
1416 session.add_message(user.clone());
1417 session.messages[0].content = "real portable hook result".into();
1418 inputs
1419 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1420 .await
1421 .unwrap();
1422 assert_eq!(
1423 current(&inputs, &session).unwrap().records()[0].input_id,
1424 user.id
1425 );
1426 let generic =
1427 UntrustedExecutionInputs::new(vec![
1428 UntrustedInputObservation::new(&user.id, None).unwrap()
1429 ])
1430 .unwrap();
1431 generic
1432 .seal_sdk_input(&mut session, Some(&repository(false, false)))
1433 .await
1434 .unwrap();
1435 assert!(generic.sdk_execution_id().is_none());
1436 assert!(generic.take_startup_observation(&session.id).is_none());
1437 }
1438 #[test]
1439 fn sdk_header_binding_accepts_only_the_canonical_prompt_changes() {
1440 let mut original = Message::user("canonical");
1441 original.content_parts = Some(vec![
1442 bamboo_domain::MessagePart::Text {
1443 text: "unrelated native text".into(),
1444 },
1445 bamboo_domain::MessagePart::Text {
1446 text: "canonical".into(),
1447 },
1448 ]);
1449 let digest = sdk_message_header_digest(&original);
1450 let mut prepared = original.clone();
1451 prepared.content = "canonical plus F".into();
1452 if let bamboo_domain::MessagePart::Text { text } =
1453 &mut prepared.content_parts.as_mut().unwrap()[1]
1454 {
1455 *text = prepared.content.clone();
1456 }
1457 assert_eq!(sdk_message_header_digest(&prepared), digest);
1458 prepared.id = "different".into();
1459 assert_ne!(sdk_message_header_digest(&prepared), digest);
1460 prepared = original;
1461 if let bamboo_domain::MessagePart::Text { text } =
1462 &mut prepared.content_parts.as_mut().unwrap()[0]
1463 {
1464 *text = "tampered".into();
1465 }
1466 assert_ne!(sdk_message_header_digest(&prepared), digest);
1467 }
1468}