Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 290 additions & 0 deletions crates/forge-attractor/src/handlers/auto_gate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
use crate::{
AttractorError, Graph, Node, NodeOutcome, NodeStatus, RuntimeContext, handlers::NodeHandler,
};
use async_trait::async_trait;
use serde_json::Value;

/// Handler for automatic gate nodes (hexagon shape with `auto_policy` attribute).
///
/// Supports:
/// - `max_iterations` — hard cap on how many times the gate's loop can cycle.
/// When exceeded, the gate forces the "pass" edge.
/// - `auto_policy="findings_empty"` — routes to the "pass" edge when no findings
/// are detected in the runtime context, otherwise routes to the "findings" edge.
///
/// Iteration count is tracked via a context variable `gate.<node_id>.iterations`.
///
/// Edge detection uses the `[P]` / `[F]` accelerator-key convention from the DOT
/// edge labels (e.g. `[P] Pass`, `[F] Findings`).
#[derive(Debug, Default)]
pub struct AutoGateHandler;

#[async_trait]
impl NodeHandler for AutoGateHandler {
async fn execute(
&self,
node: &Node,
context: &RuntimeContext,
graph: &Graph,
) -> Result<NodeOutcome, AttractorError> {
let max_iterations = node
.attrs
.get("max_iterations")
.and_then(|v| v.as_i64())
.unwrap_or(5) as usize;
Comment on lines +30 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current parsing of max_iterations only handles integer attributes. If the attribute is provided as a string in the DOT file (e.g., max_iterations="10"), as_i64() will return None, causing it to default to 5. Additionally, using as usize on a potentially negative i64 can lead to unexpected behavior. It's safer to use to_string_value().parse() which handles both numeric and string representations robustly.

Suggested change
let max_iterations = node
.attrs
.get("max_iterations")
.and_then(|v| v.as_i64())
.unwrap_or(5) as usize;
let max_iterations = node
.attrs
.get("max_iterations")
.map(|v| v.to_string_value().parse::<usize>().unwrap_or(5))
.unwrap_or(5);


let iteration_key = format!("gate.{}.iterations", node.id);
let current = context
.get(&iteration_key)
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let next = current + 1;

let edges: Vec<_> = graph.outgoing_edges(&node.id).collect();
let pass_edge = edges.iter().find(|e| {
let label = e.attrs.get_str("label").unwrap_or("");
label_is_pass(label)
});
let findings_edge = edges.iter().find(|e| {
let label = e.attrs.get_str("label").unwrap_or("");
label_is_findings(label)
});

let force_pass = next > max_iterations;
let policy_pass = if !force_pass {
evaluate_policy(node, context)
} else {
false // doesn't matter, force_pass overrides
};

let should_pass = force_pass || policy_pass;

let mut updates = RuntimeContext::new();
updates.insert(iteration_key, Value::Number(next.into()));

if should_pass {
let reason = if force_pass {
format!(
"auto gate: max_iterations reached ({}/{}), forcing pass",
next, max_iterations
)
} else {
format!(
"auto gate: policy satisfied (iteration {}/{})",
next, max_iterations
)
};

if let Some(edge) = pass_edge {
return Ok(NodeOutcome {
status: NodeStatus::Success,
preferred_label: Some(
edge.attrs.get_str("label").unwrap_or("").to_string(),
),
suggested_next_ids: vec![edge.to.clone()],
context_updates: updates,
notes: Some(reason),
..Default::default()
});
}
// No pass edge found — fall through to first edge
return Ok(NodeOutcome {
status: NodeStatus::Success,
context_updates: updates,
notes: Some(format!("{reason} (no pass edge found)")),
..Default::default()
});
Comment on lines +91 to +96
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment indicates a fallback to the first edge if no specific 'pass' edge is found, but the implementation returns a NodeOutcome with an empty suggested_next_ids list. This will cause the graph execution to stop unexpectedly at this node. The fallback should explicitly select the first available edge to maintain graph flow.

            let edge = edges.first().ok_or_else(|| AttractorError::Runtime("auto gate: no outgoing edges".to_string()))?;
            return Ok(NodeOutcome {
                status: NodeStatus::Success,
                preferred_label: Some(edge.attrs.get_str("label").unwrap_or("").to_string()),
                suggested_next_ids: vec![edge.to.clone()],
                context_updates: updates,
                notes: Some(format!("{reason} (no pass edge found, falling back to first edge)")),
                ..Default::default()
            });

}

// Policy says loop: take the findings edge
let reason = format!(
"auto gate: findings detected (iteration {}/{})",
next, max_iterations
);
if let Some(edge) = findings_edge {
Ok(NodeOutcome {
status: NodeStatus::Success,
preferred_label: Some(
edge.attrs.get_str("label").unwrap_or("").to_string(),
),
suggested_next_ids: vec![edge.to.clone()],
context_updates: updates,
notes: Some(reason),
..Default::default()
})
} else {
// No findings edge — pass by default
Ok(NodeOutcome {
status: NodeStatus::Success,
context_updates: updates,
notes: Some("auto gate: no findings edge, defaulting to pass".to_string()),
..Default::default()
})
Comment on lines +117 to +122
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When no findings edge is found, the handler should still provide a next node ID to continue execution. Currently, it returns an empty suggested_next_ids list, which effectively terminates the pipeline. It should fall back to the pass_edge or the first available edge.

            let edge = pass_edge.or_else(|| edges.first()).ok_or_else(|| AttractorError::Runtime("auto gate: no outgoing edges".to_string()))?;
            Ok(NodeOutcome {
                status: NodeStatus::Success,
                preferred_label: Some(edge.attrs.get_str("label").unwrap_or("").to_string()),
                suggested_next_ids: vec![edge.to.clone()],
                context_updates: updates,
                notes: Some("auto gate: no findings edge, defaulting to pass".to_string()),
                ..Default::default()
            })

}
}
}

/// Evaluate the `auto_policy` attribute on the node.
/// Returns `true` if the gate should pass (route to the pass edge).
fn evaluate_policy(node: &Node, context: &RuntimeContext) -> bool {
let policy = match node.attrs.get_str("auto_policy") {
Some(p) => p,
None => return false, // no policy → default to loop
};

match policy {
"findings_empty" => {
// Check for a context variable that signals findings.
// Convention: the immediately preceding stage can set
// `<stage>.findings_count` or `findings_count` to 0.
let findings_count = context
.get("findings_count")
.and_then(|v| v.as_u64());
match findings_count {
Some(0) => true, // explicitly zero findings → pass
Some(_) => false, // non-zero findings → loop
None => false, // no signal → assume findings exist, loop
}
}
"always_pass" => true,
"always_loop" => false,
_ => false, // unknown policy → default to loop
}
}

fn label_is_pass(label: &str) -> bool {
let lower = label.to_ascii_lowercase();
lower.contains("[p]") || lower.contains("pass")
}

fn label_is_findings(label: &str) -> bool {
let lower = label.to_ascii_lowercase();
lower.contains("[f]") || lower.contains("findings") || lower.contains("finding")
}
Comment on lines +155 to +163
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using contains for label matching is too permissive and can lead to incorrect routing. For example, label_is_pass would match a label like "bypass", and label_is_findings would match "no findings". It is better to check for the specific [P] / [F] prefix or perform exact word matching to ensure the correct edge is selected.

Suggested change
fn label_is_pass(label: &str) -> bool {
let lower = label.to_ascii_lowercase();
lower.contains("[p]") || lower.contains("pass")
}
fn label_is_findings(label: &str) -> bool {
let lower = label.to_ascii_lowercase();
lower.contains("[f]") || lower.contains("findings") || lower.contains("finding")
}
fn label_is_pass(label: &str) -> bool {
let lower = label.to_ascii_lowercase();
lower.contains("[p]") || lower == "pass" || lower.starts_with("pass ")
}
fn label_is_findings(label: &str) -> bool {
let lower = label.to_ascii_lowercase();
lower.contains("[f]") || lower.starts_with("finding")
}


#[cfg(test)]
mod tests {
use super::*;
use crate::parse_dot;

#[tokio::test(flavor = "current_thread")]
async fn auto_gate_first_iteration_with_findings_expected_findings_edge() {
let graph = parse_dot(
r#"
digraph G {
gate [shape=hexagon, auto_policy="findings_empty", max_iterations=5]
fix
pass
gate -> fix [label="[F] Findings"]
gate -> pass [label="[P] Pass"]
}
"#,
)
.expect("graph should parse");
let node = graph.nodes.get("gate").expect("gate should exist");
let context = RuntimeContext::new();

let outcome = AutoGateHandler
.execute(node, &context, &graph)
.await
.expect("execution should succeed");

assert_eq!(outcome.status, NodeStatus::Success);
assert_eq!(outcome.suggested_next_ids, vec!["fix".to_string()]);
assert!(outcome.notes.unwrap().contains("findings detected"));
}

#[tokio::test(flavor = "current_thread")]
async fn auto_gate_max_iterations_exceeded_expected_pass_edge() {
let graph = parse_dot(
r#"
digraph G {
gate [shape=hexagon, auto_policy="findings_empty", max_iterations=3]
fix
pass
gate -> fix [label="[F] Findings"]
gate -> pass [label="[P] Pass"]
}
"#,
)
.expect("graph should parse");
let node = graph.nodes.get("gate").expect("gate should exist");
let mut context = RuntimeContext::new();
// Simulate 3 prior iterations
context.insert(
"gate.gate.iterations".to_string(),
Value::Number(3.into()),
);

let outcome = AutoGateHandler
.execute(node, &context, &graph)
.await
.expect("execution should succeed");

assert_eq!(outcome.status, NodeStatus::Success);
assert_eq!(outcome.suggested_next_ids, vec!["pass".to_string()]);
assert!(outcome.notes.unwrap().contains("max_iterations reached"));
}

#[tokio::test(flavor = "current_thread")]
async fn auto_gate_findings_empty_with_zero_count_expected_pass() {
let graph = parse_dot(
r#"
digraph G {
gate [shape=hexagon, auto_policy="findings_empty", max_iterations=5]
fix
pass
gate -> fix [label="[F] Findings"]
gate -> pass [label="[P] Pass"]
}
"#,
)
.expect("graph should parse");
let node = graph.nodes.get("gate").expect("gate should exist");
let mut context = RuntimeContext::new();
context.insert("findings_count".to_string(), Value::Number(0.into()));

let outcome = AutoGateHandler
.execute(node, &context, &graph)
.await
.expect("execution should succeed");

assert_eq!(outcome.status, NodeStatus::Success);
assert_eq!(outcome.suggested_next_ids, vec!["pass".to_string()]);
assert!(outcome.notes.unwrap().contains("policy satisfied"));
}

#[tokio::test(flavor = "current_thread")]
async fn auto_gate_increments_iteration_count() {
let graph = parse_dot(
r#"
digraph G {
gate [shape=hexagon, auto_policy="findings_empty", max_iterations=5]
fix
pass
gate -> fix [label="[F] Findings"]
gate -> pass [label="[P] Pass"]
}
"#,
)
.expect("graph should parse");
let node = graph.nodes.get("gate").expect("gate should exist");
let mut context = RuntimeContext::new();
context.insert(
"gate.gate.iterations".to_string(),
Value::Number(1.into()),
);

let outcome = AutoGateHandler
.execute(node, &context, &graph)
.await
.expect("execution should succeed");

let updated = outcome
.context_updates
.get("gate.gate.iterations")
.and_then(|v| v.as_u64())
.expect("iteration count should exist");
assert_eq!(updated, 2);
}
}
2 changes: 2 additions & 0 deletions crates/forge-attractor/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::{AttractorError, Graph, Node, NodeOutcome, RuntimeContext};
use async_trait::async_trait;
use std::sync::Arc;

pub mod auto_gate;
pub mod codergen;
pub mod conditional;
pub mod exit;
Expand Down Expand Up @@ -55,6 +56,7 @@ pub fn core_registry_with_codergen_backend(
Arc::new(codergen::CodergenHandler::new(backend)),
);
registry.register_type("conditional", Arc::new(conditional::ConditionalHandler));
registry.register_type("auto.gate", Arc::new(auto_gate::AutoGateHandler));
registry.register_type(
"wait.human",
Arc::new(wait_human::WaitHumanHandler::new(Arc::new(
Expand Down
20 changes: 20 additions & 0 deletions crates/forge-attractor/src/handlers/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ impl HandlerRegistry {
}

let shape = node.attrs.get_str("shape").unwrap_or("box");

// Hexagons with auto_policy use the auto.gate handler instead of wait.human
if shape == "hexagon" && node.attrs.get_str("auto_policy").is_some() {
return "auto.gate".to_string();
}

self.shape_to_type
.get(shape)
.cloned()
Expand Down Expand Up @@ -161,6 +167,20 @@ mod tests {
assert_eq!(registry.resolve_handler_type(&node), "wait.human");
}

#[test]
fn resolve_handler_type_hexagon_with_auto_policy_expected_auto_gate() {
let registry = HandlerRegistry::new();
let node = node_with_attrs("shape=hexagon, auto_policy=\"findings_empty\"");
assert_eq!(registry.resolve_handler_type(&node), "auto.gate");
}

#[test]
fn resolve_handler_type_hexagon_without_auto_policy_expected_wait_human() {
let registry = HandlerRegistry::new();
let node = node_with_attrs("shape=hexagon");
assert_eq!(registry.resolve_handler_type(&node), "wait.human");
}

#[test]
fn resolve_handler_type_unknown_shape_expected_default_handler_type() {
let registry = HandlerRegistry::new();
Expand Down
Loading