Skip to main content

bamboo_engine/runtime/
config.rs

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