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