Skip to main content

heartbit_core/config/
agent.rs

1use serde::{Deserialize, Serialize};
2
3use crate::agent::routing::RoutingMode;
4
5use super::guardrails::GuardrailsConfig;
6
7pub use crate::types::{DispatchMode, SpawnConfig};
8
9/// Context window management strategy.
10#[derive(Debug, Clone, Deserialize, PartialEq)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum ContextStrategyConfig {
13    /// No trimming (default).
14    Unlimited,
15    /// Sliding window: trim old messages to stay within `max_tokens`.
16    SlidingWindow {
17        /// Maximum tokens to keep in the context window.
18        max_tokens: u32,
19    },
20    /// Summarize: compress old messages when context exceeds `threshold` tokens.
21    Summarize {
22        /// Token threshold that triggers summarization.
23        threshold: u32,
24    },
25}
26
27/// Per-agent provider override. When set on an agent, overrides the
28/// orchestrator's default provider for that agent only.
29#[derive(Debug, Clone, Deserialize)]
30pub struct AgentProviderConfig {
31    /// Provider name (`anthropic`, `openrouter`, etc.).
32    pub name: String,
33    /// Model identifier (provider-specific).
34    pub model: String,
35    /// Custom API endpoint URL (overrides the default for the provider).
36    /// Useful for self-hosted models, Azure, or proxies.
37    #[serde(default)]
38    pub base_url: Option<String>,
39    /// Direct API key (alternative to environment variable).
40    /// Prefer env vars in production; this is for testing/local dev.
41    #[serde(default)]
42    pub api_key: Option<String>,
43    /// Enable Anthropic prompt caching for this agent.
44    #[serde(default)]
45    pub prompt_caching: bool,
46    /// Per-agent model cascading override.
47    pub cascade: Option<super::provider::CascadeConfig>,
48}
49
50/// Orchestrator-level settings with sensible defaults.
51#[derive(Debug, Deserialize)]
52pub struct OrchestratorConfig {
53    /// Maximum agent loop iterations before the orchestrator aborts.
54    #[serde(default = "default_max_turns")]
55    pub max_turns: usize,
56    /// `max_tokens` cap on each LLM completion the orchestrator issues.
57    #[serde(default = "default_max_tokens")]
58    pub max_tokens: u32,
59    /// Context window management strategy for the orchestrator's own conversation.
60    pub context_strategy: Option<ContextStrategyConfig>,
61    /// Token threshold for summarization of the orchestrator's own context.
62    pub summarize_threshold: Option<u32>,
63    /// Timeout in seconds for the orchestrator's own tool calls.
64    pub tool_timeout_seconds: Option<u64>,
65    /// Maximum byte size for tool output on the orchestrator's own tools.
66    pub max_tool_output_bytes: Option<usize>,
67    /// Wall-clock deadline in seconds for the entire orchestrator run.
68    pub run_timeout_seconds: Option<u64>,
69    /// Enable the `form_squad` tool for dynamic agent squad formation.
70    /// When `None` (default), auto-enabled when there are >= 2 agents.
71    /// Set to `false` to disable for a simpler prompt with fewer tokens.
72    pub enable_squads: Option<bool>,
73    /// Reasoning/thinking effort level. Enables extended thinking on models
74    /// that support it (e.g., Qwen3 via OpenRouter, Claude with extended thinking).
75    /// Valid values: "high", "medium", "low", "none".
76    pub reasoning_effort: Option<String>,
77    /// Enable reflection prompts after tool results. When true, the agent pauses
78    /// to assess tool outputs before deciding the next action (Reflexion/CRITIC pattern).
79    pub enable_reflection: Option<bool>,
80    /// Tool output compression threshold in bytes. Outputs exceeding this size
81    /// are compressed via an LLM call that preserves factual content.
82    pub tool_output_compression_threshold: Option<usize>,
83    /// Maximum number of tool definitions sent per LLM turn. When agents have
84    /// many tools, filtering to the most relevant reduces context usage and cost.
85    pub max_tools_per_turn: Option<usize>,
86    /// Tool profile for pre-filtering tool definitions. Valid values:
87    /// "conversational", "standard", "full". Defaults to no filtering.
88    pub tool_profile: Option<String>,
89    /// Maximum consecutive identical tool-call turns before doom loop detection
90    /// triggers. When reached, tool calls get error results instead of executing.
91    pub max_identical_tool_calls: Option<u32>,
92    /// Maximum consecutive fuzzy-identical tool-call turns before doom loop detection.
93    /// Fuzzy matching compares sorted tool names (ignoring inputs).
94    pub max_fuzzy_identical_tool_calls: Option<u32>,
95    /// Maximum number of tool calls allowed in a single LLM turn. When a turn
96    /// contains more tool calls than this limit, the excess calls are rejected
97    /// with an error result (per-turn cap, not cumulative).
98    pub max_tool_calls_per_turn: Option<u32>,
99    /// Dispatch mode for orchestrator delegation. When `Sequential`, the
100    /// delegate_task schema constrains `maxItems: 1` so the LLM dispatches
101    /// one agent at a time. Defaults to `Parallel` when absent.
102    pub dispatch_mode: Option<DispatchMode>,
103    /// Task routing strategy: `auto` (default), `always_orchestrate`, `single_agent`.
104    /// `auto` uses heuristic scoring + capability matching to route simple tasks
105    /// to a single agent and complex tasks to the orchestrator.
106    #[serde(default)]
107    pub routing: RoutingMode,
108    /// Escalate from single-agent to orchestrator on failure. Default: true.
109    /// When a single-agent run fails with MaxTurnsExceeded, doom loop, or
110    /// excessive compaction, the task is re-run through the orchestrator.
111    #[serde(default = "super::default_true")]
112    pub escalation: bool,
113    /// Append the multi-agent collaboration prompt to sub-agent system prompts.
114    /// Teaches sub-agents blackboard protocol, dedup, cross-verification, and
115    /// structured execution. Default: true.
116    #[serde(default)]
117    pub multi_agent_prompt: Option<bool>,
118    /// Dynamic agent spawning configuration. When present, enables the `spawn_agent`
119    /// tool on the orchestrator, allowing the LLM to create specialist agents at runtime.
120    pub spawn: Option<SpawnConfig>,
121    /// Per-tenant in-flight token cap for the `TenantTokenTracker`.
122    /// When `None`, in-flight token tracking is disabled (effectively unbounded).
123    /// Must be > 0 when set.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub max_tokens_in_flight_per_tenant: Option<usize>,
126}
127
128pub(super) fn default_max_turns() -> usize {
129    10
130}
131
132pub(super) fn default_max_tokens() -> u32 {
133    4096
134}
135
136impl Default for OrchestratorConfig {
137    fn default() -> Self {
138        Self {
139            max_turns: default_max_turns(),
140            max_tokens: default_max_tokens(),
141            context_strategy: None,
142            summarize_threshold: None,
143            tool_timeout_seconds: None,
144            max_tool_output_bytes: None,
145            run_timeout_seconds: None,
146            enable_squads: None,
147            reasoning_effort: None,
148            enable_reflection: None,
149            tool_output_compression_threshold: None,
150            max_tools_per_turn: None,
151            tool_profile: None,
152            max_identical_tool_calls: None,
153            max_fuzzy_identical_tool_calls: None,
154            max_tool_calls_per_turn: None,
155            dispatch_mode: None,
156            routing: RoutingMode::default(),
157            escalation: true,
158            multi_agent_prompt: None,
159            spawn: None,
160            max_tokens_in_flight_per_tenant: None,
161        }
162    }
163}
164
165/// An MCP server entry: a bare URL string, a full HTTP config with auth, or a
166/// stdio command to spawn as a child process.
167///
168/// Supports backward-compatible TOML: bare strings (`"http://..."`) deserialize
169/// as `Simple`, inline tables with `url` (`{ url = "...", auth_header = "..." }`)
170/// as `Full`, and inline tables with `command` (`{ command = "npx", args = [...] }`)
171/// as `Stdio`.
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
173#[serde(untagged)]
174pub enum McpServerEntry {
175    /// Bare URL string (backward-compatible).
176    Simple(String),
177    /// Full HTTP entry with optional auth header.
178    Full {
179        /// MCP server URL.
180        url: String,
181        /// Optional pre-configured `Authorization` header value.
182        #[serde(default)]
183        auth_header: Option<String>,
184        /// RFC 8707 resource indicator — audience for exchanged tokens.
185        /// Defaults to the `url` value when absent.
186        #[serde(default)]
187        resource: Option<String>,
188        /// OAuth scopes required by this MCP server (e.g., `["gmail.readonly"]`).
189        #[serde(default)]
190        scopes: Option<Vec<String>>,
191    },
192    /// Stdio transport — spawn a child process communicating via stdin/stdout.
193    Stdio {
194        /// Command to spawn (e.g. `npx`).
195        command: String,
196        /// Command-line arguments.
197        #[serde(default)]
198        args: Vec<String>,
199        /// Extra environment variables for the child process.
200        #[serde(default)]
201        env: std::collections::HashMap<String, String>,
202    },
203}
204
205impl McpServerEntry {
206    /// Get the server URL (empty string for stdio entries).
207    pub fn url(&self) -> &str {
208        match self {
209            McpServerEntry::Simple(url) => url,
210            McpServerEntry::Full { url, .. } => url,
211            McpServerEntry::Stdio { .. } => "",
212        }
213    }
214
215    /// Get the optional auth header value.
216    pub fn auth_header(&self) -> Option<&str> {
217        match self {
218            McpServerEntry::Simple(_) => None,
219            McpServerEntry::Full { auth_header, .. } => auth_header.as_deref(),
220            McpServerEntry::Stdio { .. } => None,
221        }
222    }
223
224    /// Whether this entry uses stdio transport.
225    pub fn is_stdio(&self) -> bool {
226        matches!(self, McpServerEntry::Stdio { .. })
227    }
228
229    /// Get the RFC 8707 resource indicator (audience for token exchange).
230    /// Returns the explicit `resource` if set, otherwise falls back to the URL.
231    pub fn resource(&self) -> Option<&str> {
232        match self {
233            McpServerEntry::Simple(url) => Some(url.as_str()),
234            McpServerEntry::Full { resource, url, .. } => {
235                Some(resource.as_deref().unwrap_or(url.as_str()))
236            }
237            McpServerEntry::Stdio { .. } => None,
238        }
239    }
240
241    /// Get the OAuth scopes configured for this MCP server.
242    pub fn scopes(&self) -> Option<&[String]> {
243        match self {
244            McpServerEntry::Full { scopes, .. } => scopes.as_deref(),
245            _ => None,
246        }
247    }
248
249    /// Human-readable description for logging.
250    pub fn display_name(&self) -> String {
251        match self {
252            McpServerEntry::Simple(url) => url.clone(),
253            McpServerEntry::Full { url, .. } => url.clone(),
254            McpServerEntry::Stdio { command, args, .. } => {
255                if args.is_empty() {
256                    command.clone()
257                } else {
258                    format!("{} {}", command, args.join(" "))
259                }
260            }
261        }
262    }
263}
264
265/// How MCP resources are surfaced to agents.
266#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
267#[serde(rename_all = "snake_case")]
268pub enum McpResourceMode {
269    /// Resources become callable tools (agent decides when to read).
270    #[default]
271    Tools,
272    /// Pre-fetch resource content and inject into system prompt.
273    Context,
274    /// Skip resource discovery entirely.
275    None,
276}
277
278/// A sub-agent defined in the configuration file.
279#[derive(Debug, Deserialize)]
280pub struct AgentConfig {
281    /// Unique agent name (referenced from the orchestrator's prompt).
282    pub name: String,
283    /// Human-readable description used by routing/orchestration.
284    pub description: String,
285    /// Agent-specific system prompt (appended to template/defaults).
286    #[serde(default)]
287    pub system_prompt: String,
288    /// Agent template to use as a base. The template provides default values
289    /// for system_prompt, max_tokens, max_turns, and other settings.
290    /// User-specified values override template defaults.
291    #[serde(default)]
292    pub template: Option<String>,
293    /// Skills to auto-inject into the system prompt at config resolution time.
294    /// Each skill name maps to a bundled or filesystem SKILL.md file.
295    #[serde(default)]
296    pub skills: Vec<String>,
297    /// Directories to discover Agent Skills from (each holding `<name>/SKILL.md`).
298    /// When non-empty, a Level-1 catalog (`name: description` per skill) is
299    /// injected into the system prompt so the model knows which skills exist and
300    /// loads each one's full instructions on demand via the `skill` tool
301    /// (progressive disclosure). Earlier directories take precedence on a name
302    /// clash. Distinct from [`skills`](Self::skills), which eagerly inlines full
303    /// content for an explicit set of named skills.
304    #[serde(default)]
305    pub skill_dirs: Vec<String>,
306    /// MCP servers whose tools/resources are exposed to this agent.
307    #[serde(default)]
308    pub mcp_servers: Vec<McpServerEntry>,
309    /// A2A agent endpoints to discover and register as tools.
310    #[serde(default)]
311    pub a2a_agents: Vec<McpServerEntry>,
312    /// Context window management strategy for this agent.
313    pub context_strategy: Option<ContextStrategyConfig>,
314    /// Token threshold at which to trigger automatic summarization.
315    /// Only valid when `context_strategy` is not `SlidingWindow`.
316    pub summarize_threshold: Option<u32>,
317    /// Timeout in seconds for individual tool executions.
318    pub tool_timeout_seconds: Option<u64>,
319    /// Maximum byte size for individual tool output. Results exceeding this
320    /// limit are truncated with a `[truncated]` suffix.
321    pub max_tool_output_bytes: Option<usize>,
322    /// Per-agent turn limit. Overrides the orchestrator default when set.
323    pub max_turns: Option<usize>,
324    /// Per-agent token limit. Overrides the orchestrator default when set.
325    pub max_tokens: Option<u32>,
326    /// Optional JSON Schema for structured output. Expressed as an inline
327    /// TOML table that maps to the JSON Schema object. When set, the agent
328    /// receives a synthetic `__respond__` tool and returns structured JSON.
329    pub response_schema: Option<serde_json::Value>,
330    /// Wall-clock deadline in seconds for this agent's run.
331    pub run_timeout_seconds: Option<u64>,
332    /// Optional per-agent LLM provider override. When set, this agent uses
333    /// a different model/provider instead of the orchestrator's default.
334    pub provider: Option<AgentProviderConfig>,
335    /// Reasoning/thinking effort level. Overrides the orchestrator default.
336    /// Valid values: "high", "medium", "low", "none".
337    pub reasoning_effort: Option<String>,
338    /// Enable reflection prompts after tool results. Overrides the orchestrator default.
339    pub enable_reflection: Option<bool>,
340    /// Tool output compression threshold in bytes. Overrides the orchestrator default.
341    pub tool_output_compression_threshold: Option<usize>,
342    /// Maximum tools per turn for this agent. Overrides the orchestrator default.
343    pub max_tools_per_turn: Option<usize>,
344    /// Tool profile for pre-filtering tool definitions. Valid values:
345    /// "conversational" (memory + question only), "standard" (builtins only),
346    /// "full" (all tools). When absent, no pre-filtering is applied.
347    pub tool_profile: Option<String>,
348    /// Maximum consecutive identical tool-call turns before doom loop detection.
349    /// Overrides the orchestrator default.
350    pub max_identical_tool_calls: Option<u32>,
351    /// Maximum consecutive fuzzy-identical tool-call turns before doom loop detection.
352    /// Fuzzy matching compares sorted tool names (ignoring inputs). Overrides orchestrator default.
353    pub max_fuzzy_identical_tool_calls: Option<u32>,
354    /// Maximum number of tool calls allowed in a single LLM turn. Overrides the orchestrator default.
355    pub max_tool_calls_per_turn: Option<u32>,
356    /// Session pruning: truncate old tool results to save tokens.
357    /// When set, enables session-level pruning before each LLM call.
358    pub session_prune: Option<SessionPruneConfigToml>,
359    /// Enable recursive (cluster-then-summarize) summarization for long conversations.
360    pub recursive_summarization: Option<bool>,
361    /// Cumulative importance threshold for memory reflection triggers.
362    /// When the sum of stored memory importance values exceeds this threshold,
363    /// the store tool appends a reflection hint to guide the agent.
364    pub reflection_threshold: Option<u32>,
365    /// When true, run memory consolidation at session end (clusters related
366    /// episodic memories into semantic summaries). Requires memory and adds
367    /// LLM calls at session end.
368    pub consolidate_on_exit: Option<bool>,
369    /// Hard limit on cumulative tokens (input + output) across all turns.
370    /// When exceeded, the agent returns an error with partial usage data.
371    pub max_total_tokens: Option<u64>,
372    /// Per-agent guardrails override. When set, overrides the top-level
373    /// `[guardrails]` section for this agent.
374    pub guardrails: Option<GuardrailsConfig>,
375    /// LRU response cache capacity (number of entries). When set, identical
376    /// LLM requests (same system prompt, messages, tool names) return cached
377    /// responses without calling the LLM. Only non-streaming calls are cached.
378    #[serde(default)]
379    pub response_cache_size: Option<usize>,
380    /// How MCP resources are surfaced to the agent.
381    /// `"tools"` (default) — resources become callable tools.
382    /// `"context"` — pre-fetch and inject into system prompt.
383    /// `"none"` — skip resource discovery.
384    #[serde(default)]
385    pub mcp_resources: McpResourceMode,
386    /// Enable dangerous tools (bash) for this agent. Default: false in daemon mode.
387    #[serde(default)]
388    pub dangerous_tools: bool,
389    /// Audit mode: "full" (default) or "metadata_only".
390    /// MetadataOnly strips user content from audit records.
391    #[serde(default)]
392    pub audit_mode: Option<String>,
393    /// Optional allowlist of builtin tool names for this agent.
394    /// When set, only listed builtins are included. When absent, all builtins load.
395    /// Empty list `[]` disables all builtins (MCP-only agent).
396    #[serde(default)]
397    pub builtin_tools: Option<Vec<String>>,
398}
399
400/// TOML representation of session pruning configuration.
401#[derive(Debug, Clone, Deserialize)]
402pub struct SessionPruneConfigToml {
403    /// Number of recent message pairs to keep at full fidelity. Default: 2.
404    #[serde(default = "default_keep_recent_n")]
405    pub keep_recent_n: usize,
406    /// Maximum bytes for a pruned tool result. Default: 200.
407    #[serde(default = "default_pruned_max_bytes")]
408    pub pruned_tool_result_max_bytes: usize,
409    /// Whether to preserve the first user message (task). Default: true.
410    #[serde(default = "default_preserve_task")]
411    pub preserve_task: bool,
412}
413
414fn default_keep_recent_n() -> usize {
415    2
416}
417
418fn default_pruned_max_bytes() -> usize {
419    200
420}
421
422fn default_preserve_task() -> bool {
423    true
424}
425
426impl AgentConfig {
427    /// Clone all fields of this config into a new `AgentConfig`.
428    ///
429    /// `AgentConfig` intentionally does not derive `Clone` (to keep the derive
430    /// list short and avoid accidental copies in hot paths). Use this method
431    /// when an explicit copy is needed (e.g., template resolution).
432    pub fn clone_config(&self) -> Self {
433        Self {
434            name: self.name.clone(),
435            description: self.description.clone(),
436            system_prompt: self.system_prompt.clone(),
437            template: self.template.clone(),
438            skills: self.skills.clone(),
439            skill_dirs: self.skill_dirs.clone(),
440            mcp_servers: self.mcp_servers.clone(),
441            a2a_agents: self.a2a_agents.clone(),
442            context_strategy: self.context_strategy.clone(),
443            summarize_threshold: self.summarize_threshold,
444            tool_timeout_seconds: self.tool_timeout_seconds,
445            max_tool_output_bytes: self.max_tool_output_bytes,
446            max_turns: self.max_turns,
447            max_tokens: self.max_tokens,
448            response_schema: self.response_schema.clone(),
449            run_timeout_seconds: self.run_timeout_seconds,
450            provider: self.provider.clone(),
451            reasoning_effort: self.reasoning_effort.clone(),
452            enable_reflection: self.enable_reflection,
453            tool_output_compression_threshold: self.tool_output_compression_threshold,
454            max_tools_per_turn: self.max_tools_per_turn,
455            tool_profile: self.tool_profile.clone(),
456            max_identical_tool_calls: self.max_identical_tool_calls,
457            max_fuzzy_identical_tool_calls: self.max_fuzzy_identical_tool_calls,
458            max_tool_calls_per_turn: self.max_tool_calls_per_turn,
459            session_prune: self.session_prune.clone(),
460            recursive_summarization: self.recursive_summarization,
461            reflection_threshold: self.reflection_threshold,
462            consolidate_on_exit: self.consolidate_on_exit,
463            max_total_tokens: self.max_total_tokens,
464            guardrails: self.guardrails.clone(),
465            response_cache_size: self.response_cache_size,
466            mcp_resources: self.mcp_resources,
467            dangerous_tools: self.dangerous_tools,
468            audit_mode: self.audit_mode.clone(),
469            builtin_tools: self.builtin_tools.clone(),
470        }
471    }
472}
473
474impl AgentProviderConfig {
475    /// Clone via Option::as_ref → clone pattern for non-Clone containers.
476    pub fn take_ref(opt: &Option<Self>) -> Option<Self> {
477        opt.clone()
478    }
479}
480
481impl SessionPruneConfigToml {
482    /// Clone via Option::as_ref → clone pattern for non-Clone containers.
483    pub fn take_ref(opt: &Option<Self>) -> Option<Self> {
484        opt.clone()
485    }
486}