1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4pub type NodeId = String;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum NodeStatus {
10 Pending,
11 Scheduled,
12 Running,
13 Completed,
14 Failed,
15 Skipped,
16 Cancelled,
17}
18
19impl NodeStatus {
20 pub fn is_terminal(&self) -> bool {
21 matches!(
22 self,
23 Self::Completed | Self::Failed | Self::Skipped | Self::Cancelled
24 )
25 }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "type", rename_all = "snake_case")]
31pub enum NodeKind {
32 Model {
34 model_ref: String,
35 prompt_ref: String,
36 output_schema: String,
37 system_prompt: Option<String>,
38 #[serde(default)]
41 tools: Vec<serde_json::Value>,
42 },
43
44 Tool {
46 tool_ref: String,
47 input_mapping: HashMap<String, String>,
48 output_schema: String,
49 },
50
51 PythonFn {
53 module: String,
54 function: String,
55 output_schema: String,
56 #[serde(default)]
65 agent_tool_dispatch: bool,
66 },
67
68 JavaFn {
75 class_name: String,
76 method: String,
77 output_schema: String,
78 #[serde(default)]
82 agent_tool_dispatch: bool,
83 },
84
85 Condition { branches: Vec<ConditionalBranch> },
87
88 Parallel { branches: Vec<NodeId> },
90
91 Join {
93 wait_for: Vec<NodeId>,
94 merge_strategy: MergeStrategy,
95 },
96
97 HumanApproval {
99 description: String,
100 timeout_secs: Option<u64>,
101 fallback_node: Option<NodeId>,
102 },
103
104 Wait {
106 condition: WaitCondition,
107 correlation_key: Option<String>,
108 timeout_secs: Option<u64>,
109 },
110
111 Subgraph {
113 workflow_ref: String,
114 workflow_version: Option<String>,
115 input_mapping: HashMap<String, String>,
116 output_mapping: HashMap<String, String>,
117 },
118
119 MemoryRetrieval {
121 connector_ref: String,
122 query_expr: String,
123 output_schema: String,
124 },
125
126 Policy {
128 policy_ref: String,
129 on_violation: ViolationAction,
130 },
131
132 Finalizer {
134 tool_ref: String,
135 run_on: FinalizerTrigger,
136 },
137
138 Agent {
141 agent_ref: String,
142 input_mapping: HashMap<String, String>,
143 output_schema: String,
144 },
145
146 McpTool {
148 server: String,
149 tool: String,
150 input_mapping: HashMap<String, String>,
151 output_schema: String,
152 },
153
154 A2aTask {
156 remote_agent: String,
157 skill: String,
158 input_mapping: HashMap<String, String>,
159 output_schema: String,
160 stream: bool,
161 on_input_required: Option<NodeId>,
162 timeout_secs: Option<u64>,
163 },
164
165 #[deprecated(note = "Use Coordinator node instead")]
166 AgentDiscovery {
168 skill: String,
169 protocol: Option<String>,
170 output_binding: String,
171 },
172
173 Coordinator {
176 task: String,
177 required_skills: Vec<String>,
178 #[serde(default)]
179 preferred_skills: Vec<String>,
180 trust_domain: Option<String>,
181 budget: Option<crate::coordinator::CoordinatorBudget>,
182 tiebreaker: Option<crate::coordinator::TiebreakerConfig>,
183 #[serde(default = "default_strategy")]
184 strategy: String,
185 #[serde(default)]
186 weights: crate::coordinator::DimensionWeights,
187 #[serde(default)]
188 input_mapping: HashMap<String, String>,
189 output_key: String,
190 },
191
192 AgentTool {
194 agent: crate::agent_tool::AgentTarget,
195 #[serde(default)]
196 mode: crate::agent_tool::AgentToolMode,
197 #[serde(default)]
198 input_mapping: HashMap<String, String>,
199 output_key: String,
200 timeout_ms: Option<u64>,
201 budget: Option<crate::agent_tool::AgentToolBudget>,
202 },
203
204 Eval {
209 scorers: Vec<EvalScorer>,
211 on_fail: EvalOnFail,
213 #[serde(default)]
215 max_retries: u32,
216 input_expr: Option<String>,
218 },
219
220 LimitExceeded,
226}
227
228impl NodeKind {
229 pub fn queue_type(&self) -> QueueType {
231 match self {
232 Self::Model { .. } => QueueType::Model,
233 Self::Tool { .. } | Self::Finalizer { .. } => QueueType::Tool,
234 Self::PythonFn { .. } => QueueType::PythonTool,
235 Self::JavaFn { .. } => QueueType::JavaTool,
236 Self::MemoryRetrieval { .. } => QueueType::Retrieval,
237 Self::McpTool { .. } | Self::A2aTask { .. } => QueueType::Tool,
238 Self::Agent { .. } => QueueType::General,
239 Self::HumanApproval { .. } | Self::Wait { .. } => QueueType::General,
240 Self::Eval { .. } => QueueType::General,
241 Self::Coordinator { .. } => QueueType::General,
242 Self::AgentTool { .. } => QueueType::General,
243 _ => QueueType::General,
244 }
245 }
246
247 pub fn is_durable(&self) -> bool {
249 #[allow(deprecated)]
250 let is_agent_discovery = matches!(self, Self::AgentDiscovery { .. });
251 !matches!(self, Self::Condition { .. }) && !is_agent_discovery
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(rename_all = "snake_case")]
258pub enum QueueType {
259 Model,
260 Tool,
261 PythonTool,
262 JavaTool,
263 Retrieval,
264 Privileged,
265 General,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct ConditionalBranch {
270 pub condition: Option<String>, pub target: NodeId,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum MergeStrategy {
277 Collect,
279 First,
281 Custom { function_ref: String },
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum WaitCondition {
288 Timer,
289 ExternalEvent,
290 Either,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(rename_all = "snake_case")]
295pub enum ViolationAction {
296 Fail,
297 Branch { target: NodeId },
298 Warn,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(rename_all = "snake_case")]
303pub enum FinalizerTrigger {
304 Success,
305 Failure,
306 Always,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(tag = "type", rename_all = "snake_case")]
314pub enum EvalScorer {
315 LlmJudge {
317 model: String,
318 rubric: String,
319 #[serde(default = "default_min_score")]
321 min_score: u8,
322 },
323 Assertion {
325 checks: Vec<String>,
327 },
328 Latency {
330 threshold_ms: u64,
332 },
333 Cost {
335 threshold_usd: f64,
337 },
338 Custom {
340 module: String,
342 #[serde(default)]
344 kwargs: serde_json::Value,
345 },
346}
347
348fn default_min_score() -> u8 {
349 3
350}
351
352fn default_strategy() -> String {
353 "default".to_string()
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize, Default)]
358#[serde(rename_all = "snake_case")]
359pub enum EvalOnFail {
360 RetryWithFeedback,
362 Escalate,
364 #[default]
366 Halt,
367 LogAndContinue,
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn model_node_dispatches_to_model_queue() {
377 let node = NodeKind::Model {
378 model_ref: "openai.gpt4".into(),
379 prompt_ref: "prompts/summarize.md".into(),
380 output_schema: "schemas.Summary".into(),
381 system_prompt: None,
382 tools: vec![],
383 };
384 assert_eq!(node.queue_type(), QueueType::Model);
385 assert!(node.is_durable());
386 }
387
388 #[test]
389 fn condition_node_is_not_durable() {
390 let node = NodeKind::Condition { branches: vec![] };
391 assert!(!node.is_durable());
392 }
393
394 #[test]
395 fn coordinator_node_round_trip() {
396 let node = NodeKind::Coordinator {
397 task: "Analyze data".into(),
398 required_skills: vec!["data-analysis".into()],
399 preferred_skills: vec![],
400 trust_domain: Some("internal".into()),
401 budget: None,
402 tiebreaker: None,
403 strategy: "default".into(),
404 weights: Default::default(),
405 input_mapping: Default::default(),
406 output_key: "result".into(),
407 };
408 let json = serde_json::to_string(&node).unwrap();
409 let deserialized: NodeKind = serde_json::from_str(&json).unwrap();
410 assert!(matches!(deserialized, NodeKind::Coordinator { .. }));
411 assert_eq!(node.queue_type(), QueueType::General);
412 assert!(node.is_durable());
413 }
414
415 #[test]
416 fn agent_tool_node_round_trip() {
417 let node = NodeKind::AgentTool {
418 agent: crate::agent_tool::AgentTarget::Explicit("jamjet://org/test".into()),
419 mode: crate::agent_tool::AgentToolMode::Sync,
420 input_mapping: Default::default(),
421 output_key: "result".into(),
422 timeout_ms: Some(5000),
423 budget: None,
424 };
425 let json = serde_json::to_string(&node).unwrap();
426 let deserialized: NodeKind = serde_json::from_str(&json).unwrap();
427 assert!(matches!(deserialized, NodeKind::AgentTool { .. }));
428 assert_eq!(node.queue_type(), QueueType::General);
429 assert!(node.is_durable());
430 }
431
432 #[test]
433 fn java_fn_node_round_trip() {
434 let node = NodeKind::JavaFn {
435 class_name: "com.example.tools.WeatherTool".into(),
436 method: "getWeather".into(),
437 output_schema: "schemas.Weather".into(),
438 agent_tool_dispatch: false,
439 };
440 let json = serde_json::to_string(&node).unwrap();
441 let deserialized: NodeKind = serde_json::from_str(&json).unwrap();
442 assert!(matches!(deserialized, NodeKind::JavaFn { .. }));
443 assert!(node.is_durable());
444 assert_eq!(
446 serde_json::to_value(&node).unwrap()["type"],
447 "java_fn",
448 "JavaFn must serialize with the snake_case type tag"
449 );
450 }
451
452 #[test]
453 fn java_fn_dispatches_to_java_tool_queue() {
454 let node = NodeKind::JavaFn {
455 class_name: "com.example.tools.WeatherTool".into(),
456 method: "getWeather".into(),
457 output_schema: String::new(),
458 agent_tool_dispatch: false,
459 };
460 assert_eq!(node.queue_type(), QueueType::JavaTool);
462 assert_eq!(
465 serde_json::to_value(node.queue_type()).unwrap(),
466 "java_tool",
467 "QueueType::JavaTool must serialize to the \"java_tool\" queue string"
468 );
469 }
470
471 #[test]
472 fn python_fn_without_dispatch_flag_defaults_to_false() {
473 let json = serde_json::json!({
475 "type": "python_fn",
476 "module": "m",
477 "function": "f",
478 "output_schema": ""
479 });
480 let kind: NodeKind =
481 serde_json::from_value(json).expect("legacy python_fn must deserialize");
482 match kind {
483 NodeKind::PythonFn {
484 agent_tool_dispatch,
485 ..
486 } => assert!(!agent_tool_dispatch),
487 other => panic!("expected PythonFn, got {other:?}"),
488 }
489 }
490
491 #[test]
492 fn python_fn_dispatch_flag_round_trips() {
493 let json = serde_json::json!({
494 "type": "python_fn",
495 "module": "m",
496 "function": "f",
497 "output_schema": "",
498 "agent_tool_dispatch": true
499 });
500 let kind: NodeKind = serde_json::from_value(json).expect("python_fn must deserialize");
501 match kind {
502 NodeKind::PythonFn {
503 agent_tool_dispatch,
504 ..
505 } => assert!(agent_tool_dispatch),
506 other => panic!("expected PythonFn, got {other:?}"),
507 }
508 }
509
510 #[test]
511 fn java_fn_without_dispatch_flag_defaults_to_false() {
512 let json = serde_json::json!({
513 "type": "java_fn",
514 "class_name": "C",
515 "method": "m",
516 "output_schema": ""
517 });
518 let kind: NodeKind = serde_json::from_value(json).expect("legacy java_fn must deserialize");
519 match kind {
520 NodeKind::JavaFn {
521 agent_tool_dispatch,
522 ..
523 } => assert!(!agent_tool_dispatch),
524 other => panic!("expected JavaFn, got {other:?}"),
525 }
526 }
527
528 #[test]
529 fn java_fn_dispatch_flag_round_trips() {
530 let json = serde_json::json!({
531 "type": "java_fn",
532 "class_name": "C",
533 "method": "m",
534 "output_schema": "",
535 "agent_tool_dispatch": true
536 });
537 let kind: NodeKind = serde_json::from_value(json).expect("java_fn must deserialize");
538 match kind {
539 NodeKind::JavaFn {
540 agent_tool_dispatch,
541 ..
542 } => assert!(agent_tool_dispatch),
543 other => panic!("expected JavaFn, got {other:?}"),
544 }
545 }
546
547 #[test]
548 fn agent_discovery_is_deprecated_but_functional() {
549 #[allow(deprecated)]
550 let node = NodeKind::AgentDiscovery {
551 skill: "data-analysis".into(),
552 protocol: None,
553 output_binding: "selected_agent".into(),
554 };
555 #[allow(deprecated)]
556 let _ = node.queue_type();
557 }
558}