diff --git a/crates/arc-api/tests/integration.rs b/crates/arc-api/tests/integration.rs index 13f96a522..d074cf709 100644 --- a/crates/arc-api/tests/integration.rs +++ b/crates/arc-api/tests/integration.rs @@ -22,7 +22,7 @@ mod mtls_e2e { let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None))); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - registry.register("agent_loop", Box::new(AgentHandler::new(None))); + registry.register("agent", Box::new(AgentHandler::new(None))); registry } @@ -432,7 +432,7 @@ mod server_lifecycle { let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None))); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - registry.register("agent_loop", Box::new(AgentHandler::new(None))); + registry.register("agent", Box::new(AgentHandler::new(None))); registry.register("human", Box::new(HumanHandler::new(interviewer))); registry } @@ -630,7 +630,7 @@ mod sse_events { let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None))); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - registry.register("agent_loop", Box::new(AgentHandler::new(None))); + registry.register("agent", Box::new(AgentHandler::new(None))); registry } diff --git a/crates/arc-workflows/README.md b/crates/arc-workflows/README.md index 79665e376..cceb846f1 100644 --- a/crates/arc-workflows/README.md +++ b/crates/arc-workflows/README.md @@ -5,7 +5,7 @@ A DOT-based pipeline runner for multi-stage AI workflows. Define workflows as Gr ## Key Concepts - **Graph** -- A directed graph parsed from DOT syntax containing nodes, edges, and attributes. The graph carries a `goal` describing the pipeline's purpose. -- **Node** -- A workflow step. Graphviz shapes map to handler types (e.g., `Mdiamond` = start, `Msquare` = exit, `box` = agent_loop, `tab` = one_shot, `diamond` = conditional, `hexagon` = human gate, `component` = parallel). +- **Node** -- A workflow step. Graphviz shapes map to handler types (e.g., `Mdiamond` = start, `Msquare` = exit, `box` = agent, `tab` = prompt, `diamond` = conditional, `hexagon` = human gate, `component` = parallel). - **Edge** -- A connection between nodes with optional `condition`, `label`, `weight`, and `fidelity` attributes that control routing. - **Handler** -- An async trait implementation that executes a node and returns an `Outcome`. Built-in handlers include `StartHandler`, `ExitHandler`, `AgentHandler`, `PromptHandler`, `ConditionalHandler`, `HumanHandler`, `ParallelHandler`, `FanInHandler`, `CommandHandler`, and `SubWorkflowHandler`. - **Outcome** -- The result of executing a handler, carrying a `StageStatus` (Success, Fail, PartialSuccess, Retry, Skipped), optional routing hints (`preferred_label`, `suggested_next_ids`), and context updates. @@ -75,7 +75,7 @@ let graph = prepare_pipeline(dot_source).unwrap(); let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None))); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); -registry.register("agent_loop", Box::new(AgentHandler::new(None))); +registry.register("agent", Box::new(AgentHandler::new(None))); let engine = PipelineEngine::new(registry, EventEmitter::new()); let config = RunConfig { diff --git a/crates/arc-workflows/src/cli/backend.rs b/crates/arc-workflows/src/cli/backend.rs index ca8bfc48f..9e1a06d5e 100644 --- a/crates/arc-workflows/src/cli/backend.rs +++ b/crates/arc-workflows/src/cli/backend.rs @@ -266,7 +266,7 @@ impl CodergenBackend for AgentApiBackend { to_provider = target.provider.as_str(), to_model = target.model.as_str(), error = error_msg.as_str(), - "LLM provider failover (one_shot)" + "LLM provider failover (prompt)" ); let max_tokens = node.max_tokens().or_else(|| { @@ -306,7 +306,7 @@ impl CodergenBackend for AgentApiBackend { } let provider_used = serde_json::json!({ - "mode": "one_shot", + "mode": "prompt", "provider": &actual_provider, "model": &actual_model, }); @@ -520,7 +520,7 @@ impl CodergenBackend for AgentApiBackend { }; let provider_used = serde_json::json!({ - "mode": "agent_loop", + "mode": "agent", "provider": self.provider.as_str(), "model": &self.model, }); diff --git a/crates/arc-workflows/src/event.rs b/crates/arc-workflows/src/event.rs index 51f229de9..099baf1d8 100644 --- a/crates/arc-workflows/src/event.rs +++ b/crates/arc-workflows/src/event.rs @@ -840,7 +840,7 @@ mod tests { node_id: "plan".to_string(), name: "plan".to_string(), index: 0, - handler_type: Some("agent_loop".to_string()), + handler_type: Some("agent".to_string()), script: None, attempt: 1, max_attempts: 3, @@ -848,7 +848,7 @@ mod tests { let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("StageStarted")); assert!(json.contains("plan")); - assert!(json.contains("\"handler_type\":\"agent_loop\"")); + assert!(json.contains("\"handler_type\":\"agent\"")); assert!(json.contains("\"attempt\":1")); assert!(json.contains("\"max_attempts\":3")); @@ -1348,7 +1348,7 @@ mod tests { node_id: "plan".to_string(), name: "Plan Stage".to_string(), index: 0, - handler_type: Some("agent_loop".to_string()), + handler_type: Some("agent".to_string()), script: None, attempt: 1, max_attempts: 3, @@ -1358,7 +1358,7 @@ mod tests { assert_eq!(fields["node_id"], "plan"); assert_eq!(fields["node_label"], "Plan Stage"); assert_eq!(fields["stage_index"], 0); - assert_eq!(fields["handler_type"], "agent_loop"); + assert_eq!(fields["handler_type"], "agent"); assert_eq!(fields["attempt"], 1); assert_eq!(fields["max_attempts"], 3); // Old keys should not be present diff --git a/crates/arc-workflows/src/graph/types.rs b/crates/arc-workflows/src/graph/types.rs index f45d25d80..a22417b84 100644 --- a/crates/arc-workflows/src/graph/types.rs +++ b/crates/arc-workflows/src/graph/types.rs @@ -73,8 +73,8 @@ pub fn shape_to_handler_type(shape: &str) -> Option<&'static str> { match shape { "Mdiamond" => Some("start"), "Msquare" => Some("exit"), - "box" => Some("agent_loop"), - "tab" => Some("one_shot"), + "box" => Some("agent"), + "tab" => Some("prompt"), "hexagon" => Some("human"), "diamond" => Some("conditional"), "component" => Some("parallel"), @@ -465,8 +465,8 @@ mod tests { fn shape_to_handler_type_mappings() { assert_eq!(shape_to_handler_type("Mdiamond"), Some("start")); assert_eq!(shape_to_handler_type("Msquare"), Some("exit")); - assert_eq!(shape_to_handler_type("box"), Some("agent_loop")); - assert_eq!(shape_to_handler_type("tab"), Some("one_shot")); + assert_eq!(shape_to_handler_type("box"), Some("agent")); + assert_eq!(shape_to_handler_type("tab"), Some("prompt")); assert_eq!(shape_to_handler_type("hexagon"), Some("human")); assert_eq!(shape_to_handler_type("diamond"), Some("conditional")); assert_eq!(shape_to_handler_type("component"), Some("parallel")); diff --git a/crates/arc-workflows/src/handler/mod.rs b/crates/arc-workflows/src/handler/mod.rs index 8e4804a2e..398d16bb7 100644 --- a/crates/arc-workflows/src/handler/mod.rs +++ b/crates/arc-workflows/src/handler/mod.rs @@ -115,7 +115,7 @@ impl HandlerRegistry { /// Build a [`HandlerRegistry`] with all built-in handler types registered. /// /// The `make_backend` closure is called once per handler that needs a backend -/// (`AgentHandler` default, explicit `"agent_loop"`, and `"parallel.fan_in"`). +/// (`AgentHandler` default, explicit `"agent"`, and `"parallel.fan_in"`). #[must_use] pub fn default_registry( interviewer: Arc, @@ -125,10 +125,20 @@ pub fn default_registry( HandlerRegistry::new(Box::new(agent::AgentHandler::new(make_backend()))); registry.register("start", Box::new(start::StartHandler)); registry.register("exit", Box::new(exit::ExitHandler)); + registry.register( + "agent", + Box::new(agent::AgentHandler::new(make_backend())), + ); + // Legacy alias registry.register( "agent_loop", Box::new(agent::AgentHandler::new(make_backend())), ); + registry.register( + "prompt", + Box::new(prompt::PromptHandler::new(make_backend())), + ); + // Legacy alias registry.register( "one_shot", Box::new(prompt::PromptHandler::new(make_backend())), diff --git a/crates/arc-workflows/src/hook/runner.rs b/crates/arc-workflows/src/hook/runner.rs index 7ddf04ef4..ec6adff8e 100644 --- a/crates/arc-workflows/src/hook/runner.rs +++ b/crates/arc-workflows/src/hook/runner.rs @@ -286,7 +286,7 @@ mod tests { #[tokio::test] async fn matcher_filters_by_node_id() { let mut hook = make_hook(HookEvent::StageStart, "filtered"); - hook.matcher = Some("agent_loop".into()); + hook.matcher = Some("agent".into()); let config = HookConfig { hooks: vec![hook], }; @@ -303,7 +303,7 @@ mod tests { // Matching node_id let mut ctx = make_context(HookEvent::StageStart); - ctx.node_id = Some("agent_loop_step".into()); + ctx.node_id = Some("agent_step".into()); assert_eq!(runner.filter_hooks(&ctx).len(), 1); // Non-matching node_id @@ -315,7 +315,7 @@ mod tests { #[tokio::test] async fn matcher_filters_by_handler_type() { let mut hook = make_hook(HookEvent::StageStart, "filtered"); - hook.matcher = Some("^agent_loop$".into()); + hook.matcher = Some("^agent$".into()); let config = HookConfig { hooks: vec![hook], }; @@ -327,7 +327,7 @@ mod tests { ); let mut ctx = make_context(HookEvent::StageStart); - ctx.handler_type = Some("agent_loop".into()); + ctx.handler_type = Some("agent".into()); assert_eq!(runner.filter_hooks(&ctx).len(), 1); let mut ctx = make_context(HookEvent::StageStart); diff --git a/crates/arc-workflows/src/hook/types.rs b/crates/arc-workflows/src/hook/types.rs index 34d22f347..de23529e9 100644 --- a/crates/arc-workflows/src/hook/types.rs +++ b/crates/arc-workflows/src/hook/types.rs @@ -223,7 +223,7 @@ mod tests { cwd: Some("/tmp".into()), node_id: Some("plan".into()), node_label: Some("Plan".into()), - handler_type: Some("agent_loop".into()), + handler_type: Some("agent".into()), status: None, edge_from: None, edge_to: None, diff --git a/crates/arc-workflows/src/parser/mod.rs b/crates/arc-workflows/src/parser/mod.rs index 41bcd6f7f..0bfab2c1c 100644 --- a/crates/arc-workflows/src/parser/mod.rs +++ b/crates/arc-workflows/src/parser/mod.rs @@ -167,4 +167,16 @@ mod tests { .classes .contains(&"loop-a".to_string())); } + + #[test] + fn parse_legacy_codergen_mode_attribute() { + let input = r#"digraph Legacy { + start [shape=Mdiamond] + exit [shape=Msquare] + classify [codergen_mode="one_shot", prompt="Classify this"] + start -> classify -> exit + }"#; + let graph = parse(input).unwrap(); + assert_eq!(graph.nodes["classify"].handler_type(), Some("prompt")); + } } diff --git a/crates/arc-workflows/src/parser/semantic.rs b/crates/arc-workflows/src/parser/semantic.rs index ee6c6a815..04ec5e382 100644 --- a/crates/arc-workflows/src/parser/semantic.rs +++ b/crates/arc-workflows/src/parser/semantic.rs @@ -108,6 +108,18 @@ impl SemanticState { if let Some(cls) = subgraph_class { Self::add_class_to_node(node, cls); } + // Legacy: translate codergen_mode to type if type is not explicitly set + if node.attrs.get("type").is_none() { + if let Some(mode) = node.attrs.get("codergen_mode").and_then(AttrValue::as_str) { + let mapped = match mode { + "one_shot" => "prompt", + "agent_loop" => "agent", + other => other, + }; + node.attrs + .insert("type".to_string(), AttrValue::String(mapped.to_string())); + } + } // Parse explicit class attr into classes vec let class_str = node .attrs @@ -532,4 +544,69 @@ mod tests { assert!(graph.nodes.contains_key("a")); assert!(graph.nodes.contains_key("b")); } + + #[test] + fn codergen_mode_legacy_translates_to_type() { + let dot = DotGraph { + name: "Legacy".into(), + statements: vec![ + Statement::Node(crate::parser::ast::NodeStmt { + id: "classify".into(), + attrs: Some(vec![( + "codergen_mode".into(), + AstValue::Str("one_shot".into()), + )]), + }), + Statement::Node(crate::parser::ast::NodeStmt { + id: "work".into(), + attrs: Some(vec![( + "codergen_mode".into(), + AstValue::Str("agent_loop".into()), + )]), + }), + ], + }; + + let graph = ast_to_graph(&dot).unwrap(); + assert_eq!( + graph.nodes["classify"] + .attrs + .get("type") + .and_then(AttrValue::as_str), + Some("prompt") + ); + assert_eq!( + graph.nodes["work"] + .attrs + .get("type") + .and_then(AttrValue::as_str), + Some("agent") + ); + } + + #[test] + fn codergen_mode_does_not_override_explicit_type() { + let dot = DotGraph { + name: "ExplicitType".into(), + statements: vec![Statement::Node(crate::parser::ast::NodeStmt { + id: "gate".into(), + attrs: Some(vec![ + ("type".into(), AstValue::Str("human".into())), + ( + "codergen_mode".into(), + AstValue::Str("one_shot".into()), + ), + ]), + })], + }; + + let graph = ast_to_graph(&dot).unwrap(); + assert_eq!( + graph.nodes["gate"] + .attrs + .get("type") + .and_then(AttrValue::as_str), + Some("human") + ); + } } diff --git a/crates/arc-workflows/src/preamble.rs b/crates/arc-workflows/src/preamble.rs index b503be6a1..9a6214d30 100644 --- a/crates/arc-workflows/src/preamble.rs +++ b/crates/arc-workflows/src/preamble.rs @@ -157,7 +157,7 @@ fn render_compact_stage_details( } lines } - Some("agent_loop") | Some("one_shot") => { + Some("agent") | Some("agent_loop") | Some("prompt") | Some("one_shot") => { let mut lines = Vec::new(); if let Some(usage) = &outcome.usage { let input = format_token_count(usage.input_tokens); @@ -234,7 +234,7 @@ fn render_summary_high_stage_section( } } } - Some("agent_loop") | Some("one_shot") => { + Some("agent") | Some("agent_loop") | Some("prompt") | Some("one_shot") => { if let Some(usage) = &outcome.usage { lines.push(format!("- Model: {}", usage.model)); lines.push(format!( @@ -497,7 +497,7 @@ fn build_summary_preamble( } } } - Some("agent_loop") | Some("one_shot") => { + Some("agent") | Some("agent_loop") | Some("prompt") | Some("one_shot") => { if let Some(usage) = &outcome.usage { parts.push(format!(" - Model: {}", usage.model)); } @@ -1098,7 +1098,7 @@ mod tests { ); assert!( - preamble.contains("Handler: agent_loop"), + preamble.contains("Handler: agent"), "should show handler type" ); assert!( @@ -1505,7 +1505,7 @@ mod tests { "should have stage heading" ); assert!( - preamble.contains("Handler: agent_loop"), + preamble.contains("Handler: agent"), "should show handler" ); assert!( diff --git a/crates/arc-workflows/src/validation/rules.rs b/crates/arc-workflows/src/validation/rules.rs index 51033e674..b947c1b05 100644 --- a/crates/arc-workflows/src/validation/rules.rs +++ b/crates/arc-workflows/src/validation/rules.rs @@ -384,8 +384,10 @@ struct TypeKnownRule; const KNOWN_HANDLER_TYPES: &[&str] = &[ "start", "exit", - "agent_loop", - "one_shot", + "agent", + "agent_loop", // legacy alias + "prompt", + "one_shot", // legacy alias "human", "conditional", "parallel", @@ -618,7 +620,7 @@ impl LintRule for PromptOnLlmNodesRule { fn apply(&self, graph: &Graph) -> Vec { let mut diagnostics = Vec::new(); for node in graph.nodes.values() { - if matches!(node.handler_type(), Some("agent_loop") | Some("one_shot")) { + if matches!(node.handler_type(), Some("agent") | Some("agent_loop") | Some("prompt") | Some("one_shot")) { let has_prompt = node.prompt().is_some_and(|p| !p.is_empty()); let has_label = node .attrs @@ -2033,7 +2035,7 @@ mod tests { let mut n1 = Node::new("n1"); n1.attrs.insert( "type".to_string(), - AttrValue::String("agent_loop".to_string()), + AttrValue::String("agent".to_string()), ); g.nodes.insert("n1".to_string(), n1); @@ -2075,17 +2077,17 @@ mod tests { assert!(d.is_empty()); } - // --- prompt_on_llm_nodes: explicit type=agent_loop without prompt/label --- + // --- prompt_on_llm_nodes: explicit type=agent without prompt/label --- #[test] - fn prompt_on_llm_nodes_rule_explicit_agent_loop_type_no_prompt() { + fn prompt_on_llm_nodes_rule_explicit_agent_type_no_prompt() { let mut g = minimal_graph(); let mut node = Node::new("work"); node.attrs.insert( "type".to_string(), - AttrValue::String("agent_loop".to_string()), + AttrValue::String("agent".to_string()), ); - // No shape=box, but explicit type=agent_loop + // No shape=box, but explicit type=agent node.attrs.insert( "shape".to_string(), AttrValue::String("diamond".to_string()), diff --git a/crates/arc-workflows/tests/integration.rs b/crates/arc-workflows/tests/integration.rs index 7d1bdc806..8b7e0abc6 100644 --- a/crates/arc-workflows/tests/integration.rs +++ b/crates/arc-workflows/tests/integration.rs @@ -166,7 +166,7 @@ fn make_linear_registry() -> HandlerRegistry { let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None))); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - registry.register("agent_loop", Box::new(AgentHandler::new(None))); + registry.register("agent", Box::new(AgentHandler::new(None))); registry } @@ -322,7 +322,7 @@ async fn end_to_end_branching_pipeline() { let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None))); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - registry.register("agent_loop", Box::new(AgentHandler::new(None))); + registry.register("agent", Box::new(AgentHandler::new(None))); registry.register("conditional", Box::new(ConditionalHandler)); let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env()); @@ -1248,7 +1248,7 @@ fn make_full_registry(interviewer: Arc) -> HandlerRegistry { let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None))); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - registry.register("agent_loop", Box::new(AgentHandler::new(None))); + registry.register("agent", Box::new(AgentHandler::new(None))); registry.register("conditional", Box::new(ConditionalHandler)); registry.register("command", Box::new(CommandHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); @@ -1368,7 +1368,7 @@ async fn smoke_test_with_mock_codergen_backend() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register( - "agent_loop", + "agent", Box::new(AgentHandler::new(Some(Box::new(MockCodergenBackend)))), ); registry.register("conditional", Box::new(ConditionalHandler)); @@ -1463,7 +1463,7 @@ async fn end_to_end_parallel_fan_out_fan_in() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register( - "agent_loop", + "agent", Box::new(AgentHandler::new(Some(Box::new(MockCodergenBackend)))), ); registry.register("parallel", Box::new(ParallelHandler)); @@ -2077,7 +2077,7 @@ async fn branching_loop_back_on_failure() { let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None))); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - registry.register("agent_loop", Box::new(AgentHandler::new(None))); + registry.register("agent", Box::new(AgentHandler::new(None))); registry.register( "fail_then_succeed", Box::new(FailThenSucceedHandler { @@ -2294,7 +2294,7 @@ async fn scenario_parallel_expert_review() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register( - "agent_loop", + "agent", Box::new(AgentHandler::new(Some(Box::new(MockCodergenBackend)))), ); registry.register("parallel", Box::new(ParallelHandler)); @@ -5615,7 +5615,7 @@ mod real_llm { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register( - "agent_loop", + "agent", Box::new(AgentHandler::new(Some(make_llm_backend( make_llm_client().await.unwrap(), )))), @@ -5729,7 +5729,7 @@ mod real_llm { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register( - "agent_loop", + "agent", Box::new(AgentHandler::new(Some(make_llm_backend(client)))), ); @@ -5867,7 +5867,7 @@ mod real_llm { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register( - "agent_loop", + "agent", Box::new(AgentHandler::new(Some(make_llm_backend(client)))), ); registry.register("human", Box::new(HumanHandler::new(interviewer))); @@ -5971,7 +5971,7 @@ mod real_llm { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register( - "one_shot", + "prompt", Box::new(arc_workflows::handler::prompt::PromptHandler::new(Some( make_llm_backend(client), ))), @@ -8024,7 +8024,7 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { registry.register("exit", Box::new(ExitHandler)); registry.register("command", Box::new(CommandHandler)); registry.register( - "agent_loop", + "agent", Box::new(AgentHandler::new(Some(Box::new(MockCodergenBackend)))), ); @@ -9378,9 +9378,9 @@ async fn full_pipeline_with_cli_backend_node() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register( - "agent_loop", + "agent", Box::new(AgentHandler::new(Some(Box::new({ - // Second BackendRouter for the "agent_loop" handler + // Second BackendRouter for the "agent" handler let api2 = MockCodergenBackend; let cli2 = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic); BackendRouter::new(Box::new(api2), cli2) @@ -9510,7 +9510,7 @@ async fn stylesheet_backend_property_routes_to_cli() { let cli2 = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic); let router2 = BackendRouter::new(Box::new(api2), cli2); registry.register( - "agent_loop", + "agent", Box::new(AgentHandler::new(Some(Box::new(router2)))), );