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/// Configuration for the agent loop.
337///
338/// # One-config-per-run invariant (#44)
339///
340/// These values are SNAPSHOTTED once, from the live `Config` under a brief read
341/// lock, at the start of `AgentRuntime::execute()`. The entire multi-round run —
342/// which can last minutes — then uses this frozen snapshot. Changing config
343/// (model names, provider, `disabled_tools`/`disabled_skills`, memory flags,
344/// token budget, …) while a run is in flight does NOT affect that run; the new
345/// values are picked up on the NEXT execution (i.e. the next user turn / session
346/// restart), not the next round of the current run.
347///
348/// This is intentional: a run sees a stable configuration, so its behavior can't
349/// shift underneath it mid-execution. The deliberate exceptions are the
350/// late-bound, per-request trait objects that resolve LIVE each time they're
351/// used rather than being snapshotted — `auxiliary_model_resolver` (auxiliary
352/// model selection) and `guardian_spawner` (the reviewer child). If a frozen
353/// field ever needs to become live-per-round, follow that resolver pattern
354/// rather than widening the snapshot.
355#[non_exhaustive]
356pub struct AgentLoopConfig {
357 /// Keep tool guidance stable while activation updates append to model context.
358 pub freeze_tool_exposure_for_cache: bool,
359 /// Host-installed exact Assignment/Run permit; never taken from model args.
360 pub(crate) ticket_worker_plan: Option<Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>>,
361 pub(crate) system_prompt: Option<String>,
362 /// Skill IDs that are disabled globally for this execution.
363 pub(crate) disabled_skill_ids: BTreeSet<String>,
364 /// Optional explicit skill selection for this execution.
365 /// When set, only these skill IDs are considered for skill context and allowlists.
366 pub(crate) selected_skill_ids: Option<Vec<String>>,
367 /// Optional active skill mode for this execution.
368 ///
369 /// When set, skill discovery prefers `skills-<mode>` directories over generic
370 /// directories for the same skill id.
371 pub(crate) selected_skill_mode: Option<String>,
372 pub(crate) additional_tool_schemas: Vec<ToolSchema>,
373 pub(crate) tool_registry: Arc<ToolRegistry>,
374 pub(crate) skill_manager: Option<Arc<SkillManager>>,
375 /// Stable Project identity/resource resolver. The server wires the
376 /// authoritative registry adapter once on `AgentRuntimeBuilder`.
377 pub(crate) project_context_resolver:
378 Option<Arc<crate::project_context::ProjectContextResolver>>,
379 /// If true, skip appending the initial user message (already present in session).
380 pub(crate) skip_initial_user_message: bool,
381 /// Optional storage for persisting session changes
382 pub(crate) storage: Option<Arc<dyn Storage>>,
383 /// Optional runtime persistence for non-authoritative session saves.
384 /// When set, engine save sites use this instead of `storage` for writes.
385 pub(crate) persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
386 pub(crate) guidance_active_run_id: Option<String>,
387 /// Durable logical-session inbox admitted at safe round boundaries.
388 pub(crate) session_inbox: Option<Arc<dyn bamboo_domain::SessionInboxPort>>,
389 /// Active-owner wake generation. The loop consumes this at the same safe
390 /// boundary where it drains SessionInbox; it never interrupts an in-flight
391 /// provider/tool operation.
392 pub(crate) session_activation_notifications:
393 Option<Arc<parking_lot::Mutex<tokio::sync::watch::Receiver<u64>>>>,
394 /// Optional attachment reader for resolving `bamboo-attachment://...` references
395 /// into `data:` URLs for upstream providers. This must not mutate session storage.
396 pub(crate) attachment_reader: Option<Arc<dyn AttachmentReader>>,
397 /// Optional asynchronous metrics collector
398 pub(crate) metrics_collector: Option<MetricsCollector>,
399 /// Model name used for metrics attribution
400 pub(crate) model_name: Option<String>,
401 /// Fast/cheap model for lightweight tasks (task evaluation, search, etc.).
402 ///
403 /// Call sites may fall back to `model_name` when this is unset.
404 pub(crate) fast_model_name: Option<String>,
405 /// Optional provider override for lightweight fast-model LLM calls.
406 pub(crate) fast_model_provider: Option<Arc<dyn LLMProvider>>,
407 /// Process-wide concurrency ceiling for low-priority auxiliary evaluation
408 /// requests sharing the same provider allocation and model. Foreground
409 /// requests do not acquire this budget.
410 pub(crate) auxiliary_evaluation_max_concurrency: usize,
411 /// Fast/cheap model for memory/background tasks.
412 ///
413 /// Resolution follows the shared dedicated → fast → chat fallback chain.
414 pub(crate) background_model_name: Option<String>,
415
416 /// Model for planning/coordination tasks (task decomposition, architecture).
417 /// Falls back to `model_name` when unset.
418 pub(crate) planning_model_name: Option<String>,
419 /// Model for search/navigation tasks (grep, file listing, symbol resolution).
420 /// Falls back to `fast_model_name` when unset.
421 pub(crate) search_model_name: Option<String>,
422 /// Custom instructions for conversation summarization, injected into the
423 /// LLM summary prompt. Lets users control what the summary focuses on.
424 ///
425 /// Resolution order: session-level > config-level > built-in defaults.
426 pub(crate) compression_instructions: Option<String>,
427 /// Desired final summary size relative to the raw source tokens represented
428 /// by it. Values are normalized at the compression boundary.
429 pub(crate) summary_target_ratio: f64,
430 /// Frozen host context-management strategy for this run. Missing public
431 /// configuration resolves to the legacy summary behavior.
432 pub(crate) context_management: bamboo_config::ContextManagementConfig,
433 /// Safe request ceiling as a percentage of the selected summarization
434 /// model's context window.
435 pub(crate) summary_safe_window_percent: u8,
436 /// Dedicated model for summarization. Falls back to `background_model_name`.
437 pub(crate) summarization_model_name: Option<String>,
438 /// Optional provider override for memory/background model LLM calls.
439 ///
440 /// When set, memory recall rerank and other memory/background tasks use this
441 /// provider instead of the shared agent loop provider.
442 pub(crate) background_model_provider: Option<Arc<dyn LLMProvider>>,
443 /// Optional provider override for summarization / context compression calls.
444 ///
445 /// When set, conversation/task summarization uses this provider instead of
446 /// the shared agent loop provider.
447 pub(crate) summarization_model_provider: Option<Arc<dyn LLMProvider>>,
448 /// Provider routing key used for provider-specific request behavior.
449 ///
450 /// In multi-instance mode this may be the instance id.
451 pub(crate) provider_name: Option<String>,
452 /// Underlying provider type (for example `openai`, `anthropic`, `copilot`).
453 ///
454 /// This is distinct from `provider_name` so provider-specific behavior can
455 /// remain correct when routing keys are instance ids.
456 pub(crate) provider_type: Option<String>,
457 /// Optional request-time reasoning effort override.
458 pub(crate) reasoning_effort: Option<ReasoningEffort>,
459 /// Bamboo application data directory (typically `~/.bamboo`).
460 ///
461 /// Used by runtime features that persist auxiliary artifacts outside the
462 /// session store, such as durable plan mode files under `~/.bamboo/plan`.
463 pub(crate) app_data_dir: Option<PathBuf>,
464 /// Jiandu memory handle for this run. The default is the independent
465 /// `~/.jiandu` store; explicit construction is used by isolated tests.
466 pub(crate) memory_store: bamboo_memory::memory_store::MemoryStore,
467 /// Tool names that should be excluded from schemas sent to the LLM.
468 pub(crate) disabled_tools: BTreeSet<String>,
469 /// Token budget for context management (optional, defaults to model's limits)
470 pub(crate) token_budget: Option<TokenBudget>,
471 /// Legacy `config.json` `model_limits` value, snapshotted from the live
472 /// in-memory Config when this loop config is built. Consulted per model when
473 /// the instance-local `model_limits.json` has no matching entry or cannot be
474 /// loaded, so the engine never does a fresh disk-reading `Config::new()`
475 /// (which would also clobber the global env-var cache). #38.
476 pub(crate) legacy_model_limits: Option<serde_json::Value>,
477 /// Optional image fallback behavior applied to *LLM requests only* (never persisted).
478 ///
479 /// This is intended for text-only provider paths where image parts must be degraded
480 /// (placeholder / OCR / error) without leaking into stored session history or UI.
481 pub(crate) image_fallback: Option<ImageFallbackConfig>,
482 /// Feature flags controlling prompt-time memory injection behavior.
483 pub(crate) prompt_memory_flags: PromptMemoryFlags,
484 /// Maximum tool calls allowed per round (default: 80).
485 pub(crate) max_tool_calls_per_round: usize,
486 /// Maximum consecutive failures per tool before circuit breaker (default: 3).
487 pub(crate) max_consecutive_failures_per_tool: usize,
488 /// Per-tool execution timeout in seconds (default: 120).
489 pub(crate) per_tool_timeout_secs: u64,
490 /// Parallel batch execution timeout in seconds (default: 300).
491 pub(crate) parallel_batch_timeout_secs: u64,
492 /// Resolved LLM stream transport/semantic watchdog policy. The same value
493 /// is passed to main response streams and auxiliary silent streams.
494 pub(crate) stream_timeout: bamboo_config::StreamTimeoutConfig,
495 /// Permission mode for this execution (default: None = use PermissionConfig's mode).
496 pub(crate) permission_mode: Option<PermissionMode>,
497 /// Optional Gold observe-only evaluator configuration.
498 ///
499 /// When `None` or `enabled == false`, Gold evaluation is disabled and the
500 /// existing execute/respond/resume loop remains unchanged.
501 pub(crate) gold_config: Option<GoldConfig>,
502 /// Optional guardian adversarial-review gate configuration. When `None` or
503 /// `enabled == false`, the guardian terminal gate is inactive.
504 pub(crate) guardian_config: Option<GuardianConfig>,
505 /// Late-bound spawner for the guardian reviewer child. `None` (the default)
506 /// leaves the guardian gate inert even when `guardian_config.enabled` is set,
507 /// since the runner cannot create a child without it. Wired by the server.
508 pub(crate) guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
509 /// Late-bound hook that arranges a self-resume for a session suspended
510 /// waiting on background Bash shells (issue #84 Phase 2b). `None` (the
511 /// default) leaves the bash suspend gate inert: the gate refuses to suspend
512 /// without a wired hook, so a session can never strand itself without a
513 /// resume path. Wired by the server (the completion coordinator impl).
514 pub(crate) bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
515 /// Late-bound sink that pushes a completed background Bash shell's result
516 /// into this session's loop (issue #84 Phase 2b follow-up) — injected at the
517 /// next round boundary while the loop is actively iterating, or delivered via
518 /// resume when it is idle. Threaded onto the tool dispatch context (like
519 /// `can_async_resume`) so the Bash tool can hand it to the shell's
520 /// completion-poll task. `None` (the default) leaves the push inert; the
521 /// durable end-of-turn suspend/poll backstop (`bash_resume_hook`) still runs.
522 /// Wired by the server (the completion coordinator impl).
523 pub(crate) bash_completion_sink: Option<Arc<dyn bamboo_agent_core::BashCompletionSink>>,
524 /// Late-bound delegate that routes a child's gated-tool approval request up
525 /// to its parent (Phase 2). `None` (the default) leaves child gating on its
526 /// legacy path. Wired by the server.
527 pub(crate) approval_delegate: Option<Arc<dyn ApprovalDelegate>>,
528 /// Frozen lifecycle-hook registry for this run. The default registry is
529 /// empty, and every seam checks `has_hooks_for` before constructing payloads.
530 pub(crate) hook_runner: Arc<HookRunner>,
531 /// Enable dynamic per-round model routing based on task complexity.
532 /// When true, the pipeline classifies complexity at each round end and
533 /// stores the result in session metadata.
534 pub(crate) features_dynamic_model_routing: bool,
535 /// Optional per-round resolver for auxiliary model settings that should
536 /// follow live global config rather than stay frozen for the whole run.
537 ///
538 /// The main chat model remains session/request scoped; this hook is only
539 /// for fast/background/planning/search/summarization helpers.
540 pub(crate) auxiliary_model_resolver:
541 Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
542 /// Optional per-round resolver for the disabled tool/skill sets so they follow
543 /// LIVE global config instead of staying frozen for the whole run. Returns the
544 /// current `(disabled_tools, disabled_skill_ids)`. When `None`, the snapshotted
545 /// `disabled_tools` / `disabled_skill_ids` fields below are used (#44 behavior).
546 /// Re-resolved each round at the tool-schema filter, so disabling/re-enabling a
547 /// tool mid-run takes effect on the next round. #136.
548 pub(crate) disabled_filter_resolver: Option<DisabledFilterResolver>,
549 /// Server-level usage guidance contributed by the run's tool executor —
550 /// chiefly the `instructions` connected MCP servers return from `initialize`.
551 /// Captured once at config construction (from `ToolExecutor::tool_guidance`)
552 /// and appended to the tool-guide section of the system prompt, so a server's
553 /// own how-to-use notes appear only while that server is loaded for the run.
554 pub(crate) mcp_tool_guidance: Option<String>,
555 /// Per-run resource guardrails (issue #221): already resolved — the
556 /// per-request override merged over the config-level default (see
557 /// [`AgentRuntime::execute`](crate::runtime::runtime::AgentRuntime::execute)).
558 /// Checked after every round; exceeding a configured limit gracefully
559 /// stops the run. Includes the run's round cap (`max_rounds`): `None`
560 /// means unlimited rounds (the historical hard-coded 200 was removed);
561 /// a configured cap keeps the issue #29 exhaustion contract (metadata
562 /// stamp, visible notification, exactly one final summary turn).
563 pub(crate) run_budget: bamboo_config::RunBudgetConfig,
564}
565
566impl Default for AgentLoopConfig {
567 fn default() -> Self {
568 Self {
569 freeze_tool_exposure_for_cache: true,
570 ticket_worker_plan: None,
571 system_prompt: None,
572 disabled_skill_ids: BTreeSet::new(),
573 selected_skill_ids: None,
574 selected_skill_mode: None,
575 additional_tool_schemas: Vec::new(),
576 tool_registry: Arc::new(ToolRegistry::new()),
577 skill_manager: None,
578 project_context_resolver: None,
579 skip_initial_user_message: false,
580 storage: None,
581 persistence: None,
582 guidance_active_run_id: None,
583 session_inbox: None,
584 session_activation_notifications: None,
585 attachment_reader: None,
586 metrics_collector: None,
587 model_name: None,
588 fast_model_name: None,
589 fast_model_provider: None,
590 auxiliary_evaluation_max_concurrency: DEFAULT_AUXILIARY_EVALUATION_MAX_CONCURRENCY,
591 background_model_name: None,
592 planning_model_name: None,
593 search_model_name: None,
594 compression_instructions: None,
595 summary_target_ratio: 0.20,
596 context_management: bamboo_config::ContextManagementConfig::default(),
597 summary_safe_window_percent: 80,
598 summarization_model_name: None,
599 background_model_provider: None,
600 summarization_model_provider: None,
601 provider_name: None,
602 provider_type: None,
603 reasoning_effort: None,
604 app_data_dir: None,
605 memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
606 disabled_tools: BTreeSet::new(),
607 token_budget: None,
608 legacy_model_limits: None,
609 image_fallback: None,
610 prompt_memory_flags: PromptMemoryFlags::default(),
611 max_tool_calls_per_round: 80,
612 max_consecutive_failures_per_tool: 3,
613 per_tool_timeout_secs: 120,
614 parallel_batch_timeout_secs: 300,
615 stream_timeout: bamboo_config::StreamTimeoutConfig::default(),
616 permission_mode: None,
617 gold_config: None,
618 guardian_config: None,
619 guardian_spawner: None,
620 bash_resume_hook: None,
621 bash_completion_sink: None,
622 approval_delegate: None,
623 hook_runner: Arc::new(HookRunner::new()),
624 features_dynamic_model_routing: false,
625 auxiliary_model_resolver: None,
626 disabled_filter_resolver: None,
627 mcp_tool_guidance: None,
628 run_budget: bamboo_config::RunBudgetConfig::default(),
629 }
630 }
631}
632
633impl AgentLoopConfig {
634 pub fn bind_ticket_worker_plan(
635 &mut self,
636 plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
637 ) {
638 self.ticket_worker_plan = Some(plan);
639 }
640 /// Live `(disabled_tools, disabled_skill_ids)` for the current round: the
641 /// resolver if one is wired (#136 — follows live global config between
642 /// rounds), else the per-run snapshot (#44 frozen behavior). `Cow` avoids
643 /// cloning the snapshot in the common no-resolver path (SDK / tests).
644 pub(crate) fn resolve_disabled_filters(
645 &self,
646 ) -> (
647 std::borrow::Cow<'_, BTreeSet<String>>,
648 std::borrow::Cow<'_, BTreeSet<String>>,
649 ) {
650 match &self.disabled_filter_resolver {
651 Some(resolver) => {
652 let (tools, skills) = resolver();
653 (
654 std::borrow::Cow::Owned(tools),
655 std::borrow::Cow::Owned(skills),
656 )
657 }
658 None => (
659 std::borrow::Cow::Borrowed(&self.disabled_tools),
660 std::borrow::Cow::Borrowed(&self.disabled_skill_ids),
661 ),
662 }
663 }
664
665 /// The active session goal to surface to the main agent, or `None` when
666 /// Gold is disabled or no goal is set. Falls back to the legacy
667 /// `evaluation_prompt` for back-compat via [`GoldConfig::effective_goal`].
668 pub fn active_goal(&self) -> Option<&str> {
669 self.gold_config
670 .as_ref()
671 .filter(|cfg| cfg.enabled)
672 .and_then(GoldConfig::effective_goal)
673 }
674
675 /// Whether the Codex-style autonomous goal loop is active for this run.
676 ///
677 /// This requires Gold to be enabled, a goal to be set, AND auto-continue to
678 /// be on. Only then is the `update_goal` self-report tool surfaced to the
679 /// model and the terminal double-check allowed to veto a premature stop.
680 /// When Gold is enabled without auto-continue, the evaluator stays purely
681 /// observational (legacy behavior).
682 pub fn goal_loop_active(&self) -> bool {
683 self.gold_config.as_ref().is_some_and(|cfg| {
684 cfg.enabled && cfg.auto_continue_enabled && cfg.effective_goal().is_some()
685 })
686 }
687
688 /// Whether the guardian review gate is active for this run: a spawner is
689 /// wired (so the runner can actually create the reviewer child) AND the
690 /// config is present and enabled.
691 pub fn guardian_active(&self) -> bool {
692 self.guardian_spawner.is_some()
693 && self.guardian_config.as_ref().is_some_and(|cfg| cfg.enabled)
694 }
695
696 /// Maximum guardian review passes for this run (the budget). `0` when no
697 /// guardian config is set.
698 pub fn guardian_max_reviews(&self) -> u32 {
699 self.guardian_config
700 .as_ref()
701 .map_or(0, |cfg| cfg.max_reviews)
702 }
703
704 /// The reviewer model override, if a guardian config sets one.
705 pub fn guardian_model(&self) -> Option<&str> {
706 self.guardian_config
707 .as_ref()
708 .and_then(|cfg| cfg.model_name.as_deref())
709 }
710
711 /// Whether child→parent approval delegation is wired for this run.
712 pub fn delegation_active(&self) -> bool {
713 self.approval_delegate.is_some()
714 }
715}
716
717#[cfg(test)]
718mod tests;