Skip to content

🧠 narrow thinking policy for codex and pi - #163

Closed
sdougbrown wants to merge 1 commit into
mainfrom
fix/thinking-policy-codex-pi-exclusions
Closed

🧠 narrow thinking policy for codex and pi#163
sdougbrown wants to merge 1 commit into
mainfrom
fix/thinking-policy-codex-pi-exclusions

Conversation

@sdougbrown

Copy link
Copy Markdown
Owner

What

The thinkingPolicy eitherOf group had one fullSupport branch. It covered codex-app-server and pi together with all seven canonical values. Neither backend accepts all seven, so avenor forwarded values certain to fail after the session already existed.

Backend Start Explicit resume
codex-app-server low, medium, high, xhigh, max same
pi off, low, medium, high, xhigh, max same
claude / claude-channel lowmax (unchanged) unsupported (unchanged)

fullSupport becomes codexSupport and piSupport. The generated branch constant FullSupport splits into CodexSupport and PiSupport. One test referenced it.

Why each exclusion

The Codex app-server has no off or minimal effort level. It answers HTTP 400 for both across the GPT-5.6 family, verified against gpt-5.6-luna, -terra, -sol, and -mini. low through max are accepted on start and on resume.

Pi has no minimal level. off is a real Pi level. It reaches Pi verbatim through --thinking on a fresh client and set_thinking_level on a reused one.

Both providers pass the canonical string through as the wire value (internal/runtime/codexappserver/provider.go:89, internal/runtime/pi/provider.go:371). The schema is therefore the only place that can reject these before a provider starts.

minimal is now canonical with no backend

No backend accepts minimal after this change. It stays in the canonical tuple, which is the shared vocabulary hosts derive their enums from. A backend can gain the level without a vocabulary change. Removing it would touch THINKING_LEVELS, CanonicalValues(), the MCP enum, and every fixture row.

TestMinimalIsCanonicalButUnsupported pins the current state. A backend that starts accepting minimal fails that test, which points at the policy docs.

TypeScript needed no change

packages/core/src/thinking-policy.ts parses the same schema's eitherOf branches at module load and evaluates them directly. Its 42 conformance tests pass on the new branch shape with no source edit. Go derives supportedBackends from the schema the same way. That is the property internal/thinkingpolicy/doc.go claims, exercised by a real policy change.

Verification

go build ./..., go vet ./..., and go test ./... pass. go test -race -count=1 passes on internal/thinkingpolicy, internal/runtime, and internal/runtime/pi. bun test packages/core/src/thinking-policy.test.ts reports 42 pass, 0 fail.

Four hand-written expectation sites tracked the old policy and now assert the new one:

Site Change
internal/thinkingpolicy/backend_policy_test.go FullSupportCodexSupport/PiSupport; per-backend accept and reject rows
internal/thinkingpolicy/thinking_policy_test.go Evaluate table driven from per-backend supported sets; StartValues for codex and pi
internal/runtime/thinking_test.go policy map rows for codex and pi; resume cases assert UnsupportedValue rather than start-only
internal/runtime/pi/provider_test.go minimal dropped from the fresh-start flag subtests

A full bun test run has 20 other failures. All are missing node_modules (@umpire/core, zod, @earendil-works/pi-tui), unrelated to this change.

thinking_policy.gen.go was regenerated with the go:generate directive in doc.go (umpire-go-gen@v0.1.1) and is committed. Nothing regenerates it at build or test time.

Provenance of the pi row

The codex and claude rows are measured. The pi row — off supported, no minimal level — comes from the repository owner rather than from a run against Pi 0.83.0. Confirm it against pi --help before release.

Adjacent, not addressed here

No CI job runs go generate and diffs the result. A schema edit that changes only branch logic, with no fixture change, would compile and pass. The conformance tests compare fixtures against the compiled Check, so they catch fixture-versus-generated drift but not schema-versus-generated drift. TestSchemaCanonicalLockstep covers only the canonical tuple and the generated struct shapes.

readSchemaRules reads ../../schemas/thinking_policy.umpire.json relative to the working directory. Outside the package's own tests it therefore falls back to the hardcoded backend list in validate.go. That list is still correct, and it only selects the wording of a rejection because Fair comes from compiled code. It does narrow the schema-only-change promise in doc.go.

Roster support for thinking is separate. There is no roster entry spec in schemas/. spawn_selection.umpire.json governs how a spawn selects an entry, not what an entry contains. A roster entry validates at load time on a fresh start, so thinkingpolicy.Evaluate(entry.Backend, entry.Thinking, false) answers it without a new schema.

🤖 Generated with Claude Code

The thinkingPolicy eitherOf group had one fullSupport branch covering
codex-app-server and pi with all seven canonical values. Neither backend
accepts all seven: the Codex app-server answers HTTP 400 for off and
minimal across the GPT-5.6 family, and Pi has no minimal level.

- Split fullSupport into codexSupport (low through max) and piSupport
  (off plus low through max). Start and resume stay identical for both.
- Regenerate thinking_policy.gen.go from the schema.
- Flip the two conformance rows that asserted the old policy, and add
  rows for each new exclusion boundary on start and resume.
- Keep minimal in the canonical tuple. No backend accepts it now, but it
  is the vocabulary hosts derive their enums from, and a backend can gain
  it without a vocabulary change. A test pins the current state.
- Update the per-backend tables in docs/thinking-level.md and
  docs/backends.md, and record why each exclusion exists.

The TypeScript evaluator in packages/core derives the policy from the
same schema and needed no change.

Verified with go build ./..., go vet ./..., go test ./..., go test -race
-count=1 on internal/thinkingpolicy, internal/runtime, and
internal/runtime/pi, and bun test packages/core/src/thinking-policy.test.ts
(42 pass, 0 fail).

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

Copy link
Copy Markdown
Owner Author

👾 AI Agent

Closing unmerged. The premise is wrong.

This PR encodes off/minimal support as a backend-level fact. It is model-level. pi --help advertises all seven levels (off, minimal, low, medium, high, xhigh, max), and the HTTP 400 evidence for codex covers only the GPT-5.6 family — minimal was a real GPT-5 reasoning effort, so a family dropping it is not the backend losing the capability.

The existing design already draws this line. docs/thinking-level.md states that the app-server validates effort against the active thread model on the actual turn, and that Pi owns model-specific mapping and clamping. The static Umpire policy answers which values a backend can express and at which lifecycle points; model validity belongs to the backend, which surfaces its own error text. This PR moved a model-dependent rejection into a model-blind gate, so it would reject valid model and value combinations.

Adding a model condition to the schema is the wrong fix: it needs a per-model ladder that goes stale on every model release, and over-rejecting a valid combination is worse than surfacing the backend error.

The claude and claude-channel exclusions on main are unaffected. Those are gated on claude --help and --effort has no off or minimal value for any model.

Separate follow-up, not addressed here: avenor passes the canonical value through verbatim, and no codex effort level is spelled off. That is a missing mapping to codex none, not a capability gap.

@sdougbrown sdougbrown closed this Aug 6, 2026
@sdougbrown
sdougbrown deleted the fix/thinking-policy-codex-pi-exclusions branch August 6, 2026 21:15

@umpire-bot umpire-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR is marked... FOUL BALL. 👉

Satisfied: func() bool { v := f.Thinking; return v != nil && *v != "" }(),
Fair: (f.Thinking == nil || ((c.Backend == "codex-app-server" || c.Backend == "pi") && (f.Thinking != nil && *f.Thinking == "off" || f.Thinking != nil && *f.Thinking == "minimal" || f.Thinking != nil && *f.Thinking == "low" || f.Thinking != nil && *f.Thinking == "medium" || f.Thinking != nil && *f.Thinking == "high" || f.Thinking != nil && *f.Thinking == "xhigh" || f.Thinking != nil && *f.Thinking == "max")) || ((c.Backend == "claude" || c.Backend == "claude-channel") && c.Resume == false && (f.Thinking != nil && *f.Thinking == "low" || f.Thinking != nil && *f.Thinking == "medium" || f.Thinking != nil && *f.Thinking == "high" || f.Thinking != nil && *f.Thinking == "xhigh" || f.Thinking != nil && *f.Thinking == "max"))),
Fair: (f.Thinking == nil || (c.Backend == "codex-app-server" && (f.Thinking != nil && *f.Thinking == "low" || f.Thinking != nil && *f.Thinking == "medium" || f.Thinking != nil && *f.Thinking == "high" || f.Thinking != nil && *f.Thinking == "xhigh" || f.Thinking != nil && *f.Thinking == "max")) || (c.Backend == "pi" && (f.Thinking != nil && *f.Thinking == "off" || f.Thinking != nil && *f.Thinking == "low" || f.Thinking != nil && *f.Thinking == "medium" || f.Thinking != nil && *f.Thinking == "high" || f.Thinking != nil && *f.Thinking == "xhigh" || f.Thinking != nil && *f.Thinking == "max")) || ((c.Backend == "claude" || c.Backend == "claude-channel") && c.Resume == false && (f.Thinking != nil && *f.Thinking == "low" || f.Thinking != nil && *f.Thinking == "medium" || f.Thinking != nil && *f.Thinking == "high" || f.Thinking != nil && *f.Thinking == "xhigh" || f.Thinking != nil && *f.Thinking == "max"))),
Reason: func() *string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The Reason (and Reasons) fields in ThinkingPolicyAvailability.Thinking are computed in the generated Check function (thinking_policy.gen.go:85-122) but are never asserted on by any test. TestBackendPolicyGenerated only checks Fair and Valid (backend_policy_test.go:58-65). Since these reason strings encode the per-backend block explanations (e.g. "pi supports off and low through max"), a change to them would go uncaught. Add assertions on avail.Thinking.Reason (and Reasons) for at least one representative case so the generated reason text is pinned.

// Pi: off plus low..max, start and resume. Pi has no minimal level.
{"pi", str("off"), false, true, true},
{"pi", str("xhigh"), true, true, true},
{"pi", str("minimal"), false, false, true},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor duplication: the two adjacent table entries at backend_policy_test.go:31-32 ({"pi", str("minimal"), false, false, true} and {"pi", str("minimal"), true, false, true}) differ only in the resume flag with identical expected values. The identical expectations do document that resume does not change the outcome for pi+minimal, which is legitimate, but the near-duplicate rows add a little noise. Consider a brief comment noting the resume invariance or collapsing into a single row with a resume-agnostic note.

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