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