use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::Arc;
use bamboo_agent_core::storage::AttachmentReader;
use bamboo_agent_core::storage::Storage;
use bamboo_agent_core::tools::ToolSchema;
use bamboo_agent_core::GoldConfidence;
use bamboo_compression::TokenBudget;
use bamboo_config::MemoryConfig;
use bamboo_config::PermissionMode;
use bamboo_domain::ReasoningEffort;
use bamboo_domain::RuntimeSessionPersistence;
use bamboo_llm::LLMProvider;
use bamboo_metrics::MetricsCollector;
use bamboo_skills::SkillManager;
use bamboo_tools::ToolRegistry;
use serde::{Deserialize, Serialize};
use super::hooks::HookRunner;
pub(crate) const DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY: usize = 2;
pub(crate) const MAX_AUXILIARY_EVALUATION_MAX_CONCURRENCY: usize = 1024;
pub(crate) fn normalize_auxiliary_evaluation_max_concurrency(value: Option<u64>) -> usize {
value
.filter(|value| *value > 0)
.map(|value| value.min(MAX_AUXILIARY_EVALUATION_MAX_CONCURRENCY as u64) as usize)
.unwrap_or(DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY)
}
pub type DisabledFilterSets = (BTreeSet<String>, BTreeSet<String>);
pub type DisabledFilterResolver = Arc<dyn Fn() -> DisabledFilterSets + Send + Sync>;
#[doc(hidden)]
pub type BackgroundModelResolver =
Arc<dyn Fn(&str) -> Option<bamboo_llm::ResolvedModel> + Send + Sync>;
#[doc(hidden)]
pub type ResumeAuxiliarySelection = (
Result<Option<bamboo_llm::ResolvedModel>, bamboo_llm::LLMError>,
Option<bamboo_llm::ResolvedModel>,
);
#[doc(hidden)]
pub type ResumeAuxiliaryResolver = Arc<dyn Fn() -> ResumeAuxiliarySelection + Send + Sync>;
#[doc(hidden)]
pub type GoldTargetResolver =
Arc<dyn Fn(&str, Arc<dyn LLMProvider>) -> Arc<dyn LLMProvider> + Send + Sync>;
#[derive(Clone, Default)]
pub struct AuxiliaryModelConfig {
pub fast_model_name: Option<String>,
pub fast_model_provider: Option<Arc<dyn LLMProvider>>,
pub background_model_name: Option<String>,
pub planning_model_name: Option<String>,
pub search_model_name: Option<String>,
pub summarization_model_name: Option<String>,
pub background_model_provider: Option<Arc<dyn LLMProvider>>,
pub summarization_model_provider: Option<Arc<dyn LLMProvider>>,
pub summarization_model_error: Option<String>,
}
fn default_gold_max_output_tokens() -> u32 {
1024
}
fn default_gold_max_auto_continuations() -> u32 {
3
}
fn default_gold_min_confidence() -> GoldConfidence {
GoldConfidence::Medium
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct GoldConfig {
#[serde(default)]
pub recovery: super::goal_recovery::GoalRecoveryPolicy,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub auto_answer_enabled: bool,
#[serde(default)]
pub auto_continue_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evaluation_prompt: Option<String>,
#[serde(default = "default_gold_max_output_tokens")]
pub max_output_tokens: u32,
#[serde(default = "default_gold_max_auto_continuations")]
pub max_auto_continuations: u32,
#[serde(default = "default_gold_min_confidence")]
pub min_auto_continue_confidence: GoldConfidence,
}
impl Default for GoldConfig {
fn default() -> Self {
Self {
recovery: Default::default(),
enabled: false,
auto_answer_enabled: false,
auto_continue_enabled: false,
model_name: None,
goal: None,
evaluation_prompt: None,
max_output_tokens: default_gold_max_output_tokens(),
max_auto_continuations: default_gold_max_auto_continuations(),
min_auto_continue_confidence: default_gold_min_confidence(),
}
}
}
impl GoldConfig {
pub fn effective_goal(&self) -> Option<&str> {
self.goal
.as_deref()
.or(self.evaluation_prompt.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
}
}
fn default_guardian_max_reviews() -> u32 {
2
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct GuardianConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_name: Option<String>,
#[serde(default = "default_guardian_max_reviews")]
pub max_reviews: u32,
}
impl Default for GuardianConfig {
fn default() -> Self {
Self {
enabled: false,
model_name: None,
max_reviews: default_guardian_max_reviews(),
}
}
}
#[async_trait::async_trait]
pub trait GuardianSpawner: Send + Sync {
async fn spawn_guardian_review(
&self,
parent_session: &bamboo_agent_core::Session,
review_prompt: String,
model: String,
disabled_tools: Option<BTreeSet<String>>,
) -> Result<String, String>;
}
pub const BASH_COMPLETION_RESUME_KIND: &str = "bash_completion_resume";
pub use bamboo_agent_core::BashCompletionSink;
pub trait BashResumeHook: Send + Sync {
fn arrange_bash_self_resume(&self, session_id: String, bash_ids: Vec<String>);
fn supports_background_exec(&self) -> bool {
false
}
fn arrange_exec_self_resume(
&self,
_job: bamboo_tools::tools::exec_command::BackgroundExecJob,
_session_created_at: chrono::DateTime<chrono::Utc>,
) {
}
}
#[derive(Debug, Clone)]
pub struct ChildApprovalRequest {
pub child_session_id: String,
pub parent_session_id: String,
pub child_tool_call_id: String,
pub tool_name: String,
pub permission_type: String,
pub resource: String,
pub question: String,
pub approval_payload: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChildApprovalOutcome {
Delegated,
AutoApproved,
AutoDenied,
}
#[async_trait::async_trait]
pub trait ApprovalDelegate: Send + Sync {
async fn delegate_child_approval(
&self,
request: ChildApprovalRequest,
) -> Result<ChildApprovalOutcome, String>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFallbackMode {
Placeholder,
Error,
Ocr,
Vision,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageFallbackConfig {
pub mode: ImageFallbackMode,
pub vision_model: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PromptMemoryFlags {
pub project_prompt_injection: bool,
pub relevant_recall: bool,
pub relevant_recall_rerank: bool,
pub project_first_dream: bool,
pub ledger_agenda: bool,
}
impl Default for PromptMemoryFlags {
fn default() -> Self {
Self {
project_prompt_injection: true,
relevant_recall: true,
relevant_recall_rerank: false,
project_first_dream: true,
ledger_agenda: true,
}
}
}
impl From<&MemoryConfig> for PromptMemoryFlags {
fn from(value: &MemoryConfig) -> Self {
Self {
project_prompt_injection: value.project_prompt_injection,
relevant_recall: value.relevant_recall,
relevant_recall_rerank: value.relevant_recall_rerank,
project_first_dream: value.project_first_dream,
ledger_agenda: value.ledger_agenda_injection,
}
}
}
#[async_trait::async_trait]
pub trait SkillExecutionHost: Send + Sync {
fn observe_current_inputs(
&self,
session_id: &str,
execution_id: &str,
current: Option<&crate::runtime::managers::lifecycle::BoundedInputRequestBatch>,
) -> Result<(), bamboo_agent_core::AgentError>;
async fn refresh_runtime_scope(
&self,
_session: &mut bamboo_agent_core::Session,
_execution_id: &str,
) -> Result<(), bamboo_agent_core::AgentError> {
Ok(())
}
async fn render_skill_prompt(
&self,
session: &bamboo_agent_core::Session,
execution_id: &str,
) -> Result<String, bamboo_agent_core::AgentError>;
fn finish(&self, session_id: &str, execution_id: &str);
}
pub use SkillExecutionHost as SdkSkillExecutionHost;
#[async_trait::async_trait]
pub trait WorkspaceExecutionHost: Send + Sync {
async fn refresh_runtime_scope(
&self,
session: &mut bamboo_agent_core::Session,
execution_id: &str,
persistence: &Arc<dyn bamboo_domain::RuntimeSessionPersistence>,
) -> Result<(), bamboo_agent_core::AgentError>;
fn finish(&self);
}
#[derive(Debug, PartialEq)]
pub struct UntrustedInputObservation {
input_id: bamboo_domain::SessionMessageId,
request: Option<bamboo_domain::SessionSkillRequest>,
}
impl UntrustedInputObservation {
pub fn new(
input_id: &str,
request: Option<&bamboo_domain::SessionSkillRequest>,
) -> Option<Self> {
if input_id.is_empty() || input_id.len() > 256 {
return None;
}
let input_id = bamboo_domain::SessionMessageId::parse(input_id.to_owned()).ok()?;
if let Some(request) = request {
request.validate().ok()?;
}
Some(Self {
input_id,
request: request.cloned(),
})
}
pub fn input_id(&self) -> &str {
self.input_id.as_str()
}
pub fn request(&self) -> Option<&bamboo_domain::SessionSkillRequest> {
self.request.as_ref()
}
}
pub struct UntrustedExecutionInputs {
observations: Box<[UntrustedInputObservation]>,
startup: std::sync::Mutex<Option<StartupInputObservation>>,
sdk_execution_id: Option<String>,
sdk_hook_runner: Option<Arc<HookRunner>>,
sdk_pending: std::sync::Mutex<Option<SdkPendingInput>>,
}
#[derive(Debug)]
pub(crate) struct SdkPendingInput {
pub session_id: String,
pub input_id: String,
pub header_digest: [u8; 32],
pub final_digest: Option<[u8; 32]>,
}
impl std::fmt::Debug for UntrustedExecutionInputs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UntrustedExecutionInputs")
.field("observations", &self.observations)
.field("sdk_execution_id", &self.sdk_execution_id)
.finish_non_exhaustive()
}
}
pub(crate) fn sdk_message_digest(message: &bamboo_agent_core::Message) -> [u8; 32] {
use sha2::{Digest, Sha256};
let bytes = serde_json::to_vec(message).expect("Message serialization is infallible");
Sha256::digest(bytes).into()
}
pub(crate) fn sdk_message_header_digest(message: &bamboo_agent_core::Message) -> [u8; 32] {
let mut header = message.clone();
header.content.clear();
if let Some(parts) = header.content_parts.as_mut() {
if let Some(bamboo_domain::MessagePart::Text { text }) =
parts.iter_mut().find(|part| matches!(part, bamboo_domain::MessagePart::Text { text } if text == &message.content))
{
text.clear();
}
}
sdk_message_digest(&header)
}
#[derive(Debug)]
struct StartupInputObservation {
session_id: String,
execution_id: String,
observation: crate::runtime::managers::lifecycle::InputObservation,
}
impl UntrustedExecutionInputs {
pub fn new(observations: Vec<UntrustedInputObservation>) -> Option<Self> {
if observations.is_empty() || observations.len() > 128 {
return None;
}
let mut ids = std::collections::HashSet::with_capacity(observations.len());
if observations.iter().any(|item| !ids.insert(item.input_id())) {
return None;
}
Some(Self {
observations: observations.into_boxed_slice(),
startup: std::sync::Mutex::new(None),
sdk_execution_id: None,
sdk_hook_runner: None,
sdk_pending: std::sync::Mutex::new(None),
})
}
pub fn sdk_execution_id(&self) -> Option<&str> {
self.sdk_execution_id.as_deref()
}
pub(crate) fn bind_sdk_append(
mut self,
execution_id: String,
pending: SdkPendingInput,
hook_runner: Option<Arc<HookRunner>>,
) -> Self {
self.startup = std::sync::Mutex::new(Some(StartupInputObservation {
session_id: pending.session_id.clone(),
execution_id: execution_id.clone(),
observation: Default::default(),
}));
self.sdk_execution_id = Some(execution_id);
self.sdk_hook_runner = hook_runner;
self.sdk_pending = std::sync::Mutex::new(Some(pending));
self
}
pub(crate) fn sdk_hook_runner(&self) -> Option<Arc<HookRunner>> {
self.sdk_hook_runner.clone()
}
pub(crate) async fn seal_sdk_input(
&self,
session: &mut bamboo_agent_core::Session,
persistence: Option<&Arc<dyn RuntimeSessionPersistence>>,
) -> Result<(), bamboo_agent_core::AgentError> {
let pending = self
.sdk_pending
.lock()
.ok()
.and_then(|mut slot| slot.take());
let Some(pending) = pending else {
return Ok(());
};
let execution_id = self.sdk_execution_id.as_deref().unwrap_or("");
let invalid = || {
bamboo_agent_core::AgentError::Tool("SDK input acceptance could not be verified".into())
};
if session.id != pending.session_id || execution_id.is_empty() {
return Err(invalid());
}
let mut matching = session
.messages
.iter()
.filter(|message| message.id == pending.input_id);
let message = matching.next().ok_or_else(invalid)?;
if matching.next().is_some()
|| message.role != bamboo_agent_core::Role::User
|| sdk_message_header_digest(message) != pending.header_digest
|| pending
.final_digest
.is_some_and(|digest| sdk_message_digest(message) != digest)
{
return Err(invalid());
}
let final_digest = sdk_message_digest(message);
let created_at = message.created_at;
session.validate_conversation_state().map_err(|error| {
bamboo_agent_core::AgentError::Tool(format!("session_conversation_changed: {error}"))
})?;
let requires_canonical_generation = session.conversation_reset.is_some();
let persistence = persistence.ok_or_else(|| {
if requires_canonical_generation {
bamboo_agent_core::AgentError::Tool(
"session_conversation_changed: canonical input persistence unavailable".into(),
)
} else {
invalid()
}
})?;
let original_generation = session.conversation_generation_observation();
let checkpoint = persistence.checkpoint_runtime_session(session).await;
original_generation
.check_canonical(session)
.map_err(|error| {
bamboo_agent_core::AgentError::Tool(format!(
"session_conversation_changed: {error}"
))
})?;
checkpoint.map_err(|error| {
bamboo_agent_core::AgentError::Tool(format!("SDK input checkpoint failed: {error}"))
})?;
let accepted = persistence
.load_runtime_session(&session.id)
.await
.map_err(|error| {
bamboo_agent_core::AgentError::Tool(format!(
"SDK input verification failed: {error}"
))
})?
.ok_or_else(|| {
if requires_canonical_generation {
bamboo_agent_core::AgentError::Tool(
"session_conversation_changed: canonical input readback unavailable".into(),
)
} else {
invalid()
}
})?;
original_generation
.check_canonical(&accepted)
.map_err(|error| {
bamboo_agent_core::AgentError::Tool(format!(
"session_conversation_changed: {error}"
))
})?;
let mut matching = accepted
.messages
.iter()
.filter(|message| message.id == pending.input_id);
let accepted_user = matching.next().ok_or_else(invalid)?;
if matching.next().is_some()
|| accepted_user.role != bamboo_agent_core::Role::User
|| sdk_message_digest(accepted_user) != final_digest
{
return Err(invalid());
}
let observation = self
.observations
.iter()
.find(|item| item.input_id() == pending.input_id)
.ok_or_else(invalid)?;
let source = bamboo_domain::SessionMessageSource::User;
let record = crate::runtime::runner::state_bridge::BorrowedInputRequestRecord {
input_id: &pending.input_id,
source: &source,
kind: bamboo_domain::SessionMessageKind::UserInput,
wrapper: None,
created_at,
request: observation.request(),
};
let projection = crate::runtime::runner::state_bridge::project_input_request_batch(
&session.id,
execution_id,
&[record],
);
let observation =
crate::runtime::managers::lifecycle::InputObservation::projected(projection);
*self.startup.lock().map_err(|_| invalid())? = Some(StartupInputObservation {
session_id: session.id.clone(),
execution_id: execution_id.to_owned(),
observation,
});
Ok(())
}
pub fn observations(&self) -> &[UntrustedInputObservation] {
&self.observations
}
pub async fn admit_with_startup_observation(
session: &mut bamboo_agent_core::Session,
storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
inbox: Option<&Arc<dyn bamboo_domain::SessionInboxPort>>,
) -> (crate::runtime::runner::TurnBoundaryRefresh, Option<Self>) {
let execution_id = crate::runtime::runner::round_prelude::new_execution_id();
let (refresh, observation) =
crate::runtime::runner::state_bridge::refresh_turn_boundary_with_observation(
session,
storage,
persistence,
inbox,
None,
&execution_id,
)
.await;
let carrier = observation
.new_inputs()
.and_then(|batch| {
Self::new(
batch
.records()
.iter()
.map(|record| {
Some(UntrustedInputObservation {
input_id: bamboo_domain::SessionMessageId::parse(
record.input_id.clone(),
)
.ok()?,
request: record.request.clone(),
})
})
.collect::<Option<Vec<_>>>()?,
)
})
.map(|mut carrier| {
carrier.startup = std::sync::Mutex::new(Some(StartupInputObservation {
session_id: session.id.clone(),
execution_id,
observation,
}));
carrier
});
(refresh, carrier)
}
pub(crate) fn take_startup_observation(
&self,
session_id: &str,
) -> Option<(
String,
crate::runtime::managers::lifecycle::InputObservation,
)> {
let startup = self.startup.lock().ok()?.take()?;
(startup.session_id == session_id
&& (startup.observation.is_unavailable()
|| startup.observation.matches_session(session_id)))
.then_some((startup.execution_id, startup.observation))
}
pub fn from_committed_messages(messages: &[bamboo_agent_core::Message]) -> Option<Self> {
if messages.len() > 128 {
return None;
}
let mut observations = Vec::with_capacity(messages.len());
for message in messages {
if message.role != bamboo_agent_core::Role::User {
continue;
}
let Some(proof) = message
.metadata
.as_ref()
.and_then(|metadata| metadata.get("session_message"))
else {
continue;
};
if proof.get("id")?.as_str()? != message.id {
return None;
}
let body = proof.get("body")?;
let request = if proof.get("source")?.get("type")?.as_str()? == "user"
&& proof.get("kind")?.as_str()? == "user_input"
&& body.get("type")?.as_str()? == "content"
{
body.get("skill_request")
} else if proof.get("source")?.get("type")?.as_str()? == "runtime"
&& proof.get("source")?.get("subsystem")?.as_str()? == "chat"
&& proof.get("kind")?.as_str()? == "runtime_instruction"
&& body.get("instruction")?.as_str()? == "root_chat_turn_v1"
{
body.get("content")?.get("skill_request")
} else {
continue;
};
let request = request
.filter(|value| !value.is_null())
.map(copy_checked_request)
.transpose()
.ok()?;
if message.id.is_empty() || message.id.len() > 256 {
return None;
}
let input_id = bamboo_domain::SessionMessageId::parse(message.id.clone()).ok()?;
observations.push(UntrustedInputObservation { input_id, request });
}
Self::new(observations)
}
}
fn copy_checked_request(
value: &serde_json::Value,
) -> Result<bamboo_domain::SessionSkillRequest, ()> {
let selections = value
.get("selections")
.and_then(serde_json::Value::as_array)
.ok_or(())?;
if selections.is_empty() || selections.len() > 32 {
return Err(());
}
let mode = match value.get("mode") {
None | Some(serde_json::Value::Null) => None,
Some(value) => {
let mode = value.as_str().ok_or(())?;
if mode.is_empty()
|| mode.len() > 64
|| !mode
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err(());
}
Some(mode)
}
};
for selection in selections {
bamboo_domain::SessionSkillSelection::validate_borrowed(
selection
.get("id")
.and_then(serde_json::Value::as_str)
.ok_or(())?,
selection
.get("source")
.and_then(serde_json::Value::as_str)
.ok_or(())?,
selection
.get("revision")
.and_then(serde_json::Value::as_u64)
.ok_or(())?,
selection.get("args").unwrap_or(&serde_json::Value::Null),
)
.map_err(|_| ())?;
}
let request = bamboo_domain::SessionSkillRequest {
selections: selections
.iter()
.map(|selection| bamboo_domain::SessionSkillSelection {
id: selection["id"].as_str().unwrap().to_owned(),
source: selection["source"].as_str().unwrap().to_owned(),
revision: selection["revision"].as_u64().unwrap(),
args: selection.get("args").cloned().unwrap_or_default(),
})
.collect(),
mode: mode.map(str::to_owned),
};
request.validate().map_err(|_| ())?;
Ok(request)
}
#[async_trait::async_trait]
pub trait LiveBootstrapHost: Send + Sync {
async fn register(
&self,
owner: bamboo_domain::RootActorRuntimeWrite,
proof: bamboo_domain::LiveBootstrapProof,
persistence: Arc<dyn RuntimeSessionPersistence>,
inbox: Arc<dyn bamboo_domain::SessionInboxPort>,
offer: bamboo_llm::retry::LiveBootstrapOffer,
) -> std::io::Result<Box<dyn Send>>;
}
#[non_exhaustive]
pub struct AgentLoopConfig {
pub freeze_tool_exposure_for_cache: bool,
pub(crate) ticket_worker_plan: Option<Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>>,
pub(crate) system_prompt: Option<String>,
pub(crate) disabled_skill_ids: BTreeSet<String>,
pub(crate) selected_skill_ids: Option<Vec<String>>,
pub(crate) selected_skill_mode: Option<String>,
pub(crate) initial_untrusted_inputs: Option<UntrustedExecutionInputs>,
pub(crate) sdk_skill_execution_host: Option<Arc<dyn SdkSkillExecutionHost>>,
pub(crate) workspace_execution_host: Option<Arc<dyn WorkspaceExecutionHost>>,
pub(crate) additional_tool_schemas: Vec<ToolSchema>,
pub(crate) tool_registry: Arc<ToolRegistry>,
pub(crate) skill_manager: Option<Arc<SkillManager>>,
pub(crate) project_context_resolver:
Option<Arc<crate::project_context::ProjectContextResolver>>,
pub(crate) skip_initial_user_message: bool,
pub(crate) storage: Option<Arc<dyn Storage>>,
pub(crate) persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
pub(crate) guidance_active_run_id: Option<String>,
pub(crate) session_inbox: Option<Arc<dyn bamboo_domain::SessionInboxPort>>,
pub(crate) live_bootstrap_host: Option<Arc<dyn LiveBootstrapHost>>,
pub(crate) gold_target_resolver: Option<GoldTargetResolver>,
pub(crate) host_gold_run: Option<Arc<crate::runtime::host_gold::HostGoldRun>>,
pub(crate) session_activation_notifications:
Option<Arc<parking_lot::Mutex<tokio::sync::watch::Receiver<u64>>>>,
pub(crate) attachment_reader: Option<Arc<dyn AttachmentReader>>,
pub(crate) metrics_collector: Option<MetricsCollector>,
pub(crate) model_name: Option<String>,
pub(crate) fast_model_name: Option<String>,
pub(crate) fast_model_provider: Option<Arc<dyn LLMProvider>>,
pub(crate) auxiliary_evaluation_max_concurrency: usize,
pub(crate) background_model_name: Option<String>,
pub(crate) planning_model_name: Option<String>,
pub(crate) search_model_name: Option<String>,
pub(crate) compression_instructions: Option<String>,
pub(crate) summary_target_ratio: f64,
pub(crate) context_management: bamboo_config::ContextManagementConfig,
pub(crate) summary_safe_window_percent: u8,
pub(crate) summarization_model_name: Option<String>,
pub(crate) background_model_provider: Option<Arc<dyn LLMProvider>>,
pub(crate) summarization_model_provider: Option<Arc<dyn LLMProvider>>,
pub(crate) provider_name: Option<String>,
pub(crate) provider_type: Option<String>,
pub(crate) reasoning_effort: Option<ReasoningEffort>,
pub(crate) app_data_dir: Option<PathBuf>,
pub(crate) memory_store: bamboo_memory::memory_store::MemoryStore,
pub(crate) disabled_tools: BTreeSet<String>,
pub(crate) token_budget: Option<TokenBudget>,
pub(crate) model_limits_section:
Option<Arc<bamboo_config::LiveSection<bamboo_config::ModelLimitsSection>>>,
pub(crate) legacy_model_limits: Option<serde_json::Value>,
pub(crate) image_fallback: Option<ImageFallbackConfig>,
pub(crate) vision_model_resolver: Option<super::runner::image_fallback::VisionModelResolver>,
pub(crate) legacy_vision_model_resolver:
Option<super::runner::image_fallback::LegacyVisionModelResolver>,
pub(crate) prompt_memory_flags: PromptMemoryFlags,
pub(crate) max_tool_calls_per_round: usize,
pub(crate) max_consecutive_failures_per_tool: usize,
pub(crate) per_tool_timeout_secs: u64,
pub(crate) parallel_batch_timeout_secs: u64,
pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
pub(crate) upstream_retry: bamboo_llm::retry::UpstreamRetry,
pub(crate) permission_mode: Option<PermissionMode>,
pub(crate) gold_config: Option<GoldConfig>,
pub(crate) guardian_config: Option<GuardianConfig>,
pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
pub(crate) hook_runner: Arc<HookRunner>,
pub(crate) features_dynamic_model_routing: bool,
pub(crate) features_final_evidence_check: bool,
pub(crate) auxiliary_model_resolver:
Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
pub(crate) background_model_resolver: Option<BackgroundModelResolver>,
pub(crate) resume_auxiliary_resolver: Option<ResumeAuxiliaryResolver>,
pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
pub(crate) mcp_tool_guidance: Option<String>,
pub(crate) run_budget: bamboo_config::RunBudgetConfig,
pub(crate) counted_loop: Option<super::counted_loop::CountedLoopConfig>,
pub(crate) loop_control: Option<super::loop_control::LoopControlConfig>,
}
impl AgentLoopConfig {
pub(crate) fn effective_counted_loop(&self) -> Option<&super::counted_loop::CountedLoopConfig> {
self.counted_loop
.as_ref()
.or_else(|| self.loop_control.as_ref().and_then(|c| c.active_counted()))
}
pub fn initial_untrusted_inputs(&self) -> Option<&UntrustedExecutionInputs> {
self.initial_untrusted_inputs.as_ref()
}
}
impl Default for AgentLoopConfig {
fn default() -> Self {
Self {
freeze_tool_exposure_for_cache: true,
ticket_worker_plan: None,
system_prompt: None,
disabled_skill_ids: BTreeSet::new(),
selected_skill_ids: None,
selected_skill_mode: None,
initial_untrusted_inputs: None,
sdk_skill_execution_host: None,
workspace_execution_host: None,
additional_tool_schemas: Vec::new(),
tool_registry: Arc::new(ToolRegistry::new()),
skill_manager: None,
project_context_resolver: None,
skip_initial_user_message: false,
storage: None,
persistence: None,
guidance_active_run_id: None,
session_inbox: None,
live_bootstrap_host: None,
gold_target_resolver: None,
host_gold_run: None,
session_activation_notifications: None,
attachment_reader: None,
metrics_collector: None,
model_name: None,
fast_model_name: None,
fast_model_provider: None,
auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
background_model_name: None,
planning_model_name: None,
search_model_name: None,
compression_instructions: None,
summary_target_ratio: 0.20,
context_management: bamboo_config::ContextManagementConfig::default(),
summary_safe_window_percent: 80,
summarization_model_name: None,
background_model_provider: None,
summarization_model_provider: None,
provider_name: None,
provider_type: None,
reasoning_effort: None,
app_data_dir: None,
memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
disabled_tools: BTreeSet::new(),
token_budget: None,
model_limits_section: None,
legacy_model_limits: None,
image_fallback: None,
vision_model_resolver: None,
legacy_vision_model_resolver: None,
prompt_memory_flags: PromptMemoryFlags::default(),
max_tool_calls_per_round: 80,
max_consecutive_failures_per_tool: 3,
per_tool_timeout_secs: 120,
parallel_batch_timeout_secs: 300,
stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
upstream_retry: Default::default(),
permission_mode: None,
gold_config: None,
guardian_config: None,
guardian_spawner: None,
bash_resume_hook: None,
bash_completion_sink: None,
approval_delegate: None,
hook_runner: Arc::new(HookRunner::new()),
features_dynamic_model_routing: false,
features_final_evidence_check: false,
auxiliary_model_resolver: None,
background_model_resolver: None,
resume_auxiliary_resolver: None,
disabled_filter_resolver: None,
mcp_tool_guidance: None,
run_budget: bamboo_config::RunBudgetConfig::default(),
counted_loop: None,
loop_control: None,
}
}
}
impl AgentLoopConfig {
pub(crate) fn resolve_auxiliary_models(&self) -> AuxiliaryModelConfig {
let mut models = self
.auxiliary_model_resolver
.as_ref()
.map(|resolver| resolver())
.unwrap_or_else(|| AuxiliaryModelConfig {
fast_model_name: self.fast_model_name.clone(),
fast_model_provider: self.fast_model_provider.clone(),
background_model_name: self.background_model_name.clone(),
planning_model_name: self.planning_model_name.clone(),
search_model_name: self.search_model_name.clone(),
summarization_model_name: self.summarization_model_name.clone(),
background_model_provider: self.background_model_provider.clone(),
summarization_model_provider: self.summarization_model_provider.clone(),
summarization_model_error: None,
});
if let Some(resolve) = &self.background_model_resolver {
let background = resolve(self.provider_name.as_deref().unwrap_or_default());
models.background_model_name =
background.as_ref().map(|model| model.model_name.clone());
models.background_model_provider = background.map(|model| model.provider);
}
if let Some(resolve) = &self.resume_auxiliary_resolver {
let (summary, background) = resolve();
models.background_model_name =
background.as_ref().map(|model| model.model_name.clone());
models.background_model_provider = background.map(|model| model.provider);
match summary {
Ok(summary) => {
models.summarization_model_name =
summary.as_ref().map(|model| model.model_name.clone());
models.summarization_model_provider = summary.map(|model| model.provider);
models.summarization_model_error = None;
}
Err(error) => {
models.summarization_model_name = None;
models.summarization_model_provider = None;
models.summarization_model_error = Some(error.to_string());
}
}
}
models
}
pub fn bind_ticket_worker_plan(
&mut self,
plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
) {
self.ticket_worker_plan = Some(plan);
}
pub(crate) fn resolve_disabled_filters(
&self,
) -> (
std::borrow::Cow<'_, BTreeSet<String>>,
std::borrow::Cow<'_, BTreeSet<String>>,
) {
match &self.disabled_filter_resolver {
Some(resolver) => {
let (tools, skills) = resolver();
(
std::borrow::Cow::Owned(tools),
std::borrow::Cow::Owned(skills),
)
}
None => (
std::borrow::Cow::Borrowed(&self.disabled_tools),
std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
),
}
}
pub fn active_goal(&self) -> Option<&str> {
self.gold_config
.as_ref()
.filter(|cfg| cfg.enabled)
.and_then(GoldConfig::effective_goal)
}
pub fn goal_loop_active(&self) -> bool {
self.gold_config.as_ref().is_some_and(|cfg| {
cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
})
}
pub fn guardian_active(&self) -> bool {
self.guardian_spawner.is_some()
&& self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
}
pub fn guardian_max_reviews(&self) -> u32 {
self.guardian_config
.as_ref()
.map_or(0, |cfg| cfg.max_reviews)
}
pub fn guardian_model(&self) -> Option<&str> {
self.guardian_config
.as_ref()
.and_then(|cfg| cfg.model_name.as_deref())
}
pub fn delegation_active(&self) -> bool {
self.approval_delegate.is_some()
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod execution_input_tests {
use super::*;
fn request() -> bamboo_domain::SessionSkillRequest {
bamboo_domain::SessionSkillRequest {
selections: vec![bamboo_domain::SessionSkillSelection {
id: "exact Case".into(),
source: "builtin".into(),
revision: 1,
args: serde_json::json!({"raw":"保持原样"}),
}],
mode: None,
}
}
#[test]
fn execution_input_owned_bounds_and_old_config_default() {
assert!(AgentLoopConfig::default()
.initial_untrusted_inputs()
.is_none());
let request = request();
let input = UntrustedInputObservation::new("exact-input", Some(&request)).unwrap();
assert_eq!(input.input_id(), "exact-input");
assert_eq!(input.request(), Some(&request));
for id in [
"",
" leading",
"trailing ",
"unsafe/id",
"unsafe\\id",
"unsafe..id",
] {
assert!(UntrustedInputObservation::new(id, None).is_none());
}
assert!(UntrustedInputObservation::new(&"a".repeat(257), None).is_none());
assert!(UntrustedExecutionInputs::new(Vec::new()).is_none());
let batch = UntrustedExecutionInputs::new(
(0..128)
.map(|i| {
UntrustedInputObservation::new(&format!("input-{i}"), Some(&request)).unwrap()
})
.collect(),
)
.unwrap();
assert_eq!(batch.observations().len(), 128);
assert_eq!(batch.observations()[127].input_id(), "input-127");
assert!(UntrustedExecutionInputs::new(
(0..129)
.map(|i| UntrustedInputObservation::new(&format!("input-{i}"), None).unwrap())
.collect()
)
.is_none());
assert!(UntrustedExecutionInputs::new(vec![
UntrustedInputObservation::new("same", None).unwrap(),
UntrustedInputObservation::new("same", None).unwrap()
])
.is_none());
let mut malformed = request;
malformed.selections[0].args = (0..65).fold(serde_json::Value::Null, |v, _| {
serde_json::Value::Array(vec![v])
});
assert!(UntrustedInputObservation::new("deep", Some(&malformed)).is_none());
}
#[test]
fn execution_input_only_explicit_checked_commit_slice_copies_request_fields() {
let request = request();
let mut user = bamboo_domain::SessionMessageEnvelope::user_input("target", "ordinary");
if let bamboo_domain::SessionMessageBody::Content(content) = &mut user.body {
content.skill_request = Some(request.clone());
}
let id = user.id.to_string();
let plain = user.to_provider_message().unwrap();
let root = user
.with_root_chat_prompt("system prompt is not retained".into())
.unwrap()
.to_provider_message()
.unwrap();
for message in [plain, root] {
let batch = UntrustedExecutionInputs::from_committed_messages(&[message]).unwrap();
assert_eq!(batch.observations().len(), 1);
assert_eq!(batch.observations()[0].input_id(), id);
assert_eq!(batch.observations()[0].request(), Some(&request));
}
let text = bamboo_agent_core::Message::user("{\"skill_request\":\"fragment\"}");
assert!(UntrustedExecutionInputs::from_committed_messages(&[text]).is_none());
assert!(UntrustedExecutionInputs::from_committed_messages(&[]).is_none());
}
}
#[cfg(test)]
mod sdk_acceptance_tests {
use super::*;
use crate::runtime::managers::lifecycle::BoundedInputRequestBatch;
use bamboo_agent_core::{Message, Role, Session};
use tokio_util::sync::CancellationToken;
struct Repository {
stored: std::sync::Mutex<Option<Session>>,
reject: bool,
corrupt: bool,
}
#[async_trait::async_trait]
impl RuntimeSessionPersistence for Repository {
async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
if self.reject {
return Err(std::io::Error::other("checkpoint refused"));
}
let mut accepted = session.clone();
if self.corrupt {
accepted
.messages
.iter_mut()
.find(|message| message.role == Role::User)
.unwrap()
.content
.push_str(" changed");
}
*self.stored.lock().unwrap() = Some(accepted);
Ok(())
}
async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
self.save_runtime_session(session).await
}
async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
Ok(self.stored.lock().unwrap().clone())
}
}
fn repository(reject: bool, corrupt: bool) -> Arc<dyn RuntimeSessionPersistence> {
Arc::new(Repository {
stored: Default::default(),
reject,
corrupt,
})
}
fn nonzero_conversation(session: &mut Session) {
session.conversation_reset = Some(bamboo_domain::ConversationResetState {
schema_version: 1,
conversation_revision: 1,
receipts: vec![bamboo_domain::ConversationResetReceipt {
operation_id: format!("0:{}", uuid::Uuid::new_v4()),
birth_token: session.root_mode_birth_token(),
expected_snapshot_sha256: "a".repeat(64),
previous_revision: 0,
conversation_revision: 1,
retained_system_messages: 0,
reset_at: session.updated_at,
}],
});
}
struct ConversationChangingRepository {
stored: std::sync::Mutex<Option<Session>>,
mutate_caller: bool,
}
#[async_trait::async_trait]
impl RuntimeSessionPersistence for ConversationChangingRepository {
async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
let mut accepted = session.clone();
if let Some(state) = accepted.conversation_reset.as_mut() {
state.receipts[0].expected_snapshot_sha256 = "b".repeat(64);
} else {
nonzero_conversation(&mut accepted);
}
if self.mutate_caller {
*session = accepted.clone();
}
*self.stored.lock().unwrap() = Some(accepted);
Ok(())
}
async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
self.save_runtime_session(session).await
}
async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
Ok(self.stored.lock().unwrap().clone())
}
}
#[async_trait::async_trait]
impl bamboo_domain::Storage for ConversationChangingRepository {
async fn save_session(&self, session: &Session) -> std::io::Result<()> {
*self.stored.lock().unwrap() = Some(session.clone());
Ok(())
}
async fn load_session(&self, _: &str) -> std::io::Result<Option<Session>> {
Ok(self.stored.lock().unwrap().clone())
}
async fn delete_session(&self, _: &str) -> std::io::Result<bool> {
Err(std::io::Error::other("fixture never deletes a Session"))
}
}
#[derive(Default)]
struct SealLoopProbe {
provider_calls: std::sync::atomic::AtomicUsize,
tool_calls: std::sync::atomic::AtomicUsize,
setup_calls: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl bamboo_llm::LLMProvider for SealLoopProbe {
async fn chat_stream(
&self,
_: &[Message],
_: &[bamboo_agent_core::tools::ToolSchema],
_: Option<u32>,
_: &str,
) -> bamboo_llm::provider::Result<bamboo_llm::LLMStream> {
self.provider_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(Box::pin(futures::stream::iter(vec![
Ok(bamboo_llm::LLMChunk::Token("synthetic complete".into())),
Ok(bamboo_llm::LLMChunk::Done),
])))
}
}
#[async_trait::async_trait]
impl bamboo_agent_core::tools::ToolExecutor for SealLoopProbe {
async fn execute(
&self,
_: &bamboo_agent_core::tools::ToolCall,
) -> Result<bamboo_agent_core::tools::ToolResult, bamboo_agent_core::tools::ToolError>
{
self.tool_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(bamboo_agent_core::tools::ToolError::Execution(
"no fixture tools".into(),
))
}
fn list_tools(&self) -> Vec<bamboo_agent_core::tools::ToolSchema> {
vec![]
}
}
#[async_trait::async_trait]
impl bamboo_agent_core::AgentHook for SealLoopProbe {
fn point(&self) -> bamboo_domain::AgentHookPoint {
bamboo_domain::AgentHookPoint::AfterSessionSetup
}
async fn run(
&self,
_: bamboo_domain::AgentHookPoint,
_: &bamboo_domain::HookPayload,
_: &Session,
) -> bamboo_domain::HookResult {
self.setup_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
bamboo_domain::HookResult::Continue
}
}
fn seal_loop_config(
inputs: UntrustedExecutionInputs,
persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
storage: Option<Arc<dyn bamboo_domain::Storage>>,
probe: &Arc<SealLoopProbe>,
) -> AgentLoopConfig {
let mut hooks = crate::runtime::HookRunner::new();
hooks.register(probe.clone());
AgentLoopConfig {
initial_untrusted_inputs: Some(inputs),
persistence,
storage,
skip_initial_user_message: true,
model_name: Some("synthetic-seal-model".into()),
hook_runner: Arc::new(hooks),
prompt_memory_flags: PromptMemoryFlags {
project_prompt_injection: false,
relevant_recall: false,
relevant_recall_rerank: false,
project_first_dream: false,
ledger_agenda: false,
},
..Default::default()
}
}
#[tokio::test]
async fn full_loop_stops_changed_or_invalid_seal_before_hooks_provider_and_complete() {
for mutate_caller in [false, true] {
for generation in ["advance", "same-revision", "invalid"] {
let mut session = Session::new("sdk-full-seal-refusal", "model");
if generation == "same-revision" {
nonzero_conversation(&mut session);
}
if generation == "invalid" {
session.conversation_reset = Some(Default::default());
}
let user = Message::user("synthetic current input");
let inputs = producer(&session, &user, true);
session.add_message(user);
let repository = Arc::new(ConversationChangingRepository {
stored: std::sync::Mutex::new(Some(session.clone())),
mutate_caller,
});
let probe = Arc::new(SealLoopProbe::default());
let config = seal_loop_config(
inputs,
Some(repository.clone()),
Some(repository.clone()),
&probe,
);
let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
let error = crate::runtime::runner::run_agent_loop_with_config(
&mut session,
"synthetic current input".into(),
tx,
probe.clone(),
probe.clone(),
CancellationToken::new(),
config,
)
.await
.unwrap_err();
assert!(
error.to_string().contains("session_conversation_changed:"),
"{generation}: {error}"
);
assert_eq!(
probe
.provider_calls
.load(std::sync::atomic::Ordering::SeqCst),
0
);
assert_eq!(
probe.tool_calls.load(std::sync::atomic::Ordering::SeqCst),
0
);
assert_eq!(
probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst),
0
);
while let Ok(event) = rx.try_recv() {
assert!(!matches!(
event,
bamboo_agent_core::AgentEvent::Complete { .. }
));
}
assert!(repository
.stored
.lock()
.unwrap()
.as_ref()
.unwrap()
.conversation_reset
.is_some());
}
}
}
#[tokio::test]
async fn full_loop_preserves_ordinary_optional_seal_failure_behavior() {
let mut session = Session::new("sdk-optional-seal", "model");
let user = Message::user("synthetic optional input");
let inputs = producer(&session, &user, true);
session.add_message(user);
let probe = Arc::new(SealLoopProbe::default());
let config = seal_loop_config(inputs, None, None, &probe);
let (tx, _rx) = tokio::sync::mpsc::channel(1024);
crate::runtime::runner::run_agent_loop_with_config(
&mut session,
"synthetic optional input".into(),
tx,
probe.clone(),
probe.clone(),
CancellationToken::new(),
config,
)
.await
.unwrap();
assert!(
probe
.provider_calls
.load(std::sync::atomic::Ordering::SeqCst)
> 0
);
assert!(probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst) > 0);
}
#[tokio::test]
async fn sdk_input_seal_freezes_generation_before_a_custom_checkpoint_and_readback() {
for mutate_caller in [false, true] {
for same_revision in [false, true] {
let mut session = Session::new("sdk-generation-seal", "model");
if same_revision {
nonzero_conversation(&mut session);
}
let user = Message::user("exact id and digest still cannot cross conversation");
let inputs = producer(&session, &user, true);
session.add_message(user);
let persistence: Arc<dyn RuntimeSessionPersistence> =
Arc::new(ConversationChangingRepository {
stored: Default::default(),
mutate_caller,
});
let error = inputs
.seal_sdk_input(&mut session, Some(&persistence))
.await
.unwrap_err();
assert!(error.to_string().contains("session_conversation_changed:"));
assert!(
current(&inputs, &session).is_none(),
"cannot publish New input from a different conversation"
);
}
}
}
fn producer(session: &Session, user: &Message, typed: bool) -> UntrustedExecutionInputs {
UntrustedExecutionInputs::new(vec![UntrustedInputObservation::new(&user.id, None).unwrap()])
.unwrap()
.bind_sdk_append(
"actual-sdk-execution".into(),
SdkPendingInput {
session_id: session.id.clone(),
input_id: user.id.clone(),
header_digest: sdk_message_header_digest(user),
final_digest: typed.then(|| sdk_message_digest(user)),
},
None,
)
}
fn current(
inputs: &UntrustedExecutionInputs,
session: &Session,
) -> Option<BoundedInputRequestBatch> {
let (execution_id, observation) = inputs.take_startup_observation(&session.id).unwrap();
assert_eq!(execution_id, "actual-sdk-execution");
let mut current = None;
observation.update_current(&mut current, &session.id, &execution_id);
assert!(inputs.take_startup_observation(&session.id).is_none());
current
}
#[tokio::test]
async fn sdk_startup_requires_exact_checkpoint_and_preserves_request_absence_as_new() {
let mut session = Session::new("sdk-accept", "model");
let user = Message::user("final prepared");
let inputs = producer(&session, &user, true);
session.add_message(user.clone());
inputs
.seal_sdk_input(&mut session, Some(&repository(false, false)))
.await
.unwrap();
let batch = current(&inputs, &session).unwrap();
assert_eq!(batch.records().len(), 1);
assert_eq!(batch.records()[0].input_id, user.id);
assert_eq!(
batch.records()[0].source,
bamboo_domain::SessionMessageSource::User
);
assert!(batch.records()[0].request.is_none());
}
#[tokio::test]
async fn sdk_startup_unknown_checkpoint_or_modified_final_user_stays_unavailable() {
for (reject, corrupt, mutate, duplicate, wrong_session) in [
(true, false, false, false, false),
(false, true, false, false, false),
(false, false, true, false, false),
(false, false, false, true, false),
(false, false, false, false, true),
] {
let mut session = Session::new("sdk-deny", "model");
let user = Message::user("prepared");
let inputs = producer(&session, &user, true);
session.add_message(user.clone());
if mutate {
session.messages[0].content.push_str(" changed");
}
if duplicate {
session.add_message(user);
}
if wrong_session {
session.id = "wrong".into();
}
assert!(inputs
.seal_sdk_input(&mut session, Some(&repository(reject, corrupt)))
.await
.is_err());
if wrong_session {
assert!(inputs.take_startup_observation(&session.id).is_none());
} else {
assert!(current(&inputs, &session).is_none());
}
}
}
#[tokio::test]
async fn sdk_legacy_hook_rewrite_is_bound_to_original_id_while_generic_history_is_unsealed() {
let mut session = Session::new("sdk-string", "model");
let user = Message::user("original");
let inputs = producer(&session, &user, false);
session.add_message(user.clone());
session.messages[0].content = "real portable hook result".into();
inputs
.seal_sdk_input(&mut session, Some(&repository(false, false)))
.await
.unwrap();
assert_eq!(
current(&inputs, &session).unwrap().records()[0].input_id,
user.id
);
let generic =
UntrustedExecutionInputs::new(vec![
UntrustedInputObservation::new(&user.id, None).unwrap()
])
.unwrap();
generic
.seal_sdk_input(&mut session, Some(&repository(false, false)))
.await
.unwrap();
assert!(generic.sdk_execution_id().is_none());
assert!(generic.take_startup_observation(&session.id).is_none());
}
#[test]
fn sdk_header_binding_accepts_only_the_canonical_prompt_changes() {
let mut original = Message::user("canonical");
original.content_parts = Some(vec![
bamboo_domain::MessagePart::Text {
text: "unrelated native text".into(),
},
bamboo_domain::MessagePart::Text {
text: "canonical".into(),
},
]);
let digest = sdk_message_header_digest(&original);
let mut prepared = original.clone();
prepared.content = "canonical plus F".into();
if let bamboo_domain::MessagePart::Text { text } =
&mut prepared.content_parts.as_mut().unwrap()[1]
{
*text = prepared.content.clone();
}
assert_eq!(sdk_message_header_digest(&prepared), digest);
prepared.id = "different".into();
assert_ne!(sdk_message_header_digest(&prepared), digest);
prepared = original;
if let bamboo_domain::MessagePart::Text { text } =
&mut prepared.content_parts.as_mut().unwrap()[0]
{
*text = "tampered".into();
}
assert_ne!(sdk_message_header_digest(&prepared), digest);
}
}