Skip to content

fix(agent): append system_prompt to the Claude Code preset instead of replacing - #92

Open
Mihaiii wants to merge 5 commits into
mainfrom
fix/sys-prompt-append
Open

fix(agent): append system_prompt to the Claude Code preset instead of replacing#92
Mihaiii wants to merge 5 commits into
mainfrom
fix/sys-prompt-append

Conversation

@Mihaiii

@Mihaiii Mihaiii commented Aug 7, 2026

Copy link
Copy Markdown

Why append instead of replace

ClaudeAgentOptions.system_prompt accepts either a plain string or the claude_code preset 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's system_prompt straight 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

system_prompt: |
  You are a coding agent. Do not access files in sibling runs/* directories. Everywhere else is permitted.

(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. Every claude-sonnet-5 run 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):

  1. Zero parallel tool calls, ever. Example distribution (run 31179344004, attempt 2): 30 assistant messages — 0 multi-tool, 19 single-tool, 11 with no tool call at all. The instruction to emit independent tool calls together in one message lives in the default system prompt; with it gone, Sonnet paid one turn per call and blew the 10-turn budget in 7/7 attempts. Skill-doc prose telling the agent to batch (added and strengthened twice in UiPath/skills) was read in-transcript and changed nothing — a reference file cannot substitute for the missing system-level contract.
  2. Narration bloat. 10–11 assistant messages per run contained no tool call, just interim commentary — the default prompt's conciseness/minimal-output rules were gone.
  3. Tool-choice drift. Runs used Bash cat/sed/find where the default prompt directs the dedicated Read/Grep/Glob tools (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: when system_prompt is configured, wrap it as SystemPromptPreset(type="preset", preset="claude_code", append=...) so the default prompt survives and the experiment text is appended. None still means the untouched SDK default.
  • agent_config.py: system_prompt field description updated ("appended to the agent's default system prompt" — previously "Replaces").
  • tests/test_agent.py: two tests via the existing _capture_sdk_options pattern (append wrapping; None passthrough).

Behavioral note for existing consumers

Every experiment that sets system_prompt switches 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

… 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>
@Mihaiii
Mihaiii marked this pull request as ready for review August 7, 2026 13:34
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Claude finished @Mihaiii's task in 1m 46s —— View job


🔍 Reviewing PR #92: Fix system_prompt append behavior

Working on review...

Todo List:

  • Read .github/code_review.md for review guidelines
  • Read CLAUDE.md for project conventions
  • Review the full diff (git diff origin/main...HEAD)
  • Read each changed file in full context
  • Perform cross-file consistency checks
  • Check "what's missing" analysis
  • Post comprehensive review feedback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@akshaylive akshaylive left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: fix(agent): append system_prompt to the Claude Code preset instead of replacing

PR #92 by @Mihaiii · fix/sys-prompt-appendmain · 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

  1. The change reconfigures the scoring instrument, not just the agent under test. criteria/agent_judge.py:265 does config.system_prompt = _SYSTEM_PROMPT on a ClaudeCodeAgentConfig and runs it through SubAgentRunner → the very _build_options you 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 strict submit_verdict contract) 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_judge produces continuous scores that gate evals, so this can change score for 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: add system_prompt_mode: Literal["append", "replace"] = "append" to ClaudeCodeAgentConfig (agent-specific field on the agent-specific config, matching where claude_settings / sdk_options already live) and have agent_judge._build_agent_config force replace alongside its existing security floors — or let _build_options accept a pre-built str | SystemPromptPreset. Either way, please pin the judge's effective prompt with a test.

  2. Half the bug survives: the system_prompt is None path still loses the preset. The installed SDK maps system_prompt=None to --system-prompt "" — an explicit empty custom prompt, not the default (claude_agent_sdk/_internal/transport/subprocess_cli.py:465-466). Nearly every task in tasks/ sets no system_prompt, so the common configuration keeps running without Claude Code's guidance even after this merges. Meanwhile tests/test_agent.py:395-404 asserts system_prompt is None and 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 without append emits no system-prompt flag at all. So building SystemPromptPreset(type="preset", preset="claude_code") unconditionally and adding append only when configured fixes both halves in one guard. Please also rename that test away from "leaves_sdk_default".

  3. Cross-run comparability breaks with no marker and no note. tasks/python_cli_simulated_judged/echo_simulated_judged.yaml:19 is 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's llm_judge-graded, so its score can move. External task YAMLs in the coder-eval-uipath / eval-runner repo change behavior with no code change on their side, and nothing in run.json distinguishes 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.

  4. 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:351 passes it straight to system_instructions (replace), and agents/codex_agent.py never 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-agnostic models/ layer, against CLAUDE.md's convention. Suggested: keep the base description agent-neutral and per-agent-qualified, move the preset/append detail onto ClaudeCodeAgentConfig, and give codex either an implementation or a load-time error rather than a silent no-op.

  5. The owning doc page now says the opposite of the code. docs/agents/CLAUDE_CODE.md:102 still 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 only TaskDefinition / 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-393 asserts only the in-process dict shape. Since SystemPromptPreset is a bare TypedDict, the constructor is dict(...) 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 into SubprocessCLITransport(prompt="x", options=captured_options[0])._build_command() and asserting --append-system-prompt present / --system-prompt absent is ~4 lines, runs offline, and is the only assertion that survives an SDK shape change. Worth noting nothing in the suite today (including test_agent_golden_master.py and test_claude_settings_enforcement_live.py) would have caught the original bug either.

Reproducibility

  • exclude_dynamic_sections is 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. Setting exclude_dynamic_sections=True keeps 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-referencing environment_info.claude_code_cli (already captured in run.json via utils.py:467-474, which is a nice mitigation).

Docs

  • docs/agents/ANTIGRAVITY.md and docs/agents/CODEX.md don't document system_prompt at all, so after this change there's no page stating the per-agent semantics. Worth adding a row to each while updating CLAUDE_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:30SystemPromptPreset isn't in claude_agent_sdk's root __all__, so claude_agent_sdk.types is the only route (correct, and evaluation/verdict_tool.py:21 sets precedent). A one-line note saying so would match the treatment the _internal.transport import three lines above already gets.
  • tests/test_agent.py:380-404 — no case for system_prompt: "". It currently yields append="" (harmless), but nothing pins it, so a future refactor to if self.config.system_prompt: would silently route empty-string configs into the preset-loss path with the suite still green. Note antigravity_agent.py:351 uses or None and 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_file reaches the same append path — the two halves are covered separately but never joined.
  • CodeQL's py/unused-import at claude_code_agent.py:31 is 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.py are the second consumer of _build_options and weren't considered — triggered by claude_code_agent.py:1177-1183.
  • 🟠 agents/codex_agent.py and agents/antigravity_agent.py weren't touched, so the shared field they inherit now diverges from its own description — triggered by models/agent_config.py:151-158.

Tests

  • 🔴 Nothing pins agent_judge's effective ClaudeAgentOptions.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 set system_prompt change behavior with no change on their side; run.json records nothing marking which semantics were used.
  • 🟠 tasks/python_cli_simulated_judged/echo_simulated_judged.yaml needs re-baselining or rewording.

Display & mapping dicts

  • Nothing identified — no enum, FinalStatus, or union value changed, so no reports*.py mapping needs extending.

Harness & Lint Improvements

Static checks (lint / type)

  • Extend CE030 (tests/lint/doc_schema_parity.py) to BaseAgentConfig and the per-agent config subclasses, mapped to their docs/agents/*.md pages — would have caught the CLAUDE_CODE.md:102 drift at make lint time (name-level only; a semantics inversion still needs a human).
  • New CEnnn: a Field(description=...) on a shared base model in models/agent_config.py must 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-driving BaseAgentConfig field by name, or carry an EXEMPT entry — would have surfaced that codex_agent.py reads system_prompt nowhere.
  • 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_prompt matrix (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 effective ClaudeAgentOptions — needs a live options capture.

Top 5 Priority Actions

  1. Stop the judge from inheriting the coding-agent preset. Add a replace seam on ClaudeCodeAgentConfig (or let _build_options accept a pre-built str | SystemPromptPreset) and have agent_judge._build_agent_config force replace, next to its existing setting_sources=[] and ignore_patterns floors. Pin it with a test. This is the only finding that can move scores for identical agent output.
  2. Fix the other half of the bug: the system_prompt is None path. Emit SystemPromptPreset(type="preset", preset="claude_code") unconditionally and add append only when configured, so the unset case produces no system-prompt flag (the CLI default) instead of --system-prompt "". Then rewrite and rename test_system_prompt_none_leaves_sdk_default — today it asserts something the SDK does not do.
  3. Add a transport-level assertion feeding the captured options into SubprocessCLITransport._build_command() and checking --append-system-prompt is present and --system-prompt absent. Four lines, offline, and the only test that survives an SDK shape change.
  4. Update docs/agents/CLAUDE_CODE.md:102 (currently the exact opposite of the new behavior) and reword BaseAgentConfig.system_prompt's description to state the per-agent semantics honestly rather than asserting append generically. Add the field to ANTIGRAVITY.md / CODEX.md while there.
  5. State the blast radius and re-baseline. Add a CHANGELOG / migration note that system_prompt semantics changed from replace to append, re-check echo_simulated_judged at HEAD, and flag it for the external coder-eval-uipath pipeline 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.

Mihaiii and others added 3 commits August 8, 2026 09:36
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>
@Mihaiii

Mihaiii commented Aug 8, 2026

Copy link
Copy Markdown
Author

@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 uipreliga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: fix(agent): append system_prompt to the Claude Code preset instead of replacing

PR #92 by @Mihaiii · fix/sys-prompt-appendmain · 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

  1. [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_config opts the judge out of the preset with config.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 a ClaudeCodeAgentConfig whose system_prompt IS the sub-agent's entire identity; the second one was not updated. src/coder_eval/simulation/user_simulator.py:205-217 calls parse_agent_config(type=AgentKind.CLAUDE_CODE, ..., system_prompt=self._system_prompt) and hands it to ClaudeCodeAgent(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 to system_prompt_mode == 'append', so claude_code_agent.py:1192 takes the else-branch and the simulator's system prompt becomes SystemPromptPreset(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: set system_prompt_mode="replace" in user_simulator.py's parse_agent_config(...) call, and enforce the invariant at the shared seam the way evaluation/sub_agent.py:87 already enforces setting_sources (raise ValueError("SubAgentRunner requires agent_config.setting_sources=[] ...")) rather than relying on each caller.
  2. [Axis 8] 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 (src/coder_eval/agents/claude_code_agent.py:1218) — system_prompt=system_prompt, (line 1218) now feeds a SystemPromptPreset dict into ClaudeAgentOptions, and line 1229 self._sdk_options_dump = dump_dataclass(options) persists it verbatim into EvaluationResult.sdk_options — the record docs/REPORT_SCHEMA.md:136 lists 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-94 does prompt_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 guard tests/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 teach collect_agent_settings_rows the preset shape, add a report test fed from a real dump_dataclass(options) rather than a hand-built dict, and state the sdk_options.system_prompt type change in docs/REPORT_SCHEMA.md so the external coder-eval-uipath / eval-runner consumer can be updated in lockstep.

Non-blocking, but please consider before merge

  1. [Axis 2] 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) (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') then ClaudeCodeAgent._build_claude_query(...)): system_prompt sent: {'type': 'preset', 'preset': 'claude_code', 'exclude_dynamic_sections': True} while env_info: {'system_prompt_semantics': 'replace'}. So an operator running -D agent.system_prompt_mode=replace without 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 ("replace sends system_prompt as 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") on ClaudeCodeAgentConfig (mirroring check_prompt_exclusivity at agent_config.py:188) rejecting system_prompt_mode == "replace" when system_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 the replace + unset pair — the five new tests in tests/test_agent.py cover append / unset / empty-string / replace-with-prompt but not this combination.
  2. [Axis 2] Documentation asserts system_prompt is "never a replacement"/"always kept", contradicted by the system_prompt_mode: replace added 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. " on BaseAgentConfig — the vendor-neutral base every agent kind inherits. Fifty lines below, agent_config.py:201-208 adds system_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 for NoneAgentConfig (agent_config.py:308): grep -n "system_prompt" src/coder_eval/agents/noop_agent.py returns nothing — the NoOp agent neither appends nor replaces, it drops the field — and the same holds for any out-of-tree BYOA BaseAgentConfig subclass, since system_prompt_mode lives only on ClaudeCodeAgentConfig. Per CLAUDE.md ("Field descriptions ... defined once in Pydantic models" / "Single Source of Truth"), this description is the schema SSOT surfaced in -D did-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 via system_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

  1. [Axis 1] Empty-string system_prompt resolves to three different regimes across the three agents, all reported as append (src/coder_eval/agents/codex_agent.py:1290) — The new Codex plumbing at line 1290-1291 uses if self.config.system_prompt is not None: / options["developer_instructions"] = self.config.system_prompt, matching Claude Code's is not None at claude_code_agent.py:1196 — and the PR even pins that semantics with test_system_prompt_empty_string_appends_empty ("a future truthiness refactor must not route it into the preset-loss path"). But src/coder_eval/agents/antigravity_agent.py:351 still reads system_instructions=self.config.system_prompt or None, so system_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 to if self.config.system_prompt is not None for parity, or document why or None is required by the Antigravity SDK.
  2. [Axis 2] New test helper _transport_command(options) has an untyped parameter, and the union-typed system_prompt is indexed unchecked (tests/test_agent.py:380) — tests/test_agent.py:380 declares def _transport_command(options) -> list[str]: — the return is typed but the parameter is bare, so the helper accepts anything and options.cli_path = "claude" (line 389) / SubprocessCLITransport(prompt="x", options=options) (line 390) are unchecked. Annotate it options: ClaudeAgentOptions. Relatedly, tests/test_agent.py:441 does assert captured_options[0].system_prompt["append"] == ""ClaudeAgentOptions.system_prompt is str | SystemPromptPreset | SystemPromptFile | None (claude_agent_sdk/types.py:1752), so subscripting it is only silent because the pre-existing _capture_sdk_options at tests/test_agent.py:184 is annotated -> "list" (unparameterized) and pyright excludes tests/ 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 of SystemPromptPreset surfaces as a typed failure rather than a runtime TypeError.
  3. [Axis 3] system_prompt_semantics marker 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 only default_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 seam self.result.environment_info.update(self.agent.get_environment_info()) at src/coder_eval/orchestrator.py:1217 with a non-empty agent dict: tests/test_route_seam_exhaustiveness.py:90 passes agent=None (fake = SimpleNamespace(route=r, eval_route=r, result=SimpleNamespace(environment_info={}), agent=None)), and the two _setup tests use DummyAgents whose get_environment_info is return {} (tests/test_orchestrator.py:606 and :670). Since environment_info in run.json is the cross-repo contract consumed by the external eval-runner, add one assertion that an agent-supplied key survives the merge into EvaluationResult.environment_info.
  4. [Axis 3] _transport_command helper overclaims: exclude_dynamic_sections never 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-prompt presence. But exclude_dynamic_sections is not a CLI flag: claude_agent_sdk/_internal/client.py:148-155 extracts it from the preset dict and _internal/query.py:209-210 sends it as request["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 unconditional exclude_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_command docstring 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.
  5. [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 reads Only emits when a custom endpoint is configured (CODEX_BASE_URL). On a — but the PR made line 951 seed info: 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.py forces system_prompt_mode="replace" for the judge, but the other in-tree consumer whose system_prompt is a sub-agent's entire identity — simulation/user_simulator.py:205-214ClaudeCodeAgent(..., instance_name="simulator") at line 263 — was not updated, so the simulated user now runs the claude_code coding-agent preset with its roleplay persona merely appended (verified: options.system_prompt == the preset dict + append). Set system_prompt_mode="replace" there and enforce the invariant at the shared SubAgentRunner seam (evaluation/sub_agent.py:85, which already hard-fails on setting_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 None handling in claude_code_agent.py:1196 and codex_agent.py:1290 (and pinned it with test_system_prompt_empty_string_appends_empty), but left antigravity_agent.py:351 on system_instructions=self.config.system_prompt or Nonesystem_prompt: "" is still silently dropped on that one agent while all three report system_prompt_semantics: append. (trigger: src/coder_eval/agents/antigravity_agent.py) (restates: Axis 1: Empty-string system_prompt resolves to three different regimes across the three agents)
  • 🔵 The new system_prompt_semantics marker was hand-copied into three agents but not defaulted on the ABC: agent.py:325-337 still returns {} and its docstring only mentions "routing/environment details", and agents/noop_agent.py (plus any out-of-tree BYOA / plugin agent, e.g. the coder_eval_uipath Delegate agent) emits no marker at all. A consumer therefore cannot distinguish "pre-marker run" from "agent that never emits it" — declare the key on Agent.get_environment_info's contract (or supply the append default on the base) so new agents inherit it. (trigger: src/coder_eval/agents/antigravity_agent.py)
  • 🔵 docs/AB_EXPERIMENTS.md:136-138 enumerates the agent-dict keys an experiment variant may override (system_prompt / system_prompt_file, setting_sources, claude_settings, sdk_options) and was not extended with the new system_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" with system_prompt unset — the combination that silently falls through to the preset while environment_info records "replace". Add that case alongside the validator that should reject it (allowing system_prompt_file, which task_loader inlines later). (trigger: tests/test_agent.py) (restates: Axis 2: system_prompt_mode="replace" with no system_prompt silently runs the preset/append regime)
  • 🟡 No test feeds a real dump_dataclass(options) into reports.collect_agent_settings_rows, so the new preset-dict shape reaching the Markdown/HTML "System Prompt" row is uncovered; the existing guard tests/test_reports.py:904 still 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: Persisted sdk_options.system_prompt becomes a preset dict on every Claude Code run)
  • 🟡 The PR newly documents Antigravity's append mechanism (system_promptsystem_instructionsTemplatedSystemInstructions) and adds an env-marker test, but grep -rn "system_instructions" tests/ returns zero hits — nothing anywhere pins that system_prompt actually reaches the Antigravity SDK, so both the documented claim and the or None drop are untested. Add a _build-level assertion mirroring the new Codex developer_instructions tests. (trigger: docs/agents/ANTIGRAVITY.md)
  • 🟡 tests/test_user_simulator.py asserts 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 resolved ClaudeAgentOptions.system_prompt (or on _agent_config.system_prompt_mode), the same shape as the new judge test in tests/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 {} and test_route_seam_exhaustiveness.py:90 passes agent=None), so no test proves the marker actually lands in run.json. (trigger: tests/test_agent.py) (restates: Axis 3: system_prompt_semantics marker 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" evalboard returns nothing, evalboard/lib/runs.ts:415 types environment_info as an opaque Record, 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 by coder-eval-uipath / eval-runner — was not touched: neither the new environment_info.system_prompt_semantics key (line 52) nor the type change of sdk_options.system_prompt from str | null to 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: Persisted sdk_options.system_prompt becomes 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 full claude_code prompt, so run_limits.max_input_tokens / max_total_tokens / max_usd values calibrated pre-change (e.g. tasks/smoke_budget_exceeded.yaml, tasks/smoke_cost_budget_exceeded.yaml, experiments/default.yaml) and any commands_efficiency budgets 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: its str(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 200 SYSTEM_PROMPT_PREVIEW_CHARS are consumed by preset metadata before any prompt text. (trigger: src/coder_eval/agents/claude_code_agent.py) (restates: Axis 8: Persisted sdk_options.system_prompt becomes a preset dict on every Claude Code run)
  • 🔵 The new system_prompt_mode field is not rendered anywhere in the report surfaces — collect_agent_settings_rows has no row for it in either the agent_config or sdk_options table — 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 main an unset system_prompt produced --system-prompt "" (SDK subprocess_cli.py:465-466 — an explicitly EMPTY prompt), so every Claude Code task in every suite now runs with the full claude_code preset 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 no system_prompt_semantics key) are labelled. (trigger: src/coder_eval/agents/claude_code_agent.py)
  • 🟡 The exclude_dynamic_sections=True reproducibility guarantee is unverified on the containerized (production) path: it is not a CLI flag but a control-protocol excludeDynamicSections initialize field (SDK _internal/query.py:209-210), which the SDK's own comment notes older CLIs silently ignore, while docker/Dockerfile pins CLAUDE_CODE_VERSION=2.1.177 and docker/Dockerfile.runtime mirrors 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 as developer_instructions (codex_agent.py:1291). The new environment_info comment 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 to ALL_RULES in tests/lint/runner.py, tested in tests/test_custom_lint.py). Pattern forbidden: truthiness collapse of an agent-config field inside src/coder_eval/agents/** — any BoolOp(Or) whose left operand is an attribute chain rooted at self.config (self.config.system_prompt or None), and any bare if self.config.<field>: / if not self.config.<field>: where the field is declared str | None / list | None on a BaseAgentConfig subclass. Required form is the explicit is None / is not None test that claude_code_agent.py:1196 and codex_agent.py:1290 already use. Scope the AST match to self.config.* roots only, so the legitimate env-var idioms (os.getenv("GEMINI_API_KEY") or None, codex_agent.py:1065) stay legal — a grep of src/ shows self.config.<x> or None occurs exactly once today, at agents/antigravity_agent.py:351, i.e. the rule lands with one violation and no cleanup tail. Prevents: Finding 1 (system_prompt: "" silently dropped by antigravity_agent.py:351's self.config.system_prompt or None while Claude Code and Codex forward it) — and permanently pins the empty-string semantics that test_system_prompt_empty_string_appends_empty only asserts for one of the three agents.
  • [ce-lint] New rule CE033 EnvInfoMarkersDerivedNotRestated (tests/lint/rules/ce033_env_info_markers_derived.py, wired into tests/lint/runner.py). Pattern forbidden: inside a get_environment_info method 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 same self.config.<field> also appears as an operand of a multi-term BoolOp test 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 no system_prompt runs the preset/append regime on the wire while environment_info.system_prompt_semantics — persisted to run.json via orchestrator.py:1217 — records "replace").
  • [ce-lint] New rule CE034 InternalIdentityPromptModeExplicit (tests/lint/rules/ce034_identity_prompt_mode_explicit.py, wired into tests/lint/runner.py). Pattern forbidden: anywhere in src/coder_eval/** outside models/agent_config.py, a call to parse_agent_config(...) that passes system_prompt= (or an assignment <cfg>.system_prompt = ...) without system_prompt_mode being set in the same function body. Today that yields exactly two callsites: criteria/agent_judge.py:274 (compliant — sets system_prompt_mode = "replace" two lines below) and simulation/user_simulator.py:205-214 (violating — the roleplay persona silently ships behind the claude_code coding-agent preset). Same anti-fail-open shape as the existing SubAgentRunner setting_sources=[] guard at evaluation/sub_agent.py:85-89, but a lint rule rather than a runtime check because UserSimulator instantiates ClaudeCodeAgent directly (user_simulator.py:263) and never passes through SubAgentRunner, 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 every simulation: 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 to tests/lint/doc_schema_parity.py (CE030) and wired as a dedicated @pytest.mark.lint class in tests/test_custom_lint.py, not as a BaseRule (it reasons over the whole model tree, per the CE026-CE031 precedent). Two mechanical assertions over src/coder_eval/models/agent_config.py: (a) if a subclass adds a field whose name is <inherited_field>_<suffix> (system_prompt_mode modifying the base's system_prompt), the inherited field's Field(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, given system_prompt_mode: Literal["append", "replace"] 47 lines below on ClaudeCodeAgentConfig and criteria/agent_judge.py:274 forcing replace. This is the CLAUDE.md "field descriptions defined once in Pydantic models / Single Source of Truth" principle made mechanical. Prevents: Finding 3 (base BaseAgentConfig.system_prompt description contradicts the system_prompt_mode: replace added in the same PR, and is also wrong for NoneAgentConfig and out-of-tree BYOA subclasses), plus the parallel stale prose row at docs/agents/CLAUDE_CODE.md:102.
  • [ruff] Enable ANN (flake8-annotations) in [tool.ruff.lint] select in pyproject.tomlselect = ["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]: at tests/test_agent.py:380 — return typed, parameter bare, so options.cli_path = ... and SubprocessCLITransport(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.json with "include": ["tests"], "typeCheckingMode": "basic", and the three settings that matter here promoted to errorreportMissingTypeArgument, reportOptionalSubscript, reportIndexIssue — then wire it into make typecheck as a second invocation (pyright && pyright -p pyrightconfig.tests.json). The main [tool.pyright] block deliberately excludes tests, so nothing today type-checks the test suite at all; a separate config keeps src/ at standard while letting tests/ 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 makes captured_options[0] an Unknown, and the unchecked subscript of a union at tests/test_agent.py:441 (ClaudeAgentOptions.system_prompt is str | SystemPromptPreset | SystemPromptFile | None) — so an SDK reshape of SystemPromptPreset surfaces as a typed failure in make verify instead of a runtime TypeError.

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: the ClaudeAgentOptions.system_prompt value actually built by _build_claude_query, the transport argv (--system-prompt vs --append-system-prompt vs neither), and get_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" requires system_prompt is not None or system_prompt_file is not None, since task_loader.py:230-241 inlines 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 constructing SystemPromptPreset and handing it to the vendored SDK, not by any statically-comparable source expression. Prevents: Finding 2 (silent replace → 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=None omits the vendor field, system_prompt="" is forwarded (not dropped), system_prompt="x" reaches the vendor field, and get_environment_info() carries system_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 specific or None spelling, but semantics divergence can also arrive as if not prompt: return or 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_info propagation test at the orchestrator seam: assert that a DummyAgent whose get_environment_info() returns a non-empty dict has its keys present in the finalized EvaluationResult.environment_info and in the serialized run.json. Every existing test feeds an empty dict or agent=None (tests/test_orchestrator.py:605, :669, tests/test_route_seam_exhaustiveness.py:90), so the update() at orchestrator.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_options by calling the real dump_dataclass(ClaudeAgentOptions(...)) and use it in tests/test_reports.py instead of hand-written dicts, plus a golden snapshot of the sdk_options/environment_info slice of run.json checked into the report tests. The guard at tests/test_reports.py:904 (assert "**System Prompt**" not in report_md) still passes only because its fixture hardcodes "system_prompt": None at line 884 — a value a real Claude Code run can no longer produce, since the persisted value is now always a preset dict, which reports.py:90-94 renders 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 that dump_dataclass started returning a dict where a str | None used 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", but exclude_dynamic_sections=True never reaches argv — the SDK lifts it out of the preset dict (claude_agent_sdk/_internal/client.py:148-155) and sends it as request["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 in docs/agents/CLAUDE_CODE.md is 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_prompt is 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 touched src/coder_eval/simulation/ still silently altered every dialog-mode run's system prompt. Why not static: CE034 enforces that system_prompt_mode is 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

  1. Set system_prompt_mode="replace" in the simulator's parse_agent_config(...) call at src/coder_eval/simulation/user_simulator.py:205-214 and enforce the invariant at the shared sub-agent seam (mirroring the setting_sources guard at src/coder_eval/evaluation/sub_agent.py:86) — today the roleplay persona is merely appended to the claude_code preset, changing agent behavior and therefore scores on every simulation: task for identical agent output.
  2. 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") on ClaudeCodeAgentConfig that rejects system_prompt_mode == "replace" when both system_prompt and system_prompt_file are unset — otherwise -D agent.system_prompt_mode=replace silently 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.
  3. Teach collect_agent_settings_rows (src/coder_eval/reports.py:90-94) the SystemPromptPreset shape — 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 a None a real Claude run can no longer produce.
  4. Switch src/coder_eval/agents/antigravity_agent.py:351 from system_instructions=self.config.system_prompt or None to the is not None check used by Claude Code (:1196) and Codex (:1290), so system_prompt: "" is not silently dropped on one agent while forwarded on the other two — or document why the Antigravity SDK requires the truthiness form.
  5. 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-supplied environment_info key survives the merge at src/coder_eval/orchestrator.py:1217.

Stats: 0 🔴 · 2 🟠 · 2 🟡 · 5 🔵 across 8 axes reviewed.

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.

3 participants