Skip to content

fix(profile,codex): compare model ids through their owner, relay the provider's status - #222

Merged
drewstone merged 4 commits into
mainfrom
fix/model-id-and-error-relay-20260911
Sep 11, 2026
Merged

drewstone merged 4 commits into
mainfrom
fix/model-id-and-error-relay-20260911

Conversation

@drewstone

Copy link
Copy Markdown
Owner

What changed and why

Two defects measured on the live bridge, plus the review findings against the first
two commits on this branch.

#212 — a profiled turn was refused by an equality bug. assertExactProfileRequest
compared a request model already stripped of its harness prefix against a profile id
that was not, so an exactly matching pair was refused and the message printed two
identical strings. A kimi-code pursuit settled driver-failed with zero tokens on it.

Every codex failure left the bridge as one opaque class. A capacity refusal that
clears by itself, a rate-limit refusal worth one backoff, and a request that will fail
identically forever all reached the caller as type: "upstream", so a retry policy
could only guess.

What each change lifts, and whose code it extends

Change Owner it uses instead of re-deciding
Model agreement modelIdsMatch / qualifyModelId / unqualifyModelId from @tangle-network/agent-profile-materialize (already a pinned direct dependency, 0.19.2). Its module doc names this exact bug: "Every writer and every reader must run both sides through here … Two independent copies of this rule are exactly how that bug arises." The first fix on this branch answered #212 with a third copy; profileWireModel is now deleted.
Harness prefix Kept local, because it is the one qualification that package does not own — the request carries it, the profile does not.
Retry behaviour ChatDelta.error.status, an existing bridge field whose own doc already describes this failure ("a frame with no status falls into 'unknown, assume a bad moment', which retried a permanently malformed request to its ceiling"). Only codex's parser was not filling it in. agent-runtime's classifyDriverFailure reads it.
#161 invariant Moved to CodexBackend's argv construction, the only place that also covers a request carrying no profile.

No new dependency: package.json and pnpm-lock.yaml are unchanged.

Measured evidence from this session

The comparison now agrees with the composer. Cross-run of agent-runtime's own
profileBridgeWireModel (from the installed build) over 14 profile shapes, feeding
each composed id back into the check: 13 accepted. Both exceptions are profiles with
no model — a harness-native model.default and a provider-only profile — which
assertExecutableAgentProfile refuses before execution. The docstring states them
rather than claiming unqualified parity; one is newly pinned by a test.

The <harness>/<harness>/<model> route existed. The README claimed it could not.
Driven with a fake spawner, codex/codex/gpt-5-codex reached the CLI as
-c model_provider="codex" — a provider no config.toml defines. That request carries
no profile, so the profile comparison could never have stopped it.

The relayed code alone did not change any retry decision. Measured against the
installed runtime with classifyDriverFailure over a BackendTransportError carrying
the recorded malformed-request message:

state verdict
before this branch (upstream, no status) transient
code relayed, no status (this branch before this PR) transient
code + the provider's own status 400 terminal
rate-limit code + status 429 transient

So a 400 was still re-driven to the attempt ceiling — the failure the relay commit
cited as its motivation — until the status was relayed, and capacity refusals stay
retryable.

Provider text could choose the bridge's own meaning. A codex message quoting
{"error":{"type":"timeout"}} produced type: 'timeout', which the route turns into
504 instead of 502; parse_error in the same position is a code agent-runtime never
retries. Both are refused now and fall back to upstream, with the CLI's words intact
in message.

Fixture provenance was wrong and is corrected. One payload was labelled a verbatim
recording and was not — the stream-disconnect refusal carries codex_error_info: "other", which the fixture dropped, so its test pinned a shape occurring nowhere in
the recordings. Re-scanned ~/.codex/sessions this session under an explicit counting
rule now stated in the file (one refusal = one object carrying both message and
codex_error_info): 517 rollouts, 983 refusals — server_overloaded 410,
response_too_many_failed_attempts 309, other 164, usage_limit_exceeded 92,
unauthorized 8. Every recorded refusal sets the field. The other bucket decomposes
exactly: 139 gateway + 20 invalid_request_error + 5 stream-disconnect. All fixture
counts are restated at that denominator; the earlier 1092 is not reproducible under
any rule stated here.

reset_at is deleted, not fixed. It had no reader (none in agent-runtime's source,
none here) and no producer: 0 of 983 recorded refusals carry a machine-readable reset
instant. The only fixture exercising it was constructed for that purpose.

Red checks (each new test proven to detect the defect it covers, by reverting only
the source logic and restoring):

  • revert the argv drop → the new codex-backend case fails
  • new profile cases against the base implementation → 5 of 11 fail, including both fix(kimi): no model id satisfies Runtime, the bridge check, and the Kimi CLI at once #212 ids and the shipped profiles/opencode-generalist.json shape
  • drop the reserved-code guard → 1 fails
  • read the variant field flat instead of under its key → 2 fail
  • accept any parseable object as the provider body → 1 fails
  • let codex's other outrank the provider code → 3 fail, including the case that previously only counted distinct values and so passed under exactly that collapse

Test command and result

PATH=/Users/drew/.nvm/versions/node/v22.23.2/bin:$PATH pnpm test
  (= vitest run && pnpm run test:runtime-consumer)
tsc --noEmit
  • vitest: Test Files 4 failed | 59 passed | 2 skipped (65),
    Tests 11 failed | 1095 passed | 24 skipped (1130), 94.07s.
  • The 11 failures are pre-existing, proven not assumed. A detached worktree at
    origin/main (1d19faf) ran the same full suite: Tests 11 failed | 1063 passed | 24 skipped (1098) with a byte-identical list of failing test names. They are
    host-environment failures in subsystems this PR does not touch — tests/jail.test.ts
    (5) and tests/docker-executor.test.ts (3) compare macOS paths before realpath
    (/var/… vs /private/var/…), tests/pi-inference-isolation.test.ts (2) reads
    Linux /proc/self/environ, and tests/failure-attribution.test.ts (1) fails from
    the same realpath mismatch. The worktree has been removed.
  • runtime-consumer gate: # tests 1 / # pass 1 / # fail 0. Run separately because
    vitest's non-zero exit short-circuits the &&, so the packaged command never reaches
    it.
  • typecheck: exit 0, no output.
  • Changed files together: 43 passed, 0 failed.
  • Two earlier full-suite runs showed 12 and 13 failures, the extra ones being timeouts
    in retained-sessions and opencode-profile-isolation; both pass in isolation and
    did not recur on two subsequent runs. This machine is under load from two live
    research pursuits. Reported rather than omitted.

Not in scope

  • The other CLI backends. BackendFailureReason is a shared type, but codex is its
    only producer; kimi.ts, sandbox.ts and the rest still collapse every provider
    refusal into upstream. The type's doc says so instead of implying the fix already
    reached them.
  • src/routes/chat-completions.ts. The status selection there is safe once the
    relay cannot emit a reserved code, so the fix is applied at the producer. The route
    is untouched.
  • The non-streaming no-output path. BackendReportedFailureError carries only
    message and code, so a relayed status does not reach an error body built by
    errorResponse. It does reach the streamed frame and the partial-output body.
  • The 11 pre-existing host-environment test failures. They need the jail and docker
    test helpers to resolve symlinked macOS temp paths — a different subsystem and a
    different owner.
  • harnessModelPrefixes alias entries. Review advised deleting claude/kimi as
    unreachable. Measured otherwise: a backend's registered name and the HarnessId it
    passes are independent, and dropping the aliases fails
    tests/docker-executor.test.ts with request model "claude/opus" does not select harness "claude-code". Kept, with a comment naming the route.

🤖 Generated with Claude Code

drewstone and others added 4 commits September 10, 2026 18:27
`assertExactProfileRequest` stripped the harness prefix from the request's
model but not from the profile-qualified model it compared against, so an
exactly matching pair was refused and the message printed two identical
strings: `request model "kimi-code/kimi-for-coding" conflicts with
agent_profile.model "kimi-code/kimi-for-coding"` (#212). A kimi-code
director could not run at all: agent-runtime requires an explicit
`model.provider`, and the only provider the Kimi CLI has a config.toml key
for is the harness itself, which is the shape this check refused.

Compose the profile's declared id the way the caller composes it —
agent-runtime `profileBridgeWireModel` keeps ONE harness prefix, so a
provider equal to the harness collapses into it — then strip that id with
the same `modelWithinHarness` the request goes through. The provider-only
branch gets the same rule. The refusal now prints the two harness-relative
values it actually compared.

This supersedes #161, which refused that profile shape outright to keep a
harness name out of a CLI's `model_provider`. That invariant now holds where
it applies: the composed wire id carries one harness prefix, a provider
equal to it is spent there, and no `<harness>/<harness>/<model>` route
survives the comparison — so the codex test asserts the CLI arguments rather
than the profile shape, and README states the current rule.

The shipped profiles/opencode-generalist.json (provider `opencode`, harness
`opencode`) was refused by the same defect and is covered here.

Tests: the first direct tests for the function — claude-code, codex,
opencode and kimi-code, including the exact ids measured in #212. Five of
the eleven fail against the previous comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every codex failure left the bridge as `type: "upstream"`, whatever the
provider had said. Downstream that is one undifferentiated class: a capacity
refusal that clears by itself, a rate-limit refusal worth one backoff, and a
malformed request that will fail identically forever all read the same, so a
retry policy can only guess — and agent-runtime already decodes this exact
channel as `upstreamCode` (`bridgeUpstreamError`) and branches on it.

Read the refusal from both channels codex reports it on: the discriminant it
sets itself (`codex_error_info`) and the provider's JSON body it quotes inside
the message text. The code rides the error envelope's `type`, any reset instant
rides a new `reset_at` beside it, and the CLI's own words stay intact in
`message`. An unrecognized payload still reports `upstream`, because that is the
bridge saying it cannot classify the failure, not a classification.

Both channels are read the way the recordings show them, not the way the enum
reads on paper. `CodexErrorInfo` serializes a unit variant as a bare string but
a variant carrying data as a single-key object, so the commonest recorded
rate-limit refusal — `{"response_too_many_failed_attempts":{"http_status_code":
429}}`, 320 of 1092 — has no `type` field to read. And `other` is codex
declining to classify, so it must not outrank a code the provider did state: the
20 recorded malformed requests arrive as `other` with `invalid_request_error` in
the quoted body, which is exactly the class this change exists to separate.

The end-of-stream path ends through the delta envelope rather than a throw:
`BackendError.code` is the bridge's own closed taxonomy and cannot carry a
provider discriminant. The route answers both shapes 502, so only the code is
new.

Fixtures are recorded, not invented: 514 rollout files in ~/.codex/sessions
scanned on 2026-09-10, 1092 refusals, 8 distinct shapes, each fixture carrying
its source file and count. The one exception is the `resets_at` payload — no
recorded refusal carries a machine-readable reset instant, so that fixture is
built from codex's own `UsageErrorBody` shape and is labelled CONSTRUCTED.

Tests: capacity, rate-limit and malformed-request refusals surface three
distinct codes. Measured red: all 6 relay tests fail against the previous relay,
which threw a `BackendError` instead of yielding a terminal delta; 5 of the 14
fail against a parser that reads only `codex_error_info.type` and lets `other`
win.

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

Review of 218ccf9 found the comparison hand-rolled a rule that
@tangle-network/agent-profile-materialize already owns and that cli-bridge
already depends on. Its own module doc names the failure: "Every writer and
every reader must run both sides through here ... Two independent copies of
this rule are exactly how that bug arises." Issue #212 was that bug, and the
first fix answered it with a third copy.

`profileWireModel` is deleted. Model agreement now calls `modelIdsMatch`,
with `unqualifyModelId`/`qualifyModelId` for the harness prefix — the one
qualification that package does not own, because the request carries it and
the profile does not. Cross-run against agent-runtime's own
`profileBridgeWireModel` over 14 profile shapes, the check accepts 13 of the
ids it composes; both exceptions are profiles with no model, which
`assertExecutableAgentProfile` refuses before execution, and the docstring
now states them instead of claiming unqualified parity. One of the two is
newly pinned by a test.

The #161 invariant is enforced where it actually holds. The README claimed no
`<harness>/<harness>/<model>` route existed; it did. Measured with a fake
spawner: `codex/codex/gpt-5-codex` reached the CLI as
`-c model_provider="codex"`, a provider no config.toml defines, and the
profile branch could never have stopped it — that request carries no profile
at all. CodexBackend now drops a provider segment equal to its own name when
it builds the argv, which covers every route into the backend, and the README
says that rather than describing a composition rule.

Red checks, measured in this worktree: reverting the argv drop fails the new
codex-backend case; running the new profile cases against the base
implementation fails 5 of 11, including both #212 ids and the shipped
profiles/opencode-generalist.json shape.

The `claude`/`kimi` alias prefixes are kept, against the review's advice: a
backend's registered name and the HarnessId it passes to this file are
independent, so a ClaudeBackend registered under its default `claude` name
routes `claude/opus` into a `claude-code` check. Measured — dropping the
aliases fails tests/docker-executor.test.ts with `request model "claude/opus"
does not select harness "claude-code"`. The alias tests stay, and a comment
names the route that reaches them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…idge's own codes

Review of 59075a8 found the relay did not reach the outcome it was written
for, read one channel at the wrong nesting, and let provider text choose the
bridge's own meaning. Three fixes, plus a fixture that claimed a provenance it
did not have.

RETRY. The relayed code reaches agent-runtime as `upstreamCode`, but its
`classifyDriverFailure` branches on only three bridge codes and otherwise on
`status`, which this path never set. Measured against the installed runtime
(classifyDriverFailure over BackendTransportError): the malformed-request
refusal classified `transient` before the change AND with the code relayed but
no status — so a 400 was still re-driven to the attempt ceiling, the exact
failure 59075a8 cited as its motivation. With the provider's status relayed it
classifies `terminal`, while the 429 rate-limit shape stays `transient`. The
status rides on `ChatDelta.error.status`, a field whose own doc already
describes this problem; only codex's parser was not filling it in.

NESTING. A data-carrying `CodexErrorInfo` variant nests its fields under the
variant key — `{"response_too_many_failed_attempts":{"http_status_code":429}}`
— and the discriminant reader handled that while the field reader beside it
did not. Both now go through one `codexErrorVariantPayload`. The provider's
status is read from the wrapper that states it, beside `error` rather than
inside it, which is where the recordings put it.

TAXONOMY. The relay channel is shared with the bridge's own codes: the route
answers 504 for `timeout`, and agent-runtime never retries `parse_error`.
Provider and CLI text reaches that channel, so a quoted body naming either
word decided the bridge's status or ended a caller's retries. Relaying a
reserved code is now refused and falls back to `upstream`; the CLI's words
still reach the caller in `message`. `providerErrorBody` also requires a
candidate to look like an error body, because codex quotes tool output into
the same prose and the first balanced object there is not the provider's.

`reset_at` is deleted rather than fixed. It had no reader — none in
agent-runtime's source, none here — and no producer: of 983 recorded refusals,
zero carry a machine-readable reset instant. The one fixture that exercised it
was constructed for that purpose.

FIXTURES. One payload was labelled a verbatim recording and was not: the
stream-disconnect refusal carries `codex_error_info: "other"`, which its
fixture dropped, so its test pinned a shape occurring nowhere in the
recordings. Re-scanned this session with an explicit counting rule now stated
in the file: 517 rollouts, 983 refusals, server_overloaded 410 /
response_too_many_failed_attempts 309 / other 164 / usage_limit_exceeded 92 /
unauthorized 8, and every recorded refusal sets the field. All counts are
restated at that denominator; the earlier 1092 is not reproducible under any
rule stated here. The `other` bucket decomposes exactly: 139 gateway + 20
invalid_request_error + 5 stream-disconnect.

The commit message of 59075a8 claimed "all 6 relay tests fail against the
previous relay". That count was wrong — an independent review reproduced 8 —
and the test file has since changed, so the red checks measured here are the
current ones: reverting the reserved-code guard fails 1, the variant nesting
fails 2, the body-shape test fails 1, and letting codex's `other` outrank the
provider code fails 3, including the case that previously only counted
distinct values and so passed under that collapse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@drewstone
drewstone merged commit 44ef058 into main Sep 11, 2026
1 check passed
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