feat(record_cli): serve a different canned response per invocation - #150
feat(record_cli): serve a different canned response per invocation#150alexandrujircan wants to merge 5 commits into
Conversation
A `record_cli` shim answered every invocation with one fixed exit_code/stdout/stderr, so an agent whose next step depends on what the tool just told it could not be evaluated: `uip ixp dummy1` and `uip ixp dummy2` got the same reply. Each entry may now declare `responses`, a list of rules tried in declaration order, first match wins, falling back to the entry's own three fields for anything no rule claims. `exit_code` defaults to 0 on a rule (the opposite of the entry default of 1): a rule exists because the author described that invocation. `when` is not a second pattern language. The criterion's matcher moved to `argv_match.py` -- stdlib-only, plain dicts -- and both surfaces lower to one spec dict, so the pattern that serves a response is the pattern that grades it. `render_recorder` embeds that module's SOURCE into the shim, which runs where coder_eval is not installed; CE047 keeps its imports stdlib-only, since one package import there would make every shadowed CLI die with an ImportError the agent reads as "the tool is broken". `FlagMatch` moved to the new cycle-free leaf `models/cli_match.py` alongside `CliMatch` and the shared verb/flag validators: models/sandbox.py cannot import from models/criteria.py, which already takes RECORD_CLI_LOG from it. Two deliberate divergences from the criterion, both tested: `ignore_flags` is empty on a rule (grading must not depend on --output; dispatch may), and `tool` stays criterion-only, addressing a log record rather than argv. Also fixes a pre-existing silent no-match: a flag written into a verb (`verb: "ixp projects get --output json"`) validated and then matched nothing, because a verb is compared against the non-flag arguments -- the criterion scored 0 against a log holding that exact call. Now rejected on every surface, reusing the splitter's own is_number rule so `head -1` stays legal. The shim records `"rule": <index>` when a rule answered, and omits the key when none did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @alexandrujircan's task in 2m 27s —— View job Code Review in Progress
|
…a shim fault Addresses review on #150. Blocker: `FlagMatch.matches_regex` was never compiled at validation. The criterion's checker pre-flighted it, but the response-rule surface that now shares the model evaluates the pattern INSIDE the sandbox, where the shim swallowed the PatternError and served its fallback -- a log line byte-identical to a legitimate no-match. The task scored differently for identical agent behaviour, with nothing on any report surface. The compile moved into `FlagMatch`, so both surfaces refuse the pattern at load, and the now-unreachable checker pre-flight is gone. Second half of the same chain: when the shim's rule evaluation does raise, it returns the error and `record()` books it as `rule_error`, so an eval-config fault can no longer read as a clean no-match; `cli_called` fails the whole log on it, the way it already fails on the write-failure sentinel. Tested by corrupting a rendered shim -- the only route left now that the pattern cannot load. Also from the review: - The lowered spec crossed the model/matcher seam as `dict[str, Any]` read with permissive `.get(...) or <default>`, whose failure direction is always "unconstrained" -- a rule that matches everything, or a criterion that scores 1.0 on any log. It is now `MatchSpec` / `FlagPredicate` / `ResponseRule` TypedDicts with required keys indexed directly, so a key renamed on either side is a pyright error on both. Tests pin that the TypedDict key sets equal the model field sets, which is what makes the one cast honest. - `FlagMatch.needs_value` was dead after the matcher extraction while `argv_match.predicate_needs_value` documented a mirror contract nothing enforced. Deleted; the survivor now says it is the only implementation. - A `responses` rule an earlier rule already claims was accepted silently, unlike every other unusable declaration on this surface. Now a load error for the two decidable cases: an exact duplicate, and a verb-only rule whose verb prefixes a later one under the same flag parsing. - CE047 grew a namespace half: an embedded module may not bind a top-level name the shim binds itself, since the shim's definition wins and the resulting TypeError is swallowed into "every invocation gets the fallback". Its target set now derives from `invocation_log.EMBEDDED_MODULES` instead of a second hardcoded path, and a test asserts it matches a file that exists -- a rule guarding zero files must fail, not pass. - The three rendered-shim invariants only ever rendered the rules-less shape. Parametrized over both; the spliced shape did violate the ASCII one, so `argv_match.py` is ASCII-only now, by rule rather than by luck. - Parity test closed the other direction (a facet added to `CliMatch` alone passed before), `MergeField` dropped from `RecordedCli.responses` (never a merge root, so the strategy was inert metadata that read as a knob), doubled paren in CLAUDE.md, and the guide's example no longer uses the one flag a rule may key on but the criterion rejects. BREAKING CHANGE: a flag written inside a `verb:` (e.g. `verb: "ixp projects get --output json"`) is now rejected when a task loads, on `cli_called` and on a `record_cli` response rule. It previously validated and then matched nothing, so the criterion scored 0 against a log holding that exact call. Move the flag to `flags:`. An invalid `matches_regex` is likewise a load error rather than a check-time one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — that review found a real defect chain, not a hypothetical one. All two blockers and all five non-blocking items are addressed in dbc5afb. Blockers1 + 2 (one chain). That makes the checker's pre-flight unreachable, so it is deleted, and its two tests moved from check-time to load-time (including the For the second half — when the shim's rule evaluation does raise — I did not make Non-blocking
NitsAll taken: doubled paren fixed; BREAKING CHANGE footer added to dbc5afb, covering both the flag-in-verb rejection and the Verification: |
… use
CodeQL flagged the import as unused: the only reference was inside a QUOTED
`cast("FlagPredicate", ...)`, which pyright resolves but a static importer
scan cannot see. Unquoting makes it a genuine runtime reference, which is what
the alert was asking for and costs one name lookup per flag predicate at
config-load time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Two critical shim-safety issues and a moderate response-rule validation issue remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds ordered, per-invocation canned responses to record_cli using shared CLI argument-matching semantics.
Changes:
- Adds response rules, fallback behavior, and rule logging.
- Shares matching and validation with
cli_called. - Adds CE047 lint enforcement, documentation, and tests.
File summaries
| File | Review |
|---|---|
tests/test_sandbox_record_cli.py |
Tests response dispatch and fallback. nit (1 vote): Use “infer” after “leaves which ... to.” |
tests/test_custom_lint.py |
Tests CE047 behavior. |
tests/test_cli_match_parity.py |
Tests matching parity. |
tests/test_cli_called_criterion.py |
Updates matcher and validation tests. |
tests/lint/runner.py |
Registers CE047. |
tests/lint/rules/ce047_embedded_shim_stdlib_only.py |
Implements CE047. critical (2 votes): Imported bindings can bypass collision checks and overwrite shim globals; pass bound import names through _check_name. |
src/coder_eval/sandbox.py |
Reports generated response-rule counts. |
src/coder_eval/models/sandbox.py |
Adds response rules and reachability validation. moderate (2 votes): The reachability check can reject reachable rules when later flag predicates alter parsing. |
src/coder_eval/models/criteria.py |
Integrates shared matching definitions. |
src/coder_eval/models/cli_match.py |
Defines match models and validation. |
src/coder_eval/models/__init__.py |
Exports the new models. |
src/coder_eval/invocation_log.py |
Renders response-aware shims. critical (2 votes): SHIM_GLOBALS omits imported template globals, allowing embedded bindings to overwrite names such as sys; include every template-bound name. |
src/coder_eval/criteria/cli_called.py |
Uses the shared matcher. |
src/coder_eval/argv_match.py |
Implements shared argument matching. |
docs/TASK_DEFINITION_GUIDE.md |
Documents response rules. nit (2 votes): Use “infer” rather than “inference” after “meant to.” |
CLAUDE.md |
Updates architecture and lint guidance. |
Review details
Suppressed comments (1)
tests/test_sandbox_record_cli.py:753
- Use the verb “infer” after “leaves which ... to.”
to inference, and reads enough like a command line to invite flags."""
- Files reviewed: 16/16 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- CE047's SHIM_GLOBALS omitted the template's own imports (json, os, sys,
time), so an embedded `sys = None` passed the rule and then broke the
shim's `sys.stdout.write`. The set now covers them, and a test parses a
rendered shim and asserts SHIM_GLOBALS is EXACTLY what it binds -- the
omission was possible only because the list was maintained by hand.
- CE047 checked where an import came from but not the name it binds, so
`from typing import TypedDict as RULES` walked past the collision check.
Import-bound names now go through it too; a plain `import sys` is exempt,
since it binds the very module the shim imports anyway.
- The unreachable-rule check rejected a REACHABLE rule. A flag predicate makes
its flag known and value-bearing in that rule's parse only, so for
`--profile prod ixp projects get` a verb-only `ixp projects` rule leaves
`prod` positional and does NOT match, while a later `ixp projects get` with
`flags: {profile: prod}` does. The prefix proof now requires BOTH sides to be
free of flag predicates.
Plus the grammar fix in the guide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nvocation-responses One conflict, in CLAUDE.md's CE-rules paragraph: main landed a DIFFERENT CE047 (agent-roster parity across the onboarding surfaces, #157). Both branches claimed the next free number, which is the collision the runner's own duplicate-id assertion is written for -- "the loser must renumber" -- and main's is merged, so this branch's embedded-shim rule becomes CE048: tests/lint/rules/ce047_embedded_shim_stdlib_only.py -> ce048_... id, runner import, test class, and the CE047 references in argv_match.py and invocation_log.py move with it. Main's CE047 text is kept verbatim in the paragraph and CE048 appended after it. Nothing else conflicted; tests/test_custom_lint.py auto-merged, and the two rules' test classes (TestCE047AgentRosterParity, TestCE048EmbeddedShim- StdlibOnly) now sit side by side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
18077f5 to
22373c3
Compare

Problem
A
record_clishim answered every invocation with one fixedexit_code/stdout/stderr. So an agent whose next step depends on what the tool just told it could not be evaluated:uip ixp dummy1anduip ixp dummy2got the same reply, and anything needing a real answer fell back to a hand-written mock undermock_path_dirs.What you can write now
Rules are tried in declaration order, first match wins, and anything unclaimed gets the entry's own three fields.
exit_codedefaults to 0 on a rule — the opposite of the entry default of 1 — because a rule exists precisely because the author described that invocation. The log now carries"rule": <index>when a rule answered and omits the key when none did, so "returned the default" and "rule 2 answered, and looks like the default" are no longer the same line.whenis not a second pattern languageThe criterion's matcher moved to
src/coder_eval/argv_match.py— stdlib-only, plain dicts — and both surfaces lower to one spec dict:cli_calledcallsargv_matches(criterion.match_spec, argv); the checker lost ~160 lines.render_recorderembeds that module's source into the shim (read as a package resource), so the pattern that serves a response is the pattern that grades it. A test asserts the embedded copy is the shipped source verbatim; another asserts it is embedded only when the entry declares rules, so a shim with no rules is byte-identical to before.CE047 (new lint rule) keeps that module's imports stdlib-only: the shim runs where
coder_evaland its dependencies are not installed, and one package import there would make every shadowed CLI die with anImportErrorthe agent reads as "the tool is broken".FlagMatchmoved to the new cycle-free leafmodels/cli_match.py, alongsideCliMatchand the shared verb/flag validators —models/sandbox.pycannot import frommodels/criteria.py, which already takesRECORD_CLI_LOGfrom it.Two deliberate divergences from the criterion, both pinned by tests in
tests/test_cli_match_parity.py:cli_calledignore_flagsdefault["output"]— grading must not depend on a flag that changes nothing[]— dispatch may legitimately answer differently for--output jsontoolDrive-by fix: a flag inside a verb matched nothing, silently
verb: "ixp projects get --output json"validated and then could never match, because a verb is compared against the non-flag arguments. Silent in the worst direction:cli_calledscored 0 against a log holding that exact call. Pre-existing on the criterion; now a validation error on every surface, naming the fix. It reuses the splitter's ownis_numberrule, sohead -1stays legal.Review notes
whenis mapping-only — a barewhen: "ixp dummy1"is rejected with the{verb: ...}spelling in the message. An earlier draft accepted the string as shorthand; it was dropped so a pattern has one shape.FlagMatchkeeps its scalar shorthand (flags: {output: json}=={equals: json}). A single-valued predicate has only one facet a scalar could mean, and removing it would be a breaking change to every existing task. Happy to revisit separately if we want strict one-way-only.Verification
ruff format,ruff check,pyright, and the lint-marked suite are clean on the touched files. 4660 tests pass. Eight failures on my Windows box are pre-existing and unrelated — verified identical atorigin/mainwith these changes stashed: symlink-privilege (4),float.numeratorintest_reports_stats_nonfinite(3), and a UTF-8 decode in the CE033 drift test (1).🤖 Generated with Claude Code