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