From 9330c496319a1bc678b4d8afba332db048256626 Mon Sep 17 00:00:00 2001 From: Jeremy Manning Date: Sat, 1 Aug 2026 19:26:50 -0400 Subject: [PATCH] Close the action contract: one registry, no prose fallback (#241 follow-up) The vocabulary had three disagreeing definitions. #241 reconciled the first pair -- the executor's dispatch chain and the validator -- but left: - ModelBasedControlSystem.supported_actions, advertising transform, search, extract, filter, synthesize, create, optimize, review, write and compile, none of which had a handler anywhere; - an implicit fallback turning ANY unrecognised action into a prompt, so `action: gernate` was not an error. It became a model call and reported success. core/actions.py is now a registry of ActionSpec objects carrying canonical name, aliases, handler, whether a model or tool is required, required parameters, result schema and v1 membership. Dispatch, validator recognition, supported_actions, alias normalisation and the documentation are all *derived* from it. None is maintained by hand, so none can drift. Unknown actions are refused twice: at compile time, and again at dispatch, because a caller can build a Task and reach execution without going through YAML at all. ... stays supported -- an author explicitly asking the model to interpret an instruction is not the same as a typo falling through -- and is now a declared ActionFamily rather than an accident of the fallback. `echo ...` and the prose filesystem forms are declared the same way; their regexes moved into the registry and the two now-unused predicate methods are gone. Aliases are normalised by the compiler with a DeprecationWarning, so one spelling reaches the task graph and the trace. This exposed `generate-text`, a hyphen slip of `generate_text` used six times in the examples that had never dispatched -- the same defect class as `generate_structured` in #437. Three real bugs fell out of writing the tests: - Registered actions whose handler lives one level down (i.e. generate_structured) hit the new raise instead of delegating. - The filesystem handler keys off a hardcoded list containing "file" but not "filesystem", so normalising the alias sent canonical steps down a default path that tried to write to the read-only root. - Every failed step recorded the error handler's *recovery decision* -- {"action": "retry", "delay": 5.0, ...} -- as its error, so the reason a step failed was discarded system-wide and Task.error was not even an exception. The original error is now recorded, with the recovery decision kept beside it, and a compile failure leads with the actual messages rather than "Issues: 1 errors". Also fixed: a pre-existing SyntaxError in scripts/utilities/organization_maintenance.py -- `extend([` closed with `]` -- found by extending the correctness lint to scripts/. That file could not be imported at all. The CI cleanliness guard now uses `git status --porcelain --untracked-files=all`, since `git diff` cannot see newly created files; legacy-results.txt is excluded as the one expected artifact. docs/actions.md is generated by scripts/generate_action_docs.py and drift-tested, and documents the exit-code boundary: compile-time failures exit 2, runtime template and execution failures exit 1. tests/test_builtin_actions.py is folded into tests/test_action_contract.py (53 tests), whose assertions subsumed it once the registry became authoritative. Blocking suite 456 -> 476 passed, 13 skipped, 0 failed, 0 xfailed. Collection 3251 -> 3271. Examples validating unchanged at 3 of 111, so tightening the vocabulary cost no currently-valid pipeline. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 13 +- docs/actions.md | 83 ++++ docs/adr/0001-product-contract.md | 32 +- scripts/generate_action_docs.py | 134 ++++++ scripts/utilities/organization_maintenance.py | 2 +- src/orchestrator/compiler/yaml_compiler.py | 33 ++ .../control_systems/hybrid_control_system.py | 117 ++--- .../model_based_control_system.py | 40 +- src/orchestrator/core/actions.py | 365 +++++++++++++--- src/orchestrator/core/exceptions.py | 24 + src/orchestrator/orchestrator.py | 33 +- src/orchestrator/validation/tool_validator.py | 36 +- tests/test_action_contract.py | 413 ++++++++++++++++++ tests/test_action_contract_docs.py | 62 +++ tests/test_builtin_actions.py | 176 -------- 15 files changed, 1224 insertions(+), 339 deletions(-) create mode 100644 docs/actions.md create mode 100644 scripts/generate_action_docs.py create mode 100644 tests/test_action_contract.py create mode 100644 tests/test_action_contract_docs.py delete mode 100644 tests/test_builtin_actions.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2d12e03..dec175de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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." diff --git a/docs/actions.md b/docs/actions.md new file mode 100644 index 00000000..c4f6069e --- /dev/null +++ b/docs/actions.md @@ -0,0 +1,83 @@ + + +# 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: summarise the findings`. | +| `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`. | + +`...` 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. diff --git a/docs/adr/0001-product-contract.md b/docs/adr/0001-product-contract.md index cfd8cf9e..5081851d 100644 --- a/docs/adr/0001-product-contract.md +++ b/docs/adr/0001-product-contract.md @@ -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. `...` 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 diff --git a/scripts/generate_action_docs.py b/scripts/generate_action_docs.py new file mode 100644 index 00000000..80275228 --- /dev/null +++ b/scripts/generate_action_docs.py @@ -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 = """ + +# 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( + "`...` 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()) diff --git a/scripts/utilities/organization_maintenance.py b/scripts/utilities/organization_maintenance.py index f6433e9d..183eae6d 100644 --- a/scripts/utilities/organization_maintenance.py +++ b/scripts/utilities/organization_maintenance.py @@ -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: diff --git a/src/orchestrator/compiler/yaml_compiler.py b/src/orchestrator/compiler/yaml_compiler.py index 2e5929ed..345fcd42 100644 --- a/src/orchestrator/compiler/yaml_compiler.py +++ b/src/orchestrator/compiler/yaml_compiler.py @@ -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 @@ -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 @@ -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) diff --git a/src/orchestrator/control_systems/hybrid_control_system.py b/src/orchestrator/control_systems/hybrid_control_system.py index ed925d29..a681c7ea 100644 --- a/src/orchestrator/control_systems/hybrid_control_system.py +++ b/src/orchestrator/control_systems/hybrid_control_system.py @@ -7,7 +7,12 @@ from datetime import datetime from .model_based_control_system import ModelBasedControlSystem -from ..core.actions import BUILTIN_ACTION_HANDLERS +from ..core.actions import ( + SUPPORTED_ACTIONS, + match_action_family, + resolve_action, +) +from ..core.exceptions import UnknownActionError from ..core.task import Task from ..core.action_loop_task import ActionLoopTask from ..core.expressions import ( @@ -70,28 +75,9 @@ def __init__( if config is None: config = { "capabilities": { - "supported_actions": [ - # Model-based actions - "generate", - "analyze", - "transform", - "execute", - "search", - "extract", - "filter", - "synthesize", - "create", - "validate", - "optimize", - "review", - # File operations - "save_output", - "save_to_file", - "write_file", - "read_file", - "save", - "write", - ], + # Generated from core/actions.py, so this can no + # longer advertise an action nobody implements. + "supported_actions": list(SUPPORTED_ACTIONS), "parallel_execution": True, "streaming": True, "checkpoint_support": True, @@ -167,22 +153,34 @@ async def _execute_task_impl(self, task: Task, context: Dict[str, Any]) -> Any: # keeping a parallel chain of `if action_str == ...` branches -- is # what stops it from drifting out of step with the validator, which # accepts exactly the same names (#241). - handler_name = BUILTIN_ACTION_HANDLERS.get(action_str.strip()) - if handler_name: - logger.debug("Routing to built-in action handler: %s", handler_name) - return await getattr(self, handler_name)(task, context) - - # The remaining two families are matched by pattern, not by name: an - # action like "echo hello" or "write the following content to report.md" - # is prose, so there is no name to register. - if self._is_echo_operation(action_str): - return await self._handle_echo_operation(task, context) + spec = resolve_action(action_str) + if spec is not None: + if spec.handler is None: + # Registered, but run one level down -- `generate_structured` + # is ModelBasedControlSystem's. Falling through to the raise + # below would reject a perfectly valid action. + logger.debug("Delegating registered action %r to the model layer", spec.name) + return await super()._execute_task_impl(task, context) + logger.debug("Routing to built-in action handler: %s", spec.handler) + return await getattr(self, spec.handler)(task, context) - if self._is_file_operation(action_str): - return await self._handle_file_operation(task, context) + # The remaining families are matched by pattern, not by name: "echo + # hello", "write the following content to report.md", and an explicit + # ... instruction are prose, so there is no name to + # register. `auto` carries no handler -- it is the one case that is + # *meant* to reach the model. + family = match_action_family(str(task.action)) + if family is not None: + if family.handler is None: + logger.debug("Routing %r to the model via the 'auto' family", task.action) + return await super()._execute_task_impl(task, context) + logger.debug("Routing to action family %r: %s", family.name, family.handler) + return await getattr(self, family.handler)(task, context) - # Otherwise use model-based execution - return await super()._execute_task_impl(task, context) + # Nothing can run this. Previously execution fell through to the model, + # which turned the action itself into a prompt -- so `action: gernate` + # became a successful model call rather than a typo (#241 follow-up). + raise UnknownActionError(str(task.action), task_id=task.id) async def _handle_tool_execution(self, task: Task, tool_name: str, context: Dict[str, Any]) -> Any: """Handle execution with a specific tool.""" @@ -223,42 +221,6 @@ async def _handle_tool_execution(self, task: Task, tool_name: str, context: Dict available_tools = ", ".join(tool_handlers.keys()) raise ValueError(f"Tool '{tool_name}' not found in hybrid control system. Available tools: {available_tools}") - def _is_file_operation(self, action: str) -> bool: - """Check if action is a file operation.""" - # Simple check for 'file' action - if action.strip() == "file": - return True - - # More specific patterns that indicate file operations - file_patterns = [ - r"write.*to\s+(?:a\s+)?(?:file|path)", # Write ... to file/path - r"save.*to\s+(?:a\s+)?(?:file|path)", # Save ... to file/path - r"write.*following.*content.*to", # Write the following content to - r"save.*following.*content.*to", # Save the following content to - r"create.*file\s+at", # Create file at - r"export.*to\s+file", # Export to file - r"store.*in\s+file", # Store in file - r"write.*to\s+[^\s]+\.(txt|md|json|yaml|yml|csv|html)", # Write to filename.ext - r"save.*to\s+[^\s]+\.(txt|md|json|yaml|yml|csv|html)", # Save to filename.ext - ] - return any( - re.search(pattern, action, re.IGNORECASE | re.DOTALL) - for pattern in file_patterns - ) - - def _is_echo_operation(self, action: str) -> bool: - """Check if action is a simple echo/print operation.""" - echo_patterns = [ - r"^echo\s+", - r"^print\s+", - r"^display\s+", - r"^show\s+", - r"^output\s+", - ] - return any( - re.match(pattern, action, re.IGNORECASE) for pattern in echo_patterns - ) - async def _handle_echo_operation( self, task: Task, context: Dict[str, Any] ) -> Dict[str, Any]: @@ -480,7 +442,14 @@ async def _handle_file_operation( return await self.filesystem_tool.execute(**resolved_params) # If the action is a known filesystem operation, use FileSystemTool - filesystem_actions = ["read", "write", "copy", "move", "delete", "list", "file"] + # Mixes operations (read, write, ...) with names for the tool itself. + # `filesystem` is the canonical spelling of the latter and `file` its + # alias; both must appear or a step reaching this branch under its + # canonical name silently falls through to the default below. + filesystem_actions = [ + "read", "write", "copy", "move", "delete", "list", + "file", "filesystem", + ] if action_text in filesystem_actions and task.parameters: # Use UnifiedTemplateResolver for template resolution resolved_params = task.parameters.copy() diff --git a/src/orchestrator/control_systems/model_based_control_system.py b/src/orchestrator/control_systems/model_based_control_system.py index 451dda56..11e34af8 100644 --- a/src/orchestrator/control_systems/model_based_control_system.py +++ b/src/orchestrator/control_systems/model_based_control_system.py @@ -6,7 +6,12 @@ import re from typing import Any, Dict, List, Optional -from ..core.actions import STRUCTURED_ACTIONS +from ..core.actions import ( + STRUCTURED_ACTIONS, + SUPPORTED_ACTIONS, + is_known_action, +) +from ..core.exceptions import UnknownActionError from ..core.control_system import ControlSystem from ..core.pipeline import Pipeline from ..core.task import Task @@ -37,25 +42,9 @@ def __init__( if config is None: config = { "capabilities": { - "supported_actions": [ - "generate", - "generate_text", - "generate_structured", - "analyze", - "transform", - "execute", - "search", - "extract", - "filter", - "synthesize", - "create", - "validate", - "optimize", - "review", - "write", - "compile", - "process", - ], + # Generated from core/actions.py, so this can no + # longer advertise an action nobody implements. + "supported_actions": list(SUPPORTED_ACTIONS), "parallel_execution": True, "streaming": True, "checkpoint_support": True, @@ -217,9 +206,18 @@ async def _execute_task_impl(self, task: Task, context: Dict[str, Any]) -> Any: elif analysis_type == "trends": prompt += "\n\nIdentify and analyze key trends, patterns, and insights from this data." else: - # For other actions, use the action as the prompt action_text = str(task.action) # Convert to string in case it's not + # Anything reaching here is being turned into a prompt, which is + # only ever correct for an explicit ... instruction. + # It used to be the fate of *every* unrecognised action, so a typo + # like `action: gernate` became a successful model call instead of + # an error. The check is repeated here rather than left to the + # compiler because a caller can build a Task and reach dispatch + # directly, without going through YAML validation at all. + if not is_known_action(action_text): + raise UnknownActionError(action_text, task_id=task.id) + # Handle AUTO tags by extracting the content auto_tag_pattern = r"(.*?)" auto_match = re.search(auto_tag_pattern, action_text, re.DOTALL) diff --git a/src/orchestrator/core/actions.py b/src/orchestrator/core/actions.py index c31e6380..df9886a3 100644 --- a/src/orchestrator/core/actions.py +++ b/src/orchestrator/core/actions.py @@ -1,80 +1,337 @@ -"""The actions the runtime executes itself, without a `tool:`. +"""The action vocabulary: one authoritative registry, several generated views. A step names either a tool and an operation on it:: tool: filesystem action: read -or an action the runtime handles directly:: +or an action the runtime executes itself:: action: generate + parameters: + prompt: "..." -Both the executor and the validator need to know which names fall in the -second group, and they used to know separately. `ToolValidator` treated a -step's `action:` as a tool name whenever the step had no `tool:` key, so a -model step was reported as ``Tool 'generate' not found in registry`` while the -executor ran the very same step correctly -- #241, `validate` and `run` -disagreeing about the same document. - -This module is the single source of truth. `HybridControlSystem` *dispatches* -off `BUILTIN_ACTION_HANDLERS`, so the mapping cannot claim an action the -executor does not actually run, and the validator accepts exactly the names in -it. Adding a runtime action means adding one entry here; both sides follow. - -Not covered here are the natural-language action families -- ``"echo ..."``, -``"write the following content to report.md"`` -- which are matched by pattern -rather than by name. Those are `HybridControlSystem._is_echo_operation` and -`_is_file_operation`. +This module owns the second group. Everything downstream is *derived* from +`ACTION_SPECS` rather than restated beside it: the executor's dispatch table, +the validator's notion of a recognised action, the control systems' advertised +`supported_actions`, alias normalisation in the compiler, the documented +vocabulary, and the parametrised contract tests. A name is supported exactly +when there is an `ActionSpec` for it. + +The vocabulary had drifted into three disagreeing definitions -- #241 closed +the first pair, this module closes the rest: + +1. `HybridControlSystem`'s dispatch chain -- what actually ran. +2. `ModelBasedControlSystem.supported_actions` -- which advertised ten names + (`transform`, `search`, `extract`, `filter`, `synthesize`, `create`, + `optimize`, `review`, `write`, `compile`) that had no handler at all. +3. An implicit fallback turning *any* unrecognised action into a prompt, so + `action: gernate` did not fail -- it silently became a model request and + reported success. + +Number 3 is the dangerous one, and it is gone: an action with no spec is +refused, at compile time and again at dispatch. A census of 130 pipeline +documents found 430 steps dispatched by action name, of which 247 already used +a registered action and 167 relied on the prompt fallback -- including +`generate-text`, a hyphen slip of `generate_text` that had never dispatched. + +Two families remain matched by pattern rather than by name, because prose has +no name to register: ``"echo ..."`` and ``"write the following content to +report.md"``. They are declared as `ActionFamily` entries so they belong to +this vocabulary rather than being another undocumented special case. """ from __future__ import annotations -from typing import Dict, FrozenSet +import re +from dataclasses import dataclass +from typing import Any, Dict, FrozenSet, Optional, Pattern, Tuple -# Action name -> the `HybridControlSystem` method that runs it. +# --- what a step's result must look like ----------------------------------- # -# Values are method *names* rather than functions because the handlers are -# bound methods on the control system; the dispatcher resolves them per call. -BUILTIN_ACTION_HANDLERS: Dict[str, str] = { - "control_flow": "_handle_control_flow", - # `file` and `filesystem` are the two spellings the executor has always - # accepted for the filesystem handler. - "file": "_handle_file_operation", - "filesystem": "_handle_file_operation", - "process": "_handle_data_processing", - "validate": "_handle_validation", - "loop_complete": "_handle_loop_complete", - "capture_result": "_handle_capture_result", - "evaluate_condition": "_handle_evaluate_condition", - "create_parallel_queue": "_handle_create_parallel_queue", - "action_loop": "_handle_action_loop", - "analyze": "_handle_analyze_text", - "analyze_text": "_handle_analyze_text", - "generate": "_handle_generate_text", - "generate_text": "_handle_generate_text", +# Handlers return a mapping carrying the outcome. These are the envelopes the +# contract tests assert against, so a handler cannot quietly change shape. + +_TEXT_RESULT_SCHEMA: Dict[str, Any] = { + "type": "object", + "required": ["success", "result"], + "properties": { + "success": {"type": "boolean"}, + "result": {"type": "string"}, + "action": {"type": "string"}, + "model_used": {"type": "string"}, + "error": {"type": ["string", "null"]}, + }, } -# Structured generation is handled one level down, by -# `ModelBasedControlSystem`, so it has no entry above -- but it is still an -# action the runtime executes without a tool, and the validator must accept it. +_OBJECT_RESULT_SCHEMA: Dict[str, Any] = {"type": "object"} + + +@dataclass(frozen=True) +class ActionSpec: + """One action the runtime executes without a `tool:`.""" + + #: The single spelling that appears in the task graph and the trace. + name: str + + #: One-line description, used to generate the documented vocabulary. + summary: str + + #: Method on `HybridControlSystem` that runs it. `None` means the action is + #: handled further down, by `ModelBasedControlSystem`. + handler: Optional[str] = None + + #: Accepted spellings that normalise to `name`. Kept for compatibility; + #: the compiler rewrites them and warns. + aliases: FrozenSet[str] = frozenset() + + #: "model", "tool", or "none" -- what the action needs to do its work. + requires: str = "none" + + #: Parameters without which the action cannot run. Checked by the + #: validator, so a missing one fails at compile time rather than at 3am. + required_parameters: FrozenSet[str] = frozenset() + + #: JSON Schema the step's result must satisfy, where the shape is fixed. + result_schema: Optional[Dict[str, Any]] = None + + #: Whether this is part of the supported v1 contract. Everything shipped + #: today is; the flag exists so adding an experimental action later does + #: not require inventing a second registry for it. + v1: bool = True + + def __post_init__(self) -> None: + if self.requires not in {"model", "tool", "none"}: + raise ValueError( + f"action {self.name!r}: requires must be 'model', 'tool' or " + f"'none', got {self.requires!r}" + ) + if self.name in self.aliases: + raise ValueError(f"action {self.name!r} lists its own name as an alias") + + +@dataclass(frozen=True) +class ActionFamily: + """A family of actions matched by pattern, because they are prose. + + ``"echo Starting run"`` and ``"write the following content to report.md"`` + are instructions, not identifiers. They still dispatch deterministically -- + they simply cannot be looked up by name. + """ + + name: str + summary: str + #: Method on `HybridControlSystem`, or `None` for "hand it to the model". + handler: Optional[str] + pattern: Pattern[str] + v1: bool = True + + +ACTION_SPECS: Tuple[ActionSpec, ...] = ( + # -- model actions ------------------------------------------------------ + ActionSpec( + name="generate", + summary="Generate text from a prompt using the selected model.", + handler="_handle_generate_text", + aliases=frozenset({"generate_text", "generate-text"}), + requires="model", + required_parameters=frozenset({"prompt"}), + result_schema=_TEXT_RESULT_SCHEMA, + ), + ActionSpec( + name="generate_structured", + summary="Generate an object conforming to a declared JSON Schema.", + # No handler entry: ModelBasedControlSystem runs this one. + aliases=frozenset({"generate-structured"}), + requires="model", + required_parameters=frozenset({"prompt", "schema"}), + result_schema=_OBJECT_RESULT_SCHEMA, + ), + ActionSpec( + name="analyze_text", + summary="Analyse supplied text with the selected model.", + handler="_handle_analyze_text", + aliases=frozenset({"analyze"}), + requires="model", + ), + # -- deterministic runtime actions -------------------------------------- + ActionSpec( + name="filesystem", + summary="Filesystem operation named by the step's `action` parameter.", + handler="_handle_file_operation", + aliases=frozenset({"file"}), + requires="tool", + ), + ActionSpec( + name="process", + summary="Transform data with the deterministic data-processing tool.", + handler="_handle_data_processing", + requires="tool", + ), + ActionSpec( + name="validate", + summary="Validate data against a schema with the validation tool.", + handler="_handle_validation", + requires="tool", + ), + # -- control flow ------------------------------------------------------- + ActionSpec( + name="control_flow", + summary="Control-flow marker step.", + handler="_handle_control_flow", + ), + ActionSpec( + name="evaluate_condition", + summary="Evaluate a condition expression and record the verdict.", + handler="_handle_evaluate_condition", + required_parameters=frozenset({"condition"}), + ), + ActionSpec( + name="loop_complete", + summary="Marker emitted when a loop finishes.", + handler="_handle_loop_complete", + ), + ActionSpec( + name="capture_result", + summary="Capture a prior step's result under a new name.", + handler="_handle_capture_result", + ), + ActionSpec( + name="create_parallel_queue", + summary="Fan work out across a parallel queue.", + handler="_handle_create_parallel_queue", + ), + ActionSpec( + name="action_loop", + summary="Run a sequence of actions repeatedly until a condition holds.", + handler="_handle_action_loop", + ), +) + + +ACTION_FAMILIES: Tuple[ActionFamily, ...] = ( + ActionFamily( + name="echo", + summary='Print a message, e.g. `action: echo "starting"`.', + handler="_handle_echo_operation", + pattern=re.compile(r"^(?:echo|print|display|show|output)\s+", re.IGNORECASE), + ), + ActionFamily( + name="file_prose", + summary=( + "Filesystem instruction written as prose, e.g. " + "`action: write the following content to report.md`." + ), + handler="_handle_file_operation", + pattern=re.compile( + r"write.*to\s+(?:a\s+)?(?:file|path)" + r"|save.*to\s+(?:a\s+)?(?:file|path)" + r"|write.*following.*content.*to" + r"|save.*following.*content.*to" + r"|create.*file\s+at" + r"|export.*to\s+file" + r"|store.*in\s+file" + r"|write.*to\s+[^\s]+\.(?:txt|md|json|yaml|yml|csv|html)" + r"|save.*to\s+[^\s]+\.(?:txt|md|json|yaml|yml|csv|html)", + re.IGNORECASE | re.DOTALL, + ), + ), + ActionFamily( + name="auto", + summary=( + "An explicit request for the model to interpret the instruction, " + "e.g. `action: summarise the findings`." + ), + # No handler: this is the one case that is *meant* to reach the model. + handler=None, + pattern=re.compile(r".*?", re.IGNORECASE | re.DOTALL), + ), +) + + +def _build_index() -> Dict[str, ActionSpec]: + index: Dict[str, ActionSpec] = {} + for spec in ACTION_SPECS: + for spelling in (spec.name, *spec.aliases): + key = spelling.lower() + if key in index: + raise ValueError( + f"action spelling {spelling!r} is claimed by both " + f"{index[key].name!r} and {spec.name!r}" + ) + index[key] = spec + return index + + +_BY_SPELLING: Dict[str, ActionSpec] = _build_index() + + +# --- generated views ------------------------------------------------------- # -# Every other action here uses underscores. `generate-structured` was the lone -# hyphenated one, and for a long time the only spelling that dispatched: the -# underscore fell through to the natural-language branch, which turns an -# unrecognised action into a *prompt*, so the step returned a sentence instead -# of an object and still reported success. Both spellings are supported. +# Every one of these is derived. None is maintained by hand, so none can drift. + +#: Action spelling -> the `HybridControlSystem` method that runs it. Values are +#: method *names* because the handlers are bound methods on the control system; +#: the dispatcher resolves them per call. +BUILTIN_ACTION_HANDLERS: Dict[str, str] = { + spelling: spec.handler + for spelling, spec in _BY_SPELLING.items() + if spec.handler is not None +} + +#: Every spelling the runtime executes without a `tool:`, aliases included. +BUILTIN_ACTIONS: FrozenSet[str] = frozenset(_BY_SPELLING) + +#: The spellings of the structured-generation action. STRUCTURED_ACTIONS: FrozenSet[str] = frozenset( - {"generate-structured", "generate_structured"} + spelling + for spelling, spec in _BY_SPELLING.items() + if spec.name == "generate_structured" +) + +#: What the control systems advertise. Generated, so it can no longer promise +#: an action nobody implements. +SUPPORTED_ACTIONS: Tuple[str, ...] = tuple( + sorted(spelling for spelling, spec in _BY_SPELLING.items() if spec.v1) ) -#: Every action name the runtime executes without a `tool:`. -BUILTIN_ACTIONS: FrozenSet[str] = frozenset(BUILTIN_ACTION_HANDLERS) | STRUCTURED_ACTIONS + +def resolve_action(action: str) -> Optional[ActionSpec]: + """The spec for `action`, by canonical name or alias, else `None`. + + Matching is case-insensitive and ignores surrounding whitespace, because + the executor lowercases a task's action before dispatching on it. + """ + if not isinstance(action, str): + return None + return _BY_SPELLING.get(action.strip().lower()) + + +def canonical_action(action: str) -> Optional[str]: + """The one spelling that should appear in the task graph and the trace.""" + spec = resolve_action(action) + return spec.name if spec else None def is_builtin_action(action: str) -> bool: - """Whether `action` names something the runtime executes without a tool. + """Whether `action` names something the runtime executes without a tool.""" + return resolve_action(action) is not None + + +def match_action_family(action: str) -> Optional[ActionFamily]: + """The prose family `action` belongs to, if any.""" + if not isinstance(action, str): + return None + for family in ACTION_FAMILIES: + if family.pattern.search(action): + return family + return None + + +def is_known_action(action: str) -> bool: + """Whether anything at all can execute `action`. - Comparison is case-insensitive and ignores surrounding whitespace, matching - the executor, which lowercases a task's action before dispatching on it. + The question both the validator and the dispatcher ask. Anything that + answers `False` is refused rather than turned into a model prompt. """ - return action.strip().lower() in BUILTIN_ACTIONS + return is_builtin_action(action) or match_action_family(action) is not None diff --git a/src/orchestrator/core/exceptions.py b/src/orchestrator/core/exceptions.py index c271be94..d5d9056c 100644 --- a/src/orchestrator/core/exceptions.py +++ b/src/orchestrator/core/exceptions.py @@ -205,6 +205,30 @@ def __init__(self, parameter: str, reason: str, **kwargs): ) +class UnknownActionError(ValidationError): + """Raised when a step names an action nothing can execute. + + The runtime used to turn any unrecognised action into a prompt for the + model, so `action: gernate` did not fail -- it became a model request and + reported success. A typo is now refused: at compile time by the validator, + and again at dispatch, so constructing a `Task` directly cannot route + around the check. + """ + + def __init__(self, action: str, task_id: Optional[str] = None, **kwargs): + where = f" in task '{task_id}'" if task_id else "" + message = ( + f"Unknown action {action!r}{where}. An action must name a " + f"registered action (see orchestrator.core.actions), name a tool " + f"via `tool:`, or be an explicit ... instruction." + ) + super().__init__( + message, + details={"action": action, "task_id": task_id}, + **kwargs + ) + + class UnresolvedTemplateError(ValidationError): """Raised when a template reaches a tool with references still unresolved. diff --git a/src/orchestrator/orchestrator.py b/src/orchestrator/orchestrator.py index df4b7bac..6c0f24c2 100644 --- a/src/orchestrator/orchestrator.py +++ b/src/orchestrator/orchestrator.py @@ -1610,8 +1610,20 @@ async def _execute_level( except Exception: # Fallback to original error handled_error = result - task.fail(handled_error) - results[task_id] = {"error": str(handled_error)} + # `handle_error` returns a *recovery decision* -- + # {"action": "retry", "delay": 5.0, ...} -- not an + # error. Recording that as the task's error meant every + # failed step reported a retry policy instead of the + # reason it failed, and `task.error` was not even an + # exception. The reason is the original exception; the + # decision is kept beside it rather than on top of it. + task.fail(result) + results[task_id] = { + "success": False, + "error": str(result), + "error_type": type(result).__name__, + "recovery": handled_error, + } else: # Task succeeded results[task_id] = result @@ -2039,8 +2051,21 @@ async def _handle_task_failures( # Skip dependent tasks self._skip_dependent_tasks(pipeline, task_id) elif failure_policy == "fail": - # Fail entire pipeline - raise ExecutionError(f"Task '{task_id}' failed and policy is 'fail'") + # Fail entire pipeline. + # + # The task already knows why it failed; reporting only "failed + # and policy is 'fail'" threw that away and left the caller to + # go hunting in the logs. The reason is carried in the message + # and chained, so `raise ... from` preserves the original + # traceback and type for anyone catching it. + # `Task.error` is typed Optional[Exception] but is not always + # populated with one, so chaining is conditional. + cause = task.error + reason = f": {cause}" if cause else "" + message = f"Task '{task_id}' failed and policy is 'fail'{reason}" + if isinstance(cause, BaseException): + raise ExecutionError(message) from cause + raise ExecutionError(message) elif failure_policy == "retry": # Retry is handled in _execute_task continue diff --git a/src/orchestrator/validation/tool_validator.py b/src/orchestrator/validation/tool_validator.py index 9cb335d7..9f6f2a43 100644 --- a/src/orchestrator/validation/tool_validator.py +++ b/src/orchestrator/validation/tool_validator.py @@ -4,7 +4,7 @@ from typing import Any, Dict, List, Optional, Set, Type, Union from dataclasses import dataclass -from ..core.actions import is_builtin_action +from ..core.actions import is_known_action, resolve_action from ..tools.base import Tool, ToolRegistry, default_registry from .template_validator import TemplateValidationError @@ -151,7 +151,10 @@ def validate_pipeline_tools(self, pipeline_def: Dict[str, Any]) -> ToolValidatio # The tool lookup still comes first, so the legacy single-field # form (`action: filesystem`, naming the tool itself) keeps its # full parameter validation. - if not tool_available and "tool" not in step and is_builtin_action(tool_name): + if not tool_available and "tool" not in step and is_known_action(tool_name): + errors.extend( + self._validate_action_parameters(task_id, step, tool_name) + ) continue tool_availability[tool_name] = tool_available @@ -204,6 +207,35 @@ def validate_pipeline_tools(self, pipeline_def: Dict[str, Any]) -> ToolValidatio tool_availability=tool_availability ) + def _validate_action_parameters( + self, task_id: str, step: Dict[str, Any], action: str + ) -> List[ToolValidationError]: + """Check the parameters an action cannot run without. + + `ActionSpec.required_parameters` is the single declaration of these, so + a missing `prompt` on a `generate` step fails at compile time with exit + 2 rather than at dispatch. Prose families carry no spec and no required + parameters, so they are not checked here. + """ + spec = resolve_action(action) + if spec is None or not spec.required_parameters: + return [] + + parameters = step.get("parameters") or {} + missing = sorted(spec.required_parameters - set(parameters)) + return [ + ToolValidationError( + task_id=task_id, + tool_name=spec.name, + parameter_name=name, + error_type="missing_parameter", + message=( + f"action '{spec.name}' requires a '{name}' parameter" + ), + ) + for name in missing + ] + def _extract_tool_name(self, step: Dict[str, Any]) -> Optional[str]: """Extract tool name from step definition.""" # When a step names a `tool`, that is the registry key and `action` is diff --git a/tests/test_action_contract.py b/tests/test_action_contract.py new file mode 100644 index 00000000..c8a24186 --- /dev/null +++ b/tests/test_action_contract.py @@ -0,0 +1,413 @@ +"""The action vocabulary contract. + +`core/actions.py` is the one authoritative registry. Everything else -- the +executor's dispatch, the validator's recognition, the control systems' +advertised capabilities, alias normalisation, the documented vocabulary -- is +derived from it. These tests pin that derivation in both directions, so the +vocabulary cannot drift back apart. + +The behaviour being protected, concretely: `action: gernate` must fail. It used +to become a successful model call, because any unrecognised action was turned +into a prompt. +""" + +import asyncio +import warnings + +import jsonschema +import pytest + +from orchestrator.core.actions import ( + ACTION_FAMILIES, + ACTION_SPECS, + BUILTIN_ACTION_HANDLERS, + SUPPORTED_ACTIONS, + ActionSpec, + canonical_action, + is_known_action, + match_action_family, + resolve_action, +) +from orchestrator.compiler.yaml_compiler import YAMLCompiler +from orchestrator.control_systems.hybrid_control_system import HybridControlSystem +from orchestrator.core.exceptions import UnknownActionError, YAMLCompilerError +from orchestrator.core.task import Task +from orchestrator.models.model_registry import ModelRegistry +from orchestrator.validation.tool_validator import ToolValidator +from tests.test_infrastructure import MockTestModel, create_test_orchestrator + +pytestmark = [pytest.mark.contract] + + +@pytest.fixture +def control_system(): + registry = ModelRegistry() + registry.register_model(MockTestModel()) + return HybridControlSystem(model_registry=registry) + + +@pytest.fixture +def validator(): + """Strict: unknown names are errors, not warnings.""" + return ToolValidator(allow_unknown_tools=False) + + +@pytest.fixture +def compiler(): + registry = ModelRegistry() + registry.register_model(MockTestModel()) + return YAMLCompiler(model_registry=registry) + + +def _pipeline(action, parameters="{}"): + return f""" +id: action_contract_pipeline +name: Action Contract Pipeline +steps: + - id: only_step + action: {action} + parameters: {parameters} +""" + + +# --------------------------------------------------------------------------- +# 1. every registered action validates and dispatches +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("spec", ACTION_SPECS, ids=lambda s: s.name) +def test_every_registered_action_is_recognised_everywhere(spec, validator): + """Registry, validator and canonical lookup must agree on every spelling.""" + for spelling in (spec.name, *spec.aliases): + assert is_known_action(spelling), f"{spelling!r} unknown to the registry" + assert canonical_action(spelling) == spec.name + + result = validator.validate_pipeline_tools( + {"steps": [{"id": "s", "action": spelling, + "parameters": {p: "x" for p in spec.required_parameters}}]} + ) + rejected = [e for e in result.errors if e.error_type == "unknown_tool"] + assert not rejected, f"{spelling!r} rejected as a tool: {rejected}" + + +@pytest.mark.parametrize( + "spec", [s for s in ACTION_SPECS if s.handler], ids=lambda s: s.name +) +def test_every_registered_handler_exists(spec, control_system): + """Dispatch is `getattr(self, handler)`, so a bad name is an AttributeError. + + Naming the offending action here beats discovering it inside whichever + pipeline happened to use it. + """ + handler = getattr(control_system, spec.handler, None) + assert handler is not None, f"{spec.name!r} -> missing {spec.handler!r}" + assert callable(handler) + + +@pytest.mark.parametrize("family", ACTION_FAMILIES, ids=lambda f: f.name) +def test_every_family_handler_exists(family, control_system): + """`auto` deliberately carries no handler -- it is the model path.""" + if family.handler is None: + assert family.name == "auto" + return + assert callable(getattr(control_system, family.handler, None)) + + +def test_no_handler_is_registered_without_a_spec(control_system): + """The reverse direction: dispatch cannot reach an unregistered method.""" + for spelling, handler_name in BUILTIN_ACTION_HANDLERS.items(): + assert resolve_action(spelling) is not None + assert hasattr(control_system, handler_name) + + +def test_supported_actions_is_generated_not_restated(control_system): + """The advertised capability list must be the registry, not a copy of it. + + It used to advertise ten names -- transform, search, extract, filter, + synthesize, create, optimize, review, write, compile -- that had no handler + anywhere. Anything it lists must now be executable. + """ + advertised = control_system._capabilities.get("supported_actions", []) + + assert set(advertised) == set(SUPPORTED_ACTIONS) + for name in advertised: + assert is_known_action(name), f"advertised but unknown: {name!r}" + + +# --------------------------------------------------------------------------- +# 2. aliases normalise identically +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "alias,canonical", + [(a, s.name) for s in ACTION_SPECS for a in sorted(s.aliases)], +) +def test_alias_normalises_in_the_compiled_task_graph(alias, canonical, compiler): + """One spelling reaches the task graph, whichever the author wrote.""" + parameters = "{" + ", ".join( + f"{p}: x" for p in sorted(resolve_action(canonical).required_parameters) + ) + "}" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + pipeline = asyncio.run( + compiler.compile(_pipeline(alias, parameters), {}, resolve_ambiguities=False) + ) + + assert pipeline.tasks["only_step"].action == canonical, ( + f"alias {alias!r} was left unnormalised in the task graph" + ) + + +def test_a_deprecated_alias_warns(compiler): + """Silent rewriting would leave authors with no reason to update.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + asyncio.run( + compiler.compile( + _pipeline("generate-structured", '{prompt: p, schema: {type: object}}'), + {}, + resolve_ambiguities=False, + ) + ) + + messages = [str(w.message) for w in caught if issubclass(w.category, DeprecationWarning)] + assert any("generate-structured" in m and "generate_structured" in m for m in messages), ( + f"no deprecation warning naming both spellings: {messages}" + ) + + +# --------------------------------------------------------------------------- +# 3. unknown actions fail, through YAML *and* through direct execution +# --------------------------------------------------------------------------- + +def test_an_unknown_action_fails_to_compile(compiler): + """`gernate` is a typo, not an instruction.""" + with pytest.raises(YAMLCompilerError) as excinfo: + asyncio.run( + compiler.compile( + _pipeline("gernate", "{prompt: hello}"), {}, resolve_ambiguities=False + ) + ) + + assert "gernate" in str(excinfo.value) + + +def test_an_unknown_action_fails_at_dispatch_too(control_system): + """Compile-time validation must not be the only safety boundary. + + A caller can build a Task and reach dispatch without going through YAML at + all, so the runtime refuses independently. + """ + task = Task(id="typo", name="typo", action="gernate", parameters={"prompt": "hi"}) + + with pytest.raises(UnknownActionError) as excinfo: + asyncio.run(control_system._execute_task_impl(task, {})) + + assert excinfo.value.details["action"] == "gernate" + assert excinfo.value.details["task_id"] == "typo" + + +def test_a_typo_never_becomes_a_successful_model_call(): + """The regression this whole contract exists to prevent. + + Before: `action: gernate` was turned into a prompt, the model answered, and + the step reported success. A misspelling produced a plausible-looking + result instead of an error. + """ + # `on_failure: continue` so the run completes and the step's own envelope + # can be inspected. Under the default policy the pipeline raises instead, + # which the assertion below also covers. + tolerant = """ +id: typo_pipeline +name: Typo Pipeline +steps: + - id: only_step + action: gernate + on_failure: continue + parameters: + prompt: hello +""" + orchestrator = create_test_orchestrator() + result = asyncio.run(orchestrator.execute_yaml(yaml_content=tolerant, context={})) + + step = result["only_step"] + assert "error" in step, f"a typo must not report success: {step}" + assert "gernate" in str(step["error"]), ( + f"the step's error must name the offending action: {step['error']}" + ) + + # And with the default policy, the run fails outright rather than + # returning a plausible-looking model answer. + with pytest.raises(Exception): + asyncio.run( + orchestrator.execute_yaml( + yaml_content=_pipeline("gernate", "{prompt: hello}"), context={} + ) + ) + + +def test_an_explicit_auto_instruction_is_still_allowed(control_system): + """Removing the implicit fallback must not remove the explicit feature. + + `...` is an author deliberately asking the model to interpret + an instruction. That is not the same as a typo falling through. + """ + assert is_known_action("summarise the findings") + assert match_action_family("summarise the findings").name == "auto" + + +@pytest.mark.parametrize("prose", ['echo "starting"', "write the following content to report.md"]) +def test_declared_prose_families_are_still_allowed(prose): + """Prose families are declared, not arbitrary. They keep working.""" + assert is_known_action(prose) + assert match_action_family(prose) is not None + + +# --------------------------------------------------------------------------- +# 4. required parameters are checked at compile time +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "spec", [s for s in ACTION_SPECS if s.required_parameters], ids=lambda s: s.name +) +def test_missing_required_parameter_is_reported(spec, validator): + """Exit 2, not a mystery at dispatch.""" + result = validator.validate_pipeline_tools( + {"steps": [{"id": "s", "action": spec.name, "parameters": {}}]} + ) + + missing = [e for e in result.errors if e.error_type == "missing_parameter"] + assert {e.parameter_name for e in missing} == set(spec.required_parameters), ( + f"expected every one of {sorted(spec.required_parameters)} to be reported, " + f"got {[e.parameter_name for e in missing]}" + ) + + +def test_supplying_required_parameters_validates_cleanly(validator): + result = validator.validate_pipeline_tools( + {"steps": [{"id": "s", "action": "generate", "parameters": {"prompt": "hi"}}]} + ) + + assert not [e for e in result.errors if e.error_type == "missing_parameter"] + + +# --------------------------------------------------------------------------- +# 5. result envelopes match their declared schemas +# --------------------------------------------------------------------------- + +def test_generate_result_matches_its_declared_schema(): + """The spec's result_schema is a promise, so it is checked against a run.""" + spec = resolve_action("generate") + orchestrator = create_test_orchestrator() + + result = asyncio.run( + orchestrator.execute_yaml( + yaml_content=_pipeline("generate", "{prompt: summarise this}"), context={} + ) + ) + + jsonschema.validate(instance=result["only_step"], schema=spec.result_schema) + + +def test_structured_result_matches_its_declared_schema(): + spec = resolve_action("generate_structured") + orchestrator = create_test_orchestrator() + + result = asyncio.run( + orchestrator.execute_yaml( + yaml_content=_pipeline( + "generate_structured", + '{prompt: extract, schema: {type: object, properties: {test_output: {type: string}}}}', + ), + context={}, + ) + ) + + jsonschema.validate(instance=result["only_step"], schema=spec.result_schema) + + +# --------------------------------------------------------------------------- +# 6. the registry itself is well formed +# --------------------------------------------------------------------------- + +def test_no_spelling_is_claimed_twice(): + """Enforced at import; asserted here so the reason is recorded.""" + seen = set() + for spec in ACTION_SPECS: + for spelling in (spec.name, *spec.aliases): + assert spelling.lower() not in seen, f"duplicate spelling {spelling!r}" + seen.add(spelling.lower()) + + +def test_requires_is_constrained(): + with pytest.raises(ValueError, match="requires must be"): + ActionSpec(name="bogus", summary="x", requires="magic") + + +def test_a_spec_cannot_alias_itself(): + with pytest.raises(ValueError, match="its own name as an alias"): + ActionSpec(name="bogus", summary="x", aliases=frozenset({"bogus"})) + + +def test_every_spec_has_a_summary_for_the_generated_docs(): + for spec in ACTION_SPECS: + assert spec.summary.strip(), f"{spec.name} has no summary" + for family in ACTION_FAMILIES: + assert family.summary.strip(), f"{family.name} has no summary" + + +# --------------------------------------------------------------------------- +# 7. the tool forms are unaffected by the action contract +# +# Folded in from tests/test_builtin_actions.py (#241), whose remaining +# assertions duplicated the ones above once the registry became authoritative. +# --------------------------------------------------------------------------- + +def test_the_legacy_single_field_form_still_resolves_as_a_tool(validator): + """`action: filesystem` names the tool itself and keeps full validation. + + `filesystem` is both a registered tool and a registered action name. The + tool lookup has to win, or this form silently loses its parameter checks. + """ + result = validator.validate_pipeline_tools( + {"steps": [{"id": "s", "action": "filesystem", "parameters": {}}]} + ) + + assert result.tool_availability.get("filesystem") is True + + +def test_the_two_field_form_resolves_tool_never_action(validator): + result = validator.validate_pipeline_tools( + { + "steps": [ + { + "id": "s", + "tool": "filesystem", + "action": "read", + "parameters": {"path": "x.txt"}, + } + ] + } + ) + + assert "filesystem" in result.tool_availability + assert "read" not in result.tool_availability, ( + "`read` is an operation on the tool, not a tool to look up" + ) + + +def test_a_builtin_action_dispatches_away_from_the_model(control_system): + """`evaluate_condition` is a marker action with no model involvement. + + Reaching the model would produce prose instead of a verdict, which is the + boundary the whole contract exists to hold. + """ + task = Task( + id="check", + name="check", + action="evaluate_condition", + parameters={"condition": "1 == 1"}, + ) + result = asyncio.run(control_system._execute_task_impl(task, {})) + + assert isinstance(result, dict) + assert "result" in result or "condition" in result diff --git a/tests/test_action_contract_docs.py b/tests/test_action_contract_docs.py new file mode 100644 index 00000000..05aa387b --- /dev/null +++ b/tests/test_action_contract_docs.py @@ -0,0 +1,62 @@ +"""The documented action vocabulary must match the registry. + +`docs/actions.md` is generated from `orchestrator.core.actions`. Generating it +is only useful if regeneration is enforced -- otherwise the committed file +drifts and the documentation quietly starts describing a vocabulary the code no +longer has, which is how this project ended up advertising ten actions with no +handler in the first place. +""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.contract] + +ROOT = Path(__file__).resolve().parent.parent +GENERATOR = ROOT / "scripts" / "generate_action_docs.py" +DOC = ROOT / "docs" / "actions.md" + + +def test_the_generated_action_docs_are_committed_and_current(): + result = subprocess.run( + [sys.executable, str(GENERATOR), "--check"], + cwd=str(ROOT), + capture_output=True, + text=True, + timeout=120, + ) + + assert result.returncode == 0, ( + f"docs/actions.md is out of date. Run:\n" + f" python scripts/generate_action_docs.py\n\n" + f"{result.stdout}\n{result.stderr}" + ) + + +def test_every_registered_action_appears_in_the_documentation(): + """Belt and braces: the check above compares bytes, this checks intent.""" + from orchestrator.core.actions import ACTION_FAMILIES, ACTION_SPECS + + text = DOC.read_text() + for spec in ACTION_SPECS: + assert f"`{spec.name}`" in text, f"{spec.name} is undocumented" + for alias in spec.aliases: + assert f"`{alias}`" in text, f"alias {alias} is undocumented" + for family in ACTION_FAMILIES: + assert f"`{family.name}`" in text, f"family {family.name} is undocumented" + + +def test_the_exit_code_boundary_is_documented(): + """#153's runtime boundary versus #241's compile-time one. + + Both are failures, they are not the same failure, and the difference is + visible to anyone scripting the CLI. + """ + text = DOC.read_text() + + assert "**2**" in text and "**1**" in text + assert "compile time" in text + assert "before any side effect" in text diff --git a/tests/test_builtin_actions.py b/tests/test_builtin_actions.py deleted file mode 100644 index 70f63ce7..00000000 --- a/tests/test_builtin_actions.py +++ /dev/null @@ -1,176 +0,0 @@ -"""The built-in action registry, and the two consumers that must agree on it. - -`validate` and `run` used to hold separate notions of what an action is. The -validator treated a step's `action:` as a tool name whenever the step had no -`tool:` key, so `action: generate` was rejected as a missing *tool* while the -executor ran the same step correctly (#241). - -`core.actions` is now the single source of truth. These tests pin both -directions of that: the executor dispatches every name in the registry to a -handler that exists, and the validator accepts every name in the registry. -""" - -import asyncio - -import pytest - -from orchestrator.core.actions import ( - BUILTIN_ACTION_HANDLERS, - BUILTIN_ACTIONS, - STRUCTURED_ACTIONS, - is_builtin_action, -) -from orchestrator.control_systems.hybrid_control_system import HybridControlSystem -from orchestrator.models.model_registry import ModelRegistry -from orchestrator.validation.tool_validator import ToolValidator -from tests.test_infrastructure import MockTestModel - -pytestmark = [pytest.mark.contract] - - -@pytest.fixture -def control_system(): - registry = ModelRegistry() - registry.register_model(MockTestModel()) - return HybridControlSystem(model_registry=registry) - - -@pytest.fixture -def validator(): - """A strict validator: unknown tools are errors, not warnings.""" - return ToolValidator(allow_unknown_tools=False) - - -# --------------------------------------------------------------------------- -# the registry cannot promise what the executor cannot deliver -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("action", sorted(BUILTIN_ACTION_HANDLERS)) -def test_every_registered_action_has_a_real_handler(action, control_system): - """Dispatch is driven off this mapping, so a bad entry is an AttributeError. - - Catching it here names the offending action instead of surfacing as a - mystery failure inside whichever pipeline happened to use it. - """ - handler_name = BUILTIN_ACTION_HANDLERS[action] - handler = getattr(control_system, handler_name, None) - - assert handler is not None, ( - f"action {action!r} is registered to {handler_name!r}, " - f"which does not exist on HybridControlSystem" - ) - assert callable(handler), f"{handler_name!r} is not callable" - - -def test_structured_actions_are_builtin_but_handled_one_level_down(): - """They have no handler entry: ModelBasedControlSystem runs them. - - They must still be in BUILTIN_ACTIONS or the validator would reject them - as unknown tools -- the original #241 failure, in a different action. - """ - assert STRUCTURED_ACTIONS <= BUILTIN_ACTIONS - assert not (STRUCTURED_ACTIONS & set(BUILTIN_ACTION_HANDLERS)) - - -def test_is_builtin_action_matches_the_executors_normalisation(): - """The executor lowercases and strips before dispatching; so must this.""" - assert is_builtin_action("generate") - assert is_builtin_action(" GENERATE ") - assert is_builtin_action("Generate_Text") - assert not is_builtin_action("summarise the report in three sentences") - assert not is_builtin_action("") - - -# --------------------------------------------------------------------------- -# the validator accepts exactly what the executor runs -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("action", sorted(BUILTIN_ACTIONS)) -def test_validator_accepts_every_builtin_action(action, validator): - """No built-in action may be reported as a missing tool.""" - result = validator.validate_pipeline_tools( - {"steps": [{"id": "step_one", "action": action, "parameters": {}}]} - ) - - unknown = [e for e in result.errors if e.error_type == "unknown_tool"] - assert not unknown, ( - f"built-in action {action!r} was rejected as a tool: " - f"{[e.message for e in unknown]}" - ) - - -def test_validator_still_rejects_an_action_that_is_neither_tool_nor_builtin(validator): - """The fix must not turn the validator into a rubber stamp.""" - result = validator.validate_pipeline_tools( - {"steps": [{"id": "step_one", "action": "definitely_not_a_real_thing"}]} - ) - - assert [e for e in result.errors if e.error_type == "unknown_tool"], ( - "an unknown action still has to be reported" - ) - - -def test_validator_still_validates_the_legacy_single_field_form(validator): - """`action: filesystem` names the tool itself and keeps full validation. - - `filesystem` is both a registered tool and a built-in action name. The tool - lookup has to win, or this form would silently lose its parameter checks. - """ - result = validator.validate_pipeline_tools( - {"steps": [{"id": "step_one", "action": "filesystem", "parameters": {}}]} - ) - - assert "filesystem" in result.tool_availability, ( - "the legacy form must still be resolved as a tool, not skipped as an action" - ) - assert result.tool_availability["filesystem"] is True - - -def test_a_tool_step_is_unaffected(validator): - """The two-field form still resolves `tool:`, never `action:`.""" - result = validator.validate_pipeline_tools( - { - "steps": [ - { - "id": "step_one", - "tool": "filesystem", - "action": "read", - "parameters": {"path": "x.txt"}, - } - ] - } - ) - - assert "filesystem" in result.tool_availability - assert "read" not in result.tool_availability, ( - "`read` is an operation on the tool, not a tool to look up" - ) - - -# --------------------------------------------------------------------------- -# end to end: the two agree on the same document -# --------------------------------------------------------------------------- - -def test_builtin_action_dispatches_away_from_the_model(control_system): - """A built-in action must reach its handler, not the prompt fallback. - - The executor's last resort turns an unrecognised action into natural - language for the model. That is how `generate_structured` returned a - sentence instead of an object for as long as it did, so the boundary is - pinned: `evaluate_condition` is a marker action with no model involvement, - and reaching the model would produce prose instead of a verdict. - """ - from orchestrator.core.task import Task - - task = Task( - id="check", - name="check", - action="evaluate_condition", - parameters={"condition": "1 == 1"}, - ) - result = asyncio.run(control_system._execute_task_impl(task, {})) - - assert isinstance(result, dict), f"expected the handler's verdict, got {type(result)}" - assert "result" in result or "condition" in result, ( - f"this does not look like _handle_evaluate_condition output: {result}" - )