Skip to main content

awaken_contract/
registry_spec.rs

1//! Serializable agent definition — pure data, no trait objects.
2//!
3//! `AgentSpec` is the unified agent configuration: it describes both the
4//! declarative registry references (model binding, plugins, tools) and the runtime
5//! behavior (active_hook_filter filtering, typed plugin sections, context policy).
6//!
7//! Supersedes the former `AgentProfile` — see ADR-0009.
8
9use std::collections::{BTreeMap, HashMap, HashSet};
10
11use serde::de::DeserializeOwned;
12use serde::{Deserialize, Deserializer, Serialize};
13use serde_json::Value;
14
15use crate::contract::inference::{ContextWindowPolicy, ReasoningEffort};
16use crate::error::StateError;
17
18// ---------------------------------------------------------------------------
19// PluginConfigKey — compile-time binding between key string and config type
20// ---------------------------------------------------------------------------
21
22/// Typed plugin configuration key.
23///
24/// Binds a string key to a concrete config type at compile time.
25///
26/// ```ignore
27/// struct PermissionConfigKey;
28/// impl PluginConfigKey for PermissionConfigKey {
29///     const KEY: &'static str = "permission";
30///     type Config = PermissionConfig;
31/// }
32/// ```
33pub trait PluginConfigKey: 'static + Send + Sync {
34    /// Section key in the `sections` map.
35    const KEY: &'static str;
36
37    /// Typed configuration value.
38    type Config: Default
39        + Clone
40        + Serialize
41        + DeserializeOwned
42        + schemars::JsonSchema
43        + Send
44        + Sync
45        + 'static;
46}
47
48// ---------------------------------------------------------------------------
49// AgentSpec
50// ---------------------------------------------------------------------------
51
52/// Serializable agent definition referencing registries by ID.
53///
54/// Can be saved to JSON, loaded from config files, or transmitted over the network.
55/// Resolved at runtime via the resolve pipeline into a `ResolvedAgent`.
56///
57/// Also serves as the runtime behavior configuration passed to hooks via
58/// `PhaseContext.agent_spec`. Plugins read their typed config via `spec.config::<K>()`.
59#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
60#[serde(deny_unknown_fields)]
61pub struct AgentSpec {
62    /// Unique agent identifier.
63    pub id: String,
64    /// ModelRegistry ID — resolved to a runtime model binding.
65    pub model_id: String,
66    /// System prompt sent to the LLM.
67    pub system_prompt: String,
68    /// Maximum inference rounds before the agent stops.
69    #[serde(default = "default_max_rounds")]
70    pub max_rounds: usize,
71    /// Maximum continuation retries for truncated LLM responses.
72    #[serde(default = "default_max_continuation_retries")]
73    pub max_continuation_retries: usize,
74    /// Context window management policy. `None` disables compaction and truncation.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub context_policy: Option<ContextWindowPolicy>,
77    /// Default reasoning effort for this agent. `None` means no thinking/reasoning.
78    /// Can be overridden per-run via `InferenceOverride` or per-step via plugins.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub reasoning_effort: Option<ReasoningEffort>,
81    /// PluginRegistry IDs — resolved at build time.
82    #[serde(default)]
83    pub plugin_ids: Vec<String>,
84    /// Runtime hook filter: only hooks from plugins in this set will run.
85    /// Empty = no filtering (all loaded plugins' hooks run).
86    /// Distinct from `plugin_ids` which controls which plugins are loaded.
87    #[serde(
88        default,
89        skip_serializing_if = "HashSet::is_empty",
90        alias = "active_plugins"
91    )]
92    pub active_hook_filter: HashSet<String>,
93    /// Allowed tool IDs (whitelist). `None` = all tools.
94    #[serde(default)]
95    pub allowed_tools: Option<Vec<String>>,
96    /// Excluded tool IDs (blacklist). Applied after `allowed_tools`.
97    #[serde(default)]
98    pub excluded_tools: Option<Vec<String>>,
99    /// Optional remote endpoint. If set, this agent runs on a remote backend.
100    /// If None, this agent runs locally.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub endpoint: Option<RemoteEndpoint>,
103    /// IDs of sub-agents this agent can delegate to.
104    /// Each ID must be a registered agent in the AgentSpecRegistry.
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub delegates: Vec<String>,
107    /// Plugin-specific configuration sections (keyed by PluginConfigKey::KEY).
108    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
109    pub sections: HashMap<String, Value>,
110    /// Registry source this agent was loaded from.
111    /// `None` for locally defined agents; `Some("cloud")` for agents from the "cloud" registry.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub registry: Option<String>,
114}
115
116/// Remote backend authentication configuration.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
118pub struct RemoteAuth {
119    #[serde(rename = "type")]
120    pub auth_type: String,
121    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
122    pub params: BTreeMap<String, Value>,
123}
124
125impl RemoteAuth {
126    #[must_use]
127    pub fn bearer(token: impl Into<String>) -> Self {
128        let mut params = BTreeMap::new();
129        params.insert("token".into(), Value::String(token.into()));
130        Self {
131            auth_type: "bearer".into(),
132            params,
133        }
134    }
135
136    #[must_use]
137    pub fn param_str(&self, key: &str) -> Option<&str> {
138        self.params.get(key).and_then(Value::as_str)
139    }
140}
141
142/// Remote endpoint configuration for agents running on external backends.
143#[derive(Debug, Clone, Serialize, PartialEq, schemars::JsonSchema)]
144pub struct RemoteEndpoint {
145    #[serde(default = "default_remote_backend")]
146    pub backend: String,
147    pub base_url: String,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub auth: Option<RemoteAuth>,
150    /// Target resource on the remote backend. Backend-specific semantics.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub target: Option<String>,
153    #[serde(default = "default_timeout")]
154    pub timeout_ms: u64,
155    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
156    pub options: BTreeMap<String, Value>,
157}
158
159impl Default for RemoteEndpoint {
160    fn default() -> Self {
161        Self {
162            backend: default_remote_backend(),
163            base_url: String::new(),
164            auth: None,
165            target: None,
166            timeout_ms: default_timeout(),
167            options: BTreeMap::new(),
168        }
169    }
170}
171
172fn default_remote_backend() -> String {
173    "a2a".to_string()
174}
175
176fn default_timeout() -> u64 {
177    300_000
178}
179
180#[derive(Debug, Deserialize)]
181struct RawRemoteEndpoint {
182    #[serde(default)]
183    backend: Option<String>,
184    base_url: String,
185    #[serde(default)]
186    auth: Option<RemoteAuth>,
187    #[serde(default)]
188    target: Option<String>,
189    #[serde(default)]
190    timeout_ms: Option<u64>,
191    #[serde(default)]
192    options: BTreeMap<String, Value>,
193    #[serde(default)]
194    bearer_token: Option<String>,
195    #[serde(default)]
196    agent_id: Option<String>,
197    #[serde(default)]
198    poll_interval_ms: Option<u64>,
199}
200
201impl<'de> Deserialize<'de> for RemoteEndpoint {
202    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
203    where
204        D: Deserializer<'de>,
205    {
206        let raw = RawRemoteEndpoint::deserialize(deserializer)?;
207        let has_legacy_fields =
208            raw.bearer_token.is_some() || raw.agent_id.is_some() || raw.poll_interval_ms.is_some();
209        let has_canonical_fields = raw.backend.is_some()
210            || raw.auth.is_some()
211            || raw.target.is_some()
212            || !raw.options.is_empty();
213
214        if has_legacy_fields && has_canonical_fields {
215            return Err(serde::de::Error::custom(
216                "cannot mix legacy A2A endpoint fields with canonical remote endpoint fields",
217            ));
218        }
219
220        if has_legacy_fields {
221            let mut options = BTreeMap::new();
222            if let Some(poll_interval_ms) = raw.poll_interval_ms {
223                options.insert("poll_interval_ms".into(), Value::from(poll_interval_ms));
224            }
225            return Ok(Self {
226                backend: default_remote_backend(),
227                base_url: raw.base_url,
228                auth: raw.bearer_token.map(RemoteAuth::bearer),
229                target: raw.agent_id,
230                timeout_ms: raw.timeout_ms.unwrap_or_else(default_timeout),
231                options,
232            });
233        }
234
235        let backend = raw.backend.unwrap_or_else(default_remote_backend);
236        if backend.trim().is_empty() {
237            return Err(serde::de::Error::custom(
238                "remote endpoint backend must not be empty",
239            ));
240        }
241
242        Ok(Self {
243            backend,
244            base_url: raw.base_url,
245            auth: raw.auth,
246            target: raw.target,
247            timeout_ms: raw.timeout_ms.unwrap_or_else(default_timeout),
248            options: raw.options,
249        })
250    }
251}
252
253// ---------------------------------------------------------------------------
254// ModelBindingSpec
255// ---------------------------------------------------------------------------
256
257/// Serializable model binding from a stable ID to a provider and upstream model.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
259#[serde(deny_unknown_fields)]
260pub struct ModelBindingSpec {
261    /// Unique identifier (for example `"gpt-4o-mini"` or `"research-default"`).
262    pub id: String,
263    /// Provider spec ID referenced by this binding.
264    pub provider_id: String,
265    /// Actual model name sent to the upstream provider.
266    pub upstream_model: String,
267}
268
269// ---------------------------------------------------------------------------
270// ProviderSpec
271// ---------------------------------------------------------------------------
272
273/// Serializable provider configuration used to construct an LLM executor.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
275pub struct ProviderSpec {
276    /// Unique identifier (for example `"openai"` or `"anthropic-prod"`).
277    pub id: String,
278    /// GenAI adapter kind (for example `"openai"`, `"anthropic"`, `"ollama"`).
279    pub adapter: String,
280    /// Explicit API key. If absent, the adapter's environment variable is used.
281    ///
282    /// Wrapped in [`crate::RedactedString`] so it does not leak through
283    /// `Debug` / `Display`. The wire format remains a plain JSON string;
284    /// empty-string input deserializes to `None`.
285    #[serde(
286        default,
287        deserialize_with = "deserialize_optional_non_empty",
288        skip_serializing_if = "Option::is_none"
289    )]
290    pub api_key: Option<crate::RedactedString>,
291    /// Base URL override for proxy or self-hosted deployments. Empty-string
292    /// input deserializes to `None`.
293    #[serde(
294        default,
295        deserialize_with = "deserialize_optional_non_empty",
296        skip_serializing_if = "Option::is_none"
297    )]
298    pub base_url: Option<String>,
299    /// Request timeout in seconds.
300    #[serde(default = "default_provider_timeout_secs")]
301    pub timeout_secs: u64,
302    /// Adapter-specific non-secret options consumed by
303    /// `build_genai_provider_executor` (for example
304    /// `{"headers": {"OpenAI-Organization": "org-xxx"}}`).
305    ///
306    /// Secrets must use [`ProviderSpec::api_key`]; do not store credentials
307    /// here. Unrecognised keys are accepted by the schema but ignored at
308    /// build time.
309    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
310    pub adapter_options: BTreeMap<String, Value>,
311}
312
313/// Treat an absent field, JSON `null`, or `""` as `None`. Used by spec types
314/// that accept optional textual configuration so callers do not have to
315/// strip/convert empty values themselves.
316fn deserialize_optional_non_empty<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
317where
318    D: Deserializer<'de>,
319    T: From<String>,
320{
321    Ok(Option::<String>::deserialize(deserializer)?
322        .filter(|value| !value.is_empty())
323        .map(T::from))
324}
325
326fn default_provider_timeout_secs() -> u64 {
327    300
328}
329
330// ---------------------------------------------------------------------------
331// McpServerSpec
332// ---------------------------------------------------------------------------
333
334/// Transport type for an MCP server connection.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
336#[serde(rename_all = "lowercase")]
337pub enum McpTransportKind {
338    /// Launch an MCP server as a child process over stdio.
339    Stdio,
340    /// Connect to an MCP server over HTTP.
341    Http,
342}
343
344/// Restart policy for MCP server connections.
345#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
346pub struct McpRestartPolicy {
347    /// Whether to automatically restart on failure.
348    #[serde(default)]
349    pub enabled: bool,
350    /// Maximum number of restart attempts. `None` means unlimited.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub max_attempts: Option<u32>,
353    /// Delay between restart attempts in milliseconds.
354    #[serde(default = "default_mcp_restart_delay_ms")]
355    pub delay_ms: u64,
356    /// Exponential backoff multiplier.
357    #[serde(default = "default_mcp_restart_backoff_multiplier")]
358    pub backoff_multiplier: f64,
359    /// Maximum delay between restarts in milliseconds.
360    #[serde(default = "default_mcp_restart_max_delay_ms")]
361    pub max_delay_ms: u64,
362}
363
364impl Default for McpRestartPolicy {
365    fn default() -> Self {
366        Self {
367            enabled: false,
368            max_attempts: None,
369            delay_ms: default_mcp_restart_delay_ms(),
370            backoff_multiplier: default_mcp_restart_backoff_multiplier(),
371            max_delay_ms: default_mcp_restart_max_delay_ms(),
372        }
373    }
374}
375
376const fn default_mcp_restart_delay_ms() -> u64 {
377    1000
378}
379
380const fn default_mcp_restart_backoff_multiplier() -> f64 {
381    2.0
382}
383
384const fn default_mcp_restart_max_delay_ms() -> u64 {
385    30_000
386}
387
388/// Serializable MCP server configuration used to construct a live MCP tool registry.
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
390pub struct McpServerSpec {
391    /// Unique identifier and MCP server name.
392    pub id: String,
393    /// Connection transport kind.
394    pub transport: McpTransportKind,
395    /// Command to execute when using stdio transport.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub command: Option<String>,
398    /// Command arguments for stdio transport.
399    #[serde(default, skip_serializing_if = "Vec::is_empty")]
400    pub args: Vec<String>,
401    /// URL for HTTP transport.
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub url: Option<String>,
404    /// Server-specific configuration payload forwarded during initialization.
405    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
406    pub config: serde_json::Map<String, Value>,
407    /// Connection timeout in seconds.
408    #[serde(default = "default_mcp_timeout_secs")]
409    pub timeout_secs: u64,
410    /// Environment variables for stdio transport.
411    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
412    pub env: BTreeMap<String, String>,
413    /// Restart policy for reconnecting failed servers.
414    #[serde(default)]
415    pub restart_policy: McpRestartPolicy,
416}
417
418fn default_mcp_timeout_secs() -> u64 {
419    30
420}
421
422impl Default for McpServerSpec {
423    fn default() -> Self {
424        Self {
425            id: String::new(),
426            transport: McpTransportKind::Stdio,
427            command: None,
428            args: Vec::new(),
429            url: None,
430            config: serde_json::Map::new(),
431            timeout_secs: default_mcp_timeout_secs(),
432            env: BTreeMap::new(),
433            restart_policy: McpRestartPolicy::default(),
434        }
435    }
436}
437
438impl Default for ProviderSpec {
439    fn default() -> Self {
440        Self {
441            id: String::new(),
442            adapter: String::new(),
443            api_key: None,
444            base_url: None,
445            timeout_secs: default_provider_timeout_secs(),
446            adapter_options: BTreeMap::new(),
447        }
448    }
449}
450
451impl Default for AgentSpec {
452    fn default() -> Self {
453        Self {
454            id: String::new(),
455            model_id: String::new(),
456            system_prompt: String::new(),
457            max_rounds: default_max_rounds(),
458            max_continuation_retries: default_max_continuation_retries(),
459            context_policy: None,
460            reasoning_effort: None,
461            plugin_ids: Vec::new(),
462            active_hook_filter: HashSet::new(),
463            allowed_tools: None,
464            excluded_tools: None,
465            endpoint: None,
466            delegates: Vec::new(),
467            sections: HashMap::new(),
468            registry: None,
469        }
470    }
471}
472
473fn default_max_rounds() -> usize {
474    16
475}
476
477fn default_max_continuation_retries() -> usize {
478    2
479}
480
481impl AgentSpec {
482    /// Create a new agent spec with default settings.
483    ///
484    /// # Examples
485    ///
486    /// ```
487    /// use awaken_contract::registry_spec::AgentSpec;
488    ///
489    /// let spec = AgentSpec::new("assistant")
490    ///     .with_model_id("gpt-4o-mini")
491    ///     .with_system_prompt("You are helpful.")
492    ///     .with_max_rounds(10);
493    /// assert_eq!(spec.id, "assistant");
494    /// assert_eq!(spec.model_id, "gpt-4o-mini");
495    /// assert_eq!(spec.system_prompt, "You are helpful.");
496    /// assert_eq!(spec.max_rounds, 10);
497    /// ```
498    pub fn new(id: impl Into<String>) -> Self {
499        Self {
500            id: id.into(),
501            ..Default::default()
502        }
503    }
504
505    // -- Typed config access --
506
507    /// Read a typed plugin config section.
508    /// Returns `Config::default()` if the section is missing.
509    /// Returns error if the section exists but fails to deserialize.
510    pub fn config<K: PluginConfigKey>(&self) -> Result<K::Config, StateError> {
511        match self.sections.get(K::KEY) {
512            Some(value) => {
513                serde_json::from_value(value.clone()).map_err(|e| StateError::KeyDecode {
514                    key: K::KEY.into(),
515                    message: e.to_string(),
516                })
517            }
518            None => Ok(K::Config::default()),
519        }
520    }
521
522    /// Set a typed plugin config section.
523    pub fn set_config<K: PluginConfigKey>(&mut self, config: K::Config) -> Result<(), StateError> {
524        let value = serde_json::to_value(config).map_err(|e| StateError::KeyEncode {
525            key: K::KEY.into(),
526            message: e.to_string(),
527        })?;
528        self.sections.insert(K::KEY.to_string(), value);
529        Ok(())
530    }
531
532    // -- Builder methods --
533
534    #[must_use]
535    pub fn with_model_id(mut self, model_id: impl Into<String>) -> Self {
536        self.model_id = model_id.into();
537        self
538    }
539
540    #[must_use]
541    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
542        self.system_prompt = prompt.into();
543        self
544    }
545
546    #[must_use]
547    pub fn with_max_rounds(mut self, n: usize) -> Self {
548        self.max_rounds = n;
549        self
550    }
551
552    #[must_use]
553    pub fn with_reasoning_effort(mut self, effort: ReasoningEffort) -> Self {
554        self.reasoning_effort = Some(effort);
555        self
556    }
557
558    #[must_use]
559    pub fn with_hook_filter(mut self, plugin_id: impl Into<String>) -> Self {
560        self.active_hook_filter.insert(plugin_id.into());
561        self
562    }
563
564    /// Set a typed plugin config section (builder variant).
565    pub fn with_config<K: PluginConfigKey>(
566        mut self,
567        config: K::Config,
568    ) -> Result<Self, StateError> {
569        self.set_config::<K>(config)?;
570        Ok(self)
571    }
572
573    #[must_use]
574    pub fn with_delegate(mut self, agent_id: impl Into<String>) -> Self {
575        self.delegates.push(agent_id.into());
576        self
577    }
578
579    #[must_use]
580    pub fn with_endpoint(mut self, endpoint: RemoteEndpoint) -> Self {
581        self.endpoint = Some(endpoint);
582        self
583    }
584
585    /// Set a raw JSON section (for tests or untyped usage).
586    #[must_use]
587    pub fn with_section(mut self, key: impl Into<String>, value: Value) -> Self {
588        self.sections.insert(key.into(), value);
589        self
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use serde_json::json;
597
598    #[test]
599    fn agent_spec_serde_roundtrip() {
600        let spec = AgentSpec {
601            id: "coder".into(),
602            model_id: "claude-opus".into(),
603            system_prompt: "You are a coding assistant.".into(),
604            max_rounds: 8,
605            plugin_ids: vec!["permission".into(), "logging".into()],
606            allowed_tools: Some(vec!["read_file".into(), "write_file".into()]),
607            excluded_tools: Some(vec!["delete_file".into()]),
608            sections: {
609                let mut m = HashMap::new();
610                m.insert("permission".into(), json!({"mode": "strict"}));
611                m
612            },
613            ..Default::default()
614        };
615
616        let json_str = serde_json::to_string(&spec).unwrap();
617        let parsed: AgentSpec = serde_json::from_str(&json_str).unwrap();
618
619        assert_eq!(parsed.id, "coder");
620        assert_eq!(parsed.model_id, "claude-opus");
621        assert_eq!(parsed.system_prompt, "You are a coding assistant.");
622        assert_eq!(parsed.max_rounds, 8);
623        assert_eq!(parsed.plugin_ids, vec!["permission", "logging"]);
624        assert_eq!(
625            parsed.allowed_tools,
626            Some(vec!["read_file".into(), "write_file".into()])
627        );
628        assert_eq!(parsed.excluded_tools, Some(vec!["delete_file".into()]));
629        assert_eq!(parsed.sections["permission"]["mode"], "strict");
630    }
631
632    #[test]
633    fn agent_spec_defaults() {
634        let json_str = r#"{"id":"min","model_id":"m","system_prompt":"sp"}"#;
635        let spec: AgentSpec = serde_json::from_str(json_str).unwrap();
636
637        assert_eq!(spec.model_id, "m");
638        assert_eq!(spec.max_rounds, 16);
639        assert_eq!(spec.max_continuation_retries, 2);
640        assert!(spec.context_policy.is_none());
641        assert!(spec.plugin_ids.is_empty());
642        assert!(spec.active_hook_filter.is_empty());
643        assert!(spec.allowed_tools.is_none());
644        assert!(spec.excluded_tools.is_none());
645        assert!(spec.sections.is_empty());
646    }
647
648    #[test]
649    fn model_binding_spec_uses_canonical_names() {
650        let canonical = ModelBindingSpec {
651            id: "default".into(),
652            provider_id: "openai".into(),
653            upstream_model: "gpt-4o-mini".into(),
654        };
655
656        let encoded = serde_json::to_value(&canonical).unwrap();
657        assert_eq!(encoded["provider_id"], "openai");
658        assert_eq!(encoded["upstream_model"], "gpt-4o-mini");
659        assert!(encoded.get("provider").is_none());
660        assert!(encoded.get("model").is_none());
661    }
662
663    #[test]
664    fn provider_model_legacy_fields_are_rejected() {
665        let agent =
666            serde_json::from_str::<AgentSpec>(r#"{"id":"min","model":"m","system_prompt":"sp"}"#);
667        assert!(agent.is_err());
668
669        let model = serde_json::from_value::<ModelBindingSpec>(json!({
670            "id": "default",
671            "provider": "openai",
672            "model": "gpt-4o-mini"
673        }));
674        assert!(model.is_err());
675    }
676
677    // -- Typed config tests (merged from AgentProfile) --
678
679    struct ModelNameKey;
680    impl PluginConfigKey for ModelNameKey {
681        const KEY: &'static str = "model_name";
682        type Config = ModelNameConfig;
683    }
684
685    #[derive(
686        Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
687    )]
688    struct ModelNameConfig {
689        pub name: String,
690    }
691
692    struct PermKey;
693    impl PluginConfigKey for PermKey {
694        const KEY: &'static str = "permission";
695        type Config = PermConfig;
696    }
697
698    #[derive(
699        Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
700    )]
701    struct PermConfig {
702        pub mode: String,
703    }
704
705    #[test]
706    fn typed_config_roundtrip() {
707        let spec = AgentSpec::new("test")
708            .with_config::<ModelNameKey>(ModelNameConfig {
709                name: "opus".into(),
710            })
711            .unwrap()
712            .with_config::<PermKey>(PermConfig {
713                mode: "strict".into(),
714            })
715            .unwrap();
716
717        let model: ModelNameConfig = spec.config::<ModelNameKey>().unwrap();
718        assert_eq!(model.name, "opus");
719
720        let perm: PermConfig = spec.config::<PermKey>().unwrap();
721        assert_eq!(perm.mode, "strict");
722    }
723
724    #[test]
725    fn missing_config_returns_default() {
726        let spec = AgentSpec::new("test");
727        let model: ModelNameConfig = spec.config::<ModelNameKey>().unwrap();
728        assert_eq!(model, ModelNameConfig::default());
729    }
730
731    #[test]
732    fn config_serializes_to_json() {
733        let spec = AgentSpec::new("coder")
734            .with_model_id("sonnet")
735            .with_config::<ModelNameKey>(ModelNameConfig {
736                name: "custom".into(),
737            })
738            .unwrap();
739
740        let json = serde_json::to_string(&spec).unwrap();
741        let parsed: AgentSpec = serde_json::from_str(&json).unwrap();
742
743        assert_eq!(parsed.id, "coder");
744        assert_eq!(parsed.model_id, "sonnet");
745
746        let model: ModelNameConfig = parsed.config::<ModelNameKey>().unwrap();
747        assert_eq!(model.name, "custom");
748    }
749
750    #[test]
751    fn multiple_configs_independent() {
752        let mut spec = AgentSpec::new("test");
753        spec.set_config::<ModelNameKey>(ModelNameConfig { name: "a".into() })
754            .unwrap();
755        spec.set_config::<PermKey>(PermConfig { mode: "b".into() })
756            .unwrap();
757
758        // Update one doesn't affect the other
759        spec.set_config::<ModelNameKey>(ModelNameConfig {
760            name: "updated".into(),
761        })
762        .unwrap();
763
764        let model: ModelNameConfig = spec.config::<ModelNameKey>().unwrap();
765        assert_eq!(model.name, "updated");
766
767        let perm: PermConfig = spec.config::<PermKey>().unwrap();
768        assert_eq!(perm.mode, "b");
769    }
770
771    #[test]
772    fn with_section_raw_json_still_works() {
773        let spec =
774            AgentSpec::new("test").with_section("custom", serde_json::json!({"key": "value"}));
775        assert_eq!(spec.sections["custom"]["key"], "value");
776    }
777
778    #[test]
779    fn remote_endpoint_canonical_roundtrip_uses_single_shape() {
780        let mut options = BTreeMap::new();
781        options.insert("poll_interval_ms".into(), json!(1000));
782        let endpoint = RemoteEndpoint {
783            backend: "a2a".into(),
784            base_url: "https://remote.example.com/v1/a2a".into(),
785            auth: Some(RemoteAuth::bearer("tok_123")),
786            target: Some("worker".into()),
787            timeout_ms: 60_000,
788            options,
789        };
790
791        let encoded = serde_json::to_value(&endpoint).unwrap();
792        assert_eq!(encoded["backend"], "a2a");
793        assert_eq!(encoded["auth"]["type"], "bearer");
794        assert_eq!(encoded["auth"]["token"], "tok_123");
795        assert_eq!(encoded["target"], "worker");
796        assert_eq!(encoded["options"]["poll_interval_ms"], 1000);
797        assert!(encoded.get("bearer_token").is_none());
798        assert!(encoded.get("agent_id").is_none());
799        assert!(encoded.get("poll_interval_ms").is_none());
800
801        let parsed: RemoteEndpoint = serde_json::from_value(encoded).unwrap();
802        assert_eq!(parsed, endpoint);
803    }
804
805    #[test]
806    fn remote_endpoint_legacy_a2a_input_normalizes_to_canonical_shape() {
807        let endpoint: RemoteEndpoint = serde_json::from_value(json!({
808            "base_url": "https://remote.example.com/v1/a2a",
809            "bearer_token": "tok_legacy",
810            "agent_id": "worker",
811            "poll_interval_ms": 750,
812            "timeout_ms": 60_000
813        }))
814        .unwrap();
815
816        assert_eq!(endpoint.backend, "a2a");
817        assert_eq!(
818            endpoint
819                .auth
820                .as_ref()
821                .and_then(|auth| auth.param_str("token")),
822            Some("tok_legacy")
823        );
824        assert_eq!(endpoint.target.as_deref(), Some("worker"));
825        assert_eq!(endpoint.options.get("poll_interval_ms"), Some(&json!(750)));
826        assert_eq!(endpoint.timeout_ms, 60_000);
827    }
828
829    #[test]
830    fn remote_endpoint_rejects_mixed_legacy_and_canonical_fields() {
831        let err = serde_json::from_value::<RemoteEndpoint>(json!({
832            "backend": "a2a",
833            "base_url": "https://remote.example.com/v1/a2a",
834            "auth": { "type": "bearer", "token": "tok_new" },
835            "bearer_token": "tok_old"
836        }))
837        .unwrap_err();
838
839        assert!(
840            err.to_string()
841                .contains("cannot mix legacy A2A endpoint fields")
842        );
843    }
844
845    #[test]
846    fn builder() {
847        let spec = AgentSpec::new("reviewer")
848            .with_model_id("claude-opus")
849            .with_hook_filter("permission")
850            .with_config::<PermKey>(PermConfig {
851                mode: "strict".into(),
852            })
853            .unwrap();
854
855        assert_eq!(spec.id, "reviewer");
856        assert_eq!(spec.model_id, "claude-opus");
857        assert!(spec.active_hook_filter.contains("permission"));
858    }
859
860    // ── ProviderSpec ───────────────────────────────────────────────────
861
862    #[test]
863    fn provider_spec_debug_does_not_leak_api_key() {
864        let spec = ProviderSpec {
865            id: "openai".into(),
866            adapter: "openai".into(),
867            api_key: Some("sk-super-secret-12345".into()),
868            ..ProviderSpec::default()
869        };
870        let debug = format!("{spec:?}");
871        assert!(
872            !debug.contains("sk-super-secret-12345"),
873            "ProviderSpec Debug must not contain the api_key value, got: {debug}"
874        );
875    }
876
877    #[test]
878    fn provider_spec_empty_string_api_key_deserializes_as_none() {
879        let json_str = r#"{"id":"x","adapter":"openai","api_key":""}"#;
880        let spec: ProviderSpec = serde_json::from_str(json_str).unwrap();
881        assert!(
882            spec.api_key.is_none(),
883            "empty-string api_key should deserialize as None"
884        );
885    }
886
887    #[test]
888    fn provider_spec_empty_string_base_url_deserializes_as_none() {
889        let json_str = r#"{"id":"x","adapter":"openai","base_url":""}"#;
890        let spec: ProviderSpec = serde_json::from_str(json_str).unwrap();
891        assert!(
892            spec.base_url.is_none(),
893            "empty-string base_url should deserialize as None"
894        );
895    }
896
897    #[test]
898    fn provider_spec_adapter_options_round_trip() {
899        let mut opts = BTreeMap::new();
900        opts.insert("headers".into(), json!({"OpenAI-Organization": "org-xyz"}));
901        let spec = ProviderSpec {
902            id: "openai".into(),
903            adapter: "openai".into(),
904            adapter_options: opts,
905            ..ProviderSpec::default()
906        };
907        let encoded = serde_json::to_string(&spec).unwrap();
908        let parsed: ProviderSpec = serde_json::from_str(&encoded).unwrap();
909        assert_eq!(
910            parsed
911                .adapter_options
912                .get("headers")
913                .and_then(|value| value.get("OpenAI-Organization"))
914                .and_then(Value::as_str),
915            Some("org-xyz")
916        );
917    }
918
919    #[test]
920    fn provider_spec_adapter_options_skipped_when_empty() {
921        let spec = ProviderSpec {
922            id: "openai".into(),
923            adapter: "openai".into(),
924            ..ProviderSpec::default()
925        };
926        let encoded = serde_json::to_string(&spec).unwrap();
927        assert!(
928            !encoded.contains("adapter_options"),
929            "expected adapter_options to be elided when empty, got: {encoded}"
930        );
931    }
932}