Skip to content

feat: central enforcement — TurnMonitor, live budgets, plugin staging (plan 2 of 3) - #183

Open
uipreliga wants to merge 9 commits into
feat/harness-contractfrom
feat/harness-central-enforcement
Open

uipreliga wants to merge 9 commits into
feat/harness-contractfrom
feat/harness-central-enforcement

Conversation

@uipreliga

Copy link
Copy Markdown
Collaborator

Stacked PR. Base is feat/harness-contract (#181, plan 1 of 3). Merge #181 first; GitHub then retargets this PR to main. This diff shows only plan 2.

Implements plan 2 of 3, central enforcement (c/2026-09-17-central-enforcement.md, design c/harness-target-architecture-v2.md). Every structural cap and budget is now one TurnMonitor answer on the should_stop channel, in one unit, on every harness. Every harness receives one staged plugin root. A task that asks for something the run cannot honor is rejected or escalated instead of silently scoring.

What changes

  1. Persisted names say tool calls. FinalStatus.TOOL_CALLS_EXHAUSTED (icon C), tool_calls_exhausted, expected_tool_calls, expected_tool_calls_overage, tool_calls_remaining_at_stop. The collector derives the flag from the end status. The evalboard reads both spellings; docs/REPORT_SCHEMA.md lists the historical ones.
  2. StopReason + TurnMonitor. communicate(should_stop: Callable[[], StopReason | None]), with max_turns removed. run_limits.max_tool_calls replaces max_turns and is cumulative per task across retries and dialog turns. All five adapters dropped their own cap code and finalize with end_status_for(reason). sdk_options.max_turns is allowed on Claude Code, and the simulator and judge use it. SPI_VERSION = 2.
  3. Budgets are live stop reasons. Token and USD budgets latch mid-turn; _check_run_limits and its silent "skipping cost check" are deleted. A max_usd nobody can price (no reported cost, no rate for the configured, start or reported model) finishes ERROR via BudgetUnenforceableError. New required HarnessContract.usage_granularity.
  4. Plugin staging. stage_plugins writes <run_dir>/plugin_root (name-only manifest + skills/<name> symlinks) and records environment_info.skills_offered. Each harness receives the root natively. Manifests are read as Claude Code reads them (confirmed against the plugins reference and a CLI 2.1.273 spike): declared skills add to skills/, a path may name one skill, and a skill is named by its frontmatter name. A path with no skill, or a skill_triggered target the plugins do not offer, fails plan before the run is paid for. The per-harness scanners, utils.process_plugins and CE045 are deleted.
  5. Generated run-limits table + CE070. docs/agents/HARNESS_PARITY.md gains a third CE069-checked block rendered from RunLimits and the contracts. CE070 keeps cap identifiers and SKILL.md scans out of agents/.

Breaking changes (greenfield, no aliases)

  • run_limits.max_turns / expected_turns are rejected (extra="forbid"); use max_tool_calls / expected_tool_calls. Every in-repo task and experiment is migrated.
  • Runs recorded before this change: a task.json with final_status: MAX_TURNS_EXHAUSTED, or a recorded config with run_limits.max_turns / expected_turns, does not load in Python. evaluate re-grades from the source YAML with its fallback warning; run --resume re-executes the row.
  • SPI 2: communicate and start(plugin_root=) signatures, HarnessContract.usage_granularity, AgentEndEvent.max_turns_exhausted removed. docs/EXTENDING.md is updated. The out-of-tree Delegate agent needs a follow-up PR.
  • Only skills are staged: a plugin's agents/, commands/, hooks/, .mcp.json and files beside the skills no longer reach any harness. Claude Code names staged skills coder-eval-plugins:<skill>.

Verification

  • make verify: 6092 passed, coverage 93.28%. make evalboard-verify: 805 passed. The strict mkdocs build passes.
  • Live (Haiku; Pi/OpenCode via OpenRouter Haiku):
    • Tool-call cap (tasks/run_limits/max_tool_calls_cap.yaml): claude-code, codex, pi and opencode each stop at exactly 4 resolved calls with tool_calls_exhausted: true, no crash and no retry.
    • Budgets: TOKEN_BUDGET_EXCEEDED and COST_BUDGET_EXCEEDED on all four. Pi/OpenCode USD came from the provider-reported cost of a model the rate card does not carry.
    • Staging (tasks/skills/skill_offered.yaml): SUCCESS on all four, with skills_offered == ["probe-skill"]. Under --driver docker, codex returns SUCCESS.
    • Early stop: decision_budget_exceeded with tool_calls_remaining_at_stop set. Dialog mode: finishes with a documented stop reason.
    • Live tests: tests/test_codex_agent_live.py 5/5.
  • Not verified live:
    • an unpriceable max_usd: no local costless harness; unit and orchestrator tests only;
    • Claude Code under docker, the LLM judge and the Claude settings live test: no ANTHROPIC_API_KEY or container login here;
    • 4/9 smoke-pass rows: environment only (docker login, missing BYOD image, judge transport).
  • Observed, not reproduced: Pi/OpenCode stalled 300 s with zero events during one burst of 8 concurrent live runs. They passed alone and with 3 concurrent runs.

Defects the live runs caught (fixed here)

  • The staged plugin root was relative when run_dir was relative (the default runs/...), so Claude Code loaded no plugin.
  • The run-limit parity and skill fixtures carried fields Codex rejects. A new test resolves them on every cooperative harness.
  • smoke_task_timeout's tool-call cap pre-empted its watchdog. The current Claude CLI also refuses a long standalone sleep, so that fixture cannot reach TIMEOUT today; CI still counts it as failed.

Follow-ups

  • Delegate out-of-tree PR: usage_granularity, StopReason, start(plugin_root=), SPI 2.
  • Plan 3 (TurnEmitter and the ports): prompt in c/plan-3-turn-emitter-prompt.md.
  • tasks/agents/codex_skills_test.yaml needs PLUGIN_PATH set, or coder-eval plan over tasks/ reports it.
  • Deferred harness candidates are in .claude/harness-candidates.md: CE070 blind spots, the per-turn token delta invariant across golden streams, live tests not type-checked.

🤖 Generated with Claude Code

uipreliga and others added 9 commits September 16, 2026 12:26
…calls

FinalStatus/AgentEndStatus/TurnEndStatus TOOL_CALLS_EXHAUSTED, the
tool_calls_exhausted flag (derived by the collector from the end status;
AgentEndEvent.max_turns_exhausted deleted), run_limits.expected_tool_calls and
the run.json keys. DialogStopReason gains TOOL_CALL_CAP. The evalboard reads
both spellings; REPORT_SCHEMA names the historical ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…imits.max_tool_calls replaces max_turns

StopReason and end_status_for join the event protocol; communicate() loses
max_turns and its should_stop returns a reason. TurnMonitor (from
EarlyStopWatcher) is attached on every run and latches the armed early stop or
the cumulative tool-call cap; every adapter drops its own cap and finalizes
with end_status_for. sdk_options.max_turns is allowed on Claude Code and used
by the simulator and the judge. SPI_VERSION 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…easons; an unpriceable max_usd is an error

_check_run_limits, its silent cost skip and cost_data_available are deleted.
The monitor latches TOKEN_BUDGET / USD_BUDGET from the stream's usage and
raise_if_over_budget raises at the former call sites; a max_usd no turn can
price raises BudgetUnenforceableError and finalizes ERROR. RunLimits owns the
one bucket rule, every harness contract declares usage_granularity, and
validate_resolved_task is the one resolution-check list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ll_triggered escalates a skill that was never offered

stage_plugins turns both authored layouts into <run_dir>/plugin_root, records
environment_info.skills_offered, and every harness receives the root natively.
A plugin path with no skill fails resolution, and a skill_triggered target
outside skills_offered raises CheckerMisuseError. The per-harness scanners,
utils.process_plugins and CE045 are deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e contract; CE070 keeps caps and skill scans out of adapters

HARNESS_PARITY's hand-written run-limit table becomes a third CE069-checked
block; a RunLimits field without a cell rule fails the render. CE070 flags any
cap identifier or SKILL.md literal under agents/. CLAUDE.md and the notes
describe the TurnMonitor and plugin staging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ugin root, portable parity and skill fixtures

A relative run dir staged a relative plugin root that the harness, running in
the sandbox, could not find. The run-limit parity and skill fixtures carried
harness-specific agent fields that Codex rejects; smoke_task_timeout's cap now
stays out of the watchdog's way; the live tests use StopReason and
sdk_options.max_turns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude reports only a message id's unreported tokens on each TurnEndEvent, so
interleaved ids cannot latch a false budget stop. The TurnMonitor prices with
agent.model, then the model the agent resolved at start, before any per-message
model. A Codex turn total with an unpriceable part stays unpriced. A dialog turn
that reaches the tool-call cap records it before any other stop decision.
EXTENDING.md shows start(plugin_root=) and the delta contract for budgets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… on every cooperative harness

The parity fixtures carried permission_mode and Claude-only fields that Codex
rejects, so their documented --type runs failed at resolution. Also defers two
harness candidates from the final review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…solution; read plugin manifests as Claude Code does

validate_plugins now fails plan when a skill_triggered skill_name is not among
the offered skills, so a misnamed skill no longer costs a full run before it
finishes ERROR (a ${row...} name is checked on its expanded row; the checker
gate stays for detached grades of recorded runs).

Plugin scanning now matches Claude Code's plugins reference and a CLI 2.1.273
spike: a manifest skills path ADDS to the default skills/ instead of replacing
it, a declared path may name one skill, a root holding SKILL.md is a
single-skill plugin, and a skill is named by its SKILL.md frontmatter name.
The deliberately invalid skill_not_offered fixture is deleted; unit tests cover
the refusal on plan and resolution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown

Claude finished @uipreliga's task in 1m 32s —— View job


Code Review for PR #183: Central Enforcement

Todo List:

  • Read .github/code_review.md for review guidelines
  • Review CLAUDE.md for project conventions
  • Analyze the diff between origin/feat/harness-contract and HEAD
  • Review key architectural changes (TurnMonitor, StopReason, plugin staging)
  • Check breaking changes and migration impact
  • Verify cross-file consistency
  • Perform "what's missing" analysis
  • Check test coverage for new functionality
  • Format and post final review

Starting review now...

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.

1 participant