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}