1use serde::{Deserialize, Serialize};
8
9use meerkat_core::{
10 HookRunOverrides, OutputSchema, PeerMeta, Provider,
11 skills::{SkillId, SkillKey, SkillRef, SourceIdentityRegistry},
12};
13
14#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub shell_env: Option<std::collections::HashMap<String, String>>,
36}
37
38#[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#[derive(Debug, Clone, Serialize, Deserialize)]
49#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
50pub struct CommsParams {
51 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub peer_meta: Option<PeerMeta>,
59}
60
61#[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#[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 #[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 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 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 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 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 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(¶ms)?;
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(¶ms)?;
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(®istry)
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(¶ms)?;
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(¶ms)?;
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}