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; their original Vision
937    /// capability rejection remains in place. It never changes the main model.
938    pub(crate) vision_model_resolver: Option<super::runner::image_fallback::VisionModelResolver>,
939    /// Feature flags controlling prompt-time memory injection behavior.
940    pub(crate) prompt_memory_flags: PromptMemoryFlags,
941    /// Maximum tool calls allowed per round (default: 80).
942    pub(crate) max_tool_calls_per_round: usize,
943    /// Maximum consecutive failures per tool before circuit breaker (default: 3).
944    pub(crate) max_consecutive_failures_per_tool: usize,
945    /// Per-tool execution timeout in seconds (default: 120).
946    pub(crate) per_tool_timeout_secs: u64,
947    /// Parallel batch execution timeout in seconds (default: 300).
948    pub(crate) parallel_batch_timeout_secs: u64,
949    /// Resolved LLM stream transport/semantic watchdog policy. The same value
950    /// is passed to main response streams and auxiliary silent streams.
951    pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
952    /// Permission mode for this execution (default: None = use PermissionConfig's mode).
953    pub(crate) permission_mode: Option<PermissionMode>,
954    /// Optional Gold observe-only evaluator configuration.
955    ///
956    /// When `None` or `enabled == false`, Gold evaluation is disabled and the
957    /// existing execute/respond/resume loop remains unchanged.
958    pub(crate) gold_config: Option<GoldConfig>,
959    /// Optional guardian adversarial-review gate configuration. When `None` or
960    /// `enabled == false`, the guardian terminal gate is inactive.
961    pub(crate) guardian_config: Option<GuardianConfig>,
962    /// Late-bound spawner for the guardian reviewer child. `None` (the default)
963    /// leaves the guardian gate inert even when `guardian_config.enabled` is set,
964    /// since the runner cannot create a child without it. Wired by the server.
965    pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
966    /// Late-bound hook that arranges a self-resume for a session suspended
967    /// waiting on background Bash shells (issue #84 Phase 2b). `None` (the
968    /// default) leaves the bash suspend gate inert: the gate refuses to suspend
969    /// without a wired hook, so a session can never strand itself without a
970    /// resume path. Wired by the server (the completion coordinator impl).
971    pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
972    /// Late-bound sink that pushes a completed background Bash shell's result
973    /// into this session's loop (issue #84 Phase 2b follow-up) — injected at the
974    /// next round boundary while the loop is actively iterating, or delivered via
975    /// resume when it is idle. Threaded onto the tool dispatch context (like
976    /// `can_async_resume`) so the Bash tool can hand it to the shell's
977    /// completion-poll task. `None` (the default) leaves the push inert; the
978    /// durable end-of-turn suspend/poll backstop (`bash_resume_hook`) still runs.
979    /// Wired by the server (the completion coordinator impl).
980    pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
981    /// Late-bound delegate that routes a child's gated-tool approval request up
982    /// to its parent (Phase 2). `None` (the default) leaves child gating on its
983    /// legacy path. Wired by the server.
984    pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
985    /// Frozen lifecycle-hook registry for this run. The default registry is
986    /// empty, and every seam checks `has_hooks_for` before constructing payloads.
987    pub(crate) hook_runner: Arc<HookRunner>,
988    /// Enable dynamic per-round model routing based on task complexity.
989    /// When true, the pipeline classifies complexity at each round end and
990    /// stores the result in session metadata.
991    pub(crate) features_dynamic_model_routing: bool,
992    /// Opt-in, single auxiliary check before publishing a final answer.
993    pub(crate) features_final_evidence_check: bool,
994    /// Optional per-round resolver for auxiliary model settings that should
995    /// follow live global config rather than stay frozen for the whole run.
996    ///
997    /// The main chat model remains session/request scoped; this hook is only
998    /// for fast/background/planning/search/summarization helpers.
999    pub(crate) auxiliary_model_resolver:
1000        Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
1001    /// Optional per-round resolver for the disabled tool/skill sets so they follow
1002    /// LIVE global config instead of staying frozen for the whole run. Returns the
1003    /// current `(disabled_tools, disabled_skill_ids)`. When `None`, the snapshotted
1004    /// `disabled_tools` / `disabled_skill_ids` fields below are used (#44 behavior).
1005    /// Re-resolved each round at the tool-schema filter, so disabling/re-enabling a
1006    /// tool mid-run takes effect on the next round. #136.
1007    pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
1008    /// Server-level usage guidance contributed by the run's tool executor —
1009    /// chiefly the `instructions` connected MCP servers return from `initialize`.
1010    /// Captured once at config construction (from `ToolExecutor::tool_guidance`)
1011    /// and appended to the tool-guide section of the system prompt, so a server's
1012    /// own how-to-use notes appear only while that server is loaded for the run.
1013    pub(crate) mcp_tool_guidance: Option<String>,
1014    /// Per-run resource guardrails (issue #221): already resolved — the
1015    /// per-request override merged over the config-level default (see
1016    /// [`AgentRuntime::execute`](crate::runtime::runtime::AgentRuntime::execute)).
1017    /// Checked after every round; exceeding a configured limit gracefully
1018    /// stops the run. Includes the run's round cap (`max_rounds`): `None`
1019    /// means unlimited rounds (the historical hard-coded 200 was removed);
1020    /// a configured cap keeps the issue #29 exhaustion contract (metadata
1021    /// stamp, visible notification, exactly one final summary turn).
1022    pub(crate) run_budget: bamboo_config::RunBudgetConfig,
1023    pub(crate) counted_loop: Option<super::counted_loop::CountedLoopConfig>,
1024    pub(crate) loop_control: Option<super::loop_control::LoopControlConfig>,
1025}
1026
1027impl AgentLoopConfig {
1028    /// A host-prestarted loop or the sole model-started generation in this run.
1029    pub(crate) fn effective_counted_loop(&self) -> Option<&super::counted_loop::CountedLoopConfig> {
1030        self.counted_loop
1031            .as_ref()
1032            .or_else(|| self.loop_control.as_ref().and_then(|c| c.active_counted()))
1033    }
1034
1035    /// Data-only execution transport. This accessor does not classify or grant.
1036    pub fn initial_untrusted_inputs(&self) -> Option<&UntrustedExecutionInputs> {
1037        self.initial_untrusted_inputs.as_ref()
1038    }
1039}
1040
1041impl Default for AgentLoopConfig {
1042    fn default() -> Self {
1043        Self {
1044            freeze_tool_exposure_for_cache: true,
1045            ticket_worker_plan: None,
1046            system_prompt: None,
1047            disabled_skill_ids: BTreeSet::new(),
1048            selected_skill_ids: None,
1049            selected_skill_mode: None,
1050            initial_untrusted_inputs: None,
1051            sdk_skill_execution_host: None,
1052            additional_tool_schemas: Vec::new(),
1053            tool_registry: Arc::new(ToolRegistry::new()),
1054            skill_manager: None,
1055            project_context_resolver: None,
1056            skip_initial_user_message: false,
1057            storage: None,
1058            persistence: None,
1059            guidance_active_run_id: None,
1060            session_inbox: None,
1061            session_activation_notifications: None,
1062            attachment_reader: None,
1063            metrics_collector: None,
1064            model_name: None,
1065            fast_model_name: None,
1066            fast_model_provider: None,
1067            auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
1068            background_model_name: None,
1069            planning_model_name: None,
1070            search_model_name: None,
1071            compression_instructions: None,
1072            summary_target_ratio: 0.20,
1073            context_management: bamboo_config::ContextManagementConfig::default(),
1074            summary_safe_window_percent: 80,
1075            summarization_model_name: None,
1076            background_model_provider: None,
1077            summarization_model_provider: None,
1078            provider_name: None,
1079            provider_type: None,
1080            reasoning_effort: None,
1081            app_data_dir: None,
1082            memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
1083            disabled_tools: BTreeSet::new(),
1084            token_budget: None,
1085            model_limits_section: None,
1086            legacy_model_limits: None,
1087            image_fallback: None,
1088            vision_model_resolver: None,
1089            prompt_memory_flags: PromptMemoryFlags::default(),
1090            max_tool_calls_per_round: 80,
1091            max_consecutive_failures_per_tool: 3,
1092            per_tool_timeout_secs: 120,
1093            parallel_batch_timeout_secs: 300,
1094            stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
1095            permission_mode: None,
1096            gold_config: None,
1097            guardian_config: None,
1098            guardian_spawner: None,
1099            bash_resume_hook: None,
1100            bash_completion_sink: None,
1101            approval_delegate: None,
1102            hook_runner: Arc::new(HookRunner::new()),
1103            features_dynamic_model_routing: false,
1104            features_final_evidence_check: false,
1105            auxiliary_model_resolver: None,
1106            disabled_filter_resolver: None,
1107            mcp_tool_guidance: None,
1108            run_budget: bamboo_config::RunBudgetConfig::default(),
1109            counted_loop: None,
1110            loop_control: None,
1111        }
1112    }
1113}
1114
1115impl AgentLoopConfig {
1116    pub fn bind_ticket_worker_plan(
1117        &mut self,
1118        plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
1119    ) {
1120        self.ticket_worker_plan = Some(plan);
1121    }
1122    /// Live `(disabled_tools, disabled_skill_ids)` for the current round: the
1123    /// resolver if one is wired (#136 — follows live global config between
1124    /// rounds), else the per-run snapshot (#44 frozen behavior). `Cow` avoids
1125    /// cloning the snapshot in the common no-resolver path (SDK / tests).
1126    pub(crate) fn resolve_disabled_filters(
1127        &self,
1128    ) -> (
1129        std::borrow::Cow<'_, BTreeSet<String>>,
1130        std::borrow::Cow<'_, BTreeSet<String>>,
1131    ) {
1132        match &self.disabled_filter_resolver {
1133            Some(resolver) => {
1134                let (tools, skills) = resolver();
1135                (
1136                    std::borrow::Cow::Owned(tools),
1137                    std::borrow::Cow::Owned(skills),
1138                )
1139            }
1140            None => (
1141                std::borrow::Cow::Borrowed(&self.disabled_tools),
1142                std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
1143            ),
1144        }
1145    }
1146
1147    /// The active session goal to surface to the main agent, or `None` when
1148    /// Gold is disabled or no goal is set. Falls back to the legacy
1149    /// `evaluation_prompt` for back-compat via [`GoldConfig::effective_goal`].
1150    pub fn active_goal(&self) -> Option<&str> {
1151        self.gold_config
1152            .as_ref()
1153            .filter(|cfg| cfg.enabled)
1154            .and_then(GoldConfig::effective_goal)
1155    }
1156
1157    /// Whether the Codex-style autonomous goal loop is active for this run.
1158    ///
1159    /// This requires Gold to be enabled, a goal to be set, AND auto-continue to
1160    /// be on. Only then is the `update_goal` self-report tool surfaced to the
1161    /// model and the terminal double-check allowed to veto a premature stop.
1162    /// When Gold is enabled without auto-continue, the evaluator stays purely
1163    /// observational (legacy behavior).
1164    pub fn goal_loop_active(&self) -> bool {
1165        self.gold_config.as_ref().is_some_and(|cfg| {
1166            cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
1167        })
1168    }
1169
1170    /// Whether the guardian review gate is active for this run: a spawner is
1171    /// wired (so the runner can actually create the reviewer child) AND the
1172    /// config is present and enabled.
1173    pub fn guardian_active(&self) -> bool {
1174        self.guardian_spawner.is_some()
1175            && self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
1176    }
1177
1178    /// Maximum guardian review passes for this run (the budget). `0` when no
1179    /// guardian config is set.
1180    pub fn guardian_max_reviews(&self) -> u32 {
1181        self.guardian_config
1182            .as_ref()
1183            .map_or(0, |cfg| cfg.max_reviews)
1184    }
1185
1186    /// The reviewer model override, if a guardian config sets one.
1187    pub fn guardian_model(&self) -> Option<&str> {
1188        self.guardian_config
1189            .as_ref()
1190            .and_then(|cfg| cfg.model_name.as_deref())
1191    }
1192
1193    /// Whether child→parent approval delegation is wired for this run.
1194    pub fn delegation_active(&self) -> bool {
1195        self.approval_delegate.is_some()
1196    }
1197}
1198
1199#[cfg(test)]
1200mod tests;
1201
1202#[cfg(test)]
1203mod execution_input_tests {
1204    use super::*;
1205    fn request() -> bamboo_domain::SessionSkillRequest {
1206        bamboo_domain::SessionSkillRequest {
1207            selections: vec![bamboo_domain::SessionSkillSelection {
1208                id: "exact Case".into(),
1209                source: "builtin".into(),
1210                revision: 1,
1211                args: serde_json::json!({"raw":"保持原样"}),
1212            }],
1213            mode: None,
1214        }
1215    }
1216    #[test]
1217    fn execution_input_owned_bounds_and_old_config_default() {
1218        assert!(AgentLoopConfig::default()
1219            .initial_untrusted_inputs()
1220            .is_none());
1221        let request = request();
1222        let input = UntrustedInputObservation::new("exact-input", Some(&request)).unwrap();
1223        assert_eq!(input.input_id(), "exact-input");
1224        assert_eq!(input.request(), Some(&request));
1225        for id in [
1226            "",
1227            " leading",
1228            "trailing ",
1229            "unsafe/id",
1230            "unsafe\\id",
1231            "unsafe..id",
1232        ] {
1233            assert!(UntrustedInputObservation::new(id, None).is_none());
1234        }
1235        assert!(UntrustedInputObservation::new(&"a".repeat(257), None).is_none());
1236        assert!(UntrustedExecutionInputs::new(Vec::new()).is_none());
1237        let batch = UntrustedExecutionInputs::new(
1238            (0..128)
1239                .map(|i| {
1240                    UntrustedInputObservation::new(&format!("input-{i}"), Some(&request)).unwrap()
1241                })
1242                .collect(),
1243        )
1244        .unwrap();
1245        assert_eq!(batch.observations().len(), 128);
1246        assert_eq!(batch.observations()[127].input_id(), "input-127");
1247        assert!(UntrustedExecutionInputs::new(
1248            (0..129)
1249                .map(|i| UntrustedInputObservation::new(&format!("input-{i}"), None).unwrap())
1250                .collect()
1251        )
1252        .is_none());
1253        assert!(UntrustedExecutionInputs::new(vec![
1254            UntrustedInputObservation::new("same", None).unwrap(),
1255            UntrustedInputObservation::new("same", None).unwrap()
1256        ])
1257        .is_none());
1258        let mut malformed = request;
1259        malformed.selections[0].args = (0..65).fold(serde_json::Value::Null, |v, _| {
1260            serde_json::Value::Array(vec![v])
1261        });
1262        assert!(UntrustedInputObservation::new("deep", Some(&malformed)).is_none());
1263    }
1264    #[test]
1265    fn execution_input_only_explicit_checked_commit_slice_copies_request_fields() {
1266        let request = request();
1267        let mut user = bamboo_domain::SessionMessageEnvelope::user_input("target", "ordinary");
1268        if let bamboo_domain::SessionMessageBody::Content(content) = &mut user.body {
1269            content.skill_request = Some(request.clone());
1270        }
1271        let id = user.id.to_string();
1272        let plain = user.to_provider_message().unwrap();
1273        let root = user
1274            .with_root_chat_prompt("system prompt is not retained".into())
1275            .unwrap()
1276            .to_provider_message()
1277            .unwrap();
1278        for message in [plain, root] {
1279            let batch = UntrustedExecutionInputs::from_committed_messages(&[message]).unwrap();
1280            assert_eq!(batch.observations().len(), 1);
1281            assert_eq!(batch.observations()[0].input_id(), id);
1282            assert_eq!(batch.observations()[0].request(), Some(&request));
1283        }
1284        let text = bamboo_agent_core::Message::user("{\"skill_request\":\"fragment\"}");
1285        assert!(UntrustedExecutionInputs::from_committed_messages(&[text]).is_none());
1286        assert!(UntrustedExecutionInputs::from_committed_messages(&[]).is_none());
1287    }
1288}
1289
1290#[cfg(test)]
1291mod sdk_acceptance_tests {
1292    use super::*;
1293    use crate::runtime::managers::lifecycle::BoundedInputRequestBatch;
1294    use bamboo_agent_core::{Message, Role, Session};
1295
1296    struct Repository {
1297        stored: std::sync::Mutex<Option<Session>>,
1298        reject: bool,
1299        corrupt: bool,
1300    }
1301    #[async_trait::async_trait]
1302    impl RuntimeSessionPersistence for Repository {
1303        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1304            if self.reject {
1305                return Err(std::io::Error::other("checkpoint refused"));
1306            }
1307            let mut accepted = session.clone();
1308            if self.corrupt {
1309                accepted
1310                    .messages
1311                    .iter_mut()
1312                    .find(|message| message.role == Role::User)
1313                    .unwrap()
1314                    .content
1315                    .push_str(" changed");
1316            }
1317            *self.stored.lock().unwrap() = Some(accepted);
1318            Ok(())
1319        }
1320        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1321            self.save_runtime_session(session).await
1322        }
1323        async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1324            Ok(self.stored.lock().unwrap().clone())
1325        }
1326    }
1327    fn repository(reject: bool, corrupt: bool) -> Arc<dyn RuntimeSessionPersistence> {
1328        Arc::new(Repository {
1329            stored: Default::default(),
1330            reject,
1331            corrupt,
1332        })
1333    }
1334    fn producer(session: &Session, user: &Message, typed: bool) -> UntrustedExecutionInputs {
1335        UntrustedExecutionInputs::new(vec![UntrustedInputObservation::new(&user.id, None).unwrap()])
1336            .unwrap()
1337            .bind_sdk_append(
1338                "actual-sdk-execution".into(),
1339                SdkPendingInput {
1340                    session_id: session.id.clone(),
1341                    input_id: user.id.clone(),
1342                    header_digest: sdk_message_header_digest(user),
1343                    final_digest: typed.then(|| sdk_message_digest(user)),
1344                },
1345                None,
1346            )
1347    }
1348    fn current(
1349        inputs: &UntrustedExecutionInputs,
1350        session: &Session,
1351    ) -> Option<BoundedInputRequestBatch> {
1352        let (execution_id, observation) = inputs.take_startup_observation(&session.id).unwrap();
1353        assert_eq!(execution_id, "actual-sdk-execution");
1354        let mut current = None;
1355        observation.update_current(&mut current, &session.id, &execution_id);
1356        assert!(inputs.take_startup_observation(&session.id).is_none());
1357        current
1358    }
1359    #[tokio::test]
1360    async fn sdk_startup_requires_exact_checkpoint_and_preserves_request_absence_as_new() {
1361        let mut session = Session::new("sdk-accept", "model");
1362        let user = Message::user("final prepared");
1363        let inputs = producer(&session, &user, true);
1364        session.add_message(user.clone());
1365        inputs
1366            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1367            .await
1368            .unwrap();
1369        let batch = current(&inputs, &session).unwrap();
1370        assert_eq!(batch.records().len(), 1);
1371        assert_eq!(batch.records()[0].input_id, user.id);
1372        assert_eq!(
1373            batch.records()[0].source,
1374            bamboo_domain::SessionMessageSource::User
1375        );
1376        assert!(batch.records()[0].request.is_none());
1377    }
1378    #[tokio::test]
1379    async fn sdk_startup_unknown_checkpoint_or_modified_final_user_stays_unavailable() {
1380        for (reject, corrupt, mutate, duplicate, wrong_session) in [
1381            (true, false, false, false, false),
1382            (false, true, false, false, false),
1383            (false, false, true, false, false),
1384            (false, false, false, true, false),
1385            (false, false, false, false, true),
1386        ] {
1387            let mut session = Session::new("sdk-deny", "model");
1388            let user = Message::user("prepared");
1389            let inputs = producer(&session, &user, true);
1390            session.add_message(user.clone());
1391            if mutate {
1392                session.messages[0].content.push_str(" changed");
1393            }
1394            if duplicate {
1395                session.add_message(user);
1396            }
1397            if wrong_session {
1398                session.id = "wrong".into();
1399            }
1400            assert!(inputs
1401                .seal_sdk_input(&mut session, Some(&repository(reject, corrupt)))
1402                .await
1403                .is_err());
1404            if wrong_session {
1405                assert!(inputs.take_startup_observation(&session.id).is_none());
1406            } else {
1407                assert!(current(&inputs, &session).is_none());
1408            }
1409        }
1410    }
1411    #[tokio::test]
1412    async fn sdk_legacy_hook_rewrite_is_bound_to_original_id_while_generic_history_is_unsealed() {
1413        let mut session = Session::new("sdk-string", "model");
1414        let user = Message::user("original");
1415        let inputs = producer(&session, &user, false);
1416        session.add_message(user.clone());
1417        session.messages[0].content = "real portable hook result".into();
1418        inputs
1419            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1420            .await
1421            .unwrap();
1422        assert_eq!(
1423            current(&inputs, &session).unwrap().records()[0].input_id,
1424            user.id
1425        );
1426        let generic =
1427            UntrustedExecutionInputs::new(vec![
1428                UntrustedInputObservation::new(&user.id, None).unwrap()
1429            ])
1430            .unwrap();
1431        generic
1432            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1433            .await
1434            .unwrap();
1435        assert!(generic.sdk_execution_id().is_none());
1436        assert!(generic.take_startup_observation(&session.id).is_none());
1437    }
1438    #[test]
1439    fn sdk_header_binding_accepts_only_the_canonical_prompt_changes() {
1440        let mut original = Message::user("canonical");
1441        original.content_parts = Some(vec![
1442            bamboo_domain::MessagePart::Text {
1443                text: "unrelated native text".into(),
1444            },
1445            bamboo_domain::MessagePart::Text {
1446                text: "canonical".into(),
1447            },
1448        ]);
1449        let digest = sdk_message_header_digest(&original);
1450        let mut prepared = original.clone();
1451        prepared.content = "canonical plus F".into();
1452        if let bamboo_domain::MessagePart::Text { text } =
1453            &mut prepared.content_parts.as_mut().unwrap()[1]
1454        {
1455            *text = prepared.content.clone();
1456        }
1457        assert_eq!(sdk_message_header_digest(&prepared), digest);
1458        prepared.id = "different".into();
1459        assert_ne!(sdk_message_header_digest(&prepared), digest);
1460        prepared = original;
1461        if let bamboo_domain::MessagePart::Text { text } =
1462            &mut prepared.content_parts.as_mut().unwrap()[0]
1463        {
1464            *text = "tampered".into();
1465        }
1466        assert_ne!(sdk_message_header_digest(&prepared), digest);
1467    }
1468}