Skip to main content

quorum_rs/agents/
config.rs

1use crate::nats_utils::OrchestratorEntry;
2use serde::{Deserialize, Serialize, Serializer};
3use std::collections::HashMap;
4use std::path::PathBuf;
5use utoipa::ToSchema;
6
7/// Redact env values during serialization to avoid leaking secrets.
8/// Keys containing "KEY", "SECRET", "TOKEN", "PASSWORD", or "CREDENTIAL"
9/// (case-insensitive) are replaced with `"<redacted>"`. All other values
10/// are serialized as-is.
11fn serialize_redacted_env<S>(
12    env: &HashMap<String, String>,
13    serializer: S,
14) -> Result<S::Ok, S::Error>
15where
16    S: Serializer,
17{
18    use serde::ser::SerializeMap;
19    const SENSITIVE: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "CREDENTIAL"];
20    let mut map = serializer.serialize_map(Some(env.len()))?;
21    for (k, v) in env {
22        let upper = k.to_uppercase();
23        let redacted = SENSITIVE.iter().any(|s| upper.contains(s));
24        map.serialize_entry(k, if redacted { "<redacted>" } else { v.as_str() })?;
25    }
26    map.end()
27}
28
29/// One layer of a stacked-persona definition. See
30/// [`deserialize_persona`] for the yaml-side semantic.
31#[derive(Debug, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33enum PersonaLayer {
34    Text {
35        prompt: String,
36    },
37    /// Markdown file — `prompt` is a filesystem path. The file is read
38    /// at parse time and its content stacked into the resolved persona.
39    /// Paths resolve relative to the process CWD when `quorum serve`
40    /// (or whatever loaded the fleet config) was invoked.
41    Md {
42        prompt: PathBuf,
43    },
44}
45
46/// What the yaml field may carry — either a plain string (back-compat)
47/// or an ordered array of layers. Internal: the public field type stays
48/// `Option<String>` because the layered form is resolved eagerly into
49/// a single joined string at parse time.
50#[derive(Debug, Deserialize)]
51#[serde(untagged)]
52enum PersonaInput {
53    Inline(String),
54    Layered(Vec<PersonaLayer>),
55}
56
57/// Custom deserializer attached to [`AgentConfig::persona`].
58///
59/// Accepts:
60///
61/// 1. A plain string → returned as-is (`Some(string)`). This is the
62///    pre-existing shape; operators with old `agent.yml` files are
63///    unaffected.
64/// 2. An ordered array of `{type: text|md, prompt: ...}` layer specs.
65///    `text` layers contribute their `prompt` string verbatim; `md`
66///    layers read the file at `prompt` and contribute its content.
67///    Layers are joined with `\n\n` into a single persona string.
68/// 3. `null` / absent → `None`.
69///
70/// File-read failure on an `md` layer surfaces as a parse error
71/// (with the failing path named) — operators see the problem at
72/// fleet boot, not after the agent is already advertising a partial
73/// persona.
74fn deserialize_persona<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
75where
76    D: serde::Deserializer<'de>,
77{
78    use serde::de::Error;
79    let opt: Option<PersonaInput> = Option::deserialize(deserializer)?;
80    match opt {
81        None => Ok(None),
82        Some(PersonaInput::Inline(s)) => Ok(Some(s)),
83        Some(PersonaInput::Layered(layers)) => {
84            let mut parts: Vec<String> = Vec::with_capacity(layers.len());
85            for layer in layers {
86                match layer {
87                    PersonaLayer::Text { prompt } => parts.push(prompt),
88                    PersonaLayer::Md { prompt } => {
89                        let content = std::fs::read_to_string(&prompt).map_err(|e| {
90                            D::Error::custom(format!(
91                                "persona md layer at `{}` could not be read: {e}",
92                                prompt.display()
93                            ))
94                        })?;
95                        parts.push(content);
96                    }
97                }
98            }
99            Ok(Some(parts.join("\n\n")))
100        }
101    }
102}
103
104/// Configuration for a specific agent.
105#[derive(Debug, Deserialize, Clone, Serialize, ToSchema)]
106pub struct AgentConfig {
107    pub name: String,
108    /// Dotpath model reference: `"provider_id.model_key"`.
109    /// When set, resolves the provider and merges `ModelDef` fields into this
110    /// agent at config load time (`load_agent_from_config`). Replaces the
111    /// legacy `provider_id` + `model_name` + flat LLM field pattern.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub model: Option<String>,
114    /// Legacy provider reference. When `model` is set, this is overwritten
115    /// during resolution. Kept for backward compatibility.
116    #[serde(default)]
117    pub provider_id: String,
118    #[serde(default)]
119    pub model_name: String,
120    #[serde(default)]
121    pub temperature: f32,
122    #[serde(default)]
123    pub max_tokens: i32,
124    #[serde(default)]
125    pub system_prompt_override: Option<String>,
126    #[serde(default, deserialize_with = "deserialize_persona")]
127    pub persona: Option<String>,
128    #[serde(default = "default_max_react_iterations")]
129    pub max_react_iterations: Option<i32>,
130    #[serde(default = "default_max_scratchpad_size")]
131    pub max_scratchpad_size: Option<i32>,
132    #[serde(default = "default_max_retries")]
133    pub max_retries: Option<i32>,
134    /// Max jobs this agent runs concurrently. Enforced as the pull consumer's
135    /// `max_ack_pending`, so the broker withholds the next task until an
136    /// in-flight one finishes. Set to `1` for agents whose jobs mutate shared
137    /// state (e.g. a git repo a middleware resets per job) to prevent races.
138    /// `None` (default) leaves it unbounded.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub max_concurrent_jobs: Option<usize>,
141    #[serde(default)]
142    pub supports_native_thinking: bool,
143    #[serde(default)]
144    pub frequency_penalty: Option<f32>,
145    /// Presence penalty for the model. Defaults to `Some(1.5)` to encourage
146    /// diverse vocabulary in multi-agent deliberation (reduces repetitive
147    /// phrasing across rounds). Set to `None` or `0.0` in config to disable.
148    #[serde(default = "default_presence_penalty")]
149    pub presence_penalty: Option<f32>,
150    #[serde(default = "default_textual_feedback")]
151    pub textual_feedback: bool,
152    #[serde(default = "default_use_streaming")]
153    pub use_streaming: bool,
154    #[serde(default)]
155    pub merge_system_prompt: bool,
156    #[serde(default)]
157    pub unwrap_hallucinated_tool_calls: bool,
158    #[serde(default = "default_repair_invalid_escapes")]
159    pub repair_invalid_escapes: bool,
160    #[serde(default = "default_scratchpad_limit")]
161    pub scratchpad_limit: i32,
162
163    /// Fraction of `max_scratchpad_size` at which `compact_history`
164    /// also auto-squeezes the scratchpad. Default 0.95 — leaving 5%
165    /// headroom keeps the next tool call from immediately tripping
166    /// the persistence cap.
167    #[serde(default = "default_scratchpad_squeeze_fraction")]
168    pub scratchpad_squeeze_fraction: f64,
169
170    /// Default value of `compact_history(keep_last_n_calls)` when the
171    /// model omits the argument. Two recent tool results give the
172    /// model enough context to reason while older results fold into
173    /// the scratchpad summary.
174    #[serde(default = "default_compact_history_keep")]
175    pub compact_history_default_keep: usize,
176    #[serde(default)]
177    pub json_mode: bool,
178    #[serde(default)]
179    pub disable_native_tools: bool,
180    #[serde(default = "default_context_window")]
181    pub context_window: i32,
182    #[serde(default)]
183    pub reasoning_effort: Option<String>,
184    #[serde(default)]
185    pub tool_format: Option<String>,
186    /// USD per million input tokens. Used for cost estimation in budget reporting.
187    #[serde(default)]
188    pub input_price_per_mtok: Option<f64>,
189    /// USD per million output tokens. Used for cost estimation in budget reporting.
190    #[serde(default)]
191    pub output_price_per_mtok: Option<f64>,
192    /// Characters per token for heuristic estimation when the provider doesn't return
193    /// usage stats. Deserialized as `Option<f64>` (None when absent in config).
194    /// The runtime fallback of 4.0 (English approximation) is applied at the call
195    /// site via `.unwrap_or(4.0)` in `nsed_agent.rs`; set lower (~1.5) for CJK/code.
196    #[serde(default)]
197    pub chars_per_token: Option<f64>,
198    /// Per-agent orchestrator extensions (additive to the process-wide list).
199    /// Only used at agent startup for connection resolution; never serialized
200    /// over NATS since this is deployment topology, not agent behavior.
201    #[serde(default, skip_serializing)]
202    #[schema(ignore)]
203    pub orchestrators: Vec<OrchestratorEntry>,
204    /// Per-task-category precision parameters for the thermodynamic model.
205    /// Map from task category (e.g. "supply", "audit", "quant", "legal") to
206    /// `{ pg, pv }` where pg = zero-shot generation precision, pv = verification precision.
207    /// Used by the dashboard to compute the NSED utility function:
208    ///   U(t) = 1 - (1-pg) * exp(-Lambda*(pv-pg)*t) - beta*t^2
209    /// If absent, the dashboard falls back to built-in MODEL_PRECISION defaults.
210    #[serde(default)]
211    pub task_precision: Option<HashMap<String, TaskPrecision>>,
212    /// Controls failure dump output when parse or API errors occur.
213    /// Values: `"on"` (default — dump error + raw response), `"full"` (include
214    /// system prompt, request body, and messages), `"off"` (disable).
215    /// Dumps are written to `failures/<session>_<agent>/`.
216    /// Can also be set globally via the `NSED_FAILURE_DUMPS` env var (`1` = on, `full` = full).
217    /// The config value takes precedence over the env var.
218    #[serde(default = "default_failure_dumps")]
219    pub failure_dumps: Option<String>,
220    /// Maximum seconds this agent needs to complete a single task (propose or evaluate).
221    /// When > 0, this is a hard infrastructure constraint — the orchestrator will never
222    /// give this agent less time than this value per phase. Set to `0` to opt out of
223    /// SLA reporting (the field is omitted from heartbeats). Defaults to 3600s (1 hour).
224    #[serde(default = "default_response_sla_secs")]
225    pub response_sla_secs: u64,
226    /// Whether to propagate 402 Payment Required errors to the orchestrator.
227    /// When `true` (default), an `agent_error` event is published immediately.
228    /// When `false`, the agent silently pauses and lets the orchestrator timeout.
229    #[serde(default = "default_propagate_payment_error")]
230    pub propagate_payment_error: bool,
231
232    // ── Agent metadata (for directory/ranking/dashboard) ──
233    /// Free-form capability tags (e.g., `["legal", "audit", "quantitative"]`).
234    /// Used for filtering in agent picker and directory.
235    #[serde(default)]
236    pub capability_tags: Vec<String>,
237
238    /// Short description of the agent's specialization.
239    /// Shown in the agent directory and picker UI.
240    #[serde(default)]
241    pub description: Option<String>,
242
243    /// Signing schemes this agent supports (placeholder for #115).
244    /// Values will be validated against `SigningScheme` enum when implemented.
245    /// Empty means no signing support (legacy/internal agent).
246    #[serde(default)]
247    pub signing_schemes: Vec<String>,
248
249    /// When true, buffer entries from this agent are created with `stopped = true`,
250    /// preventing auto-release until an external system edits and explicitly
251    /// releases them via `POST /buffer/{id}/release`. Used with stub providers
252    /// for human-operated agents.
253    #[serde(default)]
254    pub auto_stop: bool,
255
256    /// Configuration for exec-based external agent providers.
257    /// When `provider_type` is `"exec"`, the agent spawns a subprocess instead
258    /// of calling an LLM. See `docs/exec-agent-protocol.md`.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub exec: Option<ExecProviderConfig>,
261
262    /// Configuration for MCP-based external agent providers.
263    /// When `provider_type` is `"mcp"`, the agent spawns a subprocess and
264    /// communicates via the Model Context Protocol (stdio transport).
265    /// See `docs/mcp-agent-protocol.md`.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub mcp: Option<McpProviderConfig>,
268
269    /// Configuration for Claude CLI as an agent provider.
270    /// When `provider_type` is `"claude"`, automatically constructs `claude`
271    /// CLI flags from AgentConfig fields (system prompt, model, session) plus
272    /// Claude-specific options (permission mode, budget, MCP tools).
273    /// See `docs/mcp-agent-protocol.md#claude-provider`.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub claude: Option<ClaudeProviderConfig>,
276
277    /// Free-form provider config for **third-party** [`ProviderFactory`]
278    /// implementations. Built-in providers (`exec` / `mcp` / `claude`) use
279    /// their typed sections above; a custom `provider.type` reads its knobs
280    /// from here, so registering a new provider needs no new field on this
281    /// core struct.
282    ///
283    /// Deserialize the whole map into a typed struct with
284    /// [`AgentConfig::provider_config_as`], or index the map directly.
285    ///
286    /// [`ProviderFactory`]: crate::providers::ProviderFactory
287    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
288    #[schema(value_type = Object)]
289    pub provider_config: HashMap<String, serde_yaml::Value>,
290
291    /// OpenRouter-specific request extensions (provider routing + ZDR).
292    /// Injected into the request body as `"provider": { ... }` when the
293    /// underlying base URL is OpenRouter. Non-OpenRouter providers will
294    /// ignore or reject the block — only set this for OpenRouter agents.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub openrouter: Option<OpenRouterConfig>,
297
298    /// Per-agent grants for built-in sandboxed tools. Attached to an
299    /// agent's tool list **only** for the native-LLM provider branch;
300    /// `provider_type: claude` / `exec` / `mcp` route their tools through
301    /// provider-native channels (claude sub-agents, the exec subprocess's
302    /// own tool surface, MCP server) so grants configured on those agents
303    /// are silently ignored at runtime (loaders are expected to warn).
304    /// Use this to give native-LLM agents scoped runtime capabilities
305    /// (e.g. read files confined to a specific filesystem root) without
306    /// going through the user_tools NATS dispatcher pipeline.
307    ///
308    /// Each grant becomes a tool in the agent's tool list at startup.
309    /// See `crate::tools::scoped_read` for the `read_file`
310    /// implementation and its security model.
311    #[serde(default, skip_serializing_if = "Vec::is_empty")]
312    pub builtin_tools: Vec<BuiltinToolGrant>,
313
314    /// Enable the `prompt_exposure` safety guardrail on this agent's LLM
315    /// responses. When `true`, the agent scans every terminal tool-call
316    /// content (proposal / batch evaluation) for internal-prompt leakage
317    /// (XML scaffolding tags, canonical tool names, meta-protocol phrases)
318    /// and forces a retry with a block-reason feedback message when a leak
319    /// is detected. Defaults to `false` so existing deployments do not
320    /// change behavior until explicitly opted in. See
321    /// [`docs/middleware.md#prompt_exposure-config`](../../docs/middleware.md)
322    /// for the detection heuristics.
323    #[serde(default)]
324    pub prompt_exposure_guard: bool,
325
326    /// Agent middleware pipelines (`before_prompt` / `on_provider_response` /
327    /// `on_completion` / `before_release`). Inert unless configured — the worker
328    /// only runs a pipeline when it's non-empty, so existing agents are
329    /// unaffected. Deserialize-only (the config carries a non-serializable
330    /// runtime `moderation_model`).
331    #[serde(default, skip_serializing)]
332    #[schema(ignore)]
333    pub middleware: crate::middleware::MiddlewareConfig,
334
335    /// Per-agent filesystem roots for the sandboxed `read_file` tool.
336    /// Each entry grants the agent permission to read any file under
337    /// the canonical path of that root. Symlink targets that resolve
338    /// outside the root are rejected. Empty (default) means the tool
339    /// isn't activated for this agent.
340    ///
341    /// Skipped on serialization so host filesystem paths never travel
342    /// over the wire (e.g. orchestrator capability advertisements).
343    /// Loaded from YAML on the agent host only.
344    #[serde(default, skip_serializing)]
345    #[schema(value_type = Vec<String>)]
346    pub read_file_roots: Vec<PathBuf>,
347}
348
349/// SDK-builtin tool grants attached to an agent at startup.
350///
351/// These differ from `user_tools` (which are job-scoped and forwarded
352/// over NATS to a dispatcher process) — `BuiltinToolGrant` entries
353/// instantiate concrete in-process `Tool` implementations whose
354/// security boundary is the configured root path. Use them when a
355/// non-claude agent needs filesystem read access scoped to a
356/// documentation corpus.
357#[derive(Debug, Clone, Deserialize, Serialize, ToSchema, PartialEq)]
358#[serde(tag = "type", rename_all = "snake_case")]
359pub enum BuiltinToolGrant {
360    /// Read a file whose canonicalized path is contained under one of
361    /// the configured roots, capped at `max_bytes`. The agent sees
362    /// this as a single `read_file(path)` tool — internally each call
363    /// validates the path against every entry in `roots` and accepts
364    /// the read iff one of them is a prefix of the canonical target.
365    /// Symlinks are followed during canonicalization, so a symlink
366    /// pointing outside every root is rejected.
367    ReadFile {
368        /// One or more allowed root directories. Each root is
369        /// canonicalized once at tool construction; the result is the
370        /// only filesystem region the tool will serve from for the
371        /// lifetime of the agent. Relative paths are resolved against
372        /// the agent's CWD at startup.
373        roots: Vec<String>,
374        /// Per-call file-size cap in bytes. Reads larger than this
375        /// return a structured error rather than truncated content,
376        /// so the agent can decide what to do (often: ask for a
377        /// smaller slice via grep/seek). Default 1 MiB.
378        #[serde(default = "default_read_file_max_bytes")]
379        max_bytes: usize,
380    },
381    /// Recursive regex search confined to one of the configured
382    /// roots. Wraps `grep -rEn` with the same canonicalize-then-prefix
383    /// sandbox the `read_file` grant uses, plus per-call result and
384    /// byte caps. The agent sees this as a single
385    /// `grep_search(pattern, [path], [include])` tool — useful when a
386    /// non-claude agent needs to locate the exact line of a peer's
387    /// `file:NNN` citation but doesn't have native Grep.
388    Grep {
389        /// Allowed root directories. Same semantics as `ReadFile`.
390        roots: Vec<String>,
391        /// Per-call stdout cap. Output beyond this is truncated and a
392        /// `truncated: true` flag returned. Default 1 MiB.
393        #[serde(default = "default_read_file_max_bytes")]
394        max_bytes: usize,
395        /// Per-call match-count cap (passed to `grep -m`). Default 200.
396        #[serde(default = "default_grep_max_results")]
397        max_results: usize,
398        /// Subprocess wall-clock timeout in seconds. Default 10 s.
399        /// ReDoS-style patterns can hang grep indefinitely without
400        /// this cap.
401        #[serde(default = "default_grep_timeout_secs")]
402        timeout_secs: u64,
403    },
404    /// Semantic PDF lookup via PageIndex `pdf_query.py`. The agent
405    /// supplies a tree filename (basename — slashes and `..` are
406    /// rejected) and a query string; the tool resolves the tree to
407    /// `<trees_root>/<tree>`, ensures the canonical path is still
408    /// under `trees_root`, then spawns
409    /// `<python_bin> <script_path> --tree <abs> --query <q> --top <k>`.
410    /// Stdout is JSON-Lines; the tool relays the buffer with the same
411    /// truncation + timeout discipline as `Grep`.
412    ///
413    /// Used to give non-claude aggregators (`provider_type` openai)
414    /// hardware-reference-manual lookup parity with the claude
415    /// specialists, which already reach pdf_query via the
416    /// `coverage_audit` and `hardware_lookup` sub-agents.
417    PdfQuery {
418        /// Directory holding the PageIndex `tree.json` files. Each
419        /// per-call `tree` argument must canonicalize under this root
420        /// — any `..`-traversal or out-of-sandbox symlink is rejected.
421        trees_root: String,
422        /// Absolute path to `pdf_query.py` (or any compatible script
423        /// that accepts `--tree`/`--query`/`--top` and prints
424        /// JSON-Lines on stdout). Validated at startup; the tool
425        /// refuses to instantiate if the script is missing.
426        script_path: String,
427        /// Interpreter binary. Default `"python3"` — override when the
428        /// runtime exposes the script via a venv shim or a wrapper.
429        #[serde(default = "default_pdf_query_python_bin")]
430        python_bin: String,
431        /// Per-call stdout cap. Output beyond this is truncated and a
432        /// `truncated: true` flag returned. Default 1 MiB.
433        #[serde(default = "default_read_file_max_bytes")]
434        max_bytes: usize,
435        /// Hard ceiling on the agent-supplied `top_k`. Requests above
436        /// this saturate to the cap; absent `top_k` defaults to this
437        /// value. Default 10.
438        #[serde(default = "default_pdf_query_max_results")]
439        max_results: usize,
440        /// Subprocess wall-clock timeout in seconds. Default 60 s
441        /// (pdf_query keyword scoring on a multi-thousand-node tree
442        /// can run for tens of seconds; raise if your trees are
443        /// larger).
444        #[serde(default = "default_pdf_query_timeout_secs")]
445        timeout_secs: u64,
446    },
447}
448
449fn default_read_file_max_bytes() -> usize {
450    1024 * 1024
451}
452
453fn default_grep_max_results() -> usize {
454    200
455}
456
457fn default_grep_timeout_secs() -> u64 {
458    10
459}
460
461fn default_pdf_query_python_bin() -> String {
462    "python3".to_string()
463}
464
465fn default_pdf_query_max_results() -> usize {
466    10
467}
468
469fn default_pdf_query_timeout_secs() -> u64 {
470    60
471}
472
473impl AgentConfig {
474    /// Deserialize the whole [`provider_config`](Self::provider_config) map
475    /// into a typed struct `T`. Third-party [`ProviderFactory`] impls use
476    /// this to read their bespoke YAML config without adding a typed section
477    /// to this core struct:
478    ///
479    /// ```ignore
480    /// #[derive(serde::Deserialize)]
481    /// struct CodexConfig { permission_mode: String, sandbox: bool }
482    /// let cfg: CodexConfig = agent_config.provider_config_as()?;
483    /// ```
484    ///
485    /// An empty map deserializes to whatever `T` makes of an empty mapping
486    /// (e.g. a struct whose fields all have `#[serde(default)]`).
487    ///
488    /// [`ProviderFactory`]: crate::providers::ProviderFactory
489    pub fn provider_config_as<T: serde::de::DeserializeOwned>(
490        &self,
491    ) -> Result<T, serde_yaml::Error> {
492        let mapping: serde_yaml::Mapping = self
493            .provider_config
494            .iter()
495            .map(|(k, v)| (serde_yaml::Value::String(k.clone()), v.clone()))
496            .collect();
497        serde_yaml::from_value(serde_yaml::Value::Mapping(mapping))
498    }
499
500    /// Validate that at most one provider section is populated and, when
501    /// `resolved_provider_type` is known, that it matches the populated section.
502    pub fn validate_provider_sections(
503        &self,
504        resolved_provider_type: Option<&str>,
505    ) -> Result<(), String> {
506        let sections: Vec<&str> = [
507            self.exec.as_ref().map(|_| "exec"),
508            self.mcp.as_ref().map(|_| "mcp"),
509            self.claude.as_ref().map(|_| "claude"),
510        ]
511        .into_iter()
512        .flatten()
513        .collect();
514
515        if sections.len() > 1 {
516            return Err(format!(
517                "agent '{}': multiple provider sections present ({}); exactly one is allowed",
518                self.name,
519                sections.join(", ")
520            ));
521        }
522
523        if let Some(ptype) = resolved_provider_type {
524            if let Some(&section) = sections.first() {
525                if section != ptype {
526                    return Err(format!(
527                        "agent '{}': provider_type '{}' does not match config section '{}'",
528                        self.name, ptype, section
529                    ));
530                }
531            }
532        }
533        Ok(())
534    }
535
536    /// Validate compaction knobs land in usable ranges. A
537    /// `scratchpad_squeeze_fraction` outside `(0.0, 1.0]` and a
538    /// `compact_history_default_keep` of zero would silently produce
539    /// degenerate compaction behavior.
540    pub fn validate_compaction_knobs(&self) -> Result<(), String> {
541        if !(self.scratchpad_squeeze_fraction > 0.0 && self.scratchpad_squeeze_fraction <= 1.0) {
542            return Err(format!(
543                "agent '{}': scratchpad_squeeze_fraction must be in (0.0, 1.0], got {}",
544                self.name, self.scratchpad_squeeze_fraction
545            ));
546        }
547        if self.compact_history_default_keep == 0 {
548            return Err(format!(
549                "agent '{}': compact_history_default_keep must be >= 1",
550                self.name
551            ));
552        }
553        Ok(())
554    }
555}
556
557/// Configuration for the exec provider, parsed from agent YAML.
558/// The agent spawns this command as a subprocess, writes the deliberation
559/// context as JSON to stdin, and reads the response JSON from stdout.
560#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
561pub struct ExecProviderConfig {
562    /// Command and arguments to spawn. First element is the binary.
563    /// Example: `["python3", "agents/my_agent.py"]`
564    pub command: Vec<String>,
565
566    /// Working directory for the subprocess. Defaults to the current directory.
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    #[schema(value_type = Option<String>)]
569    pub working_dir: Option<PathBuf>,
570
571    /// Extra environment variables passed to the subprocess (additive).
572    /// Values containing secrets are redacted during serialization.
573    #[serde(
574        default,
575        skip_serializing_if = "HashMap::is_empty",
576        serialize_with = "serialize_redacted_env"
577    )]
578    #[schema(value_type = HashMap<String, String>)]
579    pub env: HashMap<String, String>,
580
581    /// Hard timeout in seconds. Falls back to `phase_budget_remaining_secs`
582    /// from the agent context, then 300s if neither is set.
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub timeout_secs: Option<u64>,
585}
586
587/// Configuration for the MCP (Model Context Protocol) provider, parsed from
588/// agent YAML. The agent spawns this command as a subprocess and communicates
589/// via MCP over stdin/stdout (stdio transport). Unlike exec agents, MCP agents
590/// can call deliberation tools (read proposals, search history, update
591/// scratchpad) during execution.
592#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
593pub struct McpProviderConfig {
594    /// Command and arguments to spawn. First element is the binary.
595    /// Example: `["python3", "agents/mcp_agent.py"]`
596    pub command: Vec<String>,
597
598    /// Working directory for the subprocess. Defaults to the current directory.
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    #[schema(value_type = Option<String>)]
601    pub working_dir: Option<PathBuf>,
602
603    /// Extra environment variables passed to the subprocess (additive).
604    /// Values containing secrets are redacted during serialization.
605    #[serde(
606        default,
607        skip_serializing_if = "HashMap::is_empty",
608        serialize_with = "serialize_redacted_env"
609    )]
610    #[schema(value_type = HashMap<String, String>)]
611    pub env: HashMap<String, String>,
612
613    /// Hard timeout in seconds. Falls back to `phase_budget_remaining_secs`
614    /// from the agent context, then 300s if neither is set.
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub timeout_secs: Option<u64>,
617}
618
619/// Configuration for the Claude CLI provider, parsed from agent YAML.
620/// The agent spawns `claude` with flags derived from `AgentConfig` fields
621/// (system prompt, model, session persistence) and Claude-specific options
622/// (permission mode, budget, MCP config). Delegates to the MCP agent
623/// infrastructure for the hybrid stdin+MCP protocol.
624#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
625pub struct ClaudeProviderConfig {
626    /// Model override (e.g. "sonnet", "opus", "claude-sonnet-4-6").
627    /// Falls back to `AgentConfig.model_name` if not set.
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub model: Option<String>,
630
631    /// Working directory for Claude CLI.
632    #[serde(default, skip_serializing_if = "Option::is_none")]
633    #[schema(value_type = Option<String>)]
634    pub working_dir: Option<PathBuf>,
635
636    /// Extra environment variables passed to the subprocess (additive).
637    /// Values containing secrets are redacted during serialization.
638    #[serde(
639        default,
640        skip_serializing_if = "HashMap::is_empty",
641        serialize_with = "serialize_redacted_env"
642    )]
643    #[schema(value_type = HashMap<String, String>)]
644    pub env: HashMap<String, String>,
645
646    /// Hard timeout in seconds. Falls back to `phase_budget_remaining_secs`,
647    /// then 600s (Claude CLI sessions can be longer than simple scripts).
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub timeout_secs: Option<u64>,
650
651    /// Permission mode for automated use. Maps to `--permission-mode`.
652    /// Values: `"bypassPermissions"` (default), `"default"`, `"acceptEdits"`, `"plan"`.
653    #[serde(default = "default_claude_permission_mode")]
654    pub permission_mode: String,
655
656    /// Max USD budget per phase call. Maps to `--max-budget-usd`.
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub max_budget_usd: Option<f64>,
659
660    /// Path(s) to MCP config JSON files for additional tools.
661    /// Maps to `--mcp-config`. Use for giving Claude access to external tools.
662    #[serde(default, skip_serializing_if = "Vec::is_empty")]
663    #[schema(value_type = Vec<String>)]
664    pub mcp_config: Vec<PathBuf>,
665
666    /// Allowed tools filter. Maps to `--allowed-tools`.
667    /// Example: `["Bash(git:*)", "Edit", "Read"]`
668    #[serde(default, skip_serializing_if = "Vec::is_empty")]
669    pub allowed_tools: Vec<String>,
670
671    /// Disallowed tools filter. Maps to `--disallowed-tools`.
672    /// Removed from inherited or allowed tools.
673    /// Use `["Write", "Edit"]` to make all `add_dirs` effectively read-only.
674    /// Example: `["Write", "Edit", "Bash(rm:*)"]`
675    #[serde(default, skip_serializing_if = "Vec::is_empty")]
676    pub disallowed_tools: Vec<String>,
677
678    /// Context files injected into Claude's system prompt.
679    /// Each file is read by NSED at invocation time and inlined as
680    /// `--append-system-prompt "<context_file>...<contents>...</context_file>"`.
681    /// No directory access is granted — use `add_dirs` for that.
682    /// Example: `["docs/architecture.md", "specs/api-contract.json"]`
683    #[serde(default, skip_serializing_if = "Vec::is_empty")]
684    #[schema(value_type = Vec<String>)]
685    pub context_files: Vec<PathBuf>,
686
687    /// Allow Claude to write files (Write, Edit, NotebookEdit tools).
688    /// Default `false` — Claude gets read-only filesystem access.
689    /// Set `true` if Claude needs to create or modify files.
690    #[serde(default)]
691    pub writable: bool,
692
693    /// Additional directories to grant Claude tool access to.
694    /// Maps to `--add-dir`. Read-only unless `writable: true`.
695    /// Example: `["/data/shared", "./vendor"]`
696    #[serde(default, skip_serializing_if = "Vec::is_empty")]
697    #[schema(value_type = Vec<String>)]
698    pub add_dirs: Vec<PathBuf>,
699
700    /// Sub-agent definitions. Maps to `--agents`.
701    /// Lets Claude spawn specialized sub-agents during deliberation.
702    /// Keys are agent names (lowercase + hyphens), values configure each sub-agent.
703    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
704    pub agents: HashMap<String, ClaudeSubAgentDef>,
705
706    /// Additional CLI flags passed verbatim to `claude`.
707    /// Example: `["--verbose", "--no-session-persistence"]`
708    #[serde(default, skip_serializing_if = "Vec::is_empty")]
709    pub extra_args: Vec<String>,
710}
711
712/// Sub-agent definition for Claude CLI `--agents` flag.
713/// Each sub-agent runs in its own context window with a custom prompt,
714/// specific tool access, and independent permissions.
715///
716/// See <https://code.claude.com/docs/en/sub-agents>
717#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, ToSchema)]
718pub struct ClaudeSubAgentDef {
719    /// When Claude should delegate to this sub-agent.
720    pub description: String,
721
722    /// System prompt (the sub-agent's instructions).
723    pub prompt: String,
724
725    /// Tool allowlist. Inherits all tools if omitted.
726    /// Example: `["Read", "Grep", "Glob", "Bash"]`
727    #[serde(default, skip_serializing_if = "Vec::is_empty")]
728    pub tools: Vec<String>,
729
730    /// Tool denylist. Removed from inherited or allowed tools.
731    /// Example: `["Write", "Edit"]`
732    #[serde(default, skip_serializing_if = "Vec::is_empty")]
733    #[serde(rename = "disallowedTools")]
734    pub disallowed_tools: Vec<String>,
735
736    /// Model: `"sonnet"`, `"opus"`, `"haiku"`, `"inherit"`, or a full model ID.
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub model: Option<String>,
739
740    /// Permission mode: `"default"`, `"acceptEdits"`, `"dontAsk"`,
741    /// `"bypassPermissions"`, or `"plan"`.
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    #[serde(rename = "permissionMode")]
744    pub permission_mode: Option<String>,
745
746    /// Maximum number of agentic turns before the sub-agent stops.
747    #[serde(default, skip_serializing_if = "Option::is_none")]
748    #[serde(rename = "maxTurns")]
749    pub max_turns: Option<u32>,
750
751    /// MCP server definitions or references scoped to this sub-agent.
752    /// Each entry is either a server name (string reference) or an
753    /// inline definition `{ "name": { "type": "stdio", ... } }`.
754    #[serde(default, skip_serializing_if = "Vec::is_empty")]
755    #[serde(rename = "mcpServers")]
756    pub mcp_servers: Vec<serde_json::Value>,
757
758    /// Effort level: `"low"`, `"medium"`, `"high"`, `"max"` (Opus only).
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub effort: Option<String>,
761
762    /// Run as a background sub-agent (concurrent with main conversation).
763    #[serde(default, skip_serializing_if = "Option::is_none")]
764    pub background: Option<bool>,
765
766    /// Run in a temporary git worktree for isolated file access.
767    /// Set to `"worktree"` to enable.
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub isolation: Option<String>,
770
771    /// Persistent memory scope: `"user"`, `"project"`, or `"local"`.
772    #[serde(default, skip_serializing_if = "Option::is_none")]
773    pub memory: Option<String>,
774
775    /// Skills to preload into the sub-agent's context.
776    #[serde(default, skip_serializing_if = "Vec::is_empty")]
777    pub skills: Vec<String>,
778
779    /// Auto-submitted as the first user turn when running as main agent
780    /// via `--agent`. Commands and skills are processed.
781    #[serde(default, skip_serializing_if = "Option::is_none")]
782    #[serde(rename = "initialPrompt")]
783    pub initial_prompt: Option<String>,
784}
785
786fn default_claude_permission_mode() -> String {
787    "bypassPermissions".to_string()
788}
789
790impl Default for ClaudeProviderConfig {
791    fn default() -> Self {
792        Self {
793            model: None,
794            working_dir: None,
795            env: HashMap::new(),
796            timeout_secs: None,
797            permission_mode: default_claude_permission_mode(),
798            max_budget_usd: None,
799            mcp_config: Vec::new(),
800            allowed_tools: Vec::new(),
801            disallowed_tools: Vec::new(),
802            context_files: Vec::new(),
803            add_dirs: Vec::new(),
804            agents: HashMap::new(),
805            extra_args: Vec::new(),
806            writable: false,
807        }
808    }
809}
810
811/// OpenRouter provider routing extensions.
812/// Docs: <https://openrouter.ai/docs/guides/routing/provider-selection>
813///
814/// Maps to the `"provider"` object in the request body. Fields are all
815/// optional — only set the ones you want to override. Empty struct = no
816/// provider block emitted.
817#[derive(Debug, Deserialize, Clone, Serialize, Default, PartialEq, ToSchema)]
818pub struct OpenRouterConfig {
819    /// Provider prioritization: `"throughput"` | `"latency"` | `"price"`.
820    /// Defaults to OpenRouter's price-first routing when unset.
821    #[serde(default, skip_serializing_if = "Option::is_none")]
822    pub provider_sort: Option<String>,
823
824    /// When `true`, restricts routing to Zero Data Retention endpoints.
825    /// Compliance requirement for sensitive workloads.
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub zdr: Option<bool>,
828
829    /// When `false`, disables automatic fallback to backup providers on
830    /// primary failure. Use when you want deterministic routing and
831    /// prefer a hard fail over a silent reroute to a slower endpoint.
832    #[serde(default, skip_serializing_if = "Option::is_none")]
833    pub allow_fallbacks: Option<bool>,
834
835    /// Provider slugs to exclude from routing (case-insensitive OpenRouter
836    /// slug, e.g. `["nextbit", "ionstream"]`). Useful when pairing
837    /// `allow_fallbacks: false` with `provider_sort: "throughput"` —
838    /// otherwise the fastest slot is often held by a high-throughput but
839    /// low-uptime provider. Injected as `"provider.ignore": [...]`.
840    #[serde(default, skip_serializing_if = "Vec::is_empty")]
841    pub ignore: Vec<String>,
842
843    /// Provider allowlist — restrict routing to ONLY these provider
844    /// slugs (e.g. `["akashml/fp8", "parasail/fp8"]`). Mutually
845    /// exclusive in spirit with `ignore`; setting both means
846    /// "allowlist minus the ignore set". Use to pin a model to a
847    /// specific provider variant when the default routing yields a
848    /// smaller advertised context window than the targeted variant —
849    /// e.g. `qwen/qwen3.6-35b-a3b` defaults to a 131k window but the
850    /// `akashml/fp8` and `parasail/fp8` variants advertise 262k.
851    /// Injected as `"provider.only": [...]`.
852    #[serde(default, skip_serializing_if = "Vec::is_empty")]
853    pub only: Vec<String>,
854
855    /// When `true`, strip reasoning tokens from the visible `content`
856    /// stream (maps to OpenRouter's `reasoning.exclude: true`). The model
857    /// still reasons internally at the configured `reasoning_effort`; only
858    /// the chain-of-thought portion of the output is omitted. Use for
859    /// models that otherwise dump reasoning into `content` and leave zero
860    /// budget for the final structured-output tool call (observed:
861    /// gpt-oss-120b via OR native streaming). When this flag switches the
862    /// request to the unified `reasoning: { effort, exclude }` object, the
863    /// legacy `reasoning_effort` top-level field is no longer emitted.
864    #[serde(default, skip_serializing_if = "Option::is_none")]
865    pub exclude_reasoning: Option<bool>,
866
867    /// When set, enables OpenRouter's web-search plugin for this agent —
868    /// injected into the request `plugins` array. Omitted → `plugins: []`
869    /// (the explicit no-outbound-network default). See [`WebSearchConfig`].
870    #[serde(default, skip_serializing_if = "Option::is_none")]
871    pub web_search: Option<WebSearchConfig>,
872}
873
874/// OpenRouter web-search plugin (`plugins: [{ "id": "web", ... }]`). All fields
875/// optional; an empty config still enables the plugin at OpenRouter's defaults
876/// (native/exa engine, 5 results). Docs:
877/// <https://openrouter.ai/docs/guides/features/plugins/web-search>
878#[derive(Debug, Deserialize, Clone, Serialize, Default, PartialEq, ToSchema)]
879pub struct WebSearchConfig {
880    /// Search backend: `"native"` (provider-built-in, e.g. OpenAI/xAI),
881    /// `"exa"`, `"firecrawl"`, `"parallel"`, `"perplexity"`. Unset → OpenRouter
882    /// default. `native` avoids the per-request exa surcharge on models that
883    /// browse natively.
884    #[serde(default, skip_serializing_if = "Option::is_none")]
885    pub engine: Option<String>,
886
887    /// Max results to fetch (OpenRouter default 5).
888    #[serde(default, skip_serializing_if = "Option::is_none")]
889    pub max_results: Option<u32>,
890
891    /// Override the prompt prepended to the injected search results.
892    #[serde(default, skip_serializing_if = "Option::is_none")]
893    pub search_prompt: Option<String>,
894}
895
896/// Precision parameters for a specific task category.
897/// pg = P(correct answer | single zero-shot generation)
898/// pv = P(correct verification | evaluation of proposals)
899#[derive(Debug, Deserialize, Clone, Serialize, Default, ToSchema)]
900pub struct TaskPrecision {
901    /// Zero-shot generation precision (0.0 - 1.0)
902    pub pg: f64,
903    /// Verification/evaluation precision (0.0 - 1.0, typically > pg)
904    pub pv: f64,
905}
906
907pub fn default_context_window() -> i32 {
908    128_000
909}
910
911pub fn default_scratchpad_limit() -> i32 {
912    2000
913}
914
915pub fn default_scratchpad_squeeze_fraction() -> f64 {
916    0.95
917}
918
919pub fn default_compact_history_keep() -> usize {
920    2
921}
922
923pub fn default_repair_invalid_escapes() -> bool {
924    true
925}
926
927pub fn default_textual_feedback() -> bool {
928    true
929}
930
931pub fn default_use_streaming() -> bool {
932    true
933}
934
935pub fn default_presence_penalty() -> Option<f32> {
936    Some(1.5)
937}
938
939pub fn default_max_retries() -> Option<i32> {
940    Some(3)
941}
942
943pub fn default_max_react_iterations() -> Option<i32> {
944    // Doubled from the historical 10 after prod observation that
945    // agents with complex tool-call chains (search_deliberation +
946    // update_scratchpad + read_own_proposal before submit_proposal)
947    // could legitimately consume 8-12 iterations on a single turn
948    // on long prompts, leaving no headroom for retries. 20 is the
949    // knee where further increases don't reduce max-iter exhaustion
950    // — beyond this the agent is usually stuck in a loop, not making
951    // progress, and should fail fast instead.
952    Some(20)
953}
954
955pub fn default_max_scratchpad_size() -> Option<i32> {
956    Some(32_768)
957}
958
959pub fn default_failure_dumps() -> Option<String> {
960    Some("on".to_string())
961}
962
963pub fn default_response_sla_secs() -> u64 {
964    3600
965}
966
967pub fn default_propagate_payment_error() -> bool {
968    true
969}
970
971/// Manual Default implementation that matches the serde defaults.
972/// `#[derive(Default)]` would set `response_sla_secs` to 0 and
973/// `propagate_payment_error` to false, which differs from the documented
974/// serde defaults (3600s / 1 hour and true respectively).
975impl Default for AgentConfig {
976    fn default() -> Self {
977        Self {
978            name: String::new(),
979            model: None,
980            provider_id: String::new(),
981            model_name: String::new(),
982            temperature: 0.0,
983            max_tokens: 0,
984            system_prompt_override: None,
985            persona: None,
986            max_react_iterations: default_max_react_iterations(),
987            max_scratchpad_size: default_max_scratchpad_size(),
988            max_retries: default_max_retries(),
989            max_concurrent_jobs: None,
990            supports_native_thinking: false,
991            frequency_penalty: None,
992            presence_penalty: default_presence_penalty(),
993            textual_feedback: default_textual_feedback(),
994            use_streaming: default_use_streaming(),
995            merge_system_prompt: false,
996            unwrap_hallucinated_tool_calls: false,
997            repair_invalid_escapes: default_repair_invalid_escapes(),
998            scratchpad_limit: default_scratchpad_limit(),
999            scratchpad_squeeze_fraction: default_scratchpad_squeeze_fraction(),
1000            compact_history_default_keep: default_compact_history_keep(),
1001            json_mode: false,
1002            disable_native_tools: false,
1003            context_window: default_context_window(),
1004            reasoning_effort: None,
1005            tool_format: None,
1006            input_price_per_mtok: None,
1007            output_price_per_mtok: None,
1008            chars_per_token: None,
1009            orchestrators: Vec::new(),
1010            task_precision: None,
1011            failure_dumps: default_failure_dumps(),
1012            response_sla_secs: default_response_sla_secs(),
1013            propagate_payment_error: default_propagate_payment_error(),
1014            capability_tags: Vec::new(),
1015            description: None,
1016            signing_schemes: Vec::new(),
1017            auto_stop: false,
1018            exec: None,
1019            mcp: None,
1020            claude: None,
1021            provider_config: HashMap::new(),
1022            openrouter: None,
1023            builtin_tools: Vec::new(),
1024            prompt_exposure_guard: false,
1025            read_file_roots: Vec::new(),
1026            middleware: Default::default(),
1027        }
1028    }
1029}
1030
1031/// True when the agent runs through the native OpenAI-compatible LLM path
1032/// (no `exec`, no `mcp`, no `claude` provider section). Used by features
1033/// like the sandboxed `read_file` tool that don't apply to providers
1034/// with their own native filesystem affordances.
1035pub fn is_openai_family_provider(config: &AgentConfig) -> bool {
1036    config.exec.is_none() && config.mcp.is_none() && config.claude.is_none()
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042    use serde_json::json;
1043
1044    #[test]
1045    fn agent_middleware_block_deserializes() {
1046        // An agent's `middleware:` block now parses into AgentConfig; empty by
1047        // default so existing agents are unaffected.
1048        let yaml = r#"
1049name: PropductBot
1050provider_id: claude
1051middleware:
1052  before_prompt:
1053    - dylib: ./libpatch_deliberation.dylib
1054      config:
1055        patch_deliberation: { upstream: epic }
1056  on_completion:
1057    - dylib: ./libpatch_deliberation.dylib
1058  on_job_complete:
1059    - dylib: ./libpatch_deliberation.dylib
1060"#;
1061        let cfg: AgentConfig = serde_yaml::from_str(yaml).unwrap();
1062        assert_eq!(cfg.middleware.before_prompt.len(), 1);
1063        assert_eq!(cfg.middleware.on_completion.len(), 1);
1064        assert_eq!(cfg.middleware.on_job_complete.len(), 1);
1065        assert!(cfg.middleware.on_provider_response.is_empty());
1066        // default agent → empty middleware (no behavior change)
1067        assert!(AgentConfig::default().middleware.is_empty());
1068    }
1069
1070    #[test]
1071    fn provider_config_deserializes_into_typed_struct() {
1072        #[derive(Debug, serde::Deserialize, PartialEq)]
1073        struct CodexConfig {
1074            permission_mode: String,
1075            sandbox: bool,
1076            #[serde(default)]
1077            extra_args: Vec<String>,
1078        }
1079
1080        let cfg: AgentConfig = serde_yaml::from_str(
1081            r#"
1082name: codex-a
1083provider_id: my_codex
1084model_name: codex-mini
1085provider_config:
1086  permission_mode: "auto"
1087  sandbox: true
1088  extra_args: ["--yolo"]
1089"#,
1090        )
1091        .expect("agent yaml must parse");
1092
1093        let codex: CodexConfig = cfg.provider_config_as().expect("typed read");
1094        assert_eq!(
1095            codex,
1096            CodexConfig {
1097                permission_mode: "auto".into(),
1098                sandbox: true,
1099                extra_args: vec!["--yolo".into()],
1100            }
1101        );
1102    }
1103
1104    #[test]
1105    fn provider_config_empty_yields_all_defaults() {
1106        #[derive(Debug, serde::Deserialize)]
1107        struct AllDefault {
1108            #[serde(default)]
1109            flag: bool,
1110        }
1111        let cfg = AgentConfig::default();
1112        assert!(cfg.provider_config.is_empty());
1113        let parsed: AllDefault = cfg.provider_config_as().expect("empty map → defaults");
1114        assert!(!parsed.flag);
1115    }
1116
1117    #[test]
1118    fn provider_config_omitted_from_serialization_when_empty() {
1119        let cfg = AgentConfig {
1120            name: "x".into(),
1121            ..Default::default()
1122        };
1123        let yaml = serde_yaml::to_string(&cfg).unwrap();
1124        assert!(
1125            !yaml.contains("provider_config"),
1126            "empty provider_config must be skipped in serialization"
1127        );
1128    }
1129
1130    #[test]
1131    fn test_builtin_tools_roundtrip() {
1132        // Explicit max_bytes — preserved verbatim.
1133        let json = json!({
1134            "name": "test",
1135            "provider_id": "openrouter",
1136            "model_name": "glm-5.1",
1137            "builtin_tools": [
1138                {
1139                    "type": "read_file",
1140                    "roots": ["/work/corpus", "/work/linux"],
1141                    "max_bytes": 2097152
1142                }
1143            ]
1144        });
1145        let config: AgentConfig = serde_json::from_value(json).expect("deserialize");
1146        assert_eq!(config.builtin_tools.len(), 1);
1147        match &config.builtin_tools[0] {
1148            BuiltinToolGrant::ReadFile { roots, max_bytes } => {
1149                assert_eq!(
1150                    roots,
1151                    &vec!["/work/corpus".to_string(), "/work/linux".to_string()]
1152                );
1153                assert_eq!(*max_bytes, 2097152);
1154            }
1155            other => panic!("expected ReadFile, got {other:?}"),
1156        }
1157
1158        // Default max_bytes — applies the documented 1 MiB fallback.
1159        let default_json = json!({
1160            "name": "test2",
1161            "provider_id": "openrouter",
1162            "model_name": "glm-5.1",
1163            "builtin_tools": [{"type": "read_file", "roots": ["/tmp"]}]
1164        });
1165        let cfg: AgentConfig = serde_json::from_value(default_json).expect("deserialize");
1166        match &cfg.builtin_tools[0] {
1167            BuiltinToolGrant::ReadFile { max_bytes, .. } => {
1168                assert_eq!(*max_bytes, 1024 * 1024);
1169            }
1170            other => panic!("expected ReadFile, got {other:?}"),
1171        }
1172
1173        // Empty / absent builtin_tools — no panic, defaults to empty Vec.
1174        let bare_json = json!({
1175            "name": "test3",
1176            "provider_id": "p",
1177            "model_name": "m"
1178        });
1179        let bare: AgentConfig = serde_json::from_value(bare_json).expect("deserialize");
1180        assert!(bare.builtin_tools.is_empty());
1181    }
1182
1183    #[test]
1184    fn test_pdf_query_grant_roundtrip() {
1185        // Explicit fields — preserved verbatim.
1186        let json = json!({
1187            "name": "agg",
1188            "provider_id": "openrouter",
1189            "model_name": "glm-5.1",
1190            "builtin_tools": [
1191                {
1192                    "type": "pdf_query",
1193                    "trees_root": "/work/corpus/trees",
1194                    "script_path": "/work/scripts/pdf_query.py",
1195                    "python_bin": "/opt/pageindex/.venv/bin/python3",
1196                    "max_bytes": 524288,
1197                    "max_results": 8,
1198                    "timeout_secs": 90
1199                }
1200            ]
1201        });
1202        let cfg: AgentConfig = serde_json::from_value(json).expect("deserialize");
1203        match &cfg.builtin_tools[0] {
1204            BuiltinToolGrant::PdfQuery {
1205                trees_root,
1206                script_path,
1207                python_bin,
1208                max_bytes,
1209                max_results,
1210                timeout_secs,
1211            } => {
1212                assert_eq!(trees_root, "/work/corpus/trees");
1213                assert_eq!(script_path, "/work/scripts/pdf_query.py");
1214                assert_eq!(python_bin, "/opt/pageindex/.venv/bin/python3");
1215                assert_eq!(*max_bytes, 524288);
1216                assert_eq!(*max_results, 8);
1217                assert_eq!(*timeout_secs, 90);
1218            }
1219            other => panic!("expected PdfQuery, got {other:?}"),
1220        }
1221
1222        // Defaults: omit python_bin/max_bytes/max_results/timeout_secs.
1223        let defaults_json = json!({
1224            "name": "agg2",
1225            "provider_id": "openrouter",
1226            "model_name": "glm-5.1",
1227            "builtin_tools": [
1228                {
1229                    "type": "pdf_query",
1230                    "trees_root": "/work/corpus/trees",
1231                    "script_path": "/work/scripts/pdf_query.py"
1232                }
1233            ]
1234        });
1235        let cfg: AgentConfig = serde_json::from_value(defaults_json).expect("deserialize");
1236        match &cfg.builtin_tools[0] {
1237            BuiltinToolGrant::PdfQuery {
1238                python_bin,
1239                max_bytes,
1240                max_results,
1241                timeout_secs,
1242                ..
1243            } => {
1244                assert_eq!(python_bin, "python3");
1245                assert_eq!(*max_bytes, 1024 * 1024);
1246                assert_eq!(*max_results, 10);
1247                assert_eq!(*timeout_secs, 60);
1248            }
1249            other => panic!("expected PdfQuery, got {other:?}"),
1250        }
1251    }
1252
1253    #[test]
1254    fn test_agent_config_defaults() {
1255        let json = json!({
1256            "name": "test-agent",
1257            "provider_id": "ollama_local",
1258            "model_name": "model",
1259        });
1260
1261        let config: AgentConfig = serde_json::from_value(json).expect("Deserialization failed");
1262        assert_eq!(config.max_react_iterations, Some(20));
1263    }
1264
1265    #[test]
1266    fn test_agent_config_model_field_deserialization() {
1267        let json = json!({
1268            "name": "dotpath-agent",
1269            "model": "together_ai.llama-70b",
1270        });
1271        let config: AgentConfig = serde_json::from_value(json).unwrap();
1272        assert_eq!(config.model, Some("together_ai.llama-70b".to_string()));
1273        // provider_id and model_name should be at their defaults (empty)
1274        assert!(config.provider_id.is_empty());
1275        assert!(config.model_name.is_empty());
1276    }
1277
1278    #[test]
1279    fn test_agent_config_model_field_default_none() {
1280        let json = json!({
1281            "name": "no-model-field",
1282            "provider_id": "p1",
1283            "model_name": "m1",
1284        });
1285        let config: AgentConfig = serde_json::from_value(json).unwrap();
1286        assert!(config.model.is_none());
1287    }
1288
1289    #[test]
1290    fn test_agent_config_model_field_not_serialized_when_none() {
1291        let config = AgentConfig::default();
1292        let serialized = serde_json::to_value(&config).unwrap();
1293        let obj = serialized.as_object().unwrap();
1294        assert!(
1295            !obj.contains_key("model"),
1296            "model: None should be omitted from serialization"
1297        );
1298    }
1299
1300    #[test]
1301    fn test_agent_config_pricing_fields_default_none() {
1302        let json = json!({
1303            "name": "test-agent",
1304            "provider_id": "p1",
1305            "model_name": "m1",
1306        });
1307        let config: AgentConfig = serde_json::from_value(json).unwrap();
1308        assert_eq!(config.input_price_per_mtok, None);
1309        assert_eq!(config.output_price_per_mtok, None);
1310        assert_eq!(config.chars_per_token, None);
1311    }
1312
1313    #[test]
1314    fn test_agent_config_pricing_fields_roundtrip() {
1315        let json = json!({
1316            "name": "priced-agent",
1317            "provider_id": "openai",
1318            "model_name": "gpt-4",
1319            "input_price_per_mtok": 10.0,
1320            "output_price_per_mtok": 30.0,
1321            "chars_per_token": 3.5
1322        });
1323        let config: AgentConfig = serde_json::from_value(json).unwrap();
1324        assert_eq!(config.input_price_per_mtok, Some(10.0));
1325        assert_eq!(config.output_price_per_mtok, Some(30.0));
1326        assert_eq!(config.chars_per_token, Some(3.5));
1327
1328        // Roundtrip
1329        let serialized = serde_json::to_value(&config).unwrap();
1330        let deserialized: AgentConfig = serde_json::from_value(serialized).unwrap();
1331        assert_eq!(deserialized.input_price_per_mtok, Some(10.0));
1332        assert_eq!(deserialized.output_price_per_mtok, Some(30.0));
1333        assert_eq!(deserialized.chars_per_token, Some(3.5));
1334    }
1335
1336    #[test]
1337    fn test_agent_config_chars_per_token_override() {
1338        let json = json!({
1339            "name": "cjk-agent",
1340            "provider_id": "ollama",
1341            "model_name": "qwen",
1342            "chars_per_token": 1.5
1343        });
1344        let config: AgentConfig = serde_json::from_value(json).unwrap();
1345        assert_eq!(config.chars_per_token, Some(1.5));
1346    }
1347
1348    #[test]
1349    fn test_agent_config_all_defaults() {
1350        let json = json!({
1351            "name": "minimal",
1352            "provider_id": "p",
1353            "model_name": "m",
1354        });
1355        let config: AgentConfig = serde_json::from_value(json).expect("deserialize");
1356
1357        assert_eq!(config.name, "minimal");
1358        assert_eq!(config.provider_id, "p");
1359        assert_eq!(config.model_name, "m");
1360        assert_eq!(config.temperature, 0.0);
1361        assert_eq!(config.max_tokens, 0);
1362        assert!(config.system_prompt_override.is_none());
1363        assert!(config.persona.is_none());
1364        assert_eq!(config.max_react_iterations, Some(20));
1365        assert_eq!(config.max_scratchpad_size, Some(32768));
1366        assert_eq!(config.max_retries, Some(3));
1367        assert!(!config.supports_native_thinking);
1368        assert!(config.frequency_penalty.is_none());
1369        assert_eq!(config.presence_penalty, Some(1.5));
1370        assert!(config.textual_feedback);
1371        assert!(config.use_streaming);
1372        assert!(!config.merge_system_prompt);
1373        assert!(!config.unwrap_hallucinated_tool_calls);
1374        assert!(config.repair_invalid_escapes);
1375        assert_eq!(config.scratchpad_limit, 2000);
1376        assert!(!config.json_mode);
1377        assert!(!config.disable_native_tools);
1378        assert_eq!(config.context_window, 128_000);
1379        assert!(config.reasoning_effort.is_none());
1380        assert!(config.tool_format.is_none());
1381        assert!(config.input_price_per_mtok.is_none());
1382        assert!(config.output_price_per_mtok.is_none());
1383        assert!(config.chars_per_token.is_none());
1384        assert!(config.task_precision.is_none());
1385        assert_eq!(config.failure_dumps, Some("on".to_string()));
1386        assert_eq!(config.response_sla_secs, 3600);
1387        assert!(config.propagate_payment_error);
1388    }
1389
1390    #[test]
1391    fn test_agent_config_full_roundtrip() {
1392        let json = json!({
1393            "name": "full-agent",
1394            "provider_id": "openai",
1395            "model_name": "gpt-4o",
1396            "temperature": 0.7,
1397            "max_tokens": 4096,
1398            "system_prompt_override": "You are helpful.",
1399            "persona": "expert analyst",
1400            "max_react_iterations": 5,
1401            "max_scratchpad_size": 16384,
1402            "max_retries": 2,
1403            "supports_native_thinking": true,
1404            "frequency_penalty": 0.5,
1405            "presence_penalty": 0.8,
1406            "textual_feedback": false,
1407            "use_streaming": false,
1408            "merge_system_prompt": true,
1409            "unwrap_hallucinated_tool_calls": true,
1410            "repair_invalid_escapes": false,
1411            "scratchpad_limit": 500,
1412            "json_mode": true,
1413            "disable_native_tools": true,
1414            "context_window": 64000,
1415            "reasoning_effort": "high",
1416            "tool_format": "json",
1417            "input_price_per_mtok": 2.5,
1418            "output_price_per_mtok": 10.0,
1419            "chars_per_token": 1.5,
1420            "task_precision": {
1421                "supply": { "pg": 0.3, "pv": 0.8 },
1422                "audit": { "pg": 0.5, "pv": 0.9 }
1423            },
1424            "failure_dumps": "full",
1425            "response_sla_secs": 120,
1426            "propagate_payment_error": false
1427        });
1428
1429        let config: AgentConfig = serde_json::from_value(json).expect("deserialize");
1430
1431        // Verify key fields
1432        assert_eq!(config.name, "full-agent");
1433        assert_eq!(config.temperature, 0.7);
1434        assert_eq!(config.max_tokens, 4096);
1435        assert_eq!(
1436            config.system_prompt_override,
1437            Some("You are helpful.".to_string())
1438        );
1439        assert_eq!(config.persona, Some("expert analyst".to_string()));
1440        assert_eq!(config.max_react_iterations, Some(5));
1441        assert_eq!(config.max_scratchpad_size, Some(16384));
1442        assert_eq!(config.max_retries, Some(2));
1443        assert!(config.supports_native_thinking);
1444        assert_eq!(config.frequency_penalty, Some(0.5));
1445        assert_eq!(config.presence_penalty, Some(0.8));
1446        assert!(!config.textual_feedback);
1447        assert!(!config.use_streaming);
1448        assert!(config.merge_system_prompt);
1449        assert!(config.unwrap_hallucinated_tool_calls);
1450        assert!(!config.repair_invalid_escapes);
1451        assert_eq!(config.scratchpad_limit, 500);
1452        assert!(config.json_mode);
1453        assert!(config.disable_native_tools);
1454        assert_eq!(config.context_window, 64000);
1455        assert_eq!(config.reasoning_effort, Some("high".to_string()));
1456        assert_eq!(config.tool_format, Some("json".to_string()));
1457        assert_eq!(config.input_price_per_mtok, Some(2.5));
1458        assert_eq!(config.output_price_per_mtok, Some(10.0));
1459        assert_eq!(config.chars_per_token, Some(1.5));
1460        assert_eq!(config.failure_dumps, Some("full".to_string()));
1461
1462        // Verify task_precision map
1463        let tp = config
1464            .task_precision
1465            .as_ref()
1466            .expect("task_precision present");
1467        assert_eq!(tp.len(), 2);
1468        let supply = tp.get("supply").expect("supply key");
1469        assert!((supply.pg - 0.3).abs() < f64::EPSILON);
1470        assert!((supply.pv - 0.8).abs() < f64::EPSILON);
1471        let audit = tp.get("audit").expect("audit key");
1472        assert!((audit.pg - 0.5).abs() < f64::EPSILON);
1473        assert!((audit.pv - 0.9).abs() < f64::EPSILON);
1474
1475        // Roundtrip: serialize then deserialize
1476        let serialized = serde_json::to_value(&config).expect("serialize");
1477        let roundtripped: AgentConfig =
1478            serde_json::from_value(serialized).expect("deserialize roundtrip");
1479
1480        assert_eq!(roundtripped.name, "full-agent");
1481        assert_eq!(roundtripped.temperature, 0.7);
1482        assert_eq!(roundtripped.max_tokens, 4096);
1483        assert_eq!(roundtripped.tool_format, Some("json".to_string()));
1484        assert_eq!(roundtripped.failure_dumps, Some("full".to_string()));
1485        assert_eq!(roundtripped.reasoning_effort, Some("high".to_string()));
1486        assert_eq!(roundtripped.context_window, 64000);
1487        assert_eq!(config.response_sla_secs, 120);
1488        assert_eq!(roundtripped.response_sla_secs, 120);
1489        assert!(!config.propagate_payment_error);
1490        assert!(!roundtripped.propagate_payment_error);
1491        let rt_tp = roundtripped.task_precision.as_ref().unwrap();
1492        assert_eq!(rt_tp.len(), 2);
1493        assert!((rt_tp["supply"].pg - 0.3).abs() < f64::EPSILON);
1494    }
1495
1496    #[test]
1497    fn test_agent_config_orchestrators_skip_serializing() {
1498        let mut config = AgentConfig {
1499            name: "orch-test".to_string(),
1500            provider_id: "p".to_string(),
1501            model_name: "m".to_string(),
1502            ..AgentConfig::default()
1503        };
1504        config.orchestrators = vec![OrchestratorEntry {
1505            id: Some("local".to_string()),
1506            url: "http://localhost:8080".to_string(),
1507            bearer_token: None,
1508            invite_code: None,
1509        }];
1510
1511        let serialized = serde_json::to_value(&config).expect("serialize");
1512        let obj = serialized.as_object().expect("should be object");
1513        assert!(
1514            !obj.contains_key("orchestrators"),
1515            "orchestrators should be skipped during serialization"
1516        );
1517    }
1518
1519    #[test]
1520    fn test_task_precision_serde() {
1521        let tp = TaskPrecision { pg: 0.3, pv: 0.8 };
1522        let serialized = serde_json::to_value(&tp).expect("serialize");
1523        assert_eq!(serialized["pg"], 0.3);
1524        assert_eq!(serialized["pv"], 0.8);
1525
1526        let roundtripped: TaskPrecision = serde_json::from_value(serialized).expect("deserialize");
1527        assert!((roundtripped.pg - 0.3).abs() < f64::EPSILON);
1528        assert!((roundtripped.pv - 0.8).abs() < f64::EPSILON);
1529    }
1530
1531    #[test]
1532    fn test_task_precision_default() {
1533        let tp = TaskPrecision::default();
1534        assert!((tp.pg - 0.0).abs() < f64::EPSILON);
1535        assert!((tp.pv - 0.0).abs() < f64::EPSILON);
1536    }
1537
1538    #[test]
1539    fn test_default_function_values() {
1540        assert_eq!(default_context_window(), 128_000);
1541        assert_eq!(default_scratchpad_limit(), 2000);
1542        assert!(default_repair_invalid_escapes());
1543        assert!(default_textual_feedback());
1544        assert!(default_use_streaming());
1545        assert_eq!(default_presence_penalty(), Some(1.5));
1546        assert_eq!(default_max_retries(), Some(3));
1547        assert_eq!(default_max_react_iterations(), Some(20));
1548        assert_eq!(default_max_scratchpad_size(), Some(32_768));
1549        assert_eq!(default_failure_dumps(), Some("on".to_string()));
1550        assert_eq!(default_response_sla_secs(), 3600);
1551        assert!(default_propagate_payment_error());
1552    }
1553
1554    #[test]
1555    fn test_agent_config_propagate_payment_error_default_true() {
1556        let json = json!({
1557            "name": "no-config",
1558            "provider_id": "p",
1559            "model_name": "m",
1560        });
1561        let config: AgentConfig = serde_json::from_value(json).unwrap();
1562        assert!(config.propagate_payment_error, "should default to true");
1563    }
1564
1565    #[test]
1566    fn test_agent_config_propagate_payment_error_false() {
1567        let json = json!({
1568            "name": "silent-agent",
1569            "provider_id": "p",
1570            "model_name": "m",
1571            "propagate_payment_error": false
1572        });
1573        let config: AgentConfig = serde_json::from_value(json).unwrap();
1574        assert!(!config.propagate_payment_error);
1575    }
1576
1577    #[test]
1578    fn test_agent_config_response_sla_default() {
1579        let json = json!({
1580            "name": "no-sla",
1581            "provider_id": "p",
1582            "model_name": "m",
1583        });
1584        let config: AgentConfig = serde_json::from_value(json).unwrap();
1585        assert_eq!(config.response_sla_secs, 3600, "should default to 3600s");
1586    }
1587
1588    #[test]
1589    fn test_agent_config_response_sla_explicit() {
1590        let json = json!({
1591            "name": "fast-agent",
1592            "provider_id": "openai",
1593            "model_name": "gpt-4o",
1594            "response_sla_secs": 60
1595        });
1596        let config: AgentConfig = serde_json::from_value(json).unwrap();
1597        assert_eq!(config.response_sla_secs, 60);
1598
1599        // Roundtrip
1600        let serialized = serde_json::to_value(&config).unwrap();
1601        let roundtripped: AgentConfig = serde_json::from_value(serialized).unwrap();
1602        assert_eq!(roundtripped.response_sla_secs, 60);
1603    }
1604
1605    #[test]
1606    fn test_agent_config_failure_dumps_values() {
1607        // "on" (default)
1608        let json_on = json!({
1609            "name": "a", "provider_id": "p", "model_name": "m",
1610            "failure_dumps": "on"
1611        });
1612        let cfg: AgentConfig = serde_json::from_value(json_on).unwrap();
1613        assert_eq!(cfg.failure_dumps, Some("on".to_string()));
1614
1615        // "full"
1616        let json_full = json!({
1617            "name": "a", "provider_id": "p", "model_name": "m",
1618            "failure_dumps": "full"
1619        });
1620        let cfg: AgentConfig = serde_json::from_value(json_full).unwrap();
1621        assert_eq!(cfg.failure_dumps, Some("full".to_string()));
1622
1623        // "off"
1624        let json_off = json!({
1625            "name": "a", "provider_id": "p", "model_name": "m",
1626            "failure_dumps": "off"
1627        });
1628        let cfg: AgentConfig = serde_json::from_value(json_off).unwrap();
1629        assert_eq!(cfg.failure_dumps, Some("off".to_string()));
1630
1631        // Explicit null → None
1632        let json_null = json!({
1633            "name": "a", "provider_id": "p", "model_name": "m",
1634            "failure_dumps": null
1635        });
1636        let cfg: AgentConfig = serde_json::from_value(json_null).unwrap();
1637        assert!(cfg.failure_dumps.is_none());
1638    }
1639
1640    /// Ensures the manual `Default` impl stays in sync with serde defaults.
1641    /// If a field is added to `AgentConfig` with a `#[serde(default = "...")]`
1642    /// but the `Default` impl isn't updated (or vice-versa), this test fails.
1643    #[test]
1644    fn test_agent_config_default_parity_with_serde() {
1645        let from_default = AgentConfig::default();
1646
1647        // Minimal JSON — serde fills every other field from its defaults
1648        let from_serde: AgentConfig = serde_json::from_value(json!({
1649            "name": "",
1650            "provider_id": "",
1651            "model_name": "",
1652            "temperature": 0.0,
1653            "max_tokens": 0,
1654        }))
1655        .expect("minimal JSON should deserialize with serde defaults");
1656
1657        // Compare every field that has a serde(default) function
1658        assert_eq!(from_default.model, from_serde.model, "model");
1659        assert_eq!(
1660            from_default.max_react_iterations, from_serde.max_react_iterations,
1661            "max_react_iterations"
1662        );
1663        assert_eq!(
1664            from_default.max_scratchpad_size, from_serde.max_scratchpad_size,
1665            "max_scratchpad_size"
1666        );
1667        assert_eq!(
1668            from_default.max_retries, from_serde.max_retries,
1669            "max_retries"
1670        );
1671        assert_eq!(
1672            from_default.presence_penalty, from_serde.presence_penalty,
1673            "presence_penalty"
1674        );
1675        assert_eq!(
1676            from_default.textual_feedback, from_serde.textual_feedback,
1677            "textual_feedback"
1678        );
1679        assert_eq!(
1680            from_default.use_streaming, from_serde.use_streaming,
1681            "use_streaming"
1682        );
1683        assert_eq!(
1684            from_default.repair_invalid_escapes, from_serde.repair_invalid_escapes,
1685            "repair_invalid_escapes"
1686        );
1687        assert_eq!(
1688            from_default.scratchpad_limit, from_serde.scratchpad_limit,
1689            "scratchpad_limit"
1690        );
1691        assert_eq!(
1692            from_default.context_window, from_serde.context_window,
1693            "context_window"
1694        );
1695        assert_eq!(
1696            from_default.failure_dumps, from_serde.failure_dumps,
1697            "failure_dumps"
1698        );
1699        assert_eq!(
1700            from_default.response_sla_secs, from_serde.response_sla_secs,
1701            "response_sla_secs"
1702        );
1703        assert_eq!(
1704            from_default.propagate_payment_error, from_serde.propagate_payment_error,
1705            "propagate_payment_error"
1706        );
1707        assert_eq!(
1708            from_default.capability_tags, from_serde.capability_tags,
1709            "capability_tags"
1710        );
1711        assert_eq!(
1712            from_default.description, from_serde.description,
1713            "description"
1714        );
1715        assert_eq!(
1716            from_default.signing_schemes, from_serde.signing_schemes,
1717            "signing_schemes"
1718        );
1719        assert_eq!(from_default.exec, from_serde.exec, "exec");
1720        assert_eq!(from_default.mcp, from_serde.mcp, "mcp");
1721        assert_eq!(from_default.claude, from_serde.claude, "claude");
1722        assert_eq!(from_default.openrouter, from_serde.openrouter, "openrouter");
1723        assert_eq!(
1724            from_default.prompt_exposure_guard, from_serde.prompt_exposure_guard,
1725            "prompt_exposure_guard"
1726        );
1727    }
1728
1729    /// `openrouter: Option<OpenRouterConfig>` must omit from the wire
1730    /// when `None`. Skipping this check let the test pass while the
1731    /// field silently serialised as `null` — which broke forward
1732    /// compat on orchestrator deployments that pre-dated the field.
1733    #[test]
1734    fn test_openrouter_field_omitted_when_none() {
1735        let cfg = AgentConfig {
1736            name: "test".into(),
1737            provider_id: "openai".into(),
1738            model_name: "gpt-4".into(),
1739            temperature: 0.0,
1740            max_tokens: 0,
1741            ..Default::default()
1742        };
1743        assert!(cfg.openrouter.is_none(), "sanity: default is None");
1744        let serialized = serde_json::to_string(&cfg).unwrap();
1745        assert!(
1746            !serialized.contains("openrouter"),
1747            "openrouter=None should be omitted, got: {serialized}"
1748        );
1749
1750        // Round-trip a minimal JSON with no openrouter field — the
1751        // field must arrive as `None`, not as a synthetic default.
1752        let json = json!({
1753            "name": "test",
1754            "provider_id": "openai",
1755            "model_name": "gpt-4",
1756            "temperature": 0.0,
1757            "max_tokens": 0,
1758        });
1759        let parsed: AgentConfig = serde_json::from_value(json).unwrap();
1760        assert!(parsed.openrouter.is_none());
1761    }
1762
1763    /// Round-trip with a populated `OpenRouterConfig` to lock the
1764    /// wire shape. If any field is dropped by serde or the default
1765    /// impl drifts, this catches it at build time.
1766    #[test]
1767    fn test_openrouter_field_roundtrip_populated() {
1768        let cfg = AgentConfig {
1769            name: "test".into(),
1770            provider_id: "openrouter".into(),
1771            model_name: "google/gemma-4-26b-a4b-it".into(),
1772            temperature: 0.7,
1773            max_tokens: 16384,
1774            openrouter: Some(OpenRouterConfig {
1775                provider_sort: Some("throughput".into()),
1776                zdr: Some(true),
1777                allow_fallbacks: Some(false),
1778                ignore: vec!["nextbit".into()],
1779                only: vec!["akashml/fp8".into()],
1780                exclude_reasoning: Some(true),
1781                web_search: None,
1782            }),
1783            ..Default::default()
1784        };
1785        let serialized = serde_json::to_string(&cfg).unwrap();
1786        assert!(serialized.contains(r#""openrouter""#));
1787        let parsed: AgentConfig = serde_json::from_str(&serialized).unwrap();
1788        let or = parsed.openrouter.expect("openrouter must round-trip");
1789        assert_eq!(or.provider_sort.as_deref(), Some("throughput"));
1790        assert_eq!(or.zdr, Some(true));
1791        assert_eq!(or.allow_fallbacks, Some(false));
1792        assert_eq!(or.ignore, vec!["nextbit".to_string()]);
1793        assert_eq!(or.only, vec!["akashml/fp8".to_string()]);
1794        assert_eq!(or.exclude_reasoning, Some(true));
1795    }
1796
1797    #[test]
1798    fn test_capability_tags_roundtrip() {
1799        let json = json!({
1800            "name": "test",
1801            "provider_id": "openai",
1802            "model_name": "gpt-4",
1803            "temperature": 0.7,
1804            "max_tokens": 1000,
1805            "capability_tags": ["legal", "audit", "compliance"],
1806            "description": "Legal audit specialist",
1807            "signing_schemes": ["eip712", "ed25519"]
1808        });
1809        let config: AgentConfig = serde_json::from_value(json).unwrap();
1810        assert_eq!(config.capability_tags, vec!["legal", "audit", "compliance"]);
1811        assert_eq!(
1812            config.description.as_deref(),
1813            Some("Legal audit specialist")
1814        );
1815        assert_eq!(config.signing_schemes, vec!["eip712", "ed25519"]);
1816
1817        // Roundtrip
1818        let serialized = serde_json::to_string(&config).unwrap();
1819        let deserialized: AgentConfig = serde_json::from_str(&serialized).unwrap();
1820        assert_eq!(deserialized.capability_tags, config.capability_tags);
1821        assert_eq!(deserialized.description, config.description);
1822        assert_eq!(deserialized.signing_schemes, config.signing_schemes);
1823    }
1824
1825    #[test]
1826    fn test_new_fields_default_to_empty() {
1827        let json = json!({
1828            "name": "minimal",
1829            "provider_id": "test",
1830            "model_name": "test",
1831            "temperature": 0.0,
1832            "max_tokens": 0,
1833        });
1834        let config: AgentConfig = serde_json::from_value(json).unwrap();
1835        assert!(config.capability_tags.is_empty());
1836        assert!(config.description.is_none());
1837        assert!(config.signing_schemes.is_empty());
1838        assert!(config.exec.is_none());
1839    }
1840
1841    #[test]
1842    fn test_agent_config_exec_roundtrip() {
1843        let json = json!({
1844            "name": "py-agent",
1845            "provider_id": "exec_local",
1846            "model_name": "custom",
1847            "exec": {
1848                "command": ["python3", "agent.py"],
1849                "working_dir": "/opt/agents",
1850                "env": {"MY_VAR": "value"},
1851                "timeout_secs": 120
1852            }
1853        });
1854        let config: AgentConfig = serde_json::from_value(json).unwrap();
1855        let exec = config.exec.as_ref().expect("exec should be present");
1856        assert_eq!(exec.command, vec!["python3", "agent.py"]);
1857        assert_eq!(
1858            exec.working_dir.as_ref().map(|p| p.to_str().unwrap()),
1859            Some("/opt/agents")
1860        );
1861        assert_eq!(exec.env.get("MY_VAR").unwrap(), "value");
1862        assert_eq!(exec.timeout_secs, Some(120));
1863
1864        // Roundtrip
1865        let serialized = serde_json::to_value(&config).unwrap();
1866        let deserialized: AgentConfig = serde_json::from_value(serialized).unwrap();
1867        assert_eq!(deserialized.exec, config.exec);
1868    }
1869
1870    #[test]
1871    fn test_agent_config_exec_none_omitted_from_json() {
1872        let config = AgentConfig::default();
1873        let serialized = serde_json::to_value(&config).unwrap();
1874        let obj = serialized.as_object().unwrap();
1875        assert!(
1876            !obj.contains_key("exec"),
1877            "exec: None should be omitted from serialization"
1878        );
1879        assert!(
1880            !obj.contains_key("mcp"),
1881            "mcp: None should be omitted from serialization"
1882        );
1883        assert!(
1884            !obj.contains_key("claude"),
1885            "claude: None should be omitted from serialization"
1886        );
1887    }
1888
1889    #[test]
1890    fn redacted_env_hides_sensitive_keys() {
1891        let mut env = HashMap::new();
1892        env.insert("SAFE_VAR".to_string(), "visible".to_string());
1893        env.insert("API_KEY".to_string(), "super-secret".to_string());
1894        env.insert("db_password".to_string(), "pass123".to_string());
1895        env.insert("AUTH_TOKEN".to_string(), "tok_abc".to_string());
1896        env.insert("MY_CREDENTIAL_ID".to_string(), "cred".to_string());
1897        env.insert("AWS_SECRET_ACCESS_KEY".to_string(), "aws123".to_string());
1898
1899        let config = ExecProviderConfig {
1900            command: vec!["test".into()],
1901            working_dir: None,
1902            env,
1903            timeout_secs: None,
1904        };
1905        let serialized = serde_json::to_value(&config).unwrap();
1906        let env_obj = serialized["env"].as_object().unwrap();
1907
1908        assert_eq!(env_obj["SAFE_VAR"], "visible");
1909        assert_eq!(env_obj["API_KEY"], "<redacted>");
1910        assert_eq!(env_obj["db_password"], "<redacted>");
1911        assert_eq!(env_obj["AUTH_TOKEN"], "<redacted>");
1912        assert_eq!(env_obj["MY_CREDENTIAL_ID"], "<redacted>");
1913        assert_eq!(env_obj["AWS_SECRET_ACCESS_KEY"], "<redacted>");
1914    }
1915
1916    #[test]
1917    fn validate_provider_sections_rejects_multiple() {
1918        let config: AgentConfig = serde_json::from_value(json!({
1919            "name": "a", "provider_id": "p", "model_name": "m",
1920            "exec": { "command": ["test"] },
1921            "mcp": { "command": ["test"] }
1922        }))
1923        .unwrap();
1924        let err = config.validate_provider_sections(None).unwrap_err();
1925        assert!(err.contains("multiple provider sections"), "{err}");
1926    }
1927
1928    #[test]
1929    fn validate_provider_sections_rejects_mismatch() {
1930        let config: AgentConfig = serde_json::from_value(json!({
1931            "name": "a", "provider_id": "p", "model_name": "m",
1932            "exec": { "command": ["test"] }
1933        }))
1934        .unwrap();
1935        let err = config.validate_provider_sections(Some("mcp")).unwrap_err();
1936        assert!(err.contains("does not match"), "{err}");
1937    }
1938
1939    #[test]
1940    fn validate_provider_sections_accepts_matching() {
1941        let config: AgentConfig = serde_json::from_value(json!({
1942            "name": "a", "provider_id": "p", "model_name": "m",
1943            "exec": { "command": ["test"] }
1944        }))
1945        .unwrap();
1946        config.validate_provider_sections(Some("exec")).unwrap();
1947    }
1948
1949    #[test]
1950    fn validate_provider_sections_accepts_none() {
1951        let config: AgentConfig = serde_json::from_value(json!({
1952            "name": "a", "provider_id": "p", "model_name": "m"
1953        }))
1954        .unwrap();
1955        config.validate_provider_sections(Some("exec")).unwrap();
1956    }
1957
1958    #[test]
1959    fn validate_compaction_knobs_accepts_defaults() {
1960        AgentConfig::default().validate_compaction_knobs().unwrap();
1961    }
1962
1963    #[test]
1964    fn validate_compaction_knobs_rejects_zero_keep() {
1965        let cfg = AgentConfig {
1966            compact_history_default_keep: 0,
1967            ..AgentConfig::default()
1968        };
1969        let err = cfg.validate_compaction_knobs().unwrap_err();
1970        assert!(err.contains("compact_history_default_keep"));
1971    }
1972
1973    #[test]
1974    fn validate_compaction_knobs_rejects_out_of_range_fraction() {
1975        for bad in [0.0, 1.5, -0.1] {
1976            let cfg = AgentConfig {
1977                scratchpad_squeeze_fraction: bad,
1978                ..AgentConfig::default()
1979            };
1980            assert!(
1981                cfg.validate_compaction_knobs().is_err(),
1982                "fraction {bad} must be rejected"
1983            );
1984        }
1985    }
1986
1987    #[test]
1988    fn validate_compaction_knobs_accepts_boundary_one() {
1989        let cfg = AgentConfig {
1990            scratchpad_squeeze_fraction: 1.0,
1991            compact_history_default_keep: 1,
1992            ..AgentConfig::default()
1993        };
1994        cfg.validate_compaction_knobs().unwrap();
1995    }
1996
1997    // ── persona deserialization ─────────────────────────────────────
1998
1999    fn parse_agent_persona(yaml_fragment: &str) -> Option<String> {
2000        let yaml = format!("name: test\n{yaml_fragment}");
2001        let cfg: AgentConfig = serde_yaml::from_str(&yaml).expect("agent yaml must parse");
2002        cfg.persona
2003    }
2004
2005    /// Plain-string persona — back-compat path. Operators with old
2006    /// `agent.yml` files must keep parsing as if this PR never landed.
2007    #[test]
2008    fn persona_inline_string_back_compat() {
2009        let persona = parse_agent_persona("persona: \"you are a careful reviewer\"");
2010        assert_eq!(persona.as_deref(), Some("you are a careful reviewer"));
2011    }
2012
2013    #[test]
2014    fn persona_absent_stays_none() {
2015        let persona = parse_agent_persona("");
2016        assert!(persona.is_none());
2017    }
2018
2019    #[test]
2020    fn persona_layered_text_joins_with_double_newline() {
2021        let persona = parse_agent_persona(
2022            "persona:\n\
2023             - type: text\n  prompt: \"a\"\n\
2024             - type: text\n  prompt: \"b\"\n",
2025        );
2026        assert_eq!(persona.as_deref(), Some("a\n\nb"));
2027    }
2028
2029    /// Md layer reads the referenced file. Mixed with text layers in
2030    /// order produces the expected stacked string.
2031    #[test]
2032    fn persona_layered_md_reads_file_and_stacks_with_text() {
2033        let sandbox_dir = tempfile::tempdir().unwrap();
2034        let md_path = sandbox_dir.path().join("body.md");
2035        std::fs::write(&md_path, "from-md\n").unwrap();
2036        let yaml = format!(
2037            "persona:\n\
2038             - type: text\n  prompt: \"lead\"\n\
2039             - type: md\n  prompt: \"{}\"\n\
2040             - type: text\n  prompt: \"tail\"\n",
2041            md_path.display()
2042        );
2043        let persona = parse_agent_persona(&yaml);
2044        assert_eq!(persona.as_deref(), Some("lead\n\nfrom-md\n\n\ntail"));
2045    }
2046
2047    #[test]
2048    fn max_concurrent_jobs_parses_and_defaults_none() {
2049        let with =
2050            serde_yaml::from_str::<AgentConfig>("name: a\nmax_concurrent_jobs: 1\n").unwrap();
2051        assert_eq!(with.max_concurrent_jobs, Some(1));
2052        let without = serde_yaml::from_str::<AgentConfig>("name: a\n").unwrap();
2053        assert_eq!(without.max_concurrent_jobs, None);
2054    }
2055
2056    /// Missing md file → parse error naming the path. Operators see
2057    /// the failure at fleet boot rather than at agent advertisement.
2058    #[test]
2059    fn persona_layered_md_missing_file_errors_with_path() {
2060        let yaml = "name: test\n\
2061                    persona:\n\
2062                    - type: md\n  prompt: \"/path/does/not/exist/persona.md\"\n";
2063        let err = serde_yaml::from_str::<AgentConfig>(yaml).unwrap_err();
2064        let msg = err.to_string();
2065        assert!(
2066            msg.contains("/path/does/not/exist/persona.md") && msg.contains("could not be read"),
2067            "error must name the missing path; got: {msg}"
2068        );
2069    }
2070}