fix: user-sim withholds the prompt for the wire, keeps it for scoring - #2219
Open
hallerite wants to merge 3 commits into
Open
fix: user-sim withholds the prompt for the wire, keeps it for scoring#2219hallerite wants to merge 3 commits into
hallerite wants to merge 3 commits into
Conversation
user-sim withholds the scenario from the assistant by handing its interaction a
prompt-less copy of the task. It built that copy by reconstructing the class
(`type(task)(data.model_copy(...), config)`), which re-invokes `__init__` and drops
any non-data instance state — the reason `with_system_prompt` copies instead
(noted at gepa/adapter.py). Give the derivation a name and the established shape:
`Task.without_prompt()`, alongside `with_system_prompt`, both through one
`copy.copy` + `data.model_copy` helper.
Scoring runs on the withheld row, so `judge_question`'s default (empty
`question_field` -> `prompt_text`) silently judged against an empty question for
every plugged judge left on its default. Raise instead, pointing at
`question_field`.
Also drops the stale mask_prompt wording ("masked" task/chat) and repoints the
three places that spelled the maneuver out in prose at the new method.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ApprovabilityVerdict: Needs human review Introduces a new You can customize Macroscope's approvability policy. Learn more. |
Withholding the prompt dropped it, and scoring runs on the withheld row — so a plugged judge under user-sim had no question to grade against. Both built-ins call `judge_question()` unconditionally while building their prompt fields, no taskset carries a duplicate question column, and `TaskData` is closed to extras, so "point `question_field` at a scoring-side field" was not actually satisfiable: judge-verified user-sim was broken, silently before the guard and loudly after it. `without_prompt()` now MOVES the prompt to `data.withheld_prompt` instead of dropping it (idempotent), and `judge_question`'s default follows it off the wire: `prompt_text` first, then the withheld text, and only then the misconfiguration error. A stock taskset now works under user-sim with a plugged judge and no per-taskset configuration. `prompt_text` still reports the wire, which is empty for a withheld row — the rendering is shared with the new `withheld_prompt_text` rather than made to fall back, so nothing that reads the wire changes meaning. Nothing outside `judge_question` reads the new field: it never enters a request, and rides `trace.task.data` and bearer-keyed `GET /task` exactly as gold `answer` fields already do. It also puts the scenario back on the assistant's trace, which otherwise recorded `prompt: null` and no trace of the goal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_with_data` earned its keep at two call sites; inline it and let `with_system_prompt` carry the copy-not-reconstruct note for both. Trim `without_prompt`'s docstring to the contract. The withholding mechanism isn't user-sim's, so stop describing it as though it were: the field and method read as "whoever opens the conversation holds the question", with user simulation + judging as the motivating case rather than the definition. Drops the unit test for the withheld row — verified live instead: a toy scenario taskset with a plugged `reference` judge on its default `question_field`, run under `--env.id user-sim` against deepseek-v4-flash, scores `reference=1.0` with the scenario off the wire (`prompt: null`), on the row, and absent from the transcript. `test_env_id_user_sim_with_tools` keeps the e2e assertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mikasenghaas
reviewed
Aug 1, 2026
mikasenghaas
left a comment
Member
There was a problem hiding this comment.
from the prompt docstring
"""Initial user prompt; unset if the user opens the conversation."""
doesn't this mean that a judge seeing a null prompt is acc the right behavior? given that the agent turn is seed with the first user message which is on the trace
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #2208, which removed
mask_prompt/wire_dataand had user-sim hand the assistant a prompt-nulled copy of the task instead. The shape is right — nothing threads throughInteraction/Rolloutanymore — but nulling the prompt outright took judge-verified user-sim with it.The bug: a plugged judge under user-sim has nothing to grade against
Rollout.close()scores the task it was handed, which is the prompt-nulled copy.judge_questionwith the default (empty)question_fieldfalls back toprompt_text—""on that row — and both built-ins default toquestion_field = "". So a rubric or reference judge on a user-sim taskset judged against an empty question, with no error."Keep scoring on non-prompt fields" was the stated contract, but it isn't satisfiable in practice:
judge_question()unconditionally while assembling their prompt fields (judges/rubric.py:190,judges/reference.py:83), so even a rubric whose template omits{question}needs one;TaskDatais closed to extras (onlyWireTaskDatasetsextra="allow"), so user-sim can't add one for an arbitrary taskset either.And the scenario is exactly what a judge must grade a conversation against, so
answer-shaped fields don't substitute. This worked before #2208 (mask_prompt kept the real row for scoring).The fix
Task.without_prompt()moves the prompt todata.withheld_promptinstead of dropping it, andjudge_question's default follows it off the wire:prompt_text, then the withheld text, then a misconfiguration error. A stock taskset now works under user-sim with a plugged judge and no per-taskset configuration.prompt_textstill reports the wire, which is empty for a withheld row. The rendering is shared with the newwithheld_prompt_textrather than made to fall back, so nothing that reads the wire (gepa/adapter.py,envs/agentic_judge) changes meaning.judge_questionreads the new field. It never enters a request; it ridestrace.task.dataand bearer-keyedGET /taskexactly as goldanswerfields already do. It also puts the scenario back on the assistant's trace, which otherwise recordedprompt: nulland no record of the goal.without_prompt()is idempotent — withholding twice keeps the scenario rather than overwriting it withNone.Also: name the derivation
user-sim built its copy by reconstructing the class:
That re-invokes
__init__positionally and drops any non-datainstance state — precisely whywith_system_promptcopies instead, asgepa/adapter.pynotes: "copies rather than reconstructs, so subclass state survives". NoTasksubclass currently overrides__init__or carries such state, so it was latent fragility rather than a live bug, but it's the same operation with the worse spelling.without_prompt()now sits alongsidewith_system_prompt, both routed through onecopy.copy+data.model_copyhelper, and user-sim readsagents.assistant.interaction(task.without_prompt()).Leftovers from the
mask_promptremoval go too (agent.py's "(or masked) task",test_e2e.py's "masked chat"), and the four places that spelled the maneuver out in prose —interaction()'s docstring, the_turnerror message,docs/v1/agent.md,skills/create-environments/SKILL.md— now name the method and the field.Checks
ruff check --fix .clean, touched files format-clean,ty check verifiersclean.tests/v1/{test_judges,test_scoring,test_taskset,test_envs,test_configs}.pypassed against the first commit; the suite is CI's from here.Verified live end to end, not just by unit test. A throwaway toy taskset — the row's prompt is the user's scenario, scoring is a plugged
referencejudge left on its defaultquestion_field, no duplicate question column — run under--env.id user-simagainstdeepseek/deepseek-v4-flash:null;withheld_prompt: the scenario;referencereward: 1.0 (judge verdict "yes")wire_prompt_empty=1.0,scenario_on_row=1.0,scenario_not_leaked=1.0(the scenario never appears in the transcript)Judge.completeshows the scenario verbatim in the judge prompt'sTask:block; strippingwithheld_promptfrom the same row (the pre-fix state) raises the no-question errorAlso checked by hand: the move preserves class, config identity, non-prompt fields and subclass instance state, survives a
WireTaskDataJSON round-trip, handlesMessages-form prompts, and is idempotent. No new test files —test_env_id_user_sim_with_toolsnow asserts the scenario stayed off the wire and on the row, which is the e2e that exercises the whole path.One usability note found while testing, not addressed here:
ReferenceJudgedefaults toview="last_reply", which under user-sim shows the judge only the assistant's closing message — a fully successful booking conversation scored 0.0 until the judge was givenview="full_trace". Worth a doc line or a different default for conversational envs.The
markdownlint-cli2/ruff-check/ruff-format/typre-commit hooks can't run on this machine (localuvpredatesuv.lock's revision, so theiruv run --lockedalways fails; markdownlint needs node >= 22). They were skipped at commit time and run by hand as above.🤖 Generated with Claude Code
Note
Withhold scenario prompt from assistant wire while retaining it for scoring in user-sim env
Task.without_prompt()in task.py to produce a shallow-copied task withdata.prompt=Noneand the original prompt stored indata.withheld_prompt.UserSimEnv.runin env.py to passtask.without_prompt()to the assistant interaction instead of a manually constructedprompt=Nonecopy.judge_questionin judge.py to fall back totask.withheld_prompt_textwhenprompt_textis empty, and raisesValueErrorwhen neither is available.withheld_prompt_textrather than finding it absent; the e2e test assertswithheld_prompt_textis non-empty.Macroscope summarized 08b5b7d.