Skip to content

fix(tier): tier routing reaches the wire — the tier was computed and never sent - #35

Merged
linhdmn merged 1 commit into
mainfrom
fix/tier-routing
Sep 21, 2026
Merged

linhdmn merged 1 commit into
mainfrom
fix/tier-routing

Conversation

@linhdmn

@linhdmn linhdmn commented Sep 21, 2026

Copy link
Copy Markdown
Member

The finding

PRD §13.1 move 3 says "route models by step type (40–70%)". M3's row claims tierCombo does it. It did not.

The runner computed a tier per step, stored it on the run record, and then sent one fixed combo forever — onegw.Client.Chat took no model parameter, and the client was constructed with a single combo. "Tiered routing" was a field on a run.

Worse, the names. tierCombo emitted planning/execution/tiny, and this portfolio's onegw ships exactly one combo (dev). Two of the three tier names were combos upstream does not have — so even a correct implementation could not have routed them.

What changed

Before After
interface Chat(ctx, msgs…) ChatTier(ctx, tier, msgs…) — a Tierless adapter keeps Chat-only fakes working
client one combo, fixed at construction WithTiers(map[string]string); Chat stays as the default path
tier names planning / execution / tiny planning / execution / synthesis — the three decisions the loop actually makes
unknown tier n/a falls through to AGENTLOOP_ONEGW_COMBO; a misconfigured tier costs the wrong model, not the run
Reply the leg that answered both Combo (asked for) and Model (answered) — a fallback is the event tiering must be judged on, and recording only one side hid it

tierForStepName normalises an unknown plan tier onto execution, so an old plan naming tiny still routes rather than falling off the table.

Verified against a stub gateway

{"asked": ["exec-combo", "exec-combo", "exec-combo", "synth-combo"]}

Three action steps routed to the execution combo, the bound-exit answer to the synthesis combo, and the run reported answered_by: leg:synth-combo — the combo it was actually sent to.

Tests

  • 6 new in internal/onegw: tier routes to its combo, unknown tier falls back, no tiers means the default, Reply records the combo asked for.
  • 2 new in internal/loop: the step's tier reaches the model; synthesis names its own tier.
  • TestM3_PlannerDrivenRun previously asserted planning or tiny. It now asserts one of the three real tiers — that assertion is why the drift survived: it accepted a name that could never be routed.

What this does NOT claim

The 40–70% saving. That is §11.4's paired parity suite — the same cases run both ways, paired per case — and it has not been run. The mechanism works; the number is still a hypothesis. The PRD says exactly that rather than inheriting the move's figure, and USAGE §9 scopes it the same way.

Docs

  • PRD §13 records the drift, its cause, and the fix; the nine stacked Last updated stamps in the footer are now one live stamp plus a dated changelog (a nine-deep footer is a footer nobody reads).
  • USAGE's config table gains AGENTLOOP_ONEGW_COMBO_{PLANNING,EXECUTION,SYNTHESIS}; §9 replaces "tier routing is half-wired" with what is now true and what is still unproven.

Checks

make check green: gofmt, vet, 14/14 packages, lint 0 issues, PRD OK, selftest 12/12.

CI shows red on this PR — the org's Actions billing limit is still unraised (#31's caveat, recorded in USAGE §2). make check runs the identical five steps.

…never sent

PRD §13.1 move 3 says "route models by step type (40–70%)" and M3's row
claims `tierCombo` does it. It did not. The runner computed a tier per step,
stored it on the run record, and then sent **one fixed combo forever**:
`onegw.Client.Chat` took no model parameter and the client was constructed
with a single combo. "Tiered routing" was a field on a run.

Worse, the names. `tierCombo` emitted planning/execution/tiny, and this
portfolio's onegw ships exactly **one** combo (`dev`) — its own README and
the live `onegw.toml` both say so. Two of the three tier names were combos
upstream does not have, so even a correct implementation could not have
routed them.

## What changed

  - `ModelClient.ChatTier(ctx, tier, msgs…)` replaces the tier-less `Chat`
    on the interface. A `Tierless` adapter keeps any Chat-only fake working.
  - `onegw.Client.WithTiers(map[string]string)` maps tier → combo;
    `ChatTier` resolves it and `Chat` stays as the default-combo path.
  - The three tiers are now the three decisions the loop actually makes:
    **planning**, **execution**, **synthesis**. `tierForStepName`
    normalises an unknown plan tier onto execution, so an old plan naming
    `tiny` still routes instead of falling off the table.
  - An unmapped tier falls through to `AGENTLOOP_ONEGW_COMBO`. A one-combo
    deployment is unchanged — a misconfigured tier costs the wrong model,
    not the run.
  - `Reply` carries both `Combo` (asked for) and `Model` (answered), because
    a gateway fallback is precisely the event tiering has to be judged on;
    recording only the answering leg made a fallback invisible.

Env: `AGENTLOOP_ONEGW_COMBO_{PLANNING,EXECUTION,SYNTHESIS}`.

## Verified against a stub gateway

    asked: ["exec-combo", "exec-combo", "exec-combo", "synth-combo"]

Three action steps routed to the execution combo, the bound-exit answer to
the synthesis combo, and the run reported `answered_by: leg:synth-combo` —
the combo it was actually sent to.

## Tests

6 new in `internal/onegw` (tier routes to its combo, unknown tier falls back,
no tiers means the default, Reply records the combo asked for) and 2 in
`internal/loop` (the step's tier reaches the model; synthesis names its own
tier). `TestM3_PlannerDrivenRun` previously asserted `planning or tiny` —
it now asserts one of the three real tiers, which is the drift this change
exists to stop.

## What this does NOT claim

The 40–70% saving. That is §11.4's paired parity suite — the same cases run
both ways, paired per case — and it has not been run. The mechanism works and
the number is still a hypothesis; the PRD says exactly that rather than
inheriting the move's figure.

Docs: PRD §13 records the drift, its cause and the fix; USAGE's config table
gains the three variables and §9 scopes the claim. The PRD footer's nine
stacked "Last updated" stamps are now one live stamp plus a dated changelog,
because a nine-deep footer is a footer nobody reads.
@linhdmn
linhdmn merged commit cde23ba into main Sep 21, 2026
1 of 3 checks passed
@linhdmn
linhdmn deleted the fix/tier-routing branch September 21, 2026 09:54
linhdmn added a commit that referenced this pull request Sep 21, 2026
The only real conflict was the tier work landing where this branch had put
its own `/v1/systemone` client: `internal/systemone` and #37's
`internal/guardrail` are the same client built twice, and #37's is the one
on main — wired into the service, with the goal screen and `screen_errors`.
Keeping both would have meant two clients, two batteries and two answer
parsers for one wire, so this branch is reduced to what it actually adds.

Kept from this branch (the parts #37 does not have):

- **The hazard, not the label.** A blocked step's record names the hazard
  that fired (`jailbreak 0.91`) instead of the constant `noul_battery`, and a
  severity-driven block records the severity row too — a single hazard row
  would claim a low probability caused it. `internal/loop/screen.go`.
- **`RunnerConfig.Policy`** (strict by default, `AGENTLOOP_GUARDRAIL_POLICY`
  selects permissive). `experiments.Strict` was hard-coded in the loop and in
  `screenGoal`, so §17's measured strict/permissive split — a product
  decision the PRD names — was unreachable without a rebuild.
- **The answer-shape fix (#37's client, found by driving it).** #37's
  `probabilityOf` read `confidence` before the hazard, so TypeSafe's real
  envelope — `{"type":"noul","noul":0.91,"confidence":0.88}`, which is what
  the API actually sends — reported jailbreak 0.88 where it is 0.91. It also
  did not read Laya's `probabilities["1"]`. Both are now read, both pinned by
  `TestBothBackendEnvelopesAgree`, and `confidence` is out of the key list:
  it is a calibration claim, not a verdict.
- **Why the client is here at all**, in `docs/JEV-INTEGRATION.md` §2.1: policy
  and the wire belong to agentloop, backend selection belongs to onegw, and
  the "provider abstraction" that would replace all of it is a fiction —
  Laya is a non-autoregressive encoder and cannot satisfy a generation-
  provider interface.

Dropped from this branch: `internal/systemone` (superseded by
`internal/guardrail`), its `GuardrailClient` seam and `guardrail_unavailable`
exit reason (a screen that cannot run is an observation recorded in
`screen_errors`, not a new terminal state — that is #37's contract and it is
the right one), and this branch's duplicate env names.

Verified: `make check` green (gofmt, vet, test, golangci-lint, check-prd);
stub-sidecar drive through the real binary at `AGENTLOOP_GUARDRAIL_URL`
blocks/holds with the hazard row on the step.
linhdmn added a commit that referenced this pull request Sep 21, 2026
)

Second merge of `origin/main` (which now carries #37, "the TypeSafe screen is
wired"). The conflicts were all one thing: this branch and #37 built the same
client twice — `internal/systemone` here, `internal/guardrail` there — so the
resolution keeps #37's tree and this branch's four additions on top of it.

Why #37's client wins: it is wired into the service (goal screen, tool-result
screen, `screen_errors` on the run, `guardrail_wiring_test.go`), it treats a
screen that cannot run as a recorded observation rather than a new terminal
state, and it is already the API the tests on main exercise. Two clients for
one wire, each with its own battery copy and answer parser, is the duplication
`docs/DUPLICATION-AUDIT.md` exists to prevent.

What this branch adds, i.e. what the conflict resolution preserves:

1. **The hazard, not the label.** A blocked step's record names the question
   that fired (`jailbreak 0.91`) instead of the constant `noul_battery`, and a
   severity-driven block also records the severity row — one hazard row would
   claim a low probability caused a block the severity decided.
   (`internal/loop/screen.go`, `internal/loop/screen_test.go`)
2. **`RunnerConfig.Policy`.** `experiments.Strict` was hard-coded in the loop
   and in `screenGoal`, so §17's measured strict/permissive split — which the
   PRD names as an operator decision — was unreachable without a rebuild.
   `AGENTLOOP_GUARDRAIL_POLICY=permissive` now selects it, and anything
   unrecognised stays strict.
3. **The answer-shape fix in `internal/guardrail`.** `probabilityOf` read
   `confidence` before the hazard, so TypeSafe's real envelope —
   `{"type":"noul","noul":0.91,"confidence":0.88}`, which is what the API
   sends — reported jailbreak 0.88 where it is 0.91; a 0.03 error in the
   direction that under-blocks. Laya's `probabilities["1"]` was not read
   either. Both envelopes are now read and pinned by
   `TestBothBackendEnvelopesAgree`; `confidence` is out of the key list
   because a calibration claim is not a verdict.
4. **`docs/JEV-INTEGRATION.md` §2.1** — why the client lives here: policy and
   the wire belong to agentloop, backend selection belongs to onegw, and the
   generation-provider abstraction that would replace it is a fiction, because
   Laya is a non-autoregressive encoder.

Dropped: `internal/systemone`, its `GuardrailClient` seam, and its
`guardrail_unavailable` exit reason.

Verified after resolving: `make check` green (gofmt, vet, go test,
golangci-lint, `docs/check-prd.py`).
linhdmn added a commit that referenced this pull request Sep 21, 2026
…inters (#38)

The index said "the three remaining stub tools, the model-driven planner and
tier-combo names are the next milestone". Three of those four are now landed
(#33 execution, #34 the reasoner, #35 tier routing), so the note was stale and
the real next step was unnamed.

Rewritten as a handoff, with each item verified against the code rather than
recalled:

  1. **The loop cannot read a file** — four tools, none of which returns file
     *content*. `query` returns graph elements with a 400-char excerpt and a
     rung; it is not a reader. So the reasoner locates parseConfig, learns it
     is in config.go:41, and cannot look at it — which is exactly why the live
     demo wrote `func parseConfig() {}`. `write_file`'s description already
     promises a "read twin" that does not exist.
  2. **The run has no answer** — `goal_met` stores its synthesis in
     `PartialSynthesis`, a field named for the bound case, and the model's
     rationale sits in a step's `why`.
  3. **Success criteria are prose** — `ps.Success` appears exactly once in the
     codebase: as prompt text in reason.go. Nothing evaluates it.
  4. `web_search` is the last stub (or should be deleted).
  5. The two gaps #37 recorded: reply-out unscreened, ~740ms/call unmetered.
  6. Planning is still a rule table (no longer blocking — the chooser carries).

Plus the working notes a new session loses time rediscovering: the worktree
rule, `make check` as the gate, the shell quirks (nohup for backgrounded
servers, pkill matching its own command line, ports 8080/9699 taken), and the
one that matters most — every defect this repo shipped lately was a check that
could not fail (#25, #29, #32, #37, and #8's checklist ticked on the author's
behalf). Verify by breaking the thing, not by watching it pass.
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