mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Add model stylesheet templates
This commit is contained in:
parent
9223349101
commit
a522414bdc
20 changed files with 1168 additions and 38 deletions
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: "Stop workflows when a node fails"
|
||||
title: "Model stylesheet templates and failure routing"
|
||||
date: "2026-08-25"
|
||||
---
|
||||
|
||||
|
|
@ -22,3 +22,11 @@ digraph Build {
|
|||
start -> plan -> implement -> verify -> exit
|
||||
}
|
||||
```
|
||||
|
||||
## Model stylesheet templates
|
||||
|
||||
The root graph's `model_stylesheet` now supports MiniJinja templates. A stylesheet can use typed run inputs and server-managed variables through `inputs` and `vars`. Conditions, loops, filters, macros, local values, and static includes use the same template engine as workflow goals and prompts.
|
||||
|
||||
Fabro renders the stylesheet before parsing and applying its rules. Undefined values produce the existing `template_undefined_variable` diagnostic. Offline validation skips stylesheet syntax checks until those values are available, which avoids a second error from incomplete generated stylesheet text.
|
||||
|
||||
Stylesheet templates do not expose `goal`, `env`, or `secrets`. Stylesheets on imported graphs remain ignored and now produce an `imported_model_stylesheet_ignored` warning.
|
||||
|
|
|
|||
|
|
@ -444,16 +444,19 @@ repo_url = "https://github.com/fabro-sh/fabro"
|
|||
language = "rust"
|
||||
```
|
||||
|
||||
Inputs can be used in graph `goal` and node `prompt` attributes with `{{ inputs.name }}` syntax:
|
||||
Inputs can be used in graph `goal`, root `model_stylesheet`, and node `prompt` attributes with `{{ inputs.name }}` syntax:
|
||||
|
||||
```dot title="c-i.fabro"
|
||||
digraph CI {
|
||||
graph [goal="Run tests for {{ inputs.repo_name }}"]
|
||||
graph [
|
||||
goal="Run tests for {{ inputs.repo_name }}",
|
||||
model_stylesheet="{% if inputs.language == 'rust' %}* { reasoning_effort: high; }{% endif %}"
|
||||
]
|
||||
test [label="Test", prompt="Clone {{ inputs.repo_url }} and run the {{ inputs.language }} test suite."]
|
||||
}
|
||||
```
|
||||
|
||||
Inputs cannot parameterize workflow structure, file references such as node IDs, edges, `import` paths, `@file` paths, or child workflow paths, or any attribute besides `prompt` and `goal` — other attributes such as `script` and `label` are literal text.
|
||||
Inputs cannot parameterize workflow structure, file references such as node IDs, edges, `import` paths, `@file` paths, or child workflow paths, or any full-template attribute besides `prompt`, `goal`, and the root `model_stylesheet`. Command `script` supports only simple value substitution. Other attributes such as `label` are literal text.
|
||||
|
||||
If a workflow template references an undefined input like `{{ inputs.langauge }}`, `fabro validate` reports a warning. Run-style commands promote that diagnostic to an error before creating or starting a run.
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ rankdir=LR
|
|||
|---|---|---|
|
||||
| `goal` | String | Workflow objective — guides agent behavior |
|
||||
| `rankdir` | Identifier | Layout direction: `LR` (left-to-right) or `TB` (top-to-bottom) |
|
||||
| `model_stylesheet` | String | CSS-like rules for model assignment (see [Model Stylesheets](/workflows/stylesheets)) |
|
||||
| `model_stylesheet` | String | CSS-like rules for model assignment. The root value supports a MiniJinja template with `inputs` and `vars` (see [Model Stylesheets](/workflows/stylesheets)) |
|
||||
| `default_max_retries` | Integer | Default retry count for all nodes (default: 0) |
|
||||
| `on_failure` | String | Failed-node routing policy: `route` (default) or `exit` |
|
||||
| `retry_target` | String | Default node ID to jump to on retry |
|
||||
|
|
|
|||
|
|
@ -33,10 +33,70 @@ digraph Example {
|
|||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- **spec** gets Haiku (matches `*`)
|
||||
- **implement** and **test** get Sonnet with high reasoning (match `.coding`)
|
||||
- **review** gets Gemini Pro (matches `#review`)
|
||||
|
||||
## Template stylesheets
|
||||
|
||||
The root graph's `model_stylesheet` is a [MiniJinja template](/workflows/variables). It can read typed run inputs and server-managed variables through `inputs` and `vars`:
|
||||
|
||||
```dot title="variable-effort.fabro"
|
||||
digraph Review {
|
||||
graph [
|
||||
model_stylesheet="
|
||||
* { reasoning_effort: low; }
|
||||
|
||||
{% if inputs.effort == 'deep' %}
|
||||
.variable-effort { reasoning_effort: high; }
|
||||
{% elif inputs.effort == 'balanced' %}
|
||||
.variable-effort { reasoning_effort: medium; }
|
||||
{% endif %}
|
||||
"
|
||||
]
|
||||
|
||||
triage [prompt="Triage the change"]
|
||||
review [prompt="Review the change", class="variable-effort"]
|
||||
}
|
||||
```
|
||||
|
||||
Stylesheet templates support expressions, conditionals, loops, filters, macros, `{% set %}`, and normal local values such as `loop`. They do not expose `goal`, `env`, or `secrets`.
|
||||
|
||||
Fabro renders a stylesheet once. If an input or variable contains `{{ ... }}` or `{% ... %}`, that text stays literal. Fabro does not render it again.
|
||||
|
||||
Template output is not escaped as stylesheet syntax. Map user-facing choices to fixed declarations instead of inserting unrestricted text directly:
|
||||
|
||||
```dot
|
||||
model_stylesheet="
|
||||
{% set efforts = {'quick': 'low', 'thorough': 'high'} %}
|
||||
.review { reasoning_effort: {{ efforts[inputs.review_mode] }}; }
|
||||
"
|
||||
```
|
||||
|
||||
Use single quotes inside MiniJinja expressions when possible. A double quote must follow normal DOT string escaping because the surrounding graph attribute uses double quotes. MiniJinja braces need no extra escaping inside the quoted DOT attribute.
|
||||
|
||||
Static template includes are supported and resolve relative to the workflow template root:
|
||||
|
||||
```dot
|
||||
graph [model_stylesheet="{% include 'styles/models.partial' %}"]
|
||||
```
|
||||
|
||||
Include paths must be literal. Dynamic or root-escaping include paths fail validation. `model_stylesheet` does not support the `@file` shorthand.
|
||||
|
||||
Fabro uses this order:
|
||||
|
||||
1. Parse the DOT source.
|
||||
2. Expand workflow imports and supported file references.
|
||||
3. Render the root `model_stylesheet` with `{ inputs, vars }`.
|
||||
4. Parse and apply the rendered stylesheet.
|
||||
5. Resolve model and provider selectors.
|
||||
6. Validate the transformed graph.
|
||||
|
||||
A `model_stylesheet` on an imported graph is ignored and produces an `imported_model_stylesheet_ignored` warning. Put the stylesheet on the root graph. A root stylesheet can target imported nodes by their generated IDs, classes, or shapes.
|
||||
|
||||
If an input or variable is unavailable, `fabro validate` reports `template_undefined_variable`. It skips stylesheet syntax and model checks for that validation pass. Run-style commands treat the same diagnostic as an error before they create or start a run.
|
||||
|
||||
## Selectors
|
||||
|
||||
Each rule starts with a selector that determines which nodes it applies to:
|
||||
|
|
@ -60,7 +120,7 @@ This node matches both `.coding` and `.critical` rules.
|
|||
|
||||
## Properties
|
||||
|
||||
Stylesheets support four properties:
|
||||
Stylesheets support five properties:
|
||||
|
||||
| Property | Description | Example |
|
||||
|---|---|---|
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ title: "Variables"
|
|||
description: "Using templates in workflows"
|
||||
---
|
||||
|
||||
Fabro renders `{{ ... }}` templates in exactly two workflow attributes: the graph `goal` and node `prompt`s. A command node's `script` gets narrower treatment — [simple value substitution](#command-node-scripts), not templating. Every other attribute is literal text.
|
||||
Fabro renders full MiniJinja templates in three workflow attributes: the graph `goal`, the root graph's `model_stylesheet`, and node `prompt`s. A command node's `script` gets narrower treatment — [simple value substitution](#command-node-scripts), not templating. Every other attribute is literal text.
|
||||
|
||||
## Template context
|
||||
|
||||
|
|
@ -15,7 +15,9 @@ Goal templates can reference inputs and server-managed variables. Prompt templat
|
|||
| `{{ inputs.name }}` | A value from `[run.inputs]`, optionally overridden by CLI input flags |
|
||||
| `{{ vars.NAME }}` | A server-managed variable snapshotted when the run is created |
|
||||
|
||||
Secrets are **not** available in goal or prompt templates. Use `{{ secrets.NAME }}` only in the configuration fields that support run-boundary interpolation.
|
||||
The root `model_stylesheet` receives only `inputs` and `vars`. It does not receive `goal`. See [Model Stylesheets](/workflows/stylesheets#template-stylesheets) for examples and output safety guidance.
|
||||
|
||||
Secrets are **not** available in goal, prompt, or model stylesheet templates. Use `{{ secrets.NAME }}` only in the configuration fields that support run-boundary interpolation.
|
||||
|
||||
## Run config inputs
|
||||
|
||||
|
|
@ -36,7 +38,7 @@ repo_url = "https://github.com/fabro-sh/fabro"
|
|||
language = "rust"
|
||||
```
|
||||
|
||||
These values are available in the graph `goal` and node `prompt` attributes:
|
||||
These values are available in the graph `goal`, root `model_stylesheet`, and node `prompt` attributes:
|
||||
|
||||
```dot title="check.fabro"
|
||||
digraph Check {
|
||||
|
|
@ -51,7 +53,7 @@ digraph Check {
|
|||
}
|
||||
```
|
||||
|
||||
Other attributes — `label`, `model`, `provider`, `condition`, and all edge attributes — do not render templates. If one of them contains `{{ … }}` or `{% … %}`, the syntax is treated as literal text and Fabro records a `detemplated_attribute` warning suggesting you move the dynamic value into a `prompt` or `goal`.
|
||||
Other attributes — `label`, `model`, `provider`, `condition`, and all edge attributes — do not render templates. If one of them contains `{{ … }}` or `{% … %}`, the syntax is treated as literal text and Fabro records a `detemplated_attribute` warning suggesting you move the dynamic value into a `prompt`, `goal`, or `model_stylesheet`.
|
||||
|
||||
Override individual inputs at run time with repeatable `-I` / `--input` flags:
|
||||
|
||||
|
|
@ -125,7 +127,7 @@ Use server-managed variables for non-sensitive values that should be shared acro
|
|||
fabro variable set DEPLOY_ENV staging --description "Deployment target"
|
||||
```
|
||||
|
||||
Run configuration strings, graph goals, and node prompts can reference these values with `{{ vars.NAME }}`:
|
||||
Run configuration strings, graph goals, root model stylesheets, and node prompts can reference these values with `{{ vars.NAME }}`:
|
||||
|
||||
```toml title="workflow.toml"
|
||||
_version = 1
|
||||
|
|
@ -171,9 +173,10 @@ Fabro keeps workflow structure static and renders workflow templates once:
|
|||
2. Literal `import`, `@file`, graph-goal file, and child-workflow references are resolved.
|
||||
3. The graph `goal` is rendered with the `{ inputs, vars }` context.
|
||||
4. Node `prompt` attributes are rendered with the `{ goal, inputs, vars }` context.
|
||||
5. Node `script` attributes have their `{{ goal }}`, `{{ inputs.* }}`, and `{{ vars.* }}` values substituted.
|
||||
5. The root `model_stylesheet` is rendered with the `{ inputs, vars }` context, then parsed and applied.
|
||||
6. Node `script` attributes have their `{{ goal }}`, `{{ inputs.* }}`, and `{{ vars.* }}` values substituted.
|
||||
|
||||
Templates are not supported in graph syntax, node IDs, edge structure, `import` paths, `@file` paths, child workflow paths, other file references, or any attribute besides `prompt` and `goal` — and `script`, which takes value substitution rather than templates.
|
||||
Templates are not supported in graph syntax, node IDs, edge structure, `import` paths, `@file` paths, child workflow paths, other file references, or any attribute besides `prompt`, `goal`, and the root `model_stylesheet` — and `script`, which takes value substitution rather than templates.
|
||||
|
||||
Command `stdin_source` values are literal context keys. Fabro resolves them at
|
||||
stage execution time, after upstream nodes have updated the workflow context.
|
||||
|
|
@ -188,9 +191,9 @@ In a `script`, an undefined value records the same diagnostic but leaves the tok
|
|||
|
||||
## Template includes
|
||||
|
||||
Prompt and goal templates support static MiniJinja loader dependencies such as `{% include "partial.md" %}`. Includes are resolved relative to the template file being rendered and can be nested.
|
||||
Prompt, goal, and root model stylesheet templates support static MiniJinja loader dependencies such as `{% include "partial.md" %}`. Includes are resolved relative to the template file being rendered and can be nested.
|
||||
|
||||
Fabro discovers those static dependencies while building the run manifest so sandbox providers receive every required prompt file. Dynamic loader expressions such as `{% include inputs.partial %}` are rejected; use a literal include path and choose content with normal template conditionals instead.
|
||||
Fabro discovers those static dependencies while building the run manifest so sandbox providers receive every required template file. Dynamic loader expressions such as `{% include inputs.partial %}` are rejected; use a literal include path and choose content with normal template conditionals instead.
|
||||
|
||||
## Escaping
|
||||
|
||||
|
|
|
|||
|
|
@ -76,9 +76,9 @@ fn preflight_rejects_unbound_template_inputs() {
|
|||
Goal: Demo
|
||||
|
||||
error: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable)
|
||||
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
error: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
|
||||
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
× Validation failed
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,9 +199,27 @@ fn bare_fabro_with_unbound_inputs_validates_structurally_with_warning() {
|
|||
Workflow: TemplatedUnbound (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/templated_unbound.fabro
|
||||
warning: [FIXTURES]/templated_unbound.fabro:2:26: undefined template variable `inputs.app_dir` in graph attribute `goal` (template_undefined_variable)
|
||||
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
warning: [FIXTURES]/templated_unbound.fabro:7:44: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
|
||||
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
Validation: OK
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbound_model_stylesheet_input_warns_without_css_error() {
|
||||
let context = test_context!();
|
||||
let mut cmd = context.validate();
|
||||
cmd.arg(fixture("model_stylesheet_unbound.fabro"));
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
Workflow: ModelStylesheetUnbound (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/model_stylesheet_unbound.fabro
|
||||
warning: [FIXTURES]/model_stylesheet_unbound.fabro:4:38: undefined template variable `inputs.effort` in graph attribute `model_stylesheet` (template_undefined_variable)
|
||||
fix: bind `effort` via `[run.inputs]` in workflow.toml, or pass `--input effort=<value>`
|
||||
Validation: OK
|
||||
");
|
||||
}
|
||||
|
|
@ -224,7 +242,7 @@ fn bare_fabro_with_unbound_inputs_in_imported_prompt_validates_structurally_with
|
|||
Workflow: TemplatedUnboundImported (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/templated_unbound_imported/workflow.fabro
|
||||
warning: [FIXTURES]/templated_unbound_imported/work.md:1:12: undefined template variable `inputs.app_dir` in node `work` attribute `prompt` [node: work] (template_undefined_variable)
|
||||
fix: bind `inputs.app_dir` via `[run.inputs]` in workflow.toml, or pass `--input inputs.app_dir=<value>`
|
||||
fix: bind `app_dir` via `[run.inputs]` in workflow.toml, or pass `--input app_dir=<value>`
|
||||
Validation: OK
|
||||
");
|
||||
}
|
||||
|
|
@ -246,7 +264,7 @@ fn bare_fabro_with_unbound_inputs_in_template_partial_validates_structurally_wit
|
|||
Workflow: TemplatedUnboundPartial (3 nodes, 2 edges)
|
||||
Graph: [FIXTURES]/templated_unbound_partial/workflow.fabro
|
||||
warning: [FIXTURES]/templated_unbound_partial/test-include.partial.md:1:4: undefined template variable `inputs.hello` in node `test_imported_include` attribute `prompt` [node: test_imported_include] (template_undefined_variable)
|
||||
fix: bind `inputs.hello` via `[run.inputs]` in workflow.toml, or pass `--input inputs.hello=<value>`
|
||||
fix: bind `hello` via `[run.inputs]` in workflow.toml, or pass `--input hello=<value>`
|
||||
Validation: OK
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
.ok_or_else(|| anyhow!("invalid manifest workflow config path: {}", config.path))?;
|
||||
self.collect_config_dockerfile(&config_path, &config.source, &mut files)?;
|
||||
}
|
||||
self.collect_workflow_files(&scan, &mut files, &mut visited_imports)?;
|
||||
self.collect_workflow_files(&scan, &mut files, &mut visited_imports, true)?;
|
||||
|
||||
self.workflows
|
||||
.insert(dot_key.clone(), types::ManifestWorkflow {
|
||||
|
|
@ -123,6 +123,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
workflow: &WorkflowScanInput,
|
||||
files: &mut HashMap<String, types::ManifestFileEntry>,
|
||||
visited_imports: &mut HashSet<String>,
|
||||
collect_model_stylesheet: bool,
|
||||
) -> Result<()> {
|
||||
let graph = parser::parse(&workflow.source)
|
||||
.with_context(|| format!("Failed to parse {}", workflow.absolute_dot_path.display()))?;
|
||||
|
|
@ -160,6 +161,18 @@ impl<'a> WorkflowBundler<'a> {
|
|||
),
|
||||
Some(&workflow.dot_path),
|
||||
),
|
||||
GraphReference::ModelStylesheetInline { content } if collect_model_stylesheet => {
|
||||
self.collect_template_include_files(
|
||||
files,
|
||||
TemplateSource::new(
|
||||
workflow.dot_path.clone(),
|
||||
workflow_template_root.clone(),
|
||||
content.to_owned(),
|
||||
),
|
||||
Some(&workflow.dot_path),
|
||||
)
|
||||
}
|
||||
GraphReference::ModelStylesheetInline { .. } => Ok(()),
|
||||
GraphReference::FileInline { key, reference } => {
|
||||
let bundled = self.collect_bundled_file(
|
||||
files,
|
||||
|
|
@ -212,7 +225,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
dot_path: imported.path,
|
||||
source: imported_source,
|
||||
};
|
||||
self.collect_workflow_files(&imported_scan, files, visited_imports)?;
|
||||
self.collect_workflow_files(&imported_scan, files, visited_imports, false)?;
|
||||
}
|
||||
}
|
||||
for child in children {
|
||||
|
|
@ -474,6 +487,125 @@ mod tests {
|
|||
assert_eq!(goal.ref_.original, "@goal.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_model_stylesheet_bundles_nested_static_includes() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
r#"digraph Root {
|
||||
graph [model_stylesheet="{% include 'styles/base.css' %}"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&temp.path().join("styles/base.css"),
|
||||
"{% include 'nested.css' %}",
|
||||
);
|
||||
write_file(
|
||||
&temp.path().join("styles/nested.css"),
|
||||
"* { reasoning_effort: low; }",
|
||||
);
|
||||
|
||||
let workflows = bundle_graph(temp.path(), &graph).expect("workflow should bundle");
|
||||
let files = &workflows["workflow.fabro"].files;
|
||||
|
||||
assert_eq!(
|
||||
files["styles/base.css"].content,
|
||||
"{% include 'nested.css' %}"
|
||||
);
|
||||
assert_eq!(
|
||||
files["styles/nested.css"].content,
|
||||
"* { reasoning_effort: low; }"
|
||||
);
|
||||
|
||||
let store = BundleTemplateStore::new(
|
||||
files
|
||||
.iter()
|
||||
.map(|(path, entry)| {
|
||||
(
|
||||
ManifestPath::from_wire(path).expect("bundled path should parse"),
|
||||
entry.content.clone(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let rendered = fabro_template::render_source(
|
||||
&TemplateSource::new(
|
||||
ManifestPath::from_wire("workflow.fabro").unwrap(),
|
||||
ManifestPath::from_wire(".").unwrap(),
|
||||
"{% include 'styles/base.css' %}",
|
||||
),
|
||||
&TemplateContext::new().for_model_stylesheet(),
|
||||
Arc::new(store),
|
||||
TemplateRenderMode::Strict,
|
||||
)
|
||||
.expect("bundled stylesheet should render");
|
||||
assert_eq!(rendered, "* { reasoning_effort: low; }");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_model_stylesheet_rejects_invalid_includes() {
|
||||
for template in [
|
||||
"{% include 'missing.css' %}",
|
||||
"{% include inputs.stylesheet %}",
|
||||
"{% include '../outside.css' %}",
|
||||
] {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
&format!(
|
||||
r#"digraph Root {{
|
||||
graph [model_stylesheet="{template}"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}}"#,
|
||||
),
|
||||
);
|
||||
|
||||
let error = bundle_graph(temp.path(), &graph)
|
||||
.expect_err("invalid stylesheet include should fail bundling");
|
||||
assert!(
|
||||
error.to_string().contains("template dependencies"),
|
||||
"template: {template}; error: {error:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_model_stylesheet_includes_are_not_bundled() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
let graph = temp.path().join("workflow.fabro");
|
||||
write_file(
|
||||
&graph,
|
||||
r#"digraph Root {
|
||||
start [shape=Mdiamond]
|
||||
child [import="child.fabro"]
|
||||
exit [shape=Msquare]
|
||||
start -> child -> exit
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&temp.path().join("child.fabro"),
|
||||
r#"digraph Child {
|
||||
graph [model_stylesheet="{% include 'missing.css' %}"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}"#,
|
||||
);
|
||||
|
||||
let workflows = bundle_graph(temp.path(), &graph).expect("workflow should bundle");
|
||||
let files = &workflows["workflow.fabro"].files;
|
||||
|
||||
assert!(files.contains_key("child.fabro"));
|
||||
assert!(!files.contains_key("missing.css"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_errors_keep_the_graphviz_error_in_the_source_chain() {
|
||||
let temp = tempfile::tempdir().expect("temp directory should be created");
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ fn validate_graph_closure(
|
|||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
let is_entrypoint = &path == version.entrypoint();
|
||||
|
||||
visit_graph_references(&graph, |reference| match reference {
|
||||
GraphReference::GoalFile { reference } => {
|
||||
|
|
@ -291,6 +292,11 @@ fn validate_graph_closure(
|
|||
template_roots.push(&path, content);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::ModelStylesheetInline { content } if is_entrypoint => {
|
||||
template_roots.push(&path, content);
|
||||
Ok(())
|
||||
}
|
||||
GraphReference::ModelStylesheetInline { .. } => Ok(()),
|
||||
GraphReference::Import { reference } => {
|
||||
let target = resolve_reference(&path, ReferenceKind::Import, reference)?;
|
||||
require_file(version, &path, ReferenceKind::Import, target.clone())?;
|
||||
|
|
@ -752,6 +758,69 @@ mod tests {
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_root_model_stylesheet_template_closure() {
|
||||
let version = version_with(
|
||||
[
|
||||
(
|
||||
"workflow.fabro",
|
||||
r#"digraph W {
|
||||
graph [model_stylesheet="{% include 'styles/base.css' %}"]
|
||||
}"#,
|
||||
),
|
||||
("styles/base.css", "{% include 'nested.css' %}"),
|
||||
("styles/nested.css", "* { reasoning_effort: low; }"),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(version.version().files().len(), 3);
|
||||
|
||||
for template in [
|
||||
"{% include 'missing.css' %}",
|
||||
"{% include inputs.stylesheet %}",
|
||||
"{% include '../outside.css' %}",
|
||||
] {
|
||||
let graph = format!(r#"digraph W {{ graph [model_stylesheet="{template}"] }}"#);
|
||||
let error = ValidatedWorkflowVersion::new(
|
||||
WorkflowVersion::new(
|
||||
path("workflow.fabro"),
|
||||
BTreeMap::from([(path("workflow.fabro"), graph)]),
|
||||
BTreeMap::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, WorkflowVersionError::Template { .. }),
|
||||
"template: {template}; error: {error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_imported_model_stylesheet_template_closure() {
|
||||
let version = version_with(
|
||||
[
|
||||
(
|
||||
"workflow.fabro",
|
||||
r#"digraph W { imported [import="child.fabro"] }"#,
|
||||
),
|
||||
(
|
||||
"child.fabro",
|
||||
r#"digraph I {
|
||||
graph [model_stylesheet="{% include 'missing.css' %}"]
|
||||
}"#,
|
||||
),
|
||||
],
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(version.version().files().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_all_inline_graph_roots_that_share_the_graph_path() {
|
||||
let error = version_with(
|
||||
|
|
|
|||
|
|
@ -995,6 +995,50 @@ reasoning = false
|
|||
assert!(validated.has_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_input_in_model_stylesheet_warns_then_errors_at_run_create() {
|
||||
let dot = r#"digraph Test {
|
||||
graph [model_stylesheet="* { reasoning_effort: {{ inputs.effort }}; }"]
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
work [label="Work", prompt="Do work"]
|
||||
start -> work -> exit
|
||||
}"#;
|
||||
let mut validated = validate_dot(dot, WorkflowSettings::default());
|
||||
|
||||
let diagnostic = validated
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.find(|diagnostic| diagnostic.rule == TEMPLATE_UNDEFINED_VARIABLE_RULE)
|
||||
.expect("expected a template_undefined_variable diagnostic");
|
||||
assert_eq!(diagnostic.severity, Severity::Warning);
|
||||
assert!(
|
||||
diagnostic
|
||||
.message
|
||||
.contains("graph attribute `model_stylesheet`"),
|
||||
"message: {}",
|
||||
diagnostic.message
|
||||
);
|
||||
assert!(
|
||||
validated
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.all(|diagnostic| diagnostic.rule != "stylesheet_syntax")
|
||||
);
|
||||
|
||||
validated.promote_template_undefined_variables_to_errors();
|
||||
assert!(validated.has_errors());
|
||||
assert_eq!(
|
||||
validated
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.find(|diagnostic| diagnostic.rule == TEMPLATE_UNDEFINED_VARIABLE_RULE)
|
||||
.unwrap()
|
||||
.severity,
|
||||
Severity::Error
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vars_resolve_in_command_script_through_create_pipeline() {
|
||||
let dot = r#"digraph Test {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ use std::sync::Arc;
|
|||
use super::types::{Parsed, TransformOptions, Transformed};
|
||||
use crate::error::Error;
|
||||
use crate::transforms::{
|
||||
FileInliningTransform, ImportTransform, ScriptInterpolationTransform,
|
||||
StylesheetApplicationTransform, TemplateTransform, Transform,
|
||||
FileInliningTransform, ImportTransform, ModelStylesheetTemplateTransform,
|
||||
ScriptInterpolationTransform, StylesheetApplicationTransform, TemplateTransform, Transform,
|
||||
template_render_store,
|
||||
};
|
||||
|
||||
/// TRANSFORM phase: apply built-in and custom transforms to a parsed graph.
|
||||
|
|
@ -62,6 +63,23 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Result<Transform
|
|||
}
|
||||
.apply_with_diagnostics(graph)?;
|
||||
diagnostics.extend(transform_diagnostics);
|
||||
let mut stylesheet_transform = ModelStylesheetTemplateTransform::new(
|
||||
options.template_context.clone(),
|
||||
options.source_name.clone(),
|
||||
Some(source.clone()),
|
||||
options.render_mode,
|
||||
);
|
||||
if let (Some(current_dir), Some(file_resolver)) = (&options.current_dir, &options.file_resolver)
|
||||
{
|
||||
stylesheet_transform = stylesheet_transform.with_template_store(template_render_store(
|
||||
current_dir,
|
||||
Arc::clone(file_resolver),
|
||||
options.source_name.as_deref(),
|
||||
graph.model_stylesheet(),
|
||||
)?);
|
||||
}
|
||||
let (graph, transform_diagnostics) = stylesheet_transform.apply_with_diagnostics(graph)?;
|
||||
diagnostics.extend(transform_diagnostics);
|
||||
let (graph, transform_diagnostics) = ScriptInterpolationTransform {
|
||||
context: options.template_context.clone(),
|
||||
source_name: options.source_name.clone(),
|
||||
|
|
@ -163,6 +181,188 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_renders_model_stylesheet_before_applying_and_resolving_it() {
|
||||
let dot = r#"digraph Test {
|
||||
graph [
|
||||
goal="Test",
|
||||
model_stylesheet="
|
||||
* { reasoning_effort: low; }
|
||||
{# MiniJinja comments can sit beside CSS braces. #}
|
||||
{% if inputs.effort == 'deep' %}
|
||||
.variable { model: sonnet; reasoning_effort: high; }
|
||||
{% endif %}
|
||||
"
|
||||
]
|
||||
start [shape=Mdiamond]
|
||||
baseline [prompt="Baseline"]
|
||||
selected [prompt="Selected", class="variable"]
|
||||
explicit [prompt="Explicit", class="variable", reasoning_effort="medium"]
|
||||
exit [shape=Msquare]
|
||||
start -> baseline -> selected -> explicit -> exit
|
||||
}"#;
|
||||
let parsed = parse(dot).unwrap();
|
||||
let transformed = transform(parsed, &TransformOptions {
|
||||
template_context: fabro_template::TemplateContext::new().with_inputs(HashMap::from([
|
||||
(
|
||||
"effort".to_string(),
|
||||
toml::Value::String("deep".to_string()),
|
||||
),
|
||||
])),
|
||||
..transform_options()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
transformed.graph.nodes["baseline"]
|
||||
.attrs
|
||||
.get("reasoning_effort")
|
||||
.and_then(AttrValue::as_str),
|
||||
Some("low")
|
||||
);
|
||||
assert_eq!(
|
||||
transformed.graph.nodes["selected"]
|
||||
.attrs
|
||||
.get("reasoning_effort")
|
||||
.and_then(AttrValue::as_str),
|
||||
Some("high")
|
||||
);
|
||||
assert_eq!(
|
||||
transformed.graph.nodes["selected"]
|
||||
.attrs
|
||||
.get("model")
|
||||
.and_then(AttrValue::as_str),
|
||||
Some("claude-sonnet-5")
|
||||
);
|
||||
assert_eq!(
|
||||
transformed.graph.nodes["explicit"]
|
||||
.attrs
|
||||
.get("reasoning_effort")
|
||||
.and_then(AttrValue::as_str),
|
||||
Some("medium")
|
||||
);
|
||||
assert!(
|
||||
transformed
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|diagnostic| diagnostic.rule != "detemplated_attribute"),
|
||||
"{:?}",
|
||||
transformed.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_applies_rules_emitted_by_model_stylesheet_loop() {
|
||||
let dot = r#"digraph Test {
|
||||
graph [model_stylesheet="
|
||||
{% for effort in inputs.efforts %}
|
||||
.tier-{{ loop.index }} { reasoning_effort: {{ effort }}; }
|
||||
{% endfor %}
|
||||
"]
|
||||
start [shape=Mdiamond]
|
||||
low [prompt="Low", class="tier-1"]
|
||||
high [prompt="High", class="tier-2"]
|
||||
exit [shape=Msquare]
|
||||
start -> low -> high -> exit
|
||||
}"#;
|
||||
let parsed = parse(dot).unwrap();
|
||||
let transformed = transform(parsed, &TransformOptions {
|
||||
template_context: fabro_template::TemplateContext::new().with_inputs(HashMap::from([
|
||||
(
|
||||
"efforts".to_string(),
|
||||
toml::Value::Array(vec![
|
||||
toml::Value::String("low".to_string()),
|
||||
toml::Value::String("high".to_string()),
|
||||
]),
|
||||
),
|
||||
])),
|
||||
..transform_options()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
transformed.graph.nodes["low"]
|
||||
.attrs
|
||||
.get("reasoning_effort")
|
||||
.and_then(AttrValue::as_str),
|
||||
Some("low")
|
||||
);
|
||||
assert_eq!(
|
||||
transformed.graph.nodes["high"]
|
||||
.attrs
|
||||
.get("reasoning_effort")
|
||||
.and_then(AttrValue::as_str),
|
||||
Some("high")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_renders_model_stylesheet_static_include() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_file(
|
||||
&dir.path().join("styles.partial"),
|
||||
".selected { model: sonnet; }",
|
||||
);
|
||||
let source_name = dir.path().join("workflow.fabro");
|
||||
let dot = r#"digraph Test {
|
||||
graph [model_stylesheet="{% include 'styles.partial' %}"]
|
||||
start [shape=Mdiamond]
|
||||
selected [prompt="Selected", class="selected"]
|
||||
exit [shape=Msquare]
|
||||
start -> selected -> exit
|
||||
}"#;
|
||||
let parsed = parse(dot).unwrap();
|
||||
let transformed = transform(parsed, &TransformOptions {
|
||||
current_dir: Some(dir.path().to_path_buf()),
|
||||
file_resolver: Some(Arc::new(FilesystemFileResolver::new(None))),
|
||||
source_name: Some(source_name.display().to_string()),
|
||||
..transform_options()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
transformed.graph.nodes["selected"]
|
||||
.attrs
|
||||
.get("model")
|
||||
.and_then(AttrValue::as_str),
|
||||
Some("claude-sonnet-5")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_model_stylesheet_undefined_value_skips_stylesheet_parsing() {
|
||||
let dot = r#"digraph Test {
|
||||
graph [model_stylesheet="* { reasoning_effort: {{ inputs.effort }}; }"]
|
||||
start [shape=Mdiamond]
|
||||
work [prompt="Work"]
|
||||
exit [shape=Msquare]
|
||||
start -> work -> exit
|
||||
}"#;
|
||||
let parsed = parse(dot).unwrap();
|
||||
let transformed = transform(parsed, &TransformOptions {
|
||||
render_mode: crate::operations::RenderMode::Structural,
|
||||
model_resolution: None,
|
||||
..transform_options()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(transformed.graph.model_stylesheet(), "");
|
||||
assert_eq!(
|
||||
transformed
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|diagnostic| diagnostic.rule == TEMPLATE_UNDEFINED_VARIABLE_RULE)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
transformed
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|diagnostic| diagnostic.rule != "detemplated_attribute")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_inlines_files_before_variable_expansion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub fn validate(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
use crate::pipeline::parse::parse;
|
||||
use crate::pipeline::transform;
|
||||
|
|
@ -106,4 +108,68 @@ mod tests {
|
|||
// Then raise
|
||||
assert!(validated.raise_on_errors().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_fully_rendered_model_stylesheet_syntax() {
|
||||
let dot = r#"digraph Test {
|
||||
graph [model_stylesheet="* { {{ inputs.declaration }} }"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}"#;
|
||||
let transformed = transform::transform(parse(dot).unwrap(), &TransformOptions {
|
||||
current_dir: None,
|
||||
file_resolver: None,
|
||||
template_context: fabro_template::TemplateContext::new().with_inputs(HashMap::from([
|
||||
(
|
||||
"declaration".to_string(),
|
||||
toml::Value::String("garbage garbage".to_string()),
|
||||
),
|
||||
])),
|
||||
source_name: Some("workflow.fabro".to_string()),
|
||||
render_mode: crate::operations::RenderMode::Structural,
|
||||
custom_transforms: vec![],
|
||||
model_resolution: None,
|
||||
})
|
||||
.unwrap();
|
||||
let validated = validate(transformed, None, &[]);
|
||||
|
||||
assert!(
|
||||
validated
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.rule == "stylesheet_syntax"),
|
||||
"{:?}",
|
||||
validated.diagnostics()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_model_stylesheet_skips_stylesheet_rules() {
|
||||
let dot = r#"digraph Test {
|
||||
graph [model_stylesheet="* { model: {{ vars.MODEL }}; }"]
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
start -> exit
|
||||
}"#;
|
||||
let transformed = transform::transform(parse(dot).unwrap(), &TransformOptions {
|
||||
current_dir: None,
|
||||
file_resolver: None,
|
||||
template_context: fabro_template::TemplateContext::new(),
|
||||
source_name: Some("workflow.fabro".to_string()),
|
||||
render_mode: crate::operations::RenderMode::Structural,
|
||||
custom_transforms: vec![],
|
||||
model_resolution: None,
|
||||
})
|
||||
.unwrap();
|
||||
let validated = validate(transformed, None, &[]);
|
||||
|
||||
assert!(validated.diagnostics().iter().any(|diagnostic| {
|
||||
diagnostic.rule == "template_undefined_variable"
|
||||
&& diagnostic.message.contains("vars.MODEL")
|
||||
}));
|
||||
assert!(validated.diagnostics().iter().all(|diagnostic| {
|
||||
diagnostic.rule != "stylesheet_syntax" && diagnostic.rule != "stylesheet_model_known"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
|||
use fabro_graphviz::parser;
|
||||
use fabro_template::{TemplateContext, validate_static_reference};
|
||||
use fabro_types::graph::ReferenceKind;
|
||||
use fabro_validate::Diagnostic;
|
||||
use fabro_validate::{Diagnostic, Severity};
|
||||
|
||||
use super::file_inlining::template_render_store;
|
||||
use super::{FileInliningTransform, Transform};
|
||||
|
|
@ -45,6 +45,23 @@ enum ImportPrepareError {
|
|||
Soft(String),
|
||||
}
|
||||
|
||||
const IMPORTED_MODEL_STYLESHEET_IGNORED_RULE: &str = "imported_model_stylesheet_ignored";
|
||||
|
||||
fn imported_model_stylesheet_ignored_diagnostic(source_name: &str) -> Diagnostic {
|
||||
Diagnostic {
|
||||
rule: IMPORTED_MODEL_STYLESHEET_IGNORED_RULE.to_string(),
|
||||
severity: Severity::Warning,
|
||||
message: "imported graph attribute `model_stylesheet` is ignored; only the root graph stylesheet is applied"
|
||||
.to_string(),
|
||||
fix: Some(
|
||||
"move the stylesheet to the root graph; it can target imported nodes by ID, class, or shape"
|
||||
.to_string(),
|
||||
),
|
||||
source_path: Some(source_name.to_string()),
|
||||
..Diagnostic::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for ImportPrepareError {
|
||||
fn from(error: Error) -> Self {
|
||||
Self::Hard(error)
|
||||
|
|
@ -198,6 +215,10 @@ impl ImportTransform {
|
|||
))
|
||||
})?;
|
||||
|
||||
if !graph.model_stylesheet().is_empty() {
|
||||
diagnostics.push(imported_model_stylesheet_ignored_diagnostic(&source_name));
|
||||
}
|
||||
|
||||
let import_base_dir = resolved_file
|
||||
.path
|
||||
.parent()
|
||||
|
|
@ -822,6 +843,69 @@ mod tests {
|
|||
assert!(!rendered.contains("<string>"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_model_stylesheets_are_ignored_with_a_warning() {
|
||||
for stylesheet in [
|
||||
"* { reasoning_effort: high; }",
|
||||
"{% if inputs.deep %}* { reasoning_effort: high; }{% endif %}",
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_file(
|
||||
&dir.path().join("child.fabro"),
|
||||
&format!(
|
||||
r#"digraph Child {{
|
||||
graph [model_stylesheet="{stylesheet}"]
|
||||
start [shape=Mdiamond]
|
||||
work [prompt="Do it"]
|
||||
exit [shape=Msquare]
|
||||
start -> work -> exit
|
||||
}}"#,
|
||||
),
|
||||
);
|
||||
let graph = parse_graph(
|
||||
r#"digraph Test {
|
||||
start [shape=Mdiamond]
|
||||
child [import="./child.fabro"]
|
||||
exit [shape=Msquare]
|
||||
start -> child -> exit
|
||||
}"#,
|
||||
);
|
||||
|
||||
let (graph, diagnostics) = ImportTransform::new(
|
||||
dir.path().to_path_buf(),
|
||||
Arc::new(FilesystemFileResolver::new(None)),
|
||||
TemplateContext::new(),
|
||||
)
|
||||
.apply_with_diagnostics(graph)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
graph.nodes["child.work"]
|
||||
.attrs
|
||||
.get("reasoning_effort")
|
||||
.and_then(AttrValue::as_str),
|
||||
None
|
||||
);
|
||||
let warning = diagnostics
|
||||
.iter()
|
||||
.find(|diagnostic| diagnostic.rule == IMPORTED_MODEL_STYLESHEET_IGNORED_RULE)
|
||||
.expect("expected ignored imported stylesheet warning");
|
||||
assert!(
|
||||
warning
|
||||
.source_path
|
||||
.as_deref()
|
||||
.is_some_and(|path| path.ends_with("child.fabro")),
|
||||
"{warning:?}"
|
||||
);
|
||||
assert!(
|
||||
diagnostics
|
||||
.iter()
|
||||
.all(|diagnostic| diagnostic.rule != "detemplated_attribute"),
|
||||
"{diagnostics:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn basic_import_source() -> &'static str {
|
||||
r#"digraph validate {
|
||||
start [shape=Mdiamond]
|
||||
|
|
|
|||
|
|
@ -12,14 +12,17 @@ mod file_inlining;
|
|||
mod import;
|
||||
mod importable_field;
|
||||
mod model_resolution;
|
||||
mod model_stylesheet_template;
|
||||
mod preamble;
|
||||
pub mod stylesheet;
|
||||
mod stylesheet_application;
|
||||
pub mod variable_expansion;
|
||||
|
||||
pub use file_inlining::FileInliningTransform;
|
||||
pub(crate) use file_inlining::template_render_store;
|
||||
pub use import::ImportTransform;
|
||||
pub use model_resolution::ModelResolutionTransform;
|
||||
pub(crate) use model_stylesheet_template::ModelStylesheetTemplateTransform;
|
||||
pub use preamble::PreambleTransform;
|
||||
pub use stylesheet_application::StylesheetApplicationTransform;
|
||||
pub use variable_expansion::{RenderMode, ScriptInterpolationTransform, TemplateTransform};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
use fabro_graphviz::graph::{AttrValue, Graph};
|
||||
use fabro_template::TemplateContext;
|
||||
use fabro_validate::Diagnostic;
|
||||
|
||||
use super::Transform;
|
||||
use super::variable_expansion::{
|
||||
RenderMode, TemplateRenderOutcome, TemplateRenderStore, TemplateRenderTarget,
|
||||
render_template_for_target_outcome,
|
||||
};
|
||||
use crate::error::Error;
|
||||
|
||||
/// Renders the root graph's `model_stylesheet` with its restricted template
|
||||
/// context after imports are expanded and before stylesheet parsing.
|
||||
pub(crate) struct ModelStylesheetTemplateTransform {
|
||||
context: TemplateContext,
|
||||
source_name: Option<String>,
|
||||
source_text: Option<String>,
|
||||
render_mode: RenderMode,
|
||||
template_store: Option<TemplateRenderStore>,
|
||||
}
|
||||
|
||||
impl ModelStylesheetTemplateTransform {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
context: TemplateContext,
|
||||
source_name: Option<String>,
|
||||
source_text: Option<String>,
|
||||
render_mode: RenderMode,
|
||||
) -> Self {
|
||||
Self {
|
||||
context,
|
||||
source_name,
|
||||
source_text,
|
||||
render_mode,
|
||||
template_store: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn with_template_store(mut self, template_store: TemplateRenderStore) -> Self {
|
||||
self.template_store = Some(template_store);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn apply_with_diagnostics(
|
||||
&self,
|
||||
graph: Graph,
|
||||
) -> Result<(Graph, Vec<Diagnostic>), Error> {
|
||||
let stylesheet = graph.model_stylesheet();
|
||||
if stylesheet.is_empty() {
|
||||
return Ok((graph, Vec::new()));
|
||||
}
|
||||
|
||||
let mut target =
|
||||
TemplateRenderTarget::graph_attr(self.source_name.clone(), "model_stylesheet")
|
||||
.with_source_origin(self.source_text.as_deref(), stylesheet);
|
||||
if let Some(template_store) = self.template_store.clone() {
|
||||
target = target.with_template_store(template_store);
|
||||
}
|
||||
|
||||
let mut diagnostics = Vec::new();
|
||||
let outcome = render_template_for_target_outcome(
|
||||
stylesheet,
|
||||
&self.context.for_model_stylesheet(),
|
||||
self.render_mode,
|
||||
&target,
|
||||
&mut diagnostics,
|
||||
)?;
|
||||
let rendered = match outcome {
|
||||
TemplateRenderOutcome::Rendered(rendered) => rendered,
|
||||
// Do not feed raw or partly rendered MiniJinja source to the
|
||||
// stylesheet parser during structural validation.
|
||||
TemplateRenderOutcome::Unresolved => String::new(),
|
||||
};
|
||||
|
||||
let mut graph = graph;
|
||||
graph
|
||||
.attrs
|
||||
.insert("model_stylesheet".to_string(), AttrValue::String(rendered));
|
||||
Ok((graph, diagnostics))
|
||||
}
|
||||
}
|
||||
|
||||
impl Transform for ModelStylesheetTemplateTransform {
|
||||
fn apply(&self, graph: Graph) -> Result<Graph, Error> {
|
||||
let (graph, diagnostics) = self.apply_with_diagnostics(graph)?;
|
||||
if diagnostics.is_empty() {
|
||||
Ok(graph)
|
||||
} else {
|
||||
Err(Error::ValidationFailed { diagnostics })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use fabro_graphviz::graph::Graph;
|
||||
use fabro_util::error::collect_chain;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn graph_with_stylesheet(stylesheet: &str) -> Graph {
|
||||
let mut graph = Graph::new("test");
|
||||
graph.attrs.insert(
|
||||
"model_stylesheet".to_string(),
|
||||
AttrValue::String(stylesheet.to_string()),
|
||||
);
|
||||
graph
|
||||
}
|
||||
|
||||
fn transform(
|
||||
context: TemplateContext,
|
||||
stylesheet: &str,
|
||||
render_mode: RenderMode,
|
||||
) -> Result<(Graph, Vec<Diagnostic>), Error> {
|
||||
ModelStylesheetTemplateTransform::new(
|
||||
context,
|
||||
Some("workflow.fabro".to_string()),
|
||||
Some(format!(
|
||||
"digraph Test {{ graph [model_stylesheet=\"{stylesheet}\"] }}"
|
||||
)),
|
||||
render_mode,
|
||||
)
|
||||
.apply_with_diagnostics(graph_with_stylesheet(stylesheet))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_static_stylesheet_bytes() {
|
||||
let stylesheet = "\n * { reasoning_effort: low; }\n";
|
||||
|
||||
let (graph, diagnostics) =
|
||||
transform(TemplateContext::new(), stylesheet, RenderMode::Strict).unwrap();
|
||||
|
||||
assert!(diagnostics.is_empty());
|
||||
assert_eq!(graph.model_stylesheet(), stylesheet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_inputs_vars_control_flow_and_locals_once() {
|
||||
let stylesheet = r"{% set prefix = '.tier-' %}
|
||||
{% for effort in inputs.efforts %}
|
||||
{{ prefix }}{{ loop.index }} { reasoning_effort: {{ effort }}; }
|
||||
{% endfor %}
|
||||
.selected { model: {{ vars.MODEL }}; }
|
||||
.literal { model: {{ inputs.literal }}; }";
|
||||
let context = TemplateContext::new()
|
||||
.with_goal("must stay unavailable")
|
||||
.with_inputs(HashMap::from([
|
||||
(
|
||||
"efforts".to_string(),
|
||||
toml::Value::Array(vec![
|
||||
toml::Value::String("low".to_string()),
|
||||
toml::Value::String("high".to_string()),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"literal".to_string(),
|
||||
toml::Value::String("{{ vars.MODEL }}".to_string()),
|
||||
),
|
||||
]))
|
||||
.with_vars(HashMap::from([("MODEL".to_string(), "sonnet".to_string())]));
|
||||
|
||||
let (graph, diagnostics) = transform(context, stylesheet, RenderMode::Strict).unwrap();
|
||||
|
||||
assert!(diagnostics.is_empty());
|
||||
assert!(
|
||||
graph
|
||||
.model_stylesheet()
|
||||
.contains(".tier-1 { reasoning_effort: low; }")
|
||||
);
|
||||
assert!(
|
||||
graph
|
||||
.model_stylesheet()
|
||||
.contains(".tier-2 { reasoning_effort: high; }")
|
||||
);
|
||||
assert!(
|
||||
graph
|
||||
.model_stylesheet()
|
||||
.contains(".selected { model: sonnet; }")
|
||||
);
|
||||
assert!(
|
||||
graph
|
||||
.model_stylesheet()
|
||||
.contains(".literal { model: {{ vars.MODEL }}; }")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_undefined_value_clears_stylesheet_and_reports_context() {
|
||||
for (expression, expected_fix) in [
|
||||
("inputs.effort", "[run.inputs]"),
|
||||
("vars.MODEL", "fabro variable set MODEL"),
|
||||
("goal", "expose only `inputs` and `vars`"),
|
||||
("env.MODEL", "expose only `inputs` and `vars`"),
|
||||
("secrets.MODEL", "expose only `inputs` and `vars`"),
|
||||
] {
|
||||
let stylesheet = format!("* {{ model: {{{{ {expression} }}}}; }}");
|
||||
let (graph, diagnostics) = transform(
|
||||
TemplateContext::new().with_goal("hidden"),
|
||||
&stylesheet,
|
||||
RenderMode::Structural,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(graph.model_stylesheet(), "", "expression: {expression}");
|
||||
assert_eq!(diagnostics.len(), 1, "expression: {expression}");
|
||||
assert_eq!(diagnostics[0].rule, "template_undefined_variable");
|
||||
assert!(
|
||||
diagnostics[0]
|
||||
.message
|
||||
.contains("graph attribute `model_stylesheet`"),
|
||||
"{:?}",
|
||||
diagnostics[0]
|
||||
);
|
||||
assert!(
|
||||
diagnostics[0]
|
||||
.fix
|
||||
.as_deref()
|
||||
.is_some_and(|fix| fix.contains(expected_fix)),
|
||||
"{:?}",
|
||||
diagnostics[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_error_preserves_owner_and_source_chain() {
|
||||
let error = transform(
|
||||
TemplateContext::new(),
|
||||
"* { model: {% if %}; }",
|
||||
RenderMode::Strict,
|
||||
)
|
||||
.unwrap_err();
|
||||
let chain = collect_chain(&error).join(": ");
|
||||
|
||||
assert!(
|
||||
chain.contains("graph attribute `model_stylesheet`"),
|
||||
"{chain}"
|
||||
);
|
||||
assert!(chain.contains("template syntax error"), "{chain}");
|
||||
assert!(chain.contains("workflow.fabro"), "{chain}");
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ pub(crate) struct TemplateRenderTarget {
|
|||
pub node_id: Option<String>,
|
||||
pub edge: Option<(String, String)>,
|
||||
pub owner: String,
|
||||
attribute_name: String,
|
||||
source_origin: Option<TemplateSourceOrigin>,
|
||||
template_store: Option<TemplateRenderStore>,
|
||||
}
|
||||
|
|
@ -83,6 +84,7 @@ impl TemplateRenderTarget {
|
|||
node_id: None,
|
||||
edge: None,
|
||||
owner: format!("graph attribute `{attr_name}`"),
|
||||
attribute_name: attr_name,
|
||||
source_origin: None,
|
||||
template_store: None,
|
||||
}
|
||||
|
|
@ -101,6 +103,7 @@ impl TemplateRenderTarget {
|
|||
node_id: Some(node_id.clone()),
|
||||
edge: None,
|
||||
owner: format!("node `{node_id}` attribute `{attr_name}`"),
|
||||
attribute_name: attr_name,
|
||||
source_origin: None,
|
||||
template_store: None,
|
||||
}
|
||||
|
|
@ -121,6 +124,7 @@ impl TemplateRenderTarget {
|
|||
node_id: None,
|
||||
edge: Some((from.clone(), to.clone())),
|
||||
owner: format!("edge `{from} -> {to}` attribute `{attr_name}`"),
|
||||
attribute_name: attr_name,
|
||||
source_origin: None,
|
||||
template_store: None,
|
||||
}
|
||||
|
|
@ -154,6 +158,11 @@ impl TemplateRenderTarget {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) enum TemplateRenderOutcome {
|
||||
Rendered(String),
|
||||
Unresolved,
|
||||
}
|
||||
|
||||
pub(crate) fn render_template_for_target(
|
||||
text: &str,
|
||||
ctx: &TemplateContext,
|
||||
|
|
@ -161,18 +170,34 @@ pub(crate) fn render_template_for_target(
|
|||
target: &TemplateRenderTarget,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
) -> Result<String, Error> {
|
||||
match render_template_for_target_outcome(text, ctx, render_mode, target, diagnostics)? {
|
||||
TemplateRenderOutcome::Rendered(rendered) => Ok(rendered),
|
||||
TemplateRenderOutcome::Unresolved => {
|
||||
render_template_with_mode(text, ctx, TemplateRenderMode::Lenient, target)
|
||||
.map_err(|err| template_error_for_target(target, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_template_for_target_outcome(
|
||||
text: &str,
|
||||
ctx: &TemplateContext,
|
||||
render_mode: RenderMode,
|
||||
target: &TemplateRenderTarget,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
) -> Result<TemplateRenderOutcome, Error> {
|
||||
match render_mode {
|
||||
RenderMode::Strict => {
|
||||
render_template_with_mode(text, ctx, TemplateRenderMode::Strict, target)
|
||||
.map(TemplateRenderOutcome::Rendered)
|
||||
.map_err(|err| template_error_for_target(target, err))
|
||||
}
|
||||
RenderMode::Structural => {
|
||||
match render_template_with_mode(text, ctx, TemplateRenderMode::Strict, target) {
|
||||
Ok(rendered) => Ok(rendered),
|
||||
Ok(rendered) => Ok(TemplateRenderOutcome::Rendered(rendered)),
|
||||
Err(err @ TemplateError::UndefinedVariable { .. }) => {
|
||||
diagnostics.push(template_diagnostic(&err, target));
|
||||
render_template_with_mode(text, ctx, TemplateRenderMode::Lenient, target)
|
||||
.map_err(|err| template_error_for_target(target, err))
|
||||
Ok(TemplateRenderOutcome::Unresolved)
|
||||
}
|
||||
Err(err) => Err(template_error_for_target(target, err)),
|
||||
}
|
||||
|
|
@ -210,7 +235,6 @@ fn template_error_for_target(target: &TemplateRenderTarget, err: TemplateError)
|
|||
|
||||
fn template_diagnostic(error: &TemplateError, target: &TemplateRenderTarget) -> Diagnostic {
|
||||
let expression = error.expression();
|
||||
let name = expression.unwrap_or("<unknown>");
|
||||
let mut message = match expression {
|
||||
Some(expr) => format!("undefined template variable `{expr}`"),
|
||||
None => "undefined template variable".to_string(),
|
||||
|
|
@ -225,7 +249,7 @@ fn template_diagnostic(error: &TemplateError, target: &TemplateRenderTarget) ->
|
|||
message,
|
||||
node_id: target.node_id.clone(),
|
||||
edge: target.edge.clone(),
|
||||
fix: Some(input_binding_fix(name)),
|
||||
fix: Some(template_variable_fix(expression, target)),
|
||||
source_path: location.source_name.or_else(|| target.source_name.clone()),
|
||||
line: location.line,
|
||||
column: location.column,
|
||||
|
|
@ -235,6 +259,26 @@ fn template_diagnostic(error: &TemplateError, target: &TemplateRenderTarget) ->
|
|||
}
|
||||
}
|
||||
|
||||
fn template_variable_fix(expression: Option<&str>, target: &TemplateRenderTarget) -> String {
|
||||
let mut parts = expression.unwrap_or_default().split('.');
|
||||
let namespace = parts.next().unwrap_or_default();
|
||||
let name = parts.next().unwrap_or("<name>");
|
||||
|
||||
match namespace {
|
||||
"inputs" => input_binding_fix(name),
|
||||
"vars" => format!("set it with `fabro variable set {name} <value>`"),
|
||||
_ if target.attribute_name == "model_stylesheet" => {
|
||||
"`model_stylesheet` templates expose only `inputs` and `vars`; use one of those values or a MiniJinja local value"
|
||||
.to_string()
|
||||
}
|
||||
"goal" => "set a graph `goal` on the workflow".to_string(),
|
||||
"env" | "secrets" => {
|
||||
format!("`{namespace}` is not available in workflow templates")
|
||||
}
|
||||
_ => format!("define `{}` in the template context", expression.unwrap_or("the value")),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_binding_fix(name: &str) -> String {
|
||||
format!("bind `{name}` via `[run.inputs]` in workflow.toml, or pass `--input {name}=<value>`")
|
||||
}
|
||||
|
|
@ -382,16 +426,16 @@ fn detemplated_attribute_diagnostic(attr_name: &str, target: &TemplateRenderTarg
|
|||
severity: Severity::Warning,
|
||||
message: format!(
|
||||
"`{attr_name}` in {} is no longer a template; `{{{{ … }}}}` / `{{% … %}}` is treated \
|
||||
as literal text. Only node `prompt` and graph `goal` support templating, and node \
|
||||
command `script` supports `{{{{ goal }}}}`, `{{{{ inputs.* }}}}`, and \
|
||||
`{{{{ vars.* }}}}` interpolation.",
|
||||
as literal text. Node `prompt`, graph `goal`, and graph `model_stylesheet` support \
|
||||
templating. Node command `script` supports `{{{{ goal }}}}`, \
|
||||
`{{{{ inputs.* }}}}`, and `{{{{ vars.* }}}}` interpolation.",
|
||||
target.owner
|
||||
),
|
||||
node_id: target.node_id.clone(),
|
||||
edge: target.edge.clone(),
|
||||
fix: Some(format!(
|
||||
"remove the template syntax from `{attr_name}`, or move the dynamic value into a \
|
||||
`prompt`/`goal`"
|
||||
`prompt`/`goal`/`model_stylesheet`"
|
||||
)),
|
||||
source_path: target.source_name.clone(),
|
||||
..Diagnostic::default()
|
||||
|
|
@ -510,6 +554,12 @@ impl TemplateTransform {
|
|||
if matches!(scope, AttributeScope::Graph) && attr_name == "goal" {
|
||||
continue;
|
||||
}
|
||||
// The root model stylesheet has its own restricted template
|
||||
// pass after imports are expanded. Imported stylesheets stay
|
||||
// ignored and are diagnosed by ImportTransform.
|
||||
if matches!(scope, AttributeScope::Graph) && attr_name == "model_stylesheet" {
|
||||
continue;
|
||||
}
|
||||
if attr_name == "stack.child_dot_source" {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1691,6 +1691,66 @@ fn stylesheet_comments_apply_via_parsed_graph() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_stylesheet_template_renders_through_pipeline() {
|
||||
use fabro_workflow::pipeline::{TransformOptions, transform, validate};
|
||||
|
||||
let input = r#"digraph StyleTemplate {
|
||||
graph [
|
||||
goal="Review the change",
|
||||
model_stylesheet="
|
||||
* { reasoning_effort: low; }
|
||||
{% for effort in inputs.efforts %}
|
||||
.tier-{{ loop.index }} { reasoning_effort: {{ effort }}; }
|
||||
{% endfor %}
|
||||
"
|
||||
]
|
||||
start [shape=Mdiamond]
|
||||
baseline [prompt="Baseline"]
|
||||
selected [prompt="Selected", class="tier-2"]
|
||||
exit [shape=Msquare]
|
||||
start -> baseline -> selected -> exit
|
||||
}"#;
|
||||
let parsed = fabro_workflow::pipeline::parse(input).expect("parse should succeed");
|
||||
let transformed = transform(parsed, &TransformOptions {
|
||||
current_dir: None,
|
||||
file_resolver: None,
|
||||
template_context: fabro_template::TemplateContext::new().with_inputs(
|
||||
std::collections::HashMap::from([(
|
||||
"efforts".to_string(),
|
||||
toml::Value::Array(vec![
|
||||
toml::Value::String("medium".to_string()),
|
||||
toml::Value::String("high".to_string()),
|
||||
]),
|
||||
)]),
|
||||
),
|
||||
source_name: Some("style-template.fabro".to_string()),
|
||||
render_mode: fabro_workflow::operations::RenderMode::Structural,
|
||||
custom_transforms: vec![],
|
||||
model_resolution: None,
|
||||
})
|
||||
.expect("transform should succeed");
|
||||
let validated = validate(transformed, None, &[]);
|
||||
validated
|
||||
.raise_on_errors()
|
||||
.expect("rendered stylesheet should validate");
|
||||
|
||||
assert_eq!(
|
||||
validated.graph().nodes["baseline"]
|
||||
.attrs
|
||||
.get("reasoning_effort")
|
||||
.and_then(AttrValue::as_str),
|
||||
Some("low")
|
||||
);
|
||||
assert_eq!(
|
||||
validated.graph().nodes["selected"]
|
||||
.attrs
|
||||
.get("reasoning_effort")
|
||||
.and_then(AttrValue::as_str),
|
||||
Some("high")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_application_matches_space_separated_classes_from_dot() {
|
||||
let input = r#"digraph StyleTest {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,21 @@ impl TemplateContext {
|
|||
self
|
||||
}
|
||||
|
||||
/// Context exposed to a graph `model_stylesheet` template.
|
||||
///
|
||||
/// Stylesheets can use typed run inputs and run-scoped variables, but they
|
||||
/// cannot read the rendered graph goal. Keep this as an explicit
|
||||
/// projection so callers do not depend on when the goal enters the wider
|
||||
/// workflow template context.
|
||||
#[must_use]
|
||||
pub fn for_model_stylesheet(&self) -> Self {
|
||||
Self {
|
||||
goal: None,
|
||||
inputs: self.inputs.clone(),
|
||||
vars: self.vars.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Context that interpolates inputs but leaves `{{ goal }}` as a literal
|
||||
/// pass-through — used for structural pre-rendering before the goal is
|
||||
/// known (e.g. manifest scanning, import resolution).
|
||||
|
|
@ -867,6 +882,42 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_stylesheet_context_exposes_only_inputs_and_vars() {
|
||||
let ctx = TemplateContext::new()
|
||||
.with_goal("Ship it")
|
||||
.with_inputs(HashMap::from([(
|
||||
"policy".to_string(),
|
||||
toml::Value::Table(Map::from_iter([(
|
||||
"effort".to_string(),
|
||||
toml::Value::String("high".to_string()),
|
||||
)])),
|
||||
)]))
|
||||
.with_vars(HashMap::from([("MODEL".to_string(), "sonnet".to_string())]));
|
||||
let stylesheet_ctx = ctx.for_model_stylesheet();
|
||||
|
||||
assert_eq!(
|
||||
render(
|
||||
"{{ inputs.policy.effort }} {{ vars.MODEL }}",
|
||||
&stylesheet_ctx,
|
||||
)
|
||||
.unwrap(),
|
||||
"high sonnet"
|
||||
);
|
||||
assert!(matches!(
|
||||
render("{{ goal }}", &stylesheet_ctx),
|
||||
Err(TemplateError::UndefinedVariable { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_stylesheet_context_preserves_template_locals_and_control_flow() {
|
||||
let ctx = TemplateContext::new().for_model_stylesheet();
|
||||
let template = "{% set efforts = ['low', 'high'] %}{% for effort in efforts %}{{ loop.index }}={{ effort }}{% if not loop.last %};{% endif %}{% endfor %}";
|
||||
|
||||
assert_eq!(render(template, &ctx).unwrap(), "1=low;2=high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_top_level_variable_detects_goal_self_reference() {
|
||||
assert!(references_top_level_variable("Do {{ goal }} now", "goal"));
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
//!
|
||||
//! Workflow graphs name other files through a fixed attribute vocabulary
|
||||
//! (`import`, `stack.child_workflow`, `@`-prefixed `prompt`/`output_schema`
|
||||
//! values, and the graph `goal`). These references are *static*: they may not
|
||||
//! contain template syntax, because they are resolved before any template
|
||||
//! rendering happens.
|
||||
//! values, the graph `goal`, and inline root template fields such as
|
||||
//! `model_stylesheet`). File references are *static*: they may not contain
|
||||
//! template syntax, because they are resolved before template rendering.
|
||||
//!
|
||||
//! [`visit_graph_references`] is the one walker over that vocabulary. The
|
||||
//! manifest bundler and workflow-version validation both consume it, so a new
|
||||
|
|
@ -68,6 +68,12 @@ pub enum GraphReference<'graph> {
|
|||
GoalFile { reference: &'graph str },
|
||||
/// A non-`@` graph `goal`: inline template content.
|
||||
GoalInline { content: &'graph str },
|
||||
/// The root graph's inline `model_stylesheet` template content.
|
||||
///
|
||||
/// Consumers that recurse through imported graphs decide whether the
|
||||
/// visited graph is the workflow entrypoint before treating this as a
|
||||
/// template root.
|
||||
ModelStylesheetInline { content: &'graph str },
|
||||
/// `node [import="<reference>"]` — another graph file to walk.
|
||||
Import { reference: &'graph str },
|
||||
/// `node [stack.child_workflow="<reference>"]`.
|
||||
|
|
@ -112,6 +118,14 @@ pub fn visit_graph_references<'graph, E>(
|
|||
}
|
||||
}
|
||||
|
||||
let model_stylesheet = graph.model_stylesheet();
|
||||
if !model_stylesheet.is_empty() {
|
||||
visit(GraphReference::ModelStylesheetInline {
|
||||
content: model_stylesheet,
|
||||
})
|
||||
.map_err(GraphReferenceError::Visit)?;
|
||||
}
|
||||
|
||||
for node in graph.nodes.values() {
|
||||
for (key, value) in &node.attrs {
|
||||
let Some(value) = value.as_str() else {
|
||||
|
|
@ -191,6 +205,10 @@ mod tests {
|
|||
"goal".to_string(),
|
||||
AttrValue::String("@goal.md".to_string()),
|
||||
);
|
||||
graph.attrs.insert(
|
||||
"model_stylesheet".to_string(),
|
||||
AttrValue::String("{% include 'styles.partial' %}".to_string()),
|
||||
);
|
||||
for node in [
|
||||
node_with("imported", &[("import", "graphs/child.fabro")]),
|
||||
node_with("child", &[("stack.child_workflow", "children/check.fabro")]),
|
||||
|
|
@ -207,6 +225,9 @@ mod tests {
|
|||
seen.insert(match reference {
|
||||
GraphReference::GoalFile { reference } => format!("goal-file:{reference}"),
|
||||
GraphReference::GoalInline { content } => format!("goal-inline:{content}"),
|
||||
GraphReference::ModelStylesheetInline { content } => {
|
||||
format!("stylesheet-inline:{content}")
|
||||
}
|
||||
GraphReference::Import { reference } => format!("import:{reference}"),
|
||||
GraphReference::ChildWorkflow { reference } => format!("child:{reference}"),
|
||||
GraphReference::FileInline { key, reference } => {
|
||||
|
|
@ -227,6 +248,7 @@ mod tests {
|
|||
"child:children/check.fabro".to_string(),
|
||||
"file:prompt:prompts/task.md".to_string(),
|
||||
"inline:Do the {{ thing }}".to_string(),
|
||||
"stylesheet-inline:{% include 'styles.partial' %}".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
|
|
|||
12
test/model_stylesheet_unbound.fabro
Normal file
12
test/model_stylesheet_unbound.fabro
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
digraph ModelStylesheetUnbound {
|
||||
graph [
|
||||
model_stylesheet="
|
||||
* { reasoning_effort: {{ inputs.effort }}; }
|
||||
"
|
||||
]
|
||||
|
||||
start [shape=Mdiamond]
|
||||
work [prompt="Do work"]
|
||||
exit [shape=Msquare]
|
||||
start -> work -> exit
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue