Skip to content

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

Draft
brynary wants to merge 1 commit into
mainfrom
feat/infer-command-node-from-script
Draft

feat(workflow): infer command nodes from the script attribute#656
brynary wants to merge 1 commit into
mainfrom
feat/infer-command-node-from-script

Conversation

@brynary

@brynary brynary commented Jul 27, 2026

Copy link
Copy Markdown
Member

Why

A node with no shape defaulted to box → the agent handler. So this:

build [script="cargo build"]

ran as an LLM call prompted with the string "build" (prompt falls back to label, label falls back to id), while script was separately reported as an inert attribute. Wrong behavior behind a warning.

What

script is read by the command handler and by nothing else — inert_attribute.rs already encoded that mapping, just in the reverse direction. So a shapeless node that sets script is unambiguously a command node.

Node::shape() now infers parallelogram in that case. An explicit shape always wins, so shape=box, script="…" is still an agent node. The inference lives in shape() rather than HandlerRegistry::resolve() because that's the single chokepoint every consumer already reads through — handler resolution, stylesheet selectors, is_terminal, and the lint rules all stay consistent for free.

Two rules keep it honest:

  • script_prompt_conflict — both script and prompt on one node is an error. No handler reads both. It fires regardless of shape, so adding a shape can't downgrade an error into a warning.
  • command_requires_script — a command node with no script is an error. Without this the original trap just relocates: a node meant as a command that omits its script silently becomes an agent prompted with its label.

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

Scope notes

  • Agent vs prompt is unaffected. They take an identical attribute vocabulary, so prompt nodes still need shape=tab. The rule is "shape is optional for the two most common types," not "shape is optional."
  • test/legacy_tool.fabro was repurposed, not just deleted — it existed only to cover the tool_command alias, so it became test/inferred_command.fabro covering a shapeless script node through both validate and --dry-run.
  • The playground parser is untouched. apps/fabro-web/.../parse-fabro.ts has its own coerceShape that already diverges from Rust (it infers start/exit from node ids, which Rust doesn't). It always writes explicit shapes, so only hand-written .fabro files opened in the playground would show the gap. Pre-existing divergence, not new — but still there.

Testing

  • 7415 workspace tests pass; no existing fixture tripped either new error rule.
  • clippy --workspace --all-targets -D warnings clean, fmt --check clean.
  • New unit tests cover inference precedence (explicit shape wins, explicit type wins, empty script still infers command so the lint lands on a command node) and both new rules.

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings July 27, 2026 18:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a workflow footgun where nodes with a script attribute but no explicit shape were treated as agent nodes, by inferring shape=parallelogram (command) from the presence of script. It also removes the legacy tool_command alias, adds validation rules to prevent ambiguous node configuration, and updates fixtures/docs to match the new behavior.

Changes:

  • Infer command nodes from script when shape is omitted (Node::shape()), while keeping explicit shape/type precedence.
  • Add new validation errors for script+prompt conflicts and for command nodes missing a non-blank script.
  • Remove tool_command read paths and update fixtures, CLI integration tests, and docs to use script.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
test/legacy_tool.fabro Removed legacy fixture that only exercised tool_command.
test/inferred_command.fabro Added fixture proving shapeless script nodes execute as command nodes.
test/attractor/reference_template.dot Updated template to use script instead of tool_command.
lib/foundation/fabro-types/src/graph.rs Added Node::script() accessor and shape() inference from script, plus unit tests.
lib/components/fabro-workflow/src/handler/llm/preamble.rs Switched command-stage summaries to read script via Node::script().
lib/components/fabro-workflow/src/handler/command.rs Dropped tool_command fallback; command handler reads only script.
lib/components/fabro-validate/src/rules/script_prompt_conflict.rs New lint rule: error on nodes setting both script and prompt.
lib/components/fabro-validate/src/rules/script_absolute_cd.rs Updated rule to read script via accessor; adjusted tests for new inference behavior.
lib/components/fabro-validate/src/rules/mod.rs Registered the two new validation rules.
lib/components/fabro-validate/src/rules/command_requires_script.rs New lint rule: command nodes must have a non-blank script.
lib/apps/fabro-cli/tests/it/workflow/dry_run_examples.rs Updated dry-run fixture from legacy tool alias to inferred command behavior.
lib/apps/fabro-cli/tests/it/cmd/validate.rs Updated validate fixture from legacy tool alias to inferred command behavior.
docs/public/workflows/stages-and-nodes.mdx Documented optional command shape via script inference and updated examples.
docs/public/reference/dot-language.mdx Documented script-based inference and updated command-node attribute docs.
Comments suppressed due to low confidence (2)

lib/components/fabro-workflow/src/handler/command.rs:56

  • CommandHandler::execute() treats only the empty string as “no script”. A script containing only whitespace will be executed (or produce confusing behavior) even though validation treats blank/whitespace as missing (command_requires_script uses trim()).
        let script = node.script().unwrap_or("");

        if script.is_empty() {
            return Ok(Outcome::fail_classify("No script specified"));
        }

docs/public/reference/dot-language.mdx:185

  • This paragraph says an explicit shape always wins, but type also overrides shape-based handler selection (per the section above). The current wording can mislead readers into thinking type is ignored here.
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.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

self.str_attr("label").unwrap_or(&self.id)
}

/// The node's Graphviz shape, which selects its handler.
Comment on lines +25 to +29
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
),

### 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:
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.
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.

2 participants