feat(workflow): infer command nodes from the script attribute - #656
Draft
brynary wants to merge 1 commit into
Draft
feat(workflow): infer command nodes from the script attribute#656brynary wants to merge 1 commit into
brynary wants to merge 1 commit into
Conversation
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>
Contributor
There was a problem hiding this comment.
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
scriptwhenshapeis omitted (Node::shape()), while keeping explicitshape/typeprecedence. - Add new validation errors for
script+promptconflicts and for command nodes missing a non-blankscript. - Remove
tool_commandread paths and update fixtures, CLI integration tests, and docs to usescript.
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”. Ascriptcontaining only whitespace will be executed (or produce confusing behavior) even though validation treats blank/whitespace as missing (command_requires_scriptusestrim()).
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
shapealways wins, buttypealso overrides shape-based handler selection (per the section above). The current wording can mislead readers into thinkingtypeis 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A node with no
shapedefaulted tobox→ 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), whilescriptwas separately reported as an inert attribute. Wrong behavior behind a warning.What
scriptis read by the command handler and by nothing else —inert_attribute.rsalready encoded that mapping, just in the reverse direction. So a shapeless node that setsscriptis unambiguously a command node.Node::shape()now infersparallelogramin that case. An explicitshapealways wins, soshape=box, script="…"is still an agent node. The inference lives inshape()rather thanHandlerRegistry::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— bothscriptandprompton 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_commandalias in favor ofscriptalone, routing the six read sites through a newNode::script()accessor.Scope notes
shape=tab. The rule is "shape is optional for the two most common types," not "shape is optional."test/legacy_tool.fabrowas repurposed, not just deleted — it existed only to cover thetool_commandalias, so it becametest/inferred_command.fabrocovering a shapelessscriptnode through bothvalidateand--dry-run.apps/fabro-web/.../parse-fabro.tshas its owncoerceShapethat already diverges from Rust (it infers start/exit from node ids, which Rust doesn't). It always writes explicit shapes, so only hand-written.fabrofiles opened in the playground would show the gap. Pre-existing divergence, not new — but still there.Testing
clippy --workspace --all-targets -D warningsclean,fmt --checkclean.typewins, empty script still infers command so the lint lands on a command node) and both new rules.🤖 Generated with Claude Code