fix(agent): append system_prompt to the Claude Code preset instead of replacing - #92
fix(agent): append system_prompt to the Claude Code preset instead of replacing#92Mihaiii wants to merge 5 commits into
Conversation
… replacing A plain-string ClaudeAgentOptions.system_prompt replaces Claude Code's entire default system prompt. Every experiment that sets even a one-line system_prompt silently strips the harness's behavioral guidance — observed in skills nightly runs as zero parallel tool calls (the batching instruction lives in the default prompt), heavy narration, and raw cat/sed over Read/Grep. Wrap the configured prompt in the SDK's claude_code preset with append so the default prompt survives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @Mihaiii's task in 1m 46s —— View job 🔍 Reviewing PR #92: Fix system_prompt append behaviorTodo List:
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
akshaylive
left a comment
There was a problem hiding this comment.
Review: fix(agent): append system_prompt to the Claude Code preset instead of replacing
PR #92 by @Mihaiii · fix/sys-prompt-append → main · OPEN · reviewed against f066834
The diagnosis here is right and worth fixing: passing a plain string to ClaudeAgentOptions.system_prompt does replace Claude Code's default prompt, which silently strips the behavioral guidance (parallel tool batching, conciseness, tool-use conventions) that tasks are implicitly written against — so any task setting system_prompt has been measuring a crippled agent. The SystemPromptPreset fix is the correct mechanism, it's minimal and proportionate, and it comes with tests. Two things block merge, though. First, _build_options has a second consumer the PR wasn't scoped for: agent_judge sets config.system_prompt to its grading persona, so the judge now runs with the coding-agent preset prepended — that can move scores for byte-identical agent output. Second, the system_prompt is None branch is left as-is, and the SDK maps None to --system-prompt "", meaning the far more common no-system_prompt case still loses the preset — half the bug survives, and one of the new tests asserts that state is correct. Overall 8.9 / 10, weakest axis Evaluation Harness Quality at 5.5 / 10.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 9.9 / 10 | 0 | 0 | 0 | 1 | Last sentence of the new comment restates the code |
| 2. Type Safety | 9.9 / 10 | 0 | 0 | 0 | 1 | SystemPromptPreset imported from a non-root SDK module with no rationale note |
| 3. Test Health | 8.3 / 10 | 0 | 1 | 1 | 2 | test_system_prompt_none_leaves_sdk_default pins a false contract |
| 4. Security | 9.9 / 10 | 0 | 0 | 0 | 1 | Forced append widens the agent_judge trust boundary |
| 5. Architecture & Design | 9.0 / 10 | 0 | 1 | 0 | 0 | One shared base field now has three contradictory per-agent semantics |
| 6. Error Handling & Resilience | 10.0 / 10 | 0 | 0 | 0 | 0 | No findings |
| 7. API Surface & Maintainability | 9.0 / 10 | 0 | 1 | 0 | 0 | docs/agents/CLAUDE_CODE.md:102 now contradicts the code |
| 8. Evaluation Harness Quality | 5.5 / 10 | 1 | 1 | 1 | 0 | agent_judge's grading prompt is now appended to the coding-agent preset |
Overall Score: 8.9 / 10 · Weakest Axis: Evaluation Harness Quality at 5.5 / 10
Totals: 🔴 1 · 🟠 4 · 🟡 2 · 🔵 5 across 8 axes reviewed.
Blockers
-
The change reconfigures the scoring instrument, not just the agent under test.
criteria/agent_judge.py:265doesconfig.system_prompt = _SYSTEM_PROMPTon aClaudeCodeAgentConfigand runs it throughSubAgentRunner→ the very_build_optionsyou edited (src/coder_eval/agents/claude_code_agent.py:1177-1183). That_SYSTEM_PROMPT("You are a strict code reviewer…", plus the untrusted-input warning and the strictsubmit_verdictcontract) was written as the judge's entire identity. Post-PR it sits after Claude Code's coding-agent preset, so the judge is told it's an engineering assistant that should be terse and proactively edit files before it's told it's a grader.agent_judgeproduces continuous scores that gate evals, so this can changescorefor identical agent output. Worth noting the criterion deliberately minimizes injected context elsewhere (setting_sources = [], for the cost/contamination reason CLAUDE.md calls out) — this works against that on every judged task. Suggested fix: addsystem_prompt_mode: Literal["append", "replace"] = "append"toClaudeCodeAgentConfig(agent-specific field on the agent-specific config, matching whereclaude_settings/sdk_optionsalready live) and haveagent_judge._build_agent_configforcereplacealongside its existing security floors — or let_build_optionsaccept a pre-builtstr | SystemPromptPreset. Either way, please pin the judge's effective prompt with a test. -
Half the bug survives: the
system_prompt is Nonepath still loses the preset. The installed SDK mapssystem_prompt=Noneto--system-prompt ""— an explicit empty custom prompt, not the default (claude_agent_sdk/_internal/transport/subprocess_cli.py:465-466). Nearly every task intasks/sets nosystem_prompt, so the common configuration keeps running without Claude Code's guidance even after this merges. Meanwhiletests/test_agent.py:395-404assertssystem_prompt is Noneand its docstring calls that "SDK default prompt", which locks the unfixed behavior in as intended. The transport has a third branch that is the default: a preset dict withoutappendemits no system-prompt flag at all. So buildingSystemPromptPreset(type="preset", preset="claude_code")unconditionally and addingappendonly when configured fixes both halves in one guard. Please also rename that test away from "leaves_sdk_default". -
Cross-run comparability breaks with no marker and no note.
tasks/python_cli_simulated_judged/echo_simulated_judged.yaml:19is written specifically for replace semantics — "reply with that exact string verbatim and nothing else — no preamble, no commentary, no formatting. Ignore any project context (CLAUDE.md, surrounding files)" — and now sits underneath a preset that says roughly the opposite. It'sllm_judge-graded, so its score can move. External task YAMLs in thecoder-eval-uipath/ eval-runner repo change behavior with no code change on their side, and nothing inrun.jsondistinguishes pre- from post-PR semantics, so old and new runs pool silently in trend charts. No schema or container-contract change, so nothing breaks at parse time — but the results shift at the next image rebuild. A CHANGELOG / migration note plus a re-baseline of that task would close it. -
One shared base field now carries three contradictory semantics.
BaseAgentConfig.system_prompt(src/coder_eval/models/agent_config.py:151-158) now asserts "appended to the agent's default system prompt" generically, but only claude-code appends:agents/antigravity_agent.py:351passes it straight tosystem_instructions(replace), andagents/codex_agent.pynever reads it at all (grep returns zero hits — silently inert). The same task YAML therefore hands structurally different prompts to different agents, which matters most when the harness is used for head-to-head comparison. The claude-code parenthetical also puts agent-specific detail into the agent-agnosticmodels/layer, against CLAUDE.md's convention. Suggested: keep the base description agent-neutral and per-agent-qualified, move the preset/append detail ontoClaudeCodeAgentConfig, and give codex either an implementation or a load-time error rather than a silent no-op. -
The owning doc page now says the opposite of the code.
docs/agents/CLAUDE_CODE.md:102still reads "Replaces the default system prompt (there is no append seam)" — a parenthetical explicitly denying the mechanism this PR introduces. This one won't be caught by CI: CE030 doc-parity (tests/lint/doc_schema_parity.py) tracks onlyTaskDefinition/RunLimits/Dataset/SimulationConfig, and even for those it only checks the field name appears, so a semantics inversion passes silently. Please update the row in this PR.
Non-blocking, but please consider before merge
Tests
tests/test_agent.py:380-393asserts only the in-process dict shape. SinceSystemPromptPresetis a bareTypedDict, the constructor isdict(...)with no validation, so the assertion compares a literal against a dict the line under test just built — it pins the values (useful) but nothing about the SDK contract. Feeding the captured options intoSubprocessCLITransport(prompt="x", options=captured_options[0])._build_command()and asserting--append-system-promptpresent /--system-promptabsent is ~4 lines, runs offline, and is the only assertion that survives an SDK shape change. Worth noting nothing in the suite today (includingtest_agent_golden_master.pyandtest_claude_settings_enforcement_live.py) would have caught the original bug either.
Reproducibility
exclude_dynamic_sectionsis left unset, so the preset's dynamic sections (working directory, git status, auto-memory) are injected — in a tempdir sandbox that puts a run-varying path in the system prompt. Settingexclude_dynamic_sections=Truekeeps the prompt static and cache-friendly; the SDK re-injects the stripped content into the first user message, so nothing is lost. Also worth a doc line that the prompt baseline is now CLI-version-dependent, cross-referencingenvironment_info.claude_code_cli(already captured inrun.jsonviautils.py:467-474, which is a nice mitigation).
Docs
docs/agents/ANTIGRAVITY.mdanddocs/agents/CODEX.mddon't documentsystem_promptat all, so after this change there's no page stating the per-agent semantics. Worth adding a row to each while updatingCLAUDE_CODE.md.
Nits
src/coder_eval/agents/claude_code_agent.py:1177-1183— the comment's first two lines carry real "why" (the SDK footgun); the third ("Always keep the default via the SDK preset and append the configured prompt after it") is a prose transcription of the line below it. Consider dropping it.src/coder_eval/agents/claude_code_agent.py:30—SystemPromptPresetisn't inclaude_agent_sdk's root__all__, soclaude_agent_sdk.typesis the only route (correct, andevaluation/verdict_tool.py:21sets precedent). A one-line note saying so would match the treatment the_internal.transportimport three lines above already gets.tests/test_agent.py:380-404— no case forsystem_prompt: "". It currently yieldsappend=""(harmless), but nothing pins it, so a future refactor toif self.config.system_prompt:would silently route empty-string configs into the preset-loss path with the suite still green. Noteantigravity_agent.py:351usesor Noneand treats""as unset, so the two agents already disagree here.- No test pins the per-agent divergence (codex ignoring the field, antigravity replacing), nor that
system_prompt_filereaches the same append path — the two halves are covered separately but never joined. - CodeQL's
py/unused-importatclaude_code_agent.py:31is a stale pre-existing note, not something this PR introduced — the new symbol is used both as an annotation and as a runtime constructor call, and ruff F401 is clean.
What's Missing
Parallel paths
- 🔴
criteria/agent_judge.py/evaluation/sub_agent.pyare the second consumer of_build_optionsand weren't considered — triggered byclaude_code_agent.py:1177-1183. - 🟠
agents/codex_agent.pyandagents/antigravity_agent.pyweren't touched, so the shared field they inherit now diverges from its own description — triggered bymodels/agent_config.py:151-158.
Tests
- 🔴 Nothing pins
agent_judge's effectiveClaudeAgentOptions.system_prompt, so the judge's persona change is invisible. - 🟡 No transport-level assertion — the surface the original bug lived on is untested.
- 🔵 No case for
system_prompt: ""; no test pinning the per-agent divergence.
Downstream consumers
- 🟠 External task YAMLs in
coder-eval-uipath/ eval-runner that setsystem_promptchange behavior with no change on their side;run.jsonrecords nothing marking which semantics were used. - 🟠
tasks/python_cli_simulated_judged/echo_simulated_judged.yamlneeds re-baselining or rewording.
Display & mapping dicts
- Nothing identified — no enum,
FinalStatus, or union value changed, so noreports*.pymapping needs extending.
Harness & Lint Improvements
Static checks (lint / type)
- Extend CE030 (
tests/lint/doc_schema_parity.py) toBaseAgentConfigand the per-agent config subclasses, mapped to theirdocs/agents/*.mdpages — would have caught theCLAUDE_CODE.md:102drift atmake linttime (name-level only; a semantics inversion still needs a human). - New
CEnnn: aField(description=...)on a shared base model inmodels/agent_config.pymust not contain a registered agent-kind string — grep-shaped, would have caught the claude-code parenthetical leaking into the agnostic base model, and permanently enforces the agent-agnostic-core convention. - New
CEnnn(CE031-style dead-config extension): every concrete agent module must reference each behavior-drivingBaseAgentConfigfield by name, or carry anEXEMPTentry — would have surfaced thatcodex_agent.pyreadssystem_promptnowhere. - Not statically reachable: the judge-persona and comparability findings need semantic knowledge of what the CLI does with a flag and what a prompt means to a model. Those stay tests and review.
Harness improvements
- A transport-level parity test over the
system_promptmatrix (None/""/ set / preset-without-append) asserting exact argv — needs the installed SDK's arg construction, so it can't be static; would have caught both test-health findings and the original bug. - Record the resolved system-prompt mode (or a hash of the effective prompt) in the run record — needs runtime state; makes the silent pre/post pooling visible.
- A pinning test for
agent_judge's effectiveClaudeAgentOptions— needs a live options capture.
Top 5 Priority Actions
- Stop the judge from inheriting the coding-agent preset. Add a replace seam on
ClaudeCodeAgentConfig(or let_build_optionsaccept a pre-builtstr | SystemPromptPreset) and haveagent_judge._build_agent_configforce replace, next to its existingsetting_sources=[]andignore_patternsfloors. Pin it with a test. This is the only finding that can move scores for identical agent output. - Fix the other half of the bug: the
system_prompt is Nonepath. EmitSystemPromptPreset(type="preset", preset="claude_code")unconditionally and addappendonly when configured, so the unset case produces no system-prompt flag (the CLI default) instead of--system-prompt "". Then rewrite and renametest_system_prompt_none_leaves_sdk_default— today it asserts something the SDK does not do. - Add a transport-level assertion feeding the captured options into
SubprocessCLITransport._build_command()and checking--append-system-promptis present and--system-promptabsent. Four lines, offline, and the only test that survives an SDK shape change. - Update
docs/agents/CLAUDE_CODE.md:102(currently the exact opposite of the new behavior) and rewordBaseAgentConfig.system_prompt's description to state the per-agent semantics honestly rather than asserting append generically. Add the field toANTIGRAVITY.md/CODEX.mdwhile there. - State the blast radius and re-baseline. Add a CHANGELOG / migration note that
system_promptsemantics changed from replace to append, re-checkecho_simulated_judgedat HEAD, and flag it for the externalcoder-eval-uipathpipeline owners.
Change class: complex — it changes prompt-construction semantics on a shared config field, and that field feeds the agent_judge scoring path, so correctness requires reasoning about consumers outside the diff.
Stats: 1 🔴 · 4 🟠 · 2 🟡 · 5 🔵 across 8 axes reviewed.
CodexAgent silently dropped config.system_prompt; forward it as developer_instructions (injected on top of the Codex base prompt) to match the append semantics of Claude Code (claude_code preset) and Antigravity (TemplatedSystemInstructions, which already appended). Also document the ripple effects of append-only system_prompt: - agent_judge: the reviewer prompt is now layered after the full Claude Code preset instead of replacing it (accepted trade-off, noted in code) - BaseAgentConfig.system_prompt description states per-agent semantics - docs: fix the stale "Replaces the default" claim in CLAUDE_CODE.md, add a System prompt row to CODEX.md, document Antigravity's append shorthand Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blockers from the PR #92 review: - system_prompt unset no longer loses the preset: the SDK maps None to --system-prompt "" (an explicit EMPTY prompt), so _build_options now always sends the claude_code preset — bare (CLI default prompt) when unset, with `append` when configured. This fixes the common no- system_prompt case, which previously ran without Claude Code's default behavioral guidance. - agent_judge no longer inherits the coding-agent preset: new ClaudeCodeAgentConfig.system_prompt_mode ("append" default / "replace"), forced to "replace" in _build_agent_config next to the existing security floors, so the judge prompt stays its entire identity and verdicts can't shift with the preset. Pinned by test. - exclude_dynamic_sections=True on the preset keeps the system prompt static across runs (no per-run tempdir path baked in); the SDK re-injects the stripped sections into the first user message. - Transport-level tests: captured options are rendered through SubprocessCLITransport._build_command() asserting the exact flag emitted (--append-system-prompt vs --system-prompt vs none) — the surface the original bug lived on. Also pins system_prompt: "" and the renamed unset-case test (the old name asserted a false SDK contract). - BaseAgentConfig.system_prompt description is agent-neutral again; the claude-specific mechanism lives on ClaudeCodeAgentConfig + docs/agents/. MIGRATION NOTE: system_prompt semantics on claude-code changed from replace to append, and runs WITHOUT system_prompt now get the real Claude Code default prompt instead of an empty one. Scores are comparable only within one semantics regime — re-baseline judged tasks (e.g. tasks/python_cli_simulated_judged/echo_simulated_judged.yaml, whose prompt was written against replace semantics) and pin runs to the CLI version recorded in environment_info.claude_code_cli. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trend dashboards need to segment runs by system-prompt regime instead of silently pooling pre-/post-append-semantics scores (PR #92 review, cross-run comparability blocker). Each built-in agent now emits system_prompt_semantics via get_environment_info(), merged into run.json: - claude-code: the resolved system_prompt_mode ("append" / "replace") - codex: "append" (developer_instructions; previously the field was silently dropped, so codex runs also cross a semantics boundary here) - antigravity: "append" (unchanged behavior, emitted for uniformity) Runs without the marker predate the change and used replace-on-set / empty-on-unset (claude-code) or dropped (codex) semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@akshaylive The review is addressed now, please have a second look. Antigravity doesn't need any change because it already is on append mode by default, not replace. |
uipreliga
left a comment
There was a problem hiding this comment.
Review: fix(agent): append system_prompt to the Claude Code preset instead of replacing
PR #92 by @Mihaiii · fix/sys-prompt-append → main · OPEN · reviewed against 3083fc8
Change class: complex — changes the system-prompt regime for every Claude Code run (preset-append instead of replace) and adds a new system_prompt_mode public config field plus a cross-agent environment_info marker; correctness requires reasoning about SDK prompt semantics and score comparability across the boundary.
The codebase is in strong shape — clean security, error handling, and test hygiene, with no critical findings and every issue traceable to one new feature — but the system_prompt_mode: replace rollout is incomplete in ways that silently change behavior: the user simulator's persona now ships behind the Claude Code coding-agent preset (altering every dialog-mode evaluation), a replace request with no prompt is silently ignored while run.json still labels the run "replace", and the persisted preset dict leaks into the report's System Prompt row; fix those three plus the Antigravity empty-string divergence and the stale append-only prose, and this lands comfortably at its 9.6.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 9.9 / 10 | 0 | 0 | 0 | 1 | Empty-string system_prompt resolves to three different regimes across the three agents, all reported as append |
| 2. Type Safety | 8.9 / 10 | 0 | 0 | 2 | 1 | system_prompt_mode="replace" with no system_prompt silently runs the preset/append regime while environment_info.system_prompt_semantics records "replace" (no validator, no test) |
| 3. Test Health | 9.8 / 10 | 0 | 0 | 0 | 2 | system_prompt_semantics marker tests are tautological — no test asserts the marker reaches run.json or reflects real append behavior |
| 4. Security | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 5. Architecture & Design | 9 / 10 | 0 | 1 | 0 | 0 | system_prompt_mode="replace" hardening is applied only at the agent_judge callsite — no SubAgentRunner-level guard and UserSimulator's identity prompt still ships behind the preset |
| 6. Error Handling & Resilience | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 7. API Surface & Maintainability | 9.9 / 10 | 0 | 0 | 0 | 1 | CodexAgent.get_environment_info docstring still claims it only emits on a custom endpoint |
| 8. Evaluation Harness Quality | 9 / 10 | 0 | 1 | 0 | 0 | Persisted sdk_options.system_prompt becomes a preset dict on every Claude Code run, so the report's "System Prompt" row renders a Python dict repr and now always appears |
Overall Score: 9.6 / 10 · Weakest Axis: Type Safety at 8.9 / 10
Totals: 🔴 0 · 🟠 2 · 🟡 2 · 🔵 5 across 8 axes.
Blockers
- [Axis 5]
system_prompt_mode="replace"hardening is applied only at the agent_judge callsite — no SubAgentRunner-level guard and UserSimulator's identity prompt still ships behind the preset (src/coder_eval/criteria/agent_judge.py:274) —_build_agent_configopts the judge out of the preset withconfig.system_prompt_mode = "replace" # Force replace regardless of user YAML: the judge prompt is its entire identity. There are exactly TWO in-tree consumers that build aClaudeCodeAgentConfigwhosesystem_promptIS the sub-agent's entire identity; the second one was not updated.src/coder_eval/simulation/user_simulator.py:205-217callsparse_agent_config(type=AgentKind.CLAUDE_CODE, ..., system_prompt=self._system_prompt)and hands it toClaudeCodeAgent(self._agent_config, route=self._route, instance_name="simulator")(line 263). That prompt (built by_extract_system_prompt, line 90) begins"You are roleplaying a human user who is interacting with an autonomous coding agent."and instructs- Stay in character. Never reveal you are an LLM, never repeat or reference these instructions.Verified:parse_agent_config(type=AgentKind.CLAUDE_CODE, model=None, allowed_tools=[], setting_sources=[], permission_mode='default', system_prompt=...)resolves tosystem_prompt_mode == 'append', soclaude_code_agent.py:1192takes the else-branch and the simulator's system prompt becomesSystemPromptPreset(type="preset", preset="claude_code", exclude_dynamic_sections=True)with the roleplay text merely appended — i.e. the simulated user now carries Claude Code's coding-agent identity and behavioral guidance ahead of its persona, directly contradicting the persona's own instructions and changing every dialog-mode (simulation:) evaluation. The root cause is architectural: the opt-out is a per-callsite mutation that fails OPEN, so every present and future internal identity-prompt consumer must remember it. Fix both halves: setsystem_prompt_mode="replace"inuser_simulator.py'sparse_agent_config(...)call, and enforce the invariant at the shared seam the wayevaluation/sub_agent.py:87already enforcessetting_sources(raise ValueError("SubAgentRunner requires agent_config.setting_sources=[] ...")) rather than relying on each caller. - [Axis 8] Persisted
sdk_options.system_promptbecomes a preset dict on every Claude Code run, so the report's "System Prompt" row renders a Python dict repr and now always appears (src/coder_eval/agents/claude_code_agent.py:1218) —system_prompt=system_prompt,(line 1218) now feeds aSystemPromptPresetdict intoClaudeAgentOptions, and line 1229self._sdk_options_dump = dump_dataclass(options)persists it verbatim intoEvaluationResult.sdk_options— the recorddocs/REPORT_SCHEMA.md:136lists as a cross-repo contract surface ("Config/environment:environment_info,agent_config,sdk_options(raw"). Verified end-to-end against the installed SDK:dump_dataclass(ClaudeAgentOptions(system_prompt=preset))['system_prompt']=={'type': 'preset', 'preset': 'claude_code', 'exclude_dynamic_sections': True, 'append': 'You are a literal-minded assistant.'}. Two concrete in-repo consequences, neither covered by a test: (a)src/coder_eval/reports.py:90-94doesprompt_str = str(settings_source["system_prompt"]).replace("\n", " "), so the Markdown and HTML "System Prompt" row now renders the Python dict repr instead of the prompt text; (b) that row previously vanished when no prompt was configured (system_prompt is None) and now ALWAYS appears, showing{'type': 'preset', 'preset': 'claude_code', 'exclude_dynamic_sections': True}. The existing guardtests/test_reports.py:904(assert "**System Prompt**" not in report_md) still passes only because its fixture hardcodes"system_prompt": None(tests/test_reports.py:883) — a value a real Claude run can no longer produce. Fix: unwrap for reporting/persistence (store the effective prompt string plus the mode) or teachcollect_agent_settings_rowsthe preset shape, add a report test fed from a realdump_dataclass(options)rather than a hand-built dict, and state thesdk_options.system_prompttype change indocs/REPORT_SCHEMA.mdso the externalcoder-eval-uipath/ eval-runner consumer can be updated in lockstep.
Non-blocking, but please consider before merge
- [Axis 2]
system_prompt_mode="replace"with nosystem_promptsilently runs the preset/append regime whileenvironment_info.system_prompt_semanticsrecords "replace" (no validator, no test) (src/coder_eval/agents/claude_code_agent.py:1251) — The regime is decided by a two-term condition at claude_code_agent.py:1192 —if self.config.system_prompt_mode == "replace" and self.config.system_prompt is not None:— but the telemetry marker at claude_code_agent.py:1251 reports only one term:return {"system_prompt_semantics": self.config.system_prompt_mode}. Nothing validates the pair. Verified empirically at PR HEAD (parse_agent_config(type=AgentKind.CLAUDE_CODE, system_prompt_mode='replace')thenClaudeCodeAgent._build_claude_query(...)):system_prompt sent: {'type': 'preset', 'preset': 'claude_code', 'exclude_dynamic_sections': True}whileenv_info: {'system_prompt_semantics': 'replace'}. So an operator running-D agent.system_prompt_mode=replacewithout a prompt gets the FULL claude_code coding-agent preset — the opposite of what agent_config.py:206 documents ("'replace' sends it as the ENTIRE system prompt") and of docs/agents/CLAUDE_CODE.md's new row ("replacesendssystem_promptas the entire system prompt (no preset)") — and run.json labels that run"replace", so the marker whose own docstring says "trend dashboards must not pool scores across that boundary" (claude_code_agent.py:1249) mis-buckets it. Fix both halves: (a) add a@model_validator(mode="after")onClaudeCodeAgentConfig(mirroringcheck_prompt_exclusivityat agent_config.py:188) rejectingsystem_prompt_mode == "replace"whensystem_prompt is None— the inverse guard of the new conditional; (b) derive the marker from the same expression the options builder uses (extract an_effective_prompt_mode() -> Literal["append", "replace"]helper called from both line 1192 and line 1251) so the run record can never disagree with the wire. Add a test for thereplace+ unset pair — the five new tests in tests/test_agent.py cover append / unset / empty-string / replace-with-prompt but not this combination. - [Axis 2] Documentation asserts
system_promptis "never a replacement"/"always kept", contradicted by thesystem_prompt_mode: replaceadded in the same PR (src/coder_eval/models/agent_config.py:154) — agent_config.py:154 now reads"Custom system prompt, appended to the agent's default system prompt — never a replacement. "onBaseAgentConfig— the vendor-neutral base every agent kind inherits. Fifty lines below, agent_config.py:201-208 addssystem_prompt_mode: Literal["append", "replace"]whose own description says"'replace' sends it as the ENTIRE system prompt.", and src/coder_eval/criteria/agent_judge.py:272 forces exactly that (config.system_prompt_mode = "replace"). The absolute "never" is therefore false for the flagship agent on its most security-relevant path. It is also false forNoneAgentConfig(agent_config.py:308):grep -n "system_prompt" src/coder_eval/agents/noop_agent.pyreturns nothing — the NoOp agent neither appends nor replaces, it drops the field — and the same holds for any out-of-tree BYOABaseAgentConfigsubclass, sincesystem_prompt_modelives only onClaudeCodeAgentConfig. Per CLAUDE.md ("Field descriptions ... defined once in Pydantic models" / "Single Source of Truth"), this description is the schema SSOT surfaced in-Ddid-you-mean help and generated docs. Soften it to the honest contract, e.g. "Custom system prompt. Built-in agents layer it on top of their default prompt rather than replacing it; Claude Code can opt out viasystem_prompt_mode: replace. Each agent's doc page (docs/agents/) states the exact mechanism." — keeping the per-agent doc pointer already on line 155.
Nits
- [Axis 1] Empty-string
system_promptresolves to three different regimes across the three agents, all reported asappend(src/coder_eval/agents/codex_agent.py:1290) — The new Codex plumbing at line 1290-1291 usesif self.config.system_prompt is not None:/options["developer_instructions"] = self.config.system_prompt, matching Claude Code'sis not Noneatclaude_code_agent.py:1196— and the PR even pins that semantics withtest_system_prompt_empty_string_appends_empty("a future truthiness refactor must not route it into the preset-loss path"). Butsrc/coder_eval/agents/antigravity_agent.py:351still readssystem_instructions=self.config.system_prompt or None, sosystem_prompt: ""is silently dropped there while it is forwarded on the other two. Technique 2 (parallel code paths): the PR deliberately unifies system-prompt semantics across agents yet leaves this one divergence, and the new empty-string test covers only Claude Code. Either switch Antigravity toif self.config.system_prompt is not Nonefor parity, or document whyor Noneis required by the Antigravity SDK. - [Axis 2] New test helper
_transport_command(options)has an untyped parameter, and the union-typedsystem_promptis indexed unchecked (tests/test_agent.py:380) — tests/test_agent.py:380 declaresdef _transport_command(options) -> list[str]:— the return is typed but the parameter is bare, so the helper accepts anything andoptions.cli_path = "claude"(line 389) /SubprocessCLITransport(prompt="x", options=options)(line 390) are unchecked. Annotate itoptions: ClaudeAgentOptions. Relatedly, tests/test_agent.py:441 doesassert captured_options[0].system_prompt["append"] == ""—ClaudeAgentOptions.system_promptisstr | SystemPromptPreset | SystemPromptFile | None(claude_agent_sdk/types.py:1752), so subscripting it is only silent because the pre-existing_capture_sdk_optionsat tests/test_agent.py:184 is annotated-> "list"(unparameterized) and pyright excludestests/anyway. Parameterize that helper as-> list[ClaudeAgentOptions]and assert the whole dict (as the sibling tests at lines 400-405 already do) instead of indexing the union, so an SDK reshape ofSystemPromptPresetsurfaces as a typed failure rather than a runtimeTypeError. - [Axis 3]
system_prompt_semanticsmarker tests are tautological — no test asserts the marker reaches run.json or reflects real append behavior (tests/test_agent.py:461) —test_environment_info_reports_system_prompt_semantics(tests/test_agent.py:461) asserts onlydefault_agent.get_environment_info() == {"system_prompt_semantics": "append"}on the agent method; the same is true of the Codex (tests/test_codex_agent.py:334) and Antigravity (tests/test_antigravity_agent.py:67) markers. Nothing exercises the merge seamself.result.environment_info.update(self.agent.get_environment_info())atsrc/coder_eval/orchestrator.py:1217with a non-empty agent dict:tests/test_route_seam_exhaustiveness.py:90passesagent=None(fake = SimpleNamespace(route=r, eval_route=r, result=SimpleNamespace(environment_info={}), agent=None)), and the two_setuptests use DummyAgents whoseget_environment_infoisreturn {}(tests/test_orchestrator.py:606 and :670). Sinceenvironment_infoin run.json is the cross-repo contract consumed by the external eval-runner, add one assertion that an agent-supplied key survives the merge intoEvaluationResult.environment_info. - [Axis 3]
_transport_commandhelper overclaims:exclude_dynamic_sectionsnever reaches argv, so the reproducibility half of the change is untested (tests/test_agent.py:380) — The helper's docstring says it "pins the SDK contract (which flag the transport emits)", and the tests assert on--append-system-prompt/--system-promptpresence. Butexclude_dynamic_sectionsis not a CLI flag:claude_agent_sdk/_internal/client.py:148-155extracts it from the preset dict and_internal/query.py:209-210sends it asrequest["excludeDynamicSections"]in the control-protocol initialize message (if self._exclude_dynamic_sections is not None: request["excludeDynamicSections"] = self._exclude_dynamic_sections). The SDK comment there notes "older CLIs ignore unknown initialize fields". So the only coverage of the unconditionalexclude_dynamic_sections=True(src/coder_eval/agents/claude_code_agent.py:1195) is the dict-literal assertion; the run-comparability claim documented in docs/agents/CLAUDE_CODE.md is unverified. Either narrow the_transport_commanddocstring to say it pins argv only, or add an assertion that the SDK's extraction path picks the flag up from the options we build. - [Axis 7] CodexAgent.get_environment_info docstring still claims it only emits on a custom endpoint (
src/coder_eval/agents/codex_agent.py:940) — Line 940 still readsOnly emits when a custom endpoint is configured (CODEX_BASE_URL). On a— but the PR made line 951 seedinfo: dict[str, Any] = {"system_prompt_semantics": "append"}and line 954 return it unconditionally, which the amended test at tests/test_codex_agent.py:339 pins (assert agent.get_environment_info() == {"system_prompt_semantics": "append"}). Reword the docstring so the conditional applies to the routing keys (codex_base_url_host/codex_wire_api/codex_api_version), not the whole dict; the new inline comment at 947-950 documents the unconditional key but the docstring above it was left stale.
What's Missing
Parallel paths:
- 🟠
criteria/agent_judge.pyforcessystem_prompt_mode="replace"for the judge, but the other in-tree consumer whosesystem_promptis a sub-agent's entire identity —simulation/user_simulator.py:205-214→ClaudeCodeAgent(..., instance_name="simulator")at line 263 — was not updated, so the simulated user now runs theclaude_codecoding-agent preset with its roleplay persona merely appended (verified: options.system_prompt == the preset dict +append). Setsystem_prompt_mode="replace"there and enforce the invariant at the sharedSubAgentRunnerseam (evaluation/sub_agent.py:85, which already hard-fails onsetting_sources) rather than per callsite. (trigger: src/coder_eval/criteria/agent_judge.py) (restates: Axis 5:system_prompt_mode="replace"hardening applied only at the agent_judge callsite) - 🔵 The PR unified
system_prompt is not Nonehandling inclaude_code_agent.py:1196andcodex_agent.py:1290(and pinned it withtest_system_prompt_empty_string_appends_empty), but leftantigravity_agent.py:351onsystem_instructions=self.config.system_prompt or None—system_prompt: ""is still silently dropped on that one agent while all three reportsystem_prompt_semantics: append. (trigger: src/coder_eval/agents/antigravity_agent.py) (restates: Axis 1: Empty-stringsystem_promptresolves to three different regimes across the three agents) - 🔵 The new
system_prompt_semanticsmarker was hand-copied into three agents but not defaulted on the ABC:agent.py:325-337still returns{}and its docstring only mentions "routing/environment details", andagents/noop_agent.py(plus any out-of-tree BYOA / plugin agent, e.g. thecoder_eval_uipathDelegate agent) emits no marker at all. A consumer therefore cannot distinguish "pre-marker run" from "agent that never emits it" — declare the key onAgent.get_environment_info's contract (or supply theappenddefault on the base) so new agents inherit it. (trigger: src/coder_eval/agents/antigravity_agent.py) - 🔵
docs/AB_EXPERIMENTS.md:136-138enumerates theagent-dict keys an experiment variant may override (system_prompt/system_prompt_file,setting_sources,claude_settings,sdk_options) and was not extended with the newsystem_prompt_mode— the field most likely to be A/B-tested (append vs replace arms) is missing from the one page that lists variant levers. (trigger: src/coder_eval/models/agent_config.py)
Tests:
- 🟡 The five new tests cover append-with-prompt, unset, empty-string, replace-with-prompt and the marker, but not the fourth cell of the 2×2:
system_prompt_mode="replace"withsystem_promptunset — the combination that silently falls through to the preset whileenvironment_inforecords"replace". Add that case alongside the validator that should reject it (allowingsystem_prompt_file, which task_loader inlines later). (trigger: tests/test_agent.py) (restates: Axis 2:system_prompt_mode="replace"with nosystem_promptsilently runs the preset/append regime) - 🟡 No test feeds a real
dump_dataclass(options)intoreports.collect_agent_settings_rows, so the new preset-dict shape reaching the Markdown/HTML "System Prompt" row is uncovered; the existing guardtests/test_reports.py:904still passes only because its fixture hardcodes"system_prompt": None(line 884) — a value a real Claude Code run can no longer produce. (trigger: tests/test_agent.py) (restates: Axis 8: Persistedsdk_options.system_promptbecomes a preset dict on every Claude Code run) - 🟡 The PR newly documents Antigravity's append mechanism (
system_prompt→system_instructions→TemplatedSystemInstructions) and adds an env-marker test, butgrep -rn "system_instructions" tests/returns zero hits — nothing anywhere pins thatsystem_promptactually reaches the Antigravity SDK, so both the documented claim and theor Nonedrop are untested. Add a_build-level assertion mirroring the new Codexdeveloper_instructionstests. (trigger: docs/agents/ANTIGRAVITY.md) - 🟡
tests/test_user_simulator.pyasserts only the rendered prompt string (sim.system_prompt), never the SDK options the simulator agent is built with — which is exactly why the preset now silently wrapping the simulator persona passes CI. Add a simulation-side assertion on the resolvedClaudeAgentOptions.system_prompt(or on_agent_config.system_prompt_mode), the same shape as the new judge test intests/test_agent_judge_criterion.py. (trigger: src/coder_eval/agents/claude_code_agent.py) (restates: Axis 5:system_prompt_mode="replace"hardening applied only at the agent_judge callsite) - 🔵 All three new marker tests assert the agent method's hardcoded return value; nothing exercises
orchestrator.py:1217(environment_info.update(self.agent.get_environment_info())) with a non-empty agent dict (the orchestrator DummyAgents return{}andtest_route_seam_exhaustiveness.py:90passesagent=None), so no test proves the marker actually lands inrun.json. (trigger: tests/test_agent.py) (restates: Axis 3:system_prompt_semanticsmarker tests are tautological)
Downstream consumers:
- 🟡 The marker's stated purpose — "trend dashboards must not pool scores across that boundary" (
claude_code_agent.py:1249) — has no consumer:grep -rn "system_prompt_semantics" evalboardreturns nothing,evalboard/lib/runs.ts:415typesenvironment_infoas an opaqueRecord, and every stored historical run lacks the key entirely. Either add the evalboard segmentation/back-fill rule (absent key ⇒ pre-append regime) or soften the docstring to "recorded for offline segmentation". (trigger: src/coder_eval/agents/claude_code_agent.py) - 🟡
docs/REPORT_SCHEMA.md— the documented cross-repo run-record contract consumed bycoder-eval-uipath/ eval-runner — was not touched: neither the newenvironment_info.system_prompt_semanticskey (line 52) nor the type change ofsdk_options.system_promptfromstr | nullto a preset dict (line 136) is stated, so external consumers that string-handle that field get no notice. (trigger: src/coder_eval/agents/claude_code_agent.py) (restates: Axis 8: Persistedsdk_options.system_promptbecomes a preset dict on every Claude Code run) - 🔵 Cumulative-budget caps are computed from token counts that just changed: with the preset now always sent (main sent
--system-prompt ""), every turn's input/cache-creation tokens rise by the fullclaude_codeprompt, sorun_limits.max_input_tokens/max_total_tokens/max_usdvalues calibrated pre-change (e.g.tasks/smoke_budget_exceeded.yaml,tasks/smoke_cost_budget_exceeded.yaml,experiments/default.yaml) and anycommands_efficiencybudgets may now trip or pass differently. Nothing in the PR revisits those thresholds. (trigger: src/coder_eval/agents/claude_code_agent.py)
Display & mapping dicts:
- 🟡
reports.collect_agent_settings_rows(reports.py:90-94) was not extended for the new value shape: itsstr(settings_source["system_prompt"])now renders a Python dict repr in both Markdown and HTML, the row that used to disappear when no prompt was set is now always present, and ~72 of the 200SYSTEM_PROMPT_PREVIEW_CHARSare consumed by preset metadata before any prompt text. (trigger: src/coder_eval/agents/claude_code_agent.py) (restates: Axis 8: Persistedsdk_options.system_promptbecomes a preset dict on every Claude Code run) - 🔵 The new
system_prompt_modefield is not rendered anywhere in the report surfaces —collect_agent_settings_rowshas no row for it in either theagent_configorsdk_optionstable — so a report reader can only infer the regime from the raw Environment key/value dump. Add a "System Prompt Mode" row next to "System Prompt" when it is non-default. (trigger: src/coder_eval/models/agent_config.py)
Daily/nightly:
- 🟠 Blast radius on the production/nightly path is unstated outside a docs blockquote: on
mainan unsetsystem_promptproduced--system-prompt ""(SDKsubprocess_cli.py:465-466— an explicitly EMPTY prompt), so every Claude Code task in every suite now runs with the fullclaude_codepreset instead. Pass rates, turn counts, token/cost baselines and stored trend series all shift at this commit; the PR should say whether nightly baselines are re-run, from which run_id the series is re-based, and how historical runs (which carry nosystem_prompt_semanticskey) are labelled. (trigger: src/coder_eval/agents/claude_code_agent.py) - 🟡 The
exclude_dynamic_sections=Truereproducibility guarantee is unverified on the containerized (production) path: it is not a CLI flag but a control-protocolexcludeDynamicSectionsinitialize field (SDK_internal/query.py:209-210), which the SDK's own comment notes older CLIs silently ignore, whiledocker/DockerfilepinsCLAUDE_CODE_VERSION=2.1.177anddocker/Dockerfile.runtimemirrors it. Nothing asserts a minimum CLI version or states what the docker-driver nightly actually gets. (trigger: src/coder_eval/agents/claude_code_agent.py) - 🟡 The Codex half is a silent behavior change on any existing Codex task or experiment variant that sets
agent.system_prompt: the field was previously DROPPED and is now injected asdeveloper_instructions(codex_agent.py:1291). The newenvironment_infocomment records the boundary, but the PR does not state which Codex-backed suites are affected or whether their scores need re-baselining alongside the Claude Code ones. (trigger: src/coder_eval/agents/codex_agent.py)
Harness & Lint Improvements
Static checks (lint / type):
- [ce-lint] New rule CE032
NoTruthyOptionalConfigCoercion(tests/lint/rules/ce032_no_truthy_optional_config_coercion.py, added toALL_RULESintests/lint/runner.py, tested intests/test_custom_lint.py). Pattern forbidden: truthiness collapse of an agent-config field insidesrc/coder_eval/agents/**— anyBoolOp(Or)whose left operand is an attribute chain rooted atself.config(self.config.system_prompt or None), and any bareif self.config.<field>:/if not self.config.<field>:where the field is declaredstr | None/list | Noneon aBaseAgentConfigsubclass. Required form is the explicitis None/is not Nonetest thatclaude_code_agent.py:1196andcodex_agent.py:1290already use. Scope the AST match toself.config.*roots only, so the legitimate env-var idioms (os.getenv("GEMINI_API_KEY") or None,codex_agent.py:1065) stay legal — a grep ofsrc/showsself.config.<x> or Noneoccurs exactly once today, atagents/antigravity_agent.py:351, i.e. the rule lands with one violation and no cleanup tail. Prevents: Finding 1 (system_prompt: ""silently dropped byantigravity_agent.py:351'sself.config.system_prompt or Nonewhile Claude Code and Codex forward it) — and permanently pins the empty-string semantics thattest_system_prompt_empty_string_appends_emptyonly asserts for one of the three agents. - [ce-lint] New rule CE033
EnvInfoMarkersDerivedNotRestated(tests/lint/rules/ce033_env_info_markers_derived.py, wired intotests/lint/runner.py). Pattern forbidden: inside aget_environment_infomethod body, returning a raw config attribute as a telemetry value (return {"system_prompt_semantics": self.config.system_prompt_mode},claude_code_agent.py:1251) when that sameself.config.<field>also appears as an operand of a multi-termBoolOptest elsewhere in the same module (claude_code_agent.py:1192:system_prompt_mode == "replace" and system_prompt is not None). The fix the rule forces is the one the finding recommends: extract_effective_prompt_mode() -> Literal["append", "replace"]and call it from both the options builder and the marker, so the persisted run record cannot disagree with the wire. Generalizes cleanly: any run-record marker whose value is decided by more conditions than the marker reads is a mis-bucketing bug for trend dashboards. Prevents: Finding 2 (system_prompt_mode="replace"with nosystem_promptruns the preset/append regime on the wire whileenvironment_info.system_prompt_semantics— persisted to run.json viaorchestrator.py:1217— records"replace"). - [ce-lint] New rule CE034
InternalIdentityPromptModeExplicit(tests/lint/rules/ce034_identity_prompt_mode_explicit.py, wired intotests/lint/runner.py). Pattern forbidden: anywhere insrc/coder_eval/**outsidemodels/agent_config.py, a call toparse_agent_config(...)that passessystem_prompt=(or an assignment<cfg>.system_prompt = ...) withoutsystem_prompt_modebeing set in the same function body. Today that yields exactly two callsites:criteria/agent_judge.py:274(compliant — setssystem_prompt_mode = "replace"two lines below) andsimulation/user_simulator.py:205-214(violating — the roleplay persona silently ships behind theclaude_codecoding-agent preset). Same anti-fail-open shape as the existingSubAgentRunnersetting_sources=[]guard atevaluation/sub_agent.py:85-89, but a lint rule rather than a runtime check becauseUserSimulatorinstantiatesClaudeCodeAgentdirectly (user_simulator.py:263) and never passes throughSubAgentRunner, so no shared runtime seam can see it. Prevents: Finding 6 / high (the simulated user's identity prompt is appended to Claude Code's coding-agent preset, changing everysimulation:dialog-mode evaluation) — and stops the next internal identity-prompt consumer from inheriting the same fail-open default. - [ce-lint] New rule CE035
InheritedFieldDescriptionMentionsModifier— a doc-surface/whole-tree check next totests/lint/doc_schema_parity.py(CE030) and wired as a dedicated@pytest.mark.lintclass intests/test_custom_lint.py, not as aBaseRule(it reasons over the whole model tree, per the CE026-CE031 precedent). Two mechanical assertions oversrc/coder_eval/models/agent_config.py: (a) if a subclass adds a field whose name is<inherited_field>_<suffix>(system_prompt_modemodifying the base'ssystem_prompt), the inherited field'sField(description=...)must mention the modifier field name inline; (b) a base-model field description must not contain an absolute negation (never a replacement,always kept) about behavior a subclass field can invert.agent_config.py:154("appended ... — never a replacement") violates both, givensystem_prompt_mode: Literal["append", "replace"]47 lines below onClaudeCodeAgentConfigandcriteria/agent_judge.py:274forcingreplace. This is the CLAUDE.md "field descriptions defined once in Pydantic models / Single Source of Truth" principle made mechanical. Prevents: Finding 3 (baseBaseAgentConfig.system_promptdescription contradicts thesystem_prompt_mode: replaceadded in the same PR, and is also wrong forNoneAgentConfigand out-of-tree BYOA subclasses), plus the parallel stale prose row atdocs/agents/CLAUDE_CODE.md:102. - [ruff] Enable
ANN(flake8-annotations) in[tool.ruff.lint] selectinpyproject.toml—select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "RUF", "ANN", "PLR0915", "PLR0912"]— with[tool.ruff.lint.per-file-ignores]"tests/**" = ["ANN201", "ANN202"]so test functions themselves stay unannotated while ANN001 (missing parameter annotation) still applies to test helpers.tests/currently has zero type enforcement (pyright excludes it, ruff selects no ANN), which is why a new helper shipped with a bare parameter. Prevents: Finding 4 (def _transport_command(options) -> list[str]:attests/test_agent.py:380— return typed, parameter bare, sooptions.cli_path = ...andSubprocessCLITransport(options=options)are unchecked). Expect a one-time annotation sweep over existing fixture helpers; that sweep is the point. - [pyright] Add a tests-scoped second pyright pass: a
pyrightconfig.tests.jsonwith"include": ["tests"],"typeCheckingMode": "basic", and the three settings that matter here promoted toerror—reportMissingTypeArgument,reportOptionalSubscript,reportIndexIssue— then wire it intomake typecheckas a second invocation (pyright && pyright -p pyrightconfig.tests.json). The main[tool.pyright]block deliberately excludestests, so nothing today type-checks the test suite at all; a separate config keepssrc/atstandardwhile lettingtests/start at a permissive basic baseline. Prevents: Finding 4's second half: the unparameterized-> "list"on_capture_sdk_options(tests/test_agent.py:184) that makescaptured_options[0]anUnknown, and the unchecked subscript of a union attests/test_agent.py:441(ClaudeAgentOptions.system_promptisstr | SystemPromptPreset | SystemPromptFile | None) — so an SDK reshape ofSystemPromptPresetsurfaces as a typed failure inmake verifyinstead of a runtimeTypeError.
Harness improvements (not statically reachable):
- Prompt-regime matrix test in
tests/test_agent.py: parametrize the full cartesian product of (system_prompt_mode∈ {unset, append, replace}) × (system_prompt∈ {unset, "", "text"}) × (system_prompt_file∈ {unset, set}), and for each cell assert three things agree: theClaudeAgentOptions.system_promptvalue actually built by_build_claude_query, the transport argv (--system-promptvs--append-system-promptvs neither), andget_environment_info()["system_prompt_semantics"]. The PR's five new tests cover append / unset / empty-string / replace-with-prompt but not replace-with-unset-prompt — the one broken cell. Pair it with the corrected validator (system_prompt_mode == "replace"requiressystem_prompt is not None or system_prompt_file is not None, sincetask_loader.py:230-241inlines the file later). Why not static: CE033 can force the marker and the wire to share one expression, but only a runtime build of the SDK options can prove which regime each (mode, prompt) pair actually lands in — the fallback value is produced by constructingSystemPromptPresetand handing it to the vendored SDK, not by any statically-comparable source expression. Prevents: Finding 2 (silentreplace→ preset fallback with a mislabelled run.json marker). - Agent-parity conformance suite driven by
AgentRegistry: one parametrized test that enumerates every registered agent kind (so a new agent inherits the assertions automatically, the CE025 registry-enumeration pattern applied to tests) and asserts the shared prompt contract per agent —system_prompt=Noneomits the vendor field,system_prompt=""is forwarded (not dropped),system_prompt="x"reaches the vendor field, andget_environment_info()carriessystem_prompt_semantics. Today each of the three agents has its own bespoke, differently-shaped assertion (tests/test_agent.py:466,tests/test_codex_agent.py:339,tests/test_antigravity_agent.py:72) and only Claude Code has the empty-string case. Why not static: CE032 catches the specificor Nonespelling, but semantics divergence can also arrive asif not prompt: returnor a vendor SDK that itself drops empty strings — proving equivalence needs the config actually driven through each agent's options builder. Prevents: Findings 1 and 5 (per-agent empty-string divergence; three hand-written, mutually inconsistent marker tests). environment_infopropagation test at the orchestrator seam: assert that a DummyAgent whoseget_environment_info()returns a non-empty dict has its keys present in the finalizedEvaluationResult.environment_infoand in the serializedrun.json. Every existing test feeds an empty dict oragent=None(tests/test_orchestrator.py:605,:669,tests/test_route_seam_exhaustiveness.py:90), so theupdate()atorchestrator.py:1217— the cross-repo contract seam the external eval-runner consumes — has zero coverage; the new marker tests assert only that a method returns its own hardcoded constant. Why not static: The defect class is a lost/overwritten dict merge at runtime plus JSON serialization, not a source pattern — no AST shape distinguishes a merge that survives finalization from one that is later clobbered. Prevents: Finding 5 (tautological marker tests; nothing verifies the marker reaches the run record).- Producer-built fixtures for cross-repo record fields: add a shared fixture factory that builds
sdk_optionsby calling the realdump_dataclass(ClaudeAgentOptions(...))and use it intests/test_reports.pyinstead of hand-written dicts, plus a golden snapshot of thesdk_options/environment_infoslice ofrun.jsonchecked into the report tests. The guard attests/test_reports.py:904(assert "**System Prompt**" not in report_md) still passes only because its fixture hardcodes"system_prompt": Noneat line 884 — a value a real Claude Code run can no longer produce, since the persisted value is now always a preset dict, whichreports.py:90-94renders into the Markdown/HTML "System Prompt" row as a raw Python dict repr on an unconditionally-present row. Why not static: The drift is in the runtime value shape emitted by a third-party SDK dataclass dump; no lint rule can know thatdump_dataclassstarted returning a dict where astr | Noneused to be — only a fixture produced by the real producer can. Prevents: Finding 8 (report row renders a dict repr and now always appears; stale hand-built fixture masks it). - Pin the non-argv half of the SDK contract, or narrow the claim:
_transport_command's docstring (tests/test_agent.py:380-386) says it "pins the SDK contract", butexclude_dynamic_sections=Truenever reaches argv — the SDK lifts it out of the preset dict (claude_agent_sdk/_internal/client.py:148-155) and sends it asrequest["excludeDynamicSections"]in the control-protocol initialize message (_internal/query.py:209-210). Either assert on that extraction path (drive the options through the SDK's initialize-request builder and check the field) or narrow the docstring to "pins argv only" so the reproducibility claim indocs/agents/CLAUDE_CODE.mdis not backed by a test that cannot see it. Why not static: The value travels through a vendored third-party runtime control protocol, and the SDK explicitly notes "older CLIs ignore unknown initialize fields" — whether the flag takes effect is observable only by exercising the SDK, never from our source tree. Prevents: Finding 7 (the reproducibility half of the change is untested while the helper's docstring claims otherwise). - Simulation-mode wire snapshot: add one test in the user-simulator suite asserting the built
ClaudeAgentOptions.system_promptis the bare persona string (no{'type': 'preset', 'preset': 'claude_code', ...}wrapper). Existing simulator tests assert only on the rendered prompt text (sim.system_prompt), which is why a change that never touchedsrc/coder_eval/simulation/still silently altered every dialog-mode run's system prompt. Why not static: CE034 enforces thatsystem_prompt_modeis passed explicitly, but not that the chosen value is the correct one for an identity prompt; only inspecting the assembled SDK options shows whether the coding-agent preset prefixes the persona. Prevents: Finding 6 / high (simulator persona shipped behind the Claude Code preset, contradicting its own "stay in character" instruction).
Top 5 Priority Actions
- Set
system_prompt_mode="replace"in the simulator'sparse_agent_config(...)call at src/coder_eval/simulation/user_simulator.py:205-214 and enforce the invariant at the shared sub-agent seam (mirroring thesetting_sourcesguard at src/coder_eval/evaluation/sub_agent.py:86) — today the roleplay persona is merely appended to theclaude_codepreset, changing agent behavior and therefore scores on everysimulation:task for identical agent output. - Reconcile the two-term regime condition at src/coder_eval/agents/claude_code_agent.py:1192 with the one-term telemetry marker at :1251 by extracting a shared
_effective_prompt_mode()helper and adding a@model_validator(mode="after")onClaudeCodeAgentConfigthat rejectssystem_prompt_mode == "replace"when bothsystem_promptandsystem_prompt_fileare unset — otherwise-D agent.system_prompt_mode=replacesilently runs the full preset while run.json records"replace", mis-bucketing trend dashboards the marker's own docstring says must not pool across that boundary. - Teach
collect_agent_settings_rows(src/coder_eval/reports.py:90-94) theSystemPromptPresetshape — or unwrap the effective prompt string plus mode before persisting at src/coder_eval/agents/claude_code_agent.py:1218-1229 — since the Markdown and HTML "System Prompt" row now renders a Python dict repr and always appears, with the existing guard at tests/test_reports.py:904 green only because its fixture hardcodes aNonea real Claude run can no longer produce. - Switch src/coder_eval/agents/antigravity_agent.py:351 from
system_instructions=self.config.system_prompt or Noneto theis not Nonecheck used by Claude Code (:1196) and Codex (:1290), sosystem_prompt: ""is not silently dropped on one agent while forwarded on the other two — or document why the Antigravity SDK requires the truthiness form. - Correct the append-only prose the same PR falsified: the absolute "never a replacement" at src/coder_eval/models/agent_config.py:154, the "always kept" row at docs/agents/CLAUDE_CODE.md:102, and the stale "only emits when a custom endpoint is configured" docstring at src/coder_eval/agents/codex_agent.py:940 (the dict is now returned unconditionally), and close the two thin test seams — a
replace-with-unset-prompt case and one assertion that an agent-suppliedenvironment_infokey survives the merge at src/coder_eval/orchestrator.py:1217.
Stats: 0 🔴 · 2 🟠 · 2 🟡 · 5 🔵 across 8 axes reviewed.

Why append instead of replace
ClaudeAgentOptions.system_promptaccepts either a plain string or theclaude_codepreset dict. A plain string replaces Claude Code's entire default system prompt — the only way to keep the default is{"type": "preset", "preset": "claude_code", "append": ...}. coder_eval passes the experiment'ssystem_promptstraight through as a string, so any experiment that sets even a one-line prompt silently strips every behavioral instruction the harness ships with.That is exactly what the skills-repo experiments do. The nightly config sets an innocuous sandbox guard:
https://github.com/UiPath/skills/blob/main/tests/experiments/nightly.yaml#L39-L40
(same pattern in
tests/experiments/default.yaml#L21,smoke.yaml#L39,smoke-windows.yaml#L23, and the skill-comparison templates)One sentence of sandbox policy costs the whole Claude Code system prompt.
Observed impact (skills nightly,
skill-rpa-execution-map-greenfield)The task is a turn-budget gate (
max_turns: 10, expected 6) that assumes the agent batches tool calls per turn. Everyclaude-sonnet-5run exhausted the cap; pass/fail depended on where the cap happened to land. Transcript analysis across four runs (31172161551, 31174117722 ×3 attempts, 31178128981, 31179344004 ×2 attempts):Bashcat/sed/findwhere the default prompt directs the dedicatedRead/Grep/Globtools (e.g.cd TextReport && cat project.json && cat Main.xaml), losing the harness's file-tracking and permission integration.Beyond the observed items, replacing the prompt also drops the default guidance on code-reference formatting, task management, professional tone, and the security guardrails — none of which an experiment author intends to disable when adding a sandbox-scoping sentence.
The change
claude_code_agent.py: whensystem_promptis configured, wrap it asSystemPromptPreset(type="preset", preset="claude_code", append=...)so the default prompt survives and the experiment text is appended.Nonestill means the untouched SDK default.agent_config.py:system_promptfield description updated ("appended to the agent's default system prompt" — previously "Replaces").tests/test_agent.py: two tests via the existing_capture_sdk_optionspattern (append wrapping;Nonepassthrough).Behavioral note for existing consumers
Every experiment that sets
system_promptswitches from replace to append semantics with this release. For the known consumers (sandbox-scoping one-liners) this is the intended repair. An experiment that deliberately relied on full replacement to suppress default Claude Code behavior would need a different mechanism.Judge (
agent_judge.py) and user-simulator paths construct their own options and are unaffected.🤖 Generated with Claude Code