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}