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    /// Master switch for Gold observe-only evaluation.
71    #[serde(default)]
72    pub enabled: bool,
73    /// Independent switch for Phase 2 low-risk auto-answer.
74    ///
75    /// Kept separate from `enabled` so Phase 1 observe-only users do not
76    /// implicitly opt into automatic clarification responses.
77    #[serde(default)]
78    pub auto_answer_enabled: bool,
79    /// Independent switch for Phase 3 server-side auto-continue.
80    ///
81    /// Kept separate from both `enabled` and `auto_answer_enabled` so users can
82    /// opt into terminal auto-resume explicitly without enabling other Gold
83    /// automation behaviors.
84    #[serde(default)]
85    pub auto_continue_enabled: bool,
86    /// Optional dedicated model for Gold evaluation. Falls back to fast model,
87    /// then the main chat model when absent.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub model_name: Option<String>,
90    /// The user's goal for this session.
91    ///
92    /// Unlike `evaluation_prompt` (which only tunes the *judge*), the goal is
93    /// surfaced to the *main* executing agent as a persistent system-prompt
94    /// block so it actively works toward it. The Gold evaluator also measures
95    /// progress against this text.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub goal: Option<String>,
98    /// Optional custom prompt suffix appended to the built-in Gold evaluator
99    /// prompt. This tunes the judge only; it does not set the goal.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub evaluation_prompt: Option<String>,
102    /// Output token limit for the Gold evaluator call.
103    #[serde(default = "default_gold_max_output_tokens")]
104    pub max_output_tokens: u32,
105    /// Maximum number of automatic Gold continuations allowed per session.
106    #[serde(default = "default_gold_max_auto_continuations")]
107    pub max_auto_continuations: u32,
108    /// Minimum evaluator confidence required before Gold auto-continues or
109    /// auto-answers. Defaults to `medium` so the loop fires on reasonably
110    /// confident verdicts rather than only `high`.
111    #[serde(default = "default_gold_min_confidence")]
112    pub min_auto_continue_confidence: GoldConfidence,
113}
114
115impl Default for GoldConfig {
116    fn default() -> Self {
117        Self {
118            enabled: false,
119            auto_answer_enabled: false,
120            auto_continue_enabled: false,
121            model_name: None,
122            goal: None,
123            evaluation_prompt: None,
124            max_output_tokens: default_gold_max_output_tokens(),
125            max_auto_continuations: default_gold_max_auto_continuations(),
126            min_auto_continue_confidence: default_gold_min_confidence(),
127        }
128    }
129}
130
131impl GoldConfig {
132    /// The session goal text, falling back to the legacy `evaluation_prompt`
133    /// for sessions created before the dedicated `goal` field existed.
134    ///
135    /// Returns `None` when neither field holds non-empty text.
136    pub fn effective_goal(&self) -> Option<&str> {
137        self.goal
138            .as_deref()
139            .or(self.evaluation_prompt.as_deref())
140            .map(str::trim)
141            .filter(|value| !value.is_empty())
142    }
143}
144
145fn default_guardian_max_reviews() -> u32 {
146    2
147}
148
149/// Configuration for the guardian adversarial-review terminal gate.
150///
151/// Mirrors [`GoldConfig`]: a plain, serde-defaulting struct surfaced per run.
152/// When `enabled` is false (the default) the guardian gate is inactive and the
153/// terminal completion path is unchanged.
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155#[serde(default)]
156pub struct GuardianConfig {
157    /// Master switch for the guardian review gate.
158    #[serde(default)]
159    pub enabled: bool,
160    /// Optional dedicated reviewer model. Falls back to the run's main model.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub model_name: Option<String>,
163    /// Maximum guardian review passes per run (budget; mirrors
164    /// [`GoldConfig::max_auto_continuations`]).
165    #[serde(default = "default_guardian_max_reviews")]
166    pub max_reviews: u32,
167}
168
169impl Default for GuardianConfig {
170    fn default() -> Self {
171        Self {
172            enabled: false,
173            model_name: None,
174            max_reviews: default_guardian_max_reviews(),
175        }
176    }
177}
178
179/// Late-bound spawner for the guardian reviewer child.
180///
181/// The runner cannot construct a child directly: the `SpawnScheduler` is built
182/// *after* the `Agent` that drives the runner (a construction-order cycle), so
183/// the terminal gate spawns the reviewer through this trait object, injected
184/// per-request on [`AgentLoopConfig`] exactly like `auxiliary_model_resolver`.
185/// The implementation lives in the server (it captures the already-built
186/// scheduler + child-session adapter); the engine holds only the trait, keeping
187/// the engine free of any dependency on server/AppState types.
188#[async_trait::async_trait]
189pub trait GuardianSpawner: Send + Sync {
190    /// Create a read-only reviewer child for `parent_session_id`, seeded with
191    /// `review_prompt`, enqueue it to run, and return its session id so the
192    /// caller can register a wait on it.
193    async fn spawn_guardian_review(
194        &self,
195        parent_session: &bamboo_agent_core::Session,
196        review_prompt: String,
197        model: String,
198        disabled_tools: Option<BTreeSet<String>>,
199    ) -> Result<String, String>;
200}
201
202/// Hidden resume-message `runtime_kind` metadata value for a bash-completion
203/// self-resume (issue #84 Phase 2b). Shared by the producer (the self-resume
204/// task that appends the resume message) and the consumer (the suspend-
205/// finalization discriminant arm that preserves it), so a typo in one cannot
206/// desync from the other and silently drop the resume trigger.
207pub const BASH_COMPLETION_RESUME_KIND: &str = "bash_completion_resume";
208
209/// Re-exported so peers that already `use crate::runtime::config::{BashResumeHook, …}`
210/// (the runtime/spawn threading) can name the completion sink the same way,
211/// rather than reaching into `bamboo_agent_core` separately.
212pub use bamboo_agent_core::BashCompletionSink;
213
214/// Late-bound hook that arranges a self-resume for a session suspended waiting
215/// on background Bash shells (issue #84 Phase 2b). Injected per-request on
216/// [`AgentLoopConfig`] exactly like [`GuardianSpawner`]; the implementation
217/// lives in the session-app layer (on the completion coordinator) where the
218/// resume port ([`crate::session_app::resume::ResumeExecutionPort`]) is
219/// reachable.
220///
221/// The hook spawns a detached task that **polls the live background-shell
222/// registry** until every captured shell is no longer running, then clears the
223/// wait and resumes the session. Polling — not the one-shot `BashCompleted`
224/// event — is the liveness guarantee: even if a shell completes between the
225/// suspend snapshot and the hook's first poll, or before any event subscriber
226/// exists, the registry will report it as not-running and the session resumes.
227pub trait BashResumeHook: Send + Sync {
228    /// Arrange a detached self-resume for `session_id`, which has just been
229    /// durably suspended waiting on the background shells in `bash_ids`.
230    fn arrange_bash_self_resume(&self, session_id: String, bash_ids: Vec<String>);
231}
232
233/// A child sub-agent's request to have a gated tool approved by its parent.
234///
235/// A non-bypassed child cannot answer its own permission prompt (no human is
236/// attached to a child session), so the request is delegated up to the parent.
237#[derive(Debug, Clone)]
238pub struct ChildApprovalRequest {
239    pub child_session_id: String,
240    pub parent_session_id: String,
241    /// The gated tool call on the child to re-execute once approved.
242    pub child_tool_call_id: String,
243    pub tool_name: String,
244    /// Permission type as a string (e.g. "WriteFile", "ExecuteCommand").
245    pub permission_type: String,
246    /// The concrete resource the permission applies to (path, command, …).
247    pub resource: String,
248    /// Human-facing approval question to surface on the parent.
249    pub question: String,
250    /// The raw `awaiting_permission_approval` payload the child's executor built,
251    /// so the parent can reuse the existing grant-extraction path verbatim.
252    pub approval_payload: serde_json::Value,
253}
254
255/// What the executor should do after delegating a child's approval upward.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum ChildApprovalOutcome {
258    /// Registered on the parent; the child must SUSPEND and await the decision.
259    Delegated,
260    /// Parent policy auto-approved (bypass / existing grant); proceed to execute.
261    AutoApproved,
262    /// Parent policy auto-denied; the executor must deny the tool.
263    AutoDenied,
264}
265
266/// Late-bound delegate that routes a child's approval request up to its parent.
267///
268/// Injected per-request on [`AgentLoopConfig`] exactly like [`GuardianSpawner`];
269/// the trait lives in the engine, the implementation in the server (it owns the
270/// parent session store + pending-question + notification machinery).
271#[async_trait::async_trait]
272pub trait ApprovalDelegate: Send + Sync {
273    /// Register `request` on its parent (or auto-resolve by policy) and report
274    /// what the child's executor should do next.
275    async fn delegate_child_approval(
276        &self,
277        request: ChildApprovalRequest,
278    ) -> Result<ChildApprovalOutcome, String>;
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum ImageFallbackMode {
283    Placeholder,
284    Error,
285    Ocr,
286    /// Use a vision-capable LLM to describe the image, then replace the image
287    /// with the textual description so that text-only models can understand
288    /// the content.
289    Vision,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct ImageFallbackConfig {
294    pub mode: ImageFallbackMode,
295    /// Vision model name for `Vision` mode. Falls back to the session's main model
296    /// when `None`.
297    pub vision_model: Option<String>,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub struct PromptMemoryFlags {
302    pub project_prompt_injection: bool,
303    pub relevant_recall: bool,
304    pub relevant_recall_rerank: bool,
305    pub project_first_dream: bool,
306    pub ledger_agenda: bool,
307}
308
309impl Default for PromptMemoryFlags {
310    fn default() -> Self {
311        Self {
312            project_prompt_injection: true,
313            relevant_recall: true,
314            relevant_recall_rerank: false,
315            project_first_dream: true,
316            ledger_agenda: true,
317        }
318    }
319}
320
321impl From<&MemoryConfig> for PromptMemoryFlags {
322    fn from(value: &MemoryConfig) -> Self {
323        Self {
324            project_prompt_injection: value.project_prompt_injection,
325            relevant_recall: value.relevant_recall,
326            relevant_recall_rerank: value.relevant_recall_rerank,
327            project_first_dream: value.project_first_dream,
328            ledger_agenda: value.ledger_agenda_injection,
329        }
330    }
331}
332
333/// Configuration for the agent loop.
334///
335/// # One-config-per-run invariant (#44)
336///
337/// These values are SNAPSHOTTED once, from the live `Config` under a brief read
338/// lock, at the start of `AgentRuntime::execute()`. The entire multi-round run —
339/// which can last minutes — then uses this frozen snapshot. Changing config
340/// (model names, provider, `disabled_tools`/`disabled_skills`, memory flags,
341/// token budget, …) while a run is in flight does NOT affect that run; the new
342/// values are picked up on the NEXT execution (i.e. the next user turn / session
343/// restart), not the next round of the current run.
344///
345/// This is intentional: a run sees a stable configuration, so its behavior can't
346/// shift underneath it mid-execution. The deliberate exceptions are the
347/// late-bound, per-request trait objects that resolve LIVE each time they're
348/// used rather than being snapshotted — `auxiliary_model_resolver` (auxiliary
349/// model selection) and `guardian_spawner` (the reviewer child). If a frozen
350/// field ever needs to become live-per-round, follow that resolver pattern
351/// rather than widening the snapshot.
352#[non_exhaustive]
353pub struct AgentLoopConfig {
354    pub(crate) max_rounds: usize,
355    pub(crate) system_prompt: Option<String>,
356    /// Skill IDs that are disabled globally for this execution.
357    pub(crate) disabled_skill_ids: BTreeSet<String>,
358    /// Optional explicit skill selection for this execution.
359    /// When set, only these skill IDs are considered for skill context and allowlists.
360    pub(crate) selected_skill_ids: Option<Vec<String>>,
361    /// Optional active skill mode for this execution.
362    ///
363    /// When set, skill discovery prefers `skills-<mode>` directories over generic
364    /// directories for the same skill id.
365    pub(crate) selected_skill_mode: Option<String>,
366    pub(crate) additional_tool_schemas: Vec<ToolSchema>,
367    pub(crate) tool_registry: Arc<ToolRegistry>,
368    pub(crate) skill_manager: Option<Arc<SkillManager>>,
369    /// Stable Project identity/resource resolver. The server wires the
370    /// authoritative registry adapter once on `AgentRuntimeBuilder`.
371    pub(crate) project_context_resolver:
372        Option<Arc<crate::project_context::ProjectContextResolver>>,
373    /// If true, skip appending the initial user message (already present in session).
374    pub(crate) skip_initial_user_message: bool,
375    /// Optional storage for persisting session changes
376    pub(crate) storage: Option<Arc<dyn Storage>>,
377    /// Optional runtime persistence for non-authoritative session saves.
378    /// When set, engine save sites use this instead of `storage` for writes.
379    pub(crate) persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
380    /// Durable logical-session inbox admitted at safe round boundaries.
381    pub(crate) session_inbox: Option<Arc<dyn bamboo_domain::SessionInboxPort>>,
382    /// Active-owner wake generation. The loop consumes this at the same safe
383    /// boundary where it drains SessionInbox; it never interrupts an in-flight
384    /// provider/tool operation.
385    pub(crate) session_activation_notifications:
386        Option<Arc<parking_lot::Mutex<tokio::sync::watch::Receiver<u64>>>>,
387    /// Optional attachment reader for resolving `bamboo-attachment://...` references
388    /// into `data:` URLs for upstream providers. This must not mutate session storage.
389    pub(crate) attachment_reader: Option<Arc<dyn AttachmentReader>>,
390    /// Optional asynchronous metrics collector
391    pub(crate) metrics_collector: Option<MetricsCollector>,
392    /// Model name used for metrics attribution
393    pub(crate) model_name: Option<String>,
394    /// Fast/cheap model for lightweight tasks (task evaluation, search, etc.).
395    ///
396    /// Call sites may fall back to `model_name` when this is unset.
397    pub(crate) fast_model_name: Option<String>,
398    /// Optional provider override for lightweight fast-model LLM calls.
399    pub(crate) fast_model_provider: Option<Arc<dyn LLMProvider>>,
400    /// Process-wide concurrency ceiling for low-priority auxiliary evaluation
401    /// requests sharing the same provider allocation and model. Foreground
402    /// requests do not acquire this budget.
403    pub(crate) auxiliary_evaluation_max_concurrency: usize,
404    /// Fast/cheap model for memory/background tasks.
405    ///
406    /// This must not silently fall back to the main interaction model.
407    pub(crate) background_model_name: Option<String>,
408
409    /// Model for planning/coordination tasks (task decomposition, architecture).
410    /// Falls back to `model_name` when unset.
411    pub(crate) planning_model_name: Option<String>,
412    /// Model for search/navigation tasks (grep, file listing, symbol resolution).
413    /// Falls back to `fast_model_name` when unset.
414    pub(crate) search_model_name: Option<String>,
415    /// Custom instructions for conversation summarization, injected into the
416    /// LLM summary prompt. Lets users control what the summary focuses on.
417    ///
418    /// Resolution order: session-level > config-level > built-in defaults.
419    pub(crate) compression_instructions: Option<String>,
420    /// Desired final summary size relative to the raw source tokens represented
421    /// by it. Values are normalized at the compression boundary.
422    pub(crate) summary_target_ratio: f64,
423    /// Safe request ceiling as a percentage of the selected summarization
424    /// model's context window.
425    pub(crate) summary_safe_window_percent: u8,
426    /// Dedicated model for summarization. Falls back to `background_model_name`.
427    pub(crate) summarization_model_name: Option<String>,
428    /// Optional provider override for memory/background model LLM calls.
429    ///
430    /// When set, memory recall rerank and other memory/background tasks use this
431    /// provider instead of the shared agent loop provider.
432    pub(crate) background_model_provider: Option<Arc<dyn LLMProvider>>,
433    /// Optional provider override for summarization / context compression calls.
434    ///
435    /// When set, conversation/task summarization uses this provider instead of
436    /// the shared agent loop provider.
437    pub(crate) summarization_model_provider: Option<Arc<dyn LLMProvider>>,
438    /// Provider routing key used for provider-specific request behavior.
439    ///
440    /// In multi-instance mode this may be the instance id.
441    pub(crate) provider_name: Option<String>,
442    /// Underlying provider type (for example `openai`, `anthropic`, `copilot`).
443    ///
444    /// This is distinct from `provider_name` so provider-specific behavior can
445    /// remain correct when routing keys are instance ids.
446    pub(crate) provider_type: Option<String>,
447    /// Optional request-time reasoning effort override.
448    pub(crate) reasoning_effort: Option<ReasoningEffort>,
449    /// Bamboo application data directory (typically `~/.bamboo`).
450    ///
451    /// Used by runtime features that persist auxiliary artifacts outside the
452    /// session store, such as durable plan mode files under `~/.bamboo/plan`.
453    pub(crate) app_data_dir: Option<PathBuf>,
454    /// Tool names that should be excluded from schemas sent to the LLM.
455    pub(crate) disabled_tools: BTreeSet<String>,
456    /// Token budget for context management (optional, defaults to model's limits)
457    pub(crate) token_budget: Option<TokenBudget>,
458    /// Legacy `config.json` `model_limits` value, snapshotted from the live
459    /// in-memory Config when this loop config is built. Consulted per model when
460    /// the instance-local `model_limits.json` has no matching entry or cannot be
461    /// loaded, so the engine never does a fresh disk-reading `Config::new()`
462    /// (which would also clobber the global env-var cache). #38.
463    pub(crate) legacy_model_limits: Option<serde_json::Value>,
464    /// Optional image fallback behavior applied to *LLM requests only* (never persisted).
465    ///
466    /// This is intended for text-only provider paths where image parts must be degraded
467    /// (placeholder / OCR / error) without leaking into stored session history or UI.
468    pub(crate) image_fallback: Option<ImageFallbackConfig>,
469    /// Feature flags controlling prompt-time memory injection behavior.
470    pub(crate) prompt_memory_flags: PromptMemoryFlags,
471    /// Maximum tool calls allowed per round (default: 80).
472    pub(crate) max_tool_calls_per_round: usize,
473    /// Maximum consecutive failures per tool before circuit breaker (default: 3).
474    pub(crate) max_consecutive_failures_per_tool: usize,
475    /// Per-tool execution timeout in seconds (default: 120).
476    pub(crate) per_tool_timeout_secs: u64,
477    /// Parallel batch execution timeout in seconds (default: 300).
478    pub(crate) parallel_batch_timeout_secs: u64,
479    /// Resolved LLM stream transport/semantic watchdog policy. The same value
480    /// is passed to main response streams and auxiliary silent streams.
481    pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
482    /// Permission mode for this execution (default: None = use PermissionConfig's mode).
483    pub(crate) permission_mode: Option<PermissionMode>,
484    /// Optional Gold observe-only evaluator configuration.
485    ///
486    /// When `None` or `enabled == false`, Gold evaluation is disabled and the
487    /// existing execute/respond/resume loop remains unchanged.
488    pub(crate) gold_config: Option<GoldConfig>,
489    /// Optional guardian adversarial-review gate configuration. When `None` or
490    /// `enabled == false`, the guardian terminal gate is inactive.
491    pub(crate) guardian_config: Option<GuardianConfig>,
492    /// Late-bound spawner for the guardian reviewer child. `None` (the default)
493    /// leaves the guardian gate inert even when `guardian_config.enabled` is set,
494    /// since the runner cannot create a child without it. Wired by the server.
495    pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
496    /// Late-bound hook that arranges a self-resume for a session suspended
497    /// waiting on background Bash shells (issue #84 Phase 2b). `None` (the
498    /// default) leaves the bash suspend gate inert: the gate refuses to suspend
499    /// without a wired hook, so a session can never strand itself without a
500    /// resume path. Wired by the server (the completion coordinator impl).
501    pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
502    /// Late-bound sink that pushes a completed background Bash shell's result
503    /// into this session's loop (issue #84 Phase 2b follow-up) — injected at the
504    /// next round boundary while the loop is actively iterating, or delivered via
505    /// resume when it is idle. Threaded onto the tool dispatch context (like
506    /// `can_async_resume`) so the Bash tool can hand it to the shell's
507    /// completion-poll task. `None` (the default) leaves the push inert; the
508    /// durable end-of-turn suspend/poll backstop (`bash_resume_hook`) still runs.
509    /// Wired by the server (the completion coordinator impl).
510    pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
511    /// Late-bound delegate that routes a child's gated-tool approval request up
512    /// to its parent (Phase 2). `None` (the default) leaves child gating on its
513    /// legacy path. Wired by the server.
514    pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
515    /// Frozen lifecycle-hook registry for this run. The default registry is
516    /// empty, and every seam checks `has_hooks_for` before constructing payloads.
517    pub(crate) hook_runner: Arc<HookRunner>,
518    /// Enable dynamic per-round model routing based on task complexity.
519    /// When true, the pipeline classifies complexity at each round end and
520    /// stores the result in session metadata.
521    pub(crate) features_dynamic_model_routing: bool,
522    /// Optional per-round resolver for auxiliary model settings that should
523    /// follow live global config rather than stay frozen for the whole run.
524    ///
525    /// The main chat model remains session/request scoped; this hook is only
526    /// for fast/background/planning/search/summarization helpers.
527    pub(crate) auxiliary_model_resolver:
528        Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
529    /// Optional per-round resolver for the disabled tool/skill sets so they follow
530    /// LIVE global config instead of staying frozen for the whole run. Returns the
531    /// current `(disabled_tools, disabled_skill_ids)`. When `None`, the snapshotted
532    /// `disabled_tools` / `disabled_skill_ids` fields below are used (#44 behavior).
533    /// Re-resolved each round at the tool-schema filter, so disabling/re-enabling a
534    /// tool mid-run takes effect on the next round. #136.
535    pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
536    /// Server-level usage guidance contributed by the run's tool executor —
537    /// chiefly the `instructions` connected MCP servers return from `initialize`.
538    /// Captured once at config construction (from `ToolExecutor::tool_guidance`)
539    /// and appended to the tool-guide section of the system prompt, so a server's
540    /// own how-to-use notes appear only while that server is loaded for the run.
541    pub(crate) mcp_tool_guidance: Option<String>,
542    /// Per-run resource guardrails (issue #221): already resolved — the
543    /// per-request override merged over the config-level default (see
544    /// [`AgentRuntime::execute`](crate::runtime::runtime::AgentRuntime::execute)).
545    /// Checked after every round; exceeding a configured limit gracefully
546    /// stops the run (mirrors the `max_rounds` exhaustion path).
547    pub(crate) run_budget: bamboo_config::RunBudgetConfig,
548}
549
550impl Default for AgentLoopConfig {
551    fn default() -> Self {
552        Self {
553            max_rounds: 200,
554            system_prompt: None,
555            disabled_skill_ids: BTreeSet::new(),
556            selected_skill_ids: None,
557            selected_skill_mode: None,
558            additional_tool_schemas: Vec::new(),
559            tool_registry: Arc::new(ToolRegistry::new()),
560            skill_manager: None,
561            project_context_resolver: None,
562            skip_initial_user_message: false,
563            storage: None,
564            persistence: None,
565            session_inbox: None,
566            session_activation_notifications: None,
567            attachment_reader: None,
568            metrics_collector: None,
569            model_name: None,
570            fast_model_name: None,
571            fast_model_provider: None,
572            auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
573            background_model_name: None,
574            planning_model_name: None,
575            search_model_name: None,
576            compression_instructions: None,
577            summary_target_ratio: 0.20,
578            summary_safe_window_percent: 80,
579            summarization_model_name: None,
580            background_model_provider: None,
581            summarization_model_provider: None,
582            provider_name: None,
583            provider_type: None,
584            reasoning_effort: None,
585            app_data_dir: None,
586            disabled_tools: BTreeSet::new(),
587            token_budget: None,
588            legacy_model_limits: None,
589            image_fallback: None,
590            prompt_memory_flags: PromptMemoryFlags::default(),
591            max_tool_calls_per_round: 80,
592            max_consecutive_failures_per_tool: 3,
593            per_tool_timeout_secs: 120,
594            parallel_batch_timeout_secs: 300,
595            stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
596            permission_mode: None,
597            gold_config: None,
598            guardian_config: None,
599            guardian_spawner: None,
600            bash_resume_hook: None,
601            bash_completion_sink: None,
602            approval_delegate: None,
603            hook_runner: Arc::new(HookRunner::new()),
604            features_dynamic_model_routing: false,
605            auxiliary_model_resolver: None,
606            disabled_filter_resolver: None,
607            mcp_tool_guidance: None,
608            run_budget: bamboo_config::RunBudgetConfig::default(),
609        }
610    }
611}
612
613impl AgentLoopConfig {
614    /// Live `(disabled_tools, disabled_skill_ids)` for the current round: the
615    /// resolver if one is wired (#136 — follows live global config between
616    /// rounds), else the per-run snapshot (#44 frozen behavior). `Cow` avoids
617    /// cloning the snapshot in the common no-resolver path (SDK / tests).
618    pub(crate) fn resolve_disabled_filters(
619        &self,
620    ) -> (
621        std::borrow::Cow<'_, BTreeSet<String>>,
622        std::borrow::Cow<'_, BTreeSet<String>>,
623    ) {
624        match &self.disabled_filter_resolver {
625            Some(resolver) => {
626                let (tools, skills) = resolver();
627                (
628                    std::borrow::Cow::Owned(tools),
629                    std::borrow::Cow::Owned(skills),
630                )
631            }
632            None => (
633                std::borrow::Cow::Borrowed(&self.disabled_tools),
634                std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
635            ),
636        }
637    }
638
639    /// The active session goal to surface to the main agent, or `None` when
640    /// Gold is disabled or no goal is set. Falls back to the legacy
641    /// `evaluation_prompt` for back-compat via [`GoldConfig::effective_goal`].
642    pub fn active_goal(&self) -> Option<&str> {
643        self.gold_config
644            .as_ref()
645            .filter(|cfg| cfg.enabled)
646            .and_then(GoldConfig::effective_goal)
647    }
648
649    /// Whether the Codex-style autonomous goal loop is active for this run.
650    ///
651    /// This requires Gold to be enabled, a goal to be set, AND auto-continue to
652    /// be on. Only then is the `update_goal` self-report tool surfaced to the
653    /// model and the terminal double-check allowed to veto a premature stop.
654    /// When Gold is enabled without auto-continue, the evaluator stays purely
655    /// observational (legacy behavior).
656    pub fn goal_loop_active(&self) -> bool {
657        self.gold_config.as_ref().is_some_and(|cfg| {
658            cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
659        })
660    }
661
662    /// Whether the guardian review gate is active for this run: a spawner is
663    /// wired (so the runner can actually create the reviewer child) AND the
664    /// config is present and enabled.
665    pub fn guardian_active(&self) -> bool {
666        self.guardian_spawner.is_some()
667            && self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
668    }
669
670    /// Maximum guardian review passes for this run (the budget). `0` when no
671    /// guardian config is set.
672    pub fn guardian_max_reviews(&self) -> u32 {
673        self.guardian_config
674            .as_ref()
675            .map_or(0, |cfg| cfg.max_reviews)
676    }
677
678    /// The reviewer model override, if a guardian config sets one.
679    pub fn guardian_model(&self) -> Option<&str> {
680        self.guardian_config
681            .as_ref()
682            .and_then(|cfg| cfg.model_name.as_deref())
683    }
684
685    /// Whether child→parent approval delegation is wired for this run.
686    pub fn delegation_active(&self) -> bool {
687        self.approval_delegate.is_some()
688    }
689}
690
691#[cfg(test)]
692mod tests;