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    /// Descriptions already prepared for this round's retained images.
1063    ///
1064    /// Request-scoped only: it never persists, never crosses rounds, and is
1065    /// cleared whenever provider-relevant source state changes. A final-request
1066    /// checkpoint that changes nothing the provider is asked must not pay for the
1067    /// same nondeterministic Legacy Vision description again.
1068    pub(crate) vision_description_reuse:
1069        std::sync::Arc<super::runner::image_fallback::VisionDescriptionReuse>,
1070    /// Feature flags controlling prompt-time memory injection behavior.
1071    pub(crate) prompt_memory_flags: PromptMemoryFlags,
1072    /// Maximum tool calls allowed per round (default: 80).
1073    pub(crate) max_tool_calls_per_round: usize,
1074    /// Maximum consecutive failures per tool before circuit breaker (default: 3).
1075    pub(crate) max_consecutive_failures_per_tool: usize,
1076    /// Per-tool execution timeout in seconds (default: 120).
1077    pub(crate) per_tool_timeout_secs: u64,
1078    /// Parallel batch execution timeout in seconds (default: 300).
1079    pub(crate) parallel_batch_timeout_secs: u64,
1080    /// Resolved LLM stream transport/semantic watchdog policy. The same value
1081    /// is passed to main response streams and auxiliary silent streams.
1082    pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
1083    /// Main bootstrap only; never inherited by auxiliary or child requests.
1084    pub(crate) upstream_retry: bamboo_llm::retry::UpstreamRetry,
1085    /// Permission mode for this execution (default: None = use PermissionConfig's mode).
1086    pub(crate) permission_mode: Option<PermissionMode>,
1087    /// Optional Gold observe-only evaluator configuration.
1088    ///
1089    /// When `None` or `enabled == false`, Gold evaluation is disabled and the
1090    /// existing execute/respond/resume loop remains unchanged.
1091    pub(crate) gold_config: Option<GoldConfig>,
1092    /// Optional guardian adversarial-review gate configuration. When `None` or
1093    /// `enabled == false`, the guardian terminal gate is inactive.
1094    pub(crate) guardian_config: Option<GuardianConfig>,
1095    /// Late-bound spawner for the guardian reviewer child. `None` (the default)
1096    /// leaves the guardian gate inert even when `guardian_config.enabled` is set,
1097    /// since the runner cannot create a child without it. Wired by the server.
1098    pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
1099    /// Late-bound hook that arranges a self-resume for a session suspended
1100    /// waiting on background Bash shells (issue #84 Phase 2b). `None` (the
1101    /// default) leaves the bash suspend gate inert: the gate refuses to suspend
1102    /// without a wired hook, so a session can never strand itself without a
1103    /// resume path. Wired by the server (the completion coordinator impl).
1104    pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
1105    /// Late-bound sink that pushes a completed background Bash shell's result
1106    /// into this session's loop (issue #84 Phase 2b follow-up) — injected at the
1107    /// next round boundary while the loop is actively iterating, or delivered via
1108    /// resume when it is idle. Threaded onto the tool dispatch context (like
1109    /// `can_async_resume`) so the Bash tool can hand it to the shell's
1110    /// completion-poll task. `None` (the default) leaves the push inert; the
1111    /// durable end-of-turn suspend/poll backstop (`bash_resume_hook`) still runs.
1112    /// Wired by the server (the completion coordinator impl).
1113    pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
1114    /// Late-bound delegate that routes a child's gated-tool approval request up
1115    /// to its parent (Phase 2). `None` (the default) leaves child gating on its
1116    /// legacy path. Wired by the server.
1117    pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
1118    /// Frozen lifecycle-hook registry for this run. The default registry is
1119    /// empty, and every seam checks `has_hooks_for` before constructing payloads.
1120    pub(crate) hook_runner: Arc<HookRunner>,
1121    /// Enable dynamic per-round model routing based on task complexity.
1122    /// When true, the pipeline classifies complexity at each round end and
1123    /// stores the result in session metadata.
1124    pub(crate) features_dynamic_model_routing: bool,
1125    /// Opt-in, single auxiliary check before publishing a final answer.
1126    pub(crate) features_final_evidence_check: bool,
1127    /// Optional per-round resolver for auxiliary model settings that should
1128    /// follow live global config rather than stay frozen for the whole run.
1129    ///
1130    /// The main chat model remains session/request scoped; this hook is only
1131    /// for fast/background/planning/search/summarization helpers.
1132    pub(crate) auxiliary_model_resolver:
1133        Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
1134    /// Override only the background pair without changing other resolver semantics.
1135    pub(crate) background_model_resolver: Option<BackgroundModelResolver>,
1136    /// Resolve resume summary and background using the same live snapshot.
1137    pub(crate) resume_auxiliary_resolver: Option<ResumeAuxiliaryResolver>,
1138    /// Optional per-round resolver for the disabled tool/skill sets so they follow
1139    /// LIVE global config instead of staying frozen for the whole run. Returns the
1140    /// current `(disabled_tools, disabled_skill_ids)`. When `None`, the snapshotted
1141    /// `disabled_tools` / `disabled_skill_ids` fields below are used (#44 behavior).
1142    /// Re-resolved each round at the tool-schema filter, so disabling/re-enabling a
1143    /// tool mid-run takes effect on the next round. #136.
1144    pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
1145    /// Server-level usage guidance contributed by the run's tool executor —
1146    /// chiefly the `instructions` connected MCP servers return from `initialize`.
1147    /// Captured once at config construction (from `ToolExecutor::tool_guidance`)
1148    /// and appended to the tool-guide section of the system prompt, so a server's
1149    /// own how-to-use notes appear only while that server is loaded for the run.
1150    pub(crate) mcp_tool_guidance: Option<String>,
1151    /// Per-run resource guardrails (issue #221): already resolved — the
1152    /// per-request override merged over the config-level default (see
1153    /// [`AgentRuntime::execute`](crate::runtime::runtime::AgentRuntime::execute)).
1154    /// Checked after every round; exceeding a configured limit gracefully
1155    /// stops the run. Includes the run's round cap (`max_rounds`): `None`
1156    /// means unlimited rounds (the historical hard-coded 200 was removed);
1157    /// a configured cap keeps the issue #29 exhaustion contract (metadata
1158    /// stamp, visible notification, exactly one final summary turn).
1159    pub(crate) run_budget: bamboo_config::RunBudgetConfig,
1160    pub(crate) counted_loop: Option<super::counted_loop::CountedLoopConfig>,
1161    pub(crate) loop_control: Option<super::loop_control::LoopControlConfig>,
1162}
1163
1164impl AgentLoopConfig {
1165    /// A host-prestarted loop or the sole model-started generation in this run.
1166    pub(crate) fn effective_counted_loop(&self) -> Option<&super::counted_loop::CountedLoopConfig> {
1167        self.counted_loop
1168            .as_ref()
1169            .or_else(|| self.loop_control.as_ref().and_then(|c| c.active_counted()))
1170    }
1171
1172    /// Data-only execution transport. This accessor does not classify or grant.
1173    pub fn initial_untrusted_inputs(&self) -> Option<&UntrustedExecutionInputs> {
1174        self.initial_untrusted_inputs.as_ref()
1175    }
1176}
1177
1178impl Default for AgentLoopConfig {
1179    fn default() -> Self {
1180        Self {
1181            freeze_tool_exposure_for_cache: true,
1182            ticket_worker_plan: None,
1183            system_prompt: None,
1184            disabled_skill_ids: BTreeSet::new(),
1185            selected_skill_ids: None,
1186            selected_skill_mode: None,
1187            initial_untrusted_inputs: None,
1188            sdk_skill_execution_host: None,
1189            workspace_execution_host: None,
1190            additional_tool_schemas: Vec::new(),
1191            tool_registry: Arc::new(ToolRegistry::new()),
1192            skill_manager: None,
1193            project_context_resolver: None,
1194            skip_initial_user_message: false,
1195            storage: None,
1196            persistence: None,
1197            guidance_active_run_id: None,
1198            session_inbox: None,
1199            live_bootstrap_host: None,
1200            gold_target_resolver: None,
1201            host_gold_run: None,
1202            session_activation_notifications: None,
1203            attachment_reader: None,
1204            metrics_collector: None,
1205            model_name: None,
1206            fast_model_name: None,
1207            fast_model_provider: None,
1208            auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
1209            background_model_name: None,
1210            planning_model_name: None,
1211            search_model_name: None,
1212            compression_instructions: None,
1213            summary_target_ratio: 0.20,
1214            context_management: bamboo_config::ContextManagementConfig::default(),
1215            summary_safe_window_percent: 80,
1216            summarization_model_name: None,
1217            background_model_provider: None,
1218            summarization_model_provider: None,
1219            provider_name: None,
1220            provider_type: None,
1221            reasoning_effort: None,
1222            app_data_dir: None,
1223            memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
1224            disabled_tools: BTreeSet::new(),
1225            token_budget: None,
1226            model_limits_section: None,
1227            legacy_model_limits: None,
1228            image_fallback: None,
1229            vision_model_resolver: None,
1230            legacy_vision_model_resolver: None,
1231            vision_description_reuse: Default::default(),
1232            prompt_memory_flags: PromptMemoryFlags::default(),
1233            max_tool_calls_per_round: 80,
1234            max_consecutive_failures_per_tool: 3,
1235            per_tool_timeout_secs: 120,
1236            parallel_batch_timeout_secs: 300,
1237            stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
1238            upstream_retry: Default::default(),
1239            permission_mode: None,
1240            gold_config: None,
1241            guardian_config: None,
1242            guardian_spawner: None,
1243            bash_resume_hook: None,
1244            bash_completion_sink: None,
1245            approval_delegate: None,
1246            hook_runner: Arc::new(HookRunner::new()),
1247            features_dynamic_model_routing: false,
1248            features_final_evidence_check: false,
1249            auxiliary_model_resolver: None,
1250            background_model_resolver: None,
1251            resume_auxiliary_resolver: None,
1252            disabled_filter_resolver: None,
1253            mcp_tool_guidance: None,
1254            run_budget: bamboo_config::RunBudgetConfig::default(),
1255            counted_loop: None,
1256            loop_control: None,
1257        }
1258    }
1259}
1260
1261impl AgentLoopConfig {
1262    /// Resolve one coherent global auxiliary-model snapshot. A live resolver's
1263    /// empty result is authoritative; only callers without one use the startup
1264    /// fields (SDK and standalone-loop compatibility).
1265    pub(crate) fn resolve_auxiliary_models(&self) -> AuxiliaryModelConfig {
1266        let mut models = self
1267            .auxiliary_model_resolver
1268            .as_ref()
1269            .map(|resolver| resolver())
1270            .unwrap_or_else(|| AuxiliaryModelConfig {
1271                fast_model_name: self.fast_model_name.clone(),
1272                fast_model_provider: self.fast_model_provider.clone(),
1273                background_model_name: self.background_model_name.clone(),
1274                planning_model_name: self.planning_model_name.clone(),
1275                search_model_name: self.search_model_name.clone(),
1276                summarization_model_name: self.summarization_model_name.clone(),
1277                background_model_provider: self.background_model_provider.clone(),
1278                summarization_model_provider: self.summarization_model_provider.clone(),
1279                summarization_model_error: None,
1280            });
1281        if let Some(resolve) = &self.background_model_resolver {
1282            let background = resolve(self.provider_name.as_deref().unwrap_or_default());
1283            models.background_model_name =
1284                background.as_ref().map(|model| model.model_name.clone());
1285            models.background_model_provider = background.map(|model| model.provider);
1286        }
1287        if let Some(resolve) = &self.resume_auxiliary_resolver {
1288            let (summary, background) = resolve();
1289            models.background_model_name =
1290                background.as_ref().map(|model| model.model_name.clone());
1291            models.background_model_provider = background.map(|model| model.provider);
1292            match summary {
1293                Ok(summary) => {
1294                    models.summarization_model_name =
1295                        summary.as_ref().map(|model| model.model_name.clone());
1296                    models.summarization_model_provider = summary.map(|model| model.provider);
1297                    models.summarization_model_error = None;
1298                }
1299                Err(error) => {
1300                    models.summarization_model_name = None;
1301                    models.summarization_model_provider = None;
1302                    models.summarization_model_error = Some(error.to_string());
1303                }
1304            }
1305        }
1306        models
1307    }
1308
1309    pub fn bind_ticket_worker_plan(
1310        &mut self,
1311        plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
1312    ) {
1313        self.ticket_worker_plan = Some(plan);
1314    }
1315    /// Live `(disabled_tools, disabled_skill_ids)` for the current round: the
1316    /// resolver if one is wired (#136 — follows live global config between
1317    /// rounds), else the per-run snapshot (#44 frozen behavior). `Cow` avoids
1318    /// cloning the snapshot in the common no-resolver path (SDK / tests).
1319    pub(crate) fn resolve_disabled_filters(
1320        &self,
1321    ) -> (
1322        std::borrow::Cow<'_, BTreeSet<String>>,
1323        std::borrow::Cow<'_, BTreeSet<String>>,
1324    ) {
1325        match &self.disabled_filter_resolver {
1326            Some(resolver) => {
1327                let (tools, skills) = resolver();
1328                (
1329                    std::borrow::Cow::Owned(tools),
1330                    std::borrow::Cow::Owned(skills),
1331                )
1332            }
1333            None => (
1334                std::borrow::Cow::Borrowed(&self.disabled_tools),
1335                std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
1336            ),
1337        }
1338    }
1339
1340    /// The active session goal to surface to the main agent, or `None` when
1341    /// Gold is disabled or no goal is set. Falls back to the legacy
1342    /// `evaluation_prompt` for back-compat via [`GoldConfig::effective_goal`].
1343    pub fn active_goal(&self) -> Option<&str> {
1344        self.gold_config
1345            .as_ref()
1346            .filter(|cfg| cfg.enabled)
1347            .and_then(GoldConfig::effective_goal)
1348    }
1349
1350    /// Whether the Codex-style autonomous goal loop is active for this run.
1351    ///
1352    /// This requires Gold to be enabled, a goal to be set, AND auto-continue to
1353    /// be on. Only then is the `update_goal` self-report tool surfaced to the
1354    /// model and the terminal double-check allowed to veto a premature stop.
1355    /// When Gold is enabled without auto-continue, the evaluator stays purely
1356    /// observational (legacy behavior).
1357    pub fn goal_loop_active(&self) -> bool {
1358        self.gold_config.as_ref().is_some_and(|cfg| {
1359            cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
1360        })
1361    }
1362
1363    /// Whether the guardian review gate is active for this run: a spawner is
1364    /// wired (so the runner can actually create the reviewer child) AND the
1365    /// config is present and enabled.
1366    pub fn guardian_active(&self) -> bool {
1367        self.guardian_spawner.is_some()
1368            && self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
1369    }
1370
1371    /// Maximum guardian review passes for this run (the budget). `0` when no
1372    /// guardian config is set.
1373    pub fn guardian_max_reviews(&self) -> u32 {
1374        self.guardian_config
1375            .as_ref()
1376            .map_or(0, |cfg| cfg.max_reviews)
1377    }
1378
1379    /// The reviewer model override, if a guardian config sets one.
1380    pub fn guardian_model(&self) -> Option<&str> {
1381        self.guardian_config
1382            .as_ref()
1383            .and_then(|cfg| cfg.model_name.as_deref())
1384    }
1385
1386    /// Whether child→parent approval delegation is wired for this run.
1387    pub fn delegation_active(&self) -> bool {
1388        self.approval_delegate.is_some()
1389    }
1390}
1391
1392#[cfg(test)]
1393mod tests;
1394
1395#[cfg(test)]
1396mod execution_input_tests {
1397    use super::*;
1398    fn request() -> bamboo_domain::SessionSkillRequest {
1399        bamboo_domain::SessionSkillRequest {
1400            selections: vec![bamboo_domain::SessionSkillSelection {
1401                id: "exact Case".into(),
1402                source: "builtin".into(),
1403                revision: 1,
1404                args: serde_json::json!({"raw":"保持原样"}),
1405            }],
1406            mode: None,
1407        }
1408    }
1409    #[test]
1410    fn execution_input_owned_bounds_and_old_config_default() {
1411        assert!(AgentLoopConfig::default()
1412            .initial_untrusted_inputs()
1413            .is_none());
1414        let request = request();
1415        let input = UntrustedInputObservation::new("exact-input", Some(&request)).unwrap();
1416        assert_eq!(input.input_id(), "exact-input");
1417        assert_eq!(input.request(), Some(&request));
1418        for id in [
1419            "",
1420            " leading",
1421            "trailing ",
1422            "unsafe/id",
1423            "unsafe\\id",
1424            "unsafe..id",
1425        ] {
1426            assert!(UntrustedInputObservation::new(id, None).is_none());
1427        }
1428        assert!(UntrustedInputObservation::new(&"a".repeat(257), None).is_none());
1429        assert!(UntrustedExecutionInputs::new(Vec::new()).is_none());
1430        let batch = UntrustedExecutionInputs::new(
1431            (0..128)
1432                .map(|i| {
1433                    UntrustedInputObservation::new(&format!("input-{i}"), Some(&request)).unwrap()
1434                })
1435                .collect(),
1436        )
1437        .unwrap();
1438        assert_eq!(batch.observations().len(), 128);
1439        assert_eq!(batch.observations()[127].input_id(), "input-127");
1440        assert!(UntrustedExecutionInputs::new(
1441            (0..129)
1442                .map(|i| UntrustedInputObservation::new(&format!("input-{i}"), None).unwrap())
1443                .collect()
1444        )
1445        .is_none());
1446        assert!(UntrustedExecutionInputs::new(vec![
1447            UntrustedInputObservation::new("same", None).unwrap(),
1448            UntrustedInputObservation::new("same", None).unwrap()
1449        ])
1450        .is_none());
1451        let mut malformed = request;
1452        malformed.selections[0].args = (0..65).fold(serde_json::Value::Null, |v, _| {
1453            serde_json::Value::Array(vec![v])
1454        });
1455        assert!(UntrustedInputObservation::new("deep", Some(&malformed)).is_none());
1456    }
1457    #[test]
1458    fn execution_input_only_explicit_checked_commit_slice_copies_request_fields() {
1459        let request = request();
1460        let mut user = bamboo_domain::SessionMessageEnvelope::user_input("target", "ordinary");
1461        if let bamboo_domain::SessionMessageBody::Content(content) = &mut user.body {
1462            content.skill_request = Some(request.clone());
1463        }
1464        let id = user.id.to_string();
1465        let plain = user.to_provider_message().unwrap();
1466        let root = user
1467            .with_root_chat_prompt("system prompt is not retained".into())
1468            .unwrap()
1469            .to_provider_message()
1470            .unwrap();
1471        for message in [plain, root] {
1472            let batch = UntrustedExecutionInputs::from_committed_messages(&[message]).unwrap();
1473            assert_eq!(batch.observations().len(), 1);
1474            assert_eq!(batch.observations()[0].input_id(), id);
1475            assert_eq!(batch.observations()[0].request(), Some(&request));
1476        }
1477        let text = bamboo_agent_core::Message::user("{\"skill_request\":\"fragment\"}");
1478        assert!(UntrustedExecutionInputs::from_committed_messages(&[text]).is_none());
1479        assert!(UntrustedExecutionInputs::from_committed_messages(&[]).is_none());
1480    }
1481}
1482
1483#[cfg(test)]
1484mod sdk_acceptance_tests {
1485    use super::*;
1486    use crate::runtime::managers::lifecycle::BoundedInputRequestBatch;
1487    use bamboo_agent_core::{Message, Role, Session};
1488    use tokio_util::sync::CancellationToken;
1489
1490    struct Repository {
1491        stored: std::sync::Mutex<Option<Session>>,
1492        reject: bool,
1493        corrupt: bool,
1494    }
1495    #[async_trait::async_trait]
1496    impl RuntimeSessionPersistence for Repository {
1497        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1498            if self.reject {
1499                return Err(std::io::Error::other("checkpoint refused"));
1500            }
1501            let mut accepted = session.clone();
1502            if self.corrupt {
1503                accepted
1504                    .messages
1505                    .iter_mut()
1506                    .find(|message| message.role == Role::User)
1507                    .unwrap()
1508                    .content
1509                    .push_str(" changed");
1510            }
1511            *self.stored.lock().unwrap() = Some(accepted);
1512            Ok(())
1513        }
1514        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1515            self.save_runtime_session(session).await
1516        }
1517        async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1518            Ok(self.stored.lock().unwrap().clone())
1519        }
1520    }
1521    fn repository(reject: bool, corrupt: bool) -> Arc<dyn RuntimeSessionPersistence> {
1522        Arc::new(Repository {
1523            stored: Default::default(),
1524            reject,
1525            corrupt,
1526        })
1527    }
1528
1529    fn nonzero_conversation(session: &mut Session) {
1530        session.conversation_reset = Some(bamboo_domain::ConversationResetState {
1531            schema_version: 1,
1532            conversation_revision: 1,
1533            receipts: vec![bamboo_domain::ConversationResetReceipt {
1534                operation_id: format!("0:{}", uuid::Uuid::new_v4()),
1535                birth_token: session.root_mode_birth_token(),
1536                expected_snapshot_sha256: "a".repeat(64),
1537                previous_revision: 0,
1538                conversation_revision: 1,
1539                retained_system_messages: 0,
1540                reset_at: session.updated_at,
1541            }],
1542        });
1543    }
1544
1545    struct ConversationChangingRepository {
1546        stored: std::sync::Mutex<Option<Session>>,
1547        mutate_caller: bool,
1548    }
1549    #[async_trait::async_trait]
1550    impl RuntimeSessionPersistence for ConversationChangingRepository {
1551        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1552            let mut accepted = session.clone();
1553            if let Some(state) = accepted.conversation_reset.as_mut() {
1554                state.receipts[0].expected_snapshot_sha256 = "b".repeat(64);
1555            } else {
1556                nonzero_conversation(&mut accepted);
1557            }
1558            if self.mutate_caller {
1559                *session = accepted.clone();
1560            }
1561            *self.stored.lock().unwrap() = Some(accepted);
1562            Ok(())
1563        }
1564        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1565            self.save_runtime_session(session).await
1566        }
1567        async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1568            Ok(self.stored.lock().unwrap().clone())
1569        }
1570    }
1571
1572    #[async_trait::async_trait]
1573    impl bamboo_domain::Storage for ConversationChangingRepository {
1574        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
1575            *self.stored.lock().unwrap() = Some(session.clone());
1576            Ok(())
1577        }
1578        async fn load_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1579            Ok(self.stored.lock().unwrap().clone())
1580        }
1581        async fn delete_session(&self, _: &str) -> std::io::Result<bool> {
1582            Err(std::io::Error::other("fixture never deletes a Session"))
1583        }
1584    }
1585
1586    #[derive(Default)]
1587    struct SealLoopProbe {
1588        provider_calls: std::sync::atomic::AtomicUsize,
1589        tool_calls: std::sync::atomic::AtomicUsize,
1590        setup_calls: std::sync::atomic::AtomicUsize,
1591    }
1592    #[async_trait::async_trait]
1593    impl bamboo_llm::LLMProvider for SealLoopProbe {
1594        async fn chat_stream(
1595            &self,
1596            _: &[Message],
1597            _: &[bamboo_agent_core::tools::ToolSchema],
1598            _: Option<u32>,
1599            _: &str,
1600        ) -> bamboo_llm::provider::Result<bamboo_llm::LLMStream> {
1601            self.provider_calls
1602                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1603            Ok(Box::pin(futures::stream::iter(vec![
1604                Ok(bamboo_llm::LLMChunk::Token("synthetic complete".into())),
1605                Ok(bamboo_llm::LLMChunk::Done),
1606            ])))
1607        }
1608    }
1609    #[async_trait::async_trait]
1610    impl bamboo_agent_core::tools::ToolExecutor for SealLoopProbe {
1611        async fn execute(
1612            &self,
1613            _: &bamboo_agent_core::tools::ToolCall,
1614        ) -> Result<bamboo_agent_core::tools::ToolResult, bamboo_agent_core::tools::ToolError>
1615        {
1616            self.tool_calls
1617                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1618            Err(bamboo_agent_core::tools::ToolError::Execution(
1619                "no fixture tools".into(),
1620            ))
1621        }
1622        fn list_tools(&self) -> Vec<bamboo_agent_core::tools::ToolSchema> {
1623            vec![]
1624        }
1625    }
1626    #[async_trait::async_trait]
1627    impl bamboo_agent_core::AgentHook for SealLoopProbe {
1628        fn point(&self) -> bamboo_domain::AgentHookPoint {
1629            bamboo_domain::AgentHookPoint::AfterSessionSetup
1630        }
1631        async fn run(
1632            &self,
1633            _: bamboo_domain::AgentHookPoint,
1634            _: &bamboo_domain::HookPayload,
1635            _: &Session,
1636        ) -> bamboo_domain::HookResult {
1637            self.setup_calls
1638                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1639            bamboo_domain::HookResult::Continue
1640        }
1641    }
1642
1643    fn seal_loop_config(
1644        inputs: UntrustedExecutionInputs,
1645        persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
1646        storage: Option<Arc<dyn bamboo_domain::Storage>>,
1647        probe: &Arc<SealLoopProbe>,
1648    ) -> AgentLoopConfig {
1649        let mut hooks = crate::runtime::HookRunner::new();
1650        hooks.register(probe.clone());
1651        AgentLoopConfig {
1652            initial_untrusted_inputs: Some(inputs),
1653            persistence,
1654            storage,
1655            skip_initial_user_message: true,
1656            model_name: Some("synthetic-seal-model".into()),
1657            hook_runner: Arc::new(hooks),
1658            prompt_memory_flags: PromptMemoryFlags {
1659                project_prompt_injection: false,
1660                relevant_recall: false,
1661                relevant_recall_rerank: false,
1662                project_first_dream: false,
1663                ledger_agenda: false,
1664            },
1665            ..Default::default()
1666        }
1667    }
1668
1669    #[tokio::test]
1670    async fn full_loop_stops_changed_or_invalid_seal_before_hooks_provider_and_complete() {
1671        for mutate_caller in [false, true] {
1672            for generation in ["advance", "same-revision", "invalid"] {
1673                let mut session = Session::new("sdk-full-seal-refusal", "model");
1674                if generation == "same-revision" {
1675                    nonzero_conversation(&mut session);
1676                }
1677                if generation == "invalid" {
1678                    session.conversation_reset = Some(Default::default());
1679                }
1680                let user = Message::user("synthetic current input");
1681                let inputs = producer(&session, &user, true);
1682                session.add_message(user);
1683                let repository = Arc::new(ConversationChangingRepository {
1684                    stored: std::sync::Mutex::new(Some(session.clone())),
1685                    mutate_caller,
1686                });
1687                let probe = Arc::new(SealLoopProbe::default());
1688                let config = seal_loop_config(
1689                    inputs,
1690                    Some(repository.clone()),
1691                    Some(repository.clone()),
1692                    &probe,
1693                );
1694                let (tx, mut rx) = tokio::sync::mpsc::channel(1024);
1695                let error = crate::runtime::runner::run_agent_loop_with_config(
1696                    &mut session,
1697                    "synthetic current input".into(),
1698                    tx,
1699                    probe.clone(),
1700                    probe.clone(),
1701                    CancellationToken::new(),
1702                    config,
1703                )
1704                .await
1705                .unwrap_err();
1706                assert!(
1707                    error.to_string().contains("session_conversation_changed:"),
1708                    "{generation}: {error}"
1709                );
1710                assert_eq!(
1711                    probe
1712                        .provider_calls
1713                        .load(std::sync::atomic::Ordering::SeqCst),
1714                    0
1715                );
1716                assert_eq!(
1717                    probe.tool_calls.load(std::sync::atomic::Ordering::SeqCst),
1718                    0
1719                );
1720                assert_eq!(
1721                    probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst),
1722                    0
1723                );
1724                while let Ok(event) = rx.try_recv() {
1725                    assert!(!matches!(
1726                        event,
1727                        bamboo_agent_core::AgentEvent::Complete { .. }
1728                    ));
1729                }
1730                // The fake Host's accepted data is preserved, not rolled back
1731                // or relabelled not-executed by the input-observation consumer.
1732                assert!(repository
1733                    .stored
1734                    .lock()
1735                    .unwrap()
1736                    .as_ref()
1737                    .unwrap()
1738                    .conversation_reset
1739                    .is_some());
1740            }
1741        }
1742    }
1743
1744    #[tokio::test]
1745    async fn full_loop_preserves_ordinary_optional_seal_failure_behavior() {
1746        let mut session = Session::new("sdk-optional-seal", "model");
1747        let user = Message::user("synthetic optional input");
1748        let inputs = producer(&session, &user, true);
1749        session.add_message(user);
1750        let probe = Arc::new(SealLoopProbe::default());
1751        let config = seal_loop_config(inputs, None, None, &probe);
1752        let (tx, _rx) = tokio::sync::mpsc::channel(1024);
1753        crate::runtime::runner::run_agent_loop_with_config(
1754            &mut session,
1755            "synthetic optional input".into(),
1756            tx,
1757            probe.clone(),
1758            probe.clone(),
1759            CancellationToken::new(),
1760            config,
1761        )
1762        .await
1763        .unwrap();
1764        assert!(
1765            probe
1766                .provider_calls
1767                .load(std::sync::atomic::Ordering::SeqCst)
1768                > 0
1769        );
1770        assert!(probe.setup_calls.load(std::sync::atomic::Ordering::SeqCst) > 0);
1771    }
1772
1773    #[tokio::test]
1774    async fn sdk_input_seal_freezes_generation_before_a_custom_checkpoint_and_readback() {
1775        for mutate_caller in [false, true] {
1776            for same_revision in [false, true] {
1777                let mut session = Session::new("sdk-generation-seal", "model");
1778                if same_revision {
1779                    nonzero_conversation(&mut session);
1780                }
1781                let user = Message::user("exact id and digest still cannot cross conversation");
1782                let inputs = producer(&session, &user, true);
1783                session.add_message(user);
1784                let persistence: Arc<dyn RuntimeSessionPersistence> =
1785                    Arc::new(ConversationChangingRepository {
1786                        stored: Default::default(),
1787                        mutate_caller,
1788                    });
1789                let error = inputs
1790                    .seal_sdk_input(&mut session, Some(&persistence))
1791                    .await
1792                    .unwrap_err();
1793                assert!(error.to_string().contains("session_conversation_changed:"));
1794                assert!(
1795                    current(&inputs, &session).is_none(),
1796                    "cannot publish New input from a different conversation"
1797                );
1798            }
1799        }
1800    }
1801    fn producer(session: &Session, user: &Message, typed: bool) -> UntrustedExecutionInputs {
1802        UntrustedExecutionInputs::new(vec![UntrustedInputObservation::new(&user.id, None).unwrap()])
1803            .unwrap()
1804            .bind_sdk_append(
1805                "actual-sdk-execution".into(),
1806                SdkPendingInput {
1807                    session_id: session.id.clone(),
1808                    input_id: user.id.clone(),
1809                    header_digest: sdk_message_header_digest(user),
1810                    final_digest: typed.then(|| sdk_message_digest(user)),
1811                },
1812                None,
1813            )
1814    }
1815    fn current(
1816        inputs: &UntrustedExecutionInputs,
1817        session: &Session,
1818    ) -> Option<BoundedInputRequestBatch> {
1819        let (execution_id, observation) = inputs.take_startup_observation(&session.id).unwrap();
1820        assert_eq!(execution_id, "actual-sdk-execution");
1821        let mut current = None;
1822        observation.update_current(&mut current, &session.id, &execution_id);
1823        assert!(inputs.take_startup_observation(&session.id).is_none());
1824        current
1825    }
1826    #[tokio::test]
1827    async fn sdk_startup_requires_exact_checkpoint_and_preserves_request_absence_as_new() {
1828        let mut session = Session::new("sdk-accept", "model");
1829        let user = Message::user("final prepared");
1830        let inputs = producer(&session, &user, true);
1831        session.add_message(user.clone());
1832        inputs
1833            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1834            .await
1835            .unwrap();
1836        let batch = current(&inputs, &session).unwrap();
1837        assert_eq!(batch.records().len(), 1);
1838        assert_eq!(batch.records()[0].input_id, user.id);
1839        assert_eq!(
1840            batch.records()[0].source,
1841            bamboo_domain::SessionMessageSource::User
1842        );
1843        assert!(batch.records()[0].request.is_none());
1844    }
1845    #[tokio::test]
1846    async fn sdk_startup_unknown_checkpoint_or_modified_final_user_stays_unavailable() {
1847        for (reject, corrupt, mutate, duplicate, wrong_session) in [
1848            (true, false, false, false, false),
1849            (false, true, false, false, false),
1850            (false, false, true, false, false),
1851            (false, false, false, true, false),
1852            (false, false, false, false, true),
1853        ] {
1854            let mut session = Session::new("sdk-deny", "model");
1855            let user = Message::user("prepared");
1856            let inputs = producer(&session, &user, true);
1857            session.add_message(user.clone());
1858            if mutate {
1859                session.messages[0].content.push_str(" changed");
1860            }
1861            if duplicate {
1862                session.add_message(user);
1863            }
1864            if wrong_session {
1865                session.id = "wrong".into();
1866            }
1867            assert!(inputs
1868                .seal_sdk_input(&mut session, Some(&repository(reject, corrupt)))
1869                .await
1870                .is_err());
1871            if wrong_session {
1872                assert!(inputs.take_startup_observation(&session.id).is_none());
1873            } else {
1874                assert!(current(&inputs, &session).is_none());
1875            }
1876        }
1877    }
1878    #[tokio::test]
1879    async fn sdk_legacy_hook_rewrite_is_bound_to_original_id_while_generic_history_is_unsealed() {
1880        let mut session = Session::new("sdk-string", "model");
1881        let user = Message::user("original");
1882        let inputs = producer(&session, &user, false);
1883        session.add_message(user.clone());
1884        session.messages[0].content = "real portable hook result".into();
1885        inputs
1886            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1887            .await
1888            .unwrap();
1889        assert_eq!(
1890            current(&inputs, &session).unwrap().records()[0].input_id,
1891            user.id
1892        );
1893        let generic =
1894            UntrustedExecutionInputs::new(vec![
1895                UntrustedInputObservation::new(&user.id, None).unwrap()
1896            ])
1897            .unwrap();
1898        generic
1899            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1900            .await
1901            .unwrap();
1902        assert!(generic.sdk_execution_id().is_none());
1903        assert!(generic.take_startup_observation(&session.id).is_none());
1904    }
1905    #[test]
1906    fn sdk_header_binding_accepts_only_the_canonical_prompt_changes() {
1907        let mut original = Message::user("canonical");
1908        original.content_parts = Some(vec![
1909            bamboo_domain::MessagePart::Text {
1910                text: "unrelated native text".into(),
1911            },
1912            bamboo_domain::MessagePart::Text {
1913                text: "canonical".into(),
1914            },
1915        ]);
1916        let digest = sdk_message_header_digest(&original);
1917        let mut prepared = original.clone();
1918        prepared.content = "canonical plus F".into();
1919        if let bamboo_domain::MessagePart::Text { text } =
1920            &mut prepared.content_parts.as_mut().unwrap()[1]
1921        {
1922            *text = prepared.content.clone();
1923        }
1924        assert_eq!(sdk_message_header_digest(&prepared), digest);
1925        prepared.id = "different".into();
1926        assert_ne!(sdk_message_header_digest(&prepared), digest);
1927        prepared = original;
1928        if let bamboo_domain::MessagePart::Text { text } =
1929            &mut prepared.content_parts.as_mut().unwrap()[0]
1930        {
1931            *text = "tampered".into();
1932        }
1933        assert_ne!(sdk_message_header_digest(&prepared), digest);
1934    }
1935}