Close the action contract: one registry, no prose fallback (#241 follow-up) - #441
Conversation
…ow-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. <AUTO>...</AUTO> 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) <noreply@anthropic.com>
|
CI 9/9 green, and the legacy backlog is unchanged — exactly, not approximately.
Every count identical. The only movement is Notably zero How this number was obtained, because the last two attempts were wrongFirst I inferred
The job publishes its tally as an annotation precisely so the backlog stays visible past Unrelated, but visible in the same annotationsFour actions need bumping. Not touched here — it belongs with the release-engineering item that also wants actions pinned by commit SHA. |
Narrowly bounded follow-up to #241, as requested, before the Result API.
What was measured first
A census of 130 pipeline documents, counting only steps without a
tool:key — steps with one route by tool and never touch the action vocabulary:<AUTO>instructionsecho …)My first census was wrong and counted
write/execute/readas prose — they are tool operations in the two-field form. The numbers above are from parsed steps, not rawaction:lines.Among the 167:
generate-text, used six times, had never dispatched. A hyphen slip ofgenerate_text— the same defect class asgenerate_structuredin #437, which is exactly the drift this PR closes.The registry
ActionSpeccarries canonical name, aliases, handler, whether a model or tool is required, required parameters, result schema and v1 membership. Everything else is derived:resolve_action(...).handleris_known_action(...)supported_actionsSUPPORTED_ACTIONScanonical_action(...), applied by the compilerdocs/actions.md, generated + drift-testedThe ten advertised-but-unimplemented names are gone. Anything advertised is now executable, with a test asserting exactly that.
Unknown actions are refused twice
At compile time, and again at dispatch — a caller can build a
Taskand reach execution without going through YAML, so compile-time validation is not the only boundary. Both are tested.<AUTO>...</AUTO>stays supported and is now a declaredActionFamilyrather than an accident of the fallback: an author explicitly asking the model to interpret an instruction is not the same as a typo falling through.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 deleted.Three real bugs fell out of writing the tests
generate_structuredlives onModelBasedControlSystem) hit the new raise instead of delegating."file"but not"filesystem". Normalising the alias sent canonical steps past that branch into a default that tried to write to the read-only root.{"action": "retry", "delay": 5.0, ...}— as its error. The reason a step failed was being discarded system-wide, andTask.errorwas not even an exception. Now the original error is recorded with the recovery decision beside it, and a compile failure leads with the actual messages instead ofIssues: 1 errors.Number 3 is the one worth noting for the Result API: structured errors were not merely missing, they were actively overwritten.
Also in here
SyntaxErrorinscripts/utilities/organization_maintenance.py—extend([closed with]. That file could not be imported at all. Found by extending the correctness lint toscripts/.git status --porcelain --untracked-files=all, sincegit diffcannot see newly created ones.legacy-results.txtis excluded as the single expected artifact.tests/test_builtin_actions.pyfolded intotests/test_action_contract.py(53 tests); its assertions were subsumed once the registry became authoritative.Numbers
src+scripts)uv lock --check,git diff --check, ci.yml parseDeliberately not here
Parameter schemas, only required-parameter lists.
ActionSpechas the field, and the validator checks required names, but full JSON Schema per action means deciding the parameter vocabulary for every action — that belongs with the canonical YAML schema work, not smuggled into a closure PR.Result schemas are declared for
generateandgenerate_structuredonly — the two whose envelope shape is actually fixed today. Asserting a schema for handlers that return ad-hoc dicts would mean inventing their contracts here rather than in the Result API, where the per-step envelope gets designed once.