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    /// Vision model name for `Vision` mode. Falls back to the session's main model
299    /// when `None`.
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), `model_limits_section` (validated model limits), and
796/// `guardian_spawner` (the reviewer child). If a frozen
797/// field ever needs to become live-per-round, follow that resolver pattern
798/// rather than widening the snapshot.
799#[non_exhaustive]
800pub struct AgentLoopConfig {
801    /// Keep tool guidance stable while activation updates append to model context.
802    pub freeze_tool_exposure_for_cache: bool,
803    /// Host-installed exact Assignment/Run permit; never taken from model args.
804    pub(crate) ticket_worker_plan: Option<Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>>,
805    pub(crate) system_prompt: Option<String>,
806    /// Skill IDs that are disabled globally for this execution.
807    pub(crate) disabled_skill_ids: BTreeSet<String>,
808    /// Optional explicit skill selection for this execution.
809    /// When set, only these skill IDs are considered for skill context and allowlists.
810    pub(crate) selected_skill_ids: Option<Vec<String>>,
811    /// Optional active skill mode for this execution.
812    ///
813    /// When set, skill discovery prefers `skills-<mode>` directories over generic
814    /// directories for the same skill id.
815    pub(crate) selected_skill_mode: Option<String>,
816    /// Untrusted inputs supplied by this startup call, never inferred.
817    pub(crate) initial_untrusted_inputs: Option<UntrustedExecutionInputs>,
818    pub(crate) sdk_skill_execution_host: Option<Arc<dyn SdkSkillExecutionHost>>,
819    pub(crate) additional_tool_schemas: Vec<ToolSchema>,
820    pub(crate) tool_registry: Arc<ToolRegistry>,
821    pub(crate) skill_manager: Option<Arc<SkillManager>>,
822    /// Stable Project identity/resource resolver. The server wires the
823    /// authoritative registry adapter once on `AgentRuntimeBuilder`.
824    pub(crate) project_context_resolver:
825        Option<Arc<crate::project_context::ProjectContextResolver>>,
826    /// If true, skip appending the initial user message (already present in session).
827    pub(crate) skip_initial_user_message: bool,
828    /// Optional storage for persisting session changes
829    pub(crate) storage: Option<Arc<dyn Storage>>,
830    /// Optional runtime persistence for non-authoritative session saves.
831    /// When set, engine save sites use this instead of `storage` for writes.
832    pub(crate) persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
833    pub(crate) guidance_active_run_id: Option<String>,
834    /// Durable logical-session inbox admitted at safe round boundaries.
835    pub(crate) session_inbox: Option<Arc<dyn bamboo_domain::SessionInboxPort>>,
836    /// Active-owner wake generation. The loop consumes this at the same safe
837    /// boundary where it drains SessionInbox; it never interrupts an in-flight
838    /// provider/tool operation.
839    pub(crate) session_activation_notifications:
840        Option<Arc<parking_lot::Mutex<tokio::sync::watch::Receiver<u64>>>>,
841    /// Optional attachment reader for resolving `bamboo-attachment://...` references
842    /// into `data:` URLs for upstream providers. This must not mutate session storage.
843    pub(crate) attachment_reader: Option<Arc<dyn AttachmentReader>>,
844    /// Optional asynchronous metrics collector
845    pub(crate) metrics_collector: Option<MetricsCollector>,
846    /// Model name used for metrics attribution
847    pub(crate) model_name: Option<String>,
848    /// Fast/cheap model for lightweight tasks (task evaluation, search, etc.).
849    ///
850    /// Call sites may fall back to `model_name` when this is unset.
851    pub(crate) fast_model_name: Option<String>,
852    /// Optional provider override for lightweight fast-model LLM calls.
853    pub(crate) fast_model_provider: Option<Arc<dyn LLMProvider>>,
854    /// Process-wide concurrency ceiling for low-priority auxiliary evaluation
855    /// requests sharing the same provider allocation and model. Foreground
856    /// requests do not acquire this budget.
857    pub(crate) auxiliary_evaluation_max_concurrency: usize,
858    /// Fast/cheap model for memory/background tasks.
859    ///
860    /// Resolution follows the shared dedicated → fast → chat fallback chain.
861    pub(crate) background_model_name: Option<String>,
862
863    /// Model for planning/coordination tasks (task decomposition, architecture).
864    /// Falls back to `model_name` when unset.
865    pub(crate) planning_model_name: Option<String>,
866    /// Model for search/navigation tasks (grep, file listing, symbol resolution).
867    /// Falls back to `fast_model_name` when unset.
868    pub(crate) search_model_name: Option<String>,
869    /// Custom instructions for conversation summarization, injected into the
870    /// LLM summary prompt. Lets users control what the summary focuses on.
871    ///
872    /// Resolution order: session-level > config-level > built-in defaults.
873    pub(crate) compression_instructions: Option<String>,
874    /// Desired final summary size relative to the raw source tokens represented
875    /// by it. Values are normalized at the compression boundary.
876    pub(crate) summary_target_ratio: f64,
877    /// Frozen host context-management strategy for this run. Missing public
878    /// configuration resolves to the legacy summary behavior.
879    pub(crate) context_management: bamboo_config::ContextManagementConfig,
880    /// Safe request ceiling as a percentage of the selected summarization
881    /// model's context window.
882    pub(crate) summary_safe_window_percent: u8,
883    /// Dedicated model for summarization. Falls back to `background_model_name`.
884    pub(crate) summarization_model_name: Option<String>,
885    /// Optional provider override for memory/background model LLM calls.
886    ///
887    /// When set, memory recall rerank and other memory/background tasks use this
888    /// provider instead of the shared agent loop provider.
889    pub(crate) background_model_provider: Option<Arc<dyn LLMProvider>>,
890    /// Optional provider override for summarization / context compression calls.
891    ///
892    /// When set, conversation/task summarization uses this provider instead of
893    /// the shared agent loop provider.
894    pub(crate) summarization_model_provider: Option<Arc<dyn LLMProvider>>,
895    /// Provider routing key used for provider-specific request behavior.
896    ///
897    /// In multi-instance mode this may be the instance id.
898    pub(crate) provider_name: Option<String>,
899    /// Underlying provider type (for example `openai`, `anthropic`, `copilot`).
900    ///
901    /// This is distinct from `provider_name` so provider-specific behavior can
902    /// remain correct when routing keys are instance ids.
903    pub(crate) provider_type: Option<String>,
904    /// Optional request-time reasoning effort override.
905    pub(crate) reasoning_effort: Option<ReasoningEffort>,
906    /// Bamboo application data directory (typically `~/.bamboo`).
907    ///
908    /// Used by runtime features that persist auxiliary artifacts outside the
909    /// session store, such as durable plan mode files under `~/.bamboo/plan`.
910    pub(crate) app_data_dir: Option<PathBuf>,
911    /// Jiandu memory handle for this run. The default is the independent
912    /// `~/.jiandu` store; explicit construction is used by isolated tests.
913    pub(crate) memory_store: bamboo_memory::memory_store::MemoryStore,
914    /// Tool names that should be excluded from schemas sent to the LLM.
915    pub(crate) disabled_tools: BTreeSet<String>,
916    /// Token budget for context management (optional, defaults to model's limits)
917    pub(crate) token_budget: Option<TokenBudget>,
918    /// Host-owned model-limit section. The handle stays stable for the run;
919    /// each budget resolution reads its immutable last-known-good snapshot.
920    /// Standalone runtimes without this handle retain instance-local file loading.
921    pub(crate) model_limits_section:
922        Option<Arc<bamboo_config::LiveSection<bamboo_config::ModelLimitsSection>>>,
923    /// Legacy `config.json` `model_limits` value, snapshotted from the live
924    /// in-memory Config when this loop config is built. Consulted per model when
925    /// the host section or standalone `model_limits.json` has no matching entry
926    /// or cannot be loaded, so the engine never does a disk-reading `Config::new()`
927    /// (which would also clobber the global env-var cache). #38.
928    pub(crate) legacy_model_limits: Option<serde_json::Value>,
929    /// Optional image fallback behavior applied to *LLM requests only* (never persisted).
930    ///
931    /// This is intended for text-only provider paths where image parts must be degraded
932    /// (placeholder / OCR / error) without leaking into stored session history or UI.
933    pub(crate) image_fallback: Option<ImageFallbackConfig>,
934    /// Feature flags controlling prompt-time memory injection behavior.
935    pub(crate) prompt_memory_flags: PromptMemoryFlags,
936    /// Maximum tool calls allowed per round (default: 80).
937    pub(crate) max_tool_calls_per_round: usize,
938    /// Maximum consecutive failures per tool before circuit breaker (default: 3).
939    pub(crate) max_consecutive_failures_per_tool: usize,
940    /// Per-tool execution timeout in seconds (default: 120).
941    pub(crate) per_tool_timeout_secs: u64,
942    /// Parallel batch execution timeout in seconds (default: 300).
943    pub(crate) parallel_batch_timeout_secs: u64,
944    /// Resolved LLM stream transport/semantic watchdog policy. The same value
945    /// is passed to main response streams and auxiliary silent streams.
946    pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
947    /// Permission mode for this execution (default: None = use PermissionConfig's mode).
948    pub(crate) permission_mode: Option<PermissionMode>,
949    /// Optional Gold observe-only evaluator configuration.
950    ///
951    /// When `None` or `enabled == false`, Gold evaluation is disabled and the
952    /// existing execute/respond/resume loop remains unchanged.
953    pub(crate) gold_config: Option<GoldConfig>,
954    /// Optional guardian adversarial-review gate configuration. When `None` or
955    /// `enabled == false`, the guardian terminal gate is inactive.
956    pub(crate) guardian_config: Option<GuardianConfig>,
957    /// Late-bound spawner for the guardian reviewer child. `None` (the default)
958    /// leaves the guardian gate inert even when `guardian_config.enabled` is set,
959    /// since the runner cannot create a child without it. Wired by the server.
960    pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
961    /// Late-bound hook that arranges a self-resume for a session suspended
962    /// waiting on background Bash shells (issue #84 Phase 2b). `None` (the
963    /// default) leaves the bash suspend gate inert: the gate refuses to suspend
964    /// without a wired hook, so a session can never strand itself without a
965    /// resume path. Wired by the server (the completion coordinator impl).
966    pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
967    /// Late-bound sink that pushes a completed background Bash shell's result
968    /// into this session's loop (issue #84 Phase 2b follow-up) — injected at the
969    /// next round boundary while the loop is actively iterating, or delivered via
970    /// resume when it is idle. Threaded onto the tool dispatch context (like
971    /// `can_async_resume`) so the Bash tool can hand it to the shell's
972    /// completion-poll task. `None` (the default) leaves the push inert; the
973    /// durable end-of-turn suspend/poll backstop (`bash_resume_hook`) still runs.
974    /// Wired by the server (the completion coordinator impl).
975    pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
976    /// Late-bound delegate that routes a child's gated-tool approval request up
977    /// to its parent (Phase 2). `None` (the default) leaves child gating on its
978    /// legacy path. Wired by the server.
979    pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
980    /// Frozen lifecycle-hook registry for this run. The default registry is
981    /// empty, and every seam checks `has_hooks_for` before constructing payloads.
982    pub(crate) hook_runner: Arc<HookRunner>,
983    /// Enable dynamic per-round model routing based on task complexity.
984    /// When true, the pipeline classifies complexity at each round end and
985    /// stores the result in session metadata.
986    pub(crate) features_dynamic_model_routing: bool,
987    /// Opt-in, single auxiliary check before publishing a final answer.
988    pub(crate) features_final_evidence_check: bool,
989    /// Optional per-round resolver for auxiliary model settings that should
990    /// follow live global config rather than stay frozen for the whole run.
991    ///
992    /// The main chat model remains session/request scoped; this hook is only
993    /// for fast/background/planning/search/summarization helpers.
994    pub(crate) auxiliary_model_resolver:
995        Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
996    /// Optional per-round resolver for the disabled tool/skill sets so they follow
997    /// LIVE global config instead of staying frozen for the whole run. Returns the
998    /// current `(disabled_tools, disabled_skill_ids)`. When `None`, the snapshotted
999    /// `disabled_tools` / `disabled_skill_ids` fields below are used (#44 behavior).
1000    /// Re-resolved each round at the tool-schema filter, so disabling/re-enabling a
1001    /// tool mid-run takes effect on the next round. #136.
1002    pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
1003    /// Server-level usage guidance contributed by the run's tool executor —
1004    /// chiefly the `instructions` connected MCP servers return from `initialize`.
1005    /// Captured once at config construction (from `ToolExecutor::tool_guidance`)
1006    /// and appended to the tool-guide section of the system prompt, so a server's
1007    /// own how-to-use notes appear only while that server is loaded for the run.
1008    pub(crate) mcp_tool_guidance: Option<String>,
1009    /// Per-run resource guardrails (issue #221): already resolved — the
1010    /// per-request override merged over the config-level default (see
1011    /// [`AgentRuntime::execute`](crate::runtime::runtime::AgentRuntime::execute)).
1012    /// Checked after every round; exceeding a configured limit gracefully
1013    /// stops the run. Includes the run's round cap (`max_rounds`): `None`
1014    /// means unlimited rounds (the historical hard-coded 200 was removed);
1015    /// a configured cap keeps the issue #29 exhaustion contract (metadata
1016    /// stamp, visible notification, exactly one final summary turn).
1017    pub(crate) run_budget: bamboo_config::RunBudgetConfig,
1018    pub(crate) counted_loop: Option<super::counted_loop::CountedLoopConfig>,
1019}
1020
1021impl AgentLoopConfig {
1022    /// Data-only execution transport. This accessor does not classify or grant.
1023    pub fn initial_untrusted_inputs(&self) -> Option<&UntrustedExecutionInputs> {
1024        self.initial_untrusted_inputs.as_ref()
1025    }
1026}
1027
1028impl Default for AgentLoopConfig {
1029    fn default() -> Self {
1030        Self {
1031            freeze_tool_exposure_for_cache: true,
1032            ticket_worker_plan: None,
1033            system_prompt: None,
1034            disabled_skill_ids: BTreeSet::new(),
1035            selected_skill_ids: None,
1036            selected_skill_mode: None,
1037            initial_untrusted_inputs: None,
1038            sdk_skill_execution_host: None,
1039            additional_tool_schemas: Vec::new(),
1040            tool_registry: Arc::new(ToolRegistry::new()),
1041            skill_manager: None,
1042            project_context_resolver: None,
1043            skip_initial_user_message: false,
1044            storage: None,
1045            persistence: None,
1046            guidance_active_run_id: None,
1047            session_inbox: None,
1048            session_activation_notifications: None,
1049            attachment_reader: None,
1050            metrics_collector: None,
1051            model_name: None,
1052            fast_model_name: None,
1053            fast_model_provider: None,
1054            auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
1055            background_model_name: None,
1056            planning_model_name: None,
1057            search_model_name: None,
1058            compression_instructions: None,
1059            summary_target_ratio: 0.20,
1060            context_management: bamboo_config::ContextManagementConfig::default(),
1061            summary_safe_window_percent: 80,
1062            summarization_model_name: None,
1063            background_model_provider: None,
1064            summarization_model_provider: None,
1065            provider_name: None,
1066            provider_type: None,
1067            reasoning_effort: None,
1068            app_data_dir: None,
1069            memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
1070            disabled_tools: BTreeSet::new(),
1071            token_budget: None,
1072            model_limits_section: None,
1073            legacy_model_limits: None,
1074            image_fallback: None,
1075            prompt_memory_flags: PromptMemoryFlags::default(),
1076            max_tool_calls_per_round: 80,
1077            max_consecutive_failures_per_tool: 3,
1078            per_tool_timeout_secs: 120,
1079            parallel_batch_timeout_secs: 300,
1080            stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
1081            permission_mode: None,
1082            gold_config: None,
1083            guardian_config: None,
1084            guardian_spawner: None,
1085            bash_resume_hook: None,
1086            bash_completion_sink: None,
1087            approval_delegate: None,
1088            hook_runner: Arc::new(HookRunner::new()),
1089            features_dynamic_model_routing: false,
1090            features_final_evidence_check: false,
1091            auxiliary_model_resolver: None,
1092            disabled_filter_resolver: None,
1093            mcp_tool_guidance: None,
1094            run_budget: bamboo_config::RunBudgetConfig::default(),
1095            counted_loop: None,
1096        }
1097    }
1098}
1099
1100impl AgentLoopConfig {
1101    pub fn bind_ticket_worker_plan(
1102        &mut self,
1103        plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
1104    ) {
1105        self.ticket_worker_plan = Some(plan);
1106    }
1107    /// Live `(disabled_tools, disabled_skill_ids)` for the current round: the
1108    /// resolver if one is wired (#136 — follows live global config between
1109    /// rounds), else the per-run snapshot (#44 frozen behavior). `Cow` avoids
1110    /// cloning the snapshot in the common no-resolver path (SDK / tests).
1111    pub(crate) fn resolve_disabled_filters(
1112        &self,
1113    ) -> (
1114        std::borrow::Cow<'_, BTreeSet<String>>,
1115        std::borrow::Cow<'_, BTreeSet<String>>,
1116    ) {
1117        match &self.disabled_filter_resolver {
1118            Some(resolver) => {
1119                let (tools, skills) = resolver();
1120                (
1121                    std::borrow::Cow::Owned(tools),
1122                    std::borrow::Cow::Owned(skills),
1123                )
1124            }
1125            None => (
1126                std::borrow::Cow::Borrowed(&self.disabled_tools),
1127                std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
1128            ),
1129        }
1130    }
1131
1132    /// The active session goal to surface to the main agent, or `None` when
1133    /// Gold is disabled or no goal is set. Falls back to the legacy
1134    /// `evaluation_prompt` for back-compat via [`GoldConfig::effective_goal`].
1135    pub fn active_goal(&self) -> Option<&str> {
1136        self.gold_config
1137            .as_ref()
1138            .filter(|cfg| cfg.enabled)
1139            .and_then(GoldConfig::effective_goal)
1140    }
1141
1142    /// Whether the Codex-style autonomous goal loop is active for this run.
1143    ///
1144    /// This requires Gold to be enabled, a goal to be set, AND auto-continue to
1145    /// be on. Only then is the `update_goal` self-report tool surfaced to the
1146    /// model and the terminal double-check allowed to veto a premature stop.
1147    /// When Gold is enabled without auto-continue, the evaluator stays purely
1148    /// observational (legacy behavior).
1149    pub fn goal_loop_active(&self) -> bool {
1150        self.gold_config.as_ref().is_some_and(|cfg| {
1151            cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
1152        })
1153    }
1154
1155    /// Whether the guardian review gate is active for this run: a spawner is
1156    /// wired (so the runner can actually create the reviewer child) AND the
1157    /// config is present and enabled.
1158    pub fn guardian_active(&self) -> bool {
1159        self.guardian_spawner.is_some()
1160            && self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
1161    }
1162
1163    /// Maximum guardian review passes for this run (the budget). `0` when no
1164    /// guardian config is set.
1165    pub fn guardian_max_reviews(&self) -> u32 {
1166        self.guardian_config
1167            .as_ref()
1168            .map_or(0, |cfg| cfg.max_reviews)
1169    }
1170
1171    /// The reviewer model override, if a guardian config sets one.
1172    pub fn guardian_model(&self) -> Option<&str> {
1173        self.guardian_config
1174            .as_ref()
1175            .and_then(|cfg| cfg.model_name.as_deref())
1176    }
1177
1178    /// Whether child→parent approval delegation is wired for this run.
1179    pub fn delegation_active(&self) -> bool {
1180        self.approval_delegate.is_some()
1181    }
1182}
1183
1184#[cfg(test)]
1185mod tests;
1186
1187#[cfg(test)]
1188mod execution_input_tests {
1189    use super::*;
1190    fn request() -> bamboo_domain::SessionSkillRequest {
1191        bamboo_domain::SessionSkillRequest {
1192            selections: vec![bamboo_domain::SessionSkillSelection {
1193                id: "exact Case".into(),
1194                source: "builtin".into(),
1195                revision: 1,
1196                args: serde_json::json!({"raw":"保持原样"}),
1197            }],
1198            mode: None,
1199        }
1200    }
1201    #[test]
1202    fn execution_input_owned_bounds_and_old_config_default() {
1203        assert!(AgentLoopConfig::default()
1204            .initial_untrusted_inputs()
1205            .is_none());
1206        let request = request();
1207        let input = UntrustedInputObservation::new("exact-input", Some(&request)).unwrap();
1208        assert_eq!(input.input_id(), "exact-input");
1209        assert_eq!(input.request(), Some(&request));
1210        for id in [
1211            "",
1212            " leading",
1213            "trailing ",
1214            "unsafe/id",
1215            "unsafe\\id",
1216            "unsafe..id",
1217        ] {
1218            assert!(UntrustedInputObservation::new(id, None).is_none());
1219        }
1220        assert!(UntrustedInputObservation::new(&"a".repeat(257), None).is_none());
1221        assert!(UntrustedExecutionInputs::new(Vec::new()).is_none());
1222        let batch = UntrustedExecutionInputs::new(
1223            (0..128)
1224                .map(|i| {
1225                    UntrustedInputObservation::new(&format!("input-{i}"), Some(&request)).unwrap()
1226                })
1227                .collect(),
1228        )
1229        .unwrap();
1230        assert_eq!(batch.observations().len(), 128);
1231        assert_eq!(batch.observations()[127].input_id(), "input-127");
1232        assert!(UntrustedExecutionInputs::new(
1233            (0..129)
1234                .map(|i| UntrustedInputObservation::new(&format!("input-{i}"), None).unwrap())
1235                .collect()
1236        )
1237        .is_none());
1238        assert!(UntrustedExecutionInputs::new(vec![
1239            UntrustedInputObservation::new("same", None).unwrap(),
1240            UntrustedInputObservation::new("same", None).unwrap()
1241        ])
1242        .is_none());
1243        let mut malformed = request;
1244        malformed.selections[0].args = (0..65).fold(serde_json::Value::Null, |v, _| {
1245            serde_json::Value::Array(vec![v])
1246        });
1247        assert!(UntrustedInputObservation::new("deep", Some(&malformed)).is_none());
1248    }
1249    #[test]
1250    fn execution_input_only_explicit_checked_commit_slice_copies_request_fields() {
1251        let request = request();
1252        let mut user = bamboo_domain::SessionMessageEnvelope::user_input("target", "ordinary");
1253        if let bamboo_domain::SessionMessageBody::Content(content) = &mut user.body {
1254            content.skill_request = Some(request.clone());
1255        }
1256        let id = user.id.to_string();
1257        let plain = user.to_provider_message().unwrap();
1258        let root = user
1259            .with_root_chat_prompt("system prompt is not retained".into())
1260            .unwrap()
1261            .to_provider_message()
1262            .unwrap();
1263        for message in [plain, root] {
1264            let batch = UntrustedExecutionInputs::from_committed_messages(&[message]).unwrap();
1265            assert_eq!(batch.observations().len(), 1);
1266            assert_eq!(batch.observations()[0].input_id(), id);
1267            assert_eq!(batch.observations()[0].request(), Some(&request));
1268        }
1269        let text = bamboo_agent_core::Message::user("{\"skill_request\":\"fragment\"}");
1270        assert!(UntrustedExecutionInputs::from_committed_messages(&[text]).is_none());
1271        assert!(UntrustedExecutionInputs::from_committed_messages(&[]).is_none());
1272    }
1273}
1274
1275#[cfg(test)]
1276mod sdk_acceptance_tests {
1277    use super::*;
1278    use crate::runtime::managers::lifecycle::BoundedInputRequestBatch;
1279    use bamboo_agent_core::{Message, Role, Session};
1280
1281    struct Repository {
1282        stored: std::sync::Mutex<Option<Session>>,
1283        reject: bool,
1284        corrupt: bool,
1285    }
1286    #[async_trait::async_trait]
1287    impl RuntimeSessionPersistence for Repository {
1288        async fn save_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1289            if self.reject {
1290                return Err(std::io::Error::other("checkpoint refused"));
1291            }
1292            let mut accepted = session.clone();
1293            if self.corrupt {
1294                accepted
1295                    .messages
1296                    .iter_mut()
1297                    .find(|message| message.role == Role::User)
1298                    .unwrap()
1299                    .content
1300                    .push_str(" changed");
1301            }
1302            *self.stored.lock().unwrap() = Some(accepted);
1303            Ok(())
1304        }
1305        async fn checkpoint_runtime_session(&self, session: &mut Session) -> std::io::Result<()> {
1306            self.save_runtime_session(session).await
1307        }
1308        async fn load_runtime_session(&self, _: &str) -> std::io::Result<Option<Session>> {
1309            Ok(self.stored.lock().unwrap().clone())
1310        }
1311    }
1312    fn repository(reject: bool, corrupt: bool) -> Arc<dyn RuntimeSessionPersistence> {
1313        Arc::new(Repository {
1314            stored: Default::default(),
1315            reject,
1316            corrupt,
1317        })
1318    }
1319    fn producer(session: &Session, user: &Message, typed: bool) -> UntrustedExecutionInputs {
1320        UntrustedExecutionInputs::new(vec![UntrustedInputObservation::new(&user.id, None).unwrap()])
1321            .unwrap()
1322            .bind_sdk_append(
1323                "actual-sdk-execution".into(),
1324                SdkPendingInput {
1325                    session_id: session.id.clone(),
1326                    input_id: user.id.clone(),
1327                    header_digest: sdk_message_header_digest(user),
1328                    final_digest: typed.then(|| sdk_message_digest(user)),
1329                },
1330                None,
1331            )
1332    }
1333    fn current(
1334        inputs: &UntrustedExecutionInputs,
1335        session: &Session,
1336    ) -> Option<BoundedInputRequestBatch> {
1337        let (execution_id, observation) = inputs.take_startup_observation(&session.id).unwrap();
1338        assert_eq!(execution_id, "actual-sdk-execution");
1339        let mut current = None;
1340        observation.update_current(&mut current, &session.id, &execution_id);
1341        assert!(inputs.take_startup_observation(&session.id).is_none());
1342        current
1343    }
1344    #[tokio::test]
1345    async fn sdk_startup_requires_exact_checkpoint_and_preserves_request_absence_as_new() {
1346        let mut session = Session::new("sdk-accept", "model");
1347        let user = Message::user("final prepared");
1348        let inputs = producer(&session, &user, true);
1349        session.add_message(user.clone());
1350        inputs
1351            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1352            .await
1353            .unwrap();
1354        let batch = current(&inputs, &session).unwrap();
1355        assert_eq!(batch.records().len(), 1);
1356        assert_eq!(batch.records()[0].input_id, user.id);
1357        assert_eq!(
1358            batch.records()[0].source,
1359            bamboo_domain::SessionMessageSource::User
1360        );
1361        assert!(batch.records()[0].request.is_none());
1362    }
1363    #[tokio::test]
1364    async fn sdk_startup_unknown_checkpoint_or_modified_final_user_stays_unavailable() {
1365        for (reject, corrupt, mutate, duplicate, wrong_session) in [
1366            (true, false, false, false, false),
1367            (false, true, false, false, false),
1368            (false, false, true, false, false),
1369            (false, false, false, true, false),
1370            (false, false, false, false, true),
1371        ] {
1372            let mut session = Session::new("sdk-deny", "model");
1373            let user = Message::user("prepared");
1374            let inputs = producer(&session, &user, true);
1375            session.add_message(user.clone());
1376            if mutate {
1377                session.messages[0].content.push_str(" changed");
1378            }
1379            if duplicate {
1380                session.add_message(user);
1381            }
1382            if wrong_session {
1383                session.id = "wrong".into();
1384            }
1385            assert!(inputs
1386                .seal_sdk_input(&mut session, Some(&repository(reject, corrupt)))
1387                .await
1388                .is_err());
1389            if wrong_session {
1390                assert!(inputs.take_startup_observation(&session.id).is_none());
1391            } else {
1392                assert!(current(&inputs, &session).is_none());
1393            }
1394        }
1395    }
1396    #[tokio::test]
1397    async fn sdk_legacy_hook_rewrite_is_bound_to_original_id_while_generic_history_is_unsealed() {
1398        let mut session = Session::new("sdk-string", "model");
1399        let user = Message::user("original");
1400        let inputs = producer(&session, &user, false);
1401        session.add_message(user.clone());
1402        session.messages[0].content = "real portable hook result".into();
1403        inputs
1404            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1405            .await
1406            .unwrap();
1407        assert_eq!(
1408            current(&inputs, &session).unwrap().records()[0].input_id,
1409            user.id
1410        );
1411        let generic =
1412            UntrustedExecutionInputs::new(vec![
1413                UntrustedInputObservation::new(&user.id, None).unwrap()
1414            ])
1415            .unwrap();
1416        generic
1417            .seal_sdk_input(&mut session, Some(&repository(false, false)))
1418            .await
1419            .unwrap();
1420        assert!(generic.sdk_execution_id().is_none());
1421        assert!(generic.take_startup_observation(&session.id).is_none());
1422    }
1423    #[test]
1424    fn sdk_header_binding_accepts_only_the_canonical_prompt_changes() {
1425        let mut original = Message::user("canonical");
1426        original.content_parts = Some(vec![
1427            bamboo_domain::MessagePart::Text {
1428                text: "unrelated native text".into(),
1429            },
1430            bamboo_domain::MessagePart::Text {
1431                text: "canonical".into(),
1432            },
1433        ]);
1434        let digest = sdk_message_header_digest(&original);
1435        let mut prepared = original.clone();
1436        prepared.content = "canonical plus F".into();
1437        if let bamboo_domain::MessagePart::Text { text } =
1438            &mut prepared.content_parts.as_mut().unwrap()[1]
1439        {
1440            *text = prepared.content.clone();
1441        }
1442        assert_eq!(sdk_message_header_digest(&prepared), digest);
1443        prepared.id = "different".into();
1444        assert_ne!(sdk_message_header_digest(&prepared), digest);
1445        prepared = original;
1446        if let bamboo_domain::MessagePart::Text { text } =
1447            &mut prepared.content_parts.as_mut().unwrap()[0]
1448        {
1449            *text = "tampered".into();
1450        }
1451        assert_ne!(sdk_message_header_digest(&prepared), digest);
1452    }
1453}