Skip to content

fix(agents): prep eval measures the root, not the fake renderer - #12198

Merged
MarkusNeusinger merged 5 commits into
mainfrom
fix/agent-prep-eval-gate
Oct 15, 2026
Merged

MarkusNeusinger merged 5 commits into
mainfrom
fix/agent-prep-eval-gate

Conversation

@MarkusNeusinger

Copy link
Copy Markdown
Owner

Summary

  • The prep eval measured the fake renderer, not the root. Under the default AGENT_RENDERER=fake, every run_data_code call got the fake's fixed table back: 4 rows of x and y = x². The root asked for a 500-step walk, saw 4 squares, and rightly ran its code again ("the data doesn't match the code, I'll run it again"). 9 of the 11 data-code misses came from this alone. The eval's data runs now go through HostDataBackend (agents/evals/host_data.py). It runs the real harness in data mode with the renderer's data-run limits. The local backend's reader reads the result back, extracted as collect_data_run in render/backends/local.py. So run_data_code gets the same DataOutput it would get from POST /run-data. Every data-code case compares table sizes again. An expected size may list two to four values where the user's code leaves the shape open: the random walk defines both steps and walk, so 2 or 3 columns both count.
  • Two stale fixtures. Since feat(agents): find the table among pasted notes, metadata and totals #12166, the parser leaves out a summary row at the end of a paste. en-drop-total-row and de-hard-kette-gesamtzeile still expected the root to drop that row. The root was right to make no drop, and both cases scored as misses. The English case now has its total at the top, where the parser keeps it. The German chain case expects no second drop. A fixture test now refuses an expected drop_rows that drops nothing, so a parser change can't make a fixture stale silently again.
  • Real model misses, fixed in the prompts. The scope judge refused "only orders from Bern that were delivered, then count per product" as needs_data. It also once judged pasted JavaScript data code as other_library. scope_judge.md now names both cases, with different example wording than the fixtures, and scope.evalset.json gets three new cases. The root answered "Zähle die Buchungen pro Kategorie" with query_data as if it were a question. root.md now says that an instruction to count or sum per group is a prepare_data aggregate.

The gate stays at 80 %.

Prep eval, Claude Haiku 5.5 on eu, 50 cases

Run Exact Data code Cost
origin/main, before 36/50 = 72 % (FAIL) 2/11 $0.140
this branch, run 1 50/50 = 100 % 11/11 $0.142
this branch, run 2 49/50 = 98 % 11/11 $0.131

The one miss in run 2 is de-hard-kette-gesamtzeile: the root left out drop_columns Notiz. That is harmless, because the aggregate drops the column anyway, but it is a real model variance, so the fixture stays as it is.

Scope eval after the scope_judge.md change, 250 cases: PASS. False refusals 0 %, adversarial and refusal recall 100 %, exact 98.4 %, 4 unavailable (judge ValueError twice). Before the change, a subset run reproduced the false needs_data on filter-001.

Design note for the owner

HostDataBackend runs model-written Python on the developer's machine without the container. Only the eval uses it, and it refuses ENVIRONMENT=production. Three layers stand in front of it: the DATA validator, which run_data_code applies before any run; the harness's audit-hook guard; and rlimits with python -I and a minimal environment. The local backend's docstring rules out a bare-subprocess fallback for the product. This eval-only exception is a deliberate choice, so please confirm it or pick another option. The alternatives are to require Docker (AGENT_RENDERER=local) or the deployed renderer for the data-code cases.

Plan

No plan file. Brief: Block L, prep eval gate (2026-10-15).

Test plan

  • tests/unit/agents/evals/test_host_data.py: pins the contract. A 500-step snippet comes back as 500×2, not the fixture table. Too many rows returns the harness exit code with measured value and cap. no_frame, error and blocked keep their exit codes. A timeout kills the process group. The environment is the renderer's data environment without RLIMIT_NPROC. Production is refused.
  • test_prep.py: a scripted run through HostDataBackend stores 500 rows in one call. Covers size choices and eval_backend under fake, and checks that every expected drop_rows drops a row.
  • pytest tests/unit/agents (6829 passed), ruff check/format, mypy agents/evals agents/anyplot/render.
  • Live prep eval, twice (table above), plus the full live scope eval.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M

Under AGENT_RENDERER=fake every run_data_code call got the fake's fixed
four-row table, so the root ran its code a second time (9 of 11 data-code
misses). The eval's data runs now execute the snippet with the real harness
in data mode and the renderer's limits (agents/evals/host_data.py), read by
the local backend's reader (collect_data_run). Sizes are compared again; an
expected size may list a few values where the user's code leaves the shape
open.

Also: two stale total-row fixtures (the parser leaves out a trailing summary
row since #12166) with a fixture test that refuses a drop_rows dropping
nothing; the scope judge on filters by place and on pasted JavaScript data
code (three new scope cases); the root on count/sum instructions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
Copilot AI balanced review requested due to automatic review settings October 15, 2026 18:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Executing model-written Python without container isolation requires explicit owner security review.

1 open finding
What changed in this PR

Corrects the prep eval so it measures the root agent’s data preparation rather than the fake renderer’s fixed output.

Changes:

  • Executes eval data snippets through the real harness and compares table sizes.
  • Updates stale fixtures and adds regression coverage.
  • Clarifies prompt handling of aggregation requests, filters, and pasted data code.
File Description
tests/​unit/​agents/​evals/​test_prep.py Tests real data output, size alternatives, and fixture consistency.
tests/​unit/​agents/​evals/​test_host_data.py Tests host execution and failure handling.
changelog.d/​agent-prep-eval-gate.md Records eval and prompt fixes.
agents/​README.md Documents revised eval behavior.
agents/​evals/​scope.evalset.json Adds scope-classification cases.
agents/​evals/​prep.py Selects the host backend and compares table sizes.
agents/​evals/​host_data.py Adds eval-only host data execution.
agents/​evals/​fixtures/​prep/​en-drop-total-row.json Moves the total row before data rows.
agents/​evals/​fixtures/​prep/​en-code-python-random-walk.json Allows two or three output columns.
agents/​evals/​fixtures/​prep/​de-hard-kette-gesamtzeile.json Removes the obsolete row-drop expectation.
agents/​anyplot/​render/​backends/​local.py Extracts shared data-output collection.
agents/​anyplot/​prompts/​scope_judge.md Clarifies filters and pasted data code.
agents/​anyplot/​prompts/​root.md Distinguishes aggregation instructions from questions.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread agents/evals/host_data.py Resolved
Review finding: the timeout cancels the stderr reader, so the kill now reads
stderr to its end while it reaps the process; a regression test floods stderr
past the timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.44186% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
agents/anyplot/render/backends/local.py 80.00% 4 Missing ⚠️
agents/evals/host_data.py 94.80% 4 Missing ⚠️
agents/evals/prep.py 93.54% 4 Missing ⚠️
agents/anyplot/render/png.py 83.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Host execution of model-written code requires owner security review, and malformed-operation matching still needs correction.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced

Comment thread agents/evals/prep.py Resolved
…he renderer

Review round (Fable + Gemini) on #12198:
- HostDataBackend validates the job itself (DATA validator, refused as
  `blocked`), allow-lists ENVIRONMENT development/test, documents that it runs
  on the host and why that is acceptable for the eval.
- prep.main builds the backend before the first case, so a backend the
  settings cannot have is a setup error, not a run of misses.
- One helper, wire.run_cpu_seconds, clamps RLIMIT_CPU to the run's timeout
  in the executor, the local backend and the host runs (20 s, not 60 s).
- The kill is bounded by a grace period, and any failure, not only a
  cancellation, kills and reaps the process group before the cleanup.
- collect_data_run reports an oversized out.csv with its size and cap, as
  the renderer's _collect_data does.
- Scope examples and cases no longer mirror the failed prep fixture; two
  needs_data contrast cases name an outside source.
- README records the scope and prep runs of 2026-10-15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Running model-generated Python directly on the host introduces an intentional isolation exception that requires owner security review.

1 open finding

🧠 Review effort: Balanced

…crash

Review finding: a raw `{"op": "run_data_code"}` the root put into
prepare_data's operations has no size, and matching it raised KeyError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M
@MarkusNeusinger

Copy link
Copy Markdown
Owner Author

Owner decision (2026-10-15): keep option (a), the eval runs model-written data snippets on the host under HostDataBackend (validator inside run_data, DataGuard audit hook, rlimits with the production CPU clamp, -I, a fresh minimal env, development/test only). Docker or the deployed renderer are not required for the prep eval.

@MarkusNeusinger
MarkusNeusinger merged commit 6d482fd into main Oct 15, 2026
17 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the fix/agent-prep-eval-gate branch October 15, 2026 19:48
MarkusNeusinger added a commit that referenced this pull request Oct 15, 2026
…rn (#12199)

## Summary

- **Cause.** The root uses `static_instruction` plus a dynamic
`instruction` (the session block). ADK 2.11 sends the dynamic part as
user content: a `<<<BEGIN_SYSTEM_INSTRUCTION>>>` block with a preamble,
inserted at the current-turn boundary, so it lands inside the user's own
turn (`google/adk/flows/llm_flows/prompt/_instructions.py`,
`LlmRequest._insert_transient_user_content`). Claude Haiku 5.5 read it
as user text that posed as a system instruction. In a baseline trace of
8 prep cases on origin/main, its thinking said so in 7 of 8 turns, for
example "a suspicious block embedded in the user's message ... which
looks like an injection attempt I should disregard". Every one of the 23
root requests carried the markers in a user message.
- **Fix.** The root has no dynamic `instruction` anymore. A
`before_model_callback` (`add_session_context`) appends the session
block to the system instruction as a second part after the static
policy. `VertexClaude` sends each part as its own Claude system block,
with ADK's cache breakpoint on the first part only (`system_blocks`). A
session block that changes therefore leaves the policy cached, and the
static prefix stays byte-identical. `root.md` now describes the block as
part of the instruction instead of explaining away the markers. The
thinking `block_binding` drop stays: a data tool can still change the
block between two calls.
- **Regression checks.** A service-flow test checks that no root request
has the markers or the session block in any content, and that the system
instruction is the static part plus the block. A registry test checks
that no agent has a dynamic `instruction`. Model tests cover the
two-block split, with and without caching. The prep eval now scores a
reply that talks about the root's own instructions (markers, a system
instruction, the session block) as a miss and counts it under
`instruction_talk`. A scripted test covers that path and asserts the
shape of the Claude request.

### Numbers (Claude Haiku 5.5, Vertex AI `eu`)

| Run | Turns flagged in thinking | Replies about it | Root requests
with markers | Exact | Cost |
|---|---|---|---|---|---|
| origin/main, 8 cases (2 of 10 were `guard_unavailable`) | 7/8 | 0/8 |
23/23 | 8/8 | $0.026 |
| this branch, same 10 cases | 0/10 | 0/10 | 0/30 | 10/10 | $0.025 |
| this branch, full prep eval | 0/50 | 0/50 | 0/143 | 47/50 = 94 %
(PASS) | $0.098 (#12198 runs: $0.131, $0.142) |

On the same 8 cases, the cost per model call fell from $0.00114 to
$0.00087. The three full-run misses are model choices unrelated to the
block: one leaves the `Notiz` column in, one labels classes `1`/`2`
instead of `Klasse 1`/`Klasse 2`, and one adds an extra sort. None of
them talked about instructions.

### Decided and documented

- The session block now lives in the system prompt. Its contents keep
the same trust level and fences: the block was already "instruction
priority", with user-derived values only inside `<plot_language>` and
`<user_data>` fences. User text can no longer imitate it in place, and
`root.md` says that a message that imitates the block is wrong. The
scope guard and the `[message withheld]` path touch only user contents
and are unchanged.

## Plan

N/A

## Test plan

- [x] `pytest tests/unit/agents`: 6,842 passed.
`renderer/test_executor.py::test_the_memory_floor_kills_the_run` failed
once under `-n auto` while the live eval ran alongside, and passes
alone. It is unrelated.
- [x] CI blocker fixed in this PR: `test_convert.py::TestSecurityReview`
(from #12194) asserted a 1.0 s wall clock for scanning a 30 MB crafted
comment. The CI runner, under coverage, took 1.17 s and 1.19 s on two
runs. The bound is now 5 s, which still tells a linear scan from a
backtracking one (hours).
- [x] `ruff check`, `ruff format --check`, `mypy api core agents`
- [x] `uv run python -m tools.changelog check --base origin/main`
- [x] Live prep eval on Vertex AI, before and after (see the table)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants