Skip to main content

bamboo_engine/runtime/
config.rs

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
22/// Default process-wide concurrency ceiling for low-priority auxiliary
23/// evaluations sharing one provider/model allocation.
24pub(crate) const DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY: usize = 2;
25/// Defensive ceiling for operator-provided auxiliary concurrency. This stays
26/// far below Tokio semaphore's implementation maximum and prevents a malformed
27/// flattened config value from panicking process startup.
28pub(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
37/// The live disabled tool and skill-id sets resolved at a round boundary.
38pub type DisabledFilterSets = (BTreeSet<String>, BTreeSet<String>);
39
40/// Late-bound resolver for disabled tool and skill-id filters.
41pub 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    /// Master switch for Gold observe-only evaluation.
73    #[serde(default)]
74    pub enabled: bool,
75    /// Independent switch for Phase 2 low-risk auto-answer.
76    ///
77    /// Kept separate from `enabled` so Phase 1 observe-only users do not
78    /// implicitly opt into automatic clarification responses.
79    #[serde(default)]
80    pub auto_answer_enabled: bool,
81    /// Independent switch for Phase 3 server-side auto-continue.
82    ///
83    /// Kept separate from both `enabled` and `auto_answer_enabled` so users can
84    /// opt into terminal auto-resume explicitly without enabling other Gold
85    /// automation behaviors.
86    #[serde(default)]
87    pub auto_continue_enabled: bool,
88    /// Optional dedicated model for Gold evaluation. Falls back to fast model,
89    /// then the main chat model when absent.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub model_name: Option<String>,
92    /// The user's goal for this session.
93    ///
94    /// Unlike `evaluation_prompt` (which only tunes the *judge*), the goal is
95    /// surfaced to the *main* executing agent as a persistent system-prompt
96    /// block so it actively works toward it. The Gold evaluator also measures
97    /// progress against this text.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub goal: Option<String>,
100    /// Optional custom prompt suffix appended to the built-in Gold evaluator
101    /// prompt. This tunes the judge only; it does not set the goal.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub evaluation_prompt: Option<String>,
104    /// Output token limit for the Gold evaluator call.
105    #[serde(default = "default_gold_max_output_tokens")]
106    pub max_output_tokens: u32,
107    /// Maximum number of automatic Gold continuations allowed per session.
108    #[serde(default = "default_gold_max_auto_continuations")]
109    pub max_auto_continuations: u32,
110    /// Minimum evaluator confidence required before Gold auto-continues or
111    /// auto-answers. Defaults to `medium` so the loop fires on reasonably
112    /// confident verdicts rather than only `high`.
113    #[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    /// The session goal text, falling back to the legacy `evaluation_prompt`
136    /// for sessions created before the dedicated `goal` field existed.
137    ///
138    /// Returns `None` when neither field holds non-empty text.
139    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/// Configuration for the guardian adversarial-review terminal gate.
153///
154/// Mirrors [`GoldConfig`]: a plain, serde-defaulting struct surfaced per run.
155/// When `enabled` is false (the default) the guardian gate is inactive and the
156/// terminal completion path is unchanged.
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(default)]
159pub struct GuardianConfig {
160    /// Master switch for the guardian review gate.
161    #[serde(default)]
162    pub enabled: bool,
163    /// Optional dedicated reviewer model. Falls back to the run's main model.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub model_name: Option<String>,
166    /// Maximum guardian review passes per run (budget; mirrors
167    /// [`GoldConfig::max_auto_continuations`]).
168    #[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/// Late-bound spawner for the guardian reviewer child.
183///
184/// The runner cannot construct a child directly: the `SpawnScheduler` is built
185/// *after* the `Agent` that drives the runner (a construction-order cycle), so
186/// the terminal gate spawns the reviewer through this trait object, injected
187/// per-request on [`AgentLoopConfig`] exactly like `auxiliary_model_resolver`.
188/// The implementation lives in the server (it captures the already-built
189/// scheduler + child-session adapter); the engine holds only the trait, keeping
190/// the engine free of any dependency on server/AppState types.
191#[async_trait::async_trait]
192pub trait GuardianSpawner: Send + Sync {
193    /// Create a read-only reviewer child for `parent_session_id`, seeded with
194    /// `review_prompt`, enqueue it to run, and return its session id so the
195    /// caller can register a wait on it.
196    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
205/// Hidden resume-message `runtime_kind` metadata value for a bash-completion
206/// self-resume (issue #84 Phase 2b). Shared by the producer (the self-resume
207/// task that appends the resume message) and the consumer (the suspend-
208/// finalization discriminant arm that preserves it), so a typo in one cannot
209/// desync from the other and silently drop the resume trigger.
210pub const BASH_COMPLETION_RESUME_KIND: &str = "bash_completion_resume";
211
212/// Re-exported so peers that already `use crate::runtime::config::{BashResumeHook, …}`
213/// (the runtime/spawn threading) can name the completion sink the same way,
214/// rather than reaching into `bamboo_agent_core` separately.
215pub use bamboo_agent_core::BashCompletionSink;
216
217/// Late-bound hook that arranges a self-resume for a session suspended waiting
218/// on background Bash shells (issue #84 Phase 2b). Injected per-request on
219/// [`AgentLoopConfig`] exactly like [`GuardianSpawner`]; the implementation
220/// lives in the session-app layer (on the completion coordinator) where the
221/// resume port ([`crate::session_app::resume::ResumeExecutionPort`]) is
222/// reachable.
223///
224/// The hook spawns a detached task that **polls the live background-shell
225/// registry** until every captured shell is no longer running, then clears the
226/// wait and resumes the session. Polling — not the one-shot `BashCompleted`
227/// event — is the liveness guarantee: even if a shell completes between the
228/// suspend snapshot and the hook's first poll, or before any event subscriber
229/// exists, the registry will report it as not-running and the session resumes.
230pub trait BashResumeHook: Send + Sync {
231    /// Arrange a detached self-resume for `session_id`, which has just been
232    /// durably suspended waiting on the background shells in `bash_ids`.
233    fn arrange_bash_self_resume(&self, session_id: String, bash_ids: Vec<String>);
234}
235
236/// A child sub-agent's request to have a gated tool approved by its parent.
237///
238/// A non-bypassed child cannot answer its own permission prompt (no human is
239/// attached to a child session), so the request is delegated up to the parent.
240#[derive(Debug, Clone)]
241pub struct ChildApprovalRequest {
242    pub child_session_id: String,
243    pub parent_session_id: String,
244    /// The gated tool call on the child to re-execute once approved.
245    pub child_tool_call_id: String,
246    pub tool_name: String,
247    /// Permission type as a string (e.g. "WriteFile", "ExecuteCommand").
248    pub permission_type: String,
249    /// The concrete resource the permission applies to (path, command, …).
250    pub resource: String,
251    /// Human-facing approval question to surface on the parent.
252    pub question: String,
253    /// The raw `awaiting_permission_approval` payload the child's executor built,
254    /// so the parent can reuse the existing grant-extraction path verbatim.
255    pub approval_payload: serde_json::Value,
256}
257
258/// What the executor should do after delegating a child's approval upward.
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum ChildApprovalOutcome {
261    /// Registered on the parent; the child must SUSPEND and await the decision.
262    Delegated,
263    /// Parent policy auto-approved (bypass / existing grant); proceed to execute.
264    AutoApproved,
265    /// Parent policy auto-denied; the executor must deny the tool.
266    AutoDenied,
267}
268
269/// Late-bound delegate that routes a child's approval request up to its parent.
270///
271/// Injected per-request on [`AgentLoopConfig`] exactly like [`GuardianSpawner`];
272/// the trait lives in the engine, the implementation in the server (it owns the
273/// parent session store + pending-question + notification machinery).
274#[async_trait::async_trait]
275pub trait ApprovalDelegate: Send + Sync {
276    /// Register `request` on its parent (or auto-resolve by policy) and report
277    /// what the child's executor should do next.
278    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    /// Use a vision-capable LLM to describe the image, then replace the image
290    /// with the textual description so that text-only models can understand
291    /// the content.
292    Vision,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct ImageFallbackConfig {
297    pub mode: ImageFallbackMode,
298    /// Explicit Vision model name for the low-level fallback API. Missing
299    /// names are a configuration error; never guess a model or use the main model.
300    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/// Execution-local adapter for a registered host. Input presence is data,
337/// never a Skill grant; each host owns and validates its actual caller policy.
338#[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    /// Adopt only a scope transition already committed by this registered Host.
348    /// Ordinary canonical drift is not authorization to renew a Source.
349    async fn refresh_runtime_scope(
350        &self,
351        _session: &mut bamboo_agent_core::Session,
352        _execution_id: &str,
353    ) -> Result<(), bamboo_agent_core::AgentError> {
354        Ok(())
355    }
356
357    async fn render_skill_prompt(
358        &self,
359        session: &bamboo_agent_core::Session,
360        execution_id: &str,
361    ) -> Result<String, bamboo_agent_core::AgentError>;
362
363    fn finish(&self, session_id: &str, execution_id: &str);
364}
365
366/// Compatibility name for the default SDK host.
367pub use SkillExecutionHost as SdkSkillExecutionHost;
368
369/// Execution-local Host bridge for an already committed Workspace transaction.
370/// It carries no Skill policy and cannot activate or replace an execution owner.
371#[async_trait::async_trait]
372pub trait WorkspaceExecutionHost: Send + Sync {
373    async fn refresh_runtime_scope(
374        &self,
375        session: &mut bamboo_agent_core::Session,
376        execution_id: &str,
377        persistence: &Arc<dyn bamboo_domain::RuntimeSessionPersistence>,
378    ) -> Result<(), bamboo_agent_core::AgentError>;
379
380    /// Close this execution's observation before a successor may be dispatched.
381    fn finish(&self);
382}
383
384/// One bounded, explicitly supplied input observation. This carries caller data,
385/// never proof of currentness, a Source grant, or permission to invoke a Skill.
386#[derive(Debug, PartialEq)]
387pub struct UntrustedInputObservation {
388    input_id: bamboo_domain::SessionMessageId,
389    request: Option<bamboo_domain::SessionSkillRequest>,
390}
391
392impl UntrustedInputObservation {
393    pub fn new(
394        input_id: &str,
395        request: Option<&bamboo_domain::SessionSkillRequest>,
396    ) -> Option<Self> {
397        // Bound before the owned ID/request copy. Use the existing canonical ID
398        // contract, including its path rules, without trimming or normalizing.
399        if input_id.is_empty() || input_id.len() > 256 {
400            return None;
401        }
402        let input_id = bamboo_domain::SessionMessageId::parse(input_id.to_owned()).ok()?;
403        if let Some(request) = request {
404            request.validate().ok()?;
405        }
406        Some(Self {
407            input_id,
408            request: request.cloned(),
409        })
410    }
411
412    pub fn input_id(&self) -> &str {
413        self.input_id.as_str()
414    }
415    pub fn request(&self) -> Option<&bamboo_domain::SessionSkillRequest> {
416        self.request.as_ref()
417    }
418}
419
420/// Execution-owned transport, with at most one existing Inbox admission batch
421/// (128 records), each retaining only an ID and an I-W-bounded request. There
422/// is no whole-batch Clone/Arc or body/config/Source snapshot. This is not Q-B's
423/// aggregate compact projection or its eventual current-input classification.
424pub struct UntrustedExecutionInputs {
425    observations: Box<[UntrustedInputObservation]>,
426    startup: std::sync::Mutex<Option<StartupInputObservation>>,
427    sdk_execution_id: Option<String>,
428    sdk_hook_runner: Option<Arc<HookRunner>>,
429    sdk_pending: std::sync::Mutex<Option<SdkPendingInput>>,
430}
431
432#[derive(Debug)]
433pub(crate) struct SdkPendingInput {
434    pub session_id: String,
435    pub input_id: String,
436    pub header_digest: [u8; 32],
437    pub final_digest: Option<[u8; 32]>,
438}
439
440impl std::fmt::Debug for UntrustedExecutionInputs {
441    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442        f.debug_struct("UntrustedExecutionInputs")
443            .field("observations", &self.observations)
444            .field("sdk_execution_id", &self.sdk_execution_id)
445            .finish_non_exhaustive()
446    }
447}
448
449/// Hash one original record, without retaining a body in the input carrier.
450pub(crate) fn sdk_message_digest(message: &bamboo_agent_core::Message) -> [u8; 32] {
451    use sha2::{Digest, Sha256};
452    let bytes = serde_json::to_vec(message).expect("Message serialization is infallible");
453    Sha256::digest(bytes).into()
454}
455
456/// F may alter content and the first native Text, while identity and all other
457/// original fields must remain bound to the SDK producer.
458pub(crate) fn sdk_message_header_digest(message: &bamboo_agent_core::Message) -> [u8; 32] {
459    let mut header = message.clone();
460    header.content.clear();
461    if let Some(parts) = header.content_parts.as_mut() {
462        if let Some(bamboo_domain::MessagePart::Text { text }) =
463            parts.iter_mut().find(|part| matches!(part, bamboo_domain::MessagePart::Text { text } if text == &message.content))
464        {
465            text.clear();
466        }
467    }
468    sdk_message_digest(&header)
469}
470
471#[derive(Debug)]
472struct StartupInputObservation {
473    session_id: String,
474    execution_id: String,
475    observation: crate::runtime::managers::lifecycle::InputObservation,
476}
477
478impl UntrustedExecutionInputs {
479    pub fn new(observations: Vec<UntrustedInputObservation>) -> Option<Self> {
480        if observations.is_empty() || observations.len() > 128 {
481            return None;
482        }
483        let mut ids = std::collections::HashSet::with_capacity(observations.len());
484        if observations.iter().any(|item| !ids.insert(item.input_id())) {
485            return None;
486        }
487        Some(Self {
488            observations: observations.into_boxed_slice(),
489            startup: std::sync::Mutex::new(None),
490            sdk_execution_id: None,
491            sdk_hook_runner: None,
492            sdk_pending: std::sync::Mutex::new(None),
493        })
494    }
495
496    /// Exact producer-reserved execution identity; generic carriers have none.
497    pub fn sdk_execution_id(&self) -> Option<&str> {
498        self.sdk_execution_id.as_deref()
499    }
500
501    pub(crate) fn bind_sdk_append(
502        mut self,
503        execution_id: String,
504        pending: SdkPendingInput,
505        hook_runner: Option<Arc<HookRunner>>,
506    ) -> Self {
507        // Reserve the real execution identity even when optional acceptance
508        // fails. Identity is not a positive input observation or Source grant.
509        self.startup = std::sync::Mutex::new(Some(StartupInputObservation {
510            session_id: pending.session_id.clone(),
511            execution_id: execution_id.clone(),
512            observation: Default::default(),
513        }));
514        self.sdk_execution_id = Some(execution_id);
515        self.sdk_hook_runner = hook_runner;
516        self.sdk_pending = std::sync::Mutex::new(Some(pending));
517        self
518    }
519
520    pub(crate) fn sdk_hook_runner(&self) -> Option<Arc<HookRunner>> {
521        self.sdk_hook_runner.clone()
522    }
523
524    /// Consume the private append receipt after the real startup hook, then
525    /// checkpoint and verify the exact final User before publishing New data.
526    pub(crate) async fn seal_sdk_input(
527        &self,
528        session: &mut bamboo_agent_core::Session,
529        persistence: Option<&Arc<dyn RuntimeSessionPersistence>>,
530    ) -> Result<(), bamboo_agent_core::AgentError> {
531        let pending = self
532            .sdk_pending
533            .lock()
534            .ok()
535            .and_then(|mut slot| slot.take());
536        let Some(pending) = pending else {
537            return Ok(());
538        };
539        let execution_id = self.sdk_execution_id.as_deref().unwrap_or("");
540        let invalid = || {
541            bamboo_agent_core::AgentError::Tool("SDK input acceptance could not be verified".into())
542        };
543        if session.id != pending.session_id || execution_id.is_empty() {
544            return Err(invalid());
545        }
546        let mut matching = session
547            .messages
548            .iter()
549            .filter(|message| message.id == pending.input_id);
550        let message = matching.next().ok_or_else(invalid)?;
551        if matching.next().is_some()
552            || message.role != bamboo_agent_core::Role::User
553            || sdk_message_header_digest(message) != pending.header_digest
554            || pending
555                .final_digest
556                .is_some_and(|digest| sdk_message_digest(message) != digest)
557        {
558            return Err(invalid());
559        }
560        let final_digest = sdk_message_digest(message);
561        let created_at = message.created_at;
562        session.validate_conversation_state().map_err(|error| {
563            bamboo_agent_core::AgentError::Tool(format!("session_conversation_changed: {error}"))
564        })?;
565        let requires_canonical_generation = session.conversation_reset.is_some();
566        let persistence = persistence.ok_or_else(|| {
567            if requires_canonical_generation {
568                bamboo_agent_core::AgentError::Tool(
569                    "session_conversation_changed: canonical input persistence unavailable".into(),
570                )
571            } else {
572                invalid()
573            }
574        })?;
575        let original_generation = session.conversation_generation_observation();
576        let checkpoint = persistence.checkpoint_runtime_session(session).await;
577        original_generation
578            .check_canonical(session)
579            .map_err(|error| {
580                bamboo_agent_core::AgentError::Tool(format!(
581                    "session_conversation_changed: {error}"
582                ))
583            })?;
584        checkpoint.map_err(|error| {
585            bamboo_agent_core::AgentError::Tool(format!("SDK input checkpoint failed: {error}"))
586        })?;
587        let accepted = persistence
588            .load_runtime_session(&session.id)
589            .await
590            .map_err(|error| {
591                bamboo_agent_core::AgentError::Tool(format!(
592                    "SDK input verification failed: {error}"
593                ))
594            })?
595            .ok_or_else(|| {
596                if requires_canonical_generation {
597                    bamboo_agent_core::AgentError::Tool(
598                        "session_conversation_changed: canonical input readback unavailable".into(),
599                    )
600                } else {
601                    invalid()
602                }
603            })?;
604        original_generation
605            .check_canonical(&accepted)
606            .map_err(|error| {
607                bamboo_agent_core::AgentError::Tool(format!(
608                    "session_conversation_changed: {error}"
609                ))
610            })?;
611        let mut matching = accepted
612            .messages
613            .iter()
614            .filter(|message| message.id == pending.input_id);
615        let accepted_user = matching.next().ok_or_else(invalid)?;
616        if matching.next().is_some()
617            || accepted_user.role != bamboo_agent_core::Role::User
618            || sdk_message_digest(accepted_user) != final_digest
619        {
620            return Err(invalid());
621        }
622        let observation = self
623            .observations
624            .iter()
625            .find(|item| item.input_id() == pending.input_id)
626            .ok_or_else(invalid)?;
627        let source = bamboo_domain::SessionMessageSource::User;
628        let record = crate::runtime::runner::state_bridge::BorrowedInputRequestRecord {
629            input_id: &pending.input_id,
630            source: &source,
631            kind: bamboo_domain::SessionMessageKind::UserInput,
632            wrapper: None,
633            created_at,
634            request: observation.request(),
635        };
636        let projection = crate::runtime::runner::state_bridge::project_input_request_batch(
637            &session.id,
638            execution_id,
639            &[record],
640        );
641        let observation =
642            crate::runtime::managers::lifecycle::InputObservation::projected(projection);
643        *self.startup.lock().map_err(|_| invalid())? = Some(StartupInputObservation {
644            session_id: session.id.clone(),
645            execution_id: execution_id.to_owned(),
646            observation,
647        });
648        Ok(())
649    }
650
651    pub fn observations(&self) -> &[UntrustedInputObservation] {
652        &self.observations
653    }
654
655    /// Additive checked boundary companion. Original public refresh layout stays
656    /// unchanged; the caller still performs its original error/persist checks.
657    /// An unavailable projection changes only this optional data handoff.
658    pub async fn admit_with_startup_observation(
659        session: &mut bamboo_agent_core::Session,
660        storage: Option<&Arc<dyn bamboo_agent_core::storage::Storage>>,
661        persistence: Option<&Arc<dyn bamboo_domain::RuntimeSessionPersistence>>,
662        inbox: Option<&Arc<dyn bamboo_domain::SessionInboxPort>>,
663    ) -> (crate::runtime::runner::TurnBoundaryRefresh, Option<Self>) {
664        let execution_id = crate::runtime::runner::round_prelude::new_execution_id();
665        let (refresh, observation) =
666            crate::runtime::runner::state_bridge::refresh_turn_boundary_with_observation(
667                session,
668                storage,
669                persistence,
670                inbox,
671                None,
672                &execution_id,
673            )
674            .await;
675        // Compatibility slice is created only after complete aggregate fit.
676        // It retains one extra initial IE view: bounded slots and args AST,
677        // whose compact payload fits the same cap (not a heap/RSS bound).
678        let carrier = observation
679            .new_inputs()
680            .and_then(|batch| {
681                Self::new(
682                    batch
683                        .records()
684                        .iter()
685                        .map(|record| {
686                            Some(UntrustedInputObservation {
687                                input_id: bamboo_domain::SessionMessageId::parse(
688                                    record.input_id.clone(),
689                                )
690                                .ok()?,
691                                request: record.request.clone(),
692                            })
693                        })
694                        .collect::<Option<Vec<_>>>()?,
695                )
696            })
697            .map(|mut carrier| {
698                carrier.startup = std::sync::Mutex::new(Some(StartupInputObservation {
699                    session_id: session.id.clone(),
700                    execution_id,
701                    observation,
702                }));
703                carrier
704            });
705        (refresh, carrier)
706    }
707
708    pub(crate) fn take_startup_observation(
709        &self,
710        session_id: &str,
711    ) -> Option<(
712        String,
713        crate::runtime::managers::lifecycle::InputObservation,
714    )> {
715        let startup = self.startup.lock().ok()?.take()?;
716        (startup.session_id == session_id
717            && (startup.observation.is_unavailable()
718                || startup.observation.matches_session(session_id)))
719        .then_some((startup.execution_id, startup.observation))
720    }
721
722    /// Caller supplies ONLY this call's newly checkpointed messages, after its
723    /// ACK/rejection checks. Never call this on stored Session history. The
724    /// existing admission producer already checked their envelope proof.
725    pub fn from_committed_messages(messages: &[bamboo_agent_core::Message]) -> Option<Self> {
726        if messages.len() > 128 {
727            return None;
728        }
729        let mut observations = Vec::with_capacity(messages.len());
730        for message in messages {
731            if message.role != bamboo_agent_core::Role::User {
732                continue;
733            }
734            let Some(proof) = message
735                .metadata
736                .as_ref()
737                .and_then(|metadata| metadata.get("session_message"))
738            else {
739                continue;
740            };
741            if proof.get("id")?.as_str()? != message.id {
742                return None;
743            }
744            let body = proof.get("body")?;
745            let request = if proof.get("source")?.get("type")?.as_str()? == "user"
746                && proof.get("kind")?.as_str()? == "user_input"
747                && body.get("type")?.as_str()? == "content"
748            {
749                body.get("skill_request")
750            } else if proof.get("source")?.get("type")?.as_str()? == "runtime"
751                && proof.get("source")?.get("subsystem")?.as_str()? == "chat"
752                && proof.get("kind")?.as_str()? == "runtime_instruction"
753                && body.get("instruction")?.as_str()? == "root_chat_turn_v1"
754            {
755                body.get("content")?.get("skill_request")
756            } else {
757                continue;
758            };
759            let request = request
760                .filter(|value| !value.is_null())
761                .map(copy_checked_request)
762                .transpose()
763                .ok()?;
764            if message.id.is_empty() || message.id.len() > 256 {
765                return None;
766            }
767            let input_id = bamboo_domain::SessionMessageId::parse(message.id.clone()).ok()?;
768            observations.push(UntrustedInputObservation { input_id, request });
769        }
770        Self::new(observations)
771    }
772}
773
774// Read only the exact bounded request, never deserialize/copy the Message,
775// images, body, proof, or publication metadata merely to transport caller data.
776fn copy_checked_request(
777    value: &serde_json::Value,
778) -> Result<bamboo_domain::SessionSkillRequest, ()> {
779    let selections = value
780        .get("selections")
781        .and_then(serde_json::Value::as_array)
782        .ok_or(())?;
783    if selections.is_empty() || selections.len() > 32 {
784        return Err(());
785    }
786    let mode = match value.get("mode") {
787        None | Some(serde_json::Value::Null) => None,
788        Some(value) => {
789            let mode = value.as_str().ok_or(())?;
790            if mode.is_empty()
791                || mode.len() > 64
792                || !mode
793                    .bytes()
794                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
795            {
796                return Err(());
797            }
798            Some(mode)
799        }
800    };
801    // Bound every original args tree before copying any selection. Existing
802    // I-W validation supplies nonrecursive depth/node/compact-byte checks.
803    for selection in selections {
804        bamboo_domain::SessionSkillSelection::validate_borrowed(
805            selection
806                .get("id")
807                .and_then(serde_json::Value::as_str)
808                .ok_or(())?,
809            selection
810                .get("source")
811                .and_then(serde_json::Value::as_str)
812                .ok_or(())?,
813            selection
814                .get("revision")
815                .and_then(serde_json::Value::as_u64)
816                .ok_or(())?,
817            selection.get("args").unwrap_or(&serde_json::Value::Null),
818        )
819        .map_err(|_| ())?;
820    }
821    let request = bamboo_domain::SessionSkillRequest {
822        selections: selections
823            .iter()
824            .map(|selection| bamboo_domain::SessionSkillSelection {
825                id: selection["id"].as_str().unwrap().to_owned(),
826                source: selection["source"].as_str().unwrap().to_owned(),
827                revision: selection["revision"].as_u64().unwrap(),
828                args: selection.get("args").cloned().unwrap_or_default(),
829            })
830            .collect(),
831        mode: mode.map(str::to_owned),
832    };
833    request.validate().map_err(|_| ())?;
834    Ok(request)
835}
836
837/// Configuration for the agent loop.
838///
839/// # One-config-per-run invariant (#44)
840///
841/// These values are SNAPSHOTTED once, from the live `Config` under a brief read
842/// lock, at the start of `AgentRuntime::execute()`. The entire multi-round run —
843/// which can last minutes — then uses this frozen snapshot. Changing config
844/// (model names, provider, `disabled_tools`/`disabled_skills`, memory flags,
845/// token budget, …) while a run is in flight does NOT affect that run; the new
846/// values are picked up on the NEXT execution (i.e. the next user turn / session
847/// restart), not the next round of the current run.
848///
849/// This is intentional: a run sees a stable configuration, so its behavior can't
850/// shift underneath it mid-execution. The deliberate exceptions are the
851/// late-bound handles that resolve LIVE each time they're
852/// used rather than being snapshotted — `auxiliary_model_resolver` (auxiliary
853/// model selection), `vision_model_resolver` (explicit ViewImage target),
854/// `model_limits_section` (validated model limits), and
855/// `guardian_spawner` (the reviewer child). If a frozen
856/// field ever needs to become live-per-round, follow that resolver pattern
857/// rather than widening the snapshot.
858#[non_exhaustive]
859pub struct AgentLoopConfig {
860    /// Keep tool guidance stable while activation updates append to model context.
861    pub freeze_tool_exposure_for_cache: bool,
862    /// Host-installed exact Assignment/Run permit; never taken from model args.
863    pub(crate) ticket_worker_plan: Option<Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>>,
864    pub(crate) system_prompt: Option<String>,
865    /// Skill IDs that are disabled globally for this execution.
866    pub(crate) disabled_skill_ids: BTreeSet<String>,
867    /// Optional explicit skill selection for this execution.
868    /// When set, only these skill IDs are considered for skill context and allowlists.
869    pub(crate) selected_skill_ids: Option<Vec<String>>,
870    /// Optional active skill mode for this execution.
871    ///
872    /// When set, skill discovery prefers `skills-<mode>` directories over generic
873    /// directories for the same skill id.
874    pub(crate) selected_skill_mode: Option<String>,
875    /// Untrusted inputs supplied by this startup call, never inferred.
876    pub(crate) initial_untrusted_inputs: Option<UntrustedExecutionInputs>,
877    pub(crate) sdk_skill_execution_host: Option<Arc<dyn SdkSkillExecutionHost>>,
878    pub(crate) workspace_execution_host: Option<Arc<dyn WorkspaceExecutionHost>>,
879    pub(crate) additional_tool_schemas: Vec<ToolSchema>,
880    pub(crate) tool_registry: Arc<ToolRegistry>,
881    pub(crate) skill_manager: Option<Arc<SkillManager>>,
882    /// Stable Project identity/resource resolver. The server wires the
883    /// authoritative registry adapter once on `AgentRuntimeBuilder`.
884    pub(crate) project_context_resolver:
885        Option<Arc<crate::project_context::ProjectContextResolver>>,
886    /// If true, skip appending the initial user message (already present in session).
887    pub(crate) skip_initial_user_message: bool,
888    /// Optional storage for persisting session changes
889    pub(crate) storage: Option<Arc<dyn Storage>>,
890    /// Optional runtime persistence for non-authoritative session saves.
891    /// When set, engine save sites use this instead of `storage` for writes.
892    pub(crate) persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
893    pub(crate) guidance_active_run_id: Option<String>,
894    /// Durable logical-session inbox admitted at safe round boundaries.
895    pub(crate) session_inbox: Option<Arc<dyn bamboo_domain::SessionInboxPort>>,
896    /// Active-owner wake generation. The loop consumes this at the same safe
897    /// boundary where it drains SessionInbox; it never interrupts an in-flight
898    /// provider/tool operation.
899    pub(crate) session_activation_notifications:
900        Option<Arc<parking_lot::Mutex<tokio::sync::watch::Receiver<u64>>>>,
901    /// Optional attachment reader for resolving `bamboo-attachment://...` references
902    /// into `data:` URLs for upstream providers. This must not mutate session storage.
903    pub(crate) attachment_reader: Option<Arc<dyn AttachmentReader>>,
904    /// Optional asynchronous metrics collector
905    pub(crate) metrics_collector: Option<MetricsCollector>,
906    /// Model name used for metrics attribution
907    pub(crate) model_name: Option<String>,
908    /// Fast/cheap model for lightweight tasks (task evaluation, search, etc.).
909    ///
910    /// Call sites may fall back to `model_name` when this is unset.
911    pub(crate) fast_model_name: Option<String>,
912    /// Optional provider override for lightweight fast-model LLM calls.
913    pub(crate) fast_model_provider: Option<Arc<dyn LLMProvider>>,
914    /// Process-wide concurrency ceiling for low-priority auxiliary evaluation
915    /// requests sharing the same provider allocation and model. Foreground
916    /// requests do not acquire this budget.
917    pub(crate) auxiliary_evaluation_max_concurrency: usize,
918    /// Fast/cheap model for memory/background tasks.
919    ///
920    /// Resolution follows the shared dedicated → fast → chat fallback chain.
921    pub(crate) background_model_name: Option<String>,
922
923    /// Model for planning/coordination tasks (task decomposition, architecture).
924    /// Falls back to `model_name` when unset.
925    pub(crate) planning_model_name: Option<String>,
926    /// Model for search/navigation tasks (grep, file listing, symbol resolution).
927    /// Falls back to `fast_model_name` when unset.
928    pub(crate) search_model_name: Option<String>,
929    /// Custom instructions for conversation summarization, injected into the
930    /// LLM summary prompt. Lets users control what the summary focuses on.
931    ///
932    /// Resolution order: session-level > config-level > built-in defaults.
933    pub(crate) compression_instructions: Option<String>,
934    /// Desired final summary size relative to the raw source tokens represented
935    /// by it. Values are normalized at the compression boundary.
936    pub(crate) summary_target_ratio: f64,
937    /// Frozen host context-management strategy for this run. Missing public
938    /// configuration resolves to the legacy summary behavior.
939    pub(crate) context_management: bamboo_config::ContextManagementConfig,
940    /// Safe request ceiling as a percentage of the selected summarization
941    /// model's context window.
942    pub(crate) summary_safe_window_percent: u8,
943    /// Dedicated model for summarization. Falls back to `background_model_name`.
944    pub(crate) summarization_model_name: Option<String>,
945    /// Optional provider override for memory/background model LLM calls.
946    ///
947    /// When set, memory recall rerank and other memory/background tasks use this
948    /// provider instead of the shared agent loop provider.
949    pub(crate) background_model_provider: Option<Arc<dyn LLMProvider>>,
950    /// Optional provider override for summarization / context compression calls.
951    ///
952    /// When set, conversation/task summarization uses this provider instead of
953    /// the shared agent loop provider.
954    pub(crate) summarization_model_provider: Option<Arc<dyn LLMProvider>>,
955    /// Provider routing key used for provider-specific request behavior.
956    ///
957    /// In multi-instance mode this may be the instance id.
958    pub(crate) provider_name: Option<String>,
959    /// Underlying provider type (for example `openai`, `anthropic`, `copilot`).
960    ///
961    /// This is distinct from `provider_name` so provider-specific behavior can
962    /// remain correct when routing keys are instance ids.
963    pub(crate) provider_type: Option<String>,
964    /// Optional request-time reasoning effort override.
965    pub(crate) reasoning_effort: Option<ReasoningEffort>,
966    /// Bamboo application data directory (typically `~/.bamboo`).
967    ///
968    /// Used by runtime features that persist auxiliary artifacts outside the
969    /// session store, such as durable plan mode files under `~/.bamboo/plan`.
970    pub(crate) app_data_dir: Option<PathBuf>,
971    /// Jiandu memory handle for this run. The default is the independent
972    /// `~/.jiandu` store; explicit construction is used by isolated tests.
973    pub(crate) memory_store: bamboo_memory::memory_store::MemoryStore,
974    /// Tool names that should be excluded from schemas sent to the LLM.
975    pub(crate) disabled_tools: BTreeSet<String>,
976    /// Token budget for context management (optional, defaults to model's limits)
977    pub(crate) token_budget: Option<TokenBudget>,
978    /// Host-owned model-limit section. The handle stays stable for the run;
979    /// each budget resolution reads its immutable last-known-good snapshot.
980    /// Standalone runtimes without this handle retain instance-local file loading.
981    pub(crate) model_limits_section:
982        Option<Arc<bamboo_config::LiveSection<bamboo_config::ModelLimitsSection>>>,
983    /// Legacy `config.json` `model_limits` value, snapshotted from the live
984    /// in-memory Config when this loop config is built. Consulted per model when
985    /// the host section or standalone `model_limits.json` has no matching entry
986    /// or cannot be loaded, so the engine never does a disk-reading `Config::new()`
987    /// (which would also clobber the global env-var cache). #38.
988    pub(crate) legacy_model_limits: Option<serde_json::Value>,
989    /// Optional image fallback behavior applied to *LLM requests only* (never persisted).
990    ///
991    /// This is intended for text-only provider paths where image parts must be degraded
992    /// (placeholder / OCR / error) without leaking into stored session history or UI.
993    pub(crate) image_fallback: Option<ImageFallbackConfig>,
994    /// Host-owned resolver only for ViewImage tool-result fallback. Direct
995    /// conversation uploads/history never invoke it during preparation: a
996    /// non-Vision main receives scoped references instead. Only a requested
997    /// ViewImage call resolves the target; it never changes the main model.
998    pub(crate) vision_model_resolver: Option<super::runner::image_fallback::VisionModelResolver>,
999    /// Feature flags controlling prompt-time memory injection behavior.
1000    pub(crate) prompt_memory_flags: PromptMemoryFlags,
1001    /// Maximum tool calls allowed per round (default: 80).
1002    pub(crate) max_tool_calls_per_round: usize,
1003    /// Maximum consecutive failures per tool before circuit breaker (default: 3).
1004    pub(crate) max_consecutive_failures_per_tool: usize,
1005    /// Per-tool execution timeout in seconds (default: 120).
1006    pub(crate) per_tool_timeout_secs: u64,
1007    /// Parallel batch execution timeout in seconds (default: 300).
1008    pub(crate) parallel_batch_timeout_secs: u64,
1009    /// Resolved LLM stream transport/semantic watchdog policy. The same value
1010    /// is passed to main response streams and auxiliary silent streams.
1011    pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
1012    /// Permission mode for this execution (default: None = use PermissionConfig's mode).
1013    pub(crate) permission_mode: Option<PermissionMode>,
1014    /// Optional Gold observe-only evaluator configuration.
1015    ///
1016    /// When `None` or `enabled == false`, Gold evaluation is disabled and the
1017    /// existing execute/respond/resume loop remains unchanged.
1018    pub(crate) gold_config: Option<GoldConfig>,
1019    /// Optional guardian adversarial-review gate configuration. When `None` or
1020    /// `enabled == false`, the guardian terminal gate is inactive.
1021    pub(crate) guardian_config: Option<GuardianConfig>,
1022    /// Late-bound spawner for the guardian reviewer child. `None` (the default)
1023    /// leaves the guardian gate inert even when `guardian_config.enabled` is set,
1024    /// since the runner cannot create a child without it. Wired by the server.
1025    pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
1026    /// Late-bound hook that arranges a self-resume for a session suspended
1027    /// waiting on background Bash shells (issue #84 Phase 2b). `None` (the
1028    /// default) leaves the bash suspend gate inert: the gate refuses to suspend
1029    /// without a wired hook, so a session can never strand itself without a
1030    /// resume path. Wired by the server (the completion coordinator impl).
1031    pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
1032    /// Late-bound sink that pushes a completed background Bash shell's result
1033    /// into this session's loop (issue #84 Phase 2b follow-up) — injected at the
1034    /// next round boundary while the loop is actively iterating, or delivered via
1035    /// resume when it is idle. Threaded onto the tool dispatch context (like
1036    /// `can_async_resume`) so the Bash tool can hand it to the shell's
1037    /// completion-poll task. `None` (the default) leaves the push inert; the
1038    /// durable end-of-turn suspend/poll backstop (`bash_resume_hook`) still runs.
1039    /// Wired by the server (the completion coordinator impl).
1040    pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
1041    /// Late-bound delegate that routes a child's gated-tool approval request up
1042    /// to its parent (Phase 2). `None` (the default) leaves child gating on its
1043    /// legacy path. Wired by the server.
1044    pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
1045    /// Frozen lifecycle-hook registry for this run. The default registry is
1046    /// empty, and every seam checks `has_hooks_for` before constructing payloads.
1047    pub(crate) hook_runner: Arc<HookRunner>,
1048    /// Enable dynamic per-round model routing based on task complexity.
1049    /// When true, the pipeline classifies complexity at each round end and
1050    /// stores the result in session metadata.
1051    pub(crate) features_dynamic_model_routing: bool,
1052    /// Opt-in, single auxiliary check before publishing a final answer.
1053    pub(crate) features_final_evidence_check: bool,
1054    /// Optional per-round resolver for auxiliary model settings that should
1055    /// follow live global config rather than stay frozen for the whole run.
1056    ///
1057    /// The main chat model remains session/request scoped; this hook is only
1058    /// for fast/background/planning/search/summarization helpers.
1059    pub(crate) auxiliary_model_resolver:
1060        Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
1061    /// Optional per-round resolver for the disabled tool/skill sets so they follow
1062    /// LIVE global config instead of staying frozen for the whole run. Returns the
1063    /// current `(disabled_tools, disabled_skill_ids)`. When `None`, the snapshotted
1064    /// `disabled_tools` / `disabled_skill_ids` fields below are used (#44 behavior).
1065    /// Re-resolved each round at the tool-schema filter, so disabling/re-enabling a
1066    /// tool mid-run takes effect on the next round. #136.
1067    pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
1068    /// Server-level usage guidance contributed by the run's tool executor —
1069    /// chiefly the `instructions` connected MCP servers return from `initialize`.
1070    /// Captured once at config construction (from `ToolExecutor::tool_guidance`)
1071    /// and appended to the tool-guide section of the system prompt, so a server's
1072    /// own how-to-use notes appear only while that server is loaded for the run.
1073    pub(crate) mcp_tool_guidance: Option<String>,
1074    /// Per-run resource guardrails (issue #221): already resolved — the
1075    /// per-request override merged over the config-level default (see
1076    /// [`AgentRuntime::execute`](crate::runtime::runtime::AgentRuntime::execute)).
1077    /// Checked after every round; exceeding a configured limit gracefully
1078    /// stops the run. Includes the run's round cap (`max_rounds`): `None`
1079    /// means unlimited rounds (the historical hard-coded 200 was removed);
1080    /// a configured cap keeps the issue #29 exhaustion contract (metadata
1081    /// stamp, visible notification, exactly one final summary turn).
1082    pub(crate) run_budget: bamboo_config::RunBudgetConfig,
1083    pub(crate) counted_loop: Option<super::counted_loop::CountedLoopConfig>,
1084    pub(crate) loop_control: Option<super::loop_control::LoopControlConfig>,
1085}
1086
1087impl AgentLoopConfig {
1088    /// A host-prestarted loop or the sole model-started generation in this run.
1089    pub(crate) fn effective_counted_loop(&self) -> Option<&super::counted_loop::CountedLoopConfig> {
1090        self.counted_loop
1091            .as_ref()
1092            .or_else(|| self.loop_control.as_ref().and_then(|c| c.active_counted()))
1093    }
1094
1095    /// Data-only execution transport. This accessor does not classify or grant.
1096    pub fn initial_untrusted_inputs(&self) -> Option<&UntrustedExecutionInputs> {
1097        self.initial_untrusted_inputs.as_ref()
1098    }
1099}
1100
1101impl Default for AgentLoopConfig {
1102    fn default() -> Self {
1103        Self {
1104            freeze_tool_exposure_for_cache: true,
1105            ticket_worker_plan: None,
1106            system_prompt: None,
1107            disabled_skill_ids: BTreeSet::new(),
1108            selected_skill_ids: None,
1109            selected_skill_mode: None,
1110            initial_untrusted_inputs: None,
1111            sdk_skill_execution_host: None,
1112            workspace_execution_host: None,
1113            additional_tool_schemas: Vec::new(),
1114            tool_registry: Arc::new(ToolRegistry::new()),
1115            skill_manager: None,
1116            project_context_resolver: None,
1117            skip_initial_user_message: false,
1118            storage: None,
1119            persistence: None,
1120            guidance_active_run_id: None,
1121            session_inbox: None,
1122            session_activation_notifications: None,
1123            attachment_reader: None,
1124            metrics_collector: None,
1125            model_name: None,
1126            fast_model_name: None,
1127            fast_model_provider: None,
1128            auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
1129            background_model_name: None,
1130            planning_model_name: None,
1131            search_model_name: None,
1132            compression_instructions: None,
1133            summary_target_ratio: 0.20,
1134            context_management: bamboo_config::ContextManagementConfig::default(),
1135            summary_safe_window_percent: 80,
1136            summarization_model_name: None,
1137            background_model_provider: None,
1138            summarization_model_provider: None,
1139            provider_name: None,
1140            provider_type: None,
1141            reasoning_effort: None,
1142            app_data_dir: None,
1143            memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
1144            disabled_tools: BTreeSet::new(),
1145            token_budget: None,
1146            model_limits_section: None,
1147            legacy_model_limits: None,
1148            image_fallback: None,
1149            vision_model_resolver: None,
1150            prompt_memory_flags: PromptMemoryFlags::default(),
1151            max_tool_calls_per_round: 80,
1152            max_consecutive_failures_per_tool: 3,
1153            per_tool_timeout_secs: 120,
1154            parallel_batch_timeout_secs: 300,
1155            stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
1156            permission_mode: None,
1157            gold_config: None,
1158            guardian_config: None,
1159            guardian_spawner: None,
1160            bash_resume_hook: None,
1161            bash_completion_sink: None,
1162            approval_delegate: None,
1163            hook_runner: Arc::new(HookRunner::new()),
1164            features_dynamic_model_routing: false,
1165            features_final_evidence_check: false,
1166            auxiliary_model_resolver: None,
1167            disabled_filter_resolver: None,
1168            mcp_tool_guidance: None,
1169            run_budget: bamboo_config::RunBudgetConfig::default(),
1170            counted_loop: None,
1171            loop_control: None,
1172        }
1173    }
1174}
1175
1176impl AgentLoopConfig {
1177    pub fn bind_ticket_worker_plan(
1178        &mut self,
1179        plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
1180    ) {
1181        self.ticket_worker_plan = Some(plan);
1182    }
1183    /// Live `(disabled_tools, disabled_skill_ids)` for the current round: the
1184    /// resolver if one is wired (#136 — follows live global config between
1185    /// rounds), else the per-run snapshot (#44 frozen behavior). `Cow` avoids
1186    /// cloning the snapshot in the common no-resolver path (SDK / tests).
1187    pub(crate) fn resolve_disabled_filters(
1188        &self,
1189    ) -> (
1190        std::borrow::Cow<'_, BTreeSet<String>>,
1191        std::borrow::Cow<'_, BTreeSet<String>>,
1192    ) {
1193        match &self.disabled_filter_resolver {
1194            Some(resolver) => {
1195                let (tools, skills) = resolver();
1196                (
1197                    std::borrow::Cow::Owned(tools),
1198                    std::borrow::Cow::Owned(skills),
1199                )
1200            }
1201            None => (
1202                std::borrow::Cow::Borrowed(&self.disabled_tools),
1203                std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
1204            ),
1205        }
1206    }
1207
1208    /// The active session goal to surface to the main agent, or `None` when
1209    /// Gold is disabled or no goal is set. Falls back to the legacy
1210    /// `evaluation_prompt` for back-compat via [`GoldConfig::effective_goal`].
1211    pub fn active_goal(&self) -> Option<&str> {
1212        self.gold_config
1213            .as_ref()
1214            .filter(|cfg| cfg.enabled)
1215            .and_then(GoldConfig::effective_goal)
1216    }
1217
1218    /// Whether the Codex-style autonomous goal loop is active for this run.
1219    ///
1220    /// This requires Gold to be enabled, a goal to be set, AND auto-continue to
1221    /// be on. Only then is the `update_goal` self-report tool surfaced to the
1222    /// model and the terminal double-check allowed to veto a premature stop.
1223    /// When Gold is enabled without auto-continue, the evaluator stays purely
1224    /// observational (legacy behavior).
1225    pub fn goal_loop_active(&self) -> bool {
1226        self.gold_config.as_ref().is_some_and(|cfg| {
1227            cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
1228        })
1229    }
1230
1231    /// Whether the guardian review gate is active for this run: a spawner is
1232    /// wired (so the runner can actually create the reviewer child) AND the
1233    /// config is present and enabled.
1234    pub fn guardian_active(&self) -> bool {
1235        self.guardian_spawner.is_some()
1236            && self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
1237    }
1238
1239    /// Maximum guardian review passes for this run (the budget). `0` when no
1240    /// guardian config is set.
1241    pub fn guardian_max_reviews(&self) -> u32 {
1242        self.guardian_config
1243            .as_ref()
1244            .map_or(0, |cfg| cfg.max_reviews)
1245    }
1246
1247    /// The reviewer model override, if a guardian config sets one.
1248    pub fn guardian_model(&self) -> Option<&str> {
1249        self.guardian_config
1250            .as_ref()
1251            .and_then(|cfg| cfg.model_name.as_deref())
1252    }
1253
1254    /// Whether child→parent approval delegation is wired for this run.
1255    pub fn delegation_active(&self) -> bool {
1256        self.approval_delegate.is_some()
1257    }
1258}
1259
1260#[cfg(test)]
1261mod tests;
1262
1263#[cfg(test)]
1264mod execution_input_tests {
1265    use super::*;
1266    fn request() -> bamboo_domain::SessionSkillRequest {
1267        bamboo_domain::SessionSkillRequest {
1268            selections: vec![bamboo_domain::SessionSkillSelection {
1269                id: "exact Case".into(),
1270                source: "builtin".into(),
1271                revision: 1,
1272                args: serde_json::json!({"raw":"保持原样"}),
1273            }],
1274            mode: None,
1275        }
1276    }
1277    #[test]
1278    fn execution_input_owned_bounds_and_old_config_default() {
1279        assert!(AgentLoopConfig::default()
1280            .initial_untrusted_inputs()
1281            .is_none());
1282        let request = request();
1283        let input = UntrustedInputObservation::new("exact-input", Some(&request)).unwrap();
1284        assert_eq!(input.input_id(), "exact-input");
1285        assert_eq!(input.request(), Some(&request));
1286        for id in [
1287            "",
1288            " leading",
1289            "trailing ",
1290            "unsafe/id",
1291            "unsafe\\id",
1292            "unsafe..id",
1293        ] {
1294            assert!(UntrustedInputObservation::new(id, None).is_none());
1295        }
1296        assert!(UntrustedInputObservation::new(&"a".repeat(257), None).is_none());
1297        assert!(UntrustedExecutionInputs::new(Vec::new()).is_none());
1298        let batch = UntrustedExecutionInputs::new(
1299            (0..128)
1300                .map(|i| {
1301                    UntrustedInputObservation::new(&format!("input-{i}"), Some(&request)).unwrap()
1302                })
1303                .collect(),
1304        )
1305        .unwrap();
1306        assert_eq!(batch.observations().len(), 128);
1307        assert_eq!(batch.observations()[127].input_id(), "input-127");
1308        assert!(UntrustedExecutionInputs::new(
1309            (0..129)
1310                .map(|i| UntrustedInputObservation::new(&format!("input-{i}"), None).unwrap())
1311                .collect()
1312        )
1313        .is_none());
1314        assert!(UntrustedExecutionInputs::new(vec![
1315            UntrustedInputObservation::new("same", None).unwrap(),
1316            UntrustedInputObservation::new("same", None).unwrap()
1317        ])
1318        .is_none());
1319        let mut malformed = request;
1320        malformed.selections[0].args = (0..65).fold(serde_json::Value::Null, |v, _| {
1321            serde_json::Value::Array(vec![v])
1322        });
1323        assert!(UntrustedInputObservation::new("deep", Some(&malformed)).is_none());
1324    }
1325    #[test]
1326    fn execution_input_only_explicit_checked_commit_slice_copies_request_fields() {
1327        let request = request();
1328        let mut user = bamboo_domain::SessionMessageEnvelope::user_input("target", "ordinary");
1329        if let bamboo_domain::SessionMessageBody::Content(content) = &mut user.body {
1330            content.skill_request = Some(request.clone());
1331        }
1332        let id = user.id.to_string();
1333        let plain = user.to_provider_message().unwrap();
1334        let root = user
1335            .with_root_chat_prompt("system prompt is not retained".into())
1336            .unwrap()
1337            .to_provider_message()
1338            .unwrap();
1339        for message in [plain, root] {
1340            let batch = UntrustedExecutionInputs::from_committed_messages(&[message]).unwrap();
1341            assert_eq!(batch.observations().len(), 1);
1342            assert_eq!(batch.observations()[0].input_id(), id);
1343            assert_eq!(batch.observations()[0].request(), Some(&request));
1344        }
1345        let text = bamboo_agent_core::Message::user("{\"skill_request\":\"fragment\"}");
1346        assert!(UntrustedExecutionInputs::from_committed_messages(&[text]).is_none());
1347        assert!(UntrustedExecutionInputs::from_committed_messages(&[]).is_none());
1348    }
1349}
1350
1351#[cfg(test)]
1352mod sdk_acceptance_tests {
1353    use super::*;
1354    use crate::runtime::managers::lifecycle::BoundedInputRequestBatch;
1355    use bamboo_agent_core::{Message, Role, Session};
1356    use tokio_util::sync::CancellationToken;
1357
1358    struct Repository {
1359        stored: std::sync::Mutex<Option<Session>>,
1360        reject: bool,
1361        corrupt: bool,
1362    }
1363    #[async_trait::async_trait]
1364    impl RuntimeSessionPersistence for Repository {
1365        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1366            if self.reject {
1367                return Err(std::io::Error::other("checkpoint refused"));
1368            }
1369            let mut accepted = session.clone();
1370            if self.corrupt {
1371                accepted
1372                    .messages
1373                    .iter_mut()
1374                    .find(|message| message.role == Role::User)
1375                    .unwrap()
1376                    .content
1377                    .push_str(" changed");
1378            }
1379            *self.stored.lock().unwrap() = Some(accepted);
1380            Ok(())
1381        }
1382        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1383            self.save_runtime_session(session).await
1384        }
1385        async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1386            Ok(self.stored.lock().unwrap().clone())
1387        }
1388    }
1389    fn repository(reject: bool, corrupt: bool) -> Arc<dyn RuntimeSessionPersistence> {
1390        Arc::new(Repository {
1391            stored: Default::default(),
1392            reject,
1393            corrupt,
1394        })
1395    }
1396
1397    fn nonzero_conversation(session: &mut Session) {
1398        session.conversation_reset = Some(bamboo_domain::ConversationResetState {
1399            schema_version: 1,
1400            conversation_revision: 1,
1401            receipts: vec![bamboo_domain::ConversationResetReceipt {
1402                operation_id: format!("0:{}", uuid::Uuid::new_v4()),
1403                birth_token: session.root_mode_birth_token(),
1404                expected_snapshot_sha256: "a".repeat(64),
1405                previous_revision: 0,
1406                conversation_revision: 1,
1407                retained_system_messages: 0,
1408                reset_at: session.updated_at,
1409            }],
1410        });
1411    }
1412
1413    struct ConversationChangingRepository {
1414        stored: std::sync::Mutex<Option<Session>>,
1415        mutate_caller: bool,
1416    }
1417    #[async_trait::async_trait]
1418    impl RuntimeSessionPersistence for ConversationChangingRepository {
1419        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1420            let mut accepted = session.clone();
1421            if let Some(state) = accepted.conversation_reset.as_mut() {
1422                state.receipts[0].expected_snapshot_sha256 = "b".repeat(64);
1423            } else {
1424                nonzero_conversation(&mut accepted);
1425            }
1426            if self.mutate_caller {
1427                *session = accepted.clone();
1428            }
1429            *self.stored.lock().unwrap() = Some(accepted);
1430            Ok(())
1431        }
1432        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1433            self.save_runtime_session(session).await
1434        }
1435        async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1436            Ok(self.stored.lock().unwrap().clone())
1437        }
1438    }
1439
1440    #[async_trait::async_trait]
1441    impl bamboo_domain::Storage for ConversationChangingRepository {
1442        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
1443            *self.stored.lock().unwrap() = Some(session.clone());
1444            Ok(())
1445        }
1446        async fn load_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1447            Ok(self.stored.lock().unwrap().clone())
1448        }
1449        async fn delete_session(&self, _: &str) -> std::io::Result<bool> {
1450            Err(std::io::Error::other("fixture never deletes a Session"))
1451        }
1452    }
1453
1454    #[derive(Default)]
1455    struct SealLoopProbe {
1456        provider_calls: std::sync::atomic::AtomicUsize,
1457        tool_calls: std::sync::atomic::AtomicUsize,
1458        setup_calls: std::sync::atomic::AtomicUsize,
1459    }
1460    #[async_trait::async_trait]
1461    impl bamboo_llm::LLMProvider for SealLoopProbe {
1462        async fn chat_stream(
1463            &self,
1464            _: &[Message],
1465            _: &[bamboo_agent_core::tools::ToolSchema],
1466            _: Option<u32>,
1467            _: &str,
1468        ) -> bamboo_llm::provider::Result<bamboo_llm::LLMStream> {
1469            self.provider_calls
1470                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1471            Ok(Box::pin(futures::stream::iter(vec![
1472                Ok(bamboo_llm::LLMChunk::Token("synthetic complete".into())),
1473                Ok(bamboo_llm::LLMChunk::Done),
1474            ])))
1475        }
1476    }
1477    #[async_trait::async_trait]
1478    impl bamboo_agent_core::tools::ToolExecutor for SealLoopProbe {
1479        async fn execute(
1480            &self,
1481            _: &bamboo_agent_core::tools::ToolCall,
1482        ) -> Result<bamboo_agent_core::tools::ToolResult, bamboo_agent_core::tools::ToolError>
1483        {
1484            self.tool_calls
1485                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1486            Err(bamboo_agent_core::tools::ToolError::Execution(
1487                "no fixture tools".into(),
1488            ))
1489        }
1490        fn list_tools(&self) -> Vec<bamboo_agent_core::tools::ToolSchema> {
1491            vec![]
1492        }
1493    }
1494    #[async_trait::async_trait]
1495    impl bamboo_agent_core::AgentHook for SealLoopProbe {
1496        fn point(&self) -> bamboo_domain::AgentHookPoint {
1497            bamboo_domain::AgentHookPoint::AfterSessionSetup
1498        }
1499        async fn run(
1500            &self,
1501            _: bamboo_domain::AgentHookPoint,
1502            _: &bamboo_domain::HookPayload,
1503            _: &Session,
1504        ) -> bamboo_domain::HookResult {
1505            self.setup_calls
1506                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1507            bamboo_domain::HookResult::Continue
1508        }
1509    }
1510
1511    fn seal_loop_config(
1512        inputs: UntrustedExecutionInputs,
1513        persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
1514        storage: Option<Arc<dyn bamboo_domain::Storage>>,
1515        probe: &Arc<SealLoopProbe>,
1516    ) -> AgentLoopConfig {
1517        let mut hooks = crate::runtime::HookRunner::new();
1518        hooks.register(probe.clone());
1519        AgentLoopConfig {
1520            initial_untrusted_inputs: Some(inputs),
1521            persistence,
1522            storage,
1523            skip_initial_user_message: true,
1524            model_name: Some("synthetic-seal-model".into()),
1525            hook_runner: Arc::new(hooks),
1526            prompt_memory_flags: PromptMemoryFlags {
1527                project_prompt_injection: false,
1528                relevant_recall: false,
1529                relevant_recall_rerank: false,
1530                project_first_dream: false,
1531                ledger_agenda: false,
1532            },
1533            ..Default::default()
1534        }
1535    }
1536
1537    #[tokio::test]
1538    async fn full_loop_stops_changed_or_invalid_seal_before_hooks_provider_and_complete() {
1539        for mutate_caller in [false, true] {
1540            for generation in ["advance", "same-revision", "invalid"] {
1541                let mut session = Session::new("sdk-full-seal-refusal", "model");
1542                if generation == "same-revision" {
1543                    nonzero_conversation(&mut session);
1544                }
1545                if generation == "invalid" {
1546                    session.conversation_reset = Some(Default::default());
1547                }
1548                let user = Message::user("synthetic current input");
1549                let inputs = producer(&session, &user, true);
1550                session.add_message(user);
1551                let repository = Arc::new(ConversationChangingRepository {
1552                    stored: std::sync::Mutex::new(Some(session.clone())),
1553                    mutate_caller,
1554                });
1555                let probe = Arc::new(SealLoopProbe::default());
1556                let config = seal_loop_config(
1557                    inputs,
1558                    Some(repository.clone()),
1559                    Some(repository.clone()),
1560                    &probe,
1561                );
1562                let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
1563                let error = crate::runtime::runner::run_agent_loop_with_config(
1564                    &mut session,
1565                    "synthetic current input".into(),
1566                    tx,
1567                    probe.clone(),
1568                    probe.clone(),
1569                    CancellationToken::new(),
1570                    config,
1571                )
1572                .await
1573                .unwrap_err();
1574                assert!(
1575                    error.to_string().contains("session_conversation_changed:"),
1576                    "{generation}: {error}"
1577                );
1578                assert_eq!(
1579                    probe
1580                        .provider_calls
1581                        .load(std::sync::atomic::Ordering::SeqCst),
1582                    0
1583                );
1584                assert_eq!(
1585                    probe.tool_calls.load(std::sync::atomic::Ordering::SeqCst),
1586                    0
1587                );
1588                assert_eq!(
1589                    probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst),
1590                    0
1591                );
1592                while let Ok(event) = rx.try_recv() {
1593                    assert!(!matches!(
1594                        event,
1595                        bamboo_agent_core::AgentEvent::Complete { .. }
1596                    ));
1597                }
1598                // The fake Host's accepted data is preserved, not rolled back
1599                // or relabelled not-executed by the input-observation consumer.
1600                assert!(repository
1601                    .stored
1602                    .lock()
1603                    .unwrap()
1604                    .as_ref()
1605                    .unwrap()
1606                    .conversation_reset
1607                    .is_some());
1608            }
1609        }
1610    }
1611
1612    #[tokio::test]
1613    async fn full_loop_preserves_ordinary_optional_seal_failure_behavior() {
1614        let mut session = Session::new("sdk-optional-seal", "model");
1615        let user = Message::user("synthetic optional input");
1616        let inputs = producer(&session, &user, true);
1617        session.add_message(user);
1618        let probe = Arc::new(SealLoopProbe::default());
1619        let config = seal_loop_config(inputs, None, None, &probe);
1620        let (tx, _rx) = tokio::sync::mpsc::channel(1024);
1621        crate::runtime::runner::run_agent_loop_with_config(
1622            &mut session,
1623            "synthetic optional input".into(),
1624            tx,
1625            probe.clone(),
1626            probe.clone(),
1627            CancellationToken::new(),
1628            config,
1629        )
1630        .await
1631        .unwrap();
1632        assert!(
1633            probe
1634                .provider_calls
1635                .load(std::sync::atomic::Ordering::SeqCst)
1636                > 0
1637        );
1638        assert!(probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst) > 0);
1639    }
1640
1641    #[tokio::test]
1642    async fn sdk_input_seal_freezes_generation_before_a_custom_checkpoint_and_readback() {
1643        for mutate_caller in [false, true] {
1644            for same_revision in [false, true] {
1645                let mut session = Session::new("sdk-generation-seal", "model");
1646                if same_revision {
1647                    nonzero_conversation(&mut session);
1648                }
1649                let user = Message::user("exact id and digest still cannot cross conversation");
1650                let inputs = producer(&session, &user, true);
1651                session.add_message(user);
1652                let persistence: Arc<dyn RuntimeSessionPersistence> =
1653                    Arc::new(ConversationChangingRepository {
1654                        stored: Default::default(),
1655                        mutate_caller,
1656                    });
1657                let error = inputs
1658                    .seal_sdk_input(&mut session, Some(&persistence))
1659                    .await
1660                    .unwrap_err();
1661                assert!(error.to_string().contains("session_conversation_changed:"));
1662                assert!(
1663                    current(&inputs, &session).is_none(),
1664                    "cannot publish New input from a different conversation"
1665                );
1666            }
1667        }
1668    }
1669    fn producer(session: &Session, user: &Message, typed: bool) -> UntrustedExecutionInputs {
1670        UntrustedExecutionInputs::new(vec![UntrustedInputObservation::new(&user.id, None).unwrap()])
1671            .unwrap()
1672            .bind_sdk_append(
1673                "actual-sdk-execution".into(),
1674                SdkPendingInput {
1675                    session_id: session.id.clone(),
1676                    input_id: user.id.clone(),
1677                    header_digest: sdk_message_header_digest(user),
1678                    final_digest: typed.then(|| sdk_message_digest(user)),
1679                },
1680                None,
1681            )
1682    }
1683    fn current(
1684        inputs: &UntrustedExecutionInputs,
1685        session: &Session,
1686    ) -> Option<BoundedInputRequestBatch> {
1687        let (execution_id, observation) = inputs.take_startup_observation(&session.id).unwrap();
1688        assert_eq!(execution_id, "actual-sdk-execution");
1689        let mut current = None;
1690        observation.update_current(&mut current, &session.id, &execution_id);
1691        assert!(inputs.take_startup_observation(&session.id).is_none());
1692        current
1693    }
1694    #[tokio::test]
1695    async fn sdk_startup_requires_exact_checkpoint_and_preserves_request_absence_as_new() {
1696        let mut session = Session::new("sdk-accept", "model");
1697        let user = Message::user("final prepared");
1698        let inputs = producer(&session, &user, true);
1699        session.add_message(user.clone());
1700        inputs
1701            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1702            .await
1703            .unwrap();
1704        let batch = current(&inputs, &session).unwrap();
1705        assert_eq!(batch.records().len(), 1);
1706        assert_eq!(batch.records()[0].input_id, user.id);
1707        assert_eq!(
1708            batch.records()[0].source,
1709            bamboo_domain::SessionMessageSource::User
1710        );
1711        assert!(batch.records()[0].request.is_none());
1712    }
1713    #[tokio::test]
1714    async fn sdk_startup_unknown_checkpoint_or_modified_final_user_stays_unavailable() {
1715        for (reject, corrupt, mutate, duplicate, wrong_session) in [
1716            (true, false, false, false, false),
1717            (false, true, false, false, false),
1718            (false, false, true, false, false),
1719            (false, false, false, true, false),
1720            (false, false, false, false, true),
1721        ] {
1722            let mut session = Session::new("sdk-deny", "model");
1723            let user = Message::user("prepared");
1724            let inputs = producer(&session, &user, true);
1725            session.add_message(user.clone());
1726            if mutate {
1727                session.messages[0].content.push_str(" changed");
1728            }
1729            if duplicate {
1730                session.add_message(user);
1731            }
1732            if wrong_session {
1733                session.id = "wrong".into();
1734            }
1735            assert!(inputs
1736                .seal_sdk_input(&mut session, Some(&repository(reject, corrupt)))
1737                .await
1738                .is_err());
1739            if wrong_session {
1740                assert!(inputs.take_startup_observation(&session.id).is_none());
1741            } else {
1742                assert!(current(&inputs, &session).is_none());
1743            }
1744        }
1745    }
1746    #[tokio::test]
1747    async fn sdk_legacy_hook_rewrite_is_bound_to_original_id_while_generic_history_is_unsealed() {
1748        let mut session = Session::new("sdk-string", "model");
1749        let user = Message::user("original");
1750        let inputs = producer(&session, &user, false);
1751        session.add_message(user.clone());
1752        session.messages[0].content = "real portable hook result".into();
1753        inputs
1754            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1755            .await
1756            .unwrap();
1757        assert_eq!(
1758            current(&inputs, &session).unwrap().records()[0].input_id,
1759            user.id
1760        );
1761        let generic =
1762            UntrustedExecutionInputs::new(vec![
1763                UntrustedInputObservation::new(&user.id, None).unwrap()
1764            ])
1765            .unwrap();
1766        generic
1767            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1768            .await
1769            .unwrap();
1770        assert!(generic.sdk_execution_id().is_none());
1771        assert!(generic.take_startup_observation(&session.id).is_none());
1772    }
1773    #[test]
1774    fn sdk_header_binding_accepts_only_the_canonical_prompt_changes() {
1775        let mut original = Message::user("canonical");
1776        original.content_parts = Some(vec![
1777            bamboo_domain::MessagePart::Text {
1778                text: "unrelated native text".into(),
1779            },
1780            bamboo_domain::MessagePart::Text {
1781                text: "canonical".into(),
1782            },
1783        ]);
1784        let digest = sdk_message_header_digest(&original);
1785        let mut prepared = original.clone();
1786        prepared.content = "canonical plus F".into();
1787        if let bamboo_domain::MessagePart::Text { text } =
1788            &mut prepared.content_parts.as_mut().unwrap()[1]
1789        {
1790            *text = prepared.content.clone();
1791        }
1792        assert_eq!(sdk_message_header_digest(&prepared), digest);
1793        prepared.id = "different".into();
1794        assert_ne!(sdk_message_header_digest(&prepared), digest);
1795        prepared = original;
1796        if let bamboo_domain::MessagePart::Text { text } =
1797            &mut prepared.content_parts.as_mut().unwrap()[0]
1798        {
1799            *text = "tampered".into();
1800        }
1801        assert_ne!(sdk_message_header_digest(&prepared), digest);
1802    }
1803}