Skip to main content

jamjet_core/
node.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4pub type NodeId = String;
5
6/// The lifecycle status of a single node within an execution.
7#[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/// All node kinds supported by the JamJet runtime.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "type", rename_all = "snake_case")]
31pub enum NodeKind {
32    /// LLM call with a prompt and structured output.
33    Model {
34        model_ref: String,
35        prompt_ref: String,
36        output_schema: String,
37        system_prompt: Option<String>,
38        /// OpenAI-format tool/function schemas offered to the model for this call.
39        /// Empty means no tools are offered (standard text completion).
40        #[serde(default)]
41        tools: Vec<serde_json::Value>,
42    },
43
44    /// Python function, HTTP endpoint, or gRPC tool.
45    Tool {
46        tool_ref: String,
47        input_mapping: HashMap<String, String>,
48        output_schema: String,
49    },
50
51    /// Arbitrary Python function executed by a Python worker.
52    PythonFn {
53        module: String,
54        function: String,
55        output_schema: String,
56        /// True when this node is an ADK agent tool-dispatch node, i.e. it runs a
57        /// whole turn's model-chosen tool calls in one shot.
58        ///
59        /// Policy for these nodes cannot be derived from the static node kind:
60        /// the tool names only exist in runtime state. The worker instead
61        /// evaluates the frozen pending calls carried in the work-item payload.
62        /// `#[serde(default)]` so IRs compiled before this field existed
63        /// deserialize to `false` and behave exactly as they did before.
64        #[serde(default)]
65        agent_tool_dispatch: bool,
66    },
67
68    /// Arbitrary Java method executed by an external Java tool-worker.
69    ///
70    /// The Java analog of [`NodeKind::PythonFn`]: a `class_name` + `method`
71    /// pair the worker reflects/dispatches. Routes to the `java_tool` queue,
72    /// which a net-new Java worker drains over the same HTTP claim/complete
73    /// API the Python `python_tool` worker uses — durable, exactly-once.
74    JavaFn {
75        class_name: String,
76        method: String,
77        output_schema: String,
78        /// See [`NodeKind::PythonFn::agent_tool_dispatch`]. The Java tool-worker
79        /// receives identical payload enrichment, so it has the identical
80        /// enforcement gap and needs the identical marker.
81        #[serde(default)]
82        agent_tool_dispatch: bool,
83    },
84
85    /// Router — evaluates expressions and branches.
86    Condition { branches: Vec<ConditionalBranch> },
87
88    /// Fan-out to multiple branches concurrently.
89    Parallel { branches: Vec<NodeId> },
90
91    /// Waits for all parallel branches to complete.
92    Join {
93        wait_for: Vec<NodeId>,
94        merge_strategy: MergeStrategy,
95    },
96
97    /// Pauses workflow for human decision.
98    HumanApproval {
99        description: String,
100        timeout_secs: Option<u64>,
101        fallback_node: Option<NodeId>,
102    },
103
104    /// Suspends until a timer fires or external event arrives.
105    Wait {
106        condition: WaitCondition,
107        correlation_key: Option<String>,
108        timeout_secs: Option<u64>,
109    },
110
111    /// Executes a child workflow.
112    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    /// Retrieves context from a memory/retrieval connector.
120    MemoryRetrieval {
121        connector_ref: String,
122        query_expr: String,
123        output_schema: String,
124    },
125
126    /// Evaluates policy rules; can block or branch on violation.
127    Policy {
128        policy_ref: String,
129        on_violation: ViolationAction,
130    },
131
132    /// Side-effect node (notifications, writes).
133    Finalizer {
134        tool_ref: String,
135        run_on: FinalizerTrigger,
136    },
137
138    // ── Protocol nodes ──────────────────────────────────────────────────
139    /// Delegates to a local JamJet agent.
140    Agent {
141        agent_ref: String,
142        input_mapping: HashMap<String, String>,
143        output_schema: String,
144    },
145
146    /// Invokes a tool from an external MCP server.
147    McpTool {
148        server: String,
149        tool: String,
150        input_mapping: HashMap<String, String>,
151        output_schema: String,
152    },
153
154    /// Delegates a task to an external A2A agent.
155    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    /// Dynamically discovers and selects an agent at runtime.
167    AgentDiscovery {
168        skill: String,
169        protocol: Option<String>,
170        output_binding: String,
171    },
172
173    /// Dynamic agent routing with structured scoring + LLM tiebreaker.
174    /// Supersedes AgentDiscovery.
175    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    /// Invoke a registered agent as a callable tool.
193    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    /// Evaluates the preceding node's output using configurable scorers.
205    ///
206    /// Supports LLM-judge, deterministic assertions, latency/cost thresholds,
207    /// and custom Python scorer plugins.
208    Eval {
209        /// Ordered list of scorer configurations.
210        scorers: Vec<EvalScorer>,
211        /// Action on overall failure (any scorer below threshold).
212        on_fail: EvalOnFail,
213        /// Maximum retry attempts before propagating failure.
214        #[serde(default)]
215        max_retries: u32,
216        /// Input expression — which state field to evaluate (default: last node output).
217        input_expr: Option<String>,
218    },
219
220    /// Terminal node emitted by strategy compilers when an iteration or cost
221    /// limit is reached. The runtime records the workflow as `LimitExceeded`
222    /// and stops further execution. No fields are required — it is purely a
223    /// marker that carries optional descriptive metadata in the node's
224    /// `description` / `labels`.
225    LimitExceeded,
226}
227
228impl NodeKind {
229    /// Returns the queue type this node should be dispatched to.
230    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    /// Returns true if this node requires durable tracking across crashes.
248    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/// Which queue a node's work item is dispatched to.
256#[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>, // None = default/else branch
271    pub target: NodeId,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum MergeStrategy {
277    /// Merge all branch outputs into a list.
278    Collect,
279    /// Take the first completed branch output.
280    First,
281    /// Custom merge function.
282    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// ── Eval node types ──────────────────────────────────────────────────────────
310
311/// A scorer within an `Eval` node.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(tag = "type", rename_all = "snake_case")]
314pub enum EvalScorer {
315    /// LLM-as-judge: sends output to a model with a rubric, expects a score 1-5.
316    LlmJudge {
317        model: String,
318        rubric: String,
319        /// Minimum acceptable score (1-5). Scores below this fail.
320        #[serde(default = "default_min_score")]
321        min_score: u8,
322    },
323    /// Deterministic Python expressions evaluated against the output.
324    Assertion {
325        /// Each check is a Python expression that must evaluate to truthy.
326        checks: Vec<String>,
327    },
328    /// Ensures node execution completed within a latency threshold.
329    Latency {
330        /// Maximum allowed duration in milliseconds.
331        threshold_ms: u64,
332    },
333    /// Ensures the execution cost is within budget.
334    Cost {
335        /// Maximum allowed cost in USD.
336        threshold_usd: f64,
337    },
338    /// Custom Python scorer loaded via entry point or module path.
339    Custom {
340        /// Python dotted path: "my_package.scorers:MyScorer"
341        module: String,
342        /// Optional keyword arguments passed to the scorer.
343        #[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/// What the eval node does when one or more scorers fail.
357#[derive(Debug, Clone, Serialize, Deserialize, Default)]
358#[serde(rename_all = "snake_case")]
359pub enum EvalOnFail {
360    /// Feed scorer feedback back to the previous node and retry.
361    RetryWithFeedback,
362    /// Escalate to human (triggers HumanApproval fallback node).
363    Escalate,
364    /// Fail the workflow immediately.
365    #[default]
366    Halt,
367    /// Record the failure but continue the workflow.
368    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        // Node-kind serde tag mirrors PythonFn's snake_case ("python_fn" -> "java_fn").
445        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        // Mirrors PythonFn -> PythonTool: JavaFn routes to its own durable queue.
461        assert_eq!(node.queue_type(), QueueType::JavaTool);
462        // The queue string the work item carries (routes.rs/runner.rs serialize
463        // `queue_type()` to snake_case) must be exactly "java_tool".
464        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        // An IR persisted before this field existed must deserialize unchanged.
474        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}