feat(workflow): infer command nodes from the script attribute

A node with no `shape` defaulted to `box`, which resolves to the agent
handler. That made a shapeless `script` node run as an LLM call prompted
with its own label, while the `script` was reported as inert — wrong
behavior behind a warning.

`script` is read by the command handler and by nothing else, so a
shapeless node that sets it is unambiguously a command node. `shape()`
now infers `parallelogram` in that case. An explicit `shape` still wins.

Two rules keep the inference honest:

- `script_prompt_conflict` — setting both `script` and `prompt` is an
  error. No handler reads both. It fires regardless of shape so that
  adding one cannot downgrade the error to a warning.
- `command_requires_script` — a command node without a script is an
  error. Without this the original trap just moves: a node meant as a
  command that omits its script silently becomes an agent again.

Also drops the `tool_command` alias in favor of `script` alone, routing
the six read sites through a new `Node::script()` accessor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Release Repro 2026-07-27 14:59:31 -04:00
parent 6efba896f4
commit 69a51e65b9
No known key found for this signature in database
14 changed files with 419 additions and 95 deletions

View file

@ -161,7 +161,7 @@ Each node's `shape` attribute determines its execution behavior. See [Nodes & St
| `Msquare` | exit | Workflow terminal (exactly one required) |
| `box` (default) | agent | Multi-turn LLM with tool access |
| `tab` | prompt | Single LLM call, no tools |
| `parallelogram` | command | Execute a shell script |
| `parallelogram` (inferred from `script`) | command | Execute a shell script |
| `hexagon` | human | Human-in-the-loop decision gate |
| `diamond` | conditional | Route based on conditions |
| `component` | parallel | Fan-out to concurrent branches |
@ -173,6 +173,19 @@ The `type` attribute can also be set explicitly to override the shape-based mapp
Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be identified by ID (`exit`, `Exit`, `end`, or `End`).
### Omitting the shape
The two most common node types don't need a `shape`. A node that sets `script` is a command node; every other shapeless node is an agent node:
```dot
plan [prompt="Plan the work"] // agent
build [script="cargo build"] // command
```
An explicit `shape` always wins, so `box` with a `script` is still an agent node — and the `script` is then read by nothing. Setting both `script` and `prompt` on one node is an error: no handler reads both.
Other node types still need their shape, because their attributes don't identify them uniquely. In particular, agent and prompt nodes take the same attributes, so a prompt node needs `shape=tab`.
## Node attributes
### All nodes
@ -244,7 +257,7 @@ audit [
| Attribute | Type | Description |
|---|---|---|
| `script` | String | Shell command to execute |
| `script` | String | Shell command to execute (required). Its presence also makes `shape=parallelogram` optional. |
| `language` | String | `"shell"` (default) or `"python"` |
| `output_schema` | String | Optional structured output validation. Accepts `routing`, `@path/to/schema.json`, or an inline JSON Schema object string. See [Structured output validation](#structured-output-validation). |

View file

@ -35,7 +35,7 @@ exit [shape=Msquare, label="Exit"]
### Agent
**Shape:** `box` (default)
**Shape:** `box` (default — a node with no shape and no `script` is an agent node)
Runs an LLM with access to tools — bash, file editing, sub-agents — in an agentic loop. The agent works autonomously, calling tools as needed, until it decides the task is complete.
@ -94,17 +94,19 @@ Prompt nodes accept the same attributes as agent nodes (`prompt`, `reasoning_eff
### Command
**Shape:** `parallelogram`
**Shape:** `parallelogram` (optional — inferred from `script`)
Runs a shell script inside the configured sandbox and captures its output. The output is available to downstream nodes as context. This ensures command nodes execute in the same environment as agent nodes.
```dot
test [label="Run Tests", shape=parallelogram, script="cargo test 2>&1 || true"]
test [label="Run Tests", script="cargo test 2>&1 || true"]
```
`script` is what makes a node a command node, so the shape can be left off. Writing `shape=parallelogram` explicitly is still valid and does the same thing.
| Attribute | Description |
|---|---|
| `script` | The shell command to execute |
| `script` | The shell command to execute (required) |
| `language` | `"shell"` (default) or `"python"` |
### Human

View file

@ -135,17 +135,17 @@ fn styled() {
}
#[test]
fn legacy_tool() {
fn inferred_command() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("legacy_tool.fabro"));
cmd.arg(fixture("inferred_command.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: LegacyTool (3 nodes, 2 edges)
Graph: [FIXTURES]/legacy_tool.fabro
Workflow: InferredCommand (3 nodes, 2 edges)
Graph: [FIXTURES]/inferred_command.fabro
Validation: OK
");
}

View file

@ -148,9 +148,9 @@ fn dry_run_styled() {
}
#[test]
fn dry_run_legacy_tool() {
fn dry_run_inferred_command() {
let context = test_context!();
let workflow = context.install_fixture("legacy_tool.fabro");
let workflow = context.install_fixture("inferred_command.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(&workflow);
@ -159,9 +159,9 @@ fn dry_run_legacy_tool() {
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: LegacyTool (3 nodes, 2 edges)
Workflow: InferredCommand (3 nodes, 2 edges)
Graph: [GRAPH_PATH]
Goal: Verify backwards compatibility with old tool naming
Goal: Verify a shapeless script node runs as a command
Run: [ULID]
Web UI: http://localhost:3000/runs/[ULID]

View file

@ -0,0 +1,131 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"command_requires_script"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if node.handler_type() != Some("command") {
continue;
}
if node.script().is_some_and(|s| !s.trim().is_empty()) {
continue;
}
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!("Command node '{}' has no 'script' to run", node.id),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Add a 'script' attribute, or remove the command shape or type if this was \
meant to be an agent node"
.to_string(),
),
..Diagnostic::default()
});
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
fn node_with(id: &str, attrs: &[(&str, &str)]) -> Node {
let mut node = Node::new(id);
for (key, value) in attrs {
node.attrs
.insert((*key).to_string(), AttrValue::String((*value).to_string()));
}
node
}
#[test]
fn errors_on_command_shape_without_script() {
let mut g = minimal_graph();
g.nodes.insert(
"run".to_string(),
node_with("run", &[("shape", "parallelogram"), ("label", "Build")]),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert!(d[0].message.contains("no 'script'"));
assert_eq!(d[0].node_id.as_deref(), Some("run"));
}
#[test]
fn errors_on_explicit_command_type_without_script() {
let mut g = minimal_graph();
g.nodes
.insert("run".to_string(), node_with("run", &[("type", "command")]));
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn errors_on_blank_script() {
let mut g = minimal_graph();
g.nodes.insert(
"run".to_string(),
node_with("run", &[("shape", "parallelogram"), ("script", " ")]),
);
assert_eq!(Rule.apply(&g).len(), 1);
}
#[test]
fn accepts_command_node_with_script() {
let mut g = minimal_graph();
g.nodes.insert(
"run".to_string(),
node_with("run", &[
("shape", "parallelogram"),
("script", "cargo build"),
]),
);
g.nodes.insert(
"build".to_string(),
node_with("build", &[("script", "cargo build")]),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn ignores_non_command_nodes() {
let mut g = minimal_graph();
g.nodes.insert(
"plan".to_string(),
node_with("plan", &[("prompt", "do it")]),
);
g.nodes.insert(
"gate".to_string(),
node_with("gate", &[("shape", "hexagon")]),
);
assert!(Rule.apply(&g).is_empty());
}
}

View file

@ -1,5 +1,6 @@
mod all_conditional_edges;
mod backend_valid;
mod command_requires_script;
mod condition_syntax;
mod direction_valid;
mod edge_target_exists;
@ -21,6 +22,7 @@ mod reachability;
mod reserved_keyword_node_id;
mod retry_target_exists;
mod script_absolute_cd;
mod script_prompt_conflict;
mod selection_valid;
mod start_no_incoming;
mod start_node;
@ -59,6 +61,8 @@ pub fn built_in_rules() -> Vec<Box<dyn LintRule>> {
all_conditional_edges::rule(),
orphan_custom_outcome::rule(),
script_absolute_cd::rule(),
script_prompt_conflict::rule(),
command_requires_script::rule(),
import_error::rule(),
join_policy_removed::rule(),
unresolved_file_ref::rule(),

View file

@ -40,12 +40,7 @@ impl LintRule for Rule {
if node.handler_type() != Some("command") {
continue;
}
let script = node
.attrs
.get("script")
.or_else(|| node.attrs.get("tool_command"))
.and_then(|v| v.as_str())
.unwrap_or("");
let script = node.script().unwrap_or("");
if contains_cd_absolute(script) {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
@ -114,7 +109,22 @@ mod tests {
}
#[test]
fn script_absolute_cd_warns_on_legacy_tool_command() {
fn script_absolute_cd_warns_on_shapeless_script_node() {
let mut g = minimal_graph();
let mut node = Node::new("run");
node.attrs.insert(
"script".to_string(),
AttrValue::String("cd /home/user && make".to_string()),
);
g.nodes.insert("run".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
#[test]
fn script_absolute_cd_ignores_legacy_tool_command() {
let mut g = minimal_graph();
let mut node = Node::new("run");
node.attrs.insert(
@ -127,9 +137,7 @@ mod tests {
);
g.nodes.insert("run".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(rule.apply(&g).is_empty());
}
#[test]

View file

@ -0,0 +1,135 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"script_prompt_conflict"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if node.script().is_none() || node.prompt().is_none() {
continue;
}
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"Node '{}' sets both 'script' and 'prompt'. No node type reads both: \
'script' selects the command handler and 'prompt' selects an LLM handler",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Remove whichever attribute is wrong, or split the node into a command node \
and an agent node"
.to_string(),
),
..Diagnostic::default()
});
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
fn node_with(id: &str, attrs: &[(&str, &str)]) -> Node {
let mut node = Node::new(id);
for (key, value) in attrs {
node.attrs
.insert((*key).to_string(), AttrValue::String((*value).to_string()));
}
node
}
#[test]
fn errors_when_shapeless_node_sets_both() {
let mut g = minimal_graph();
g.nodes.insert(
"work".to_string(),
node_with("work", &[("script", "cargo build"), ("prompt", "do it")]),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert!(d[0].message.contains("'script'"));
assert!(d[0].message.contains("'prompt'"));
assert_eq!(d[0].node_id.as_deref(), Some("work"));
}
#[test]
fn errors_even_when_shape_is_explicit() {
// An explicit shape resolves the handler, but the unread attribute is
// still a mistake, and reporting it the same way everywhere keeps
// adding a shape from turning an error into a warning.
let mut g = minimal_graph();
g.nodes.insert(
"run".to_string(),
node_with("run", &[
("shape", "parallelogram"),
("script", "cargo build"),
("prompt", "do it"),
]),
);
g.nodes.insert(
"plan".to_string(),
node_with("plan", &[
("shape", "box"),
("script", "cargo build"),
("prompt", "do it"),
]),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 2);
assert!(d.iter().all(|d| d.severity == Severity::Error));
}
#[test]
fn accepts_either_attribute_alone() {
let mut g = minimal_graph();
g.nodes.insert(
"build".to_string(),
node_with("build", &[("script", "cargo build")]),
);
g.nodes.insert(
"plan".to_string(),
node_with("plan", &[("prompt", "do it")]),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn ignores_legacy_tool_command_attribute() {
let mut g = minimal_graph();
g.nodes.insert(
"work".to_string(),
node_with("work", &[
("tool_command", "cargo build"),
("prompt", "do it"),
]),
);
assert!(Rule.apply(&g).is_empty());
}
}

View file

@ -31,12 +31,7 @@ impl Handler for CommandHandler {
_run_dir: &Path,
_services: &EngineServices,
) -> Result<Outcome, Error> {
let script = node
.attrs
.get("script")
.or_else(|| node.attrs.get("tool_command"))
.and_then(|v| v.as_str())
.unwrap_or("");
let script = node.script().unwrap_or("");
let mut outcome = Outcome::simulated(&node.id);
outcome.notes = Some(format!("[Simulated] Command skipped: {script}"));
@ -54,12 +49,7 @@ impl Handler for CommandHandler {
run_dir: &Path,
services: &EngineServices,
) -> Result<Outcome, Error> {
let script = node
.attrs
.get("script")
.or_else(|| node.attrs.get("tool_command"))
.and_then(|v| v.as_str())
.unwrap_or("");
let script = node.script().unwrap_or("");
if script.is_empty() {
return Ok(Outcome::fail_classify("No script specified"));
@ -1264,7 +1254,7 @@ mod tests {
}
#[tokio::test]
async fn tool_command_attribute_fallback() {
async fn tool_command_attribute_is_not_read() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
@ -1280,13 +1270,10 @@ mod tests {
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
let command_output = outcome.context_updates.get(keys::COMMAND_OUTPUT).unwrap();
assert!(
command_text(&services, command_output)
.await
.contains("legacy")
);
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
assert!(outcome.failure_reason().unwrap().contains("No script"));
}
#[tokio::test]

View file

@ -161,15 +161,8 @@ fn render_compact_stage_details(
match handler {
Some("command") => {
let mut lines = Vec::new();
if let Some(n) = node {
if let Some(cmd) = n
.attrs
.get("script")
.or_else(|| n.attrs.get("tool_command"))
.and_then(|v| v.as_str())
{
lines.push(format!(" - Script: `{cmd}`"));
}
if let Some(cmd) = node.and_then(Node::script) {
lines.push(format!(" - Script: `{cmd}`"));
}
if let Some(output_val) = outcome.context_updates.get(keys::COMMAND_OUTPUT) {
let output = format_value(output_val);
@ -215,15 +208,8 @@ fn render_summary_high_stage_section(
match handler {
Some("command") => {
if let Some(n) = node {
if let Some(cmd) = n
.attrs
.get("script")
.or_else(|| n.attrs.get("tool_command"))
.and_then(|v| v.as_str())
{
lines.push(format!("- Script: `{cmd}`"));
}
if let Some(cmd) = node.and_then(Node::script) {
lines.push(format!("- Script: `{cmd}`"));
}
if let Some(output_val) = outcome.context_updates.get(keys::COMMAND_OUTPUT) {
if let Some(path) = artifact_path(output_val) {
@ -531,15 +517,8 @@ fn build_summary_preamble(
}
match handler {
Some("command") => {
if let Some(n) = node {
if let Some(cmd) = n
.attrs
.get("script")
.or_else(|| n.attrs.get("tool_command"))
.and_then(|v| v.as_str())
{
parts.push(format!(" - Script: `{cmd}`"));
}
if let Some(cmd) = node.and_then(Node::script) {
parts.push(format!(" - Script: `{cmd}`"));
}
}
h if is_llm_handler_type(h) => {

View file

@ -153,9 +153,21 @@ impl Node {
self.str_attr("label").unwrap_or(&self.id)
}
/// The node's Graphviz shape, which selects its handler.
///
/// An explicit `shape` attribute always wins. Otherwise the shape is
/// inferred from the attributes the node carries: `script` is read by the
/// command handler and by nothing else, so a shapeless node that sets it
/// is a command node. Everything else falls back to `box` (agent).
#[must_use]
pub fn shape(&self) -> &str {
self.str_attr("shape").unwrap_or("box")
if let Some(shape) = self.str_attr("shape") {
return shape;
}
if self.script().is_some() {
return "parallelogram";
}
"box"
}
#[must_use]
@ -168,6 +180,12 @@ impl Node {
self.str_attr("prompt")
}
/// The shell or Python source a command node runs.
#[must_use]
pub fn script(&self) -> Option<&str> {
self.str_attr("script")
}
#[must_use]
pub fn output_schema(&self) -> Option<&str> {
self.str_attr("output_schema")
@ -606,6 +624,53 @@ mod tests {
assert!(node.project_memory());
}
fn node_with(id: &str, attrs: &[(&str, &str)]) -> Node {
let mut node = Node::new(id);
for (key, value) in attrs {
node.attrs
.insert((*key).to_string(), AttrValue::String((*value).to_string()));
}
node
}
#[test]
fn shapeless_script_node_infers_command() {
let node = node_with("build", &[("script", "cargo build")]);
assert_eq!(node.shape(), "parallelogram");
assert_eq!(node.handler_type(), Some("command"));
}
#[test]
fn shapeless_node_without_script_stays_agent() {
let node = node_with("plan", &[("prompt", "Plan the work")]);
assert_eq!(node.shape(), "box");
assert_eq!(node.handler_type(), Some("agent"));
}
#[test]
fn explicit_shape_wins_over_script_inference() {
let node = node_with("odd", &[("shape", "box"), ("script", "cargo build")]);
assert_eq!(node.shape(), "box");
assert_eq!(node.handler_type(), Some("agent"));
}
#[test]
fn explicit_type_wins_over_script_inference() {
let node = node_with("odd", &[("type", "agent"), ("script", "cargo build")]);
assert_eq!(node.shape(), "parallelogram");
assert_eq!(node.handler_type(), Some("agent"));
}
#[test]
fn empty_script_still_infers_command() {
// The command-requires-script lint reports this; inference only asks
// whether the attribute is present so the diagnostic lands on a
// command node rather than a silently-agent one.
let node = node_with("build", &[("script", "")]);
assert_eq!(node.shape(), "parallelogram");
assert_eq!(node.handler_type(), Some("command"));
}
#[test]
fn node_project_memory_false_overrides_default() {
let mut node = Node::new("x");

View file

@ -43,11 +43,11 @@ digraph reference_template {
start [shape=Mdiamond, label="Start"]
// Toolchain gate — fail fast before LLM stages.
// Replace tool_command with project-specific checks.
// Replace script with project-specific checks.
check_toolchain [
shape=parallelogram,
max_retries=0,
tool_command="echo 'Replace with project-specific toolchain checks'; exit 0"
script="echo 'Replace with project-specific toolchain checks'; exit 0"
]
// PROMPT: expand_spec
@ -187,42 +187,42 @@ digraph reference_template {
check_implement [shape=diamond, label="Implement OK?"]
// Auto-fix formatting before verify gate.
// Replace tool_command with project-specific auto-formatter.
// Replace script with project-specific auto-formatter.
fix_fmt [
shape=parallelogram,
max_retries=0,
tool_command="echo 'Replace with project-specific auto-formatter'; exit 0"
script="echo 'Replace with project-specific auto-formatter'; exit 0"
]
// Replace tool_command with project-specific formatter check.
// Replace script with project-specific formatter check.
verify_fmt [
shape=parallelogram,
max_retries=0,
tool_command="echo 'Replace with project-specific formatter check'; exit 0"
script="echo 'Replace with project-specific formatter check'; exit 0"
]
check_fmt [shape=diamond, label="Fmt OK?"]
// Replace tool_command with project-specific build command.
// Replace script with project-specific build command.
verify_build [
shape=parallelogram,
tool_command="echo 'Replace with project-specific build check'; exit 0"
script="echo 'Replace with project-specific build check'; exit 0"
]
check_build [shape=diamond, label="Build OK?"]
// Replace tool_command with project-specific test command.
// Replace script with project-specific test command.
verify_test [
shape=parallelogram,
tool_command="echo 'Replace with project-specific test check'; exit 0"
script="echo 'Replace with project-specific test check'; exit 0"
]
check_test [shape=diamond, label="Tests OK?"]
// Replace tool_command with artifact hygiene check.
// Replace script with artifact hygiene check.
// Confirm deliverables meet their interface contract (exports, endpoints,
// CLI behavior, observable outputs); file existence alone is insufficient.
verify_artifacts [
shape=parallelogram,
max_retries=0,
tool_command="echo 'Replace with artifact hygiene check'; exit 0"
script="echo 'Replace with artifact hygiene check'; exit 0"
]
check_artifacts [shape=diamond, label="Artifacts OK?"]

View file

@ -0,0 +1,11 @@
digraph InferredCommand {
graph [goal="Verify a shapeless script node runs as a command"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
echo_task [label="Echo", script="echo hello"]
start -> echo_task -> exit
}

View file

@ -1,11 +0,0 @@
digraph LegacyTool {
graph [goal="Verify backwards compatibility with old tool naming"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
echo_task [label="Echo", shape=parallelogram, tool_command="echo hello"]
start -> echo_task -> exit
}