Compare commits

...

45 Commits

Author SHA1 Message Date
Bryan Qiu e0279337bd docs: record the personal CLI setup and the provider topology
LOCAL_SETUP.md covered the repo, but a fresh clone still does not
reproduce the environment: the whole Claude Code and Codex setup lives
in $HOME. New section 9 carries it - the three personal ~/.claude
files, the model-serving proxy mode and its refresh hook, the Codex
Databricks provider block and the five personal hooks that Omnigent's
generated hooks.json must merge with, the two secrets that have to
move out of band, and the transfer order.

Section 9.5 records the provider topology, which is easy to misread:
the global config's default provider is a Claude subscription, its
AIGW provider (the /ai-gateway/anthropic route, which is the Gateway
despite the path) is not default, and the worktree config is a
separate staging workspace. Measured with omnigent.gateway_inference:
global reports False for both families, the worktree True for both.

That measurement surfaced a real defect, now recorded in plan block
3f: the codex check reads the base URL Omnigent resolves, so a
kind: cli-config provider (which defers to the user's own
~/.codex/config.toml) yields None and is reported as not-backed rather
than unknown. False hides the Smart Routing option; unknown does not.
The rewrite must read the delegated config or report unknown.

Co-authored-by: Isaac
2026-08-02 18:39:35 -07:00
Bryan Qiu 5d4940e387 docs: add LOCAL_SETUP.md; drop the stray npm lockfile
R0 documented how to run the stack but not how to build it, and two
things stopped a fresh machine cold: .omnigent-local/config.yaml is
gitignored, so run-server.sh exits immediately with nothing explaining
what belongs in it, and run-frontend.sh hardcoded this machine's nvm
path. LOCAL_SETUP.md now covers prerequisites, uv sync + pnpm install,
the databricks profile the router needs, the config template (with the
two details that break things quietly: system.ai. keeps its trailing
dot, and router_name must be task_v1), bring-up, a health check, the
known local quirks, and teardown. R0 points at it and wave 0 carries
it across.

run-frontend.sh now resolves node from PATH, falling back to the newest
nvm install, and fails with a pointer if pnpm is missing.

Separately: web/package-lock.json was tracked again after the rebase.
The repo uses pnpm (pnpm-lock.yaml, packageManager pnpm@11.15.1) and
main has no npm lockfile, so this was 3,451 lines of generated
wrong-package-manager noise in the PR diff. Untracked, deleted, and
gitignored so it cannot come back.

Co-authored-by: Isaac
2026-08-02 15:51:55 -07:00
Bryan Qiu f2c8a57b32 docs: close the cold-read audit's blockers on the rewrite plan
A subagent with no context from this session read the plan as an
executor would and found that its load-bearing inputs are unreachable
from the branch it tells you to start on. Confirmed and fixed.

Blockers:
- routing-mvp-v1 was an aspiration, not a branch. It now exists,
  pinned at f200a8bd, and 0c/1a cite the sha.
- None of the required-reading docs, and none of the R0/R6/R9/R10
  verification harness, exists on origin/main. Wave 0 now carries all
  twelve paths across, or every stream stops at its first instruction
  and both live barriers have no stack to run on.
- 2f never named the preview flag. It is managed-side
  (databricks.mas.omnigent.intelligentRouting, default off), so OSS
  gets a per-request predicate the deployment supplies, plus a
  default; stream 2 builds the seam, not a flag system.
- The migration had two owners. Wave 0 creates the empty revision and
  stream 4 fills it.
- The file partition existed only as a promise, and where implied it
  double-booked subagent_routing.py. New block 4f is the table, with
  named modules for the transport/policy and turn-gate/create-path
  splits, and cli.py declared lead-owned.

Also: new 2g records what main already ships (both routing clients and
the wire-compat redirect), which shrinks stream 2; wave 0 slims the
registry so waves 1-2 are gated on a true list; 6d had R5 and R6
transposed; 6e dropped row B3 and now names CUJ_STATUS as the row
authority; barrier-1's apply script has an owner; the UI acceptance
names Bryan, since no agent can close it; and the size figures in 1a
and 3h are re-measured (29,924/155, and web/src minus its lockfile).

One gap only Bryan can close, now flagged in 6e: INTELLIGENT_ROUTING_
PLAN.md section 11.1 does not embed the P-SOL prompt, and rows A3, B2,
C2 need it.

Co-authored-by: Isaac
2026-08-02 15:08:24 -07:00
Bryan Qiu f200a8bd37 docs: clear the last session-only references from the plan
3g was still written as "rewrite, not transplant" against a suite the
fleet never sees, and it cited a commit's method rather than a rule.
It now states the rule directly: start from the behavior inventory in
CUJ_STATUS.md section 2, one test per behavior, coverage as the gate.
The reference suite is described as what not to copy and why.

Also replaced the two remaining "three review waves" references, which
name history a fresh reader cannot resolve, with "the reference
implementation".

Co-authored-by: Isaac
2026-08-02 14:53:16 -07:00
Bryan Qiu 897af13965 docs: make the rewrite plan readable without session context
The plan hands off to a fresh fleet that has none of this session's
history, so the spec sections (0-6) now read as instructions rather
than as diffs against earlier drafts. Removed the negations of
assumptions a new reader never held (the glm route is "not CLI work",
managed readiness is "not 2a", 3c "reverses the earlier cut"), the
RESOLVED-with-date tags inside spec blocks, and references only this
session could resolve. Section 7 keeps the full decision record, which
is its job. Empirical findings survive the trim: the A-sub
deny-message result, the zero-live-triggers evidence, and the
authorization-order trap now cite the document that records them.

Wave design is now the lead's rather than a placeholder: a wave-0
contract commit, 7 foundation streams, 6 integration streams, and a
4-stream closure wave. The turn gate and the create paths move into
separate modules so they stop colliding in orchestration.py; web and
CLI move into wave 2 behind the wave-0 HTTP contract, which keeps the
two largest surfaces off the critical path. Barrier 1 gains a real
check (apply a hardcoded model to a claude pane and a codex session
with no router involved) and barrier 3 gains the flag-off backend row.

Co-authored-by: Isaac
2026-08-02 14:52:15 -07:00
Bryan Qiu b1e6cd3a32 docs: request-time managed flag, parallel wave plan, and four scope reversals
Bryan's review of the rewrite plan (2026-08-02) produced five changes.

The managed preview flag is evaluated per request, not at
construction, and it moves out of 2a into its own block 2f: flag off
routes through the naive LLM judge, flag on routes through the AI
Gateway, so a flag-off workspace degrades rather than loses the
feature. That also dissolves the managed-swap report's objection.

The glm gateway route is codex work, not CLI work, and the Smart
Routing harness inherits it because it runs codex underneath.

Cross-harness spawning is reinstated: harness agents get
sys_session_create instead of a deny message (3c, 7i). Telemetry
leaves the PR entirely for a follow-up Bryan owns (3e, 7j). The design
docs ride the branch for his reference and a final commit deletes them
before merge, so no docs PR exists (3a, 7j).

Execution is now three waves of five or six parallel workstreams on
one branch, preceded by a lead-authored wave-0 contract commit that
declares every shared signature and pre-creates every shared touch
point (4a, 4b, 4e, 6a, 6e, 7k). Size is a preference for
reviewability, not a target (3j).

Co-authored-by: Isaac
2026-08-02 14:44:49 -07:00
Bryan Qiu 22e1f640da docs: switch the plan to a from-scratch rewrite (7g)
Bryan chose a complete rewrite from scratch (2026-08-02) to keep the
new code as clean as possible, reversing the plan's earlier 'assemble,
do not re-implement' constraint.

The scope decisions all survive; the method and the safety net change.
New blocks: 0c names the three inputs an agent must read before it
writes a slice (the behavior inventory, the trap list, and the
reference implementation on routing-mvp-v1), 0d says to rewrite the
shape but transcribe the empirically-derived constants, 3l reframes
the cut list as 'do not build', 4e contains the integration risk that
moves to the end, 6f records that no evidence transfers, and 7g is the
decision itself. 3j becomes a ceiling rather than a subtraction, which
also retires its old arithmetic gap, and 5b turns the two CLI commits
into specifications rather than patches to apply.

Co-authored-by: Isaac
2026-08-02 12:54:07 -07:00
Bryan Qiu 37030d8886 docs: luna is the gpt+glm fallback, sonnet follows the alias pin; add verification criteria (6c-6e)
Bryan's final fallback rulings (2026-08-01): the gpt and glm families
both fall back to luna (databricks-gpt-5-6-luna, itself a frozen arm,
so a glm fallback never leaves the codex harness), and the claude
fallback is whatever the sonnet alias pin resolves to rather than a
hardcoded id. Terra is out; glm no longer declines. No open
assumptions remain in the plan.

New plan blocks 6c-6e state the verification criteria: the evidence
bars per layer, the registry recipe handles (R0-R10; R8 dies with the
enforcement cut), and the per-slice verification gates for the fleet.

Co-authored-by: Isaac
2026-08-01 22:56:04 -07:00
Bryan Qiu 5866ad455b docs: finish the STE pass, restructure 3i to the three rulings, pin the fallback-id assumptions
Reconciles the fold-agent's late completion (it amended 0baeea1c
locally; this lands the same tree as a follow-up commit instead of a
force-push). The whole plan now meets the STE caps, 3i lists Bryan's
three rulings as ruled (pi had been displaced by a mechanism bullet),
and the open-assumption list grows to three: glm declines with no
fallback; terra is today only a pi-exclusion entry, so the code must
add it as a servable target; sonnet pins to databricks-claude-sonnet-5.

Co-authored-by: Isaac
2026-08-01 22:39:58 -07:00
Bryan Qiu 0baeea1c2f docs: fold the model-resolution rulings into the plans; STE pass on the rewrite plan
Bryan ruled on the three open resolution questions (2026-08-01): revert
the resolution machinery to main's shape (cut MODEL_LISTS, the cost
table, the allowlist), drop pi from the routed set for now (bar list
goes with it), and use one fixed fallback model per family (claude ->
sonnet, gpt -> terra) with an honest decline behind it. The rewrite
plan is now fully decided and rewritten in ASD-STE100 style; the
overview's subsystem fates, invariants, and decision records match.

Co-authored-by: Isaac
2026-08-01 22:27:51 -07:00
Bryan Qiu e3e888ecb9 docs: fold Bryan's critique decisions into the rewrite plan
Co-authored-by: Isaac
2026-08-01 21:35:26 -07:00
Bryan Qiu d89d0c61ea docs: high-level routing system map for slimming iteration
Add designs/ROUTING_OVERVIEW.md: a one-altitude map of the Smart Routing
feature — the four user journeys, the fifteen subsystems with size and
rewrite fate, the invariants that must survive any cut, and the five open
decisions. Written in ASD-STE100 style with block IDs so the slimming
pass can cut and keep by reference.

Co-authored-by: Isaac
2026-08-01 18:04:57 -07:00
Bryan Qiu b70e0f61b5 docs: track the PR review fix list (rounds 1-2, all items addressed)
Co-authored-by: Isaac
2026-08-01 13:16:48 -07:00
Bryan Qiu 7c175afad0 docs: cover the CLI smart-routing entry points
`omnigent claude|codex --smart-routing -p` (tier 2) and `omnigent run
--smart-routing -p` (tier 3) were undocumented. Record the fourth surface:
CUJ_IMPLEMENTATION.md gains §6 (commands and tiers, prompt delivery,
preflight, the create-time MODEL route for a fixed native harness, the
create the CLI drives, rejected combinations, the routed launch, decision
persistence, and the agent-name import fix), and known-open moves to §7.

CUJ_STATUS.md gains recipe R10 and §2.10 — unit rows stamped from the three
suites that pass at HEAD, every process-truth row  because no routed CLI
launch has run live yet.

PR_REWRITE_PLAN.md §2d/§5 corrected: both CLI halves have merged, and the
tier-2 server half is already its own commit, so the commit-3/commit-8 split
is mechanical. The CLI commit did not extend `_resolve_native_smart_routing`
— the fixed-harness route is a parallel path — but it does share the auto
path's lifted `_routing_host_for_create` helper, which the assembler must
keep.

Co-authored-by: Isaac
2026-08-01 13:12:50 -07:00
Bryan Qiu cd9fdccb20 docs: cover the glm gateway-route fix
907f8886 pins the id the glm arm is applied under: the gateway serves GLM
on the Responses API only as the model route `system.ai.glm-5-2`, so the
catalog's `databricks-glm-5-2` row 400s every codex turn. Record the
mechanics in CUJ_IMPLEMENTATION.md §3.5h (with the §1.3 spelling note and
the residual "pinned, not discovered" open item), and close the C1 /
§2.8 blocker in CUJ_STATUS.md against the live session 80fb6d1f: config
mirror and every rollout turn context on system.ai.glm-5-2, zero
BAD_REQUEST, real generation. The only error left on that thread is a
gateway-capacity 429, which is load and not routing.

Co-authored-by: Isaac
2026-08-01 12:56:59 -07:00
Bryan Qiu 46adc92336 chore: track the isolated dev-stack scripts the test registry references
Co-authored-by: Isaac
2026-08-01 12:51:51 -07:00
Bryan Qiu b203b51ff0 docs: PR rewrite plan — cut list, commit series, CLI integration
Co-authored-by: Isaac
2026-08-01 12:39:00 -07:00
Bryan Qiu b10a7239ad fix(cli): resolve the claude agent name from harness_plugins on this branch
Co-authored-by: Isaac
2026-08-01 12:38:46 -07:00
Bryan Qiu 8d7c9cb2fa feat(cli): route the model (and harness) before a native TUI launch
Smart Routing was web-only: a CLI user who wanted the server to pick a
model had to start the session in the browser. Add the two launch surfaces
Bryan asked for, both of which route *before* anything starts — the harness
pick is physical (a session is a live claude/codex process) and the model is
applied as a launch flag, so there is nothing to change after the fact.

- `omnigent claude|codex --smart-routing -p "<prompt>"` and
  `run --harness <native> --smart-routing -p ...` route the model and keep
  the requested harness.
- `omnigent run --smart-routing -p "<prompt>"` (no --harness, or
  `--harness auto`) routes harness *and* model, then launches that wrapper.

One session, routed at create: the CLI creates it through the standard JSON
`POST /v1/sessions` (bound to the host it will run on, whose model options
are the router's candidate catalog) and the wrapper ATTACHES to it instead
of bundling its own. The row the server writes already carries the agent
binding, the wrapper's presentation labels, the routed model and the
decision card, so a routed CLI launch gets the same chip and provenance the
web UI does. The resolved harness is read from `SessionResponse.harness`;
native rows leave `harness_override` null on purpose.

`--smart-routing` requires `-p`: routing needs text, and the degraded
route-on-turn-2 mode is not shipping, so an empty invocation is a usage
error pointing at `-p` or the web UI. It also rejects an AGENT, the
REPL-only flags, and `--resume`/`--continue` (routing is a create-time
decision, so a routed launch is always a new session). Preflight
(`smart_routing_enabled` plus the host's per-harness `gateway_inference`)
is a hard error naming the reason, because a routed model the pane cannot
reach is worse than no pick; the create itself always fails open — the
wrapper then starts a plain session behind one notice line.

`omnigent claude` also gains `-p`, and claude/codex now accept a prompt
through `run --harness <native> -p` instead of rejecting it. The prompt
travels as argv (Claude Code's positional prompt; Codex keeps its existing
first-turn delivery), so multi-line prompts survive intact.

Co-authored-by: Isaac
2026-08-01 12:35:13 -07:00
Bryan Qiu 6f2893d921 merge: create-time model routing for fixed native harnesses (server half)
Co-authored-by: Isaac
2026-08-01 12:34:29 -07:00
Bryan Qiu ff9edfc616 docs: track the routing design docs again
Re-adds the plan (with the decision log), the test registry, the
enumerated CUJ walkthrough, and the codex model-state notes, all
current as of the post-verification state.

Co-authored-by: Isaac
2026-08-01 12:31:42 -07:00
Bryan Qiu 907f8886a9 fix(routing): apply the glm arm under the gateway's model route
The task_v1 codex arm `glm-5-2` resolved to the catalog's
`databricks-glm-5-2`, which the codex turn then failed to serve: that
serving endpoint advertises chat-completions only and 400s on
`/codex/v1`. Probes on staging and prod (2026-08-01) show the Responses
API does serve GLM — but only under the gateway model route
`system.ai.glm-5-2`. GLM appears in no discovery listing, so the working
name can only be pinned, not discovered.

Add a per-model servable-alias map next to the arm tables and consult it
when an arm resolves to a servable id, so the codex apply layer writes
`system.ai.glm-5-2`. Subagent candidates are offered under the same
spelling, so a rewrite spawns with the id routing resolves to. The
router's arm id stays `glm-5-2`, and the alias strips to the same bare id
so decision records show no substitution.

Co-authored-by: Isaac
2026-08-01 12:10:13 -07:00
Bryan Qiu 6d4cb367c7 fix(routing): clear the codex spawn audit per launch too
Same staleness class as the canary (51e36c8c): the audit is reconciled
against the routing decisions *this* launch's endpoint relayed, so a line
left by a previous launch — whose approving decision lives in that
launch's router — reads as a spawn the router never approved. The
per-workspace bridge dir is reused across launches, so `clear_bridge_state`
now drops the audit alongside the canary.

Co-authored-by: Isaac
2026-07-31 15:35:26 -07:00
Bryan Qiu 51e36c8c01 fix(routing): gate the codex canary check on a real turn, clear it per launch
`subagent_routing_unenforced` was posted on codex-native sessions whose
routing hooks were in fact trusted and running. Codex dispatches
`SessionStart` (the canary) when a thread's *first turn* begins, but the
enforcement watcher's first-turn gate was released by any
`thread/status/changed → active` or `item/*` event — and the MCP startup
round activates the thread and emits items without running a turn. So a
session that had not been asked anything yet (or whose first turn was
interrupted before it started) failed the canary check 30s later. Live
evidence (session e6074fb1...): thread activated by the MCP startup round
at 13:58:06, warning posted at 13:58:36, and the canary file for that same
session/app-server finally appeared at 14:01:36 when a real turn ran —
proving the hooks were trusted and effective. The stale warning stuck only
because the runner was stopped before the repair tick.

Direct probes against `codex app-server` (isolated CODEX_HOME) also
disprove the "codex captures hook trust at process start" theory: trust
written after the spawn (the shipped ordering) takes effect, even for a
turn already in flight when `config/batchWrite` lands. The real invariant
is that trust must land before the first *turn*, which `start()` already
guarantees — now written down where it can be broken.

Second fix: the canary is the proof that *this* launch's hooks ran, so
`clear_bridge_state` now drops it. The per-workspace bridge dir is reused
across launches, and a canary left by an earlier launch masked a genuine
fail-open for the rest of the session. Transition-only posting still
clears a previous launch's warning on the new forwarder's first check.

Co-authored-by: Isaac
2026-07-31 15:33:32 -07:00
Bryan Qiu 60b39177e7 fix(web): float the session warning banner over the chat
The session warning strip rendered in-flow between the chat header and
<main>, so a warning arriving mid-session pushed the whole conversation
down. Render it as an overlay instead, on the same positioning contract
as the chat header: anchored inside the chat column, below the header,
stopping short of the workspace panel via --workspace-panel-offset, and
transparent to pointer events outside its own rows so the chat stays
scrollable. Multiple warnings stack downward inside the overlay.

Co-authored-by: Isaac
2026-07-31 15:13:19 -07:00
Bryan Qiu e159290239 fix(routing): no substitution arrow for prefix-only subagent raw picks
Co-authored-by: Isaac
2026-07-31 13:48:13 -07:00
Bryan Qiu 3ccf86e383 fix(routing): serve turn routing the launch-exact claude vocabulary
Two claude-path defects from the live verification round.

Turn-1 routing on a claude-native pane could substitute the routed arm.
`_native_turn_catalog` read `_model_options_cache` without consulting
`_model_options_stale`, so a catalog hydrated from the session's *host*
before launch (whose family aliases carry the workspace default) became
the offered vocabulary. With the launch pinning `opus ->
databricks-claude-opus-4-8` and turn 1 routing ~100ms later, the pinned
arm had no spelling on offer and the router substituted sonnet. Turn
routing now awaits a refetch from the bound runner's
`claude-model-options` endpoint — which reports the launch-pinned
aliases — whenever the cached entry is stale, and falls back to the
stale catalog when no runner can answer.

Every claude-native turn also 400'd with `invalid beta flag`: the ucode
gateway launch env never set `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`,
and Claude Code 2.1.220 sends three flags the Databricks gateway
rejects (`prompt-caching-scope-2026-01-05`, `advisor-tool-2026-03-01`
and, under `ENABLE_TOOL_SEARCH`, `advanced-tool-use-2025-11-20`), which
fails the whole request. Set the knob on that path too.

Co-authored-by: Isaac
2026-07-31 13:35:50 -07:00
Bryan Qiu 8f3c0c6025 feat(routing): route the model at create time for a fixed native harness
A native terminal launches with the session row and its turns originate in
the TUI, so the server never sees the first message pre-inference — the turn
gate that routes a plain claude/codex session never fires for a CLI-driven
one. Create-time routing existed only on the `harness_override: "auto"` path,
which picks harness AND model.

A create that carries `cost_control_mode_override: "on"`, a non-empty
`smart_routing_message`, and a FIXED native harness (claude-native /
codex-native, via the wrapper agent, `harness_override`, or the spec) now
routes its MODEL during the create: candidates come from the host's
pre-launch catalog for that one harness, the pick is constrained to it, and
the routed id is persisted as `model_override` with the routing-decision
label plus a session-scoped decision record. Fails open — an unconfigured
router, or a pick the harness cannot run, pins nothing and records the
reason, so the session still opens on the CLI's default model.

Session-start cadence is unchanged: the pinned model closes the per-turn gate
exactly as the auto path's create pin does. The branch is skipped for SDK
harnesses (which still route on their first turn), child and sub-agent
sessions, and a create that pinned its own model.

Co-authored-by: Isaac
2026-07-31 12:53:56 -07:00
Bryan Qiu c0b08f6877 docs: keep the routing design docs local-only
The four routing design documents (plan, test registry, CUJ walkthrough,
live model state) stay on disk for local reference but leave version
control — they are working notes, not reviewable deliverables.

Co-authored-by: Isaac
2026-07-30 23:31:26 -07:00
Bryan Qiu 05470a9ce8 docs: stamp the gateway-inference positive half
Co-authored-by: Isaac
2026-07-30 23:31:25 -07:00
Bryan Qiu 592ca5c0e4 docs: rewrite the CUJ walkthrough in simplified technical English
Rewrite designs/CUJ_IMPLEMENTATION.md in ASD-STE100-inspired Simplified
Technical English so every sentence parses one way only: active voice with a
named actor, simple tenses, one statement per sentence, noun clusters of at
most three words, and lists for any sequence of three or more steps. Add a
six-term glossary (arm, seam, pane, rollout, canary, spelling) to the intro.
Remove the hard 80-column wrapping so each paragraph is one soft-wrapped line.

No facts change: every sha citation and every file:line reference is
byte-identical to bc4b6c0.

Co-authored-by: Isaac
2026-07-30 23:30:27 -07:00
Bryan Qiu 2476d1c6ae docs(routing): record the gateway-backed availability decision
Plan §10 gains decision 9 (Smart Routing offered only where the apply
layer can work, with the per-surface rule and the absent-means-unknown
compatibility contract), and §8 gains the two follow-ups it defers: a
liveness probe, and moving the routes:select call host-side so routing
auth/workspace always matches the host's inference.

CUJ_STATUS gains recipe R9 (point a host at a non-AIGW config and assert
the option disappears) plus one pending check row per gated surface.

Co-authored-by: Isaac
2026-07-30 23:21:59 -07:00
Bryan Qiu c393842df2 feat(routing): offer Smart Routing only where the apply layer can work
Smart Routing rewrites a launch's model through the Databricks AI Gateway,
so a host whose claude-native or codex inference resolves anywhere else
(Bedrock, a plain API key, the vendor CLI's own login) got an option that
could never take effect. Gate each surface on the fact that decides it.

The host already resolves this at launch, so reuse those resolutions as a
cheap config-only check — no process launch, no network — and report a
`gateway_inference` map alongside `configured_harnesses` on registration
and every readiness refresh. It rides the host frames into the store and
out through GET /v1/hosts. A host that never reports it sends `null`, and
`null` means unknown: nothing is gated away on older host builds.

Web gates the three surfaces independently, classified in the single
`smartRoutingAvailability` point as a new `not-gateway-backed` cause:
Configure Claude Code's Model row needs the claude family, Configure
Codex's needs the codex family, and the top-level Smart Routing harness
row needs both (it drives the five-arm menu).

Co-authored-by: Isaac
2026-07-30 23:21:49 -07:00
Bryan Qiu bc4b6c0604 docs: re-sync CUJ walkthrough with the rebased tree
Co-authored-by: Isaac
2026-07-30 23:11:32 -07:00
Bryan Qiu 7247de4cf2 docs: registry stamps — rebased-tree battery green, session-start verified live
Co-authored-by: Isaac
2026-07-30 22:30:26 -07:00
Bryan Qiu b7c894a734 docs(routing): record the routing design and verification state
Captures the plan the implementation followed, the per-CUJ verification
status, and the observed live-model state the harness bar list is derived
from — the gateway rejections that catalog metadata does not advertise.

Co-authored-by: Isaac
2026-07-30 22:15:41 -07:00
Bryan Qiu c0a867ce46 test(routing): cover the routing apply layer end to end
Adds the remaining routing coverage: the CLI's routing-client build, the
native Smart Routing create path, an end-to-end routing integration test,
and the discovery/override unit tests. Also updates the existing native
bridge, forwarder, and launch-arg tests for the model-override plumbing.

Co-authored-by: Isaac
2026-07-30 22:15:41 -07:00
Bryan Qiu 890d98e5df feat(web): surface routing decisions and Smart Routing controls
Adds the Smart Routing harness option to new-chat, a routing chip that
shows the routed model on the session, a sub-agent routing row, and a
warning banner for the non-fatal routing conditions the server reports.

The chip reports what actually happened. When a decision could not be
applied it says so and names the model in use, instead of showing the
router's request as though it were the outcome.

Co-authored-by: Isaac
2026-07-30 22:08:36 -07:00
Bryan Qiu 627a61f585 feat(routing): route sub-agent spawns from harness hooks
Sub-agents spawned by a native CLI never pass through the server's
session-create path, so they were unroutable. Adds hook scripts the
Claude and Codex CLIs invoke at spawn time, plus a runner-side router
that answers them, so a spawned child is routed on its own task text and
launched on the chosen model.

A child is only ever offered its parent's harness family: routing may
change which model a sub-agent runs, never which vendor it belongs to.
Hook commands run under ``python -I`` so a repo-local module on the CLI's
cwd cannot shadow the interpreter's own imports.

Co-authored-by: Isaac
2026-07-30 22:06:23 -07:00
Bryan Qiu 0534ac22a0 feat(codex): apply a routed model to Codex
The Codex side of the apply layer: the native app server and executor
accept a routed model override and enforce it on the session they launch,
so a verdict that names a GLM/Kimi delegate arm reaches the CLI instead
of being dropped for the harness default.

Codex spawns with no routable signal skip the router outright rather
than routing on an empty prompt and recording a decision nobody asked
for.

Co-authored-by: Isaac
2026-07-30 22:06:23 -07:00
Bryan Qiu b0c311324d feat(claude): apply a routed model to Claude Code
A routed arm only matters if the harness actually runs it. Adds a Claude
model vocabulary that maps between router arm ids, catalog spellings, and
the ``/model`` names Claude Code accepts, and pins the CLI's family
aliases to the frozen task_v1 Claude arms at launch so the first turn's
switch can reach whatever the router picked.

The vocabulary reads its catalog prefixes from one definition shared with
the server seam, so the hook path — which cannot read server config —
cannot drift from it.

Co-authored-by: Isaac
2026-07-30 22:06:23 -07:00
Bryan Qiu 6dd7d6cd1a feat(server): route sessions at start and expose the decision
Wires the routing core into session lifecycle. A session created in
Smart Routing mode is routed once, at start, from the first user message:
the verdict picks the harness and the model before the runner launches,
and pre-launch host model options supply the candidate catalog when no
runner exists yet. Later turns never re-route — a session's harness is
settled once so a conversation cannot change identity underneath the
user.

The decision is exposed on the session snapshot and event stream with
its applied state, so the UI can distinguish "the router picked X and we
are running X" from "the router picked X and we could not apply it",
rather than silently showing the request as the outcome.

Co-authored-by: Isaac
2026-07-30 22:06:23 -07:00
Bryan Qiu 80d3bcc7d7 feat(routing): session-start smart routing core
Adds the server-side routing core behind Smart Routing: an external
``task_v1`` route-options seam that offers the router the frozen arm menu
its scenario requires, maps a pick back onto a servable catalog id via
nearest-cost substitution, and derives the harness that can actually run
it. Routing settings become one value object on ``RuntimeCaps`` so every
consumer reads the same knobs instead of re-parsing config. Databricks
model discovery resolves catalog spellings deterministically so the same
endpoint is named the same way on every path.

Reconciled against main's catalog-driven routing:

- Main's ``_fetch_runner_catalog`` / ``_RunnerModel`` plumbing and its
  cost-tier ordering are the single source of live model availability;
  ``fetch_runner_models`` remains the id-only adapter over it.
- Main's ``ModelIntent``-parameterized judge rubric replaces the
  family-specific tier hints.
- Main's catalog wire-API check survives as
  ``_redirect_wire_incompatible_pick``, layered after the static
  ``_HARNESS_EXCLUDED_MODELS`` bar list. The two cover different things:
  the catalog knows what an endpoint advertises, the bar list knows the
  client-side rejections it does not.
- ``model_family_token`` defers to ``is_codex_compatible_model`` so the
  GLM/Kimi delegate arms read as the codex family everywhere.

The static ``MODEL_LISTS`` table is retained, unlike main, because the
nearest-cost substitution needs a family cost ordering on paths with no
catalog in reach (hook scripts, pre-session creates).

Co-authored-by: Isaac
2026-07-30 22:06:23 -07:00
Bryan Qiu c8c061525f feat(sessions): persist routing decisions and session warnings
A routing decision has to survive the turn that produced it, so the UI
can show what the router chose and — crucially — whether it was actually
applied. Adds ``RoutingDecisionData`` to the conversation entity with
store support, and a ``session_warnings`` module for the non-fatal
routing conditions a session needs to surface (router unreachable,
verdict not applied) without failing the turn.

Records are honest by construction: a decision that could not be applied
is stored with ``applied=false`` and its reason rather than being
dropped or reported as a success.

Co-authored-by: Isaac
2026-07-30 21:50:57 -07:00
Bryan Qiu 7d1cc46df7 feat(telemetry): routing decision and setting-change events
Routing needs to be answerable after the fact: which arm the router
picked, whether it was applied, and what the user changed. Adds
``RoutingDecisionEvent`` and ``RoutingSettingChangedEvent`` plus a
``model_labels`` helper that reduces a model id to a family/tier pair, so
records stay useful without carrying raw model ids.

Co-authored-by: Isaac
2026-07-30 21:50:41 -07:00
156 changed files with 26864 additions and 1176 deletions
+1
View File
@@ -85,3 +85,4 @@ omnigent/server/static/web-ui/
# reason — `bundle deploy` must be able to sync it to the app source folder.
# DAB local state directory (created by `databricks bundle deploy`).
deploy/databricks/.databricks/
web/package-lock.json
+337
View File
@@ -0,0 +1,337 @@
# Local setup for the routing MVP stack
How to bring up the isolated dev stack that `CUJ_STATUS.md` §1 recipe **R0**
assumes. Do this once per machine. Everything here runs against a private config
home inside the worktree, so your real `~/.omnigent` is never touched.
## 1. Prerequisites
| Tool | Version used | Notes |
| --- | --- | --- |
| `uv` | 0.12.1 | Manages Python. `pyproject.toml` requires Python >= 3.12. |
| `node` | v24.14.0 | Any recent LTS works. `run-frontend.sh` finds it. |
| `pnpm` | 11.15.1 | Pinned by `packageManager` in `package.json`. **Not npm.** |
| `databricks` | 1.2.1 | Needed for the OAuth profile the router uses. |
| `sqlite3` | any | Recipe **R1** reads the chat DB with it. |
| `tmux` | any | Recipe **R2** captures the claude pane through it. |
On macOS: `brew install uv pnpm node sqlite tmux databricks`.
## 2. Install dependencies
```sh
git clone git@github.com:omnigent-ai/omnigent.git
cd omnigent
git checkout routing-mvp
uv sync # the run-*.sh scripts pass --no-sync, so do this first
cd web && pnpm install && cd ..
```
`uv sync` rewrites `uv.lock` with registry churn on some machines. Run
`git checkout -- uv.lock` before committing if it does.
## 3. Authenticate
The router calls staging AI Gateway through a Databricks CLI profile:
```sh
databricks auth login --profile eng-ml-agent-platform
```
This is interactive, so run it in your own terminal. The session's model
catalogs come from the same workspace. If the profile lapses, the router
returns 403 and every routing decision fails open — the sessions still run,
they just do not route.
## 4. Create the isolated config
`.omnigent-local/` is gitignored, so a fresh clone has no config and
`run-server.sh` exits immediately. Create it:
```sh
mkdir -p .omnigent-local
cat > .omnigent-local/config.yaml <<'YAML'
# Isolated config for the routing-MVP worktree. Loaded via
# OMNIGENT_CONFIG_HOME=<worktree>/.omnigent-local so the global
# ~/.omnigent is never touched.
providers:
eng-ml-agent-platform:
default: true
kind: databricks
profile: eng-ml-agent-platform
routing:
provider: external
base_url: https://eng-ml-agent-platform.staging.cloud.databricks.com/ai-gateway/routing/v1
router_name: task_v1
model_prefix:
- databricks-
- system.ai.
YAML
```
Two details matter. `system.ai.` **keeps its trailing dot** — without it the
code strips ids to `.claude-opus-5` and sends malformed names to the router.
And `router_name` must be `task_v1`, because the arm menus in the code are that
router version's wire contract.
## 5. Start the stack
Three terminals, from the worktree root:
```sh
./run-server.sh # :6868
./run-host.sh # host daemon, registers against the server
./run-frontend.sh # :5273
```
All three source `dev-env.sh`, which pins `OMNIGENT_CONFIG_HOME` and
`OMNIGENT_DATA_DIR` into `.omnigent-local/`. Never start these with your global
`omni` — it writes to the real `~/.omnigent`.
## 6. Confirm it works
```sh
# server is listening and the host registered
curl -s localhost:6868/v1/hosts | head -c 400
# the router answers (recipe R6)
./scripts/probe_routing_api.sh
```
A healthy host reports `gateway_inference` true for the claude and codex
families. If it reports false, Smart Routing is correctly hidden in the UI —
that is the gate in plan block `3f`, not a bug.
Then open `http://localhost:5273`.
## 7. Known local quirks
- **nvm's shell shim breaks in non-interactive shells** (`_load_nvm` FUNCNEST).
`run-frontend.sh` works around it by resolving a real `node` binary. If you
invoke node tooling by hand and it fails this way, use an absolute path.
- **`uv run` inside a worktree rewrites `uv.lock`.** Check it out before
committing.
- **Pre-existing test failures are normal**: ~10 in `tests/cli`, and a set of
Linux-only sandbox tests (bwrap/seccomp), AF_UNIX path length, tmux socket,
and tty styling that fail on macOS. Compare failure counts against a clean
main before blaming a change.
- **The `tests/e2e_ui` Playwright suite leaks seeded workspace files** into the
main checkout root. Clean up after local runs.
- **`web` type-check has many pre-existing errors** repo-wide; the CI check for
it is off. Do not treat them as yours.
## 8. Tearing down
```sh
uv run --no-sync omni host stop # or kill the run-host.sh process
# then kill run-server.sh and run-frontend.sh
lsof -ti:6868,5273 | xargs -r kill # confirm nothing is still listening
```
Test sessions spawn real agents that do real work in whatever workspace they
are pointed at. Check `git status` in that workspace before and after a matrix
run.
## 9. Personal CLI setup (for moving machines)
Everything above is what the *repo* needs. This section is the personal Claude
Code and Codex configuration that lives in `$HOME` — none of it is in this
directory, which is why a fresh clone alone does not reproduce the environment.
Replace `<user>` with your macOS username in any absolute path.
### 9.1 What this directory actually contributes
Nothing personal. It carries `CLAUDE.md` (a symlink to `AGENTS.md`) and
`.claude/skills/` (eight tracked harness e2e skills). There is no
`.claude/settings.json`, no `.mcp.json`, and no `.envrc`. The only untracked
local state is `.omnigent-local/` (§4) and the staged prompts in `/tmp/p_*.txt`.
### 9.2 Claude Code
Three files are personal and worth copying verbatim:
- `~/.claude/settings.json` — see below.
- `~/.claude/CLAUDE.md` — global agent instructions.
- `~/.claude/keybindings.json` — key bindings.
```json
{
"env": { "CLAUDE_CODE_NO_FLICKER": "1", "CLAUDE_CODE_SCROLL_SPEED": "5" },
"permissions": {
"allow": ["Read","Write","Edit","Glob","Grep","Agent","WebFetch","WebSearch",
"NotebookEdit","Bash","mcp__github__github_read_api_call",
"mcp__github__github_write_api_call","Edit(.claude/**)","Write(.claude/**)",
"Edit(**/CLAUDE.md)","Write(**/CLAUDE.md)","Edit(**/AGENTS.md)","Write(**/AGENTS.md)"],
"defaultMode": "dontAsk"
},
"model": "claude-fable-5[1m]",
"alwaysThinkingEnabled": true,
"effortLevel": "medium",
"theme": "light-daltonized",
"switchModelsOnFlag": true,
"teammateMode": "in-process",
"remoteControlAtStartup": true,
"agentPushNotifEnabled": true,
"skipAutoPermissionPrompt": true,
"useAutoModeDuringPlan": false,
"voiceEnabled": true,
"autoUpdater": { "enabled": true },
"statusLine": {
"type": "command",
"command": "bash /Users/<user>/.cache/spec-driven-development/statusline/wrapper.sh"
},
"enabledPlugins": {
"plugin-builder@experimental-plugin-marketplace": true,
"lite@experimental-plugin-marketplace": true,
"liteswap@experimental-plugin-marketplace": true,
"codex@openai-codex": true,
"security-review-assistant@production-plugin-marketplace": true,
"dev-productivity@experimental-plugin-marketplace": true,
"swift-lsp@claude-plugins-official": true,
"debug-copilot@experimental-plugin-marketplace": true,
"spec-driven-development@experimental-plugin-marketplace": true,
"session-report@experimental-plugin-marketplace": true
},
"extraKnownMarketplaces": {
"openai-codex": { "source": { "source": "github", "repo": "openai/codex-plugin-cc" } }
}
}
```
Do **not** hand-copy `/Library/Application Support/ClaudeCode/managed-settings.json`.
It is root-owned, the Databricks install writes it, and it carries only OTEL and
telemetry env — no `apiKeyHelper` and no `ANTHROPIC_BASE_URL`.
Auth comes from `~/.config/llm-cli/config.json`:
```json
{ "claude_code_proxy_mode": "model-serving", "claude_code_power_user": false,
"preferred_agent": "claude_code", "mcp_safe_auto_enabled": true,
"mcp_web_search_auto_enabled": true }
```
The bearer token lives in `~/.databricks/model-serving-token.json` and an
isaac-managed `UserPromptSubmit` hook
(`~/.config/llm-cli/hooks/refresh_model_serving_token.sh`) refreshes it when it
nears expiry. Binary: `~/.local/bin/claude`, version 2.1.220.
### 9.3 Codex
`~/.codex/config.toml`, minus the machine-local blocks:
```toml
model_provider = "Databricks"
model = "gpt-5.6-sol"
model_reasoning_effort = "xhigh"
project_doc_fallback_filenames = ["CLAUDE.md", "COPILOT.md"]
[model_providers.Databricks]
name = "Databricks AI Gateway"
base_url = "https://1965859176160743.ai-gateway.cloud.databricks.com/codex/v1"
wire_api = "responses"
[model_providers.Databricks.auth]
command = "jq"
args = ["-r", ".access_token", "/Users/<user>/.databricks/model-serving-token.json"]
timeout_ms = 5000
refresh_interval_ms = 1500000
[model_providers.Databricks.http_headers]
Databricks-Ai-Gateway-Request-Tags = "{\"source\": \"isaac-cli\"}"
[features]
remote_connections = true
hooks = true
js_repl = false
[profiles.default]
model_provider = "Databricks"
```
Skip the `[otel.*]` and `[marketplaces.*]` blocks: the marketplace entries are
machine-local cache paths that the installer recreates, and the otel blocks
belong to isaac (and hold a token — see §9.4).
`~/.codex/hooks.json` holds five personal hooks, all under
`~/.config/llm-cli/hooks/`:
| Event | Matcher | Script |
| --- | --- | --- |
| `UserPromptSubmit` | — | `handle_prompt_submit.sh`, `refresh_model_serving_token.sh` |
| `PreToolUse` | `^Bash$` | `guard_high_risk_commands.sh` |
| `PreToolUse` | — | `handle_secrets_pre_tool_use.sh` |
| `PreToolUse` | `^mcp__toolproxy__run_command$` | `guard_toolproxy_commands.sh` |
| `PostToolUse` | — | `handle_secrets_post_tool_use.sh` |
These matter for the routing work: they are the user-owned half that Omnigent's
generated `hooks.json` has to merge with in one atomic write.
Binary: `codex-cli 0.145.0` from `~/.nvm/versions/node/v24.14.0/bin/codex`. Keep
that version — 0.145 refuses to load a config that contains `wire_api = "chat"`.
### 9.4 Secrets — move these out of band
Never copy these through git or a chat window:
1. `~/.databricks/model-serving-token.json` — OAuth access plus refresh token.
Regenerate on the new machine instead of copying.
2. A live `dapi…` PAT sits in the `[otel.*.headers]` blocks of
`~/.codex/config.toml`. Let isaac rewrite those blocks rather than copying
the file whole.
### 9.5 Provider topology: three different workspaces
This is the part that is easy to misread. The global Omnigent config and this
worktree's config point somewhere different, and only one of them is
gateway-backed.
`~/.omnigent/config.yaml` (the global host, server on `:8000`):
| Provider | Kind | Resolves to |
| --- | --- | --- |
| `claude` (**default**) | `subscription` | the Claude subscription login — no gateway |
| `codex-databricks` | `cli-config` | defers to `~/.codex/config.toml` → AIGW workspace `1965859176160743` |
| `isaac-databricks-ai-gateway` | `gateway` | AIGW workspace `dbc-a5d4177a-49dc`, `/ai-gateway/anthropic`, default model `system.ai.claude-opus-4-8[1m]` |
`.omnigent-local/config.yaml` (this worktree, server on `:6868`): one provider,
`eng-ml-agent-platform`, `kind: databricks`, `default: true` — staging AIGW —
plus the `routing:` block from §4.
Two things follow. The `/ai-gateway/anthropic` URL in the global config **is**
the AI Gateway, despite "anthropic" in the path; that is the Gateway's
Anthropic-wire route, not `api.anthropic.com`. But that provider is not the
default, so it is not what a launch resolves.
Measured on 2026-08-02 with `omnigent.gateway_inference`:
| Config | claude family | codex family |
| --- | --- | --- |
| global `~/.omnigent` | `False` (config resolves to `None`) | `False` (launch `base_url` is `None`) |
| worktree `.omnigent-local` | `True` | `True` |
So the claude and codex processes that Omnigent spawns during a matrix run in
this worktree are **not** using the personal CLI auth. They are configured by
`.omnigent-local/config.yaml`. The personal setup and the routing stack are
meant to be separate, and the gateway-inference gate reports them differently.
The codex `False` on the global row is a **known false negative**: a
`kind: cli-config` provider makes Omnigent defer to `~/.codex/config.toml`
without resolving a base URL of its own, so the check sees `None` and reports
"not backed" even though that codex install does route through the AI Gateway.
`PR_REWRITE_PLAN.md` block `3f` records it for the rewrite.
### 9.6 Order of operations on a new machine
1. Install isaac. It writes the managed settings, the `~/.config/llm-cli` hooks,
the Codex Databricks provider, and the token refresher.
2. Copy the personal files only: `~/.claude/settings.json`, `~/.claude/CLAUDE.md`,
`~/.claude/keybindings.json`, and your Codex `model`,
`model_reasoning_effort`, and `project_doc_fallback_filenames` preferences.
3. Run `isaac auth login` (or let the refresh hook fire) to mint
`~/.databricks/model-serving-token.json`.
4. Run `databricks auth login --profile eng-ml-agent-platform` — separate from
step 3, and needed only for this worktree's stack.
5. Follow §1–§6 above for the routing stack itself.
+367
View File
@@ -0,0 +1,367 @@
# PR #3506 fix list — Intelligent Routing MVP
Review findings for https://github.com/omnigent-ai/omnigent/pull/3506.
Branch `routing-mvp` (= PR head), base `origin/main`. Single PR — do not split;
fix in place. Suggested execution order at the bottom.
> **STATUS (round 3, 2026-07-31):** the branch was rebased onto new main and
> restructured into an 11-commit stack (merge-base `ca4007b1`). All ROUND 2
> items were re-verified on the rebased tree: everything is FIXED except the
> residuals listed in **ROUND 3** below, plus one new P0 the R2-10 hardening
> introduced. Round-1 fixes all survived the rebase (spot-checked). Line
> numbers in older sections are stale — locate by symbol. **Work ROUND 3
> only**; do not re-fix anything verdict-FIXED in rounds 12.
---
## ROUND 3 — remaining blockers after the rebase (do these)
### R3 P0
**R3-1. pid-liveness probe silently disables ALL subagent routing under the default Linux sandbox**
- `omnigent/inner/hook_scripts/subagent_router.py:199-211` (rejection at `:156-158`); advertisement pid written at `subagent_routing.py:725`
- The advertisement records the *runner's* pid, but the hook runs inside the harness terminal, which is bwrap-wrapped with `--unshare-pid` whenever a spawn-time backend is active (`omnigent/inner/bwrap_sandbox.py:573-585`, `omnigent/inner/terminal.py:1125-1135`) — the platform default on Linux (`omnigent/inner/sandbox.py:936-937`). Inside that PID namespace `os.kill(pid, 0)` raises `ProcessLookupError` → advertisement rejected → every spawn falls open. Invisible: ledger stays empty so `reconcile_spawn_audit` returns `[]`, canary still fires, no warning ever posts. Only signal is one stderr line in the TUI pane.
- Fix: don't let raw pid liveness be authoritative across a namespace boundary. Either record the runner's pid-namespace identity (`os.stat("/proc/self/ns/pid").st_ino`) alongside the pid and skip the `os.kill` probe when the hook's namespace differs, or replace liveness with freshness (per-router `instance_id` + `updated_at` the runner refreshes; stale = dead). Keep "pid present and well-formed" — that part is sound. Add a regression test simulating the namespace case (advertised pid absent from this namespace) asserting routing still resolves.
### R3 P1
**R3-2. R2-2 residual: identity guard misses the real relaunch ordering (reproduced)**
- `omnigent/runner/subagent_routing.py:1131-1142` (guard), `ensure_session_router` handle-reuse at `:1038-1049`; forwarder cancel gives up after 10s (`orchestration.py:156-167`)
- When relaunch happens *before* the stale forwarder's `finally` (the expected case, since cancel is bounded), `ensure_session_router` returns the SAME handle, so the delayed `shutdown_session_router(session_id, old_router)` passes the `is` check and closes the router the new terminal is using. Reproduced: registry `None`, advertisement `None`, r2 closed. Routing dead for the session; `subagent_routing_armed` is False so no warning fires.
- Fix: scope on launch, not object identity — give `SubagentRouter` a `generation` bumped by every `ensure_session_router` call; `shutdown_session_router` compares the caller's captured generation. Extend `test_stale_handle_shutdown_leaves_a_relaunched_router_alive` with the relaunch-before-teardown ordering.
**R3-3. R2-5 residual: a native child's FIRST routed turn still pins a model the pane can't switch to**
- `omnigent/server/routes/_sessions/orchestration.py:4348-4353`
- Gate is `_native_scope == "turn" or ROUTING_DECISION_LABEL_KEY in conv.labels`, skipping the spelling check on a child's first routed turn. The justifying comment ("launch env carries the id") is false: `_ensure_native_terminal_ready` creates the pane at `:4262-4270`, ~60 lines before routing runs at `:4325`, with `model_override` still None (forced-auto child clears it at `:5947+`; create-time pinning is gated `parent_session_id is None`). The pane launches on its default model; the pick can only land via `/model` — exactly the case needing a spelling. `tests/server/integration/test_routing_integration.py:1173` bakes in the wrong assumption and only tests an in-vocabulary first pick.
- Fix: the pane is *always* up by this point — apply `_routed_turn_model_spelling` unconditionally, delete `_native_pane_routed_before`, fix the test docstring, add a case where the child's first pick is outside the cached picker vocabulary.
**R3-4. Stray `web/package-lock.json` (+3,451 lines) — rebase artifact, remove it**
- Committed in `890d98e5`; exists in neither merge-base nor origin/main (deleted by main's pnpm-workspace migration `dc97ade9`). Root pnpm workspace owns deps (`pnpm-workspace.yaml`, `pnpm-lock.yaml` `web:` importer, `packageManager: pnpm@11.15.1`); nothing reads the npm lockfile. It's a supply-chain divergence (`npm install` would bypass the workspace `overrides`/`minimumReleaseAge`/catalog pins) and trips `.github/scripts/security-scan/sensitive-paths.sh:48`.
- Fix: `git rm web/package-lock.json`, fixup into `890d98e5`; confirm `git diff origin/main..HEAD --stat -- web/` no longer lists it.
### R3 P2
**R3-5.** Session/child routing path still climbs cost on pi: `route_session_harness` doesn't prefilter `_HARNESS_EXCLUDED_MODELS` (deliberate — whole menu for the router) but `substitute_model`'s same-family constraint turns a haiku pick into sonnet-4-6 while gpt-5-4-nano is servable (`smart_routing.py:1532-1572` vs `:1722-1739`, `:1609-1620`). Allow cross-family fallback within the harness's servable set when same-family has nothing at-or-below the pick's cost position, or document the one-tier cost acceptance.
**R3-6.** `_parse_model_prefixes` returns `[]` (honored as "no prefix") for malformed lists like `[123]`/`[""]`, contradicting its docstring's promise of `None` (`cli.py:103-109`, honored at `:140`) — return `None` when a non-empty list yields no usable strings.
**R3-7.** Native launch failure paths leak the router until session close — start at `orchestration.py:3735-3740` precedes app-server/event-client/terminal failures at `:3765-3767`, `:3873-3877` which never call `_shutdown_session_router_async`; same shape on claude-native before `_forwarder_task`. Add teardown to those excepts. Also correct the "this is its only teardown" comment at `app.py:3106-3108` (it's the generic path for every harness).
**R3-8.** SDK hook timeout equals the request timeout it wraps — `claude_sdk_executor.py:1953-1959` sets `HookMatcher(timeout=REQUEST_TIMEOUT_S)` (30 == 30), the inversion item 20 fixed elsewhere; use `REQUEST_TIMEOUT_S + 10`. Note `HookMatcher.timeout` doesn't exist at the `claude-agent-sdk>=0.1.62` floor — old SDK resolution would TypeError; bump the floor or guard.
**R3-9.** Failed warning POST recorded as posted — `codex_native_forwarder.py:5872-5873` advances the `posted` sentinel even when `_post_session_event` swallowed a non-2xx; a transient 500 on the transition tick loses the warning until the state changes again. Only advance on success.
**R3-10.** Same-task-name spawns union their approved models (`codex_executor.py:1203-1205`, `:1227-1229`) — either audit record matches either approval; join on `agent_id` where codex supplies one, or document the fail-quiet choice.
**R3-11.** `route_subagent_hook` authorizes at `LEVEL_READ` while persisting transcript items + telemetry (`routes_hooks.py:1349-1352`) — confirm parity with other hook relays or raise to `LEVEL_EDIT`.
**R3-12.** Warning-banner poll polish (web): gate `refetchIntervalMs` on `serverInfo.smart_routing_enabled` (`AppShell.tsx:317`); don't latch the poll off permanently after 2 transient 404s when the query previously succeeded (`useSession.ts:32,111-114`).
**R3-13.** `save()` swallows a rejected `setModel` and silently drops the sub-agent-routing PATCH, closing the modal regardless (`ChatPage.tsx:5792-5823`) — separate try for the order-independent PATCH or surface a toast.
**R3-14.** `SUBAGENT_ROUTING_HARNESSES` literal set duplicates ids owned elsewhere and omits `AUTO_NATIVE_HARNESS_ID` (`CostRoutingControl.tsx:24-30`) — derive from `SMART_ROUTING_ARMS` + sentinels.
**R3-15.** `handleSelectSmartRoutingHarness` clears the dropped-notice before its `placeholder == null` early return (`NewChatDialog.tsx`) — move the return first.
**R3-16.** Stale prose: `cli.py:242-244` docstring still cites `scenario_menus`; `sessionsApi.ts:319-321` comment claims a wire distinction (`undefined` vs `[]`) the server (`schemas.py:1888`, `default_factory=list`) never produces; `INTELLIGENT_ROUTING_PLAN.md` still describes deleted knobs as live (defensible for a plan doc); ~13 four/five-line comment blocks remain in `smart_routing.py` (worst in-function offenders `:707-710`, `:1519-1523`, `:1594-1597`).
**R3-17.** PR body: "Later turns never re-route" is false for child sessions (they route every spawn by design — qualify the sentence); Demo section still unfilled on a UI-flagged PR.
---
## ROUND 2 — new/residual findings after the fix commits (do these)
### R2 P0
**R2-1. `substitute_model` fallback escalates to the most expensive model — cost routing inverted**
- `omnigent/server/smart_routing.py:704-710`; `_ARM_SUBSTITUTES` at `:543-569`; exclusion table `:648`
- When a barred/unservable pick has no `_ARM_SUBSTITUTES` chain entry, the fallback is `same_family[-1]` — the *most capable* candidate. Two live repros: (a) pi turn path: `databricks-claude-haiku-4-5` is in both `MODEL_LISTS["pi"]` and `_HARNESS_EXCLUDED_MODELS["pi"]`, so a SIMPLE-task haiku pick substitutes to **`databricks-claude-opus-4-8`** on every simple pi turn. (b) codex-native/non-Databricks panes: picker rows use dot spellings (`gpt-5.6-sol`) while `_ARM_SUBSTITUTES` keys use dashes, so `_local_id` misses and picks `gpt-5-6-luna`/`gpt-5-6-sol`/`glm-5-2` **all** collapse to the priciest row; the router is also offered a duplicated menu (`gpt-5.6-luna` and `gpt-5-6-luna`).
- Fix: (a) fallback picks the *nearest* candidate (walk from the pick's position / bias downward), never blanket `[-1]`; (b) prefilter `_HARNESS_EXCLUDED_MODELS` out of the curated candidate list in `route_turn` before offering (haiku should never be offered to pi); (c) normalize dots→dashes in `_bare_id` (or key `_ARM_SUBSTITUTES` on `normalized_model_id`) so picker spellings match arm ids, and dedupe the offered menu. Add a regression test: "cheapest barred pick must not become the most expensive candidate."
### R2 P1
**R2-2. `shutdown_session_router` not identity-scoped — teardown races relaunch and kills the live router**
- `omnigent/runner/subagent_routing.py:1095-1102`; new call sites `orchestration.py:4034`, `:4081`; `_cancel_auto_forwarder_task` gives up after 10s (`orchestration.py:155-161`)
- On terminal re-create, the old forwarder's delayed `finally` pops and closes the **new** router; advertisement stays on disk so `subagent_routing_armed` reports armed and no warning fires — routing silently dead for the session.
- Fix: `shutdown_session_router(session_id, router=None)`; inside the lock, return early if `_session_routers.get(session_id) is not router`. Thread the handle from `_start_subagent_router_for_native_session` to `_shutdown_session_router_async`.
**R2-3. `router_dir_for_session` can raise `RuntimeError` out of session init → 500 for every SDK harness**
- `omnigent/runner/subagent_routing.py:1120-1139` (raises via `ensure_secure_dir`); call outside the guard at `omnigent/runner/app.py:9800-9807`; `ensure_session_router_quietly` only catches `OSError` (`:1076`)
- A pre-existing wrong-uid/symlinked `$TMPDIR/omnigent-<uid>` breaks session creation for *all* SDK harnesses incl. pi/copilot/goose that can't use routing at all.
- Fix: move dir resolution inside the guard; catch `(OSError, RuntimeError)`; skip the router start entirely unless `harness` is in `_CLAUDE_HOOK_HARNESSES | _CODEX_HOOK_HARNESSES` (no bearer-token endpoint for harnesses that get `{}` env).
**R2-4. Unified "unparseable version = supported" policy emits an unknown CLI flag on old codex**
- `omnigent/runner/native/orchestration.py:3809-3812` feeds `bypass_hook_trust``--dangerously-bypass-hook-trust` in TUI argv (`codex_native_app_server.py:2141-2142`); flag doesn't exist below codex 0.131; `_codex_cli_version` returns `None` on transient probe failure too (`codex_executor.py:361-374`)
- A probe hiccup on old codex = dead terminal at argv parse (was: recoverable trust prompt).
- Fix: keep "None = supported" for the hooks-file gate (cheap, caught downstream) but require a positively-parsed version for the argv flag; note inline why the two gates differ.
**R2-5. Item-8 hole: native *child* sessions still pin a model the pane can't switch to**
- `orchestration.py:4296-4301``_native_applied_model` only consults `_routed_turn_model_spelling` when `_native_scope == "turn"`; children route every turn (`:4280-4282`) and their 2nd+ turn is a mid-turn `/model` on a running pane.
- Fix: apply the spelling check whenever the pane is already running (key on "first turn of the pane", not decision scope).
**R2-6. `_redirect_incompatible_pick` lets a child escape the parent's harness family**
- `omnigent/server/smart_routing.py:1365-1371`, `:759-781` — hardcodes `"claude-sdk"`/`"codex"` escape hatches with no membership check against the offered candidate set; verified `allowed_family="pi"` returning `harness="codex"`, persisted as the child's `harness_override` (`orchestration.py:3912-3935`).
- Fix: pass the candidate set in; return `None` (decline) rather than a non-candidate harness, or substitute the model instead of the harness when the harness is fixed.
**R2-7. Prompt paraphrase still reaches INFO via the rationale**
- `omnigent/server/smart_routing.py:1380-1385`, `:1484-1488` log `rationale=%s` at INFO; the judge prompt (`:316-319`) tells the model to embed a task-derived reason; the new comment at `:387-388` says to keep exactly this off INFO.
- Fix: model/harness at INFO, rationale at DEBUG, both entry points.
**R2-8. `WARNING_TITLES` prototype-chain lookup can crash the session header (web)**
- `web/src/shell/SessionWarningBanner.tsx:35,41``warning.code in WARNING_TITLES` matches inherited keys; `{code:"__proto__"}` throws during render (verified); `{code:"toString"}` passes the filter.
- Fix: `Object.hasOwn` in the filter; build the record via `Object.create(null)` or a `Map`; drop the `!` for a guarded lookup.
**R2-9. 30s poll reuses `refresh_state=true`, thrashing runner caches (web+server)**
- `web/src/hooks/useSession.ts:63,69` + `AppShell.tsx:321-323` — every poll pops `_runner_skills_cache`/`_model_options_cache` (`helpers.py:3652-3657`) and returns empty `model_options`/`skills` (refill is fire-and-forget); two extra runner round-trips per 30s per open session, forever; poll never stops on a deleted/404 session.
- Fix: `refreshState` only on the initial fetch (`state.data === undefined`) or a flag on `UseSessionOptions`; stop polling after repeated 404s. (Better long-term: publish a session-stream event from `session_warnings.record/clear` and invalidate instead of polling.)
### R2 P2
**R2-10.** `_advertiser_alive` returns True when `pid` is missing/non-int (`subagent_router.py:177-178`) — hostile advertisement just omits it; runner always writes pid now, so require it. Add an "advisory only, same-uid agents can spoof" note near `_LOOPBACK_HOSTS`. Also print a stderr diagnostic on both rejection branches (`:149-152`), and note `os.kill` fails under `--unshare-pid` sandboxes.
**R2-11.** Empty warning post clears codes the publisher doesn't own (`routes_events.py:738-744`); relay clear (`routes_hooks.py:1235`) also wipes the "spawned on unapproved model" audit warning. Scope clears to the codes the publisher checked.
**R2-12.** Enforcement watcher now POSTs an empty warning list every 30s per healthy session (`codex_native_forwarder.py:5754-5757`, `:5847-5860`) — post only on transition.
**R2-13.** `reconcile_spawn_audit` all-or-nothing escape (`codex_executor.py:1218-1219`): a session mixing routed and unrouted spawns (routing toggled off mid-session / router outage) flags every inherited-model spawn. Reconcile per record via the ledger's agent_id/task_name.
**R2-14.** `SubagentRouter.close()` joins in-flight handlers up to 20s (`subagent_routing.py:784-785`) — set `httpd.daemon_threads = True` at construction (`:819`); this also narrows R2-2's race window.
**R2-15.** Third hardcoded prefix list: `claude_model_vocabulary._CATALOG_PREFIXES:59` duplicates `smart_routing.MODEL_ID_PREFIXES:503` and ignores configured `model_prefix` — cross-reference comments at both sites + an equality test. Related: `harness_bars_model`/`_redirect_incompatible_pick`/raw-model checks use default prefixes not `routing_settings().model_prefixes` (`smart_routing.py:755`, `:772`, `:657`, `:1377`, `:1490`); and `cli.py:130` `prefixes or MODEL_ID_PREFIXES` makes explicit-empty fall back silently.
**R2-16.** Non-ASCII `Authorization` header raises `TypeError` out of `do_POST` (`subagent_routing.py:847-848`) — compare bytes or wrap.
**R2-17.** `write_advertisement` fixed `.tmp` name + unlink/O_EXCL interleave is latent-racy (`subagent_routing.py:721`, `:732-734`) — use `tempfile.mkstemp(dir=…)` + `os.fchmod`.
**R2-18.** `_prune_router_dirs` guard should be strictly-below: `bridge_dir != root and bridge_dir.is_relative_to(root)` (`subagent_routing.py:1114-1117`).
**R2-19.** Smart-Routing "dropped" notice names the wrong cause and fires unprompted on load (`NewChatDialog.tsx:4204-4218`, drop effect `:2532-2539`, localStorage restore `:1964`) — derive the actual reason, suppress on mount-restored picks, avoid stacking with `HarnessSetupNotice`.
**R2-20.** AppShell "refetched snapshot" test is a remount, not a refetch (`AppShell.test.tsx:3117-3149`) — drive with fake timers through a real QueryClient (closes test gap 53 properly).
**R2-21.** `_publish_routed_model` docstring says tier alias; `child_session` call sites pass catalog ids (`orchestration.py:3626-3628` vs `:3943`, `:4309`) — align.
**R2-22.** `session_warnings` per-session growth: dedup key includes free-text `harness` (500-char, unbounded cardinality) — cap entries (~8) or allowlist harness (`session_warnings.py:83-90`).
**R2-23.** `catalog_models_for_harness` reassigns a `list[str]` loop var with `| None` (`smart_routing.py:160`) — rename.
**R2-24.** Residual >3-line comment blocks in `smart_routing.py` (`:487-493`, `:497-502`, `:639-644`, `:1200-1205`, `:1208-1215`, `:1218-1223`, `:1296-1301`, `:1423-1428`).
**R2-25.** `designs/CUJ_IMPLEMENTATION.md` documents code deleted later in the same range (`task_cache_key`, `_fail_mode_decision`, `subagent_fail_mode`, `subagent_cache_ttl_s`, `_mark_placeholder_routed`, `scenario_menus` at `:94`, `:197`, `:200`, `:222`, `:554`, `:739`) — re-sync or drop those sections.
**R2-26.** PR body is stale: still advertises `scenario_menus`, `subagent_fail_mode`, fail-open/closed config, the decision cache, and the "RouteOptionSource seam" — all deleted. Regenerate from the current diff; add "Databricks deployments now default routing on" to the Changelog (round-1 item 24); Demo section still "to follow" (item 56).
---
## P0 — Critical
### 1. Double resolution silently downgrades the router's pick on the default Databricks path
- `omnigent/server/smart_routing.py:1443` (`route_session_harness`) and `:1540` (`route_turn`); wiring at `omnigent/cli.py:244`
- `ExternalRoutingClient` on the zero-config Databricks path resolves with hardcoded `_AIGW_MODEL_PREFIXES`, but the server then **re-resolves** the pick through `route_option_source()`, which reads `routing_settings().model_prefixes``()` when there's no `routing:` block. Bare arms can't match `databricks-…` catalog ids, so picks fall into `_nearest_servable`; `gpt-5-6-luna` is in `_CURRENT_GENERATION_MODELS` but not `MODEL_LISTS`, so `_listed_rank` is -1 and the pick collapses to cheapest. Verified: client resolves `databricks-gpt-5-6-luna`, server re-resolution returns `databricks-gpt-5-4-mini`.
- Fix: make one place own resolution — when the client returns `harness` + `raw_model`, trust its `model`; delete the second resolution pass. Fix `tests/server/test_smart_routing.py:1893` (`…without_prefixes_cannot_match_the_catalog_id`), which currently asserts the downgrade as correct.
### 2. Bearer-token directory bypasses the repo's own /tmp hardening
- `omnigent/runner/subagent_routing.py:1160-1175` (`router_dir_for_session`), `:814` and `:825-827` (`write_advertisement`)
- `mkdir(mode=0o700, parents=True)` applies the mode to the leaf only and trusts pre-existing ancestors — the exact symlink/world-writable attack `claude_native_bridge._ensure_secure_dir` (docstring at `omnigent/claude_native_bridge.py:677-701`) was written to stop, on the exact same `/tmp/omnigent-<uid>` path. `write_advertisement` also mkdirs with no mode and writes the token via `write_text` **before** `chmod 0600` (briefly world-readable).
- Fix: promote `_ensure_secure_dir` to a shared helper and use it in `router_dir_for_session`; create dirs `mode=0o700`; write the token via `os.open(..., 0o600)`/`mkstemp` so it's never world-readable.
- Note: item 47 (deleting the SDK loopback path) removes most of this surface — do 47 first if taking it.
### 3. Arming subagent routing on codex < 0.129 silently deletes the user's codex hooks
- `omnigent/codex_native_app_server.py:620-627` vs `:649-654`; symlink drop at `omnigent/inner/codex_executor.py:784-787`
- `_populate_codex_home_config(..., subagent_routing=True)` drops the user's `hooks.json` symlink *before* the version gate decides to skip `_write_codex_policy_hooks_file` — old codex ends up with no `hooks.json` at all.
- Fix: resolve the codex version before `_populate_codex_home_config` and pass `subagent_routing=False` when the hooks file won't be written (or always write the merged file, omitting only routing entries).
### 4. Session router leaked on 2 of 3 launch paths
- Only teardown is claude-native's `finally` (`omnigent/runner/native/orchestration.py:6110`). Codex-native (`orchestration.py:3691-3700`) and the SDK path (`omnigent/runner/app.py:9789-9800`, `_ensure_session_subagent_router`) never call `shutdown_session_router`.
- Leaks per session: `ThreadingHTTPServer` + daemon thread + loopback socket + `_relayed`/`_cache` entries + a live bearer-token file on disk.
- Fix: call `shutdown_session_router(session_id)` from codex-native teardown and the runner's session-close path for SDK harnesses.
### 5. Deferred routing chip permanently duplicates the /model echo bubble (web)
- `web/src/lib/renderItems.ts:459`, `:472`, `:354`
- For a user message paired with a deferred chip, the cache hardcodes `lastBubbleCount = 2`, but on claude-native the chip↔message region also contains the injected `/model` `slash_command` block, which renders as its own assistant bubble → region produces 3 bubbles, cache drops 2, the echo bubble is emitted twice on every later incremental frame. Reproduced frame-by-frame from the 2nd routed turn; persists (duplicate React keys too) until full rebuild (reload/session switch).
- Fix: record `regionBubbleStart = bubbles.length` when `lastBubbleStart` is assigned and set `lastBubbleCount = bubbles.length - regionBubbleStart` at region end. Related: `isChipPairingSkippable` (`renderItems.ts:656-658`) wrongly classifies `slash_command` as non-rendering — split "renders nothing" from "may sit between chip and message". Add a frame-by-frame test with the chip+echo pattern at a **non-zero block offset** (second turn) — all existing tests start at block 0 where cache reuse is disabled (`lastBubbleStart <= 0` bails out).
---
## P1 — Important
### 6. Native Smart Routing touches `body.host_id` before any ownership check
- `omnigent/server/routes/_sessions/orchestration.py:5769-5790` vs `:5914-5922`
- `_resolve_native_smart_routing` reads the host and pushes `HostModelOptionsFrame`s over its live connection ~150 lines before `_validate_session_workspace` authorizes the caller — violating the invariant that function's own docstring states. Leaks CLI/catalog presence on foreign hosts; pushes frames into another user's host connection.
- Fix: verify host ownership (`resolve_host_owner`) before `_resolve_native_smart_routing`, or move routing after workspace validation.
### 7. User prompt logged at INFO on every external routing call
- `omnigent/server/smart_routing.py:1192` — logs the full `SelectRouteRequest` body incl. up to 4000 chars of `task.prompt`. Check `LLMRoutingClient`'s raw-response log at `:386` too.
- Fix: INFO logs route options/router name only; body at DEBUG with a length-only prompt summary.
### 8. Unapplicable routed model persisted as `model_override`, then disables routing
- `orchestration.py:4293-4300` vs `:4337`; same ordering issue at `:3960-3966` (`_publish_routed_model` before the downgrade check)
- When the claude-native pane has no `/model` spelling for the routed id, the chip says "not applied" but `conv.model_override` is persisted anyway → the `model_override is None` gate at `:4272` disables routing for all later turns, and usage attribution lies.
- Fix: compute "can the pane apply this?" *before* persisting; skip `update_conversation` and the in-band forward when unapplicable. This should also collapse `_mark_unapplied_native_turn_decision` (~58 lines) into the pre-persist check.
### 9. `subagent_fail_mode: "closed"` cannot deliver — delete it
- `omnigent/inner/hook_scripts/subagent_router.py:320-322`, `:495-497`; `omnigent/runner/subagent_routing.py:395-398`, `:526-537`, `:927`; `omnigent/cli.py:57`
- Every transport failure, unadvertised endpoint, bind failure (`ensure_session_router_quietly` swallows `OSError` at `:1135-1142`), untrusted hook, or hook timeout falls through to allow. The knob fails open exactly when an operator wants closed.
- Fix: delete `subagent_fail_mode`, `DEFAULT_FAIL_MODE`, `_fail_mode`, both `fail_mode` params, the CLI parse, and the `closed` branch; document the gate as advisory (and soften the docstring claim at `subagent_routing.py:3-5` — see item 12).
### 10. Session warning banner can effectively never fire (web + server)
- Web: `web/src/shell/AppShell.tsx:1337`, `web/src/lib/sessionsApi.ts:319`, `useSession` (`staleTime: Infinity`, no invalidation on routing/canary events). Server: `omnigent/runtime/session_warnings.py:27,35` has no `clear()`/prune (sticky forever, unbounded growth).
- The warning is by nature discovered after the snapshot the UI cached → banner only shows after hard reload, then never clears.
- Fix: invalidate `["session", conversationId]` from `chatStore` when routing/canary events arrive (or poll while bound); add `clear(session_id, code=None)` server-side, call it when the canary fires, prune on session delete; allowlist accepted `code` values (`routes_events.py:740` stores arbitrary dict shapes).
### 11. `ensure_session_router` check-then-act race + unconditional advertisement unlink
- `omnigent/runner/subagent_routing.py:1066-1086`, `:846-851`
- Lock released between read and insert → two concurrent starts both bind sockets; loser is never closed. `close()` unlinks the advertisement unconditionally (unlike the `tool_relay.json` pattern at `claude_native_bridge.py:665-672`), so a stale router's close kills live routing.
- Fix: hold `_lifecycle_lock` across the whole start (or `setdefault` + close loser); guard `close()` on the advertisement still naming this router's URL. Also handle the `bridge_dir` mismatch orphan (`:1069-1075`): track every advertised dir and unlink all on shutdown.
### 12. Hook trusts any advertised URL; `pid` written but never checked
- `omnigent/inner/hook_scripts/subagent_router.py:127-136`, `:306-318`; `omnigent/runner/subagent_routing.py:817-822`
- `request_decision` POSTs the token + full spawn prompt to whatever `url` the advertisement names. The bridge dir is agent-writable → self-approval or off-box exfiltration; a stale advertisement's port can be re-bound by another local process.
- Fix: reject non-`http` schemes and any host other than `127.0.0.1`/`::1`; check the advertised `pid` is alive before POSTing; soften the "cannot proceed on an unapproved model" docstring.
### 13. Decision cache re-emits duplicate `decision_id` — delete the cache
- `omnigent/runner/subagent_routing.py:634-636``:594-611`
- Cache hits re-run `record_routing_decision` + `persist(decision_record(...))` with the same `decision_id` (documented as an identity at `:235-236`, used as a join key at `:69`) → duplicate transcript rows, double-counted telemetry, non-unique join key.
- Fix (preferred): delete `_CacheEntry`, `_cache`, `task_cache_key`, `_cached`, `_remember`, `clear_cache`, `subagent_cache_ttl_s` (~70 lines) — it's an optimization on a path that tolerates 30s. If kept: mint a fresh `decision_id` on hit and skip re-persistence deliberately.
### 14. Codex spawn-audit reconciliation compares unnormalized model spellings → false "unenforced" banners
- `omnigent/inner/codex_executor.py:1145-1180`; consumed at `omnigent/codex_native_forwarder.py:5772-5785`
- Exact-string compare of codex's model spelling vs router catalog ids (`databricks-gpt-5-5`); any spelling difference posts `subagent_routing_unenforced` every 30s on a healthy session. Normalizers (`_bare_model_id`, `normalized_model_id`) exist and aren't used.
- Fix: compare normalized ids.
### 15. `router_env` injects both harness families' env vars into every harness process
- `omnigent/runner/subagent_routing.py:1215-1220`; consumed at `omnigent/runner/app.py:9882-9888`
- A codex executor spawned beneath a claude-sdk session sees `OMNIGENT_CODEX_SUBAGENT_ROUTER_*` with the *parent's* session id → routes/audits as the wrong session.
- Fix: set only the vars for the harness being launched. (Moot for the SDK path if item 47 is taken.)
### 16. `_HARNESS_EXCLUDED_MODELS` unenforced on the turn path
- `omnigent/server/smart_routing.py:1538-1545`
- `route_turn` discards the resolved harness, and for `pi` (multi-family) the family filter removes nothing → a pi session can be routed onto a model its gateway 400s on (the exact failures the table documents: `eager_input_streaming` for Claude, default `reasoning_effort` for gpt-5.5/5.6).
- Fix: post-filter/substitute models the table bars for the session's own harness in `route_turn`.
### 17. Sub-agent routing row: re-picking the displayed inherited value silently no-ops (web)
- `web/src/pages/ChatPage.tsx:5714-5718`, `:5730`, `:5582`
- Radix Select doesn't fire `onValueChange` for the already-selected value, contrary to the comment "re-picking the inherited value still persists an override" — verified 0 calls to `setSubagentRouting`. Also `:5582` renders "Default" for spec-default-routed sessions that actually route.
- Fix: explicit "Inherit" option or commit the effective value on save when the row was touched; fix the label predicate and the comment. Add a test for "select the value already displayed".
---
## P2 — Should fix
### 18. Non-constant-time token compare + keep-alive body not drained
- `omnigent/runner/subagent_routing.py:905`, `:904-928`. Use `secrets.compare_digest`; drain or `Connection: close` on 401/404 (`protocol_version = "HTTP/1.1"` enables keep-alive and leftover bodies corrupt the next request's parse).
### 19. `httpd.shutdown()` called synchronously in an async `finally`
- `omnigent/runner/subagent_routing.py:848` via `orchestration.py:6110`. Blocks up to 0.5s; use `await asyncio.to_thread(router.close)`.
### 20. Inconsistent timeout budget across the 4 hops
- Claude bridge 30s (`omnigent/claude_native_bridge.py:1401`) == hook 30s (`subagent_router.py:65`) so the hook's fail-open branch may never run; runner/server wait 60s (`subagent_routing.py:861`, `:948`); codex outer 120s (`codex_executor.py:869`) is dead code behind the hook's 30s. One budget, strictly decreasing outward.
### 21. Enforcement-watcher task leak
- `omnigent/codex_native_forwarder.py:1766-1773`, `:5846`: blocks on `turn_observed.wait()` forever if the session never takes a turn; cancel in the forwarder's `finally`.
### 22. `bypass_hook_trust` inverted for unparseable versions
- `omnigent/runner/native/orchestration.py:3790-3796` — a failed version probe now leaves a wedged interactive trust prompt no subagent can answer, and contradicts `codex_native_app_server.py:642-646` which treats unparseable as supported. Pick one policy, apply in both places.
### 23. `discover_databricks_claude_models` removed without deprecation shim
- `omnigent/databricks_model_discovery.py:338`; CLAUDE.md requires a named removal version on deprecations. Also the replacement always issues the gateway listing even when UC already returned Claude models (extra HTTP round trip per terminal launch) — restore the short-circuit.
### 24. Routing-on-by-default for Databricks deployments not called out
- `omnigent/cli.py:112-153`, `:3574-3585`. With no `routing:` block, Databricks deployments silently build an `ExternalRoutingClient`. Behavioral default change; add to PR body/changelog.
### 25. `routed_model` conflates routed pick with manual pin
- `omnigent/server/routes/_sessions/helpers.py:7992`: reported for every child incl. user-pinned models where routing never ran (`routing_decision_id` is `None`). Gate on the `ROUTING_DECISION_LABEL_KEY` label so the two fields agree.
### 26. Telemetry emitted before decision validated
- `helpers.py:5507` vs `:5519`; on `parse_item_data` failure a decision is counted with no chip. Move `record_routing_decision` after validation succeeds; fix the contradictory docstring (":returns: … None is never returned" two lines above a `return None`).
### 27. Two catalog readers disagree on picker rows
- `orchestration.py:3517` (`option["model"]`, required) vs `:5610` (`model or id` fallback). `NativeModelOption.model` is optional and `model_dump(exclude_none=True)` drops it → the turn path silently loses its vocabulary constraint. Use `model or id` in both.
### 28. `_publish_routed_model` publishes catalog id on a tier-alias channel, SDK path only
- `orchestration.py:3604`. `SessionModelEvent.model` is documented as a tier alias (e.g. `opus`); this publishes `databricks-claude-opus-4-8`, and only on the SDK path. Publish picker-vocabulary spelling and make both paths agree.
### 29. `RoutingClient` protocol grew required `last_error`
- `omnigent/server/smart_routing.py:199`. Accessor (`routing_last_error`) is already `getattr`-defensive; drop `last_error` from the Protocol or note the break for custom clients.
### 30. Unbounded `_relayed` ledger with uncapped agent-authored `task_name`
- `omnigent/runner/subagent_routing.py:1028-1037`. One dict per spawn for the session's life; `SubagentRouteRequest.from_payload` caps nothing. Cap the list and the field.
### 31. Dead `RoutingDecisionChip` extended
- `web/src/components/blocks/StatusBlocks.tsx:130-190`: nothing renders it — `BubbleView` (`ChatPage.tsx:3050`) uses `RoutingDecisionCard`; only tests reference it. Delete it and its tests, or wire it.
### 32. 4 new TS errors in test fixtures
- `web/src/lib/renderItems.test.ts:1421,1614,1650,1653`: `response_start` literals omit required `model`/`responseId`/`conversationId`; `slash_command` omits `output`. Masked because CI type-check is commented out (`.github/workflows/lint.yml:129`). Use full literals or local helpers (see main's pattern at `renderItems.test.ts:371`); ideally re-enable type-check.
### 33. `smart_routing_message` ≠ delivered prompt
- `web/src/shell/NewChatDialog.tsx:3178` vs `:3215`: router classifies raw `message`; agent receives `buildMentionPreamble(...) + sanitizeInitialPrompt(message)`. Compute `initialPrompt` before the POST and send that to routing.
### 34. Fork telemetry predicate wrong + extra fetch
- `web/src/shell/ForkSessionDialog.tsx:394`, `:197`: `costControlModeOverride === "on"` misses spec-default-routed sessions; the `useSession` subscription exists only for telemetry and triggers a real fetch. Fix the predicate or drop the event (see item 45).
### 35. `warningTitle` ignores `warning.code`
- `web/src/shell/SessionWarningBanner.tsx:32-37`: branches only on `harness`; a second code in `RENDERED_CODES` (line 17) would silently render the wrong copy. Key copy off `code` (a record), derive `RENDERED_CODES` from it.
### 36. Host-switch silently downgrades "Smart Routing" → "Claude Code"
- `web/src/shell/NewChatDialog.tsx:2529-2534`: when a native arm becomes unconfigured on the newly selected host, `pickedHarness` resets to `null` with no notice. Surface it in the existing harness-readiness notice.
### 37. Two independent hooks.json writer/merge implementations
- `omnigent/inner/codex_executor.py:975-1053` (`write_codex_router_hooks_file` + `merge_codex_user_hooks`) vs `omnigent/codex_native_app_server.py:1002-1128` (`_write_codex_policy_hooks_file` + `_merge_user_hooks` + `_merge_hook_payloads`). Their divergence caused P0 item 3. Collapse to one writer taking a list of payloads (~80 lines saved).
### 38. Duplicate `:param bridge_dir:` in docstring
- `omnigent/codex_native_app_server.py:914-930``:param bridge_dir:` appears twice with prose wedged between.
### 39. `_host_model_options` near-duplicates `_proxy_model_options`
- `orchestration.py:5559-5614` vs `omnigent/server/routes/hosts.py:89-122` (same request-id/future/frame/timeout/finally shape). Factor one out.
---
## Simplifications (deliberate deletions; several P2s fixed by removal)
### 40. Delete `NO_SIGNAL_TASK` placeholder routing (~40 lines)
- `omnigent/runner/subagent_routing.py`: routing the literal string "Codex subagent task" returns the same verdict by construction. Replace with "unnamed codex spawn → allow unchanged"; delete `_mark_placeholder_routed`, the rationale prefix, and the placeholder-aware cache key.
### 41. `RouteOptionSource` Protocol → concrete class
- One implementor (`TaskV1RouteOptionSource`), one factory (`route_option_source`); ~25 lines of indirection.
### 42. Flatten the one-key `router_name` nesting
- `TASK_V1_MENUS` / `TASK_V1_ARM_TIERS` are `router_name → …` two-level `MappingProxyType`s with exactly one key (justified in-comment by a hypothetical `task_v2`). Flattening also deletes `_parse_scenario_menus` (~30 lines in `cli.py`) and the `scenario_menus` threading through `RoutingSettings`, `route_option_source`, `TaskV1RouteOptionSource`, and `ExternalRoutingClient`.
### 43. Replace the capability-ranking engine with a table (~170 → ~10 lines)
- `omnigent/server/smart_routing.py:499-526, 705-756, 947-999`: `_SIZE_CLASS_SEGMENTS`, `_size_class`, `_version_key`, `_listed_rank`, `_capability_key`, `_at_or_below`, `_nearest_servable`, `_CURRENT_GENERATION_MODELS`, `ARM_TIER_*`, `TASK_V1_ARM_TIERS`. Its whole job is substituting one of five frozen arms when the workspace lacks an endpoint. A `{arm: (preferred, fallback, …)}` table is deterministic, reviewable, and doesn't have the `_listed_rank == -1` hole that fed item 1.
### 44. Unify the two prefix mechanisms
- Hardcoded `_BARE_ID_PREFIXES` (used by `_bare_id`/`_model_family`/`_listed_rank`) vs configurable `model_prefixes` (used by `to_router_id`). They must agree, nothing enforces it, and their disagreement is item 1. One mechanism. Also: `strip_catalog_prefix`'s `_PREFIX_SEPARATORS` defends against a misconfigured prefix — that's config validation, not routing.
### 45. Delete `routingTelemetry.ts` + both call sites (web)
- No user-visible value, no tests, wrong fork predicate (item 34). Call sites: `chatStore.setCostControlMode`, `ForkSessionDialog.tsx`.
### 46. Shrink `model_labels.py`
- `omnigent/telemetry/model_labels.py`: 86 lines of regex-per-segment allowlist reducible to ~25 lines of substring checks over the same two tuples.
### 47. Delete the SDK loopback path (~80 lines) — judgment call, recommended
- `router_dir_for_session`, `session_router_env`, `router_env`, `_ensure_session_subagent_router`, and the `_build_spawn_env_from_spec` threading. The claude-agent-sdk `PreToolUse` callback runs **in-process** — it can call `resolve_subagent_route` directly; no HTTP server, advertisement file, bearer token, or `/tmp` dir needed. Deleting this removes item 2's attack surface and item 15 outright. If kept, items 2 and 15 must be fixed instead.
### 48. Comment-convention pass (repo CLAUDE.md: ≤3-line comments, scenario not change-history)
- Offenders: `smart_routing.py:31-37, 65-70, 484-491, 508-513, 528-531, 640-648, 665-673, 705-715, 1272-1283`; `orchestration.py:3546-3570, 3604-3616`; `codex_native_forwarder.py:382-391` (11-line block on two fields), `:2755-2758` ("used to" change-history prose); `subagent_routing.py:79-89, 480-487, 693-697`; `claude_native.py:400-410`; ~20 repeated `# type: ignore[explicit-any]` justifications in `subagent_router.py` → one module-level note.
- Stale docs: `smart_routing.py:1373-1378` and the `ExternalRoutingClient` docstring still describe `task_v0` though `DEFAULT_ROUTER_NAME` is `task_v1`.
### 49. Trim "not routed because X" INFO blocks
- `orchestration.py:3883-3891, 4261-4269, 4278-4283` (~35 lines): restate the branch condition they sit next to; two duplicate each other across SDK/native paths.
---
## Test gaps to close
### 50. Frame-by-frame chip+echo test at non-zero offset
- See item 5. Use `expectFrameByFrameStable` with a preceding turn in the block list so `reusablePrefix` is actually exercised.
### 51. `routingTelemetry.ts` untested
- No test file; rollback-vs-emit ordering at `web/src/store/chatStore.ts:1808` unasserted. Moot if item 45 deletes it.
### 52. Sub-agent routing row: "select the already-displayed value"
- `web/src/pages/ChatPage.composer.test.tsx` covers `null→on` and `on(inherited)→off` but not the silent no-op case (item 17).
### 53. AppShell → `activeSession.warnings` integration path
- `SessionWarningBanner.test.tsx` covers the component only; an integration test asserting the banner appears after a snapshot refetch would have exposed item 10.
### 54. `serverInfo.smart_routing_enabled` gate unasserted
- At the `web/src/pages/ChatPage.tsx:882-885` call site of `isSubagentRoutingSession`.
---
## Housekeeping
### 55. Normalize uv.lock
- Working tree has `uv.lock` rewritten to `pypi-proxy.dev.databricks.com` (~3200 lines, not from the PR). Run `just normalize-locks` before committing so it doesn't ride along.
### 56. PR Demo section
- Still says "to follow" on a UI-heavy change — record a video/screenshots before merge.
### 57. Verification commands
- `uv run pytest tests/server tests/runner tests/inner tests/entities`
- `npx vitest run` (in `web/`)
- `pre-commit run --all-files`
- Known pre-existing local failures (not caused by this branch): `test_sessions_snapshot` ordering flakes (pass in isolation); bwrap/seccomp/tmux/egress env failures in `tests/inner`.
---
## Suggested execution order
1. **Items 15** (P0s) — independent files, parallelizable.
2. **Items 617** (P1s). Do **47 before 2 and 15** to avoid fixing code you're about to delete; 13 and 9 are deletions, do them early.
3. **Deletions 4046, 4849.**
4. **P2 cleanup 1839** (skip any made moot by the deletions).
5. **Tests 5054**, then housekeeping 5557 and a full verification pass.
+26 -36
View File
@@ -273,6 +273,30 @@ def _build_local_llm_routing_client(
return LLMRoutingClient(policy_client)
def _build_routing(
cfg: dict[str, Any],
server_llm: Any, # type: ignore[explicit-any] # LLMConfig | None
) -> tuple[Any, Any]: # type: ignore[explicit-any] # (RoutingClient | None, RoutingSettings)
"""Build the routing client and settings from the ``routing:`` block.
Reuses the CLI's parser and builder so a Docker deployment honours the
same ``routing.*`` keys (router name, selection model, model prefixes) a
local server does.
:param cfg: The parsed server config mapping.
:param server_llm: The parsed server-level ``LLMConfig``, used for the
built-in judge when no external router is configured.
:returns: ``(routing_client, routing_settings)`` for ``RuntimeCaps``.
"""
from omnigent.cli import _build_external_routing_client, parse_routing_settings
routing_cfg = cfg.get("routing")
settings = parse_routing_settings(routing_cfg)
if isinstance(routing_cfg, dict) and routing_cfg.get("provider") == "external":
return _build_external_routing_client(routing_cfg, settings), settings
return _build_local_llm_routing_client(server_llm), settings
def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
"""Resolve config if needed, wire the stores, and build the app.
@@ -338,47 +362,13 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
server_llm = parse_server_llm(cfg.get("llm"))
routing_cfg = cfg.get("routing")
if isinstance(routing_cfg, dict) and routing_cfg.get("provider") == "external":
from omnigent.server.smart_routing import ExternalRoutingClient, _bearer_auth
base_url = (routing_cfg.get("base_url") or "").strip()
router_name = (routing_cfg.get("router_name") or "").strip()
api_key_raw = (routing_cfg.get("api_key") or "").strip()
profile = (routing_cfg.get("profile") or "").strip()
raw_prefixes = routing_cfg.get("model_prefix")
if isinstance(raw_prefixes, str):
raw_prefixes = [raw_prefixes]
model_prefixes = (
[p.strip() for p in raw_prefixes if isinstance(p, str) and p.strip()]
if isinstance(raw_prefixes, list)
else []
)
if base_url and router_name:
auth = None
databricks_profile: str | None = None
if api_key_raw:
from omnigent.spec import expand_env_vars
auth = _bearer_auth(expand_env_vars({"api_key": api_key_raw})["api_key"])
elif profile:
databricks_profile = profile
routing_client = ExternalRoutingClient(
base_url=base_url,
router_name=router_name,
auth=auth,
databricks_profile=databricks_profile,
model_prefixes=model_prefixes,
)
else:
routing_client = None
else:
routing_client = _build_local_llm_routing_client(server_llm)
routing_client, routing_settings = _build_routing(cfg, server_llm)
caps = RuntimeCaps(
default_policies=parse_default_policies(cfg.get("policies")),
llm=server_llm,
routing_client=routing_client,
routing_settings=routing_settings,
)
init_runtime(
+675
View File
@@ -0,0 +1,675 @@
# Smart Routing CUJs: end-to-end implementation walkthrough
> **How to reference this document.** Every content block carries a short spoken id: the section number plus a letter, assigned top to bottom (`1.3a`, `1.3b`, …, restarting at `a` in each numbered section). Paragraph ids are bolded at the start of the paragraph; a table, list, or code block carries its id on the short label line directly above it. Blocks before §1 use `0a`, `0b`, …, and a lead-in under a top-level section uses the parent number, e.g. `2a`. Say "2.3b" to name exactly one block.
**0a** This document records what we had to build, in pipeline order, for the three critical user journeys on `routing-mvp`. Companion documents:
**0b:**
- `designs/INTELLIGENT_ROUTING_PLAN.md` — the plan. Its §12 "Implementation deltas" holds the per-fix narratives that this document expands into full chains.
- `designs/CUJ_STATUS.md` — the evidence layer per CUJ, and the 14/14 matrix run.
- `designs/LIVE_MODEL_STATE.md` — the codex model-state mechanics in protocol detail.
**0c** This document cites commit shas inline, as §12 does. Most of those shas name the original per-fix commits, which is the granularity that the narratives need. We since rebased the branch onto `origin/main` and reconciled it with main's catalog-routing work, so those pre-rebase shas are no longer reachable. The shipped series is `git log --oneline origin/main..HEAD`. That series holds thirty-one commits. `80d3bcc7` "feat(routing): session-start smart routing core" leads the series and holds the reconciliation. Several shas that this document cites landed *after* the rebase and are therefore reachable in the series: `c393842d` (the gateway-backed availability gate), `3ccf86e3` (the claude turn-catalog staleness fix and the beta-flag fix), `e1592902` (the subagent `raw_model` compare), `60b39177` (the floating warning banner), `907f8886` (the GLM gateway route), and the CLI workstream of §6 (`8f3c0c60`, `8d7c9cb2`, `b10a7239`). HEAD is `cd9fdccb`. Every line number below is a line number in HEAD.
**0d** This document uses six domain terms:
**0e:**
- **arm** — one model that the router can choose from a fixed menu.
- **seam** — the one module boundary that holds all knowledge of the router's contract.
- **pane** — the tmux pane that runs a native harness CLI.
- **rollout** — one codex turn on the running thread.
- **canary** — a hook that writes a file, plus a watcher that reports the file as absent.
- **spelling** — one of several literal id strings that name the same model, for example `system.ai.claude-opus-5` and `databricks-claude-opus-5`.
**0f** The three journeys:
**0g:**
1. **Claude Code CUJ.** The user opens *Configure Claude Code* on the new-chat landing. The user picks **Smart Routing** in the Model dropdown. The server creates the session with routing on and with no model pin. The router scores the first message of each turn over the Claude arms. The executor switches the claude-native terminal to the routed model before it injects the message.
2. **Codex CUJ.** The user makes the same choice in *Configure Codex*, over the Codex arms. The executor applies the routed model to the running codex thread and to that thread's on-disk mirror. It does not type the model into a pane.
3. **Smart Routing (auto) harness CUJ.** The user picks the top-level **Smart Routing** row in the harness dropdown. That row is not a harness. It is a router over the harnesses. The server chooses both the harness (claude-native or codex-native) and the model at session create, from the first message. Both choices stay for the session's life.
**0h** Reading order:
**0i:**
- §1 describes the shared substrate that every CUJ sits on. §2 to §4 reference §1 rather than repeat it.
- §2 and §3 describe the two apply layers. Nearly all the real work was there.
- §4 is mostly create-time composition of §1 to §3, plus its own permission rules and persistence rules.
- §5 collects the run-time behaviour that all three CUJs share.
- §6 is the fourth surface: the CLI entry points. It adds no apply layer of its own, so it composes §1 to §5 and records only what is new — flag parsing, preflight, the create it drives itself, and the wrapper launch.
**0j** Every product decision appears in the section where it bites, with its rationale. `designs/INTELLIGENT_ROUTING_PLAN.md` §10 holds the decision records themselves. This index maps each one to its section, so a reader can audit the trail:
**0k:**
| Plan §10 decision | Where it bites |
| --- | --- |
| 1. Smart Routing is a Model choice in the per-harness modal | §2.1, §3.1 |
| 2. The fully-auto mode is named "Smart Routing", above the Harnesses list | §4.1 |
| 3. Configure Smart Routing is Permissions-only, locked and disabled | §4.1 |
| 4. Main-agent routing is session-start only | §2.1, §4.6, §5.1 |
| 5. `subagent_routing_override` is per-session and per-call | §1.5, §5.2 |
| 6. Both toggles exist (main agent at start, subagents at any time) | §2.1, §5.2 |
| 7. The Smart Routing harness pick is session-pinned | §4.6 |
| 8. A routed `/model` writing the user's claude default is accepted | §2.6 |
| 9. Smart Routing is offered only where the apply layer can work | §1.8, §2.1, §3.1, §4.1 |
**0l** Three decisions are not in §10, and they shape as much behaviour. The advisory (fail-open) gate philosophy is §5.4. The honest `applied=false` record is §2.4. The soft redirect for a cross-harness pick is §4.7.
---
## 1. Shared routing infrastructure
### 1.1 The route-options seam
**1.1a** `omnigent/server/smart_routing.py` holds all knowledge of the router's contract. One concrete route-options source, `TaskV1RouteOptionSource` (`:931`), holds it. `build_route_options` (`:958`) takes a harness set plus a catalog, and returns the option list that the router requires. `resolve_selection` (`:992`) takes the router's pick, and returns a `(harness, servable id)` pair.
**1.1b** The source always injects the frozen task_v1 arm menus (`TASK_V1_MENUS`, `:599`). It injects them even when the workspace serves no endpoint for them. task_v1 returns 400 for a partial menu, and eng-ml-inference cannot serve two of its arms (plan §1).
**1.1c** Those menus are a **wire contract**, and that is a decision rather than a convenience. A router version is frozen upstream, so its menu is frozen too. The arm list therefore lives in code, and it moves only when a deployment bumps `routing.router_name` (plan §1.1, plan risk 4). We do not derive the menu from the workspace catalog. A workspace that adds `claude-opus-5` must not change what the router is offered, because the recipe was calibrated on the arms that it names. §1.3 records the consequence: the frozen arm and the live catalog disagree, and every layer below has to reconcile them.
**1.1d** Three callers use the source: `route_session_harness` (`:1467`), `route_turn` (`:1655`), and the runner's subagent endpoint. No caller sees router vocabulary. Each caller reaches the source through `route_option_source` (`:1162`).
**1.1e** The source began as a `RouteOptionSource` Protocol with one implementor, and the menus sat one level deeper under a single `router_name` key. `36a17c65` collapsed both. It merged the Protocol into its implementor. It flattened the menus into `{scenario: arms}` tables. That flattening also deleted `routing.scenario_menus`, `_parse_scenario_menus`, and the `scenario_menus` threading.
**1.1f** Four problems forced changes here.
**1.1g — Two places resolved the pick, and the second one downgraded it.** The routing client always resolved its own pick to a servable local id. Both callers therefore passed `resolve_selection` an already-local id, such as `databricks-claude-opus-4-8`. `resolve_selection` expected router vocabulary, such as `claude-opus-4-8`. No arm matched, so `resolve_selection` returned `None` for the harness. The caller read that `None` as "routing unavailable". Smart Routing sessions then ran on the default harness without a message, and turn decisions recorded `applied=false`.
**1.1h** The first fix (`972dea9d`) made the seam idempotent. The seam now resolves from `RoutingResult.raw_model`, and it maps an already-local id back to router vocabulary first. The second resolution pass still remained, and that pass still lost information on the zero-config Databricks path. The server's second pass reads `routing_settings().model_prefixes`. Without a `routing:` block that value was `()`. No bare arm could then match a `databricks-` catalog id, and `databricks-gpt-5-6-luna` fell back to the cheapest model. The client now owns resolution (`36a17c65`): `route_session_harness` applies the client's `model` verbatim and derives a harness only when the client names none, and `route_turn` does not re-resolve at all.
**1.1i — One prefix list, configurable, with an honest empty case.** `strip_catalog_prefix` (`:574`) removes a leftover leading separator. A prefix that a deployment configures without its trailing dot, such as `system.ai`, produced router ids like `.claude-opus-5` (`972dea9d`). The code once split the prefix list in two: the hardcoded `_BARE_ID_PREFIXES` and the configurable `model_prefixes`. `36a17c65` collapsed both onto one list, `MODEL_ID_PREFIXES` (`:571`). That one list is the default for `RoutingSettings`, for the seam, and for `ExternalRoutingClient`, so the two ends can no longer disagree. Every prefix comparison reads `routing.model_prefix`. An explicit `model_prefix: []` now means bare catalog ids, and it no longer falls back to the defaults (`46a50556`).
**1.1j — Substitution for an unservable arm is a table, not a ranking engine.** The plan described the fallback as "nearest available". In practice that fallback trusted catalog list order: an alphabetical live catalog substituted `gpt-5-nano` for the codex anchor arm `gpt-5-6-sol`. The first attempt was a capability-ranking engine of about 170 lines, which held `_capability_key`, `_size_class`, `_version_key`, `_listed_rank`, and arm tiers. `36a17c65` deleted that engine and added a reviewable `{arm: (preferred, fallback, …)}` table, `_ARM_SUBSTITUTES` (`:611`). `substitute_model` (`:783`) reads that table. The ranking engine had a `_listed_rank == -1` hole: it ranked a current-generation model below everything when `MODEL_LISTS` did not list that model. The table has no such hole.
**1.1k** Sometimes the chain names no model on offer. `substitute_model` then takes the same-family candidate nearest to the pick's own cost position (`_cost_position`, `:757`), and on a tie it takes the cheaper candidate. The earlier fallback took the most capable same-family model, which inverted cost outright. That earlier fallback raised every SIMPLE pi turn to opus, because pi bars haiku (`46a50556`). Id comparison reads a dot as a dash (`_bare_id`, `:732`), so a picker's `gpt-5.6-sol` matches the router's `gpt-5-6-sol`. Before that change such an id matched no chain and collapsed onto the most expensive row. The offered menu now also carries one row per model instead of two. Live pre-session catalogs (§1.3) make an exact match the common case, and substitution the exception, which is what the matrix's "no fallback arrows" bar requires.
**1.1l — Harness bars were unenforced on the turn path.** `_redirect_incompatible_pick` (`:898`) stays in the seam as post-verdict harness correction. The offered menu keeps the `_HARNESS_EXCLUDED_MODELS` (`:711`) pairs when the harness is itself in play, because the router needs its full menu and otherwise returns 400. The function therefore moves an incompatible pick to a harness that can run it. `46a50556` landed two corrections:
**1.1m:**
1. The redirect now takes the *offered* harness set. It declines instead of returning a harness that nobody offered. A child that its parent's family restricts can therefore no longer escape onto `codex` or `claude-sdk`.
2. A turn cannot change harness at all. A turn therefore removes the models that its own gateway bars before it offers them. When an injected arm comes back barred, the turn swaps the *model* through `substitute_model`.
**1.1n** `harness_bars_model` (`:884`) is the shared predicate. `designs/LIVE_MODEL_STATE.md` documents why each `pi` exclusion exists.
**1.1o** Post-verdict harness correction runs as **two layers, in order**, and only on the session path. Our layer runs first: `_redirect_incompatible_pick` over the static `_HARNESS_EXCLUDED_MODELS` pairs. Only this layer can swap the model instead of the harness. `_redirect_wire_incompatible_pick` (`:1428`) runs second, on whatever the first layer returns. This second layer is the catalog-driven companion, and it reads the `_RunnerModel` wire APIs that the code keeps from the live catalog (§1.3). It moves a `pi` pick to `claude-sdk` when the catalog reports that the Claude-family endpoint does not speak Anthropic Messages. This layer carries the same on-offer guard, so a family-restricted child cannot escape through it either, and it never reads unknown metadata from an older runner as incompatible. `route_turn` runs only the first layer, because a turn cannot change harness at all.
### 1.2 RoutingSettings on RuntimeCaps
**1.2a** `RoutingSettings` (`smart_routing.py:653`) is the frozen deployment record. It now holds three fields:
**1.2b:**
- `router_name`.
- `selection_model`. The code passes this field through as `route_selector.config.model`, so a deployment can pin an extraction model that it has query access to.
- `model_prefixes`.
**1.2c** The flattened menu tables (§1.1) took `scenario_menus` with them. The deleted knob and the deleted cache (§1.5) took `subagent_fail_mode` and `subagent_cache_ttl_s` with them. `36a17c65` and `6112e6cb` deleted all three fields. Every reader reads the record through one accessor, `routing_settings(caps)` (`:1129`), which returns all defaults when the caps carry no record. The de-scarring pass collapsed several ad-hoc re-parses into that accessor. The same pass also fixed Docker's `RuntimeCaps` construction, which dropped the routing settings entirely (`d181cbd5`).
**1.2d** `cli.py` chooses the router client at *build* time. It constructs exactly one client into `RuntimeCaps.routing_client`, so there is no runtime fallback chain. A router failure returns `None` and sets `last_error`. `routing_last_error` (`:1149`) reports that error, and the caller continues unrouted and attaches the reason (plan §2). This behaviour made the task_v1 rollback incident a logged degradation instead of an outage. `last_error` is not part of the `RoutingClient` Protocol (`:192`). The accessor always read the attribute defensively with `getattr`, so a declaration would only imply a contract that the clients did not have (`36a17c65`).
**1.2e** Two logging-posture corrections belong here:
**1.2f:**
1. The external router request body carries up to 4000 characters of the user's prompt. The request log therefore logs at DEBUG and replaces the prompt with its length. The INFO log keeps the router name and the route options. The judge client's raw response also logs at DEBUG (`36a17c65`).
2. The router's *rationale* paraphrases the prompt. Both entry points therefore log the rationale at DEBUG, and log the model and the harness at INFO (`46a50556`).
### 1.3 Catalogs and spelling determinism
**1.3a** Three catalog sources, in order of preference:
**1.3b:**
- **Live per-session:** `fetch_runner_models` (`:323`) is a thin id-only adapter over `_fetch_runner_catalog` (`:246`). `_fetch_runner_catalog` calls the runner's `/v1/sessions/{id}/models`. It keeps each row's wire APIs and cost tier on a `_RunnerModel` (`:222`). It orders the rows by cost tier, and it breaks a tie by catalog order. The post-verdict wire check reads the wire metadata that this source keeps. `catalog_models_for_harness` (`:124`) extracts the harness's slice.
- **Pre-session:** `_pre_session_model_catalog` (`server/routes/_sessions/orchestration.py:5725`) asks the host for its pre-launch model options for each candidate harness. A create has no session, so the live catalog is out of reach. The host holds the CLIs and already resolves their picker options. `158042a3` added this source, because create-time Smart Routing routed over the static tables before that commit. That is how the server offered a codex session models that the session could not run. One helper now owns the host model-options round trip for both callers, and both readers accept a picker row that spells the id as `model` or as `id` (`3b00d101`).
- **Static:** `infer_models` (`:89`) is the last resort. It serves the harnesses that the host cannot answer for. The table behind it is `MODEL_LISTS` (`:39`) plus `_CURRENT_GENERATION_MODELS` (`:68`). That table is a deliberate fork: main deleted its other uses. We keep the table here because substitution needs a cost ordering on the paths that have no catalog. Deleting it with main would have left `substitute_model` with no way to order two candidates on the static path, and `_cost_position` is what keeps a SIMPLE pi turn off opus. The fork is therefore a retention decision, and the cost of it is one table that main no longer reads.
**1.3c** A fourth source bounds the **turn** path on a claude-native session only. It is the session's own picker rows rather than a catalog, because a turn can reach only what `/model` accepts. §2.3 holds it.
**1.3d** `models_in_family` (`:107`) filters the candidate set by family, whatever the source. One shared authority decides family compatibility for codex: `is_codex_compatible_model` (`model_override.py:126`). It matches each id segment, and it allows an optional trailing generation number. `system.ai.glm-5-2` and `kimi-k2-instruct` therefore pass, and a lookalike endpoint name such as `glmqlfit-eval` does not. Before that authority existed, three independent gates each rejected non-GPT ids on codex harnesses: `model_catalog`, `model_override`, and candidate filtering. We had therefore recorded GLM as an external distribution gap. The codex catalog carried `databricks-glm-5-2`, and *our own* code removed it (`158042a3`, §12 delta 9).
**1.3e** Spelling determinism was the other latent defect. The workspace lists the same endpoint twice, as `system.ai.claude-opus-5` and as `databricks-claude-opus-5`. `databricks_model_discovery.py` answered with whichever listing succeeded, so a routed turn could end up holding a spelling that the pane refuses. Discovery now unions both listings. It collapses duplicates onto the `databricks-` spelling. It sorts versions on the bare id, so a spelling can never outrank a version (`972dea9d`).
**1.3f** Discovery also returns the *full* servable Claude catalog instead of the newest model per family. The workspace kept adding newer generations, such as `claude-opus-5`, while task_v1's arms stay frozen at `claude-opus-4-8` and `claude-sonnet-5`. Newest-per-family alias pins drifted with the workspace (`af42b36c`, §12 delta 3). `discover_databricks_claude_models` survives only as a deprecation shim over the catalog lookup, and we remove that shim in v0.10.0. Its Unity Catalog short-circuit stays deleted. Unity Catalog spells every id with `system.ai.`, and a skipped gateway listing would make the catalog spelling depend on which listing answers (`3b00d101`).
**1.3g** A third kind of spelling belongs to the **gateway** rather than to a listing. The workspace serves GLM on the Responses API under one model-route name only, and no listing carries that name, so one pinned alias maps the arm onto it. §3.5 holds the mechanics.
### 1.4 Decision records and chip rendering
**1.4a** Every routing decision is a transcript item. `RoutingDecisionData` gained five fields: `harness`, `scope`, `decision_id`, `raw_model`, and `attempted_override`. `scope` is one of `session`, `turn`, `child_session`, or `native_subagent`. All five fields carry a default for legacy rows (plan §5.2). `_emit_server_routing_decision` (`server/routes/_sessions/helpers.py:5499`) writes the item, and it writes the item after the decision validates. It no longer writes before a parse failure that produces no chip (`3b00d101`). `_stamp_routing_decision_label` (`orchestration.py:4114`) writes the decision id onto the session under `ROUTING_DECISION_LABEL_KEY` (`subagent_routing.py:88`), so a reader can join a persisted `model_override` back to the decision that produced it. The `routed_model` field on a child-session row requires that label, so a user-pinned model no longer reports as routed with a null decision id (`3b00d101`).
**1.4b** Only one component renders a decision: `RoutingDecisionCard` in `web/src/components/blocks/StatusBlocks.tsx`, which is what `BubbleView` mounts. A second component, `RoutingDecisionChip`, sat unused for a while. `2245f57d` deleted it and moved its coverage onto the card. "Chip" below means the card in its paired position below the message.
**1.4c** Four rules matter for the UI. The first three exist because native sessions behave differently:
**1.4d:**
- **`applied` must be honest.** See §2.4. A decision that claims a model the process never ran is worse than a visible `applied=false`.
- **The chip renders below the user message that it routed.** A native terminal session writes the decision *before* the message, so order-faithful rendering put the chip at the top of the chat. `deferredRoutingChips` (`web/src/lib/renderItems.ts:639`) pairs a session-scoped or turn-scoped chip with the adjacent user message, and moves the chip below that message. It leaves an already-correct order untouched. It never moves a subagent chip. Streaming rebuilds the pair atomically in both arrival orders (`8fa280ea`). Only claude breaks the adjacency: on claude the injected `/model` echo persists as a `slash_command` item *between* the decision and the message, and codex sends the model over the app-server and writes no such item. `isChipPairingSkippable` (`:383`, `:390`) therefore skips `slash_command` blocks in both directions (`25b75c62`).
- **The incremental cache must count the region that it just rendered.** The same `/model` echo renders its own bubble *inside* the region between the chip and the message. The cache hardcoded two bubbles per region, so it dropped one bubble too few. It then re-sent the echo bubble on every later frame of any turn after the first turn, and it produced duplicate React keys until a full rebuild. The region now records `regionBubbleStart` (`:467`), and it reports `lastBubbleCount = bubbles.length - regionBubbleStart` (`:483`). A frame-by-frame test covers a non-zero block offset, because the cache reuses only there. Every earlier test started at block 0, where reuse bails out (`2245f57d`). The fix also split two conditions that `isChipPairingSkippable` had treated as one: "the block renders nothing", and "the block may sit between the chip and the message".
- **`raw_model` must mean substitution, and never spelling.** The field means "the router asked for something else", and the card renders it as a `raw → applied` arrow (`StatusBlocks.tsx:224`). The router answers in bare router vocabulary, such as `claude-opus-4-8`, and the applied model carries a catalog prefix, such as `databricks-claude-opus-4-8`. Those two strings name the *same* arm. The session path and the turn path already compared through `_bare_id`, so they stamped no `raw_model` on a prefix-only difference. The subagent path did not: `_decision_from_result` (`subagent_routing.py:590`) compared the two strings raw. Every `native_subagent` row of the 2026-07-31 round therefore carried a `raw_model` for a same-arm restore, which `CUJ_STATUS.md` §2.1 records as an open item on otherwise-exact rows. The card hid the arrow in most of those cases, because `rawPickName` collapses through `shortModelName` (`:126`, `CostRoutingControl.tsx:61`), which is lossy on purpose: it maps every claude id to its tier word and strips a `databricks-` prefix. A `system.ai.`-spelled pick survives that collapse and draws the arrow. Either way the raw field reaches the card's decision-details payload (`:175`) and sets the telemetry flag `raw_model_resolved` (`telemetry/routing.py:82`), so both a reader auditing rows and the substitution rate itself counted a substitution that never happened. `e1592902` made the subagent compare read `_bare_id(raw) != _bare_id(model)`, which is the predicate that the other two paths already use.
**1.4e** The chips earn their keep. A difference between the raw pick and the applied model caught two real apply-layer bugs. That is exactly why the false arrow above mattered: a signal that fires on a non-event stops being read.
### 1.5 The route-subagent loopback and hook machinery
**1.5a** A native in-harness spawn never reaches the server. Routing such a spawn therefore needs a runner-local endpoint that the harness's own hook subprocess can call. `omnigent/runner/subagent_routing.py` serves that endpoint:
**1.5b:**
- `start_subagent_router` (`:806`) binds an HTTP server on `127.0.0.1:0`, and then writes `subagent_router.json` into the session's bridge dir. That file holds `{url, token, pid, session_id, updated_at}`. It uses the same advertisement pattern as `tool_relay.json`. `SubagentRouter.close` (`:780`) deletes the file. `ensure_session_router` and `ensure_session_router_quietly` (`:1016`, `:1059`) start the router whenever a server client exists. They do *not* start it only for sessions that begin routed, so a mid-session toggle to `on` has an endpoint to call (§5).
- `resolve_subagent_route` (`:476`) holds the policy. It builds the candidate set with `candidate_models` (`:397`). It calls the router. It returns a `SubagentRouteDecision` (`:236`) of `allow`, `rewrite`, `redirect`, or `deny`, with `model`, `harness`, `raw_model`, `rationale`, and `decision_id` (plan §5.1). It denies a pick that nobody offered, because "the spawn did not run" beats "the spawn ran on the wrong model", and that case is the *only* remaining `deny`. Two callers one hop out read the enablement gate **per call**: the server relay route (`server/routes/sessions/routes_hooks.py:1388`) and the child-session path (`orchestration.py:687`). Both read it through `subagent_routing_enabled` (`:157`), which layers the per-session override over the session's own cost-control state, or over the parent's state.
- The family rules live here too: `harness_family` (`:338`), `model_in_family` (`:378`), and `auto_harness_session` (`:355`). `auto_harness_session` allows a cross-family pick *only* under the Smart Routing harness (§4.7).
**1.5c** We built two pieces of this layer and then deliberately deleted them (`6112e6cb`):
**1.5d:**
- **The configurable strict mode.** `subagent_fail_mode` took `open` or `closed`. `closed` was meant to make an unrouted spawn fatal, on the argument that an unrouted spawn silently voids the determinism guarantee. Every failure path already fell through to allow: no client, no candidates, a router exception, an empty verdict, a transport error, or a hook timeout. `closed` therefore could not deliver what it promised. The module docstring now documents the gate as **advisory**. `_unavailable_decision` (`:456`) is the single path that allows the spawn and states the reason. The knob, its plumbing, and the deny-on-failure branch are gone.
- **The per-`(session, task)` decision cache.** The cache saved one task_v1 extraction round trip on identical spawns. A cache hit re-sent a `decision_id` that the contract documents as an *identity*, so one decision produced duplicate transcript rows and duplicate telemetry. Correctness won.
**1.5e — Hardening.** The advertisement carries a bearer token. `write_advertisement` (`:706`) therefore writes it through `os.open(..., 0o600)` into a temp file, and then moves that temp file into place with `os.replace`. The file is never world-readable, not even for the instant between a `write_text` call and a later `chmod` call. The SDK harnesses have no bridge dir of their own, so `router_dir_for_session` (`:1164`) creates a private dir for them through the shared ancestor check for bridge dirs (`ensure_secure_dir`, `claude_native_bridge.py:740`). It does not use `mkdir(mode=0o700, parents=True)`, which applies the mode to the leaf dir only, and which trusts an ancestor that already exists on the same `/tmp/omnigent-<uid>` path that the bridge hardening defends. The hook side rejects an advertisement unless it meets two conditions: the url is plain http on `127.0.0.1` or on `::1` (`_is_loopback_url`), and the advertising pid is still alive (`_advertiser_alive`). Token comparison runs in constant time. A 401 or a 404 drains the request body and closes the connection, so keep-alive cannot mis-frame the next request (`6112e6cb`).
**1.5f — Lifecycle.** The router used to leak on two of the three launch paths, because only claude-native shut it down. Each leaked session cost a `ThreadingHTTPServer`, a daemon thread, a loopback socket, ledger entries, and a live token file. Shutdown now runs unconditionally, and it is idempotent. Three call sites call `shutdown_session_router` (`:1119`): both codex-native forwarder exits, and the claude-native `finally` block (`runner/native/orchestration.py:4073`, `:4124`, `:6157`). All three call it through `_shutdown_session_router_async` (`runner/native/orchestration.py:442`), because the close joins the serving thread. For the SDK harnesses the runner's session-delete path calls it instead (`runner/app.py:3111`). `close()` deletes only an advertisement that still names its own url, because a session that forks, clears, or resumes keeps the same bridge dir, so a newer router may own the file. The router tracks every dir that it advertises into, and it prunes each one (`c46ef54d`, `6112e6cb`). `session_router_env` (`:1186`) scopes the router env vars to the launching harness, so a codex executor beneath a claude session no longer inherits the parent's session id (`6112e6cb`, `de2acfdb`).
**1.5g — Timeout budget.** Four hops wait on each other. Each hop's timeout is strictly larger than the timeout of the hop that it waits on. Otherwise an inner fail-open branch can never run. The four timeouts are:
**1.5h:**
1. Harness hook — 40s.
2. Hook script `HOOK_REQUEST_TIMEOUT_S` — 30s.
3. Runner relay `RELAY_TIMEOUT_S` — 20s.
4. Server hop `SERVER_HOP_TIMEOUT_S` — 15s.
**1.5i** The module docstring documents these four values in one place. The values also align with the codex executor's outer timeout, which had been a dead 120s (`6112e6cb`, `de2acfdb`). A long-running session can spawn without limit, so the code caps the relay ledger (`_RELAYED_CAP`) and the agent-authored `task_name` (`_TASK_NAME_CAP`).
**1.5j** One shared module holds the hook scripts: `omnigent/inner/hook_scripts/subagent_router.py`. Two thin per-harness entry points call it: `claude_router_hook.py` and `codex_router_hook.py`. The module imports stdlib only, so a subprocess on the spawn path can import it. The module does five things:
**1.5k:**
1. It finds the advertisement (`discover_router_dir`, `read_router_endpoint`).
2. It reads the parent model and the terminal's model vocabulary out of `bridge.json` (`resolve_parent_model`, `resolve_model_vocabulary_env`).
3. It builds the request (`build_route_request`).
4. It calls the endpoint (`request_decision`).
5. It renders the harness's hook output (`decision_to_hook_output`, `route_pre_tool_use`).
**1.5l** `run_route_subagent_main` (`:642`) always exits `0`, because routing must never be the reason that a spawn fails. v1 exempts fork spawns (`FORK_SUBAGENT_TYPES`, `_FORK_SUFFIXES`).
**1.5m** The de-scarring pass collapsed the per-harness duplicates into this one module. The same pass fixed ten latent defects, and it added a regression test for each one. Those defects include the hook argparse exit-0 contract, codex fork detection, cross-harness label agreement, and family-filtered candidates (`d181cbd5`).
### 1.6 The enforcement canary
**1.6a** A hook that does not run, and that says nothing, is the worst failure mode available. The UI shows routing as on. The spawns run unrouted. Nothing reports a problem. The canary is the detector: a `SessionStart` hook writes a file into the bridge dir, and a watcher posts the session-scoped warning `subagent_routing_unenforced` (`runtime/session_warnings.py:34`) when the file is absent. We had to invert the arming logic before the canary worked; see §3.7, where the canary caught both codex apply-layer bugs. The canary still earns its place: §3.7 records an open trust-versus-launch race that the canary is the only detector for. A publisher can retract the warning as well as post it; see §5.3.
### 1.7 Telemetry
**1.7a** Routing telemetry uses OSS analytics events, not OTel spans. There are two events: `RoutingDecisionEvent` and `RoutingSettingChangedEvent` (`omnigent/telemetry/events.py:90`, `:142`). Two functions send them: `record_routing_decision` and `record_routing_setting_changed` (`omnigent/telemetry/routing.py:41`, `:91`). The original shape was a span-event helper in `runtime/telemetry.py`. Review rejected that shape for two reasons: it read as debug-only, and it created orphan spans when nothing recorded.
**1.7b** The replacement takes an allowlist posture. It reduces a model id to a `model_family` label and a `model_tier` label. A rationale, a prompt, and a task name never leave the transcript. Routing enablement is state on `SessionCreatedEvent`, and not an event that the code sends when it starts the router, because the runner process starts the router and the analytics client never initializes in that process. The parent-transcript mirror no longer double-counts. `c7f78f26` deleted the OTel helper and its constants.
**1.7c** There is no browser-side routing telemetry. `web/src/lib/routingTelemetry.ts` once recorded `ROUTING_DISABLED_MID_SESSION` and `ROUTING_FORK_FROM_ROUTED_SESSION`. That file landed, and then it went away with both of its call sites: it carried no user-visible value, and the fork predicate was wrong. `ForkSessionDialog`'s session fetch went away with it, because it existed only to feed that event (`2245f57d`).
### 1.8 The gateway-backed availability gate
**1.8a** The apply layer rewrites a launch's model through the Databricks AI Gateway, because that is where the routable catalog lives. A host whose claude-native or codex inference resolves anywhere else — Bedrock, a plain API key, the vendor CLI's own login — can never run a routed pick. Before `c393842d` such a host still offered Smart Routing. The pick then resolved a model that the pane cannot reach, which is worse than no pick. Plan §10 decision 9 (Bryan, 2026-07-31) therefore redefines availability as two conditions, not one: routing is available on the server (the existing `smart_routing_enabled` capability), **and** the selected host's inference for that harness family is gateway-backed.
**1.8b — The check is host-side, and it is config-only.** The host already resolves the launch config, so the check reuses those resolutions. It launches no process, and it opens no socket. `omnigent/gateway_inference.py` holds it:
**1.8c:**
- `claude_gateway_inference_backed` (`:28`) calls `resolve_native_claude_config(spec=None, refresh_models=False)`, and it requires both an `ANTHROPIC_BASE_URL` and an api-key helper. That pair is exactly the resolution that the runner logs as `configured=True`. The Bedrock path sets `ANTHROPIC_BEDROCK_BASE_URL` with no helper, and a subscription login resolves no config at all. Neither is routable.
- `codex_gateway_inference_backed` (`:46`) resolves the codex launch and reads its provider `base_url`. The url must be a gateway url, and it must end with `/codex/v1`.
- `gateway_inference_map` (`:66`) evaluates each family once, and then fans the answer out over every wire spelling of that family (`CLAUDE_GATEWAY_HARNESSES`, `CODEX_GATEWAY_HARNESSES`). A family whose check *raises* is **omitted** rather than reported as `False`, so the server can tell "not gateway-backed" apart from "could not tell".
**1.8d** One predicate decides what a gateway url is: `is_databricks_ai_gateway_url` (`omnigent/databricks_ai_gateway.py:32`). `c393842d` moved that predicate and its two constant tables out of `pi_native_credentials.py` into a shared module. pi-native needed the same answer for its own base-url rewrite, and a look-alike host must be rejected identically on both surfaces. The predicate accepts two url shapes, an `ai-gateway` DNS label and a workspace host with an `/ai-gateway/` path prefix. It requires https and a trusted Databricks domain suffix in both shapes, because a forged host would otherwise receive a forwarded token.
**1.8e — Transport.** The map rides the host frames that already carry `configured_harnesses`. The host sends it on registration, on every readiness refresh, and in the result of a UI-driven install or credential write (`host/connect.py:1627`, `:1641`, `:1743`, `:2319`). The readiness loop re-computes the map on a worker thread, because the check reads config from disk, and it sends a frame only when the map changes (`:2377-2392`). `HostHelloFrame` and `HostHarnessReadinessFrame` carry the field (`host/frames.py:109`, `:129`), and `_optional_str_bool_map` (`:1941`) parses it. The tunnel writes it onto the live connection and into the store (`server/routes/host_tunnel.py:256`, `:476-480`). `GET /v1/hosts` and `GET /v1/hosts/{id}` echo it as-is (`server/routes/hosts.py:606`, `:648`), and so do the credential-write and install routes, so the UI can flip a badge without a reconnect (`:1316`, `:1419`).
**1.8f — One schema addition.** The whole routing feature adds exactly one column: `hosts.gateway_inference`, a nullable JSON text column (`db/db_models.py:1306`), added by migration `d5e6f7a8b9c0`. Every other routing record lives in existing tables — a decision is a conversation item, and the two labels are conversation labels (§4.3, §1.4). `_parse_gateway_inference` (`stores/host_store.py:142`) reads the column, and it logs and ignores a malformed value rather than failing the read.
**1.8g — `null` means unknown, and unknown never gates.** A host on an older build reports nothing, and a sandbox has no host row at all. Gating those away would hide Smart Routing on every deployment that cannot yet answer. Only an explicit `false` from the host withholds an option. `hostBacksHarnessWithGateway` (`web/src/lib/smartRoutingAvailability.ts:70`) is that rule in one line: `host?.gateway_inference?.[harness] !== false`. We tighten this once hosts have rolled forward (plan §10 decision 9).
**1.8h — Three gates, one per surface, each independent.** Each surface gates on the family that it will actually launch:
**1.8i:**
1. *Configure Claude Code* → the Model row needs the claude family (`NewChatDialog.tsx:2291`, `smartRoutingEligible`).
2. *Configure Codex* → the Model row needs the codex family (the same expression, second branch).
3. The top-level **Smart Routing** harness row needs **both**, because it routes over the five-arm `both` menu (`:2543`).
**1.8j** Independence is the point. A host whose Claude Code runs off a subscription can still offer routing on Codex. The web classification stays in one place, and it grows one new cause, `not-gateway-backed`, ordered *after* `harnesses-unready`: a CLI that is not installed makes its inference config irrelevant, so the more fundamental cause wins the notice slot (`smartRoutingAvailability.ts:33`, `:55-58`). The notice reads "needs Claude Code and Codex running on the workspace AI gateway on <host>" (`:112`).
**1.8k — Deliberately no liveness probe.** Availability is config-level only. A gateway that is configured but down still offers Smart Routing. A probe would add a network round trip to the new-chat landing, and it would still be stale by launch time. Plan §8 records the probe as a follow-up, not as MVP. Plan §8 also records the related follow-up: move the `routes:select` call host-side, so the router's auth and workspace always match the host whose inference we just gated on.
**1.8l** The verification state is in `CUJ_STATUS.md` R9. Both signal halves are live: the staging host reports `true` for both families, and a deliberate flip of the codex provider to a non-gateway key provider reported `codex-native: false` while `claude-native` stayed `true`, which proves per-family independence. The three UI-hidden checks are still owed.
---
## 2. Claude Code CUJ, end to end
### 2.1 UI entry point
**2.1a** Smart Routing is a **Model** choice in *Configure Claude Code*. The client shows the choice only when the server sends the `smart_routing_enabled` capability, and only for `claude-code` and for `codex` (`web/src/shell/NewChatDialog.tsx`, `smartRoutingEligible` at `:2291`). Since `c393842d` the choice also requires the selected host to back the *claude* family with the workspace AI gateway (§1.8, plan §10 decision 9). A `false` on the codex family cannot hide it here. The choice freezes the Effort row to an em-dash, because the router picks effort per task and a live value in that row would be wrong. The choice leaves the Permissions row alone. Plan §10 decision 1 gives the reason for the placement: routing is a property of *which model runs*, so it belongs in the Model dropdown rather than in a fourth control that a user must find. The gear tooltip mirrors the modal (`configSummary`, `:2325`; the Model row that reports routing at `:2332`), so a user can read the active setting without opening the modal. The tooltip also checks eligibility, so a stale `"on"` never renders a misleading routing row.
**2.1b** There is deliberately **no** in-session "Model = Smart Routing" toggle. Main-agent routing is a session-start concept, and an in-session switch would promise a change that cannot take effect (plan §10 decision 4). That is also why the in-session eligibility predicate excludes a native terminal outright (`isCostRoutingEligible`, `web/src/pages/ChatPage.tsx:222`): the dead sentinel is intentional, and not an oversight. The in-session control is *Subagent routing* instead (§5.2), which *is* meaningful mid-flight, because it changes only future spawns. Plan §10 decision 6 records that the two toggles together close the Jul 28 meeting-note requirement for "a toggle for subagent routing as well as main agent routing".
### 2.2 Session create payload
**2.2a** The create carries `cost_control_mode_override: "on"`. It carries **no** model pin and **no** effort pin. That is the whole handshake. Routing runs only when nothing is pinned, so the missing pin is what arms routing. The `session_overrides` column on the created row is the evidence for this layer (`CUJ_STATUS.md` §2.2).
### 2.3 The turn gate and the routing call
**2.3a** `_forward_event_to_runner` (`server/routes/_sessions/orchestration.py:3722`) computes `effective_runner_override` (`:3880`). It takes the per-event override, and otherwise the persisted column. It tests with `is not None` and never with `or`, per the no-invented-defaults rule. The function then evaluates the `_should_route` gate (`:3976`), which requires four conditions:
**2.3b:**
1. Routing is enabled.
2. The event is a `message`.
3. The auto-harness block did not already route this turn.
4. No model is pinned.
**2.3c** In practice the gate passes on the **session's first message only**. The routed turn writes its pick as `model_override`, which is itself a pin, so turn 2 onward reuses the routed model and does not call the router.
**2.3d** `c2f79f1c` added a set of per-branch INFO logs that named *why* the gate declined a route: "auto-harness already routed this turn", "model already pinned (…)", and "event type … is not a message". `3b00d101` removed those logs. Each log restated the condition of the branch that held it, so each carried no information that the gate expression did not already state. The declined-route diagnostics that remain sit one level down, inside `route_turn`, where a reader cannot infer the reason from the caller. There are three such reasons: no routing client is configured, the harness has no candidate models, and the harness bars every candidate.
**2.3e** `route_turn` (`smart_routing.py:1655`) scores the raw user text against the `cc` scenario menu, with the candidates filtered to the Claude family. It caps the text at 4000 characters. It adds no wrapper and no summary, because `task.prompt` is the entire routing signal (plan §1.1).
**2.3f — The turn's candidate set is the pane's own picker rows.** A turn can reach only what `/model` accepts, so the offered menu must be the terminal's vocabulary and not a catalog. `_native_turn_catalog` (`:3585`) supplies it. The function returns `None` for anything other than a claude-native session, and the caller then keeps its own candidate resolution. Otherwise it reads the session's cached picker rows (`_model_options_cache`), and it accepts a row that spells the id as `model` or as `id`.
**2.3g** That cache is where the launch pin and the router disagreed. The cache can hold rows hydrated from the session's **host** *before* the terminal existed (`_load_model_options_from_host`, `helpers.py:8597`), and on that pre-launch snapshot the `opus` alias carries the workspace default, `claude-opus-5`. The launch then pins `opus` to the routed arm, `databricks-claude-opus-4-8` (§2.5). Turn 1 routes about 100 ms after the pin, long before live picker rows can replace the snapshot. The pinned arm therefore had no spelling on offer, and `substitute_model` fell back to sonnet-5 — a correct substitution over a wrong vocabulary. Main already marked such an entry in `_model_options_stale` (`_sessions/common.py:467`), and `_native_turn_catalog` never consulted that set. The 2026-07-31 matrix caught it as row B1: the chip and the pane agreed with each other, and both disagreed with the router's pick.
**2.3h** `3ccf86e3` made turn routing **await** a refetch. `_refresh_stale_native_model_options` (`:3547`) asks the bound runner's `claude-model-options` endpoint, which reports the launch-pinned aliases, and it joins an in-flight fetch instead of starting a second one. It leaves the stale entry in place when no runner is bound or the fetch fails, because a stale vocabulary still beats none. The fix made `_native_turn_catalog` async, so both call sites now await it (`:4049`, `:4386`). Row B1 then landed Opus 4.8 exactly, and the P-TRIVIAL control session still landed sonnet-5, which proves that the fix did not flatten the menu (`CUJ_STATUS.md` §2.1).
### 2.4 Decision persistence and the chip
**2.4a** `_routed_turn_model_spelling` (`orchestration.py:3627`) answers one question *before* the server writes anything: can this pane apply the pick? A mid-session switch on a Claude pane goes in as a typed `/model` command, and that command accepts only the session's own picker vocabulary. The executor skips a routed id outside that vocabulary: it fails open, and the turn runs on the current model. The server therefore runs the *same* translation that the executor runs — `model_vocabulary_env` over the session's cached picker rows, and then `claude_model_command_arg` — and it returns `None` when the pane has no spelling for the pick. Unknown vocabulary means that no picker rows are cached yet, and in that case the function returns the routed id unchanged: the launch env is the only authority, and a guess in either direction is its own inaccuracy.
**2.4b** `None` now stops the *whole* downstream sequence, not only the chip. The first version of this check was `_mark_unapplied_native_turn_decision` (`af42b36c`). It only corrected the verdict to `applied=false`, and the server wrote the pin anyway. Any `model_override` blocks routing (§5.1), so one unapplicable pick disabled routing for that session permanently, and it attributed the session's usage to a model that the session never ran. `3b00d101` collapsed that marker into this pre-write check: no spelling means no `model_override` and no in-band switch, and `_unapplied_routed_verdict` (`:3676`) appends the reason to the rationale and clears `applied`. An honest `applied=false` beats a silent wrong value, and it is what makes the matrix's no-arrows bar meaningful. This is a standing decision rather than one fix: a record must describe the process, so every layer below prefers a visible degradation to a plausible claim (§5.4).
**2.4c** The server sends the chip *after* the runner forward and after `input.consumed`, so the live SSE stream delivers the user bubble first. The client renders the chip below the message, per §1.4, including the `slash_command` skip that only claude needs.
### 2.5 The apply layer
**2.5a** Nearly all the work was here. Four separate problems follow, in launch order.
**2.5b — Launch env pins.** claude-native launches its terminal *before* any turn decision exists, and `/model` can reach only the ids that the launch env spells. So when `launch_metadata.routing_enabled` is true, `runner/native/orchestration.py:5941` pins the family aliases at the router's frozen arms. It calls `claude_config_with_routed_arms_pinned(claude_config, task_v1_claude_arms())`. One accessor, `task_v1_claude_arms` (`smart_routing.py:640`), reads the arm list from `_TASK_V1_CLAUDE_ARMS`, and no other code duplicates that list. Without this pin, `/model opus` landed on the workspace's newest opus (`claude-opus-5`) while the chip claimed the routed arm (`claude-opus-4-8`). The workspace had moved ahead of the frozen router (`972dea9d`, §12 delta 3).
**2.5c — The launch env also had to stop the CLI's beta flags.** Every claude-native turn on the ucode gateway path returned 400 `invalid beta flag`. The apply layer was correct, and the turn still failed. The gateway allowlists beta flags, and it rejects the whole request on one flag that it does not know, so the failure takes the turn rather than the feature. Claude Code 2.1.220 sends three flags that the Databricks staging gateway rejected: `prompt-caching-scope-2026-01-05`, `advisor-tool-2026-03-01`, and, under `ENABLE_TOOL_SEARCH`, `advanced-tool-use-2025-11-20`. `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` is the only client-side way to drop them. The CLI computes the `anthropic-beta` header itself, and it ignores `ANTHROPIC_CUSTOM_HEADERS`. Two sibling paths already set the knob — the plain provider path (`claude_native.py:1925`) and the Bedrock path (`:2027`) — and the plain path *skipped* it when `CLAUDE_CODE_USE_GATEWAY=1`, to keep tool search enabled. The ucode gateway profile is exactly the path that sets that variable, so it never set the knob. `3ccf86e3` sets it in the ucode gateway env too (`_ucode_config_for_profile`, `:1706`; the env dict at `:1802-1814`). The carve-out that the skip protected was empty anyway: tool search rides on `advanced-tool-use`, which is one of the rejected flags, so it was never reachable on that path. The staging gateway's allowlist has since moved and now accepts the CLI's set, verified live on 2026-07-31 with a byte-identical launch env. That is an external condition, and it can regress at any time. The only tell is the pane's 400, so re-check a pane before a demo (`CUJ_STATUS.md` §2.8).
**2.5d — The custom picker slot.** Claude Code has exactly one extra picker slot, and that slot takes an *exact* id. `claude_config_with_launch_model_pinned` (`claude_native.py:437`) puts the launch model in that slot when no alias spells the model. A Smart Routing create hits that case, because the harness CUJ resolves an exact model before the terminal exists (§4). The slot also gives the user a picker row to return to. The code writes both pin sets into the bridge config as `model_env` (`claude_native_bridge.py:889-895`). `MODEL_VOCABULARY_ENV_VARS` (`:891`) names the keys, and `read_model_env` (`:1067`) reads them back. The executor and the server do not share the terminal's environment, and both need to know its vocabulary.
**2.5e — The vocabulary itself.** `omnigent/claude_model_vocabulary.py` is the shared authority. It imports stdlib only, so a hook subprocess can import it on the spawn path. We learned its premise the hard way: Claude's model vocabulary is *closed*. The `model` parameter of the `Agent` tool and the `Task` tool is an alias enum (`sonnet`, `opus`, `haiku`, `fable`), so a catalog id fails schema validation and the spawn dies before it starts. `/model` accepts three things: an alias, the byte-exact `ANTHROPIC_CUSTOM_MODEL_OPTION` value, or an arbitrary id. It accepts an arbitrary id only when a live one-token endpoint probe succeeds mid-turn. A servable catalog id is therefore *not* a spelling that the harness accepts, and the plan's single-step `resolve_selection` had assumed that it is.
**2.5f** The module does three things. It inverts the `ANTHROPIC_DEFAULT_*_MODEL` pins (`alias_pins`). It rebuilds a vocabulary from picker rows (`model_vocabulary_env`). It translates a model id with two functions: `claude_model_alias` (`:132`) for the Agent enum, and `claude_model_command_arg` (`:176`) for `/model`. `claude_model_command_arg` also accepts the custom slot's exact id. `normalized_model_id` (`:68`) sits in the same file and produces the comparison spelling, and the codex spawn audit reuses it (§3.7), so a prefix difference or a case difference never reads as a different model. Translation requires an **exact** pin match. A family segment alone is not enough: with `opus` pinned to `claude-opus-5`, the alias would run a model that nobody routed to while the record claimed the routed one. Both functions fail open, and `None` means "leave the model alone" (`539b00ae`, `af42b36c`).
**2.5g — The routed model never reached the executor.** The plan listed native `/model` injection as existing capability, and it treated the act of applying a routed model as solved plumbing. In reality the runner's `_run_turn_bg` (`omnigent/runner/app.py`) rebuilt the harness request field by field, and it never copied `model_override` off the incoming message. The executor's injection branch therefore never ran, and a routed session kept its launch model. `/effort` is a separate session change, and `/effort` worked, which is exactly why nobody noticed. The code now forwards the field explicitly, and it logs at INFO on every hop: server forward, runner intake, turn dispatch, and the executor's type-or-skip-with-reason step. The chain therefore cannot go silent again (`82cac6fa`). The field reaches the executor from the runner as `ExecutorConfig.model` (`runtime/harnesses/_executor_adapter.py:285`).
**2.5h — The switch itself.** `ClaudeNativeExecutor.run_turn` (`inner/claude_native_executor.py:107`, switch-and-inject at `:155-195`) applies the switch and injects the message as one step under `_inject_lock`. `inject_slash_command("/model <arg>", auto_confirm=True)` runs to completion first, and `inject_user_message` runs next. One lock over both steps removed a second writer on the same tmux pane. Routing used to switch the model with a separate `model_change` event that the server sent, and that event raced the inject, so the message's keystrokes could land in the middle of the switch and disappear. That race was the "routing drops the first message" bug. `_model_command_arg` (`:196`) types the command only when two conditions hold. First, `_should_switch_model` (`:253`) reports that the pane does not already run the model; that check seeds its baseline from the spawn `launch_model`, so turn 1 compares against the model that Claude actually booted with. Second, the vocabulary translation succeeds. Every skip logs its reason.
### 2.6 Live-state visibility
**2.6a** The switch goes through `/model`, so the harness's own UI shows it. The pane echoes the command, and the pane prints the new model banner. That banner is exactly the process-truth handle that the matrix reads (`tmux capture-pane`, plan §11.2). Claude Code writes that pick as the machine's default model, so a routed session leaves the user's next manual `claude` launch on the routed arm. We accept that behaviour for the MVP, because the Model picker in the harness config modal already behaves the same way (plan §10 decision 8).
### 2.7 Subagent routing
**2.7a** `build_hook_settings` (`claude_native_bridge.py:1188`) registers `claude_router_hook` as a `PreToolUse` hook on the agent-tool matcher. That matcher is `AGENT_TOOL_MATCHER` (`hook_scripts/subagent_router.py:59`), and the registration sits at `:1441`. A settings-level hook recurses into nested subagents. The hook rewrites `tool_input.model` through `hookSpecificOutput.updatedInput` with `permissionDecision: "allow"`, or the hook denies the spawn. The Agent tool's `model` is a closed enum, so the hook translates the id through `claude_model_alias`, and it reads the vocabulary out of `bridge.json` (`claude_model_translator`, `hook_scripts/subagent_router.py:425`). That translation turned a 7 ms schema failure into a spawn that ran to completion on the routed arm (`CUJ_STATUS.md` §2.5). The code filters the candidates by family, so a `cc` session can never spawn a Codex arm. §5.2 covers mid-session toggling.
### 2.8 Warnings and telemetry
**2.8a** Claude's hooks are settings-level, and Claude cannot mark them untrusted, so there is no canary path here. Three signals stay visible: the decision chips, the per-subagent model in the sub-agents panel, and one `RoutingDecisionEvent` per decision (§1.7).
---
## 3. Codex CUJ, end to end
### 3.1 UI entry point
**3.1a** The entry point is identical. Smart Routing sits in the Model row of *Configure Codex*, behind the same eligibility check. Codex folds routing into its Model row and shows no separate toggle, so the gear tooltip reports routing the same way that Claude's tooltip reports it (`routingRow`, `NewChatDialog.tsx:2353`). The gateway gate here reads the *codex* family only: a host whose Claude Code runs off a subscription still offers Smart Routing on Codex (§1.8). The codex half of that gate is the one we flipped live, and it is R9's evidence for per-family independence.
### 3.23.4 Create, gate, decision
**3.2a** Codex uses the same create payload (`cost_control_mode_override: "on"`, no pin). It uses the same `_should_route` gate. It uses the same `route_turn` call, over the `codex` scenario menu, which holds all three Codex arms. `is_codex_compatible_model` filters the candidates, and that filter is what lets `databricks-glm-5-2` reach an applied pick (§1.3). The apply layer then writes that pick under the id the gateway serves, which for GLM is not the catalog's own spelling (§3.5h). Chip pairing needs no `slash_command` skip here: codex sends the model over the app-server and writes no transcript item for it.
### 3.5 The apply layer
**3.5a — Three writers** apply a model to codex. Neither §2 nor §3 of the plan expected three, and the three fought each other:
**3.5b:**
1. `thread/settings/update` switches the running thread. It switches only the thread, and it does not write `config.toml`.
2. The top-level `model` key in the per-session `config.toml`. An in-TUI `/model` writes that key, and two omnigent readers read it: the forwarder's mirror and the cost-gate hook.
3. The launch pin `_pin_codex_config_model` (`codex_native_app_server.py:204`) seeds that key, and the launch command passes the same value to the TUI as `-c model="…"`.
**3.5c** The observed symptom was that the routed model survived exactly one turn. The sequence ran as follows:
**3.5d:**
1. The router routed turn N.
2. The executor sent the model to the thread.
3. The rollout genuinely ran the routed model.
4. The forwarder's `turn/started` handler then re-read the **stale** `config.toml`.
5. The handler posted `external_model_change(launch default)`.
6. The server wrote that value as `model_override`.
7. Turn N+1 skipped routing, because a model was already pinned.
8. Turn N+1 sent the launch default back onto the thread.
**3.5e** Every surface settled back on the launch model. `designs/LIVE_MODEL_STATE.md` holds the full trace and the protocol probes behind it.
**3.5f** What landed (`0fcc313f`, `51801530`):
**3.5g:**
- **First-turn send under the inject lock.** The forwarded message carries `model_override` in band. `CodexNativeExecutor.run_turn` sends `thread/settings/update` before the bare `turn/start`, under `_inject_lock`, which is the same switch-then-inject discipline that claude-native uses. This design also closes the launch race by construction: a terminal that the code auto-creates at session bind starts before the first message, so no re-read timing could help, but every turn re-applies `ExecutorConfig.model` and the thread converges on the routed model at turn 1.
- **A `config.toml` mirror on a successful switch.** `write_codex_config_model` (`codex_native_bridge.py:345`) writes the same key that the TUI's `/model` writes, so the cost gate and the mirror agree instead of diverging.
- **Forwarder precedence** (`codex_native_forwarder.py:2737`, `_refresh_model_from_config`). The state tracks two values. `settings_model` holds the last live `thread/settings/updated` value, which is the running thread's truth. `last_config_model` holds the last value read from `config.toml`. A config value that *changed* since the previous read wins, because that change is either a genuine in-TUI `/model` or our own mirror write. An unchanged config value loses to the model that the executor sent. This rule keeps the routed model even when the mirror write fails, and it still obeys a user's in-TUI switch. `_sync_model_change` (`:2774`) posts `external_model_change` only on a real difference, and the server de-duplicates against `conv.model_override`, so there is no echo loop.
- **A `session.model` SSE event when the server writes the routing decision.** `_publish_routed_model` (`orchestration.py:3693`) sends it, so the web dropdown tracks live state instead of waiting for a reload. The event carries the spelling that the session's picker uses, which is a tier alias and not a catalog id, because that is what the dropdown matches against. The native path sends picker vocabulary too (`3b00d101`).
**3.5h — The applied id is the gateway's, not the catalog's (`907f8886`).** Every writer above takes one id, so an id the gateway does not serve breaks the whole turn rather than one surface. The `glm-5-2` arm resolved to the catalog row `databricks-glm-5-2`. That serving endpoint advertises `mlflow/v1/chat/completions` only, and codex speaks the Responses API, so each turn came back `BAD_REQUEST: API type 'openai/v1/responses' is not supported`. Probes on staging and prod (2026-08-01) show the same gateway *does* serve GLM on the Responses API — under the model route `system.ai.glm-5-2`. GLM appears in no discovery listing, so no code can find that name, and we pin it. `_SERVABLE_ALIASES` (`smart_routing.py:649`) holds the one entry, `glm-5-2 → system.ai.glm-5-2`, beside the arm tables. `apply_servable_alias` (`:652`) reads it, keyed on `_bare_id`, so both catalog spellings and a picker's dotted `glm-5.2` map onto the served name. Two call sites apply it: `substitute_model` (`:805`) on every id it returns, and `resolve_selection` (`:1016`) on the exact-match path. `candidate_models` (`runner/subagent_routing.py:443`) offers the router the same spelling and drops the duplicate when the catalog lists both, so a rewritten spawn asks for the id that routing resolves to. This is a spelling, and never a substitution: the router's arm id stays `glm-5-2`, and `_bare_id` strips the alias back to that arm, so the decision records no `raw_model` and the chip draws no arrow (§1.4). The map is per model on purpose, and not a prefix rule. Each entry is one probed fact about one model, and a rule would guess about the rest.
### 3.6 Live-state visibility
**3.6a** We probed codex-cli 0.145.0 live. `thread/settings/update` requires the `experimentalApi` capability, which we already send. It emits `thread/settings/updated`. The app-server broadcasts that notification to other clients that resumed the thread, so the `--remote` TUI receives it and its **bottom status bar updates immediately**. The startup banner box is static, and the `/model` picker does not highlight a model outside its own catalog. Both behaviours belong to the upstream TUI, and we record them rather than work around them. The reversion loop is now fixed, so the thread genuinely stays on the routed model, and `/status`, the status bar, and a resumed TUI all agree.
### 3.7 Subagent routing
**3.7a** Codex needs the most machinery of the three harnesses, and a live failure forced every piece of it.
**3.7b — Hook generation and merge.** `codex_router_hooks_settings` (`inner/codex_executor.py:876`) builds the Omnigent half of a `hooks.json`. That half holds three hooks: a `PreToolUse` gate on the spawn tool, a `SessionStart` canary, and a `SubagentStart` audit writer. The spawn matcher is the regex `.*spawn_agent` (`_CODEX_SPAWN_AGENT_MATCHER`, `:824`), because codex flattens the tool name to `collaborationspawn_agent` on 0.145.x. On the SDK executor path, `write_codex_router_hooks_file` (`:1026`) merges that half with the user's hooks. On the app-server path, `_write_codex_policy_hooks_file` (`codex_native_app_server.py:1008`) merges the policy hooks, the routing hooks, and the user's hooks.
**3.7c — One writer, one file, and probe the version first.** Subagent routing on codex earlier than 0.129 used to *delete the user's hooks*. `_populate_codex_home_config` deleted the symlink to `~/.codex/hooks.json`, because the generated file was going to take that name. Only afterwards did the version check decide to write no file, and the private `CODEX_HOME` then held no `hooks.json` at all. The code now probes the version before it populates the home dir (`codex_native_app_server.py:628-639`), so an unsupported codex keeps the symlink. The root cause was two divergent `hooks.json` writers, and whichever writer ran last erased the other writer's contribution. Both now call one shared function, `write_codex_hooks_file`, which takes a *list* of payloads — the policy payload, the routing payload, and the user's own — and merges them into a single atomic write (`c46ef54d`).
**3.7d — `--dangerously-bypass-hook-trust` is a no-op for app-server-dispatched hooks.** The plan recorded the bypass flag as existing groundwork, and it assumed that the flag handled the trust gate. A live probe matrix showed a different result: the generated routing hooks stayed untrusted and codex *silently skipped* them, while the policy hooks worked, because the code had only ever written the policy module's hashes. Both app-server launch paths now run a trust handshake for the router hook module and write the result. The handshake has three steps:
**3.7e:**
1. Call `hooks/list`.
2. Call `config/batchWrite` to set `hooks.state.<key>.trusted_hash = currentHash` (`_persist_hook_trust`, `:1157`).
3. Call `hooks/list` again to check the result (`trust_codex_router_hooks`, `:1194`; the policy equivalent is `trust_native_policy_hooks`, `:1257`).
**3.7f** Both paths run the handshake immediately after the app-server connects (`:774-781`). Both filter by hook module, so the trust step never touches a hook that the user's own file contributed. The handshake is best-effort and isolated, so a routing-trust failure can never disable the policy gate (`e32c4925`). The flag survives only where it actually works, which is the interactive TUI launch (`_CODEX_BYPASS_HOOK_TRUST_FLAG`, `codex_native_app_server.py:2035`), and nothing on the app-server path depends on it. Both paths treat a codex version that we cannot parse as *supported*, so a flaky probe can never wedge a terminal on a prompt that no subagent can answer (`c46ef54d`).
**3.7g — The false "unenforced" banner, and the disproof of a race theory (`51e36c8c`).** A live banner on 2026-07-31 looked like a trust-versus-launch race: the theory said codex reads `hooks.json` and its trust state once at process start, so a launch that beats the turn-time handshake leaves the hooks untrusted for the process's life. Direct probes against a real isolated app-server **disproved** that theory. Trust written *after* the spawn takes effect — the `config/batchWrite` triggers a config reload, and even a turn already in flight ran its hooks once the write landed. `SessionStart` dispatches when a **turn** begins, never at `thread/start`. `app_server.start()` already completes the handshake before the TUI spawns and before any turn, and the handshake *cannot* precede the spawn because `hooks/list` is an app-server RPC; a comment in `start()` now records that invariant. The real bug was in the watcher: its "first turn" gate released on any `thread/status/changed → active` or `item/*` event, and the MCP startup round emits exactly those without running a turn. The watcher then checked for a canary that had no reason to exist yet and posted a false `subagent_routing_unenforced`; the warning stuck because that session's runner stopped before the repair tick. The fix gates the watcher on real `turn/*` events only (`_event_indicates_turn_started`), and `clear_bridge_state` now deletes a stale canary — and a stale spawn-audit file — at launch, so state from a previous launch can never vouch for, or falsely accuse, the current one. The banner is once again a trustworthy signal: R8's provoke recipe (a deliberately broken hook) still fires it, and a healthy idle session no longer does.
**3.7h — `python -I`.** The plan described the hook scripts as "pure functions around the endpoint call", and it gave no thought to how a process imports them. Codex runs a hook command with the *session workspace* as the cwd, and `python -m` puts the cwd first on `sys.path`. A workspace that holds an `omnigent/` directory therefore shadowed the installed package, and this repo is the single most likely such workspace. Every generated hook then died on import, and none of them said so: the routing gate, the canary, the spawn audit, and the policy hook alike. `_codex_router_hook_command` (`:833`) now runs `python -I -m …`, which matches the bridge MCP command's posture. A subprocess regression test runs the real canary from a workspace that holds a decoy package (`518376ba`).
**3.7i — The canary was a circular detector.** As we first built it, the watcher read the relay ledger that the broken hooks would have written, so a *total* hook failure looked like silence. The watcher now arms on the router advertisement (`subagent_routing_armed`, `codex_native_forwarder.py:5748`), and it anchors on the first turn, because codex sends `sessionStart` at the first turn and not at thread start. `_watch_subagent_routing_enforcement` (`:5809`) posts `subagent_routing_unenforced` within one tick when the canary file is absent (`e32c4925`). cwd shadowing proved that a second failure mode exists, so the message now reads "untrusted, or the hook command failed" (`518376ba`). Teardown cancels the watcher task, so a session that never takes a turn cannot leak it (`c46ef54d`). This watcher caught both codex bugs. `reconcile_spawn_audit` (`codex_executor.py:1153`) also compares the actual `model` in the `SubagentStart` audit against the models that we routed to, and it compares through `normalized_model_id`, because codex reports its own spelling and a prefix difference or a case difference is not a different model (`c46ef54d`).
**3.7j — Encrypted spawn payloads and the no-signal path.** Codex encrypts the spawn `message` in a hook payload, so routing must work from `task_name` plus metadata. The plan knew that, but it assumed that a name is always present. A live spawn frequently carries no task name and no agent name, and an empty task produced a router 400 that the chip reported as a router outage. The first answer matched ucode PR 251 (`e034d86a`), and it did four things:
**3.7k:**
1. It routed an unnamed spawn on the fixed placeholder task `"Codex subagent task"`. That task is short and holds no code, so it deterministically chose the cheap arm.
2. It disclosed exactly what the router scored.
3. It shared one router call across identical no-signal spawns.
4. It announced the rewrite in the TUI through a `systemMessage` (`with_system_message`, `hook_scripts/codex_router_hook.py:96`, which is still the codex hook's post-processor).
**3.7l** Two later commits replaced that answer. `a95105c9` short-circuited a signal-free spawn to allow-with-parent-model, because the `SubagentStart` audit proves that a spawn inherits the routed thread model, which keeps both the chip and the audit reconciliation truthful. `6112e6cb` then deleted the placeholder task and its disclosure marker outright. `_routing_task` (`subagent_routing.py:441`) returns `None` when there is no signal, and `_decide` (`:533`) allows the spawn unchanged on `req.parent_model`, with the rationale "No routable signal (encrypted prompt, no task name); subagent inherits the session model". No code scores a synthetic prompt any more, so no code has to disclose one.
### 3.8 Warnings and telemetry
**3.8a** The server delivers `subagent_routing_unenforced` on the session-status channel, and the client renders it as a session warning banner. §5.3 holds its visibility rule. Decision telemetry uses the shared path (§1.7).
---
## 4. Smart Routing (auto) harness CUJ, end to end
### 4.1 UI entry point
**4.1a** Smart Routing is its own **unlabeled dropdown group above** the Harnesses list (`NewChatDialog.tsx:1160-1177`). The placement is a decision: a router over the harnesses cannot sit inside the list that it routes over, and the group above it reads as "before you pick a harness". `76749e03` deleted the helper blurb for the same reason: Smart Routing routes *over* the harnesses, and it is not one of them. The label changed three times — "Intelligent Routing" → "Auto Harness" → "Auto" → **Smart Routing**. Plan §10 decision 2 recorded the third of those, and it recorded the rationale that still governs: the chip is a glance-level affordance, so the long forms crowded it and read like jargon, and the meaning belongs in the hover description ("Harness and model picked per task by intelligent routing") rather than in the label. Decision 2 named "Auto" as final; the fourth rename to **Smart Routing** superseded it later, under the same rationale. That final rename (`e5c8a160`) swept every user-facing surface: the harness chip, the dropdown item, the Configure modal, the Claude Code and Codex Model option, the in-session subagent row, the decision chip and card headers, the `sys_advise_models` tool title, and the subagents-panel tooltip. The rename deliberately left the API fields, the storage keys, the sentinels, and the telemetry names unchanged. The labels live in `web/src/lib/agentLabels.ts` (`SMART_ROUTING_LABEL`, `AUTO_HARNESS_ID` = `"auto"`, `AUTO_NATIVE_HARNESS_ID` = `"auto-native"`).
**4.1b — Persistence and degrade.** The client remembers the pick in the same last-harness store that it uses for every other harness (`handleSelectSmartRoutingHarness`, `:2999`), and it stores the pick under the placeholder wrapper agent's id as `AUTO_NATIVE_HARNESS_ID`. The client cannot use a restored sentinel in three cases: routing is disabled, this host has no native arm, or this host does not back both arms with the gateway (§1.8). In each case the client falls back to the default pick. A click on the placeholder wrapper's own row clears the remembered sentinel, so the explicit choice is what survives a reload (`ee26ff7c`). The landing's "Smart Routing dropped" notice was itself wrong for a while: it always blamed host readiness, it appeared for a `localStorage` pick that was never available during that visit, and it stacked with the harness-readiness notice. The client now derives the cause and quotes it, the notice now requires a loss of availability *while the landing is open*, and the readiness notice wins the slot (`2245f57d`, `9c81bbb8`). `c393842d` added a fourth cause, `not-gateway-backed`, and ordered it last (§1.8).
**4.1c — Configure Smart Routing is Permissions-only, locked to a disabled "Default".** The modal shows no Model row and no Effort row, because the router owns both. The create payload carries **no** permission override at all, so the chosen harness inherits the machine's own defaults, byte-identical to a native launch of that harness. The client no longer reads a stale stored mode for the sentinel (`320b6b59`). Byte-identical is the point of the empty payload: a routed session must be indistinguishable from a native launch of the same CLI on its own default permission mode, so routing introduces no new sandbox posture. We researched a cross-harness permission mapping on 2026-07-29 — the Claude permission modes against codex `approval_policy` × `sandbox` × permission profiles, with a proposed Read Only / Default / Auto / Full Access set — and we then deliberately deferred it. The four-way mapping holds enough asymmetry that a wrong version would loosen sandboxing without a message. Showing the row disabled keeps the slot visible until that mapping lands and unlocks the remaining options (plan §10 decision 3). The write-up stays in the session scratchpad, and it moves into `designs/` when we adopt it.
### 4.2 The create payload
**4.2a** Two fields do the work. The first is `harness_override: "auto"`. The second is `smart_routing_message`, which carries the user's first-message text (`server/schemas.py:1380`, sent at `NewChatDialog.tsx:3248`). That field carries the text as the client *delivers* it, which means the mention preamble plus sanitization. It does not carry the raw box contents, so the router scores what the harness will actually see (`2245f57d`). The create needs a concrete `agent_id`, so the client binds the Claude native wrapper as a *placeholder*. The picker hides that row's highlight while the sentinel is active, so the row does not look like a Claude Code pick.
### 4.3 Create-time routing over native harnesses
**4.3a** A native session's harness cannot wait for the first message. The bundle-agent auto path can wait, but a native terminal launches as soon as the session row exists. `_resolve_native_smart_routing` (`orchestration.py:5754`) therefore routes at create time, in five steps:
**4.3b:**
1. It authorizes the caller's `host_id` (`resolve_host_owner`, `_host_launch.py:49`, called at `:5799`).
2. It reads the host.
3. It filters `AUTO_NATIVE_ROUTING_HARNESSES` (`smart_routing.py:1413`) down to the CLIs that the host actually installs (`_installed_native_harnesses`, `:5642`).
4. It calls `route_session_harness` over the `both` five-arm menu, with candidates from `_pre_session_model_catalog` (§1.3).
5. It returns the chosen native **wrapper agent name**.
**4.3c** The caller rebinds `agent` to that wrapper (`:5908-5928`). From that point the create is byte-identical to a normal native create, including the terminal launch, and nothing launches twice. The caller passes the routed model into `validate_session_model_metadata` (`:5946`) as the session's `model_override`, so the model reaches the CLI as a `--model` argv element at launch. `--model` is a different contract from `/model`, and a more permissive one: `--model` takes any string verbatim. This is why the harness CUJ needs the custom picker slot (§2.5): the session boots on an exact id that no alias spells.
**4.3d** The order of the host authorization is not incidental. As we first wrote this function, it read the host's harness readiness and sent `HostModelOptionsFrame`s over the host's live connection, and it did both about 150 lines *before* `_validate_session_workspace` authorized the caller. That order leaked the presence of a CLI and of a catalog on a foreign host, and it delivered frames into another user's host connection. `resolve_host_owner` now runs first (`3b00d101`).
**4.3e** This path writes `harness_override` as `None` rather than `"auto"`. A native wrapper rejects a harness override, and a sentinel left behind would make the first message re-route a terminal that already runs. The code instead writes a durable label, `omnigent.routing.auto_harness` (`AUTO_HARNESS_LABEL_KEY`, `subagent_routing.py:94`). The first message consumes the sentinel, so nothing else would survive to answer one question: was this session genuinely Smart Routing? (`0fb7ea95`).
**4.3f** An unavailable router does not fail the create. The create lands on the first installed CLI with that CLI's own default model, and it returns an `error` string that the routing card shows. `_resolve_native_smart_routing` returns `None` for the agent only when the host installs no native CLI at all, which is a hard 400.
### 4.4 The double-resolution fix
**4.4a** This CUJ is where the seam bug of §1.1 was fatal rather than cosmetic. `route_session_harness` passed the client's already-local pick back through `resolve_selection`. No arm matched, so `resolve_selection` returned `None` for the harness, and the create used the fallback harness without a message. That failure is the "auto sessions lost their harness" failure. `972dea9d` fixed it by resolving from `raw_model` and making `resolve_selection` idempotent. `36a17c65` then removed the second resolution pass entirely, so `route_session_harness` applies the client's `model` verbatim and derives a harness only when the client names none. The matrix re-run on 2026-07-30 closed rows A1A4 on the first fix: the panes showed Opus 4.8, and the log held zero `harness=None` warnings. The 2026-07-31 round re-confirmed all four rows and again logged zero `harness=None` warnings.
### 4.5 Decision, chip and live state
**4.5a** The server writes one **session**-scope decision, and that decision carries both the harness and the model. The client renders it as the decision card under the first user message, per §1.4. After that the session behaves exactly as §2 or §3 describes, and the winning arm decides which one. A later model change on that session therefore travels the winning arm's own apply path: the codex thread send, or the claude `/model` injection.
### 4.6 No re-routing after session start
**4.6a** Turn 2 must not produce a second session-scope decision. The harness pick is *physical*: a session is a live `claude` process or a live `codex` process, with its own bridge, config, and pane. A re-route on turn 2 therefore means that the code kills and relaunches a process in the middle of a conversation. Plan §10 decision 7 records this rule for the harness pick (Bryan, 2026-07-29, re-affirmed 2026-07-30), and decision 4 records the same cadence for the model. Per-turn harness routing waits on the router's unused `session_history` field. §5.1 holds the cadence decision and the experiment that tested it.
### 4.7 Cross-harness subagents, only here
**4.7a** A spawn under a genuine Smart Routing session may pick either family. A spawn anywhere else may not, and the naive version of this rule was a live bug. `_force_auto_for_child` treated *any* routed parent as Smart Routing, so every child of a plain codex session or a plain claude session got `harness_override: "auto"`. The router then routed that child over a family-mixed catalog, and the child inherited the cross-family escape hatch. We found the bug live: one codex parent had nine forced-auto children, and some of them ran claude-opus.
**4.7b** `5a397d6f` made three changes. The auto treatment now requires the parent to actually run in auto mode (`auto_harness_session`, checked at `orchestration.py:5985`). Child routing now passes the parent's family as a candidate filter (`allowed_family`, `:3998-4013`). `route_turn` now removes out-of-family models from the self catalog. `46a50556` closed the last escape: the post-verdict harness redirect (§1.1) used to return `codex` or `claude-sdk` whether or not the caller offered them, so a family-restricted child could still land outside its family. The redirect now declines unless the caller offered the replacement, and it swaps the model instead. The same family rule backs the hook path for in-harness spawns, so a native spawn and an omnigent child session cannot disagree.
**4.7c — A cross-harness pick is delivered as a soft redirect, and that is deliberate.** No harness lets a hook move a spawn to another harness. The `redirect` verdict therefore blocks the in-harness spawn and tells the agent to re-issue it through `sys_session_send` with the routed harness and model. The agent may ignore that instruction. We accepted the soft form, because the worst case is a spawn that does not happen, and the alternative — letting the spawn run in place — is a spawn on the wrong harness. "Did not spawn" beats "wrong model" here for the same reason that it does for an unoffered pick (§1.5). Plan risk 3 records the compliance exposure, and the decision records are how we measure the redirect-follow rate during the pilot. The live A-sub row shows the verdict arriving in the pane as exactly that instruction.
### 4.8 Warnings and telemetry
**4.8a** Telemetry is the same as §1.7. The routing card's `error` string is the only auto-specific surface: a degraded create explains itself through that string ("Routing unavailable; using the default native harness.").
---
## 5. What all three CUJs share at run time
### 5.1 Session-start routing, then session-pinned
**5.1a** Routing runs **once per session**, on the session's first message, and the model that routing picks stays for the session's life. There is no per-turn re-routing. `_should_route` requires that no effective override exists, and the routed turn itself writes `model_override`, so the pin that the router installs is what stops turn 2 from routing again. For the Smart Routing harness the code writes the same pin at create time instead.
**5.1b — The cadence is a product decision, and it was tested against its alternative.** Plan §10 decision 4 records it (Bryan, affirmed 2026-07-30): the router runs once, the pick persists, and later turns do not re-route however different they look. On 2026-07-30 we implemented per-turn re-routing anyway (`23cfdbc2`). It held a provenance gate, so a `model_override` that the *router* had written stayed routable through the `omnigent.routing.decision` label instead of pinning the session, plus an apply-skip when a new pick matched the old one. It worked live. Bryan then ruled that routing is session-start only, and the whole behaviour was reverted the same day (`720b145b`, with docs `05a4b9e5` and `88ec745f`). The revert restored a fully-verified state, and the 15/15 matrix at `de2acfdb` is that state. Per-turn routing stays gated on the router's unused `session_history` field, and it is out of MVP scope (plan §12 delta 24).
**5.1c — A manual pick blocks routing**, by the identical rule: any `model_override` is an effective override, whoever wrote it. That is pre-existing session semantics rather than a routing rule, and it is why the two controls are mutually exclusive in the UI as well — picking a model turns routing off in the same modal (`ChatPage.tsx:5770-5772`). The decline is silent. The gate reaches no `route_turn` call, and the observable signal is the *absence* of a `routing turn session=` log line for that turn. The 2026-07-31 round verified it live: turn 1 routed to sonnet-5, a `PATCH` pinned that model, and a P-OPUS turn 2 produced no second decision after 60 seconds (`CUJ_STATUS.md` §2.1).
**5.1d — A decision persists in the transcript, not in a new table.** Every routing record is a conversation item, and the two joins are conversation labels: `omnigent.routing.decision` (§1.4) and `omnigent.routing.auto_harness` (§4.3). Nothing about a session-pinned decision needs a schema of its own — the pin is `model_override` on the conversation row, which already exists. The whole feature therefore adds exactly one column anywhere, `hosts.gateway_inference` (§1.8). That kept the migration surface to one reversible, nullable addition.
**5.1e** The per-branch "not routed because X" INFO logs first made the cadence legible, and they let us read the codex reversion loop as "model already pinned" rather than "routing broken". We removed those logs once the gate expression said the same thing on its own (§2.3).
### 5.2 Subagent routing: inherit, override, per-call
**5.2a** `subagent_routing_override` on the session takes `"on"`, `"off"`, or `null`, and `null` inherits the session-start choice: a Smart Routing main agent routes its subagents, and a manually pinned main agent does not. The in-session gear row shows this control for Claude Code sessions, for Codex sessions (native and SDK), and for Smart Routing sessions. A user can toggle it at any time, and the new value takes effect on the next spawn (plan §10 decision 5; `0fb7ea95`, web `1d030f22`, sticky per-harness default `2a415cf4`). This is the one routing control that *is* meaningful mid-session, and the reason is narrow: it changes only future spawns, so it promises nothing about the turn in flight (§2.1).
**5.2b — "Inherit" is its own option** in that row (`web/src/pages/ChatPage.tsx:5673`, `:5781`). The row used to collapse that option onto the effective `on` or `off`, and that collapse broke the row in two ways. Radix sends no `onValueChange` for the value that it already displays, so a second pick of the inherited value wrote nothing. The row also labelled the option "Default" for sessions that the spec routes by default (`2245f57d`).
**5.2c** The original design read the enforcement decision once, at launch, so a routed session enforced subagent routing forever and a mid-session toggle could not work. Two changes make the toggle work:
**5.2d:**
1. The code re-reads the enablement gate **per call** on the way in (`subagent_routing_enabled`, §1.5).
2. The code installs the hooks whenever a server client exists. It no longer installs them only for a session that starts routed, so a mid-session toggle to `on` has an endpoint to reach.
**5.2e** We checked both directions live on both harnesses (matrix rows B-tog and C-tog). With the toggle off, the gate declines per call, the server writes no decision, and the spawn proceeds. With the toggle on, the router routes the very next spawn. A toggle sends `RoutingSettingChangedEvent`.
### 5.3 Warning hygiene
**5.3a** The code now installs the hooks unconditionally, so the canary posts its warning on a session that has routing *off*, which is a direct consequence of the previous change. The recorded observation stays durable. Each session-snapshot build derives visibility again, through the same effective gate that the relay applies: the override, and otherwise the session's own cost-control state or the parent's state (`orchestration.py:687-697`). A mid-session toggle to `on` therefore shows the warning, and a toggle to `off` hides it, and neither toggle posts the warning again (`5444a1a4`).
**5.3b** Four follow-on changes made the banner behave:
**5.3c:**
- **Warnings are clearable.** Two call sites call `session_warnings.clear(session_id, codes=None)` (`runtime/session_warnings.py:123`). A publisher that posts an empty list calls it, scoped to `EXTERNAL_WARNING_CODES`, which are the codes that this publisher's own check covers (`routes_events.py:757`). Session delete calls it unscoped (`:1704`), because the session is gone and every code goes with it. The relay path does *not* clear: a relayed spawn proves only that one hook ran, and the blanket clear there wiped exactly the warnings that the publisher had just raised (`routes_hooks.py:1370-1377`), so the canary watcher owns the repair and posts the warning on its next check. The code allowlists the codes and reduces the payload to known string fields, so the index cannot grow arbitrary shapes (`3b00d101`, empty-list clearing `c46ef54d`).
- **The banner can appear without a reload.** The server records a warning while the session runs, and warnings have no event channel of their own, so the web client polls the open session's snapshot. That poll first asked for `refresh_state=true` on *every* fetch, which dropped the runner's skills cache and model-options cache twice a minute, per open session, forever. Only the cache-cold read refreshes now, and the poll stops after two consecutive 404s (`2245f57d`, `9c81bbb8`).
- **An unknown code cannot break the header.** The copy table in `SessionWarningBanner` is a `Map` (`web/src/shell/SessionWarningBanner.tsx:26`), and not a plain record. A wire code that names an `Object.prototype` member, such as `__proto__` or `toString`, passed the old filter and then threw during rendering (`9c81bbb8`).
- **The banner shifts no layout.** The strip used to render in flow, between the chat header and `<main>`, so a warning that arrived mid-session pushed the whole conversation down. That is the worst moment to move the text that a user is reading. The strip is now an overlay on the chat header's own positioning contract: absolute inside the chat column, `top-14`, `z-20` (just under the header's `z-30`), and stopping short of the workspace panel through `--workspace-panel-offset` (`SessionWarningBanner.tsx:76`, mounted at `AppShell.tsx:1357`). The container ignores pointer events so the chat under it stays scrollable, and each warning row takes them back. Several warnings stack downward inside the overlay (`60b39177`).
### 5.4 Fail-open, with the reason attached
**5.4a** Every path fails open. This is the governing decision of the whole feature: routing is an **advisory** layer over a system that must keep working without it. A router outage leaves the turn unrouted, and the UI shows `last_error`. The task_v1 rollback incident proves that behaviour: the 400s produced turns that ran unrouted and logged the reason, rather than broken turns.
**5.4b** The same philosophy sets the availability rules elsewhere. An absent `gateway_inference` map never gates an option away (§1.8). An unparseable codex version reads as *supported*, so a flaky probe cannot wedge a terminal (§3.7). A hook script always exits `0` (§1.5). A vocabulary translation that fails returns `None`, which means "leave the model alone" (§2.5). In each case the fallback is the unrouted behaviour that shipped before routing existed.
**5.4c** The spawn path was *meant* to be the exception. A configurable `subagent_fail_mode` with the setting `closed` would refuse the spawn, on the argument that an unrouted spawn silently voids the determinism guarantee. Every failure branch already allowed the spawn, so the knob promised enforcement that it never delivered. `6112e6cb` deleted the knob, and the module docstring of `subagent_routing` now documents the gate as advisory (§1.5).
**5.4d** The one surviving `deny` is a router pick outside the offered menu, which is a *wrong* answer rather than a missing one. In every case the decision record is the only durable log of a routing decision, because AIGW has no server-side decision logging yet. An honest `applied` value (§2.4) and a visible difference between the raw pick and the applied model on the chip (§1.4) are therefore load-bearing rather than cosmetic.
---
## 6. CLI entry points, end to end
**6a** Smart Routing shipped web-only. A CLI user who wanted the server to pick a model had to start the session in a browser, because a native TUI's turns originate in the pane: the server never sees the first message before inference, so the turn gate of §2.3 — the gate that routes a plain claude or codex session — never fires for a CLI-driven one. §6 is the fourth surface, and it routes **before** anything starts. The harness pick is physical (a session *is* a live `claude` or `codex` process) and the model is applied as a launch flag, so there is nothing left to change after the fact.
**6b** Three commits hold the surface, and they split cleanly:
**6c:**
- `8f3c0c60` (merged as `6f2893d9`) is the **server half**: create-time MODEL routing for a create already pinned to one native harness. This is the genuinely new server behaviour, and §6.4 holds it. It touches `orchestration.py` and its test, and nothing else.
- `8d7c9cb2` is the **CLI half**: the flags, `omnigent/smart_routing_cli.py`, the prompt parameter, and the two dispatch paths. It touches **no** server file, and it did **not** extend `_resolve_native_smart_routing`.
- `b10a7239` fixes one import in `smart_routing_cli.py`; §6.8 holds it.
**6d** Two rules shape the whole surface, and the module docstring of `smart_routing_cli.py` (`:16-23`) states both. **Preflight is a hard error**: routing the server cannot do, or a host whose inference is not AI-Gateway-backed, means the pick could not be applied, so the CLI says so and stops (plan §10 decision 9, §1.8). **Routing itself fails open**: once preflight passes, any router or create failure returns a decision with one notice line and no pick, and the launch always happens (§5.4).
### 6.1 Commands, flags and tiers
**6.1a** Three commands take `--smart-routing`, and they form three tiers. Tier 1 is the web UI, which §2 to §4 already describe.
**6.1b:**
| Invocation | Tier | Routes |
| --- | --- | --- |
| `omnigent claude --smart-routing -p "…"` | 2 | the model; harness stays claude-native |
| `omnigent codex --smart-routing -p "…"` | 2 | the model; harness stays codex-native |
| `omnigent run --harness <native> --smart-routing -p "…"` | 2 | the model; harness stays as pinned |
| `omnigent run --smart-routing -p "…"` (no `--harness`, or `--harness auto`) | 3 | the harness **and** the model |
**6.1c** `omnigent claude` also gains `-p/--prompt` (`cli_native.py:162-167`). `omnigent codex` already had one. The `--smart-routing` flag is declared three times, once per command surface (`cli_native.py:168-174`, `:344-350`, `cli.py:6896-6902`, help text `_SMART_ROUTING_HELP` at `:5629`).
**6.1d** `run --harness claude-native -p …` and `run --harness codex-native -p …` are now **accepted** rather than rejected. `_NativeTerminalDispatchSpec` gained a `prompt_param` field (`cli.py:5910`), and the claude and codex specs set it (`:5918`, `:5925`); kiro-native already had one (`:5953`). `_dispatch_native_terminal_harness` rejects `-p` only for a spec that names no prompt parameter (`:6040`), and it forwards the text otherwise (`:6098-6099`).
### 6.2 How the prompt travels
**6.2a** The prompt is **routed, not dispatched**: the router scores it, and the TUI then delivers it as its own first input. Each wrapper keeps the delivery it already had.
**6.2b:**
- **claude-native — argv.** Claude Code takes the initial prompt as a trailing positional argument, so `run_claude_native` appends it to the launch args after the resume args are stripped (`claude_native.py:722-727`). One argv entry keeps newlines, blank lines, and quotes intact, and it is persisted for the runner on the remote path. Never a tmux paste. `tests/test_native_initial_prompt.py` pins all five cases, including a multi-line prompt.
- **codex-native — its existing first-turn delivery.** The remote path posts the text to `POST /v1/sessions/{id}/events` as a `message` event (`_post_initial_prompt`, `codex_native.py:968`); the local app-server path starts the turn directly (`_start_initial_turn`, `:2426`). The CLI adds nothing here.
**6.2c** The codex path therefore *does* send the first message through the server. It still produces exactly one decision, because the create already wrote `model_override` and any pin closes the turn gate (§5.1). That is the same mechanism that stops turn 2 of a web session from re-routing.
**6.2d** Only a prompt-capable native harness is routable. `_smart_routing_capable_harness` (`cli.py:6118`) is the predicate: it resolves the harness to its native coding agent and requires that agent's dispatch spec to carry a `prompt_param`. Bare `claude` canonicalizes to the SDK harness and is therefore not routable here.
### 6.3 Preflight
**6.3a** `check_smart_routing_available` (`smart_routing_cli.py:95`) runs two config-level gates, and no liveness probe (§1.8k):
**6.3b:**
1. **The server can route.** `GET /v1/info` must report `smart_routing_enabled: true`. Otherwise the error names the server and points at `--model`.
2. **This host's inference is gateway-backed.** `GET /v1/hosts` carries the `gateway_inference` map of §1.8. Each harness family the route may pick must not be an explicit `false`. The error names the harness, quotes the host's own reason string, and points at `omnigent configure harnesses`.
**6.3c** `smart_routing_families` (`:77`) decides which families to check, and it mirrors the web's per-surface gating exactly (§1.8h): a fixed harness needs only its own family, and the auto route needs **both** `claude-native` and `codex-native`, because it picks across the five-arm `both` menu. `_gateway_state` (`:318`) keys off the canonical harness spelling and falls back to the caller's spelling, because the map is keyed by wire spellings and never by a bare family name.
**6.3d** Unknown never gates, exactly as it does on the web (§1.8g). An absent map, an absent entry, an unknown host, or an unreadable response all mean "could not tell", and `_get_json` (`:350`) returns `{}` for any failure rather than raising.
**6.3e** The host id is resolved before preflight, and it is resolved defensively. `_smart_routing_decision` (`cli.py:6193`) calls `_ensure_host_daemon` first — the server builds the router's candidate catalog from the bound host's model-options frames, so the daemon has to be connected before the create — and then passes the local identity through `known_host_id` (`smart_routing_cli.py:273`), which returns it only when it appears in `GET /v1/hosts`. Binding a session to a host the server has never seen would 4xx the create and cost the verdict, so an unregistered host degrades to a hostless route. A missing identity file degrades the same way.
### 6.4 Create-time model routing for a fixed harness (the server half)
**6.4a** Tier 2 needed new server behaviour, and `8f3c0c60` added it as a **parallel** path rather than an extension of `_resolve_native_smart_routing` (`:5904`, which is still the auto path of §4.3). The auto path routes harness *and* model. Tier 2's harness is the caller's own choice, so only the model is routed.
**6.4b** `_fixed_native_routing_harness` (`orchestration.py:5818`) is the gate. It returns `"claude-native"` or `"codex-native"` — the `AUTO_NATIVE_ROUTING_HARNESSES` pair (`smart_routing.py:1441`) — only when every condition holds: `cost_control_mode_override == "on"`, a non-empty `smart_routing_message`, `harness_override != "auto"`, no `parent_session_id` and no `sub_agent_name`, and no client-pinned `model_override`. `_create_resolved_harness` (`:5785`) resolves the harness before any row exists, from the wrapper agent name, else `harness_override`, else the agent spec.
**6.4c** Everything else keeps routing where it already did: an SDK harness on its first turn through the server (§2.3), the auto path on its own create branch (§4.3), and a child or sub-agent session on the spawn path, which knows the parent's family (§1.5).
**6.4d** `_resolve_fixed_native_model_routing` (`:5857`) does the routing. Candidates come from `_pre_session_model_catalog` for that one harness (§1.3) — no runner exists yet — and `route_session_harness` is offered that single harness, so the seam can only change the model. Two fail-open exits, both of which pin nothing and put the reason on the card: the router returned no model, or the pick fails `models_in_family`. The second guard is not redundant: with one harness on offer the seam has nothing to redirect an out-of-family pick onto, and the launch would then pass a `--model` the CLI cannot run.
**6.4e** The host authorization is shared, and the order still matters for the reason §4.3d gives. `8f3c0c60` lifted the auto path's authorize-first block into `_routing_host_for_create` (`:5754`), and both create paths now call it. It resolves ownership through `resolve_host_owner` before anything is read from the host or landed in its owner's connection.
**6.4f** The caller runs the branch at `:6064-6084` and wires its result in two places. `validate_session_model_metadata` receives the routed model as the row's `model_override`, so the model reaches the CLI as a launch flag (`:6103-6113`); and the create emits the decision — `_emit_server_routing_decision(scope="session", harness=…)` plus `_stamp_routing_decision_label` on success, or an `applied=false` card carrying the reason when routing produced nothing (`:6424-6446`). Session-start cadence is unchanged: the pin closes the per-turn gate exactly as the auto path's create pin does (§5.1).
### 6.5 The create the CLI drives
**6.5a** The CLI creates the session itself, through the standard JSON `POST /v1/sessions`, and the wrapper then **attaches** to it. One session, routed at create. Nothing is created twice and nothing is deleted, so the row the server wrote already carries the agent binding, the wrapper's presentation labels, the routed model, and the decision card — which is how a routed CLI launch gets the same chip and provenance the web UI gets.
**6.5b** `create_smart_routing_session` (`smart_routing_cli.py:144`) sends the routing contract: `cost_control_mode_override: "on"`, `smart_routing_message: <prompt>`, `host_type: "external"`, the provenance label `omnigent.smart_routing: "cli-route"` (`ROUTING_SESSION_LABELS`, `:48`), and — on the auto route only — `harness_override: "auto"` (`AUTO_HARNESS`, `:44`). A fixed harness needs no override: it comes from the bound wrapper agent, which is what §6.4b resolves. `host_id` rides along with `workspace` (the launch cwd), because the server stats that path on the host to validate the agent's cwd boundary and rejects a `host_id` without one.
**6.5c** `_routing_agent_id` (`:256`) picks the built-in agent to bind. The bound agent only has to exist, because the verdict rides on the session row: a fixed harness uses its own `*-native-ui` built-in, and the auto route uses the claude-native built-in as the placeholder — the same placeholder the web client binds (§4.2).
**6.5d** The verdict is read back off the create response. The resolved harness is `SessionResponse.harness`, **not** `harness_override`: a native row leaves the override null on purpose (§4.3e). The model is `model_override`. When either is missing the code re-reads the session snapshot once (`:214-217`), and `_clean_str` (`:387`) normalizes both.
**6.5e** The function never raises. A rejected create, an unreachable server, or a response with no session id returns `_unavailable` (`:238`) — no session, no model, and one notice line — and the caller launches a plain wrapper session instead. A create that lands but picks no model keeps the session and carries the softer "launching on the harness default" notice. `_dispatch_smart_routing` prints whichever line applies, and prints `omnigent: Smart Routing picked <harness> on <model>.` when the router answered (`cli.py:6249-6257`).
### 6.6 Rejected combinations
**6.6a** `--smart-routing` requires `-p`. Routing needs text, and the degraded route-on-turn-2 mode is not shipping, so an empty invocation is a `click.UsageError` that points at the two surfaces that do work: `-p`, or the web UI (`_SMART_ROUTING_NEEDS_PROMPT`, `cli.py:6110`; `_require_smart_routing_prompt`, `:6141`). Both subcommands validate it **before** any side effect — no daemon spawn, no server discovery — so a missing prompt fails instantly (`cli_native.py:209-212`, `:382-385`).
**6.6b** Three more rejections, all of them loud:
**6.6c:**
1. **A resume.** `--resume <id>`, a bare `--resume` picker, `--continue`, and the deprecated `--session` are all refused, because routing is a create-time decision and a routed launch is therefore always a new session (`_reject_smart_routing_resume`, `cli.py:6154`; the subcommand call sites at `cli_native.py:240-245`, `:392-397`).
2. **An AGENT** on `run`. A routed session is a native TUI, where an agent spec's prompt and tools are never consulted. The error offers both alternatives: drop the AGENT to route the harness too, or pass `--harness claude-native` to route the model only (`:6394-6399`).
3. **The REPL-only options** `--system-prompt`, `--tools`, `--log`, `--debug-events`, `--fork`, and `--no-session`. These are the same options the plain native dispatch rejects, for the same reason: a routed launch is still a TUI attach, so they would be silently dropped (`:6375-6391`).
**6.6d** A `--harness` that is native and prompt-capable but *not* in `AUTO_NATIVE_ROUTING_HARNESSES` — kiro-native today — passes the CLI's own routability check and then routes nothing, because §6.4b's gate only fires for claude-native and codex-native. The launch still happens, behind the "did not pick a model" notice. §7 records it.
### 6.7 Launch with the routed model
**6.7a** Each surface applies the routed model the way its wrapper takes one:
**6.7b:**
- **claude-native** appends `--model <routed>` to the wrapper's pass-through args (`_with_routed_model_arg`, `cli.py:6175`, called at `cli_native.py:286`). A `--model` the user typed themselves wins, in either the `--model x` or `--model=x` spelling: they asked for that model explicitly, and routing is a default-filling service.
- **codex-native** takes the model first-class, so the routed value is assigned to the `model` parameter — but only when the user passed no `--model` of their own (`cli_native.py:425-435`).
- **`run` (both tiers)** goes through `_dispatch_native_terminal_harness` with `model_from_cli=True` whenever a routed model exists (`cli.py:6304-6325`), so a wrapper that only forwards a model the user asked for still receives this one.
**6.7c** `--model` is the permissive contract of §4.3c: it takes any string verbatim, which is why a routed create can boot on an exact catalog id that no alias spells. The claude launch env then puts that id in the custom picker slot (§2.5d), so the pane has a row to return to. A GLM pick arrives as the gateway's own model-route spelling, not the catalog's (§3.5h).
**6.7d** Tier 3 chooses the wrapper from the harness the server bound. `_dispatch_smart_routing` (`:6261`) reads `decision.harness` through `_smart_routing_capable_harness`, and falls back to `_SMART_ROUTING_FALLBACK_HARNESS` (`:6115`, `claude-native`) behind a notice when the create resolved nothing, or resolved a harness the CLI cannot hand a prompt to. Either way the launch happens.
**6.7e** In every case the wrapper attaches to `decision.session_id`, and `None` (the create failed) lets the wrapper start its own session as it always did.
### 6.8 Decision persistence, and the agent-name fix
**6.8a** The CLI persists **nothing of its own**. Every routed CLI launch produces the same records the web UI produces, because the server writes them on the same create: one session-scope `RoutingDecisionData` conversation item, the `omnigent.routing.decision` label joining the row's `model_override` back to it, and — on the auto route — the `omnigent.routing.auto_harness` label (§1.4, §4.3e, §5.1d). Tier 2's records come from §6.4f; tier 3's come from the auto path of §4.3, unchanged. The only CLI-specific record is the session label `omnigent.smart_routing: "cli-route"` of §6.5b, which is provenance and not a decision.
**6.8b** `b10a7239` fixes an import that would have crashed the whole surface on this branch. `8d7c9cb2` was authored against a tree where `omnigent/native_coding_agents.py` exported `CLAUDE_NATIVE_AGENT_NAME`. On `routing-mvp` the native-agent records live in `omnigent/harness_plugins.py`, and that module holds no such constant, so importing `smart_routing_cli` raised `ImportError` — which is the first thing any `--smart-routing` invocation does. The module now imports `CLAUDE_NATIVE_CODING_AGENT` from `harness_plugins` and derives the name from its `agent_name` field (`smart_routing_cli.py:38-41`), which is the pattern the server already uses (`server/app.py:169`). The value is unchanged: `claude-native-ui`.
### 6.9 What is verified, and what is not
**6.9a** `tests/cli/test_smart_routing_cli.py` (54 cases) and `tests/test_native_initial_prompt.py` (7 cases) cover the CLI half against a mocked server: the preflight matrix in both directions, the create contract per tier, the fail-open branches, the two dispatch tiers, the tier-3 fallback, the rejected combinations, and argv prompt delivery. `tests/server/routes/test_native_smart_routing_create.py` (39 cases) covers the server half. All 100 pass at HEAD. None of it is a live launch — no routed CLI session has been driven end to end against a real pane yet, and `CUJ_STATUS.md` §2.10 holds the recipe and the pending rows.
---
## 7. Known-open items
**7a:**
- **Codex spawn naming rarely reaches the hooks.** Most codex spawns therefore carry no routable signal at all, and the gate allows them through on the parent's model rather than routes them (matrix row C-sub). The routing gate is real on those spawns, but it has nothing to score.
- **task_v1 prices a well-written prompt at opus.** P-OPUS escalates because it is clear, contained, and code-referencing. Under the `both` scenario the GLM-shaped case escalates too, rather than delegates. The recipe does what it says. The recipe is frozen, so this item is task_v2 feedback for the AIGW team, and not a client-side change.
- **Cross-harness permission mapping** stays deferred. Configure Smart Routing keeps its disabled Permissions row as the slot for it (§4.1).
- **Fork spawns** are exempt from routing in v1. Tests pin that exemption.
- **The codex hook trust handshake races the launch.** Codex reads `hooks.json` and its trust state once, at process start, and the open-time launch can win against the turn-time handshake. The routing hooks then stay untrusted for that process's life, and the canary reports it. A fix is in flight; §3.7 holds the detail.
- **No routing-availability liveness probe.** §1.8 gates on config-level availability only, so a gateway that is configured but *down* still offers Smart Routing. Plan §8 records the probe as a follow-up, not as MVP.
- **`gateway_inference` absent still means "offer everything".** That is deliberate for the rollout, and it is meant to be tightened once hosts have rolled forward (plan §10 decision 9).
- **GLM's served name is pinned, not discovered.** `_SERVABLE_ALIASES` names `system.ai.glm-5-2` because no listing does (§3.5h). A gateway that later serves GLM under another name, or lists it at last, makes that entry wrong rather than merely redundant. The ask on the AIGW owners stays: advertise `openai/v1/responses` on the `databricks-glm-5-2` endpoint, or list the model route. Then the entry goes.
- **The CLI surface has no live verification yet.** §6 is unit-verified only. No routed `omnigent claude` / `omnigent codex` / `omnigent run` launch has been driven against a real pane end to end. `CUJ_STATUS.md` §2.10 holds the recipe and the pending rows.
- **A prompt-capable native harness outside the routed pair routes nothing.** `--smart-routing --harness kiro-native` passes the CLI's routability check and its preflight (kiro has no `gateway_inference` entry, and unknown never gates), and then the server's create-time gate declines it, because that gate only fires for claude-native and codex-native. The launch proceeds behind the "did not pick a model" notice (§6.6d). Either widen the server pair or narrow the CLI predicate.
- **Move the `routes:select` call host-side.** Availability is already host-derived (§1.8), so the router call should run where the inference config lives, and its auth and workspace would then always match the host that we gated on (plan §8).
+575
View File
@@ -0,0 +1,575 @@
# Intelligent Routing — test registry (`routing-mvp`)
**This file is the source of truth for everything under test on** `routing-mvp`**.**
Open it to learn every check we run, exactly how to run it, what ground truth it
reads, its current status, and when (date + commit) it was last verified. It is
a registry first and a history second — the archive lives in §5.
Canonical test _definitions_ live in
`INTELLIGENT_ROUTING_PLAN.md` [§11](INTELLIGENT_ROUTING_PLAN.md) (the four
verbatim prompts, the matrix, the headless driver recipe). This file does **not**
duplicate the prompts; it records status and the runbook. Chain-level narrative
of how the pieces fit is in `[CUJ_IMPLEMENTATION.md](CUJ_IMPLEMENTATION.md)`.
## Legend
-**user** — Bryan confirmed it live
-**evidence** — verified from process-level ground truth (logs / DB /
harness-written files), not just UI
- 🟡 **ui-only / stale** — the UI claims it, but process reality is unverified,
known to diverge, or the prior evidence has been invalidated by later commits
- ❌ — confirmed broken (fix status noted)
- ⬜ — not yet tested live
"UI" = what chips/dropdowns/panels display. "Process" = what the harness process
actually runs (rollout files, panes, config, spawned models). **Process truth
beats UI**: a chip alone is 🟡, never ✅ evidence (plan §11 D4).
## How to update this file
1. **A status change requires named evidence.** Put the artifact in the row: a
log line, a DB row, a pane capture, a config/rollout file, or a test run.
"It looked right" is 🟡, not ✅.
2. **Stamp** `last verified` **with a date _and_ a commit** (`YYYY-MM-DD / <sha>`).
A row verified before a commit that touched its code path is stale — demote
it to 🟡 "re-verify" rather than leaving a green row standing.
3. **Never widen a status without re-running the check.** Carry statuses
forward verbatim if you did not re-run them.
4. **Recipes belong in §1** (as `R`\* handles) so rows stay one line and the
commands stay in one place. New surface → new subsection in §2, not a note in
the history.
5. **Compress narrative into §5** when it stops being actionable.
---
## 1. Verification recipes (`R*` handles)
Rows in §2 reference these instead of repeating commands.
| Handle | Recipe |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **R0** stack | First time on a machine, follow `LOCAL_SETUP.md` — a fresh clone has no `.omnigent-local/config.yaml` and `run-server.sh` exits without it. Then: `./run-server.sh` (:6868), `./run-host.sh`, `./run-frontend.sh` (:5273). All three source `dev-env.sh`, which pins `OMNIGENT_CONFIG_HOME=$WORKTREE/.omnigent-local` and `OMNIGENT_DATA_DIR=$WORKTREE/.omnigent-local/data` — the user's real `~/.omnigent` must stay untouched. Staging AIGW, `router_name: task_v1`. |
| **R1** decisions (DB) | `sqlite3 "$OMNIGENT_DATA_DIR/chat.db" "SELECT hex(conversation_id), data FROM conversation_items WHERE data LIKE '%rationale%' ORDER BY rowid;"` — one row per decision, carrying scope, raw pick, `model`, `applied`, and the router rationale. Key by `hex(conversation_id)` to attribute decisions to a session; count rows before/after an action to prove "a decision fired" / "no decision fired". |
| **R2** claude process truth | Take `tmux_socket=…` (and `tmux_target=`) from the session's runner log — `grep -o 'tmux_socket=[^ ]*' .omnigent-local/data/logs/runner/runner-<session>-*.log` — then `tmux -S <socket> capture-pane -p -t main`. Assert the injected `/model <alias>` line **and** the model banner that follows it. |
| **R3** codex process truth | In the session's bridge dir, read the codex-home `config.toml` (`model = …`) and the newest rollout `.jsonl` under that codex-home's `sessions/`. The rollout is what the process actually ran. TUI status bar is the UI-side companion. |
| **R4** server gate log | `grep smart_routing .omnigent-local/data/logs/server/*.log`. Names every route and every no-route reason: `route_turn skipped for session=… : no routing client configured`, `… harness=X cannot run Y`, `auto-harness harness=… model=… rationale=…`, `router pick '…' is not servable here`, `routes:select returned 400/403`, and the claude-pane `no spelling` warning. Subagent gate declines log as `route-subagent: subagent routing disabled for session=… harness=…`. |
| **R5** UI surfaces | Chip rendered under the _triggering_ user message (no substitution arrow); decision card expands with the router's predicates; sub-agents panel shows the per-subagent routed model; session warning banner. |
| **R6** router contract probe | `scripts/probe_routing_api.sh` — recorded curl battery against eng-ml-inference staging via `databricks auth token`. Run before demos and whenever AIGW deploys. |
| **R7** headless driver | Plan §11.3, steps 15: `POST /v1/sessions` with `cost_control_mode_override: "on"` and **no** model/effort pin, `POST` the raw canonical prompt as a `message` event, then score with R1 + R3/R2. A pin silently disables routing; any wrapper around the prompt changes the answer. |
| **R8** canary / warning | Session snapshot (or `GET` the session) carries the `subagent_routing_unenforced` warning when a harness's router hook did not execute; the watcher re-posts every 30s. **To provoke it deliberately** (verified 2026-07-31): race a writer against session create that rewrites the session codex-home `hooks.json` `SessionStart` **`session-canary`** command to a nonexistent binary — codex reads `hooks.json` once at start, so a post-launch rewrite is ignored; poll for the file and patch within ~50 ms. Clear it by `touch`ing `<bridge_dir>/subagent_routing_canary`; the next healthy tick posts the empty list (`enforcement repaired`). Breaking the `PreToolUse` **route-subagent** hook instead does **not** warn — see the §2.5 note. |
| **R9** gateway-backed gate | Point the host at a **non-AIGW** inference config and assert the Smart Routing option disappears for that family only. Claude: in the host's `OMNIGENT_CONFIG_HOME` provider config make the claude-sdk default a `subscription` (or Bedrock) entry, so `resolve_native_claude_config` yields no `ANTHROPIC_BASE_URL` + api-key helper. Codex: point the codex default at a non-gateway `key` provider. **Exact codex flip used on 2026-07-31**: in `.omnigent-local/config.yaml` narrow the databricks entry to `default: anthropic` and add `openrouter-r9: {default: openai, kind: key, openai: {base_url: https://openrouter.ai/api/v1, api_key: …}}`. `cp` the file first, restart **only** the host (`kill $(pgrep -f '.venv/bin/omni host')`, then `./run-host.sh`), confirm the host's readiness push (`GET /v1/hosts``gateway_inference`) reports `false` for that family, then check the surface. Restore the config byte-exactly (verify md5) and restart the host again. Absent field (old host build) must gate **nothing**. |
| **R10** CLI routed launch | Source `dev-env.sh` in a fresh shell so the CLI shares the **R0** config home and data dir, keep `./run-server.sh` + `./run-host.sh` up, then launch one surface from a git workspace. Tier 2 claude: `uv run --no-sync omnigent claude --server http://127.0.0.1:6868 --smart-routing -p "<P-OPUS>"`. Tier 2 codex: `uv run --no-sync omnigent codex --server http://127.0.0.1:6868 --smart-routing -p "<P-SOL>"`. Tier 2 through `run`: `uv run --no-sync omnigent run --harness codex-native --server http://127.0.0.1:6868 --smart-routing -p "<P-SOL>"`. Tier 3: `uv run --no-sync omnigent run --server http://127.0.0.1:6868 --smart-routing -p "<P-TRIVIAL>"` (no `--harness`, or `--harness auto`). Read stderr for the one-line verdict (`omnigent: Smart Routing picked <harness> on <model>.`) or the fail-open notice, then score the launched session with **R1** (exactly one **session**-scope decision row, `harness` set) plus **R2**/**R3** process truth and the runner log's `launch_model=`. Negative checks need no stack and no server: `--smart-routing` with no `-p`, with `--resume`/`--continue`/`--session`, with an AGENT, or with a REPL-only flag must each exit non-zero before any daemon starts. |
---
## 2. Test inventory
### 2.1 Canonical CUJ matrix (plan §11) — 15/15 exact (C1's gateway blocker now cleared)
Definition, prompts (P-OPUS / P-GLM / P-SOL / P-TRIVIAL) and per-row verify
handles: **plan §11.1–§11.4**. The bar is `raw_model == applied_model`; a
substitution arrow is a failure. Run headless via **R7**, or by hand on the same
**R0** stack. C1's applied id reads `system.ai.glm-5-2` rather than the catalog's
`databricks-glm-5-2`: that is the gateway's own spelling of the same arm, so it
stamps no `raw_model` and is an exact pass (`907f8886`, `CUJ_IMPLEMENTATION.md`
§3.5h).
Results as of **2026-07-31 / 3ccf86e3** — A1A4, B2, B3, C1C3, C-sub, C-tog
carried verbatim from the `c0b08f68` full re-run (their code paths are untouched
by `3ccf86e3`); **B1 re-verified exact** after the turn-catalog fix, and
**B-sub / B-tog / A-sub run live for the first time since de2acfdb** now that
claude turns execute. Session ids are the headless-driver sessions; every row was
scored with **R1** plus **R2**/**R3** process truth. **Scoring note:** a decision
row with `model=databricks-X, applied=true` and **no** `raw_model` is an exact
pass — prefix-only restores no longer record divergence on the turn/session path,
so a present `raw_model` there means a genuine substitution. The
`native_subagent` path has **not** been given that normalization yet — see the
prefix-only note below the matrix.
Blocker status:
- **`invalid beta flag`, every claude-native turn — ✅ resolved (external),
verified live 2026-07-31.** Claude panes now answer normally: `cb35efd1`
replied to `hi`, ran two parallel Task spawns and a third; `c9ce897d` ran a
full P-OPUS turn on Opus 4.8. **No omnigent code changed** — the launch env is
byte-identical (`env_keys` still carry `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`
and nothing was added), so the staging gateway's beta allowlist is what moved.
Treat it as an external dependency that can regress: if it returns, B-sub /
B-tog / A-sub go dark again and the tell is the pane 400, not any routing log.
- **C1's glm gateway 400 — ✅ resolved (ours), verified live 2026-08-01 /
`907f8886`.** The arm now applies under the gateway's model route
`system.ai.glm-5-2`, which is the only name that serves GLM on the Responses
API. Session `80fb6d1f` ran clean: zero `BAD_REQUEST`, and a turn completed
with an answer. See the C1 note.
| Row | Surface / prompt | Session | Decision (raw → applied) | Process truth (R2/R3) | Bar |
| ----- | --------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| A1 | Smart Routing harness / P-OPUS | `d0db7be2` | session-scope, `claude-native`, `model=databricks-claude-opus-4-8`, applied=true, no `raw_model` | runner log `launch_model=databricks-claude-opus-4-8`; pane status bar `Opus 4.8` | ✅ exact (turn itself hit the `invalid beta flag` 400 — external) |
| A2 | Smart Routing harness / P-GLM | `9dcacfaf` | session-scope, `claude-native`, `databricks-claude-opus-4-8`, applied=true | `launch_model=databricks-claude-opus-4-8`; pane `Opus 4.8` | ✅ exact; recipe quirk confirmed again — the GLM case escalates to opus under `both` |
| A3 | Smart Routing harness / P-SOL | `7fe2224f` | session-scope, `codex-native`, `databricks-gpt-5-6-sol`, applied=true | `config.toml` `model = "databricks-gpt-5-6-sol"`; rollout `turn_context` sol | ✅ exact (also proves auto picks the harness) |
| A4 | Smart Routing harness / P-TRIVIAL | `a73575f0` | session-scope, `codex-native`, `databricks-gpt-5-6-luna`, applied=true | `config.toml` + rollout `turn_context` = luna; both turns answered | ✅ exact; turn 2 (`what time is it?`) answered on luna with the decision count still **1** |
| B1 | Claude Code / P-OPUS | `4467f86f` (+ `85e189c0` trivial control) | turn-scope, `model=databricks-claude-opus-4-8`, applied=true, **no `raw_model`** | pane `/model opus``Opus 4.8` | ✅ **exact — fixed at `3ccf86e3`** (turn routing now serves the launch-exact claude vocabulary); the stale-catalog substitution below is resolved. Control session `85e189c0` (P-TRIVIAL) still lands sonnet-5 exactly, so the fix did not flatten the menu |
| B2 | Claude Code / P-SOL | `70cd188e` | turn-scope, `databricks-claude-sonnet-5`, applied=true, no `raw_model` | pane status bar `Sonnet 5` | ✅ exact (plan §11 B2 — conjunction-failing → default) |
| B3 | Claude Code / P-TRIVIAL | `a55e01bd` | turn-scope, `databricks-claude-sonnet-5`, applied=true | pane `/model sonnet``Set model to Sonnet 5`; status bar `Sonnet 5` | ✅ exact |
| B-sub | Claude Task spawns | `cb35efd1` | 2 `native_subagent` rows, one per spawn, both `applied=true`, both `harness=claude-native`: `general-purpose``databricks-claude-opus-4-8` (conjunction all-holds), `Explore``databricks-claude-sonnet-5` (rule-0). No cross-family arm | turn 1 pane `/model sonnet``Set model to Sonnet 5`, `hi` answered; both spawns **ran** (sub-agents panel `Explore` + `general-purpose Plan dry-run flag for deploy CLI 44s`); 2 × `POST …/hooks/route-subagent 200` in **R4** | ✅ **exact, class-differentiated** — the routed arm tracks the *Task prompt*, not the session model (parent on sonnet, opus spawn still issued). Both rows carry a prefix-only `raw_model` (`claude-opus-4-8` / `claude-sonnet-5`) — cosmetic, see the note below |
| B-tog | Toggle off → on mid-session | `cb35efd1` | off (`PATCH {"subagent_routing_override":"off"}`): decisions **3→3**, hookcalls 2→3, spawn still landed. on: decisions **3→4** on the very next spawn — a new `Explore``databricks-claude-sonnet-5` `native_subagent` row, hookcalls 3→4 | `route-subagent: subagent routing disabled for session=cb35efd1322a4ea984bbd32272134ccd harness=claude-native` at 13:41:37 (**R4**); the off-spawn's Explore still ran (2 s) on the harness default | ✅ **per-call gate, immediate both ways on claude** — matches the codex half (C-tog) exactly: the declined spawn still reaches the hook and still executes |
| C1 | Codex / P-GLM | `addfe5c0` | turn-scope, `databricks-glm-5-2`, applied=true, no `raw_model` | `config.toml` `model = "databricks-glm-5-2"`; rollout `turn_context model=databricks-glm-5-2` | ✅ **end-to-end** — live re-run 2026-08-01 / 907f8886, session `80fb6d1f`: decision `system.ai.glm-5-2` applied (no raw_model), `config.toml` + all rollout turn contexts on `system.ai.glm-5-2`, zero BAD_REQUEST, real generation on both turns. The P-GLM turn reached the model and then aborted on gateway capacity (`exceeded retry limit, last status: 429 Too Many Requests`); turn 2 on the same thread completed in 3.9 s ("ok"). The 400 is gone; the 429 is load, not routing. See the C1 note below |
| C2 | Codex / P-SOL | `6055d8a9` | turn-scope, `databricks-gpt-5-6-sol`, applied=true | `config.toml` + rollout `turn_context` = sol; agent replied | ✅ exact |
| C3 | Codex / P-TRIVIAL | `12fa70be` | turn-scope, `databricks-gpt-5-6-luna`, applied=true | `config.toml` + rollout `turn_context` = luna; agent replied | ✅ exact |
| C-sub | Codex spawns | `12fa70be` | 2 `native_subagent` rows, `applied=false`, `model=databricks-gpt-5-6-luna`, rationale `No routable signal (encrypted prompt, no task name); subagent inherits the session model` | `subagent_spawn_audit.jsonl` entry `model=databricks-gpt-5-6-luna, task_name=null`; 2 × `POST …/hooks/route-subagent 200` | ✅ w/ note — the router is **skipped** (not fed a placeholder) per `a95105c9`, superseding plan §11 C5; `task_name` still missing (follow-up) |
| C-tog | Toggle off → on | `6055d8a9` | off: 1→1 decisions, hook still called once; on: 2nd `native_subagent` row (`databricks-gpt-5-6-sol`) on the next spawn | `route-subagent: subagent routing disabled for session=6055d8a9… harness=codex-native` (**R4**), hookcalls 1→2 | ✅ per-call gate, immediate both ways; the off-spawn still reached the hook and still landed |
| A-sub | Cross-harness under auto | `c9ce897d` | session-scope `claude-native` / `databricks-claude-opus-4-8` (applied=true, no `raw_model`), then a trivial `Explore` Task spawn routed **cross-family**: `{"model": "databricks-gpt-5-6-luna", "applied": true, "harness": "codex-native", "scope": "native_subagent", "agent": "Explore"}` | pane status bar `Opus 4.8` (auto landed claude-native, the turn ran); the spawn's soft redirect is echoed in the pane verbatim: `Router selected codex-native/databricks-gpt-5-6-luna. Use sys_session_send with args.harness=codex-native, args.model=databricks-gpt-5-6-luna instead.` | ✅ **cross-family permitted under `auto`, first live evidence since de2acfdb** — a claude-parent session got a **Codex** arm for its spawn, which the `cc`/`codex` scenarios must never do (plan §11 D3). Delivery is a **soft redirect**: the claude Task tool cannot host a codex arm, so the hook denies the native spawn and hands back the `sys_session_send` recipe. Recorded as-is — this run's agent chose not to follow the redirect, so no cross-family child actually launched |
Run-level assertions for the **B-sub / B-tog / A-sub / B1 slice**, **R4** on
`server-20260731-132450-786102.log`: 0 `harness=None`, 0 `no spelling`, 0
`route_turn skipped`, 0 `cannot run`, 0 `routes:select returned 40x`; 1
`auto-harness harness=claude-native model=databricks-claude-opus-4-8`, 3
`routing turn session=`, 1 `route-subagent: subagent routing disabled`. All **8**
`router pick '…' is not servable here` lines are **prefix-only** restores (bare
arm → `databricks-`-prefixed same arm: sonnet-5 ×4, opus-4-8 ×3, luna ×1) — **0
real divergences**, which is the B1 fix showing up in the log.
Prior round's run-level assertions, **R4** on
`server-20260730-232309-780879.log` (round slice, 2416 lines): 0 `harness=None`,
0 `no spelling` warnings, 0 `subagent_routing_unenforced` **on healthy
sessions**, 0 `route_turn skipped`, 0 `cannot run`, 0 `routes:select returned
40x`; 5 `auto-harness`, 15 `routing turn session=`, 1 `route-subagent:
subagent routing disabled`. Of 20 `router pick '…' is not servable here` lines,
17 are **prefix-only** restores (luna ×6, sonnet ×4, opus-4-8 ×3, sol ×2, glm
×2) and **3 are real divergences** — all three `claude-opus-4-8`
`databricks-claude-sonnet-5`, i.e. B1 twice plus the claude child session (see
§2.5).
> **✅ B1 fixed at `3ccf86e3` — the claude turn path now reaches
> `claude-opus-4-8`.** `route_turn` is served the launch-exact claude
> vocabulary instead of the pre-launch `_model_options_cache` snapshot, so the
> routed arm has a spelling and `substitute_model` no longer falls back. Verified
> on `4467f86f` (P-OPUS → `databricks-claude-opus-4-8`, applied=true, **no**
> `raw_model`, pane `Opus 4.8`) with `85e189c0` (P-TRIVIAL → sonnet-5) as the
> control. **Original diagnosis, kept for the record:** the `cc` turn route is offered
> `['claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5', 'claude-opus-4-8']`
> (3 catalog rows + the injected frozen arm), picks `claude-opus-4-8`, and then
> logs `router pick 'claude-opus-4-8' is not servable here; using
> 'databricks-claude-sonnet-5'`. The launch **did** pin the arm — the runner log
> carries `native-claude: pinned routed arms onto family aliases: {'opus':
> 'databricks-claude-opus-4-8'}` — but `route_turn`'s candidate list comes from
> `_native_turn_catalog` (`orchestration.py:3546-3576`), which reads
> `_model_options_cache`. That cache was filled **pre-launch** from the host
> catalog by `_hydrate_model_options_from_host`
> (`helpers.py:8600-8624`), where `opus` resolves to the newest arm
> (`claude-opus-5`), and it is inserted into `_model_options_stale` — a set
> `_native_turn_catalog` never consults. Turn 1 routes ~100 ms after the pin
> (pin 12:27:22.461, route 12:27:22.568), long before the live pane's picker
> rows could replace the stale snapshot, so the routed arm has no spelling and
> `substitute_model` falls back to sonnet-5. Session-scope (auto) routing is
> unaffected because it resolves before launch and becomes `launch_model`
> directly — which is why A1/A2 land Opus 4.8 exactly.
> **Note (2026-07-31 / `3ccf86e3`) — `native_subagent` rows still stamp a
> prefix-only `raw_model`.** Every subagent decision this round carries
> `raw_model` set to the bare arm (`claude-opus-4-8`, `claude-sonnet-5`,
> `gpt-5-6-luna`) while `model` is the `databricks-` prefixed spelling of the
> **same** arm. The turn/session path normalizes this away — `smart_routing.py`
> compares `_bare_id(raw_model, prefixes) != _bare_id(model, prefixes)` before
> setting the field — but `_decision_from_result`
> (`omnigent/runner/subagent_routing.py`) used a plain `raw != model` string
> compare. FIXED in `e1592902`: `_decision_from_result` now compares through
> `_bare_id`, so a prefix-only restore records no `raw_model`. Note the UI
> exposure was narrower than first thought: `shortModelName` collapsed the
> arrow for `databricks-` spellings; only `system.ai.` spellings drew it.
**Cross-harness constraint.** **Re-A/B'd live this round** (see A-sub): the
identical trivial `Explore` prompt gave `codex-native` / `databricks-gpt-5-6-luna`
from the auto session `c9ce897d` and `claude-native` / `databricks-claude-sonnet-5`
from the `cc` session `cb35efd1` — cross-family allowed only under `auto`, exactly
as plan §11 D3 requires. The **same-family** half is additionally verified in both
directions on omnigent child sessions — see §2.5.
Last full A/B: 2026-07-30 / de2acfdb (identical trivial `Explore` prompt →
`claude-sonnet-5` from the `cc` session `453f7da0`, `gpt-5-6-luna` from the auto
session `75379db2`).
> **C1 gateway blocker, as it stood through 2026-07-31.** Routing and apply were
> exact — the codex process was configured for and requested
> `databricks-glm-5-2`. The turn then **errored at the gateway**: `{"error_code":"BAD_REQUEST","message":"API type 'openai/v1/responses' is not supported by 'databricks-glm-5-2'. Supported API types: [mlflow/v1/chat/completions]."}`
> (that round's rollout:
> `~/.omnigent/codex-native/0a65921baffdebc31113db9ef843816a/codex-home/sessions/2026/07/31/rollout-2026-07-31T12-25-13-019fb9a3-48ed-7db1-9376-3138a53252af.jsonl`,
> `task_complete.error` at 19:25:46Z). It was present in the 2026-07-29 and
> 2026-07-30 glm rollouts too, so pre-existing and external. GLM routed and
> applied; it could not *serve* codex.
>
> **✅ Resolved 2026-08-01 / `907f8886`: gateway model-route alias. Verified
> live.** Probes on both staging (`eng-ml-agent-platform`) and the prod org
> gateway show the Responses API *does* serve GLM — but only under the
> model-route name `system.ai.glm-5-2` (200 with real generation). The serving
> endpoint `databricks-glm-5-2` still 400s on `/codex/v1` (`api_types` =
> chat-completions only) and `system.ai.databricks-glm-5-2` 404s. GLM appears in
> no discovery listing (neither foundation-models nor UC model-services), so the
> working name is only knowable a priori. `907f8886` pins it in
> `_SERVABLE_ALIASES` (`omnigent/server/smart_routing.py:649`) and applies it
> through `apply_servable_alias` (`:652`) whenever the `glm-5-2` arm resolves to
> a servable id; `candidate_models`
> (`omnigent/runner/subagent_routing.py:443`) offers spawns the same spelling.
> The router arm id is unchanged, and the alias strips to the same bare id, so
> the decision stamps no `raw_model` (`CUJ_IMPLEMENTATION.md` §3.5h).
> **Live evidence, session `80fb6d1f`** (bridge dir
> `~/.omnigent/codex-native/9f3b154ff6a94b8e83fc0a42f5b2dd22/`): codex-home
> `config.toml` `model = "system.ai.glm-5-2"`, all four rollout `turn_context`
> entries on `system.ai.glm-5-2`, **zero** `BAD_REQUEST` in the rollout, and two
> `agent_message` items. The P-GLM turn itself aborted on gateway capacity
> (`task_complete.error` = `exceeded retry limit, last status: 429 Too Many
> Requests`) after the model had already answered; turn 2 completed in 3.9 s.
> Note the probe's response payload reports `"model":"/mosaicml/local_model"` —
> nothing on our side reads the response model field, so labels stay on the
> decision id.
>
> **How to re-run C1** (R7 + R3, on the R0 stack): create a codex-native session
> with `cost_control_mode_override: "on"` and no model pin, send the P-GLM prompt
> from `/tmp/p_glm.txt` verbatim, and expect the turn to complete. Score with
> **R3** — codex-home `config.toml` `model = "system.ai.glm-5-2"`, the newest
> rollout `.jsonl` free of `BAD_REQUEST`, a `task_complete` with a
> `last_agent_message` — plus the pane, whose bottom status bar tracks the live
> thread model. A 429 there is gateway load: send a short prompt on the same
> thread and read the second turn.
**Routing cadence: session-start only.** Product decision (plan §10 decision 4)
— the router runs once, on the session's first message, and the routed model
persists for the session's life. The gate is `_should_route`'s
`effective_runner_override is None`
(`omnigent/server/routes/_sessions/orchestration.py:3890-3897`); the routed turn
persists its own pick as `model_override`, so that pin is what stops turn 2 from
routing again. A brief per-turn re-routing experiment was live-verified on
2026-07-30 and reverted the same day (`720b145b`, see §5). Both rows are now
re-verified live on the post-revert stack, on **both** harnesses:
| Check | How to run | Ground-truth signal | Status | Last verified |
| -------------------------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------ |
| **Session-start-only routing — no re-route on turn 2** | R7 step 1, then send a second prompt of a *different* class (e.g. P-TRIVIAL session → P-OPUS turn 2); count R1 rows and re-check R2/R3 | **No** new decision row via R1 (count unchanged), and the process still on the turn-1 model via R2 (claude pane status bar / no new `/model`) or R3 (codex `config.toml` + newest rollout `turn_context`) | ✅ evidence — **claude** `873b88a2`: turn 1 "hi"→sonnet-5, turn 2 P-OPUS, count still 1, pane still Sonnet 5. **codex** `63dbaf02` (this round): turn 1 "hi"→luna applied; turn 2 **P-SOL** left the count at 1 and `config.toml` + rollout `turn_context` still `databricks-gpt-5-6-luna` — a single `routing turn session=63dbaf02…` line in R4. Also holds on A4 (`a73575f0`, turn 2 answered on luna, count 1). | 2026-07-31 / c0b08f68 (codex half) |
| Manual model pick stops routing (pre-existing semantics) | R0, create a routing-enabled session, let turn 1 route, then `PATCH {"model_override": …}` and send a prompt of a different class; R1 + R4 | no **new** decision row; the gate never reaches `route_turn` for turn 2 | ✅ evidence — session `d7b30950`: turn 1 "hi" routed to `databricks-claude-sonnet-5` (R1 count 1, pane `/model sonnet``Set model to Sonnet 5`); `PATCH {"model_override":"databricks-claude-sonnet-5"}`, then **P-OPUS** as turn 2 → R1 count still **1** after 60 s, exactly one `smart_routing: routing turn session=d7b30950…` line in the log (12:41:55, turn 1), and the pane shows the manual pin applied as ` /model databricks-claude-sonnet-5` with the status bar staying `Sonnet 5`. **Registry correction:** there is no `model already pinned` INFO line — `grep -rn "already pinned" omnigent/` finds nothing in the routing path. The gate declines **silently**; the observable signal is the *absence* of a `routing turn session=` line for that turn. | 2026-07-31 / c0b08f68 |
### 2.2 Claude Code CUJ (Smart Routing on the claude-native harness)
| Check | How to run | Ground-truth signal | Status | Last verified |
| ------------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------- |
| Smart Routing selectable in Configure Claude Code, Effort greys out | R0, open Configure Claude Code | UI dropdown state | ✅ user | 2026-07-29 |
| Smart Routing hidden in Configure Claude Code when the host's claude inference is not AIGW-backed (plan §10 decision 9) | R9, claude half | host `gateway_inference["claude-native"] = false` on `GET /v1/hosts`; the Model dropdown lists Default + models only, and a `false` **codex** entry must NOT hide it here | 🟡 signal half only — the **claude** flip was not run (the codex flip was, see §2.3). The negative-codex round proved the *independence* half from the other side: with `codex-native: false` the host still reported `claude-native: true`, so a `false` codex entry cannot hide the claude surface. The claude-side `false` signal + the UI check stay for Bryan. | 2026-07-31 / c0b08f68 (independence half) |
| Sticky default next session, same harness | R0, create a second session on the same harness | UI preselection | ✅ user | 2026-07-29 |
| Session created with routing flag, no model pin | R7 step 1, then inspect `session_overrides` in `chat.db` | `conversations.session_overrides` on `453f7da0` = `{"model_override":"sonnet_5","cost_control_mode_override":"on"}` — the create payload carried no model/effort pin; the `model_override` present afterwards is the **routed** pick written by the apply layer | ✅ evidence | 2026-07-30 / de2acfdb |
| Router decision + chip below the message | R7 steps 13 + R5 | R1 decision row (`task_v1`, `cc` scenario) paired with the chip | ✅ user (rationale correct, task_v1 `cc`) | 2026-07-29 |
| Gateway env prepared at launch (ucode) | R0 launch, then R4 | runner log `d0db7be2` (A1): `configured=True env_keys=['ANTHROPIC_BASE_URL', …, 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS', …] api_key_helper_set=True model_set=True launch_model=databricks-claude-opus-4-8`, plus `native-claude: pinned routed arms onto family aliases: {'opus': 'databricks-claude-opus-4-8'}` | ✅ evidence (env prepared; the gateway still 400s the CLI's beta set — §2.1) | 2026-07-31 / c0b08f68 |
| **Process runs the routed model** | R2 on the session pane, two turns | panes this round: `/model sonnet``Set model to Sonnet 5` + status bar `Sonnet 5` (B2, B3, `d7b30950`); A1/A2 launched directly on `databricks-claude-opus-4-8` with status bar `Opus 4.8`. **B1 is the exception**: the pane faithfully runs the *applied* model, but the applied model is not the routed one — see the B1 note in §2.1 | ❌ **partial** — apply is faithful, but the claude **turn** path cannot reach `claude-opus-4-8` at all (B1); every other claude pick is exact | 2026-07-31 / c0b08f68 |
Root cause history for the apply layer: `model_override` was dropped in
`_run_turn_bg`, plus an alias-vocabulary mismatch (§5).
### 2.3 Codex CUJ (Smart Routing on the codex-native harness)
| Check | How to run | Ground-truth signal | Status | Last verified |
| ---------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------ |
| Smart Routing selectable in Configure Codex | R0, open Configure Codex | UI dropdown state | ✅ user | not recorded |
| Smart Routing hidden in Configure Codex when the host's codex provider is not AIGW-backed (plan §10 decision 9) | R9, codex half | host `gateway_inference["codex-native"] = false` on `GET /v1/hosts`; the Codex Model row disappears entirely (it is the only choice there), and a `false` **claude** entry must NOT hide it here | ✅ **signal evidence** — R9 codex flip run live: after the flip + host-only restart, `GET /v1/hosts` reported `{'claude-native': True, 'native-claude': True, 'codex': False, 'codex-native': False, 'native-codex': False}` — the codex family flipped and **claude stayed `True`**, proving per-family independence. Config restored byte-exactly (md5 `4be2c560a20fad68c51defaeed93410e` before and after) and the host re-reported all-`True`. The UI-hidden check stays for Bryan. | 2026-07-31 / c0b08f68 |
| Router decision + chip | R7 + R1 | matrix C1/C2/C3 + A3/A4 this round: glm / sol / luna, all exact servable matches (no `raw_model` on any row) | ✅ evidence | 2026-07-31 / c0b08f68 |
| **Process runs the routed model** | R3 (bridge-dir codex-home `config.toml` + newest rollout `.jsonl`) | 6 sessions this round (A3, A4, C1, C2, C3, `63dbaf02`): runner log `received model_override=databricks-<pick> (forwarding to harness)`, codex-home `config.toml` `model = "databricks-<pick>"`, rollout `turn_context model=databricks-<pick>` — glm, sol, luna. Codex is **0 divergences, 0 blockers** apart from glm's serving gap, which closed on 2026-08-01 / `907f8886` — C1 session `80fb6d1f` mirrors `system.ai.glm-5-2` on both surfaces and serves the turn (the rest of this row is carried from `c0b08f68`, not re-run) | ✅ evidence | 2026-07-31 / c0b08f68 (glm half 2026-08-01 / 907f8886) |
| Codex TUI reflects the live model | R0 + watch the TUI status bar | thread-level push (`thread/settings/update`) live-updates the status bar (probed); `/model` picker highlight is upstream codex behavior — see `designs/LIVE_MODEL_STATE.md` | 🟡 not exercised this round: no mid-session model change to push (see below), and the TUI status bar was not eyeballed | 51801530 |
| Post-launch model push (re-route / lost launch race) | R0, force a re-route after launch, then R3 | first-turn push + config mirror re-verified (row above). A **forced re-route** is unreachable **by design**: routing is session-start only (plan §10 decision 4), gated on `effective_runner_override is None` (orchestration.py:3890-3897), and the routed turn's own `model_override` is the pin — a P-SOL turn sent to the luna session `0aec2b51` produced no decision and left `config.toml` on luna | 🟡 half-verified — mirror/push yes; re-route path unreachable by design, not a gap | 2026-07-30 / de2acfdb (mirror half) |
### 2.4 Auto / top-level Smart Routing harness CUJ
| Check | How to run | Ground-truth signal | Status | Last verified |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| "Auto" chip + dropdown item + description | R0, landing dropdown | UI (naming iterations settled; Smart Routing now its own unlabeled group above harnesses, 76749e03) | ✅ user | 2026-07-30 / 76749e03 |
| Smart Routing harness row hidden unless BOTH families are AIGW-backed on the host (plan §10 decision 9) | R9, either half — the row needs the five-arm `both` menu | row absent from the landing dropdown with either `gateway_inference` entry `false`; present when both are `true`; present when the host reports no `gateway_inference` at all (older host build ⇒ unknown, never gated); a mid-session host switch that loses it announces the `not-gateway-backed` notice | 🟡 **both signal halves now evidenced, UI half still owed**: positive — `GET /v1/hosts` reports `true` for both families on the staging-AIGW host (re-confirmed this round after two host restarts); negative — the R9 codex flip made `codex-native: false` while `claude-native` stayed `true`, which is exactly the "either entry false ⇒ row absent" input. The dropdown absence/presence, the older-host `unknown` case and the mid-session `not-gateway-backed` notice are UI and stay for Bryan | 2026-07-31 / c0b08f68 (both signal halves) |
| Configure Auto = Permissions only, locked Default | R0, open Configure on the Auto entry | create payload carries no permission override (test-pinned) | ✅ user | not recorded |
| Harness + model decision at session start | R7 (no harness pin) + R1 + R4 | matrix A1A4 this round: session-scope decisions picked claude-native/opus-4-8 (A1 `d0db7be2`, A2 `9dcacfaf`) and codex-native/sol, /luna (A3 `7fe2224f`, A4 `a73575f0`) from the five-arm menu; `smart_routing: auto-harness harness=… model=…` ×5 in the log; model persisted as `launch_model`; the two codex sessions ran their turns (the two claude ones hit the external `invalid beta flag` 400) | ✅ evidence | 2026-07-31 / c0b08f68 |
| Session-scope decision only — turn 2 produces no second session decision | R7, send two turns, count R1 rows | A4 (`a73575f0`): second turn ("what time is it?") answered by the agent on luna (`It's 12:44 PM…`), R1 count unchanged at 1 — no second decision of any scope, `config.toml` still luna | ✅ evidence | 2026-07-31 / c0b08f68 |
| Cross-harness subagents allowed ONLY here | R7 in scenario A, spawn from an auto session; then attempt the same from `cc`/`codex` | **re-A/B'd live (A-sub).** Identical trivial `Explore` prompt: auto session `c9ce897d` (claude-native parent, Opus 4.8) → `{"harness": "codex-native", "model": "databricks-gpt-5-6-luna", "applied": true, "scope": "native_subagent"}` — cross-family **allowed**, delivered as a deny+redirect in the pane (`Use sys_session_send with args.harness=codex-native, args.model=databricks-gpt-5-6-luna`); `cc` session `cb35efd1``claude-native`/`databricks-claude-sonnet-5`, never a codex arm. Same-family half additionally evidenced on omnigent child sessions (§2.5) | ✅ evidence, both halves fresh | 2026-07-31 / 3ccf86e3 |
### 2.5 Subagent routing
| Check | How to run | Ground-truth signal | Status | Last verified |
| ------------------------------------------------------------------ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------ |
| Claude subagent decisions (chips per spawn) | R0, Task spawn ×N, R5 | one chip per spawn | ✅ user | not recorded |
| **Claude subagent spawns get the routed model** | R0, Explore spawn; R1 + sub-agents panel | **re-verified live** (B-sub, `cb35efd1`): two parallel Task spawns, one `native_subagent` decision each, both `applied=true` and both claude-family — `Explore``databricks-claude-sonnet-5` (rule-0), `general-purpose``databricks-claude-opus-4-8` (conjunction all-holds), i.e. the arm follows the **Task prompt** while the parent stays on sonnet. Both spawns ran (sub-agents panel), 2 × `POST …/hooks/route-subagent 200`. Caveat: rows carry a prefix-only `raw_model` (§2.1 note) | ✅ evidence | 2026-07-31 / 3ccf86e3 |
| Same-harness constraint (native spawns + omnigent children) | native: R0, spawn from a codex parent. **children**: `POST /v1/sessions` with `parent_session_id` + `sub_agent_name` under a parent of that family, then send a prompt whose *other*-family route differs, and read the `child_session`-scope R1 row | **omnigent child sessions, first live evidence, both directions.** codex parent `6055d8a9` → child `d5de9a8b` (linkage confirmed in `conversations.parent_conversation_id`), sent **P-OPUS** — the prompt whose `cc`/`both` route is `claude-opus-4-8` — and got `{"model": "databricks-glm-5-2", "applied": true, "harness": "codex-native", "scope": "child_session"}`, i.e. a **Codex arm**, never a claude one. claude parent `a55e01bd` → child `64f5d545`, sent **P-GLM** — whose `codex` route is `glm-5-2` — and got `harness: claude-native` with a **Claude arm**. Native spawns: `codex` parent → codex arm re-verified (C-sub, C-tog) Native claude half **now fresh too** (B-sub `cb35efd1`: both Task spawns claude-family only) | ✅ evidence — children ✅, native codex ✅, native claude ✅ all live | 2026-07-31 / 3ccf86e3 |
| **Codex subagent hooks execute at all** | R0 codex session, spawn; R4 + SubagentStart audit | `12fa70be` / `6055d8a9` this round: `codex subagent-routing hooks trusted (3 of 3 newly): preToolUse, sessionStart, subagentStart`; canary file written; enforcement watcher `armed=True`; `POST …/hooks/route-subagent 200` per spawn; `subagent_spawn_audit.jsonl` recorded every spawn. The trust line also **degrades correctly** — with one hook deliberately broken it read `2 of 2 newly` and named only the surviving hooks | ✅ evidence | 2026-07-31 / c0b08f68 |
| Canary → `subagent_routing_unenforced` warning **server signal** | R8 (watcher posts every 30s) + R5 for the banner | **positive half, first live fire.** Session `678ed16109374bce97b2d5c26ad9dc04`, `SessionStart` `session-canary` hook broken at create (R8 recipe): runner log `subagent routing enforcement watcher started … (armed=True, interval=30.0s)` then `posting subagent routing warnings … ['subagent_routing_unenforced']`; `GET /v1/sessions/{id}` returned `[{'code': 'subagent_routing_unenforced', 'harness': 'codex-native', 'reason': 'SessionStart canary did not fire; codex did not run the generated routing hooks (untrusted, or the hook command failed).'}]` ~37 s after the first tick. **Clear half:** `touch <bridge_dir>/subagent_routing_canary` → next tick logged `subagent routing enforcement repaired` and the snapshot went back to `[]`. **Hygiene half:** 0 `subagent_routing_unenforced` in the log across the 17 healthy sessions of this round | ✅ evidence — fire, clear **and** hygiene all live. The **banner render** (R5) is still owed to Bryan | 2026-07-31 / c0b08f68 |
| In-session Subagent routing row (Smart Routing / Default, inherit) | R0, gear → Subagent routing | UI row toggles | 🟡 not exercised — headless round, no browser; the underlying `PATCH subagent_routing_override` it drives is ✅ (row below) | pre-2245f57d |
| Mid-session toggle affects the **next** spawn (process level) | R0, flip off → spawn immediately → flip on → spawn; R1 + R4 | **codex re-verified** (`6055d8a9`, C-tog): off → `route-subagent: subagent routing disabled for session=6055d8a9… harness=codex-native`, hook still called (hookcalls 1) and the spawn still landed, decision count 1→1; on → hookcalls 1→2 and a new `native_subagent` row on the very **next** spawn. **claude re-verified** (`cb35efd1`, B-tog): off → `route-subagent: subagent routing disabled for session=cb35efd1322a4ea984bbd32272134ccd harness=claude-native`, hookcalls 2→3 and the spawn still ran, decision count 3→3; on → hookcalls 3→4 and a new `Explore``databricks-claude-sonnet-5` `native_subagent` row on the very **next** spawn | ✅ evidence, both harnesses fresh | 2026-07-31 / 3ccf86e3 |
| Fork spawns exempt (v1 policy) | R0, fork a routed session, spawn | no decision row in R1 for fork-originated spawns | ⬜ test-pinned only | — |
Codex-hook root causes worth remembering: the app-server ignored the bypass
flag (persisted trust handshake added) and cwd shadowing killed hook imports
(fixed by running hook commands with `python -I`).
> **Enforcement blind spot found while provoking the canary (no code changed).**
> Breaking only the `PreToolUse` **route-subagent** hook produces a session that
> audits spawns and routes **none** of them, and yet reports `warnings: []`
> forever. Verified live on `d62d72cfb0b04a7f94356c3e721b7ca2`: 1 audited spawn,
> **0** `POST …/hooks/route-subagent` calls, trust line `2 of 2 newly:
> preToolUse, subagentStart` — and the watcher's first tick logged
> `subagent routing enforcement repaired` (its wording for a *healthy* verdict).
> Cause is deliberate: `subagent_routing_warnings`
> (`codex_native_forwarder.py:5773`) falls through to
> `reconcile_spawn_audit`, whose contract is "empty *relayed* means nothing was
> routed, so there is nothing to contradict"
> (`codex_executor.py:1153-1188`) — it detects a *contradicted* rewrite, not a
> *missing* one. The only detector for "hooks did not run" is the SessionStart
> canary, which in this scenario fired normally because that hook was intact.
> Worth a decision: whether "armed + audited spawns + zero relayed decisions +
> routing on" should warn.
### 2.6 Visibility & telemetry
| Check | How to run | Ground-truth signal | Status | Last verified |
| ---------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| Decision chips show raw→applied divergence | R5 | the arrow itself — this is how two real bugs were caught, keep it | ✅ user | not recorded |
| Chip pairs below the user message | R0 on a **fresh claude session**, R5 | chip renders under the triggering message, not orphaned | 🟡 render rule (8fa280ea) + claude fix: the injected `/model` echo broke pairing on claude only, now skipped (25b75c62); chip cache reworked in 2245f57d — awaiting user visual confirm | pre-2245f57d |
| Per-subagent routed model in sub-agents panel | R0 fresh session, spawn, R5 | panel model == R1 applied model for that spawn | 🟡 apply fixes landed on both harnesses so the displayed override matches reality on fresh sessions; not re-eyeballed since | pre-2245f57d |
| Session warning banner renders when server publishes | R8 + R5 | live banner screenshotted during the shadowing incident; over-warning on routing-off sessions fixed same day | 🟡 render path still not eyeballed (headless round), but the **server side is now proven in both directions** at c0b08f68: a genuinely unenforced session (`678ed161`) had the warning in its `GET /v1/sessions/{id}` payload and a `touch` of the canary cleared it back to `[]` — see §2.5. Only the browser render is owed | 2026-07-31 / c0b08f68 (server side) |
| Routing analytics (OSS telemetry pipeline) | inspect `.omnigent-local/data/telemetry.json` / a live ingestion endpoint | `RoutingDecisionEvent` / `RoutingSettingChangedEvent` with family/tier-only model labels | 🟡 reworked per PR review (c7f78f26): OTel helper deleted; not yet observed against a live ingestion endpoint | — |
| Switch-off / fork telemetry triggers | as above | server-side toggle event ships in `RoutingSettingChangedEvent` | ⬜ browser-side spans were `routingTelemetry.ts`, **deleted in 2245f57d** — the browser path needs re-confirming, server path unverified | — |
### 2.7 Meta / contract checks
| Check | How to run | Ground-truth signal | Status | Last verified |
| ------------------------------------------------------ | ----------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------- |
| Router contract (task_v1 scenarios, live probe 6/6) | R6 | recorded curl battery: scenario inference, full-menu 400s, extras tolerated, tag passthrough | ✅ evidence | 2026-07-28/29 |
| Fail-open on router outage, with reason | kill/point-away the router mid-session, then R4 | task_v1 rollback incident: 400s → session unrouted + logged | ✅ evidence | not recorded |
| Gate INFO logs name every no-route reason | R4 | this round (`server-20260730-232309-780879.log`, 2416-line slice): `auto-harness …` ×5, `routing turn session=… harness=…` ×15, `router pick '…' is not servable here; using '…'` ×20 (17 prefix-only + 3 real), `route-subagent: subagent routing disabled …` ×1, 0 `harness=None`, 0 `no spelling`, 0 `bars every candidate` | 🟡 success + gate-decline lines ✅ evidence; the failure-reason lines (`route_turn skipped`, `cannot run`, `returned 40x`) again never fired because the router never failed — still unexercised. Also newly confirmed: the **manual-pin decline logs nothing at all** (§2.1) | 2026-07-31 / c0b08f68 |
| Isolation regression — real `~/.omnigent` untouched | R0, then `ls -la ~/.omnigent` mtimes | config home + data dir stay worktree-local | ❌ partial leak, **reproduced**: config + `chat.db` are worktree-local, but codex-native bridge dirs / per-session codex homes land in the **real** `~/.omnigent/codex-native/<hash>/` — 10 more created during this round (12:2413:01, plus `process-owners/`). The path shape is hardcoded to `~/.omnigent` (`omnigent/inner/codex_executor.py:637-660` matches `parts[-4] == ".omnigent"`), so it is pre-existing, not routing-caused. The **workspace** side is clean: `git -C ~/omnigent status --short` was byte-identical before and after the round (`deploy/databricks/README.md`, `deploy/databricks/deploy.py`, `uv.lock` modified; `host-id/`, `tests/deploy/test_databricks_deploy_dry_run.py` untracked — all pre-existing from the 07-30 round; **no new delta**) | 2026-07-31 / c0b08f68 |
| SAFE flag (universe), L6 live E2E suite, PR demo shots | plan §6 L6, §8 | — | ⬜ outstanding | — |
### 2.8 Renames & external asks
| Item | Status |
| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| All UI labels renamed to "Smart Routing" | ✅ user-directed, shipped e5c8a160 |
| Top-level Smart Routing harness in the landing dropdown (agentless auto over native claude/codex) | ✅ shipped; dropdown group landed 76749e03. Functional half re-verified headless at c0b08f68 (matrix A1A4) |
| Claude Code CLI 2.1.220 vs the staging gateway's beta allowlist | ✅ **fixed / cleared externally, verified live 2026-07-31 / 3ccf86e3.** Claude-native turns execute again: `cb35efd1` answered `hi`, ran two parallel Task spawns plus two more, and `c9ce897d` completed a full P-OPUS turn on Opus 4.8 — which unblocked B-sub / B-tog / A-sub. **No omnigent change**: the launch env is byte-identical (`CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` still present, nothing added), so the gateway's allowlist is what moved. Not ours to hold: it can regress at any time, and the only tell is the pane 400 — re-check the pane before any demo. `omnigent/inner/claude_gateway_shim.py` still documents the 2.1.168 round of this fight |
| GLM absent from codex model list (eng-ml-agent-platform) | ✅ **resolved 2026-08-01 / `907f8886`: gateway model-route alias, verified live.** Probes on staging and prod show the Responses API serves GLM under the model-route name `system.ai.glm-5-2` (200, real generation); the serving endpoint `databricks-glm-5-2` still 400s on `/codex/v1` (`api_types` = chat-completions only) and `system.ai.databricks-glm-5-2` 404s. GLM is in **no** discovery listing (not foundation-models, not UC model-services), so the name is only knowable a priori — pinned in `_SERVABLE_ALIASES` (`omnigent/server/smart_routing.py:649`), applied by `apply_servable_alias` (`:652`) when the `glm-5-2` arm resolves to a servable id, and offered to spawns by `candidate_models` (`omnigent/runner/subagent_routing.py:443`). The router arm id is unchanged, so no `raw_model` is stamped. **Live**: C1 session `80fb6d1f``config.toml` `model = "system.ai.glm-5-2"`, all rollout turn contexts the same, zero `BAD_REQUEST`, real generation. The only error left on that thread is a gateway-capacity 429 on the P-GLM turn, which is load and not routing. Re-run recipe: the C1 note under §2.1 |
| task_v1 escalates clear+contained prompts to opus (well-written spawn prompts always pay opus) | 📝 recipe feedback for Ivan — frozen router, needs task_v2 |
### 2.9 Automated suites
Always `uv run --no-sync` (never plain `uv run` / `uv sync` — it rewrites
`uv.lock`; `git checkout -- uv.lock` if it moves). Web tests need the nvm
binary on `PATH` because nvm's lazy shim breaks in non-interactive shells.
| Suite | How to run | Known pre-existing / environmental failures to ignore | Status | Last verified |
| ------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------- |
| Python — routing-relevant | `uv run --no-sync pytest tests/server tests/runner tests/inner tests/entities` | `tests/server` `test_sessions_snapshot` ordering flakes; `test_filesystem_registry` ×2; openai-agents provider failures; `tests/inner` sandbox-env failures; `test_relay_close_keeps_advertisement…` | 🟡 still owed — the de2acfdb round was live-CUJ only, no suites run | pre-review-wave |
| Python — CLI | `uv run --no-sync pytest tests/cli` | `test_configure_models`, `test_update_check` — pre-existing | 🟡 still owed — the de2acfdb round was live-CUJ only, no suites run | pre-review-wave |
| Web | `PATH="$HOME/.nvm/versions/node/v24.14.0/bin:$PATH" npx vitest run` from `web/` | none known | 🟡 still owed (2245f57d rewrote chip/banner/dialog tests); not run in the de2acfdb round | pre-review-wave |
| Lint / hooks | `uv run --no-sync pre-commit run --all-files` (single file: `--files <path>`) | none known | 🟡 still owed — the de2acfdb round was live-CUJ only, no suites run | pre-review-wave |
| Live router probe | R6 | depends on staging AIGW availability + `databricks auth token` | ✅ evidence | 2026-07-28/29 |
Layered test plan (L1L8: unit, contract fixtures, live probe, hook unit,
server integration with a fake router, live-harness E2E, manual CUJ pass,
full-suite regression) is defined in plan §6; this table is the runbook for the
layers we actually execute on this branch.
### 2.10 CLI entry points (route before a native TUI launch)
The fourth surface: `omnigent claude|codex --smart-routing -p` (tier 2) and
`omnigent run --smart-routing -p` (tier 3, routes the harness too). Mechanics and
the per-commit split are in `CUJ_IMPLEMENTATION.md` §6. Landed as `8f3c0c60` /
`6f2893d9` (server half — create-time MODEL routing for a fixed native harness),
`8d7c9cb2` (CLI half), `b10a7239` (agent-name import fix). Run rows through
**R10**; unit rows are the three suites named below.
Suite command for every unit row:
`uv run --no-sync pytest tests/cli/test_smart_routing_cli.py tests/test_native_initial_prompt.py tests/server/routes/test_native_smart_routing_create.py`
(100 cases, all passing at `cd9fdccb`).
**Nothing on this surface has been driven live yet.** A unit row here is ✅
evidence of the *contract* only — it proves what the CLI sends and what it does
with the answer against a mocked server, never that a pane ran the routed model.
Every process-truth row is ⬜ until **R10** runs.
| Check | How to run | Ground-truth signal | Status | Last verified |
| --------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ---------------------- |
| Flags exist and self-document on all three commands | `omnigent claude/codex/run --help` | `-p/--prompt` + `--smart-routing` on `claude`; `--smart-routing` on `codex`; `--smart-routing` on `run` with both tier examples in the help epilogue | ✅ evidence (`--help` output) | 2026-08-01 / cd9fdccb |
| `--smart-routing` without `-p` is a usage error, before any side effect | R10, negative half | `click.UsageError` naming `-p` and the web UI; no daemon spawn, no server discovery | ✅ evidence (unit) | 2026-08-01 / cd9fdccb |
| Rejects `--resume` / `--continue` / `--session`, an AGENT, and the REPL-only flags | R10, negative half | `click.ClickException` per combination, each naming what to drop | ✅ evidence (unit) | 2026-08-01 / cd9fdccb |
| Preflight hard-errors when the server cannot route | R10 with `routing:` unset on the server | `GET /v1/info` `smart_routing_enabled` false ⇒ error naming the server and pointing at `--model`; **no** session created | ✅ evidence (unit) / ⬜ live | 2026-08-01 / cd9fdccb |
| Preflight hard-errors when the host's inference is not AIGW-backed (per family; auto needs both) | R9 flip, then R10 | host `gateway_inference[<harness>] = false` on `GET /v1/hosts` ⇒ error naming that harness and quoting the host's reason; absent map/entry must gate **nothing** | ✅ evidence (unit) / ⬜ live | 2026-08-01 / cd9fdccb |
| Create carries the routing contract (tier 2 = no `harness_override`; tier 3 = `"auto"`) | R10 + `chat.db` | `POST /v1/sessions` body `cost_control_mode_override:"on"` + `smart_routing_message` + `labels.omnigent.smart_routing="cli-route"`, `host_id` always with `workspace` | ✅ evidence (unit) | 2026-08-01 / cd9fdccb |
| **Tier 2: the create routes the MODEL for a fixed native harness** | R10 tier-2 rows + R1 | one **session**-scope decision row with `harness=claude-native`/`codex-native`, `applied=true`, no `raw_model`; `conversations.model_override` = that pick | ✅ evidence (unit, server half) / ⬜ live | 2026-08-01 / cd9fdccb |
| **Tier 3: the create routes harness AND model, and the CLI execs that wrapper** | R10 tier-3 row + R1 + R4 | `smart_routing: auto-harness harness=… model=…` in the server log; the wrapper the CLI launched matches `SessionResponse.harness` | ⬜ | — |
| Wrapper **attaches** to the routed session (never bundles a second one) | R10 + R1 | one conversation row for the launch, carrying the wrapper's presentation labels + the decision label; no orphan session | ✅ evidence (unit) / ⬜ live | 2026-08-01 / cd9fdccb |
| **The pane runs the routed model** | R10, then R2 (claude) / R3 (codex) | claude: runner log `launch_model=<routed>` and the pane's model banner; codex: `config.toml` `model = "<routed>"` + rollout `turn_context` | ⬜ | — |
| An explicit `--model` beats the routed pick | R10 with `--model <x>` | the launch carries `<x>`, not the router's id | ✅ evidence (unit) | 2026-08-01 / cd9fdccb |
| The prompt reaches the TUI intact (multi-line included) | R10 with a two-line `-p` | claude: one trailing argv entry, newlines preserved; codex: its own first-turn delivery (`message` event remotely, `_start_initial_turn` locally) | ✅ evidence (unit) / ⬜ live | 2026-08-01 / cd9fdccb |
| Exactly one decision — codex's first-turn `message` event does not re-route | R10 codex row, then count R1 rows | decision count stays **1**: the create's `model_override` closes the turn gate (§2.1 cadence rule) | ⬜ | — |
| Fail-open: a rejected/unreachable create still launches, behind a notice | R10 with the server stopped after preflight | stderr `omnigent: Smart Routing was unavailable (…); launching on the default harness/model.` and a plain wrapper session | ✅ evidence (unit) / ⬜ live | 2026-08-01 / cd9fdccb |
| Tier-3 fallback when the create resolves no launchable harness | R10, tier 3 | stderr `… did not resolve a launchable harness; launching claude-native.` | ✅ evidence (unit) | 2026-08-01 / cd9fdccb |
| Hostless degrade when the server has not seen this host | R10 before the host daemon registers | `known_host_id` returns `None`, the create omits `host_id`/`workspace`, and routing still runs over whatever it can resolve | ✅ evidence (unit) / ⬜ live | 2026-08-01 / cd9fdccb |
| `--smart-routing --harness kiro-native` (prompt-capable, outside the routed pair) | R10 with `--harness kiro-native` | preflight passes (no `gateway_inference` entry ⇒ unknown), the server's create-time gate declines, launch proceeds behind the "did not pick a model" notice | ⬜ known gap — `CUJ_IMPLEMENTATION.md` §6.6d, §7 | — |
---
## 3. Pre-manual-test round — status (2026-07-31 / `c0b08f68`)
Full headless round on the rebased tree (review waves + gateway-inference
gating), run before Bryan's manual visual pass. 17 live sessions; every check
that can run without a browser or a TUI eyeball was attempted, including four
rows that had never been exercised live.
| Area | Outcome at `c0b08f68` |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Canonical matrix (§2.1) | ✅ **15/15 exact as of 2026-07-31 / 3ccf86e3** (C1 on its tracked external note). Was ❌ 9 exact / 1 red / 3 blocked at `c0b08f68`; **B1** fixed by `3ccf86e3` (`4467f86f``databricks-claude-opus-4-8`, applied, no `raw_model`) and **B-sub / B-tog / A-sub** run live once the `invalid beta flag` 400 cleared (`cb35efd1`, `c9ce897d`). One cosmetic follow-up: `native_subagent` rows still stamp a prefix-only `raw_model`, so a subagent chip draws a substitution arrow for a same-arm restore |
| Codex decision + apply (§2.3) | ✅ re-verified — 6 sessions, `config.toml` + rollout `turn_context` on every one, 0 divergences |
| Claude decision + apply (§2.2) | ✅ **fixed at `3ccf86e3`** — apply was always faithful, and the *candidate list* now carries the launch-exact vocabulary, so the `cc` escalate arm reaches `claude-opus-4-8` (B1 exact, pane `Opus 4.8`) |
| Session-start-only cadence, **codex half** (§2.1) | ✅ **new**`63dbaf02`: trivial→luna, then a **P-SOL** turn 2 with the count still 1 and `config.toml` still luna |
| **Manual pin stops routing** (§2.1) | ✅ **new, never live before**`d7b30950`: pin then P-OPUS ⇒ no new decision, no `routing turn` line, pane holds Sonnet 5. Registry corrected: there is **no** `model already pinned` log line; the gate is silent |
| **R9 negative half — gateway-backed gating** (§2.3, §2.4) | ✅ **new, never live before** — codex flip ⇒ `codex-native: false` with `claude-native: true`; config restored byte-exactly (md5 match) and the host re-reported all-`true`. UI-hidden checks stay for Bryan |
| **Positive canary fire + clear** (§2.5) | ✅ **new, never live before**`678ed161` raised `subagent_routing_unenforced` in its session payload and cleared on the next healthy tick |
| **Omnigent child-session family constraint** (§2.5) | ✅ **new, never live before, both directions** — codex parent + P-OPUS ⇒ `databricks-glm-5-2` (`scope: child_session`); claude parent + P-GLM ⇒ a claude arm |
| Codex enforcement chain — hook trust, watcher, spawn audit (§2.5) | ✅ re-verified, incl. correct degradation of the trust line when a hook is broken |
| Warning hygiene (§2.5) | ✅ re-verified — 0 `subagent_routing_unenforced` across the 17 healthy sessions |
| Mid-session toggle, **codex + claude** (§2.5) | ✅ re-verified per-call both ways on **both** harnesses — codex `6055d8a9`, claude `cb35efd1` (B-tog) |
| **Claude Task spawns + cross-harness A/B** (§2.1, §2.4, §2.5) | ✅ **unblocked and fresh** — B-sub `cb35efd1` (Explore→sonnet-5, general-purpose→opus-4-8, both ran, claude-family only) and A-sub `c9ce897d` (claude parent → **codex** arm `databricks-gpt-5-6-luna` for its spawn, soft redirect in the pane) |
| Run-level log assertions (§2.7) | ✅ 0 `harness=None`, 0 `no spelling`, 0 `route_turn skipped`, 0 `cannot run`, 0 `routes:select 40x` |
| Enforcement blind spot (§2.5 note) | ❗ **new finding** — a broken `route-subagent` hook (audited spawns, zero routing) reports healthy |
| Codex TUI status bar, chip pairing, sub-agents panel, warning banner render | 🟡 **still owed** — headless round; no browser, no TUI eyeballing |
| Forced mid-session re-route / lost-launch-race half (§2.3) | 🟡 **unreachable by design** — routing is session-start only (plan §10 decision 4); not a coverage gap |
| Four automated suites (§2.9) | ✅ carried forward from the rebased-tree run (2026-07-31 / b7c894a7); not re-run this round, no code changed since |
| Telemetry against a live ingestion endpoint (§2.6) | 🟡 unchanged |
| Fork-spawn exemption (§2.5) | ⬜ unchanged, test-pinned only |
| Isolation (§2.7) | ❌ reproduced — 10 more codex bridge dirs under the real `~/.omnigent/codex-native/`. Workspace side clean: **no** `git status` delta in `~/omnigent` |
**Remaining steps:**
1. ~~**Fix B1**~~**done at `3ccf86e3`**: turn routing is served the
launch-exact claude vocabulary, so the `cc` escalate arm no longer degrades to
sonnet-5. Re-verified on `4467f86f`.
2. ~~**Chase `invalid beta flag`**~~**cleared externally** (gateway allowlist,
no omnigent change). Watch for regressions: the tell is the pane 400.
3. ~~Re-run B-sub / B-tog / A-sub~~**done 2026-07-31 / `3ccf86e3`** on
`cb35efd1` and `c9ce897d`; all three exact. New follow-up: give
`_decision_from_result` (`omnigent/runner/subagent_routing.py:599-600`) the
same `_bare_id` prefix normalization the turn path has, so `native_subagent`
rows stop stamping a prefix-only `raw_model` and subagent chips stop drawing a
false substitution arrow.
4. Eyeball §2.6 on a fresh claude and a fresh codex session (chip pairing,
sub-agents panel model, warning banner) and the codex TUI status bar — the
only remaining browser/TUI work.
5. Decide whether "armed + audited spawns + zero relayed decisions" should warn
(§2.5 blind spot).
6. ~~Take the glm serving gap (responses API vs chat/completions) back to the
AIGW/ucode owners~~ — **worked around client-side 2026-08-01 / `907f8886`**:
the arm applies under the gateway model route `system.ai.glm-5-2` and C1
completes. The ask still stands, and it is now cosmetic rather than blocking:
advertise `openai/v1/responses` on the `databricks-glm-5-2` endpoint, or list
the model route, and the pinned alias can go.
## 4. Where we stand
The **codex** arm is fully evidence-verified at `c0b08f68` — decisions, apply,
subagent hooks, the per-call toggle, session-start cadence and the manual pin.
Four rows that had never run live all passed this round: the manual pin, the R9
negative gating signal, the positive `subagent_routing_unenforced` canary (fire
*and* clear), and the omnigent child-session family constraint in both
directions.
The **claude** arm had one real bug and one external wall at `c0b08f68`; **both
are gone as of 2026-07-31 / `3ccf86e3`.** The bug was **B1** — the turn-scope
route scored against a stale pre-launch picker snapshot, so the router's
`claude-opus-4-8` escalation was silently substituted with `claude-sonnet-5`;
`3ccf86e3` serves turn routing the launch-exact vocabulary and B1 is exact again
(`4467f86f`). The wall was `invalid beta flag` (claude CLI 2.1.220 vs the staging
gateway), cleared gateway-side with no omnigent change — which let **B-sub,
B-tog and A-sub** run live and pass. Remaining claude-side nit: `native_subagent`
decisions still record a prefix-only `raw_model`, so subagent chips draw a false
substitution arrow (§2.1 note).
The **glm serving gap is closed** as of 2026-08-01 / `907f8886`. GLM serves codex
under the gateway model route `system.ai.glm-5-2`, the arm applies under that
name, and C1 ran end to end on session `80fb6d1f` with zero `BAD_REQUEST`. One
residual: that name is pinned in code, because no listing carries it.
Also open: the UI/TUI visual layer (§2.6 + codex status bar), fork routing
policy, telemetry against a real ingestion endpoint, the `~/.omnigent`
codex-bridge isolation leak, and the enforcement blind spot in §2.5.
---
## 5. History (archive)
- **2026-07-29** — First live CUJ pass. Claude Code: Smart Routing selectable,
sticky default, decision + chip with correct `task_v1` `cc` rationale
confirmed by Bryan. Codex CUJ, auto CUJ and subagent chips confirmed the same
day. UI labels renamed to "Smart Routing" (`e5c8a160`).
- **Claude apply-layer bug** (fixed, proof at `82cac6fa`) — the routed model
never reached the process: `model_override` was dropped in `_run_turn_bg` and
the alias vocabulary did not match. Fix verified by pane capture (`/model sonnet` under the inject lock, banner Opus 5 → Sonnet 5, idempotent on
turn 2).
- **Codex apply-layer race** (fixed at `51801530`) — launch race lost the model
push; fixed with a first-turn push + config mirror + forwarder hardening,
verified by `model_override` and `config.toml` both holding luna.
- **Child-session family leak** (fixed at `5a397d6f`) — a codex parent produced
nine forced-auto children, some on claude-opus. Children now stay in the
parent's family unless the parent is genuinely Smart Routing.
- **Codex hook shadowing incident** (fixed at `518376ba`) — codex subagent hooks
did not execute at all: the app-server ignored the bypass flag (persisted
trust handshake added) and cwd shadowing killed hook imports (hook commands
now run under `python -I`). The `subagent_routing_unenforced` canary watcher
is what caught it, and Bryan screenshotted the live warning banner during the
incident.
- **Codex spawns with no routable signal** (`a95105c9`) — the router is skipped
rather than fed an empty prompt.
- **Per-turn re-routing, built and reverted same-day** (`23cfdbc2`, reverted by
`720b145b`) — a provenance gate let a router-authored `model_override` stay
routable so every turn re-routed. It was **live-verified** on 2026-07-30
(turn-2 re-route observed with a new decision row and the process switched),
then Bryan ruled routing is **session-start only** and the behavior was
reverted the same day, along with its docs (`05a4b9e5`, `88ec745f`). The
branch is back to the fully-verified pre-experiment state (15/15 matrix at
`de2acfdb`); the §2.1 cadence rows are marked pending re-verification on the
post-revert stack.
- **2026-07-30, first matrix pass (**`158042a3`**)** — 11/14 rows exact; A2/A4/A-sub
red.
- **2026-07-30, matrix closed (**`972dea9d`**)** — 14/14 exact after fixing:
`route_session_harness` double-resolved the client's already-local pick and
dropped the harness (auto fell back to default); model discovery flipped
between `databricks-` and `system.ai.` spellings nondeterministically (now
unioned, `databricks-` preferred); launch alias pins now target the frozen
`task_v1` claude arms so turn-1 `/model` can reach the routed model; prefix
stripping is separator-safe (no more `.claude-*` router ids). Log clean: 0
`harness=None`, 0 no-spelling warnings; panes showed Opus 4.8 on A1 and B1.
- **Smart Routing dropdown group** (`76749e03`) — lifted into its own unlabeled
group above the harnesses.
- **2026-07-30, full post-review-wave round (**`de2acfdb`**)** — every matrix
row re-run headless on the live stack, 15/15 exact with process truth; both
toggles, both subagent surfaces and the run-level log assertions green. Three
findings that were not visible before: (a) glm routes and applies exactly but
the endpoint refuses codex's `openai/v1/responses` API, so the C1 turn errors
— present in the 2026-07-29 rollout too, so the earlier "end to end" wording
was too strong; (b) routing is first-turn-only by construction (the routed
turn's own `model_override` is the pin the gate reads), which makes a forced
mid-session re-route unreachable — since ratified as the product decision;
(c) codex per-session bridge dirs are created under the
real `~/.omnigent/codex-native/` despite `OMNIGENT_CONFIG_HOME`, so R0's
isolation claim is only partly true.
- **2026-07-31, pre-manual-test round (**`c0b08f68`**)** — full headless round
on the rebased tree, 17 sessions. Four never-live rows closed: the manual pin
(`d7b30950`), the R9 negative gating signal (codex `false` / claude `true`,
config restored md5-identical), the positive `subagent_routing_unenforced`
canary (`678ed161`, fire **and** healthy-tick clear), and the omnigent
child-session family constraint in both directions (codex parent + P-OPUS →
`databricks-glm-5-2`; claude parent + P-GLM → a claude arm). Codex arm is
clean end to end. Three findings that were not visible before: (a) **B1 is
red** — the claude turn path scores its candidate list from
`_model_options_cache`, filled pre-launch from the host picker and never
re-read despite being marked stale, so the router's `claude-opus-4-8` is
substituted with `claude-sonnet-5` ~100 ms after the launch env pinned the
arm correctly; (b) **claude CLI 2.1.220 cannot complete any turn** against the
staging gateway (`invalid beta flag`) even with
`CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` set, which blocks B-sub, B-tog and
A-sub; (c) an **enforcement blind spot** — breaking only the `route-subagent`
hook yields audited-but-unrouted spawns that report `warnings: []`, because
`reconcile_spawn_audit` detects a *contradicted* rewrite, not a missing one.
Also corrected: the "gate logs `model already pinned`" claim — no such line
exists; the manual-pin decline is silent.
- **Still-open notes from the 972dea9d run** — codex spawn naming rarely reaches
hooks (`task_name` missing on named spawns); GLM-case→opus under the `both`
scenario is recipe feedback for Ivan (needs `task_v2`).
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
# Live model state: making every surface reflect the running model
Owner: routing MVP. Status: partially implemented (see "Implemented" /
"Deferred" at the end). Companion evidence: live protocol probes against
codex-cli 0.145.0 (`codex app-server`, tmux-driven `--remote` TUI).
## Problem
On a codex-native session with intelligent routing, the rollout file proves
turns ran the routed model (`gpt-5.6-luna`), but:
- the codex TUI `/model` surface shows the thread's startup default
(`databricks-gpt-5-5`),
- the omnigent web dropdown does not track the routed model, and
- `conversations.model_override` can silently revert to the launch default.
## Where "current model" lives today (evidence)
### Codex-native process side
- Launch pins the model twice: the app-server pins it into the per-session
`config.toml` (`_pin_codex_config_model`,
`omnigent/codex_native_app_server.py:205-244`) and the TUI is launched with
the same value as a CLI override (`-c model="..."` in
`build_codex_remote_args`, `omnigent/codex_native_app_server.py:2140-2219`).
- A web/routed model change is applied thread-level: the runner threads
`request.model_override``ExecutorConfig.model`
(`omnigent/runtime/harnesses/_executor_adapter.py:281-285`) and
`CodexNativeExecutor.run_turn` sends `thread/settings/update` before the
bare `turn/start` (`omnigent/inner/codex_native_executor.py`, run_turn's
no-active-turn branch; `_model_effort_overrides` at the bottom of the file).
- The forwarder mirrors TUI→omnigent: `_refresh_model_from_config` reads the
per-session `config.toml` top-level `model` key (what an in-TUI `/model`
writes) at subscription (`omnigent/codex_native_forwarder.py:2123-2132`) and
at **every** `turn/started`
(`omnigent/codex_native_forwarder.py:2893-2900`), then `_sync_model_change`
posts `external_model_change` when it differs from the last posted baseline
(`omnigent/codex_native_forwarder.py:2735-2774`). `thread/settings/updated`
notifications also feed `forwarder_state.model`
(`omnigent/codex_native_forwarder.py:2923-2938`, state at `:383-460`).
- The cost-gate hook reads `config.toml` synchronously at tool-gate time
(`omnigent/codex_native_hook.py:134-150`,
`read_codex_config_model` in `omnigent/codex_native_bridge.py:277-311`).
### Server side
- `conversations.model_override` (packed into `session_overrides` JSON,
`omnigent/db/db_models.py:797-802`; entity
`omnigent/entities/conversation.py:221`) is written by: session create
(`orchestration.py:5620-5628`), first-message/turn routing
(`orchestration.py:3774-3790` top-level, `:3743-3756` child), auto-harness
(`orchestration.py:3653-3655`), and `external_model_change`
(`_persist_external_model_change`,
`omnigent/server/routes/_sessions/helpers.py:1901-1952` — dedupes against
`conv.model_override`, publishes a `session.model` SSE).
- Routing only runs when no model is pinned:
`_should_route` requires `effective_runner_override is None` for top-level
sessions (`orchestration.py:3706-3713`).
- The forwarded runner body carries
`model_override = body.model_override or conv.model_override`
(`helpers.py:5302-5306`, `orchestration.py:3792-3793`).
### Web UI
- `sessionModelOverride` mirrors the server field
(`web/src/store/chatStore.ts:379`, hydrated at `:2282`), updated by the
PATCH path (`:1768-1783`) and by the `session.model` SSE (`:4205-4218`).
- The dropdown/status label resolve through `useResolvedComposerModel`
(`web/src/pages/ChatPage.tsx:5960-6041`); the router's session pick renders
as `RoutingDecisionCard` (`web/src/components/blocks/StatusBlocks.tsx:144-300`,
rendered at `ChatPage.tsx:3048-3060`).
### Claude-native (reference behavior)
- The executor applies a routed switch by typing `/model <m>` and injecting
the message under one lock
(`omnigent/inner/claude_native_executor.py:152-189`, dedupe baseline
`_should_switch_model` at `:190-212`), so the harness's own UI reflects the
switch; the claude forwarder mirrors it back. This is the model (a) truth
flow we want for codex.
## Codex 0.145.0 app-server facts (probed live)
Probes: `probe_appserver.py`, `probe_broadcast.py`, `probe_tui.py`
(scratchpad; reproducible against `codex-cli 0.145.0`).
1. `thread/settings/update` exists and **is** the thread-level model switch —
it requires the `experimentalApi` capability at `initialize`
(omnigent already sends it, `codex_native_app_server.py:409-414`).
2. It emits a `thread/settings/updated` notification carrying the new
`threadSettings.model`, and the notification **is broadcast to other
connected clients that resumed the thread** (verified with two ws
clients) — i.e. the `--remote` TUI receives it.
3. It does **not** write `config.toml`.
4. `turn/start` has no per-turn model parameter (an extra `model` field is
silently ignored).
5. The live TUI (tmux probe): the **bottom status bar updates immediately**
to the new model after a remote `thread/settings/update`
("`gpt-5.3-codex default · …`"); the startup banner box stays on the
launch model (static), and the `/model` picker list does not highlight
models outside its catalog.
## Root cause of the observed divergence: the config.toml reversion loop
The routed switch is applied thread-level (rollout runs `gpt-5.6-luna`), but
`thread/settings/update` never touches `config.toml`, which still holds the
pinned launch model. Then:
1. Turn N routes → `model_override = luna` persisted
(`orchestration.py:3779-3783`) → executor `thread/settings/update(luna)`
`turn/start`.
2. Forwarder handles `thread/settings/updated` → posts
`external_model_change(luna)` (deduped server-side, `model_override`
already `luna`).
3. Forwarder handles `turn/started``_refresh_model_from_config` re-reads
the **stale** `config.toml` (`databricks-gpt-5-5`)
(`codex_native_forwarder.py:2893-2900`) → `_sync_model_change` posts
`external_model_change(databricks-gpt-5-5)` → server persists
`model_override = databricks-gpt-5-5` and publishes `session.model` with
the default.
4. Turn N+1: `model_override` is non-None (`databricks-gpt-5-5`) so routing
is skipped ("model already pinned", `orchestration.py:3714-3723`) and the
executor sends `thread/settings/update(databricks-gpt-5-5)` — the thread
itself **reverts to the default**.
Net effect: the routed model survives one turn; every surface (TUI status
bar, `/model`, web dropdown, cost gate via the hook's `config.toml` read)
settles back on the launch default — exactly what was observed live.
Secondary gaps:
- **No SSE on routing persist**: routing wrote `model_override` without
publishing `session.model`, so the web dropdown lagged until reload.
- **Launch race**: the runner's terminal auto-create reads the snapshot's
`model_override` (`omnigent/runner/native/orchestration.py:711-739`,
used at `:3458-3473`) — a first message routed after the snapshot read
launches the TUI pinned to the default. (Benign once the reversion loop is
fixed: every turn re-applies `ExecutorConfig.model` via
`thread/settings/update`, so the running thread converges on the routed
model on the very first turn.)
## pi harness: which models it cannot serve, and why
`_HARNESS_EXCLUDED_MODELS["pi"]` in `omnigent/server/smart_routing.py` names the
(harness, model) pairs corrected AFTER the router verdict. They are deliberately
NOT pruned from the candidate set: the router requires its full scenario menu
and 400s on a partial one, so the full set must be offered and an incompatible
pick moved to a harness that can run it (`_redirect_incompatible_pick`).
The pi harness reaches Databricks two incompatible ways for these families:
- **Claude models** ride pi's Anthropic Messages gateway, whose request path
adds an `eager_input_streaming` field the serving endpoint rejects with a 400
when tools are present.
- **The gpt-5.5 / gpt-5.6 reasoning models** ride pi's openai-completions path
(`/chat/completions`); Databricks applies a default `reasoning_effort` there
and rejects tool calls with "Function tools with reasoning_effort are not
supported for gpt-5.5 … use /v1/responses or set reasoning_effort to 'none'."
pi's provider cannot send that override, so tool turns 400.
Where each excluded pick lands instead: `claude-sdk` serves Claude, and `codex`
serves gpt-5.5+ over the Responses API. The gpt-5.4 family works on pi and is
left alone. `databricks-gpt-5-6-terra` stays on the pi exclusion list (a real
serving incompatibility) even though it is not one of the router's arms.
## Design
Priority order per the goal:
(a) **Running process is truth.** The codex thread's settings are the truth;
`config.toml`'s top-level `model` is the on-disk mirror all omnigent readers
(forwarder mirror, cost-gate hook) already use. Therefore: whenever omnigent
switches the thread model, it must update **both** the thread
(`thread/settings/update`) and the mirror file, the same key an in-TUI
`/model` writes. Last-writer-wins matches user-switch semantics.
(b) **Switch through a surface the harness UI reflects.** The thread-level
switch is already the mechanism, and the TUI's status bar live-updates from
`thread/settings/updated` (probed). The `/model` picker's highlight is
upstream TUI behavior; with the reversion loop fixed the thread genuinely
stays on the routed model, so `/status` / the status bar / a resumed TUI all
agree. The `RoutingDecisionCard` remains the explicit marker of the routed
model in the omnigent transcript.
(c) **Session snapshot/UI shows the live model.** The forwarder's
`external_model_change` mirror already covers harness-observed state; with
the mirror file in sync it reports the routed model instead of clobbering
it. Additionally routing now publishes `session.model` at persist time so
open web clients update immediately; if the harness-side apply fails, the
forwarder's next mirror corrects the value (self-healing, harness wins).
(d) **Launch race closed by the per-turn push.** Because every turn carries
`model_override``ExecutorConfig.model``thread/settings/update`, a
terminal launched before routing persisted still converges on the routed
model at its first omnigent-driven turn. No launch-ordering change needed.
### Changes
1. `omnigent/codex_native_bridge.py` — new `write_codex_config_model`
(companion to `read_codex_config_model:277`): upserts the top-level
`model` key only (stops at the first `[section]`), best-effort
(`False` on OSError; the live thread already switched).
2. `omnigent/inner/codex_native_executor.py` — in `run_turn`, after a
successful `thread/settings/update` that carried a `model`, mirror it via
`write_codex_config_model` (warn on failure). This closes the reversion
loop and fixes the cost-gate hook's model read for routed turns.
3. `omnigent/server/routes/_sessions/orchestration.py` — new
`_publish_routed_model` helper; called after routing persists
`model_override` (top-level path and child path) so the web dropdown
updates live (same event `_persist_external_model_change` publishes).
### Interaction notes
- In-TUI `/model` still wins: it writes the same `config.toml` key; the
forwarder mirrors it up; the next turn's `ExecutorConfig.model` equals the
new `model_override`, so the executor's `thread/settings/update` is a
no-op re-assert of the user's pick.
- Server-side dedupe (`helpers.py:1940`) prevents event echo loops: the
forwarder's mirror of an omnigent-initiated switch matches
`conv.model_override` and no-ops.
- A steered (mid-turn) message skips the settings branch by design; the
switch lands at the next turn boundary.
### Forwarder hardening (implemented — follow-up packet)
`_CodexForwarderState` now tracks `settings_model` (last live
`thread/settings/updated` model — the running thread's truth) and
`last_config_model` (the config.toml value as of the previous read).
`_refresh_model_from_config` precedence: a config.toml value that CHANGED
since the last read wins (in-TUI `/model` or the executor's mirror write —
freshest signal); an unchanged config defers to `settings_model`; otherwise
the config value is adopted as before. This keeps the routed model even when
the executor's config mirror write fails, while still honoring a genuine
in-TUI `/model` rewrite.
### Launch race — source analysis (no further change needed)
- A terminal created AFTER routing already launches on the routed model:
`_codex_native_launch_config` GETs the live snapshot at create time and
prefers `model_override` (`omnigent/runner/native/orchestration.py:694-748`,
used at `:3458-3473`).
- The racy case is auto-create at session bind (POST /v1/sessions), which by
definition precedes the first message — `model_override` cannot exist yet,
so no re-read timing helps, and the pin cannot be skipped (it exists to
stop the stale shared-config model from being mirrored;
`codex_native_app_server.py:205-244`, applied at `:635` — a file owned by
the enforcement agent). The race is closed functionally by the first-turn
push: the forwarded message carries `model_override` in-band
(`orchestration.py:3337-3342`), and `CodexNativeExecutor.run_turn` applies
`thread/settings/update` + the config mirror under `_inject_lock` BEFORE
`turn/start` — the same locked switch-then-inject discipline as
claude-native's `/model` injection.
Note: the "native TUI flow" and the executor flow are the same path —
`omnigent/inner/codex_native_harness.py` builds `CodexNativeExecutor` as the
sole injection bridge for web-originated codex-native messages, so the
executor-side push covers the native TUI sessions.
## Implemented (this packet — all files outside the two in-flight agents' sets)
- `omnigent/codex_native_bridge.py`: `write_codex_config_model` (+ `re` import).
- `omnigent/inner/codex_native_executor.py`: mirror write after
`thread/settings/update`.
- `omnigent/server/routes/_sessions/orchestration.py`:
`_publish_routed_model` + calls in both routing persist paths.
- Tests: `tests/test_codex_native_bridge.py` (writer upsert/insert/create),
`tests/inner/test_codex_native_executor.py` (config mirror on model
switch; effort-only leaves model), and
`tests/server/integration/test_routing_integration.py`
(`session.model` SSE published on routed persist).
- `omnigent/codex_native_forwarder.py` (unblocked mid-flight, edits confined
to the model-mirror regions: state fields, `note_thread_settings_updated`,
`_refresh_model_from_config`): settings-model preference described above,
plus regression tests in `tests/test_codex_native_forwarder.py`
(pushed-model holds over stale config; changed config wins; launch-race
scenario ends on the routed model).
## Deferred — patch plan for the next packet (owned files in flight)
1. `omnigent/inner/codex_executor.py` (owned): if the SDK-codex harness ever
gains routing-driven model changes, apply the same "switch + mirror" rule
there.
2. Upstream/TUI: the `/model` picker's current-selection highlight after a
remote switch is codex TUI behavior; the status bar already reflects the
live model on 0.145.0. If a stronger in-terminal marker is wanted, the
terminal wrapper label (`omnigent/_wrapper_labels.py`) could append the
live model to the tmux status line, driven by the same `session.model`
stream — nice-to-have, not required for correctness.
3. Forwarder reconnect edge: a forwarder that reconnects after a failed
config mirror write re-adopts the stale config at subscription (its
`settings_model` baseline is per-connection). Acceptable — the next
routed turn re-pushes and re-mirrors; a full fix would seed
`settings_model` from the `thread/resume` response, which touches
resume semantics deliberately left to the forwarder's owner.
+259
View File
@@ -0,0 +1,259 @@
# PR rewrite plan — slimmer, restructured routing PR
> **How to reference this document.** Every block carries an ID: section number + letter (`2c` = third block of §2). Speak the ID and it names the block.
**0a** Goal: a rewritten branch replaces PR #3506. The new branch ships the same three CUJs plus the CLI entry points. It is a fraction of the current size. Its commit series is short enough for a reviewer to read. The three CUJs are Smart Routing as a model choice on Claude Code and Codex, the Smart Routing harness, and routed native subagent spawns. Bryan critiques this plan before the Opus fleet executes it.
**0b** Hard constraints. Bryan chose a complete rewrite from scratch on 2026-08-02 (7g). The fleet therefore writes new code, and it does not move old code. The behavior target does not change, and 2a2e define it. The branch starts from current origin/main, which is 201 commits ahead again. No evidence transfers: the registry's 15/15 matrix attests to the OLD tree, so every row returns to unverified and the fleet earns it again (6f). Cuts remove scope, not correctness. Only a fully verified branch replaces #3506.
**0c** A from-scratch build must not rediscover what this branch already learned. Three inputs are therefore required reading, per slice, before an agent writes code:
- **The behavior inventory.** `designs/CUJ_STATUS.md` §2 lists every behavior the reference implementation verified, and it is the specification for what to build. It is also the coverage gate for the tests (3g). Read it as a behavior list, not as a status report: its ✅/🟡/⬜ marks and dates describe the OLD tree (6f), and wave 0 removes the rows for work this plan does not build.
- **The trap list.** `designs/CUJ_IMPLEMENTATION.md` states each step in the form "why the naive approach failed". `designs/INTELLIGENT_ROUTING_PLAN.md` §12 holds the deltas in the form plan assumption → what reality showed → what shipped. Both documents exist because the naive implementation of nearly every step is wrong. An agent that skips them rediscovers each trap by breaking a live session. §11 of the same file holds the test matrix and its verbatim prompts, and §1 names the five router arms.
- **The reference implementation.** Branch `routing-mvp-v1`, pinned at `f200a8bd`, is the working version. It stays checked out in a sibling worktree for the whole build. An agent reads it to answer "what did the working version do here". An agent never copies from it wholesale. `designs/ROUTING_OVERVIEW.md` is this plan's companion: it names every subsystem, its files, and its wave.
**0d** Rewrite the shape, and transcribe the constants. The structure is worth writing again: the module boundaries, the names, the control flow, and the tests. A small set of values and orderings is NOT worth rediscovering, because experiment produced each one and only the old tree records it. These are examples: `python -I` in the hook command; the codex version probe that runs before config population; the trust handshake that follows the app-server connect and precedes the first turn; the alias-pin exactness check; the timeout ladder; separator-safe prefix stripping. An agent transcribes such a value verbatim, and it cites the trap in a one-line comment. An agent never "cleans up" a constant that it cannot explain.
## 1. What the PR is today
**1a** PR #3506, the reference implementation on `routing-mvp-v1` (`f200a8bd`), adds **29,924 insertions over 155 files** against origin/main. The composition is: python tests 9,591; web 7,729 (of which `web/package-lock.json` is 3,451 generated lines and `web/src` is 4,278, itself 2,625 tests); docs 3,498 under `designs/` plus `REVIEW_FIXES.md`; server 2,461; runner+inner 3,054; adapters 1,394; cli+other 1,873; telemetry 296. Hand-written production source is about 8,000 lines, and tests are 12,216 lines. Treat every figure here as a scale reference, not as a budget: 3j holds the size rule.
**1b** These are the largest single files: `runner/subagent_routing.py` +1,248; `server/smart_routing.py` +1,120; `NewChatDialog.test.tsx` +956; `orchestration.py` +886; `hook_scripts/subagent_router.py` +675; `cli.py` +505; `NewChatDialog.tsx` +488; `codex_executor.py` +484; `smart_routing_cli.py` +394 (the CLI workstream).
## 2. Keep-core: the minimum each CUJ needs
**2a** CUJ A is the model choice on claude and codex. It keeps:
- `smart_routing.py`: the client, the seam, the arm menus, and the family fallback from 3i.
- The orchestration turn gates.
- Decision persistence as conversation items.
- The claude apply layer: the alias vocabulary, the alias pins, and `/model` injection.
- The codex apply layer: the settings push, the config mirror, forwarder precedence, and the glm gateway route (`907f8886`).
- The chip rendering rules.
- The configure-dialog model option.
- Gateway-backed gating, which is Bryan's explicit rule.
2f holds the routing backend selection that sits behind this seam.
**2b** CUJ B is the Smart Routing harness. It keeps `_resolve_native_smart_routing`, the pre-session catalogs, `smart_routing_message`, and the harness row with its persistence. It is the only harness that allows cross-family subagents, and 3c states how a cross-family need is met. It runs the codex machinery underneath when it resolves to codex, so it inherits the whole codex apply layer, including the glm gateway route.
**2c** CUJ C is routed subagent spawns. It keeps the hook scripts, the loopback relay, and the server policy (`resolve_subagent_route`). It also keeps the family constraints and the per-session override with its Inherit row. It also keeps the codex `hooks.json` generation, the trust handshake, and `python -I`.
**2d** The CLI is the new workstream, and it must survive the rewrite. It keeps `smart_routing_cli.py` and the `--smart-routing`/`-p` flags. It also keeps the tier-2/3 commits, which are now merged. `8f3c0c60` (merge `6f2893d9`) is the server half: create-time MODEL routing for a create pinned to one *fixed* native harness. The turn gate can never reach that case, because a TUI's turns originate in the pane. `8d7c9cb2` is the CLI half: the flags, `smart_routing_cli.py`, the dispatch-spec `prompt_param`, and both dispatch tiers. `b10a7239` fixes the `CLAUDE_NATIVE_AGENT_NAME` import against this branch's `harness_plugins` layout. `CUJ_IMPLEMENTATION.md` §6 holds the mechanics. `CUJ_STATUS.md` §2.10 holds the registry rows.
**2e** **The in-session model indicator must show the routed model (new must-fix).** The session UI shows the active model at the bottom right. Bryan saw the terminal run the routed model while that display showed the old one. The rewrite treats this as a bug to fix, not as inherited behavior. Two channels disagree, and that disagreement is the cause: `SessionModelEvent` plus the chatStore picker state on one side, and the pane on the other. The fix belongs to wave-2 stream 5, and it makes the display show the same routed value that the pane applied. Three UI surfaces must pass acceptance:
1. Smart Routing appears as a model option on the Claude Code and Codex configure dialogs.
2. Smart Routing appears as a harness.
3. The in-session model display shows the routed model.
**2f** **The managed routing backend, chosen per request (7h).** This work ships in this PR. One routing seam holds two backends. A preview-flag evaluation chooses the backend on **every request**, and not once at construction. When the flag is on for the workspace, the seam routes through the AI Gateway `routes:select` API. When the flag is off, the seam routes through the naive LLM judge. A flag-off workspace therefore still gets Smart Routing. The flag selects the routing *quality*, and it never removes the feature. Three consequences follow:
- No surface needs a flag-aware gate. Routing is available either way, so the `routing_client is not None` checks stay correct.
- The two backends offer different menus. task_v1 requires its frozen arm menu, because that menu is a wire contract. The judge scores over the workspace's servable catalog instead. The family fallback (3i) is therefore a task_v1-path concern, because the judge can never pick a model the workspace does not serve.
- The backend choice is independent of gateway-inference gating (3f). The gate asks whether the *pane* runs on the gateway. The flag asks which router picks the model.
The flag itself is managed-side, and OSS must not hardcode it. The Databricks managed plugin evaluates the SAFE flag `databricks.mas.omnigent.intelligentRouting`, which defaults to OFF. OSS therefore exposes a **per-request predicate** on the seam — a callable that the deployment supplies and that the seam consults on each `route()` — and the managed plugin binds that predicate to its flag evaluation. When no deployment supplies a predicate, OSS defaults to "use the AI Gateway client when one is configured, and the judge otherwise". Wave-1 stream 2 builds the predicate seam, the dispatch, and the default. It does not build a flag system.
**2g** **What main already provides.** The fleet writes new code, and it must not re-implement what `origin/main` already ships. `omnigent/server/smart_routing.py` on main (850 lines) already holds `ExternalRoutingClient` (the `routes:select` client), `LLMRoutingClient` (the judge), and `_redirect_incompatible_pick` (the wire-compat redirect that 3k keeps). `omnigent/cli.py` on main already builds one client or the other at startup (`routing_client: ExternalRoutingClient | LLMRoutingClient | None`). Main also owns the discovered-catalog plumbing and the judge rubric. Every stream reads its area on main **before** it writes, and it extends main's mechanism rather than adding a parallel one. This is what shrinks wave-1 stream 2 to a predicate, a dispatch, and a default (2f).
## 3. Cut list — each with size, what is lost, and my recommendation
**3a** **Docs ride the branch, and a final commit deletes them (7j).** Six files ride: `designs/PR_REWRITE_PLAN.md`, `designs/ROUTING_OVERVIEW.md`, `designs/CUJ_STATUS.md`, `designs/CUJ_IMPLEMENTATION.md`, `designs/INTELLIGENT_ROUTING_PLAN.md`, `designs/LIVE_MODEL_STATE.md`, plus `REVIEW_FIXES.md` at the repo root. Wave 0 brings them onto the branch (4b), because none of them exists on `origin/main`. They stay tracked while the PR is open, because Bryan reads them there. They are his reference, and they are not a deliverable. A final commit deletes exactly those paths before the merge, so the merged diff carries **no** docs and no docs PR follows. The other files under `designs/` belong to main; leave them alone.
**3b** **The codex enforcement stack → cut entirely (~1,200 src + ~1,500 tests). RESOLVED (7a).** These parts all leave the tree: the canary, the enforcement watcher, the spawn audit and its reconciliation, the warning banner (web and server halves), `session_warnings`, and the R8 machinery. Bryan's call: make hook execution work all the time, instead of reporting when it does not. A banner that tells the user routing may not have applied is not a product surface. Fix the underlying path instead. So the rewrite ships no canary, no watcher, no spawn audit, no warning banner, and no `session_warnings`. The rewrite keeps the hook generation and the trust handshake, because deterministic subagent routing depends on them. A follow-up may reintroduce observability if hook execution ever proves unreliable in the field. Nothing in this plan schedules that work. This is still the single biggest source-side cut.
**3c** **The Smart Routing harness agents get the session-creation tool (7i).** Every Smart Routing harness agent gets `sys_session_create`, so an agent that needs another family creates a session instead of failing. A deny message is not a substitute for the tool. The reference implementation tried exactly that, and the `A-sub` row in `CUJ_STATUS.md` records the result: a model reads "spawn denied, use `sys_session_send` instead", and then it gives up. Bryan owns the iteration on how well the agents use it, so the fleet flags the observed behavior for him once the implementation lands, and it does not tune the prompt.
**3d** **Fork-spawn exemption → cut (~80).** Only tests pin it. Nobody verified it live. It has no user-visible surface. Recommendation: cut it. A fork then inherits the session model implicitly.
**3e** **No telemetry in this PR (7j).** The routing analytics events do not ship here. Bryan takes all telemetry in a follow-up PR (4d), so this PR emits no routing events and adds nothing under `omnigent/telemetry/`.
**3f** **Gateway-inference gating → keep, as a wave-1 workstream (~900 src+tests).** It is Bryan's explicit product rule, and it holds the PR's only migration. The rule has two clauses. First, the Model row offers Smart Routing for a harness only when the host reports that **that harness's** family resolves its inference to the AI Gateway. Second, the Smart Routing harness row appears only when the host reports gateway inference for **both** the claude and the codex families, because the harness routes across both. A host that reports nothing counts as unknown, and unknown never hides the option. One defect in the reference implementation must not survive the rewrite. Its codex check reads the base URL that Omnigent itself resolves, so a provider of `kind: cli-config` — which delegates to the user's own `~/.codex/config.toml` — yields `None` and reports **not backed**, even when that codex install does route through the Gateway. Measured 2026-08-02 on the reference machine's global config: the codex family reported `False` for exactly this reason. The new check must read the delegated config, or it must report the family as unknown. It must never report `False` from an unresolved base URL, because `False` hides the option and unknown does not.
**3g** **Write a directed suite, and do not copy the reference suite (7d).** Start from the behavior inventory in `CUJ_STATUS.md` §2, and write one test per behavior the branch must hold. **No numeric line target governs this.** The gate is coverage against that inventory, and not a line count: every keep-core behavior gets a test, and nothing gets a test only to reach a number. The reference implementation's 12,216 lines of tests are not the model to follow. They grew one fix at a time, so they pin intermediate states that no longer exist, they duplicate coverage across files that were later merged, and they carry fixtures for machinery this plan does not build. The new suite is smaller because it never acquires those, and whatever size falls out of the inventory is the right size.
**3h** **Web slimming.** The reference implementation's `web/src` is 4,278 lines, of which 2,625 are tests, so hand-written web source is about 1,653 lines. Cutting the banner takes a meaningful fraction of that, not a rounding error. 3b takes the banner, its availability plumbing, and their tests out of the tree for good. No banner code and no banner carve-out survives anywhere in the web layer. The dead-code deletions already happened in review. The remaining +488 in `NewChatDialog.tsx` is mostly the harness row, the gating, and the persistence, so keep it. The web commit also carries the 2e model-indicator fix. Recommendation: make no web cuts beyond what 3b implies.
**3i** **Model resolution (7e).** Two things stay: honest `applied=false` records and the raw/applied chip, because they caught two real bugs; and the session-start cadence machinery, because it IS the simple path. Three rules govern the rest:
1. **Build no resolution machinery.** The seam gets no `MODEL_LISTS` table, no cost ordering (`_cost_position`, a nearest-cost walk), and no hardcoded id allowlist. The reference implementation had all three (~260 source lines), and main does not. Evidence for leaving them out: on the reference workspace the substitution path has zero live triggers, all five frozen arms resolve exactly, and of the 20 raw-model events ever recorded, 17 were prefix-spelling restores and 3 came from a single bug.
2. **Pi is not a routed harness, for now.** 3k holds the rule and its consequences.
3. **The fallback is one fixed model per family.** The claude family falls back to sonnet. Sonnet means the id that the `sonnet` alias pin resolves to (today `databricks-claude-sonnet-5`); the fallback follows the pin, never a hardcoded id. The gpt family and the glm family both fall back to luna (`databricks-gpt-5-6-luna`). Luna is itself a frozen arm, so the router's own menu already contains the fallback target. Luna serves on the codex side, so a glm fallback never leaves its harness. When the router picks an arm the workspace does not serve, the seam applies that family's fallback and stamps `raw_model`, so the record and the chip stay honest.
The chain therefore has four steps: strip the prefix → match the catalog exactly → apply the family fallback → decline honestly. An honest decline writes no pin, records `applied=false`, shows a decision card, and keeps the session default model. The session never breaks. Two boundaries hold for the fleet. First, the gateway spelling pin stays: `glm-5-2` serves under the `system.ai.glm-5-2` route, and that pin is a spelling, not a substitution. Second, the claude `/model` alias vocabulary is NOT part of this cut, because the Claude CLI accepts only its own aliases for a mid-session switch; 2a keeps that vocabulary.
**3j** Size. **No line count is a target (Bryan, 2026-08-02).** The goal is a PR that a reviewer can actually read in one sitting, and the whole PR stays as one PR (4c). Shorter is better, and every stream prefers the smaller construction when two work. PR #3506's 28,991 insertions (1a) are the reference point that the fleet must beat by a wide margin, and these items are why the number falls:
- No docs in the merged diff: about 2,900.
- No enforcement stack: about 2,700 with its tests.
- No telemetry: about 300 with its tests.
- No fork exemption: about 80.
- No resolution machinery and no bar list: about 600 with their tests.
- A directed test suite instead of an accreted one: about 4,500.
- No per-fix consolidation scar tissue: about 500.
From-scratch code should land below any estimate that assumes assembly, because the fleet never writes the intermediate states that the reference implementation accumulated. The coverage gate (3g) still wins over any size preference.
**3k** **Pi is not a routed harness, for now (7e).** Smart-routing eligibility requires a gateway-backed family. That requirement includes the mid-session toggle on ChatPage, which main calls `costRoutingEligible` and the reference implementation renamed to `isCostRoutingEligible`. `gateway_inference` reports only the claude and codex families, so pi leaves the routed set. This closes a real hole: a pi session could turn Smart Routing on and pass the gateway rule vacuously, because the rule never saw pi's family. Two consequences follow. The rewrite cuts the bar list (~137 lines plus plumbing), because pi was its only consumer. The branch adds no layered redirect, because main's `_redirect_incompatible_pick` stays as main wrote it (2g). The door stays open for later. This PR does no pi work.
**3l** Under the from-scratch method (7g), every entry in this section reads as "do not build this", and not as "delete this". The negative numbers are therefore budget that the fleet does not spend, and they are not deletions from a diff. The reasoning per entry does not change.
## 4. Shape of the rewritten branch
**4a** Method: write the branch from scratch (7g), and write it in parallel (7k). Start one branch from current origin/main, and keep ONE worktree. Build each workstream against the 2a2f specification and the 0c inputs. Never copy a file wholesale from `routing-mvp-v1`; read it, and then write the new version. One benefit is large: the reconciliation that dominated the last rebase disappears, because the fleet writes against main's current mechanisms from the start. Two rules make the parallelism safe:
- **Disjoint file ownership.** A workstream owns a file set, and no two concurrent streams own the same file. An agent stages only the files it owns, and it never runs `git add -A`. An agent that meets a failure in a file it does not own reports the failure to the lead, and it does not fix the file. The lead resolves every cross-stream break.
- **The wave-0 contract.** An agent codes against a declared signature, and it never waits for another agent's implementation.
**4b** The build runs as a lead-authored contract commit, two full parallel waves, and a smaller closure wave. Each stream commits its own code with its own tests. A barrier separates the waves, and the lead holds it (4e).
**Wave 0 — the contract (the lead, before wave 1).** One commit, and no wave-1 agent starts before it lands. It does five things:
1. **Declares every shared surface** as a type signature with no logic: the routing-client interface, the backend predicate and dispatch (2f), the decision record, the settings fields, the seam's public functions, the subagent verdict shape, the `gateway_inference` field, and the HTTP create and read-back payloads.
2. **Pre-creates every shared touch point** a wave agent would otherwise have to edit: the settings fields, the route registration, an **empty** alembic revision that wave-1 stream 4 fills, and the two orchestration call sites that wave 2 fills.
3. **Carries the inputs onto the branch.** None of 0c's documents and none of the verification harness exists on `origin/main`. Wave 0 copies the seven doc paths in 3a, plus `LOCAL_SETUP.md`, `dev-env.sh`, `run-server.sh`, `run-host.sh`, `run-frontend.sh`, and `scripts/probe_routing_api.sh`, from `routing-mvp-v1`. Without this step every stream's required reading and both live barriers are unreachable.
4. **Slims the registry.** `designs/CUJ_STATUS.md` still carries rows for the enforcement stack, the warning banner, R8, and telemetry, all of which 3b and 3e do not build. Wave 0 deletes those rows and resets every remaining row to unverified (6f), so waves 1 and 2 are gated on a list that is true.
5. **Writes the barrier-1 apply script** (4e), because that check must exist before wave 1 finishes and no stream owns it.
It also publishes the file partition (4f).
**Wave 1 — foundations (7 streams).** Every stream codes against wave 0, and against nothing else.
1. Routing core and the seam: the client, the arm menus, the resolution chain, and the family fallback.
2. The routing backend selection: the per-request flag evaluation, the AI Gateway path, and the LLM-judge path (2f).
3. Decision persistence: the decision record's writer and reader, and the `model_override` / `harness_override` / `cost_control_mode_override` keys inside the existing `session_overrides` blob. The `subagent_routing_override` key belongs to wave-2 stream 4, and its Inherit row belongs to wave-2 stream 5.
4. The gateway-inference signal: the host-side check, the host frames, the server surface, and the columns inside wave 0's empty migration (3f). The web half of the gate belongs to wave 2, stream 5.
5. The claude apply layer: the alias vocabulary, the alias pins, and `/model` injection. It owns both claude executors, the native one and the SDK one; the claude hook script belongs to wave-2 stream 3.
6. The codex model apply: the settings push, the config mirror, forwarder precedence, and the glm gateway route.
7. Codex `hooks.json` generation and the trust handshake. It owns different files from stream 6, which is why the codex work splits in two: together it is the largest area in the build.
**Wave 2 — integration (6 streams).** Each stream consumes wave 1.
1. The turn gate, in `routing_turn_gate.py`.
2. The create paths, in `routing_create.py`: the Smart Routing harness resolution, the fixed-harness model routing, and the pre-session catalogs. Streams 1 and 2 would collide in `orchestration.py` if they shared it, so each owns a module and orchestration keeps only the call sites that wave 0 declared. That split also produces the cleaner layout that this rewrite exists for.
3. The subagent transport, in `subagent_routing_transport.py` plus the hook scripts: the loopback endpoint, the env plumbing, and the hook subprocess.
4. The subagent policy, in `subagent_routing_policy.py` plus the server relay route: `resolve_subagent_route`, the family constraints, and the per-session override. The reference implementation put the transport and the policy in one 1,248-line file, which two concurrent agents cannot share; the split is deliberate. The policy module is runner-side, and the server route is a thin relay to it.
5. The web surfaces: the dialog, the harness row, the gating consumption, the decision card, and the in-session model-indicator fix (2e).
6. The CLI: the flags, the preflight, and both dispatch tiers.
Streams 5 and 6 sit behind the HTTP boundary, so they consume the wave-0 API contract rather than wave-1 code, and they run in this wave rather than after it. That is what keeps the two largest surfaces off the critical path.
**Wave 3 — closure (3 streams plus the lead).** This wave is smaller and mixed, and it is not a third wave of feature work.
1. `sys_session_create` for the Smart Routing harness agents (3c).
2. The coverage sweep against the registry inventory (3g).
3. The verification agent's barrier-3 runs (6e).
The lead holds barrier-2 fallout — the defects that appear only when the waves run together — and staffs it from whichever streams finished, because it cannot be scoped before barrier 2 exists. The lead then deletes the docs (3a), regenerates the PR body, and runs the full gate (6a).
**4c** **RESOLVED (7b): the code ships as one single PR, and no PR stacks on it.** Gating (3f) is a workstream inside the one PR. The docs ride the same branch and leave it before the merge (3a, 7j), so no docs PR exists.
**4d** One follow-up PR is planned: **telemetry.** No routing telemetry ships in this PR (3e, 7j). Bryan owns the follow-up, and it covers both halves: the OSS analytics events and the wiring into the Databricks-managed telemetry pipeline. That work happens alongside the managed plugin swap that 2f prepares. This block is a placeholder for it. It is *not* the enforcement follow-up: 3b cuts that stack outright and schedules nothing.
**4e** Parallel work concentrates the integration risk at the wave barriers, and the lead holds every barrier. A wave does not start until the previous barrier passes.
**Barrier 1**, after wave 1. Nothing is user-visible yet, so the checks are mechanical and narrow:
1. Every stream's unit tests pass in ONE run, and not only stream by stream.
2. The wave-0 contract file is unchanged. A stream that needs a different signature tells the lead, and the lead re-declares it for everybody.
3. The apply layers work without a router. Wave 0's script pins a hardcoded model onto a claude pane and onto a codex session, and **R2** and **R3** prove it. This de-risks the two hardest layers before any gate exists to reach them through.
**Barrier 2**, after wave 2. The first end-to-end proof: 6e runs 1 to 3.
**Barrier 3**, after wave 3. 6e run 4, and then 6a's five items.
One hazard belongs to the shared worktree, and it is common: one agent's in-progress edit breaks another agent's test collection. Two things contain it. The ownership rule in 4a says report, do not fix. The lead may also serialize two streams that prove to be coupled, and a serialized pair is cheaper than a corrupted barrier.
**4f** The file partition. Wave 0 publishes this table, and 4a's ownership rule refers to it. A stream creates and owns the files on its row; it reads anything else. Paths are for the new branch, and `ROUTING_OVERVIEW.md` §2 maps each subsystem to its reference-implementation files.
| Stream | Owns |
| --- | --- |
| W1·1 routing core | `omnigent/server/smart_routing.py` (extends main's) |
| W1·2 backend | `omnigent/server/routing_backend.py` |
| W1·3 persistence | the decision writer and reader, the session-overrides fields |
| W1·4 gateway signal | `omnigent/gateway_inference.py`, the host frames, the hosts route, wave 0's migration body |
| W1·5 claude apply | `claude_model_vocabulary.py`, `claude_native*.py`, both claude executors |
| W1·6 codex model apply | `codex_native_forwarder.py`, `codex_native_app_server.py`, `codex_native_bridge.py`, `inner/codex_native_executor.py` |
| W1·7 codex hooks and trust | `omnigent/inner/codex_executor.py` |
| W2·1 turn gate | `routing_turn_gate.py` |
| W2·2 create paths | `routing_create.py` |
| W2·3 subagent transport | `subagent_routing_transport.py`, `omnigent/inner/hook_scripts/` |
| W2·4 subagent policy | `subagent_routing_policy.py`, `routes_hooks.py` |
| W2·5 web | `web/src/**` |
| W2·6 CLI | `omnigent/smart_routing_cli.py`, `omnigent/cli_native.py` |
| W3·1 session-create tool | the harness tool exposure |
| W3·2 coverage sweep | test files only, in any area |
`omnigent/cli.py` is **lead-owned for the whole build**, because main already wires the routing client there and both W1·2 and W2·6 need it. A stream sends the lead its `cli.py` change, and the lead applies it. This is a deliberate serialization of the one known hotspot.
## 5. CLI fixes integration
**5a** Every CLI worktree has already merged into `routing-mvp` (`907f8886`, then `8f3c0c60`/`6f2893d9` + `8d7c9cb2` + `b10a7239`), so nothing is inbound and nothing gates the build's start. The *verification* is still owed: the CLI surface is unit-verified only, so a run must add the `CUJ_STATUS.md` §2.10 rows (recipe **R10**).
**5b** In the rewrite, the CLI is a wave-2 workstream, and the create-path work it depends on is a different wave-2 workstream (4b). The two CLI commits are **specifications, not patches to apply** (7g). `8f3c0c60` specifies the server behavior for the create-paths stream, and `8d7c9cb2` specifies the CLI stream. The split is clean, because `8f3c0c60` touches only `orchestration.py` plus its test, and `8d7c9cb2` touches **no** server file. `8d7c9cb2` also did **not** extend `_resolve_native_smart_routing`. The fixed-harness route is a parallel path (`_fixed_native_routing_harness` + `_resolve_fixed_native_model_routing`). One trap must survive the rewrite. Both create paths share `_routing_host_for_create`, and that helper authorizes the host BEFORE it looks the host up. The new code keeps that order, because the reverse order is the authorization bug that `CUJ_IMPLEMENTATION.md` §4.3d describes.
## 6. Execution and verification
**6a** Fleet plan: seven agents in wave 1, six in wave 2, and three in the closure wave, all on one branch and in ONE worktree, per 4b. Strict file ownership (4a, 4f) is what makes concurrent agents safe in one worktree. The verification agent is a standing role from barrier 1 onward; wave-3 stream 3 is that same agent's barrier-3 shift, and not a second one. The lead holds the barriers, verifies, and pushes. Before anything replaces #3506, the fleet must complete all of this:
1. The full suites.
2. `pre-commit --all-files`.
3. The 15-row matrix, plus the session-start, manual-pin, and R9 checks, run live.
4. The registry re-stamped.
5. The PR body regenerated from the final diff.
6c defines the evidence bars for these checks. 6d names the recipes. 6e scopes each verification run.
**6b** The old branch survives as `routing-mvp-v1`, like `routing-mvp-backup` before it. Under the from-scratch method it is more than a backup. It is the reference implementation and the behavioral oracle (0c), so the fleet keeps it checked out in a sibling worktree for the whole build. The PR either force-pushes or opens fresh, and Bryan makes that call at handoff time.
**6c** The evidence bars. A behavior counts as verified only when it clears the bar for its layer:
- A routing decision is exact when the raw pick and the applied model name the same arm, and the record shows `applied=true`. A spelling difference is not a substitution.
- Process truth beats UI truth. For claude, the proof is the pane: the status bar shows the routed model, and the transcript holds exactly one `/model` injection per switch. For codex, the proof is the bridge dir: `config.toml` and the newest rollout `turn_context` name the routed model.
- The server log must show zero anomalies for the run: no `harness=None`, no missing-spelling warnings, no malformed router ids.
- The UI acceptance is 2e's three surfaces. No agent can close it: Bryan checks them on a live stack, and the fleet's job is to have the stack running and to say what to look at. Treat that as a scheduled handoff at barrier 3, not as a blocker discovered there.
- The fallback and decline steps (3i) have no live trigger on the reference workspace, so unit tests with a synthetic catalog verify them. The live matrix verifies the exact-match path.
**6d** The recipes. `CUJ_STATUS.md` §1 holds the exact commands as reusable handles, and the fleet reuses them instead of inventing new ones: **R0** stack bring-up (the three `run-*.sh` scripts), **R1** the decisions query against the chat DB, **R2** claude pane capture over the runner's tmux socket, **R3** codex `config.toml` + rollout ground truth, **R4** the server-log signature greps, **R5** the UI surface checklist, **R6** the router contract probe (`scripts/probe_routing_api.sh`), **R7** the headless session driver, **R9** the gateway-gating flip, **R10** the CLI routed launch. **R8** (the canary provoke) dies with 3b, so the slim branch's registry drops it. Wave 3 updates the registry to the slimmed scope before the deletion commit removes it (3a), and the registry stays the source of truth for how to verify every row while the PR is open.
**6e** The gates. Every workstream passes its own unit tests before it commits (4a), and the lead re-runs the shared suite after each commit. A red shared suite blocks the next commit. The verification runs sit at the barriers (4e). `CUJ_STATUS.md` §2 is the authority for row names, and `INTELLIGENT_ROUTING_PLAN.md` §11 holds the prompts. One prompt is missing and only Bryan can supply it: §11.1 does not embed **P-SOL**, and rows A3, B2, C2 and two R10 invocations need it. Get it before barrier 2, because §11.1 warns that prompt length changes the router's answer.
1. Barrier 2 — the decision-and-apply matrix rows (A1A4, B1B3, C1C3) via R1 + R2 + R3, with R4 clean.
2. Barrier 2 — the spawn and toggle rows (B-sub, B-tog, C-sub, C-tog, A-sub), with the family constraints proven in both directions.
3. Barrier 2 — the CLI rows (R10), because the CLI lands in wave 2.
4. Barrier 3 — the three 2e surfaces by hand on a live stack, the model-indicator fix, R9's gating flip in both directions, and the flag-off backend: a session in a flag-off workspace still routes, and it routes through the judge (2f).
The final gate before anything replaces #3506 is 6a's five items, run once on the finished branch.
**6f** Evidence does not transfer. Every ✅ in `CUJ_STATUS.md` attests to the OLD tree, and the new branch inherits none of it. The fleet therefore resets every row to unverified, and it earns each row again. Two consequences follow. The verification tail grows rather than shrinks, and 6e's four runs become the only proof that the new branch works. The registry's own update contract still applies: a status changes only with named evidence, a date, and a commit.
## 7. Bryan's critique — the decisions
**7a** **RESOLVED: cut the enforcement stack entirely; do not defer it with a banner.** Bryan: make it work all the time instead; the warning does not make sense; just fix it. No canary, no watcher, no spawn audit, no warning banner, no `session_warnings`. Hooks generation and the trust handshake stay, because deterministic subagent routing needs them. See 3b, 3h, and overview `2i`.
**7b** **RESOLVED: one single PR, and nothing stacks on it.** 7j later removed the docs split, so the docs ride the branch and leave before the merge instead. See 4c and 3a.
**7c** **RESOLVED: the CLI stays in this PR**, as a wave-2 workstream (4b, 5b). The create-path work it depends on is a separate wave-2 workstream, and the HTTP contract from wave 0 lets the two run at the same time.
**7d** **RESOLVED: no numeric test target.** The fleet writes directed, useful tests that pin the final behavior. Coverage against the registry inventory gates them. The ≤5,500 / ≤1,500 numbers are withdrawn. See 3g.
**7e** **RESOLVED in full (2026-08-01; was "partially resolved").** The settled keeps stand: honest `applied=false` and the raw/applied chip stay. Bryan then ruled on the rest:
1. Cut the `MODEL_LISTS` fork and the cost-substitution table, and revert the resolution machinery to main's shape.
2. Drop pi from the routed set, for now.
3. Use the per-family fallback (claude → sonnet, gpt and glm → luna), with an honest decline behind it.
See 3i and 3k. Bryan closed the last assumptions on 2026-08-01: the gpt and glm families fall back to luna (`databricks-gpt-5-6-luna`, itself a frozen arm); the claude fallback follows the `sonnet` alias pin (today `databricks-claude-sonnet-5`); terra is out. No open assumptions remain.
**7f** Two items entered the plan from the same critique, rather than leaving it. Managed-plugin readiness is a build requirement, and 7h moved it out of 2a into 2f. The in-session model indicator is a must-fix bug (2e).
**7g** **RESOLVED (2026-08-02): a complete rewrite from scratch.** Bryan: keep the code as clean as possible. The fleet writes new code against the 2a2e specification, and it does not move code from `routing-mvp`. This decision reverses the earlier rule, which said "assemble the branch, do not re-implement it". Five blocks carry the consequences: 0b holds the constraint, 0c holds the required inputs, 0d holds the transcribe rule, 4a and 4f hold the method, and 6f holds the evidence reset. The rest of the plan survives the reversal in substance. The cut list still says what not to ship, 4b is now a build order rather than a slicing order, and the verification plan (6a6f) is unchanged except that it now carries the whole safety burden.
**7h** **RESOLVED (2026-08-02): the managed preview flag is evaluated per request, and not at construction.** Bryan: a workspace without the flag still routes through the naive LLM judge, and a workspace with it routes through the AI Gateway. The work belongs in this PR, and 2f holds the design. One objection to per-request evaluation exists, and it does not apply here: a flag-off workspace would advertise a routing feature that returns no verdict. That cannot happen when the flag-off path routes through the judge.
**7i** **RESOLVED (2026-08-02): keep cross-harness spawning, and give the harness agents `sys_session_create`.** This reverses 3c's cut. An agent that needs another family creates a session, rather than reads a deny message and gives up. Bryan owns the iteration on how well the agents use the tool. The fleet therefore reports the observed behavior after the implementation lands, and it does not tune the prompt.
**7j** **RESOLVED (2026-08-02): telemetry leaves this PR, and the docs ride it without merging.** All routing telemetry moves to a follow-up PR that Bryan owns (3e, 4d). The design documents stay on the branch while the PR is open, because Bryan reads them there, and a final commit deletes them before the merge (3a). The merged diff therefore carries no docs and no telemetry.
**7k** **RESOLVED (2026-08-02): build it in parallel waves.** Bryan set the shape — as parallel as possible, several agents at a time, all on one branch, each in its own workstream — and left the wave count and the verification design to the lead. The plan lands on a wave-0 contract commit, two full waves (7 streams, then 6), and a smaller closure wave (4 streams plus the lead). 4a holds the two safety rules, 4b holds the streams, 4e holds the barriers, and 6a and 6e hold the fleet and gate shapes.
+105
View File
@@ -0,0 +1,105 @@
# Smart Routing: system map
> **How to reference this document.** Every block carries an ID: the section number plus a letter (`2c` = the third block of §2). Speak the ID and it names the block.
**0a** This document is the companion to `designs/PR_REWRITE_PLAN.md`, which is the plan of record; read that first. This one maps the Smart Routing feature at one altitude: the subsystem. It states what each subsystem does, why it must exist, how large it is, and what the rewrite plan does with it. It holds no history and no bug narratives. `designs/CUJ_IMPLEMENTATION.md` holds those. `designs/PR_REWRITE_PLAN.md` holds the cut list that §2 cites by ID.
**0b** Sizes are insertions against `origin/main` at HEAD, from `git diff --numstat`. A size covers the whole file, so a file that serves two subsystems reports the same number in both blocks.
---
## 1. The four user journeys
**1a — Claude Code.** The user opens _Configure Claude Code_ and picks **Smart Routing** in the Model row. The server creates the session with routing on and with no model pin. The router scores the first message over the Claude arms. The claude-native executor types `/model` into the pane, and then it injects the message.
**1b — Codex.** The user makes the same pick in _Configure Codex_, over the Codex arms. The codex-native executor sends the routed model to the running thread, and it mirrors the model into the session's `config.toml`. It types nothing into the pane.
**1c — Smart Routing harness.** The user picks the top-level **Smart Routing** row in the harness dropdown. That row is a router over the harnesses, and it is not a harness. The server picks both the harness and the model at session create, from the first message. Both picks stay for the session's life. The routed harness set is claude and codex today, and pi is out of it for now (`2j`, `4g`).
**1d — CLI.** The user runs `omnigent claude --smart-routing -p "…"`, or the same flag on `omnigent codex` or `omnigent run`. The CLI checks availability, creates the routed session itself, and then attaches the wrapper to it. A pinned `--harness` routes the model only. No `--harness` routes the harness and the model.
**1e — Subagent spawns.** Every journey above also routes the spawns that the harness makes in-harness. A hook subprocess calls a runner-local endpoint, and the endpoint returns a verdict that rewrites the spawn's model.
---
## 2. The subsystems
**2a — The routing client and the task_v1 contract.** The client calls the AI Gateway `routes:select` API, and it resolves the router's pick to a servable catalog id. `cli.py` builds exactly one client at startup, so no runtime fallback chain exists. Without the client no journey can produce a pick. It lives in `omnigent/server/smart_routing.py` (+1,120 lines, shared with 2b). Fate: keep-core, wave-1 workstream 1 (plan `2a`, `4b`). It is one of two backends behind the seam, and 2p holds the other one and the choice between them.
**2b — The resolution seam and the family fallback.** One module boundary holds all router vocabulary: it builds the offered menu, resolves the pick to a `(harness, model)` pair, and applies the family fallback when the workspace does not serve the picked arm. Without the seam every caller would learn the router's vocabulary. It shares `smart_routing.py` with 2a. Fate: keep-core, wave-1 workstream 1, minus the resolution machinery — **RESOLVED (2026-08-01, plan `3i`)**. We cut the `MODEL_LISTS` fork, the cost-substitution table (~260 source lines), the 10-id allowlist, and the bar list (~137 lines; pi was its only consumer — plan `3k`). The seam reverts to main's simple shape, and our layered-redirect diff against main goes to ~zero. The chain has four steps: strip the prefix → match the catalog exactly → apply the family fallback → decline honestly. The fallback is one fixed model per family: claude → sonnet (the id the `sonnet` alias pin resolves to, today `databricks-claude-sonnet-5`), and gpt and glm → luna (`databricks-gpt-5-6-luna`, itself a frozen arm). A fallback stamps `raw_model`, so the record and the chip stay honest. Research basis: the substitution path has zero live triggers on the reference workspace.
**2c — The turn and create gates in orchestration.** The server decides per turn whether to route, and it decides per create whether a native session routes its harness, its model, or neither. Without the gates a routed session would re-route on every turn and lose its pin. It lives in `omnigent/server/routes/_sessions/orchestration.py` (+886) and `helpers.py` (+79). Fate: keep-core, wave-2 workstreams 1 and 2 — the turn gates and the create paths split into two streams, because they parallelize cleanly (plan `2a`, `2b`, `4b`, `5b`).
**2d — Decision persistence.** The server writes each decision as a conversation item, and it joins the item to the session through two conversation labels. Without persistence no chip renders and no reader can audit a pick. The records add no table and one column. The web reader is `web/src/lib/routingDecision.ts` (+102) plus the server writer inside 2c. Fate: keep-core, wave-1 workstream 3.
**2e — The claude apply layer.** This layer pins the router's arms onto the launch aliases, translates a model id into the pane's own vocabulary, and injects `/model` before the message. Without it a routed pick never reaches the process. It spans `claude_model_vocabulary.py` (+200), `claude_native.py` (+157), `claude_native_bridge.py` (+111), `runner/native/orchestration.py` (+161), and two executors (+123). Fate: keep-core, wave-1 workstream 5 (plan `2a`).
**2f — The codex apply layer.** This layer sends the routed model to the running thread, mirrors it into `config.toml`, and gives the config mirror precedence only when the config changed. Without it the launch default overwrites the routed model after one turn. It spans `codex_native_forwarder.py` (+263), `codex_native_app_server.py` (+271), `codex_native_bridge.py` (+64), and `inner/codex_native_executor.py` (+15). Fate: keep-core, wave-1 workstream 6 (plan `2a`). This stream also owns the glm gateway route — glm serves the Responses API only under its `system.ai.` spelling. The Smart Routing harness runs codex underneath, so it inherits the fix (plan `2b`).
**2g — Codex hooks generation and trust.** The code generates the Omnigent half of `hooks.json`, merges it with the user's half in one atomic write, and then runs a trust handshake over the app-server. Without the handshake codex silently skips the routing hooks. It lives in `omnigent/inner/codex_executor.py` (+484). Fate: keep-core, wave-1 workstream 7 — file-disjoint from 2f, so the codex work runs as two streams. The plan keeps this half of the codex machinery even though it cuts 2i, because deterministic subagent routing depends on it (plan `2c`, `3b`).
**2h — The subagent loopback, hook scripts, and policy.** A runner-local HTTP endpoint answers the harness's hook subprocess, and the policy returns `allow`, `rewrite`, `redirect`, or `deny`. Without the loopback an in-harness spawn never reaches any router. It spans `runner/subagent_routing.py` (+1,248), `inner/hook_scripts/` (+941), and `routes_hooks.py` (+150). Fate: keep-core, split into wave-2 workstreams 3 and 4. The reference implementation put both in one 1,248-line file (policy `resolve_subagent_route` at `:484`, transport constants at `:68`/`:72`), which two concurrent agents cannot share, so the rewrite gives each its own module (plan `4b`, `4f`). One cut remains: the fork-spawn exemption (plan `3d`, ~80). Cross-harness spawning stays (plan `3c`, `4j`): a Smart Routing harness agent gets `sys_session_create`, so it creates a session for another family instead of reading a deny message. Wave-3 workstream 1 adds the tool.
**2i — The enforcement and observability stack.** A canary hook writes a file, a watcher reports the file as absent, a spawn audit reconciles the models that actually ran, and a banner shows the warning. Without it codex can skip the hooks and nothing reports the failure. It spans `runtime/session_warnings.py` (+165), `SessionWarningBanner.tsx` (+95), and the watcher and audit code inside 2f and 2g. Fate: **cut entirely** (plan `3b`, `7a`, ~1,200 source and ~1,500 tests). Bryan's call: make the hooks run every time instead of reporting when they do not, because a warning banner is not a product answer. The rewrite therefore ships no canary, no watcher, no spawn audit, no banner, and no `session_warnings`. The plan schedules no follow-up. A future PR may reintroduce observability only if hook execution proves unreliable in the field. This is still the largest source-side cut.
**2j — Gateway-inference gating.** The host reports, per harness family, whether its inference resolves to the workspace AI Gateway, and every surface hides Smart Routing on an explicit `false`. Without the gate a host offers a pick that its pane can never run. It spans `gateway_inference.py` (+93), `databricks_ai_gateway.py` (+68), the host frames and store (+192), the hosts routes (+98), one migration (+49), and `smartRoutingAvailability.ts` (+114). Fate: keep in-PR as wave-1 workstream 4 (plan `3f`, ~900 source and tests). The rule has two clauses (Bryan, 2026-08-02). The Model row offers Smart Routing for a harness only when the host reports gateway inference for *that harness's* family. The Smart Routing harness row appears only when the host reports gateway inference for *both* families, because the harness routes across both. A host that reports nothing counts as unknown, and unknown never hides the option. The gate also fixes the routed harness set: eligibility, including the mid-session toggle, requires a gateway-backed family, and pi has none, so pi is not a routed harness for now (plan `3k`, resolved 2026-08-01).
**2k — The web surfaces.** The new-chat dialog holds the Model option, the harness row, and the availability notices. The chat page holds the subagent-routing row, and the status blocks render the decision card. Without them the user has no way to pick routing or to read a decision. `web/src` adds +4,278 lines, of which the tests are about 2,625. The largest files are `NewChatDialog.tsx` (+488), `renderItems.ts` (+186), and `ChatPage.tsx` (+180). Fate: keep as wave-2 workstream 5, minus the banner that 2i takes with it, and with no banner carve-out left behind (plan `3h`, ~1,000). It sits behind the HTTP boundary, so it codes against the wave-0 contract and runs beside the server work rather than after it. This layer also owns a **known bug the rewrite must fix**: the in-session model indicator at the bottom right of the session UI must show the routed model. Bryan saw the terminal run the routed model while that display still showed the old one. The `SessionModelEvent` and chatStore picker-state channel disagrees with the pane, and the fix makes the display read the value the pane applied (plan `2e`). The three UI surfaces that must pass acceptance are: Smart Routing as a model option on the Claude Code and Codex configure dialogs, Smart Routing as a harness, and a correct in-session model display.
**2l — Telemetry.** Two analytics events record a decision and a setting change, and both reduce a model id to a family label and a tier label. Without them no deployment can measure the routing rate. It lives in `omnigent/telemetry/` (+296). Fate: **cut from this PR** (plan `3e`, `4k`, resolved 2026-08-02). Bryan takes all telemetry in a follow-up PR that he owns, and it covers both the OSS events and the Databricks-managed pipeline wiring (plan `4d`). This PR emits no routing events.
**2m — The CLI layer.** Three commands take `--smart-routing`, and the CLI runs preflight, drives the create, and passes the routed model to the wrapper as a launch flag. Without it a CLI user must start every routed session in a browser. It spans `cli.py` (+505), `smart_routing_cli.py` (+394), and `cli_native.py` (+77). Fate: keep as wave-2 workstream 6, inside the single PR (plan `2d`, `5b`, `4b`). It sits behind the HTTP boundary, so it runs beside the create-path stream it depends on.
**2n — The tests.** The reference implementation carries +12,278 lines of Python and web tests. They accreted one fix at a time, so they pin intermediate states and duplicate coverage. Fate: write a directed suite against the behavior inventory instead of copying them (plan `3g`). No line target governs the rewrite. The gate is coverage against the registry inventory, and the suite is as large as directed, useful tests of the final behavior require.
**2o — The docs.** Six design documents add +3,131 lines under `designs/`, and `REVIEW_FIXES.md` adds +367 at the repo root. They carry the plan, the walkthrough, the evidence registry, and the codex model-state protocol. Fate: **ride the branch, then leave it** (plan `3a`, `4k`, resolved 2026-08-02). They stay tracked while the PR is open, because Bryan reads them there. A final commit deletes them before the merge, so the merged diff carries no docs and no docs PR follows.
**2p — The routing backend selection.** A preview-flag evaluation runs on **every request** and chooses which router answers: the AI Gateway `routes:select` API (2a) when the flag is on, or the naive LLM judge when it is off. Without it a managed deployment cannot ship Smart Routing behind a flag, and a flag-off workspace would lose the feature instead of degrading it. Fate: build it in this PR as wave-1 workstream 2 (plan `2f`, `7h`, resolved 2026-08-02). Three consequences: no surface needs a flag-aware gate, because routing exists either way; the two backends offer different menus, since task_v1 needs its frozen arms as a wire contract while the judge scores over the servable catalog; and the flag is independent of 2j, because 2j asks whether the *pane* runs on the gateway while the flag asks which router picks the model.
---
## 3. The invariants
**3a — Routing runs once per session.** The router runs on the session's first message, and the pick stays for the session's life. The routed turn writes `model_override`, and that pin closes the gate for turn 2. Any cut must keep this cadence.
**3b —** `applied` **must be honest.** The server writes `applied=false` when the pane cannot apply the pick, and it then writes no pin. A record that claims a model the process never ran is worse than a visible failure.
**3c — A spawn stays in its parent's family.** A child of a Claude session takes a Claude model, and a child of a Codex session takes a Codex model. Only a genuine Smart Routing session may pick across the two families.
**3d — Every gate fails open.** A router outage, a hook timeout, a transport error, or a failed translation leaves the turn unrouted and attaches the reason. Routing is advisory over a system that must work without it.
**3e — The arm menus are a wire contract on the task_v1 path.** The router version is frozen upstream, so the arm list lives in code and the workspace catalog cannot change it. The judge backend (2p) has no such contract, and it scores over the servable catalog instead. A menu that the code derives from the catalog returns 400 or scores against an uncalibrated recipe. The machinery that resolves an arm to a servable model is now settled (plan `3i`): exact match, then the family fallback, then an honest decline. The wire contract itself does not change.
**3f — A manual pin blocks routing.** Any `model_override` closes the turn gate, whoever wrote it. The two controls are therefore mutually exclusive in the UI as well.
**3g — The display agrees with the pane.** The in-session model indicator shows the model the process is running. If the pane applied a routed model, the indicator shows that model. A display that disagrees with the pane teaches the user to distrust the feature, so the picker-state channel follows the applied value and never the stale pick (plan `2e`).
**3h — An unservable pick falls back inside the family, or it declines.** When the workspace does not serve the router's pick, the seam applies the family's designated fallback (claude → sonnet, gpt and glm → luna) and stamps `raw_model`. When the workspace does not serve the fallback either, the seam declines honestly and the session keeps its default model. The session never breaks (plan `3i`).
---
## 4. The decisions
**4a — RESOLVED: the enforcement stack (2i) is cut, not deferred.** Bryan's call: make the hooks run every time instead of warning that they may not have. No canary, no watcher, no spawn audit, no banner. Hooks generation and the trust handshake (2g) stay (plan `7a`, `3b`).
**4b — RESOLVED: one single PR, and nothing stacks on it.** Gating (2j) is a workstream inside that PR, and the docs (2o) ride the branch and leave before the merge (plan `7b`, `4k`).
**4c — RESOLVED: the CLI layer (2m) stays in this PR**, as a wave-2 workstream that runs beside the create-path stream it depends on (plan `7c`, `5b`).
**4d — RESOLVED: no numeric test target.** The fleet writes directed tests of the final behavior and gates them on coverage against the registry inventory. The 5,500 and 1,500 line numbers are withdrawn (plan `7d`, `3g`).
**4e — RESOLVED (2026-08-01).** The honest `applied=false` record and the raw-versus-applied arrow on the chip stay. Bryan ruled on the rest: cut the `MODEL_LISTS` fork and the cost table; revert the seam to main's shape; use the per-family fallback (claude → sonnet, gpt and glm → luna) with an honest decline behind it (plan `7e`, `3i`). Bryan closed the last assumptions on 2026-08-01: gpt and glm fall back to luna (`databricks-gpt-5-6-luna`, itself a frozen arm); the claude fallback follows the `sonnet` alias pin. No open assumptions remain.
**4f — Two items the same critique added.** Managed-plugin readiness is a wave-1 requirement in its own subsystem (2p, 4i), and the in-session model indicator is a must-fix bug (2k, 3g).
**4g — RESOLVED: pi is not a routed harness for now.** Smart-routing eligibility requires a gateway-backed family, and the requirement includes the mid-session toggle. The bar list goes with it, and the layered-redirect diff against main goes to ~zero (plan `3k`).
**4h — RESOLVED (2026-08-02): the fleet writes the new branch from scratch.** Bryan: keep the code as clean as possible. This reverses the earlier "assemble, do not re-implement" rule. Nothing in §2's fates changes, and the §3 invariants still hold. Three plan blocks carry the risk that the method adds: `0c` names the inputs an agent reads before it writes a slice (the behavior inventory, the trap list, and the reference implementation on `routing-mvp-v1`), `0d` says to rewrite the shape but transcribe the empirical constants, and `6f` records that no evidence transfers, so the fleet earns every registry row again.
**4i — RESOLVED (2026-08-02): the managed preview flag is evaluated per request.** A flag-off workspace still gets Smart Routing through the naive LLM judge, and a flag-on workspace gets AI Gateway routing. The work ships in this PR, in its own block (2p, plan `2f`, `7h`). This reverses the managed-swap report's construction-time recommendation.
**4j — RESOLVED (2026-08-02): cross-harness spawning stays, with a real affordance.** Every Smart Routing harness agent gets `sys_session_create`, so it creates a session for another family rather than reading a deny message. Bryan owns the iteration on how well the agents use it, and the fleet only reports the behavior it observes (2h, plan `3c`, `7i`).
**4k — RESOLVED (2026-08-02): telemetry leaves, and the docs ride without merging.** All routing telemetry moves to a follow-up PR that Bryan owns (2l). The design documents stay on the branch while the PR is open and a final commit deletes them before the merge (2o, plan `7j`).
**4l — RESOLVED (2026-08-02): build it in parallel waves.** Bryan set the shape and left the wave count to the lead: a wave-0 contract commit, then 7 streams, then 6, then a closure wave of 3. Every stream owns a disjoint file set, and the contract commit declares every shared signature first, so no stream waits on another stream's implementation. The lead holds a barrier between the waves (plan `4a`, `4b`, `4e`, `7k`).
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Source this (or let run-*.sh do it) to fully isolate this worktree's
# omnigent from the global ~/.omnigent install.
WORKTREE="$(cd "$(dirname "$0")" && pwd)"
export OMNIGENT_CONFIG_HOME="$WORKTREE/.omnigent-local"
export OMNIGENT_DATA_DIR="$WORKTREE/.omnigent-local/data"
export UV_DEFAULT_INDEX="https://pypi-proxy.cloud.databricks.com/simple"
export COREPACK_NPM_REGISTRY="https://npm-proxy.cloud.databricks.com/"
export OMNIGENT_LOG_TO_STDERR=1
export ROUTING_SERVER_PORT=6868
export ROUTING_FRONTEND_PORT=5273
+18
View File
@@ -49,5 +49,23 @@ omnigent/onboarding/wizard.py databricks-gpt-5-4 1
omnigent/onboarding/wizard.py gpt-4o 1
omnigent/policies/builtins/routing.py databricks-claude-opus-4-6 1
omnigent/policies/builtins/routing.py o3 1
omnigent/server/smart_routing.py databricks-claude-haiku-4-5 3
omnigent/server/smart_routing.py databricks-claude-opus-4-8 2
omnigent/server/smart_routing.py databricks-claude-sonnet-4-6 2
omnigent/server/smart_routing.py databricks-claude-sonnet-5 2
omnigent/server/smart_routing.py databricks-gpt-5-4 2
omnigent/server/smart_routing.py databricks-gpt-5-4-mini 2
omnigent/server/smart_routing.py databricks-gpt-5-4-nano 2
omnigent/server/smart_routing.py databricks-gpt-5-5 3
omnigent/server/smart_routing.py databricks-gpt-5-5-pro 1
omnigent/server/smart_routing.py databricks-gpt-5-6-luna 2
omnigent/server/smart_routing.py databricks-gpt-5-6-sol 2
omnigent/server/smart_routing.py databricks-gpt-5-6-terra 1
omnigent/tools/builtins/spawn.py databricks-claude-opus-4-8 1
omnigent/tools/builtins/spawn.py system.ai.glm-5-2 1
scripts/probe_routing_api.sh claude-haiku-4-5 1
scripts/probe_routing_api.sh claude-opus-4-8 3
scripts/probe_routing_api.sh claude-sonnet-5 2
scripts/probe_routing_api.sh gpt-5-5 1
scripts/probe_routing_api.sh gpt-5-6-luna 2
scripts/probe_routing_api.sh gpt-5-6-sol 2
+200
View File
@@ -0,0 +1,200 @@
"""Claude Code's model vocabulary, and how to speak it.
Omnigent routes to servable catalog ids (``databricks-claude-sonnet-5``),
but two Claude Code surfaces accept only the family *aliases*:
* the ``Agent`` / ``Task`` tool's ``model`` parameter — a closed enum
(``sonnet``, ``opus``, ``haiku``, ``fable``), so a catalog id fails
schema validation and the spawn dies before it starts;
* the ``/model`` slash command — an alias (or the custom slot's exact id)
resolves offline with no validation; ANY other value, catalog id or
canonical vendor id alike, is accepted only if a live one-token request
to the configured endpoint succeeds, so it depends on the gateway
answering mid-turn and fails as a network error otherwise.
Claude Code resolves each alias to a concrete id via the workspace's
``ANTHROPIC_DEFAULT_*_MODEL`` env (set by omnigent's launch config), so
inverting that mapping is exact — and only exact: a family segment alone
is not enough, because a workspace serving two generations of a family
pins the alias to the newer one, and speaking the alias would run a model
nobody routed to. Both surfaces fail OPEN on an id with no accepted
spelling: skip the switch rather than send something the CLI drops.
``--model`` at launch is a different contract: it takes any string
verbatim, so a session STARTS on an exact id without needing a pin.
Stdlib-only so hook subprocesses can import it on the spawn path.
"""
from __future__ import annotations
import os
import re
from collections.abc import Iterable, Mapping
from typing import Any
#: Family aliases both surfaces accept, longest-lived family first.
CLAUDE_MODEL_ALIASES: tuple[str, ...] = ("fable", "opus", "sonnet", "haiku")
#: Alias → env var Claude Code reads to pin that alias to one model id.
ALIAS_MODEL_ENV_VARS: dict[str, str] = {
"fable": "ANTHROPIC_DEFAULT_FABLE_MODEL",
"opus": "ANTHROPIC_DEFAULT_OPUS_MODEL",
"sonnet": "ANTHROPIC_DEFAULT_SONNET_MODEL",
"haiku": "ANTHROPIC_DEFAULT_HAIKU_MODEL",
}
#: Extra picker slot pinned to one exact id. ``/model`` accepts that id
#: offline, compared BYTE-EXACTLY (case included) against this value — so
#: translation returns the env's own spelling, never the caller's. The
#: Agent tool's enum has no such slot, so only ``/model`` uses it.
CUSTOM_MODEL_OPTION_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION"
#: Launch-env keys that define this session's model vocabulary.
MODEL_VOCABULARY_ENV_VARS: tuple[str, ...] = (
*ALIAS_MODEL_ENV_VARS.values(),
CUSTOM_MODEL_OPTION_ENV_VAR,
)
#: Catalog prefixes stripped before comparing ids. Must equal
#: :data:`omnigent.server.smart_routing.MODEL_ID_PREFIXES` (asserted by
#: ``test_catalog_prefixes_match_the_routing_defaults``); duplicated because
#: this module stays stdlib-only for hook subprocesses, which also means it
#: cannot honour a deployment's ``routing.model_prefix`` override.
_CATALOG_PREFIXES: tuple[str, ...] = ("databricks-", "system.ai.")
_SEGMENT_RE = re.compile(r"[^a-z0-9]+")
def normalized_model_id(model: str) -> str:
"""Lower-case a model id, dropping catalog prefix and ``[1m]`` suffix.
:param model: Any model id or alias.
:returns: The comparable bare id, e.g. ``"claude-sonnet-5"``.
"""
bare = model.strip().lower().removesuffix("[1m]")
for prefix in _CATALOG_PREFIXES:
if bare.startswith(prefix):
return bare[len(prefix) :]
return bare
def alias_pins(env: Mapping[str, str] | None = None) -> dict[str, str]:
"""Read the session's alias → model-id pinning.
:param env: Environment mapping. ``None`` reads :data:`os.environ`.
:returns: Alias → pinned model id, for the aliases that are pinned.
"""
environ = os.environ if env is None else env
pins: dict[str, str] = {}
for alias, env_var in ALIAS_MODEL_ENV_VARS.items():
pinned = environ.get(env_var, "").strip()
if pinned:
pins[alias] = pinned
return pins
def model_vocabulary_env(options: Iterable[Mapping[str, Any]]) -> dict[str, str]:
"""Rebuild a session's model vocabulary from its picker rows.
The native model picker's rows ARE the launch env's pinning read back
out: a row keyed by a family alias is that alias's pin, and any other
row occupies the single custom slot. This lets a process that never
saw the terminal's env (the server) ask
:func:`claude_model_command_arg` the same question the executor will.
Rows that only restate their own key (a direct Claude login's curated
``opus`` / ``sonnet`` rows) pin nothing — Claude resolves those
itself — so they are skipped rather than read as a pin onto an alias.
:param options: Picker rows, e.g.
``[{"id": "opus", "model": "databricks-claude-opus-5"}]``.
:returns: A vocabulary env mapping, e.g.
``{"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5"}``.
Empty when the rows pin no concrete model ids.
"""
env: dict[str, str] = {}
for option in options:
if not isinstance(option, Mapping):
continue
row_id = option.get("id")
model = option.get("model")
if not isinstance(model, str) or not model.strip():
continue
if model.strip().lower() in CLAUDE_MODEL_ALIASES or model == row_id:
continue
key = ALIAS_MODEL_ENV_VARS.get(row_id if isinstance(row_id, str) else "")
if key is None:
key = CUSTOM_MODEL_OPTION_ENV_VAR
env.setdefault(key, model.strip())
return env
def claude_model_alias(
model: str,
env: Mapping[str, str] | None = None,
) -> str | None:
"""Translate a servable model id into Claude's alias vocabulary.
An exact hit on the pinning is authoritative. The id's own family
segment names the alias only when NOTHING is pinned at all (a direct
Anthropic login, where the alias resolves to the vendor's own model
of that family). Once this session pins aliases, a family segment is
not enough: an unpinned alias resolves to a canonical vendor id the
gateway rejects, and a MISMATCHED pin is worse — the alias resolves
to the pinned id, so the pane runs a model nobody routed to while
the record claims the routed one (workspace serving both
``claude-opus-4-8`` and ``claude-opus-5``, ``opus`` pinned to the
latter, ``claude-opus-4-8`` routed).
:param model: Model id from a routing decision, or an alias already.
:param env: Environment mapping holding the alias pinning. ``None``
reads :data:`os.environ` — a hook subprocess inherits the CLI's.
:returns: An accepted alias, or ``None`` when the id maps to nothing
Claude would accept; callers must then leave the model alone.
"""
if not isinstance(model, str) or not model.strip():
return None
candidate = model.strip().lower()
if candidate in CLAUDE_MODEL_ALIASES:
return candidate
pins = alias_pins(env)
normalized = normalized_model_id(model)
for alias, pinned in pins.items():
if normalized_model_id(pinned) == normalized:
return alias
if pins:
# Every pinned alias was compared exactly above, so reaching here
# means the routed id is not what any alias resolves to.
return None
segments = set(_SEGMENT_RE.split(normalized))
for alias in CLAUDE_MODEL_ALIASES:
if alias in segments:
return alias
return None
def claude_model_command_arg(
model: str,
env: Mapping[str, str] | None = None,
) -> str | None:
"""Translate a model id into a ``/model`` argument.
Same alias vocabulary as :func:`claude_model_alias`, except the extra
picker slot: ``/model`` takes that exact id, so a routed model pinned
there is applied precisely instead of stepping down to its family
alias.
:param model: Model id from a routing decision, or an alias already.
:param env: Environment mapping holding the session's pinning.
``None`` reads :data:`os.environ`.
:returns: The ``/model`` argument, or ``None`` when the id maps to
nothing the command accepts (the caller must skip the switch —
an unaccepted value silently keeps the current model).
"""
if not isinstance(model, str) or not model.strip():
return None
environ = os.environ if env is None else env
custom = environ.get(CUSTOM_MODEL_OPTION_ENV_VAR, "").strip()
if custom and normalized_model_id(custom) == normalized_model_id(model):
return custom
return claude_model_alias(model, env)
+157 -10
View File
@@ -28,8 +28,8 @@ import uuid
if sys.platform != "win32":
import termios
import tty
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
@@ -310,11 +310,20 @@ class ClaudeNativeUcodeConfig:
``apiKeyHelper`` once ``CLAUDE_CODE_USE_BEDROCK=1``).
:param model: Optional model id from ucode state, e.g.
``"databricks-claude-opus-4-7"``.
:param routable_models: Every Claude id this endpoint serves, newest
first, e.g. ``("databricks-claude-opus-5",
"databricks-claude-opus-4-8")``. A superset of the aliases in
``env``, which only pin the newest of each family: an older
generation is still launchable (``--model`` takes an exact id),
so a router may pick it. Empty when the endpoint's catalog was
not enumerated (cached ucode state, managed settings, a
non-Databricks provider).
"""
env: dict[str, str]
api_key_helper: str | None = None
model: str | None = None
routable_models: tuple[str, ...] = ()
def _serves_canonical_anthropic_ids(claude_config: ClaudeNativeUcodeConfig) -> bool:
@@ -378,6 +387,110 @@ def resolve_claude_native_model_selection(
return "claude-sonnet-5"
def claude_config_with_routed_arms_pinned(
claude_config: ClaudeNativeUcodeConfig | None,
routed_arms: Sequence[str],
) -> ClaudeNativeUcodeConfig | None:
"""Repoint Claude Code's family aliases at the router's frozen arms.
The terminal launches before the first turn decision, so ``/model`` can
only reach ids this env spells. Pinning each alias to its family's routed
arm makes turn one's ``/model opus`` land on the router's pick; arms with
no servable spelling keep the discovery-derived pin.
:param claude_config: Resolved provider config for the terminal, or
``None`` (Claude's own login pins nothing).
:param routed_arms: Arm ids the router may select, in router or catalog
vocabulary, e.g. ``("claude-opus-4-8", "claude-sonnet-5")``.
:returns: ``claude_config`` itself when no pin changes, otherwise a copy
with the alias env repointed.
"""
from omnigent.claude_model_vocabulary import normalized_model_id
if claude_config is None or not routed_arms:
return claude_config
servable = {normalized_model_id(m): m for m in reversed(claude_config.routable_models)}
env = dict(claude_config.env)
repinned: dict[str, str] = {}
for arm in routed_arms:
normalized = normalized_model_id(arm)
model_id = servable.get(normalized)
if model_id is None:
continue
tier = next(
(family for family in _UCODE_CLAUDE_TIER_TO_ENV if family in normalized.split("-")),
None,
)
if tier is None:
continue
env_var = _UCODE_CLAUDE_TIER_TO_ENV[tier]
if env.get(env_var) == model_id:
continue
env[env_var] = model_id
repinned[tier] = model_id
if not repinned:
return claude_config
_logger.info("native-claude: pinned routed arms onto family aliases: %s", repinned)
return replace(claude_config, env=env)
def claude_config_with_launch_model_pinned(
claude_config: ClaudeNativeUcodeConfig | None,
launch_model: str | None,
) -> ClaudeNativeUcodeConfig | None:
"""Pin an exact launch model into Claude Code's custom picker slot.
The four family aliases are pinned to the NEWEST model each family
serves, so a session launched on an older generation of a family it
still serves (Smart Routing picking ``claude-opus-4-8`` while
``opus`` resolves to ``claude-opus-5``) has no spelling of its own
model: ``/model`` would take the alias and silently move the pane to
the newer one. Claude Code's one extra picker slot takes an exact id,
so parking the launch model there gives the session a spelling for
the model it actually runs — and a picker row the user can return to.
:param claude_config: Resolved provider config for the terminal, or
``None`` (Claude's own login pins nothing).
:param launch_model: The model this terminal launches with, e.g.
``"databricks-claude-opus-4-8"``. Family aliases and ids already
covered by a pin need no slot.
:returns: The config to launch with — ``claude_config`` itself when
no slot change is needed, otherwise a copy with the custom-option
env set.
"""
from omnigent.claude_model_vocabulary import (
claude_model_command_arg,
normalized_model_id,
)
if claude_config is None or not launch_model or not launch_model.strip():
return claude_config
model = launch_model.strip()
if model in _UCODE_CLAUDE_TIER_TO_ENV or model == _UCODE_CLAUDE_CUSTOM_TIER:
return claude_config
if claude_model_command_arg(model, claude_config.env) is not None:
# Already speakable: an alias pinned to exactly this id, or the
# custom slot already holding it.
return claude_config
normalized = normalized_model_id(model)
tier = next(
(family for family in _UCODE_CLAUDE_TIER_TO_ENV if family in normalized.split("-")),
None,
)
env = dict(claude_config.env)
displaced = env.get(_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV)
env[_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV] = model
env[_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV] = (
_claude_model_display_name(tier, model) if tier is not None else model
)
_logger.info(
"native-claude: pinned launch model %s into the custom picker slot%s",
model,
f" (displacing {displaced})" if displaced else "",
)
return replace(claude_config, env=env)
def _claude_model_display_name(tier: str, model_id: str) -> str:
"""Build a friendly family/version label from a routable model id."""
normalized = model_id.lower().removesuffix("[1m]")
@@ -553,6 +666,7 @@ def run_claude_native(
extra_args: tuple[str, ...] | None = None,
claude_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
prompt: str | None = None,
command: str = _DEFAULT_CLAUDE_COMMAND,
use_claude_config: bool = False,
auto_open_conversation: bool = False,
@@ -572,6 +686,11 @@ def run_claude_native(
:param resume_picker: ``True`` runs the claude-native picker
once the server is reachable; ``False`` keeps the existing
``session_id``-or-fresh-session behavior.
:param prompt: Optional first prompt for the TUI, e.g.
``"review the last commit"``. Delivered as Claude Code's
positional prompt argument, so a multi-line prompt survives
intact (one argv entry — never a tmux paste). ``None`` starts
the TUI empty.
:param command: Executable to run in the terminal resource,
e.g. ``"claude"``. Kept off the public CLI surface so v0
always exposes Claude Code, while tests can supply a fake
@@ -601,6 +720,11 @@ def run_claude_native(
_preflight_local_tools(resolved_command)
startup_profiler.mark("local tools ready")
sanitized_args = _strip_resume_from_claude_args(claude_args)
# Claude Code takes the initial prompt as a positional argument, so it
# rides along with the launch args (persisted for the runner on the remote
# path). One argv entry keeps newlines and quotes intact.
if prompt and prompt.strip():
sanitized_args = (*sanitized_args, prompt)
startup_profiler.mark("claude args normalized")
# Resolve the launch config across all offerings: a configured provider
# (configure harnesses), the Databricks ucode profile, or Claude's own
@@ -1651,18 +1775,21 @@ def _ucode_config_for_profile(
agent_state.auth_refresh_interval_ms or _DEFAULT_UCODE_AUTH_REFRESH_INTERVAL_MS
)
claude_models = dict(workspace_state.claude_models)
routable_models: tuple[str, ...] = ()
if refresh_models:
live_models: dict[str, str] | None = None
try:
from omnigent.databricks_model_discovery import (
discover_databricks_claude_models,
discover_databricks_claude_catalog,
)
from omnigent.runtime.credentials.databricks import (
resolve_databricks_workspace,
)
creds = resolve_databricks_workspace(profile)
live_models = discover_databricks_claude_models(creds.host, creds.token)
live_catalog = discover_databricks_claude_catalog(creds.host, creds.token)
live_models = live_catalog.families
routable_models = live_catalog.model_ids
except Exception: # noqa: BLE001 — cached ucode state is the launch fallback
_logger.warning(
"native-claude: live Databricks model discovery failed for profile %r; "
@@ -1673,6 +1800,9 @@ def _ucode_config_for_profile(
if live_models is not None:
if not workspace_state.fable_enabled:
live_models.pop("fable", None)
routable_models = tuple(
model_id for model_id in routable_models if "fable" not in model_id.lower()
)
if not live_models:
raise click.ClickException(
f"Databricks profile {profile!r} exposes no Claude model services. "
@@ -1685,6 +1815,13 @@ def _ucode_config_for_profile(
_UCODE_CLAUDE_BASE_URL_ENV: base_url,
_CLAUDE_CODE_API_KEY_HELPER_TTL_ENV: str(refresh_interval_ms),
_CLAUDE_CODE_USE_GATEWAY_ENV: "1",
# The gateway allowlists beta flags and 400s the whole request
# ("invalid beta flag") on one it does not know, failing the turn
# rather than the feature. This env var is the only client-side way to
# drop them: the CLI computes ``anthropic-beta`` itself and ignores
# ANTHROPIC_CUSTOM_HEADERS. Tool search rides on a rejected flag
# (``advanced-tool-use``), so it was never reachable here anyway.
_CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS_ENV: "1",
}
# Pin each Claude Code model-tier alias to the corresponding Databricks
# gateway model ID so that the /model picker natively shows gateway model
@@ -1738,6 +1875,7 @@ def _ucode_config_for_profile(
model=default_model
or configured_default
or model_catalog.resolve_catalog_model("databricks", family="claude").model_id,
routable_models=routable_models,
)
@@ -1906,6 +2044,8 @@ def _bedrock_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcode
def _native_claude_config_from_entry(
entry: ProviderEntry,
*,
refresh_models: bool = True,
) -> ClaudeNativeUcodeConfig | None:
"""Map a resolved provider entry to a native Claude launch config.
@@ -1918,6 +2058,8 @@ def _native_claude_config_from_entry(
Claude Enterprise seat) — intentional, not a fallback to ucode.
:param entry: The resolved provider entry.
:param refresh_models: Forwarded to the ucode path's model discovery; pass
``False`` for a network-free lookup.
:returns: The launch config, or ``None`` to use Claude's own login.
"""
from omnigent.onboarding.provider_config import (
@@ -1934,7 +2076,7 @@ def _native_claude_config_from_entry(
return _bedrock_config_for_native_claude(entry)
if entry.kind == DATABRICKS_KIND:
_logger.info("native-claude routing: Databricks ucode profile %r", entry.profile)
return _ucode_config_for_profile(entry.profile)
return _ucode_config_for_profile(entry.profile, refresh_models=refresh_models)
_logger.info("native-claude routing: Claude CLI login (subscription provider %r)", entry.name)
return None
@@ -1942,6 +2084,7 @@ def _native_claude_config_from_entry(
def resolve_native_claude_config(
*,
spec: AgentSpec | None,
refresh_models: bool = True,
) -> ClaudeNativeUcodeConfig | None:
"""Resolve the native Claude Code launch config across all offerings.
@@ -1965,6 +2108,9 @@ def resolve_native_claude_config(
:param spec: The agent spec, or ``None`` for the bare ``omnigent
claude`` launch.
:param refresh_models: Query Databricks for the workspace's current Claude
model services while resolving the ucode config. Capability checks that
only need the routing shape pass ``False`` to stay network-free.
:returns: The launch config, or ``None`` to use Claude's own login.
"""
from omnigent.onboarding.detected import effective_config_with_detected
@@ -1982,18 +2128,18 @@ def resolve_native_claude_config(
if spec is not None:
entry = _resolve_provider_for_build(spec, harness_type="claude-sdk")
if entry is not None:
return _native_claude_config_from_entry(entry)
return _ucode_config_for_profile(spec.executor.profile)
return _native_claude_config_from_entry(entry, refresh_models=refresh_models)
return _ucode_config_for_profile(spec.executor.profile, refresh_models=refresh_models)
# 2. Spec-less (omnigent claude): explicit default wins first.
explicit = load_config()
entry = default_provider_for_harness(explicit, "claude-sdk")
if entry is not None:
return _native_claude_config_from_entry(entry)
return _native_claude_config_from_entry(entry, refresh_models=refresh_models)
# A global databricks auth block → ucode.
global_auth = _load_global_auth()
if isinstance(global_auth, DatabricksAuth):
return _ucode_config_for_profile(global_auth.profile)
return _ucode_config_for_profile(global_auth.profile, refresh_models=refresh_models)
if global_auth is not None:
# A global api_key auth: let Claude's own login handle it (parity
# with the subscription path); the in-process harness would inject
@@ -2002,7 +2148,7 @@ def resolve_native_claude_config(
# 3. Ambient detection (first run without configure).
entry = default_provider_for_harness(effective_config_with_detected(explicit), "claude-sdk")
if entry is not None:
return _native_claude_config_from_entry(entry)
return _native_claude_config_from_entry(entry, refresh_models=refresh_models)
_logger.info(
"native-claude routing: Claude CLI login (no provider configured for the Claude "
"harness, no Databricks profile). Run `omnigent setup --no-internal-beta` to route "
@@ -3452,6 +3598,7 @@ async def _prepare_claude_terminal(
bridge_id=bridge_id,
workspace=Path.cwd(),
launch_model=claude_config.model if claude_config else None,
launch_env=claude_config.env if claude_config else None,
)
_mark_startup_step(
startup_profiler,
+111 -2
View File
@@ -42,7 +42,7 @@ import tempfile
import threading
import time
import urllib.parse
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
from http import HTTPStatus
@@ -52,6 +52,7 @@ from typing import TYPE_CHECKING, Any
from urllib import error, request
from omnigent._platform import stable_user_id
from omnigent.claude_model_vocabulary import MODEL_VOCABULARY_ENV_VARS
from omnigent.claude_native_message_display_hook import MESSAGE_DELTAS_FILE
from omnigent.kiro_native_bridge import bridge_root as kiro_bridge_root
@@ -60,6 +61,9 @@ if TYPE_CHECKING:
from omnigent.inner.bundle_skills import claude_native_skill_args
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.inner.hook_scripts.subagent_router import (
AGENT_TOOL_MATCHER as CLAUDE_SUBAGENT_TOOL_MATCHER,
)
from omnigent.inner.os_env import OSEnvironment, create_os_environment
from omnigent.reasoning_effort import CLAUDE_EFFORTS
from omnigent.tools.base import Tool, ToolContext
@@ -292,11 +296,17 @@ def _trusted_parent_for_bridge_dir(target: Path) -> Path:
if target.is_relative_to(acp_root):
return _absolute_syntactic_path(acp_root.parent.parent)
# The subagent router's per-session dirs sit beside the native bridges
# ($TMPDIR/omnigent-<uid>/subagent-router), so trust the same parent.
router_root = _absolute_syntactic_path(subagent_router_bridge_root())
if target.is_relative_to(router_root):
return _absolute_syntactic_path(router_root.parent.parent)
raise RuntimeError(
f"bridge dir {target!s} is not under an allowed bridge root "
f"({claude_root!s}, {codex_root!s}, {cursor_root!s}, "
f"{antigravity_root!s}, {qwen_root!s}, {hermes_root!s}, {opencode_root!s}, "
f"{kiro_root!s}, {acp_root!s})"
f"{kiro_root!s}, {acp_root!s}, {router_root!s})"
)
@@ -727,6 +737,31 @@ def _ensure_secure_dir(target: Path) -> None:
os.chmod(ancestor, 0o700)
def ensure_secure_dir(target: Path) -> None:
"""Public alias for :func:`_ensure_secure_dir`.
The subagent router (``omnigent.runner.subagent_routing``) writes a
bearer-token advertisement under its own uid-scoped temp root and needs
the same ancestor hardening the bridges use.
:param target: Directory path to ensure, e.g. a router advertisement dir.
:raises RuntimeError: If validation fails for any ancestor.
"""
_ensure_secure_dir(target)
def subagent_router_bridge_root() -> Path:
"""Root for the subagent router's own advertisement directories.
Shares the uid-scoped temp parent with claude-native
(``$TMPDIR/omnigent-<uid>/subagent-router``) so per-session router dirs
pass the :func:`_trusted_parent_for_bridge_dir` secure-root check.
:returns: The subagent-router root directory (not created here).
"""
return _BRIDGE_ROOT_PARENT / "subagent-router"
def acp_mcp_bridge_root() -> Path:
"""Bridge root for the headless ACP harnesses' Omnigent-MCP relay.
@@ -812,6 +847,7 @@ def prepare_bridge_dir(
bridge_id: str | None = None,
workspace: Path,
launch_model: str | None = None,
launch_env: Mapping[str, str] | None = None,
) -> Path:
"""
Create or refresh the bridge directory for a native Claude session.
@@ -826,6 +862,11 @@ def prepare_bridge_dir(
forwarder can re-inject it when Claude Code's ``/model``
normalizes the name to one the gateway rejects. ``None`` when
no ucode profile is active.
:param launch_env: Launch environment for the terminal. Its model
vocabulary keys (``ANTHROPIC_DEFAULT_*_MODEL`` /
``ANTHROPIC_CUSTOM_MODEL_OPTION``) are persisted so runner-side
callers — which don't share the terminal's env — can translate a
routed model id into a ``/model`` argument the CLI accepts.
:returns: Bridge directory path.
"""
resolved_bridge_id = bridge_id or conversation_id
@@ -845,6 +886,13 @@ def prepare_bridge_dir(
}
if launch_model is not None:
payload["launch_model"] = launch_model
model_env = {
key: launch_env[key]
for key in MODEL_VOCABULARY_ENV_VARS
if launch_env is not None and launch_env.get(key)
}
if model_env:
payload["model_env"] = model_env
_write_json_file(bridge_dir / _CONFIG_FILE, payload)
# Keep ``_PERMISSION_HOOK_FILE`` — the PermissionRequest command hook
# reads the Omnigent server URL from it at runtime, so wiping it on re-prep
@@ -1016,6 +1064,28 @@ def read_launch_model(bridge_dir: Path) -> str | None:
return model if isinstance(model, str) and model else None
def read_model_env(bridge_dir: Path) -> dict[str, str]:
"""
Read the launch env keys defining this session's model vocabulary.
:param bridge_dir: Bridge directory path.
:returns: ``{env var: model id}`` for the pinned aliases and custom
model option; empty when the session predates the record or ran
without a ucode profile.
"""
config = _read_json_file(bridge_dir / _CONFIG_FILE)
if not isinstance(config, dict):
return {}
model_env = config.get("model_env")
if not isinstance(model_env, dict):
return {}
return {
str(key): str(value)
for key, value in model_env.items()
if isinstance(key, str) and isinstance(value, str) and value
}
def read_bridge_id(bridge_dir: Path) -> str | None:
"""
Read the opaque bridge id from bridge config.
@@ -1125,6 +1195,7 @@ def build_hook_settings(
launch_model: str | None = None,
launch_permission_mode: str | None = None,
launch_effort: str | None = None,
subagent_router_dir: Path | None = None,
) -> dict[str, Any]:
"""
Build invocation-local Claude Code hook settings.
@@ -1153,6 +1224,10 @@ def build_hook_settings(
for the same re-exec hardening.
:param launch_effort: Effective launch effort from ``--effort``.
Mirrored into ``effortLevel`` for restart/re-exec parity.
:param subagent_router_dir: Directory where the runner advertises its
``route-subagent`` endpoint (``subagent_router.json``). When set,
a ``PreToolUse`` hook routes native subagent spawns; ``None``
leaves spawns unrouted.
:returns: JSON-serializable Claude settings fragment.
"""
python = python_executable or sys.executable
@@ -1337,6 +1412,34 @@ def build_hook_settings(
# server-side. Covers both web-UI-injected and direct-terminal
# prompts, since both fire UserPromptSubmit.
hooks["UserPromptSubmit"].append({"hooks": [evaluate_policy_hook]})
if subagent_router_dir is not None:
# Route natively spawned subagents (the Task/Agent tool) through
# the runner's route-subagent endpoint. Settings-level hooks also
# apply to nested spawns, so a routed subagent's own spawns are
# routed too. The script fails open — an unreachable endpoint
# emits no output and the spawn proceeds unchanged.
router_command_parts = [
python,
"-I",
"-m",
"omnigent.inner.hook_scripts.claude_router_hook",
"--bridge-dir",
str(bridge_dir),
"--router-dir",
str(subagent_router_dir),
]
router_hook: dict[str, Any] = {
"type": "command",
"command": shlex.join(router_command_parts),
# Outermost hop of the routing timeout budget documented in
# ``omnigent.runner.subagent_routing``: it must exceed the hook
# script's own 30s request timeout so the script's fail-open
# branch can actually run before Claude kills it.
"timeout": 40,
}
hooks.setdefault("PreToolUse", []).append(
{"matcher": CLAUDE_SUBAGENT_TOOL_MATCHER, "hooks": [router_hook]}
)
settings: dict[str, Any] = {"hooks": hooks}
if launch_model:
settings["model"] = launch_model
@@ -1401,6 +1504,7 @@ def augment_claude_args(
skills_filter: str | list[str] = "all",
append_system_prompt: str | None = None,
allowed_tools: tuple[str, ...] = (),
subagent_router_dir: Path | None = None,
) -> list[str]:
"""
Return Claude CLI args with Omnigent MCP/hook/skill injection.
@@ -1437,6 +1541,10 @@ def augment_claude_args(
append through Claude Code's native ``--append-system-prompt`` flag.
:param allowed_tools: Optional narrowly scoped Claude tool names to merge
into ``--allowedTools`` without replacing the user's allowlist.
:param subagent_router_dir: Directory advertising the runner's
``route-subagent`` endpoint, threaded to
:func:`build_hook_settings` so native ``Task`` spawns are routed.
``None`` leaves them unrouted.
:returns: Augmented argument list for the terminal resource.
"""
mcp_config = build_mcp_config(bridge_dir, python_executable=python_executable)
@@ -1449,6 +1557,7 @@ def augment_claude_args(
launch_model=_arg_value(claude_args, "--model"),
launch_permission_mode=_arg_value(claude_args, "--permission-mode"),
launch_effort=_arg_value(claude_args, "--effort"),
subagent_router_dir=subagent_router_dir,
)
args = _merge_disallowed_tools(list(claude_args), _OMNIGENT_DISALLOWED_TOOLS)
args = _merge_allowed_tools(args, allowed_tools)
+505 -16
View File
@@ -73,6 +73,7 @@ if TYPE_CHECKING:
import httpx
from omnigent.onboarding.acp_auth import AcpAgentEntry
from omnigent.smart_routing_cli import RoutingDecision
from omnigent.update_check import _InstalledWheelInfo
@@ -90,22 +91,141 @@ def _load_config(path: str | None) -> dict[str, Any]: # type: ignore[explicit-a
def _parse_model_prefixes(
raw: Any, # type: ignore[explicit-any] # str | list | None from YAML
) -> list[str]:
) -> list[str] | None:
"""Normalize the ``model_prefix`` config into a list of prefixes.
Accepts a single string (``"databricks-"``) or a list
(``["databricks-", "system.ai."]``); blanks are dropped. Returns an
empty list when unset, so catalog ids are sent verbatim.
(``["databricks-", "system.ai."]``); blanks are dropped.
:returns: The configured prefixes an explicit empty list is honoured as
"this catalog carries no prefix" or ``None`` when the key is absent or
malformed, leaving :data:`MODEL_ID_PREFIXES` in place.
"""
if raw is None:
return None
if isinstance(raw, str):
raw = [raw]
if not isinstance(raw, list):
return []
return None
return [p.strip() for p in raw if isinstance(p, str) and p.strip()]
def parse_routing_settings(
routing_cfg: Any, # type: ignore[explicit-any] # parsed YAML block
) -> Any: # type: ignore[explicit-any] # RoutingSettings
"""Parse the ``routing:`` block into the shared ``RoutingSettings``.
This is the only place ``routing.*`` config is read; every consumer
(the routing clients, the subagent router) reads the dataclass off
``RuntimeCaps`` instead.
:param routing_cfg: The parsed ``routing:`` mapping, or ``None``.
:returns: A :class:`~omnigent.server.smart_routing.RoutingSettings`;
all-defaults when the block is absent or malformed.
"""
from omnigent.server.smart_routing import (
DEFAULT_ROUTER_NAME,
MODEL_ID_PREFIXES,
RoutingSettings,
)
if not isinstance(routing_cfg, dict):
return RoutingSettings()
router_name = (routing_cfg.get("router_name") or "").strip() or DEFAULT_ROUTER_NAME
selection_model = (routing_cfg.get("selection_model") or "").strip() or None
prefixes = _parse_model_prefixes(routing_cfg.get("model_prefix"))
return RoutingSettings(
router_name=router_name,
selection_model=selection_model,
# Only an absent key falls back: ``model_prefix: []`` means bare ids.
model_prefixes=MODEL_ID_PREFIXES if prefixes is None else tuple(prefixes),
)
# Databricks workspaces serve the routing API under this path.
_AIGW_ROUTING_PATH = "/ai-gateway/routing/v1"
def _databricks_provider_profile(
cfg: Any, # type: ignore[explicit-any] # parsed server config
) -> str | None:
"""Return the profile of the config's Databricks provider, if any.
Reads the server ``--config`` first and falls back to the global
``providers:`` block, which is where most deployments declare their
workspace. A ``default:``-flagged entry wins so a workspace that also
declares a secondary Databricks provider still routes against the primary.
:param cfg: The parsed server ``--config`` mapping.
:returns: The Databricks profile name, or ``None`` when the deployment
declares no ``kind: databricks`` provider.
"""
providers = cfg.get("providers") if isinstance(cfg, dict) else None
if not isinstance(providers, dict):
from omnigent.onboarding.provider_config import load_config as load_provider_config
providers = load_provider_config().get("providers")
if not isinstance(providers, dict):
return None
matches: list[tuple[bool, str]] = []
for entry in providers.values():
if not isinstance(entry, dict) or entry.get("kind") != "databricks":
continue
profile = entry.get("profile")
if isinstance(profile, str) and profile.strip():
matches.append((bool(entry.get("default")), profile.strip()))
if not matches:
return None
matches.sort(key=lambda m: not m[0])
return matches[0][1]
def _build_default_databricks_routing_client(
cfg: Any, # type: ignore[explicit-any] # parsed server config
settings: Any, # type: ignore[explicit-any] # RoutingSettings
) -> Any | None: # type: ignore[explicit-any] # ExternalRoutingClient | None
"""Route through the workspace's AI Gateway when no ``routing:`` block exists.
A Databricks-backed deployment gets smart routing without extra
config: the client points at that workspace's routing API and authenticates
with the same profile. Returns ``None`` for any other deployment, so the
built-in judge stays the fallback.
:param cfg: The parsed server ``--config`` mapping.
:param settings: The parsed routing settings (all defaults here).
:returns: A configured client, or ``None`` when there is no Databricks
provider or its workspace host can't be resolved.
"""
profile = _databricks_provider_profile(cfg)
if profile is None:
return None
from omnigent.runtime.credentials.databricks import resolve_databricks_workspace
try:
host = resolve_databricks_workspace(profile).host.rstrip("/")
except Exception: # noqa: BLE001 — unresolvable workspace just means no routing
logging.getLogger(__name__).info(
"routing: could not resolve workspace host for Databricks profile %r; "
"leaving smart routing off",
profile,
)
return None
if not host:
return None
from omnigent.server.smart_routing import ExternalRoutingClient
return ExternalRoutingClient(
base_url=host + _AIGW_ROUTING_PATH,
router_name=settings.router_name,
databricks_profile=profile,
model_prefixes=list(settings.model_prefixes),
selection_model=settings.selection_model,
)
def _build_external_routing_client(
routing_cfg: Any, # type: ignore[explicit-any] # parsed YAML block
settings: Any = None, # type: ignore[explicit-any] # RoutingSettings | None
) -> Any | None: # type: ignore[explicit-any] # ExternalRoutingClient | None
"""Build an :class:`ExternalRoutingClient` from the ``routing:`` config.
@@ -120,14 +240,18 @@ def _build_external_routing_client(
:param routing_cfg: The parsed ``routing:`` mapping (a dict with
``provider == "external"``, per the caller).
:param settings: The parsed routing settings, supplying the extraction
model, scenario menus, and model prefixes. ``None`` parses them from
*routing_cfg*.
:returns: A configured client, or ``None`` when required config is
missing (a warning is logged; routing stays off rather than raising).
"""
if settings is None:
settings = parse_routing_settings(routing_cfg)
base_url = (routing_cfg.get("base_url") or "").strip()
router_name = (routing_cfg.get("router_name") or "").strip()
api_key = (routing_cfg.get("api_key") or "").strip()
profile = (routing_cfg.get("profile") or "").strip()
model_prefixes = _parse_model_prefixes(routing_cfg.get("model_prefix"))
if not base_url or not router_name:
click.echo(
@@ -159,7 +283,8 @@ def _build_external_routing_client(
router_name=router_name,
auth=auth,
databricks_profile=databricks_profile,
model_prefixes=model_prefixes,
model_prefixes=list(settings.model_prefixes),
selection_model=settings.selection_model,
)
@@ -3410,24 +3535,34 @@ def server(
server_llm = parse_server_llm(cfg.get("llm"))
# Build the routing client from configuration alone — no opt-in env needed.
# Two mutually-exclusive providers, chosen by ``routing.provider``:
# - ``external``: call an external ``routes:select`` service (built when a
# ``routing:`` block declares ``provider: external``).
# - ``llm`` (default): the built-in judge using the ``llm:`` block (built
# whenever a server ``llm:`` block is configured).
# Stays None when neither is configured. Managed deployments override
# RuntimeCaps.routing_client with their own implementation.
# An explicit ``routing:`` block always wins, chosen by its ``provider``:
# - ``external``: call an external ``routes:select`` service.
# - ``none``: opt out of routing entirely.
# - anything else (default): the built-in judge from the ``llm:`` block.
# With no ``routing:`` block, a Databricks-backed deployment routes through
# its own workspace AI Gateway; other deployments fall back to the judge.
# Managed deployments override RuntimeCaps.routing_client themselves.
routing_cfg = cfg.get("routing")
if isinstance(routing_cfg, dict) and routing_cfg.get("provider") == "external":
routing_client = _build_external_routing_client(routing_cfg)
routing_settings = parse_routing_settings(routing_cfg)
if isinstance(routing_cfg, dict):
provider = routing_cfg.get("provider")
if provider == "external":
routing_client = _build_external_routing_client(routing_cfg, routing_settings)
elif provider == "none":
routing_client = None
else:
routing_client = _build_local_llm_routing_client(server_llm)
else:
routing_client = _build_local_llm_routing_client(server_llm)
routing_client = _build_default_databricks_routing_client(
cfg, routing_settings
) or _build_local_llm_routing_client(server_llm)
caps = RuntimeCaps(
execution_timeout=int(effective_timeout),
default_policies=parse_default_policies(cfg.get("policies")),
llm=server_llm,
routing_client=routing_client,
routing_settings=routing_settings,
)
init_runtime(
conversation_store=conversation_store,
@@ -5491,6 +5626,10 @@ _RESUME_HELP = (
)
_CONTINUE_HELP = "Continue the most recent conversation for this agent."
_NO_SESSION_HELP = "Use a fresh temporary local session store for this run."
_SMART_ROUTING_HELP = (
"Let the server pick the model for this launch (and the harness too, "
"unless --harness pins one). Requires -p."
)
_FORK_HELP = "Fork an existing session by id and open the REPL on the fork."
_LOG_HELP = "Write a JSON dump of the conversation to ~/.omnigent/logs/ on exit."
@@ -5776,12 +5915,14 @@ _NATIVE_TERMINAL_DISPATCH_SPECS: dict[str, _NativeTerminalDispatchSpec] = {
module="omnigent.claude_native",
function="run_claude_native",
args_param="extra_args",
prompt_param="prompt",
),
"codex": _NativeTerminalDispatchSpec(
module="omnigent.codex_native",
function="run_codex_native",
args_param="extra_args",
model_strategy="first_class",
prompt_param="prompt",
),
"pi": _NativeTerminalDispatchSpec(
module="omnigent.pi_native",
@@ -5962,6 +6103,318 @@ def _dispatch_native_terminal_harness(
return True
# ── Smart Routing (route before launch) ──────────────────────────────────
# Bryan's decision: --smart-routing requires -p. Routing needs text, and the
# degraded "route on turn 2" mode is not shipping — so an empty invocation is
# a usage error that points at the two surfaces that do work.
_SMART_ROUTING_NEEDS_PROMPT = (
'--smart-routing needs the text to route: pass -p "<prompt>", or start '
"the session from the web UI (which routes on your first message)."
)
#: Fail-open harness for a tier-3 route that returned nothing usable.
_SMART_ROUTING_FALLBACK_HARNESS = "claude-native"
def _smart_routing_capable_harness(harness: str | None) -> str | None:
"""
Canonical native harness for *harness*, when Smart Routing can launch it.
A routed launch has to carry the prompt into the TUI, so only native
terminal harnesses whose dispatch spec accepts a prompt qualify.
:param harness: Requested harness (canonical or alias), e.g.
``"claude-native"``. Bare ``"claude"`` canonicalizes to the SDK
harness, which is not routable here.
:returns: The canonical harness id, or ``None`` when it is not routable.
"""
from omnigent.native_coding_agents import native_coding_agent_for_harness
native = native_coding_agent_for_harness(harness)
if native is None:
return None
spec = _NATIVE_TERMINAL_DISPATCH_SPECS.get(native.key)
if spec is None or spec.prompt_param is None:
return None
return native.harness
def _require_smart_routing_prompt(prompt: str | None) -> str:
"""
Reject ``--smart-routing`` without a prompt to route.
:param prompt: The ``-p`` text, or ``None``.
:returns: The prompt, unchanged.
:raises click.UsageError: When there is no text to route.
"""
if prompt is None or not prompt.strip():
raise click.UsageError(_SMART_ROUTING_NEEDS_PROMPT)
return prompt
def _reject_smart_routing_resume(*, resuming: bool, flag: str = "--resume") -> None:
"""
Reject ``--smart-routing`` combined with a resume.
Routing happens when the session is created, so a routed launch is always a
new session; resuming one would silently ignore the routing request.
:param resuming: ``True`` when the invocation targets an existing session.
:param flag: The flag to name in the error, e.g. ``"--continue"``.
:returns: None when the combination is fine.
:raises click.ClickException: When *resuming* is ``True``.
"""
if not resuming:
return
raise click.ClickException(
f"--smart-routing routes a new session, so it cannot be combined with {flag}. "
f"Drop {flag} to route, or drop --smart-routing to reopen the existing session "
"on its own model."
)
def _with_routed_model_arg(args: tuple[str, ...], model: str | None) -> tuple[str, ...]:
"""
Append ``--model <routed>`` to a wrapper's pass-through args.
A ``--model`` the user typed themselves wins: they asked for that model
explicitly, and routing is a default-filling service.
:param args: The wrapper's pass-through args, e.g. ``("--verbose",)``.
:param model: Routed model id, or ``None`` to leave *args* alone.
:returns: The args, with the routed model appended when appropriate.
"""
if not model:
return args
if any(arg == "--model" or arg.startswith("--model=") for arg in args):
return args
return (*args, "--model", model)
def _smart_routing_decision(
*,
server: str,
prompt: str,
harness: str | None,
) -> RoutingDecision:
"""
Preflight Smart Routing, then create the routed session for *prompt*.
Preflight failures raise (a pick that cannot be applied is worse than no
pick); a create the server rejects comes back as a decision with no session
whose notice is printed here, so the caller only has to launch a plain
wrapper session.
:param server: Resolved Omnigent server base URL.
:param prompt: The text to route (also the TUI's initial input).
:param harness: Canonical harness to pin, or ``None`` to route the harness
too.
:returns: The routed session and pick to launch on.
:raises click.ClickException: When Smart Routing is unavailable.
"""
from omnigent.smart_routing_cli import (
check_smart_routing_available,
create_smart_routing_session,
known_host_id,
smart_routing_families,
)
# The session must be bound to the host it will run on: the server builds
# the router's candidate model catalog from that host's model-options
# frames, so the daemon has to be connected before we create (the wrapper
# ensures it again on attach; the call is idempotent).
host_id: str | None
try:
from omnigent.host.identity import load_or_create_host_identity
_ensure_host_daemon(server)
host_id = known_host_id(base_url=server, host_id=load_or_create_host_identity().host_id)
except (OSError, ValueError):
# No host identity yet — the per-host gate has nothing to read, which
# is the same "unknown does not gate" case as an older host.
host_id = None
check_smart_routing_available(
base_url=server,
harnesses=smart_routing_families(harness),
host_id=host_id,
)
decision = create_smart_routing_session(
base_url=server,
prompt=prompt,
harness=harness,
host_id=host_id,
# The server requires a workspace with a host_id, and this is the cwd
# the wrapper will attach in.
workspace=str(Path.cwd().resolve()) if host_id is not None else None,
)
if decision.notice is not None:
click.echo(decision.notice, err=True)
elif decision.model is not None:
picked = (
f"{decision.harness} on {decision.model}"
if decision.harness is not None
else decision.model
)
click.echo(f"omnigent: Smart Routing picked {picked}.", err=True)
return decision
def _dispatch_smart_routing(
*,
harness: str | None,
server: str | None,
prompt: str | None,
model: str | None,
auto_open_conversation: bool,
) -> None:
"""
Create the routed session, then attach its native TUI wrapper to it.
Tier 2 (*harness* given) routes the model only and keeps the requested
harness. Tier 3 (*harness* ``None``) routes both, and the wrapper is chosen
from the harness the server bound falling back to
:data:`_SMART_ROUTING_FALLBACK_HARNESS` (with a notice) when the create
resolved nothing, or a harness the CLI cannot hand a prompt to.
The wrapper always attaches to the created session rather than bundling its
own, so the routed model, the decision card, and the wrapper labels the
server wrote at create are the ones the launch runs on. When the create
failed entirely, the wrapper starts a plain session instead the launch is
never blocked.
:param harness: Canonical native harness to pin, or ``None`` for the auto
route.
:param server: ``--server`` value (or its config default), or ``None``.
:param prompt: The routed prompt; also the TUI's initial input.
:param model: ``--model`` fallback used when routing returns no model.
:param auto_open_conversation: Whether to open the web conversation.
:returns: None once the TUI attach ends.
:raises click.ClickException: When Smart Routing is unavailable.
"""
prompt = _require_smart_routing_prompt(prompt)
server = _ensure_backend(server)
decision = _smart_routing_decision(server=server, prompt=prompt, harness=harness)
launch_harness = harness or _smart_routing_capable_harness(decision.harness)
if launch_harness is None:
launch_harness = _SMART_ROUTING_FALLBACK_HARNESS
click.echo(
f"omnigent: Smart Routing did not resolve a launchable harness; "
f"launching {launch_harness}.",
err=True,
)
routed_model = decision.model or model
_dispatch_native_terminal_harness(
harness=launch_harness,
server=server,
model=routed_model,
# A routed model is an explicit request, so wrappers that only take a
# model when the user asked for one still receive it.
model_from_cli=routed_model is not None,
prompt=prompt,
system_prompt=None,
tools=None,
log=False,
debug_events=False,
# Attach to the routed session; ``None`` (create failed) lets the
# wrapper start its own.
resume_conversation_id=decision.session_id,
resume_picker=False,
resume_latest=False,
fork_session_id=None,
ephemeral=False,
auto_open_conversation=auto_open_conversation,
)
def _run_smart_routing(
*,
target: str | None,
harness: str | None,
prompt: str | None,
server: str | None,
model: str | None,
resume_conversation_id: str | None,
resume_picker: bool,
resume_latest: bool,
auto_open_conversation: bool,
system_prompt: str | None = None,
tools: str | None = None,
log: bool = False,
debug_events: bool = False,
fork_session_id: str | None = None,
ephemeral: bool = False,
) -> None:
"""
Handle ``omnigent run --smart-routing``: validate, then route and launch.
``--harness`` (a native terminal harness) pins the harness and routes the
model; ``--harness auto`` or no ``--harness`` at all routes both. An AGENT
is rejected: a routed session is a native TUI, and an agent spec's
prompt/tools are never consulted there.
:param target: The AGENT argument as the user passed it, or ``None``.
:param harness: The ``--harness`` value the user passed, or ``None``.
:param prompt: The ``-p`` text, or ``None`` (a usage error).
:param server: ``--server`` value or its config default.
:param model: ``--model`` fallback for an unrouted launch.
:param resume_conversation_id: ``--resume <id>`` target, rejected when set.
:param resume_picker: ``--resume`` with no value, rejected when set.
:param resume_latest: ``--continue``, rejected when set.
:param auto_open_conversation: Whether to open the web conversation.
:param system_prompt: ``--system-prompt`` value, rejected when set.
:param tools: ``--tools`` value, rejected when set.
:param log: ``--log``, rejected when set.
:param debug_events: ``--debug-events``, rejected when set.
:param fork_session_id: ``--fork`` value, rejected when set.
:param ephemeral: ``--no-session``, rejected when set.
:returns: None once the TUI attach ends.
:raises click.ClickException: On a rejected combination, or when Smart
Routing is unavailable.
"""
# The same REPL-only options the plain native dispatch rejects: a routed
# launch is still a TUI attach, so they would be silently dropped.
repl_only = [
flag
for flag, active in (
("--system-prompt", system_prompt is not None),
("--tools", tools is not None),
("--log", log),
("--debug-events", debug_events),
("--fork", fork_session_id is not None),
("--no-session", ephemeral),
)
if active
]
if repl_only:
raise click.ClickException(
"--smart-routing launches a native harness TUI; the REPL-only option(s) "
f"{', '.join(repl_only)} have no effect there — remove them."
)
_reject_smart_routing_resume(resuming=resume_conversation_id is not None or resume_picker)
_reject_smart_routing_resume(resuming=resume_latest, flag="--continue")
if target is not None:
raise click.ClickException(
"--smart-routing launches a native harness TUI, so it takes no AGENT. "
"Drop the AGENT to route the harness and model, or pass "
"`--harness claude-native` to route the model only."
)
requested: str | None = None
if harness is not None and harness != "auto":
requested = _smart_routing_capable_harness(harness)
if requested is None:
raise click.ClickException(
f"--smart-routing does not support --harness {harness!r}. Use a native "
"terminal harness that accepts a prompt (claude-native, codex-native, "
"kiro-native), or `--harness auto` to route the harness too."
)
_dispatch_smart_routing(
harness=requested,
server=server,
prompt=prompt,
model=model,
auto_open_conversation=auto_open_conversation,
)
def _reject_agent_with_native_terminal_harness(harness: str) -> None:
"""
Reject ``run AGENT --harness <x>-native``: native harnesses own their TUI.
@@ -6439,6 +6892,13 @@ def attach(
help="Client-side tool set name (e.g. 'coding') for shell access.",
)
@click.option("--harness", default=None, help=_RUN_HARNESS_HELP)
@click.option(
"--smart-routing",
"smart_routing",
is_flag=True,
default=False,
help=_SMART_ROUTING_HELP,
)
@click.option(
"--from-openclaw",
"from_openclaw",
@@ -6500,6 +6960,7 @@ def run(
target: str | None,
tools: str | None,
harness: str | None,
smart_routing: bool,
from_openclaw: str | None,
model: str | None,
prompt: str | None,
@@ -6529,6 +6990,8 @@ def run(
Examples:
omnigent run --harness claude-sdk
omnigent run --harness codex -p "review the last commit"
omnigent run --smart-routing -p "review the last commit"
omnigent run --harness claude-native --smart-routing -p "fix the flaky test"
omnigent run --from-openclaw "Gemini CLI" -p "review the last commit"
omnigent run examples/hello_world.yaml
omnigent run examples/hello_world.yaml --harness codex --model gpt-5.4-mini
@@ -6544,6 +7007,32 @@ def run(
model_from_cli = model_source is click.core.ParameterSource.COMMANDLINE
harness_source = click.get_current_context().get_parameter_source("harness")
harness_from_cli = harness_source is not None and harness_source.name == "COMMANDLINE"
# Smart Routing owns the whole launch: it routes before anything is
# created, then execs a native TUI wrapper. Handle it here, before the
# default-agent / first-run resolution below can substitute an agent the
# routed launch would have to reject.
if smart_routing:
_smart_routing_cfg = _load_effective_config()
_smart_routing_resume = _split_resume_value(resume)
_run_smart_routing(
target=target,
harness=harness if harness_from_cli else None,
prompt=prompt,
server=server if server_from_cli else _smart_routing_cfg.get("server"),
model=model if model_from_cli else None,
resume_conversation_id=_smart_routing_resume.conversation_id,
resume_picker=_smart_routing_resume.picker,
resume_latest=resume_latest,
auto_open_conversation=_resolve_auto_open_conversation_from_config(_smart_routing_cfg),
system_prompt=system_prompt,
tools=tools,
log=log,
debug_events=debug_events,
fork_session_id=fork_session_id,
ephemeral=ephemeral,
)
return
acp_agent: AcpAgentEntry | None = None
if from_openclaw is not None:
if target is not None:
+77 -1
View File
@@ -74,6 +74,10 @@ def register_native_commands(cli: click.Group) -> None:
)
_resolve_harness_startup_args = _late_bound(lambda: _cli._resolve_harness_startup_args)
_split_resume_value = _late_bound(lambda: _cli._split_resume_value)
_reject_smart_routing_resume = _late_bound(lambda: _cli._reject_smart_routing_resume)
_require_smart_routing_prompt = _late_bound(lambda: _cli._require_smart_routing_prompt)
_smart_routing_decision = _late_bound(lambda: _cli._smart_routing_decision)
_with_routed_model_arg = _late_bound(lambda: _cli._with_routed_model_arg)
@cli.command(
context_settings={
@@ -155,6 +159,19 @@ def register_native_commands(cli: click.Group) -> None:
"flag will be removed in a future release."
),
)
@click.option(
"-p",
"--prompt",
default=None,
help="Open the Claude Code TUI with this as its initial prompt.",
)
@click.option(
"--smart-routing",
"smart_routing",
is_flag=True,
default=False,
help=("Let the server pick the model for this launch from the prompt. Requires -p."),
)
@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED)
def claude(
server: str | None,
@@ -164,6 +181,8 @@ def register_native_commands(cli: click.Group) -> None:
use_claude_config: bool,
profile_startup: bool,
claude_command: str | None,
prompt: str | None,
smart_routing: bool,
claude_args: tuple[str, ...],
) -> None:
# Param docs live in comments — Click uses the docstring for --help.
@@ -173,6 +192,8 @@ def register_native_commands(cli: click.Group) -> None:
# :param use_claude_config: When True, skip ucode/Databricks auth and use
# existing Claude config.
# :param profile_startup: When True, print startup timing marks.
# :param prompt: Optional initial TUI prompt.
# :param smart_routing: When True, route the model from ``prompt``.
# :param claude_args: Pass-through args for ``claude``.
"""Launch Claude Code in an Omnigent terminal.
@@ -182,8 +203,13 @@ def register_native_commands(cli: click.Group) -> None:
omnigent claude --resume conv_abc123
omnigent claude --resume # interactive picker
omnigent claude --server https://<app>.databricksapps.com
omnigent claude --smart-routing -p "fix the flaky test"
"""
_reject_native_on_windows("claude")
if smart_routing:
# Validate before any side effects (daemon spawn, server discovery)
# so a missing prompt fails instantly.
prompt = _require_smart_routing_prompt(prompt)
startup_profiler = StartupProfiler.from_env(
name="omnigent claude",
env_var=_CLAUDE_STARTUP_PROFILE_ENV_VAR,
@@ -211,6 +237,12 @@ def register_native_commands(cli: click.Group) -> None:
"--session and --resume are mutually exclusive; "
"prefer --resume (--session is deprecated).",
)
if smart_routing:
_reject_smart_routing_resume(
resuming=choice.picker
or choice.conversation_id is not None
or session_id is not None
)
startup_profiler.mark("arguments validated")
# Ensure the host daemon (local when ``--server`` is omitted/empty,
@@ -243,11 +275,22 @@ def register_native_commands(cli: click.Group) -> None:
explicit=claude_command,
cfg=cfg,
)
extra_args = _resolve_harness_startup_args(cfg, "claude-native", claude_args)
if smart_routing:
# Routing creates the session (that is where the model is picked and
# the decision card is written), so attach to it instead of letting
# the wrapper bundle a fresh one.
decision = _smart_routing_decision(
server=server, prompt=prompt, harness="claude-native"
)
extra_args = _with_routed_model_arg(extra_args, decision.model)
resolved_session_id = decision.session_id or resolved_session_id
run_claude_native(
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
extra_args=_resolve_harness_startup_args(cfg, "claude-native", claude_args),
extra_args=extra_args,
prompt=prompt,
use_claude_config=use_claude_config,
auto_open_conversation=auto_open_conversation,
startup_profiler=startup_profiler,
@@ -298,6 +341,13 @@ def register_native_commands(cli: click.Group) -> None:
default=None,
help="Send this as the first message after the Codex TUI starts.",
)
@click.option(
"--smart-routing",
"smart_routing",
is_flag=True,
default=False,
help=("Let the server pick the model for this launch from the prompt. Requires -p."),
)
@click.argument("codex_args", nargs=-1, type=click.UNPROCESSED)
def codex(
server: str | None,
@@ -305,6 +355,7 @@ def register_native_commands(cli: click.Group) -> None:
session_id: str | None,
model: str | None,
prompt: str | None,
smart_routing: bool,
codex_args: tuple[str, ...],
) -> None:
# Param docs live in comments — Click uses the docstring for --help.
@@ -313,6 +364,7 @@ def register_native_commands(cli: click.Group) -> None:
# :param session_id: Legacy ``--session`` id; mutually exclusive with ``--resume``.
# :param model: Codex model id.
# :param prompt: Optional first prompt.
# :param smart_routing: When True, route the model from ``prompt``.
# :param codex_args: Pass-through args for ``codex`` before ``resume``.
"""Launch Codex TUI in an Omnigent terminal.
@@ -322,14 +374,27 @@ def register_native_commands(cli: click.Group) -> None:
omnigent codex --resume conv_abc123
omnigent codex --resume # interactive picker
omnigent codex --server https://<app>.databricksapps.com
omnigent codex --smart-routing -p "fix the flaky test"
"""
_reject_native_on_windows("codex")
model_source = click.get_current_context().get_parameter_source("model")
model_from_cli = model_source is click.core.ParameterSource.COMMANDLINE
if smart_routing:
# Validate before any side effects (daemon spawn, server discovery)
# so a missing prompt fails instantly.
prompt = _require_smart_routing_prompt(prompt)
choice = _split_resume_value(resume)
if session_id is not None and (choice.picker or choice.conversation_id is not None):
raise click.UsageError(
"--session and --resume are mutually exclusive; "
"prefer --resume (--session is deprecated).",
)
if smart_routing:
_reject_smart_routing_resume(
resuming=choice.picker
or choice.conversation_id is not None
or session_id is not None
)
from omnigent.codex_native import run_codex_native
from omnigent.harness_startup_config import resolve_harness_command
@@ -357,6 +422,17 @@ def register_native_commands(cli: click.Group) -> None:
explicit=None,
cfg=cfg,
)
if smart_routing:
decision = _smart_routing_decision(
server=server, prompt=prompt, harness="codex-native"
)
# Codex takes the model first-class. A routed model beats the
# configured default (the user asked to route) but never an
# explicit ``--model``.
if decision.model is not None and not model_from_cli:
model = decision.model
# Attach to the routed session — routing created it.
resolved_session_id = decision.session_id or resolved_session_id
run_codex_native(
server=server,
session_id=resolved_session_id,
+271 -108
View File
@@ -10,9 +10,8 @@ import os
import re
import shlex
import sys
import tempfile
import uuid
from collections.abc import AsyncIterator, Sequence
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -36,6 +35,7 @@ from omnigent.codex_native_process_registry import (
)
from omnigent.inner import _proc
from omnigent.inner.codex_executor import (
_CODEX_ROUTER_HOOK_MODULE,
_clean_codex_env,
_codex_cli_version,
_codex_home_config_source_from_env,
@@ -45,6 +45,10 @@ from omnigent.inner.codex_executor import (
_find_codex_cli,
_populate_codex_home_config,
_provider_codex_config_overrides,
codex_router_bridge_dir,
codex_router_hooks_settings,
codex_router_session_id,
write_codex_hooks_file,
)
from omnigent.inner.databricks_executor import _databricks_gateway_host
@@ -52,6 +56,9 @@ _logger = logging.getLogger(__name__)
CodexMessage = dict[str, Any]
CodexParams = dict[str, Any]
# A bound app-server JSON-RPC request coroutine (``client.request`` or the
# SDK executor's ``_request``), so the trust helpers work over either transport.
CodexRequestFn = Callable[[str, CodexParams], Awaitable[CodexMessage]]
_CONNECT_RETRY_DELAY_SECONDS = 0.05
_CONNECT_TIMEOUT_SECONDS = 10.0
@@ -89,9 +96,9 @@ _TRUSTED_HOOK_STATUSES = frozenset({"trusted", "managed"})
# warning rather than crash startup on an un-trustable hook.
_MIN_POLICY_HOOK_CODEX_VERSION = (0, 129, 0)
# Minimum codex CLI version that accepts ``--dangerously-bypass-hook-trust``.
# Added in openai/codex PR #21768, shipped in rust-v0.131.0 (2026-05-18).
# Below this the flag is unknown and codex exits immediately with an error,
# so we skip it and fall back to the old behaviour (trust prompt may appear).
# Older binaries exit immediately on the unknown flag, so below this floor
# (including a version we could not parse) the flag is omitted and the
# interactive trust prompt may appear instead.
_MIN_BYPASS_HOOK_TRUST_CODEX_VERSION = (0, 131, 0)
@@ -595,6 +602,7 @@ class CodexNativeAppServer:
process_registry_tag: str | None = None
process_owner_lock: CodexNativeProcessOwnerLock | None = None
codex_cli_version: tuple[int, int, int] | None = None
router_hooks_registered: bool = False
async def start(self) -> None:
"""
@@ -607,9 +615,31 @@ class CodexNativeAppServer:
if self.listen_url is None or self.listen_url.startswith("unix://"):
with contextlib.suppress(FileNotFoundError):
self.socket_path.unlink()
# Native policy enforcement needs codex's hook-trust protocol
# (``currentHash`` / ``trustStatus`` in ``hooks/list``), added in
# codex 0.129. Below that the hook can never be trusted, so
# registering it would only fail at the trust gate. Probed before
# the home is populated: on an unsupported codex no hooks file is
# generated at all, so the user's hooks.json must still be
# symlinked in rather than left missing. A version we cannot parse
# (``None``) is treated as supported so a flaky probe never
# silently disables enforcement — a genuine trust failure is then
# caught below.
codex_version = await _codex_cli_version(self.codex_path)
self.codex_cli_version = codex_version
policy_hooks_supported = (
codex_version is None or codex_version >= _MIN_POLICY_HOOK_CODEX_VERSION
)
# When the runner advertises a route-subagent endpoint, the generated
# hooks file owns hooks.json, so the user's copy is merged in rather
# than symlinked over.
router_bridge_dir = codex_router_bridge_dir(self.env)
self.router_hooks_registered = router_bridge_dir is not None and policy_hooks_supported
config_source = _codex_home_config_source_from_env()
_populate_codex_home_config(
self.codex_home,
_codex_home_config_source_from_env(),
config_source,
subagent_routing=self.router_hooks_registered,
)
# Write the MCP server config into config.toml so the app-server
# discovers it at config load. The -c overrides may not be honored
@@ -621,18 +651,7 @@ class CodexNativeAppServer:
self.codex_home,
self.developer_instructions,
)
# Native policy enforcement needs codex's hook-trust protocol
# (``currentHash`` / ``trustStatus`` in ``hooks/list``), added in
# codex 0.129. Below that the hook can never be trusted, so
# registering it would only fail at the trust gate. Detect the
# version up front; below the minimum we skip registration and
# degrade to "no enforcement" with a surfaced reason. A version we
# cannot parse (``None``) is treated as supported so a flaky probe
# never silently disables enforcement — a genuine trust failure is
# then caught below.
codex_version = await _codex_cli_version(self.codex_path)
self.codex_cli_version = codex_version
if codex_version is not None and codex_version < _MIN_POLICY_HOOK_CODEX_VERSION:
if codex_version is not None and not policy_hooks_supported:
self._disable_policy_hook(
f"Codex CLI {_format_codex_version(codex_version)} is older than "
f"{_format_codex_version(_MIN_POLICY_HOOK_CODEX_VERSION)}; upgrade "
@@ -646,7 +665,12 @@ class CodexNativeAppServer:
# ap_server_url the hook is still registered + trusted but
# no-ops.
_write_codex_policy_hooks_file(
self.codex_home, self.bridge_dir, self.python_executable
self.codex_home,
self.bridge_dir,
self.python_executable,
router_bridge_dir=router_bridge_dir,
router_session_id=codex_router_session_id(self.env),
user_hooks_source=config_source / _CODEX_HOOKS_FILE,
)
if self.ap_server_url:
write_policy_hook_config(
@@ -699,6 +723,13 @@ class CodexNativeAppServer:
self._stderr_loop(),
name="codex-native-app-server-stderr",
)
# Ordering invariant: hooks.json is written before the spawn above,
# and the trust handshake must complete before the first turn — codex
# resolves trust when it dispatches a hook, so trust landing after the
# spawn is fine, but a turn started before it runs unhooked. The
# handshake cannot precede the spawn (``hooks/list`` is an app-server
# RPC), so callers must not launch the TUI or dispatch a turn until
# ``start()`` returns.
# Readiness failure (the app-server never came up) is fatal and
# tears down the subprocess so it is not orphaned. Policy-hook
# trust, by contrast, is best-effort: a trust failure degrades the
@@ -748,6 +779,18 @@ class CodexNativeAppServer:
await client.connect()
try:
await trust_native_policy_hooks(client, cwd=str(self.cwd))
# Routing hooks live in the same generated file but under a
# different module, so they need their own trust pass. Best
# effort: a routing-trust failure must not disable the policy
# gate, and the canary watcher surfaces it as a session warning.
if self.router_hooks_registered:
try:
await trust_codex_router_hooks(client.request, cwd=str(self.cwd))
except Exception: # noqa: BLE001 - routing trust never blocks startup
_logger.warning(
"codex subagent-routing hook trust failed; routing will not be enforced",
exc_info=True,
)
except RuntimeError as exc:
raise RuntimeError(f"{exc}{self._codex_config_error_hint()}") from exc
finally:
@@ -906,17 +949,33 @@ def _codex_policy_hook_command(bridge_dir: Path, python_executable: str | None)
"""
Build the shell command codex runs for the policy hook.
Runs python in isolated mode (``-I``): codex executes hooks with the
session's workspace as cwd, and ``-m`` would otherwise put that
workspace first on ``sys.path``. A workspace holding a directory named
like one of our packages (the omnigent checkout itself, most obviously)
then shadows the installed one and the hook dies on an import error
that codex discards — a silent fail-open. Mirrors the ``-I`` the
bridge's MCP server command already uses.
:param bridge_dir: Native Codex bridge directory passed to the hook
via ``--bridge-dir``.
:param python_executable: Python executable to run, e.g.
``"/path/to/python"``. ``None`` uses :data:`sys.executable`.
:returns: A shell-escaped command string, e.g.
``"/path/python -m omnigent.codex_native_hook evaluate-policy
``"/path/python -I -m omnigent.codex_native_hook evaluate-policy
--bridge-dir /home/u/.omnigent/codex-native/abc"``.
"""
python = python_executable or sys.executable
return shlex.join(
[python, "-m", _POLICY_HOOK_MODULE, "evaluate-policy", "--bridge-dir", str(bridge_dir)]
[
python,
"-I",
"-m",
_POLICY_HOOK_MODULE,
"evaluate-policy",
"--bridge-dir",
str(bridge_dir),
]
)
@@ -953,92 +1012,65 @@ def _codex_policy_hooks_settings(
}
def _merge_user_hooks(policy_payload: dict[str, Any], user_hooks_path: Path) -> dict[str, Any]:
"""
Merge user-declared hooks into the policy hooks payload.
When a symlinked ``hooks.json`` exists in the private ``CODEX_HOME``
(the user's real ``~/.codex/hooks.json``), its hook entries are
appended after Omnigent's policy hooks for each shared event, and any
events declared only by the user are added wholesale. This preserves
all user hooks while keeping the Omnigent policy hooks in first
position so they always run before user hooks.
:param policy_payload: The ``hooks.json``-shaped dict built by
:func:`_codex_policy_hooks_settings`.
:param user_hooks_path: Path to the user's real ``hooks.json``; must
be readable.
:returns: Merged payload, or *policy_payload* unchanged on any read
or parse error (best-effort — policy enforcement must never fail
because the user's hooks file is malformed).
"""
try:
user_data = json.loads(user_hooks_path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return policy_payload
user_hooks: dict[str, Any] = user_data.get("hooks", {}) if isinstance(user_data, dict) else {}
if not user_hooks:
return policy_payload
merged: dict[str, Any] = dict(policy_payload)
merged["hooks"] = dict(policy_payload["hooks"])
for event, entries in user_hooks.items():
if not isinstance(entries, list):
continue
if event in merged["hooks"]:
merged["hooks"][event] = list(merged["hooks"][event]) + entries
else:
merged["hooks"][event] = entries
return merged
def _write_codex_policy_hooks_file(
codex_home: Path, bridge_dir: Path, python_executable: str | None
codex_home: Path,
bridge_dir: Path,
python_executable: str | None,
*,
router_bridge_dir: Path | None = None,
router_session_id: str | None = None,
user_hooks_source: Path | None = None,
) -> None:
"""
Write ``hooks.json`` into the private CODEX_HOME (atomically).
When ``_populate_codex_home_config`` has symlinked the user's
``hooks.json`` into the private home, its entries are merged into the
policy hooks payload before the file is written so user hooks fire
alongside Omnigent's policy hooks. The symlink is replaced by a
regular merged file.
This file is the only ``hooks.json`` codex loads, so the policy hooks,
the subagent-routing hooks and the user's own hooks all go through the
shared :func:`write_codex_hooks_file` into one payload — written
separately, whichever ran last would erase the other.
:param codex_home: Private per-session ``CODEX_HOME`` directory.
:param bridge_dir: Native Codex bridge directory for the hook command.
:param python_executable: Python executable for the hook command.
:param router_bridge_dir: Directory advertising the route-subagent
endpoint. ``None`` leaves native subagent spawns unrouted.
:param router_session_id: Session id baked into the routing hook
commands.
:param user_hooks_source: The user's real ``hooks.json`` to merge when
the private home holds no symlink to it (the routing path unlinks
it before this runs).
:returns: None.
"""
codex_home.mkdir(mode=0o700, parents=True, exist_ok=True)
path = codex_home / _CODEX_HOOKS_FILE
payload = _codex_policy_hooks_settings(bridge_dir, python_executable)
if path.is_symlink() and path.exists():
payload = _merge_user_hooks(payload, path.resolve())
path.unlink()
fd, tmp_name = tempfile.mkstemp(prefix=f"{_CODEX_HOOKS_FILE}.", dir=str(codex_home))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, sort_keys=True)
handle.write("\n")
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
payloads: list[Mapping[str, Any]] = [
_codex_policy_hooks_settings(bridge_dir, python_executable)
]
if router_bridge_dir is not None:
payloads.append(
codex_router_hooks_settings(
router_bridge_dir,
session_id=router_session_id,
harness="codex-native",
python_executable=python_executable,
)
)
_ = write_codex_hooks_file(codex_home, payloads, user_hooks_source=user_hooks_source)
def _our_policy_hooks_from_list(listed: dict[str, Any], cwd: str) -> list[dict[str, Any]]:
def _our_hooks_from_list(listed: dict[str, Any], cwd: str, module: str) -> list[dict[str, Any]]:
"""
Extract *our* policy hooks for *cwd* from a ``hooks/list`` response.
Extract the hooks for *cwd* whose command runs *module*.
Filters to hooks whose command references :data:`_POLICY_HOOK_MODULE`
so the trust step never touches hooks the user's symlinked
``config.toml`` might declare.
Filtering by module keeps the trust step from ever touching hooks the
user's own ``hooks.json`` contributed to the merged file.
:param listed: Parsed ``hooks/list`` response envelope, with
``result.data`` a list of ``{cwd, hooks: [...]}`` entries.
:param cwd: The cwd whose hook set to read, e.g.
``"/home/user/repo"``.
:returns: The matching Omnigent hook metadata dicts (possibly
empty), each with ``key``, ``currentHash``, ``trustStatus``.
:param module: Hook-script module marker, e.g.
``"omnigent.codex_native_hook"``.
:returns: The matching hook metadata dicts (possibly empty), each
with ``key``, ``currentHash``, ``trustStatus``.
"""
result = listed.get("result", listed)
data = result.get("data", []) if isinstance(result, dict) else []
@@ -1046,13 +1078,23 @@ def _our_policy_hooks_from_list(listed: dict[str, Any], cwd: str) -> list[dict[s
if isinstance(entry, dict) and entry.get("cwd") == cwd:
hooks = entry.get("hooks", [])
return [
h
for h in hooks
if isinstance(h, dict) and _POLICY_HOOK_MODULE in str(h.get("command", ""))
h for h in hooks if isinstance(h, dict) and module in str(h.get("command", ""))
]
return []
def _our_policy_hooks_from_list(listed: dict[str, Any], cwd: str) -> list[dict[str, Any]]:
"""
Extract *our* policy hooks for *cwd* from a ``hooks/list`` response.
:param listed: Parsed ``hooks/list`` response envelope.
:param cwd: The cwd whose hook set to read, e.g.
``"/home/user/repo"``.
:returns: The matching Omnigent policy-hook metadata dicts.
"""
return _our_hooks_from_list(listed, cwd, _POLICY_HOOK_MODULE)
def _hooks_list_diagnostics(listed: dict[str, Any], cwd: str) -> str:
"""
Summarize a ``hooks/list`` response for a discovery-failure error.
@@ -1119,6 +1161,106 @@ def _untrusted_hook_detail(hooks: list[dict[str, Any]]) -> str:
)
async def _persist_hook_trust(request: CodexRequestFn, untrusted: list[dict[str, Any]]) -> None:
"""
Write ``hooks.state.<key>.trusted_hash`` for each untrusted hook.
Persisted trust is the *only* mechanism that makes a hook run under
``codex app-server``: the ``--dangerously-bypass-hook-trust`` CLI flag
is honored by the interactive/exec paths only, so app-server threads
silently skip anything left ``untrusted``.
:param request: Bound app-server JSON-RPC request coroutine, e.g.
``client.request``.
:param untrusted: Hook metadata dicts from ``hooks/list`` carrying
``key`` and ``currentHash``.
:returns: None.
"""
trust_value = {
str(h["key"]): {"trusted_hash": h["currentHash"]}
for h in untrusted
if h.get("key") and h.get("currentHash")
}
if not trust_value:
return
await request(
"config/batchWrite",
{
"edits": [
{
"keyPath": "hooks.state",
"mergeStrategy": "upsert",
"value": trust_value,
}
],
"reloadUserConfig": True,
},
)
async def trust_codex_router_hooks(request: CodexRequestFn, *, cwd: str) -> list[str]:
"""
Trust the generated subagent-routing hooks so codex runs them.
Codex skips untrusted hooks without a word, which for the routing gate
is a fail-open, and app-server threads honor persisted trust only (the
``--dangerously-bypass-hook-trust`` flag covers the interactive /
``exec`` paths, not this one), so the handshake is the only way in.
The routing gate (``PreToolUse`` on the spawn tool), the
``SessionStart`` canary and the ``SubagentStart`` audit live in the
same generated ``hooks.json`` as the policy hook but under a different
module, so the policy trust pass leaves them ``untrusted``. Same
``hooks/list`` → ``config/batchWrite`` flow, but best-effort: a
routing-trust failure must not disable policy enforcement, so it is
reported instead of raised (the canary watcher then surfaces the
session warning).
:param request: Bound app-server JSON-RPC request coroutine, e.g.
``client.request`` (or the SDK executor's ``_request``).
:param cwd: The session cwd the hooks are scoped to, e.g.
``"/home/user/repo"``.
:returns: Keys of routing hooks still untrusted afterwards; empty when
every routing hook is trusted (or none are registered).
"""
listed = await request("hooks/list", {"cwds": [cwd]})
ours = _our_hooks_from_list(listed, cwd, _CODEX_ROUTER_HOOK_MODULE)
if not ours:
_logger.info(
"codex subagent-routing hooks: none discovered for cwd %s (%s)",
cwd,
_hooks_list_diagnostics(listed, cwd),
)
return []
untrusted = [h for h in ours if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES]
if not untrusted:
_logger.info(
"codex subagent-routing hooks: all %d already trusted for cwd %s", len(ours), cwd
)
return []
await _persist_hook_trust(request, untrusted)
relisted = await request("hooks/list", {"cwds": [cwd]})
still_untrusted = [
h
for h in _our_hooks_from_list(relisted, cwd, _CODEX_ROUTER_HOOK_MODULE)
if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES
]
if still_untrusted:
_logger.warning(
"codex subagent-routing hooks still untrusted after config/batchWrite; "
"native subagent routing will NOT be enforced: %s",
_untrusted_hook_detail(still_untrusted),
)
return [str(h.get("key")) for h in still_untrusted]
_logger.info(
"codex subagent-routing hooks trusted (%d of %d newly): %s",
len(untrusted),
len(ours),
", ".join(sorted(str(h.get("eventName")) for h in ours)),
)
return []
async def trust_native_policy_hooks(client: CodexAppServerClient, *, cwd: str) -> None:
"""
Trust the Omnigent policy hook so codex actually runs it.
@@ -1149,24 +1291,7 @@ async def trust_native_policy_hooks(client: CodexAppServerClient, *, cwd: str) -
untrusted = [h for h in ours if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES]
if not untrusted:
return
trust_value = {
str(h["key"]): {"trusted_hash": h["currentHash"]}
for h in untrusted
if h.get("key") and h.get("currentHash")
}
await client.request(
"config/batchWrite",
{
"edits": [
{
"keyPath": "hooks.state",
"mergeStrategy": "upsert",
"value": trust_value,
}
],
"reloadUserConfig": True,
},
)
await _persist_hook_trust(client.request, untrusted)
relisted = await client.request("hooks/list", {"cwds": [cwd]})
still_untrusted = [
h
@@ -1364,6 +1489,44 @@ def codex_session_meta_model_provider(launch: NativeCodexLaunch) -> str:
return "openai"
def native_codex_launch_base_url(launch: NativeCodexLaunch) -> str | None:
"""Inference base URL a resolved launch pins, or None when it defers to Codex's own login.
Mirrors how the launch is actually applied: the Databricks-profile branch of
:func:`build_native_codex_app` derives the base URL from the profile host,
while a generic provider carries it inside the generated
``model_providers.…`` config override.
:param launch: Resolved native-Codex launch, e.g. one returned by
:func:`resolve_native_codex_launch`.
:returns: The base URL the launch routes through, or ``None`` when the
launch pins none.
"""
if launch.profile is not None:
host = _databricks_gateway_host(launch.profile)
if not host:
return None
return _databricks_codex_base_url(host.rstrip("/"))
for override in launch.config_overrides:
_, sep, table = override.partition("=")
if not sep or not override.startswith("model_providers."):
continue
marker = "base_url="
index = table.find(marker)
if index < 0:
continue
decoder = json.JSONDecoder()
try:
base_url, _ = decoder.raw_decode(table[index + len(marker) :])
except ValueError:
continue
if isinstance(base_url, str):
return base_url
# A cli-config entry pins only a provider *name*; its table lives in the
# user's ~/.codex/config.toml, which this process does not read.
return None
def _codex_provider_launch(entry: ProviderEntry, model: str | None) -> NativeCodexLaunch | None:
"""Build a native-Codex launch that routes through a single provider entry.
+64 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import secrets
import sys
import tempfile
@@ -341,6 +342,52 @@ def read_codex_config_model(bridge_dir: Path) -> str | None:
return model if isinstance(model, str) and model else None
def write_codex_config_model(bridge_dir: Path, model: str) -> bool:
"""
Upsert the top-level ``model`` key in this session's Codex ``config.toml``.
Companion writer to :func:`read_codex_config_model`, used when Omnigent
itself switches the running thread's model (web picker / intelligent
routing via ``thread/settings/update``). That RPC changes the live thread
but does NOT touch ``config.toml`` — while the forwarder's mirror and the
cost-gate hook both treat ``config.toml`` as the source of truth. Without
this write, the next ``turn/started`` re-reads the stale launch model and
mirrors it back to Omnigent as an ``external_model_change``, silently
reverting the switch. Writing the same top-level key an in-TUI ``/model``
writes keeps every reader consistent; a later in-TUI switch simply
overwrites it (last-wins, as for user switches).
Best-effort: an unreadable/unwritable file returns ``False`` — the live
thread already runs the new model, so failing the turn over a mirror
file would be worse than a temporarily stale mirror.
:param bridge_dir: The session's native-Codex bridge directory.
:param model: Model id to record, e.g. ``"gpt-5.6-luna"``.
:returns: ``True`` when the file was updated.
"""
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
pin_line = f"model = {json.dumps(model)}"
try:
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
lines = existing.splitlines()
replaced = False
for i, line in enumerate(lines):
# Only the top-level table: stop at the first [section] header.
if line.startswith("["):
break
if re.match(r"^model\s*=", line):
lines[i] = pin_line
replaced = True
break
if not replaced:
lines.insert(0, pin_line)
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
except OSError:
return False
return True
def write_bridge_state(bridge_dir: Path, state: CodexNativeBridgeState) -> None:
"""
Persist shared native Codex state atomically.
@@ -383,10 +430,26 @@ def clear_bridge_state(bridge_dir: Path) -> None:
its current transport and thread instead of injecting into stale
state.
The subagent-routing canary and spawn audit are cleared for the same
reason: both are evidence about *this* launch, checked against the
routing decisions this launch's endpoint relayed. A canary left by an
earlier launch would mask a genuine fail-open (codex skipping
untrusted hooks), and a leftover audit line — whose approving decision
lives in the previous launch's router — reads as a spawn the router
never approved.
:param bridge_dir: Native Codex bridge directory.
:returns: None.
"""
for name in (_STATE_FILE, _STARTUP_ERROR_FILE, _MCP_STARTUP_FILE):
from omnigent.inner.hook_scripts.codex_router_hook import AUDIT_FILENAME, CANARY_FILENAME
for name in (
_STATE_FILE,
_STARTUP_ERROR_FILE,
_MCP_STARTUP_FILE,
CANARY_FILENAME,
AUDIT_FILENAME,
):
try:
(bridge_dir / name).unlink()
except FileNotFoundError:
+263 -13
View File
@@ -382,6 +382,12 @@ class _CodexForwarderState:
model: str | None = None
posted_model: str | None = None
# The running thread's authoritative model, from a live
# ``thread/settings/updated``; beats a stale config.toml re-read.
settings_model: str | None = None
# The config.toml model as of the last _refresh_model_from_config read,
# so the refresh can tell an unchanged file from a rewritten one.
last_config_model: str | None = None
effort: str | None = None
posted_effort: str | None = None
posted_effort_known: bool = False
@@ -457,6 +463,13 @@ class _CodexForwarderState:
self._note_effort_fields(settings)
self._note_collaboration_mode_fields(settings)
self._note_approval_mode_fields(settings)
# Live thread settings are the running process's truth: remember
# the model so a stale config.toml re-read at the next
# turn/started cannot roll the mirror back (see
# _refresh_model_from_config).
model = settings.get("model")
if isinstance(model, str) and model:
self.settings_model = model
def record_completed_plan(self, params: dict[str, Any]) -> None:
"""
@@ -1739,6 +1752,19 @@ async def supervise_forwarder(
# outage or restart). Runs before live forwarding begins, so no
# other writer races the dead-letter files (#1579).
await _replay_dead_letters_on_startup(ap_client, bridge_dir)
# Surface "routing hooks never ran" / "spawned a model the router
# didn't approve" on the session's warning channel. Held until the
# first turn, which is when codex dispatches SessionStart (and so
# when the routing canary can exist at all).
turn_observed = asyncio.Event()
_enforcement_task = asyncio.create_task(
_watch_subagent_routing_enforcement(
ap_client, session_id, bridge_dir, turn_observed=turn_observed
),
name=f"codex-routing-enforcement-{session_id}",
)
_ENFORCEMENT_TASKS.add(_enforcement_task)
_enforcement_task.add_done_callback(_ENFORCEMENT_TASKS.discard)
# Synthesize the thread's MCP startup round (see the comment on
# _CODEX_MCP_STARTUP_STATUS_METHOD): the fresh-launch forwarder
# starts right at thread creation, which is when codex boots its
@@ -1817,6 +1843,13 @@ async def supervise_forwarder(
# waiting forever on an idle fresh thread.
if not thread_active.is_set() and _event_indicates_thread_active(event):
thread_active.set()
# The routing-enforcement watcher needs a stricter signal:
# codex dispatches SessionStart when a turn begins, so only
# a turn-start event proves the canary should exist by now.
# Thread-active alone also covers the MCP startup round,
# which activates the thread without running a turn.
if not turn_observed.is_set() and _event_indicates_turn_started(event):
turn_observed.set()
await _handle_event(
ap_client,
session_id=target.session_id,
@@ -1842,6 +1875,11 @@ async def supervise_forwarder(
subscribe_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await subscribe_task
# A session that never took a turn leaves the watcher parked on
# ``turn_observed``, where closing the client cannot reach it.
_enforcement_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _enforcement_task
await client.close()
@@ -2166,6 +2204,23 @@ def _event_indicates_thread_active(event: CodexMessage) -> bool:
return False
def _event_indicates_turn_started(event: CodexMessage) -> bool:
"""
Return whether a notification proves a turn has actually begun.
Codex dispatches ``SessionStart`` (the routing canary) when a thread's
first *turn* starts. ``thread/status/changed active`` and ``item/*``
are weaker: the MCP startup round activates a thread and emits items
without any turn, so using them to release the routing-enforcement
watcher flags a session that has simply not been asked anything yet.
:param event: A Codex JSON-RPC notification envelope.
:returns: ``True`` for a ``turn/*`` notification.
"""
method = event.get("method")
return isinstance(method, str) and method.startswith("turn/")
def _is_thread_not_ready_error(exc: Exception) -> bool:
"""
Return whether a subscription failure is Codex's fresh-thread not-ready gap.
@@ -2702,26 +2757,39 @@ async def _maybe_handle_codex_request(
def _refresh_model_from_config(bridge_dir: Path, forwarder_state: _CodexForwarderState) -> None:
"""
Update the forwarder's known model from this session's ``config.toml``.
Update the forwarder's known model from config.toml and thread settings.
Reads the source-of-truth model via the shared
:func:`~omnigent.codex_native_bridge.read_codex_config_model` (the
``model`` key an in-TUI ``/model`` writes see that function for why
config.toml is the source of truth and its caveats) and stores it on
``forwarder_state.model`` so a following ``_sync_model_change`` mirrors
it to Omnigent as ``model_override``. This mirror is a fallback to the codex
hook, which stamps the live model onto the evaluation request at gate
time; the gate prefers the hook's value. No-op when the model can't be
determined, leaving the prior value.
Reads the ``model`` key an in-TUI ``/model`` writes via the shared
:func:`~omnigent.codex_native_bridge.read_codex_config_model` and stores
the freshest value on ``forwarder_state.model`` so a following
``_sync_model_change`` mirrors it to Omnigent as ``model_override``. This
mirror is a fallback to the codex hook, which stamps the live model onto
the evaluation request at gate time; the gate prefers the hook's value.
Precedence: a config.toml value that CHANGED since the last read wins
(an in-TUI ``/model`` or the executor's mirror write — the freshest
signal). An unchanged config defers to the last live
``thread/settings/updated`` model when one was seen: an
Omnigent-initiated ``thread/settings/update`` switches the running
thread without touching config.toml, so re-adopting the stale file
would revert a routed model one turn after it applied. No-op when
nothing is known, leaving the prior value.
:param bridge_dir: The session's native-Codex bridge directory.
:param forwarder_state: Mutable forwarder state whose ``model`` is
updated in place.
:returns: None.
"""
model = read_codex_config_model(bridge_dir)
if model:
forwarder_state.model = model
config_model = read_codex_config_model(bridge_dir)
config_changed = bool(config_model) and config_model != forwarder_state.last_config_model
if config_model:
forwarder_state.last_config_model = config_model
if config_changed:
forwarder_state.model = config_model
elif forwarder_state.settings_model:
forwarder_state.model = forwarder_state.settings_model
elif config_model:
forwarder_state.model = config_model
async def _sync_model_change(
@@ -5667,6 +5735,188 @@ async def _post_external_item(
)
# Strong refs for the per-session enforcement watchers; the forwarder
# cancels its own task when it stops.
_ENFORCEMENT_TASKS: set[asyncio.Task[None]] = set()
async def _post_session_warnings(
client: httpx.AsyncClient,
session_id: str,
warnings: list[dict[str, Any]],
) -> None:
"""
Publish session-scoped warnings so the chat header can show them.
An empty list is posted too: the server reads it as "condition
repaired" and clears the banner, so skipping it would leave a stale
warning up for the rest of the session.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param warnings: Warning payloads, each ``{"code", "harness", "reason"}``.
:returns: None.
"""
response = await _post_session_event(
client,
session_id,
event_type="external_session_warning",
data={"warnings": warnings},
)
_log_failed_session_event_post("external_session_warning", response)
def subagent_routing_armed(bridge_dir: Path) -> bool:
"""
Report whether subagent routing was set up for this session.
Presence of the router advertisement in the bridge dir is the signal:
the runner writes it exactly when it wires the ``route-subagent``
endpoint, which is also when the routing hooks are generated. This must
not be inferred from *relayed decisions* the routing gate is the thing
that relays them, so gating on decisions makes "the gate never ran"
undetectable.
"Armed" is not "expected to enforce": hooks are installed on every
native session so the setting can be flipped mid-session, so a posted
warning is re-checked against the session's *effective* subagent-routing
state where it reaches a client (see
``_visible_session_warnings`` in the server's session orchestration).
:param bridge_dir: Native Codex bridge directory.
:returns: ``True`` when the router advertisement is present.
"""
from omnigent.runner.subagent_routing import ADVERTISEMENT_FILE
return (bridge_dir / ADVERTISEMENT_FILE).is_file()
def subagent_routing_warnings(session_id: str, bridge_dir: Path) -> list[dict[str, Any]]:
"""
Report why this session's subagent routing is not being enforced.
Two independent checks: the ``SessionStart`` canary (absent means codex
skipped the generated hooks, so no spawn is gated at all), and the
``SubagentStart`` audit (a spawn that started on a model the router
never approved).
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param bridge_dir: Native Codex bridge directory holding the canary and
audit files.
:returns: Warning payloads; empty when routing is off for this session
or enforcement is intact.
"""
from omnigent.inner.codex_executor import (
codex_router_canary_fired,
read_codex_spawn_audit,
reconcile_spawn_audit,
subagent_routing_unenforced_warning,
)
from omnigent.runner.subagent_routing import relayed_decisions
if not subagent_routing_armed(bridge_dir):
# Routing is off for this session — no enforcement claim to check.
return []
if not codex_router_canary_fired(bridge_dir):
return [
subagent_routing_unenforced_warning(
"SessionStart canary did not fire; codex did not run the generated "
"routing hooks (untrusted, or the hook command failed)."
)
]
return reconcile_spawn_audit(read_codex_spawn_audit(bridge_dir), relayed_decisions(session_id))
async def _watch_subagent_routing_enforcement(
client: httpx.AsyncClient,
session_id: str,
bridge_dir: Path,
*,
interval_s: float = 30.0,
turn_observed: asyncio.Event | None = None,
) -> None:
"""
Re-check subagent-routing enforcement while the session runs.
Both signals are written by codex asynchronously (the canary at session
start, audit lines as spawns happen), so a single check at forwarder
startup would miss most of them. Only *transitions* are posted: an
unchanged verdict is already on the server (warnings dedupe there on
``(code, harness)``), and re-posting an empty list every tick meant a
healthy session issued a clear-everything request every 30s forever.
The "repaired" clear is posted exactly once, on the edge back to
healthy.
Codex dispatches ``SessionStart`` when a thread's *first turn* begins,
not at ``thread/start``, so checking before then would flag every idle
session. *turn_observed* holds the first check until the forwarder has
seen a ``turn/*`` event thread activity alone also covers the MCP
startup round, which runs no turn and so dispatches no ``SessionStart``.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id.
:param bridge_dir: Native Codex bridge directory.
:param interval_s: Seconds between checks.
:param turn_observed: Set by the forwarder on the thread's first
turn-start event. ``None`` checks immediately (tests / resumed
sessions).
:returns: None. Runs until cancelled.
"""
armed = subagent_routing_armed(bridge_dir)
_logger.info(
"subagent routing enforcement watcher started for session %s (armed=%s, interval=%ss)",
session_id,
armed,
interval_s,
)
if turn_observed is not None:
await turn_observed.wait()
# ``None`` until the first check: the server has no verdict yet, so the
# first result is always a transition (including a healthy one, which
# clears anything a previous forwarder left behind).
posted: list[dict[str, Any]] | None = None
while not client.is_closed:
await asyncio.sleep(interval_s)
if client.is_closed:
return
try:
warnings = subagent_routing_warnings(session_id, bridge_dir)
if posted is not None and _same_warnings(posted, warnings):
_logger.debug("subagent routing enforcement unchanged for session %s", session_id)
continue
if warnings:
_logger.info(
"posting subagent routing warnings for session %s: %s",
session_id,
[w.get("code") for w in warnings],
)
else:
_logger.info("subagent routing enforcement repaired for session %s", session_id)
await _post_session_warnings(client, session_id, warnings)
posted = warnings
except (httpx.HTTPError, OSError):
_logger.debug("subagent routing enforcement check failed", exc_info=True)
def _same_warnings(left: list[dict[str, Any]], right: list[dict[str, Any]]) -> bool:
"""Report whether two warning lists describe the same state.
Compared on the fields the server dedupes and renders on, so a changed
timestamp or field order is not a transition.
:param left: Previously posted warnings.
:param right: Freshly computed warnings.
:returns: ``True`` when the two carry the same warnings.
"""
def _key(warnings: list[dict[str, Any]]) -> list[tuple[str, str, str]]:
return sorted(
(str(w.get("code")), str(w.get("harness")), str(w.get("reason"))) for w in warnings
)
return _key(left) == _key(right)
async def _post_status(
client: httpx.AsyncClient,
session_id: str,
+68
View File
@@ -0,0 +1,68 @@
"""Canonical predicate for recognizing a Databricks AI Gateway base URL.
Several surfaces need the same answer — pi-native rewrites a gateway Codex
base URL to the Anthropic surface, and host-side routing capability checks ask
whether a resolved harness launch is gateway-backed. Keeping one predicate here
means a look-alike host is rejected identically everywhere.
"""
from __future__ import annotations
from typing import Final
from urllib.parse import urlparse
# Trusted parent domain suffixes for a Databricks-owned host. The AI Gateway
# lives under a per-workspace subdomain of one of these (the canonical form is
# ``<workspace>.ai-gateway.cloud.databricks.com``); the Azure / GCP control
# planes serve workspaces under their own parent domains. We anchor on the
# leading "." so a look-alike like ``...cloud.databricks.com.evil.test`` (which
# ends in ``.evil.test``) is rejected.
DATABRICKS_TRUSTED_HOST_SUFFIXES: Final[tuple[str, ...]] = (
".cloud.databricks.com", # AWS workspaces + ai-gateway (incl. *.staging.cloud.databricks.com)
".azuredatabricks.net", # Azure Databricks
".gcp.databricks.com", # GCP Databricks
)
# A genuine AI Gateway host carries the ``ai-gateway`` DNS label; we require it
# (alongside a trusted suffix) so a non-gateway Databricks host isn't routed as
# the gateway's Anthropic surface.
DATABRICKS_AI_GATEWAY_LABEL: Final[str] = "ai-gateway"
def is_databricks_ai_gateway_url(base_url: str) -> bool:
"""Return ``True`` only for a genuine Databricks AI Gateway base URL.
Two URL shapes are accepted:
1. **Dedicated AI Gateway subdomain** — ``ai-gateway`` is a full DNS label
in the hostname (e.g. ``<id>.ai-gateway.cloud.databricks.com``). Used by
the standard ``isaac configure codex`` setup.
2. **Workspace-hosted gateway** — the hostname is a plain Databricks
workspace (ends with a trusted suffix) and the path starts with
``/ai-gateway/`` (e.g. ``<workspace>.cloud.databricks.com/ai-gateway/...``).
Used by ucode / Codex app profile setups.
Both cases require ``https`` and a hostname ending with a trusted
Databricks-owned domain suffix to prevent token-forwarding attacks.
:param base_url: An inference base URL, e.g. the codex provider table's
``base_url``.
:returns: ``True`` iff the URL is an https Databricks AI Gateway endpoint.
"""
parsed = urlparse(base_url)
if parsed.scheme != "https":
return False
hostname = parsed.hostname
if not hostname:
return False
hostname = hostname.lower()
trusted = any(hostname.endswith(suffix) for suffix in DATABRICKS_TRUSTED_HOST_SUFFIXES)
if not trusted:
return False
# Shape 1: ``ai-gateway`` is a full DNS label in the hostname.
labels = hostname.split(".")
if DATABRICKS_AI_GATEWAY_LABEL in labels:
return True
# Shape 2: workspace hostname + /ai-gateway/ path prefix.
path = parsed.path or ""
return path.startswith("/ai-gateway/")
+177 -44
View File
@@ -4,6 +4,9 @@ from __future__ import annotations
import logging
import re
import warnings
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
import httpx
@@ -23,29 +26,92 @@ _MAX_PAGES = 100
_HTTP_TIMEOUT_S = 10.0
#: Catalog spellings the same endpoint can be served under. Ordered by
#: preference: a workspace exposing both keeps the ``databricks-`` id, so every
#: consumer (routing candidates, the model picker, the launch alias pins) names
#: a model the same way no matter which listing answered.
_CATALOG_SPELLINGS: tuple[str, ...] = ("databricks-", _SYSTEM_MODEL_PREFIX)
def _bare_model_id(model_id: str) -> str:
"""Strip the catalog spelling so ids compare across vocabularies."""
lowered = model_id.lower()
for prefix in _CATALOG_SPELLINGS:
if lowered.startswith(prefix):
return lowered[len(prefix) :]
return lowered
def _natural_model_key(model_id: str) -> tuple[tuple[int, str | int], ...]:
"""Return a comparison key that orders numeric model versions naturally."""
"""Return a comparison key that orders numeric model versions naturally.
Keyed on the bare id so the catalog spelling never outranks the version.
"""
return tuple(
(1, int(part)) if part.isdigit() else (0, part)
for part in re.split(r"(\d+)", model_id.lower())
for part in re.split(r"(\d+)", _bare_model_id(model_id))
if part
)
def _prefer_databricks_spelling(model_ids: Iterable[str]) -> list[str]:
"""Collapse duplicate spellings of one model onto the preferred one.
:param model_ids: Catalog ids from one or more listings, possibly naming
the same endpoint under two spellings.
:returns: One id per model, sorted, with ``databricks-`` winning ties.
"""
best: dict[str, str] = {}
for model_id in model_ids:
bare = _bare_model_id(model_id)
current = best.get(bare)
if current is None or _spelling_rank(model_id) < _spelling_rank(current):
best[bare] = model_id
return sorted(best.values())
def _spelling_rank(model_id: str) -> int:
"""Rank a catalog spelling; lower wins."""
lowered = model_id.lower()
for rank, prefix in enumerate(_CATALOG_SPELLINGS):
if lowered.startswith(prefix):
return rank
return len(_CATALOG_SPELLINGS)
def _claude_family_of(model_id: str, *, marker: str) -> str | None:
"""Return the Claude family *model_id* belongs to, if any."""
_, separator, suffix = model_id.lower().partition(marker)
if not separator:
return None
segments = suffix.split("-")
return next((family for family in CLAUDE_MODEL_FAMILIES if family in segments), None)
def _models_by_claude_family(model_ids: list[str], *, marker: str) -> dict[str, str]:
"""Select the newest model id for every Claude family in *model_ids*."""
result: dict[str, str] = {}
for family in CLAUDE_MODEL_FAMILIES:
candidates = []
for model_id in model_ids:
_, separator, suffix = model_id.lower().partition(marker)
if separator and family in suffix.split("-"):
candidates.append(model_id)
candidates = [
model_id
for model_id in model_ids
if _claude_family_of(model_id, marker=marker) == family
]
if candidates:
result[family] = max(candidates, key=_natural_model_key)
return result
def _all_claude_models(model_ids: list[str], *, marker: str) -> tuple[str, ...]:
"""Keep every Claude-family id in *model_ids*, newest first per family."""
claude_ids = [
model_id
for model_id in model_ids
if _claude_family_of(model_id, marker=marker) is not None
]
return tuple(sorted(claude_ids, key=_natural_model_key, reverse=True))
def _list_model_service_ids(
client: httpx.Client,
workspace_url: str,
@@ -127,6 +193,92 @@ def _list_anthropic_gateway_ids(
]
@dataclass(frozen=True)
class DatabricksClaudeCatalog:
"""Every Claude endpoint a workspace serves, plus the family picks.
:param families: Family alias → newest routable id, e.g.
``{"opus": "system.ai.claude-opus-5"}``. What the launch env pins
each Claude Code alias to.
:param model_ids: Every Claude-family id the workspace serves, newest
first, e.g. ``("system.ai.claude-opus-5",
"system.ai.claude-opus-4-8")``. A superset of ``families``: an
older generation is still servable and still routable, it just
does not own an alias.
"""
families: dict[str, str]
model_ids: tuple[str, ...]
def discover_databricks_claude_catalog(
workspace_url: str,
token: str,
*,
transport: httpx.BaseTransport | None = None,
) -> DatabricksClaudeCatalog:
"""Discover every Claude endpoint a Databricks workspace serves.
Both listings are consulted, because a workspace can serve the same
endpoint under both spellings (``system.ai.claude-opus-5`` from Unity
Catalog model services, ``databricks-claude-opus-5`` from the Anthropic AI
Gateway) and answering with whichever listing happened to succeed makes the
catalog nondeterministic. Duplicates collapse onto the ``databricks-``
spelling so every consumer names a model the same way.
The gateway listing is therefore issued even when Unity Catalog already
named Claude models — short-circuiting on the UC hit would cost one HTTP
round trip less per launch, but UC only ever spells ids ``system.ai.``, so
the spelling a consumer sees would depend on whether the (transiently
failing) UC call answered.
:param workspace_url: Workspace origin, e.g. ``"https://example.com"``.
:param token: Workspace bearer token.
:param transport: Optional HTTP transport used by tests.
:returns: The workspace's Claude catalog. Empty ``families`` with empty
``model_ids`` is authoritative: the model-services listing answered
successfully and no Claude models are exposed.
:raises httpx.HTTPError: When the primary listing fails and the fallback
cannot compensate (it fails too, or exposes no Claude models).
:raises ValueError: Same contract for malformed responses.
"""
headers = {"Authorization": f"Bearer {token}"}
primary_error: Exception | None = None
gateway_error: Exception | None = None
model_service_ids: list[str] = []
gateway_ids: list[str] = []
with httpx.Client(transport=transport, timeout=_HTTP_TIMEOUT_S) as client:
try:
model_service_ids = _list_model_service_ids(client, workspace_url, headers)
except (httpx.HTTPError, ValueError) as exc:
primary_error = exc
try:
gateway_ids = _list_anthropic_gateway_ids(client, workspace_url, headers)
except (httpx.HTTPError, ValueError) as exc:
gateway_error = exc
if primary_error is not None and gateway_error is not None:
raise gateway_error from primary_error
merged = _prefer_databricks_spelling([*model_service_ids, *gateway_ids])
models = _models_by_claude_family(merged, marker="claude-")
if models:
return DatabricksClaudeCatalog(
families=models,
model_ids=_all_claude_models(merged, marker="claude-"),
)
if primary_error is not None:
# Neither listing named a Claude model and the authoritative one failed
# — an empty result here is NOT authoritative (e.g. a transient UC 503
# plus an unused legacy gateway). Surface the primary failure so callers
# fall back to cached models instead of treating the workspace as having
# none.
raise primary_error
# A successful permission-aware UC listing is authoritative even when the
# compatibility endpoint is not enabled.
return DatabricksClaudeCatalog(families={}, model_ids=())
def discover_databricks_claude_models(
workspace_url: str,
token: str,
@@ -135,46 +287,27 @@ def discover_databricks_claude_models(
) -> dict[str, str]:
"""Discover the live Claude family mapping for a Databricks workspace.
Unity Catalog model services are authoritative when they expose Claude
models. The Anthropic AI Gateway model-list endpoint is the compatibility
fallback for workspaces that have not moved to model services yet.
.. deprecated:: 0.8.0
Use :func:`discover_databricks_claude_catalog` and read its
``families``, which also carries every servable id. Removed in
``v0.10.0``.
:param workspace_url: Workspace origin, e.g. ``"https://example.com"``.
:param token: Workspace bearer token.
:param transport: Optional HTTP transport used by tests.
:returns: Family aliases mapped to routable model ids. An empty mapping is
authoritative: at least one endpoint answered successfully and no
Claude models are exposed.
:raises httpx.HTTPError: When the primary listing fails and the fallback
cannot compensate (it fails too, or exposes no Claude models).
:raises ValueError: Same contract for malformed responses.
authoritative: the listing answered and no Claude models are exposed.
:raises httpx.HTTPError: Same contract as the catalog lookup.
:raises ValueError: Same contract as the catalog lookup.
"""
headers = {"Authorization": f"Bearer {token}"}
primary_error: Exception | None = None
with httpx.Client(transport=transport, timeout=_HTTP_TIMEOUT_S) as client:
try:
model_service_ids = _list_model_service_ids(client, workspace_url, headers)
except (httpx.HTTPError, ValueError) as exc:
primary_error = exc
else:
models = _models_by_claude_family(model_service_ids, marker="claude-")
if models:
return models
try:
gateway_ids = _list_anthropic_gateway_ids(client, workspace_url, headers)
except (httpx.HTTPError, ValueError) as exc:
if primary_error is not None:
raise exc from primary_error
# A successful permission-aware UC listing is authoritative even
# when the compatibility endpoint is not enabled.
return {}
gateway_models = _models_by_claude_family(gateway_ids, marker="databricks-claude-")
if not gateway_models and primary_error is not None:
# The gateway answered but routes no Claude models, and the primary
# listing failed — an empty result here is NOT authoritative (e.g. a
# transient UC 503 plus an unused legacy gateway). Surface the primary
# failure so callers fall back to cached models instead of treating
# the workspace as having none.
raise primary_error
return gateway_models
warnings.warn(
"discover_databricks_claude_models() is deprecated and will be removed in "
"v0.10.0; call discover_databricks_claude_catalog() and read .families.",
DeprecationWarning,
stacklevel=2,
)
return discover_databricks_claude_catalog(
workspace_url,
token,
transport=transport,
).families
+9
View File
@@ -1266,6 +1266,13 @@ class SqlHost(OmnigentBase):
host has never reported it (older host build) — unknown, not
"nothing configured". Surfaced via ``GET /v1/hosts`` so the web
agent picker can warn about unconfigured harnesses.
:param gateway_inference: JSON-encoded per-harness map of whether that
family's launch on the host resolves AI-Gateway-backed inference, e.g.
``'{"claude-native": true, "codex": false}'``. A family the host could
not evaluate is omitted; ``NULL`` means the host never reported the map
(older host build) — unknown, not "nothing is gateway-backed".
Surfaced via ``GET /v1/hosts`` so the web UI only offers Smart Routing
where the routing apply layer can work.
"""
__tablename__ = "hosts"
@@ -1295,6 +1302,8 @@ class SqlHost(OmnigentBase):
sandbox_id: Mapped[str | None] = mapped_column(String(256), nullable=True)
# Opaque; never SQL-filtered — stored compressed (CompressedText).
configured_harnesses: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
# Opaque; never SQL-filtered — stored compressed (CompressedText).
gateway_inference: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
__table_args__ = (
CheckConstraint(
@@ -0,0 +1,49 @@
"""add gateway_inference to hosts
Revision ID: d5e6f7a8b9c0
Revises: c4d5e6f7a8b9
Create Date: 2026-07-30 00:00:00.000000
Adds ``hosts.gateway_inference`` — the JSON-encoded per-harness map a host
reports alongside its readiness, recording whether that harness family's launch
on the host resolves AI-Gateway-backed inference (e.g.
``'{"claude-native": true, "codex": false}'``). A family the host could not
evaluate is omitted from the map; NULL means the host never reported the map at
all (an older host build) and is treated as unknown, never as "nothing is
gateway-backed". Surfaced via ``GET /v1/hosts`` so the web UI only offers Smart
Routing where the routing apply layer can actually rewrite the launch model.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d5e6f7a8b9c0"
down_revision: str | None = "c4d5e6f7a8b9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add the nullable ``gateway_inference`` column to ``hosts``.
Batch mode so the DDL runs on SQLite too, and so the project's
migration-safety test (which requires every schema change to go
through ``batch_alter_table``) passes.
"""
with op.batch_alter_table("hosts") as batch_op:
batch_op.add_column(sa.Column("gateway_inference", sa.Text(), nullable=True))
def downgrade() -> None:
"""Drop the ``gateway_inference`` column from ``hosts``.
Batch mode so ``DROP COLUMN`` works on SQLite (rejected by the bare
``op`` proxy pre-3.35).
"""
with op.batch_alter_table("hosts") as batch_op:
batch_op.drop_column("gateway_inference")
+31
View File
@@ -118,6 +118,13 @@ class Conversation:
``PATCH /v1/sessions/{id}`` (the web "Cost Optimized"
toggle). Read by the cost-control advisor pipeline at turn
start; mirrors the persistence shape of ``model_override``.
:param subagent_routing_override: Per-session subagent-routing
switch: ``"on"`` routes native/SDK subagent spawns, ``"off"``
leaves them on the parent's model, and ``None`` (the default)
inherits the session's main routing state (its own — or its
parent's — ``cost_control_mode_override == "on"``). Mutable via
``PATCH /v1/sessions/{id}`` at any time; read per spawn by the
route-subagent relay, so a change takes effect on the next spawn.
:param harness_override: Per-session harness override for the
bound agent's brain, e.g. ``"pi"`` or ``"openai-agents"``.
``None`` means use the harness declared in the agent spec
@@ -213,6 +220,7 @@ class Conversation:
reasoning_effort: str | None = None
model_override: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
harness_override: str | None = None
sub_agent_name: str | None = None
external_session_id: str | None = None
@@ -532,6 +540,24 @@ class RoutingDecisionData(BaseModel):
:param rationale: The router's one-line explanation, shown as muted
secondary text, e.g. ``"Multi-file refactor needs deep
reasoning."``.
:param harness: Harness the decision applies to, e.g.
``"claude-native"`` or ``"codex"``. ``None`` when the decision
picked a model only (no harness dimension).
:param scope: What the decision governs — ``"session"`` (auto-harness
session routing), ``"turn"`` (per-turn routing), ``"child_session"``
(an Omnigent-spawned sub-agent) or ``"native_subagent"`` (a Task /
``spawn_agent`` spawn routed inside the harness). Defaults to
``"turn"`` so rows persisted before this field deserialize.
:param decision_id: Router decision identifier, e.g.
``"3f1c…"``. Correlates the transcript item with the routing
telemetry event and the child-sessions API row. ``None`` for
decisions made before decision ids existed.
:param raw_model: The router-vocabulary pick before resolution to a
servable catalog id, e.g. ``"gpt-5-6-sol"``. ``None`` when the
pick needed no resolution.
:param attempted_override: Model an LLM-supplied ``args.model``
asked for and the router overrode, e.g.
``"databricks-gpt-5-5"``. ``None`` when nothing was attempted.
"""
model: str
@@ -541,6 +567,11 @@ class RoutingDecisionData(BaseModel):
#: item is being mirrored into the parent's transcript, e.g. ``"claude_code"``.
#: ``None`` for session-local routing decisions (the usual case).
agent: str | None = None
harness: str | None = None
scope: Literal["session", "turn", "child_session", "native_subagent"] = "turn"
decision_id: str | None = None
raw_model: str | None = None
attempted_override: str | None = None
@field_validator("model")
@classmethod
+93
View File
@@ -0,0 +1,93 @@
"""Host-side checks for whether a harness family's inference is AI-Gateway-backed.
Smart Routing's apply layer can only rewrite a launch's model when the launch
resolves through the Databricks AI Gateway — that is where the routable model
catalog lives. These checks answer that question per harness family from config
resolution alone: no process launch, no network round-trip, so the host can
report the answer alongside harness readiness on every registration.
"""
from __future__ import annotations
import logging
from typing import Final
_logger = logging.getLogger(__name__)
# Every spelling the Claude family travels under on the wire.
CLAUDE_GATEWAY_HARNESSES: Final[tuple[str, ...]] = ("claude-native", "native-claude")
# Every spelling the Codex family travels under on the wire.
CODEX_GATEWAY_HARNESSES: Final[tuple[str, ...]] = ("codex", "codex-native", "native-codex")
# The AI Gateway serves Codex/OpenAI-Responses under this path suffix; both
# gateway URL shapes (dedicated subdomain and workspace-hosted) end with it.
_CODEX_GATEWAY_PATH_SUFFIX = "/codex/v1"
def claude_gateway_inference_backed() -> bool:
"""Whether a claude-native launch on this host resolves gateway-backed inference.
A gateway-backed launch pins ``ANTHROPIC_BASE_URL`` and delivers its bearer
token through Claude Code's ``apiKeyHelper``. The Bedrock path sets
``ANTHROPIC_BEDROCK_BASE_URL`` with no helper, and a subscription / CLI
login resolves no config at all — neither is routable.
:returns: ``True`` iff the resolved config is AI-Gateway-backed.
"""
from omnigent.claude_native import resolve_native_claude_config
config = resolve_native_claude_config(spec=None, refresh_models=False)
if config is None:
return False
return bool(config.env.get("ANTHROPIC_BASE_URL")) and bool(config.api_key_helper)
def codex_gateway_inference_backed() -> bool:
"""Whether a codex-native launch on this host resolves gateway-backed inference.
:returns: ``True`` iff the resolved launch routes through an AI Gateway
Codex base URL.
"""
from omnigent.codex_native_app_server import (
native_codex_launch_base_url,
resolve_native_codex_launch,
)
from omnigent.databricks_ai_gateway import is_databricks_ai_gateway_url
base_url = native_codex_launch_base_url(resolve_native_codex_launch(model=None))
if not base_url:
return False
if not is_databricks_ai_gateway_url(base_url):
return False
return base_url.rstrip("/").endswith(_CODEX_GATEWAY_PATH_SUFFIX)
def gateway_inference_map() -> dict[str, bool]:
"""Per-harness map of whether this host's inference for that family is gateway-backed.
Each family is evaluated once and the result fanned out over every spelling
that family travels under. A family whose check raises is omitted rather
than reported as ``False``, so the server can tell "not gateway-backed"
apart from "could not tell".
:returns: Harness spelling → gateway-backed flag, omitting unevaluable
families.
"""
result: dict[str, bool] = {}
for family, spellings, check in (
("claude", CLAUDE_GATEWAY_HARNESSES, claude_gateway_inference_backed),
("codex", CODEX_GATEWAY_HARNESSES, codex_gateway_inference_backed),
):
try:
backed = check()
except Exception: # noqa: BLE001 — an unevaluable family is omitted, not False
_logger.warning(
"gateway-inference check for the %s family failed; omitting it",
family,
exc_info=True,
)
continue
for spelling in spellings:
result[spelling] = backed
return result
+16 -1
View File
@@ -24,6 +24,7 @@ from websockets.exceptions import InvalidStatus, InvalidURI
from omnigent._platform import WINDOWS_ENV_PASSTHROUGH
from omnigent.env_credentials import env_names_with_omnigent_prefix
from omnigent.gateway_inference import gateway_inference_map
from omnigent.harness_aliases import canonicalize_harness
from omnigent.harness_availability import HARNESS_BINARY_MISSING, HarnessAvailability
from omnigent.host.frames import (
@@ -1623,6 +1624,7 @@ class HostProcess:
request_id=frame.request_id,
status="ok",
configured_harnesses=configured_harness_map(),
gateway_inference=gateway_inference_map(),
)
installed, reason = try_install_harness_cli(key)
if not installed:
@@ -1636,6 +1638,7 @@ class HostProcess:
request_id=frame.request_id,
status="ok",
configured_harnesses=configured_harness_map(),
gateway_inference=gateway_inference_map(),
)
def _handle_store_secret(self, frame: HostStoreSecretFrame) -> HostStoreSecretResultFrame:
@@ -1737,6 +1740,7 @@ class HostProcess:
request_id=frame.request_id,
status="ok",
configured_harnesses=configured_harness_map(),
gateway_inference=gateway_inference_map(),
)
def _handle_detect_credentials(
@@ -1869,6 +1873,9 @@ class HostProcess:
request_id=frame.request_id,
status="ok",
models=models,
# The picker names the newest model of each family; the endpoint
# serves older generations too, and a launch takes an exact id.
routable_models=list(config.routable_models) if config is not None else [],
)
@staticmethod
@@ -2309,6 +2316,7 @@ class HostProcess:
except Exception: # noqa: BLE001
pass
configured_harnesses = await asyncio.to_thread(configured_harness_map)
gateway_inference = await asyncio.to_thread(gateway_inference_map)
hello = HostHelloFrame(
version=VERSION,
frame_protocol_version=1,
@@ -2317,6 +2325,7 @@ class HostProcess:
# Off the event loop: probes PATH and reads local config.
# The loop below refreshes changes; launch remains authoritative.
configured_harnesses=configured_harnesses,
gateway_inference=gateway_inference,
telemetry_opt_out=_tel_opt_out,
installation_id=_tel_install_id,
)
@@ -2365,16 +2374,22 @@ class HostProcess:
if refresh_full_map:
latest_harnesses = await asyncio.to_thread(configured_harness_map)
latest_gateway_inference = await asyncio.to_thread(gateway_inference_map)
next_full_refresh = now + HARNESS_READINESS_FULL_REFRESH_INTERVAL_S
if latest_harnesses != configured_harnesses:
if (
latest_harnesses != configured_harnesses
or latest_gateway_inference != gateway_inference
):
await ws.send(
encode_host_frame(
HostHarnessReadinessFrame(
configured_harnesses=latest_harnesses,
gateway_inference=latest_gateway_inference,
)
)
)
configured_harnesses = latest_harnesses
gateway_inference = latest_gateway_inference
if isinstance(raw, str):
await self._handle_raw_message(ws, raw)
+75 -2
View File
@@ -93,6 +93,12 @@ class HostHelloFrame:
treat ``None`` as "nothing is configured". Changes arrive in
:class:`HostHarnessReadinessFrame`; launch-time checks remain
authoritative.
:param gateway_inference: Per-harness flag for whether that family's
launch on this host resolves AI-Gateway-backed inference, e.g.
``{"claude-native": True, "codex": False}`` (see
``omnigent.gateway_inference``). A family that could not be evaluated
is omitted. ``None`` means unknown (an older host that doesn't report
it) — never treat it as "nothing is gateway-backed".
"""
version: str
@@ -100,6 +106,7 @@ class HostHelloFrame:
name: str
runners: list[str] = field(default_factory=list)
configured_harnesses: dict[str, HarnessAvailability] | None = None
gateway_inference: dict[str, bool] | None = None
telemetry_opt_out: bool = False
installation_id: str | None = None
@@ -110,9 +117,16 @@ class HostHarnessReadinessFrame:
:param configured_harnesses: Current launch readiness keyed by every
accepted harness spelling. Sent only when the map changes.
:param gateway_inference: Per-harness flag for whether that family's
launch on this host resolves AI-Gateway-backed inference, e.g.
``{"claude-native": True, "codex": False}`` (see
``omnigent.gateway_inference``). A family that could not be evaluated
is omitted. ``None`` means unknown (an older host that doesn't report
it) — never treat it as "nothing is gateway-backed".
"""
configured_harnesses: dict[str, HarnessAvailability]
gateway_inference: dict[str, bool] | None = None
@dataclass
@@ -626,6 +640,12 @@ class HostInstallHarnessResultFrame:
after the install attempt, e.g. ``{"claude-native": True,
"codex-native": "needs-auth"}``. ``None`` when the install could
not run (the server keeps its prior readiness view).
:param gateway_inference: Per-harness flag for whether that family's
launch on this host resolves AI-Gateway-backed inference, e.g.
``{"claude-native": True, "codex": False}`` (see
``omnigent.gateway_inference``). A family that could not be evaluated
is omitted. ``None`` means unknown (an older host that doesn't report
it) — never treat it as "nothing is gateway-backed".
:param error: Why the install failed, e.g. ``"npm not found"`` or
``"install timed out"``. ``None`` on success.
"""
@@ -633,6 +653,7 @@ class HostInstallHarnessResultFrame:
request_id: str
status: str
configured_harnesses: dict[str, HarnessAvailability] | None = None
gateway_inference: dict[str, bool] | None = None
error: str | None = None
@@ -694,6 +715,12 @@ class HostStoreSecretResultFrame:
otherwise (paired with a non-secret ``error``).
:param configured_harnesses: Readiness recomputed after the write, e.g.
``{"claude-native": True}``. ``None`` when the write could not run.
:param gateway_inference: Per-harness flag for whether that family's
launch on this host resolves AI-Gateway-backed inference, e.g.
``{"claude-native": True, "codex": False}`` (see
``omnigent.gateway_inference``). A family that could not be evaluated
is omitted. ``None`` means unknown (an older host that doesn't report
it) — never treat it as "nothing is gateway-backed".
:param error: Non-secret failure reason, e.g. ``"a gateway requires a
base_url"``. ``None`` on success.
"""
@@ -701,6 +728,7 @@ class HostStoreSecretResultFrame:
request_id: str
status: str
configured_harnesses: dict[str, HarnessAvailability] | None = None
gateway_inference: dict[str, bool] | None = None
error: str | None = None
@@ -799,12 +827,21 @@ class HostModelOptionsFrame:
@dataclass
class HostModelOptionsResultFrame:
"""Host → server: pre-launch model choices resolved on that machine."""
"""Host → server: pre-launch model choices resolved on that machine.
:param models: Picker rows the harness can be launched/switched onto
by name, e.g. ``[{"id": "opus", "model": "…-opus-5"}]``.
:param routable_models: Every model id the harness's endpoint serves,
including generations no picker row names — launchable exactly
(``--model``) even without a row, so a router may pick one.
Empty when the harness cannot enumerate its endpoint.
"""
request_id: str
status: str
models: list[dict[str, Any]] = field(default_factory=list)
error: str | None = None
routable_models: list[str] = field(default_factory=list)
HostFrame = (
@@ -879,6 +916,7 @@ def encode_host_frame(frame: HostFrame) -> str:
"name": frame.name,
"runners": list(frame.runners),
"configured_harnesses": frame.configured_harnesses,
"gateway_inference": frame.gateway_inference,
"telemetry_opt_out": frame.telemetry_opt_out,
"installation_id": frame.installation_id,
}
@@ -888,6 +926,7 @@ def encode_host_frame(frame: HostFrame) -> str:
{
"kind": HostFrameKind.HARNESS_READINESS.value,
"configured_harnesses": frame.configured_harnesses,
"gateway_inference": frame.gateway_inference,
}
)
if isinstance(frame, HostLaunchRunnerFrame):
@@ -1095,6 +1134,7 @@ def encode_host_frame(frame: HostFrame) -> str:
"request_id": frame.request_id,
"status": frame.status,
"configured_harnesses": frame.configured_harnesses,
"gateway_inference": frame.gateway_inference,
"error": frame.error,
}
)
@@ -1119,6 +1159,7 @@ def encode_host_frame(frame: HostFrame) -> str:
"request_id": frame.request_id,
"status": frame.status,
"configured_harnesses": frame.configured_harnesses,
"gateway_inference": frame.gateway_inference,
"error": frame.error,
}
)
@@ -1176,6 +1217,7 @@ def encode_host_frame(frame: HostFrame) -> str:
"status": frame.status,
"models": frame.models,
"error": frame.error,
"routable_models": frame.routable_models,
}
)
raise TypeError(f"unknown host frame type: {type(frame).__name__}")
@@ -1315,6 +1357,7 @@ def _decode_host_hello(msg: dict[str, Any]) -> HostHelloFrame:
name=_required_str(msg, "name"),
runners=_optional_str_list(msg, "runners"),
configured_harnesses=_optional_str_availability_map(msg, "configured_harnesses"),
gateway_inference=_optional_str_bool_map(msg, "gateway_inference"),
telemetry_opt_out=bool(msg.get("telemetry_opt_out", False)),
installation_id=_optional_nullable_str(msg, "installation_id"),
)
@@ -1330,7 +1373,10 @@ def _decode_harness_readiness(msg: dict[str, Any]) -> HostHarnessReadinessFrame:
raise ValueError("harness readiness frame contains an unsupported availability state")
if not configured_harnesses:
raise ValueError("harness readiness frame requires a non-empty configured_harnesses map")
return HostHarnessReadinessFrame(configured_harnesses=configured_harnesses)
return HostHarnessReadinessFrame(
configured_harnesses=configured_harnesses,
gateway_inference=_optional_str_bool_map(msg, "gateway_inference"),
)
def _decode_launch_runner(msg: dict[str, Any]) -> HostLaunchRunnerFrame:
@@ -1671,6 +1717,7 @@ def _decode_install_harness_result(msg: dict[str, Any]) -> HostInstallHarnessRes
request_id=_required_str(msg, "request_id"),
status=_required_str(msg, "status"),
configured_harnesses=_optional_str_availability_map(msg, "configured_harnesses"),
gateway_inference=_optional_str_bool_map(msg, "gateway_inference"),
error=_optional_nullable_str(msg, "error"),
)
@@ -1703,6 +1750,7 @@ def _decode_store_secret_result(msg: dict[str, Any]) -> HostStoreSecretResultFra
request_id=_required_str(msg, "request_id"),
status=_required_str(msg, "status"),
configured_harnesses=_optional_str_availability_map(msg, "configured_harnesses"),
gateway_inference=_optional_str_bool_map(msg, "gateway_inference"),
error=_optional_nullable_str(msg, "error"),
)
@@ -1796,11 +1844,17 @@ def _decode_model_options_result(msg: dict[str, Any]) -> HostModelOptionsResultF
models = msg.get("models", [])
if not isinstance(models, list) or not all(isinstance(model, dict) for model in models):
raise ValueError("frame field must be a list of JSON objects: 'models'")
# Absent from hosts older than the routable-catalog field; the picker rows
# alone remain a valid answer.
routable = msg.get("routable_models", [])
if not isinstance(routable, list) or not all(isinstance(model, str) for model in routable):
raise ValueError("frame field must be a list of strings: 'routable_models'")
return HostModelOptionsResultFrame(
request_id=_required_str(msg, "request_id"),
status=_required_str(msg, "status"),
models=models,
error=_optional_nullable_str(msg, "error"),
routable_models=routable,
)
@@ -1884,6 +1938,25 @@ def _optional_str_availability_map(
return {k: v for k, v in val.items() if isinstance(k, str) and is_harness_availability(v)}
def _optional_str_bool_map(msg: dict[str, Any], key: str) -> dict[str, bool] | None:
"""Return an optional string→bool mapping field.
Tolerant like :func:`_optional_str_availability_map`: absent, null, or
non-mapping values decode to ``None`` ("unknown"), and entries whose key
isn't a string or whose value isn't a bool are dropped, so a garbled or
newer peer's payload never breaks the tunnel.
:param msg: Decoded frame object.
:param key: Field name, e.g. ``"gateway_inference"``.
:returns: The mapping, e.g. ``{"claude-native": True}``, or ``None`` when
absent / null / not a JSON object.
"""
val = msg.get(key)
if not isinstance(val, dict):
return None
return {k: v for k, v in val.items() if isinstance(k, str) and isinstance(v, bool)}
def _optional_nullable_str(msg: dict[str, Any], key: str) -> str | None:
"""Return an optional nullable string field.
+66 -3
View File
@@ -9,6 +9,7 @@ from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from omnigent.claude_model_vocabulary import claude_model_command_arg
from omnigent.claude_native_bridge import (
BRIDGE_DIR_ENV_VAR,
REQUEST_SESSION_ID_ENV_VAR,
@@ -16,6 +17,7 @@ from omnigent.claude_native_bridge import (
inject_user_message,
read_active_session_id,
read_launch_model,
read_model_env,
)
from omnigent.inner.executor import (
Executor,
@@ -160,14 +162,17 @@ class ClaudeNativeExecutor(Executor):
# box and verifies its submit) delivers the message — in order,
# once.
wanted_model = config.model if config is not None else None
# ``/model`` only accepts this session's aliases / custom slot; a
# bare catalog id is ignored and the pane keeps its old model.
wanted_model_arg = self._model_command_arg(wanted_model)
try:
with telemetry.span("claude_native.inject"):
async with self._inject_lock:
if self._should_switch_model(wanted_model):
if wanted_model_arg is not None:
await asyncio.to_thread(
inject_slash_command,
self._bridge_dir,
command=f"/model {wanted_model}",
command=f"/model {wanted_model_arg}",
# Accept the switch dialog if the CLI ever pops one,
# matching the manual picker path. Runs to completion
# before the message inject below (same lock), so its
@@ -175,7 +180,8 @@ class ClaudeNativeExecutor(Executor):
# gateway pane, which switches inline with no dialog.
auto_confirm=True,
)
# ``wanted_model`` is non-None here (guarded above).
# Track the routed id, not the alias: the next turn's
# comparison is against what routing asked for.
self._applied_model = wanted_model
await asyncio.to_thread(
inject_user_message,
@@ -187,6 +193,63 @@ class ClaudeNativeExecutor(Executor):
return
yield TurnComplete(response=None)
def _model_command_arg(self, wanted_model: str | None) -> str | None:
"""
Return the ``/model`` argument for this turn, or ``None`` to skip.
Two gates: the switch must be needed at all
(:meth:`_should_switch_model`), and the routed catalog id must
translate into vocabulary ``/model`` accepts — the session's
family aliases, or the exact id of its custom picker slot. The
pinning comes from the terminal's launch env, recorded in the
bridge config because this process doesn't share that env.
An untranslatable id fails open: the message still goes in, on
the current model, with a warning. Typing a value the CLI won't
take leaves the pane on its old model while reporting success.
:param wanted_model: The turn's routed model, or ``None``.
:returns: A ``/model`` argument, or ``None`` when no switch
should be typed.
"""
if wanted_model is None:
_logger.info("claude-native: turn carries no routed model; not typing /model")
return None
if not self._should_switch_model(wanted_model):
_logger.info(
"claude-native: skipping /model — pane is already on %s",
wanted_model,
)
return None
env = read_model_env(self._bridge_dir) or None
wanted_arg = claude_model_command_arg(wanted_model, env)
if wanted_arg is None:
_logger.warning(
"claude-native: skipping /model — routed model %r has no spelling this "
"session accepts (pins=%s); sending the turn on the current model",
wanted_model,
sorted(env or ()),
)
return None
if (
self._applied_model is not None
and claude_model_command_arg(self._applied_model, env) == wanted_arg
):
# Resolves to the model the pane is already on, so the switch
# would be a pointless prompt (and can pop a confirm dialog).
_logger.info(
"claude-native: skipping /model — %r resolves to %r, already applied",
wanted_model,
wanted_arg,
)
return None
_logger.info(
"claude-native: typing /model %s for routed model %s",
wanted_arg,
wanted_model,
)
return wanted_arg
def _should_switch_model(self, wanted_model: str | None) -> bool:
"""
Return whether this turn must type ``/model`` before the message.
+57
View File
@@ -46,6 +46,7 @@ from omnigent import model_catalog
from omnigent._platform import resolve_cli_binary, stable_user_id
from omnigent.inner import _proc
from omnigent.inner.bundle_skills import ensure_bundle_plugin_manifest
from omnigent.inner.hook_scripts import subagent_router
from omnigent.llms._usage_observer import notify_from_dict as _notify_usage_from_dict
from omnigent.llms.adapters._content import parse_data_uri as _parse_replay_data_uri
from omnigent.reasoning_effort import CLAUDE_EFFORTS, validate_effort
@@ -1903,6 +1904,60 @@ class ClaudeSDKExecutor(Executor):
return str(metadata["session_id"])
return "default"
def _install_subagent_router_hook(
self,
sdk: _ClaudeSDK,
options: Any, # type: ignore[explicit-any] # ClaudeAgentOptions — avoid a hard sdk import
model: str | None,
) -> None:
"""
Register the in-process subagent-routing ``PreToolUse`` hook.
The claude-agent-sdk runs hook callbacks in this process, so the
native hook script's decision logic is imported instead of
subprocessed. No-op unless the runner advertises a
``route-subagent`` endpoint, so unrouted sessions register nothing.
:param sdk: The ``claude_agent_sdk`` module (or a test double).
:param options: ``ClaudeAgentOptions`` to mutate.
:param model: Model this session runs on, sent as the spawn's
parent model.
"""
hook_matcher_cls = getattr(sdk, "HookMatcher", None)
if hook_matcher_cls is None:
return
router_dir = subagent_router.discover_router_dir()
if subagent_router.read_router_endpoint(router_dir) is None:
return
async def route_spawn(
payload: Any, # type: ignore[explicit-any] # HookInput TypedDict
tool_use_id: str | None, # noqa: ARG001 -- HookCallback signature
context: Any, # type: ignore[explicit-any] # HookContext # noqa: ARG001 -- HookCallback signature
) -> dict[str, Any]: # type: ignore[explicit-any] # HookJSONOutput
if not isinstance(payload, dict):
return {}
output = await asyncio.to_thread(
subagent_router.route_pre_tool_use,
payload,
harness="claude-sdk",
router_dir=router_dir,
parent_model=model,
)
return output or {}
hooks = dict(getattr(options, "hooks", None) or {})
entries = list(hooks.get("PreToolUse") or [])
entries.append(
hook_matcher_cls(
matcher=subagent_router.AGENT_TOOL_MATCHER,
hooks=[route_spawn],
timeout=subagent_router.REQUEST_TIMEOUT_S,
)
)
hooks["PreToolUse"] = entries
options.hooks = hooks
async def _can_use_tool_for_permission(
self,
tool_name: str,
@@ -2339,6 +2394,8 @@ class ClaudeSDKExecutor(Executor):
):
options.can_use_tool = self._can_use_tool_gate
self._install_subagent_router_hook(sdk, options, model)
# Log the full configuration for debugging
logger.info(
"ClaudeSDKExecutor: model=%s, gateway=%s, base_url=%s, tools=%d, thinking=%r",
+484 -2
View File
@@ -13,10 +13,19 @@ import json
import logging
import os
import re
import shlex
import shutil
import sys
import tempfile
import time
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
from collections.abc import (
AsyncIterator,
Awaitable,
Callable,
Iterable,
Mapping,
Sequence,
)
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
@@ -53,9 +62,17 @@ from .executor import (
TurnComplete,
classify_tool_result,
)
from .hook_scripts.codex_router_hook import AUDIT_FILENAME as _CODEX_SPAWN_AUDIT_FILENAME
from .hook_scripts.codex_router_hook import CANARY_FILENAME as _CODEX_ROUTER_CANARY_FILENAME
from .hook_scripts.subagent_router import REQUEST_TIMEOUT_S as _ROUTER_REQUEST_TIMEOUT_S
logger = logging.getLogger(__name__)
# Session-scoped warning emitted when the generated codex routing hooks did
# not run (untrusted hooks are silently skipped), so in-harness subagent
# spawns are not being enforced.
SUBAGENT_ROUTING_UNENFORCED_WARNING = "subagent_routing_unenforced"
# Default auth-token refresh cadence (ms) for the vendor-neutral gateway
# transport when ``HARNESS_CODEX_GATEWAY_AUTH_REFRESH_INTERVAL_MS`` is unset.
# Not Databricks-specific: the same fallback applies to any gateway producer
@@ -105,6 +122,9 @@ _STREAM_READ_CHUNK_SIZE = 65536
# to running sessions without any action from Omnigent.
_CODEX_HOME_SYMLINK_FILES = ("auth.json",)
_CODEX_HOME_GLOBAL_INSTRUCTION_FILES = ("AGENTS.md", "AGENTS.override.md", "hooks.json")
# Name of the hooks file inside a CODEX_HOME. Symlinked from the user's home
# by default; generated as a merged regular file when subagent routing is on.
_CODEX_HOOKS_FILENAME = "hooks.json"
# Files copied (not symlinked) from the real CODEX_HOME into the per-session
# temp home. config.toml is intentionally copied so that an in-TUI ``/model``
@@ -673,6 +693,7 @@ def _populate_codex_home_config(
source_dir: Path,
*,
minimal_config: bool | None = None,
subagent_routing: bool = False,
) -> None:
"""
Bridge user config files from the real ``CODEX_HOME`` into the temp one.
@@ -705,6 +726,9 @@ def _populate_codex_home_config(
skipped.
:param minimal_config: Copy only auth and provider-routing config when
``True``. ``None`` preserves the environment-controlled behavior.
:param subagent_routing: Skip the ``hooks.json`` symlink because
:func:`write_codex_router_hooks_file` generates a merged file (user
hooks + Omnigent routing hooks) at that path instead.
"""
if not source_dir.is_dir():
return
@@ -718,6 +742,10 @@ def _populate_codex_home_config(
symlink_files = _CODEX_HOME_SYMLINK_FILES
if not minimal_config:
symlink_files += _CODEX_HOME_GLOBAL_INSTRUCTION_FILES
if subagent_routing:
# The generated hooks file owns this path — a symlink to the user's
# home would either shadow it or (worse) be written through.
symlink_files = tuple(name for name in symlink_files if name != _CODEX_HOOKS_FILENAME)
for filename in symlink_files:
source_file = source_dir / filename
if not source_file.is_file():
@@ -783,6 +811,433 @@ def _populate_codex_home_config(
_normalize_copied_codex_effort(dest_path)
# Bridge directory holding the ``subagent_router.json`` advertisement. Its
# presence in the codex process env is what turns generated routing hooks on:
# without an endpoint to ask there is nothing to enforce, so the user's
# ``hooks.json`` keeps being symlinked untouched.
CODEX_ROUTER_DIR_ENV_VAR = "OMNIGENT_CODEX_SUBAGENT_ROUTER_DIR"
# Session the spawns belong to, baked into the generated hook commands.
CODEX_ROUTER_SESSION_ID_ENV_VAR = "OMNIGENT_CODEX_SUBAGENT_ROUTER_SESSION_ID"
_CODEX_ROUTER_HOOK_MODULE = "omnigent.inner.hook_scripts.codex_router_hook"
# Codex flattens the spawn tool name (``collaborationspawn_agent`` on
# 0.145.x), so the matcher is a regex suffix and never a bare literal.
_CODEX_SPAWN_AGENT_MATCHER = r".*spawn_agent"
# Kept just above the hook's own request budget so codex's kill is the
# outermost bound: the hook fails open on its timeout, codex only steps in
# if the hook itself wedged.
_CODEX_ROUTER_HOOK_TIMEOUT_SECONDS = int(_ROUTER_REQUEST_TIMEOUT_S) + 10
# Canary / audit hooks only touch a file; they must never delay a session.
_CODEX_AUDIT_HOOK_TIMEOUT_SECONDS = 10
def _codex_router_hook_command(
subcommand: str,
bridge_dir: Path,
*,
session_id: str | None,
python_executable: str | None,
extra_args: Iterable[str] = (),
) -> str:
"""
Build the shell command codex runs for one routing hook event.
Runs python in isolated mode (``-I``). Codex executes hooks with the
session's workspace as cwd, and ``-m`` would otherwise put that
workspace first on ``sys.path``: a workspace containing a directory
named ``omnigent`` (any checkout of this project) shadows the installed
package, the hook dies on ``ModuleNotFoundError``, and codex discards
the failure the routing gate silently fails open.
:param subcommand: Hook-script subcommand, e.g. ``"route-subagent"``.
:param bridge_dir: Session bridge directory holding the router
advertisement and the canary / audit files.
:param session_id: Omnigent session id, or ``None`` when the
advertisement is expected to carry it.
:param python_executable: Python to run; ``None`` uses
:data:`sys.executable`.
:param extra_args: Extra flags, e.g. ``("--harness", "codex-native")``.
:returns: A shell-escaped command string.
"""
argv = [
python_executable or sys.executable,
"-I",
"-m",
_CODEX_ROUTER_HOOK_MODULE,
subcommand,
"--bridge-dir",
str(bridge_dir),
]
if session_id:
argv.extend(["--session-id", session_id])
argv.extend(extra_args)
return shlex.join(argv)
def codex_router_hooks_settings(
bridge_dir: Path,
*,
session_id: str | None = None,
harness: str = "codex",
python_executable: str | None = None,
) -> dict[str, Any]:
"""
Build the Omnigent half of a routing ``hooks.json`` payload.
Three events: a ``PreToolUse`` gate on the spawn tool (matched by
regex because codex flattens the name), a ``SessionStart`` canary
proving the hooks were trusted and actually ran, and a
``SubagentStart`` audit writer recording the ``agent_id`` / ``model``
codex really started.
:param bridge_dir: Session bridge directory.
:param session_id: Omnigent session id baked into the commands.
:param harness: Harness label sent to the endpoint, e.g. ``"codex"``.
:param python_executable: Python for the hook commands.
:returns: A ``hooks.json``-shaped dict.
"""
def hook(subcommand: str, timeout: int, extra_args: Iterable[str] = ()) -> dict[str, Any]:
return {
"type": "command",
"command": _codex_router_hook_command(
subcommand,
bridge_dir,
session_id=session_id,
python_executable=python_executable,
extra_args=extra_args,
),
"timeout": timeout,
}
return {
"hooks": {
"PreToolUse": [
{
"matcher": _CODEX_SPAWN_AGENT_MATCHER,
"hooks": [
hook(
"route-subagent",
_CODEX_ROUTER_HOOK_TIMEOUT_SECONDS,
("--harness", harness),
)
],
}
],
"SessionStart": [
{"hooks": [hook("session-canary", _CODEX_AUDIT_HOOK_TIMEOUT_SECONDS)]}
],
"SubagentStart": [
{"hooks": [hook("record-subagent", _CODEX_AUDIT_HOOK_TIMEOUT_SECONDS)]}
],
}
}
def merge_codex_user_hooks(payload: dict[str, Any], user_hooks_path: Path) -> dict[str, Any]:
"""
Merge the user's ``hooks.json`` entries into a generated payload.
Omnigent's entries stay in first position per event so the routing
gate runs before user hooks; events the user declares alone are added
wholesale. A missing or malformed user file leaves *payload*
unchanged routing must not break because the user's hooks file is
bad.
:param payload: Payload from :func:`codex_router_hooks_settings`.
:param user_hooks_path: The user's real ``hooks.json``.
:returns: The merged payload.
"""
try:
user_data = json.loads(user_hooks_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return payload
user_hooks = user_data.get("hooks", {}) if isinstance(user_data, dict) else {}
if not isinstance(user_hooks, dict) or not user_hooks:
return payload
return merge_codex_hook_payloads([payload, {"hooks": user_hooks}])
def merge_codex_hook_payloads(payloads: Iterable[Mapping[str, Any]]) -> dict[str, Any]:
"""
Merge ``hooks.json``-shaped payloads, earlier ones first per event.
Codex loads exactly one hooks file per ``CODEX_HOME``, so every
generator (policy hooks, routing hooks, the user's own hooks) has to
share a single payload; order decides which hook gates first.
:param payloads: Payloads to merge, most privileged first.
:returns: The merged payload.
"""
merged_hooks: dict[str, Any] = {}
for payload in payloads:
hooks = payload.get("hooks") or {}
if not isinstance(hooks, Mapping):
continue
for event, entries in hooks.items():
if not isinstance(entries, list):
continue
existing = merged_hooks.get(event)
merged_hooks[event] = list(existing) + list(entries) if existing else list(entries)
return {"hooks": merged_hooks}
def write_codex_hooks_file(
codex_home: Path,
payloads: Sequence[Mapping[str, Any]],
*,
user_hooks_source: Path | None = None,
) -> Path:
"""
Write the private CODEX_HOME's single ``hooks.json`` (atomically).
The one writer for every hook generator: *payloads* are merged in
order (Omnigent's stay in first position per event) and the user's
hooks are appended last. A symlink to the user's file is replaced by
the merged regular file, and is the merge source when
*user_hooks_source* is not given.
:param codex_home: Private per-session ``CODEX_HOME``.
:param payloads: ``hooks.json``-shaped payloads, most privileged first.
:param user_hooks_source: The user's real ``hooks.json`` to merge.
:returns: Path of the written file.
"""
codex_home.mkdir(mode=0o700, parents=True, exist_ok=True)
path = codex_home / _CODEX_HOOKS_FILENAME
payload = merge_codex_hook_payloads(payloads)
merge_source = user_hooks_source
if merge_source is None and path.is_symlink() and path.exists():
merge_source = path.resolve()
if merge_source is not None and merge_source.is_file():
payload = merge_codex_user_hooks(payload, merge_source)
if path.is_symlink() or path.exists():
path.unlink()
fd, tmp_name = tempfile.mkstemp(prefix=f"{_CODEX_HOOKS_FILENAME}.", dir=str(codex_home))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, sort_keys=True)
handle.write("\n")
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
return path
def write_codex_router_hooks_file(
codex_home: Path,
bridge_dir: Path,
*,
session_id: str | None = None,
harness: str = "codex",
python_executable: str | None = None,
user_hooks_source: Path | None = None,
) -> Path:
"""
Write a ``hooks.json`` holding only the routing hooks (plus user hooks).
Used by harnesses that register no other hooks; the native app-server
merges the routing payload with its policy hooks instead.
:param codex_home: Private per-session ``CODEX_HOME``.
:param bridge_dir: Session bridge directory.
:param session_id: Omnigent session id baked into the hook commands.
:param harness: Harness label sent to the endpoint.
:param python_executable: Python for the hook commands.
:param user_hooks_source: The user's real ``hooks.json`` to merge.
:returns: Path of the written file.
"""
return write_codex_hooks_file(
codex_home,
[
codex_router_hooks_settings(
bridge_dir,
session_id=session_id,
harness=harness,
python_executable=python_executable,
)
],
user_hooks_source=user_hooks_source,
)
def codex_router_bridge_dir(env: Mapping[str, str] | None = None) -> Path | None:
"""
Read the routing bridge directory from a process environment.
:param env: Environment to read; ``None`` uses :data:`os.environ`.
:returns: Bridge directory, or ``None`` when routing is off for this
session (no endpoint advertised, so nothing to enforce).
"""
source = os.environ if env is None else env
raw = (source.get(CODEX_ROUTER_DIR_ENV_VAR) or "").strip()
return Path(raw) if raw else None
def codex_router_session_id(env: Mapping[str, str] | None = None) -> str | None:
"""
Read the routing session id from a process environment.
:param env: Environment to read; ``None`` uses :data:`os.environ`.
:returns: Session id, or ``None`` when unset.
"""
source = os.environ if env is None else env
return (source.get(CODEX_ROUTER_SESSION_ID_ENV_VAR) or "").strip() or None
def codex_router_canary_fired(bridge_dir: Path) -> bool:
"""
Report whether the ``SessionStart`` canary hook ran.
An untrusted hook is silently skipped by codex, so an absent canary
means enforcement is off the caller emits the
``subagent_routing_unenforced`` warning instead of failing open
invisibly.
:param bridge_dir: Session bridge directory.
:returns: ``True`` when the canary file exists.
"""
return (bridge_dir / _CODEX_ROUTER_CANARY_FILENAME).is_file()
def read_codex_spawn_audit(bridge_dir: Path) -> list[dict[str, Any]]:
"""
Parse the ``SubagentStart`` audit records codex wrote.
:param bridge_dir: Session bridge directory.
:returns: One dict per well-formed JSON line (``agent_id``,
``model``, ...); malformed lines are skipped, a missing file
yields ``[]``.
"""
path = bridge_dir / _CODEX_SPAWN_AUDIT_FILENAME
try:
raw = path.read_text(encoding="utf-8")
except OSError:
return []
records: list[dict[str, Any]] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except ValueError:
continue
if isinstance(record, dict):
records.append(record)
return records
def subagent_routing_unenforced_warning(
reason: str, *, harness: str = "codex-native"
) -> dict[str, Any]:
"""
Build the session-scoped warning for unenforced subagent routing.
The payload shape is the frozen contract the web UI renders (keyed by
``code``, the field the header banner reads). This helper is the
single place it is built so both sides stay in step. Posted on the
session's ``external_session_warning`` channel by the codex forwarder.
:param reason: Human-readable cause, e.g. ``"SessionStart canary did
not fire; codex skipped the generated hooks (untrusted)."``.
:param harness: Harness the warning is about, e.g. ``"codex-native"``.
:returns: Warning payload ``{"code", "harness", "reason"}``.
"""
return {
"code": SUBAGENT_ROUTING_UNENFORCED_WARNING,
"harness": harness,
"reason": reason,
}
def reconcile_spawn_audit(
records: Iterable[Mapping[str, Any]],
relayed: Iterable[Mapping[str, Any]],
*,
harness: str = "codex-native",
) -> list[dict[str, Any]]:
"""
Compare ``SubagentStart`` audit records against the relayed verdicts.
The audit is the only place codex reports the model it *actually*
started a subagent on, so a rewrite the harness ignored shows up here
and nowhere else. Reconciled **per spawn**, joined on ``task_name``:
a session can mix routed and unrouted spawns (routing toggled off
mid-session, or a router outage), and comparing every audit record
against the session-wide set of approved models flagged each
inherited-model spawn as a violation. A spawn is only a violation when
the router demonstrably approved a *different* model for that same
spawn.
Records the ledger cannot be joined to are reconciled against the
whole session's approved models, but only when every relayed verdict
routed the spawn otherwise "unrouted" is indistinguishable from
"ignored the rewrite".
Ids are compared normalized: codex reports its own spelling of the
model, which differs from the router's catalog id by prefix or case
without being a different model.
:param records: Audit records from :func:`read_codex_spawn_audit`.
:param relayed: Verdicts the runner relayed for the session
(``{action, model, task_name, ...}``), from
``omnigent.runner.subagent_routing.relayed_decisions``. Empty means
nothing was routed, so there is nothing to contradict.
:param harness: Harness label for the emitted warnings.
:returns: Warning payloads, one per mismatching record.
"""
from omnigent.claude_model_vocabulary import normalized_model_id
def _task_key(entry: Mapping[str, Any]) -> str:
name = entry.get("task_name")
return name.strip() if isinstance(name, str) else ""
per_task: dict[str, set[str]] = {}
session_wide: set[str] = set()
every_spawn_routed = True
for verdict in relayed:
model = verdict.get("model")
routed = model if verdict.get("action") in ("rewrite", "allow") and model else None
key = _task_key(verdict)
if key:
bucket = per_task.setdefault(key, set())
if isinstance(routed, str):
bucket.add(routed)
if isinstance(routed, str):
session_wide.add(routed)
else:
every_spawn_routed = False
if not session_wide:
return []
warnings: list[dict[str, Any]] = []
for record in records:
spawned = record.get("model")
if not isinstance(spawned, str) or not spawned:
continue
key = _task_key(record)
if key in per_task:
approved = per_task[key]
elif every_spawn_routed:
approved = session_wide
else:
# Some spawn in this session was relayed but not routed, so an
# unjoinable audit record cannot be told apart from one of them.
continue
if not approved or normalized_model_id(spawned) in {
normalized_model_id(model) for model in approved
}:
continue
expected = ", ".join(sorted(approved))
warnings.append(
subagent_routing_unenforced_warning(
f"spawned model {spawned} != routed model {expected}",
harness=harness,
)
)
return warnings
# Top-level ``model_reasoning_effort = "<value>"`` assignment, tolerating
# leading whitespace and a trailing comment. Only applied to lines *before*
# the first table header so keys inside ``[profiles.*]`` etc. are never
@@ -1358,10 +1813,23 @@ class _CodexAppServerSession:
# definitions) from ``$CODEX_HOME``; without this step a freshly-
# created temp dir has neither, causing 401 Unauthorized errors
# for subscription-authenticated users.
config_source = _codex_home_config_source_from_env()
# When the runner advertises a subagent-routing endpoint, the user's
# hooks.json is merged into a generated file registering the routing
# hooks instead of being symlinked in untouched.
router_bridge_dir = codex_router_bridge_dir(self._env)
_populate_codex_home_config(
self._codex_home_dir,
_codex_home_config_source_from_env(),
config_source,
subagent_routing=router_bridge_dir is not None,
)
if router_bridge_dir is not None:
write_codex_router_hooks_file(
self._codex_home_dir,
router_bridge_dir,
session_id=codex_router_session_id(self._env),
user_hooks_source=config_source / _CODEX_HOOKS_FILENAME,
)
# Override CODEX_HOME so Codex stores its data (including conversation
# history) in a private temp directory rather than the user's ~/.codex/.
# This prevents subagent sessions from polluting the user's Codex history.
@@ -1394,6 +1862,20 @@ class _CodexAppServerSession:
},
)
self._started = True
if router_bridge_dir is not None:
# App-server threads run persisted-trusted hooks only, so the
# routing hooks need the trust handshake to be enforced.
# Imported here: the app-server module imports this one.
from omnigent.codex_native_app_server import trust_codex_router_hooks
try:
await trust_codex_router_hooks(self._request, cwd=self._cwd or os.getcwd())
except Exception: # noqa: BLE001 - never block session startup
logger.warning(
"codex subagent-routing hook trust failed; "
"routing will not be enforced for this session",
exc_info=True,
)
except Exception:
await self.close()
raise
+15
View File
@@ -21,6 +21,7 @@ from omnigent.codex_native_bridge import (
read_bridge_state,
read_mcp_startup,
update_active_turn_id,
write_codex_config_model,
)
from omnigent.inner.codex_goal_command import goal_objective_from_content
from omnigent.inner.executor import (
@@ -297,6 +298,20 @@ class CodexNativeExecutor(Executor):
**settings_overrides,
},
)
# Mirror the accepted switch into config.toml —
# the file the forwarder's model mirror and the
# cost-gate hook read. thread/settings/update does
# not write it, so without this the stale launch
# model is mirrored back at the next turn/started
# and silently reverts the switch.
switched_model = settings_overrides.get("model")
if isinstance(switched_model, str) and switched_model:
if not write_codex_config_model(self._bridge_dir, switched_model):
_logger.warning(
"Failed to mirror codex model switch into "
"config.toml: model=%s",
switched_model,
)
turn_params: dict[str, Any] = {
"threadId": state.thread_id,
"input": input_items,
@@ -0,0 +1,41 @@
"""Claude Code ``PreToolUse`` hook that routes native subagent spawns.
Registered by ``build_hook_settings`` on the ``Task|Agent`` matcher and
run as a subprocess per spawn. Reads the hook payload from stdin, asks
the runner's ``route-subagent`` endpoint what to do, and writes the
decision to stdout.
Always exits ``0``: routing must never be the reason a spawn fails. When
the endpoint is unadvertised, unreachable, or answers ``allow``, the hook
emits nothing and Claude proceeds unchanged.
"""
from __future__ import annotations
import sys
from omnigent.inner.hook_scripts.subagent_router import run_route_subagent_main
_HARNESS = "claude-native"
_LABEL = "omnigent claude router hook"
_PROG = "omnigent-claude-router-hook"
def main(argv: list[str] | None = None) -> int:
"""
Run the hook.
:param argv: Command-line arguments, excluding the program name.
``None`` uses :data:`sys.argv`.
:returns: Always ``0`` so a routing failure never blocks a spawn.
"""
return run_route_subagent_main(
list(sys.argv[1:] if argv is None else argv),
prog=_PROG,
harness=_HARNESS,
label=_LABEL,
)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,225 @@
"""Codex hook entrypoint for deterministic subagent routing.
Registered in the per-session private ``CODEX_HOME`` ``hooks.json``
generated by :mod:`omnigent.inner.codex_executor`. Three subcommands,
one per hook event:
- ``route-subagent`` (``PreToolUse``, matcher ``.*spawn_agent``) asks
the runner's ``route-subagent`` endpoint what model the spawn may use
and rewrites / denies accordingly.
- ``session-canary`` (``SessionStart``) touches a file in the bridge
dir proving the generated hooks actually ran (an untrusted hook is
silently skipped by codex, which would fail open invisibly).
- ``record-subagent`` (``SubagentStart``) appends the ``agent_id`` /
``model`` codex actually started to an audit file for reconciliation.
Stdlib-only and short-lived: ``PreToolUse`` blocks the spawn.
Codex encrypts the spawn ``message`` field in hook payloads, so routing
signal comes from ``task_name`` plus session metadata only, and the
original ``tool_input`` is echoed back verbatim on a rewrite.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
from typing import Any
from omnigent.inner.hook_scripts.subagent_router import (
parse_hook_args,
read_stdin_payload,
run_route_subagent_main,
)
# Codex flattens MCP-ish tool names, so the spawn tool arrives as
# ``collaborationspawn_agent`` on 0.145.x. Both the hooks.json matcher
# and this check must stay suffix-based rather than a bare literal.
SPAWN_AGENT_TOOL_SUFFIX = "spawn_agent"
# Bridge-dir artifacts the canary / audit subcommands write.
CANARY_FILENAME = "subagent_routing_canary"
AUDIT_FILENAME = "subagent_spawn_audit.jsonl"
# Harness label sent to the endpoint when argv names none.
DEFAULT_HARNESS = "codex"
_LABEL = "omnigent codex router hook"
_MODULE = "omnigent.inner.hook_scripts.codex_router_hook"
def main(argv: list[str] | None = None) -> int:
"""
Dispatch a codex router-hook subcommand.
:param argv: Argv override excluding the program name. ``None`` reads
:data:`sys.argv`.
:returns: Process exit code. Always ``0`` verdicts travel on stdout
so a hook failure never wedges codex.
"""
raw_argv = sys.argv[1:] if argv is None else argv
command = raw_argv[0] if raw_argv else ""
if command == "route-subagent":
return run_route_subagent_main(
raw_argv[1:],
prog=_prog(command),
harness=DEFAULT_HARNESS,
label=_LABEL,
**ROUTE_SEAMS,
)
if command == "session-canary":
return _main_session_canary(raw_argv[1:])
if command == "record-subagent":
return _main_record_subagent(raw_argv[1:])
print(f"{_LABEL}: unknown subcommand {command!r}", file=sys.stderr)
return 0
def _prog(command: str) -> str:
return f"python -m {_MODULE} {command}"
def is_spawn_agent_tool(tool_name: Any) -> bool: # type: ignore[explicit-any] # hook payloads are untrusted JSON
"""
Report whether a hook payload names codex's subagent-spawn tool.
:param tool_name: ``tool_name`` from the hook payload, e.g.
``"collaborationspawn_agent"``.
:returns: ``True`` when the (flattened) name ends in
``spawn_agent``.
"""
if not isinstance(tool_name, str):
return False
return tool_name.strip().lower().endswith(SPAWN_AGENT_TOOL_SUFFIX)
def with_system_message(
output: dict[str, Any] | None, # type: ignore[explicit-any] # hook output JSON
) -> dict[str, Any] | None: # type: ignore[explicit-any] # hook output JSON
"""
Add the routed-model notice codex's TUI surfaces to the user.
A rewrite is otherwise invisible: codex reports no model change of its
own, so the top-level ``systemMessage`` (alongside, not inside,
``hookSpecificOutput``) is the only place the decision shows up.
:param output: Hook output from ``decision_to_hook_output``, or
``None`` for "no opinion".
:returns: *output* with a ``systemMessage`` when it rewrote the spawn's
model, otherwise *output* unchanged.
"""
if not isinstance(output, dict):
return output
hook_output = output.get("hookSpecificOutput")
if not isinstance(hook_output, dict) or hook_output.get("permissionDecision") != "allow":
return output
updated_input = hook_output.get("updatedInput")
if not isinstance(updated_input, dict):
return output
model = updated_input.get("model")
if not isinstance(model, str) or not model:
return output
return {**output, "systemMessage": f"Using Smart Routing. Routing to {model}."}
def _payload_model(payload: dict[str, Any]) -> str | None: # type: ignore[explicit-any] # hook payloads are untrusted JSON
"""
Extract the parent session's model from a hook payload.
:param payload: Codex hook payload.
:returns: Model id, or ``None`` when the payload carries none.
"""
model = payload.get("model")
return model if isinstance(model, str) and model else None
#: How codex differs from the claude-native default: its own spawn-tool
#: name, its task-name keys, no prompt (codex encrypts the spawn message),
#: catalog ids injected verbatim, and the TUI routing notice.
ROUTE_SEAMS: dict[str, Any] = { # type: ignore[explicit-any] # route_pre_tool_use seams
"tool_matcher": is_spawn_agent_tool,
"task_keys": ("task_name", "agent_name"),
"include_prompt": False,
"parent_model_resolver": _payload_model,
"model_translator_factory": None,
"post_process": with_system_message,
}
def canary_path(bridge_dir: str | Path) -> Path:
"""
Path of the ``SessionStart`` canary file.
:param bridge_dir: Session bridge directory.
:returns: Canary file path.
"""
return Path(bridge_dir) / CANARY_FILENAME
def audit_path(bridge_dir: str | Path) -> Path:
"""
Path of the ``SubagentStart`` audit file.
:param bridge_dir: Session bridge directory.
:returns: Audit file path (JSON lines).
"""
return Path(bridge_dir) / AUDIT_FILENAME
def _main_session_canary(argv: list[str]) -> int:
"""
Touch the canary file proving the generated hooks ran.
:param argv: Argv after the ``session-canary`` subcommand.
:returns: Process exit code. Always ``0``.
"""
command = "session-canary"
args = parse_hook_args(_prog(command), argv)
if not args.bridge_dir:
print(f"{_LABEL}: {command} needs --bridge-dir", file=sys.stderr)
return 0
path = canary_path(args.bridge_dir)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"session_id": args.session_id, "at": time.time()}),
encoding="utf-8",
)
except OSError as exc:
print(f"{_LABEL}: could not write canary: {exc}", file=sys.stderr)
return 0
def _main_record_subagent(argv: list[str]) -> int:
"""
Append one ``SubagentStart`` audit record.
:param argv: Argv after the ``record-subagent`` subcommand.
:returns: Process exit code. Always ``0``.
"""
command = "record-subagent"
args = parse_hook_args(_prog(command), argv)
if not args.bridge_dir:
print(f"{_LABEL}: {command} needs --bridge-dir", file=sys.stderr)
return 0
payload = read_stdin_payload(_LABEL)
if payload is None:
return 0
record = {
"agent_id": payload.get("agent_id") or payload.get("agentId"),
"model": payload.get("model"),
"task_name": payload.get("task_name"),
"at": time.time(),
}
path = audit_path(args.bridge_dir)
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record) + "\n")
except OSError as exc:
print(f"{_LABEL}: could not write audit record: {exc}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,675 @@
"""Shared subagent-routing decision logic for harness hooks.
Stdlib-only on purpose (bar the equally light
:mod:`omnigent.claude_model_vocabulary`): the Claude-native hook runs as
a per-spawn subprocess (``python -I -m
omnigent.inner.hook_scripts.claude_router_hook``) and blocks the spawn,
so importing anything heavier would show up as spawn latency. The
claude-agent-sdk executor imports the same functions for its in-process
``PreToolUse`` callback, so both paths map decisions identically.
The runner advertises its ``route-subagent`` endpoint by writing
``subagent_router.json`` (``{"url": ..., "token": ..., "pid": ...}``) into
the session bridge directory. A missing, malformed, non-loopback or
dead-pid advertisement means the router is unreachable: the hook allows
the spawn unchanged and emits nothing.
Every ``explicit-any`` type-ignore below marks the same thing hook
payloads, request bodies and hook outputs are untrusted JSON with no
schema this process can import so the per-site justifications are
omitted.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from omnigent.claude_model_vocabulary import claude_model_alias
# Advertisement file written by the runner's subagent-routing endpoint,
# mirroring the ``tool_relay.json`` discovery pattern.
ADVERTISEMENT_FILE = "subagent_router.json"
# Claude-native bridge config, read for the session id / launch model.
_BRIDGE_CONFIG_FILE = "bridge.json"
# Explicit advertisement directory. Set for harnesses that have no
# claude-native bridge dir (e.g. the claude-agent-sdk executor).
ROUTER_DIR_ENV_VAR = "OMNIGENT_SUBAGENT_ROUTER_DIR"
# Session the spawn belongs to, when the harness knows it out of band.
SESSION_ID_ENV_VAR = "OMNIGENT_SUBAGENT_ROUTER_SESSION_ID"
# Claude-native bridge discovery, already exported to the harness.
BRIDGE_DIR_ENV_VAR = "HARNESS_CLAUDE_NATIVE_BRIDGE_DIR"
NATIVE_SESSION_ID_ENV_VAR = "HARNESS_CLAUDE_NATIVE_REQUEST_SESSION_ID"
# Claude Code's subagent-spawn tool. ``Agent`` is the current name;
# ``Task`` was renamed to it in CLI 2.1.63 and still works as an alias,
# so both are matched. Also used verbatim as the settings/SDK matcher —
# Claude Code reads a pipe-separated list as exact alternatives.
AGENT_TOOL_NAMES = ("Agent", "Task")
AGENT_TOOL_MATCHER = "|".join(AGENT_TOOL_NAMES)
# Subagent types that inherit the caller's context instead of starting
# fresh. Routing a fork would price a task whose real cost is dominated
# by inherited context, so v1 reports them and lets the server exempt
# them.
FORK_SUBAGENT_TYPES = frozenset({"fork"})
_FORK_SUFFIXES = ("-fork", "_fork", ":fork")
# Hosts an advertised router URL may name. The advertisement lives in an
# agent-writable directory, so anything else is a self-approval or
# exfiltration target rather than our own runner.
#
# Advisory only: these checks stop an off-box exfiltration target and a
# stale port, not a same-uid agent, which can bind its own loopback port
# and advertise a live pid. Routing is an advisory gate, not a sandbox.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"})
# Hop 2 of the routing timeout budget documented in
# ``omnigent.runner.subagent_routing``: larger than the runner's own wait so
# the runner answers first, smaller than the harness's hook timeout so this
# script's fail-open branch can run.
REQUEST_TIMEOUT_S = 30.0
# ``tool_input`` keys naming the requested subagent, in preference order.
# Claude Code sends ``subagent_type``; codex sends ``task_name`` /
# ``agent_name``.
DEFAULT_TASK_KEYS: tuple[str, ...] = ("subagent_type",)
# Flags every harness hook entrypoint accepts. None is required: a hook
# misconfiguration must degrade to "no opinion", not an argparse exit.
STANDARD_HOOK_FLAGS: tuple[str, ...] = (
"--bridge-dir",
"--router-dir",
"--session-id",
"--harness",
)
@dataclass(frozen=True)
class RouterEndpoint:
"""Advertised ``route-subagent`` endpoint."""
url: str
token: str
session_id: str | None = None
def discover_router_dir(bridge_dir: str | Path | None = None) -> Path | None:
"""
Locate the directory holding the router advertisement.
:param bridge_dir: Explicit directory, e.g. the ``--router-dir`` /
``--bridge-dir`` argv value. ``None`` falls back to
:data:`ROUTER_DIR_ENV_VAR` then :data:`BRIDGE_DIR_ENV_VAR`.
:returns: Directory path, or ``None`` when nothing advertises one.
"""
if bridge_dir:
return Path(bridge_dir)
for env_var in (ROUTER_DIR_ENV_VAR, BRIDGE_DIR_ENV_VAR):
raw = os.environ.get(env_var, "").strip()
if raw:
return Path(raw)
return None
def read_router_endpoint(router_dir: str | Path | None) -> RouterEndpoint | None:
"""
Read the advertised endpoint.
The advertisement lives in an agent-writable directory, so it is
validated before anything is sent to it: the URL must be plain ``http``
on a loopback address, and the advertising process must still be alive
(a stale entry's port can be re-bound by another local process). Either
check failing means "router unreachable".
:param router_dir: Directory containing
:data:`ADVERTISEMENT_FILE`.
:returns: Endpoint, or ``None`` when the advertisement is absent,
malformed, or fails validation.
"""
if router_dir is None:
return None
try:
raw = (Path(router_dir) / ADVERTISEMENT_FILE).read_text(encoding="utf-8")
payload = json.loads(raw)
except (OSError, ValueError):
return None
if not isinstance(payload, dict):
return None
url = payload.get("url")
token = payload.get("token")
if not isinstance(url, str) or not url or not isinstance(token, str) or not token:
return None
if not _is_loopback_url(url):
_diagnose(f"ignoring advertised router at {url!r}: not plain http on loopback")
return None
if not _advertiser_alive(payload.get("pid")):
_diagnose(f"ignoring advertised router at {url!r}: advertiser pid not alive")
return None
session_id = payload.get("session_id")
return RouterEndpoint(
url=url.rstrip("/"),
token=token,
session_id=session_id if isinstance(session_id, str) and session_id else None,
)
def _diagnose(message: str) -> None:
"""Print a routing diagnostic to stderr.
The hook has no logger (stdlib-only, runs as a short-lived subprocess)
and its stdout is the harness's hook protocol, so stderr is the only
channel a user can see why routing silently fell open.
"""
print(f"omnigent subagent router: {message}", file=sys.stderr)
def _is_loopback_url(url: str) -> bool:
"""Report whether *url* is plain HTTP on a loopback address."""
try:
parsed = urllib.parse.urlsplit(url)
host = parsed.hostname
except ValueError:
return False
return parsed.scheme == "http" and host in _LOOPBACK_HOSTS
def _advertiser_alive(pid: Any) -> bool: # type: ignore[explicit-any]
"""Report whether the advertised runner process still exists.
The runner always writes ``pid``, so a missing or malformed one means
the advertisement was not written by us rejected rather than
trusted. On Windows ``os.kill(pid, 0)`` is unreliable, so the liveness
probe itself is skipped there and the pid's presence is all that is
checked. The probe is also blind inside a PID-namespaced sandbox
(``--unshare-pid``), where the runner's pid is simply not visible; a
hook running there sees "unreachable" and falls open on the inherited
model.
"""
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
return False
if not hasattr(os, "getuid"):
return True
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except OSError:
# EPERM: alive but owned by someone else. That is not our router
# either, but the bearer token still gates the request.
return True
return True
def resolve_session_id(
endpoint: RouterEndpoint,
*,
bridge_dir: str | Path | None = None,
) -> str | None:
"""
Resolve the Omnigent session the spawn belongs to.
:param endpoint: Advertised endpoint, which may carry the session id.
:param bridge_dir: Claude-native bridge directory, read as a last
resort (``bridge.json`` tracks the active session across
``/clear`` rotations).
:returns: Session id, e.g. ``"conv_abc123"``, or ``None``.
"""
if endpoint.session_id:
return endpoint.session_id
for env_var in (SESSION_ID_ENV_VAR, NATIVE_SESSION_ID_ENV_VAR):
raw = os.environ.get(env_var, "").strip()
if raw:
return raw
config = _read_bridge_config(bridge_dir)
for key in ("active_session_id", "conversation_id"):
value = config.get(key)
if isinstance(value, str) and value:
return value
return None
def resolve_parent_model(bridge_dir: str | Path | None) -> str | None:
"""
Resolve the model the parent session runs on.
:param bridge_dir: Claude-native bridge directory whose
``bridge.json`` records the launch model.
:returns: Gateway model name, or ``None`` when unknown.
"""
model = _read_bridge_config(bridge_dir).get("launch_model")
return model if isinstance(model, str) and model else None
def resolve_model_vocabulary_env(bridge_dir: str | Path | None) -> Mapping[str, str] | None:
"""
Resolve the session's alias pinning for model translation.
:param bridge_dir: Claude-native bridge directory whose
``bridge.json`` records the launch env's model keys.
:returns: The recorded ``{env var: model id}`` mapping, or ``None``
to fall back to this process's environment (a hook subprocess
inherits the CLI's).
"""
model_env = _read_bridge_config(bridge_dir).get("model_env")
if not isinstance(model_env, dict):
return None
resolved = {
str(key): str(value)
for key, value in model_env.items()
if isinstance(key, str) and isinstance(value, str) and value
}
return resolved or None
def _read_bridge_config(
bridge_dir: str | Path | None,
) -> dict[str, Any]: # type: ignore[explicit-any]
if bridge_dir is None:
return {}
try:
config = json.loads((Path(bridge_dir) / _BRIDGE_CONFIG_FILE).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return config if isinstance(config, dict) else {}
def is_agent_tool(tool_name: Any) -> bool: # type: ignore[explicit-any]
"""
Report whether a hook payload names the subagent-spawn tool.
:param tool_name: ``tool_name`` from the hook payload.
:returns: ``True`` for Claude Code's ``Task`` / ``Agent`` tool.
"""
return isinstance(tool_name, str) and tool_name in AGENT_TOOL_NAMES
def spawn_task_name(
tool_input: dict[str, Any], # type: ignore[explicit-any]
task_keys: Sequence[str] = DEFAULT_TASK_KEYS,
) -> str:
"""
Read the requested subagent's name out of a spawn's ``tool_input``.
:param tool_input: ``tool_input`` from the hook payload.
:param task_keys: Keys to try, in preference order.
:returns: The name, or ``""`` when the spawn names none (the server
supplies the placeholder task; the hook does not invent one).
"""
for key in task_keys:
value = tool_input.get(key)
if isinstance(value, str) and value:
return value
return ""
def is_fork_spawn(
tool_input: dict[str, Any], # type: ignore[explicit-any]
task_keys: Sequence[str] = DEFAULT_TASK_KEYS,
) -> bool:
"""
Detect a context-inheriting (fork-typed) spawn.
:param tool_input: ``tool_input`` from the hook payload.
:param task_keys: Extra name keys to try when ``subagent_type`` is
absent, e.g. codex's ``task_name``.
:returns: ``True`` when the requested subagent type inherits the
caller's context.
"""
keys = dict.fromkeys(("subagent_type", *task_keys))
normalized = spawn_task_name(tool_input, tuple(keys)).strip().lower()
return normalized in FORK_SUBAGENT_TYPES or normalized.endswith(_FORK_SUFFIXES)
def build_route_request(
tool_input: dict[str, Any], # type: ignore[explicit-any]
*,
harness: str,
parent_model: str | None = None,
task_keys: Sequence[str] = DEFAULT_TASK_KEYS,
include_prompt: bool = True,
) -> dict[str, Any]: # type: ignore[explicit-any]
"""
Build the ``route-subagent`` request body.
:param tool_input: ``tool_input`` from the hook payload.
:param harness: Requesting harness, e.g. ``"claude-native"``.
:param parent_model: Model the parent session runs on, when known.
:param task_keys: ``tool_input`` keys naming the subagent, in
preference order.
:param include_prompt: ``False`` sends ``prompt: null``, for harnesses
whose spawn message is encrypted in hook payloads (codex).
:returns: JSON-serializable request body.
"""
prompt = tool_input.get("prompt") if include_prompt else None
return {
"harness": harness,
"task_name": spawn_task_name(tool_input, task_keys),
"prompt": prompt if isinstance(prompt, str) and prompt else None,
"fork": is_fork_spawn(tool_input, task_keys),
"parent_model": parent_model,
}
def request_decision(
endpoint: RouterEndpoint,
session_id: str,
body: dict[str, Any], # type: ignore[explicit-any]
*,
timeout: float = REQUEST_TIMEOUT_S,
) -> dict[str, Any] | None: # type: ignore[explicit-any]
"""
POST one routing request to the runner.
:param endpoint: Advertised endpoint.
:param session_id: Omnigent session id.
:param body: Request body from :func:`build_route_request`.
:param timeout: Socket timeout in seconds.
:returns: Decoded decision, or ``None`` on any transport / decode
failure (callers treat that as "allow unchanged").
"""
url = f"{endpoint.url}/v1/sessions/{urllib.parse.quote(session_id, safe='')}/route-subagent"
# Loopback runner URL read from the owner-only bridge dir.
req = urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {endpoint.token}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
return None
return payload if isinstance(payload, dict) else None
def _allow_with_model(
tool_input: dict[str, Any], # type: ignore[explicit-any]
model: str,
reason: str,
) -> dict[str, Any]: # type: ignore[explicit-any]
output: dict[str, Any] = { # type: ignore[explicit-any]
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {**tool_input, "model": model},
}
if reason:
output["permissionDecisionReason"] = reason
return {"hookSpecificOutput": output}
def _deny(reason: str) -> dict[str, Any]: # type: ignore[explicit-any]
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
def claude_model_translator(
bridge_dir: str | Path | None,
) -> Callable[[str], str | None]:
"""
Build the model translator for Claude's spawn tool.
:param bridge_dir: Directory whose ``bridge.json`` records the
session's alias pinning.
:returns: Callable mapping a servable id to an accepted alias.
"""
# Claude's Agent/Task ``model`` is a closed enum of family aliases, so a
# catalog id ("databricks-claude-sonnet-5") fails its schema and the
# spawn dies. Same vocabulary as ``/model``.
vocabulary_env = resolve_model_vocabulary_env(bridge_dir)
return lambda model: claude_model_alias(model, vocabulary_env)
def redirect_reason(harness: str, model: str) -> str:
"""
Build the cross-harness redirect instruction shown to the model.
:param harness: Harness the router picked, e.g. ``"codex"``.
:param model: Model the router picked.
:returns: Deny reason telling the model how to respawn correctly.
"""
return (
f"Router selected {harness}/{model}. Use sys_session_send with "
f"args.harness={harness}, args.model={model} instead."
)
def decision_to_hook_output(
decision: dict[str, Any], # type: ignore[explicit-any]
tool_input: dict[str, Any], # type: ignore[explicit-any]
*,
model_translator: Callable[[str], str | None] | None = None,
) -> dict[str, Any] | None: # type: ignore[explicit-any]
"""
Map a ``route-subagent`` decision to Claude ``PreToolUse`` output.
:param decision: Decoded endpoint response.
:param tool_input: Original ``tool_input``, preserved on rewrite.
:param model_translator: Converts the decision's servable model id
into the spawn tool's own ``model`` vocabulary, returning ``None``
when it maps to nothing the tool accepts (the spawn is then
allowed unchanged a degraded model beats a dead spawn, and an
unacceptable value beats neither). ``None`` injects the id as-is,
which is what codex's ``spawn_agent`` expects.
:returns: Hook output, or ``None`` for "no opinion" (allow the spawn
unchanged with no emitted decision).
"""
action = decision.get("action")
model = decision.get("model")
rationale = decision.get("rationale")
rationale = rationale if isinstance(rationale, str) else ""
if action == "rewrite" and isinstance(model, str) and model:
if model_translator is None:
return _allow_with_model(tool_input, model, rationale)
translated = model_translator(model)
if translated is None:
return None
if translated != model:
rationale = f"{rationale} (applied as {translated!r})".strip()
return _allow_with_model(tool_input, translated, rationale)
if action == "redirect":
harness = decision.get("harness")
if isinstance(harness, str) and harness and isinstance(model, str) and model:
return _deny(redirect_reason(harness, model))
# A redirect without a target can't be followed — fail open.
return None
if action == "deny":
return _deny(rationale or "Spawn denied by Omnigent smart routing.")
return None
def route_pre_tool_use(
payload: dict[str, Any], # type: ignore[explicit-any]
*,
harness: str,
router_dir: str | Path | None = None,
bridge_dir: str | Path | None = None,
parent_model: str | None = None,
session_id: str | None = None,
timeout: float = REQUEST_TIMEOUT_S,
tool_matcher: Callable[[Any], bool] = is_agent_tool, # type: ignore[explicit-any]
task_keys: Sequence[str] = DEFAULT_TASK_KEYS,
include_prompt: bool = True,
parent_model_resolver: Callable[[dict[str, Any]], str | None] | None = None, # type: ignore[explicit-any]
model_translator_factory: Callable[[str | Path | None], Callable[[str], str | None]]
| None = claude_model_translator,
post_process: Callable[[dict[str, Any] | None], dict[str, Any] | None] | None = None, # type: ignore[explicit-any]
) -> dict[str, Any] | None: # type: ignore[explicit-any]
"""
Route one ``PreToolUse`` payload end to end.
:param payload: ``PreToolUse`` hook payload.
:param harness: Requesting harness, e.g. ``"claude-sdk"``.
:param router_dir: Advertisement directory; ``None`` discovers it.
:param bridge_dir: Claude-native bridge directory for session-id
fallback.
:param parent_model: Model the parent session runs on, when known.
:param session_id: Session baked into the hook command, used when the
advertisement carries none.
:param timeout: Socket timeout in seconds.
:param tool_matcher: Recognizes the harness's spawn tool by name.
:param task_keys: ``tool_input`` keys naming the subagent.
:param include_prompt: ``False`` withholds the spawn prompt.
:param parent_model_resolver: Derives the parent model from the
payload; ``None`` reads the bridge config instead.
:param model_translator_factory: Builds the decision-model translator
for this harness's spawn tool. ``None`` injects the routed id
verbatim, which is what codex's ``spawn_agent`` expects.
:param post_process: Last pass over the hook output, e.g. codex's
routed-model notice.
:returns: Hook output, or ``None`` for "no opinion" every failure
lands here so a spawn is never blocked by routing infrastructure.
"""
if not tool_matcher(payload.get("tool_name")):
return None
tool_input = payload.get("tool_input")
if not isinstance(tool_input, dict):
return None
endpoint = read_router_endpoint(discover_router_dir(router_dir))
if endpoint is None:
return None
resolved_session = (
endpoint.session_id
or session_id
or resolve_session_id(endpoint, bridge_dir=bridge_dir or router_dir)
)
if not resolved_session:
return None
if parent_model is None:
parent_model = (
parent_model_resolver(payload)
if parent_model_resolver is not None
else resolve_parent_model(bridge_dir)
)
body = build_route_request(
tool_input,
harness=harness,
parent_model=parent_model,
task_keys=task_keys,
include_prompt=include_prompt,
)
decision = request_decision(endpoint, resolved_session, body, timeout=timeout)
if decision is None:
return None
translator = (
model_translator_factory(bridge_dir or router_dir)
if model_translator_factory is not None
else None
)
output = decision_to_hook_output(decision, tool_input, model_translator=translator)
return post_process(output) if post_process is not None else output
def read_stdin_payload(
label: str,
) -> dict[str, Any] | None: # type: ignore[explicit-any]
"""
Read one hook payload from stdin.
:param label: Diagnostic prefix, e.g. ``"omnigent codex router hook"``.
:returns: Decoded object, or ``None`` when stdin is empty, malformed,
or not a JSON object (a diagnostic goes to stderr).
"""
try:
payload = json.loads(sys.stdin.read() or "{}")
except ValueError as exc:
print(f"{label}: malformed JSON: {exc}", file=sys.stderr)
return None
if not isinstance(payload, dict):
print(f"{label}: expected JSON object", file=sys.stderr)
return None
return payload
def hook_arg_parser(
prog: str,
*,
extra_flags: Sequence[str] = (),
) -> argparse.ArgumentParser:
"""
Build the argument parser shared by the harness hook entrypoints.
:param prog: Program label for usage text.
:param extra_flags: Flags beyond :data:`STANDARD_HOOK_FLAGS`.
:returns: Parser whose every flag is optional and defaults to ``None``.
"""
parser = argparse.ArgumentParser(prog=prog)
for flag in (*STANDARD_HOOK_FLAGS, *extra_flags):
parser.add_argument(flag, default=None)
return parser
def parse_hook_args(
prog: str,
argv: Sequence[str],
*,
extra_flags: Sequence[str] = (),
) -> argparse.Namespace:
"""
Parse a hook entrypoint's arguments, tolerating anything unexpected.
Unknown flags are dropped rather than raising ``SystemExit(2)``: a
stale generated hook command must not turn into a failed spawn.
:param prog: Program label for usage text.
:param argv: Arguments after the subcommand, if any.
:param extra_flags: Flags beyond :data:`STANDARD_HOOK_FLAGS`.
:returns: Parsed namespace.
"""
args, _unknown = hook_arg_parser(prog, extra_flags=extra_flags).parse_known_args(list(argv))
return args
def run_route_subagent_main(
argv: Sequence[str],
*,
prog: str,
harness: str,
label: str | None = None,
**route_kwargs: Any, # type: ignore[explicit-any]
) -> int:
"""
Run a hook entrypoint's spawn-routing body.
:param argv: Arguments after the subcommand, if any.
:param prog: Program label for usage text.
:param harness: Requesting harness, used when argv names none.
:param label: Diagnostic prefix; ``None`` uses *prog*.
:param route_kwargs: Per-harness seams for
:func:`route_pre_tool_use`.
:returns: Always ``0`` so a routing failure never blocks a spawn.
"""
args = parse_hook_args(prog, argv)
payload = read_stdin_payload(label or prog)
if payload is None:
return 0
output = route_pre_tool_use(
payload,
harness=args.harness or harness,
router_dir=args.router_dir or args.bridge_dir,
bridge_dir=args.bridge_dir,
session_id=args.session_id,
**route_kwargs,
)
if output is not None:
sys.stdout.write(json.dumps(output))
return 0
+8 -7
View File
@@ -52,7 +52,7 @@ from omnigent.model_metadata import (
ModelMetadata,
ModelWireAPI,
)
from omnigent.model_override import model_family_mismatch
from omnigent.model_override import is_codex_compatible_model, model_family_mismatch
from omnigent.model_resolver import (
ModelResolution,
ModelResolutionError,
@@ -302,19 +302,20 @@ def clear_model_catalog_cache() -> None:
def model_family_token(model_id: str) -> str:
"""Tag a model id with its vendor family.
"""Tag a model id with the harness family that can serve it.
Mirrors the token rule in
Shares the token rule with
:func:`omnigent.model_override.model_family_mismatch`: Claude ids
contain ``"claude"``; GPT ids contain ``"gpt"`` or ``"codex"``.
contain ``"claude"``; the ``"openai"`` token covers every
codex-compatible id (gpt/codex plus the GLM and Kimi families, which
serve on the same Responses wire).
:param model_id: Model id, e.g. ``"databricks-claude-opus-4-8"``.
:returns: ``"claude"``, ``"openai"``, or ``"other"``.
"""
lower = model_id.lower()
if "claude" in lower:
if "claude" in model_id.lower():
return "claude"
if "gpt" in lower or "codex" in lower:
if is_codex_compatible_model(model_id):
return "openai"
return "other"
+48 -16
View File
@@ -75,10 +75,11 @@ def validate_model_override(value: str) -> str:
_CLAUDE_FAMILY_HARNESSES: frozenset[str] = frozenset(
{"claude-native", "native-claude", "claude-sdk", "claude_sdk"}
)
# CODEX_CANONICAL_HARNESSES stays single-vendor (GPT-only): the gateway serves
# codex over the Anthropic-incompatible Responses wire, and codex >= 0.137
# dropped the chat/completions wire that was the only path to Claude — so a
# codex x Claude dispatch is genuinely broken and must fail loud here.
# CODEX_CANONICAL_HARNESSES is restricted to the codex-compatible families
# (see is_codex_compatible_model): the gateway serves codex over the
# Anthropic-incompatible Responses wire, and codex >= 0.137 dropped the
# chat/completions wire that was the only path to Claude — so a codex x Claude
# dispatch is genuinely broken and must fail loud here.
# openai-agents (and its "openai-agents-sdk" / "agents_sdk" spellings) is
# intentionally not included: a live SDK probe completed a Claude
# tool-calling turn on the gateway over the chat wire, so the harness is
@@ -109,15 +110,43 @@ _ANTIGRAVITY_FAMILY_HARNESSES: frozenset[str] = frozenset(
_DATABRICKS_GATEWAY_PREFIX = "databricks-"
# Vendor tokens that name a codex-runnable model. GPT/codex ids are the
# obvious case; GLM and Kimi serve on the same OpenResponses wire codex
# speaks (see the Responses-capable listing in
# ``omnigent/pi_native_credentials.py``), so codex can run them too.
_CODEX_COMPATIBLE_SEGMENT_TOKENS: tuple[str, ...] = ("gpt", "codex", "glm", "kimi")
# Ids are matched per segment (``-``/``_``/``.``/``/`` separated) with an
# optional trailing generation number, so ``system.ai.glm-5-2`` and
# ``kimi-k2-instruct`` match while an unrelated endpoint name that merely
# contains the letters (``glmqlfit-eval``) does not.
_ID_SEGMENT_SPLIT = re.compile(r"[^a-z0-9]+")
def is_codex_compatible_model(model: str) -> bool:
"""Report whether *model* can run on a codex harness.
:param model: Model id in any vocabulary, e.g. ``"databricks-glm-5-2"``.
:returns: ``True`` for the GPT/codex, GLM, and Kimi families.
"""
segments = _ID_SEGMENT_SPLIT.split(model.lower())
return any(
re.fullmatch(rf"{token}\d*", segment)
for segment in segments
for token in _CODEX_COMPATIBLE_SEGMENT_TOKENS
)
def model_family_mismatch(harness: str, model: str) -> str | None:
"""
Return a rejection reason when *model*'s family cannot run on *harness*.
Family is detected by vendor token: Claude ids contain ``"claude"``
(``databricks-claude-opus-4-8``), GPT ids contain ``"gpt"`` or
``"codex"`` (``databricks-gpt-5-4``). Single-vendor harnesses reject
the other family and ids whose family cannot be determined failing
loud at dispatch beats an opaque harness/gateway error after spawn.
(``databricks-claude-opus-4-8``); codex-compatible ids name gpt,
codex, glm, or kimi (``databricks-gpt-5-4``, ``system.ai.glm-5-2``).
Single-vendor harnesses reject the other family and ids whose family
cannot be determined failing loud at dispatch beats an opaque
harness/gateway error after spawn.
The Gemini-native ``antigravity`` harness rejects the Claude/GPT
families and any ``databricks-`` gateway id (it has no gateway path),
but accepts Gemini shapes and bare/ambiguous ids the SDK may honor.
@@ -132,20 +161,23 @@ def model_family_mismatch(harness: str, model: str) -> str | None:
canon = canonicalize_harness(harness)
lower = model.lower()
is_claude = "claude" in lower
# Antigravity's reject-list stays the narrow GPT/codex rule: GLM and Kimi
# ids carry no Gemini-native verdict, so they are not newly excluded here.
is_gpt = "gpt" in lower or "codex" in lower
if canon in _CLAUDE_FAMILY_HARNESSES and not is_claude:
return (
f"harness {canon!r} only runs Claude models (id containing "
f"'claude'); got {model!r}. Use the codex worker for GPT models "
"or the pi / openai-agents worker for any other gateway model."
)
if canon in CODEX_CANONICAL_HARNESSES and not is_gpt:
return (
f"harness {canon!r} only runs GPT models (id containing 'gpt' "
f"or 'codex'); got {model!r}. Use the claude_code worker for "
"Claude models or the pi / openai-agents worker for any other "
f"'claude'); got {model!r}. Use the codex worker for GPT / GLM / "
"Kimi models or the pi / openai-agents worker for any other "
"gateway model."
)
if canon in CODEX_CANONICAL_HARNESSES and not is_codex_compatible_model(model):
return (
f"harness {canon!r} only runs codex-compatible models (id naming "
f"'gpt', 'codex', 'glm', or 'kimi'); got {model!r}. Use the "
"claude_code worker for Claude models or the pi / openai-agents "
"worker for any other gateway model."
)
if canon in _ANTIGRAVITY_FAMILY_HARNESSES and (
is_claude or is_gpt or lower.startswith(_DATABRICKS_GATEWAY_PREFIX)
):
+10 -6
View File
@@ -97,15 +97,18 @@ KIRO_KEY = "kiro"
# - claude: `--mcp-config` (required by the native bridge) introduced long
# before 2026-06-01. The first Claude Code release after the cutoff is
# 2.1.161, so use that as the supported floor.
# - codex: native policy hook requires >= 0.129.0, but that shipped before
# 2026-06-01. The first Codex release after the cutoff is 0.137.0.
# - codex: native policy hook requires >= 0.129.0 and the hook-trust bypass
# >= 0.131.0, but both shipped before 2026-06-01. The floor is 0.145.0 — the
# version the subagent-router ``PreToolUse`` hook (``spawn_agent``
# rewrite/deny) is verified against; on older CLIs the flattened spawn tool
# name differs and routing silently no-ops.
# - cursor: Cursor's CLI uses ``YYYY.MM.DD[-build]`` date versions. Default
# to the day after 2026-06-01 so we don't support stale pre-June builds.
# - kimi: first ``kimi-cli`` release after 2026-06-01 is 1.47.0
# (https://github.com/MoonshotAI/kimi-cli/blob/main/CHANGELOG.md).
# - hermes: parent_session_id schema was introduced in v0.17.0, but Hermes now
# ships date-tagged releases; the first one after 2026-06-01 is 2026.06.05.
_CODEX_MIN_VERSION = "0.137.0"
_CODEX_MIN_VERSION = "0.145.0"
_PI_MIN_VERSION = "0.79.0"
_QWEN_MIN_VERSION = "0.18.1"
_GOOSE_MIN_VERSION = "1.38.0"
@@ -166,9 +169,10 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
login_args=("login",),
logout_args=("logout",),
status_args=("login", "status"),
# The native Codex policy hook requires ``codex >= 0.129.0``;
# anything older silently disables tool-call enforcement. Setup
# enforces the same floor up-front.
# The native Codex policy hook requires ``codex >= 0.129.0`` and the
# hook-trust bypass ``>= 0.131.0``; anything older silently disables
# tool-call enforcement. The subagent-router hook is verified on
# 0.145.0, which sets the floor. Setup enforces it up-front.
min_version=_CODEX_MIN_VERSION,
),
PI_KEY: HarnessInstallSpec(
+11 -54
View File
@@ -30,6 +30,11 @@ from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from omnigent import model_catalog
from omnigent.databricks_ai_gateway import (
DATABRICKS_AI_GATEWAY_LABEL,
DATABRICKS_TRUSTED_HOST_SUFFIXES,
is_databricks_ai_gateway_url,
)
from omnigent.model_metadata import ModelWireAPI
from omnigent.model_override import normalize_model_for_provider
from omnigent.onboarding.provider_config import (
@@ -88,60 +93,12 @@ _DATABRICKS_ANTHROPIC_GATEWAY_PATH = "/ai-gateway/anthropic"
_DATABRICKS_GATEWAY_CODEX_SUFFIX = "/codex/v1"
_DATABRICKS_GATEWAY_ANTHROPIC_SUFFIX = "/anthropic"
# Trusted parent domain suffixes for a Databricks-owned host. The AI Gateway
# lives under a per-workspace subdomain of one of these (the canonical form is
# ``<workspace>.ai-gateway.cloud.databricks.com``); the Azure / GCP control
# planes serve workspaces under their own parent domains. We anchor on the
# leading "." so a look-alike like ``...cloud.databricks.com.evil.test`` (which
# ends in ``.evil.test``) is rejected.
_DATABRICKS_TRUSTED_HOST_SUFFIXES = (
".cloud.databricks.com", # AWS workspaces + ai-gateway (incl. *.staging.cloud.databricks.com)
".azuredatabricks.net", # Azure Databricks
".gcp.databricks.com", # GCP Databricks
)
# A genuine AI Gateway host carries the ``ai-gateway`` DNS label; we require it
# (alongside a trusted suffix) so a non-gateway Databricks host isn't routed as
# the gateway's Anthropic surface.
_DATABRICKS_AI_GATEWAY_LABEL = "ai-gateway"
def _is_databricks_ai_gateway_url(base_url: str) -> bool:
"""Return ``True`` only for a genuine Databricks AI Gateway base URL.
Two URL shapes are accepted:
1. **Dedicated AI Gateway subdomain** ``ai-gateway`` is a full DNS label
in the hostname (e.g. ``<id>.ai-gateway.cloud.databricks.com``). Used by
the standard ``isaac configure codex`` setup.
2. **Workspace-hosted gateway** the hostname is a plain Databricks
workspace (ends with a trusted suffix) and the path starts with
``/ai-gateway/`` (e.g. ``<workspace>.cloud.databricks.com/ai-gateway/...``).
Used by ucode / Codex app profile setups.
Both cases require ``https`` and a hostname ending with a trusted
Databricks-owned domain suffix to prevent token-forwarding attacks.
:param base_url: The codex provider table's ``base_url``.
:returns: ``True`` iff the URL is an https Databricks AI Gateway endpoint.
"""
parsed = urlparse(base_url)
if parsed.scheme != "https":
return False
hostname = parsed.hostname
if not hostname:
return False
hostname = hostname.lower()
trusted = any(hostname.endswith(suffix) for suffix in _DATABRICKS_TRUSTED_HOST_SUFFIXES)
if not trusted:
return False
# Shape 1: ``ai-gateway`` is a full DNS label in the hostname.
labels = hostname.split(".")
if _DATABRICKS_AI_GATEWAY_LABEL in labels:
return True
# Shape 2: workspace hostname + /ai-gateway/ path prefix.
path = parsed.path or ""
return path.startswith("/ai-gateway/")
# Aliases for the canonical Databricks AI Gateway predicate and its constants,
# which live in :mod:`omnigent.databricks_ai_gateway` so every surface that must
# recognize the gateway agrees.
_DATABRICKS_TRUSTED_HOST_SUFFIXES = DATABRICKS_TRUSTED_HOST_SUFFIXES
_DATABRICKS_AI_GATEWAY_LABEL = DATABRICKS_AI_GATEWAY_LABEL
_is_databricks_ai_gateway_url = is_databricks_ai_gateway_url
def _databricks_workspace_url_for_gateway(
+82 -2
View File
@@ -2565,11 +2565,17 @@ def create_runner_app(
if _start_verdict.data is not None:
_apply_sandbox_override_from_verdict(spec, _start_verdict.data)
await _ensure_session_subagent_router(
session_id,
harness_name,
server_client=server_client,
)
spawn_env = _build_spawn_env_from_spec(
spec,
harness_name,
workdir=_resolved_spec_workdir(spec_entry),
cwd=await _session_runtime_cwd(session_id),
session_id=session_id,
)
if spawn_env is None:
spawn_env = await _resolve_native_spawn_env(
@@ -3097,6 +3103,13 @@ def create_runner_app(
session_id=session_id,
)
# The SDK harnesses' router is started here (not by a terminal launch
# path), so this is its only teardown: without it the session leaks an
# HTTP server, its thread, and a live bearer token on disk.
from omnigent.runner.subagent_routing import shutdown_session_router
await asyncio.to_thread(shutdown_session_router, session_id)
_session_spec_cache.pop(session_id, None)
_session_skills_cache.pop(session_id, None)
_drop_session_claude_launch_config(session_id)
@@ -4992,6 +5005,7 @@ def create_runner_app(
workdir=cached_spec_workdir,
cwd=await _session_runtime_cwd(conv),
model_override=msg_body.get("model_override"),
session_id=conv,
)
from omnigent.runtime.prompt import build_instructions
@@ -5023,6 +5037,19 @@ def create_runner_app(
"role": "user",
"model": msg_body.get("model", ""),
}
# The routed model rides in-band on the forwarded message. This body is
# built field by field (not copied), so it must be threaded explicitly:
# the harness forwards it onto CreateResponseRequest.model_override and
# the executor adapter into ExecutorConfig.model, which is how a native
# terminal learns to switch models for this turn.
_model_override = msg_body.get("model_override")
if isinstance(_model_override, str) and _model_override:
harness_body["model_override"] = _model_override
_logger.info(
"_run_turn_bg: conv=%s received model_override=%s (forwarding to harness)",
conv,
_model_override,
)
if _session_histories[conv]:
history = _session_histories[conv]
if any("created_by" in item for item in history):
@@ -5785,7 +5812,8 @@ def create_runner_app(
body = await request.json()
body_type = body.get("type") if isinstance(body, dict) else None
_logger.info(
"post_session_events: conv=%s type=%s active=%s buffer_len=%d content_types=%s",
"post_session_events: conv=%s type=%s active=%s buffer_len=%d content_types=%s "
"model_override=%s",
conversation_id,
body_type,
conversation_id in _active_turns,
@@ -5793,6 +5821,7 @@ def create_runner_app(
[b.get("type") for b in body.get("content", []) if isinstance(b, dict)]
if isinstance(body, dict)
else "N/A",
body.get("model_override") if isinstance(body, dict) else None,
)
if body_type == "message" or body_type is None:
if not isinstance(body, dict):
@@ -8480,7 +8509,12 @@ async def _resolve_harness_config(
harness = harness_override or spec.executor.config.get("harness") or spec.executor.type
harness = canonicalize_harness(harness) or harness
spawn_env = _build_spawn_env_from_spec(
spec, harness, cwd=cwd, workdir=workdir, model_override=model_override
spec,
harness,
cwd=cwd,
workdir=workdir,
model_override=model_override,
session_id=session_id,
)
return harness, spawn_env
@@ -8515,6 +8549,40 @@ _HARNESS_MODEL_ENV_KEY: dict[str, str] = {
_HARNESS_MODEL_ENV_KEY = model_env_keys()
async def _ensure_session_subagent_router(
session_id: str,
harness: str | None,
*,
server_client: httpx.AsyncClient | None,
) -> None:
"""Start this session's subagent-routing endpoint.
Only for the SDK harness families: the native terminals know their own
bridge directory and start the router from their launch paths, where
the harness's hooks are also pointed at it. Started for every session
regardless of its routing state the server gates each spawn on the
session's (mid-session togglable) subagent-routing setting.
Never raises: ``ensure_session_router_quietly`` owns the bridge-dir
resolution too, so a hostile or pre-existing ``$TMPDIR`` root cannot
fail session creation for harnesses that do not even use routing.
:param session_id: Session/conversation identifier.
:param harness: Canonical harness name, e.g. ``"claude-sdk"``.
:param server_client: Runnerserver client the relay forwards on.
``None`` (in-process tests) skips the start.
"""
from omnigent.runner.subagent_routing import ensure_session_router_quietly
if is_native_harness(harness):
return
ensure_session_router_quietly(
session_id,
server_client=server_client,
harness=harness,
)
def _build_spawn_env_from_spec(
spec: Any,
harness: str,
@@ -8522,6 +8590,7 @@ def _build_spawn_env_from_spec(
cwd: Path | None = None,
workdir: Path | None = None,
model_override: str | None = None,
session_id: str | None = None,
) -> dict[str, str] | None:
"""Build spawn-env from spec — mirrors workflow.py's helpers.
@@ -8529,6 +8598,8 @@ def _build_spawn_env_from_spec(
:param harness: Canonical harness name, e.g. ``"claude-sdk"``.
:param cwd: Runtime working directory for harnesses that need it.
:param workdir: Bundle workdir, threaded to the builders.
:param session_id: Session/conversation id, used to hand the harness
this session's subagent-routing endpoint. ``None`` omits it.
:param model_override: The per-session ``/model`` override, e.g.
``"claude-sonnet-4-6"``, or ``None``. When set, it overrides the
``HARNESS_<H>_MODEL`` the builder baked in (spec model / provider
@@ -8598,6 +8669,15 @@ def _build_spawn_env_from_spec(
except ImportError:
return None
# Point the harness process at this session's subagent-routing endpoint
# when one is running (started at session init). Scoped to *harness* so a
# codex executor beneath a claude session never sees the codex router vars
# carrying the parent's session id. Empty when the session has no router.
if env is not None and session_id:
from omnigent.runner.subagent_routing import session_router_env
env.update(session_router_env(session_id, harness))
# Per-session ``/model`` override wins over everything the builder baked
# into HARNESS_<H>_MODEL. Without this, `/model` is recorded in the
# readout but the turn still uses the provider/catalog default.
+164 -25
View File
@@ -21,13 +21,14 @@ import urllib.parse
import uuid
from collections.abc import Awaitable, Callable, Mapping, MutableMapping
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, NamedTuple
if TYPE_CHECKING:
# Type-only import: the runner keeps codex deps out of its runtime import
# graph (they are imported lazily inside the codex-native helpers).
from omnigent.claude_native import ClaudeNativeUcodeConfig
from omnigent.codex_native_app_server import CodexAppServerClient
from omnigent.runner.subagent_routing import SubagentRouter
import click
import httpx
@@ -389,6 +390,76 @@ class _KiroNativeLaunchConfig:
model_override: str | None = None
class _NativeRouterLaunch(NamedTuple):
"""What a native launch site needs back from the router start.
:param advertised_dir: Directory to point the harness's hooks at, or
``None`` when no endpoint is running.
:param router: The handle to hand back to
:func:`_shutdown_session_router_async`, so a delayed teardown from
this launch cannot close a router a re-create has since installed.
"""
advertised_dir: Path | None
router: SubagentRouter | None
def _start_subagent_router_for_native_session(
session_id: str,
*,
bridge_dir: Path,
harness: str,
server_client: httpx.AsyncClient | None,
) -> _NativeRouterLaunch:
"""Start the subagent-routing endpoint for a native session.
Native harnesses enforce routing through hooks configured at terminal
launch, so the endpoint has to be live (and advertised in the bridge
dir the hooks read) before the CLI starts. Installed for every
session, routed or not: the server re-reads the session's
subagent-routing setting on each spawn, so a session that starts
unrouted can still be toggled on mid-flight.
:param session_id: Session/conversation identifier.
:param bridge_dir: Session bridge directory the hooks discover.
:param harness: Harness the router is being installed for; logged on
failure.
:param server_client: Runnerserver client the relay forwards on.
:returns: The advertisement directory to point hooks at (``None`` when
the endpoint could not start) paired with the router handle.
"""
from omnigent.runner.subagent_routing import ensure_session_router_quietly
router = ensure_session_router_quietly(
session_id,
bridge_dir=bridge_dir,
server_client=server_client,
harness=harness,
)
return _NativeRouterLaunch(bridge_dir if router is not None else None, router)
async def _shutdown_session_router_async(
session_id: str, router: SubagentRouter | None = None
) -> None:
"""Tear down a session's subagent-routing endpoint off the event loop.
``shutdown_session_router`` joins the router's serving thread, so
calling it inline would block the loop for up to the shutdown poll
interval. A session with no router is a no-op.
:param session_id: Session/conversation identifier.
:param router: Handle this launch started. Passing it scopes the
teardown to that router, so a forwarder whose ``finally`` runs
after a terminal re-create does not close the new session's live
endpoint.
:returns: None.
"""
from omnigent.runner.subagent_routing import shutdown_session_router
await asyncio.to_thread(shutdown_session_router, session_id, router)
def _required_runner_env(name: str) -> str:
"""
Return a required runner environment variable.
@@ -3659,6 +3730,19 @@ async def _auto_create_codex_terminal(
ap_auth_headers=policy_headers,
bypass_sandbox=launch_config.bypass_sandbox,
)
# Generate routing hooks.json (and bypass codex's hook-trust prompt): the
# app-server reads the endpoint out of its own process env at start, and
# the server decides per spawn whether to route.
_codex_router_dir, _codex_router = _start_subagent_router_for_native_session(
session_id,
bridge_dir=bridge_dir,
harness="codex-native",
server_client=server_client,
)
if _codex_router_dir is not None:
from omnigent.runner.subagent_routing import router_env
app_server.env.update(router_env(session_id, _codex_router_dir, harness="codex-native"))
app_server.listen_url = codex_ws_url
await app_server.start()
_AUTO_CODEX_APP_SERVERS[session_id] = app_server
@@ -3745,11 +3829,19 @@ async def _auto_create_codex_terminal(
# Omnigent provisions the private CODEX_HOME and vets
# hook sources itself; skip the interactive trust prompt
# that headless sub-agents can never answer.
# Gated on version: the flag was added in 0.140.0; on
# older binaries it causes an immediate exit error.
#
# Requires a *positively parsed* version, unlike the
# hooks-file gate in ``codex_native_app_server``, which
# treats an unknown version as supported. The two differ
# because their failure modes do: an unsupported hooks
# file is ignored by codex and caught downstream at the
# trust check, whereas an unknown CLI flag aborts argv
# parsing — so a transient ``codex --version`` hiccup on a
# pre-0.131 codex would turn a recoverable trust prompt
# into a dead terminal.
bypass_hook_trust=(
app_server.codex_cli_version is None
or app_server.codex_cli_version >= _MIN_BYPASS_HOOK_TRUST_CODEX_VERSION
app_server.codex_cli_version is not None
and app_server.codex_cli_version >= _MIN_BYPASS_HOOK_TRUST_CODEX_VERSION
),
),
env=codex_terminal_env(app_server),
@@ -3794,6 +3886,7 @@ async def _auto_create_codex_terminal(
codex_home=codex_home,
event_client=event_client,
routing_summary=_codex_launch.summary,
subagent_router=_codex_router,
)
if launch_config.external_session_id is None
else _codex_forward_known_thread(
@@ -3801,6 +3894,7 @@ async def _auto_create_codex_terminal(
bridge_dir=bridge_dir,
codex_ws_url=codex_ws_url,
thread_id=launch_config.external_session_id,
subagent_router=_codex_router,
)
),
name=f"codex-forwarder-{session_id}",
@@ -3830,6 +3924,7 @@ async def _codex_discover_thread_and_forward(
codex_home: Path,
event_client: CodexAppServerClient,
routing_summary: str,
subagent_router: SubagentRouter | None = None,
) -> None:
"""
Adopt the fresh Codex TUI's thread, then mirror it into the Omnigent session.
@@ -3854,6 +3949,9 @@ async def _codex_discover_thread_and_forward(
routing (provider / profile / model, or the login-fallback state),
threaded into the startup-timeout error so hosted users can diagnose
without runner-log access (see #2745).
:param subagent_router: Router this terminal launch started, torn down
in the ``finally``. Passed so a late teardown cannot close the
endpoint a re-created terminal has since installed.
"""
from omnigent.codex_native_bridge import (
CodexNativeBridgeState,
@@ -3972,6 +4070,7 @@ async def _codex_discover_thread_and_forward(
if leftover_app_server is not None:
with contextlib.suppress(Exception):
await leftover_app_server.close()
await _shutdown_session_router_async(session_id, subagent_router)
async def _codex_forward_known_thread(
@@ -3980,6 +4079,7 @@ async def _codex_forward_known_thread(
bridge_dir: Path,
codex_ws_url: str,
thread_id: str,
subagent_router: SubagentRouter | None = None,
) -> None:
"""
Forward a runner-owned Codex terminal that resumes an existing thread.
@@ -3990,6 +4090,9 @@ async def _codex_forward_known_thread(
``"ws://127.0.0.1:9876"``.
:param thread_id: Existing Codex app-server thread id, e.g.
``"thread_abc123"``.
:param subagent_router: Router this terminal launch started, torn down
in the ``finally``. Passed so a late teardown cannot close the
endpoint a re-created terminal has since installed.
:returns: None. Runs until cancelled or the app-server connection
closes.
"""
@@ -4018,6 +4121,7 @@ async def _codex_forward_known_thread(
if leftover_app_server is not None:
with contextlib.suppress(Exception):
await leftover_app_server.close()
await _shutdown_session_router_async(session_id, subagent_router)
async def _run_antigravity_reader(
@@ -5310,12 +5414,14 @@ class _ClaudeSessionLaunchMetadata:
external_session_id: str | None = None
fork_source_external_id: str | None = None
fork_carry_history: bool = False
routing_enabled: bool = False
def _claude_launch_metadata_from_envelope(
session_init: RunnerSessionInitEnvelope,
) -> _ClaudeSessionLaunchMetadata:
"""Project Claude launch metadata without server callbacks."""
from omnigent.runner.subagent_routing import routing_enabled
from omnigent.stores.conversation_store import (
FORK_CARRY_HISTORY_LABEL_KEY,
FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY,
@@ -5324,6 +5430,7 @@ def _claude_launch_metadata_from_envelope(
snapshot = session_init.snapshot
fork_source = snapshot.labels.get(FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY)
return _ClaudeSessionLaunchMetadata(
routing_enabled=routing_enabled(snapshot.cost_control_mode_override),
reasoning_effort=snapshot.reasoning_effort,
model_override=snapshot.model_override,
terminal_launch_args=snapshot.terminal_launch_args,
@@ -5340,6 +5447,7 @@ async def _load_legacy_claude_launch_metadata(
session_id: str,
) -> _ClaudeSessionLaunchMetadata:
"""Fetch Claude launch metadata for servers predating the init envelope."""
from omnigent.runner.subagent_routing import routing_enabled
from omnigent.stores.conversation_store import (
FORK_CARRY_HISTORY_LABEL_KEY,
FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY,
@@ -5367,7 +5475,11 @@ async def _load_legacy_claude_launch_metadata(
labels = snapshot.get("labels")
labels = labels if isinstance(labels, dict) else {}
fork_source = labels.get(FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY)
cost_control_mode = snapshot.get("cost_control_mode_override")
metadata = _ClaudeSessionLaunchMetadata(
routing_enabled=routing_enabled(
cost_control_mode if isinstance(cost_control_mode, str) else None
),
reasoning_effort=effort if isinstance(effort, str) and effort else None,
model_override=(
model_override if isinstance(model_override, str) and model_override else None
@@ -5603,6 +5715,8 @@ async def _auto_create_claude_terminal(
from omnigent.claude_native import (
augment_claude_args,
build_native_claude_terminal_env,
claude_config_with_launch_model_pinned,
claude_config_with_routed_arms_pinned,
resolve_claude_native_model_selection,
resolve_native_claude_config,
)
@@ -5818,24 +5932,36 @@ async def _auto_create_claude_terminal(
"and that the secret resolves in this process.",
exc_info=True,
)
if record_launch_config is not None:
record_launch_config(session_id, claude_config)
_logger.info(
"Claude terminal provider config resolved: session=%s configured=%s "
"env_keys=%s api_key_helper_set=%s model_set=%s",
session_id,
claude_config is not None,
sorted(claude_config.env) if claude_config is not None else [],
bool(claude_config.api_key_helper) if claude_config is not None else False,
bool(claude_config.model) if claude_config is not None else False,
)
# A routed session's turn-1 ``/model`` can only reach ids this launch env
# spells, so point the family aliases at the router's frozen arms before the
# launch model is derived from them.
if launch_metadata.routing_enabled:
from omnigent.server.smart_routing import task_v1_claude_arms
claude_config = claude_config_with_routed_arms_pinned(claude_config, task_v1_claude_arms())
launch_model = resolve_claude_native_model_selection(
session_model_override
or _claude_native_model_from_spec(agent_spec)
or (claude_config.model if claude_config is not None else None),
claude_config,
)
# Give an exact launch model (a Smart Routing pick is resolved before the
# terminal exists) a spelling of its own in the picker, so a later
# ``/model`` can return to it instead of stepping onto whatever the family
# alias points at. Recorded below, so the picker and the launch agree.
claude_config = claude_config_with_launch_model_pinned(claude_config, launch_model)
if record_launch_config is not None:
record_launch_config(session_id, claude_config)
_logger.info(
"Claude terminal provider config resolved: session=%s configured=%s "
"env_keys=%s api_key_helper_set=%s model_set=%s launch_model=%s",
session_id,
claude_config is not None,
sorted(claude_config.env) if claude_config is not None else [],
bool(claude_config.api_key_helper) if claude_config is not None else False,
bool(claude_config.model) if claude_config is not None else False,
launch_model,
)
base_claude_args = _build_claude_native_base_args(
reasoning_effort=session_effort,
# Precedence: per-session ``/model`` override > agent-spec pin
@@ -5855,6 +5981,15 @@ async def _auto_create_claude_terminal(
# has the spec resolver) expose a bundle's ``skills/`` to Claude Code
# via ``--plugin-dir`` — the CLI mirror of the SDK plugin wiring.
# ``api_key_helper`` (ucode) registers Claude's gateway token command.
# Gate natively spawned subagents (the Task/Agent tool): start the loopback
# endpoint in the bridge dir the PreToolUse hook already discovers. Always
# installed — the server decides per spawn whether to route.
subagent_router_dir, _subagent_router = _start_subagent_router_for_native_session(
session_id,
bridge_dir=bridge_dir,
harness="claude-native",
server_client=server_client,
)
claude_args = augment_claude_args(
base_claude_args,
bridge_dir=bridge_dir,
@@ -5864,6 +5999,7 @@ async def _auto_create_claude_terminal(
agent_name=agent_name,
skills_filter=skills_filter,
api_key_helper=claude_config.api_key_helper if claude_config is not None else None,
subagent_router_dir=subagent_router_dir,
)
# Let a registered launcher plugin (e.g. Databricks' isaac) rewrite the
@@ -6007,15 +6143,18 @@ async def _auto_create_claude_terminal(
from omnigent.claude_native_forwarder import supervise_forwarder
async def _supervise_bridge() -> None:
await supervise_forwarder(
base_url=server_url,
headers=_runner_headers,
session_id=session_id,
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=resume_external_session_id is not None,
auth=_runner_auth,
)
try:
await supervise_forwarder(
base_url=server_url,
headers=_runner_headers,
session_id=session_id,
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=resume_external_session_id is not None,
auth=_runner_auth,
)
finally:
await _shutdown_session_router_async(session_id, _subagent_router)
_forwarder_task = asyncio.create_task(
_supervise_bridge(),
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -8,10 +8,17 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from omnigent.server.smart_routing import RoutingClient
from omnigent.server.smart_routing import RoutingClient, RoutingSettings
from omnigent.spec.types import LLMConfig, PolicySpec
def _default_routing_settings() -> RoutingSettings:
"""Build the default :class:`RoutingSettings` (imported lazily)."""
from omnigent.server.smart_routing import RoutingSettings
return RoutingSettings()
@dataclass
class RuntimeCaps:
"""
@@ -78,3 +85,8 @@ class RuntimeCaps:
# Managed deployments can supply a different implementation (e.g.
# a rules engine or remote service). ``None`` disables routing.
routing_client: RoutingClient | None = None
# Routing knobs parsed from the ``routing:`` block of the server --config
# YAML (router name, extraction model, scenario menus, subagent fail mode).
# Always present so consumers read one value object instead of re-parsing
# config; the defaults describe an unconfigured deployment.
routing_settings: RoutingSettings = field(default_factory=_default_routing_settings)
+165
View File
@@ -0,0 +1,165 @@
"""In-process index of session-scoped warnings.
A *warning* is a degraded-but-running condition the UI shows in the
session header while the session keeps working today only
``subagent_routing_unenforced``, published when a harness's router hook
never fired so native sub-agent spawns are not being gated.
Same shape as the other transient recovery indexes
(:mod:`pending_elicitations`, :mod:`pending_inputs`): populated by the
route layer, replayed into the cold-load snapshot
(``GET /v1/sessions/{id}``) via :func:`snapshot_for`, in-memory only and
process-affine. Losing a warning on restart is acceptable the
publisher re-posts it the next time it observes the condition.
Entries are deduplicated on ``(code, harness)`` so a forwarder that
re-observes the same condition every poll tick does not grow the list.
A warning is not sticky: the condition can be repaired mid-session (the
canary fires on a later turn), so publishers clear it with :func:`clear`
and a deleted session's entries are pruned. A clear names the codes the
caller actually checked, so one publisher's "repaired" cannot drop
another publisher's warning.
"""
from __future__ import annotations
import logging
import threading
from collections.abc import Iterable
from typing import Any
_logger = logging.getLogger(__name__)
#: Warning code for "a harness ran without the router hook enforcing picks".
SUBAGENT_ROUTING_UNENFORCED = "subagent_routing_unenforced"
#: Codes this index accepts. Publishers are runner-side and reach it over
#: the event API, so an unknown code is dropped rather than stored: the UI
#: has no copy for it and an unbounded key space is a memory sink.
ACCEPTED_CODES: frozenset[str] = frozenset({SUBAGENT_ROUTING_UNENFORCED})
#: Codes published over the session's ``external_session_warning`` channel.
#: A publisher on that channel re-checks all of them every tick, so its
#: "nothing is wrong" post may clear exactly these and nothing else.
EXTERNAL_WARNING_CODES: frozenset[str] = frozenset({SUBAGENT_ROUTING_UNENFORCED})
#: Fields kept off a posted payload, and the cap on free-text values.
_KEPT_FIELDS: tuple[str, ...] = ("code", "harness", "reason")
_MAX_FIELD_LEN = 500
#: ``harness`` is part of the dedup key, so a long free-text value would let
#: one publisher mint unbounded distinct entries; it names a harness, so a
#: short cap is enough for every real value ("codex-native").
_MAX_HARNESS_LEN = 64
#: Hard cap on entries per session. Codes are allowlisted and the key's other
#: half is a harness id, so a session realistically holds one or two; the cap
#: bounds a misbehaving publisher to a fixed footprint. Oldest entries are
#: evicted first so the newest observation always lands.
_MAX_ENTRIES_PER_SESSION = 8
_warnings: dict[str, list[dict[str, Any]]] = {}
_lock = threading.Lock()
def _key(warning: dict[str, Any]) -> tuple[str, str]:
return (str(warning.get("code") or ""), str(warning.get("harness") or ""))
def _sanitized(warning: dict[str, Any]) -> dict[str, Any] | None:
"""Reduce a posted payload to the known string fields, or reject it."""
code = warning.get("code")
if not isinstance(code, str) or code not in ACCEPTED_CODES:
return None
entry: dict[str, Any] = {}
for field in _KEPT_FIELDS:
value = warning.get(field)
if isinstance(value, str) and value:
cap = _MAX_HARNESS_LEN if field == "harness" else _MAX_FIELD_LEN
entry[field] = value[:cap]
return entry
def record(session_id: str, warning: dict[str, Any]) -> None:
"""
Record one warning for *session_id*, replacing any same-key entry.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param warning: Warning payload, e.g. ``{"code":
"subagent_routing_unenforced", "harness": "codex-native",
"reason": "SessionStart canary did not fire"}``. Ignored unless
its ``code`` is in :data:`ACCEPTED_CODES`; unknown fields are
dropped and free text is truncated. A session holds at most
:data:`_MAX_ENTRIES_PER_SESSION` entries the oldest is evicted
to make room.
"""
entry = _sanitized(warning)
if entry is None:
_logger.info(
"ignoring session warning with unknown code %r for session %s",
warning.get("code"),
session_id,
)
return
with _lock:
entries = _warnings.setdefault(session_id, [])
key = _key(entry)
for index, existing in enumerate(entries):
if _key(existing) == key:
entries[index] = entry
return
while len(entries) >= _MAX_ENTRIES_PER_SESSION:
dropped = entries.pop(0)
_logger.info(
"session %s holds %d warnings; dropping the oldest (%r)",
session_id,
_MAX_ENTRIES_PER_SESSION,
dropped.get("code"),
)
entries.append(entry)
def clear(session_id: str, codes: Iterable[str] | None = None) -> None:
"""
Drop a session's recorded warnings.
Called when a publisher reports the condition repaired (the router
canary fired on a later turn) and when the session is deleted, so a
banner does not outlive what it describes.
A caller passes the codes its own check covers: warnings published by
someone else are evidence this caller never looked at, so a blanket
clear would silently drop them. ``None`` is for teardown only (the
session is gone, so every code goes with it).
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param codes: Only drop entries carrying one of these codes, e.g.
``("subagent_routing_unenforced",)``. ``None`` drops all of them.
"""
with _lock:
if codes is None:
_warnings.pop(session_id, None)
return
dropped_codes = frozenset(codes)
entries = _warnings.get(session_id)
if entries is None:
return
remaining = [entry for entry in entries if entry.get("code") not in dropped_codes]
if remaining:
_warnings[session_id] = remaining
else:
_warnings.pop(session_id, None)
def snapshot_for(session_id: str) -> list[dict[str, Any]]:
"""
Return the warnings to replay into a session snapshot.
:param session_id: Session/conversation identifier.
:returns: Warning payloads in the order they were first recorded;
empty when nothing is wrong (the common case).
"""
with _lock:
return [dict(entry) for entry in _warnings.get(session_id, ())]
+5 -4
View File
@@ -224,8 +224,8 @@ class HostConnection:
:param pending_installs: Per-``request_id`` futures for in-flight
``host.install_harness`` requests. Resolved when the host sends
``host.install_harness_result``. Values carry the result fields
(``status``, ``configured_harnesses``, ``error``). Same ``Any``
typing rationale as ``pending_stats``.
(``status``, ``configured_harnesses``, ``gateway_inference``,
``error``). Same ``Any`` typing rationale as ``pending_stats``.
:param inflight_installs: Install tasks used to coalesce concurrent
install requests for the same harness family (a double-click, or
two spellings of one npm package) onto one in-flight install, so
@@ -235,8 +235,9 @@ class HostConnection:
:param pending_secret_writes: Per-``request_id`` futures for in-flight
``host.store_secret`` requests (a UI-driven harness credential write).
Resolved when the host sends ``host.store_secret_result``. Values carry
the result fields (``status``, ``configured_harnesses``, ``error``)
never the secret. Same ``Any`` typing rationale as ``pending_stats``.
the result fields (``status``, ``configured_harnesses``,
``gateway_inference``, ``error``) never the secret. Same ``Any``
typing rationale as ``pending_stats``.
:param credential_write_lock: Serializes credential writes to this host so
two overlapping requests (a double-click, or key + gateway in quick
succession) can't interleave the daemon's non-atomic
@@ -0,0 +1,48 @@
"""One round-trip helper for ``host.model_options``.
Two callers ask a host which models a harness could launch with: the
``/v1/hosts/{id}/model-options`` route (which turns a failure into an HTTP
error) and the session-create routing path (which degrades to no
candidates). Only the failure handling differs, so the request-id /
future / frame / timeout / cleanup shape lives here once.
"""
from __future__ import annotations
import asyncio
import secrets
from typing import Any
from omnigent.host.frames import HostModelOptionsFrame, encode_host_frame
from omnigent.server.host_registry import HostConnection, HostRegistry
async def request_host_model_options(
*,
host_registry: HostRegistry,
host_conn: HostConnection,
harness: str,
timeout_s: float,
) -> dict[str, Any]:
"""
Send a ``host.model_options`` frame and await the host's result.
:param host_registry: Registry used to enqueue the outbound frame.
:param host_conn: Live host connection to query.
:param harness: Native harness id, e.g. ``"claude-native"``.
:param timeout_s: Seconds to wait for the result frame.
:returns: The result payload, e.g. ``{"status": "ok", "models": [...]}``.
:raises ConnectionError: The host connection dropped before the frame
could be enqueued.
:raises asyncio.TimeoutError: The host did not answer within
*timeout_s*.
"""
request_id = secrets.token_hex(8)
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
host_conn.pending_model_options[request_id] = future
frame = encode_host_frame(HostModelOptionsFrame(request_id=request_id, harness=harness))
try:
host_registry.send_text(host_conn, frame)
return await asyncio.wait_for(future, timeout=timeout_s)
finally:
host_conn.pending_model_options.pop(request_id, None)
@@ -92,6 +92,12 @@ _EXTERNAL_ELICITATION_RESOLVED_TYPE: str = "external_elicitation_resolved"
_EXTERNAL_SESSION_STATUS_TYPE: str = "external_session_status"
# Session-scoped degraded-but-running conditions posted by a native
# forwarder (e.g. a router hook that never fired). Recorded in the
# in-process warnings index and replayed on the session snapshot.
_EXTERNAL_SESSION_WARNING_TYPE: str = "external_session_warning"
_EXTERNAL_SESSION_STATUS_VALUES: frozenset[str] = frozenset(
{"idle", "running", "waiting", "failed"}
)
@@ -359,6 +365,7 @@ _ALLOWED_EVENT_TYPES: frozenset[str] = frozenset(ITEM_TYPE_TO_DATA_CLS.keys()) |
_EXTERNAL_SESSION_SUPERSEDED_TYPE,
_EXTERNAL_ELICITATION_RESOLVED_TYPE,
_EXTERNAL_SESSION_STATUS_TYPE,
_EXTERNAL_SESSION_WARNING_TYPE,
_EXTERNAL_SESSION_USAGE_TYPE,
_EXTERNAL_COMPACTION_STATUS_TYPE,
_EXTERNAL_MCP_STARTUP_TYPE,
@@ -621,6 +628,11 @@ _MAX_TERMINAL_LAUNCH_ARG_LEN = 4096
COST_CONTROL_OVERRIDE_VALUES = frozenset({"on", "off"})
# Per-session subagent-routing switch; ``None`` (absent) inherits the
# session's main routing state instead of forcing either way.
SUBAGENT_ROUTING_OVERRIDE_VALUES = frozenset({"on", "off"})
_CHILD_PREVIEW_LIMIT = 150
@@ -702,6 +714,7 @@ def get_server_host_registry() -> HostRegistry | None:
__all__ = [
"COST_CONTROL_OVERRIDE_VALUES",
"SUBAGENT_ROUTING_OVERRIDE_VALUES",
"_ALLOWED_EVENT_TYPES",
"_ANTIGRAVITY_NATIVE_ELICITATION_HOOK_TIMEOUT_S",
"_APPROVAL_TYPE",
@@ -765,6 +778,7 @@ __all__ = [
"_EXTERNAL_SESSION_SUPERSEDED_TYPE",
"_EXTERNAL_SESSION_TODOS_TYPE",
"_EXTERNAL_SESSION_USAGE_TYPE",
"_EXTERNAL_SESSION_WARNING_TYPE",
"_EXTERNAL_STATUS_ASSISTANT_SCAN_LIMIT",
"_EXTERNAL_SUBAGENT_START_TYPE",
"_EXTERNAL_TOOL_OUTPUT_DELTA_TYPE",
+79 -11
View File
@@ -69,6 +69,7 @@ from omnigent.runner.identity import (
token_bound_runner_id,
)
from omnigent.runner.routing import RunnerRouter
from omnigent.runner.subagent_routing import ROUTING_DECISION_LABEL_KEY
from omnigent.runner.transports.ws_tunnel.registry import TunnelRegistry
from omnigent.runtime import (
get_policy_store,
@@ -5502,25 +5503,48 @@ async def _emit_server_routing_decision(
verdict: dict[str, Any],
*,
agent: str | None = None,
) -> None:
scope: str = "turn",
harness: str | None = None,
decision_id: str | None = None,
attempted_override: str | None = None,
) -> str | None:
"""Persist and publish a ``routing_decision`` transcript chip.
Called by the server-side routing path before the turn is forwarded
to the runner. The chip shows the judge's model pick at turn start
the same UX the runner-side advisor produced, but driven entirely
by the server.
by the server. Also records the decision in usage telemetry
(:mod:`omnigent.telemetry.routing`), so every server-side routing
decision is reported in exactly one place; parent-transcript mirrors
(``agent`` set) restate a decision and are not counted again.
:param agent: Sub-agent name to include when mirroring a child
session's routing decision into the parent's transcript.
:param scope: What the decision governs, e.g. ``"child_session"``.
:param harness: Harness the decision applies to, when it picked one.
:param decision_id: Decision identity shared with telemetry and the
child-sessions API. ``None`` mints one.
:param attempted_override: Model an LLM-supplied ``args.model`` asked
for and the router overrode. ``None`` when nothing was attempted.
:returns: The decision id, so callers can join it onto the session
row, or ``None`` when the payload failed validation and no chip
was recorded.
"""
import uuid
rationale = verdict.get("rationale", "")
applied = verdict.get("applied", True)
resolved_decision_id = decision_id or str(uuid.uuid4())
raw_model = verdict.get("raw_model")
item_data: dict[str, Any] = {
"model": model,
"applied": bool(applied),
"rationale": rationale if isinstance(rationale, str) else "",
"scope": scope,
"harness": harness,
"decision_id": resolved_decision_id,
"raw_model": raw_model if isinstance(raw_model, str) and raw_model else None,
"attempted_override": attempted_override,
}
if agent is not None:
item_data["agent"] = agent
@@ -5528,7 +5552,28 @@ async def _emit_server_routing_decision(
parsed_data = parse_item_data("routing_decision", item_data)
except (ValueError, TypeError):
_logger.warning("Server routing: failed to parse routing_decision data")
return
return None
# Counted only once the decision is known well-formed — a payload that
# produces no chip must not show up as a decision in telemetry. A mirror
# copy into the parent's transcript (``agent`` set) restates a decision
# already recorded, so only the primary emission is counted.
if agent is None:
from omnigent.telemetry import record_routing_decision
record_routing_decision(
session_id,
scope=scope,
harness=harness,
# The server-side path has no allow/deny vocabulary: it either
# installed the router's pick or left the turn alone.
action="rewrite" if applied else "allow",
applied=bool(applied),
model=model,
raw_model=item_data["raw_model"],
overrode_agent_model=attempted_override is not None,
decision_id=resolved_decision_id,
)
routing_item = NewConversationItem(
type="routing_decision",
@@ -5557,6 +5602,7 @@ async def _emit_server_routing_decision(
},
},
)
return resolved_decision_id
@dataclass
@@ -7040,6 +7086,25 @@ def _validated_cost_control_mode_override(value: str | None) -> str | None:
)
def _validated_subagent_routing_override(value: str | None) -> str | None:
"""
Validate a caller-supplied per-session subagent-routing switch.
:param value: The candidate value, e.g. ``"on"``, or ``None`` when
the caller did not set / wants to clear the override (inherit
the session's main routing state).
:returns: The value unchanged when valid, or ``None``.
:raises OmnigentError: 400 (``invalid_input``) when *value* is
anything other than ``"on"``, ``"off"``, or ``None``.
"""
if value is None or value in SUBAGENT_ROUTING_OVERRIDE_VALUES:
return value
raise OmnigentError(
f"invalid subagent_routing_override: {value!r} (expected 'on', 'off', or null to clear)",
code=ErrorCode.INVALID_INPUT,
)
def _parse_session_create_metadata(metadata: str) -> SessionCreateMetadata:
"""
Parse the JSON metadata part from bundled session creation.
@@ -7951,6 +8016,7 @@ def _child_session_summary_from_conversation(
collapsed = " ".join(raw_prompt.split())
last_message_preview = collapsed[:_CHILD_PREVIEW_LIMIT] or None
routing_decision_id = conv.labels.get(ROUTING_DECISION_LABEL_KEY)
return ChildSessionSummary(
id=conv.id,
parent_session_id=parent_session_id,
@@ -7973,6 +8039,13 @@ def _child_session_summary_from_conversation(
# in-memory index that feeds the sidebar badge, so the Agents
# rail can flag a child that's awaiting user input.
pending_elicitations_count=pending_elicitations.count_for(conv.id),
# The model routing picked for this child, reported only when a
# decision actually produced it: a user-pinned model_override is not a
# routed model, and reporting one with a null decision id makes the
# two fields contradict each other. The decision is joined through a
# conversation label rather than a new column.
routed_model=conv.model_override if routing_decision_id is not None else None,
routing_decision_id=routing_decision_id,
)
@@ -8025,7 +8098,7 @@ async def _handle_advise_models_mcp(
return _mcp_tool_result(rpc_id, json.dumps({"router_on": False, "recommendations": []}))
from omnigent.model_catalog import spec_harness
from omnigent.server.smart_routing import fetch_runner_models
from omnigent.server.smart_routing import _WORKER_NAME_TO_HARNESS, fetch_runner_models
# Fetch live model catalog from the runner once; used below to populate
# per-agent model lists when the caller omits explicit models.
@@ -8057,12 +8130,6 @@ async def _handle_advise_models_mcp(
"_handle_advise_models_mcp: failed to load spec for agent=%s", conv.agent_id
)
_WORKER_HARNESS: dict[str, str] = {
"claude_code": "claude-sdk",
"codex": "codex",
"pi": "pi",
}
def _resolve_harness_for_worker(agent: str) -> str | None:
if spec is not None:
sub_agents = getattr(spec, "sub_agents", None) or []
@@ -8072,7 +8139,7 @@ async def _handle_advise_models_mcp(
if h:
return h
break
return _WORKER_HARNESS.get(agent)
return _WORKER_NAME_TO_HARNESS.get(agent)
recommendations: list[dict[str, Any]] = []
for task in tasks:
@@ -8763,6 +8830,7 @@ __all__ = [
"_validated_cost_control_mode_override",
"_validated_harness_override",
"_validated_harness_override_executor_type",
"_validated_subagent_routing_override",
"_wait_for_managed_runner_tunnel",
"_wait_for_runner_client",
"announce_hosts_changed",
File diff suppressed because it is too large Load Diff
+8
View File
@@ -253,6 +253,7 @@ def create_host_tunnel_router(
user_id=tunnel_owner,
allow_host_id_reown=allow_host_id_reown,
configured_harnesses=frame.configured_harnesses,
gateway_inference=frame.gateway_inference,
)
conn = host_registry.register(
@@ -472,8 +473,12 @@ async def _receive_loop(
host_store.update_harness_readiness,
host_id,
frame.configured_harnesses,
frame.gateway_inference,
)
conn.hello.configured_harnesses = dict(frame.configured_harnesses)
conn.hello.gateway_inference = (
dict(frame.gateway_inference) if frame.gateway_inference is not None else None
)
if on_host_update is not None:
try:
await on_host_update(host_id, conn.owner)
@@ -618,6 +623,7 @@ async def _receive_loop(
{
"status": frame.status,
"configured_harnesses": frame.configured_harnesses,
"gateway_inference": frame.gateway_inference,
"error": frame.error,
}
)
@@ -630,6 +636,7 @@ async def _receive_loop(
{
"status": frame.status,
"configured_harnesses": frame.configured_harnesses,
"gateway_inference": frame.gateway_inference,
"error": frame.error,
}
)
@@ -662,6 +669,7 @@ async def _receive_loop(
{
"status": frame.status,
"models": frame.models,
"routable_models": frame.routable_models,
"error": frame.error,
}
)
+50 -35
View File
@@ -36,7 +36,6 @@ from omnigent.host.frames import (
HostInstallHarnessFrame,
HostLaunchRunnerFrame,
HostListDirFrame,
HostModelOptionsFrame,
HostStoreSecretFrame,
encode_host_frame,
)
@@ -93,33 +92,28 @@ async def _proxy_model_options(
harness: str,
) -> dict[str, Any]:
"""Ask a host for the model catalog it would use for a new session."""
request_id = secrets.token_hex(8)
loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
host_conn.pending_model_options[request_id] = future
frame = encode_host_frame(
HostModelOptionsFrame(request_id=request_id, harness=harness),
)
from omnigent.server.routes._host_model_options import request_host_model_options
try:
try:
host_registry.send_text(host_conn, frame)
except ConnectionError as exc:
raise HTTPException(
status_code=502,
detail=f"host '{host_conn.host_id}' connection lost",
) from exc
try:
return await asyncio.wait_for(future, timeout=_MODEL_OPTIONS_TIMEOUT_S)
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=504,
detail=(
f"host '{host_conn.host_id}' did not resolve model options within "
f"{_MODEL_OPTIONS_TIMEOUT_S:.0f}s"
),
) from exc
finally:
host_conn.pending_model_options.pop(request_id, None)
return await request_host_model_options(
host_registry=host_registry,
host_conn=host_conn,
harness=harness,
timeout_s=_MODEL_OPTIONS_TIMEOUT_S,
)
except ConnectionError as exc:
raise HTTPException(
status_code=502,
detail=f"host '{host_conn.host_id}' connection lost",
) from exc
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=504,
detail=(
f"host '{host_conn.host_id}' did not resolve model options within "
f"{_MODEL_OPTIONS_TIMEOUT_S:.0f}s"
),
) from exc
async def _proxy_list_dir(
@@ -274,7 +268,9 @@ async def _proxy_install_harness(
:param harness: The UI harness identifier to install, e.g. ``"claude"``.
:returns: Dict with the result fields: ``status`` (``"ok"`` /
``"failed"``), ``configured_harnesses`` (the refreshed readiness map or
``None``), ``error`` (string or ``None``).
``None``), ``gateway_inference`` (the refreshed per-harness
AI-Gateway-backed inference map or ``None``), ``error`` (string or
``None``).
:raises HTTPException: 504 on timeout, 502 on connection drop.
"""
request_id = secrets.token_hex(8)
@@ -334,7 +330,8 @@ async def _proxy_store_secret(
:param host_registry: Server-side registry; used to enqueue the frame.
:param host_conn: Live host connection.
:param frame: The store-secret frame to forward (carries the secret).
:returns: Dict with ``status`` / ``configured_harnesses`` / ``error``.
:returns: Dict with ``status`` / ``configured_harnesses`` /
``gateway_inference`` / ``error``.
:raises HTTPException: 504 on timeout, 502 on connection drop.
"""
request_id = frame.request_id
@@ -563,7 +560,10 @@ def create_hosts_router(
information for online hosts.
:param request: The incoming request (for auth).
:returns: ``{"hosts": [...]}`` with host details.
:returns: ``{"hosts": [...]}`` with host details ``host_id``,
``name``, ``owner``, ``status``, ``sandbox_provider``,
``configured_harnesses``, and ``gateway_inference`` (``None`` when
the host never reported it).
"""
# require_user: unauthenticated callers 401. user_id is None
# only when auth is disabled entirely — there the single-user
@@ -601,6 +601,9 @@ def create_hosts_router(
# user-connectable machines.
"sandbox_provider": host.sandbox_provider,
"configured_harnesses": host.configured_harnesses,
# ``None`` means the host never reported it — emitted as-is
# so a client can tell "unknown" from "not gateway-backed".
"gateway_inference": host.gateway_inference,
}
)
return {"hosts": result}
@@ -612,7 +615,8 @@ def create_hosts_router(
:param request: The incoming request (for auth).
:param host_id: Host identifier, e.g.
``"host_a1b2c3d4..."``.
:returns: Host details dict.
:returns: Host details dict the ``list_hosts`` fields (including
``gateway_inference``, ``None`` when unreported) plus ``runners``.
:raises HTTPException: 404 if the host does not exist.
"""
# require_user: with an auth provider configured, an
@@ -639,6 +643,9 @@ def create_hosts_router(
# server-managed sandbox host (e.g. "modal").
"sandbox_provider": host.sandbox_provider,
"configured_harnesses": host.configured_harnesses,
# ``None`` means the host never reported it — emitted as-is so a
# client can tell "unknown" from "not gateway-backed".
"gateway_inference": host.gateway_inference,
"runners": [],
}
@@ -1231,8 +1238,10 @@ def create_hosts_router(
:param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``.
:param harness: Harness identifier to install, e.g. ``"claude"``.
:returns: ``{"object": "harness_install", "harness": ...,
"configured_harnesses": {...}}`` the host's refreshed readiness
map so the UI can flip the badge without a reconnect.
"configured_harnesses": {...}, "gateway_inference": {...} | None}``
the host's refreshed readiness map so the UI can flip the badge
without a reconnect, plus its refreshed gateway-inference map
(``None`` when the host didn't report one).
:raises HTTPException: 404 when the feature is disabled or the host is
unknown, 400 when the harness is not UI-installable, 403 when the
caller is not the host owner, 409 when the host is offline, 502 on
@@ -1303,6 +1312,8 @@ def create_hosts_router(
"object": "harness_install",
"harness": harness,
"configured_harnesses": result.get("configured_harnesses") or {},
# Passed through as-is: ``None`` is "unknown", not "none backed".
"gateway_inference": result.get("gateway_inference"),
}
@router.post("/hosts/{host_id}/harnesses/{harness}/credential")
@@ -1333,8 +1344,10 @@ def create_hosts_router(
:param harness: Harness being configured, e.g. ``"claude"``.
:param body: The credential payload (kind + secret / gateway / adopt).
:returns: ``{"object": "harness_credential", "harness": ...,
"configured_harnesses": {...}}`` refreshed readiness so the UI can
flip the badge without a reconnect.
"configured_harnesses": {...}, "gateway_inference": {...} | None}``
refreshed readiness so the UI can flip the badge without a
reconnect, plus the refreshed gateway-inference map (``None`` when
the host didn't report one).
:raises HTTPException: 404 when disabled or host unknown, 400 when the
harness isn't UI-configurable or the body is invalid, 403 when not
the owner, 409 when offline, 502 on host-side failure, 504 on
@@ -1402,6 +1415,8 @@ def create_hosts_router(
"object": "harness_credential",
"harness": harness,
"configured_harnesses": result.get("configured_harnesses") or {},
# Passed through as-is: ``None`` is "unknown", not "none backed".
"gateway_inference": result.get("gateway_inference"),
}
@router.get("/hosts/{host_id}/credentials/detected")
@@ -1590,6 +1590,17 @@ def register_core_routes(
body.cost_control_mode_override
)
# Same presence-is-the-clear-signal rule for the subagent-routing
# switch: an explicit null returns the session to inheriting its
# main routing state.
clear_subagent_routing = (
"subagent_routing_override" in body.model_fields_set
and body.subagent_routing_override is None
)
subagent_routing_override = _validated_subagent_routing_override(
body.subagent_routing_override
)
# Native-terminal pass-through args: ``None`` leaves them
# unchanged; a provided list (including ``[]``) replaces the
# stored value wholesale (resume is last-write-wins, never an
@@ -1695,11 +1706,27 @@ def register_core_routes(
_unset_model_override=clear_model,
cost_control_mode_override=None if clear_cost_control else cost_control_mode_override,
_unset_cost_control_mode_override=clear_cost_control,
subagent_routing_override=(
None if clear_subagent_routing else subagent_routing_override
),
_unset_subagent_routing_override=clear_subagent_routing,
terminal_launch_args=terminal_launch_args,
archived=body.archived,
)
if updated is None:
raise _session_not_found()
if subagent_routing_override is not None or clear_subagent_routing:
from omnigent.telemetry import (
SETTING_SUBAGENT_ROUTING,
record_routing_setting_changed,
)
record_routing_setting_changed(
session_id,
setting=SETTING_SUBAGENT_ROUTING,
value=updated.subagent_routing_override or "default",
user_id=user_id,
)
# Archiving hides the session from the default view (and its unread
# dot), so drop its per-user read-state to bound in-memory growth.
# Only on archive→true; unarchiving leaves it pruned (reads as seen).
@@ -29,6 +29,7 @@ from omnigent.runner.identity import RUNNER_TUNNEL_TOKEN_HEADER, token_bound_run
from omnigent.runner.routing import RunnerRouter
from omnigent.runtime import (
session_stream,
session_warnings,
)
from omnigent.runtime.agent_cache import AgentCache
from omnigent.runtime.policies.approval import _ELICITATION_MODE
@@ -285,6 +286,7 @@ def register_events_routes(
_EXTERNAL_SESSION_SUPERSEDED_TYPE,
_EXTERNAL_ELICITATION_RESOLVED_TYPE,
_EXTERNAL_SESSION_STATUS_TYPE,
_EXTERNAL_SESSION_WARNING_TYPE,
_EXTERNAL_SESSION_USAGE_TYPE,
_EXTERNAL_COMPACTION_STATUS_TYPE,
_EXTERNAL_MCP_STARTUP_TYPE,
@@ -738,6 +740,26 @@ def register_events_routes(
)
_signal_harness_elicitation_resolved_by_id(session_id, elicitation_id)
return {"queued": False}
if body.type == _EXTERNAL_SESSION_WARNING_TYPE:
warnings = body.data.get("warnings")
if not isinstance(warnings, list):
raise OmnigentError(
"external_session_warning requires a list data.warnings",
code=ErrorCode.INVALID_INPUT,
)
if not warnings:
# The publisher re-checks every tick and posts what it still
# sees, so an empty list is "the condition is repaired" (the
# router canary fired on a later turn) — drop the banner.
# Scoped to the codes this channel's publishers check: a
# warning recorded by anyone else is not covered by this
# post and must survive it.
session_warnings.clear(session_id, codes=session_warnings.EXTERNAL_WARNING_CODES)
return {"queued": False}
for warning in warnings:
if isinstance(warning, dict):
session_warnings.record(session_id, warning)
return {"queued": False}
if body.type == _EXTERNAL_SESSION_STATUS_TYPE:
status = body.data.get("status")
if status not in _EXTERNAL_SESSION_STATUS_VALUES:
@@ -1677,6 +1699,9 @@ def register_events_routes(
# while the session exists (the extension only pushes on start), so a
# deleted session would otherwise leak its entry for the process life.
_pushed_model_options_cache.pop(session_id, None)
# Same for session warnings: they are keyed by session id and no
# snapshot will ever read them again once the row is gone.
session_warnings.clear(session_id)
# Drop the deleted session's per-user read-state from every user's
# caches so they don't accumulate orphan entries for the process
# lifetime.
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import json
from typing import Any, NamedTuple
@@ -1276,3 +1277,152 @@ def register_hooks_routes(
content=json.dumps(result.model_dump(exclude_none=True)),
media_type="application/json",
)
async def _route_subagent_catalog(session_id: str) -> dict[str, list[str]] | None:
"""
Fetch the session's live model catalog for subagent routing.
:param session_id: Parent session/conversation id.
:returns: Worker servable model ids, or ``None`` when the runner
is unreachable (callers fall back to the static table).
"""
from omnigent.server.smart_routing import fetch_runner_models
try:
runner_client = await _get_runner_client(
session_id, runner_router or get_server_runner_router()
)
if runner_client is None:
return None
return await fetch_runner_models(session_id, runner_client)
except Exception:
_logger.debug(
"route-subagent: live catalog unavailable for session=%s",
session_id,
exc_info=True,
)
return None
@router.post(
"/sessions/{session_id}/hooks/route-subagent",
# Internal runner relay — hidden from the public API reference.
include_in_schema=False,
response_model=None,
dependencies=[Depends(require_json_content_type)],
)
async def route_subagent_hook(
request: Request,
session_id: str,
) -> Response:
"""
Decide the model/harness a native subagent spawn may use.
The runner's loopback router (advertised to harness
``PreToolUse`` hooks via ``subagent_router.json``) relays here
because ``RuntimeCaps.routing_client`` only lives in the server
process. Request and response follow the frozen route-subagent
contract; every routed verdict also lands as a
``routing_decision`` transcript item.
The session's subagent-routing setting is re-read on every call
(it is togglable mid-session), so a session whose routing is off
gets its spawn allowed unchanged without calling the router.
Candidate models stay inside the session's own harness family
unless the session started in auto-harness mode.
:param request: FastAPI request body is the route-subagent
request JSON.
:param session_id: Parent session/conversation id from the path.
:returns: The route-subagent decision as JSON.
:raises OmnigentError: 400 when the body is not a JSON object or
omits ``harness``.
"""
from omnigent.runner.subagent_routing import (
SubagentRouteDecision,
SubagentRouteRequest,
auto_harness_session,
resolve_subagent_route,
store_persister,
subagent_routing_enabled,
)
user_id = _get_user_id(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
try:
payload = await request.json()
except json.JSONDecodeError as exc:
raise OmnigentError(
f"Invalid JSON in route-subagent body: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if not isinstance(payload, dict):
raise OmnigentError(
"route-subagent body must be a JSON object.",
code=ErrorCode.INVALID_INPUT,
)
try:
route_request = SubagentRouteRequest.from_payload(payload)
except ValueError as exc:
raise OmnigentError(str(exc), code=ErrorCode.INVALID_INPUT) from exc
# A relayed spawn is evidence the harness ran its routing hook, but it
# is deliberately NOT turned into a clear here: the same warning code
# also carries the spawn-audit verdict ("started on a model the router
# never approved"), which a relay does not disprove — and every spawn
# that produces such a verdict is itself relayed, so clearing here
# wiped exactly the warnings the publisher had just raised (it only
# re-posts on a transition, so the wipe was permanent). The publisher
# owns the clear: its next check sees the canary and posts the repair.
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
parent = None
if conv is not None and conv.parent_conversation_id is not None:
parent = await asyncio.to_thread(
conversation_store.get_conversation, conv.parent_conversation_id
)
parent_cost_control_mode = (
parent.cost_control_mode_override if parent is not None else None
)
if conv is None or not subagent_routing_enabled(
conv.subagent_routing_override,
cost_control_mode=conv.cost_control_mode_override,
parent_cost_control_mode=parent_cost_control_mode,
):
# Allowed unchanged, and deliberately not persisted: an
# unrouted spawn is not a decision worth a transcript item.
_logger.info(
"route-subagent: subagent routing disabled for session=%s harness=%s",
session_id,
route_request.harness,
)
unrouted = SubagentRouteDecision(
action="allow",
rationale="subagent routing disabled for this session",
)
return Response(
content=json.dumps(unrouted.to_payload()),
media_type="application/json",
)
# Only a session started in auto-harness mode may be moved across
# harness families; everyone else is offered their own family, so a
# Claude Code session never gets a Codex suggestion.
cross_harness = auto_harness_session(conv, parent)
# Offer the live catalog: the static table lags model generations, and
# a pick the workspace serves must not look unservable and get
# substituted down a tier.
catalog = await _route_subagent_catalog(session_id)
decision = await resolve_subagent_route(
session_id,
route_request,
caps=get_caps(),
catalog=catalog,
cross_harness=cross_harness,
persist=store_persister(session_id, conversation_store),
)
return Response(
content=json.dumps(decision.to_payload()),
media_type="application/json",
)
+49
View File
@@ -792,6 +792,16 @@ class ChildSessionSummary(BaseModel):
a fanned-out sub-agent that needs attention is visible
without opening its chat. Mirrors
:attr:`SessionListItem.pending_elicitations_count`.
:param routed_model: Model this sub-agent runs on when one was pinned
for it, e.g. ``"databricks-claude-opus-4-8"``. Read from the
child's ``model_override`` — the field intelligent routing writes
when it picks a model for a spawned child. ``None`` when the child
inherits the parent/spec model.
:param routing_decision_id: Identifier of the routing decision that
produced :attr:`routed_model`, mirroring
``RoutingDecisionData.decision_id``. Read from the child's
``omnigent.routing.decision_id`` label, stamped when routing pins
the model. ``None`` when the child was not routed.
"""
id: str
@@ -812,6 +822,8 @@ class ChildSessionSummary(BaseModel):
last_task_error: dict[str, str] | None = None
last_message_preview: str | None = None
pending_elicitations_count: int = 0
routed_model: str | None = None
routing_decision_id: str | None = None
# ── Responses ───────────────────────────────────────────────────
@@ -1322,6 +1334,11 @@ class SessionCreateRequest(BaseModel):
default) defers to the spec default. Set by the web UI's
new-session "Cost Optimized" option; read by the cost-control
advisor pipeline at turn start.
:param subagent_routing_override: Optional per-session
subagent-routing switch to persist at create time: ``"on"``
routes subagent spawns, ``"off"`` leaves them unrouted.
``None`` (the default) inherits the session's main routing
state. Mutable mid-session via ``PATCH /v1/sessions/{id}``.
:param harness_override: Optional per-session brain-harness
override to persist at create time, e.g. ``"pi"`` or
``"openai-agents"``. Set by the web UI's new-chat harness
@@ -1333,6 +1350,15 @@ class SessionCreateRequest(BaseModel):
the spec's declared harness. Create-time only — there is no
PATCH path, since the harness process spawns on the first
turn.
:param smart_routing_message: The user's first-message text, used to
route the harness at create time. Only read on the top-level
Smart Routing path (``harness_override: "auto"`` on a native
wrapper agent), where the terminal launches as soon as the
session row exists and so the harness must be decided before it.
Routing-only: not persisted or dispatched the client sends the
real message after the create returns. ``None`` everywhere else,
including the bundle-agent auto path, which routes on the first
message event instead.
"""
agent_id: str
@@ -1349,7 +1375,9 @@ class SessionCreateRequest(BaseModel):
model_override: str | None = None
reasoning_effort: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
harness_override: str | None = None
smart_routing_message: str | None = None
@model_validator(mode="after")
def _check_git_requires_host(self) -> SessionCreateRequest:
@@ -1692,6 +1720,12 @@ class SessionResponse(BaseModel):
applies). Set at create time or via
``PATCH /v1/sessions/{id}`` (the web "Cost Optimized"
toggle); read by the cost-control advisor pipeline.
:param subagent_routing_override: Per-session subagent-routing
switch: ``"on"`` routes subagent spawns, ``"off"`` leaves them
unrouted. ``None`` means the session inherits its main routing
state (own or parent ``cost_control_mode_override == "on"``)
the value the in-session "Subagent routing" row renders as
"Default". Set via ``PATCH /v1/sessions/{id}``.
:param context_window: The model's context window size in tokens
as looked up server-side from litellm's registry (or from the
``AP_CONTEXT_WINDOW_OVERRIDE`` env var), e.g. ``200_000``.
@@ -1840,11 +1874,18 @@ class SessionResponse(BaseModel):
harness: str | None = None
model_override: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
context_window: int | None = None
last_total_tokens: int | None = None
total_cost_usd: float | None = None
usage_by_model: dict[str, ModelUsage] | None = None
last_task_error: dict[str, str] | None = None
# Session-scoped degraded-but-running conditions at snapshot time,
# each ``{"code", "harness", "reason"}`` — e.g. a harness whose
# router hook never fired, so native sub-agent spawns aren't gated.
# Rendered by the chat header's warning banner. Empty when nothing
# is wrong (the common case).
warnings: list[dict[str, Any]] = Field(default_factory=list)
external_session_id: str | None = None
terminal_launch_args: list[str] | None = None
pending_elicitations: list[dict[str, Any]] = Field(default_factory=list)
@@ -1919,6 +1960,13 @@ class UpdateSessionRequest(BaseModel):
default; omitting the field leaves it unchanged (``"off"`` is
a real value here, so the field's *presence* — not a clear
alias is the clear signal, unlike ``model_override``).
:param subagent_routing_override: Per-session subagent-routing
switch: ``"on"`` routes subagent spawns, ``"off"`` leaves them
unrouted. Explicit JSON ``null`` clears the override so the
session inherits its main routing state again; omitting the
field leaves it unchanged (same presence-is-the-clear-signal
rule as ``cost_control_mode_override``). Effective on the next
spawn, so it can be changed at any point in a session.
:param external_session_id: Runtime-native session id captured
by a wrapper bridge (e.g. Claude Code's session uuid for
``omnigent claude`` sessions). Idempotent on same-value
@@ -1962,6 +2010,7 @@ class UpdateSessionRequest(BaseModel):
model_override: str | None = None
collaboration_mode: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
external_session_id: str | None = None
terminal_launch_args: list[str] | None = None
archived: bool | None = None
File diff suppressed because it is too large Load Diff
+394
View File
@@ -0,0 +1,394 @@
"""CLI-side Smart Routing: pick a harness/model *before* the TUI launches.
``omnigent claude --smart-routing -p "..."`` (tier 2) and
``omnigent run --smart-routing -p "..."`` (tier 3) both need a routing verdict
in hand before a native wrapper starts, because the harness pick is physical
(a session *is* a live ``claude``/``codex`` process) and the model is applied
as a launch flag. The web UI gets the same verdict server-side at session
create; the CLI takes the same path it creates the session itself through the
standard JSON ``POST /v1/sessions`` with the routing contract fields, reads the
resolved ``harness`` / ``model_override`` back off the response, and attaches
the matching native wrapper to that session. One session, routed at create: the
row already carries the agent binding, the wrapper's presentation labels, the
routed model, and the routing decision card, so the launched session shows the
same chip and provenance the web UI gets.
Two rules shape everything here:
* **Preflight is a hard error.** Routing that the server cannot do, or a host
whose inference is not AI-Gateway-backed, means the pick could not be
applied say so and stop (designs/INTELLIGENT_ROUTING_PLAN.md §10).
* **Routing itself fails open.** Once preflight passes, any router failure
(missing verdict, HTTP error, unreachable server) returns a decision with a
one-line notice and no pick. The launch always happens.
"""
from __future__ import annotations
import json
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
import click
import httpx
from omnigent.db.utils import builtin_agent_id
from omnigent.harness_aliases import canonicalize_harness
from omnigent.harness_plugins import CLAUDE_NATIVE_CODING_AGENT
from omnigent.native_coding_agents import native_coding_agent_for_harness
CLAUDE_NATIVE_AGENT_NAME = CLAUDE_NATIVE_CODING_AGENT.agent_name
#: Sentinel ``harness_override`` that asks the server to route the harness too.
AUTO_HARNESS = "auto"
#: Provenance label on a CLI-routed session. The server merges the wrapper's
#: own presentation labels (``omnigent.ui`` / ``omnigent.wrapper``) over it.
ROUTING_SESSION_LABELS = {"omnigent.smart_routing": "cli-route"}
_TIMEOUT = httpx.Timeout(10.0, read=60.0)
@dataclass(frozen=True)
class RoutingDecision:
"""
The routed session the CLI attaches to, and what the router picked.
:param session_id: The created session, e.g. ``"conv_abc123"``. The wrapper
attaches to this instead of bundling its own. ``None`` when the create
failed the caller then launches a fresh wrapper session.
:param harness: Canonical harness bound to the session, e.g.
``"codex-native"`` (for an ``"auto"`` create the server rebinds the
agent to the wrapper it picked). ``None`` when it could not be read.
:param model: Routed model id, e.g. ``"databricks-claude-sonnet-4-6"``.
``None`` means launch on the harness default.
:param notice: One user-facing line explaining a missing pick, e.g.
``"omnigent: Smart Routing was unavailable (...)"``. ``None`` when the
router answered.
"""
session_id: str | None
harness: str | None
model: str | None
notice: str | None
def smart_routing_families(harness: str | None) -> tuple[str, ...]:
"""
Harness families whose inference must be gateway-backed for *harness*.
A fixed-harness route only applies to that harness's pane. The auto route
picks across the claude + codex arms, so it needs both mirroring the web's
per-surface gating (top-level Smart Routing needs both; a per-harness
Model row needs only its own).
:param harness: Canonical harness id, or ``None`` / :data:`AUTO_HARNESS`
for the auto route.
:returns: Harness ids to check, e.g. ``("claude-native", "codex-native")``.
"""
if harness is None or harness == AUTO_HARNESS:
return ("claude-native", "codex-native")
return (harness,)
def check_smart_routing_available(
*,
base_url: str,
harnesses: Sequence[str],
host_id: str | None = None,
) -> None:
"""
Fail loud when Smart Routing cannot be applied for *harnesses*.
Two gates, both config-level (no liveness probing): the server must have a
routing client (``GET /v1/info`` ``smart_routing_enabled``), and this
machine's inference for each harness family must be AI-Gateway-backed
(``GET /v1/hosts`` ``gateway_inference``). An absent ``gateway_inference``
map or an absent entry in it is *unknown*, not unavailable: hosts on
older builds keep every option.
:param base_url: Omnigent server base URL, e.g. ``"http://127.0.0.1:6767"``.
:param harnesses: Harness ids the route may pick, e.g.
``("claude-native",)``.
:param host_id: This machine's host id, e.g. ``"host_abc123"``. ``None``
skips the per-host gate (nothing to look up).
:returns: None when routing may proceed.
:raises click.ClickException: When routing is unavailable, naming why.
"""
info = _get_json(base_url=base_url, path="/v1/info")
if not (isinstance(info, dict) and info.get("smart_routing_enabled") is True):
raise click.ClickException(
f"Smart Routing is not enabled on {base_url}: the server has no routing "
"model configured. Re-run without --smart-routing, or pass --model to "
"pick a model yourself."
)
if host_id is None:
return
gateway = _gateway_inference_for_host(base_url=base_url, host_id=host_id)
if gateway is None:
return
for harness in harnesses:
state = _gateway_state(gateway, harness)
if state is None or state is True:
continue
reason = state if isinstance(state, str) else "not gateway-backed"
raise click.ClickException(
f"Smart Routing is unavailable for {harness} on this host: its inference "
f"is not AI-Gateway-backed ({reason}), so a routed model would not be "
"reachable from the pane. Re-run without --smart-routing, or point the "
"harness at the workspace AI Gateway (`omnigent configure harnesses`)."
)
def create_smart_routing_session(
*,
base_url: str,
prompt: str,
harness: str | None,
host_id: str | None = None,
workspace: str | None = None,
) -> RoutingDecision:
"""
Create the routed session, and read the verdict back off the create.
Sends the routing contract ``cost_control_mode_override="on"``,
``smart_routing_message=<prompt>``, and (auto route only)
``harness_override="auto"``; a fixed harness comes from the bound wrapper
agent, so it needs no override. The response carries the resolved
``harness`` and ``model_override``, with the session snapshot as a fallback.
This is the session the wrapper attaches to nothing is deleted.
Never raises: a create the server rejects (including the auto route's
"no native CLI on this host") yields a decision with no session and a
notice, and the caller launches a fresh wrapper session instead.
:param base_url: Omnigent server base URL.
:param prompt: The user's ``-p`` text. Routed, not dispatched — the TUI
delivers it as its own first input.
:param harness: Canonical harness to pin, or ``None`` for the auto route.
:param host_id: Host this session will run on, e.g. ``"host_abc123"``.
Needed for a real verdict: the server builds the candidate model
catalog by round-tripping the bound host's model-options frames, so a
hostless create gives the router an empty menu. ``None`` (the server
does not know this host yet) still routes, over whatever it can resolve
without one.
:param workspace: Absolute workspace path on *host_id* the launch cwd.
Required by the server whenever ``host_id`` is set (it is validated
against the agent's cwd boundary), so it is sent only with *host_id*.
:returns: The :class:`RoutingDecision` to launch on.
"""
body: dict[str, Any] = {
"agent_id": _routing_agent_id(harness),
"host_type": "external",
"labels": dict(ROUTING_SESSION_LABELS),
"cost_control_mode_override": "on",
"smart_routing_message": prompt,
}
if harness is None:
# Auto route: the sentinel tells the server to pick the harness and
# rebind the session's agent to that wrapper.
body["harness_override"] = AUTO_HARNESS
if host_id is not None:
body["host_id"] = host_id
# host_id without workspace is a 400 — the server stats the path on the
# host to validate the agent's cwd boundary.
body["workspace"] = workspace
session_id: str | None = None
picked_harness: str | None = None
picked_model: str | None = None
try:
with httpx.Client(
base_url=base_url, headers=_headers(base_url), timeout=_TIMEOUT
) as client:
resp = client.post("/v1/sessions", json=body)
if resp.status_code >= 400:
return _unavailable(f"the server rejected the routed session ({resp.status_code})")
payload = _json_object(resp)
raw_id = payload.get("id") or payload.get("session_id")
session_id = raw_id if isinstance(raw_id, str) and raw_id else None
# ``harness`` (not ``harness_override``) is the resolved harness on
# SessionResponse; native rows leave the override null on purpose.
picked_harness = _clean_str(payload.get("harness"))
picked_model = _clean_str(payload.get("model_override"))
if (picked_model is None or picked_harness is None) and session_id is not None:
snapshot = _json_object(client.get(f"/v1/sessions/{session_id}"))
picked_harness = picked_harness or _clean_str(snapshot.get("harness"))
picked_model = picked_model or _clean_str(snapshot.get("model_override"))
except httpx.HTTPError as exc:
return _unavailable(f"could not reach {base_url}: {exc}")
if session_id is None:
return _unavailable("the create returned no session id")
notice = (
None
if picked_model is not None
else (
"omnigent: Smart Routing did not pick a model for this session; "
"launching on the harness default."
)
)
return RoutingDecision(
session_id=session_id,
harness=picked_harness,
model=picked_model,
notice=notice,
)
def _unavailable(reason: str) -> RoutingDecision:
"""
Build the fail-open decision for *reason*.
:param reason: Short cause, e.g. ``"the create returned no session id"``.
:returns: A decision with no session and one user-facing notice line.
"""
return RoutingDecision(
session_id=None,
harness=None,
model=None,
notice=(
f"omnigent: Smart Routing was unavailable ({reason}); "
"launching on the default harness/model."
),
)
def _routing_agent_id(harness: str | None) -> str:
"""
Built-in agent to bind the routing session to.
The bound agent only has to exist the routing verdict rides on the
session row, not the agent. A fixed harness uses its own ``*-native-ui``
built-in; the auto route uses the claude-native built-in, which every
server seeds.
:param harness: Canonical harness id, or ``None`` for the auto route.
:returns: A deterministic built-in agent id.
"""
native = native_coding_agent_for_harness(harness) if harness else None
name = native.agent_name if native is not None else CLAUDE_NATIVE_AGENT_NAME
return builtin_agent_id(name)
def known_host_id(*, base_url: str, host_id: str | None) -> str | None:
"""
Return *host_id* only when the server already knows that host.
Binding a routing session to a host the server has never seen would 4xx
the create and cost us the verdict, so an unregistered host degrades to a
hostless route instead.
:param base_url: Omnigent server base URL.
:param host_id: This machine's host id, or ``None``.
:returns: *host_id* when it appears in ``GET /v1/hosts``, else ``None``.
"""
if host_id is None:
return None
payload = _get_json(base_url=base_url, path="/v1/hosts")
hosts = payload.get("hosts") if isinstance(payload, dict) else None
if not isinstance(hosts, list):
return None
for host in hosts:
if isinstance(host, dict) and host.get("host_id") == host_id:
return host_id
return None
def _gateway_inference_for_host(*, base_url: str, host_id: str) -> dict[str, Any] | None:
"""
Read this host's ``gateway_inference`` map from ``GET /v1/hosts``.
:param base_url: Omnigent server base URL.
:param host_id: Host id to match, e.g. ``"host_abc123"``.
:returns: The map, or ``None`` when the host, the field, or the request is
unavailable (all of which mean "unknown", which does not gate).
"""
payload = _get_json(base_url=base_url, path="/v1/hosts")
hosts = payload.get("hosts") if isinstance(payload, dict) else None
if not isinstance(hosts, list):
return None
for host in hosts:
if not isinstance(host, dict) or host.get("host_id") != host_id:
continue
gateway = host.get("gateway_inference")
return gateway if isinstance(gateway, dict) else None
return None
def _gateway_state(gateway: dict[str, Any], harness: str) -> Any:
"""
Look up *harness* in a ``gateway_inference`` map, tolerating spellings.
The map is keyed by harness spellings (the ``configured_harnesses``
convention: ``claude-native`` / ``native-claude``, ``codex`` /
``codex-native`` / ``native-codex``), never by a bare family name so key
off the canonical id, falling back to the spelling the caller passed.
:param gateway: The host's ``gateway_inference`` map.
:param harness: Harness id to look up, e.g. ``"codex-native"``.
:returns: The stored value, or ``None`` when absent (= unknown).
"""
canonical = canonicalize_harness(harness) or harness
for key in (canonical, harness):
if key in gateway:
return gateway[key]
return None
def _headers(base_url: str) -> dict[str, str]:
"""
Auth headers for *base_url*, matching every other CLI server call.
:param base_url: Omnigent server base URL.
:returns: Header mapping, possibly empty for a local server.
"""
from omnigent.chat import _remote_headers
return _remote_headers(server_url=base_url)
def _get_json(*, base_url: str, path: str) -> dict[str, Any]:
"""
GET *path* and return its JSON object, or ``{}`` on any failure.
Preflight reads treat an unreadable answer as "unknown" and let the
caller's own defaults decide, so this never raises.
:param base_url: Omnigent server base URL.
:param path: Request path, e.g. ``"/v1/info"``.
:returns: The decoded object, or ``{}``.
"""
try:
with httpx.Client(
base_url=base_url, headers=_headers(base_url), timeout=_TIMEOUT
) as client:
resp = client.get(path)
if resp.status_code >= 400:
return {}
return _json_object(resp)
except httpx.HTTPError:
return {}
def _json_object(resp: httpx.Response) -> dict[str, Any]:
"""
Decode *resp* as a JSON object.
:param resp: The HTTP response.
:returns: The decoded object, or ``{}`` when the body is not one.
"""
try:
payload = resp.json()
except (json.JSONDecodeError, ValueError):
return {}
return payload if isinstance(payload, dict) else {}
def _clean_str(value: Any) -> str | None:
"""
Normalize a wire value to a non-empty string.
:param value: Raw JSON value, e.g. ``"codex-native"`` or ``None``.
:returns: The stripped string, or ``None`` when it is not usable.
"""
return value.strip() if isinstance(value, str) and value.strip() else None
+10 -1
View File
@@ -763,6 +763,8 @@ class ConversationStore(ABC):
_unset_model_override: bool = False,
cost_control_mode_override: str | None = None,
_unset_cost_control_mode_override: bool = False,
subagent_routing_override: str | None = None,
_unset_subagent_routing_override: bool = False,
harness_override: str | None = None,
_unset_harness_override: bool = False,
terminal_launch_args: list[str] | None = None,
@@ -772,7 +774,8 @@ class ConversationStore(ABC):
Update mutable fields on a conversation.
For ``reasoning_effort``, ``model_override``,
``cost_control_mode_override``, and ``harness_override``,
``cost_control_mode_override``, ``subagent_routing_override``,
and ``harness_override``,
``None`` means "leave unchanged". To explicitly clear them
back to ``None``, pass
the matching ``_unset_*`` flag.
@@ -796,6 +799,12 @@ class ConversationStore(ABC):
:param _unset_cost_control_mode_override: When ``True``, set
``cost_control_mode_override`` to ``None`` regardless of
the ``cost_control_mode_override`` param value.
:param subagent_routing_override: Per-session subagent-routing
switch, ``"on"`` or ``"off"``. ``None`` leaves unchanged.
:param _unset_subagent_routing_override: When ``True``, set
``subagent_routing_override`` to ``None`` regardless of the
``subagent_routing_override`` param value back to
inheriting the session's main routing state.
:param harness_override: Per-session brain-harness override,
e.g. ``"pi"``. ``None`` leaves unchanged. No ``_unset``
variant the override is set once at session create and
@@ -101,6 +101,7 @@ _SESSION_OVERRIDE_KEYS = (
"reasoning_effort",
"model_override",
"cost_control_mode_override",
"subagent_routing_override",
"harness_override",
)
@@ -110,7 +111,7 @@ def _encode_session_overrides(overrides: dict[str, str | None]) -> str | None:
Omits keys whose value is ``None`` and returns ``None`` when nothing is
set, so a session on all agent/spec defaults stores SQL ``NULL`` rather
than an empty object. Only the four :data:`_SESSION_OVERRIDE_KEYS` are
than an empty object. Only the :data:`_SESSION_OVERRIDE_KEYS` are
considered; any other keys in *overrides* are ignored.
:param overrides: Mapping of override key to value (missing / ``None``
@@ -126,7 +127,7 @@ def _encode_session_overrides(overrides: dict[str, str | None]) -> str | None:
def _decode_session_overrides(raw: str | None) -> dict[str, str | None]:
"""Unpack the ``session_overrides`` blob to a full override dict.
Every one of the four :data:`_SESSION_OVERRIDE_KEYS` is present in the
Every one of the :data:`_SESSION_OVERRIDE_KEYS` is present in the
result (unset keys read back as ``None``) so read-modify-write callers can
treat the dict uniformly regardless of which overrides were stored.
@@ -191,6 +192,7 @@ def _to_conversation(
reasoning_effort=overrides["reasoning_effort"],
model_override=overrides["model_override"],
cost_control_mode_override=overrides["cost_control_mode_override"],
subagent_routing_override=overrides["subagent_routing_override"],
harness_override=overrides["harness_override"],
sub_agent_name=meta.sub_agent_name if meta else None,
external_session_id=meta.external_session_id if meta else None,
@@ -2576,6 +2578,8 @@ class SqlAlchemyConversationStore(ConversationStore):
_unset_model_override: bool = False,
cost_control_mode_override: str | None = None,
_unset_cost_control_mode_override: bool = False,
subagent_routing_override: str | None = None,
_unset_subagent_routing_override: bool = False,
harness_override: str | None = None,
_unset_harness_override: bool = False,
terminal_launch_args: list[str] | None = None,
@@ -2599,6 +2603,11 @@ class SqlAlchemyConversationStore(ConversationStore):
switch, ``"on"`` or ``"off"``. ``None`` leaves unchanged.
:param _unset_cost_control_mode_override: When ``True``, clear
``cost_control_mode_override`` to ``None``.
:param subagent_routing_override: Per-session subagent-routing
switch, ``"on"`` or ``"off"``. ``None`` leaves unchanged.
:param _unset_subagent_routing_override: When ``True``, clear
``subagent_routing_override`` to ``None`` (back to
inheriting the session's main routing state).
:param harness_override: Per-session brain-harness override,
e.g. ``"pi"``. ``None`` leaves unchanged.
:param _unset_harness_override: When ``True``, clear
@@ -2648,6 +2657,12 @@ class SqlAlchemyConversationStore(ConversationStore):
elif cost_control_mode_override is not None:
overrides["cost_control_mode_override"] = cost_control_mode_override
overrides_changed = True
if _unset_subagent_routing_override:
overrides["subagent_routing_override"] = None
overrides_changed = True
elif subagent_routing_override is not None:
overrides["subagent_routing_override"] = subagent_routing_override
overrides_changed = True
if _unset_harness_override:
overrides["harness_override"] = None
overrides_changed = True
@@ -3416,7 +3431,8 @@ class SqlAlchemyConversationStore(ConversationStore):
creating_clone = cloned_agent_bundle_location is not None
# Model-family-bound overrides (reasoning_effort, model_override, and
# — same gate — harness_override) copy only when copy_model_settings.
# cost_control_mode_override is intentionally never carried onto a fork.
# The routing switches (cost_control_mode_override,
# subagent_routing_override) are intentionally never carried onto a fork.
fork_overrides = _encode_session_overrides(
{
"reasoning_effort": (
+69 -1
View File
@@ -71,6 +71,11 @@ class Host:
``{"claude-sdk": True, "codex": False}``. ``None`` when the
host has never reported it (older host build) unknown, not
"nothing configured".
:param gateway_inference: Per-harness flag for whether that family's
launch on the host resolves AI-Gateway-backed inference, e.g.
``{"claude-native": True, "codex": False}``. A family the host could
not evaluate is omitted; ``None`` means the host never reported the
map at all (older build) unknown, not "nothing is gateway-backed".
"""
host_id: str
@@ -82,6 +87,7 @@ class Host:
sandbox_provider: str | None = None
sandbox_id: str | None = None
configured_harnesses: dict[str, HarnessAvailability] | None = None
gateway_inference: dict[str, bool] | None = None
def host_is_live(host: Host, now: int | None = None) -> bool:
@@ -133,6 +139,32 @@ def _parse_configured_harnesses(raw: str | None) -> dict[str, HarnessAvailabilit
return {k: v for k, v in parsed.items() if isinstance(k, str) and is_harness_availability(v)}
def _parse_gateway_inference(raw: str | None) -> dict[str, bool] | None:
"""
Parse the JSON-encoded ``hosts.gateway_inference`` column.
Tolerant like :func:`_parse_configured_harnesses`: ``NULL``, malformed
JSON, or a non-object payload map to ``None`` ("unknown"), and entries
whose value is not a bool are dropped, so a corrupt value never breaks
host listing.
:param raw: The raw column value, e.g. ``'{"claude-native": true}'`` or
``None``.
:returns: The gateway-inference map, or ``None`` when absent or
unparseable.
"""
if raw is None:
return None
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
_logger.warning("Ignoring malformed hosts.gateway_inference value")
return None
if not isinstance(parsed, dict):
return None
return {k: v for k, v in parsed.items() if isinstance(k, str) and isinstance(v, bool)}
def _row_to_host(row: SqlHost) -> Host:
"""
Convert a :class:`SqlHost` ORM row to a :class:`Host` entity.
@@ -150,6 +182,7 @@ def _row_to_host(row: SqlHost) -> Host:
sandbox_provider=row.sandbox_provider,
sandbox_id=row.sandbox_id,
configured_harnesses=_parse_configured_harnesses(row.configured_harnesses),
gateway_inference=_parse_gateway_inference(row.gateway_inference),
)
@@ -195,6 +228,7 @@ class HostStore:
*,
allow_host_id_reown: bool = False,
configured_harnesses: dict[str, HarnessAvailability] | None = None,
gateway_inference: dict[str, bool] | None = None,
) -> Host:
"""
Register or update a host on WebSocket connect.
@@ -234,12 +268,19 @@ class HostStore:
Written on every connect including ``None`` from an older
host that doesn't report it, which correctly resets any
stale value back to "unknown".
:param gateway_inference: Per-harness AI-Gateway-backed inference flags
from the host's ``host.hello`` frame, e.g.
``{"claude-native": True}``. Written on every connect, including
``None`` from a host that doesn't report it.
:returns: The upserted :class:`Host`.
"""
now = now_epoch()
harnesses_json = (
json.dumps(configured_harnesses) if configured_harnesses is not None else None
)
gateway_inference_json = (
json.dumps(gateway_inference) if gateway_inference is not None else None
)
with self._session() as session:
# Primary lookup: by (workspace_id, host_id) — the new PK.
row = session.get(SqlHost, (current_workspace_id(), host_id))
@@ -260,6 +301,7 @@ class HostStore:
row.status = encode_host_status("online")
row.updated_at = now
row.configured_harnesses = harnesses_json
row.gateway_inference = gateway_inference_json
return _row_to_host(row)
# host_id is new — check whether (workspace_id, user_id, name)
@@ -274,6 +316,7 @@ class HostStore:
name=name,
user_id=user_id,
configured_harnesses_json=harnesses_json,
gateway_inference_json=gateway_inference_json,
)
if reowned is not None:
return reowned
@@ -290,7 +333,14 @@ class HostStore:
# host_id is now part of the PK, so we can't UPDATE it via the
# ORM — delete the old row and insert a fresh one that carries
# the new host_id while preserving created_at.
row = self._rotate_host_id(session, existing_by_name, host_id, now, harnesses_json)
row = self._rotate_host_id(
session,
existing_by_name,
host_id,
now,
harnesses_json,
gateway_inference_json,
)
return _row_to_host(row)
# Genuinely new host: plain INSERT.
@@ -302,6 +352,7 @@ class HostStore:
created_at=now,
updated_at=now,
configured_harnesses=harnesses_json,
gateway_inference=gateway_inference_json,
)
session.add(row)
return _row_to_host(row)
@@ -313,6 +364,7 @@ class HostStore:
new_host_id: str,
now: int,
harnesses_json: str | None,
gateway_inference_json: str | None = None,
) -> SqlHost:
"""Replace a host row's host_id while repointing its conversations.
@@ -333,6 +385,8 @@ class HostStore:
:param new_host_id: The host_id the host reconnected with.
:param now: Unix epoch seconds for the updated_at timestamp.
:param harnesses_json: JSON-encoded harness readiness, or None.
:param gateway_inference_json: JSON-encoded gateway-inference map, or
None when the host didn't report it.
:returns: The newly inserted :class:`SqlHost` row.
"""
old_host_id = row.host_id
@@ -386,6 +440,7 @@ class HostStore:
sandbox_provider=sandbox_provider,
sandbox_id=sandbox_id,
configured_harnesses=harnesses_json,
gateway_inference=gateway_inference_json,
)
session.add(new_row)
session.flush()
@@ -411,6 +466,7 @@ class HostStore:
name: str,
user_id: str,
configured_harnesses_json: str | None = None,
gateway_inference_json: str | None = None,
) -> Host | None:
"""Re-own an existing host_id row under a new ``(user_id, name)``.
@@ -435,6 +491,9 @@ class HostStore:
``'{"claude-sdk": true}'``, or ``None`` when unreported.
Written like the normal connect paths so a re-owned row
carries fresh (not stale) readiness.
:param gateway_inference_json: JSON-encoded gateway-inference map from
the connecting host's hello, e.g. ``'{"claude-native": true}'``, or
``None`` when unreported.
:returns: The re-owned :class:`Host`, or ``None`` if no row holds
*host_id* (caller falls through to a normal insert).
"""
@@ -459,6 +518,7 @@ class HostStore:
status=encode_host_status("online"),
updated_at=now,
configured_harnesses=configured_harnesses_json,
gateway_inference=gateway_inference_json,
)
)
return Host(
@@ -471,6 +531,7 @@ class HostStore:
sandbox_provider=existing.sandbox_provider,
sandbox_id=existing.sandbox_id,
configured_harnesses=_parse_configured_harnesses(configured_harnesses_json),
gateway_inference=_parse_gateway_inference(gateway_inference_json),
)
def set_offline(self, host_id: str) -> None:
@@ -497,11 +558,15 @@ class HostStore:
self,
host_id: str,
configured_harnesses: dict[str, HarnessAvailability],
gateway_inference: dict[str, bool] | None = None,
) -> None:
"""Replace a connected host's live per-harness readiness map.
:param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``.
:param configured_harnesses: Current readiness keyed by harness spelling.
:param gateway_inference: Current per-harness AI-Gateway-backed
inference flags, or ``None`` when the host didn't report them
(stored as NULL, i.e. "unknown").
"""
with self._session() as session:
session.execute(
@@ -512,6 +577,9 @@ class HostStore:
)
.values(
configured_harnesses=json.dumps(configured_harnesses),
gateway_inference=(
json.dumps(gateway_inference) if gateway_inference is not None else None
),
updated_at=now_epoch(),
)
)
+13 -1
View File
@@ -12,5 +12,17 @@ The :func:`emit` function accepts any event dataclass defined in
from __future__ import annotations
from omnigent.telemetry.client import emit, init_client, is_disabled
from omnigent.telemetry.routing import (
SETTING_SUBAGENT_ROUTING,
record_routing_decision,
record_routing_setting_changed,
)
__all__ = ["emit", "init_client", "is_disabled"]
__all__ = [
"SETTING_SUBAGENT_ROUTING",
"emit",
"init_client",
"is_disabled",
"record_routing_decision",
"record_routing_setting_changed",
]
+80
View File
@@ -32,6 +32,9 @@ class SessionCreatedEvent:
:param agent_name: Agent name for known multi-agent orchestrators
(e.g. ``"polly"``, ``"debby"``); ``None`` for all other agents to
avoid leaking user-defined agent names.
:param routing_enabled: ``True`` when smart routing is on for this
session at creation time. Later toggles arrive as
:class:`RoutingSettingChangedEvent`.
"""
installation_id: str | None
@@ -44,6 +47,7 @@ class SessionCreatedEvent:
is_fork: bool
is_sub_agent: bool
agent_name: str | None = None
routing_enabled: bool = False
@dataclass
@@ -80,3 +84,79 @@ class SessionDeletedEvent:
input_tokens: int | None
output_tokens: int | None
total_cost_usd: float | None
@dataclass
class RoutingDecisionEvent:
"""Fired once per smart-routing decision the server makes.
Covers every scope the router decides at: a turn's model, a spawned
child session's model, the harness picked for an auto-harness session,
and a native-subagent spawn. Mirror copies written into a parent's
transcript do not fire a second event one decision, one event,
joinable to the transcript chip by ``decision_id``.
Nothing free-form is recorded: the judge's rationale and the routed
prompt never leave the process, and model ids are reduced to
allowlisted tokens by :mod:`omnigent.telemetry.model_labels` because a
servable id can be a user-named workspace endpoint.
:param installation_id: Server-side installation ID.
:param session_id: Omnigent conversation/session identifier. No
``anon_user_id`` is carried: the deciding paths run without a
request identity, and ``SessionCreatedEvent`` already maps a
session to its user.
:param scope: What the decision governs: ``"turn"``,
``"child_session"``, ``"session"`` (auto-harness) or
``"native_subagent"``.
:param harness: Harness the decision applies to, e.g. ``"codex"``.
:param action: ``"allow"``, ``"rewrite"``, ``"redirect"`` or
``"deny"``. Server-side turn decisions are ``"rewrite"`` when
applied and ``"allow"`` when not.
:param applied: ``True`` when the pick actually changed the spawn/turn.
:param model_family: Vendor family token of the picked model, e.g.
``"claude"``; ``"other"`` when unrecognised.
:param model_tier: Capability tier token of the picked model, e.g.
``"opus"``; ``"other"`` when unrecognised.
:param raw_model_resolved: ``True`` when the router answered in its own
vocabulary and Omnigent had to resolve it to a servable id.
:param overrode_agent_model: ``True`` when the router overrode a model
the calling agent asked for.
:param decision_id: Decision identity shared with the transcript chip.
"""
installation_id: str | None
session_id: str
scope: str
harness: str | None
action: str
applied: bool
model_family: str | None
model_tier: str | None
raw_model_resolved: bool
overrode_agent_model: bool
decision_id: str
@dataclass
class RoutingSettingChangedEvent:
"""Fired when a routing setting is changed on a live session.
This is the mid-session enable/disable signal: pair it with
``SessionCreatedEvent.routing_enabled`` to reconstruct a session's
routing state over time.
:param installation_id: Server-side installation ID.
:param session_id: Omnigent conversation/session identifier.
:param anon_user_id: First 16 hex chars of ``sha256("<installation_id>:<user_id>")``.
:param setting: Which setting changed; today always
``"subagent_routing"``.
:param value: ``"on"``, ``"off"``, or ``"default"`` when the override
was cleared and the session falls back to the agent's setting.
"""
installation_id: str | None
session_id: str
anon_user_id: str | None
setting: str
value: str
+84
View File
@@ -0,0 +1,84 @@
"""Model id → coarse family/tier labels for routing telemetry.
A routing decision names a *servable model id*, which on Databricks is a
workspace serving-endpoint name (e.g. ``"acme-internal-review-llm"``).
Those names are user-defined, so no model string is ever shipped
verbatim. Both label functions are strict allowlists: they return a token
from a fixed tuple defined here, or ``"other"``. This mirrors the
``agent_name`` convention in :mod:`omnigent.telemetry.events`, where only
known built-in agent names are recorded.
A token must be a whole id segment (``-``/``_``/``.`` separated), bar a
trailing generation number, so ``"qwen3-coder"`` reads as ``qwen`` while
``"gemini-3-flash"`` does not read as the ``mini`` tier and
``"acme-project-zephyr"`` does not read as the ``pro`` tier.
"""
from __future__ import annotations
import re
#: Vendor families recognised in model ids.
_FAMILIES: tuple[str, ...] = (
"claude",
"gpt",
"codex",
"glm",
"gemini",
"llama",
"qwen",
"kimi",
"deepseek",
"mistral",
"grok",
)
#: Capability tiers recognised in model ids. ``"mini"``/``"nano"`` are
#: OpenAI spellings; the rest are Anthropic/Google.
_TIERS: tuple[str, ...] = (
"opus",
"sonnet",
"haiku",
"fable",
"mini",
"nano",
"flash",
"pro",
"max",
)
_SEGMENT_SPLIT = re.compile(r"[^a-z0-9]+")
def _match(model: str | None, tokens: tuple[str, ...]) -> str | None:
"""Return the first token in *tokens* naming a segment of *model*.
:param model: Servable model id. ``None`` or empty yields ``None``.
:param tokens: Allowlisted tokens to look for.
:returns: The matching token, ``"other"`` when none matches, or
``None`` when there is no model.
"""
if not model:
return None
segments = {seg.rstrip("0123456789") for seg in _SEGMENT_SPLIT.split(model.lower())}
return next((token for token in tokens if token in segments), "other")
def model_family(model: str | None) -> str | None:
"""Return the vendor family token for *model*.
:param model: Servable model id, e.g. ``"databricks-claude-opus-4-8"``.
:returns: A token from :data:`_FAMILIES`, ``"other"`` when none
matches, or ``None`` when there is no model.
"""
return _match(model, _FAMILIES)
def model_tier(model: str | None) -> str | None:
"""Return the capability tier token for *model*.
:param model: Servable model id, e.g. ``"databricks-claude-opus-4-8"``.
:returns: A token from :data:`_TIERS`, ``"other"`` when none matches,
or ``None`` when there is no model.
"""
return _match(model, _TIERS)
+119
View File
@@ -0,0 +1,119 @@
"""Recorders for smart-routing usage telemetry.
The routing paths are spread across the turn dispatcher, the subagent
policy and the session PATCH handler. Keeping the event construction
here means the pipeline's conventions — installation id, anonymised user
id, and the model-label allowlist are applied in one place rather than
restated at every call site.
Both recorders are fire-and-forget and never raise: :func:`omnigent.telemetry.emit`
already no-ops when telemetry is disabled.
"""
from __future__ import annotations
import hashlib
import logging
from omnigent.telemetry.client import emit
from omnigent.telemetry.events import RoutingDecisionEvent, RoutingSettingChangedEvent
from omnigent.telemetry.model_labels import model_family, model_tier
_logger = logging.getLogger(__name__)
#: The only setting :func:`record_routing_setting_changed` reports today.
SETTING_SUBAGENT_ROUTING = "subagent_routing"
def _anon_user_id(installation_id: str | None, user_id: str | None) -> str | None:
"""Hash *user_id* into the pipeline's anonymised user identifier.
:param installation_id: Server-side installation ID, used as salt.
:param user_id: Requesting user's identifier. ``None`` yields ``None``.
:returns: First 16 hex chars of ``sha256("<installation_id>:<user_id>")``.
"""
if user_id is None:
return None
salt = f"{installation_id}:{user_id}" if installation_id else user_id
return hashlib.sha256(salt.encode()).hexdigest()[:16]
def record_routing_decision(
session_id: str,
*,
scope: str,
harness: str | None,
action: str,
applied: bool,
model: str | None,
raw_model: str | None,
overrode_agent_model: bool,
decision_id: str,
) -> None:
"""Record one routing decision.
:param session_id: Session the decision was made for.
:param scope: ``"turn"``, ``"child_session"``, ``"session"`` or
``"native_subagent"``.
:param harness: Harness the decision applies to.
:param action: ``"allow"``, ``"rewrite"``, ``"redirect"`` or ``"deny"``.
:param applied: ``True`` when the pick changed the spawn/turn.
:param model: Picked servable model id. Reduced to family/tier tokens
before it leaves the process; never shipped verbatim.
:param raw_model: The router's own-vocabulary pick, when it needed
resolving. Only its presence is recorded.
:param overrode_agent_model: ``True`` when the router overrode a model
the calling agent asked for.
:param decision_id: Decision identity shared with the transcript chip.
"""
try:
from omnigent.telemetry.installation_id import get_installation_id
emit(
RoutingDecisionEvent(
installation_id=get_installation_id(),
session_id=session_id,
scope=scope,
harness=harness,
action=action,
applied=applied,
model_family=model_family(model),
model_tier=model_tier(model),
raw_model_resolved=bool(raw_model),
overrode_agent_model=overrode_agent_model,
decision_id=decision_id,
)
)
except Exception:
_logger.debug("Routing decision telemetry failed; dropping event", exc_info=True)
def record_routing_setting_changed(
session_id: str,
*,
setting: str,
value: str,
user_id: str | None,
) -> None:
"""Record a mid-session routing setting change.
:param session_id: Session whose setting changed.
:param setting: Setting name, e.g. :data:`SETTING_SUBAGENT_ROUTING`.
:param value: ``"on"``, ``"off"`` or ``"default"``.
:param user_id: Requesting user, anonymised before emission.
"""
try:
from omnigent.telemetry.installation_id import get_installation_id
installation_id = get_installation_id()
emit(
RoutingSettingChangedEvent(
installation_id=installation_id,
session_id=session_id,
anon_user_id=_anon_user_id(installation_id, user_id),
setting=setting,
value=value,
)
)
except Exception:
_logger.debug("Routing setting telemetry failed; dropping event", exc_info=True)
+120 -4
View File
@@ -503,6 +503,30 @@
"title": "Pending Elicitations Count",
"type": "integer"
},
"routed_model": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Model this sub-agent runs on when one was pinned for it, e.g. `\"databricks-claude-opus-4-8\"`. Read from the child's `model_override` \u2014 the field intelligent routing writes when it picks a model for a spawned child. `None` when the child inherits the parent/spec model.",
"title": "Routed Model"
},
"routing_decision_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Identifier of the routing decision that produced `routed_model`, mirroring `RoutingDecisionData.decision_id`. Read from the child's `omnigent.routing.decision_id` label, stamped when routing pins the model. `None` when the child was not routed.",
"title": "Routing Decision Id"
},
"session_name": {
"anyOf": [
{
@@ -3215,6 +3239,42 @@
"title": "Applied",
"type": "boolean"
},
"attempted_override": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Model an LLM-supplied `args.model` asked for and the router overrode, e.g. `\"databricks-gpt-5-5\"`. `None` when nothing was attempted.",
"title": "Attempted Override"
},
"decision_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Router decision identifier, e.g. `\"3f1c\u2026\"`. Correlates the transcript item with the routing telemetry event and the child-sessions API row. `None` for decisions made before decision ids existed.",
"title": "Decision Id"
},
"harness": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Harness the decision applies to, e.g. `\"claude-native\"` or `\"codex\"`. `None` when the decision picked a model only (no harness dimension).",
"title": "Harness"
},
"model": {
"description": "The concrete brain model the router chose, e.g. `\"databricks-claude-opus-4-8\"`.",
"title": "Model",
@@ -3224,6 +3284,30 @@
"description": "The router's one-line explanation, shown as muted secondary text, e.g. `\"Multi-file refactor needs deep reasoning.\"`.",
"title": "Rationale",
"type": "string"
},
"raw_model": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "The router-vocabulary pick before resolution to a servable catalog id, e.g. `\"gpt-5-6-sol\"`. `None` when the pick needed no resolution.",
"title": "Raw Model"
},
"scope": {
"default": "turn",
"description": "What the decision governs \u2014 `\"session\"` (auto-harness session routing), `\"turn\"` (per-turn routing), `\"child_session\"` (an Omnigent-spawned sub-agent) or `\"native_subagent\"` (a Task / `spawn_agent` spawn routed inside the harness). Defaults to `\"turn\"` so rows persisted before this field deserialize.",
"enum": [
"session",
"turn",
"child_session",
"native_subagent"
],
"title": "Scope",
"type": "string"
}
},
"required": [
@@ -5207,6 +5291,18 @@
"description": "For sub-agent sessions, the sub-agent type name within the parent's spec tree, e.g. `\"summarizer\"`. `None` for top-level sessions.",
"title": "Sub Agent Name"
},
"subagent_routing_override": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Per-session subagent-routing switch: `\"on\"` routes subagent spawns, `\"off\"` leaves them unrouted. `None` means the session inherits its main routing state (own or parent `cost_control_mode_override == \"on\"`) \u2014 the value the in-session \"Subagent routing\" row renders as \"Default\". Set via `PATCH /v1/sessions/{id}`.",
"title": "Subagent Routing Override"
},
"terminal_launch_args": {
"anyOf": [
{
@@ -5276,6 +5372,14 @@
"description": "Per-model breakdown of the same subtree usage, keyed by the raw harness model id, e.g. `{\"claude-sonnet-4-6\": ModelUsage(input_tokens=12000, ...)}`. `None` when no per-model usage has been recorded (older sessions recorded before this field existed, or before the first turn). Lets the UI show which models a session spent its tokens / budget on.",
"title": "Usage By Model"
},
"warnings": {
"items": {
"additionalProperties": true,
"type": "object"
},
"title": "Warnings",
"type": "array"
},
"workspace": {
"anyOf": [
{
@@ -6556,6 +6660,18 @@
"title": "Silent",
"type": "boolean"
},
"subagent_routing_override": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Per-session subagent-routing switch: `\"on\"` routes subagent spawns, `\"off\"` leaves them unrouted. Explicit JSON `null` clears the override so the session inherits its main routing state again; omitting the field leaves it unchanged (same presence-is-the-clear-signal rule as `cost_control_mode_override`). Effective on the next spawn, so it can be changed at any point in a session.",
"title": "Subagent Routing Override"
},
"terminal_launch_args": {
"anyOf": [
{
@@ -7084,7 +7200,7 @@
},
"/v1/hosts": {
"get": {
"description": "List all hosts owned by the authenticated user.\n\nReturns both online and offline hosts, with live runner\ninformation for online hosts.\n\n**Returns:** `{\"hosts\": [...]}` with host details.",
"description": "List all hosts owned by the authenticated user.\n\nReturns both online and offline hosts, with live runner\ninformation for online hosts.\n\n**Returns:** `{\"hosts\": [...]}` with host details \u2014 `host_id`, `name`, `owner`, `status`, `sandbox_provider`, `configured_harnesses`, and `gateway_inference` (`None` when the host never reported it).",
"operationId": "list_hosts_v1_hosts_get",
"responses": {
"200": {
@@ -7114,7 +7230,7 @@
},
"/v1/hosts/{host_id}": {
"get": {
"description": "Get details for a single host.\n\n**Returns:** Host details dict.\n\n**Raises**\n\n- `HTTPException` \u2014 404 if the host does not exist.",
"description": "Get details for a single host.\n\n**Returns:** Host details dict \u2014 the `list_hosts` fields (including `gateway_inference`, `None` when unreported) plus `runners`.\n\n**Raises**\n\n- `HTTPException` \u2014 404 if the host does not exist.",
"operationId": "get_host_v1_hosts__host_id__get",
"parameters": [
{
@@ -7458,7 +7574,7 @@
},
"/v1/hosts/{host_id}/harnesses/{harness}/credential": {
"post": {
"description": "Write a harness provider credential onto a connected host.\n\nBacks the Web UI setup dialog's \"Add a credential\" action so a user can\nconfigure a Claude / Codex / Pi credential on a connected host without a\nterminal. Owner-scoped, allowlisted, and gated behind\n`OMNIGENT_HARNESS_INSTALL_ENABLED` exactly like the install route\n(404 when disabled). The host daemon does the write with the same\nnon-interactive core the `omnigent setup` wizard uses.\n\nSecurity: the server is an authz'd pass-through \u2014 it validates\nownership + the allowlist and forwards the secret over the (TLS) tunnel;\nit never persists the secret or logs it. The secret rides in the request\nbody (not the URL), and the frame field is redaction-named so it never\nlands on a telemetry span.\n\n**Parameters**\n\n- `body` \u2014 The credential payload (kind + secret / gateway / adopt).\n\n**Returns:** `{\"object\": \"harness_credential\", \"harness\": ..., \"configured_harnesses\": {...}}` \u2014 refreshed readiness so the UI can flip the badge without a reconnect.\n\n**Raises**\n\n- `HTTPException` \u2014 404 when disabled or host unknown, 400 when the harness isn't UI-configurable or the body is invalid, 403 when not the owner, 409 when offline, 502 on host-side failure, 504 on timeout.",
"description": "Write a harness provider credential onto a connected host.\n\nBacks the Web UI setup dialog's \"Add a credential\" action so a user can\nconfigure a Claude / Codex / Pi credential on a connected host without a\nterminal. Owner-scoped, allowlisted, and gated behind\n`OMNIGENT_HARNESS_INSTALL_ENABLED` exactly like the install route\n(404 when disabled). The host daemon does the write with the same\nnon-interactive core the `omnigent setup` wizard uses.\n\nSecurity: the server is an authz'd pass-through \u2014 it validates\nownership + the allowlist and forwards the secret over the (TLS) tunnel;\nit never persists the secret or logs it. The secret rides in the request\nbody (not the URL), and the frame field is redaction-named so it never\nlands on a telemetry span.\n\n**Parameters**\n\n- `body` \u2014 The credential payload (kind + secret / gateway / adopt).\n\n**Returns:** `{\"object\": \"harness_credential\", \"harness\": ..., \"configured_harnesses\": {...}, \"gateway_inference\": {...} | None}` \u2014 refreshed readiness so the UI can flip the badge without a reconnect, plus the refreshed gateway-inference map (`None` when the host didn't report one).\n\n**Raises**\n\n- `HTTPException` \u2014 404 when disabled or host unknown, 400 when the harness isn't UI-configurable or the body is invalid, 403 when not the owner, 409 when offline, 502 on host-side failure, 504 on timeout.",
"operationId": "store_host_harness_credential_v1_hosts__host_id__harnesses__harness__credential_post",
"parameters": [
{
@@ -7524,7 +7640,7 @@
},
"/v1/hosts/{host_id}/harnesses/{harness}/install": {
"post": {
"description": "Install a missing, npm-installable harness CLI onto a host.\n\nBacks the Web UI's New Chat dialog \"Install\" action so a user can\ninstall a harness the connected host is missing without dropping to a\nterminal. Owner-scoped like the other host actions: only the host owner\nmay install onto it. Scoped to the UI-installable allowlist (claude,\ncodex, pi, opencode, qwen) \u2014 curl/brew and interactive-auth harnesses\nare refused. The whole route is gated behind\n`OMNIGENT_HARNESS_INSTALL_ENABLED` (default off): when disabled it\nreturns 404 so the feature is invisible until opted in.\n\nConcurrent requests for the same (host, harness) coalesce onto one\nin-flight install so a double-click can't fire two global npm installs.\n\n**Returns:** `{\"object\": \"harness_install\", \"harness\": ..., \"configured_harnesses\": {...}}` \u2014 the host's refreshed readiness map so the UI can flip the badge without a reconnect.\n\n**Raises**\n\n- `HTTPException` \u2014 404 when the feature is disabled or the host is unknown, 400 when the harness is not UI-installable, 403 when the caller is not the host owner, 409 when the host is offline, 502 on a host-side install failure, 504 on host timeout.",
"description": "Install a missing, npm-installable harness CLI onto a host.\n\nBacks the Web UI's New Chat dialog \"Install\" action so a user can\ninstall a harness the connected host is missing without dropping to a\nterminal. Owner-scoped like the other host actions: only the host owner\nmay install onto it. Scoped to the UI-installable allowlist (claude,\ncodex, pi, opencode, qwen) \u2014 curl/brew and interactive-auth harnesses\nare refused. The whole route is gated behind\n`OMNIGENT_HARNESS_INSTALL_ENABLED` (default off): when disabled it\nreturns 404 so the feature is invisible until opted in.\n\nConcurrent requests for the same (host, harness) coalesce onto one\nin-flight install so a double-click can't fire two global npm installs.\n\n**Returns:** `{\"object\": \"harness_install\", \"harness\": ..., \"configured_harnesses\": {...}, \"gateway_inference\": {...} | None}` \u2014 the host's refreshed readiness map so the UI can flip the badge without a reconnect, plus its refreshed gateway-inference map (`None` when the host didn't report one).\n\n**Raises**\n\n- `HTTPException` \u2014 404 when the feature is disabled or the host is unknown, 400 when the harness is not UI-installable, 403 when the caller is not the host owner, 409 when the host is offline, 502 on a host-side install failure, 504 on host timeout.",
"operationId": "install_host_harness_v1_hosts__host_id__harnesses__harness__install_post",
"parameters": [
{
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
. "$(cd "$(dirname "$0")" && pwd)/dev-env.sh"
# nvm's lazy-load shim breaks in non-interactive shells, so resolve a real
# binary: PATH first, then the newest nvm install, plus homebrew for pnpm.
if ! command -v node >/dev/null 2>&1; then
NODE_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)"
[ -n "$NODE_BIN" ] && PATH="$NODE_BIN:$PATH"
fi
PATH="$PATH:/opt/homebrew/bin"
command -v pnpm >/dev/null 2>&1 || { echo "pnpm not found; see LOCAL_SETUP.md" >&2; exit 1; }
cd "$WORKTREE/web"
OMNIGENT_URL="http://localhost:$ROUTING_SERVER_PORT" exec pnpm run dev -- --port "$ROUTING_FRONTEND_PORT"
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
. "$(cd "$(dirname "$0")" && pwd)/dev-env.sh"
cd "$WORKTREE"
exec uv run --no-sync omni host "http://localhost:$ROUTING_SERVER_PORT"
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
. "$(cd "$(dirname "$0")" && pwd)/dev-env.sh"
cd "$WORKTREE"
exec uv run --no-sync omni server -c "$OMNIGENT_CONFIG_HOME/config.yaml" --port "$ROUTING_SERVER_PORT"
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# Live contract probe for the AIGW intelligent-routing API (L3 in
# designs/INTELLIGENT_ROUTING_PLAN.md §6).
#
# Asserts the facts the Omnigent routing client depends on: task_v1 infers a
# scenario from which model arms are offered, each scenario requires its full
# fixed menu, extra non-arm models are tolerated, and an unknown router name
# enumerates the registered routers (so a task_v2 landing is loud).
#
# Not commit-gating — run before demos and whenever AIGW deploys.
#
# Usage:
# scripts/probe_routing_api.sh # eng-ml-inference staging
# ROUTING_PROFILE=my-ws ROUTING_BASE_URL=https://... scripts/probe_routing_api.sh
#
# Requires: bash, curl, python3, databricks CLI (authenticated profile).
set -uo pipefail
PROFILE="${ROUTING_PROFILE:-eng-ml-inference}"
BASE_URL="${ROUTING_BASE_URL:-https://eng-ml-inference.staging.cloud.databricks.com}"
URL="${BASE_URL%/}/ai-gateway/routing/v1/routes:select"
ROUTER="${ROUTING_ROUTER_NAME:-task_v1}"
CLAUDE_ARMS='{"model":"claude-opus-4-8","harness":"claude-sdk"},{"model":"claude-sonnet-5","harness":"claude-sdk"}'
CODEX_ARMS='{"model":"glm-5-2","harness":"codex"},{"model":"gpt-5-6-sol","harness":"codex"},{"model":"gpt-5-6-luna","harness":"codex"}'
CLAUDE_MODELS="claude-opus-4-8 claude-sonnet-5"
CODEX_MODELS="glm-5-2 gpt-5-6-sol gpt-5-6-luna"
PROMPT='Rename the retry_count variable to attempt_count in one file.'
pass_count=0
fail_count=0
die() {
echo "FATAL: $*" >&2
exit 2
}
command -v curl >/dev/null || die "curl not found"
command -v python3 >/dev/null || die "python3 not found"
command -v databricks >/dev/null || die "databricks CLI not found"
echo "probe_routing_api: profile=$PROFILE router=$ROUTER"
echo " url=$URL"
echo
TOKEN_JSON="$(databricks auth token -p "$PROFILE" -o json 2>&1)" || die \
"databricks auth token -p $PROFILE failed: $TOKEN_JSON"
TOKEN="$(printf '%s' "$TOKEN_JSON" | python3 -c '
import json, sys
try:
print(json.load(sys.stdin).get("access_token", ""))
except Exception:
print("")
')"
[ -n "$TOKEN" ] || die "could not extract access_token for profile $PROFILE"
# post <body> -> sets RESP_BODY / RESP_CODE
post() {
local body="$1" raw
raw="$(curl -sS -m 60 -w $'\n__HTTP__%{http_code}' -X POST "$URL" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "$body" 2>&1)"
RESP_CODE="${raw##*__HTTP__}"
RESP_BODY="${raw%$'\n'__HTTP__*}"
case "$RESP_CODE" in
[0-9][0-9][0-9]) ;;
*) RESP_CODE="000"; RESP_BODY="$raw" ;;
esac
}
body_for() {
# body_for <route_options_json> [router_name]
local options="$1" router="${2:-$ROUTER}"
python3 - "$options" "$router" "$PROMPT" <<'PY'
import json, sys
options, router, prompt = sys.argv[1], sys.argv[2], sys.argv[3]
print(json.dumps({
"route_options": json.loads("[" + options + "]"),
"task": {"prompt": prompt},
"route_selector": {"router_name": router},
}))
PY
}
CHECKER="$(mktemp -t probe_routing_check.XXXXXX.py)"
trap 'rm -f "$CHECKER"' EXIT
cat >"$CHECKER" <<'PY'
import json, sys
code, mode, args = sys.argv[1], sys.argv[2], sys.argv[3:]
raw = sys.stdin.read()
try:
doc = json.loads(raw)
except Exception:
print(f"response is not JSON (HTTP {code})")
sys.exit(1)
def selection():
sel = doc.get("route_selection") or []
if not sel:
raise AssertionError("route_selection is empty")
opt = sel[0].get("route_option") or {}
return opt.get("model"), opt.get("harness")
try:
if mode == "routes_from":
assert code == "200", f"expected HTTP 200, got {code}"
model, harness = selection()
allowed = args
assert model in allowed, f"picked {model!r}, expected one of {allowed}"
assert doc.get("rationale"), "rationale missing"
print(f"picked model={model} harness={harness}")
elif mode == "missing_arms":
assert code == "400", f"expected HTTP 400, got {code}"
msg = doc.get("message") or ""
assert "full menu" in msg, f"message does not mention the full menu: {msg!r}"
for arm in args:
assert arm in msg, f"message does not name missing arm {arm!r}: {msg!r}"
print(msg)
elif mode == "unknown_router":
assert code == "400", f"expected HTTP 400, got {code}"
msg = doc.get("message") or ""
assert "unknown router" in msg.lower(), f"unexpected message: {msg!r}"
for router in args:
assert router in msg, f"known-routers list omits {router!r}: {msg!r}"
print(msg)
else:
raise AssertionError(f"unknown check mode {mode!r}")
except AssertionError as exc:
print(str(exc))
sys.exit(1)
sys.exit(0)
PY
# check <mode> [args...] — reads RESP_BODY on stdin, prints a reason, exits 0/1
check() {
printf '%s' "$RESP_BODY" | python3 "$CHECKER" "$RESP_CODE" "$@"
}
# case <label> <route_options|--router:NAME options> <check mode + args...>
run_case() {
local label="$1" options="$2" router="$3"
shift 3
local body
body="$(body_for "$options" "$router")"
post "$body"
local detail rc
detail="$(check "$@")"
rc=$?
if [ "$rc" -eq 0 ]; then
pass_count=$((pass_count + 1))
echo "PASS $label"
else
fail_count=$((fail_count + 1))
echo "FAIL $label"
fi
echo " HTTP $RESP_CODE :: $detail"
echo " resp: $(printf '%s' "$RESP_BODY" | head -c 300)"
echo
}
# (a) full five-arm menu (scenario "both") routes.
run_case "a) task_v1 full 5-arm menu routes" \
"$CLAUDE_ARMS,$CODEX_ARMS" "$ROUTER" \
routes_from $CLAUDE_MODELS $CODEX_MODELS
# (b) codex arms only -> a codex arm (harness constraint via the offered menu).
run_case "b) codex-arms-only routes to a codex arm" \
"$CODEX_ARMS" "$ROUTER" \
routes_from $CODEX_MODELS
# (c) claude arms only -> a claude arm.
run_case "c) claude-arms-only routes to a claude arm" \
"$CLAUDE_ARMS" "$ROUTER" \
routes_from $CLAUDE_MODELS
# (d) partial menu -> 400 naming the missing arms.
run_case "d) partial menu 400s naming missing arms" \
'{"model":"claude-opus-4-8","harness":"claude-sdk"}' "$ROUTER" \
missing_arms claude-sonnet-5
# (e) full menu + extra non-arm models is tolerated (catalog superset).
run_case "e) menu + extra non-arm models still routes" \
"$CLAUDE_ARMS,$CODEX_ARMS,{\"model\":\"gpt-5-5\",\"harness\":\"codex\"},{\"model\":\"claude-haiku-4-5\",\"harness\":\"claude-sdk\"}" \
"$ROUTER" \
routes_from $CLAUDE_MODELS $CODEX_MODELS
# (f) unknown router name enumerates the known routers — catches a task_v2
# landing or task_v1 being retired.
run_case "f) unknown router 400 enumerates known routers (task_v1 present)" \
"$CLAUDE_ARMS,$CODEX_ARMS" "omnigent_probe_no_such_router" \
unknown_router task_v1
echo "----"
echo "probe_routing_api: $pass_count passed, $fail_count failed"
[ "$fail_count" -eq 0 ] || exit 1
+5 -1
View File
@@ -26,7 +26,11 @@ def test_external_builds_client() -> None:
assert client._url == "https://host/ai-gateway/routing/v1/routes:select"
assert client._router_name == "task_v0"
assert client._auth is None # no profile -> unauthenticated
assert client._model_prefixes == [] # no prefix -> catalog ids sent verbatim
# No prefix configured -> the module's shared catalog-prefix list, so the
# client and the server-side seam can't disagree about a catalog id.
from omnigent.server.smart_routing import MODEL_ID_PREFIXES
assert client._model_prefixes == list(MODEL_ID_PREFIXES)
def test_external_threads_model_prefix_scalar() -> None:
+30 -2
View File
@@ -5453,12 +5453,12 @@ def test_native_terminal_dispatch_specs_cover_registered_native_agents() -> None
(
"claude-native",
"omnigent.claude_native.run_claude_native",
{"extra_args": ("--model", "native-model")},
{"extra_args": ("--model", "native-model"), "prompt": None},
),
(
"codex-native",
"omnigent.codex_native.run_codex_native",
{"extra_args": (), "model": "native-model"},
{"extra_args": (), "model": "native-model", "prompt": None},
),
(
"pi-native",
@@ -5625,6 +5625,34 @@ def test_dispatch_native_terminal_harness_kiro_forwards_prompt(
assert captured["prompt"] == "review repo"
@pytest.mark.parametrize(
("harness", "target"),
[
("claude-native", "omnigent.claude_native.run_claude_native"),
("codex-native", "omnigent.codex_native.run_codex_native"),
],
)
def test_dispatch_native_terminal_harness_forwards_prompt_to_claude_and_codex(
monkeypatch: pytest.MonkeyPatch, harness: str, target: str
) -> None:
"""``run --harness claude-native -p`` is supported, not rejected.
Both wrappers deliver the text as the TUI's initial input, so a prompt is
no longer a REPL-only option for them. A multi-line prompt must arrive as
one value the wrappers put it on argv rather than pasting it.
"""
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda _s: "http://localhost:0")
captured: dict[str, object] = {}
monkeypatch.setattr(target, lambda **kwargs: captured.update(kwargs))
handled = _dispatch_native_terminal_harness(
**_native_dispatch_kwargs(harness=harness, prompt="first line\nsecond line")
)
assert handled is True
assert captured["prompt"] == "first line\nsecond line"
@pytest.mark.parametrize(
("harness", "target", "args_param"),
[
+696
View File
@@ -0,0 +1,696 @@
"""Tests for CLI-side Smart Routing: preflight, the routed create, and launch."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import httpx
import pytest
import respx
from click import ClickException, UsageError
from click.testing import CliRunner
from omnigent.cli import (
_dispatch_smart_routing,
_require_smart_routing_prompt,
_run_smart_routing,
_smart_routing_capable_harness,
_with_routed_model_arg,
cli,
)
from omnigent.smart_routing_cli import (
ROUTING_SESSION_LABELS,
check_smart_routing_available,
create_smart_routing_session,
known_host_id,
smart_routing_families,
)
_BASE = "http://localhost:6767"
_HOST_ID = "host_abc123"
_SESSION_ID = "conv_routed"
def _mock_info(*, enabled: bool = True) -> None:
respx.get(f"{_BASE}/v1/info").mock(
return_value=httpx.Response(200, json={"smart_routing_enabled": enabled})
)
def _mock_hosts(gateway: dict[str, Any] | None) -> None:
host: dict[str, Any] = {"host_id": _HOST_ID, "status": "online"}
if gateway is not None:
host["gateway_inference"] = gateway
respx.get(f"{_BASE}/v1/hosts").mock(return_value=httpx.Response(200, json={"hosts": [host]}))
def _mock_create(**session: Any) -> respx.Route:
"""Mock the routed create (and its snapshot re-read) with one session body."""
body = {"id": _SESSION_ID, **session}
respx.get(f"{_BASE}/v1/sessions/{_SESSION_ID}").mock(
return_value=httpx.Response(200, json=body)
)
return respx.post(f"{_BASE}/v1/sessions").mock(return_value=httpx.Response(201, json=body))
# ── preflight ────────────────────────────────────────────────────────────
@respx.mock
def test_preflight_passes_when_routing_enabled_and_gateway_absent() -> None:
"""An absent ``gateway_inference`` map is unknown, and unknown does not gate."""
_mock_info()
_mock_hosts(None)
check_smart_routing_available(base_url=_BASE, harnesses=("claude-native",), host_id=_HOST_ID)
@respx.mock
def test_preflight_rejects_when_server_cannot_route() -> None:
"""No routing client on the server is a hard error naming the reason."""
_mock_info(enabled=False)
with pytest.raises(ClickException, match="no routing model configured"):
check_smart_routing_available(
base_url=_BASE, harnesses=("claude-native",), host_id=_HOST_ID
)
@respx.mock
def test_preflight_rejects_when_host_inference_is_not_gateway_backed() -> None:
"""A routed model the pane cannot reach is worse than no pick — fail loud."""
_mock_info()
_mock_hosts({"claude-native": False, "codex-native": True})
with pytest.raises(ClickException, match=r"claude-native.*not AI-Gateway-backed"):
check_smart_routing_available(
base_url=_BASE, harnesses=("claude-native",), host_id=_HOST_ID
)
@respx.mock
def test_preflight_reports_the_hosts_own_reason_string() -> None:
"""A string entry carries the host's reason into the error text."""
_mock_info()
_mock_hosts({"codex-native": "provider-not-gateway"})
with pytest.raises(ClickException, match="provider-not-gateway"):
check_smart_routing_available(
base_url=_BASE, harnesses=("codex-native",), host_id=_HOST_ID
)
@respx.mock
def test_preflight_ignores_other_families_than_the_requested_one() -> None:
"""A fixed-harness route only needs its own family to be gateway-backed."""
_mock_info()
_mock_hosts({"claude-native": True, "codex-native": False})
check_smart_routing_available(base_url=_BASE, harnesses=("claude-native",), host_id=_HOST_ID)
@respx.mock
def test_preflight_keys_off_harness_spellings_not_bare_families() -> None:
"""``gateway_inference`` is keyed per harness spelling; ``claude`` is not one."""
_mock_info()
_mock_hosts({"claude": False})
# A bare-family key is not in the map's vocabulary, so it must read as "no
# entry" (unknown) rather than gate the launch.
check_smart_routing_available(base_url=_BASE, harnesses=("claude-native",), host_id=_HOST_ID)
@respx.mock
def test_preflight_reads_the_spelling_it_was_asked_about() -> None:
"""The map carries every spelling, so the caller's own is a valid key."""
_mock_info()
_mock_hosts({"native-claude": False})
with pytest.raises(ClickException, match="not AI-Gateway-backed"):
check_smart_routing_available(
base_url=_BASE, harnesses=("native-claude",), host_id=_HOST_ID
)
def test_auto_route_requires_both_native_families() -> None:
assert smart_routing_families(None) == ("claude-native", "codex-native")
assert smart_routing_families("auto") == ("claude-native", "codex-native")
assert smart_routing_families("codex-native") == ("codex-native",)
# ── the routed create ────────────────────────────────────────────────────
@respx.mock
def test_create_sends_the_routing_contract_for_a_fixed_harness() -> None:
"""Tier 2 binds the wrapper agent; the harness needs no override."""
route = _mock_create(harness="claude-native", model_override="claude-sonnet-5")
decision = create_smart_routing_session(
base_url=_BASE,
prompt="fix the flaky test",
harness="claude-native",
host_id=_HOST_ID,
workspace="/repo",
)
payload = json.loads(route.calls.last.request.content)
assert payload["cost_control_mode_override"] == "on"
assert payload["smart_routing_message"] == "fix the flaky test"
assert payload["host_type"] == "external"
# Bound to the launch host + its workspace: the router's candidate catalog
# comes from that host's model options, and the server 400s a host_id with
# no workspace.
assert payload["host_id"] == _HOST_ID
assert payload["workspace"] == "/repo"
assert payload["labels"] == ROUTING_SESSION_LABELS
# A fixed harness comes from the bound wrapper agent, so no override is
# sent — only the auto route uses the sentinel.
assert "harness_override" not in payload
assert (decision.session_id, decision.model) == (_SESSION_ID, "claude-sonnet-5")
assert decision.notice is None
@respx.mock
def test_create_asks_for_a_harness_on_the_auto_route() -> None:
"""Tier 3 sends the ``auto`` sentinel and reads the bound harness back."""
route = _mock_create(harness="codex-native", model_override="gpt-5.4")
decision = create_smart_routing_session(
base_url=_BASE,
prompt="port the parser",
harness=None,
host_id=_HOST_ID,
workspace="/repo",
)
assert json.loads(route.calls.last.request.content)["harness_override"] == "auto"
assert (decision.harness, decision.model) == ("codex-native", "gpt-5.4")
@respx.mock
def test_create_omits_the_workspace_when_there_is_no_host() -> None:
"""No host means no workspace either — the server validates them together."""
route = _mock_create(harness="claude-native", model_override="claude-sonnet-5")
create_smart_routing_session(base_url=_BASE, prompt="hello", harness="claude-native")
payload = json.loads(route.calls.last.request.content)
assert "host_id" not in payload
assert "workspace" not in payload
@respx.mock
def test_create_never_deletes_the_routed_session() -> None:
"""The routed session IS the session — the wrapper attaches to it."""
deleted = respx.delete(f"{_BASE}/v1/sessions/{_SESSION_ID}").mock(
return_value=httpx.Response(204)
)
_mock_create(harness="claude-native", model_override="claude-sonnet-5")
create_smart_routing_session(base_url=_BASE, prompt="hello", harness="claude-native")
assert not deleted.called
@respx.mock
def test_create_falls_back_to_the_session_snapshot() -> None:
"""A create response without the verdict is re-read from the snapshot."""
respx.post(f"{_BASE}/v1/sessions").mock(
return_value=httpx.Response(201, json={"id": _SESSION_ID})
)
respx.get(f"{_BASE}/v1/sessions/{_SESSION_ID}").mock(
return_value=httpx.Response(
200, json={"harness": "codex-native", "model_override": "gpt-5.4-mini"}
)
)
decision = create_smart_routing_session(base_url=_BASE, prompt="hello", harness=None)
assert (decision.harness, decision.model) == ("codex-native", "gpt-5.4-mini")
@respx.mock
def test_create_keeps_the_session_when_no_model_was_picked() -> None:
"""Fail-open: the session still launches, on the harness default model."""
_mock_create(harness="claude-native", model_override=None)
decision = create_smart_routing_session(
base_url=_BASE, prompt="hello", harness="claude-native"
)
assert decision.session_id == _SESSION_ID
assert decision.model is None
assert decision.notice is not None
assert "did not pick a model" in decision.notice
@respx.mock
def test_create_fails_open_on_a_server_error() -> None:
"""A rejected create (e.g. no native CLI for the auto route) never blocks."""
respx.post(f"{_BASE}/v1/sessions").mock(return_value=httpx.Response(400, text="no native CLI"))
decision = create_smart_routing_session(base_url=_BASE, prompt="hello", harness=None)
assert (decision.session_id, decision.harness, decision.model) == (None, None, None)
assert decision.notice is not None
assert "400" in decision.notice
@respx.mock
def test_create_fails_open_when_the_server_is_unreachable() -> None:
respx.post(f"{_BASE}/v1/sessions").mock(side_effect=httpx.ConnectError("refused"))
decision = create_smart_routing_session(
base_url=_BASE, prompt="hello", harness="claude-native"
)
assert decision.session_id is None
assert decision.notice is not None
assert "could not reach" in decision.notice
@respx.mock
def test_create_fails_open_when_the_response_has_no_session_id() -> None:
respx.post(f"{_BASE}/v1/sessions").mock(return_value=httpx.Response(201, json={}))
decision = create_smart_routing_session(
base_url=_BASE, prompt="hello", harness="claude-native"
)
assert decision.session_id is None
assert decision.notice is not None
# ── dispatch ─────────────────────────────────────────────────────────────
@pytest.fixture
def _routing_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Point the dispatch helpers at a fake backend, daemon, and host identity."""
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda _s: _BASE)
monkeypatch.setattr("omnigent.cli._ensure_host_daemon", lambda _s: False)
monkeypatch.setattr(
"omnigent.host.identity.load_or_create_host_identity",
lambda: type("_Id", (), {"host_id": _HOST_ID})(),
)
@respx.mock
def test_dispatch_tier2_attaches_the_wrapper_to_the_routed_session(
monkeypatch: pytest.MonkeyPatch, _routing_env: None
) -> None:
"""A fixed harness keeps its wrapper, gains ``--model``, and attaches."""
_mock_info()
_mock_hosts({"claude-native": True})
route = _mock_create(harness="claude-native", model_override="claude-opus-4-7")
captured: dict[str, Any] = {}
monkeypatch.setattr(
"omnigent.claude_native.run_claude_native", lambda **kw: captured.update(kw)
)
_dispatch_smart_routing(
harness="claude-native",
server=None,
prompt="fix the flaky test",
model=None,
auto_open_conversation=False,
)
# Attach, not bundle: the routed session already carries the model, the
# decision card, and the wrapper labels the server wrote at create.
assert captured["session_id"] == _SESSION_ID
assert captured["extra_args"] == ("--model", "claude-opus-4-7")
assert captured["prompt"] == "fix the flaky test"
payload = json.loads(route.calls.last.request.content)
assert payload["host_id"] == _HOST_ID
assert payload["workspace"] == str(Path.cwd().resolve())
@respx.mock
def test_dispatch_tier3_launches_the_harness_the_server_bound(
monkeypatch: pytest.MonkeyPatch, _routing_env: None
) -> None:
"""The auto route picks the harness; the CLI execs that wrapper on it."""
_mock_info()
_mock_hosts(None)
_mock_create(harness="codex-native", model_override="gpt-5.4")
captured: dict[str, Any] = {}
monkeypatch.setattr("omnigent.codex_native.run_codex_native", lambda **kw: captured.update(kw))
_dispatch_smart_routing(
harness=None,
server=None,
prompt="port the parser",
model=None,
auto_open_conversation=False,
)
assert captured["session_id"] == _SESSION_ID
assert captured["model"] == "gpt-5.4"
assert captured["prompt"] == "port the parser"
@respx.mock
def test_dispatch_fails_open_to_a_fresh_wrapper_session(
monkeypatch: pytest.MonkeyPatch, _routing_env: None, capsys: pytest.CaptureFixture[str]
) -> None:
"""A rejected create still launches — the wrapper bundles its own session."""
_mock_info()
_mock_hosts(None)
respx.post(f"{_BASE}/v1/sessions").mock(return_value=httpx.Response(503, text="down"))
captured: dict[str, Any] = {}
monkeypatch.setattr(
"omnigent.claude_native.run_claude_native", lambda **kw: captured.update(kw)
)
_dispatch_smart_routing(
harness=None,
server=None,
prompt="port the parser",
model=None,
auto_open_conversation=False,
)
err = capsys.readouterr().err
assert "Smart Routing was unavailable" in err
assert "launching claude-native" in err
assert captured["session_id"] is None
assert captured["extra_args"] == ()
assert captured["prompt"] == "port the parser"
@respx.mock
def test_dispatch_tier3_falls_back_when_the_pick_cannot_take_a_prompt(
monkeypatch: pytest.MonkeyPatch, _routing_env: None, capsys: pytest.CaptureFixture[str]
) -> None:
"""A harness the CLI cannot hand a prompt to is not a usable pick."""
_mock_info()
_mock_hosts(None)
_mock_create(harness="cursor-native", model_override="composer-2.5")
captured: dict[str, Any] = {}
monkeypatch.setattr(
"omnigent.claude_native.run_claude_native", lambda **kw: captured.update(kw)
)
_dispatch_smart_routing(
harness=None,
server=None,
prompt="port the parser",
model=None,
auto_open_conversation=False,
)
assert "did not resolve a launchable harness" in capsys.readouterr().err
assert captured["extra_args"] == ("--model", "composer-2.5")
@respx.mock
def test_dispatch_preflight_error_blocks_the_launch(
monkeypatch: pytest.MonkeyPatch, _routing_env: None
) -> None:
"""An unavailable-routing error must stop before any wrapper runs."""
_mock_info(enabled=False)
_mock_hosts(None)
def _must_not_launch(**_kwargs: Any) -> None:
raise AssertionError("wrapper launched despite unavailable routing")
monkeypatch.setattr("omnigent.claude_native.run_claude_native", _must_not_launch)
with pytest.raises(ClickException, match="Smart Routing is not enabled"):
_dispatch_smart_routing(
harness="claude-native",
server=None,
prompt="hello",
model=None,
auto_open_conversation=False,
)
@respx.mock
def test_dispatch_routes_hostlessly_when_the_server_does_not_know_the_host(
monkeypatch: pytest.MonkeyPatch, _routing_env: None
) -> None:
"""An unregistered host degrades to a hostless route, not a failed create."""
_mock_info()
respx.get(f"{_BASE}/v1/hosts").mock(return_value=httpx.Response(200, json={"hosts": []}))
route = _mock_create(harness="claude-native", model_override="claude-sonnet-5")
monkeypatch.setattr("omnigent.claude_native.run_claude_native", lambda **kw: None)
_dispatch_smart_routing(
harness="claude-native",
server=None,
prompt="hello",
model=None,
auto_open_conversation=False,
)
payload = json.loads(route.calls.last.request.content)
assert "host_id" not in payload
assert "workspace" not in payload
def test_known_host_id_requires_the_server_to_have_seen_the_host() -> None:
with respx.mock:
_mock_hosts(None)
assert known_host_id(base_url=_BASE, host_id=_HOST_ID) == _HOST_ID
assert known_host_id(base_url=_BASE, host_id="host_other") is None
assert known_host_id(base_url=_BASE, host_id=None) is None
# ── flag parsing / rejects ───────────────────────────────────────────────
def test_require_prompt_rejects_missing_and_blank_text() -> None:
for value in (None, " "):
with pytest.raises(UsageError, match="needs the text to route"):
_require_smart_routing_prompt(value)
assert _require_smart_routing_prompt("hi") == "hi"
def _run_kwargs(**overrides: Any) -> dict[str, Any]:
"""``_run_smart_routing`` kwargs with a valid routed launch as the baseline."""
base: dict[str, Any] = {
"target": None,
"harness": None,
"prompt": "hello",
"server": None,
"model": None,
"resume_conversation_id": None,
"resume_picker": False,
"resume_latest": False,
"auto_open_conversation": False,
}
base.update(overrides)
return base
def test_run_smart_routing_rejects_an_agent() -> None:
with pytest.raises(ClickException, match="takes no AGENT"):
_run_smart_routing(**_run_kwargs(target="examples/hello_world.yaml"))
@pytest.mark.parametrize(
("overrides", "flag"),
[
({"tools": "coding"}, "--tools"),
({"system_prompt": "be terse"}, "--system-prompt"),
({"log": True}, "--log"),
({"debug_events": True}, "--debug-events"),
({"fork_session_id": "conv_src"}, "--fork"),
({"ephemeral": True}, "--no-session"),
],
)
def test_run_smart_routing_rejects_repl_only_options(overrides: dict[str, Any], flag: str) -> None:
"""A routed launch is still a TUI attach — REPL-only flags fail loud."""
with pytest.raises(ClickException, match=flag):
_run_smart_routing(**_run_kwargs(**overrides))
@pytest.mark.parametrize(
("overrides", "flag"),
[
({"resume_conversation_id": "conv_old"}, "--resume"),
({"resume_picker": True}, "--resume"),
({"resume_latest": True}, "--continue"),
],
)
def test_run_smart_routing_rejects_resuming(overrides: dict[str, Any], flag: str) -> None:
"""Routing happens at create, so a routed launch is always a new session."""
with pytest.raises(ClickException, match=f"cannot be combined with {flag}"):
_run_smart_routing(**_run_kwargs(**overrides))
def test_run_smart_routing_rejects_a_non_routable_harness() -> None:
with pytest.raises(ClickException, match="does not support --harness 'claude-sdk'"):
_run_smart_routing(**_run_kwargs(harness="claude-sdk"))
@pytest.mark.parametrize(
"args",
[
["run", "--smart-routing"],
["claude", "--smart-routing"],
["codex", "--smart-routing"],
],
)
def test_smart_routing_without_a_prompt_is_a_usage_error(args: list[str]) -> None:
"""Bryan's call: no degraded turn-2 mode — point at ``-p`` or the web UI."""
result = CliRunner().invoke(cli, args)
assert result.exit_code == 2, result.output
assert "needs the text to route" in result.output
assert '-p "<prompt>"' in result.output
@pytest.mark.parametrize(
"args",
[
["run", "--smart-routing", "-p", "hi", "--continue"],
["claude", "--smart-routing", "-p", "hi", "--resume", "conv_old"],
["codex", "--smart-routing", "-p", "hi", "--resume", "conv_old"],
["claude", "--smart-routing", "-p", "hi", "--session", "conv_old"],
],
)
def test_smart_routing_with_a_resume_is_rejected(args: list[str]) -> None:
result = CliRunner().invoke(cli, args)
assert result.exit_code == 1, result.output
assert "routes a new session" in result.output
@respx.mock
def test_run_smart_routing_with_a_pinned_harness_routes_the_model_only(
monkeypatch: pytest.MonkeyPatch, _routing_env: None
) -> None:
"""``run --harness codex-native --smart-routing`` keeps codex, routes model."""
_mock_info()
_mock_hosts({"codex-native": True})
route = _mock_create(harness="codex-native", model_override="gpt-5.4-mini")
captured: dict[str, Any] = {}
monkeypatch.setattr("omnigent.codex_native.run_codex_native", lambda **kw: captured.update(kw))
_run_smart_routing(**_run_kwargs(harness="codex-native", prompt="add a --dry-run flag"))
assert "harness_override" not in json.loads(route.calls.last.request.content)
assert captured["model"] == "gpt-5.4-mini"
assert captured["session_id"] == _SESSION_ID
def test_routable_harnesses_are_the_prompt_capable_native_ones() -> None:
assert _smart_routing_capable_harness("claude-native") == "claude-native"
assert _smart_routing_capable_harness("codex-native") == "codex-native"
assert _smart_routing_capable_harness("kiro-native") == "kiro-native"
# Wrappers with no prompt parameter, and non-native harnesses, are out —
# including bare ``claude``, which canonicalizes to the SDK harness.
assert _smart_routing_capable_harness("cursor-native") is None
assert _smart_routing_capable_harness("claude") is None
assert _smart_routing_capable_harness("claude-sdk") is None
assert _smart_routing_capable_harness(None) is None
@pytest.mark.parametrize(
("args", "model", "expected"),
[
((), "sonnet", ("--model", "sonnet")),
(("--verbose",), "sonnet", ("--verbose", "--model", "sonnet")),
# An explicit user model wins over the routed default.
(("--model", "opus"), "sonnet", ("--model", "opus")),
(("--model=opus",), "sonnet", ("--model=opus",)),
(("--verbose",), None, ("--verbose",)),
],
)
def test_routed_model_arg_merge(
args: tuple[str, ...], model: str | None, expected: tuple[str, ...]
) -> None:
assert _with_routed_model_arg(args, model) == expected
# ── the dedicated subcommands ────────────────────────────────────────────
@respx.mock
def test_claude_subcommand_attaches_to_the_routed_session(
monkeypatch: pytest.MonkeyPatch, _routing_env: None
) -> None:
"""``omnigent claude --smart-routing -p`` routes, then attaches with --model."""
_mock_info()
_mock_hosts({"claude-native": True})
_mock_create(harness="claude-native", model_override="claude-sonnet-5")
captured: dict[str, Any] = {}
monkeypatch.setattr("omnigent.cli._load_effective_config", dict)
monkeypatch.setattr(
"omnigent.claude_native.run_claude_native", lambda **kw: captured.update(kw)
)
result = CliRunner().invoke(cli, ["claude", "--smart-routing", "-p", "fix the flaky test"])
assert result.exit_code == 0, result.output
assert captured["session_id"] == _SESSION_ID
assert captured["extra_args"] == ("--model", "claude-sonnet-5")
assert captured["prompt"] == "fix the flaky test"
@respx.mock
def test_codex_subcommand_attaches_to_the_routed_session(
monkeypatch: pytest.MonkeyPatch, _routing_env: None
) -> None:
"""Codex takes the routed model first-class, and keeps its own -p delivery."""
_mock_info()
_mock_hosts({"codex-native": True})
_mock_create(harness="codex-native", model_override="gpt-5.4-mini")
captured: dict[str, Any] = {}
monkeypatch.setattr("omnigent.cli._load_effective_config", dict)
monkeypatch.setattr("omnigent.codex_native.run_codex_native", lambda **kw: captured.update(kw))
result = CliRunner().invoke(cli, ["codex", "--smart-routing", "-p", "port the parser"])
assert result.exit_code == 0, result.output
assert captured["session_id"] == _SESSION_ID
assert captured["model"] == "gpt-5.4-mini"
assert captured["prompt"] == "port the parser"
@respx.mock
def test_explicit_model_beats_the_routed_pick(
monkeypatch: pytest.MonkeyPatch, _routing_env: None
) -> None:
"""Routing fills in a default; a model the user typed still wins."""
_mock_info()
_mock_hosts(None)
_mock_create(harness="codex-native", model_override="gpt-5.4-mini")
captured: dict[str, Any] = {}
monkeypatch.setattr("omnigent.cli._load_effective_config", dict)
monkeypatch.setattr("omnigent.codex_native.run_codex_native", lambda **kw: captured.update(kw))
result = CliRunner().invoke(
cli, ["codex", "--smart-routing", "-p", "hello", "--model", "gpt-5.4"]
)
assert result.exit_code == 0, result.output
assert captured["model"] == "gpt-5.4"
@respx.mock
def test_claude_subcommand_falls_back_to_a_fresh_session(
monkeypatch: pytest.MonkeyPatch, _routing_env: None
) -> None:
"""A rejected create leaves the wrapper to bundle its own session."""
_mock_info()
_mock_hosts(None)
respx.post(f"{_BASE}/v1/sessions").mock(return_value=httpx.Response(500, text="boom"))
captured: dict[str, Any] = {}
monkeypatch.setattr("omnigent.cli._load_effective_config", dict)
monkeypatch.setattr(
"omnigent.claude_native.run_claude_native", lambda **kw: captured.update(kw)
)
result = CliRunner().invoke(cli, ["claude", "--smart-routing", "-p", "hello"])
assert result.exit_code == 0, result.output
assert captured["session_id"] is None
assert captured["prompt"] == "hello"
assert "Smart Routing was unavailable" in result.output
@@ -0,0 +1,59 @@
"""Tests for the hosts gateway_inference migration (d5e6f7a8b9c0).
Verifies the nullable ``hosts.gateway_inference`` column exists at head and that
downgrade removes it, so the Smart-Routing capability map has somewhere to land
without breaking the rest of the chain.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
import sqlalchemy as sa
from alembic import command
from sqlalchemy.engine import Engine
from omnigent.db.utils import (
_build_alembic_config,
clear_engine_cache,
get_or_create_engine,
)
@pytest.fixture
def db_engine(tmp_path: Path) -> Iterator[Engine]:
"""Fresh SQLite database with the full migration chain applied."""
db_path = tmp_path / "test.db"
uri = f"sqlite:///{db_path}"
engine = get_or_create_engine(uri)
try:
yield engine
finally:
clear_engine_cache()
def test_gateway_inference_column_nullable_at_head(db_engine: Engine) -> None:
columns = {c["name"]: c for c in sa.inspect(db_engine).get_columns("hosts")}
assert "gateway_inference" in columns
assert columns["gateway_inference"]["nullable"] is True
def test_downgrade_drops_gateway_inference(tmp_path: Path) -> None:
db_path = tmp_path / "downgrade.db"
uri = f"sqlite:///{db_path}"
engine = get_or_create_engine(uri)
config = _build_alembic_config(uri)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, "c4d5e6f7a8b9")
columns = {c["name"] for c in sa.inspect(engine).get_columns("hosts")}
assert "gateway_inference" not in columns
# The sibling readiness column is untouched by this migration.
assert "configured_harnesses" in columns
engine.dispose()
clear_engine_cache()
@@ -153,3 +153,35 @@ def test_select_artifact_store(
port=8000,
)
assert isinstance(_select_artifact_store(resolved), expected_type)
# ── routing wiring ────────────────────────────────────────────────────────
# A Docker deploy must honour its own `routing:` block rather than running on
# all-default knobs, so the settings that reach RuntimeCaps are the parsed ones.
def test_build_routing_carries_the_configured_settings() -> None:
from deploy.docker.entrypoint import _build_routing
cfg = {
"routing": {
"provider": "external",
"base_url": "https://host/ai-gateway/routing/v1",
"router_name": "task_v1",
"model_prefix": ["databricks-", "system.ai."],
}
}
client, settings = _build_routing(cfg, None)
assert settings.model_prefixes == ("databricks-", "system.ai.")
assert client is not None
assert client._model_prefixes == ["databricks-", "system.ai."]
def test_build_routing_defaults_without_a_routing_block() -> None:
from deploy.docker.entrypoint import _build_routing
from omnigent.server.smart_routing import RoutingSettings
client, settings = _build_routing({}, None)
assert client is None
assert settings == RoutingSettings()
@@ -0,0 +1,106 @@
"""
Tests for the routing-identity fields on ``RoutingDecisionData``.
The intelligent-routing MVP adds harness / scope / decision identity to
the routing-decision transcript item. Every field is defaulted so rows
persisted before they existed still deserialize.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from omnigent.entities.conversation import RoutingDecisionData, parse_item_data
_LEGACY_ROW = {
"model": "databricks-claude-opus-4-8",
"applied": True,
"rationale": "Multi-file refactor needs deep reasoning.",
}
def test_legacy_row_deserializes_with_defaults() -> None:
data = parse_item_data("routing_decision", dict(_LEGACY_ROW))
assert isinstance(data, RoutingDecisionData)
assert data.model == "databricks-claude-opus-4-8"
assert data.harness is None
assert data.scope == "turn"
assert data.decision_id is None
assert data.raw_model is None
assert data.attempted_override is None
@pytest.mark.parametrize(
("row", "expected_keys"),
[
# Every routing-identity field set: the dump carries each one and
# rebuilding from it reproduces the model exactly.
(
{
"model": "databricks-gpt-5-6-sol",
"applied": True,
"rationale": "Short prompt, cheapest arm.",
"agent": "claude_code",
"harness": "codex",
"scope": "native_subagent",
"decision_id": "dec_abc123",
"raw_model": "gpt-5-6-sol",
"attempted_override": "databricks-gpt-5-5",
},
{
"harness": "codex",
"scope": "native_subagent",
"decision_id": "dec_abc123",
"raw_model": "gpt-5-6-sol",
},
),
# An unapplied advisory decision: the unset optional stays null in the
# dump rather than being dropped from the wire.
(
{
"model": "databricks-claude-sonnet-5",
"applied": False,
"rationale": "Advise only.",
"harness": "claude-native",
"scope": "session",
"decision_id": "dec_1",
},
{
"harness": "claude-native",
"scope": "session",
"decision_id": "dec_1",
"raw_model": None,
},
),
],
)
def test_dump_carries_the_routing_identity_and_round_trips(
row: dict[str, object], expected_keys: dict[str, object]
) -> None:
original = RoutingDecisionData(**row) # type: ignore[arg-type]
dumped = original.model_dump()
for key, want in expected_keys.items():
assert dumped[key] == want, key
assert RoutingDecisionData(**dumped) == original
@pytest.mark.parametrize("scope", ["session", "turn", "child_session", "native_subagent"])
def test_every_scope_value_validates(scope: str) -> None:
data = RoutingDecisionData(
model="databricks-claude-sonnet-5",
applied=True,
rationale="ok",
scope=scope, # type: ignore[arg-type]
)
assert data.scope == scope
def test_unknown_scope_rejected() -> None:
with pytest.raises(ValidationError):
RoutingDecisionData(
model="databricks-claude-sonnet-5",
applied=True,
rationale="ok",
scope="galaxy", # type: ignore[arg-type]
)
+67
View File
@@ -99,6 +99,38 @@ async def test_handle_model_options_uses_host_claude_configuration(
)
async def test_handle_model_options_reports_the_endpoints_wider_catalog(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Generations no picker row names are still launchable, so they ship too."""
from omnigent import claude_native
monkeypatch.setattr(
claude_native,
"resolve_native_claude_config",
lambda *, spec: claude_native.ClaudeNativeUcodeConfig(
env={"ANTHROPIC_DEFAULT_OPUS_MODEL": "system.ai.claude-opus-5"},
model="system.ai.claude-opus-5",
routable_models=("system.ai.claude-opus-5", "system.ai.claude-opus-4-8"),
),
)
monkeypatch.setattr(
claude_native,
"claude_native_model_options",
lambda config: [{"id": "opus", "model": "system.ai.claude-opus-5"}],
)
host = _make_host_process()
result = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_models", harness="claude-native"),
)
assert result.routable_models == [
"system.ai.claude-opus-5",
"system.ai.claude-opus-4-8",
]
def _make_host_process() -> HostProcess:
"""Create a HostProcess with a test identity.
@@ -536,6 +568,7 @@ async def test_live_host_refreshes_harness_readiness_without_reconnect(
) -> None:
"""A setup completed after connect must replace the advertised readiness."""
readiness = iter(({"pi": False}, {"pi": True}))
monkeypatch.setattr("omnigent.host.connect.gateway_inference_map", lambda: {"codex": True})
monkeypatch.setattr(
"omnigent.host.connect.configured_harness_map",
lambda: next(readiness),
@@ -568,6 +601,7 @@ async def test_live_host_full_refresh_detects_auth_completion(
) -> None:
"""The full-refresh fallback catches readiness changes beyond binary installs."""
readiness = iter(({"codex": "needs-auth"}, {"codex": True}))
monkeypatch.setattr("omnigent.host.connect.gateway_inference_map", lambda: {"codex": True})
monkeypatch.setattr(
"omnigent.host.connect.configured_harness_map",
lambda: next(readiness),
@@ -593,6 +627,7 @@ async def test_live_host_does_not_repeat_unchanged_readiness(
) -> None:
"""A periodic full refresh sends nothing when the readiness map is unchanged."""
readiness = iter(({"codex": "needs-auth"}, {"codex": "needs-auth"}))
monkeypatch.setattr("omnigent.host.connect.gateway_inference_map", lambda: {"codex": True})
monkeypatch.setattr(
"omnigent.host.connect.configured_harness_map",
lambda: next(readiness),
@@ -611,6 +646,38 @@ async def test_live_host_does_not_repeat_unchanged_readiness(
assert isinstance(decode_host_frame(tunnel.sent[0]), HostHelloFrame)
async def test_live_host_repushes_when_only_gateway_inference_changes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A gateway-inference flip alone must reach the server, readiness unchanged."""
gateway = iter(({"codex": False}, {"codex": True}))
monkeypatch.setattr(
"omnigent.host.connect.configured_harness_map",
lambda: {"codex": True},
)
monkeypatch.setattr(
"omnigent.host.connect.gateway_inference_map",
lambda: next(gateway, {"codex": True}),
)
monkeypatch.setattr(
"omnigent.host.connect.HARNESS_READINESS_FULL_REFRESH_INTERVAL_S",
0.01,
)
host = _make_host_process()
tunnel = _ReadinessChangingTunnel()
with pytest.raises(ConnectionError, match="test disconnect"):
await host._serve_frames(tunnel) # type: ignore[arg-type] — duck-typed ws
hello = decode_host_frame(tunnel.sent[0])
assert isinstance(hello, HostHelloFrame)
assert hello.gateway_inference == {"codex": False}
refresh = decode_host_frame(tunnel.sent[1])
assert isinstance(refresh, HostHarnessReadinessFrame)
assert refresh.configured_harnesses == {"codex": True}
assert refresh.gateway_inference == {"codex": True}
async def test_handle_launch_immediate_exit_reports_exit_code_and_log_tail(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
+104
View File
@@ -80,6 +80,23 @@ def test_model_options_frames_round_trip() -> None:
"displayName": "Sonnet 4.6",
}
]
# Absent from an older host's payload, and present when it reports the
# endpoint's wider catalog.
assert result.routable_models == []
with_routable = decode_host_frame(
encode_host_frame(
HostModelOptionsResultFrame(
request_id="req_models",
status="ok",
routable_models=["system.ai.claude-opus-5", "system.ai.claude-opus-4-8"],
)
)
)
assert isinstance(with_routable, HostModelOptionsResultFrame)
assert with_routable.routable_models == [
"system.ai.claude-opus-5",
"system.ai.claude-opus-4-8",
]
def test_encode_injects_traceparent_under_active_span() -> None:
@@ -252,6 +269,70 @@ def test_harness_readiness_frame_round_trip() -> None:
assert decoded.configured_harnesses == {"pi": True, "codex": "needs-auth"}
def test_hello_frame_gateway_inference_round_trip() -> None:
original = HostHelloFrame(
version="0.1.0",
frame_protocol_version=1,
name="corey-laptop",
configured_harnesses={"claude-native": True},
gateway_inference={"claude-native": True, "codex": False},
)
decoded = decode_host_frame(encode_host_frame(original))
assert isinstance(decoded, HostHelloFrame)
assert decoded.gateway_inference == {"claude-native": True, "codex": False}
def test_hello_frame_absent_gateway_inference_decodes_to_none() -> None:
encoded = json.dumps(
{
"kind": "host.hello",
"version": "0.1.0",
"frame_protocol_version": 1,
"name": "corey-laptop",
}
)
decoded = decode_host_frame(encoded)
assert isinstance(decoded, HostHelloFrame)
assert decoded.gateway_inference is None
def test_hello_frame_drops_non_bool_gateway_inference_values() -> None:
encoded = json.dumps(
{
"kind": "host.hello",
"version": "0.1.0",
"frame_protocol_version": 1,
"name": "corey-laptop",
"gateway_inference": {"codex": "maybe", "claude-native": True},
}
)
decoded = decode_host_frame(encoded)
assert isinstance(decoded, HostHelloFrame)
assert decoded.gateway_inference == {"claude-native": True}
def test_harness_readiness_frame_gateway_inference_round_trip() -> None:
original = HostHarnessReadinessFrame(
configured_harnesses={"codex": True},
gateway_inference={"codex": True, "native-codex": True},
)
decoded = decode_host_frame(encode_host_frame(original))
assert isinstance(decoded, HostHarnessReadinessFrame)
assert decoded.gateway_inference == {"codex": True, "native-codex": True}
def test_harness_readiness_frame_without_gateway_inference_is_none() -> None:
encoded = json.dumps(
{
"kind": "host.harness_readiness",
"configured_harnesses": {"codex": True},
}
)
decoded = decode_host_frame(encoded)
assert isinstance(decoded, HostHarnessReadinessFrame)
assert decoded.gateway_inference is None
def test_harness_readiness_frame_rejects_unknown_availability() -> None:
"""Unknown readiness states cannot partially replace the live map."""
encoded = json.dumps(
@@ -1189,9 +1270,32 @@ def test_install_harness_result_failure_round_trip() -> None:
assert isinstance(decoded, HostInstallHarnessResultFrame)
assert decoded.status == "failed"
assert decoded.configured_harnesses is None
assert decoded.gateway_inference is None
assert decoded.error == "npm not found"
def test_result_frames_round_trip_gateway_inference() -> None:
install = HostInstallHarnessResultFrame(
request_id="req_install_4",
status="ok",
configured_harnesses={"claude-native": True},
gateway_inference={"claude-native": False},
)
decoded_install = decode_host_frame(encode_host_frame(install))
assert isinstance(decoded_install, HostInstallHarnessResultFrame)
assert decoded_install.gateway_inference == {"claude-native": False}
secret = HostStoreSecretResultFrame(
request_id="req_cred_2",
status="ok",
configured_harnesses={"codex": True},
gateway_inference={"codex": True},
)
decoded_secret = decode_host_frame(encode_host_frame(secret))
assert isinstance(decoded_secret, HostStoreSecretResultFrame)
assert decoded_secret.gateway_inference == {"codex": True}
def test_store_secret_key_frame_round_trip() -> None:
"""A key store-secret request survives encode → decode with the secret.
+36
View File
@@ -8,6 +8,7 @@ import asyncio
import faulthandler
import gc
import inspect
import json
import logging
import os
import pathlib
@@ -151,6 +152,41 @@ async def _hang_diagnostic_task_dumper() -> asyncio.AsyncGenerator[None, None]:
handle.cancel()
def advertise_router(
router_dir: pathlib.Path,
*,
session_id: str | None = "conv_abc",
**extra: object,
) -> pathlib.Path:
"""Write a subagent-router advertisement into *router_dir*.
Shared by the claude and codex router-hook suites. The filename comes
from the hook script's own ``ADVERTISEMENT_FILE`` constant, so renaming
it fails these tests instead of quietly making every advertisement
invisible to the hook under test.
:param router_dir: Bridge/router directory the hook is pointed at.
:param session_id: Baked-in session id; ``None`` omits the key so the
hook has to fall back to its env/bridge-config sources.
:param extra: Extra advertisement keys to merge in.
:returns: *router_dir*, for use as the hook's ``--bridge-dir``.
"""
from omnigent.inner.hook_scripts import subagent_router
# A live ``pid`` by default: the hook rejects an advertisement without
# one, since the runner always writes it.
payload: dict[str, object] = {
"url": "http://127.0.0.1:1/",
"token": "t0k",
"pid": os.getpid(),
**extra,
}
if session_id is not None:
payload["session_id"] = session_id
(router_dir / subagent_router.ADVERTISEMENT_FILE).write_text(json.dumps(payload))
return router_dir
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
if "model_name" not in metafunc.fixturenames:
return
+159 -1
View File
@@ -832,6 +832,9 @@ async def test_run_turn_applies_routed_model_before_message_under_one_lock(
the call order across both injectors and asserts ``/model`` lands first,
then the message, exactly once each. A regression that dropped the switch
(or ran it concurrently) would fail the ordering assertion.
The typed argument is the session's alias for the routed catalog id:
``/model`` rejects a bare gateway id and silently keeps the old model.
"""
monkeypatch.delenv(REQUEST_SESSION_ID_ENV_VAR, raising=False)
bridge_dir = tmp_path / "bridge"
@@ -861,6 +864,11 @@ async def test_run_turn_applies_routed_model_before_message_under_one_lock(
# No ucode profile at launch -> unknown baseline -> the routed model is
# treated as a change and switched.
monkeypatch.setattr(claude_native_executor, "read_launch_model", lambda _bridge: None)
monkeypatch.setattr(
claude_native_executor,
"read_model_env",
lambda _bridge: {"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-sonnet-5"},
)
monkeypatch.setattr(claude_native_executor, "inject_slash_command", fake_inject_slash_command)
monkeypatch.setattr(claude_native_executor, "inject_user_message", fake_inject_user_message)
@@ -878,12 +886,162 @@ async def test_run_turn_applies_routed_model_before_message_under_one_lock(
assert calls == [
# auto_confirm=True mirrors the manual picker path so the switch is
# accepted if the CLI ever pops a confirmation dialog.
("slash", "/model databricks-claude-sonnet-5", True),
("slash", "/model sonnet", True),
("message", "review this function"),
], f"Expected /model (auto_confirm) then message, in order; got {calls}."
assert events == [TurnComplete(response=None)]
@pytest.mark.asyncio
async def test_run_turn_uses_the_custom_model_slot_id_verbatim(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""A model pinned to the custom picker slot is applied exactly."""
monkeypatch.delenv(REQUEST_SESSION_ID_ENV_VAR, raising=False)
slash_calls: list[str] = []
def fake_inject_slash_command(
bridge_dir_arg: Path, *, command: str, timeout_s: float = 30.0, auto_confirm: bool = False
) -> None:
del bridge_dir_arg, timeout_s, auto_confirm
slash_calls.append(command)
monkeypatch.setattr(claude_native_executor, "read_launch_model", lambda _bridge: None)
monkeypatch.setattr(
claude_native_executor,
"read_model_env",
lambda _bridge: {
"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-sonnet-4-6",
"ANTHROPIC_CUSTOM_MODEL_OPTION": "databricks-claude-sonnet-5",
},
)
monkeypatch.setattr(claude_native_executor, "inject_slash_command", fake_inject_slash_command)
monkeypatch.setattr(
claude_native_executor,
"inject_user_message",
lambda bridge_dir_arg, *, content, timeout_s=30.0: None,
)
executor = ClaudeNativeExecutor(tmp_path / "bridge")
events = [
event
async for event in executor.run_turn(
messages=[{"role": "user", "content": "hi"}],
tools=[],
system_prompt="",
config=ExecutorConfig(model="databricks-claude-sonnet-5"),
)
]
assert slash_calls == ["/model databricks-claude-sonnet-5"]
assert events == [TurnComplete(response=None)]
@pytest.mark.asyncio
async def test_run_turn_skips_switch_for_untranslatable_model(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""
A routed id this session can't spell fails open — message still sent.
Typing a value ``/model`` doesn't accept leaves the pane on its old
model while reporting success, so the switch is skipped instead.
"""
monkeypatch.delenv(REQUEST_SESSION_ID_ENV_VAR, raising=False)
slash_calls: list[str] = []
msg_calls: list[str] = []
def fake_inject_slash_command(
bridge_dir_arg: Path, *, command: str, timeout_s: float = 30.0, auto_confirm: bool = False
) -> None:
del bridge_dir_arg, timeout_s, auto_confirm
slash_calls.append(command)
def fake_inject_user_message(
bridge_dir_arg: Path, *, content: str, timeout_s: float = 30.0
) -> None:
del bridge_dir_arg, timeout_s
msg_calls.append(content)
monkeypatch.setattr(claude_native_executor, "read_launch_model", lambda _bridge: None)
# Only opus is pinned, so a sonnet id has no spelling this pane accepts:
# the bare "sonnet" alias would resolve to a vendor id the gateway rejects.
monkeypatch.setattr(
claude_native_executor,
"read_model_env",
lambda _bridge: {"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8"},
)
monkeypatch.setattr(claude_native_executor, "inject_slash_command", fake_inject_slash_command)
monkeypatch.setattr(claude_native_executor, "inject_user_message", fake_inject_user_message)
executor = ClaudeNativeExecutor(tmp_path / "bridge")
events = [
event
async for event in executor.run_turn(
messages=[{"role": "user", "content": "hello"}],
tools=[],
system_prompt="",
config=ExecutorConfig(model="databricks-claude-sonnet-5"),
)
]
assert slash_calls == []
assert msg_calls == ["hello"]
assert events == [TurnComplete(response=None)]
@pytest.mark.asyncio
async def test_run_turn_skips_switch_when_the_family_pin_drifted(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""A mismatched family pin must not be spoken as its alias.
The workspace serves two opus generations and ``opus`` is pinned to the
newer one, so ``/model opus`` would move the pane off the routed model
while the transcript claimed it ran.
"""
monkeypatch.delenv(REQUEST_SESSION_ID_ENV_VAR, raising=False)
slash_calls: list[str] = []
msg_calls: list[str] = []
monkeypatch.setattr(claude_native_executor, "read_launch_model", lambda _bridge: None)
monkeypatch.setattr(
claude_native_executor,
"read_model_env",
lambda _bridge: {"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5"},
)
monkeypatch.setattr(
claude_native_executor,
"inject_slash_command",
lambda bridge_dir_arg, *, command, timeout_s=30.0, auto_confirm=False: slash_calls.append(
command
),
)
monkeypatch.setattr(
claude_native_executor,
"inject_user_message",
lambda bridge_dir_arg, *, content, timeout_s=30.0: msg_calls.append(content),
)
executor = ClaudeNativeExecutor(tmp_path / "bridge")
events = [
event
async for event in executor.run_turn(
messages=[{"role": "user", "content": "hello"}],
tools=[],
system_prompt="",
config=ExecutorConfig(model="databricks-claude-opus-4-8"),
)
]
assert slash_calls == []
assert msg_calls == ["hello"]
assert events == [TurnComplete(response=None)]
@pytest.mark.asyncio
async def test_run_turn_without_model_override_injects_message_only(
monkeypatch: pytest.MonkeyPatch,
+491
View File
@@ -0,0 +1,491 @@
from __future__ import annotations
import io
import json
import os
from pathlib import Path
from typing import Any
import pytest
from omnigent.claude_model_vocabulary import claude_model_alias
from omnigent.inner.hook_scripts import claude_router_hook, subagent_router
from tests.inner.conftest import advertise_router
def _payload(
*,
tool_name: str = "Agent",
subagent_type: str = "code-reviewer",
prompt: str = "review the diff",
) -> dict[str, Any]:
return {
"hook_event_name": "PreToolUse",
"tool_name": tool_name,
"tool_input": {"subagent_type": subagent_type, "prompt": prompt},
"tool_use_id": "toolu_1",
}
def _run_hook_main(
monkeypatch: pytest.MonkeyPatch,
stdin: str,
argv: list[str],
) -> str:
"""Drive ``claude_router_hook.main`` over *stdin* and return its stdout."""
monkeypatch.setattr("sys.stdin", io.StringIO(stdin))
out = io.StringIO()
monkeypatch.setattr("sys.stdout", out)
assert claude_router_hook.main(argv) == 0
return out.getvalue()
def _no_router(monkeypatch: pytest.MonkeyPatch, why: str) -> None:
"""Fail the test if the hook reaches the router at all."""
def unreachable(*args: object, **kwargs: object) -> dict[str, Any] | None:
raise AssertionError(why)
monkeypatch.setattr(subagent_router, "request_decision", unreachable)
def _run_hook(
monkeypatch: pytest.MonkeyPatch,
router_dir: Path,
payload: dict[str, Any],
decision: dict[str, Any] | None,
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
"""Run the hook with a canned router *decision*; return output + requests."""
seen: list[dict[str, Any]] = []
def fake_request(
endpoint: subagent_router.RouterEndpoint,
session_id: str,
body: dict[str, Any],
*,
timeout: float = 0.0,
) -> dict[str, Any] | None:
seen.append({"endpoint": endpoint, "session_id": session_id, "body": body})
return decision
monkeypatch.setattr(subagent_router, "request_decision", fake_request)
raw = _run_hook_main(monkeypatch, json.dumps(payload), ["--bridge-dir", str(router_dir)])
return (json.loads(raw) if raw else None), seen
def test_rewrite_allows_with_routed_model(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
out, _requests = _run_hook(
monkeypatch,
router_dir,
_payload(),
{
"action": "rewrite",
"model": "databricks-claude-haiku-4-5",
"raw_model": "router-vocab-model",
"rationale": "cheapest arm",
"decision_id": "dec-1",
},
)
assert out == {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"subagent_type": "code-reviewer",
"prompt": "review the diff",
# Claude's Agent tool takes tier aliases, never catalog ids.
"model": "haiku",
},
"permissionDecisionReason": "cheapest arm (applied as 'haiku')",
}
}
@pytest.mark.parametrize(
("model", "expected"),
[
("databricks-claude-sonnet-5", "sonnet"),
("databricks-claude-sonnet-4-6", "sonnet"),
("databricks-claude-haiku-4-5", "haiku"),
("databricks-claude-opus-4-8", "opus"),
("databricks-claude-fable-5", "fable"),
("system.ai.claude-sonnet-5", "sonnet"),
("claude-opus-4-8[1m]", "opus"),
("sonnet", "sonnet"),
("databricks-gpt-5-5", None),
("mystery-model", None),
("", None),
],
)
def test_agent_tool_model_translation(model: str, expected: str | None) -> None:
assert claude_model_alias(model, {}) == expected
def test_agent_tool_model_prefers_workspace_alias_pinning() -> None:
# The workspace pins "sonnet" to a model whose own name says otherwise;
# the env mapping is authoritative over the name heuristic.
env = {"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-mystery-9"}
assert claude_model_alias("databricks-claude-mystery-9", env) == "sonnet"
def test_untranslatable_model_allows_spawn_unchanged(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An id with no Agent-tool alias must not be injected — the CLI 400s."""
router_dir = advertise_router(tmp_path)
for env_var in ("ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL"):
monkeypatch.delenv(env_var, raising=False)
out, _requests = _run_hook(
monkeypatch,
router_dir,
_payload(),
{"action": "rewrite", "model": "mystery-model", "rationale": "r", "decision_id": "d"},
)
assert out is None
def test_bridge_recorded_pinning_gates_the_translation(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The launch pinning recorded on the bridge decides what's spellable."""
router_dir = advertise_router(tmp_path)
(tmp_path / "bridge.json").write_text(
json.dumps(
{
"active_session_id": "conv_abc",
# Only opus is pinned to a gateway id, so a routed sonnet has
# no accepted spelling — "sonnet" would resolve to a vendor id
# the gateway rejects.
"model_env": {"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8"},
}
)
)
decision = {
"action": "rewrite",
"model": "databricks-claude-sonnet-5",
"rationale": "r",
"decision_id": "d",
}
out, _requests = _run_hook(monkeypatch, router_dir, _payload(), decision)
assert out is None
decision["model"] = "databricks-claude-opus-4-8"
out, _requests = _run_hook(monkeypatch, router_dir, _payload(), decision)
assert out is not None
assert out["hookSpecificOutput"]["updatedInput"]["model"] == "opus"
def test_codex_style_output_keeps_the_catalog_id() -> None:
"""Without a translator the servable id is injected verbatim (codex)."""
decision = {"action": "rewrite", "model": "databricks-gpt-5-5", "rationale": "r"}
output = subagent_router.decision_to_hook_output(decision, {"task_name": "t"})
assert output is not None
assert output["hookSpecificOutput"]["updatedInput"] == {
"task_name": "t",
"model": "databricks-gpt-5-5",
}
def test_redirect_denies_with_sys_session_send_instruction(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
router_dir = advertise_router(tmp_path)
out, _requests = _run_hook(
monkeypatch,
router_dir,
_payload(),
{
"action": "redirect",
"model": "other-model",
"harness": "codex",
"rationale": "cross-harness pick",
"decision_id": "dec-2",
},
)
assert out == {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"Router selected codex/other-model. Use sys_session_send with "
"args.harness=codex, args.model=other-model instead."
),
}
}
def test_deny_carries_router_rationale(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
out, _requests = _run_hook(
monkeypatch,
router_dir,
_payload(),
{"action": "deny", "model": None, "rationale": "router unreachable", "decision_id": "d"},
)
assert out == {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "router unreachable",
}
}
def test_allow_emits_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
out, _requests = _run_hook(
monkeypatch,
router_dir,
_payload(),
{"action": "allow", "model": None, "rationale": "", "decision_id": "d"},
)
assert out is None
def test_fork_typed_spawn_reports_fork_true(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
router_dir = advertise_router(tmp_path)
_out, requests = _run_hook(
monkeypatch,
router_dir,
_payload(subagent_type="fork"),
{"action": "allow", "rationale": "", "decision_id": "d"},
)
body = requests[0]["body"]
assert body == {
"harness": "claude-native",
"task_name": "fork",
"prompt": "review the diff",
"fork": True,
"parent_model": None,
}
def test_endpoint_down_allows_unchanged(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
out, _requests = _run_hook(monkeypatch, router_dir, _payload(), None)
assert out is None
def test_missing_advertisement_allows_unchanged(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv(subagent_router.ROUTER_DIR_ENV_VAR, raising=False)
monkeypatch.delenv(subagent_router.BRIDGE_DIR_ENV_VAR, raising=False)
_no_router(monkeypatch, "router must not be called without an advertisement")
stdout = _run_hook_main(monkeypatch, json.dumps(_payload()), ["--bridge-dir", str(tmp_path)])
assert stdout == ""
@pytest.mark.parametrize(
"url",
[
# Off-box exfiltration: the bridge dir is agent-writable, so an
# advertisement naming a remote host would leak the spawn prompt.
"http://evil.example.com:8080",
# A non-http scheme is not our loopback runner either.
"file:///tmp/x",
"https://127.0.0.1:9000",
# Not loopback, even though it is an IP literal.
"http://10.0.0.5:9000",
],
)
def test_non_loopback_advertisement_is_rejected(tmp_path: Path, url: str) -> None:
advertise_router(tmp_path, url=url)
assert subagent_router.read_router_endpoint(tmp_path) is None
def test_advertisement_from_a_dead_pid_is_rejected(tmp_path: Path) -> None:
"""A stale advertisement's port can be re-bound by another process."""
dead_pid = 2**22 - 1
advertise_router(tmp_path, pid=dead_pid)
assert subagent_router.read_router_endpoint(tmp_path) is None
def test_advertisement_from_a_live_pid_is_accepted(tmp_path: Path) -> None:
advertise_router(tmp_path, pid=os.getpid())
assert subagent_router.read_router_endpoint(tmp_path) is not None
@pytest.mark.parametrize("pid", [None, "1234", 0, -1, 1.5, True])
def test_advertisement_without_a_usable_pid_is_rejected(
tmp_path: Path, pid: object, capsys: pytest.CaptureFixture[str]
) -> None:
"""The runner always writes an int pid, so anything else is not ours."""
advertise_router(tmp_path, pid=pid)
assert subagent_router.read_router_endpoint(tmp_path) is None
assert "pid not alive" in capsys.readouterr().err
def test_rejected_advertisements_explain_themselves_on_stderr(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
advertise_router(tmp_path, url="http://10.0.0.5:9000")
assert subagent_router.read_router_endpoint(tmp_path) is None
assert "not plain http on loopback" in capsys.readouterr().err
def test_other_tools_are_ignored(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
_no_router(monkeypatch, "non-spawn tools must not reach the router")
stdout = _run_hook_main(
monkeypatch,
json.dumps(_payload(tool_name="Bash")),
["--bridge-dir", str(router_dir)],
)
assert stdout == ""
def test_legacy_task_tool_name_is_routed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
out, _requests = _run_hook(
monkeypatch,
router_dir,
_payload(tool_name="Task"),
{
"action": "rewrite",
"model": "databricks-claude-sonnet-5",
"rationale": "",
"decision_id": "d",
},
)
assert out is not None
assert out["hookSpecificOutput"]["updatedInput"]["model"] == "sonnet"
def test_malformed_stdin_allows_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
assert _run_hook_main(monkeypatch, "not json", []) == ""
def test_session_id_falls_back_to_bridge_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
router_dir = advertise_router(tmp_path, session_id=None)
(tmp_path / "bridge.json").write_text(
json.dumps({"active_session_id": "conv_from_bridge", "launch_model": "parent-model"})
)
monkeypatch.delenv(subagent_router.SESSION_ID_ENV_VAR, raising=False)
monkeypatch.delenv(subagent_router.NATIVE_SESSION_ID_ENV_VAR, raising=False)
_out, requests = _run_hook(
monkeypatch,
router_dir,
_payload(),
{"action": "allow", "rationale": "", "decision_id": "d"},
)
request = requests[0]
assert request["session_id"] == "conv_from_bridge"
assert request["body"]["parent_model"] == "parent-model"
def test_malformed_advertisement_is_treated_as_absent(tmp_path: Path) -> None:
(tmp_path / subagent_router.ADVERTISEMENT_FILE).write_text("{not json")
assert subagent_router.read_router_endpoint(tmp_path) is None
(tmp_path / subagent_router.ADVERTISEMENT_FILE).write_text(json.dumps({"url": "u"}))
assert subagent_router.read_router_endpoint(tmp_path) is None
def test_redirect_without_target_fails_open() -> None:
decision = {"action": "redirect", "model": None, "harness": None, "rationale": "x"}
assert subagent_router.decision_to_hook_output(decision, {}) is None
class _FakeHookMatcher:
def __init__(self, *, matcher: str | None = None, hooks: list[Any], timeout: float) -> None:
self.matcher = matcher
self.hooks = hooks
self.timeout = timeout
class _FakeSDK:
HookMatcher = _FakeHookMatcher
class _FakeOptions:
hooks: dict[str, list[Any]] | None = None
def _install() -> _FakeOptions:
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
options = _FakeOptions()
ClaudeSDKExecutor()._install_subagent_router_hook(_FakeSDK(), options, "parent-model") # type: ignore[arg-type]
return options
def test_sdk_hook_registered_when_router_advertised(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
advertise_router(tmp_path)
monkeypatch.setenv(subagent_router.ROUTER_DIR_ENV_VAR, str(tmp_path))
options = _install()
assert options.hooks is not None
matcher = options.hooks["PreToolUse"][0]
assert matcher.matcher == subagent_router.AGENT_TOOL_MATCHER
assert matcher.timeout == subagent_router.REQUEST_TIMEOUT_S
def test_sdk_hook_not_registered_without_advertisement(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(subagent_router.ROUTER_DIR_ENV_VAR, str(tmp_path))
options = _install()
assert options.hooks is None
async def test_sdk_callback_maps_rewrite(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
advertise_router(tmp_path)
monkeypatch.setenv(subagent_router.ROUTER_DIR_ENV_VAR, str(tmp_path))
bodies: list[dict[str, Any]] = []
def fake_request(
endpoint: subagent_router.RouterEndpoint,
session_id: str,
body: dict[str, Any],
*,
timeout: float = 0.0,
) -> dict[str, Any]:
bodies.append(body)
return {"action": "rewrite", "model": "databricks-claude-sonnet-5", "rationale": "r"}
monkeypatch.setattr(subagent_router, "request_decision", fake_request)
options = _install()
assert options.hooks is not None
callback = options.hooks["PreToolUse"][0].hooks[0]
output = await callback(_payload(), "toolu_1", {"signal": None})
# The SDK callback shares the hook's translation: alias, not catalog id.
assert output["hookSpecificOutput"]["updatedInput"]["model"] == "sonnet"
assert bodies[0]["harness"] == "claude-sdk"
assert bodies[0]["parent_model"] == "parent-model"
async def test_sdk_callback_allows_unchanged_when_router_down(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
advertise_router(tmp_path)
monkeypatch.setenv(subagent_router.ROUTER_DIR_ENV_VAR, str(tmp_path))
monkeypatch.setattr(
subagent_router,
"request_decision",
lambda *args, **kwargs: None,
)
options = _install()
assert options.hooks is not None
callback = options.hooks["PreToolUse"][0].hooks[0]
assert await callback(_payload(), None, {"signal": None}) == {}
@pytest.mark.parametrize(
("subagent_type", "expected"),
[
("fork", True),
("Fork", True),
("research-fork", True),
("plugin:fork", True),
("code-reviewer", False),
("", False),
],
)
def test_fork_detection(subagent_type: str, expected: bool) -> None:
assert subagent_router.is_fork_spawn({"subagent_type": subagent_type}) is expected
+435
View File
@@ -0,0 +1,435 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import shlex
import subprocess
import sys
from pathlib import Path
from typing import Any
import pytest
from omnigent.inner import codex_executor
from omnigent.inner.codex_executor import (
CODEX_ROUTER_DIR_ENV_VAR,
CODEX_ROUTER_SESSION_ID_ENV_VAR,
SUBAGENT_ROUTING_UNENFORCED_WARNING,
_CodexAppServerSession,
_populate_codex_home_config,
codex_router_bridge_dir,
codex_router_canary_fired,
codex_router_hooks_settings,
codex_router_session_id,
merge_codex_user_hooks,
read_codex_spawn_audit,
reconcile_spawn_audit,
subagent_routing_unenforced_warning,
write_codex_router_hooks_file,
)
from omnigent.inner.hook_scripts.codex_router_hook import AUDIT_FILENAME, CANARY_FILENAME
from omnigent.inner.hook_scripts.subagent_router import REQUEST_TIMEOUT_S
_USER_HOOKS = {
"hooks": {
"PreToolUse": [{"hooks": [{"type": "command", "command": "user-pre"}]}],
"Stop": [{"hooks": [{"type": "command", "command": "user-stop"}]}],
}
}
def _write_user_home(tmp_path: Path, *, hooks: dict[str, object] | None = None) -> Path:
source = tmp_path / "user-codex"
source.mkdir()
(source / "auth.json").write_text("{}")
(source / "config.toml").write_text('model = "gpt-5.4-mini"\n')
if hooks is not None:
(source / "hooks.json").write_text(json.dumps(hooks))
return source
def test_router_hooks_settings_registers_three_events(tmp_path: Path) -> None:
payload = codex_router_hooks_settings(
tmp_path / "bridge",
session_id="conv_abc",
python_executable="/usr/bin/python3",
)
hooks = payload["hooks"]
assert set(hooks) == {"PreToolUse", "SessionStart", "SubagentStart"}
(pre_entry,) = hooks["PreToolUse"]
# Regex, never the flattened literal ``collaborationspawn_agent``.
assert pre_entry["matcher"] == r".*spawn_agent"
(pre_hook,) = pre_entry["hooks"]
assert pre_hook["type"] == "command"
# Codex's kill is the outermost bound: just above the hook's own budget.
assert pre_hook["timeout"] > REQUEST_TIMEOUT_S
assert pre_hook["timeout"] < 2 * REQUEST_TIMEOUT_S
assert "route-subagent" in pre_hook["command"]
assert "--session-id conv_abc" in pre_hook["command"]
assert "--harness codex" in pre_hook["command"]
assert f"--bridge-dir {tmp_path / 'bridge'}" in pre_hook["command"]
assert "session-canary" in hooks["SessionStart"][0]["hooks"][0]["command"]
assert "record-subagent" in hooks["SubagentStart"][0]["hooks"][0]["command"]
assert "matcher" not in hooks["SessionStart"][0]
def test_router_hooks_settings_omits_session_flag_when_unknown(tmp_path: Path) -> None:
payload = codex_router_hooks_settings(tmp_path, python_executable="/usr/bin/python3")
assert "--session-id" not in payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
def test_merge_user_hooks_preserves_user_entries_after_omnigent(tmp_path: Path) -> None:
user_hooks = tmp_path / "hooks.json"
user_hooks.write_text(json.dumps(_USER_HOOKS))
payload = codex_router_hooks_settings(tmp_path, python_executable="/usr/bin/python3")
merged = merge_codex_user_hooks(payload, user_hooks)
pre = merged["hooks"]["PreToolUse"]
assert len(pre) == 2
assert pre[0]["matcher"] == r".*spawn_agent"
assert pre[1]["hooks"][0]["command"] == "user-pre"
assert merged["hooks"]["Stop"][0]["hooks"][0]["command"] == "user-stop"
# The original payload is not mutated.
assert len(payload["hooks"]["PreToolUse"]) == 1
def test_merge_user_hooks_tolerates_malformed_user_file(tmp_path: Path) -> None:
user_hooks = tmp_path / "hooks.json"
user_hooks.write_text("{not json")
payload = codex_router_hooks_settings(tmp_path, python_executable="/usr/bin/python3")
assert merge_codex_user_hooks(payload, user_hooks) == payload
def test_write_router_hooks_file_replaces_symlink_and_merges(tmp_path: Path) -> None:
source = _write_user_home(tmp_path, hooks=_USER_HOOKS)
codex_home = tmp_path / "private"
codex_home.mkdir()
_populate_codex_home_config(codex_home, source)
assert (codex_home / "hooks.json").is_symlink()
path = write_codex_router_hooks_file(
codex_home,
tmp_path / "bridge",
session_id="conv_abc",
python_executable="/usr/bin/python3",
)
assert not path.is_symlink()
payload = json.loads(path.read_text())
assert [entry.get("matcher") for entry in payload["hooks"]["PreToolUse"]] == [
r".*spawn_agent",
None,
]
assert payload["hooks"]["Stop"][0]["hooks"][0]["command"] == "user-stop"
# The user's real hooks.json is untouched.
assert json.loads((source / "hooks.json").read_text()) == _USER_HOOKS
def test_populate_skips_hooks_symlink_when_routing_on(tmp_path: Path) -> None:
source = _write_user_home(tmp_path, hooks=_USER_HOOKS)
codex_home = tmp_path / "private"
codex_home.mkdir()
_populate_codex_home_config(codex_home, source, subagent_routing=True)
assert not (codex_home / "hooks.json").exists()
assert (codex_home / "auth.json").is_symlink()
assert (codex_home / "config.toml").is_file()
def test_populate_symlinks_hooks_when_routing_off(tmp_path: Path) -> None:
source = _write_user_home(tmp_path, hooks=_USER_HOOKS)
codex_home = tmp_path / "private"
codex_home.mkdir()
_populate_codex_home_config(codex_home, source)
assert (codex_home / "hooks.json").is_symlink()
assert (codex_home / "hooks.json").resolve() == (source / "hooks.json").resolve()
def test_write_router_hooks_file_without_user_hooks(tmp_path: Path) -> None:
codex_home = tmp_path / "private"
codex_home.mkdir()
path = write_codex_router_hooks_file(
codex_home,
tmp_path / "bridge",
user_hooks_source=tmp_path / "missing" / "hooks.json",
python_executable="/usr/bin/python3",
)
payload = json.loads(path.read_text())
assert len(payload["hooks"]["PreToolUse"]) == 1
def test_router_env_discovery(tmp_path: Path) -> None:
env = {
CODEX_ROUTER_DIR_ENV_VAR: str(tmp_path),
CODEX_ROUTER_SESSION_ID_ENV_VAR: " conv_abc ",
}
assert codex_router_bridge_dir(env) == tmp_path
assert codex_router_session_id(env) == "conv_abc"
assert codex_router_bridge_dir({}) is None
assert codex_router_session_id({}) is None
def test_canary_detection(tmp_path: Path) -> None:
assert not codex_router_canary_fired(tmp_path)
(tmp_path / CANARY_FILENAME).write_text(json.dumps({"session_id": "conv_abc"}))
assert codex_router_canary_fired(tmp_path)
def test_read_spawn_audit_skips_malformed_lines(tmp_path: Path) -> None:
assert read_codex_spawn_audit(tmp_path) == []
(tmp_path / AUDIT_FILENAME).write_text(
'{"agent_id": "a1", "model": "claude-sonnet-5"}\n'
"not json\n"
"\n"
'{"agent_id": "a2", "model": "gpt-5-6-sol"}\n'
"[1, 2]\n"
)
records = read_codex_spawn_audit(tmp_path)
assert [(r["agent_id"], r["model"]) for r in records] == [
("a1", "claude-sonnet-5"),
("a2", "gpt-5-6-sol"),
]
class _HooksSnapshot:
def __init__(self, path: Path) -> None:
self.is_symlink = path.is_symlink()
self.payload: dict[str, Any] | None = None # type: ignore[explicit-any]
if path.is_file():
self.payload = json.loads(path.read_text())
def _start_app_server(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
*,
env: dict[str, str],
) -> tuple[tuple[str, ...], _HooksSnapshot]:
source = _write_user_home(tmp_path, hooks=_USER_HOOKS)
workspace = tmp_path / "work"
workspace.mkdir()
captured: list[tuple[tuple[str, ...], _HooksSnapshot]] = []
async def fake_exec(*argv: str, **kwargs: Any) -> None: # type: ignore[explicit-any]
# The session deletes its private CODEX_HOME on the launch failure
# below, so snapshot the hooks file while codex would have read it.
home = Path(kwargs["env"]["CODEX_HOME"])
captured.append((argv, _HooksSnapshot(home / "hooks.json")))
raise RuntimeError("stop")
monkeypatch.setattr(codex_executor, "populate_codex_skills_from_bundle", lambda *a, **k: None)
monkeypatch.setattr(codex_executor, "_codex_home_config_source_from_env", lambda: source)
monkeypatch.setattr(codex_executor, "_create_subprocess_exec", fake_exec)
session = _CodexAppServerSession(
codex_path="/bin/echo",
cwd=str(workspace),
env=env,
tool_executor=None,
)
with contextlib.suppress(RuntimeError):
asyncio.run(session.start())
assert captured, "the app-server was never launched"
return captured[0]
def test_app_server_argv_carries_no_hook_trust_flag(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
bridge = tmp_path / "bridge"
bridge.mkdir()
argv, hooks = _start_app_server(
tmp_path,
monkeypatch,
env={
CODEX_ROUTER_DIR_ENV_VAR: str(bridge),
CODEX_ROUTER_SESSION_ID_ENV_VAR: "conv_abc",
},
)
assert argv[:2] == ("/bin/echo", "app-server")
assert "--dangerously-bypass-hook-trust" not in argv
assert hooks.payload is not None
payload = hooks.payload
assert len(payload["hooks"]["PreToolUse"]) == 2
assert "--session-id conv_abc" in payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
def test_app_server_keeps_symlinked_hooks_when_routing_off(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
argv, hooks = _start_app_server(tmp_path, monkeypatch, env={})
assert argv[:2] == ("/bin/echo", "app-server")
assert hooks.is_symlink
def test_unenforced_warning_payload() -> None:
warning = subagent_routing_unenforced_warning("canary did not fire")
assert warning == {
"code": SUBAGENT_ROUTING_UNENFORCED_WARNING,
"harness": "codex-native",
"reason": "canary did not fire",
}
def test_reconcile_spawn_audit_flags_a_model_the_router_never_approved() -> None:
warnings = reconcile_spawn_audit(
[
{"agent_id": "a1", "task_name": "reviewer", "model": "gpt-5-6-luna"},
{"agent_id": "a2", "task_name": "tester", "model": "glm-5-2"},
],
[
{"action": "rewrite", "model": "glm-5-2", "task_name": "reviewer"},
{"action": "rewrite", "model": "glm-5-2", "task_name": "tester"},
],
)
assert len(warnings) == 1
assert warnings[0]["code"] == SUBAGENT_ROUTING_UNENFORCED_WARNING
assert "gpt-5-6-luna" in warnings[0]["reason"]
assert "glm-5-2" in warnings[0]["reason"]
def test_reconcile_spawn_audit_accepts_a_different_spelling_of_a_routed_model() -> None:
"""Codex's own spelling of an approved model is not a violation.
The audit reports whatever id codex ran with, which differs from the
router's catalog id by prefix / case. Comparing raw strings posted an
``unenforced`` banner every 30s on a perfectly healthy session.
"""
assert (
reconcile_spawn_audit(
[{"agent_id": "a1", "task_name": "reviewer", "model": "GPT-5-5"}],
[{"action": "rewrite", "model": "databricks-gpt-5-5", "task_name": "reviewer"}],
)
== []
)
def test_reconcile_spawn_audit_is_silent_without_routed_models() -> None:
assert reconcile_spawn_audit([{"model": "gpt-5-6-luna"}], []) == []
def test_reconcile_spawn_audit_ignores_a_spawn_routing_was_toggled_off_for() -> None:
"""A session mixing routed and unrouted spawns must flag neither.
Routing toggled off mid-session (or a router outage) relays a verdict
that approves no model, so that spawn runs on the inherited model. The
all-or-nothing compare flagged every such spawn as "unenforced".
"""
warnings = reconcile_spawn_audit(
[
{"agent_id": "a1", "task_name": "routed", "model": "glm-5-2"},
{"agent_id": "a2", "task_name": "unrouted", "model": "gpt-5-6-luna"},
],
[
{"action": "rewrite", "model": "glm-5-2", "task_name": "routed"},
# Routing off for this spawn: relayed, but no model approved.
{"action": "allow", "model": None, "task_name": "unrouted"},
],
)
assert warnings == []
def test_reconcile_spawn_audit_still_flags_the_routed_spawn_in_a_mixed_session() -> None:
warnings = reconcile_spawn_audit(
[{"agent_id": "a1", "task_name": "routed", "model": "gpt-5-6-luna"}],
[
{"action": "rewrite", "model": "glm-5-2", "task_name": "routed"},
{"action": "allow", "model": None, "task_name": "unrouted"},
],
)
assert [w["reason"] for w in warnings] == [
"spawned model gpt-5-6-luna != routed model glm-5-2"
]
def test_reconcile_spawn_audit_skips_unjoinable_records_in_a_mixed_session() -> None:
"""Without a task name, "unrouted" cannot be told from "ignored"."""
assert (
reconcile_spawn_audit(
[{"agent_id": "a1", "model": "gpt-5-6-luna"}],
[
{"action": "rewrite", "model": "glm-5-2", "task_name": "routed"},
{"action": "allow", "model": None, "task_name": "unrouted"},
],
)
== []
)
def test_reconcile_spawn_audit_falls_back_session_wide_when_every_spawn_was_routed() -> None:
warnings = reconcile_spawn_audit(
[{"agent_id": "a1", "model": "gpt-5-6-luna"}],
[{"action": "rewrite", "model": "glm-5-2", "task_name": "routed"}],
)
assert len(warnings) == 1
def test_router_hook_commands_run_python_isolated(tmp_path: Path) -> None:
"""Every routing hook command passes ``-I`` before ``-m``.
Codex runs hooks with the session's *workspace* as cwd, and ``-m``
prepends cwd to ``sys.path``. A workspace holding a directory named
``omnigent`` any checkout of this project, the most likely workspace
of all then shadows the installed package and the hook dies on
``ModuleNotFoundError``. Codex discards the failure, so the routing gate
fails open in total silence. Observed live: a session whose cwd was a
second omnigent checkout ran with all three hooks *trusted* and none of
them working.
"""
hooks = codex_router_hooks_settings(
tmp_path, session_id="conv_abc", python_executable="/venv/bin/python"
)["hooks"]
commands = [h["command"] for entries in hooks.values() for e in entries for h in e["hooks"]]
assert commands, "no routing hook commands generated"
for command in commands:
argv = shlex.split(command)
assert argv[1:3] == ["-I", "-m"], f"expected isolated python in {command!r}"
def test_router_hook_survives_a_shadowing_workspace(tmp_path: Path) -> None:
"""The canary hook works when cwd holds a decoy ``omnigent`` package.
The end-to-end guard for the isolation flag: runs the real generated
command from a workspace that shadows the installed package, exactly
the live failure. Without ``-I`` this exits non-zero and writes nothing.
"""
workspace = tmp_path / "workspace"
decoy = workspace / "omnigent"
decoy.mkdir(parents=True)
(decoy / "__init__.py").write_text("raise AssertionError('decoy package imported')\n")
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
hooks = codex_router_hooks_settings(
bridge_dir, session_id="conv_abc", python_executable=sys.executable
)["hooks"]
command = hooks["SessionStart"][0]["hooks"][0]["command"]
result = subprocess.run(
shlex.split(command),
cwd=str(workspace),
capture_output=True,
text=True,
timeout=120,
)
assert result.returncode == 0, result.stderr
assert codex_router_canary_fired(bridge_dir), result.stderr
+56
View File
@@ -12,6 +12,7 @@ import pytest
from omnigent.codex_native_bridge import (
CodexNativeBridgeState,
read_bridge_state,
read_codex_config_model,
write_bridge_startup_error,
write_bridge_state,
)
@@ -838,6 +839,61 @@ def test_web_model_pick_applied_via_thread_settings_update(
]
def test_model_settings_update_mirrors_model_into_config_toml(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""
An applied model switch is mirrored into codex-home/config.toml.
``thread/settings/update`` changes the live thread but not
``config.toml`` the file the forwarder's model mirror and the
cost-gate hook treat as source of truth. Without the mirror write, the
next ``turn/started`` re-reads the stale launch model and posts an
``external_model_change`` back to Omnigent, silently reverting a routed
or web-picked model to the spawn default.
"""
_FakeCodexNativeClient.requests = []
_FakeCodexNativeClient.created = []
_FakeCodexNativeClient.next_turn = 1
monkeypatch.setattr(
"omnigent.codex_native_app_server.CodexAppServerClient",
_FakeCodexNativeClient,
)
_start_state(tmp_path)
home = tmp_path / "codex-home"
home.mkdir(parents=True, exist_ok=True)
(home / "config.toml").write_text('model = "databricks-gpt-5-5"\n')
executor = CodexNativeExecutor(bridge_dir=tmp_path)
_run_turn_with_config(executor, "hello", ExecutorConfig(model="gpt-5.6-luna"))
assert read_codex_config_model(tmp_path) == "gpt-5.6-luna"
def test_effort_only_settings_update_leaves_config_toml_model(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""An effort-only settings update must not rewrite the config model."""
_FakeCodexNativeClient.requests = []
_FakeCodexNativeClient.created = []
_FakeCodexNativeClient.next_turn = 1
monkeypatch.setattr(
"omnigent.codex_native_app_server.CodexAppServerClient",
_FakeCodexNativeClient,
)
_start_state(tmp_path)
home = tmp_path / "codex-home"
home.mkdir(parents=True, exist_ok=True)
(home / "config.toml").write_text('model = "databricks-gpt-5-5"\n')
executor = CodexNativeExecutor(bridge_dir=tmp_path)
_run_turn_with_config(executor, "hello", ExecutorConfig(extra={"reasoning_effort": "high"}))
assert read_codex_config_model(tmp_path) == "databricks-gpt-5-5"
def test_no_settings_update_when_overrides_unset(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
+411
View File
@@ -0,0 +1,411 @@
from __future__ import annotations
import io
import json
from pathlib import Path
from typing import Any
import pytest
from omnigent.inner.hook_scripts import codex_router_hook as hook
from omnigent.inner.hook_scripts import subagent_router
from tests.inner.conftest import advertise_router
_ENCRYPTED_MESSAGE = "enc:AAAABBBBCCCC=="
def _payload(**tool_input: Any) -> dict[str, Any]: # type: ignore[explicit-any]
return {
"hook_event_name": "PreToolUse",
"tool_name": "collaborationspawn_agent",
"model": "gpt-5-6-sol",
"tool_input": {
"task_name": "refactor-tests",
"message": _ENCRYPTED_MESSAGE,
**tool_input,
},
}
def _route(
payload: dict[str, Any], # type: ignore[explicit-any]
*,
router_dir: Path,
session_id: str | None = None,
) -> dict[str, Any] | None: # type: ignore[explicit-any]
return subagent_router.route_pre_tool_use(
payload,
harness=hook.DEFAULT_HARNESS,
router_dir=router_dir,
session_id=session_id,
**hook.ROUTE_SEAMS,
)
def _build(
tool_input: dict[str, Any], # type: ignore[explicit-any]
*,
parent_model: str | None = None,
) -> dict[str, Any]: # type: ignore[explicit-any]
return subagent_router.build_route_request(
tool_input,
harness="codex-native",
parent_model=parent_model,
task_keys=hook.ROUTE_SEAMS["task_keys"],
include_prompt=hook.ROUTE_SEAMS["include_prompt"],
)
class _Router:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = [] # type: ignore[explicit-any]
self.response: dict[str, Any] | None = None # type: ignore[explicit-any]
def __call__(
self,
endpoint: Any, # type: ignore[explicit-any]
session_id: str,
body: dict[str, Any], # type: ignore[explicit-any]
**kwargs: Any, # type: ignore[explicit-any]
) -> dict[str, Any] | None: # type: ignore[explicit-any]
self.calls.append({"endpoint": endpoint, "session_id": session_id, "body": body})
return self.response
@pytest.fixture
def router(monkeypatch: pytest.MonkeyPatch) -> _Router:
fake = _Router()
monkeypatch.setattr(subagent_router, "request_decision", fake)
return fake
def test_is_spawn_agent_tool_matches_flattened_name() -> None:
assert hook.is_spawn_agent_tool("collaborationspawn_agent")
assert hook.is_spawn_agent_tool("spawn_agent")
assert not hook.is_spawn_agent_tool("Bash")
assert not hook.is_spawn_agent_tool(None)
def test_build_route_request_never_sends_the_encrypted_prompt() -> None:
body = _build(_payload()["tool_input"], parent_model="gpt-5-6-sol")
assert body == {
"harness": "codex-native",
"task_name": "refactor-tests",
"prompt": None,
"fork": False,
"parent_model": "gpt-5-6-sol",
}
assert _ENCRYPTED_MESSAGE not in json.dumps(body)
@pytest.mark.parametrize(
("tool_input", "expected_task_name", "expected_fork"),
[
# Codex names the spawn ``agent_name`` on some paths, ``task_name`` on
# others; the explicit ``task_name`` wins when both are present.
({"agent_name": "doc-writer", "message": _ENCRYPTED_MESSAGE}, "doc-writer", False),
({"task_name": "refactor-tests", "agent_name": "doc-writer"}, "refactor-tests", False),
# The server supplies the placeholder task; the hook does not invent one.
({"message": _ENCRYPTED_MESSAGE}, "", False),
# Fork is detected from the task name, never from a field codex does
# not send — a stray boolean must not be trusted.
({"task_name": "planner-fork"}, "planner-fork", True),
({"fork": True, "task_name": "refactor-tests"}, "refactor-tests", False),
],
)
def test_build_route_request_derives_task_name_and_fork(
tool_input: dict[str, Any], # type: ignore[explicit-any]
expected_task_name: str,
expected_fork: bool,
) -> None:
body = _build(tool_input)
assert body["task_name"] == expected_task_name
assert body["fork"] is expected_fork
# The encrypted message is never forwarded on any of these paths.
assert body["prompt"] is None
def test_rewrite_injects_model_and_passes_message_verbatim(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
router.response = {
"action": "rewrite",
"model": "claude-sonnet-5",
"rationale": "cheapest arm",
"decision_id": "d1",
}
out = _route(_payload(), router_dir=tmp_path)
assert out == {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"task_name": "refactor-tests",
"message": _ENCRYPTED_MESSAGE,
"model": "claude-sonnet-5",
},
"permissionDecisionReason": "cheapest arm",
},
"systemMessage": "Using Smart Routing. Routing to claude-sonnet-5.",
}
assert router.calls[0]["session_id"] == "conv_abc"
assert router.calls[0]["body"]["prompt"] is None
@pytest.mark.parametrize(
("response", "expected_notice"),
[
# A rewrite is otherwise invisible — codex reports no model change — so
# the routed model is announced in the TUI.
(
{"action": "rewrite", "model": "gpt-5-6-luna", "rationale": "cheap"},
"Using Smart Routing. Routing to gpt-5-6-luna.",
),
# A deny routed to nothing, so there is no model to announce.
({"action": "deny", "rationale": "over budget"}, None),
],
)
def test_routing_notice_announces_only_a_routed_model(
tmp_path: Path,
router: _Router,
response: dict[str, Any], # type: ignore[explicit-any]
expected_notice: str | None,
) -> None:
advertise_router(tmp_path)
router.response = response
out = _route(_payload(), router_dir=tmp_path)
assert out is not None
if expected_notice is None:
assert "systemMessage" not in out
else:
# Top level, alongside hookSpecificOutput — codex reads it there.
assert out["systemMessage"] == expected_notice
assert "systemMessage" not in out["hookSpecificOutput"]
def test_with_system_message_passes_no_opinion_through() -> None:
assert hook.with_system_message(None) is None
def test_redirect_denies_with_sys_session_send_instruction(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
router.response = {
"action": "redirect",
"harness": "claude-native",
"model": "claude-opus-4-8",
}
out = _route(_payload(), router_dir=tmp_path)
assert out == {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"Router selected claude-native/claude-opus-4-8. Use sys_session_send "
"with args.harness=claude-native, args.model=claude-opus-4-8 instead."
),
}
}
def test_deny_uses_router_rationale(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
router.response = {"action": "deny", "rationale": "router unavailable"}
out = _route(_payload(), router_dir=tmp_path)
assert out is not None
assert out["hookSpecificOutput"]["permissionDecision"] == "deny"
assert out["hookSpecificOutput"]["permissionDecisionReason"] == "router unavailable"
def test_allow_emits_no_opinion(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
router.response = {"action": "allow", "rationale": "fork exempt"}
assert _route(_payload(), router_dir=tmp_path) is None
def test_router_unreachable_allows_unchanged(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
router.response = None
assert _route(_payload(), router_dir=tmp_path) is None
def test_missing_advertisement_allows_unchanged(
tmp_path: Path,
router: _Router,
) -> None:
assert _route(_payload(), router_dir=tmp_path) is None
assert router.calls == []
def test_malformed_advertisement_allows_unchanged(
tmp_path: Path,
router: _Router,
) -> None:
(tmp_path / subagent_router.ADVERTISEMENT_FILE).write_text("{not json")
assert _route(_payload(), router_dir=tmp_path) is None
assert router.calls == []
def test_unknown_session_allows_unchanged(
tmp_path: Path,
router: _Router,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv(subagent_router.SESSION_ID_ENV_VAR, raising=False)
monkeypatch.delenv(subagent_router.NATIVE_SESSION_ID_ENV_VAR, raising=False)
advertise_router(tmp_path, session_id=None)
assert _route(_payload(), router_dir=tmp_path) is None
assert router.calls == []
def test_baked_session_id_used_when_advertisement_has_none(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path, session_id=None)
router.response = {"action": "allow"}
_route(_payload(), router_dir=tmp_path, session_id="conv_baked")
assert router.calls[0]["session_id"] == "conv_baked"
def test_non_spawn_tool_is_ignored(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
payload = _payload()
payload["tool_name"] = "shell"
assert _route(payload, router_dir=tmp_path) is None
assert router.calls == []
def test_fork_spawn_reported(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
router.response = {"action": "allow"}
_route(_payload(task_name="planner-fork"), router_dir=tmp_path)
assert router.calls[0]["body"]["fork"] is True
def test_parent_model_falls_back_to_payload_model(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
router.response = {"action": "allow"}
_route(_payload(), router_dir=tmp_path)
assert router.calls[0]["body"]["parent_model"] == "gpt-5-6-sol"
def test_route_subagent_without_a_bridge_dir_emits_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv(subagent_router.ROUTER_DIR_ENV_VAR, raising=False)
monkeypatch.delenv(subagent_router.BRIDGE_DIR_ENV_VAR, raising=False)
monkeypatch.setattr(hook.sys, "stdin", _Stdin(json.dumps(_payload())))
out = io.StringIO()
monkeypatch.setattr(hook.sys, "stdout", out)
assert hook.main(["route-subagent"]) == 0
assert out.getvalue() == ""
def test_route_subagent_tolerates_unknown_flags(
tmp_path: Path,
router: _Router,
monkeypatch: pytest.MonkeyPatch,
) -> None:
advertise_router(tmp_path)
router.response = {"action": "allow"}
monkeypatch.setattr(hook.sys, "stdin", _Stdin(json.dumps(_payload())))
out = io.StringIO()
monkeypatch.setattr(hook.sys, "stdout", out)
assert hook.main(["route-subagent", "--unknown-flag", "x", "--bridge-dir", str(tmp_path)]) == 0
assert router.calls[0]["session_id"] == "conv_abc"
def test_session_canary_subcommand_writes_file(tmp_path: Path) -> None:
assert hook.main(["session-canary", "--bridge-dir", str(tmp_path), "--session-id", "c1"]) == 0
record = json.loads(hook.canary_path(tmp_path).read_text())
assert record["session_id"] == "c1"
def test_session_canary_without_a_bridge_dir_is_a_no_op(
capsys: pytest.CaptureFixture[str],
) -> None:
assert hook.main(["session-canary"]) == 0
assert "needs --bridge-dir" in capsys.readouterr().err
def test_record_subagent_subcommand_appends_jsonl(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
for agent_id, model in (("a1", "claude-sonnet-5"), ("a2", "gpt-5-6-sol")):
monkeypatch.setattr(
hook.sys,
"stdin",
_Stdin(json.dumps({"agent_id": agent_id, "model": model, "task_name": "t"})),
)
assert hook.main(["record-subagent", "--bridge-dir", str(tmp_path)]) == 0
lines = hook.audit_path(tmp_path).read_text().splitlines()
assert [json.loads(line)["agent_id"] for line in lines] == ["a1", "a2"]
assert json.loads(lines[0])["model"] == "claude-sonnet-5"
def test_record_subagent_without_a_bridge_dir_is_a_no_op(
capsys: pytest.CaptureFixture[str],
) -> None:
assert hook.main(["record-subagent"]) == 0
assert "needs --bridge-dir" in capsys.readouterr().err
def test_unknown_subcommand_is_a_no_op(capsys: pytest.CaptureFixture[str]) -> None:
assert hook.main(["nope"]) == 0
assert "unknown subcommand" in capsys.readouterr().err
class _Stdin:
def __init__(self, text: str) -> None:
self._text = text
def read(self) -> str:
return self._text
+1 -1
View File
@@ -1080,7 +1080,7 @@ def test_ui_setup_steps_generic_for_non_installable() -> None:
(hi.CURSOR_KEY, "2026.06.02", None),
(hi.KIMI_KEY, "1.47.0", None),
(ANTHROPIC_FAMILY, "2.1.161", None),
(OPENAI_FAMILY, "0.137.0", None),
(OPENAI_FAMILY, "0.145.0", None),
(hi.PI_KEY, "0.79.0", None),
(hi.QWEN_KEY, "0.18.1", None),
(hi.GOOSE_KEY, "1.38.0", None),
@@ -212,6 +212,44 @@ def test_native_launch_passes_synthesized_model_as_flag() -> None:
assert args == ("--model", "gateway-served-claude")
def test_routed_launch_model_reaches_the_terminal_env_as_the_custom_slot() -> None:
"""A routed exact id is launchable AND switchable back to mid-session.
Mirrors the runner's composition: the session override becomes
``--model`` and the same value is pinned into Claude Code's custom picker
slot, which is the only spelling ``/model`` accepts for an id no family
alias points at (``opus`` here resolves to the newer generation).
"""
from omnigent.claude_model_vocabulary import claude_model_command_arg
from omnigent.claude_native import claude_config_with_launch_model_pinned
config = ClaudeNativeUcodeConfig(
env={
"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5",
},
api_key_helper="printf %s sk-gateway",
model="databricks-claude-opus-5",
)
session_model_override = "databricks-claude-opus-4-8"
launched = claude_config_with_launch_model_pinned(config, session_model_override)
assert launched is not None
args = _build_claude_native_base_args(
reasoning_effort=None,
model_override=session_model_override,
terminal_launch_args=None,
)
terminal_env = build_native_claude_terminal_env(launched)
assert args == ("--model", "databricks-claude-opus-4-8")
assert terminal_env["ANTHROPIC_CUSTOM_MODEL_OPTION"] == "databricks-claude-opus-4-8"
assert (
claude_model_command_arg(session_model_override, terminal_env)
== "databricks-claude-opus-4-8"
)
def test_build_native_claude_terminal_env_rejects_raw_key_on_helper_path() -> None:
"""The env-build seam fails loud if a raw key rides the apiKeyHelper path.
@@ -978,7 +978,7 @@ async def test_auto_create_claude_terminal_injects_ucode_gateway_config(
# ``omnigent.claude_native`` per call, so patch it at the source.
monkeypatch.setattr(
"omnigent.claude_native._ucode_config_for_profile",
lambda profile: ucode,
lambda profile, *, refresh_models=True: ucode,
)
captured: dict[str, Any] = {}
@@ -413,7 +413,7 @@ async def test_auto_create_codex_terminal_uses_persisted_resume_launch_config(
"""Minimal app-server object used by ``codex_terminal_env``."""
codex_path = "/opt/codex/bin/codex"
codex_cli_version: tuple[int, int, int] | None = None
codex_cli_version: tuple[int, int, int] | None = (0, 145, 0)
def __init__(self) -> None:
""":returns: None."""
@@ -611,6 +611,11 @@ async def test_auto_create_codex_terminal_uses_persisted_resume_launch_config(
)
]
assert published_events[0]["type"] == "session.resource.created"
assert len(forward_calls) == 1
# The router handle is threaded through so the forwarder's ``finally``
# tears down *its* endpoint, not a re-created session's live one.
assert "subagent_router" in forward_calls[0]
del forward_calls[0]["subagent_router"]
assert forward_calls == [
{
"session_id": session_id,
@@ -1413,6 +1418,15 @@ async def test_auto_create_codex_terminal_uses_worktree_workspace_not_bundle_dir
assert launched_sandbox is not None and launched_sandbox.type == "none"
assert launch_captured["parent_os_env"] is codex_os_env
# This fake app-server reports no codex version (an unparseable / failed
# probe). The argv flag requires a positively parsed version: on a
# pre-0.131 codex an unknown flag aborts argv parsing outright, which is
# strictly worse than the recoverable trust prompt. (The app-server's
# hooks-file gate keeps the opposite "unknown = supported" policy, since
# an unsupported hooks file is only ignored.)
assert app_server.codex_cli_version is None
assert "--dangerously-bypass-hook-trust" not in launch_captured["spec"].args
@pytest.mark.asyncio
async def test_auto_create_codex_terminal_starts_relay_at_session_creation(
@@ -807,6 +807,55 @@ async def test_messages_reach_harness_in_submission_order() -> None:
)
@pytest.mark.asyncio
async def test_forwarded_model_override_reaches_the_harness() -> None:
"""A routed model rides the forwarded message all the way to the harness.
Intelligent routing puts its pick in-band on the native-terminal message
(``model_override``); the harness forwards it into
``CreateResponseRequest.model_override`` and the executor adapter into
``ExecutorConfig.model``, which is the only way a native TUI learns to
type ``/model`` for this turn. ``_run_turn_bg`` builds the harness body
field by field, so a missing thread-through silently drops the switch
the routing card claims a model was applied while the pane never moves.
"""
hc = _ScriptedHarnessClient(
[
_sse({"type": "response.created", "response": {"id": "resp_1"}}),
_sse({"type": "response.completed", "response": {"id": "resp_1"}}),
]
)
pm = _FakeProcessManager(hc)
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
resp = await client.post(
"/v1/sessions/dd0f1b1a7e3f4a6c8f2b5c9d0e1f2a3b/events",
json={
"type": "message",
"role": "user",
"model": "test-agent",
"content": [{"type": "input_text", "text": "hi"}],
"harness": "claude-native",
"model_override": "databricks-claude-sonnet-5",
},
)
assert resp.status_code == 202
for _ in range(200):
if hc.posted_bodies:
break
await asyncio.sleep(0.01)
assert hc.posted_bodies, "harness never received a turn"
assert hc.posted_bodies[0].get("model_override") == "databricks-claude-sonnet-5", (
"the routed model was dropped between the runner's message intake and "
f"the harness body: {hc.posted_bodies[0].keys()}"
)
@pytest.mark.asyncio
async def test_buffered_continuation_skips_transient_idle() -> None:
"""End-of-turn `idle` is suppressed when a buffered message will start a new turn."""
+1 -1
View File
@@ -3364,7 +3364,7 @@ async def test_sys_session_send_model_rejected_for_unplumbed_harness(
pytest.param(
"codex-native",
"databricks-claude-sonnet-4-6",
"only runs GPT models",
"only runs codex-compatible models",
id="claude-on-codex",
),
pytest.param(
+239
View File
@@ -0,0 +1,239 @@
"""Launch-site behaviour for the per-session subagent-routing endpoint."""
from __future__ import annotations
import asyncio
import http.client
import stat
from pathlib import Path
from typing import Any
import pytest
from omnigent.inner.hook_scripts.subagent_router import read_router_endpoint
from omnigent.runner import subagent_routing
from omnigent.runner.app import _ensure_session_subagent_router
from omnigent.runner.native.orchestration import _start_subagent_router_for_native_session
class _DeadClient:
"""Stands in for the runner→server client; never actually called."""
async def post(self, *args: Any, **kwargs: Any) -> Any: # type: ignore[explicit-any]
raise RuntimeError("server down")
@pytest.fixture(autouse=True)
def _cleanup_routers() -> Any: # type: ignore[explicit-any]
yield
for session_id in ("conv_native_launch", "conv_sdk_launch"):
subagent_routing.shutdown_session_router(session_id)
async def test_native_launch_installs_the_router_for_an_unrouted_session(tmp_path: Path) -> None:
advertised, router = _start_subagent_router_for_native_session(
"conv_native_launch",
bridge_dir=tmp_path,
harness="claude-native",
server_client=_DeadClient(), # type: ignore[arg-type]
)
# No session flag consulted: the hooks are installed either way and
# the server decides per spawn.
assert advertised == tmp_path
assert router is not None
assert read_router_endpoint(tmp_path) is not None
async def test_native_launch_skips_without_a_server_client(tmp_path: Path) -> None:
assert _start_subagent_router_for_native_session(
"conv_native_launch",
bridge_dir=tmp_path,
harness="claude-native",
server_client=None,
) == (None, None)
async def test_stale_handle_shutdown_leaves_a_relaunched_router_alive(tmp_path: Path) -> None:
"""A forwarder's late ``finally`` must not kill the re-created router."""
session_id = "conv_native_launch"
_, first = _start_subagent_router_for_native_session(
session_id,
bridge_dir=tmp_path,
harness="claude-native",
server_client=_DeadClient(), # type: ignore[arg-type]
)
assert first is not None
# Terminal re-create: the old router goes away and a new one binds.
subagent_routing.shutdown_session_router(session_id, first)
_, second = _start_subagent_router_for_native_session(
session_id,
bridge_dir=tmp_path,
harness="claude-native",
server_client=_DeadClient(), # type: ignore[arg-type]
)
assert second is not None and second is not first
# The old forwarder's delayed teardown fires now.
subagent_routing.shutdown_session_router(session_id, first)
assert subagent_routing._session_routers.get(session_id) is second
assert not second._closed
advertised = read_router_endpoint(tmp_path)
assert advertised is not None
assert advertised.url == second.url
async def test_unscoped_shutdown_still_tears_down_the_live_router(tmp_path: Path) -> None:
session_id = "conv_native_launch"
_, router = _start_subagent_router_for_native_session(
session_id,
bridge_dir=tmp_path,
harness="claude-native",
server_client=_DeadClient(), # type: ignore[arg-type]
)
assert router is not None
subagent_routing.shutdown_session_router(session_id)
assert router._closed
assert session_id not in subagent_routing._session_routers
@pytest.mark.parametrize(
("harness", "session_env_var"),
[
("claude-sdk", "OMNIGENT_SUBAGENT_ROUTER_SESSION_ID"),
("codex", "OMNIGENT_CODEX_SUBAGENT_ROUTER_SESSION_ID"),
],
)
async def test_sdk_launch_installs_the_router_for_an_unrouted_session(
harness: str, session_env_var: str
) -> None:
await _ensure_session_subagent_router(
"conv_sdk_launch",
harness,
server_client=_DeadClient(), # type: ignore[arg-type]
)
env = subagent_routing.session_router_env("conv_sdk_launch", harness)
assert env[session_env_var] == "conv_sdk_launch"
async def test_router_env_is_scoped_to_the_launching_harness(tmp_path: Path) -> None:
"""A codex spawn beneath a claude session must not see the codex vars.
They would carry the parent claude session's id, so the codex executor
would route and audit as the wrong session.
"""
claude_env = subagent_routing.router_env("conv_x", tmp_path, harness="claude-sdk")
codex_env = subagent_routing.router_env("conv_x", tmp_path, harness="codex")
assert set(claude_env) == {
"OMNIGENT_SUBAGENT_ROUTER_DIR",
"OMNIGENT_SUBAGENT_ROUTER_SESSION_ID",
}
assert set(codex_env) == {
"OMNIGENT_CODEX_SUBAGENT_ROUTER_DIR",
"OMNIGENT_CODEX_SUBAGENT_ROUTER_SESSION_ID",
}
# A harness with no routing hooks gets nothing at all.
assert subagent_routing.router_env("conv_x", tmp_path, harness="pi") == {}
async def test_sdk_launch_survives_an_unusable_router_root(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A poisoned bridge root must not fail session creation."""
from omnigent.runner import subagent_routing as routing_mod
def _boom(session_id: str) -> Path:
raise RuntimeError("unsafe bridge root")
monkeypatch.setattr(routing_mod, "router_dir_for_session", _boom)
await _ensure_session_subagent_router(
"conv_sdk_launch",
"claude-sdk",
server_client=_DeadClient(), # type: ignore[arg-type]
)
assert subagent_routing.session_router_env("conv_sdk_launch", "claude-sdk") == {}
@pytest.mark.parametrize("harness", ["pi", "copilot", "goose"])
async def test_sdk_launch_skips_harnesses_without_spawn_hooks(harness: str) -> None:
"""No hook reads the advertisement, so no endpoint is started at all."""
await _ensure_session_subagent_router(
"conv_sdk_launch",
harness,
server_client=_DeadClient(), # type: ignore[arg-type]
)
assert "conv_sdk_launch" not in subagent_routing._session_routers
async def test_sdk_launch_skips_native_harnesses() -> None:
await _ensure_session_subagent_router(
"conv_sdk_launch",
"claude-native",
server_client=_DeadClient(), # type: ignore[arg-type]
)
assert subagent_routing.session_router_env("conv_sdk_launch", "claude-native") == {}
# ── Endpoint hardening ──────────────────────────────────────────────
async def test_non_ascii_authorization_header_is_rejected_not_crashed(tmp_path: Path) -> None:
"""``compare_digest`` raises TypeError on a non-ASCII str operand."""
async def _resolver(session_id: str, req: Any) -> Any: # type: ignore[explicit-any]
raise AssertionError("resolver must not run for an unauthorized request")
router = subagent_routing.start_subagent_router(
bridge_dir=tmp_path,
session_id="conv_auth",
resolver=_resolver,
loop=asyncio.get_running_loop(),
)
try:
host, port = router.httpd.server_address[0], router.httpd.server_address[1]
def _post() -> int:
conn = http.client.HTTPConnection(str(host), int(port), timeout=10)
try:
conn.request(
"POST",
"/v1/sessions/conv_auth/route-subagent",
body=b"{}",
headers={"Authorization": "Bearer \u00fc\u00e9"},
)
return conn.getresponse().status
finally:
conn.close()
assert await asyncio.to_thread(_post) == 401
finally:
router.close()
def test_advertisement_is_written_owner_only_with_no_leftover_temp(tmp_path: Path) -> None:
path = subagent_routing.write_advertisement(
tmp_path, url="http://127.0.0.1:1", token="tok", session_id="conv_x"
)
assert stat.S_IMODE(path.stat().st_mode) == 0o600
# A unique temp name is used, so nothing may survive the rename.
assert [p.name for p in tmp_path.iterdir()] == [path.name]
subagent_routing.write_advertisement(tmp_path, url="http://127.0.0.1:2", token="tok2")
assert [p.name for p in tmp_path.iterdir()] == [path.name]
def test_prune_never_removes_the_shared_router_root(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
from omnigent import claude_native_bridge
root = tmp_path / "subagent-routers"
root.mkdir()
monkeypatch.setattr(claude_native_bridge, "subagent_router_bridge_root", lambda: root)
router = subagent_routing.SubagentRouter(
bridge_dir=root,
url="http://127.0.0.1:1",
token="tok",
httpd=None, # type: ignore[arg-type]
)
subagent_routing._prune_router_dirs(router)
assert root.is_dir()
+104
View File
@@ -0,0 +1,104 @@
"""
Unit tests for :mod:`omnigent.runtime.session_warnings`.
The index is a process-global map of session id degraded-but-running
conditions the chat header shows. Tests here pin the invariants that keep
a banner honest and the map bounded: only allowlisted codes are stored,
payloads are reduced to known string fields, entries dedupe on
``(code, harness)``, and :func:`clear` drops them again for the codes
a publisher checked, or wholesale on teardown so a repaired condition
or a deleted session leaves nothing behind while one publisher's repair
never drops another's warning.
"""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from omnigent.runtime import session_warnings
@pytest.fixture(autouse=True)
def _clean_index() -> Iterator[None]:
session_warnings._warnings.clear()
yield
session_warnings._warnings.clear()
def _warning(**overrides: str) -> dict[str, str]:
payload = {
"code": session_warnings.SUBAGENT_ROUTING_UNENFORCED,
"harness": "codex-native",
"reason": "SessionStart canary did not fire",
}
payload.update(overrides)
return payload
def test_record_keeps_only_allowlisted_codes() -> None:
session_warnings.record("conv_a", _warning(code="something_invented"))
session_warnings.record("conv_a", {"harness": "codex-native"})
assert session_warnings.snapshot_for("conv_a") == []
session_warnings.record("conv_a", _warning())
assert session_warnings.snapshot_for("conv_a") == [_warning()]
def test_record_drops_unknown_fields_and_caps_free_text() -> None:
session_warnings.record(
"conv_a",
{**_warning(reason="x" * 900), "callback_url": "http://evil", "payload": {"deep": 1}},
)
(entry,) = session_warnings.snapshot_for("conv_a")
assert set(entry) == {"code", "harness", "reason"}
assert len(entry["reason"]) == 500
def test_record_dedupes_on_code_and_harness() -> None:
session_warnings.record("conv_a", _warning(reason="first"))
session_warnings.record("conv_a", _warning(reason="second"))
session_warnings.record("conv_a", _warning(harness="claude-native", reason="other pane"))
reasons = [entry["reason"] for entry in session_warnings.snapshot_for("conv_a")]
assert reasons == ["second", "other pane"]
def test_clear_drops_named_codes_or_the_whole_session() -> None:
session_warnings.record("conv_a", _warning())
session_warnings.record("conv_b", _warning())
session_warnings.clear("conv_a", codes=("not_this_one",))
assert session_warnings.snapshot_for("conv_a") == [_warning()]
session_warnings.clear("conv_a", codes=session_warnings.EXTERNAL_WARNING_CODES)
assert session_warnings.snapshot_for("conv_a") == []
# Clearing the last entry prunes the session's list rather than leaving
# an empty one behind for the process lifetime.
assert "conv_a" not in session_warnings._warnings
assert session_warnings.snapshot_for("conv_b") == [_warning()]
session_warnings.clear("conv_b")
assert session_warnings._warnings == {}
# Clearing an unknown session is a no-op, not a KeyError.
session_warnings.clear("conv_missing")
session_warnings.clear("conv_missing", codes=(session_warnings.SUBAGENT_ROUTING_UNENFORCED,))
def test_record_caps_the_harness_field_and_the_entry_count() -> None:
# ``harness`` is half the dedup key, so a publisher that varied it freely
# could mint an entry per post; both the value and the count are bounded.
for index in range(session_warnings._MAX_ENTRIES_PER_SESSION + 4):
session_warnings.record("conv_a", _warning(harness=f"{index}-{'h' * 200}"))
entries = session_warnings.snapshot_for("conv_a")
assert len(entries) == session_warnings._MAX_ENTRIES_PER_SESSION
assert all(len(entry["harness"]) == session_warnings._MAX_HARNESS_LEN for entry in entries)
# The newest observation is kept; the oldest was evicted for it.
assert entries[-1]["harness"].startswith("11-")
def test_snapshot_is_a_copy() -> None:
session_warnings.record("conv_a", _warning())
snapshot = session_warnings.snapshot_for("conv_a")
snapshot[0]["reason"] = "mutated"
assert session_warnings.snapshot_for("conv_a") == [_warning()]
+66 -1
View File
@@ -9,7 +9,7 @@ import re
import tarfile
import threading
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, ClassVar
import click
@@ -24,6 +24,7 @@ from omnigent.onboarding.sandboxes import (
)
from omnigent.runner.transports.ws_tunnel.frames import HelloFrame
from omnigent.runtime import session_stream
from omnigent.server.smart_routing import RoutingResult, RoutingSettings
# Sentinel ready event so a stream collector's registration is a
# deterministic sync point (first delivered item) rather than a
@@ -942,3 +943,67 @@ class CapturingRunnerClient:
if body.get("type") == "cost_approval_popup":
self.popup_seen.set()
return httpx.Response(202, request=httpx.Request("POST", f"http://runner{url}"))
# ── Routing stubs ───────────────────────────────────────────────────
#
# Shared by the routing suites (unit, endpoint, integration) so the
# router double has one definition instead of one per file.
class FakeRoutingClient:
"""Routing-client double: canned verdict, recorded offers, optional error.
:param result: The verdict :meth:`route` returns, or ``None`` for a
no-decision (router declined).
:param error: Raised from :meth:`route` instead of returning, to
exercise the fail-open paths.
:param last_error: Seeds the protocol's ``last_error`` reason field.
:ivar calls: ``(message, offered_models)`` per :meth:`route` call.
:ivar offered: The ``available_models`` mapping per call.
"""
def __init__(
self,
result: RoutingResult | None = None,
*,
error: Exception | None = None,
last_error: str | None = None,
) -> None:
self._result = result
self._error = error
self.last_error = last_error
self.calls: list[tuple[str, dict[str, list[str]]]] = []
self.offered: list[dict[str, list[str]]] = []
async def route(
self, message: str, available_models: dict[str, list[str]]
) -> RoutingResult | None:
"""Record the offer and return the canned verdict (or raise)."""
offer = dict(available_models)
self.calls.append((message, offer))
self.offered.append(offer)
if self._error is not None:
raise self._error
return self._result
@dataclass
class FakeCaps:
"""Caps double carrying only what the routing code reads off it."""
routing_client: Any = None # type: ignore[explicit-any]
routing_settings: Any = field(default_factory=RoutingSettings) # type: ignore[explicit-any]
def echo_runner_client() -> httpx.AsyncClient:
"""A runner client that acks every forwarded turn 202, like the runner."""
def _handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(202, json={"queued": True})
return httpx.AsyncClient(
base_url="http://runner.test",
transport=httpx.MockTransport(_handler),
)
+67 -1
View File
@@ -57,6 +57,7 @@ def _websocket_scope(path: str) -> dict[str, object]:
def _make_hello(
name: str = "test-laptop",
configured_harnesses: dict[str, bool | str] | None = None,
gateway_inference: dict[str, bool] | None = None,
) -> str:
"""Encode a HostHelloFrame for tests.
@@ -64,6 +65,9 @@ def _make_hello(
:param configured_harnesses: Per-harness readiness map to report,
e.g. ``{"claude-sdk": True}``; ``None`` mimics an older host
that doesn't report it.
:param gateway_inference: Per-harness AI-Gateway-backed inference map to
report, e.g. ``{"claude-native": True}``; ``None`` mimics a host that
doesn't report it.
:returns: JSON-encoded hello frame.
"""
return encode_host_frame(
@@ -72,6 +76,7 @@ def _make_hello(
frame_protocol_version=1,
name=name,
configured_harnesses=configured_harnesses,
gateway_inference=gateway_inference,
)
)
@@ -106,6 +111,7 @@ async def _connect_host(
host_id: str = _HOST_ID,
name: str = "test-laptop",
configured_harnesses: dict[str, bool | str] | None = None,
gateway_inference: dict[str, bool] | None = None,
) -> ApplicationCommunicator:
"""Connect a mock host via WebSocket tunnel.
@@ -115,6 +121,8 @@ async def _connect_host(
:param name: Host name for the hello frame.
:param configured_harnesses: Readiness map for the hello frame,
e.g. ``{"codex": False}``; ``None`` mimics an older host.
:param gateway_inference: Gateway-inference map for the hello frame,
e.g. ``{"codex": True}``; ``None`` mimics a host that doesn't report it.
:returns: Connected ASGI communicator.
"""
path = f"/v1/hosts/{host_id}/tunnel"
@@ -124,7 +132,10 @@ async def _connect_host(
assert accepted["type"] == "websocket.accept"
await comm.send_input(
{"type": "websocket.receive", "text": _make_hello(name, configured_harnesses)},
{
"type": "websocket.receive",
"text": _make_hello(name, configured_harnesses, gateway_inference),
},
)
while registry.get(host_id) is None:
await asyncio.sleep(0.01)
@@ -298,6 +309,61 @@ async def test_hosts_api_configured_harnesses_null_for_older_host(
assert resp.json()["hosts"][0]["configured_harnesses"] is None
async def test_hosts_api_surfaces_gateway_inference(
host_api_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
) -> None:
"""
Verify the gateway-inference map a host reports in its hello is persisted
and surfaced by both GET /v1/hosts and GET /v1/hosts/{id}.
This is the signal the web UI gates Smart Routing on. If it is dropped
anywhere along hello upsert_on_connect hosts route, the UI would offer
Smart Routing on a host whose apply layer cannot work.
"""
app, registry, _hs, _cs = host_api_app
_comm = await _connect_host(
app,
registry,
configured_harnesses={"claude-native": True, "codex": True},
gateway_inference={"claude-native": True, "codex": False},
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
listing = await client.get("/v1/hosts")
single = await client.get(f"/v1/hosts/{_HOST_ID}")
assert listing.status_code == 200
assert listing.json()["hosts"][0]["gateway_inference"] == {
"claude-native": True,
"codex": False,
}
assert single.status_code == 200
assert single.json()["gateway_inference"] == {"claude-native": True, "codex": False}
async def test_hosts_api_gateway_inference_null_for_older_host(
host_api_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
) -> None:
"""
Verify a host that never reported gateway inference lists with
``gateway_inference`` null unknown, never ``{}``.
``{}`` would gate Smart Routing away from every old host; ``null`` is the
contract the web helper keys on to leave it enabled.
"""
app, registry, _hs, _cs = host_api_app
_comm = await _connect_host(app, registry)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
listing = await client.get("/v1/hosts")
single = await client.get(f"/v1/hosts/{_HOST_ID}")
assert listing.status_code == 200
assert listing.json()["hosts"][0]["gateway_inference"] is None
assert single.status_code == 200
assert single.json()["gateway_inference"] is None
async def test_get_host_404(
host_api_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
) -> None:
File diff suppressed because it is too large Load Diff
@@ -690,11 +690,10 @@ async def test_smart_routing_overrides_orchestrator_model_for_child_session(
) -> None:
"""Smart routing wins over the orchestrator's model choice for child sessions.
When the parent session has the routing toggle on, a sub-agent created via
sys_session_send is forced to ``harness_override="auto"`` at create time
(ignoring the orchestrator's harness/model). The first-message auto-harness
path then routes both harness and model via ``route_session_harness`` and
the verdict replaces the orchestrator's choice in the runner body.
When the parent session has the routing toggle on, the child's first
message routes both harness and model via ``route_session_harness``
(within the parent's harness family) and the verdict replaces the
orchestrator's ``model``/``harness`` choice in the runner body.
"""
captured = _stub_runner_client(monkeypatch)
@@ -0,0 +1,919 @@
"""Create-time Smart Routing onto a native terminal harness.
The landing screen's top-level "Smart Routing" harness sends
``harness_override: "auto"`` with a native wrapper agent as a placeholder and
the first message as ``smart_routing_message``. A native terminal launches with
the session row, so the harness is decided during the create not on the first
message event the way the bundle-agent auto path does and the session is
rebound to the wrapper the router picked.
A create pinned to one native harness (the CLI's ``omni claude --smart-routing``,
or the web UI picking a harness with routing on) routes on the same seam for the
same reason, but only the MODEL: its turns originate in the TUI, so the server
never sees the first message pre-inference and the turn gate would never fire.
"""
from __future__ import annotations
from collections.abc import Iterator
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import patch
import httpx
import pytest
from omnigent.db.utils import generate_agent_id
from omnigent.runner.subagent_routing import AUTO_HARNESS_LABEL_KEY, ROUTING_DECISION_LABEL_KEY
from omnigent.server.routes._sessions.orchestration import (
_installed_native_harnesses,
_pre_session_model_catalog,
)
from omnigent.server.smart_routing import (
AUTO_NATIVE_ROUTING_HARNESSES,
RoutePick,
RoutingResult,
TaskV1RouteOptionSource,
infer_models,
route_session_harness,
)
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore
from omnigent.stores.host_store import Host
from tests.server.helpers import FakeCaps, FakeRoutingClient, create_test_agent
# Names ``init_runtime`` (via the shared ``runtime_init`` fixture) rebinds and
# never restores.
_RUNTIME_GLOBALS = (
"_conversation_store",
"_agent_store",
"_agent_cache",
"_file_store",
"_artifact_store",
"_comment_store",
"_policy_store",
"_terminal_registry",
)
@pytest.fixture(autouse=True)
def _restore_runtime_globals() -> Iterator[None]:
"""Put the process-global stores back the way this module found them.
The ``app`` fixture initializes the runtime with stores bound to a
per-test database and leaves them installed. A later test that resolves
a session against its own fake ids then reaches this module's dead
store instead of the ``None`` it expects.
:yields: None.
"""
from omnigent.runtime import _globals
saved = {name: getattr(_globals, name) for name in _RUNTIME_GLOBALS}
yield
for name, value in saved.items():
setattr(_globals, name, value)
CLAUDE_MODEL = "databricks-claude-opus-4-8"
GPT_MODEL = "databricks-gpt-5-5"
ROUTING_MESSAGE = "refactor the auth module and add tests"
SPAWN_PAYLOAD = {
"harness": "claude-native",
"task_name": "code-reviewer",
"prompt": "review the auth module",
"parent_model": CLAUDE_MODEL,
}
async def _native_wrappers(client: httpx.AsyncClient, db_uri: str) -> dict[str, str]:
"""Register both native wrapper agents and return ``harness -> agent_id``.
The server resolves a routed harness to its wrapper by agent NAME via
``get_by_name``, which only sees TEMPLATE agents so the wrappers are
seeded as templates over a real uploaded bundle (the app fixture skips the
lifespan that would seed the builtins).
:param client: Test HTTP client.
:param db_uri: Database URI for a direct store handle.
:returns: ``{"claude-native": id, "codex-native": id}``.
"""
source = await create_test_agent(client, name="native-wrapper-bundle-source")
store = SqlAlchemyAgentStore(db_uri)
bundle = store.get(str(source["id"]))
assert bundle is not None
wrappers: dict[str, str] = {}
for harness, agent_name in (
("claude-native", "claude-native-ui"),
("codex-native", "codex-native-ui"),
):
agent_id = generate_agent_id()
store.create(agent_id, name=agent_name, bundle_location=bundle.bundle_location)
wrappers[harness] = agent_id
return wrappers
async def _create_smart_routing_session(
client: httpx.AsyncClient,
wrappers: dict[str, str],
routing_client: FakeRoutingClient | None,
) -> httpx.Response:
"""POST the landing screen's Smart Routing create payload.
:param client: Test HTTP client.
:param wrappers: Registered wrapper ids from :func:`_native_wrappers`.
:param routing_client: Stub router, or ``None`` to leave routing unconfigured.
:returns: The raw create response.
"""
body = {
# The Claude wrapper is the client-side placeholder; the server rebinds.
"agent_id": wrappers["claude-native"],
"harness_override": "auto",
"cost_control_mode_override": "on",
"smart_routing_message": ROUTING_MESSAGE,
}
with patch("omnigent.runtime._globals._caps", new=FakeCaps(routing_client=routing_client)):
return await client.post("/v1/sessions", json=body)
async def _create_fixed_harness_session(
client: httpx.AsyncClient,
agent_id: str,
routing_client: FakeRoutingClient | None,
**extra: Any, # type: ignore[explicit-any]
) -> httpx.Response:
"""POST a Smart Routing create for a session pinned to one harness.
The CLI's ``omni claude --smart-routing`` shape: a fixed harness (the native
wrapper agent, no ``harness_override``) plus routing on and the prompt as
``smart_routing_message``.
:param client: Test HTTP client.
:param agent_id: Agent to bind.
:param routing_client: Stub router, or ``None`` to leave routing unconfigured.
:param extra: Extra create-body fields, merged last.
:returns: The raw create response.
"""
body: dict[str, Any] = {
"agent_id": agent_id,
"cost_control_mode_override": "on",
"smart_routing_message": ROUTING_MESSAGE,
**extra,
}
with patch("omnigent.runtime._globals._caps", new=FakeCaps(routing_client=routing_client)):
return await client.post("/v1/sessions", json=body)
def _routing_decision_items(db_uri: str, session_id: str) -> list[dict[str, Any]]:
"""The session's ``routing_decision`` item payloads, oldest first.
:param db_uri: Database URI for a direct store handle.
:param session_id: Session whose transcript to read.
:returns: The ``data`` dict of each routing_decision item.
"""
store = SqlAlchemyConversationStore(db_uri)
items = store.list_items(session_id, type="routing_decision").data
return [cast("dict[str, Any]", item.data.model_dump()) for item in items]
@pytest.mark.parametrize(
("harness", "picked_model"),
[
("claude-native", CLAUDE_MODEL),
("codex-native", GPT_MODEL),
],
)
async def test_fixed_native_harness_create_routes_the_model(
client: httpx.AsyncClient,
db_uri: str,
harness: str,
picked_model: str,
) -> None:
wrappers = await _native_wrappers(client, db_uri)
routing_client = FakeRoutingClient(RoutingResult(model=picked_model, rationale="sized task"))
created = await _create_fixed_harness_session(client, wrappers[harness], routing_client)
assert created.status_code == 201, created.text
session_id = created.json()["id"]
# Only the session's own harness is on the menu: routing may change the
# model, never the harness the caller asked for.
assert list(routing_client.offered[0]) == [harness]
conv = SqlAlchemyConversationStore(db_uri).get_conversation(session_id)
assert conv is not None
# The wrapper launches with ``--model <routed>``, and the label records what
# pinned it.
assert conv.model_override == picked_model
assert conv.agent_id == wrappers[harness]
assert conv.harness_override is None
decision_id = conv.labels.get(ROUTING_DECISION_LABEL_KEY)
assert decision_id is not None
# A fixed harness is not auto: subagents stay in this session's family.
assert AUTO_HARNESS_LABEL_KEY not in conv.labels
decisions = _routing_decision_items(db_uri, session_id)
assert len(decisions) == 1
assert decisions[0]["model"] == picked_model
assert decisions[0]["applied"] is True
assert decisions[0]["scope"] == "session"
assert decisions[0]["harness"] == harness
assert decisions[0]["decision_id"] == decision_id
# The create response and the snapshot both carry the model the CLI launches
# with. (``harness`` is read off the bound agent's SPEC, which these seeded
# wrappers share with a generic test bundle, so it is not asserted here.)
assert created.json()["model_override"] == picked_model
snapshot = await client.get(f"/v1/sessions/{session_id}")
assert snapshot.status_code == 200, snapshot.text
assert snapshot.json()["model_override"] == picked_model
# The launch harness is reported as ``harness`` on both (there is no
# ``harness_override`` field on the snapshot — a native session stores none).
assert "harness_override" not in snapshot.json()
assert created.json()["harness"] == snapshot.json()["harness"]
async def test_fixed_harness_create_does_not_pin_an_unrunnable_pick(
client: httpx.AsyncClient,
db_uri: str,
) -> None:
wrappers = await _native_wrappers(client, db_uri)
# A gpt pick for a Claude terminal: the harness is not negotiable here, so
# nothing is pinned and the card says so.
routing_client = FakeRoutingClient(RoutingResult(model=GPT_MODEL, rationale="narrow change"))
created = await _create_fixed_harness_session(
client, wrappers["claude-native"], routing_client
)
assert created.status_code == 201, created.text
session_id = created.json()["id"]
conv = SqlAlchemyConversationStore(db_uri).get_conversation(session_id)
assert conv is not None
assert conv.model_override is None
assert ROUTING_DECISION_LABEL_KEY not in conv.labels
decisions = _routing_decision_items(db_uri, session_id)
assert len(decisions) == 1
assert decisions[0]["applied"] is False
assert GPT_MODEL in decisions[0]["rationale"]
async def test_fixed_harness_create_fails_open_when_the_router_is_down(
client: httpx.AsyncClient,
db_uri: str,
) -> None:
wrappers = await _native_wrappers(client, db_uri)
created = await _create_fixed_harness_session(client, wrappers["codex-native"], None)
assert created.status_code == 201, created.text
session_id = created.json()["id"]
conv = SqlAlchemyConversationStore(db_uri).get_conversation(session_id)
assert conv is not None
# The session still opens a terminal, on the CLI's own default model.
assert conv.model_override is None
assert ROUTING_DECISION_LABEL_KEY not in conv.labels
decisions = _routing_decision_items(db_uri, session_id)
assert len(decisions) == 1
assert decisions[0]["applied"] is False
assert "not configured" in decisions[0]["rationale"]
@pytest.mark.parametrize(
"extra",
[
# Routing off: the create is a plain fixed-harness create.
{"cost_control_mode_override": "off"},
# No routing text — nothing to size the task with.
{"smart_routing_message": None},
{"smart_routing_message": " "},
# A client-pinned model wins over the router, as it does per turn.
{"model_override": CLAUDE_MODEL},
],
)
async def test_fixed_harness_create_routes_only_when_asked(
client: httpx.AsyncClient,
db_uri: str,
extra: dict[str, Any],
) -> None:
wrappers = await _native_wrappers(client, db_uri)
routing_client = FakeRoutingClient(RoutingResult(model=CLAUDE_MODEL, rationale="sized task"))
created = await _create_fixed_harness_session(
client, wrappers["claude-native"], routing_client, **extra
)
assert created.status_code == 201, created.text
assert routing_client.offered == []
conv = SqlAlchemyConversationStore(db_uri).get_conversation(created.json()["id"])
assert conv is not None
assert conv.model_override == extra.get("model_override")
assert _routing_decision_items(db_uri, created.json()["id"]) == []
async def test_sdk_harness_create_still_routes_on_its_first_turn(
client: httpx.AsyncClient,
db_uri: str,
) -> None:
# An SDK harness's first message reaches the server before inference, so the
# turn gate routes it; a create-time pin would only disable that gate.
agent = await create_test_agent(client, name="fixed-harness-sdk-agent")
routing_client = FakeRoutingClient(RoutingResult(model=CLAUDE_MODEL, rationale="sized task"))
created = await _create_fixed_harness_session(client, agent["id"], routing_client)
assert created.status_code == 201, created.text
assert routing_client.offered == []
conv = SqlAlchemyConversationStore(db_uri).get_conversation(created.json()["id"])
assert conv is not None
assert conv.model_override is None
assert _routing_decision_items(db_uri, created.json()["id"]) == []
async def test_child_session_create_is_not_routed_at_create_time(
client: httpx.AsyncClient,
db_uri: str,
) -> None:
wrappers = await _native_wrappers(client, db_uri)
parent = await client.post("/v1/sessions", json={"agent_id": wrappers["claude-native"]})
assert parent.status_code == 201, parent.text
routing_client = FakeRoutingClient(RoutingResult(model=CLAUDE_MODEL, rationale="sized task"))
created = await _create_fixed_harness_session(
client,
wrappers["claude-native"],
routing_client,
parent_session_id=parent.json()["id"],
)
assert created.status_code == 201, created.text
# A child is routed by the spawn / turn paths, which know the parent's
# family and its own routing state.
assert routing_client.offered == []
conv = SqlAlchemyConversationStore(db_uri).get_conversation(created.json()["id"])
assert conv is not None
assert conv.model_override is None
@pytest.mark.parametrize(
("picked_model", "expected_harness"),
[
(CLAUDE_MODEL, "claude-native"),
(GPT_MODEL, "codex-native"),
],
)
async def test_create_binds_the_wrapper_the_router_picked(
client: httpx.AsyncClient,
db_uri: str,
picked_model: str,
expected_harness: str,
) -> None:
wrappers = await _native_wrappers(client, db_uri)
routing_client = FakeRoutingClient(RoutingResult(model=picked_model, rationale="sized task"))
created = await _create_smart_routing_session(client, wrappers, routing_client)
assert created.status_code == 201, created.text
conv = SqlAlchemyConversationStore(db_uri).get_conversation(created.json()["id"])
assert conv is not None
assert conv.agent_id == wrappers[expected_harness]
# The wrapper's terminal launches with the routed model baked in.
assert conv.model_override == picked_model
# No sentinel survives: a native wrapper rejects harness_override, and a
# leftover "auto" would re-route an already-running terminal.
assert conv.harness_override is None
# The auto marker is what keeps subagents cross-harness-eligible.
assert conv.labels.get(AUTO_HARNESS_LABEL_KEY) == "1"
# What a client reads back to launch the TUI: the routed model plus the
# bound wrapper. The row keeps no ``harness_override`` on this path, so
# ``harness`` (resolved from the bound wrapper) is the harness field.
assert created.json()["model_override"] == picked_model
assert created.json()["agent_id"] == wrappers[expected_harness]
assert "harness_override" not in created.json()
async def test_router_is_offered_both_native_families(
client: httpx.AsyncClient,
db_uri: str,
) -> None:
wrappers = await _native_wrappers(client, db_uri)
routing_client = FakeRoutingClient(RoutingResult(model=GPT_MODEL, rationale="narrow change"))
created = await _create_smart_routing_session(client, wrappers, routing_client)
assert created.status_code == 201, created.text
assert set(routing_client.offered[0]) == {"claude-native", "codex-native"}
async def test_routed_wrapper_gets_terminal_first_labels(
client: httpx.AsyncClient,
db_uri: str,
) -> None:
wrappers = await _native_wrappers(client, db_uri)
routing_client = FakeRoutingClient(RoutingResult(model=GPT_MODEL, rationale="narrow change"))
created = await _create_smart_routing_session(client, wrappers, routing_client)
assert created.status_code == 201, created.text
conv = SqlAlchemyConversationStore(db_uri).get_conversation(created.json()["id"])
assert conv is not None
# The routed wrapper's own presentation labels, not the placeholder's.
assert conv.labels.get("omnigent.ui") == "terminal"
assert conv.labels.get("omnigent.wrapper") == "codex-native-ui"
async def test_create_falls_back_to_a_native_cli_when_routing_is_unavailable(
client: httpx.AsyncClient,
db_uri: str,
) -> None:
wrappers = await _native_wrappers(client, db_uri)
created = await _create_smart_routing_session(client, wrappers, None)
assert created.status_code == 201, created.text
conv = SqlAlchemyConversationStore(db_uri).get_conversation(created.json()["id"])
assert conv is not None
# Still lands on a terminal, with the CLI's own default model.
assert conv.agent_id == wrappers["claude-native"]
assert conv.model_override is None
assert conv.harness_override is None
assert conv.labels.get(AUTO_HARNESS_LABEL_KEY) == "1"
async def test_smart_routing_session_keeps_cross_harness_subagents(
client: httpx.AsyncClient,
db_uri: str,
) -> None:
wrappers = await _native_wrappers(client, db_uri)
routing_client = FakeRoutingClient(RoutingResult(model=CLAUDE_MODEL, rationale="sized task"))
created = await _create_smart_routing_session(client, wrappers, routing_client)
assert created.status_code == 201, created.text
session_id = created.json()["id"]
spawn_router = FakeRoutingClient(
RoutingResult(model=GPT_MODEL, rationale="narrow change", harness="codex")
)
with patch("omnigent.runtime._globals._caps", new=FakeCaps(routing_client=spawn_router)):
resp = await client.post(
f"/v1/sessions/{session_id}/hooks/route-subagent",
json=SPAWN_PAYLOAD,
)
assert resp.status_code == 200, resp.text
# The auto label survives the create, so the spawn is offered both families
# and may leave the session's own harness family.
assert set(spawn_router.offered[0]) == {"claude-native", "codex-native"}
assert resp.json()["harness"] == "codex-native"
async def test_bundle_agent_auto_path_is_unchanged(
client: httpx.AsyncClient,
db_uri: str,
) -> None:
agent = await create_test_agent(client, name="smart-routing-bundle-agent")
routing_client = FakeRoutingClient(RoutingResult(model=CLAUDE_MODEL, rationale="sized task"))
with patch("omnigent.runtime._globals._caps", new=FakeCaps(routing_client=routing_client)):
created = await client.post(
"/v1/sessions",
json={
"agent_id": agent["id"],
"harness_override": "auto",
"cost_control_mode_override": "on",
"smart_routing_message": ROUTING_MESSAGE,
},
)
assert created.status_code == 201, created.text
conv = SqlAlchemyConversationStore(db_uri).get_conversation(created.json()["id"])
assert conv is not None
# A non-native agent keeps the sentinel for first-message resolution, and
# the create must not have called the router.
assert conv.harness_override == "auto"
assert conv.model_override is None
assert conv.agent_id == agent["id"]
assert conv.labels.get(AUTO_HARNESS_LABEL_KEY) == "1"
assert routing_client.offered == []
async def test_native_candidates_impose_no_family_constraint() -> None:
# The candidate override and the parent-family filter are orthogonal: an
# auto session passes no allowed_family, so both native families reach the
# router even though the family filter is applied to the same tuple.
routing_client = FakeRoutingClient(RoutingResult(model=GPT_MODEL, rationale="narrow change"))
with patch("omnigent.runtime._globals._caps", new=FakeCaps(routing_client=routing_client)):
harness, model, _verdict, error = await route_session_harness(
ROUTING_MESSAGE,
harness_candidates=AUTO_NATIVE_ROUTING_HARNESSES,
)
assert error is None
assert set(routing_client.offered[0]) == {"claude-native", "codex-native"}
assert harness == "codex-native"
assert model == GPT_MODEL
async def test_native_candidates_still_honor_an_explicit_family() -> None:
# A family constraint (a child of a pinned parent) narrows the same tuple,
# so the two knobs compose rather than fight.
routing_client = FakeRoutingClient(
RoutingResult(model=CLAUDE_MODEL, rationale="deep reasoning")
)
with patch("omnigent.runtime._globals._caps", new=FakeCaps(routing_client=routing_client)):
harness, _model, _verdict, error = await route_session_harness(
ROUTING_MESSAGE,
harness_candidates=AUTO_NATIVE_ROUTING_HARNESSES,
allowed_family="claude",
)
assert error is None
assert set(routing_client.offered[0]) == {"claude-native"}
assert harness == "claude-native"
async def test_no_installed_native_candidates_reports_the_standard_error() -> None:
routing_client = FakeRoutingClient(RoutingResult(model=GPT_MODEL, rationale="narrow change"))
with patch("omnigent.runtime._globals._caps", new=FakeCaps(routing_client=routing_client)):
harness, model, verdict, error = await route_session_harness(
ROUTING_MESSAGE,
harness_candidates=(),
)
assert (harness, model, verdict) == (None, None, None)
assert error == "No routable harnesses are available on this runner."
assert routing_client.offered == []
def _host(readiness: dict[str, Any] | None) -> Host: # type: ignore[explicit-any]
return Host(
host_id="host_1",
name="dev",
user_id="alice@example.com",
status="online",
created_at=0,
updated_at=0,
configured_harnesses=readiness,
)
@pytest.mark.parametrize(
("host", "expected"),
[
(_host({"claude-native": True, "codex-native": True}), ["claude-native", "codex-native"]),
(_host({"claude-native": True, "codex-native": "binary-missing"}), ["claude-native"]),
(_host({"claude-native": "needs-auth", "codex-native": True}), ["codex-native"]),
(_host({"claude-native": "version-too-low", "codex-native": False}), []),
# An unreported harness can't be assumed installed.
(_host({"claude-native": True}), ["claude-native"]),
# Fails open: a host with no readiness map doesn't disable routing.
(_host(None), ["claude-native", "codex-native"]),
# No host at all fails open the same way.
(None, ["claude-native", "codex-native"]),
],
)
def test_installed_native_harnesses_follows_host_readiness(
host: Host | None, expected: list[str]
) -> None:
assert _installed_native_harnesses(host) == expected
# ── Pre-session candidate catalog ───────────────────────────────────────────
#
# A create routes before any session (and so any runner) exists, so the live
# per-session catalog is out of reach. The host answers instead, and the static
# table only tops up what it could not answer for. Either way the router's
# current arms must be servable candidates — an arm missing from the candidate
# set is substituted down a generation (a routed gpt-5-6-sol applying 5-5).
SOL = "databricks-gpt-5-6-sol"
LUNA = "databricks-gpt-5-6-luna"
HOST_GPT_CATALOG = [LUNA, SOL]
@pytest.mark.parametrize(
("verdict_model", "harness_candidates", "catalog", "expected_offer", "expected_pick"),
[
# The host's catalog is offered verbatim instead of the static table,
# and a servable pick applies exactly (no substitution, no raw pick).
(
SOL,
("codex-native",),
{"codex-native": HOST_GPT_CATALOG},
{"codex-native": HOST_GPT_CATALOG},
("codex-native", SOL),
),
# Out-of-family rows in the host's answer are filtered out before the
# offer, so the router can never pick an unspawnable model.
(
SOL,
("codex-native",),
{"codex-native": [*HOST_GPT_CATALOG, CLAUDE_MODEL]},
{"codex-native": HOST_GPT_CATALOG},
("codex-native", SOL),
),
# Hosts only resolve a pre-launch catalog for the CLIs that can report
# one without running; the static table tops up the rest.
(
SOL,
AUTO_NATIVE_ROUTING_HARNESSES,
{"codex-native": HOST_GPT_CATALOG},
{
"codex-native": HOST_GPT_CATALOG,
"claude-native": None, # filled from infer_models below
},
("codex-native", SOL),
),
# No host answer at all: the static table is the whole offer.
(
GPT_MODEL,
AUTO_NATIVE_ROUTING_HARNESSES,
{},
{"claude-native": None, "codex-native": None},
("codex-native", GPT_MODEL),
),
],
)
async def test_pre_session_catalog_is_offered_instead_of_the_static_table(
verdict_model: str,
harness_candidates: tuple[str, ...],
catalog: dict[str, list[str]],
expected_offer: dict[str, list[str] | None],
expected_pick: tuple[str, str],
) -> None:
routing_client = FakeRoutingClient(
RoutingResult(model=verdict_model, rationale="deep reasoning")
)
with patch("omnigent.runtime._globals._caps", new=FakeCaps(routing_client=routing_client)):
harness, model, verdict, error = await route_session_harness(
ROUTING_MESSAGE,
harness_candidates=harness_candidates,
catalog=catalog,
)
assert error is None
# ``None`` in the expectation means "whatever the static table serves".
want = {
name: rows if rows is not None else infer_models(name)
for name, rows in expected_offer.items()
}
assert routing_client.offered[0] == want
assert (harness, model) == expected_pick
# A servable pick applies exactly, so the card shows no divergent raw pick.
assert verdict is not None
assert "raw_model" not in verdict
def test_static_candidates_serve_the_routers_current_codex_arms() -> None:
# The last-resort table must still cover the arms task_v1 picks from, or the
# seam substitutes them down a generation.
codex_models = infer_models("codex-native")
assert codex_models is not None
source = TaskV1RouteOptionSource(model_prefixes=["databricks-"])
for arm in ("gpt-5-6-sol", "gpt-5-6-luna"):
resolved = source.resolve_selection(
RoutePick(model=arm),
["codex-native"],
{"codex-native": list(codex_models)},
)
assert resolved is not None
# Applied exactly: the same model the router named, prefixed for this
# workspace's catalog vocabulary.
assert resolved.model == f"databricks-{arm}"
assert resolved.raw_model == arm
@pytest.mark.parametrize(
("has_registry", "answers", "expected"),
[
# The row's launchable ``model`` id, not its picker key; a harness the
# host cannot answer for is simply absent.
(
True,
{"claude-native": {"models": [{"id": "opus", "model": "databricks-claude-opus-4-8"}]}},
{"claude-native": ["databricks-claude-opus-4-8"]},
),
# ``routable_models`` widens the catalog past the picker rows (which
# name the newest of each family only), so a frozen arm the workspace
# still serves stays routable.
(
True,
{
"claude-native": {
"models": [{"id": "opus", "model": "databricks-claude-opus-5"}],
"routable_models": [
"databricks-claude-opus-5",
"databricks-claude-opus-4-8",
],
}
},
{
"claude-native": [
"databricks-claude-opus-5",
"databricks-claude-opus-4-8",
]
},
),
# No live host: nothing to ask, so no catalog.
(False, {}, {}),
],
)
async def test_pre_session_catalog_reads_the_hosts_model_options(
has_registry: bool,
answers: dict[str, dict[str, Any]], # type: ignore[explicit-any]
expected: dict[str, list[str]],
) -> None:
from omnigent.host.frames import decode_host_frame
conn = SimpleNamespace(host_id="host_1", pending_model_options={})
def send_text(host_conn: Any, frame: str) -> None: # type: ignore[explicit-any]
decoded = decode_host_frame(frame)
answer = answers.get(decoded.harness)
future = host_conn.pending_model_options[decoded.request_id]
future.set_result(
{"status": "ok", **answer}
if answer is not None
else {"status": "failed", "error": "unsupported"}
)
registry = (
SimpleNamespace(get=lambda host_id: conn, send_text=send_text) if has_registry else None
)
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(host_registry=registry)))
catalog = await _pre_session_model_catalog(
cast("Any", request),
_host(None),
AUTO_NATIVE_ROUTING_HARNESSES,
)
assert catalog == expected
# No in-flight request is left behind on any path.
assert conn.pending_model_options == {}
def _native_conv(session_id: str) -> Any: # type: ignore[explicit-any]
from omnigent.harness_plugins import CLAUDE_NATIVE_CODING_AGENT
return SimpleNamespace(
id=session_id,
labels={"omnigent.wrapper": CLAUDE_NATIVE_CODING_AGENT.wrapper_label},
)
async def test_turn_catalog_and_verdict_follow_the_panes_vocabulary() -> None:
"""The picker rows bound both what a turn may pick and what it may claim."""
from omnigent.server.routes._sessions.orchestration import (
_model_options_cache,
_native_turn_catalog,
_routed_turn_model_spelling,
_unapplied_routed_verdict,
)
conv = _native_conv("conv_vocab")
_model_options_cache["conv_vocab"] = [
{"id": "opus", "model": "databricks-claude-opus-5"},
{"id": "sonnet_5", "model": "databricks-claude-opus-4-8"},
]
try:
assert await _native_turn_catalog("conv_vocab", conv) == [
"databricks-claude-opus-5",
"databricks-claude-opus-4-8",
]
# An applicable pick answers with the pane's own spelling for it.
assert (
_routed_turn_model_spelling("conv_vocab", conv, "databricks-claude-opus-4-8")
== "databricks-claude-opus-4-8"
)
assert (
_routed_turn_model_spelling("conv_vocab", conv, "databricks-claude-opus-5") == "opus"
)
assert (
_routed_turn_model_spelling("conv_vocab", conv, "databricks-claude-sonnet-5") is None
)
verdict = {"rationale": "deep refactor", "applied": True}
downgraded = _unapplied_routed_verdict("databricks-claude-sonnet-5", verdict)
assert downgraded["applied"] is False
assert "Not applied" in downgraded["rationale"]
# The caller's verdict is never mutated in place.
assert verdict == {"rationale": "deep refactor", "applied": True}
finally:
_model_options_cache.pop("conv_vocab", None)
# A picker row with no ``model`` still names its vocabulary through ``id``.
_model_options_cache["conv_rowkey"] = [{"id": "opus"}]
try:
assert await _native_turn_catalog("conv_rowkey", _native_conv("conv_rowkey")) == ["opus"]
finally:
_model_options_cache.pop("conv_rowkey", None)
# Not a claude-native session, and a native one with no rows cached: both
# leave the caller's own resolution and claim untouched.
plain = SimpleNamespace(id="conv_plain", labels={})
assert await _native_turn_catalog("conv_plain", plain) is None
assert await _native_turn_catalog("conv_vocab", conv) is None
assert (
_routed_turn_model_spelling("conv_vocab", conv, "databricks-gpt-5-5")
== "databricks-gpt-5-5"
)
async def test_turn_catalog_refetches_a_stale_pre_launch_catalog() -> None:
"""A pre-launch host catalog never bounds a turn once a runner is bound."""
from omnigent.server.routes._sessions.orchestration import (
_model_options_cache,
_model_options_stale,
_native_turn_catalog,
_routed_turn_model_spelling,
)
session_id = "conv_stale"
conv = _native_conv(session_id)
# Hydrated from the host BEFORE launch: the opus alias carries the
# workspace default, not this session's routed pin.
_model_options_cache[session_id] = [{"id": "opus", "model": "databricks-claude-opus-5"}]
_model_options_stale.add(session_id)
requested: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
requested.append(request.url.path)
# What the runner reports post-launch: the launch config pinned the
# routed arm onto the opus alias.
return httpx.Response(
200, json={"models": [{"id": "opus", "model": "databricks-claude-opus-4-8"}]}
)
runner_client = httpx.AsyncClient(
transport=httpx.MockTransport(handler), base_url="http://runner.invalid"
)
try:
assert await _native_turn_catalog(session_id, conv, runner_client) == [
"databricks-claude-opus-4-8"
]
assert requested == [f"/v1/sessions/{session_id}/claude-model-options"]
assert session_id not in _model_options_stale
# The same refreshed vocabulary now spells the pick for the apply.
assert (
_routed_turn_model_spelling(session_id, conv, "databricks-claude-opus-4-8") == "opus"
)
# A fresh entry is served without a second fetch.
assert await _native_turn_catalog(session_id, conv, runner_client) == [
"databricks-claude-opus-4-8"
]
assert len(requested) == 1
finally:
await runner_client.aclose()
_model_options_cache.pop(session_id, None)
_model_options_stale.discard(session_id)
async def test_turn_catalog_keeps_a_stale_catalog_when_the_refetch_fails() -> None:
"""A stale vocabulary still bounds the turn when the runner cannot answer."""
from omnigent.server.routes._sessions.orchestration import (
_model_options_cache,
_model_options_stale,
_native_turn_catalog,
)
session_id = "conv_stale_fail"
conv = _native_conv(session_id)
_model_options_cache[session_id] = [{"id": "opus", "model": "databricks-claude-opus-5"}]
_model_options_stale.add(session_id)
async def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("runner gone")
runner_client = httpx.AsyncClient(
transport=httpx.MockTransport(handler), base_url="http://runner.invalid"
)
try:
assert await _native_turn_catalog(session_id, conv, runner_client) == [
"databricks-claude-opus-5"
]
# No runner bound at all: the cache is served untouched.
assert await _native_turn_catalog(session_id, conv, None) == ["databricks-claude-opus-5"]
finally:
await runner_client.aclose()
_model_options_cache.pop(session_id, None)
_model_options_stale.discard(session_id)
async def test_routing_authorizes_host_ownership_before_touching_the_host() -> None:
"""A foreign ``host_id`` is rejected before any host read or frame push."""
from fastapi import HTTPException
from omnigent.server.routes._sessions.orchestration import _resolve_native_smart_routing
sent: list[str] = []
conn = SimpleNamespace(host_id="host_1", pending_model_options={})
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
host_store=SimpleNamespace(get_host=lambda host_id: _host(None)),
host_registry=SimpleNamespace(
get=lambda host_id: conn,
send_text=lambda host_conn, frame: sent.append(frame),
),
)
)
)
body = SimpleNamespace(
host_id="host_1",
smart_routing_message=ROUTING_MESSAGE,
)
with pytest.raises(HTTPException) as excinfo:
await _resolve_native_smart_routing(
cast("Any", body),
cast("Any", request),
"mallory@example.com",
)
assert excinfo.value.status_code == 403
# Nothing was pushed into the owner's live host connection.
assert sent == []
assert conn.pending_model_options == {}
File diff suppressed because it is too large Load Diff
+711
View File
@@ -0,0 +1,711 @@
"""Tests for the native-subagent routing endpoint and policy."""
from __future__ import annotations
import asyncio
import json
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import pytest
from omnigent.entities.conversation import RoutingDecisionData
from omnigent.inner.hook_scripts.subagent_router import read_router_endpoint
from omnigent.runner.subagent_routing import (
ADVERTISEMENT_FILE,
SubagentRouteDecision,
SubagentRouteRequest,
candidate_models,
decision_record,
ensure_session_router,
ensure_session_router_quietly,
harness_family,
make_server_relay_resolver,
model_in_family,
persist_subagent_decision,
relayed_decisions,
resolve_subagent_route,
routed_models,
router_dir_for_session,
routing_enabled,
session_router_env,
shutdown_session_router,
start_subagent_router,
subagent_routing_enabled,
write_advertisement,
)
from omnigent.server.smart_routing import RoutingResult, RoutingSettings
from tests.server.helpers import FakeCaps, FakeRoutingClient
CLAUDE_MODEL = "databricks-claude-opus-4-8"
GPT_MODEL = "databricks-gpt-5-5"
GLM_MODEL = "databricks-glm-5-2"
# The spelling the gateway actually serves GLM under; see ``_SERVABLE_ALIASES``.
GLM_SERVABLE = "system.ai.glm-5-2"
KIMI_MODEL = "databricks-kimi-k2-6"
PARENT_MODEL = "databricks-claude-sonnet-4-6"
# ── Stubs ───────────────────────────────────────────────────────────
@dataclass
class _PersistedItem:
id: str
@dataclass
class _FakeStore:
appended: list[Any] = field(default_factory=list) # type: ignore[explicit-any]
def append(self, session_id: str, items: list[Any]) -> list[_PersistedItem]: # type: ignore[explicit-any]
del session_id
self.appended.extend(items)
return [_PersistedItem(id=f"item_{len(self.appended)}")]
def _request(**overrides: Any) -> SubagentRouteRequest:
kwargs: dict[str, Any] = {
"harness": "claude-native",
"task_name": "code-reviewer",
"prompt": "review the diff",
"parent_model": PARENT_MODEL,
}
kwargs.update(overrides)
return SubagentRouteRequest(**kwargs)
# ── Candidate set ───────────────────────────────────────────────────
def test_candidate_models_stays_in_family_by_default() -> None:
candidates = candidate_models("claude-native")
assert set(candidates) == {"claude-native"}
assert CLAUDE_MODEL in candidates["claude-native"]
def test_candidate_models_prefers_the_live_catalog() -> None:
"""A model the workspace serves today must not look unservable."""
catalog = {"self": ["databricks-claude-sonnet-5", "databricks-claude-opus-4-8"]}
candidates = candidate_models("claude-native", catalog=catalog)
assert candidates == {
"claude-native": ["databricks-claude-sonnet-5", "databricks-claude-opus-4-8"]
}
def test_candidate_models_falls_back_to_the_static_table_per_harness() -> None:
catalog = {"self": ["databricks-claude-sonnet-5"]}
candidates = candidate_models("claude-native", cross_harness=True, catalog=catalog)
assert candidates["claude-native"] == ["databricks-claude-sonnet-5"]
# No codex row in the catalog — the static table fills that harness in.
assert GPT_MODEL in candidates["codex-native"]
def test_candidate_models_ignores_an_empty_catalog() -> None:
candidates = candidate_models("claude-native", catalog={})
assert CLAUDE_MODEL in candidates["claude-native"]
def test_candidate_models_applies_the_family_constraint_to_catalog_rows() -> None:
"""A codex ``"self"`` row keeps GLM/Kimi and loses the Claude ids.
The gateway behind a codex session serves Claude ids too, so the row
carries models codex cannot speak; offering one earns a hard
``model_family_mismatch`` at dispatch. GLM and Kimi serve on the
Responses wire codex does speak, so they must survive.
"""
catalog = {"self": [GPT_MODEL, GLM_MODEL, KIMI_MODEL, CLAUDE_MODEL]}
candidates = candidate_models("codex-native", catalog=catalog)
assert candidates == {"codex-native": [GPT_MODEL, GLM_SERVABLE, KIMI_MODEL]}
assert model_in_family(harness_family("codex-native"), GLM_MODEL) is True
assert model_in_family(harness_family("claude-native"), GLM_MODEL) is False
def test_candidate_models_offers_glm_under_its_servable_alias() -> None:
"""Both catalog spellings collapse to the one the gateway serves.
The offered id is what a rewrite spawns with, so it has to match the id
routing resolves the ``glm-5-2`` arm to.
"""
catalog = {"self": [GPT_MODEL, GLM_MODEL, GLM_SERVABLE]}
assert candidate_models("codex-native", catalog=catalog) == {
"codex-native": [GPT_MODEL, GLM_SERVABLE]
}
def test_candidate_models_drops_a_harness_with_nothing_servable() -> None:
catalog = {"self": [CLAUDE_MODEL]}
assert candidate_models("codex-native", catalog=catalog) == {}
def test_candidate_models_offers_both_families_for_auto_sessions() -> None:
candidates = candidate_models("claude-native", cross_harness=True)
assert set(candidates) == {"claude-native", "codex-native"}
assert CLAUDE_MODEL in candidates["claude-native"]
assert GPT_MODEL in candidates["codex-native"]
# ── Policy ──────────────────────────────────────────────────────────
async def test_same_family_pick_rewrites() -> None:
client = FakeRoutingClient(
RoutingResult(model=CLAUDE_MODEL, rationale="deep reasoning", harness="claude-sdk")
)
decision = await resolve_subagent_route(
"conv_1", _request(), caps=FakeCaps(routing_client=client)
)
assert decision.action == "rewrite"
assert decision.model == CLAUDE_MODEL
assert decision.harness is None
assert decision.raw_model is None
assert decision.rationale == "deep reasoning"
assert len(decision.decision_id) == 36
async def test_raw_model_is_omitted_when_it_matches_the_resolved_model() -> None:
client = FakeRoutingClient(
RoutingResult(model=CLAUDE_MODEL, rationale="r", raw_model=CLAUDE_MODEL)
)
decision = await resolve_subagent_route(
"conv_1", _request(), caps=FakeCaps(routing_client=client)
)
assert decision.model == CLAUDE_MODEL
assert decision.raw_model is None
async def test_raw_model_is_preserved_when_the_router_named_another_arm() -> None:
client = FakeRoutingClient(
RoutingResult(model=CLAUDE_MODEL, rationale="r", raw_model="claude-opus-4-8-thinking")
)
decision = await resolve_subagent_route(
"conv_1", _request(), caps=FakeCaps(routing_client=client)
)
assert decision.model == CLAUDE_MODEL
assert decision.raw_model == "claude-opus-4-8-thinking"
assert decision_record(_request(), decision).raw_model == "claude-opus-4-8-thinking"
async def test_live_catalog_pick_is_applied_exactly() -> None:
"""A live-catalog model is offered and applied verbatim — no substitution."""
client = FakeRoutingClient(
RoutingResult(model="databricks-claude-sonnet-5", rationale="r", harness="claude-sdk")
)
decision = await resolve_subagent_route(
"conv_1",
_request(),
caps=FakeCaps(routing_client=client),
catalog={"self": ["databricks-claude-sonnet-5", "databricks-claude-opus-4-8"]},
)
assert client.calls[0][1] == {
"claude-native": ["databricks-claude-sonnet-5", "databricks-claude-opus-4-8"]
}
assert decision.action == "rewrite"
assert decision.model == "databricks-claude-sonnet-5"
async def test_cross_family_pick_redirects_to_counterpart_harness() -> None:
client = FakeRoutingClient(
RoutingResult(model=GPT_MODEL, rationale="narrow change", harness="codex")
)
decision = await resolve_subagent_route(
"conv_1", _request(), caps=FakeCaps(routing_client=client), cross_harness=True
)
assert decision.action == "redirect"
assert decision.model == GPT_MODEL
assert decision.harness == "codex-native"
async def test_in_family_session_only_offers_its_own_harness() -> None:
client = FakeRoutingClient(
RoutingResult(model=GPT_MODEL, rationale="narrow change", harness="codex")
)
decision = await resolve_subagent_route(
"conv_1", _request(), caps=FakeCaps(routing_client=client)
)
# Only Claude arms were offered, so a Codex pick is unrunnable — a
# constrained session can never redirect.
assert set(client.calls[0][1]) == {"claude-native"}
assert decision.action == "deny"
async def test_pick_outside_candidate_set_denies() -> None:
client = FakeRoutingClient(
RoutingResult(model="databricks-kimi-k2", rationale="unavailable", harness="claude-sdk")
)
decision = await resolve_subagent_route(
"conv_1", _request(), caps=FakeCaps(routing_client=client)
)
assert decision.action == "deny"
assert decision.model is None
assert "cannot run" in decision.rationale
# The deny message still names the pick it rejected.
assert decision.raw_model == "databricks-kimi-k2"
async def test_parent_model_pick_allows_unchanged() -> None:
client = FakeRoutingClient(
RoutingResult(model=PARENT_MODEL, rationale="parent model fits", harness="claude-sdk")
)
decision = await resolve_subagent_route(
"conv_1", _request(), caps=FakeCaps(routing_client=client)
)
assert decision.action == "allow"
assert decision.model == PARENT_MODEL
async def test_fork_is_exempt_and_never_calls_router() -> None:
client = FakeRoutingClient(RoutingResult(model=CLAUDE_MODEL, rationale="x"))
decision = await resolve_subagent_route(
"conv_1", _request(fork=True), caps=FakeCaps(routing_client=client)
)
assert decision.action == "allow"
assert decision.model == PARENT_MODEL
assert client.calls == []
async def test_no_routable_signal_allows_the_spawn_unchanged() -> None:
"""A spawn with no prompt and no task name is not routed at all.
Codex encrypts the spawn message, so an unnamed subagent carries nothing
to score; calling the router with an empty task earns an HTTP 400 that
reads on the chip as an outage.
"""
client = FakeRoutingClient(RoutingResult(model=CLAUDE_MODEL, rationale="cheap arm fits"))
decision = await resolve_subagent_route(
"conv_1",
_request(prompt=None, task_name=""),
caps=FakeCaps(routing_client=client),
)
assert client.calls == []
assert decision.action == "allow"
assert decision.model == PARENT_MODEL
assert "No routable signal" in decision.rationale
@pytest.mark.parametrize(
("client", "reason_fragment"),
[
# A router outage allows the spawn unchanged: the gate is advisory.
(FakeRoutingClient(error=RuntimeError("router down")), "Routing unavailable"),
# A no-verdict (not an outage) carries the router's own reason so the
# user sees why nothing was routed.
(FakeRoutingClient(None, last_error="menu mismatch"), "menu mismatch"),
# No routing client configured at all is the same advisory outcome.
(None, "no routing client configured"),
],
)
async def test_router_failure_allows_the_spawn_unchanged(
client: FakeRoutingClient | None,
reason_fragment: str,
) -> None:
caps = FakeCaps(routing_client=client, routing_settings=RoutingSettings())
decision = await resolve_subagent_route("conv_1", _request(), caps=caps)
assert decision.action == "allow"
assert reason_fragment in decision.rationale
async def test_identical_spawns_are_each_routed() -> None:
"""Decisions are not cached: a decision_id is an identity, not a key."""
client = FakeRoutingClient(
RoutingResult(model=CLAUDE_MODEL, rationale="deep reasoning", harness="claude-sdk")
)
caps = FakeCaps(routing_client=client)
first = await resolve_subagent_route("conv_1", _request(), caps=caps)
second = await resolve_subagent_route("conv_1", _request(), caps=caps)
assert len(client.calls) == 2
assert second.decision_id != first.decision_id
async def test_task_name_is_capped_at_parse_time() -> None:
"""``task_name`` is agent-authored, so it is bounded before it is stored."""
req = SubagentRouteRequest.from_payload({"harness": "codex", "task_name": "x" * 5000})
assert len(req.task_name) == 200
# ── Decision persistence ────────────────────────────────────────────
async def test_every_decision_is_persisted_with_native_subagent_scope() -> None:
client = FakeRoutingClient(
RoutingResult(model=CLAUDE_MODEL, rationale="deep reasoning", harness="claude-sdk")
)
records: list[RoutingDecisionData] = []
async def _persist(record: RoutingDecisionData) -> None:
records.append(record)
decision = await resolve_subagent_route(
"conv_1",
_request(),
caps=FakeCaps(routing_client=client),
persist=_persist,
)
assert len(records) == 1
data = records[0].model_dump()
assert data["scope"] == "native_subagent"
assert data["decision_id"] == decision.decision_id
# The router named the model it resolved to, so there is no raw pick to
# report separately.
assert data["raw_model"] is None
assert data["model"] == CLAUDE_MODEL
assert data["harness"] == "claude-native"
assert data["applied"] is True
assert data["rationale"] == "deep reasoning"
async def test_deny_is_persisted_unapplied() -> None:
"""An unoffered pick is the only deny left, and it persists unapplied."""
client = FakeRoutingClient(
RoutingResult(model="databricks-kimi-k2", rationale="unavailable", harness="claude-sdk")
)
records: list[RoutingDecisionData] = []
async def _persist(record: RoutingDecisionData) -> None:
records.append(record)
caps = FakeCaps(routing_client=client)
await resolve_subagent_route("conv_1", _request(), caps=caps, persist=_persist)
assert records[0].applied is False
assert records[0].model == PARENT_MODEL
async def test_persist_appends_routing_decision_item() -> None:
store = _FakeStore()
record = RoutingDecisionData(
model=CLAUDE_MODEL,
applied=True,
rationale="deep reasoning",
decision_id="dec_1",
harness="claude-native",
raw_model="claude-opus-4-8",
scope="native_subagent",
)
await persist_subagent_decision("conv_1", store, record)
assert len(store.appended) == 1
item = store.appended[0]
assert item.type == "routing_decision"
assert item.data.model == CLAUDE_MODEL
assert item.data.rationale == "deep reasoning"
# The additive fields ride along once the item model carries them.
if hasattr(item.data, "scope"):
assert item.data.scope == "native_subagent"
assert item.data.decision_id == "dec_1"
assert item.data.raw_model == "claude-opus-4-8"
assert item.data.harness == "claude-native"
async def test_persist_failure_does_not_break_the_decision() -> None:
class _BoomStore:
def append(self, session_id: str, items: list[Any]) -> list[_PersistedItem]:
raise RuntimeError("db down")
client = FakeRoutingClient(
RoutingResult(model=CLAUDE_MODEL, rationale="deep reasoning", harness="claude-sdk")
)
async def _persist(record: RoutingDecisionData) -> None:
await persist_subagent_decision("conv_1", _BoomStore(), record)
decision = await resolve_subagent_route(
"conv_1",
_request(),
caps=FakeCaps(routing_client=client),
persist=_persist,
)
assert decision.action == "rewrite"
# ── Advertisement + loopback endpoint ───────────────────────────────
def test_advertisement_roundtrip(tmp_path: Path) -> None:
path = write_advertisement(tmp_path, url="http://127.0.0.1:1234", token="tok")
assert path.name == ADVERTISEMENT_FILE
endpoint = read_router_endpoint(tmp_path)
assert endpoint is not None
assert (endpoint.url, endpoint.token) == ("http://127.0.0.1:1234", "tok")
def _post(url: str, body: dict[str, Any], token: str | None) -> tuple[int, dict[str, Any]]:
req = urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
if token is not None:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read() or b"{}")
async def test_loopback_endpoint_serves_decisions_and_checks_token(tmp_path: Path) -> None:
canned = SubagentRouteDecision(
action="rewrite", rationale="deep reasoning", model=CLAUDE_MODEL, raw_model=CLAUDE_MODEL
)
seen: list[SubagentRouteRequest] = []
async def _resolver(session_id: str, req: SubagentRouteRequest) -> SubagentRouteDecision:
del session_id
seen.append(req)
return canned
router = start_subagent_router(
bridge_dir=tmp_path,
session_id="conv_1",
resolver=_resolver,
loop=asyncio.get_running_loop(),
)
try:
advertised = read_router_endpoint(tmp_path)
assert advertised is not None
url = f"{advertised.url}/v1/sessions/conv_1/route-subagent"
body = {
"harness": "claude-native",
"task_name": "code-reviewer",
"prompt": "review the diff",
"fork": False,
"parent_model": PARENT_MODEL,
}
status, payload = await asyncio.to_thread(_post, url, body, advertised.token)
assert status == 200
assert payload == canned.to_payload()
assert seen[0].task_name == "code-reviewer"
status, _ = await asyncio.to_thread(_post, url, body, "wrong-token")
assert status == 401
status, _ = await asyncio.to_thread(_post, url, body, None)
assert status == 401
assert len(seen) == 1
status, _ = await asyncio.to_thread(
_post,
f"{advertised.url}/v1/sessions/other/route-subagent",
body,
advertised.token,
)
assert status == 404
status, _ = await asyncio.to_thread(_post, url, {"task_name": "x"}, advertised.token)
assert status == 400
finally:
router.close()
assert not (tmp_path / ADVERTISEMENT_FILE).exists()
async def test_server_relay_resolver_forwards_and_parses() -> None:
posted: list[tuple[str, dict[str, Any]]] = []
class _Resp:
status_code = 200
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, Any]:
return {
"action": "redirect",
"model": GPT_MODEL,
"harness": "codex-native",
"raw_model": "gpt-5-6-sol",
"rationale": "narrow change",
"decision_id": "dec_9",
}
class _Client:
async def post(self, path: str, *, json: dict[str, Any], timeout: float) -> _Resp:
del timeout
posted.append((path, json))
return _Resp()
resolver = make_server_relay_resolver(_Client())
decision = await resolver("conv_1", _request())
assert posted[0][0] == "/v1/sessions/conv_1/hooks/route-subagent"
assert posted[0][1]["harness"] == "claude-native"
assert decision.action == "redirect"
assert decision.harness == "codex-native"
assert decision.decision_id == "dec_9"
async def test_server_relay_resolver_allows_on_hop_failure() -> None:
class _DeadClient:
async def post(self, path: str, *, json: dict[str, Any], timeout: float) -> Any:
raise RuntimeError("connection refused")
decision = await make_server_relay_resolver(_DeadClient())("conv_1", _request())
assert decision.action == "allow"
assert "unreachable" in decision.rationale
async def test_loopback_endpoint_allows_when_the_resolver_errors(tmp_path: Path) -> None:
async def _resolver(session_id: str, req: SubagentRouteRequest) -> SubagentRouteDecision:
raise RuntimeError("boom")
router = start_subagent_router(
bridge_dir=tmp_path,
session_id="conv_1",
resolver=_resolver,
loop=asyncio.get_running_loop(),
)
try:
advertised = read_router_endpoint(tmp_path)
assert advertised is not None
status, payload = await asyncio.to_thread(
_post,
f"{advertised.url}/v1/sessions/conv_1/route-subagent",
{"harness": "claude-native"},
advertised.token,
)
assert status == 200
assert payload["action"] == "allow"
finally:
router.close()
# ── Enablement gate + session router lifecycle (P7) ─────────────────
@pytest.mark.parametrize(
("mode", "parent_mode", "expected"),
[
("on", None, True),
("off", None, False),
(None, None, False),
# An unset session inherits the parent's mode.
(None, "on", True),
],
)
def test_routing_enabled_reads_the_session_toggle(
mode: str | None, parent_mode: str | None, expected: bool
) -> None:
assert routing_enabled(mode, parent_cost_control_mode=parent_mode) is expected
@pytest.mark.parametrize(
("routing_client", "expected"),
[
# Toggle on but nothing to route with: still off.
(None, False),
(object(), True),
],
)
def test_routing_enabled_requires_a_client_when_caps_are_given(
routing_client: object | None, expected: bool
) -> None:
assert routing_enabled("on", caps=FakeCaps(routing_client=routing_client)) is expected
@pytest.mark.parametrize(
("override", "cost_control_mode", "parent_mode", "expected"),
[
# No override: subagent routing follows the session's routing state,
# falling back to the parent's when the session's is unset.
(None, "on", None, True),
(None, "off", None, False),
(None, None, None, False),
(None, None, "on", True),
# An explicit override wins over whatever was inherited.
("off", "on", None, False),
("on", None, None, True),
("off", None, "on", False),
],
)
def test_subagent_routing_enabled_override_beats_the_inherited_state(
override: str | None,
cost_control_mode: str | None,
parent_mode: str | None,
expected: bool,
) -> None:
assert (
subagent_routing_enabled(
override,
cost_control_mode=cost_control_mode,
parent_cost_control_mode=parent_mode,
)
is expected
)
def test_router_dir_for_session_is_owner_only(tmp_path: Path) -> None:
path = router_dir_for_session("conv_router_dir")
assert path.is_dir()
assert path.stat().st_mode & 0o777 == 0o700
def test_ensure_session_router_is_idempotent_and_advertises_everywhere(
tmp_path: Path,
) -> None:
class _DeadClient:
async def post(self, *args: Any, **kwargs: Any) -> Any:
raise RuntimeError("server down")
first_dir = tmp_path / "a"
second_dir = tmp_path / "b"
async def _run() -> None:
router = ensure_session_router(
"conv_lifecycle",
bridge_dir=first_dir,
server_client=_DeadClient(),
)
again = ensure_session_router(
"conv_lifecycle",
bridge_dir=second_dir,
server_client=_DeadClient(),
)
assert again is router
# Same rendezvous advertised in both directories.
assert read_router_endpoint(first_dir) == read_router_endpoint(second_dir)
env = session_router_env("conv_lifecycle")
assert env["OMNIGENT_SUBAGENT_ROUTER_SESSION_ID"] == "conv_lifecycle"
assert env["OMNIGENT_CODEX_SUBAGENT_ROUTER_DIR"] == str(first_dir)
shutdown_session_router("conv_lifecycle")
assert session_router_env("conv_lifecycle") == {}
asyncio.run(_run())
def test_ensure_session_router_quietly_skips_without_a_server_client(tmp_path: Path) -> None:
assert (
ensure_session_router_quietly(
"conv_no_client",
bridge_dir=tmp_path,
server_client=None,
)
is None
)
assert not (tmp_path / ADVERTISEMENT_FILE).exists()
def test_ensure_session_router_quietly_swallows_a_bind_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from omnigent.runner import subagent_routing as module
def _boom(session_id: str, **kwargs: Any) -> None:
raise OSError("address in use")
monkeypatch.setattr(module, "ensure_session_router", _boom)
assert (
ensure_session_router_quietly(
"conv_bind_fail",
bridge_dir=tmp_path,
server_client=object(), # type: ignore[arg-type]
harness="codex-native",
caps=FakeCaps(),
)
is None
)
def test_relayed_decisions_start_empty() -> None:
assert relayed_decisions("conv_never_routed") == ()
assert routed_models("conv_never_routed") == frozenset()
+48
View File
@@ -180,6 +180,54 @@ def test_update_harness_readiness_replaces_live_map(host_store: HostStore) -> No
assert fetched.status == "online"
def test_upsert_persists_and_resets_gateway_inference(host_store: HostStore) -> None:
host_id = "7f97ff655a2e6c8179bd67f6a386c6d7"
host_store.upsert_on_connect(
host_id=host_id,
name="laptop-gw",
user_id="alice@example.com",
configured_harnesses={"codex": True},
gateway_inference={"codex": True, "claude-native": False},
)
fetched = host_store.get_host(host_id)
assert fetched is not None
assert fetched.gateway_inference == {"codex": True, "claude-native": False}
# Reconnect from a build that doesn't report it: back to unknown (NULL),
# never a stale map and never {}.
host_store.upsert_on_connect(
host_id=host_id,
name="laptop-gw",
user_id="alice@example.com",
)
fetched = host_store.get_host(host_id)
assert fetched is not None
assert fetched.gateway_inference is None
def test_update_harness_readiness_replaces_gateway_inference(host_store: HostStore) -> None:
host_id = "8a08009766395d81709d78060497d7e8"
host_store.upsert_on_connect(
host_id=host_id,
name="laptop-gw-live",
user_id="alice@example.com",
configured_harnesses={"codex": True},
gateway_inference={"codex": False},
)
host_store.update_harness_readiness(host_id, {"codex": True}, {"codex": True})
fetched = host_store.get_host(host_id)
assert fetched is not None
assert fetched.gateway_inference == {"codex": True}
# A refresh with no gateway map clears it back to unknown.
host_store.update_harness_readiness(host_id, {"codex": True})
fetched = host_store.get_host(host_id)
assert fetched is not None
assert fetched.gateway_inference is None
def test_malformed_configured_harnesses_column_reads_as_none(
host_store: HostStore,
db_uri: str,

Some files were not shown because too many files have changed in this diff Show More