Skip to content

Add auto gate handler for hexagon nodes#1

Open
gabehamilton wants to merge 1 commit intomainfrom
gabehamilton/auto-gate-handler
Open

Add auto gate handler for hexagon nodes#1
gabehamilton wants to merge 1 commit intomainfrom
gabehamilton/auto-gate-handler

Conversation

@gabehamilton
Copy link
Copy Markdown

Summary

  • Adds AutoGateHandler for hexagon nodes with auto_policy attribute, enabling automated review loops without human interaction
  • Routes hexagon nodes with auto_policy to auto.gate handler instead of wait.human
  • Supports findings_empty policy (pass when findings_count is 0) and max_iterations cap (force pass when exceeded)

Test plan

  • Unit tests for findings edge routing on first iteration
  • Unit tests for max_iterations force-pass
  • Unit tests for findings_empty with zero count
  • Unit tests for iteration counter incrementing
  • Registry resolution tests for hexagon with/without auto_policy

🤖 Generated with Claude Code

Hexagon nodes with an `auto_policy` attribute (e.g. `auto_policy="findings_empty"`)
now route to the new `auto.gate` handler instead of `wait.human`. This enables
automated review loops that cycle based on findings count and respect
`max_iterations` caps, without requiring human interaction at every gate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the AutoGateHandler, a new node handler for automatic gate nodes in the graph. It supports iteration limits and policy-based routing (e.g., findings_empty). The feedback identifies several critical issues: the parsing of max_iterations is fragile and doesn't handle string values from DOT files; the logic for falling back when specific edges (like 'pass' or 'findings') are missing results in empty suggested_next_ids, which would prematurely terminate the graph execution; and the edge label matching is overly permissive, potentially leading to incorrect routing if labels contain substrings like 'bypass'.

Comment on lines +30 to +34
let max_iterations = node
.attrs
.get("max_iterations")
.and_then(|v| v.as_i64())
.unwrap_or(5) as usize;
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);

Comment on lines +91 to +96
return Ok(NodeOutcome {
status: NodeStatus::Success,
context_updates: updates,
notes: Some(format!("{reason} (no pass edge found)")),
..Default::default()
});
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()
            });

Comment on lines +117 to +122
Ok(NodeOutcome {
status: NodeStatus::Success,
context_updates: updates,
notes: Some("auto gate: no findings edge, defaulting to pass".to_string()),
..Default::default()
})
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()
            })

Comment on lines +155 to +163
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")
}
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")
}

@gabehamilton
Copy link
Copy Markdown
Author

@claude address pr review comments

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant