Skip to main content

potato_type/spec/
interface.rs

1use crate::error::TypeError;
2use potato_util::PyHelperFuncs;
3use pyo3::prelude::*;
4use pythonize::{depythonize, pythonize};
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11const SPEC_FILE_EXTENSIONS: [&str; 3] = ["yaml", "yml", "json"];
12const PROMPT_RUNTIME_FIELDS: [&str; 9] = [
13    "max_iterations",
14    "retry_policy",
15    "timeout",
16    "memory",
17    "session_store",
18    "callbacks",
19    "concurrency",
20    "workflow",
21    "run_config",
22];
23const AGENT_RUNTIME_FIELDS: [&str; 12] = [
24    "max_iterations",
25    "retry_policy",
26    "timeout",
27    "memory",
28    "session_store",
29    "callbacks",
30    "concurrency",
31    "workflow",
32    "run_config",
33    "max_retries",
34    "merge_strategy",
35    "pass_output",
36];
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(deny_unknown_fields)]
40pub struct InstructionBlock {
41    pub role: String,
42    pub content: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46#[serde(deny_unknown_fields)]
47pub struct VariableSpec {
48    pub name: String,
49    #[serde(default = "default_true")]
50    pub required: bool,
51    #[serde(default)]
52    pub default: Option<Value>,
53    #[serde(default)]
54    pub schema: Option<Value>,
55}
56
57fn default_true() -> bool {
58    true
59}
60
61#[pyclass(from_py_object)]
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63#[serde(deny_unknown_fields)]
64pub struct PromptSpec {
65    #[pyo3(get)]
66    pub id: String,
67    #[pyo3(get)]
68    pub version: String,
69    #[pyo3(get)]
70    pub title: Option<String>,
71    #[pyo3(get)]
72    pub description: Option<String>,
73    #[serde(default)]
74    pub instructions: Vec<InstructionBlock>,
75    #[serde(default)]
76    pub variables: Vec<VariableSpec>,
77    #[serde(default)]
78    pub input_schema: Option<Value>,
79    #[serde(default)]
80    pub output_schema: Option<Value>,
81    #[serde(default)]
82    pub tags: Vec<String>,
83    #[serde(default)]
84    pub metadata: BTreeMap<String, Value>,
85    #[serde(default)]
86    pub extensions: BTreeMap<String, Value>,
87}
88
89#[pyclass(from_py_object)]
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91#[serde(deny_unknown_fields)]
92pub struct AgentSpec {
93    #[pyo3(get)]
94    pub id: String,
95    #[pyo3(get)]
96    pub version: String,
97    #[pyo3(get)]
98    pub name: Option<String>,
99    #[pyo3(get)]
100    pub description: Option<String>,
101    #[serde(default)]
102    pub primary_prompt: Option<String>,
103    #[serde(default)]
104    pub prompt_refs: Vec<String>,
105    #[serde(default)]
106    pub input_schema: Option<Value>,
107    #[serde(default)]
108    pub output_schema: Option<Value>,
109    #[serde(default)]
110    pub tool_refs: Vec<String>,
111    #[serde(default)]
112    pub agent_refs: Vec<String>,
113    #[serde(default)]
114    pub capabilities: Vec<String>,
115    #[serde(default)]
116    pub provider_hints: Vec<String>,
117    #[serde(default)]
118    pub framework_hints: Vec<String>,
119    #[serde(default)]
120    pub tags: Vec<String>,
121    #[serde(default)]
122    pub metadata: BTreeMap<String, Value>,
123    #[serde(default)]
124    pub extensions: BTreeMap<String, Value>,
125}
126
127#[pyclass(from_py_object)]
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
129#[serde(deny_unknown_fields)]
130pub struct PortableSpec {
131    #[pyo3(get)]
132    pub version: String,
133    #[serde(default)]
134    pub prompts: Vec<PromptSpec>,
135    #[serde(default)]
136    pub agents: Vec<AgentSpec>,
137    #[serde(default)]
138    pub metadata: BTreeMap<String, Value>,
139    #[serde(default)]
140    pub extensions: BTreeMap<String, Value>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
144struct FrameworkExport {
145    framework: String,
146    agent_id: String,
147    config: Value,
148    losses: Vec<String>,
149}
150
151fn parse_optional_vec<T>(value: Option<&Bound<'_, PyAny>>) -> Result<Vec<T>, TypeError>
152where
153    T: for<'de> Deserialize<'de>,
154{
155    match value {
156        Some(v) => depythonize(v).map_err(Into::into),
157        None => Ok(Vec::new()),
158    }
159}
160
161fn parse_optional_json(
162    value: Option<&Bound<'_, PyAny>>,
163    field_name: &str,
164) -> Result<Option<Value>, TypeError> {
165    match value {
166        Some(v) => Ok(Some(depythonize(v).map_err(|e| {
167            TypeError::Error(format!("Failed to parse '{field_name}': {e}"))
168        })?)),
169        None => Ok(None),
170    }
171}
172
173fn parse_optional_map(
174    value: Option<&Bound<'_, PyAny>>,
175    field_name: &str,
176) -> Result<BTreeMap<String, Value>, TypeError> {
177    let json = parse_optional_json(value, field_name)?;
178    match json {
179        Some(Value::Object(obj)) => Ok(obj.into_iter().collect()),
180        Some(_) => Err(TypeError::Error(format!(
181            "Field '{field_name}' must be a dictionary/object"
182        ))),
183        None => Ok(BTreeMap::new()),
184    }
185}
186
187fn contains_runtime_fields(value: &Value, banned_fields: &[&str]) -> Option<String> {
188    let obj = value.as_object()?;
189    banned_fields
190        .iter()
191        .find(|field| obj.contains_key(**field))
192        .map(|field| (*field).to_string())
193}
194
195fn validate_runtime_fields_in_prompt(value: &Value) -> Result<(), TypeError> {
196    if let Some(field) = contains_runtime_fields(value, &PROMPT_RUNTIME_FIELDS) {
197        return Err(TypeError::Error(format!(
198            "Prompt spec contains runtime-only field '{field}'. Move runtime behavior to framework/runtime configuration."
199        )));
200    }
201    Ok(())
202}
203
204fn validate_runtime_fields_in_agent(value: &Value) -> Result<(), TypeError> {
205    if let Some(field) = contains_runtime_fields(value, &AGENT_RUNTIME_FIELDS) {
206        return Err(TypeError::Error(format!(
207            "Agent spec contains runtime-only field '{field}'. Move runtime behavior to framework/runtime configuration."
208        )));
209    }
210    Ok(())
211}
212
213fn validate_runtime_fields_in_value(value: &Value) -> Result<(), TypeError> {
214    let Some(obj) = value.as_object() else {
215        return Ok(());
216    };
217
218    if let Some(prompts) = obj.get("prompts") {
219        if let Some(list) = prompts.as_array() {
220            for prompt in list {
221                validate_runtime_fields_in_prompt(prompt)?;
222            }
223        }
224    }
225
226    if let Some(agents) = obj.get("agents") {
227        if let Some(list) = agents.as_array() {
228            for agent in list {
229                validate_runtime_fields_in_agent(agent)?;
230            }
231        }
232    }
233
234    validate_runtime_fields_in_prompt(value)?;
235    validate_runtime_fields_in_agent(value)?;
236    Ok(())
237}
238
239fn merge_extension_fields(
240    config: &mut Map<String, Value>,
241    extensions: &BTreeMap<String, Value>,
242    extension_key: &str,
243) {
244    if let Some(Value::Object(extension_fields)) = extensions.get(extension_key) {
245        for (key, value) in extension_fields {
246            if !config.contains_key(key) {
247                config.insert(key.clone(), value.clone());
248            }
249        }
250    }
251}
252
253fn read_value_from_path(path: &Path) -> Result<Value, TypeError> {
254    let content = fs::read_to_string(path)?;
255    let extension = path
256        .extension()
257        .and_then(|ext| ext.to_str())
258        .ok_or_else(|| TypeError::Error(format!("Invalid spec path: {}", path.display())))?;
259
260    let value: Value = match extension.to_lowercase().as_str() {
261        "json" => serde_json::from_str(&content)?,
262        "yaml" | "yml" => serde_yaml::from_str(&content)?,
263        _ => {
264            return Err(TypeError::Error(format!(
265                "Unsupported file extension '{extension}'. Expected one of: {}",
266                SPEC_FILE_EXTENSIONS.join(", ")
267            )))
268        }
269    };
270
271    validate_runtime_fields_in_value(&value)?;
272    Ok(value)
273}
274
275fn choose_prompt_from_portable(
276    portable: PortableSpec,
277    prompt_id: Option<&str>,
278) -> Result<PromptSpec, TypeError> {
279    if let Some(id) = prompt_id {
280        portable
281            .prompts
282            .into_iter()
283            .find(|prompt| prompt.id == id)
284            .ok_or_else(|| TypeError::Error(format!("Prompt '{id}' not found in spec file")))
285    } else if portable.prompts.len() == 1 {
286        portable.prompts.into_iter().next().ok_or_else(|| {
287            TypeError::Error("Expected one prompt in spec file but found none".to_string())
288        })
289    } else {
290        Err(TypeError::Error(format!(
291            "Spec file contains {} prompts. Provide prompt_id to disambiguate.",
292            portable.prompts.len()
293        )))
294    }
295}
296
297fn choose_agent_from_portable(
298    portable: PortableSpec,
299    agent_id: Option<&str>,
300) -> Result<AgentSpec, TypeError> {
301    if let Some(id) = agent_id {
302        portable
303            .agents
304            .into_iter()
305            .find(|agent| agent.id == id)
306            .ok_or_else(|| TypeError::Error(format!("Agent '{id}' not found in spec file")))
307    } else if portable.agents.len() == 1 {
308        portable.agents.into_iter().next().ok_or_else(|| {
309            TypeError::Error("Expected one agent in spec file but found none".to_string())
310        })
311    } else {
312        Err(TypeError::Error(format!(
313            "Spec file contains {} agents. Provide agent_id to disambiguate.",
314            portable.agents.len()
315        )))
316    }
317}
318
319fn parse_prompt_value(value: Value, prompt_id: Option<&str>) -> Result<PromptSpec, TypeError> {
320    if value
321        .as_object()
322        .is_some_and(|obj| obj.contains_key("prompts") || obj.contains_key("agents"))
323    {
324        let portable: PortableSpec = serde_json::from_value(value)?;
325        return choose_prompt_from_portable(portable, prompt_id);
326    }
327
328    Ok(serde_json::from_value(value)?)
329}
330
331fn parse_agent_value(value: Value, agent_id: Option<&str>) -> Result<AgentSpec, TypeError> {
332    if value
333        .as_object()
334        .is_some_and(|obj| obj.contains_key("prompts") || obj.contains_key("agents"))
335    {
336        let portable: PortableSpec = serde_json::from_value(value)?;
337        return choose_agent_from_portable(portable, agent_id);
338    }
339
340    Ok(serde_json::from_value(value)?)
341}
342
343fn instruction_text(instruction: &InstructionBlock) -> String {
344    instruction.content.trim().to_string()
345}
346
347fn merge_prompt_and_agent_instructions(agent: &AgentSpec, prompt: Option<&PromptSpec>) -> String {
348    let mut blocks = Vec::new();
349
350    if let Some(prompt) = prompt {
351        blocks.extend(
352            prompt
353                .instructions
354                .iter()
355                .map(instruction_text)
356                .filter(|text| !text.is_empty()),
357        );
358    }
359
360    if let Some(description) = &agent.description {
361        let trimmed = description.trim();
362        if !trimmed.is_empty() {
363            blocks.push(trimmed.to_string());
364        }
365    }
366
367    blocks.join("\n\n")
368}
369
370impl PortableSpec {
371    fn find_prompt(&self, prompt_id: &str) -> Option<&PromptSpec> {
372        self.prompts.iter().find(|prompt| prompt.id == prompt_id)
373    }
374
375    fn find_agent(&self, agent_id: &str) -> Result<&AgentSpec, TypeError> {
376        self.agents
377            .iter()
378            .find(|agent| agent.id == agent_id)
379            .ok_or_else(|| TypeError::Error(format!("Agent '{agent_id}' not found")))
380    }
381
382    fn selected_prompt_for_agent<'a>(
383        &'a self,
384        agent: &'a AgentSpec,
385    ) -> Result<(Option<&'a PromptSpec>, Vec<String>), TypeError> {
386        let mut losses = Vec::new();
387        let prompt_ref = agent
388            .primary_prompt
389            .as_ref()
390            .or_else(|| agent.prompt_refs.first());
391
392        if let Some(prompt_ref) = prompt_ref {
393            let prompt = self.find_prompt(prompt_ref);
394            if prompt.is_none() {
395                losses.push(format!(
396                    "Prompt reference '{prompt_ref}' was not found; exported instructions may be incomplete."
397                ));
398            }
399            return Ok((prompt, losses));
400        }
401
402        if self.prompts.is_empty() {
403            Ok((None, losses))
404        } else if self.prompts.len() == 1 {
405            Ok((self.prompts.first(), losses))
406        } else {
407            losses.push(
408                "Agent has no prompt reference and spec contains multiple prompts; no prompt body was selected."
409                    .to_string(),
410            );
411            Ok((None, losses))
412        }
413    }
414
415    fn openai_export(&self, agent_id: &str) -> Result<FrameworkExport, TypeError> {
416        let agent = self.find_agent(agent_id)?;
417        let (prompt, mut losses) = self.selected_prompt_for_agent(agent)?;
418
419        let mut config = Map::new();
420        config.insert(
421            "name".to_string(),
422            Value::String(agent.name.clone().unwrap_or_else(|| agent.id.clone())),
423        );
424        config.insert(
425            "instructions".to_string(),
426            Value::String(merge_prompt_and_agent_instructions(agent, prompt)),
427        );
428        config.insert(
429            "tools".to_string(),
430            Value::Array(
431                agent
432                    .tool_refs
433                    .iter()
434                    .map(|name| {
435                        let mut tool = Map::new();
436                        tool.insert("name".to_string(), Value::String(name.clone()));
437                        Value::Object(tool)
438                    })
439                    .collect(),
440            ),
441        );
442
443        if let Some(schema) = agent
444            .output_schema
445            .clone()
446            .or_else(|| prompt.and_then(|p| p.output_schema.clone()))
447        {
448            config.insert("output_schema".to_string(), schema);
449        } else {
450            losses.push("No output schema available for OpenAI export.".to_string());
451        }
452
453        if !agent.agent_refs.is_empty() {
454            config.insert(
455                "handoffs".to_string(),
456                Value::Array(
457                    agent
458                        .agent_refs
459                        .iter()
460                        .cloned()
461                        .map(Value::String)
462                        .collect(),
463                ),
464            );
465        }
466
467        merge_extension_fields(&mut config, &agent.extensions, "openai_agents");
468
469        Ok(FrameworkExport {
470            framework: "openai_agents".to_string(),
471            agent_id: agent.id.clone(),
472            config: Value::Object(config),
473            losses,
474        })
475    }
476
477    fn crewai_export(&self, agent_id: &str) -> Result<FrameworkExport, TypeError> {
478        let agent = self.find_agent(agent_id)?;
479        let (prompt, mut losses) = self.selected_prompt_for_agent(agent)?;
480        let merged_instructions = merge_prompt_and_agent_instructions(agent, prompt);
481
482        let mut config = Map::new();
483        config.insert(
484            "id".to_string(),
485            Value::String(agent.name.clone().unwrap_or_else(|| agent.id.clone())),
486        );
487        config.insert(
488            "role".to_string(),
489            Value::String(agent.name.clone().unwrap_or_else(|| "Agent".to_string())),
490        );
491        config.insert(
492            "goal".to_string(),
493            Value::String(
494                agent
495                    .description
496                    .clone()
497                    .unwrap_or_else(|| "Execute assigned tasks".to_string()),
498            ),
499        );
500        config.insert("backstory".to_string(), Value::String(merged_instructions));
501        config.insert(
502            "tools".to_string(),
503            Value::Array(agent.tool_refs.iter().cloned().map(Value::String).collect()),
504        );
505
506        if let Some(schema) = agent
507            .output_schema
508            .clone()
509            .or_else(|| prompt.and_then(|p| p.output_schema.clone()))
510        {
511            config.insert("expected_output_schema".to_string(), schema);
512        } else {
513            losses.push("No output schema available for CrewAI export.".to_string());
514        }
515
516        merge_extension_fields(&mut config, &agent.extensions, "crewai");
517
518        Ok(FrameworkExport {
519            framework: "crewai".to_string(),
520            agent_id: agent.id.clone(),
521            config: Value::Object(config),
522            losses,
523        })
524    }
525
526    fn google_adk_export(&self, agent_id: &str) -> Result<FrameworkExport, TypeError> {
527        let agent = self.find_agent(agent_id)?;
528        let (prompt, mut losses) = self.selected_prompt_for_agent(agent)?;
529
530        let mut config = Map::new();
531        config.insert(
532            "name".to_string(),
533            Value::String(agent.name.clone().unwrap_or_else(|| agent.id.clone())),
534        );
535        config.insert(
536            "description".to_string(),
537            Value::String(agent.description.clone().unwrap_or_default()),
538        );
539        config.insert(
540            "instruction".to_string(),
541            Value::String(merge_prompt_and_agent_instructions(agent, prompt)),
542        );
543        config.insert(
544            "tools".to_string(),
545            Value::Array(
546                agent
547                    .tool_refs
548                    .iter()
549                    .map(|name| {
550                        let mut tool = Map::new();
551                        tool.insert("name".to_string(), Value::String(name.clone()));
552                        Value::Object(tool)
553                    })
554                    .collect(),
555            ),
556        );
557
558        if !agent.agent_refs.is_empty() {
559            config.insert(
560                "sub_agents".to_string(),
561                Value::Array(
562                    agent
563                        .agent_refs
564                        .iter()
565                        .cloned()
566                        .map(Value::String)
567                        .collect(),
568                ),
569            );
570        }
571
572        if let Some(model_hint) = agent.provider_hints.first() {
573            config.insert("model".to_string(), Value::String(model_hint.clone()));
574        } else {
575            losses.push("No provider_hints model set for Google ADK export.".to_string());
576        }
577
578        if let Some(schema) = agent
579            .output_schema
580            .clone()
581            .or_else(|| prompt.and_then(|p| p.output_schema.clone()))
582        {
583            config.insert("output_schema".to_string(), schema);
584        }
585
586        merge_extension_fields(&mut config, &agent.extensions, "google_adk");
587
588        Ok(FrameworkExport {
589            framework: "google_adk".to_string(),
590            agent_id: agent.id.clone(),
591            config: Value::Object(config),
592            losses,
593        })
594    }
595}
596
597#[pymethods]
598impl PromptSpec {
599    #[new]
600    #[pyo3(signature = (id, version, title=None, description=None, instructions=None, variables=None, input_schema=None, output_schema=None, tags=None, metadata=None, extensions=None))]
601    #[allow(clippy::too_many_arguments)]
602    pub fn new(
603        id: String,
604        version: String,
605        title: Option<String>,
606        description: Option<String>,
607        instructions: Option<&Bound<'_, PyAny>>,
608        variables: Option<&Bound<'_, PyAny>>,
609        input_schema: Option<&Bound<'_, PyAny>>,
610        output_schema: Option<&Bound<'_, PyAny>>,
611        tags: Option<&Bound<'_, PyAny>>,
612        metadata: Option<&Bound<'_, PyAny>>,
613        extensions: Option<&Bound<'_, PyAny>>,
614    ) -> Result<Self, TypeError> {
615        Ok(Self {
616            id,
617            version,
618            title,
619            description,
620            instructions: parse_optional_vec(instructions)?,
621            variables: parse_optional_vec(variables)?,
622            input_schema: parse_optional_json(input_schema, "input_schema")?,
623            output_schema: parse_optional_json(output_schema, "output_schema")?,
624            tags: parse_optional_vec(tags)?,
625            metadata: parse_optional_map(metadata, "metadata")?,
626            extensions: parse_optional_map(extensions, "extensions")?,
627        })
628    }
629
630    #[staticmethod]
631    #[pyo3(signature = (path, prompt_id=None))]
632    pub fn from_path(path: PathBuf, prompt_id: Option<String>) -> Result<Self, TypeError> {
633        let value = read_value_from_path(path.as_path())?;
634        parse_prompt_value(value, prompt_id.as_deref())
635    }
636
637    #[staticmethod]
638    pub fn model_validate_json(json_string: String) -> Result<Self, TypeError> {
639        let value: Value = serde_json::from_str(&json_string)?;
640        validate_runtime_fields_in_value(&value)?;
641        parse_prompt_value(value, None)
642    }
643
644    pub fn model_dump<'py>(&self, py: Python<'py>) -> Result<Bound<'py, PyAny>, TypeError> {
645        let value = serde_json::to_value(self)?;
646        Ok(pythonize(py, &value)?)
647    }
648
649    pub fn model_dump_json(&self) -> Result<String, TypeError> {
650        Ok(serde_json::to_string(self)?)
651    }
652
653    pub fn __str__(&self) -> String {
654        PyHelperFuncs::__str__(self)
655    }
656}
657
658#[pymethods]
659impl AgentSpec {
660    #[new]
661    #[pyo3(signature = (id, version, name=None, description=None, primary_prompt=None, prompt_refs=None, input_schema=None, output_schema=None, tool_refs=None, agent_refs=None, capabilities=None, provider_hints=None, framework_hints=None, tags=None, metadata=None, extensions=None))]
662    #[allow(clippy::too_many_arguments)]
663    pub fn new(
664        id: String,
665        version: String,
666        name: Option<String>,
667        description: Option<String>,
668        primary_prompt: Option<String>,
669        prompt_refs: Option<&Bound<'_, PyAny>>,
670        input_schema: Option<&Bound<'_, PyAny>>,
671        output_schema: Option<&Bound<'_, PyAny>>,
672        tool_refs: Option<&Bound<'_, PyAny>>,
673        agent_refs: Option<&Bound<'_, PyAny>>,
674        capabilities: Option<&Bound<'_, PyAny>>,
675        provider_hints: Option<&Bound<'_, PyAny>>,
676        framework_hints: Option<&Bound<'_, PyAny>>,
677        tags: Option<&Bound<'_, PyAny>>,
678        metadata: Option<&Bound<'_, PyAny>>,
679        extensions: Option<&Bound<'_, PyAny>>,
680    ) -> Result<Self, TypeError> {
681        let spec = Self {
682            id,
683            version,
684            name,
685            description,
686            primary_prompt,
687            prompt_refs: parse_optional_vec(prompt_refs)?,
688            input_schema: parse_optional_json(input_schema, "input_schema")?,
689            output_schema: parse_optional_json(output_schema, "output_schema")?,
690            tool_refs: parse_optional_vec(tool_refs)?,
691            agent_refs: parse_optional_vec(agent_refs)?,
692            capabilities: parse_optional_vec(capabilities)?,
693            provider_hints: parse_optional_vec(provider_hints)?,
694            framework_hints: parse_optional_vec(framework_hints)?,
695            tags: parse_optional_vec(tags)?,
696            metadata: parse_optional_map(metadata, "metadata")?,
697            extensions: parse_optional_map(extensions, "extensions")?,
698        };
699        validate_runtime_fields_in_agent(&serde_json::to_value(&spec)?)?;
700        Ok(spec)
701    }
702
703    #[staticmethod]
704    #[pyo3(signature = (path, agent_id=None))]
705    pub fn from_path(path: PathBuf, agent_id: Option<String>) -> Result<Self, TypeError> {
706        let value = read_value_from_path(path.as_path())?;
707        parse_agent_value(value, agent_id.as_deref())
708    }
709
710    #[staticmethod]
711    pub fn model_validate_json(json_string: String) -> Result<Self, TypeError> {
712        let value: Value = serde_json::from_str(&json_string)?;
713        validate_runtime_fields_in_value(&value)?;
714        parse_agent_value(value, None)
715    }
716
717    pub fn model_dump<'py>(&self, py: Python<'py>) -> Result<Bound<'py, PyAny>, TypeError> {
718        let value = serde_json::to_value(self)?;
719        Ok(pythonize(py, &value)?)
720    }
721
722    pub fn model_dump_json(&self) -> Result<String, TypeError> {
723        Ok(serde_json::to_string(self)?)
724    }
725
726    pub fn __str__(&self) -> String {
727        PyHelperFuncs::__str__(self)
728    }
729}
730
731#[pymethods]
732impl PortableSpec {
733    #[new]
734    #[pyo3(signature = (version, prompts=None, agents=None, metadata=None, extensions=None))]
735    pub fn new(
736        version: String,
737        prompts: Option<&Bound<'_, PyAny>>,
738        agents: Option<&Bound<'_, PyAny>>,
739        metadata: Option<&Bound<'_, PyAny>>,
740        extensions: Option<&Bound<'_, PyAny>>,
741    ) -> Result<Self, TypeError> {
742        let spec = Self {
743            version,
744            prompts: parse_optional_vec(prompts)?,
745            agents: parse_optional_vec(agents)?,
746            metadata: parse_optional_map(metadata, "metadata")?,
747            extensions: parse_optional_map(extensions, "extensions")?,
748        };
749        validate_runtime_fields_in_value(&serde_json::to_value(&spec)?)?;
750        Ok(spec)
751    }
752
753    #[staticmethod]
754    pub fn from_path(path: PathBuf) -> Result<Self, TypeError> {
755        let value = read_value_from_path(path.as_path())?;
756        Ok(serde_json::from_value(value)?)
757    }
758
759    #[staticmethod]
760    pub fn model_validate_json(json_string: String) -> Result<Self, TypeError> {
761        let value: Value = serde_json::from_str(&json_string)?;
762        validate_runtime_fields_in_value(&value)?;
763        Ok(serde_json::from_value(value)?)
764    }
765
766    pub fn model_dump<'py>(&self, py: Python<'py>) -> Result<Bound<'py, PyAny>, TypeError> {
767        let value = serde_json::to_value(self)?;
768        Ok(pythonize(py, &value)?)
769    }
770
771    pub fn model_dump_json(&self) -> Result<String, TypeError> {
772        Ok(serde_json::to_string(self)?)
773    }
774
775    pub fn prompt(&self, prompt_id: String) -> Result<PromptSpec, TypeError> {
776        self.find_prompt(&prompt_id)
777            .cloned()
778            .ok_or_else(|| TypeError::Error(format!("Prompt '{prompt_id}' not found")))
779    }
780
781    pub fn agent(&self, agent_id: String) -> Result<AgentSpec, TypeError> {
782        self.find_agent(&agent_id).cloned()
783    }
784
785    pub fn to_openai_agent_config<'py>(
786        &self,
787        py: Python<'py>,
788        agent_id: String,
789    ) -> Result<Bound<'py, PyAny>, TypeError> {
790        let export = self.openai_export(&agent_id)?;
791        Ok(pythonize(py, &serde_json::to_value(export)?)?)
792    }
793
794    pub fn to_crewai_agent_config<'py>(
795        &self,
796        py: Python<'py>,
797        agent_id: String,
798    ) -> Result<Bound<'py, PyAny>, TypeError> {
799        let export = self.crewai_export(&agent_id)?;
800        Ok(pythonize(py, &serde_json::to_value(export)?)?)
801    }
802
803    pub fn to_google_adk_agent_config<'py>(
804        &self,
805        py: Python<'py>,
806        agent_id: String,
807    ) -> Result<Bound<'py, PyAny>, TypeError> {
808        let export = self.google_adk_export(&agent_id)?;
809        Ok(pythonize(py, &serde_json::to_value(export)?)?)
810    }
811
812    pub fn __str__(&self) -> String {
813        PyHelperFuncs::__str__(self)
814    }
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820
821    #[test]
822    fn portable_spec_deserializes_and_exports_openai() {
823        let yaml = r#"
824version: "1.0"
825prompts:
826  - id: support_prompt
827    version: "1.0.0"
828    instructions:
829      - role: system
830        content: You classify support tickets.
831    output_schema:
832      type: object
833      properties:
834        category:
835          type: string
836agents:
837  - id: support_agent
838    version: "1.0.0"
839    name: Support Agent
840    description: Routes support tickets to the right queue.
841    primary_prompt: support_prompt
842    tool_refs: ["ticket_lookup"]
843    agent_refs: ["escalation_agent"]
844    extensions:
845      openai_agents:
846        model: gpt-4o-mini
847"#;
848
849        let value: Value = serde_yaml::from_str(yaml).unwrap();
850        let spec: PortableSpec = serde_json::from_value(value).unwrap();
851        let export = spec.openai_export("support_agent").unwrap();
852
853        assert_eq!(export.framework, "openai_agents");
854        assert_eq!(export.agent_id, "support_agent");
855        assert_eq!(
856            export.config["name"],
857            Value::String("Support Agent".to_string())
858        );
859        assert_eq!(
860            export.config["model"],
861            Value::String("gpt-4o-mini".to_string())
862        );
863        assert_eq!(
864            export.config["handoffs"],
865            Value::Array(vec![Value::String("escalation_agent".to_string())])
866        );
867    }
868
869    #[test]
870    fn runtime_field_is_rejected_in_agent() {
871        let yaml = r#"
872version: "1.0"
873agents:
874  - id: support_agent
875    version: "1.0.0"
876    max_iterations: 4
877"#;
878
879        let value: Value = serde_yaml::from_str(yaml).unwrap();
880        let result = PortableSpec::model_validate_json(serde_json::to_string(&value).unwrap());
881        assert!(result.is_err());
882        assert!(result
883            .unwrap_err()
884            .to_string()
885            .contains("runtime-only field 'max_iterations'"));
886    }
887
888    #[test]
889    fn extension_does_not_override_canonical_core_fields() {
890        let yaml = r#"
891version: "1.0"
892prompts:
893  - id: support_prompt
894    version: "1.0.0"
895    instructions:
896      - role: system
897        content: You classify support tickets.
898agents:
899  - id: support_agent
900    version: "1.0.0"
901    name: Canonical Name
902    primary_prompt: support_prompt
903    extensions:
904      openai_agents:
905        name: Wrong Override Name
906        model: gpt-4o-mini
907"#;
908
909        let value: Value = serde_yaml::from_str(yaml).unwrap();
910        let spec: PortableSpec = serde_json::from_value(value).unwrap();
911        let export = spec.openai_export("support_agent").unwrap();
912
913        assert_eq!(
914            export.config["name"],
915            Value::String("Canonical Name".to_string())
916        );
917        assert_eq!(
918            export.config["model"],
919            Value::String("gpt-4o-mini".to_string())
920        );
921    }
922}