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