Rename handler types: agent_loop → agent, one_shot → prompt

Keep legacy aliases (agent_loop, one_shot) in the handler registry
and validation rules for backwards compatibility. Add codergen_mode
attribute support in the DOT parser, translating legacy values to
the new type names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-05 07:44:33 -05:00
parent 4bb072884c
commit 059a50e1b3
13 changed files with 151 additions and 50 deletions

View file

@ -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
}

View file

@ -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 {

View file

@ -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,
});

View file

@ -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

View file

@ -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"));

View file

@ -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<dyn Interviewer>,
@ -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())),

View file

@ -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);

View file

@ -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,

View file

@ -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"));
}
}

View file

@ -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")
);
}
}

View file

@ -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!(

View file

@ -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<Diagnostic> {
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()),

View file

@ -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<dyn Interviewer>) -> 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)))),
);