Skip to main content

meerkat_contracts/wire/
params.rs

1//! Composable request fragments.
2//!
3//! Protocol crates inline the fields they support and provide accessor
4//! methods returning the fragment type. No `#[serde(flatten)]` —
5//! explicit delegation to avoid known serde/schemars issues.
6
7use serde::{Deserialize, Serialize};
8
9use meerkat_core::{
10    HookRunOverrides, OutputSchema, PeerMeta, Provider,
11    skills::{SkillId, SkillKey, SkillRef, SourceIdentityRegistry},
12};
13
14/// Core session creation parameters.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
17pub struct CoreCreateParams {
18    pub prompt: String,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub model: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub provider: Option<Provider>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub max_tokens: Option<u32>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub system_prompt: Option<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub labels: Option<std::collections::BTreeMap<String, String>>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub additional_instructions: Option<Vec<String>>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub app_context: Option<serde_json::Value>,
33    /// Per-agent environment variables injected into shell tool subprocesses.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub shell_env: Option<std::collections::HashMap<String, String>>,
36}
37
38/// Structured output parameters.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct StructuredOutputParams {
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub output_schema: Option<OutputSchema>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub structured_output_retries: Option<u32>,
45}
46
47/// Comms parameters.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
50pub struct CommsParams {
51    /// None = inherit persisted session intent, Some(true) = enable, Some(false) = disable.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub keep_alive: Option<bool>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub comms_name: Option<String>,
56    /// Friendly metadata for peer discovery.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub peer_meta: Option<PeerMeta>,
59}
60
61/// Hook parameters.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64pub struct HookParams {
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub hooks_override: Option<HookRunOverrides>,
67}
68
69/// Skills parameters for session/turn requests.
70///
71/// `preload_skills`: Pre-load these skills at session creation.
72/// `None` or empty = inventory-only mode (no pre-loading).
73/// `Some([])` is normalized to `None` to prevent silent misconfiguration.
74///
75/// `skill_references`: Skill IDs to resolve and inject for this turn.
76/// `None` or empty = no per-turn skill injection.
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
79pub struct SkillsParams {
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub preload_skills: Option<Vec<String>>,
82    /// Structured refs for Skills V2.1. Supports legacy strings via `SkillRef::Legacy`.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub skill_refs: Option<Vec<SkillRef>>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub skill_references: Option<Vec<String>>,
87}
88
89impl SkillsParams {
90    /// Normalize: `Some([])` → `None` for both fields.
91    pub fn normalize(&mut self) {
92        if let Some(ref v) = self.preload_skills
93            && v.is_empty()
94        {
95            self.preload_skills = None;
96        }
97        if let Some(ref v) = self.skill_refs
98            && v.is_empty()
99        {
100            self.skill_refs = None;
101        }
102        if let Some(ref v) = self.skill_references
103            && v.is_empty()
104        {
105            self.skill_references = None;
106        }
107    }
108
109    /// Canonicalize boundary refs by merging structured `skill_refs` and
110    /// compatibility `skill_references`.
111    pub fn canonical_skill_refs(&self) -> Option<Vec<SkillRef>> {
112        let mut refs = Vec::new();
113
114        if let Some(structured) = &self.skill_refs {
115            refs.extend(structured.iter().cloned());
116        }
117        if let Some(legacy) = &self.skill_references {
118            refs.extend(legacy.iter().cloned().map(SkillRef::Legacy));
119        }
120
121        if refs.is_empty() { None } else { Some(refs) }
122    }
123
124    /// Canonicalize to typed `SkillId`s.
125    pub fn canonical_skill_ids(&self) -> Option<Vec<SkillId>> {
126        self.canonical_skill_refs().map(|refs| {
127            refs.into_iter()
128                .map(|r| match r {
129                    SkillRef::Legacy(id) => SkillId(id),
130                    SkillRef::Structured(key) => SourceIdentityRegistry::canonical_skill_id(&key),
131                })
132                .collect()
133        })
134    }
135
136    /// Canonicalize through the source-identity resolver boundary, producing
137    /// typed canonical `SkillKey` values.
138    pub fn canonical_skill_keys_with_registry(
139        &self,
140        registry: &SourceIdentityRegistry,
141    ) -> Result<Option<Vec<SkillKey>>, meerkat_core::skills::SkillError> {
142        let Some(refs) = self.canonical_skill_refs() else {
143            return Ok(None);
144        };
145
146        let mut keys = Vec::with_capacity(refs.len());
147        for reference in refs {
148            keys.push(registry.resolve_skill_ref(&reference)?);
149        }
150
151        Ok(Some(keys))
152    }
153
154    /// Canonicalize through the source-identity resolver boundary and down-convert
155    /// to canonical `SkillId` strings for legacy callers.
156    pub fn canonical_skill_ids_with_registry(
157        &self,
158        registry: &SourceIdentityRegistry,
159    ) -> Result<Option<Vec<SkillId>>, meerkat_core::skills::SkillError> {
160        Ok(self
161            .canonical_skill_keys_with_registry(registry)?
162            .map(|keys| {
163                keys.into_iter()
164                    .map(|key| SourceIdentityRegistry::canonical_skill_id(&key))
165                    .collect()
166            }))
167    }
168}
169
170#[cfg(test)]
171#[allow(clippy::expect_used, clippy::redundant_clone)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_skills_params_none_serde() -> Result<(), serde_json::Error> {
177        let params = SkillsParams {
178            preload_skills: None,
179            skill_refs: None,
180            skill_references: None,
181        };
182        let json = serde_json::to_string(&params)?;
183        assert_eq!(json, "{}");
184
185        let parsed: SkillsParams = serde_json::from_str("{}")?;
186        assert!(parsed.preload_skills.is_none());
187        assert!(parsed.skill_refs.is_none());
188        assert!(parsed.skill_references.is_none());
189        Ok(())
190    }
191
192    #[test]
193    fn test_skills_params_empty_normalizes() {
194        let mut params = SkillsParams {
195            preload_skills: Some(vec![]),
196            skill_refs: Some(vec![]),
197            skill_references: Some(vec![]),
198        };
199        params.normalize();
200        assert!(params.preload_skills.is_none());
201        assert!(params.skill_refs.is_none());
202        assert!(params.skill_references.is_none());
203    }
204
205    #[test]
206    fn test_skills_params_with_ids() -> Result<(), serde_json::Error> {
207        let params = SkillsParams {
208            preload_skills: Some(vec!["a/b".into()]),
209            skill_refs: Some(vec![SkillRef::Legacy("a/b".to_string())]),
210            skill_references: Some(vec!["c/d".into()]),
211        };
212        let json = serde_json::to_string(&params)?;
213        let parsed: SkillsParams = serde_json::from_str(&json)?;
214        assert_eq!(parsed.preload_skills, Some(vec!["a/b".to_string()]));
215        assert_eq!(
216            parsed.skill_refs,
217            Some(vec![SkillRef::Legacy("a/b".to_string())])
218        );
219        assert_eq!(parsed.skill_references, Some(vec!["c/d".to_string()]));
220        Ok(())
221    }
222
223    #[test]
224    fn test_skill_refs_structured_and_legacy_equivalence() -> Result<(), serde_json::Error> {
225        let structured_json = r#"{
226            "skill_refs":[{"source_uuid":"dc256086-0d2f-4f61-a307-320d4148107f","skill_name":"email-extractor"}]
227        }"#;
228        let legacy_json =
229            r#"{"skill_references":["dc256086-0d2f-4f61-a307-320d4148107f/email-extractor"]}"#;
230
231        let structured: SkillsParams = serde_json::from_str(structured_json)?;
232        let legacy: SkillsParams = serde_json::from_str(legacy_json)?;
233
234        assert_eq!(
235            structured.canonical_skill_ids(),
236            Some(vec![SkillId(
237                "dc256086-0d2f-4f61-a307-320d4148107f/email-extractor".to_string()
238            )])
239        );
240        assert_eq!(
241            structured.canonical_skill_ids(),
242            legacy.canonical_skill_ids()
243        );
244        Ok(())
245    }
246
247    #[test]
248    fn test_skill_refs_canonical_mixed_order_is_deterministic() -> Result<(), serde_json::Error> {
249        let mixed_json = r#"{
250            "skill_refs":[{"source_uuid":"dc256086-0d2f-4f61-a307-320d4148107f","skill_name":"email-extractor"}],
251            "skill_references":["legacy/skill"]
252        }"#;
253        let parsed: SkillsParams = serde_json::from_str(mixed_json)?;
254        let canonical = parsed.canonical_skill_refs().expect("canonical refs");
255
256        assert_eq!(canonical.len(), 2);
257        assert!(matches!(canonical[0], SkillRef::Structured(_)));
258        assert_eq!(canonical[1], SkillRef::Legacy("legacy/skill".to_string()));
259        Ok(())
260    }
261
262    #[test]
263    fn test_skill_refs_canonicalized_via_registry_remap() {
264        use meerkat_core::skills::{
265            SkillAlias, SkillKey, SkillKeyRemap, SkillName, SourceIdentityLineage,
266            SourceIdentityLineageEvent, SourceIdentityRecord, SourceIdentityStatus,
267            SourceTransportKind, SourceUuid,
268        };
269
270        let source_old = SourceUuid::parse("dc256086-0d2f-4f61-a307-320d4148107f").expect("uuid");
271        let source_new = SourceUuid::parse("a93d587d-8f44-438f-8189-6e8cf549f6e7").expect("uuid");
272        let old_name = SkillName::parse("email-extractor").expect("slug");
273        let new_name = SkillName::parse("mail-extractor").expect("slug");
274
275        let registry = SourceIdentityRegistry::build(
276            vec![
277                SourceIdentityRecord {
278                    source_uuid: source_old.clone(),
279                    display_name: "old".to_string(),
280                    transport_kind: SourceTransportKind::Filesystem,
281                    fingerprint: "fp-a".to_string(),
282                    status: SourceIdentityStatus::Active,
283                },
284                SourceIdentityRecord {
285                    source_uuid: source_new.clone(),
286                    display_name: "new".to_string(),
287                    transport_kind: SourceTransportKind::Filesystem,
288                    fingerprint: "fp-a".to_string(),
289                    status: SourceIdentityStatus::Active,
290                },
291            ],
292            vec![SourceIdentityLineage {
293                event_id: "rotate-1".to_string(),
294                recorded_at_unix_secs: 1,
295                required_from_skills: vec![old_name.clone()],
296                event: SourceIdentityLineageEvent::Rotate {
297                    from: source_old.clone(),
298                    to: source_new.clone(),
299                },
300            }],
301            vec![SkillKeyRemap {
302                from: SkillKey {
303                    source_uuid: source_old.clone(),
304                    skill_name: old_name.clone(),
305                },
306                to: SkillKey {
307                    source_uuid: source_new.clone(),
308                    skill_name: new_name.clone(),
309                },
310                reason: None,
311            }],
312            vec![SkillAlias {
313                alias: "legacy/email".to_string(),
314                to: SkillKey {
315                    source_uuid: source_old.clone(),
316                    skill_name: old_name,
317                },
318            }],
319        )
320        .expect("registry");
321
322        let params = SkillsParams {
323            preload_skills: None,
324            skill_refs: Some(vec![SkillRef::Structured(SkillKey {
325                source_uuid: source_old,
326                skill_name: SkillName::parse("email-extractor").expect("slug"),
327            })]),
328            skill_references: Some(vec!["legacy/email".to_string()]),
329        };
330
331        let canonical = params
332            .canonical_skill_ids_with_registry(&registry)
333            .expect("canonicalization should succeed")
334            .expect("ids");
335        assert_eq!(
336            canonical,
337            vec![
338                SkillId("a93d587d-8f44-438f-8189-6e8cf549f6e7/mail-extractor".to_string()),
339                SkillId("a93d587d-8f44-438f-8189-6e8cf549f6e7/mail-extractor".to_string())
340            ]
341        );
342    }
343
344    #[test]
345    fn test_core_create_params_all_fields_roundtrip() -> Result<(), serde_json::Error> {
346        let mut labels = std::collections::BTreeMap::new();
347        labels.insert("env".to_string(), "prod".to_string());
348        labels.insert("team".to_string(), "infra".to_string());
349
350        let params = CoreCreateParams {
351            prompt: "hello".to_string(),
352            model: Some("claude-opus-4-6".to_string()),
353            provider: Some(Provider::Anthropic),
354            max_tokens: Some(1024),
355            system_prompt: Some("You are helpful.".to_string()),
356            labels: Some(labels.clone()),
357            additional_instructions: Some(vec![
358                "Be concise.".to_string(),
359                "Use JSON output.".to_string(),
360            ]),
361            app_context: Some(serde_json::json!({"org_id": "acme", "tier": "premium"})),
362            shell_env: None,
363        };
364        let json = serde_json::to_string(&params)?;
365        let parsed: CoreCreateParams = serde_json::from_str(&json)?;
366        assert_eq!(parsed.prompt, "hello");
367        assert_eq!(parsed.labels, Some(labels));
368        assert_eq!(
369            parsed.additional_instructions,
370            Some(vec![
371                "Be concise.".to_string(),
372                "Use JSON output.".to_string()
373            ])
374        );
375        assert!(parsed.app_context.is_some());
376        Ok(())
377    }
378
379    #[test]
380    fn test_core_create_params_defaults_backward_compat() -> Result<(), serde_json::Error> {
381        let json = r#"{"prompt": "hello"}"#;
382        let parsed: CoreCreateParams = serde_json::from_str(json)?;
383        assert_eq!(parsed.prompt, "hello");
384        assert!(parsed.model.is_none());
385        assert!(parsed.labels.is_none());
386        assert!(parsed.additional_instructions.is_none());
387        assert!(parsed.app_context.is_none());
388        Ok(())
389    }
390
391    #[test]
392    fn test_core_create_params_none_fields_omitted() -> Result<(), serde_json::Error> {
393        let params = CoreCreateParams {
394            prompt: "hello".to_string(),
395            model: None,
396            provider: None,
397            max_tokens: None,
398            system_prompt: None,
399            labels: None,
400            additional_instructions: None,
401            app_context: None,
402            shell_env: None,
403        };
404        let json = serde_json::to_string(&params)?;
405        assert!(!json.contains("\"labels\""));
406        assert!(!json.contains("\"additional_instructions\""));
407        assert!(!json.contains("\"app_context\""));
408        assert!(!json.contains("\"shell_env\""));
409        Ok(())
410    }
411}