Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,16 @@ jobs:
# everyone else's fixture (#431). This is the guard that keeps it fixed.
if: always()
run: |
if ! git diff --quiet; then
echo "::error::The test suite modified tracked files:"
git diff --stat
# --untracked-files=all, not `git diff`: a diff only sees
# modifications to tracked files, so a test could still scatter new
# files through the checkout and the guard would pass.
# legacy-results.txt is written by the step above, on purpose. It is
# the only expected artifact; anything else is a test misbehaving.
dirt=$(git status --porcelain --untracked-files=all \
| grep -v ' legacy-results\.txt$' || true)
if [ -n "$dirt" ]; then
echo "::error::The test suite left the repository dirty:"
echo "$dirt"
echo
echo "Tests must write generated files to a temp directory"
echo "(pytest's tmp_path), never into the repository."
Expand Down
83 changes: 83 additions & 0 deletions docs/actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<!-- Generated by scripts/generate_action_docs.py. Do not edit by hand. -->

# Action vocabulary

A step names **either** a tool and an operation on it:

```yaml
- id: save
tool: filesystem
action: write
parameters:
path: out/report.md
content: "..."
```

**or** an action the runtime executes itself:

```yaml
- id: think
action: generate
parameters:
prompt: "Summarise the findings"
```

This page lists the second group. It is generated from
`orchestrator.core.actions`, the single registry the executor dispatches on and
the validator recognises, so it cannot fall out of step with the code.

An action that appears nowhere on this page is **refused**. It is not turned
into a prompt for the model: `action: gernate` is a typo and fails, at compile
time and again at dispatch.

## When does a pipeline fail, and with which exit code?

| Situation | Detected | Exit code |
|-|-|-|
| Malformed YAML, unknown action, missing required parameter, dependency on a step that does not exist, template that can never resolve | compile time (`orchestrator validate`) | **2** |
| A reference expected to resolve from an earlier step's result, still unresolved at the moment a tool would use it | run time, before any side effect | **1** |
| A step raising during execution | run time | **1** |

The distinction matters because template resolution runs in several passes. A
reference to a prior step cannot be resolved before that step has run, so it is
not a compile error -- but it must not survive to the point where a value is
written to a file or sent to a model. At that boundary it fails the step, no
output is written, and the run exits 1.


## Actions

| Action | Requires | Required parameters | Aliases | Description |
|-|-|-|-|-|
| `action_loop` | — | — | — | Run a sequence of actions repeatedly until a condition holds. |
| `analyze_text` | model | — | `analyze` | Analyse supplied text with the selected model. |
| `capture_result` | — | — | — | Capture a prior step's result under a new name. |
| `control_flow` | — | — | — | Control-flow marker step. |
| `create_parallel_queue` | — | — | — | Fan work out across a parallel queue. |
| `evaluate_condition` | — | `condition` | — | Evaluate a condition expression and record the verdict. |
| `filesystem` | tool | — | `file` | Filesystem operation named by the step's `action` parameter. |
| `generate` | model | `prompt` | `generate-text`, `generate_text` | Generate text from a prompt using the selected model. |
| `generate_structured` | model | `prompt`, `schema` | `generate-structured` | Generate an object conforming to a declared JSON Schema. |
| `loop_complete` | — | — | — | Marker emitted when a loop finishes. |
| `process` | tool | — | — | Transform data with the deterministic data-processing tool. |
| `validate` | tool | — | — | Validate data against a schema with the validation tool. |

Aliases are accepted but deprecated: the compiler rewrites them to the
canonical name, emits a `DeprecationWarning`, and only the canonical
spelling appears in the task graph and the execution trace.

## Prose families

Two shapes are matched by pattern rather than by name, because they are
written as instructions rather than identifiers.

| Family | Description |
|-|-|
| `auto` | An explicit request for the model to interpret the instruction, e.g. `action: <AUTO>summarise the findings</AUTO>`. |
| `echo` | Print a message, e.g. `action: echo "starting"`. |
| `file_prose` | Filesystem instruction written as prose, e.g. `action: write the following content to report.md`. |

`<AUTO>...</AUTO>` is the one case where an instruction is deliberately
handed to the model to interpret. That is an explicit request, which is why
it remains supported while the implicit "unrecognised action becomes a
prompt" behaviour does not.
32 changes: 28 additions & 4 deletions docs/adr/0001-product-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,34 @@ Exit codes: `0` success, `1` execution failure, `2` validation/compile failure,

A step names either a tool and an operation on it (`tool: filesystem` with
`action: read`), or an action the runtime executes itself (`action: generate`).
`core/actions.py` is the single source of truth for the second group:
`HybridControlSystem` dispatches off it and `ToolValidator` accepts exactly the
names in it, so `validate` and `run` cannot disagree about whether an action
exists (#241).
`core/actions.py` is the single source of truth for the second group. Each
action is an `ActionSpec` carrying its canonical name, aliases, handler,
whether it needs a model or a tool, its required parameters and its result
schema. Everything else is *derived* from that registry rather than restated
beside it:

| Consumer | Derived as |
|-|-|
| Executor dispatch | `resolve_action(...).handler` |
| Validator recognition | `is_known_action(...)` |
| Advertised `supported_actions` | `SUPPORTED_ACTIONS` |
| Alias normalisation | `canonical_action(...)`, applied by the compiler |
| Documentation | `docs/actions.md`, generated and drift-tested |

So `validate` and `run` cannot disagree about whether an action exists (#241),
and the vocabulary cannot drift apart again.

**An unrecognised action is refused.** It used to become a prompt for the
model, so `action: gernate` returned a plausible answer and reported success.
It now fails at compile time, and again at dispatch — the runtime checks
independently, because a caller can construct a `Task` and reach execution
without going through YAML validation at all. `<AUTO>...</AUTO>` remains
supported: that is an author explicitly asking the model to interpret an
instruction, which is not the same as a typo falling through.

Aliases are accepted but deprecated. The compiler rewrites them to the
canonical name and warns, so exactly one spelling reaches the task graph and
the trace.

Template rendering deliberately falls back to returning the original text when
a reference is undefined, because resolution runs in several passes and a
Expand Down
134 changes: 134 additions & 0 deletions scripts/generate_action_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Generate docs/actions.md from the action registry.

The vocabulary is documented from the same object the executor dispatches on,
so the documentation cannot describe an action that does not exist or miss one
that does. `tests/test_action_contract_docs.py` re-runs this and fails if the
committed file differs, which is what stops the two drifting apart.

python scripts/generate_action_docs.py # write docs/actions.md
python scripts/generate_action_docs.py --check # exit 1 if out of date
"""

from __future__ import annotations

import argparse
import pathlib
import sys

ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))

from orchestrator.core.actions import ACTION_FAMILIES, ACTION_SPECS # noqa: E402

TARGET = ROOT / "docs" / "actions.md"

HEADER = """<!-- Generated by scripts/generate_action_docs.py. Do not edit by hand. -->

# Action vocabulary

A step names **either** a tool and an operation on it:

```yaml
- id: save
tool: filesystem
action: write
parameters:
path: out/report.md
content: "..."
```

**or** an action the runtime executes itself:

```yaml
- id: think
action: generate
parameters:
prompt: "Summarise the findings"
```

This page lists the second group. It is generated from
`orchestrator.core.actions`, the single registry the executor dispatches on and
the validator recognises, so it cannot fall out of step with the code.

An action that appears nowhere on this page is **refused**. It is not turned
into a prompt for the model: `action: gernate` is a typo and fails, at compile
time and again at dispatch.

## When does a pipeline fail, and with which exit code?

| Situation | Detected | Exit code |
|-|-|-|
| Malformed YAML, unknown action, missing required parameter, dependency on a step that does not exist, template that can never resolve | compile time (`orchestrator validate`) | **2** |
| A reference expected to resolve from an earlier step's result, still unresolved at the moment a tool would use it | run time, before any side effect | **1** |
| A step raising during execution | run time | **1** |

The distinction matters because template resolution runs in several passes. A
reference to a prior step cannot be resolved before that step has run, so it is
not a compile error -- but it must not survive to the point where a value is
written to a file or sent to a model. At that boundary it fails the step, no
output is written, and the run exits 1.

"""


def render() -> str:
lines = [HEADER, "## Actions\n"]
lines.append("| Action | Requires | Required parameters | Aliases | Description |")
lines.append("|-|-|-|-|-|")
for spec in sorted(ACTION_SPECS, key=lambda s: s.name):
aliases = ", ".join(f"`{a}`" for a in sorted(spec.aliases)) or "—"
required = ", ".join(f"`{p}`" for p in sorted(spec.required_parameters)) or "—"
requires = "—" if spec.requires == "none" else spec.requires
lines.append(
f"| `{spec.name}` | {requires} | {required} | {aliases} | {spec.summary} |"
)

lines.append("")
lines.append("Aliases are accepted but deprecated: the compiler rewrites them to the")
lines.append("canonical name, emits a `DeprecationWarning`, and only the canonical")
lines.append("spelling appears in the task graph and the execution trace.")
lines.append("")

lines.append("## Prose families\n")
lines.append(
"Two shapes are matched by pattern rather than by name, because they are\n"
"written as instructions rather than identifiers.\n"
)
lines.append("| Family | Description |")
lines.append("|-|-|")
for family in sorted(ACTION_FAMILIES, key=lambda f: f.name):
lines.append(f"| `{family.name}` | {family.summary} |")
lines.append("")
lines.append(
"`<AUTO>...</AUTO>` is the one case where an instruction is deliberately\n"
"handed to the model to interpret. That is an explicit request, which is why\n"
"it remains supported while the implicit \"unrecognised action becomes a\n"
"prompt\" behaviour does not.\n"
)
return "\n".join(lines)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="verify, do not write")
args = parser.parse_args()

rendered = render()
if args.check:
current = TARGET.read_text() if TARGET.exists() else ""
if current != rendered:
print(f"{TARGET.relative_to(ROOT)} is out of date.", file=sys.stderr)
print("Regenerate with: python scripts/generate_action_docs.py", file=sys.stderr)
return 1
print(f"{TARGET.relative_to(ROOT)} is up to date.")
return 0

TARGET.parent.mkdir(parents=True, exist_ok=True)
TARGET.write_text(rendered)
print(f"wrote {TARGET.relative_to(ROOT)}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
2 changes: 1 addition & 1 deletion scripts/utilities/organization_maintenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,7 @@ def generate_maintenance_documentation(self) -> Path:
"",
f"## System Status (as of {datetime.now().strftime('%Y-%m-%d %H:%M:%S')})",
"",
]
])

# Add current system status
try:
Expand Down
33 changes: 33 additions & 0 deletions src/orchestrator/compiler/yaml_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

import logging
import re
import warnings
from typing import Any, Dict, List, Optional

import yaml
from jinja2 import Environment, StrictUndefined

from ..core.actions import canonical_action
from ..core.pipeline import Pipeline
from ..core.task import Task
from ..core.template_metadata import TemplateMetadata
Expand Down Expand Up @@ -247,6 +249,17 @@ async def compile(
if self.validation_report.has_errors and self.validation_level == ValidationLevel.STRICT:
# Create comprehensive error message from validation report
error_message = self.validation_report.format_report(format_type=OutputFormat.SUMMARY)
# The SUMMARY format reports counts by category -- "1
# errors, tool: 1" -- which tells an author that something
# is wrong but not what. Lead with the actual messages so
# the exception alone is enough to fix the pipeline.
detail = "\n".join(
f" - {issue.message}"
for issue in self.validation_report.issues
if issue.severity is ValidationSeverity.ERROR
)
if detail:
error_message = f"{detail}\n\n{error_message}"
raise YAMLCompilerError(f"Pipeline validation failed:\n{error_message}")

# Log validation summary
Expand Down Expand Up @@ -1389,6 +1402,26 @@ def _build_task(self, task_def: Dict[str, Any], available_steps: List[str]) -> T
metadata["is_while_loop"] = True
metadata["while_condition"] = task_def["while"]

# Normalise action aliases so exactly one spelling reaches the task
# graph, the trace and every downstream consumer. Steps that name a
# `tool:` are left alone -- their `action` is an operation on that
# tool, not an entry in the action vocabulary.
if isinstance(action, str) and "tool" not in task_def:
canonical = canonical_action(action)
if canonical is not None and canonical != action.strip().lower():
warnings.warn(
f"Task '{task_id}': action '{action}' is a deprecated "
f"alias of '{canonical}'. Support for the alias will be "
f"removed; write '{canonical}' instead.",
DeprecationWarning,
stacklevel=2,
)
logger.warning(
"Task '%s': normalising deprecated action alias '%s' -> '%s'",
task_id, action, canonical,
)
action = canonical

# Analyze templates in parameters
template_metadata = self._analyze_parameter_templates(parameters, available_steps)

Expand Down
Loading
Loading