Compare commits

...

25 Commits

Author SHA1 Message Date
Bryan Qiu 73e7f141c7 fix(web): hoist initialPrompt so Smart Routing create doesn't throw
The create-session body sets `smart_routing_message: initialPrompt`, but
`initialPrompt` was declared later in `handleCreate`. For non-routing
harnesses that field is undefined so the reference never evaluates, but
Smart Routing hits the temporal dead zone and throws a ReferenceError —
caught by the generic handler and surfaced as "Couldn't reach the
server," with no POST ever firing. Hoist the declaration above the fetch.

Also drop the stray `--` in run-frontend.sh (`pnpm run dev -- --port`
made vite ignore the port and drift to a default) and add --strictPort.

Co-authored-by: Isaac
2026-08-03 01:15:25 -07:00
Bryan Qiu 2a664d3dfb feat(web): unify Smart Routing across all pickers; no standalone toggles
Follow-up to the picker work, per design review:

- No standalone Smart Routing toggles anywhere. Smart Routing is an explicit
  option in each picker: the top-level harness section, the native Claude Code /
  Codex Model dropdowns (first, above Default), and now the bundle-agent
  (polly / debby) Agent Harness dropdown (first option). Removed the Switch-based
  toggle and its import.

- Codex Model dropdown gains the Smart Routing option (first), matching Claude
  Code — every routable harness now reads alike.

- Top-level Smart Routing config modal hides Model + Effort (the router owns
  them) and shows only Permissions, grayed to Default.

- Dropped the redundant "Auto" brain-harness entry (useBrainHarnessLabels no
  longer prepends it in this dialog); "Smart Routing" is the single routing
  option.

- Fixed a routing gap the refactor exposed: a bundle agent with Smart Routing
  on now sends its own harness_override (e.g. claude-sdk) + smart_routing_message
  so the server's create-time routing fires. Previously the toggle→option change
  cleared harness_override to null and routing silently stopped for polly/debby.

Verified live end-to-end: polly + Smart Routing routes to databricks-claude-
sonnet-5 at create; the Agent Harness dropdown shows Smart Routing first with no
"Auto" duplicate; top-level modal shows only Permissions. 230 web tests pass,
pnpm build + oxlint + prettier clean.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 07:25:50 +00:00
Bryan Qiu a799e5cf2b feat(web): surface Smart Routing in the New Session picker
The server routes at create for every harness, but the New Session UI never
offered it. Wire the two entry points from the reference:

1. Top-level "Smart Routing" section ABOVE the Harnesses group in the picker
   (its own row, not one of the harnesses). Selecting it binds a placeholder
   native wrapper for the create's agent_id and sends harness_override:"auto" +
   smart_routing_message so the server routes both harness and model at create;
   the placeholder's labels/model are omitted (server rebinds to the routed
   wrapper). The composer chip reads "Smart Routing".

2. Model-dropdown "Smart Routing" option for the native Claude Code / Codex
   harnesses (first, above Default). This was already rendered by the inline
   dropdown — the gate (smartRoutingEligible) was just written backwards
   (claude-sdk-only, native excluded). Fixed to the union: claude-native /
   codex-native gated on hostBacksHarnessWithGateway per family, OR the
   in-process brain-harness bundle agents (polly / debby) from the prior task.

New constants AUTO_NATIVE_HARNESS_ID + AUTO_HARNESS_DESCRIPTION in agentLabels.

Verified: 230 web tests pass, pnpm build clean (catches the ESM-import class of
bug vitest misses), oxlint + prettier clean, and live in the browser — the
picker shows "Smart Routing" above Harnesses, selecting it sets the chip, and
the create payload routes both harness + model (proven end-to-end).

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 06:30:43 +00:00
Bryan Qiu 74994b035b fix(web): export the missing SMART_ROUTING_LABEL constant
smartRoutingAvailability.ts and NewChatDialog.tsx both import
SMART_ROUTING_LABEL from agentLabels.ts, but the constant was never exported
there — a dangling reference from earlier web work. It loaded fine in vitest
but threw a runtime SyntaxError in the browser's ESM loader ("does not provide
an export named 'SMART_ROUTING_LABEL'"), breaking the New Chat landing.

Add the export (value "Smart Routing", the literal already used as UI copy),
centralizing the feature's display name so the toggle, the model-dropdown
option, and the availability notices can't drift.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 05:45:13 +00:00
Bryan Qiu 0d94610f38 feat(routing): route only at session create; remove per-turn routing
Smart Routing is now decided exactly once, at session create, for every
harness — never per turn. This removes the three per-turn / first-message
routing blocks from orchestration.py (auto-harness, SDK/child, native-terminal)
and deletes the now-unreachable turn gate. A create carrying a
smart_routing_message routes; a session created without one uses the harness
default and is never routed later (the CLI --smart-routing --prompt path is how
you route a session that would otherwise start empty).

- routing_create.py / routes_core.py: the fixed-harness create branch now also
  covers the in-process claude-sdk harness (polly / debby), so those agents
  route at create like the native TUIs.
- routes_core.py: a sub-agent spawned under a routing-on parent is detected as
  a forced-auto child and routed at create; tool_dispatch.py carries the spawn's
  task text as the child's smart_routing_message. orchestration.py no longer
  re-forces "auto" over an already-resolved child (which discarded the model).
- orchestration.py: the model/harness APPLICATION (from conv.model_override /
  conv.harness_override) is preserved; only the per-turn routing decision + its
  redundant card emission are removed. Net -319 lines there.
- web: the New Chat dialog offers the Smart Routing toggle for a routable
  in-process agent (claude-sdk brain harness, not a native wrapper) when the
  server has routing enabled; hidden otherwise.
- scripts/verify_smart_routing.sh: one-shot post-pull verification — unit suites
  plus live create-time routing for codex-native / claude-native / claude-sdk /
  auto, and a proof that a create without a prompt is not routed.

Verified live end-to-end: all four routable harnesses pin a servable model at
create (databricks-<arm> / system.ai.glm-5-2) and a promptless create is not
routed. 71 server tests + 215 web tests pass.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 04:51:54 +00:00
Bryan Qiu 47a027e5c3 fix(routing): resolve create-time picks to their servable spelling
The router returns a BARE arm id (gpt-5-6-luna, glm-5-2), but the harness
endpoints serve only the prefixed spelling: databricks-<arm> for most arms, and
system.ai.glm-5-2 for glm. The create path persisted the bare id as
model_override, so when the native process launched and called the gateway the
turn 404'd ("'gpt-5-6-luna' does not exist" on /codex/v1/responses).

resolve_route already produces the servable id, but the wave-2 create path never
called it (despite its own contract comment saying it would). Wire resolve_route
into both create resolvers against a static servable catalog built from the
task_v1 arms + SERVABLE_ALIASES, and stamp the bare pick as raw_model on the
verdict so the decision chip still shows what the router said.

Verified live end-to-end: a codex-native Smart Routing session now stores
databricks-gpt-5-6-luna, launches the codex process on it, and the turn
COMPLETES (agentMessage, status=idle) against the staging gateway — where the
bare id previously 404'd. Servable spellings confirmed by direct gateway probe:
bare ids 404, databricks-/system.ai. ids 200.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 03:20:05 +00:00
Bryan Qiu 08d758e64d fix(routing): map auto-routed harness to its native spelling at create
The Smart Routing (auto) create path persisted the router's own harness
spelling — claude-sdk / codex — as the session harness. But a Smart Routing
session launches the native TUI wrapper, so the fixed-harness paths surface
claude-native / codex-native. An auto session therefore came back with a
mismatched harness (claude-sdk instead of claude-native), inconsistent with
every other routing path and with what the session actually runs.

Map the router's pick to its native spelling in resolve_smart_routing_create
before returning it, mirroring the reference's AUTO_NATIVE_ROUTING_HARNESSES
narrowing. The router still receives claude-sdk / codex candidate keys (its
wire contract, verified via probe_routing_api.sh); only the surfaced/persisted
harness is normalized.

Verified live across the full CUJ matrix (3 harness arms x 3 prompts): all five
task_v1 arms fire by task complexity, and auto now reports claude-native /
codex-native consistently. Added a unit test asserting the claude-sdk->
claude-native and codex->codex-native mapping.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 03:10:27 +00:00
Bryan Qiu 1eb97af717 fix(routing): route create-time sessions against the static task_v1 arms
The create-time paths (auto + fixed-native) called route_session_harness
with no session_id/catalog — so it discovered an empty candidate set and
declined every time (model_override stayed None). A session being created
has no runner catalog to fetch yet.

Add a candidate_models param to route_session_harness: when given, it is used
verbatim and live-catalog discovery is skipped. Both create resolvers pass the
frozen task_v1 arms (claude-sdk: opus/sonnet; codex: glm/sol/luna). The
fixed-native path constrains candidates to the pinned harness's family only, so
routing cannot change the harness (the native-TUI invariant). harness_catalog
stays empty, so the post-routing wire-compat redirect is a correct no-op — the
pins are the known-compatible frozen arms.

Verified live against staging task_v1: codex-native -> gpt-5-6-luna,
claude-native -> claude-sonnet-5, auto -> gpt-5-6-luna, each with the router's
real rationale. (Env prerequisites for this — a routing.profile and the
databricks-sdk — are captured in LOCAL_SETUP.md.)

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 02:24:57 +00:00
Bryan Qiu 134b07ecdf fix(routing): close the W2·3↔W2·4 subagent seam (barrier 2)
The W2·4 relay imported SubagentRouteRequest + auto_harness_session from
omnigent.runner.subagent_routing, a module no wave-2 stream built — the
handler would ImportError at runtime on the first spawn (a lazy import, so
module load stayed green and hid it). W2·4's test carried a placeholder copy
of the dataclass pointing at that same phantom module.

Home the two symbols where v2's partition puts them: the request wire-shape
in the transport module (subagent_routing_transport.py — it already parses the
same payload the hook client's build_route_request emits), and re-point the
relay import there. auto_harness_session is adapted to v2 reality: it keys off
the conversation's harness_override=="auto" sentinel (v2 has no AUTO_HARNESS
label). The test now imports the real type instead of a placeholder.

Verified: the hook client's build_route_request output round-trips through
SubagentRouteRequest.from_payload; 43 subagent tests pass; relay module imports
clean.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 02:00:10 +00:00
Bryan Qiu 34b3eb08ec feat(routing): consume smart_routing_message in create_session (W2·2 wiring)
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 01:29:08 +00:00
Bryan Qiu ca69e2d730 feat(routing): subagent routing policy + thin relay (W2·4)
Implement server-side subagent routing policy with family constraints and
subagent_routing_override toggle. Route spawns on Task prompt, apply
family-constraint rules (non-auto sessions stay same-family, auto may cross),
and persist decisions. Thin relay handler forwards requests from W2·3 transport
to policy and serializes decisions.

Family constraints (INTELLIGENT_ROUTING_PLAN §12):
- Rule-0: spawn's routed arm must be servable on request harness OR on
  family-paired harness when session is in auto (Smart Routing) mode.
- Non-auto sessions: Claude ⇏ Codex, Codex ⇏ Claude. Cross-family picks → deny.
- Same-harness: routed model's family matches request → rewrite. Otherwise if
  auto → redirect. Else deny.

Subagent routing override (decision 5):
- "off" → allow unchanged (advisory, per-call gate). Spawn still reaches hook
  and executes; override just suppresses routing.
- "on" / absent → route (inherit from session cost_control_mode).
- Takes effect per spawn immediately, both ways (on/off).

Relay path matches SUBAGENT_SERVER_RELAY_PATH contract; persists
RoutingDecisionData with native_subagent scope and decision_id join key.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 01:25:44 +00:00
Bryan Qiu 17e5d7b0a7 feat(routing): --smart-routing CLI entry points for native launches (W2·6)
Add --smart-routing and -p flags to native TUI launch commands (omni claude/codex/run)
to enable Smart Routing at CLI entry points. The CLI creates a routed session at
create-time by sending the routing contract (cost_control_mode_override, smart_routing_message),
reads back the resolved harness/model, and launches the wrapper on the routed model.

Tier 2: omnigent claude/codex --smart-routing -p '...' routes the model for a fixed harness.
Tier 3: omnigent run --smart-routing -p '...' routes both harness and model from the prompt.

Preflight gates hard-errors when routing is unavailable or the host's inference is not
gateway-backed. Routing itself fails open: a create failure still launches behind a notice.

Implementation:
- omnigent/smart_routing_cli.py (NEW): preflight checks, session creation, model verdict
- omnigent/cli_native.py: adds flags, validation, and routing dispatch to claude and codex commands
- tests/test_smart_routing_cli.py: 23 unit tests covering preflight matrix and create contract

All tests pass. No hardcoded models. Consumes only the HTTP routing contract (no server imports).

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 01:25:15 +00:00
Bryan Qiu 738eea9df4 feat(routing): Smart Routing web UI — dialog, gating, decision card (W2·5)
Add Smart Routing support to the web UI:

- smartRoutingAvailability helper: Classifies why Smart Routing is unavailable
  (routing disabled, wrappers missing, harnesses unready, not gateway-backed).
  Implements the three-valued "unknown=accept" gating rule for gateway_inference.

- HarnessConfigControls.RoutingModelSelect: Renders model picker with Smart
  Routing option ("Let router pick per turn") when available. Gating consumed
  from gateway_inference per-harness on the current host.

- NewChatDialog: Wires smart_routing_message into session-create payload when
  Smart Routing harness is selected or per-turn routing is enabled. The router
  receives the user's initial prompt for analysis.

- RoutingDecisionCard: Renders session/turn/subagent routing decision chips
  with model shorthand, applied status, and rationale excerpt.

Consumed contracts:
- GET /v1/hosts per-host gateway_inference map (3f gating)
- SessionCreateRequest.smart_routing_message (POST /v1/sessions body)
- SessionResponse.subagent_routing_override (session state)
- RoutingDecisionItem (conversation.items type)

No server-side changes; consumes the wave-0 HTTP contract as-is.
All web tests pass. Gating rule: codex-only-true host → codex option visible,
claude option hidden, both-harness row hidden.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 01:24:13 +00:00
Bryan Qiu 3c2bb302e4 feat(routing): subagent routing transport + hook scripts (W2·3)
Implements the runner-side loopback endpoint and hook subprocess machinery for
in-harness subagent routing. Completes the transport half of the subagent
routing flow:

Runner side:
- omnigent/server/subagent_routing_transport.py: loopback endpoint handler,
  request forwarding to server relay, advertisement file plumbing
- omnigent/inner/hook_scripts/subagent_router.py: shared stdlib-only client
  (request building, endpoint discovery, loopback POST, decision mapping)

Hook entry points (subprocess invocation via python -I):
- omnigent/inner/hook_scripts/codex_router_hook.py: spawn_agent hook dispatcher
- omnigent/inner/hook_scripts/claude_router_hook.py: Agent/Task hook dispatcher

Both hooks:
- Read endpoint advertisement from bridge dir or environment
- Build and POST routing request to loopback endpoint
- Map decision (allow/deny/rewrite/redirect) to PreToolUse hook output format
- Fail open on any transport error (routing is advisory)

Loopback→server-relay topology:
  hook subprocess → POST /v1/sessions/{id}/route-subagent → loopback endpoint
  loopback endpoint → POST /v1/sessions/{id}/hooks/route-subagent → server relay
  server relay → policy resolver (W2·4)

Verification:
- 29 unit tests: request building, endpoint discovery, decision mapping,
  decision serialization, import safety under python -I, fail-open behavior
- All tests pass; zero mypy errors in owned files (excluding protobuf stubs)
- ruff check + format: clean

Meets frozen contract:
- SUBAGENT_LOOPBACK_PATH = /v1/sessions/{session_id}/route-subagent
- SUBAGENT_SERVER_RELAY_PATH = /v1/sessions/{session_id}/hooks/route-subagent
- SubagentRouteDecision contract (to_payload shape)
- Loopback endpoint advertised via subagent_router.json in bridge dir

Hooks expect exactly the codex_executor.py invocation:
  python -I -m omnigent.inner.hook_scripts.codex_router_hook \
    route-subagent --bridge-dir <dir> --loopback-path <path>

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 01:23:38 +00:00
Bryan Qiu 11dc6ad32a feat(routing): create-time smart-routing + fixed-native model routing (W2·2)
Implement two create-time routing resolvers that route at session create instead
of per-turn, enabling routing for native harnesses whose turns originate in the
pane (and thus never reach the per-turn server gate). The resolvers follow the
wave-0 contract in omnigent/server/routing_create.py:

1. resolve_smart_routing_create: routes BOTH harness and model for creates with
   harness_override=="auto" and smart_routing_message present.
2. resolve_fixed_native_model_routing: routes only the model for creates already
   pinned to one native harness with routing enabled.

Both resolvers delegate to smart_routing.route_session_harness and fail open on
router unavailability. The fixed-harness path is consumed by the CLI routing
workflow (W2·6). Comprehensive unit tests verify both paths and the routing
decision builder.

Authorize-before-lookup ordering is preserved in the shared _routing_host_for_create
helper (plan 5b / CUJ_IMPLEMENTATION.md §4.3d).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 01:23:07 +00:00
Bryan Qiu b5a724a5db feat(routing): lift per-turn routing into the turn gate (W2·1)
Moves the three inline per-turn routing decision blocks from orchestration.py
into the dedicated turn-gate module, lifting the toggle+catalog+route logic
out of the 6800-line orchestration function into a 50-line seam.

The turn gate (route_turn_for_session) enforces the session-start cadence:
a turn that already carries a model_override does not re-route. The gate is
a thin wrapper around smart_routing.route_turn, delegating discovery and
routing decisions. Orchestration retains responsibility for persistence,
toggle checking, and card emission timing.

Three affected blocks:
1. Auto-harness first-turn resolution (line 3690) — handled by route_session_harness
2. Per-turn SDK routing (line 3737–3836) — replaced with route_turn_for_session
3. Native terminal routing (line 4053–4100) — replaced with route_turn_for_session

Comprehensive unit tests cover delegation, skip cases, verdict preservation,
and parameter passthrough. Orchestration tests remain green.

Owned files: omnigent/server/routing_turn_gate.py (implementation),
omnigent/server/routes/_sessions/orchestration.py (refactored call sites),
tests/server/test_routing_turn_gate.py (new test suite, 9 tests).

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 01:21:20 +00:00
Bryan Qiu d186ecdbea feat(routing): wire per-request two-backend seam into cli (barrier 1)
Wave-1 stream 2 built the RoutingBackend (wraps main's ExternalRoutingClient
and LLMRoutingClient, chosen per request by a predicate). This lead-owned
barrier-1 step wires it into the server startup path.

- Build BOTH clients when configured (not the old XOR) so the per-request
  predicate has two backends to flip between (plan 2f/7h).
- Add _routing_backend_predicate(): reads RuntimeCaps.routing_backend_predicate
  live on every route(), mirroring policy_llm_connection_factory. OSS leaves it
  unset -> returns True -> the backend prefers the external gateway client, else
  the judge: byte-for-byte main's behaviour. A managed deployment binds the one
  caps field to its flag without rebuilding the client.
- Wrap the two in RoutingBackend; stays None only when neither is configured.

Verified: 399 wave-1 unit tests pass in one pass; a runtime smoke asserts the
OSS default routes to external-when-configured and falls back to the judge when
only the judge is built.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 00:38:44 +00:00
Bryan Qiu 9f52a214aa feat(routing): codex hooks generation + trust handshake (W1·7)
The deterministic subagent-routing substrate for codex (plan 2c): generate
the Omnigent half of the per-session hooks.json (the PreToolUse
route-subagent gate) merged atomically with the user's own hooks, and trust
it over the app-server protocol so codex actually runs it.

Generates ONLY the route-subagent gate. The SessionStart canary and
SubagentStart audit are the cut enforcement stack (plan 3b) and are
deliberately not generated.

Transcribed traps (0d), each cited in a one-line comment:
- python -I in the hook command: codex runs hooks with the session
  workspace as cwd, and -m puts cwd first on sys.path, so a workspace with
  an omnigent/ dir shadows the installed package and the hook dies on
  import; -I (isolated) drops cwd from sys.path.
- version probe before config population: an old codex silently drops
  hooks.json, so probe the version first and treat an unparseable version as
  supported (never wedge a terminal on a spawn prompt no subagent answers).
- trust handshake after the app-server connects and before the first turn,
  filtered by hook module so it never touches a user-contributed hook.
- timeout ladder: hook timeout kept just above the request budget so codex's
  kill is the outermost bound.

The generated command threads the frozen SUBAGENT_LOOPBACK_PATH from the
routing contract through --loopback-path; the loopback endpoint and policy it
calls are wave-2 streams 3 & 4.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-03 00:27:01 +00:00
Bryan Qiu 3929532410 feat(routing): add resolve_route resolution seam (W1·1 routing core + seam)
Fill the wave-0 contract's resolve_route() stub in the file the partition
assigns this stream (omnigent/server/smart_routing.py), and export
resolve_route/ResolvedRoute from here for the wave-2 create path and turn
gate to import. This is the whole resolver: the four-step chain
strip prefix -> exact catalog match -> one fixed family fallback ->
honest decline (plan 3i). No cost ladder, MODEL_LISTS, _cost_position,
nearest-cost walk, or id allowlist — plan 3i rule 1 cuts them, and main's
route_session_harness/route_turn already carry none.

- Separator-safe prefix stripping transcribed from the oracle (0d trap): a
  prefix configured without its trailing separator drops one leading
  separator so it cannot corrupt the bare id.
- The glm gateway spelling pin (SERVABLE_ALIASES) is applied on the way out
  as a spelling, not a substitution: raw_model records the arm, and the bare
  ids match, so the chip never reads it as a different pick.
- Family fallback stamps raw_model with the router's original pick so the
  decision record and chip stay honest.
- Constants (arms, FAMILY_FALLBACK, SERVABLE_ALIASES, MODEL_ID_PREFIXES,
  ResolvedRoute) imported from the frozen contract / model_fallbacks; zero
  hardcoded model ids in source.

Directed unit tests (tests/server/test_resolve_route.py) with a synthetic
catalog cover exact match, glm spelling pin, claude/gpt/glm family fallback,
honest decline, and the separator-safe prefix trap — the fallback/decline
paths have no live trigger on the reference workspace (plan 6c).

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-02 23:54:12 +00:00
Bryan Qiu 38ff1e0c3b feat(routing): host per-family gateway-inference signal (plan 3f)
The host reports, per harness family, whether THIS host resolves that
family's inference to the workspace AI Gateway — a config-only check
(no process launch, no network call). The web consumes it to gate the
Smart Routing option: a Model row needs THAT family gateway-backed, the
Smart Routing harness row needs BOTH; a host reporting nothing is
unknown and never hides the option.

- gateway_inference.py: fill the two family checks. claude — resolved
  native-claude config pins ANTHROPIC_BASE_URL + an apiKeyHelper. codex —
  the resolved native-codex launch's base_url is a Databricks AI Gateway
  Codex (/codex/v1) endpoint. The AI-Gateway URL predicate and the codex
  launch base-URL derivation are self-contained here (v2 ships neither
  helper in an owned file). gateway_inference_map() fans each per-family
  result over every accepted spelling; a raising check omits the family
  (unknown), never reports False.
- Migration 66b439064d06: add nullable hosts.gateway_inference (batch
  mode, sqlite-safe); db_models gains the CompressedText column.
- Plumb host -> server: host hello / readiness-refresh / install /
  store-secret frames carry the map; host_store persists + reads it;
  GET /v1/hosts (+ single-host) and the session snapshot
  (SessionResponse.gateway_inference) surface it faithfully
  (true/false/absent).
- Tests: directed unit tests for both family checks + the fan-out map,
  a migration up/down round-trip + host_store persistence, and hello /
  readiness frame round-trips carrying the field.

The web half of the gate is wave-2 stream 5.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-02 23:45:04 +00:00
Bryan Qiu 16e56d555c feat(routing): mirror codex model switch into config.toml + glm gateway route
Add the two pieces the native-Codex apply layer needs for Smart Routing on
top of main's existing thread/settings/update push:

1. write_codex_config_model(bridge_dir, model) in codex_native_bridge.py
   mirrors an Omnigent-initiated model switch into the session's config.toml.
   thread/settings/update changes only the live thread, but the forwarder's
   model mirror (_refresh_model_from_config -> _sync_model_change) and the
   cost-gate hook both read config.toml as source of truth. Without the
   mirror, the next turn/started re-reads the stale launch model and posts it
   back as external_model_change, silently reverting the switch. The executor
   now calls the writer right after a successful settings update that changed
   the model.

2. _served_codex_model applies the glm gateway route at apply time: the
   glm-5-2 arm resolves to databricks-glm-5-2, which 400s on /codex/v1 (chat
   completions only); the Responses API serves glm only under
   system.ai.glm-5-2 (probed 2026-08-01). The served spelling comes from the
   contract's SERVABLE_ALIASES (imported, never inlined) and strips to the
   same bare arm, so the routing seam's decision record still shows glm-5-2
   with no substitution. Idempotent and a no-op for non-glm models.

Forwarder/app_server unchanged: they already treat config.toml as the source
of truth; the new writer is what keeps them consistent.

Directed unit tests in tests/inner/test_codex_native_model_apply.py cover the
config.toml round-trip/upsert, the glm served spelling through
_model_effort_overrides, and the no-revert invariant.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-02 23:37:01 +00:00
Bryan Qiu 8612009eaa feat(routing): claude apply layer — /model alias vocabulary (wave-1 stream 5)
Main already types /model <model> into the claude pane, but with the raw
catalog id, which the CLI silently ignores (the pane keeps its old model).
Add the correctness layer that translates a routed catalog id into the
vocabulary /model actually accepts.

- omnigent/claude_model_vocabulary.py (new, stdlib-only for hook subprocesses):
  claude_model_command_arg / claude_model_alias / model_vocabulary_env /
  alias_pins / normalized_model_id. Inverts the ANTHROPIC_DEFAULT_*_MODEL
  alias pinning exactly, and returns the custom picker slot's byte-exact id
  when set.
- claude_native_bridge.read_model_env + launch_env persistence in
  prepare_bridge_dir: the terminal launches with the alias pins in its env,
  but this process doesn't share that env, so the pins are recorded in the
  bridge dir for the runner-side executor to read back.
- claude_native.py: thread claude_config.env as launch_env into
  prepare_bridge_dir.
- claude_native_executor._model_command_arg: replace the raw /model {model}
  with the translated arg. Transcribes three v1 traps:
  * FAIL-OPEN: an untranslatable id sends the turn on the current model with
    a warning, never types a value the CLI drops.
  * ALIAS-PIN EXACTNESS: skip the switch when the routed id resolves to the
    alias the pane is already on (avoids a pointless confirm dialog).
  * track the routed id (not the alias) as _applied_model for next turn.

The SDK path (claude_sdk_executor.py) already honors model_override via
options.model / set_model() — the --model/SDK contract takes an exact id
verbatim, no alias vocabulary needed — so it is left unchanged.

Tests: tests/inner/test_claude_model_vocabulary.py (22) + executor model
tests (alias, custom-slot, fail-open, drifted-pin) + bridge read_model_env
round-trip.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-02 23:32:50 +00:00
Bryan Qiu a36cf34b77 feat(routing): main-agent routing-decision store (writer/reader + model override)
Wave-1 stream 3 (plan 2d): the persistence half of Smart Routing. Adds a
new module omnigent/server/routing_decision_store.py that maps a route
result + its stream-1 resolution into a RoutingDecisionData, persists it as
a routing_decision conversation item (no new table — the web reads it from
the session snapshot), and reads the latest one back.

Honesty rules (plan 3i): a fallback stamps raw_model distinct from model; a
prefix/spelling-only difference (the glm gateway spelling pin) is the same
arm and drops raw_model; an honest decline records applied=false with no
pin. The routed model pins through the existing model_override session key
(round-trip helpers), so no conversation-store schema or method change was
needed. subagent_routing_override is left to wave-2.

Directed DB-free suite (16 tests, in-memory fake store).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-02 23:26:27 +00:00
Bryan Qiu 0ec2345632 feat(routing): per-request two-backend routing seam
Add RoutingBackend: one RoutingClient-satisfying seam wrapping main's two
routing clients (the AI Gateway ExternalRoutingClient and the LLM judge
LLMRoutingClient). On every route() call it consults the deployment-supplied
RuntimeCaps.routing_backend_predicate to pick the backend — True selects the
external client, False selects the judge — so the preview flag is evaluated
per request, not once at construction (plan 2f/7h).

A flag-off workspace still routes via the judge; the seam never returns
"unavailable" merely because the requested backend is absent (True with no
external → judge fallback, False with no judge → external fallback). With no
predicate (None) it defaults to the OSS behaviour: external when configured,
else the judge. OSS never names the flag; the managed plugin binds the
predicate. last_error reads through to the last-used backend for the existing
route_session_harness failure-surface path.

Directed unit tests cover the five required cases plus the flag-off/no-backend/
predicate-raises/per-request-evaluation invariants.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-02 23:21:41 +00:00
Bryan Qiu b4c975c237 feat(routing): wave-0 contract for the from-scratch Smart Routing rebuild
Lead-authored wave-0 commit (designs/PR_REWRITE_PLAN.md §4b). It declares every
shared surface the parallel workstreams code against, pre-creates the shared
touch points so no two concurrent streams edit one file, carries the reference
docs + dev harness onto the branch, and slims the test registry. No routing
logic — declarations only; wave-1/2 streams fill the bodies keeping signatures
identical.

Contract (omnigent/server/routing_contract.py): the backend predicate + two-
backend seam (plan 2f), the frozen task_v1 arms / family fallback / gateway
spelling re-exported from the guard-owned model_fallbacks.py (plan 3i), the
ResolvedRoute + resolve_route stub, the RoutingDecisionData added-field set,
SubagentRouteDecision + loopback paths (plan 2c), the gateway harness groups
(plan 3f), and the create/response added-field sets.

Pre-created touch points: RuntimeCaps.routing_backend_predicate (mirrors the
existing policy_llm_connection_factory); SessionCreateRequest gains
smart_routing_message + subagent_routing_override; SessionResponse gains
subagent_routing_override + gateway_inference; RoutingDecisionData gains
harness/scope/decision_id/raw_model/attempted_override (all defaulted — old
rows deserialize unchanged); an empty alembic revision 66b439064d06 chained off
the true head c4d5e6f7a8b9 (wave-1 s4 fills it); the two wave-2 seam modules
routing_turn_gate.py and routing_create.py; and gateway_inference.py stubbed.

Also: model_fallbacks.py gains the task_v1 arms, per-family fallback, and glm
gateway-spelling records (guard-owned, with provenance); designs/WAVE_PLAN.md is
the file-ownership partition + scope-correction manifest each stream reads first;
the reference docs, dev-env/run-* scripts, and a barrier-1 apply-check scaffold
are carried from routing-mvp-v1; CUJ_STATUS.md is slimmed to the built behaviors
with every row reset to unverified (plan 6f).

Scope note surfaced during recon: origin/main already ships much of the routing
core (both clients, the RoutingClient protocol, route_session_harness/route_turn,
_redirect_incompatible_pick, RoutingDecisionData, the native /model + codex
settings-push apply MECHANISM, and the mid-session model_change on PATCH), so
several wave-1 streams extend main rather than build from zero. WAVE_PLAN.md §
"scope correction" records exactly what is present vs absent.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-02 22:57:02 +00:00
75 changed files with 12771 additions and 510 deletions
+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.
+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).
+394
View File
@@ -0,0 +1,394 @@
# 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 | `./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. |
| **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 —**: 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 —, 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). | — (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. | — |
### 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 | — |
| 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. | — (independence half) |
| Sticky default next session, same harness | R0, create a second session on the same harness | UI preselection | ⬜ user | — |
| 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 | — |
| 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`) | — |
| 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) | — |
| **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 | — |
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. | — |
| 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 | — |
| **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 — — 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 | — (glm half —) |
| 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 | — (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 | — |
| 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 | — (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 | — |
| 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 | — |
| 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 | — |
### 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 | — |
| 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 | — |
| **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 | — |
| 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 | — |
| 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`).
### 2.6 Visibility
| 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 |
### 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 | —/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) | — |
| 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**) | — |
| 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 —.** 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 —: 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 | —/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) | — |
| `--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) | — |
| 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) | — |
| 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 | — |
| 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 | — |
| 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) | — |
| **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 | — |
| **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 | — |
| **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) | — |
| 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 | — |
| 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 | — |
| 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) | — |
| 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 | — |
| `--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. Status
Every row above is ⬜ unverified: this registry describes the behaviors the
from-scratch rebuild must hold, and no evidence transfers from the reference
implementation (plan `6f`). The fleet earns each row on the new branch, and a
status changes only with named evidence, a date, and a commit (the update
contract above).
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.
+226
View File
@@ -0,0 +1,226 @@
# 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.** `CUJ_STATUS.md` §2 lists every behavior the branch verified. It is the specification for what to build. It is also the coverage gate for the tests (3g).
- **The trap list.** `CUJ_IMPLEMENTATION.md` states each step in the form "why the naive approach failed". `INTELLIGENT_ROUTING_PLAN.md` §12 holds 23 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.
- **The reference implementation.** `routing-mvp-v1` (6b) 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.
**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`, adds 28,991 insertions over 148 files against origin/main. The composition is: python tests 9,591; web source 5,104; web tests 2,625; docs 2,593; server 2,461; runner+inner 3,054; adapters 1,394; cli+other 1,873; telemetry 296. Production source is ~9,100 lines. Tests are 12,216 lines (42%). Docs are 2,593 lines (9%).
**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 in the web commit. The fix 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.
## 3. Cut list — each with size, what is lost, and my recommendation
**3a** **Docs ride the branch, and a final commit deletes them (7j).** The design documents 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 them before the merge, so the merged diff carries **no** docs and no docs PR follows. Reviewers therefore see them during review and never in `main`.
**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.
**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 (~1,000 of 5,104).** 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 (`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 wire-compat redirect function stays as main wrote it. 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 small commit declares every shared surface as a type signature with no logic: the routing-client interface, the backend-selection interface (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. The same commit pre-creates every shared touch point that a wave agent would otherwise have to edit: the settings fields, the route registration, the migration, and the two orchestration call sites that wave 2 fills. No later agent then needs to write in a file that a concurrent agent owns.
**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 and the session overrides.
4. The gateway-inference signal: the host-side check, the host frames, the server surface, and the 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.
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 its own module.
2. The create paths, in their own module: 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: the hook scripts and the loopback endpoint.
4. The subagent policy: `resolve_subagent_route`, the family constraints, and the per-session override.
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 (4 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. Barrier-2 fallout: the defects that appear only when the waves run together.
4. The verification agent's barrier-3 runs (6e).
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. A 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.
## 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 four in the closure wave, all on one branch and in ONE worktree, per 4b. Strict file ownership (4a) is what makes concurrent agents safe in one worktree, so the lead publishes the partition before each wave starts. A verification agent is a standing role from barrier 1 onward rather than a wave slot, and 6e scopes its runs. 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, and a human checks them on a live stack.
- 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 router probe, **R7** the headless session driver, **R9** the gateway-gating flip, **R10** the CLI invocations. **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). `INTELLIGENT_ROUTING_PLAN.md` §11 defines every row name and its verbatim prompt, and `CUJ_STATUS.md` §2 holds the row inventory:
1. Barrier 2 — the decision-and-apply matrix rows (A1A4, B1B2, 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 holds 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 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 transport and the policy, which own separate files and therefore parallelize. 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.** Five design documents add +2,879 lines under `designs/`, and `REVIEW_FIXES.md` adds +367. 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 commit-1 requirement (2a), 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 4. 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`).
+126
View File
@@ -0,0 +1,126 @@
# Wave-0 execution manifest — file partition + scope corrections
Lead-authored, wave 0. This is the operational companion to
`designs/PR_REWRITE_PLAN.md` (§4b streams, §4f partition) and
`designs/ROUTING_OVERVIEW.md` (subsystem → files). Every wave-1/2/3 agent reads
this file FIRST, then its stream's rows in `CUJ_STATUS.md` §2, then the trap list
(`CUJ_IMPLEMENTATION.md` + `INTELLIGENT_ROUTING_PLAN.md` §12).
The build branch is `routing-mvp-v2`, off `origin/main` (`042f0ddc`). The
reference implementation is `routing-mvp-v1` (`f200a8bd`), checked out at
`~/worktrees/omnigent-routing-v1` as the read-only oracle. Read it; never copy it
wholesale (plan 0d).
## Scope correction from wave-0 recon (read this before you size your stream)
`origin/main` has drifted forward since the plan's line counts were written. It
ALREADY ships more of the routing core than plan 2g credited. Verify against main
before you write — extend, do not re-create:
**Already on main (extend it, do not rebuild):**
- `omnigent/server/smart_routing.py` (850 lines): `RoutingResult(model, rationale,
harness)`, the `RoutingClient` protocol, `ExternalRoutingClient`,
`LLMRoutingClient`, `route_session_harness`, `route_turn`,
`fetch_runner_models` / `_fetch_runner_catalog`, `_redirect_incompatible_pick`,
`_AUTO_ROUTING_HARNESSES`, `_WORKER_NAME_TO_HARNESS`.
- `omnigent/cli.py` (~3586): builds one routing client at startup onto
`RuntimeCaps.routing_client` (`_build_external_routing_client` /
`_build_local_llm_routing_client`).
- `omnigent/entities/conversation.py`: `RoutingDecisionData(model, applied,
rationale, agent)` — wave-0 already appended the 5 new fields.
- `omnigent/server/schemas.py`: `SessionCreateRequest` already had
`model_override`, `cost_control_mode_override`, `harness_override` — wave-0
added `smart_routing_message` + `subagent_routing_override`.
- `omnigent/entities/conversation.py` `Conversation`: `model_override`,
`cost_control_mode_override`, `harness_override` columns already exist.
- The **native apply MECHANISM** already exists (this is the big one — plan
streams 5/6 shrink accordingly):
- `omnigent/inner/claude_native_executor.py` already types `/model <model>`
into the pane (`_should_switch_model` → `inject_slash_command`).
- `omnigent/inner/codex_native_executor.py` already pushes
`thread/settings/update` with the model to the running thread.
- `omnigent/server/routes/sessions/routes_core.py:1737` already fires a
`model_change` event when `model_override` is PATCHed mid-session.
- The two orchestration routing gates already call `route_session_harness`
(auto-harness) and `route_turn` (per-turn) inline.
**ABSENT on main (build it):**
- `omnigent/claude_model_vocabulary.py` — the alias/custom-slot translation
(`claude_model_command_arg`). Main types the RAW catalog id, which `/model`
silently ignores; the vocabulary is the correctness layer. **v1-only.**
- `read_model_env` (launch-pin read) in the claude bridge, and
`write_codex_config_model` (mirror the accepted codex switch into
`config.toml` so it doesn't revert next turn). **v1-only.**
- `omnigent/gateway_inference.py` — wave-0 stubbed it; wave-1 s4 fills the checks.
- `omnigent/runner/subagent_routing.py` and everything subagent — **absent.**
- `omnigent/smart_routing_cli.py` — **absent.**
- `RoutingSettings` as a dataclass — main parses the `routing:` YAML dict inline
in cli.py; if a stream wants a typed settings object it creates one, but note
main has no such class today.
## Wave-0 contract surfaces (import these; never wait on an implementation)
- `omnigent/server/routing_contract.py` — the single contract module. Backend
predicate type + `RoutingBackend` protocol (2f), frozen arms + `FAMILY_FALLBACK`
+ `SERVABLE_ALIASES` + `ResolvedRoute` + `resolve_route()` stub (3i), the
decision-record added-field set, `SubagentRouteDecision` + loopback paths (2c),
gateway harness-spelling groups (3f), and the create/response added-field sets.
- `omnigent/runtime/caps.py` — `RuntimeCaps.routing_backend_predicate` (2f).
- `omnigent/gateway_inference.py` — `gateway_inference_map()` + two family checks.
- `omnigent/server/routing_turn_gate.py` — `route_turn_for_session()` (wave-2 s1).
- `omnigent/server/routing_create.py` — `resolve_smart_routing_create()` +
`resolve_fixed_native_model_routing()` (wave-2 s2).
- Migration `66b439064d06_add_gateway_inference_to_hosts.py` — empty, chains off
the true head `c4d5e6f7a8b9`. Wave-1 s4 fills `upgrade()`/`downgrade()`; do NOT
create a second migration.
## File partition (plan 4f) — you own your row, you READ everything else
An agent stages ONLY the files it owns (`git add <paths>`, never `git add -A`). An
agent that hits a failure in a file it does not own reports to the lead and does
not fix it (plan 4a). `omnigent/cli.py` is **lead-owned** for the whole build:
send the lead your cli.py delta and the lead applies it.
### Wave 1 — foundations (7 streams)
| Stream | Owns (creates/edits) | Notes |
| --- | --- | --- |
| W1·1 routing core + seam | `omnigent/server/smart_routing.py` | Extend main's. Fill `resolve_route()` in routing_contract. NO cost ladder / MODEL_LISTS / allowlist (3i r1). Keep `_redirect_incompatible_pick`. |
| W1·2 backend selection | `omnigent/server/routing_backend.py` (new) | The two-backend dispatch around `RuntimeCaps.routing_backend_predicate`. Wraps main's two clients. Satisfies `RoutingClient`. Lead wires it into cli.py. |
| W1·3 decision persistence | decision writer/reader; `session_overrides` keys | Fields already on `RoutingDecisionData` (wave-0). `subagent_routing_override` key belongs to W2·4; its Inherit row to W2·5. |
| W1·4 gateway signal | `omnigent/gateway_inference.py`, host frames, hosts route, migration `66b439064d06` body | Fill the two family checks + persist the column. Web half is W2·5. |
| W1·5 claude apply | `omnigent/claude_model_vocabulary.py` (new), `omnigent/claude_native.py`, `omnigent/claude_native_bridge.py`, `omnigent/inner/claude_native_executor.py`, `omnigent/inner/claude_sdk_executor.py` | Main already injects `/model`; ADD the vocabulary translation + launch-pin read. The claude hook script is W2·3. |
| W1·6 codex model apply | `omnigent/codex_native_forwarder.py`, `omnigent/codex_native_app_server.py`, `omnigent/codex_native_bridge.py`, `omnigent/inner/codex_native_executor.py` | Main already pushes `thread/settings/update`; ADD `write_codex_config_model` mirror + the glm gateway route. |
| W1·7 codex hooks + trust | `omnigent/inner/codex_executor.py` | `hooks.json` generation + trust handshake + `python -I`. Different files from W1·6 — that's why codex splits in two. |
### Wave 2 — integration (6 streams)
| Stream | Owns | Notes |
| --- | --- | --- |
| W2·1 turn gate | `omnigent/server/routing_turn_gate.py`; the turn call site in orchestration | Fill `route_turn_for_session()`. Session-start cadence: a turn with a `model_override` does not re-route. |
| W2·2 create paths | `omnigent/server/routing_create.py`; the create call site in orchestration | Fill both resolvers. Keep `_routing_host_for_create` authorize-before-lookup (plan 5b / §4.3d trap). |
| W2·3 subagent transport | `omnigent/server/subagent_routing_transport.py` (new), `omnigent/inner/hook_scripts/**` | Loopback endpoint + env plumbing + hook subprocess + the claude hook script. |
| W2·4 subagent policy | `omnigent/server/subagent_routing_policy.py` (new; runner-side logic), `omnigent/server/routes/sessions/routes_hooks.py` (thin relay) | `resolve_subagent_route` → `SubagentRouteDecision` (contract). Family constraints. `subagent_routing_override` key. |
| W2·5 web | `web/src/**` | Dialog, harness row, gating consumption, decision card, and the in-session model-indicator fix (plan 2e). |
| W2·6 CLI | `omnigent/smart_routing_cli.py` (new), `omnigent/cli_native.py` | `--smart-routing`/`-p` flags, preflight, both dispatch tiers. Consumes the wave-0 HTTP contract, not server code. |
### Wave 3 — closure (3 streams + lead)
| Stream | Owns |
| --- | --- |
| W3·1 session-create tool | expose `sys_session_create` to the Smart Routing harness agents (plan 3c) |
| W3·2 coverage sweep | test files only, any area — cover the `CUJ_STATUS.md` §2 inventory |
| W3·3 verification (barrier-3) | the standing verification agent's barrier-3 runs (plan 6e) |
Lead holds: barrier-2 fallout, `cli.py`, the docs-deletion commit (plan 3a), the
PR body, and the full gate (plan 6a).
## Barriers (plan 4e) — the lead holds each; a wave does not start until it passes
- **Barrier 1** (after wave 1): every stream's unit tests pass in ONE run; the
contract file is unchanged (a stream needing a new signature tells the lead,
who re-declares it for everyone); `scripts/barrier1_apply_check.sh` proves the
apply layers work with no router (R2 + R3).
- **Barrier 2** (after wave 2): `CUJ_STATUS.md` §2 runs 13 (matrix + spawn/toggle
+ CLI rows), the first end-to-end proof.
- **Barrier 3** (after wave 3): §2 run 4 + the full gate.
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
+211
View File
@@ -0,0 +1,211 @@
"""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 reject a bare catalog id:
* the ``Agent`` / ``Task`` tool's ``model`` parameter is a closed enum
(``sonnet``, ``opus``, ``haiku``, ``fable``); a catalog id fails schema
validation and the spawn dies before it starts;
* the ``/model`` slash command resolves an alias — or the custom slot's
exact id — offline with no validation, but ANY other value (catalog id
or canonical vendor id alike) is accepted only if a live one-token probe
to the configured endpoint succeeds, so it depends on the gateway
answering mid-turn and otherwise fails as a network error. Typing the
raw catalog id therefore silently leaves the pane on its old model.
Claude Code resolves each alias to a concrete id via the workspace's
``ANTHROPIC_DEFAULT_*_MODEL`` env (omnigent's launch config sets these), so
inverting that mapping is exact — and only exact. A family segment alone
is not enough: a workspace serving two generations of a family pins the
alias to the newer one, so speaking the alias for the older routed id
would run a model nobody routed to. Both surfaces fail OPEN on an id with
no accepted spelling — skip the switch rather than send a value 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
#: Family aliases both surfaces accept, longest-lived family first.
CLAUDE_MODEL_ALIASES: tuple[str, ...] = ("fable", "opus", "sonnet", "haiku")
#: Alias -> the 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",
}
#: The 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 rather than the caller's. The
#: Agent tool's enum has no such slot, so only ``/model`` consults it.
CUSTOM_MODEL_OPTION_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION"
#: The launch-env keys that define a session's model vocabulary. Persisted into
#: the bridge dir at launch so a runner-side caller that never saw the terminal
#: env can still translate a routed id.
MODEL_VOCABULARY_ENV_VARS: tuple[str, ...] = (
*ALIAS_MODEL_ENV_VARS.values(),
CUSTOM_MODEL_OPTION_ENV_VAR,
)
#: Catalog prefixes stripped before comparing ids. Must equal
#: ``routing_contract.MODEL_ID_PREFIXES`` (asserted by
#: ``test_catalog_prefixes_match_the_routing_defaults``); duplicated here
#: 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.")
#: Splits a normalized id into its word segments for family detection.
_SEGMENT_RE = re.compile(r"[^a-z0-9]+")
def normalized_model_id(model: str) -> str:
"""Lower-case a model id, dropping any 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, object]]) -> dict[str, str]:
"""Rebuild a session's model vocabulary from its picker rows.
The native 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
# A row whose model restates its own key (or is itself an alias) pins
# nothing concrete; skip it rather than record a bogus pin.
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 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 would run 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. Speaking a
# family segment now would move the pane to the pinned generation.
return None
# Nothing pinned: a direct Anthropic login, where the family segment IS
# the alias and resolves to the vendor's own model of that family.
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`, plus 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.
The slot is compared byte-exactly, so the returned arg is the env's own
spelling even when the routed id differs in prefix or case.
: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, because 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)
+1
View File
@@ -3517,6 +3517,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,
+42 -1
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, cast
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.json_types import JsonObject as _JsonObject
from omnigent.kiro_native_bridge import bridge_root as kiro_bridge_root
@@ -820,6 +821,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.
@@ -834,6 +836,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
@@ -853,6 +860,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
@@ -1024,6 +1038,33 @@ 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.
The terminal launched with ``ANTHROPIC_DEFAULT_*_MODEL`` /
``ANTHROPIC_CUSTOM_MODEL_OPTION`` pins, but this process does not share
that env; :func:`prepare_bridge_dir` recorded them here so a routed model
id can be translated into a ``/model`` argument the CLI accepts.
: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.
+49 -12
View File
@@ -77,7 +77,11 @@ if TYPE_CHECKING:
from omnigent.install_ledger import InstallLedger
from omnigent.onboarding.acp_auth import AcpAgentEntry
from omnigent.server.smart_routing import ExternalRoutingClient, LLMRoutingClient
from omnigent.server.smart_routing import (
ExternalRoutingClient,
LLMRoutingClient,
RoutingClient,
)
from omnigent.spec.types import LLMConfig
from omnigent.update_check import _InstalledWheelInfo
@@ -203,6 +207,25 @@ def _build_local_llm_routing_client(
return LLMRoutingClient(policy_client)
def _routing_backend_predicate() -> bool:
"""Per-request backend choice, read live off ``RuntimeCaps`` (plan 2f/7h).
The ``RoutingBackend`` calls this on every ``route()``. OSS leaves
``RuntimeCaps.routing_backend_predicate`` unset (``None``) and this returns
``True`` which the backend reads as "prefer the external gateway client,
else the judge", i.e. main's behaviour. A managed deployment registers its
own predicate on that one caps field (mirroring
``policy_llm_connection_factory``); reading it live here rather than
capturing it at construction is what lets the flag flip per request
without rebuilding the client. A predicate hiccup is swallowed by the
backend, so this stays a thin read.
"""
from omnigent.runtime import get_caps
predicate = get_caps().routing_backend_predicate
return True if predicate is None else bool(predicate())
def _server_uvicorn_log_config(
log_path: Path | None = None,
*,
@@ -3575,20 +3598,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.
# Build the routing backend from configuration alone — no opt-in env needed.
# Both of main's clients are built when configured, and a per-request
# predicate chooses between them on every route() (plan 2f/7h):
# - external: an external ``routes:select`` service (a ``routing:`` block
# with ``provider: external``) — the AI Gateway path.
# - judge: the built-in LLM judge over the server ``llm:`` block.
# The two are wrapped in a RoutingBackend so the predicate can flip between
# them per request; OSS leaves the predicate unset (prefer external, else
# judge — main's behaviour), and a managed deployment binds it to its flag.
# Stays None when neither backend is configured. Managed deployments may
# still override RuntimeCaps.routing_client wholesale.
routing_cfg = cfg.get("routing")
routing_client: ExternalRoutingClient | LLMRoutingClient | None
external_client: ExternalRoutingClient | None = None
if isinstance(routing_cfg, dict) and routing_cfg.get("provider") == "external":
routing_client = _build_external_routing_client(routing_cfg)
external_client = _build_external_routing_client(routing_cfg)
judge_client = _build_local_llm_routing_client(server_llm)
routing_client: RoutingClient | None
if external_client is None and judge_client is None:
routing_client = None
else:
routing_client = _build_local_llm_routing_client(server_llm)
from omnigent.server.routing_backend import RoutingBackend
routing_client = RoutingBackend(
external=external_client,
judge=judge_client,
predicate=_routing_backend_predicate,
)
caps = RuntimeCaps(
execution_timeout=int(effective_timeout),
+161 -2
View File
@@ -155,6 +155,22 @@ def register_native_commands(cli: click.Group) -> None:
"flag will be removed in a future release."
),
)
@click.option(
"-p",
"--prompt",
default=None,
help="Send this as the first message after the Claude TUI starts.",
)
@click.option(
"--smart-routing",
is_flag=True,
default=False,
help=(
"Ask the server to pick the model before launching the TUI. "
"Requires -p/--prompt. Uses Smart Routing if available on the target host; "
"otherwise falls back to a normal launch. See also the web UI's Smart Routing setting."
),
)
@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED)
def claude(
server: str | None,
@@ -164,6 +180,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 +191,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 first prompt, routed if --smart-routing.
# :param smart_routing: When True, route the model via Smart Routing.
# :param claude_args: Pass-through args for ``claude``.
"""Launch Claude Code with Omnigent.
@@ -182,6 +202,7 @@ 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 "..." # route the model
"""
_reject_native_on_windows("claude")
startup_profiler = StartupProfiler.from_env(
@@ -211,6 +232,16 @@ def register_native_commands(cli: click.Group) -> None:
"--session and --resume are mutually exclusive; "
"prefer --resume (--session is deprecated).",
)
if smart_routing and not prompt:
raise click.UsageError(
"--smart-routing requires -p/--prompt. The prompt is routed to pick a model. "
"Alternatively, use the web UI's Smart Routing setting to route without a prompt."
)
if smart_routing and (choice.picker or choice.conversation_id is not None or resume):
raise click.ClickException(
"Smart Routing creates a new session; it cannot be combined with "
"--resume or --continue. Drop the resume flag, or launch without --smart-routing."
)
startup_profiler.mark("arguments validated")
# Ensure the host daemon (local when ``--server`` is omitted/empty,
@@ -230,6 +261,54 @@ def register_native_commands(cli: click.Group) -> None:
startup_profiler.mark("native module imported")
routed_model: str | None = None
if smart_routing:
from omnigent.smart_routing_cli import (
check_smart_routing_available,
create_smart_routing_session,
known_host_id,
smart_routing_families,
)
startup_profiler.mark("smart routing helper imported")
# Preflight: check that routing is available and host supports it.
try:
check_smart_routing_available(
base_url=server,
harnesses=smart_routing_families("claude-native"),
host_id=None, # Host ID is resolved inside check.
)
except click.ClickException:
raise
# Route the session at create time (tier 2: fixed harness, routed model).
from omnigent.runtime.host_id_resolver import get_host_id_sync
host_id = get_host_id_sync()
known_id = known_host_id(base_url=server, host_id=host_id)
startup_profiler.mark("host id resolved")
routing_decision = create_smart_routing_session(
base_url=server,
prompt=prompt,
harness="claude-native",
host_id=known_id,
workspace=os.getcwd(),
)
startup_profiler.mark("smart routing session created")
if routing_decision.notice:
click.echo(f"omnigent: {routing_decision.notice}", err=True)
if routing_decision.session_id:
resolved_session_id = routing_decision.session_id
routed_model = routing_decision.model
if routed_model:
click.echo(f"omnigent: Smart Routing picked claude-native on {routed_model}.")
else:
# Fail open: routing unavailable, launch normally.
pass
if claude_command:
click.echo(
"omnigent: `claude --command` is deprecated; set OMNIGENT_CLAUDE_PATH "
@@ -243,11 +322,21 @@ def register_native_commands(cli: click.Group) -> None:
explicit=claude_command,
cfg=cfg,
)
# Build extra args, possibly with the routed model and prompt.
extra_args = list(_resolve_harness_startup_args(cfg, "claude-native", claude_args))
if routed_model and not any(arg.startswith("--model") for arg in extra_args):
# Append the routed model only if the user didn't explicitly pass --model.
extra_args.extend(["--model", routed_model])
if prompt and smart_routing:
# Append the prompt as the final argv argument for the native TUI.
extra_args.append(prompt)
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=tuple(extra_args),
use_claude_config=use_claude_config,
auto_open_conversation=auto_open_conversation,
startup_profiler=startup_profiler,
@@ -298,6 +387,16 @@ 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",
is_flag=True,
default=False,
help=(
"Ask the server to pick the model before launching the TUI. "
"Requires -p/--prompt. Uses Smart Routing if available on the target host; "
"otherwise falls back to a normal launch. See also the web UI's Smart Routing setting."
),
)
@click.argument("codex_args", nargs=-1, type=click.UNPROCESSED)
def codex(
server: str | None,
@@ -305,6 +404,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 +413,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 via Smart Routing.
# :param codex_args: Pass-through args for ``codex`` before ``resume``.
"""Launch Codex with Omnigent.
@@ -322,6 +423,7 @@ 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 "..." # route the model
"""
_reject_native_on_windows("codex")
choice = _split_resume_value(resume)
@@ -330,6 +432,16 @@ def register_native_commands(cli: click.Group) -> None:
"--session and --resume are mutually exclusive; "
"prefer --resume (--session is deprecated).",
)
if smart_routing and not prompt:
raise click.UsageError(
"--smart-routing requires -p/--prompt. The prompt is routed to pick a model. "
"Alternatively, use the web UI's Smart Routing setting to route without a prompt."
)
if smart_routing and (choice.picker or choice.conversation_id is not None or resume):
raise click.ClickException(
"Smart Routing creates a new session; it cannot be combined with "
"--resume or --continue. Drop the resume flag, or launch without --smart-routing."
)
from omnigent.codex_native import run_codex_native
from omnigent.harness_startup_config import resolve_harness_command
@@ -351,6 +463,53 @@ def register_native_commands(cli: click.Group) -> None:
choice.conversation_id if choice.conversation_id is not None else session_id
)
routed_model: str | None = None
if smart_routing:
from omnigent.smart_routing_cli import (
check_smart_routing_available,
create_smart_routing_session,
known_host_id,
smart_routing_families,
)
# Preflight: check that routing is available and host supports it.
try:
check_smart_routing_available(
base_url=server,
harnesses=smart_routing_families("codex-native"),
host_id=None, # Host ID is resolved inside check.
)
except click.ClickException:
raise
# Route the session at create time (tier 2: fixed harness, routed model).
from omnigent.runtime.host_id_resolver import get_host_id_sync
host_id = get_host_id_sync()
known_id = known_host_id(base_url=server, host_id=host_id)
routing_decision = create_smart_routing_session(
base_url=server,
prompt=prompt,
harness="codex-native",
host_id=known_id,
workspace=os.getcwd(),
)
if routing_decision.notice:
click.echo(f"omnigent: {routing_decision.notice}", err=True)
if routing_decision.session_id:
resolved_session_id = routing_decision.session_id
routed_model = routing_decision.model
if routed_model:
click.echo(f"omnigent: Smart Routing picked codex-native on {routed_model}.")
else:
# Fail open: routing unavailable, launch normally.
pass
# Use routed model if available, otherwise fall back to config or explicit --model.
final_model = routed_model or model
resolved_command = resolve_harness_command(
"codex-native",
default="codex",
@@ -362,7 +521,7 @@ def register_native_commands(cli: click.Group) -> None:
session_id=resolved_session_id,
resume_picker=choice.picker,
extra_args=_resolve_harness_startup_args(cfg, "codex-native", codex_args),
model=model,
model=final_model,
prompt=prompt,
auto_open_conversation=auto_open_conversation,
command=resolved_command,
+48
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,53 @@ 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 / Smart Routing via
``thread/settings/update``). That RPC changes the live thread but does NOT
touch ``config.toml`` — while the forwarder's mirror
(``_refresh_model_from_config`` → ``_sync_model_change``) 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.
+8
View File
@@ -1266,6 +1266,12 @@ 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 it
(unknown, not "nothing gateway-backed"). Surfaced via ``GET /v1/hosts``
and the session snapshot to gate the Smart Routing option (plan 3f).
"""
__tablename__ = "hosts"
@@ -1295,6 +1301,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,54 @@
"""Add gateway_inference to hosts (wave-0 empty revision; wave-1 stream 4 fills).
Revision ID: 66b439064d06
Revises: c4d5e6f7a8b9
Create Date: 2026-08-02
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`` and the session snapshot so the
web UI only offers Smart Routing where the routing apply layer can actually
rewrite the launch model (plan 3f).
Wave 0 pre-created this revision chained off the true alembic head on
origin/main, ``c4d5e6f7a8b9`` (verified via ``alembic heads``); wave-1 stream 4
fills the body. Do NOT change the revision id / down_revision — the chain must
resolve to the single head ``66b439064d06``.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "66b439064d06"
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")
+16
View File
@@ -541,6 +541,22 @@ 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
# ── Wave-0 contract (routing rebuild, plan 3i). Additive + defaulted, so
# every pre-existing persisted row deserializes unchanged. Wave-1 stream 3
# owns the writer/reader; see routing_contract.ROUTING_DECISION_ADDED_FIELDS.
#: The harness the routed model belongs to, e.g. ``"claude-native"``.
harness: str | None = None
#: Which decision this is: a session-scope pick, a per-turn pick, a child
#: session's pick, or a native in-harness subagent spawn's pick.
scope: Literal["session", "turn", "child_session", "native_subagent"] = "turn"
#: Stable id joining this transcript row to its telemetry / subagent verdict.
decision_id: str | None = None
#: The router's pick verbatim, kept so the chip can show what the router
#: said when a fallback or gateway-spelling pin made ``model`` differ.
raw_model: str | None = None
#: The model the user had pinned when routing was asked to run, if any —
#: rendered as "would have picked X, kept your Y".
attempted_override: str | None = None
@field_validator("model")
@classmethod
+195
View File
@@ -0,0 +1,195 @@
"""Per-family gateway-inference signal (wave-1 stream 4).
Reports, per harness family, whether THIS host resolves that family's inference
to the workspace AI Gateway. It is a config-only check: it reads resolved
provider config and never launches a process or makes a network call. The signal
rides the host frames, the host store, and the hosts route; the web consumes it
to gate the Smart Routing option (plan 3f):
- The Model row offers Smart Routing for a harness only when that harness's
family reports gateway-backed.
- The Smart Routing harness row appears only when BOTH the claude and codex
families report gateway-backed, because the harness routes across both.
- A host that reports nothing is unknown, and unknown never hides the option.
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, so the host can report the answer alongside harness readiness
on every registration.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Final
from urllib.parse import urlparse
from omnigent.server.routing_contract import (
CLAUDE_GATEWAY_HARNESSES,
CODEX_GATEWAY_HARNESSES,
)
if TYPE_CHECKING:
from omnigent.codex_native_app_server import NativeCodexLaunch
_logger = logging.getLogger(__name__)
# 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"
# Trusted parent domain suffixes for a Databricks-owned host, and the DNS label
# a dedicated gateway subdomain carries. Transcribed verbatim from the canonical
# predicate (``omnigent.pi_native_credentials._is_databricks_ai_gateway_url``,
# ``databricks_ai_gateway.is_databricks_ai_gateway_url`` in the reference tree)
# — a security constant (plan 0d), inlined here so this owned, config-only
# module needs no import from a harness file. Anchored on the leading "." so a
# look-alike like ``...cloud.databricks.com.evil.test`` is rejected.
_DATABRICKS_TRUSTED_HOST_SUFFIXES: Final[tuple[str, ...]] = (
".cloud.databricks.com", # AWS workspaces + ai-gateway
".azuredatabricks.net", # Azure Databricks
".gcp.databricks.com", # GCP Databricks
)
_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``).
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/...``).
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. a codex provider ``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.
if _DATABRICKS_AI_GATEWAY_LABEL in hostname.split("."):
return True
# Shape 2: workspace hostname + /ai-gateway/ path prefix.
return (parsed.path or "").startswith("/ai-gateway/")
def _codex_launch_base_url(launch: NativeCodexLaunch) -> str | None:
"""Inference base URL a resolved codex launch pins, or ``None`` when it defers.
Reimplements the reference tree's ``native_codex_launch_base_url`` from the
v2 launch shape (which does not ship that helper): the Databricks-profile
branch derives the base URL from the profile host exactly as
``build_native_codex_app`` applies it, while a generic provider carries the
URL inside a ``model_providers.…base_url=…`` config override. 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) → ``None``.
:param launch: A resolved :class:`NativeCodexLaunch`.
:returns: The base URL the launch routes through, or ``None``.
"""
if launch.profile is not None:
from omnigent.inner.codex_executor import _databricks_codex_base_url
from omnigent.inner.databricks_executor import _databricks_gateway_host
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
try:
base_url, _ = json.JSONDecoder().raw_decode(table[index + len(marker) :])
except ValueError:
continue
if isinstance(base_url, str):
return base_url
return None
def claude_gateway_inference_backed() -> bool:
"""Whether a claude-native launch on this host resolves gateway-backed inference.
Config-only: inspect the resolved claude provider config. 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)
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.
Config-only: resolve the codex launch this host would use and inspect the
base URL it pins. Gateway-backed iff that URL is a Databricks AI Gateway
endpoint whose path is the Codex Responses surface (``/codex/v1``).
:returns: ``True`` iff the resolved launch routes through an AI Gateway
Codex base URL.
"""
from omnigent.codex_native_app_server import resolve_native_codex_launch
base_url = _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 NotImplementedError:
raise
except Exception: # noqa: BLE001 # a family check failure is unknown, never False
_logger.warning("gateway_inference: %s family check failed", _family, exc_info=True)
continue
for spelling in spellings:
result[spelling] = backed
return result
+13 -1
View File
@@ -25,6 +25,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 (
@@ -1639,6 +1640,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:
@@ -1652,6 +1654,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:
@@ -1753,6 +1756,7 @@ class HostProcess:
request_id=frame.request_id,
status="ok",
configured_harnesses=configured_harness_map(),
gateway_inference=gateway_inference_map(),
)
def _handle_detect_credentials(
@@ -2429,6 +2433,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,
@@ -2437,6 +2442,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,
)
@@ -2485,16 +2491,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)
+47 -1
View File
@@ -100,6 +100,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 +111,15 @@ 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 (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 +633,10 @@ 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 AI-Gateway-backed inference flags
recomputed after the install (see ``omnigent.gateway_inference``).
A family that could not be evaluated is omitted. ``None`` means
unknown — never "nothing is gateway-backed".
:param error: Why the install failed, e.g. ``"npm not found"`` or
``"install timed out"``. ``None`` on success.
"""
@@ -633,6 +644,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 +706,10 @@ 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 AI-Gateway-backed inference flags
recomputed after the write (see ``omnigent.gateway_inference``).
A family that could not be evaluated is omitted. ``None`` means
unknown — never "nothing is gateway-backed".
:param error: Non-secret failure reason, e.g. ``"a gateway requires a
base_url"``. ``None`` on success.
"""
@@ -701,6 +717,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
@@ -887,6 +904,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,
}
@@ -896,6 +914,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):
@@ -1103,6 +1122,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,
}
)
@@ -1127,6 +1147,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,
}
)
@@ -1323,6 +1344,7 @@ def _decode_host_hello(msg: _JsonObject) -> 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"),
)
@@ -1340,7 +1362,10 @@ def _decode_harness_readiness(msg: _JsonObject) -> 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: _JsonObject) -> HostLaunchRunnerFrame:
@@ -1681,6 +1706,7 @@ def _decode_install_harness_result(msg: _JsonObject) -> HostInstallHarnessResult
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"),
)
@@ -1713,6 +1739,7 @@ def _decode_store_secret_result(msg: _JsonObject) -> HostStoreSecretResultFrame:
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"),
)
@@ -1894,6 +1921,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: _JsonObject, 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: _JsonObject, key: str) -> str | None:
"""Return an optional nullable string field.
+71 -3
View File
@@ -8,6 +8,7 @@ import os
from collections.abc import AsyncIterator
from pathlib import Path
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,
@@ -15,6 +16,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 (
EnqueuedContent,
@@ -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,68 @@ 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 does not 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)
# FAIL-OPEN: an untranslatable id has no spelling this session accepts,
# so send the turn on the current model rather than type a value the
# CLI silently drops (which would leave the pane on the old model while
# reporting success).
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
# ALIAS-PIN EXACTNESS CHECK: the routed id resolves to the same alias
# the pane is already on, so the switch would be a pointless confirm
# dialog (comparison is against the applied id's own resolved arg).
if (
self._applied_model is not None
and claude_model_command_arg(self._applied_model, env) == wanted_arg
):
_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.
+505 -2
View File
@@ -13,10 +13,12 @@ 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, Mapping
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
@@ -25,8 +27,10 @@ from typing import Any, Protocol, TypeAlias
from omnigent import model_catalog
from omnigent._platform import resolve_cli_binary
from omnigent.inner.agent_env import clean_agent_env, declared_passthrough
from omnigent.json_types import JsonObject as _JsonObject
from omnigent.llms._usage_observer import notify_from_dict as _notify_usage_from_dict
from omnigent.reasoning_effort import CODEX_EFFORTS, EFFORT_ALIASES, validate_effort
from omnigent.server.routing_contract import SUBAGENT_LOOPBACK_PATH
from omnigent.spec.types import RetryPolicy
from . import _proc
@@ -120,6 +124,9 @@ _CODEX_HOME_COPY_FILES = ("config.toml",)
# shared cache exactly as they would without the private home.
_CODEX_HOME_SYMLINK_DIRS = (Path("plugins") / "cache",)
_CODEX_MINIMAL_CONFIG_ENV = "HARNESS_CODEX_MINIMAL_CONFIG"
# 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"
# Environment variables explicitly excluded from the codex subprocess even
# when their prefix is in the allowlist. ``OPENAI_API_KEY`` is stripped so
@@ -681,6 +688,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.
@@ -713,6 +721,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
@@ -726,6 +737,10 @@ def _populate_codex_home_config(
symlink_files: tuple[str, ...] = _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():
@@ -860,6 +875,454 @@ def _normalize_copied_codex_effort(config_path: Path) -> None:
logger.warning("could not normalize model_reasoning_effort in %s", config_path)
# ─────────────────────────────────────────────────────────────────────────
# Codex subagent-routing: hooks.json generation + trust handshake
# ─────────────────────────────────────────────────────────────────────────
#
# The deterministic substrate for native subagent routing on codex. A
# ``PreToolUse`` hook on the spawn tool asks the runner's loopback endpoint
# what model an in-harness spawn may use and rewrites / denies accordingly.
# This module generates the Omnigent half of ``hooks.json`` (merged with the
# user's own hooks) and trusts it over the app-server protocol.
#
# Deliberately NOT generated here: the ``SessionStart`` canary and the
# ``SubagentStart`` audit hooks. Those belong to the codex enforcement stack
# (canary / watcher / spawn audit / banner) that the rewrite cuts entirely
# (plan 3b) — the product answer is to make hook execution reliable, not to
# report when it silently did not. Only the route-subagent gate that makes
# routing happen survives.
# Bound app-server JSON-RPC request coroutine, e.g. the SDK executor's
# ``_request`` (or a native client's ``request``). The trust handshake works
# over either transport because both expose this shape.
CodexRequestFn = Callable[[str, CodexParams], Awaitable[CodexMessage]]
# 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"
# The route-subagent hook script (wave-2 stream 3 owns this module; it is
# absent in this wave, hence referenced by dotted name, never imported).
_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"
# 0d (timeout ladder): the wave-2 subagent_router owns the authoritative
# per-request budget (its ``REQUEST_TIMEOUT_S = 30.0``); transcribed here as a
# literal because that module does not exist in this wave.
_CODEX_ROUTER_HOOK_REQUEST_TIMEOUT_SECONDS = 30
# 0d (timeout ladder): kept just above the hook's own request budget so
# codex's kill is the outermost bound — the hook fails open on its own
# timeout, and codex only steps in if the hook itself wedged.
_CODEX_ROUTER_HOOK_TIMEOUT_SECONDS = _CODEX_ROUTER_HOOK_REQUEST_TIMEOUT_SECONDS + 10
# 0d (version probe): minimum codex CLI that carries the hook-trust protocol
# (``currentHash`` / ``trustStatus`` in ``hooks/list``), added in codex 0.129.
# Below this floor a hook can never be trusted, so codex silently drops the
# generated hooks.json — the routing gate would fail open invisibly. Probed
# BEFORE the CODEX_HOME is populated so an unsupported codex keeps the user's
# hooks.json symlinked in rather than generating a file codex will discard.
_MIN_ROUTER_HOOK_CODEX_VERSION = (0, 129, 0)
# Codex hook trust statuses that let a hook actually execute. Anything else
# (notably ``untrusted``) means codex silently skips the hook.
_TRUSTED_HOOK_STATUSES = frozenset({"trusted", "managed"})
def _codex_router_hooks_supported(version: tuple[int, int, int] | None) -> bool:
"""
Report whether this codex build runs generated routing hooks.
0d (version probe): an UNPARSEABLE version (``None`` from the probe) is
treated as SUPPORTED. A flaky ``codex --version`` must never silently
disable routing — that would wedge a terminal on a spawn prompt no
subagent can answer. A genuinely old codex fails later at the trust gate,
which degrades loudly (a logged warning), not silently.
:param version: Parsed codex version, or ``None`` when the probe could
not parse one.
:returns: ``True`` when routing hooks should be generated + trusted.
"""
return version is None or version >= _MIN_ROUTER_HOOK_CODEX_VERSION
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 the routing hook event.
0d (``python -I``): runs python in isolated mode. 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 — the most likely
workspace of all) then shadows the installed package, the hook dies on
``ModuleNotFoundError``, and codex discards the failure — the routing
gate silently fails open. ``-I`` (isolated) drops cwd from ``sys.path``
so the real installed package always wins.
The generated command threads the contract's canonical
:data:`SUBAGENT_LOOPBACK_PATH` through ``--loopback-path`` so the wave-2
hook script targets the one frozen endpoint spelling rather than
re-hardcoding it.
:param subcommand: Hook-script subcommand, e.g. ``"route-subagent"``.
:param bridge_dir: Session bridge directory holding the router
advertisement.
: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),
"--loopback-path",
SUBAGENT_LOOPBACK_PATH,
]
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,
) -> _JsonObject:
"""
Build the Omnigent half of a routing ``hooks.json`` payload.
One event: a ``PreToolUse`` gate on the spawn tool, matched by regex
because codex flattens the tool name. This is the hook that makes
subagent routing happen; the cut enforcement stack's ``SessionStart``
canary and ``SubagentStart`` audit are deliberately not generated
(plan 3b).
:param bridge_dir: Session bridge directory.
:param session_id: Omnigent session id baked into the command.
:param harness: Harness label sent to the endpoint, e.g. ``"codex"``.
:param python_executable: Python for the hook command.
:returns: A ``hooks.json``-shaped dict.
"""
route_hook: _JsonObject = {
"type": "command",
"command": _codex_router_hook_command(
"route-subagent",
bridge_dir,
session_id=session_id,
python_executable=python_executable,
extra_args=("--harness", harness),
),
"timeout": _CODEX_ROUTER_HOOK_TIMEOUT_SECONDS,
}
return {
"hooks": {
"PreToolUse": [
{
"matcher": _CODEX_SPAWN_AGENT_MATCHER,
"hooks": [route_hook],
}
],
}
}
def merge_codex_user_hooks(payload: _JsonObject, user_hooks_path: Path) -> _JsonObject:
"""
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, object]]) -> _JsonObject:
"""
Merge ``hooks.json``-shaped payloads, earlier ones first per event.
Codex loads exactly one hooks file per ``CODEX_HOME``, so every generator
(routing hooks, the user's own hooks, and — on the native path — policy
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, list[object]] = {}
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
merged_hooks.setdefault(str(event), []).extend(entries)
return {"hooks": merged_hooks}
def write_codex_hooks_file(
codex_home: Path,
payloads: Sequence[Mapping[str, object]],
*,
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 hook (plus user hooks).
Used by harnesses that register no other hooks (the codex SDK executor);
the native app-server merges the routing payload with its policy hooks
through :func:`write_codex_hooks_file` 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 command.
:param harness: Harness label sent to the endpoint.
:param python_executable: Python for the hook command.
: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 _our_router_hooks_from_list(listed: _JsonObject, cwd: str) -> list[_JsonObject]:
"""
Extract the routing hooks for *cwd* from a ``hooks/list`` response.
Filtering by :data:`_CODEX_ROUTER_HOOK_MODULE` keeps the trust step from
ever touching a hook the user's own ``hooks.json`` contributed to the
merged file — the handshake trusts only what Omnigent generated.
: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.
: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 []
if not isinstance(data, list):
return []
for entry in data:
if isinstance(entry, dict) and entry.get("cwd") == cwd:
hooks = entry.get("hooks", [])
if not isinstance(hooks, list):
return []
return [
h
for h in hooks
if isinstance(h, dict) and _CODEX_ROUTER_HOOK_MODULE in str(h.get("command", ""))
]
return []
async def _persist_router_hook_trust(
request: CodexRequestFn, untrusted: list[_JsonObject]
) -> None:
"""
Write ``hooks.state.<key>.trusted_hash`` for each untrusted routing 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.
:param untrusted: Hook metadata dicts 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 hook so codex runs it.
0d (trust handshake): the handshake must complete AFTER the app-server
connects and BEFORE the first turn — codex resolves trust when it
dispatches a hook, so trust landing after the first spawn is too late.
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 this ``hooks/list`` → ``config/batchWrite`` →
re-``hooks/list`` handshake is the only way in.
Filters to hooks whose command runs :data:`_CODEX_ROUTER_HOOK_MODULE`, so
it never touches a user-contributed hook or (on the native path) the
separately-trusted policy hook. Best-effort: a routing-trust failure is
reported, never raised, so it never blocks session startup.
:param request: Bound app-server JSON-RPC request coroutine, e.g. the SDK
executor's ``_request`` or a native client's ``request``.
:param cwd: The session cwd the hooks are scoped to.
: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_router_hooks_from_list(listed, cwd)
if not ours:
logger.info("codex subagent-routing hooks: none discovered for cwd %s", 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_router_hook_trust(request, untrusted)
relisted = await request("hooks/list", {"cwds": [cwd]})
still_untrusted = [
h
for h in _our_router_hooks_from_list(relisted, cwd)
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 for this session: %s",
", ".join(str(h.get("key")) for h in still_untrusted),
)
return [str(h.get("key")) for h in still_untrusted]
logger.info(
"codex subagent-routing hooks trusted (%d of %d newly) for cwd %s",
len(untrusted),
len(ours),
cwd,
)
return []
def _databricks_codex_base_url(host: str) -> str:
"""Return the Unity AI Gateway Codex Responses base URL for *host*."""
return f"{host.rstrip('/')}/ai-gateway/codex/v1"
@@ -1364,10 +1827,35 @@ 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()
# 0d (version probe): only when the runner advertises a route-subagent
# endpoint. Probe the codex version BEFORE populating the home — a
# codex too old for the hook-trust protocol silently discards the
# generated hooks.json, so on an unsupported build no hooks file is
# generated at all and the user's hooks.json is symlinked in untouched.
# An unparseable version counts as supported (never wedge a terminal on
# a spawn prompt no subagent can answer). No routing endpoint → no
# probe, so a plain session never pays for a ``codex --version`` spawn.
router_bridge_dir = codex_router_bridge_dir(self._env)
router_hooks_registered = False
if router_bridge_dir is not None:
codex_version = await _codex_cli_version(self._codex_path)
router_hooks_registered = _codex_router_hooks_supported(codex_version)
_populate_codex_home_config(
self._codex_home_dir,
_codex_home_config_source_from_env(),
config_source,
subagent_routing=router_hooks_registered,
)
if router_hooks_registered and router_bridge_dir is not None:
# The generated hooks file owns hooks.json (the symlink was
# skipped above); merge the user's own hooks in rather than
# clobber them.
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.
@@ -1399,6 +1887,21 @@ class _CodexAppServerSession:
},
},
)
if router_hooks_registered:
# 0d (trust handshake): AFTER the app-server connects
# (``initialize`` above) and BEFORE the first turn. App-server
# threads run persisted-trusted hooks only, so without this
# the routing gate is silently skipped. Best-effort — a trust
# failure degrades routing (a logged warning), it never blocks
# session startup.
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,
)
self._started = True
except Exception:
await self.close()
+45 -1
View File
@@ -22,6 +22,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 (
@@ -40,6 +41,7 @@ from omnigent.inner.native_attachments import (
unresolved_attachment_marker,
)
from omnigent.reasoning_effort import CODEX_EFFORTS, validate_effort
from omnigent.server.routing_contract import MODEL_ID_PREFIXES, SERVABLE_ALIASES
_logger = logging.getLogger(__name__)
@@ -301,6 +303,21 @@ class CodexNativeExecutor(Executor):
**settings_overrides,
},
)
# Mirror the accepted switch into config.toml — the
# file the forwarder's model mirror
# (_refresh_model_from_config) 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, object] = {
"threadId": state.thread_id,
"input": input_items,
@@ -355,7 +372,7 @@ def _model_effort_overrides(config: ExecutorConfig | None) -> dict[str, object]:
overrides: dict[str, object] = {}
model = config.model
if isinstance(model, str) and model:
overrides["model"] = model
overrides["model"] = _served_codex_model(model)
raw_effort = config.extra.get("reasoning_effort")
try:
effort = validate_effort(raw_effort, "codex", CODEX_EFFORTS)
@@ -369,6 +386,33 @@ def _model_effort_overrides(config: ExecutorConfig | None) -> dict[str, object]:
return overrides
def _served_codex_model(model: str) -> str:
"""
Map a routed arm id onto the spelling the gateway actually serves.
The Smart Routing ``glm-5-2`` arm resolves to the catalog id
``databricks-glm-5-2``, which the codex turn cannot serve: that endpoint
advertises chat-completions only and 400s on ``/codex/v1``. Probed
2026-08-01 (staging + prod), the Responses API serves glm only under the
gateway model route ``system.ai.glm-5-2``, and glm appears in no discovery
listing, so the working spelling is pinned in the contract's
``SERVABLE_ALIASES`` (imported, never inlined). This is a spelling, not a
substitution: the alias strips to the same bare arm id, so decision records
(owned by the routing seam) still show ``glm-5-2`` with no substitution.
Idempotent — the served spelling maps to itself — and a no-op for every
non-aliased model.
:param model: The routed/pinned model id, in any catalog vocabulary.
:returns: The served spelling, or *model* unchanged when no alias applies.
"""
bare = model
for prefix in MODEL_ID_PREFIXES:
if bare.startswith(prefix):
bare = bare[len(prefix) :]
break
return SERVABLE_ALIASES.get(bare, model)
def _bridge_dir_from_env() -> Path:
"""
Resolve the native Codex bridge directory from harness spawn env.
+1
View File
@@ -0,0 +1 @@
"""Harness hook entry points for in-harness routing and other operations."""
@@ -0,0 +1,176 @@
"""Claude hook entry point for subagent routing.
Dispatches the ``route-subagent`` hook event to the shared routing client.
Runs as a subprocess under ``python -I`` (isolated mode) to avoid cwd shadowing.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from omnigent.inner.hook_scripts import subagent_router
def main() -> int:
"""
Claude hook entry point.
Dispatches subcommands:
- ``route-subagent``: handle an Agent/Task hook event.
Environment/CLI contract:
- ``--bridge-dir``: session bridge directory (or ``OMNIGENT_SUBAGENT_ROUTER_DIR``)
- ``--session-id``: session id (or ``OMNIGENT_SUBAGENT_ROUTER_SESSION_ID``)
- ``--loopback-path``: frozen loopback path contract (unused in routing, just documented)
- ``--harness``: harness name (defaults to "claude-native")
Returns 0 always (routing is advisory; hook errors degrade to "allow").
"""
parser = argparse.ArgumentParser(
description="Omnigent subagent routing hooks for Claude",
add_help=False, # Suppress -h so a missing subcommand doesn't show usage
)
parser.add_argument("subcommand", nargs="?", default="route-subagent")
parser.add_argument("--bridge-dir", type=str, default=None)
parser.add_argument("--router-dir", type=str, default=None)
parser.add_argument("--session-id", type=str, default=None)
parser.add_argument("--loopback-path", type=str, default=None) # Frozen contract; unused here
parser.add_argument("--harness", type=str, default="claude-native")
args, _ = parser.parse_known_args()
if args.subcommand == "route-subagent":
return handle_route_subagent(
bridge_dir=args.bridge_dir,
router_dir=args.router_dir,
session_id=args.session_id,
harness=args.harness,
)
# Unknown subcommand; fail open.
return 0
def handle_route_subagent(
bridge_dir: str | None,
router_dir: str | None,
session_id: str | None,
harness: str,
) -> int:
"""
Handle an Agent/Task routing hook event.
Reads the hook payload from stdin, routes it, and writes the output to stdout.
:param bridge_dir: Session bridge directory (from --bridge-dir or env).
:param router_dir: Explicit router dir (from --router-dir or env).
:param session_id: Session id (from --session-id or env).
:param harness: Harness name (from --harness or default).
:returns: 0 always (fail-open).
"""
try:
# Read the hook payload from stdin (claude passes hook data this way).
payload_text = sys.stdin.read()
if not payload_text:
# No input; fail open.
return 0
payload = json.loads(payload_text)
except (json.JSONDecodeError, EOFError, ValueError):
# Malformed input; fail open.
return 0
try:
# Extract session id from environment if not provided.
if not session_id:
session_id = (
os.environ.get(subagent_router.SESSION_ID_ENV_VAR, "").strip()
or os.environ.get(subagent_router.NATIVE_SESSION_ID_ENV_VAR, "").strip()
)
if not session_id:
# No session id; fail open.
return 0
# Discover the router endpoint.
discovery_dir = (
Path(router_dir)
if router_dir
else (Path(bridge_dir) if bridge_dir else subagent_router.discover_router_dir())
)
endpoint = subagent_router.read_router_endpoint(discovery_dir)
if endpoint is None:
# Router unreachable; fail open.
return 0
# Extract the tool input and spawn details from the claude hook payload.
hook_input = payload.get("hookInput", {})
tool_input = hook_input.get("toolInput", {})
# Build the routing request (claude sends the prompt in tool_input).
request_body = subagent_router.build_route_request(
tool_input,
harness=harness,
parent_model=None, # Claude sends this separately if needed.
task_keys=("subagent_type", "agent_name", "task_name"),
include_prompt=True, # Claude spawns include the prompt.
)
# Call the runner's loopback endpoint.
decision = subagent_router.request_decision(endpoint, session_id, request_body)
if decision is None:
# Loopback error; fail open.
return 0
# Extract the action from the decision.
action = decision.get("action", "allow")
rationale = decision.get("rationale", "")
model = decision.get("model")
action_str = str(action) if action else "allow"
rationale_str = str(rationale) if rationale else ""
model_str = str(model) if model else None
if action_str == "deny":
# Deny the spawn.
output = subagent_router.decision_to_deny_output(rationale_str)
elif action_str in ("allow", "rewrite", "redirect"):
# Allow or rewrite with a model.
if model_str:
output = subagent_router.decision_to_allow_output(
tool_input, model_str, reason=rationale_str
)
else:
# No model in the decision; fail open (allow unchanged).
model_fallback = str(tool_input.get("model", "")) if tool_input.get("model") else ""
output = subagent_router.decision_to_allow_output(
tool_input,
model_fallback,
reason=rationale_str or "No routing model",
)
else:
# Unknown action; fail open.
model_fallback = str(tool_input.get("model", "")) if tool_input.get("model") else ""
output = subagent_router.decision_to_allow_output(
tool_input, model_fallback, reason="Unknown routing action"
)
# Write the hook output to stdout.
sys.stdout.write(json.dumps(output))
sys.stdout.flush()
except Exception: # noqa: BLE001
# Any other error; fail open by writing nothing.
pass
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,177 @@
"""Codex hook entry point for subagent routing.
Dispatches the ``route-subagent`` hook event to the shared routing client.
Runs as a subprocess under ``python -I`` (isolated mode) to avoid cwd shadowing.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from omnigent.inner.hook_scripts import subagent_router
def main() -> int:
"""
Codex hook entry point.
Dispatches subcommands:
- ``route-subagent``: handle a spawn_agent hook event.
Environment/CLI contract:
- ``--bridge-dir``: session bridge directory (or ``OMNIGENT_SUBAGENT_ROUTER_DIR``)
- ``--session-id``: session id (or ``OMNIGENT_SUBAGENT_ROUTER_SESSION_ID``)
- ``--loopback-path``: frozen loopback path contract (unused in routing, just documented)
- ``--harness``: harness name (defaults to "codex-native")
Returns 0 always (routing is advisory; hook errors degrade to "allow").
"""
parser = argparse.ArgumentParser(
description="Omnigent subagent routing hooks for Codex",
add_help=False, # Suppress -h so a missing subcommand doesn't show usage
)
parser.add_argument("subcommand", nargs="?", default="route-subagent")
parser.add_argument("--bridge-dir", type=str, default=None)
parser.add_argument("--router-dir", type=str, default=None)
parser.add_argument("--session-id", type=str, default=None)
parser.add_argument("--loopback-path", type=str, default=None) # Frozen contract; unused here
parser.add_argument("--harness", type=str, default="codex-native")
args, _ = parser.parse_known_args()
if args.subcommand == "route-subagent":
return handle_route_subagent(
bridge_dir=args.bridge_dir,
router_dir=args.router_dir,
session_id=args.session_id,
harness=args.harness,
)
# Unknown subcommand; fail open.
return 0
def handle_route_subagent(
bridge_dir: str | None,
router_dir: str | None,
session_id: str | None,
harness: str,
) -> int:
"""
Handle a spawn_agent routing hook event.
Reads the hook payload from stdin, routes it, and writes the output to stdout.
:param bridge_dir: Session bridge directory (from --bridge-dir or env).
:param router_dir: Explicit router dir (from --router-dir or env).
:param session_id: Session id (from --session-id or env).
:param harness: Harness name (from --harness or default).
:returns: 0 always (fail-open).
"""
try:
# Read the hook payload from stdin (codex passes hook data this way).
payload_text = sys.stdin.read()
if not payload_text:
# No input; fail open.
return 0
payload = json.loads(payload_text)
except (json.JSONDecodeError, EOFError, ValueError):
# Malformed input; fail open.
return 0
try:
# Extract session id from environment if not provided.
if not session_id:
session_id = (
os.environ.get(subagent_router.SESSION_ID_ENV_VAR, "").strip()
or os.environ.get(subagent_router.NATIVE_SESSION_ID_ENV_VAR, "").strip()
)
if not session_id:
# No session id; fail open.
return 0
# Discover the router endpoint.
discovery_dir = (
Path(router_dir)
if router_dir
else (Path(bridge_dir) if bridge_dir else subagent_router.discover_router_dir())
)
endpoint = subagent_router.read_router_endpoint(discovery_dir)
if endpoint is None:
# Router unreachable; fail open.
return 0
# Extract the tool input and spawn details from the codex hook payload.
# Codex spawns are encrypted, so we send prompt=None to the router.
hook_input = payload.get("hookInput", {})
tool_input = hook_input.get("toolInput", {})
# Build the routing request.
request_body = subagent_router.build_route_request(
tool_input,
harness=harness,
parent_model=None, # Codex sends this separately if needed.
task_keys=("task_name", "agent_name", "subagent_type"),
include_prompt=False, # Codex spawns are encrypted.
)
# Call the runner's loopback endpoint.
decision = subagent_router.request_decision(endpoint, session_id, request_body)
if decision is None:
# Loopback error; fail open.
return 0
# Extract the action from the decision.
action = decision.get("action", "allow")
rationale = decision.get("rationale", "")
model = decision.get("model")
action_str = str(action) if action else "allow"
rationale_str = str(rationale) if rationale else ""
model_str = str(model) if model else None
if action_str == "deny":
# Deny the spawn.
output = subagent_router.decision_to_deny_output(rationale_str)
elif action_str in ("allow", "rewrite", "redirect"):
# Allow or rewrite with a model.
if model_str:
output = subagent_router.decision_to_allow_output(
tool_input, model_str, reason=rationale_str
)
else:
# No model in the decision; fail open (allow unchanged).
model_fallback = str(tool_input.get("model", "")) if tool_input.get("model") else ""
output = subagent_router.decision_to_allow_output(
tool_input,
model_fallback,
reason=rationale_str or "No routing model",
)
else:
# Unknown action; fail open.
model_fallback = str(tool_input.get("model", "")) if tool_input.get("model") else ""
output = subagent_router.decision_to_allow_output(
tool_input, model_fallback, reason="Unknown routing action"
)
# Write the hook output to stdout.
sys.stdout.write(json.dumps(output))
sys.stdout.flush()
except Exception: # noqa: BLE001
# Any other error; fail open by writing nothing.
pass
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,250 @@
"""Shared subagent-routing decision logic for harness hooks.
Stdlib-only on purpose: 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 runner advertises its ``route-subagent`` endpoint by writing
``subagent_router.json`` (``{"url": ..., "token": ...}``) 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 routing decision call POSTs a request body to the loopback endpoint,
which forwards to the server relay and returns a :class:`SubagentRouteDecision`.
On any failure, the hook allows the spawn unchanged (routing is advisory).
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
#: Advertisement file written by the runner's subagent-routing endpoint.
ADVERTISEMENT_FILE = "subagent_router.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"
#: Hosts an advertised router URL may name (advisory, prevent same-uid exfiltration).
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"})
#: Timeout for HTTP requests to the loopback endpoint (hop 2 of the timeout budget).
REQUEST_TIMEOUT_S = 30.0
#: Subagent types that inherit context instead of starting fresh.
FORK_SUBAGENT_TYPES = frozenset({"fork"})
_FORK_SUFFIXES = ("-fork", "_fork", ":fork")
class RouterEndpoint:
"""Advertised ``route-subagent`` endpoint."""
def __init__(self, url: str, token: str, session_id: str | None = None):
self.url = url
self.token = token
self.session_id = session_id
def discover_router_dir(bridge_dir: str | Path | None = None) -> Path | None:
"""
Locate the directory holding the router advertisement.
Falls back to environment variables if no explicit directory is provided.
:param bridge_dir: Explicit directory path, or ``None`` to check env vars.
: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 is validated before use: the URL must be plain ``http``
on a loopback address. A missing, malformed, or unsafe advertisement
means "router unreachable".
:param router_dir: Directory containing :data:`ADVERTISEMENT_FILE`.
:returns: Endpoint, or ``None`` when the advertisement is missing or invalid.
"""
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 url or not token:
return None
# Validate the URL: must be http (not https) on a loopback host.
try:
parsed = urllib.parse.urlparse(url)
if parsed.scheme != "http" or parsed.hostname not in _LOOPBACK_HOSTS:
return None
except Exception: # noqa: BLE001
return None
session_id = payload.get("session_id")
return RouterEndpoint(url, token, session_id)
def spawn_task_name(tool_input: dict[str, Any], task_keys: tuple[str, ...] = ()) -> str | None: # type: ignore[explicit-any]
"""
Extract the subagent task/agent name from the tool input.
:param tool_input: The hook's ``tool_input`` dict.
:param task_keys: Keys to try in order (e.g., ``("subagent_type", "task_name")``).
:returns: Task name string, or ``None`` if not found.
"""
if not task_keys:
task_keys = ("subagent_type", "task_name", "agent_name")
for key in task_keys:
value = tool_input.get(key)
if isinstance(value, str) and value:
return value
return None
def is_fork_spawn(tool_input: dict[str, object], task_keys: tuple[str, ...] = ()) -> bool:
"""
Check if the spawn is a fork (inherits context from caller).
:param tool_input: The hook's ``tool_input`` dict.
:param task_keys: Keys to check for the spawn type.
:returns: ``True`` if the spawn is a fork variant.
"""
task_name = spawn_task_name(tool_input, task_keys)
if task_name in FORK_SUBAGENT_TYPES:
return True
if task_name:
return any(task_name.endswith(suffix) for suffix in _FORK_SUFFIXES)
return False
def build_route_request(
tool_input: dict[str, object],
harness: str,
parent_model: str | None = None,
task_keys: tuple[str, ...] = (),
include_prompt: bool = True,
) -> dict[str, object]:
"""
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: Keys naming the subagent (e.g., ``("subagent_type",)``).
:param include_prompt: ``False`` sends ``prompt: null`` for encrypted prompts (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, object],
timeout: float = REQUEST_TIMEOUT_S,
) -> dict[str, object] | None:
"""
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 dict, or ``None`` on any failure (caller allows spawn unchanged).
"""
url = f"{endpoint.url}/v1/sessions/{urllib.parse.quote(session_id, safe='')}/route-subagent"
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 decision_to_allow_output(
tool_input: dict[str, object],
model: str,
reason: str = "",
) -> dict[str, object]:
"""
Map a routing decision to a PreToolUse hook "allow" output.
:param tool_input: Original hook ``tool_input``.
:param model: Routed model id to pass to the spawn.
:param reason: Optional human-readable reason for the decision.
:returns: Hook output dict suitable for returning to the harness.
"""
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 decision_to_deny_output(reason: str) -> dict[str, object]:
"""
Map a routing decision to a PreToolUse hook "deny" output.
:param reason: Human-readable reason for the denial.
:returns: Hook output dict suitable for returning to the harness.
"""
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
+90
View File
@@ -28,6 +28,96 @@ _CLAUDE_SUBSCRIPTION_MODELS = (
_CODEX_MODELS = ("gpt-5-6-sol", "gpt-5-6-luna", "gpt-5-6-terra", "gpt-5-5")
# ── Smart Routing task_v1 arms + family fallback (routing rebuild) ───────────
#
# The frozen task_v1 arm menus and the one-per-family fallback. These are static
# because task_v1's arm set is a wire contract (the router 400s on a partial
# menu) and is frozen upstream, so they cannot be discovered from a workspace
# catalog. Owned here with provenance per the no-hardcoded-ids guard; the
# routing seam (omnigent/server/smart_routing.py) and routing_contract import
# them rather than inlining the literals.
_TASK_V1_CLAUDE_ARMS: tuple[str, ...] = ("claude-opus-4-8", "claude-sonnet-5")
_TASK_V1_CODEX_ARMS: tuple[str, ...] = ("glm-5-2", "gpt-5-6-sol", "gpt-5-6-luna")
TASK_V1_ARMS: dict[str, StaticModelFallback] = {
"claude": StaticModelFallback(
model_ids=_TASK_V1_CLAUDE_ARMS,
owner="Smart Routing task_v1 claude arms",
provenance="Omnigent's frozen task_v1 router arm menu (claude family)",
discovery_gap=(
"task_v1's arm set is a frozen wire contract; sending a partial "
"menu 400s, so the arms cannot be derived from a workspace catalog"
),
),
"codex": StaticModelFallback(
model_ids=_TASK_V1_CODEX_ARMS,
owner="Smart Routing task_v1 codex arms",
provenance="Omnigent's frozen task_v1 router arm menu (codex/gpt family)",
discovery_gap=(
"task_v1's arm set is a frozen wire contract; sending a partial "
"menu 400s, so the arms cannot be derived from a workspace catalog"
),
),
}
# One fixed fallback per family when the workspace does not serve the router's
# pick (plan 3i rule 3). claude -> the id the ``sonnet`` alias pin resolves to;
# gpt AND glm -> luna, itself a frozen codex arm so a glm fallback never leaves
# the codex harness. The fallback stamps raw_model so the record stays honest.
# The ids live in module-level tuples consumed only as StaticModelFallback
# model_ids (the guard-owned form); the lookups below are built from those
# records, never from fresh literals.
_CLAUDE_FALLBACK_IDS = ("databricks-claude-sonnet-5",)
_CODEX_FALLBACK_IDS = ("databricks-gpt-5-6-luna",)
# The glm gateway spelling pin as an (arm, served-spelling) pair. glm-5-2 serves
# the Responses API only under system.ai.glm-5-2 (probed 2026-08-01,
# staging+prod). A spelling, not a substitution (plan 3i).
_GLM_SPELLING_IDS = ("glm-5-2", "system.ai.glm-5-2")
_FAMILY_FALLBACK_RECORDS: dict[str, StaticModelFallback] = {
"claude": StaticModelFallback(
model_ids=_CLAUDE_FALLBACK_IDS,
owner="Smart Routing claude family fallback",
provenance="Bryan's per-family fallback ruling 2026-08-02 (claude -> sonnet)",
discovery_gap=(
"the fallback target must be deterministic when discovery reports "
"the picked arm unservable; it is a fixed product decision"
),
),
"codex": StaticModelFallback(
model_ids=_CODEX_FALLBACK_IDS,
owner="Smart Routing codex family fallback",
provenance="Bryan's per-family fallback ruling 2026-08-02 (gpt/glm -> luna)",
discovery_gap=(
"the fallback target must be deterministic when discovery reports "
"the picked arm unservable; it is a fixed product decision"
),
),
}
_GLM_SPELLING_RECORD = StaticModelFallback(
model_ids=_GLM_SPELLING_IDS,
owner="Smart Routing glm gateway spelling pin",
provenance=(
"probed 2026-08-01 staging+prod: glm serves the Responses API only "
"under its system.ai route"
),
discovery_gap="the served spelling differs from the catalog id and is not discoverable",
)
#: Family -> its fallback catalog id, built from the owned records above.
FAMILY_FALLBACK_ID: dict[str, str] = {
"claude": _FAMILY_FALLBACK_RECORDS["claude"].model_ids[0],
"gpt": _FAMILY_FALLBACK_RECORDS["codex"].model_ids[0],
"glm": _FAMILY_FALLBACK_RECORDS["codex"].model_ids[0],
}
#: Router arm id -> the gateway spelling it is actually served under.
SERVABLE_ALIASES: dict[str, str] = {
_GLM_SPELLING_RECORD.model_ids[0]: _GLM_SPELLING_RECORD.model_ids[1],
}
_STATIC_MODEL_FALLBACKS = {
(SUBSCRIPTION_KIND, "claude"): StaticModelFallback(
model_ids=_CLAUDE_SUBSCRIPTION_MODELS,
+6
View File
@@ -1848,6 +1848,12 @@ async def _execute_subagent_tool(
"title": f"{sub_agent_name}:{session_name}",
"sub_agent_name": sub_agent_name,
}
# Carry the spawn's task text as the child's smart-routing message.
# Smart Routing is decided once at create; a routing-on parent's child
# is forced to harness_override="auto" server-side, and this lets the
# create-time router pick the child's harness + model from its task
# (there is no per-turn routing to resolve it later).
create_body["smart_routing_message"] = message
if harness_override_canonical is not None:
create_body["harness_override"] = harness_override_canonical
if model is not None:
+9
View File
@@ -78,3 +78,12 @@ 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
# Wave-0 contract (routing rebuild, plan 2f/7h). Per-REQUEST backend
# predicate: the seam consults it on every route() to pick the backend —
# True => the external AI Gateway client, False => the LLM judge. Mirrors
# policy_llm_connection_factory above: the managed plugin registers it and
# binds it to its SAFE flag (databricks.mas.omnigent.intelligentRouting,
# default OFF), so a flag-off workspace still routes via the judge. ``None``
# => the seam's default (gateway client when configured, judge otherwise).
# Type is routing_contract.RoutingBackendPredicate; wave-1 s2 consumes it.
routing_backend_predicate: Callable[[], bool] | None = None
+50 -285
View File
@@ -664,6 +664,7 @@ def _build_session_response(
runner_online: bool | None = None,
host_online: bool | None = None,
host_resumable: bool = False,
gateway_inference: dict[str, bool] | None = None,
pending_elicitation_events: list[dict[str, Any]] | None = None,
subtree_usage: dict[str, Any] | None = None,
model_options: list[dict[str, Any]] | None = None,
@@ -723,6 +724,10 @@ def _build_session_response(
``None`` when the session has no ``host_id`` or no lookup is
wired (see :class:`SessionLiveness`). Used only to decide what
the open view shows when ``runner_online`` is ``False``.
:param gateway_inference: This session's host's per-family gateway
backing (harness spelling -> bool), consumed by the web to gate
Smart Routing. ``None`` when the session has no host or the host
reported nothing (unknown, which never hides the option) plan 3f.
:param pending_elicitation_events: Optional precomputed
outstanding elicitation events. ``None`` reads only the
current session's entries from the pending-elicitations index.
@@ -786,6 +791,10 @@ def _build_session_response(
harness=_resolve_harness(conv),
model_override=conv.model_override,
cost_control_mode_override=conv.cost_control_mode_override,
# Per-family gateway backing of this session's host, so the web can
# gate the Smart Routing option (plan 3f). None = host reported
# nothing (unknown), which never hides the option.
gateway_inference=gateway_inference,
context_window=context_window,
last_total_tokens=last_total_tokens,
# Seed the client's cost indicator on resume. Uses the SUBTREE
@@ -3664,175 +3673,20 @@ async def _forward_event_to_runner(
effective_runner_override = (
body.model_override if body.model_override is not None else conv.model_override
)
# ── Auto-harness resolution ───────────────────────────────────────
# When the session was created with harness_override="auto", the real
# harness + model are determined here on the first message where user
# text is available. After resolution the sentinel is replaced with
# the concrete harness so subsequent turns behave normally.
# Tracks whether this block ran the router this turn, so the per-turn
# routing block below doesn't re-route the same message (which would
# double the judge call, emit two cards, and risk a mismatched pick).
_auto_resolved_this_turn = False
# Auto-harness verdict captured for card emission AFTER the runner forward
# and input.consumed (so the live SSE stream delivers the user bubble
# before the routing card, matching the per-turn routing path).
_auto_card_model: str | None = None
_auto_card_verdict: dict[str, Any] | None = None
if conv.harness_override == "auto" and body.type == "message":
from omnigent.server.smart_routing import route_session_harness
_auto_text = _extract_user_text_for_routing(body)
if _auto_text:
_auto_resolved_this_turn = True
# For a forced-auto child, route against the parent's catalog (full
# spawnable-worker map) rather than the child's leaf "self" catalog.
_auto_harness, _auto_model, _auto_verdict, _auto_error = await route_session_harness(
_auto_text,
session_id=session_id,
catalog_session_id=conv.parent_conversation_id,
runner_client=runner_client,
)
try:
# Always clear the "auto" sentinel even when routing
# returned no harness (unavailable/failed) so the branch
# doesn't re-run on every subsequent turn.
_conv_updates: dict[str, Any] = (
{"harness_override": _auto_harness}
if _auto_harness is not None
else {"_unset_harness_override": True}
)
if _auto_model is not None and effective_runner_override is None:
_conv_updates["model_override"] = _auto_model
effective_runner_override = _auto_model
_updated = await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
**_conv_updates,
)
if _updated is not None:
conv = _updated
except (OSError, ValueError):
_logger.warning(
"auto-harness: failed to persist resolved harness for session=%s",
session_id,
exc_info=True,
)
# Defer card emission until after input.consumed (see below).
if _auto_model is not None and _auto_verdict is not None:
_auto_card_model = _auto_model
_auto_card_verdict = _auto_verdict
elif _auto_error is not None:
# Routing failed — surface why auto-harness fell back to defaults.
_auto_card_model = "unavailable"
_auto_card_verdict = {"rationale": _auto_error, "applied": False}
# ── Server-side intelligent routing ──────────────────────────────
# When the session toggle is ON and no model has been chosen yet,
# call the judge LLM on the FIRST message to pick the model for
# the entire session. The verdict is persisted as model_override
# on the conversation so subsequent turns reuse it without another
# judge call.
# Route if: toggle is on for this session (top-level), OR this is a
# sub-agent and its parent session has the toggle on.
_parent_routing_on = False
if conv.parent_conversation_id is not None:
_parent_conv = await asyncio.to_thread(
conversation_store.get_conversation, conv.parent_conversation_id
)
_parent_routing_on = (
_parent_conv is not None and _parent_conv.cost_control_mode_override == "on"
)
_routing_enabled = (
conv.cost_control_mode_override == "on" and conv.parent_conversation_id is None
) or _parent_routing_on
_routed_model: str | None = None
_routed_harness: str | None = None
_verdict: dict[str, Any] | None = None
# For child sessions, route even when the orchestrator specified a model via
# sys_session_send (effective_runner_override is already set). Smart routing
# always wins over the LLM's own model choice when the parent toggle is on.
_should_route = (
_routing_enabled
and body.type == "message"
# The auto-harness block above already routed this turn (harness +
# model) — don't re-run the router for the same message.
and not _auto_resolved_this_turn
and (effective_runner_override is None or conv.parent_conversation_id is not None)
)
if _should_route:
_user_text = _extract_user_text_for_routing(body)
if _user_text:
if _parent_routing_on:
# Child sessions: use route_session_harness to pick both harness
# and model, overriding whatever the orchestrator specified in
# sys_session_send.
from omnigent.server.smart_routing import route_session_harness
# Route against the PARENT's catalog: it enumerates the
# spawnable workers (claude_code/codex/pi) with full model
# lists, whereas this child's own leaf catalog is "self"-only
# and would force the static fallback (a smaller/different set).
_routed_harness, _routed_model, _verdict, _route_err = await route_session_harness(
_user_text,
session_id=session_id,
catalog_session_id=conv.parent_conversation_id,
runner_client=runner_client,
)
if _routed_model is not None:
effective_runner_override = _routed_model
try:
_child_updates: dict[str, Any] = {}
if _routed_model is not None:
_child_updates["model_override"] = _routed_model
if _routed_harness is not None:
_child_updates["harness_override"] = _routed_harness
if _child_updates:
await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
**_child_updates,
)
except (OSError, ValueError):
_logger.warning(
"smart_routing: failed to persist harness/model for child session=%s",
session_id,
exc_info=True,
)
else:
# Top-level sessions: model-only routing (harness already fixed by spec).
from omnigent.server.smart_routing import route_turn
_harness = _resolve_harness(conv)
_routed_model, _verdict = await route_turn(
_harness,
_user_text,
session_id=session_id,
runner_client=runner_client,
)
if _routed_model is not None:
effective_runner_override = _routed_model
# Persist as the session's model_override so all
# subsequent turns use this model automatically.
try:
await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
model_override=_routed_model,
)
except (OSError, ValueError):
_logger.warning(
"smart_routing: failed to persist model_override "
"for session=%s; turn still uses routed model",
session_id,
exc_info=True,
)
# ────────────────────────────────────────────────────────────────
# Smart Routing is decided ONCE at session create (see
# ``omnigent/server/routing_create.py``); there is no per-turn routing. A
# session created with a ``smart_routing_message`` already has its
# ``model_override`` (and, for the ``auto`` harness, its resolved
# ``harness_override``) persisted on the row, and the reads below apply
# them. A session created without a routing message uses the harness
# default and is never routed later.
if effective_runner_override is not None:
runner_body["model_override"] = effective_runner_override
# Per-session brain-harness override — create-time only, so no
# per-event value exists; the persisted column is the source.
# _routed_harness is non-None when the child routing path resolved one
# this turn (conv is not refreshed, so we use the in-flight value).
_effective_harness = _routed_harness or conv.harness_override
# Per-session brain-harness override — create-time only, so no per-event
# value exists; the persisted column is the source. A never-resolved
# ``auto`` sentinel (a session created auto but without a routing message)
# is not a launchable harness, so it is not forwarded.
_effective_harness = conv.harness_override
if _effective_harness is not None and _effective_harness != "auto":
runner_body["harness_override"] = _effective_harness
@@ -3848,45 +3702,8 @@ async def _forward_event_to_runner(
# Publish input.consumed AFTER the forward succeeds —
# the runner has the message and will start the turn.
_publish_input_consumed(session_id, persisted_items[0])
# Emit the routing_decision chip AFTER input.consumed so the
# live SSE stream delivers the user bubble before the chip —
# matching the store order (user message was persisted first).
# Auto-harness card (success or failure) emitted here for the same
# ordering reason; it was resolved earlier in the turn.
if _auto_card_model is not None and _auto_card_verdict is not None:
await _emit_server_routing_decision(
session_id,
conversation_store,
_auto_card_model,
_auto_card_verdict,
)
if conv.parent_conversation_id is not None:
await _emit_server_routing_decision(
conv.parent_conversation_id,
conversation_store,
_auto_card_model,
_auto_card_verdict,
agent=agent_name or "",
)
if _routed_model is not None and _verdict is not None:
await _emit_server_routing_decision(
session_id,
conversation_store,
_routed_model,
_verdict,
)
# Mirror the routing decision into the parent session so the
# orchestrator's transcript also shows which model was chosen
# for this sub-agent — the decision is otherwise only visible
# on the child session screen.
if _parent_routing_on and conv.parent_conversation_id is not None:
await _emit_server_routing_decision(
conv.parent_conversation_id,
conversation_store,
_routed_model,
_verdict,
agent=agent_name or "",
)
# The routing_decision chip is emitted by the create path (routing is
# decided once at session create); no per-turn chip is emitted here.
except (httpx.HTTPError, ConnectionError) as exc:
_logger.exception(
"Forward to runner failed for session=%s",
@@ -4041,61 +3858,10 @@ async def _dispatch_session_event_to_runner_impl(
if isinstance(content, list) and content
else None
)
# ── Server-side routing for native terminal sessions ────────
# Same logic as the SDK path in _forward_event_to_runner: if
# the toggle is on and no model_override is set, call the
# judge and persist the chosen model on the conversation row.
# The native CLI reads model_override from the session.
_native_parent_routing_on = False
if conv.parent_conversation_id is not None:
_native_parent_conv = await asyncio.to_thread(
conversation_store.get_conversation, conv.parent_conversation_id
)
_native_parent_routing_on = (
_native_parent_conv is not None
and _native_parent_conv.cost_control_mode_override == "on"
)
_native_routing_enabled = (
conv.cost_control_mode_override == "on" and conv.parent_conversation_id is None
) or _native_parent_routing_on
_native_routed_model: str | None = None
_native_verdict: dict[str, Any] | None = None
if _native_routing_enabled and (
conv.model_override is None or conv.parent_conversation_id is not None
):
from omnigent.server.smart_routing import route_turn
_harness = _resolve_harness(conv)
_user_text = _extract_user_text_for_routing(body)
if _user_text:
_native_runner_client = await _get_runner_client(session_id, runner_router)
_native_routed_model, _native_verdict = await route_turn(
_harness,
_user_text,
session_id=session_id,
runner_client=_native_runner_client,
)
if _native_routed_model is not None:
try:
await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
model_override=_native_routed_model,
)
except (OSError, ValueError):
_logger.warning(
"smart_routing: persist failed for native session=%s",
session_id,
exc_info=True,
)
# ────────────────────────────────────────────────────────────
# Forward the message, carrying any routed model in-band. The
# executor applies ``/model`` and injects the message as ONE step
# under its pane lock, so the switch can't race the message inject
# on the tmux pane (the earlier separate ``model_change`` POST did,
# dropping the first message). model_override alone is applied only
# at spawn, so the in-band switch is what makes routing take on an
# already-running pane.
# A native terminal session's model is routed once at session create
# (routing_create.py) and applied at spawn from the persisted
# model_override; there is no per-turn routing and no in-band model
# switch. Forward the message without a per-turn model override.
forwarded = False
try:
await _forward_native_terminal_message(
@@ -4105,7 +3871,7 @@ async def _dispatch_session_event_to_runner_impl(
body,
file_store=file_store,
artifact_store=artifact_store,
model_override=_native_routed_model,
model_override=None,
created_by=created_by,
author_attribution_required=author_attribution_required,
)
@@ -4113,24 +3879,6 @@ async def _dispatch_session_event_to_runner_impl(
finally:
if not forwarded and pending_id is not None:
pending_inputs.resolve(session_id, pending_id)
# Emit the routing chip AFTER forwarding the message to the
# terminal so the live SSE stream delivers the user bubble
# (echoed back by the CLI) before the chip.
if _native_routed_model is not None and _native_verdict is not None:
await _emit_server_routing_decision(
session_id,
conversation_store,
_native_routed_model,
_native_verdict,
)
if _native_parent_routing_on and conv.parent_conversation_id is not None:
await _emit_server_routing_decision(
conv.parent_conversation_id,
conversation_store,
_native_routed_model,
_native_verdict,
agent=agent_name or "",
)
return _SessionEventDispatchResult(item_id=None, pending_id=pending_id)
item_id = await _forward_event_to_runner(
session_id,
@@ -5429,10 +5177,20 @@ async def _create_session_from_existing_agent(
# Validated against the loaded spec (known harness + omnigent
# executor type) before any row exists, mirroring the CLI's
# --harness fail-loud rules.
# "auto" defers harness + model selection to the first-message routing
# path; validate executor type now but store the sentinel unchanged.
# A routing-on parent's child is forced to "auto" ONLY when it has not
# already been routed at create: the create path (routes_core) resolves a
# forced-auto child's harness + model from its smart_routing_message and
# rewrites body.harness_override to the concrete native harness. When that
# has happened, honor the resolved override instead of re-forcing "auto"
# (which would discard the routed model). An unresolved child (routing
# unavailable) still falls back to the "auto" sentinel.
_already_routed = (
_force_auto_for_child
and body.harness_override is not None
and body.harness_override != "auto"
)
harness_override: str | None
if _force_auto_for_child or body.harness_override == "auto":
if not _already_routed and (_force_auto_for_child or body.harness_override == "auto"):
await asyncio.to_thread(_validated_harness_override_executor_type, agent)
harness_override = "auto"
# Ignore any orchestrator-supplied model; routing picks it.
@@ -6720,10 +6478,16 @@ async def _get_session_snapshot(
# liveness arrives via the poll/stream). One indexed host read, gated to
# host-bound sessions.
host_resumable = False
if host_store is not None and sandbox_config is not None and conv.host_id is not None:
# This session's host's per-family gateway backing, surfaced so the web
# can gate the Smart Routing option (plan 3f). None = no host binding or
# the host reported nothing (unknown, which never hides the option).
gateway_inference: dict[str, bool] | None = None
if host_store is not None and conv.host_id is not None:
host_for_resume = await asyncio.to_thread(host_store.get_host, conv.host_id)
if host_for_resume is not None:
host_resumable = host_resume_supported(host_for_resume, sandbox_config)
gateway_inference = host_for_resume.gateway_inference
if sandbox_config is not None:
host_resumable = host_resume_supported(host_for_resume, sandbox_config)
return _build_session_response(
conv,
items,
@@ -6741,6 +6505,7 @@ async def _get_session_snapshot(
runner_online=runner_online,
host_online=host_online,
host_resumable=host_resumable,
gateway_inference=gateway_inference,
pending_elicitation_events=await asyncio.to_thread(
_pending_elicitation_snapshot_for_session,
conv_store,
+7
View File
@@ -254,6 +254,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(
@@ -473,8 +474,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)
@@ -619,6 +624,7 @@ async def _receive_loop(
{
"status": frame.status,
"configured_harnesses": frame.configured_harnesses,
"gateway_inference": frame.gateway_inference,
"error": frame.error,
}
)
@@ -631,6 +637,7 @@ async def _receive_loop(
{
"status": frame.status,
"configured_harnesses": frame.configured_harnesses,
"gateway_inference": frame.gateway_inference,
"error": frame.error,
}
)
+16 -4
View File
@@ -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}
@@ -639,6 +642,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 +1237,9 @@ 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 per-family gateway backing.
: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 +1310,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 +1342,9 @@ 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 host's refreshed per-family gateway backing.
: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 +1412,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")
@@ -103,6 +103,14 @@ from omnigent.server.routes._sessions.common import (
)
from omnigent.server.routes._sessions.helpers import *
from omnigent.server.routes._sessions.orchestration import *
from omnigent.server.routing_create import (
resolve_fixed_native_model_routing,
resolve_smart_routing_create,
)
from omnigent.server.routing_decision_store import (
build_decision,
persist_decision,
)
from omnigent.server.schemas import (
AutomaticSessionRenameRequest,
AutomaticSessionRenameResponse,
@@ -233,6 +241,97 @@ def register_core_routes(
# message survives in each entry's `msg`.
raise HTTPException(status_code=422, detail=exc.errors(include_context=False)) from exc
# Smart routing: consume body.smart_routing_message to route harness and/or model
# at create time (wave-2 stream 2, plan 2e/2f). On resolver failure, create still
# succeeds with defaults (fail-open philosophy). Routing decisions are persisted
# after the session is created (see below, after resp.id is available).
routing_decisions_to_persist: list[tuple[str, Any]] = []
if body.smart_routing_message and (body.smart_routing_message or "").strip():
# Get a runner client for the routing call (may be None if routing unavailable).
runner_client = await _get_runner_client(None, runner_router)
# A sub-agent spawned under a routing-on parent is forced to the
# "auto" harness server-side (see _create_session_from_existing_agent);
# the request itself carries no harness_override. Detect that here so
# the child routes harness + model at create like an explicit "auto".
_route_as_auto = body.harness_override == "auto"
if not _route_as_auto and body.parent_session_id is not None:
_parent = await asyncio.to_thread(
conversation_store.get_conversation, body.parent_session_id
)
_route_as_auto = (
_parent is not None and _parent.cost_control_mode_override == "on"
)
# Smart Routing path: auto-harness picks both harness and model.
if _route_as_auto:
harness, model, verdict, error = await resolve_smart_routing_create(
body.smart_routing_message,
session_id=None, # Session doesn't exist yet
catalog_session_id=None,
runner_client=runner_client,
)
if harness is not None and model is not None:
# Router accepted the routing; apply it and record the decision.
body.harness_override = harness
body.model_override = model
if verdict is not None:
routing_decisions_to_persist.append(
(
"create_smart_routing",
build_decision(
model=model,
rationale=verdict.get("rationale", ""),
harness=harness,
scope="session",
applied=True,
),
)
)
else:
# Router declined or errored; keep "auto" sentinel (don't clear it).
# The per-turn routing path will handle it on the first message.
if error:
_logger.debug(
"Smart routing declined or unavailable for auto-harness create: %s",
error,
)
# Fixed-harness path: a harness pinned at create routes only its
# model (never the harness). Covers the native TUIs and the
# in-process claude-sdk harness (polly / debby); all route once at
# create, the only routing entry point.
elif body.harness_override in ("claude-native", "codex-native", "claude-sdk"):
model, verdict, error = await resolve_fixed_native_model_routing(
body.harness_override,
body.smart_routing_message,
session_id=None, # Session doesn't exist yet
runner_client=runner_client,
)
if model is not None:
# Router accepted the model; apply it and record the decision.
body.model_override = model
if verdict is not None:
routing_decisions_to_persist.append(
(
"create_fixed_native_routing",
build_decision(
model=model,
rationale=verdict.get("rationale", ""),
harness=body.harness_override,
scope="session",
applied=True,
),
)
)
else:
# Router declined or errored; keep the harness default model.
if error:
_logger.debug(
"Fixed-harness model routing declined or unavailable: %s",
error,
)
resp = await _create_session_from_existing_agent(
conversation_store,
agent_store,
@@ -252,6 +351,14 @@ def register_core_routes(
# Without this, the runner doesn't know this session exists
# until the first forwarded event.
conv = conversation_store.get_conversation(resp.id)
# Persist create-time routing decisions (wave-2 stream 2, plan 2e/2f).
# Best-effort: failures are logged but never fatal. The routing_decision_store
# publishes live events even if the persist fails, so the web still renders
# the routing chip.
for _decision_type, decision_record in routing_decisions_to_persist:
await persist_decision(resp.id, conversation_store, decision_record)
# Mark the terminal spin-up flag at creation — the earliest
# possible point — for a host-launched terminal-first session
# (claude-native / codex-native). The runner's own pending emit
@@ -1276,3 +1276,120 @@ def register_hooks_routes(
content=json.dumps(result.model_dump(exclude_none=True)),
media_type="application/json",
)
# ── POST /sessions/{session_id}/hooks/route-subagent ─
@router.post(
"/sessions/{session_id}/hooks/route-subagent",
# Internal harness callback webhook — 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:
"""
Native-subagent routing policy endpoint.
Receives a subagent spawn request from a harness hook script,
applies family constraints and the subagent_routing_override toggle,
and returns a routing decision the hook enforces.
The decision action is:
- "allow": spawn proceeds unchanged (routing declined or disabled).
- "rewrite": spawn proceeds on a routed model (same harness).
- "redirect": spawn must move to a different harness (cross-family,
auto mode only).
- "deny": routing policy denial (harness cannot run the picked model).
Decision includes a rationale (one-line explanation) and decision_id
(join key for the transcript).
Auth: standard session ACL the wrapper's outbound headers carry
the Bearer token used for all other Omnigent requests. For
local-server mode (no auth provider), unauth'd calls are allowed.
:param request: FastAPI request carrying the spawn request
(harness, task_name, prompt, fork, parent_model).
:param session_id: Omnigent conversation id from the URL path.
:returns: SubagentRouteDecision JSON
(action, rationale, model, harness, raw_model, decision_id).
:raises OmnigentError: 404 if the session doesn't exist, 400 if the
body fails JSON parse or is missing required fields.
"""
import asyncio
from omnigent.server.subagent_routing_policy import resolve_subagent_route
from omnigent.server.subagent_routing_transport import (
SubagentRouteRequest,
auto_harness_session,
)
user_id = _get_user_id(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
# Parse the request body.
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,
)
# Parse the spawn request.
try:
req = SubagentRouteRequest.from_payload(payload)
except ValueError as exc:
raise OmnigentError(
str(exc),
code=ErrorCode.INVALID_INPUT,
) from exc
# Load session context.
conv = await asyncio.to_thread(
conversation_store.get_or_create_conversation,
session_id,
if_missing_create=False,
)
if conv is None:
raise OmnigentError(
f"Session {session_id} not found.",
code=ErrorCode.NOT_FOUND,
)
# Determine if this session is in auto-harness mode (may cross families).
cross_harness = auto_harness_session(conv)
# Get session settings for routing gate.
cost_control_mode = None
subagent_routing_override = None
if hasattr(conv, "session_overrides") and conv.session_overrides:
overrides = conv.session_overrides
if isinstance(overrides, dict):
cost_control_mode = overrides.get("cost_control_mode_override")
subagent_routing_override = overrides.get("subagent_routing_override")
# Call the policy.
decision = resolve_subagent_route(
session_id,
req,
subagent_routing_override=subagent_routing_override,
cost_control_mode=cost_control_mode,
auto_harness=cross_harness,
caps=get_caps(),
)
return Response(
content=json.dumps(decision.to_payload()),
media_type="application/json",
)
+123
View File
@@ -0,0 +1,123 @@
"""Per-request routing backend selection (plan 2f/7h).
One routing seam, two backends. ``origin/main`` builds ONE routing client at
startup and hangs it on :attr:`RuntimeCaps.routing_client`. This wraps BOTH of
main's clients — the AI Gateway :class:`~omnigent.server.smart_routing.
ExternalRoutingClient` and the naive :class:`~omnigent.server.smart_routing.
LLMRoutingClient` judge and, on **every** :meth:`RoutingBackend.route` call,
consults a deployment-supplied predicate to choose which one answers.
The predicate is evaluated per request, not once at construction (7h): flag on
the AI Gateway client; flag off the judge. A flag-off workspace therefore
still routes the flag selects routing *quality*, it never removes the feature.
OSS never names the flag. It only exposes the predicate seam
(:attr:`RuntimeCaps.routing_backend_predicate`); the managed plugin binds it to
its SAFE flag. When no deployment supplies a predicate (``None``), the seam
defaults to the OSS behaviour the gateway client when one is configured, the
judge otherwise (plan 2f).
The object satisfies main's ``RoutingClient`` protocol, so every existing caller
(``route_session_harness``, ``route_turn``) is unchanged: only the object on
:attr:`RuntimeCaps.routing_client` differs.
"""
from __future__ import annotations
import logging
from omnigent.server.routing_contract import RoutingBackendPredicate
from omnigent.server.smart_routing import RoutingClient, RoutingResult
__all__ = ["RoutingBackend"]
_logger = logging.getLogger(__name__)
class RoutingBackend:
"""Two-backend routing seam, chosen per request by a predicate.
Satisfies main's ``RoutingClient`` protocol
(``route(message, available_models) -> RoutingResult | None``).
:param external: The AI Gateway ``routes:select`` client, or ``None`` when
the deployment configures no external router.
:param judge: The naive LLM-judge client, or ``None`` when the deployment
configures no server ``llm:`` block.
:param predicate: A per-request callable ``True`` selects the external
client, ``False`` selects the judge invoked on every :meth:`route`.
``None`` (no deployment binding) selects the seam's default: the
external client when configured, else the judge.
"""
def __init__(
self,
*,
external: RoutingClient | None,
judge: RoutingClient | None,
predicate: RoutingBackendPredicate | None = None,
) -> None:
self._external = external
self._judge = judge
self._predicate = predicate
# The delegate the most recent route() dispatched to, so a caller
# reading ``last_error`` (route_session_harness does) sees the backend
# that actually answered. ``None`` before the first route() call.
self._last_delegate: RoutingClient | None = None
def _select(self) -> RoutingClient | None:
"""Choose the backend for this request from the per-request predicate.
- ``True`` the external client, falling back to the judge when no
external client is configured (a missing gateway never crashes).
- ``False`` the judge, falling back to the external client so a
flag-off workspace still routes.
- ``None`` (no predicate) the OSS default: the external client when
configured, else the judge.
A predicate that raises is treated as ``None`` (use the default) rather
than propagated a flag-service hiccup must not break routing.
"""
if self._predicate is None:
want_external: bool | None = None
else:
try:
want_external = bool(self._predicate())
except Exception: # noqa: BLE001 — a predicate hiccup must not break routing
_logger.warning(
"RoutingBackend: predicate raised; using the default backend",
exc_info=True,
)
want_external = None
if want_external is False:
return self._judge or self._external
# ``True`` and ``None`` both prefer the external client, then the judge.
return self._external or self._judge
@property
def last_error(self) -> str | None:
"""The last-used backend's failure reason, or ``None``.
``route_session_harness`` reads this off ``RuntimeCaps.routing_client``
to surface a specific cause (e.g. a gateway 401) when ``route()``
returns ``None``. Only the external client records one; the judge has
no such attribute, so this reads through defensively.
"""
return getattr(self._last_delegate, "last_error", None)
async def route(
self,
message: str,
available_models: dict[str, list[str]],
) -> RoutingResult | None:
"""Dispatch to the predicate-selected backend.
Returns the backend's :class:`RoutingResult`, or ``None`` to skip
routing including when no backend is configured at all.
"""
backend = self._select()
self._last_delegate = backend
if backend is None:
return None
return await backend.route(message, available_models)
+245
View File
@@ -0,0 +1,245 @@
"""Wave-0 contract for the Smart Routing rebuild — shared surfaces only.
This module is the single source of truth for the type signatures that the
parallel workstreams (see ``designs/PR_REWRITE_PLAN.md`` §4b) code against. It
declares shapes; it holds no routing logic. A stream imports the shape it needs
from here and never waits on another stream's implementation. Only the lead
re-declares a signature, and only at a wave barrier (plan 4e).
Everything here EXTENDS what ``origin/main`` already ships (plan 2g). Main
already has, in ``omnigent/server/smart_routing.py``: ``RoutingResult`` (model,
rationale, harness), the ``RoutingClient`` protocol, ``ExternalRoutingClient``,
``LLMRoutingClient``, ``route_session_harness``, ``route_turn``,
``fetch_runner_models``, and ``_redirect_incompatible_pick``; and in
``omnigent/entities/conversation.py``: ``RoutingDecisionData`` (model, applied,
rationale, agent). The rebuild adds the fields and modules below without
re-implementing any of that.
The declarations are intentionally logic-free (``...`` bodies, ``NotImplemented``
returns). Wave-1 streams replace each body in the file the partition (plan 4f)
assigns them, keeping the signature identical.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Literal, Protocol
# The literal model ids live in the guard-blessed home (omnigent/model_fallbacks.py)
# with provenance; the contract re-exports them so streams import one place.
from omnigent.model_fallbacks import (
FAMILY_FALLBACK_ID as FAMILY_FALLBACK,
)
from omnigent.model_fallbacks import (
SERVABLE_ALIASES,
TASK_V1_ARMS,
)
#: The contract's public surface. FAMILY_FALLBACK / SERVABLE_ALIASES / TASK_V1_ARMS
#: are re-exported from model_fallbacks so a stream imports one place (this module).
__all__ = [
"CLAUDE_GATEWAY_HARNESSES",
"CODEX_GATEWAY_HARNESSES",
"FAMILY_FALLBACK",
"MODEL_ID_PREFIXES",
"ROUTING_DECISION_ADDED_FIELDS",
"SERVABLE_ALIASES",
"SESSION_CREATE_ADDED_FIELDS",
"SESSION_RESPONSE_ADDED_FIELDS",
"SUBAGENT_LOOPBACK_PATH",
"SUBAGENT_SERVER_RELAY_PATH",
"TASK_V1_ARMS",
"TASK_V1_MENUS",
"ResolvedRoute",
"RoutingBackend",
"RoutingBackendPredicate",
"RoutingScope",
"SubagentAction",
"SubagentRouteDecision",
"resolve_route",
]
# ── 1. Routing backend selection (2f) — wave-1 stream 2 ─────────────────────
#
# Main builds ONE routing_client at cli.py startup and hangs it on
# RuntimeCaps.routing_client. The rebuild keeps that, and adds a per-REQUEST
# predicate the deployment supplies: on each route() the seam asks the predicate
# which backend answers. Flag on → the AI Gateway ExternalRoutingClient; flag
# off → the naive LLMRoutingClient judge. A flag-off workspace therefore still
# routes (plan 7h). OSS never hardcodes the flag: the managed plugin binds this
# predicate to its SAFE flag (databricks.mas.omnigent.intelligentRouting,
# default OFF); when no deployment supplies one, the seam defaults to "gateway
# client when configured, judge otherwise". The predicate rides RuntimeCaps
# next to routing_client, mirroring the existing policy_llm_connection_factory.
#: A deployment-supplied callable the seam consults on every ``route()`` to
#: choose the backend. ``True`` → use the external AI Gateway client; ``False``
#: → use the LLM judge. ``None`` (no deployment binding) → the seam's default.
RoutingBackendPredicate = Callable[[], bool]
class RoutingBackend(Protocol):
"""The two-backend seam wave-1 stream 2 builds around the predicate.
Wraps main's ``ExternalRoutingClient`` and ``LLMRoutingClient`` and dispatches
per request. It satisfies main's ``RoutingClient`` protocol
(``route(message, available_models) -> RoutingResult | None``) so every
existing caller (``route_session_harness``, ``route_turn``) is unchanged;
only the object on ``RuntimeCaps.routing_client`` differs.
"""
async def route(
self,
message: str,
available_models: dict[str, list[str]],
) -> object | None:
"""Return a ``RoutingResult`` (main's dataclass) or ``None`` to skip."""
...
# ── 2. The frozen task_v1 arms + family fallback (3i) — wave-1 stream 1 ──────
#
# The literal ids live in omnigent/model_fallbacks.py (guard-blessed, with
# provenance) and are re-exported above: TASK_V1_ARMS (family -> StaticModelFallback),
# FAMILY_FALLBACK (family -> fallback id), SERVABLE_ALIASES (the glm spelling pin).
# The rebuild does NOT build the cost-substitution ladder, MODEL_LISTS, or the id
# allowlist (plan 3i rule 1). It keeps only: the frozen arm menus (a wire
# contract — task_v1 400s on a partial menu), the ONE fixed fallback per family,
# and the gateway spelling pin.
#: The scenario menus task_v1 offers. "both" is the Smart Routing harness menu.
#: Derived from the guard-owned arm records so there is one source of truth.
TASK_V1_MENUS: Mapping[str, tuple[str, ...]] = {
"cc": TASK_V1_ARMS["claude"].model_ids,
"codex": TASK_V1_ARMS["codex"].model_ids,
"both": TASK_V1_ARMS["claude"].model_ids + TASK_V1_ARMS["codex"].model_ids,
}
#: Catalog prefixes stripped for id comparison (main already has MODEL_ID_PREFIXES).
MODEL_ID_PREFIXES: tuple[str, ...] = ("databricks-", "system.ai.")
@dataclass(frozen=True)
class ResolvedRoute:
"""A router arm translated into a servable id + its family harness.
:param model: The servable catalog id to apply (after the fallback and the
gateway-spelling pin).
:param harness: Harness derived from the arm's family, or ``None``.
:param raw_model: The router's pick verbatim, kept for the decision payload
so the chip shows what the router actually said. Differs from ``model``
only on a fallback or a spelling pin.
"""
model: str
harness: str | None
raw_model: str
def resolve_route(
picked_model: str,
*,
servable: Sequence[str],
prefixes: Sequence[str] = MODEL_ID_PREFIXES,
) -> ResolvedRoute | None:
"""Wave-1 stream 1 fills this. The four-step chain (plan 3i):
strip prefix exact catalog match family fallback honest decline.
Returns ``None`` on an honest decline (no servable arm and no servable
fallback); the caller then writes ``applied=false`` and keeps the default.
"""
raise NotImplementedError("wave-1 stream 1")
# ── 3. The decision record extension (3i keeps) — wave-1 stream 3 ────────────
#
# Main's RoutingDecisionData is (model, applied, rationale, agent). The rebuild
# ADDS these fields, all defaulted so old rows deserialize unchanged. Declared
# here as the agreed field set; stream 3 adds them to the real BaseModel in
# omnigent/entities/conversation.py.
#: Fields wave-1 stream 3 appends to ``RoutingDecisionData`` (name → type-hint).
ROUTING_DECISION_ADDED_FIELDS: Mapping[str, str] = {
"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",
}
RoutingScope = Literal["session", "turn", "child_session", "native_subagent"]
# ── 4. The subagent verdict shape (2c) — wave-2 stream 4 ─────────────────────
SubagentAction = Literal["allow", "rewrite", "redirect", "deny"]
@dataclass(frozen=True)
class SubagentRouteDecision:
"""What ``resolve_subagent_route`` returns and the loopback endpoint serves.
Frozen response shape (wave-2 stream 4 owns the implementation in
``subagent_routing_policy.py``; the transport in
``subagent_routing_transport.py`` serializes it). ``decision_id`` is a join
key against the persisted ``RoutingDecisionData``.
"""
action: SubagentAction
rationale: str
model: str | None = None
harness: str | None = None
raw_model: str | None = None
decision_id: str | None = None
def to_payload(self) -> dict[str, object]:
"""Serialize to the frozen loopback response JSON."""
return {
"action": self.action,
"model": self.model,
"harness": self.harness,
"raw_model": self.raw_model,
"rationale": self.rationale,
"decision_id": self.decision_id,
}
#: POST loopback path the hook subprocess calls (runner-local); the server relay
#: forwards to POST /v1/sessions/{id}/hooks/route-subagent (wave-2 streams 3+4).
SUBAGENT_LOOPBACK_PATH = "/v1/sessions/{session_id}/route-subagent"
SUBAGENT_SERVER_RELAY_PATH = "/v1/sessions/{session_id}/hooks/route-subagent"
# ── 5. The gateway-inference signal (3f) — wave-1 stream 4 ───────────────────
#
# Per-family "is this host's inference for that family AI-Gateway-backed?".
# Config-only host check (no launch, no network). Rides the host frames, the
# host store, and the hosts route; the web consumes it to gate the option
# (Model row → that harness's family true; harness row → BOTH families true;
# a host that reports nothing → unknown, never hides). Full stub in
# omnigent/gateway_inference.py (wave-1 stream 4 fills it).
#: Harness spellings that belong to each gateway-gated family (transcribed from v1).
CLAUDE_GATEWAY_HARNESSES: tuple[str, ...] = ("claude-native", "native-claude")
CODEX_GATEWAY_HARNESSES: tuple[str, ...] = ("codex", "codex-native", "native-codex")
# ── 6. HTTP create + read-back additions (2e, 3f) — waves 1 & 2 ──────────────
#
# Main's SessionCreateRequest already has model_override, cost_control_mode_override,
# harness_override. The rebuild ADDS the two below (wave-1 stream 3 declares,
# the create-path stream consumes). SessionResponse gains subagent_routing_override
# and gateway_inference for read-back (wave-1 stream 4 + wave-2 stream 5).
#: Fields added to ``SessionCreateRequest`` (name → type-hint).
SESSION_CREATE_ADDED_FIELDS: Mapping[str, str] = {
"smart_routing_message": "str | None = None",
"subagent_routing_override": "str | None = None",
}
#: Fields added to ``SessionResponse`` for read-back.
SESSION_RESPONSE_ADDED_FIELDS: Mapping[str, str] = {
"subagent_routing_override": "str | None = None",
"gateway_inference": "dict[str, bool] | None = None",
}
+176
View File
@@ -0,0 +1,176 @@
"""The create paths (wave-0 seam; wave-2 stream 2 fills).
Two create-time routing paths live here so they do not collide with the turn
gate in ``orchestration.py`` (plan 4b, 4f):
1. **Smart Routing harness** a create with ``harness_override == "auto"`` and a
``smart_routing_message`` routes BOTH harness and model at create, via
``smart_routing.route_session_harness``. Main does this inline in
orchestration; the rebuild lifts it here.
2. **Fixed-harness model routing** a create pinned to ONE native harness with
routing on routes only the MODEL at create, because a TUI's turns originate
in the pane and the turn gate can never reach them (plan 2d, 5b). This is the
``_fixed_native_routing_harness`` / ``_resolve_fixed_native_model_routing``
path the CLI depends on.
One trap must survive from the reference implementation (plan 5b): both paths
share ``_routing_host_for_create``, and it authorizes the host BEFORE it looks
the host up. The reverse order is the authorization bug ``CUJ_IMPLEMENTATION.md``
§4.3d describes. Wave-2 stream 2 keeps that order.
Wave 0 declares the seam; the bodies are wave-2 stream 2's.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from omnigent.model_fallbacks import SERVABLE_ALIASES, TASK_V1_ARMS
if TYPE_CHECKING:
import httpx
_logger = logging.getLogger(__name__)
# The router keys candidates by the harness id it understands: the claude arms
# under ``claude-sdk`` and the codex arms under ``codex`` (verified live via
# scripts/probe_routing_api.sh). A create has no session catalog to discover
# yet, so both create paths pass these frozen task_v1 arms as the candidate set.
_CREATE_CANDIDATE_MODELS: dict[str, list[str]] = {
"claude-sdk": list(TASK_V1_ARMS["claude"].model_ids),
"codex": list(TASK_V1_ARMS["codex"].model_ids),
}
# The single-harness candidate slice for a fixed-harness create, keyed by the
# harness the create request pins. The value's key is the harness spelling the
# router understands (claude-sdk / codex). claude-sdk is the in-process Claude
# harness (polly / debby); it routes at create like the native TUIs, and the
# harness itself does not change — only the model is pinned.
_FIXED_NATIVE_CANDIDATES: dict[str, dict[str, list[str]]] = {
"claude-native": {"claude-sdk": list(TASK_V1_ARMS["claude"].model_ids)},
"codex-native": {"codex": list(TASK_V1_ARMS["codex"].model_ids)},
"claude-sdk": {"claude-sdk": list(TASK_V1_ARMS["claude"].model_ids)},
}
# The router picks among the harness spellings it understands (``claude-sdk`` /
# ``codex``); a Smart Routing session launches the NATIVE TUI wrapper, so map
# the router's pick to its native spelling before persisting it as the session's
# harness. Mirrors the reference's AUTO_NATIVE_ROUTING_HARNESSES narrowing.
_ROUTER_HARNESS_TO_NATIVE: dict[str, str] = {
"claude-sdk": "claude-native",
"codex": "codex-native",
}
# The servable catalog for the frozen task_v1 arms. The router returns a BARE
# arm id (``gpt-5-6-luna``), but the harness endpoints serve the prefixed
# spelling: ``databricks-<arm>`` for every arm except glm, which serves only
# under its ``system.ai.`` route (SERVABLE_ALIASES). A create has no live
# catalog, so resolve_route() is run against this static servable set to
# translate the pick — verified live: the bare ids 404 on /codex/v1/responses
# while the prefixed ids return 200.
_CREATE_SERVABLE_CATALOG: tuple[str, ...] = tuple(
SERVABLE_ALIASES.get(arm, f"databricks-{arm}")
for rec in TASK_V1_ARMS.values()
for arm in rec.model_ids
)
async def resolve_smart_routing_create(
smart_routing_message: str,
*,
session_id: str | None = None,
catalog_session_id: str | None = None, # noqa: ARG001 — create has no catalog; kept for caller
runner_client: httpx.AsyncClient | None = None, # noqa: ARG001 — create has no catalog
) -> tuple[str | None, str | None, dict[str, Any] | None, str | None]:
"""Route a create with ``harness_override == "auto"`` to select BOTH harness and model.
Delegates to :func:`omnigent.server.smart_routing.route_session_harness`,
which selects from the ``both`` five-arm menu over all native harnesses.
Returns ``(harness, model, verdict, error)`` the shape of
``smart_routing.route_session_harness``, which it delegates to.
"""
from omnigent.server.smart_routing import resolve_route, route_session_harness
if not (smart_routing_message or "").strip():
return None, None, None, None
# A session being created has no catalog yet, so route against the frozen
# task_v1 arms (both families) instead of a live-catalog discovery that
# would find nothing and decline.
harness, model, verdict, error = await route_session_harness(
smart_routing_message,
session_id=session_id,
candidate_models=_CREATE_CANDIDATE_MODELS,
)
# A Smart Routing session runs the native TUI wrapper, so surface the native
# harness spelling (claude-native / codex-native), not the router's own
# claude-sdk / codex candidate key.
if harness is not None:
harness = _ROUTER_HARNESS_TO_NATIVE.get(harness, harness)
# Translate the bare router pick into the servable spelling the harness
# endpoint actually accepts (databricks-<arm>, or system.ai.glm-5-2). Without
# this the bare id 404s on the harness's Responses/gateway surface.
if model is not None:
resolved = resolve_route(model, servable=_CREATE_SERVABLE_CATALOG)
if resolved is not None:
model = resolved.model
if verdict is not None:
verdict = {**verdict, "raw_model": resolved.raw_model}
return harness, model, verdict, error
async def resolve_fixed_native_model_routing(
harness: str,
smart_routing_message: str,
*,
session_id: str | None = None,
runner_client: httpx.AsyncClient | None = None, # noqa: ARG001 — create has no catalog
) -> tuple[str | None, dict[str, Any] | None, str | None]:
"""Route the model for a create pinned to one native harness.
Routes only the model for a create already pinned to ``harness``. The
``harness`` argument names the session's fixed harness (e.g.
``"claude-native"`` or ``"codex-native"``); the candidate set is that
harness's frozen task_v1 arms ONLY, so the router cannot change the
harness. This is the "native TUI" path where turns originate in the pane
and the per-turn gate can never reach. Fails open: an unavailable router
or an unknown harness yields no model and a rationale for the routing card.
Returns ``(model, verdict, error)``; ``model`` and ``verdict`` are ``None``
when nothing should be pinned, and ``error`` then explains why.
"""
from omnigent.server.smart_routing import resolve_route, route_session_harness
if not (smart_routing_message or "").strip():
return None, None, None
candidates = _FIXED_NATIVE_CANDIDATES.get(harness)
if candidates is None:
return None, None, f"Smart Routing is not available for the {harness!r} harness."
# A session being created has no catalog yet, so route against this
# harness's frozen task_v1 arms rather than a live-catalog discovery that
# would find nothing and decline. Single-harness candidate set → the
# router cannot cross to another harness.
_harness, model, verdict, error = await route_session_harness(
smart_routing_message,
session_id=session_id,
candidate_models=candidates,
)
if model is None or verdict is None:
return None, None, error or "Routing unavailable; using the harness default model."
# Translate the bare router pick into the servable spelling the harness
# endpoint accepts (databricks-<arm> / system.ai.glm-5-2); the bare id 404s.
resolved = resolve_route(model, servable=_CREATE_SERVABLE_CATALOG)
if resolved is not None:
model = resolved.model
verdict = {**verdict, "raw_model": resolved.raw_model}
return model, verdict, None
+325
View File
@@ -0,0 +1,325 @@
"""Main-agent routing-decision writer/reader + the routed model override.
The persistence half of Smart Routing (plan 2d). A routed create or turn
produces a :class:`~omnigent.server.smart_routing.RoutingResult`; stream 1's
:func:`~omnigent.server.routing_contract.resolve_route` turns that pick into a
servable :class:`~omnigent.server.routing_contract.ResolvedRoute` (or ``None``
on an honest decline). This module maps either outcome into a
:class:`RoutingDecisionData`, persists it as a ``routing_decision`` conversation
item (no new table the web reads it from the session snapshot), and reads the
latest one back.
Three honesty rules from plan 3i live in :func:`build_decision`:
* A fallback stamps ``raw_model`` distinct from ``model`` the chip shows the
arm the router *asked for* next to the arm that was *applied*.
* A prefix/spelling-only difference is the same arm, so ``raw_model`` is dropped
(a gateway spelling pin like ``glm-5-2`` ``system.ai.glm-5-2`` is not a
substitution).
* An honest decline records ``applied=false`` and writes no pin; the record
keeps the router's would-have pick as ``model`` so the UI renders "would have
picked X".
The routed model itself is pinned through the existing ``model_override``
session key (:func:`apply_routed_model_override` /
:func:`routed_model_override`); the store already persists that column, so this
module only names the round-trip the routing path uses. The
``subagent_routing_override`` key is owned elsewhere (wave-2) and is not touched
here.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import TYPE_CHECKING, Any, Protocol
from omnigent.entities.conversation import NewConversationItem, RoutingDecisionData
from omnigent.server.routing_contract import (
MODEL_ID_PREFIXES,
ResolvedRoute,
RoutingScope,
)
if TYPE_CHECKING:
from omnigent.entities.conversation import ConversationItem
_logger = logging.getLogger(__name__)
#: Conversation-item type the decision persists as. Registered on the entity
#: side (``ITEM_TYPE_TO_DATA_CLS`` / ``NON_CONTENT_ITEM_TYPES``), so the agent
#: loop never feeds a router note back into the brain's context.
DECISION_ITEM_TYPE = "routing_decision"
class RouteResult(Protocol):
"""The pick shape this module maps from (main's ``RoutingResult``).
A structural type so unit tests and the wave-2 gates can pass any object
carrying the three fields without importing the routing seam.
"""
model: str
rationale: str
harness: str | None
class _AppendStore(Protocol):
"""Minimal store surface the writer needs (satisfied by ``ConversationStore``)."""
def append(
self, conversation_id: str, items: list[NewConversationItem]
) -> list[ConversationItem]: ...
def _bare_id(model: str) -> str:
"""Strip a known catalog prefix so two spellings of one arm compare equal.
Uses the contract's :data:`MODEL_ID_PREFIXES` (the single owned list) rather
than the routing seam, so this module never waits on another stream.
"""
bare = model.strip()
for prefix in MODEL_ID_PREFIXES:
if bare.startswith(prefix):
return bare[len(prefix) :]
return bare
def _distinct_arm(raw: str | None, model: str) -> str | None:
"""Return *raw* only when it names a different arm than *model*.
A prefix/spelling-only difference (e.g. a gateway spelling pin) is the same
arm, so it earns no ``raw_model`` the chip must not read a spelling as a
substitution (plan 3i).
"""
if not raw:
return None
return raw if _bare_id(raw) != _bare_id(model) else None
def build_decision(
*,
model: str,
rationale: str,
harness: str | None = None,
scope: RoutingScope = "turn",
applied: bool = True,
raw_model: str | None = None,
attempted_override: str | None = None,
agent: str | None = None,
decision_id: str | None = None,
) -> RoutingDecisionData:
"""Build a :class:`RoutingDecisionData` from mapped routing fields.
The primitive both :func:`decision_from_route` and the wave-2 gates use.
``raw_model`` is normalized through :func:`_distinct_arm`, so a caller can
pass the router's verbatim pick unconditionally and a spelling-only
difference is dropped. ``decision_id`` is generated when absent so every
persisted row carries a stable join key.
:param model: The servable model the turn runs on (``applied``), or the
router's would-have pick (an honest decline, ``applied=false``).
:param rationale: The router's one-line explanation.
:param harness: Harness the routed model belongs to, e.g.
``"claude-native"``.
:param scope: Which decision this is session / turn / child_session /
native_subagent.
:param applied: ``True`` when the brain actually ran on ``model``.
:param raw_model: The router's pick verbatim; kept only when it names a
different arm than ``model``.
:param attempted_override: The model the user had pinned when routing ran,
rendered as "would have picked X, kept your Y".
:param agent: Sub-agent name when mirrored into a parent transcript.
:param decision_id: Stable id; generated when ``None``.
:returns: The decision payload, ready for :func:`persist_decision`.
"""
return RoutingDecisionData(
model=model,
applied=applied,
rationale=rationale,
agent=agent,
harness=harness,
scope=scope,
decision_id=decision_id or _new_decision_id(),
raw_model=_distinct_arm(raw_model, model),
attempted_override=attempted_override,
)
def decision_from_route(
result: RouteResult,
resolved: ResolvedRoute | None,
*,
scope: RoutingScope = "turn",
attempted_override: str | None = None,
agent: str | None = None,
decision_id: str | None = None,
default_model: str | None = None,
) -> RoutingDecisionData:
"""Map a route result + its resolution into a decision record.
The two-outcome mapping the create/turn gates call once they have a pick:
* **Resolved** (``resolved`` given): the arm is servable after the fallback
and gateway-spelling pin. The record is ``applied=true`` on
``resolved.model``; ``resolved.raw_model`` becomes the chip's raw pick when
a fallback made it name a different arm.
* **Honest decline** (``resolved is None``): no servable arm and no servable
fallback (plan 3i). The record is ``applied=false`` and writes no pin it
keeps the router's would-have pick as ``model`` (falling back to
``default_model`` only if the pick is somehow empty), so the UI renders
"would have picked".
:param result: The router's pick (``model`` / ``rationale`` / ``harness``).
:param resolved: Stream 1's servable resolution, or ``None`` on a decline.
:param scope: Which decision this is.
:param attempted_override: Model the user had pinned, if any.
:param agent: Sub-agent name when mirrored into a parent transcript.
:param decision_id: Stable id; generated when ``None``.
:param default_model: Session default, used only if a decline's pick is
empty (keeps the non-empty-model invariant without inventing an id).
:returns: The decision payload.
"""
if resolved is None:
return build_decision(
model=result.model or default_model or "",
rationale=result.rationale,
harness=None,
scope=scope,
applied=False,
raw_model=None,
attempted_override=attempted_override,
agent=agent,
decision_id=decision_id,
)
return build_decision(
model=resolved.model,
rationale=result.rationale,
harness=resolved.harness or result.harness,
scope=scope,
applied=True,
raw_model=resolved.raw_model,
attempted_override=attempted_override,
agent=agent,
decision_id=decision_id,
)
async def persist_decision(
session_id: str,
store: _AppendStore,
record: RoutingDecisionData,
) -> str | None:
"""Persist *record* as a ``routing_decision`` item and publish it live.
The append runs in a worker thread (the store is sync) and is best-effort:
a persist failure is logged, never raised, and the live event still fires so
the web renders the chip. Mirrors the resilience of the other routing-decision
writers.
:param session_id: Session / conversation identifier.
:param store: Store exposing ``append``.
:param record: The decision to persist.
:returns: The store-assigned item id, or ``None`` when the append failed.
"""
item = NewConversationItem(
type=DECISION_ITEM_TYPE,
response_id=f"routing_{uuid.uuid4().hex}",
data=record,
)
try:
persisted = await asyncio.to_thread(store.append, session_id, [item])
persisted_id: str | None = persisted[0].id if persisted else None
except Exception:
_logger.exception("routing decision persist failed for session=%s", session_id)
persisted_id = None
_publish_decision(session_id, persisted_id, record)
return persisted_id
def _publish_decision(session_id: str, item_id: str | None, record: RoutingDecisionData) -> None:
"""Broadcast the decision chip to any live SSE subscriber.
Lazily imports ``session_stream`` so this module stays importable without
the runtime (unit tests, catalog probes). A no-op when nothing is listening.
"""
from omnigent.runtime import session_stream
session_stream.publish(
session_id,
{
"type": "response.output_item.done",
"item": {
"id": item_id,
"type": DECISION_ITEM_TYPE,
**record.model_dump(),
},
},
)
def latest_decision(
session_id: str,
store: Any,
) -> RoutingDecisionData | None:
"""Return the newest persisted routing decision for a session.
The read-back the web snapshot and wave-2 gates use to answer "what did the
router last decide for this session". Reads the single newest
``routing_decision`` item; the store's ``type`` filter keeps it a point read
rather than a transcript scan.
:param session_id: Session / conversation identifier.
:param store: Store exposing ``list_items``.
:returns: The latest decision payload, or ``None`` when the session has no
routing decision.
"""
page = store.list_items(session_id, limit=1, order="desc", type=DECISION_ITEM_TYPE)
for item in page.data:
data = item.data
if isinstance(data, RoutingDecisionData):
return data
return None
# ── Routed model override round-trip (the routing session key) ───────────────
def apply_routed_model_override(
session_id: str,
store: Any,
model: str,
) -> None:
"""Pin *model* as the session's ``model_override``.
The write half of the routed-model round-trip. The store already persists
``model_override`` (no schema change); this names the one write the routing
path makes so create/turn gates share a single entry point.
:param session_id: Session / conversation identifier.
:param store: Store exposing ``update_conversation``.
:param model: The routed servable model id to pin.
"""
store.update_conversation(session_id, model_override=model)
def routed_model_override(
session_id: str,
store: Any,
) -> str | None:
"""Read back the session's pinned ``model_override``.
:param session_id: Session / conversation identifier.
:param store: Store exposing ``get_conversation``.
:returns: The pinned model id, or ``None`` when the session is unpinned or
missing.
"""
conv = store.get_conversation(session_id)
return getattr(conv, "model_override", None) if conv is not None else None
def _new_decision_id() -> str:
"""Return a fresh decision id (the transcript ↔ telemetry join key)."""
return f"rd_{uuid.uuid4().hex}"
+16
View File
@@ -1350,6 +1350,14 @@ class SessionCreateRequest(BaseModel):
reasoning_effort: str | None = None
cost_control_mode_override: str | None = None
harness_override: str | None = None
# Wave-0 contract (routing rebuild, plan 2e/2f). Additive, defaulted.
# ``smart_routing_message``: first message that seeds create-time
# harness+model routing for a Smart Routing session. ``subagent_routing_override``:
# per-session "on"/"off"/None gate for routing in-harness spawns. The
# create-path stream (wave-2 s2) consumes both; see
# routing_contract.SESSION_CREATE_ADDED_FIELDS.
smart_routing_message: str | None = None
subagent_routing_override: str | None = None
@model_validator(mode="after")
def _check_git_requires_host(self) -> SessionCreateRequest:
@@ -1840,6 +1848,14 @@ class SessionResponse(BaseModel):
harness: str | None = None
model_override: str | None = None
cost_control_mode_override: str | None = None
# Wave-0 contract (routing rebuild, plan 3f). Additive, defaulted.
# ``subagent_routing_override``: read-back of the per-session spawn gate.
# ``gateway_inference``: this session's host's per-family gateway backing
# (harness spelling -> bool), consumed by the web to gate Smart Routing.
# ``None`` means the host reported nothing (unknown, never hides the option).
# Wave-1 s4 + wave-2 s5; see routing_contract.SESSION_RESPONSE_ADDED_FIELDS.
subagent_routing_override: str | None = None
gateway_inference: dict[str, bool] | None = None
context_window: int | None = None
last_total_tokens: int | None = None
total_cost_usd: float | None = None
+189 -16
View File
@@ -13,11 +13,26 @@ from __future__ import annotations
import json
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Protocol
from omnigent.model_catalog import model_family_token
from omnigent.model_metadata import ModelCostTier, ModelIntent, ModelWireAPI
# The frozen arms/fallbacks/spelling-pin live in the guard-blessed home
# (omnigent/model_fallbacks.py) and are re-exported by the wave-0 contract; the
# seam imports them from the contract so every stream reads one place. The
# contract's resolve_route body is a stub — the real implementation is defined
# here (this stream owns it) and callers import resolve_route/ResolvedRoute from
# this module.
from omnigent.server.routing_contract import (
FAMILY_FALLBACK,
MODEL_ID_PREFIXES,
SERVABLE_ALIASES,
ResolvedRoute,
)
if TYPE_CHECKING:
import httpx # used in type annotations only; runtime import is lazy in fetch_runner_models
from databricks.sdk.config import Config
@@ -609,6 +624,146 @@ class ExternalRoutingClient:
)
# ── Route resolution seam (plan 3i) ──────────────────────────────────────────
#
# The four-step chain that translates the router's picked arm into a servable
# catalog id: strip prefix → exact catalog match → one fixed family fallback →
# honest decline. The wave-2 create path and turn gate import ``resolve_route``
# from here. This is the WHOLE resolver: plan 3i rule 1 cuts the cost ladder
# (MODEL_LISTS / _cost_position / nearest-cost walk / id allowlist) the oracle
# carried, so there is exactly one fallback per family and nothing else.
#: Family key -> the native harness whose pane serves that family (CUJ A is the
#: model choice on Claude Code and Codex). gpt AND glm serve on the codex wire,
#: so both land on the codex harness — a glm fallback never leaves it (plan 3i).
_FAMILY_HARNESS: dict[str, str] = {
"claude": "claude-native",
"gpt": "codex-native",
"glm": "codex-native",
}
#: Harness id -> family the harness belongs to (reverse map for constraints).
#: Supports all harness spellings (claude-sdk, claude_sdk, etc.).
_HARNESS_FAMILY: dict[str, str] = {
"claude-sdk": "claude",
"claude_sdk": "claude",
"claude-native": "claude",
"native-claude": "claude",
"pi": "pi",
"codex": "gpt",
"codex-native": "gpt",
"native-codex": "gpt",
"openai-agents": "gpt",
"openai-agents-sdk": "gpt",
"agents_sdk": "gpt",
}
def _strip_catalog_prefix(model: str, prefixes: Sequence[str]) -> str:
"""Strip the first matching *prefixes* entry from a catalog model id.
Separator-safe (oracle 0d trap, transcribed from ``strip_catalog_prefix``):
a prefix configured without its trailing separator (e.g. ``system.ai``)
would leave a leading ``.`` behind and corrupt the id, so one leading
``.``/``-``/``_`` is dropped too.
"""
for prefix in prefixes:
if prefix and model.startswith(prefix):
rest = model[len(prefix) :]
return rest[1:] if rest[:1] in ".-_" else rest
return model
def _bare_id(model: str, prefixes: Sequence[str] = MODEL_ID_PREFIXES) -> str:
"""Comparison spelling for a model id: prefix stripped, dots→dashes, folded.
A comparison key only, never an id to send anywhere: a picker's
``gpt-5.6-sol`` and the router's ``gpt-5-6-sol`` reduce to one arm.
"""
return _strip_catalog_prefix(model, prefixes).replace(".", "-").lower()
def _arm_family(bare: str) -> str | None:
"""Family key ('claude' | 'gpt' | 'glm') for a bare arm id, else ``None``.
Indexes :data:`FAMILY_FALLBACK`. glm is its own key ``glm-5-2`` is neither
a gpt nor a codex spelling, so it is matched by name before the shared token
rule though its fallback target (luna) is the same as gpt's.
"""
if "glm" in bare:
return "glm"
token = model_family_token(bare)
if token == "claude":
return "claude"
if token == "openai":
return "gpt"
return None
def _apply_servable_alias(model: str, prefixes: Sequence[str]) -> str:
"""Map a resolved id onto the spelling this gateway actually serves.
A spelling, not a substitution (plan 3i): ``glm-5-2`` serves the Responses
API only under ``system.ai.glm-5-2`` (probed). ``raw_model`` still names the
arm, and the bare ids match, so the chip never reads it as a different pick.
"""
return SERVABLE_ALIASES.get(_bare_id(model, prefixes), model)
def resolve_route(
picked_model: str,
*,
servable: Sequence[str],
prefixes: Sequence[str] = MODEL_ID_PREFIXES,
) -> ResolvedRoute | None:
"""Translate the router's picked arm into a servable ``ResolvedRoute``.
The four-step chain (plan 3i): strip the prefix match the catalog exactly
apply the one fixed family fallback decline honestly.
:param picked_model: The router's pick, in either catalog vocabulary.
:param servable: The catalog ids this workspace actually serves.
:param prefixes: Catalog prefixes to strip before comparing ids.
:returns: A :class:`ResolvedRoute` (``raw_model`` always the router's pick,
differing from ``model`` only on a fallback or the spelling pin), or
``None`` on an honest decline (no servable arm and no servable
fallback) the caller then writes ``applied=false`` and keeps the
session default.
"""
raw = (picked_model or "").strip()
if not raw:
return None
servable_by_bare = {_bare_id(m, prefixes): m for m in servable}
pick_bare = _bare_id(raw, prefixes)
family = _arm_family(pick_bare)
# Step 2: exact catalog match — the workspace serves the picked arm itself.
exact = servable_by_bare.get(pick_bare)
if exact is not None:
return ResolvedRoute(
model=_apply_servable_alias(exact, prefixes),
harness=_FAMILY_HARNESS.get(family or ""),
raw_model=raw,
)
# Step 3: family fallback — the workspace does not serve the pick, so apply
# the one fixed fallback for its family, keeping raw_model = the router's
# pick so the record and chip stay honest. The fallback must itself be
# servable; otherwise the decline below fires.
fallback = FAMILY_FALLBACK.get(family or "")
if fallback is not None:
fallback_local = servable_by_bare.get(_bare_id(fallback, prefixes))
if fallback_local is not None:
return ResolvedRoute(
model=_apply_servable_alias(fallback_local, prefixes),
harness=_FAMILY_HARNESS.get(family or ""),
raw_model=raw,
)
# Step 4: honest decline — nothing servable for the pick or its family.
return None
# ── Public API ──────────────────────────────────────────────────────────────
# SDK harnesses offered as candidates when the user picks "auto" harness.
@@ -677,6 +832,7 @@ async def route_session_harness(
session_id: str | None = None,
catalog_session_id: str | None = None,
runner_client: httpx.AsyncClient | None = None,
candidate_models: dict[str, list[str]] | None = None,
) -> tuple[str | None, str | None, dict[str, Any] | None, str | None]:
"""Pick the best harness + model for a new session via the routing client.
@@ -684,6 +840,14 @@ async def route_session_harness(
(defaulting to *session_id*) and *runner_client* are provided. Only harnesses
in :data:`_AUTO_ROUTING_HARNESSES` are offered as candidates.
When *candidate_models* is given, it is used verbatim as the candidate set
and the live catalog is not consulted. This is the create-time path: a
session is being created and has no catalog to fetch yet, so the caller
supplies the static task_v1 arms (which the router requires as its full
menu anyway). ``harness_catalog`` stays empty, so the post-routing
``_redirect_incompatible_pick`` (which needs per-model wire metadata) is a
no-op the pins are the frozen arms, already known-compatible.
:param user_message: The user's first message text, used to size the task.
:param session_id: Session being routed (optional).
:param catalog_session_id: Session whose catalog defines the candidate set.
@@ -691,8 +855,12 @@ async def route_session_harness(
catalog enumerates the spawnable workers (claude_code/codex/pi) with
their full model lists, whereas the child's own leaf catalog only has a
``"self"`` row and would force the static fallback. Defaults to
*session_id* when unset.
:param runner_client: HTTP client pointed at the runner (optional).
*session_id* when unset. Ignored when *candidate_models* is given.
:param runner_client: HTTP client pointed at the runner (optional). Ignored
when *candidate_models* is given.
:param candidate_models: Explicit ``{harness: [model ids]}`` candidate set.
When provided, skips live-catalog discovery (used at session create,
before a catalog exists).
:returns: ``(harness, model, verdict, error)`` on success ``error`` is
``None``; on failure ``harness``, ``model``, and ``verdict`` are ``None``
and ``error`` carries a human-readable reason shown in the UI.
@@ -712,11 +880,6 @@ async def route_session_harness(
# against _AUTO_ROUTING_HARNESSES. Prefer catalog_session_id (the parent
# for a sub-agent) so the candidate set is the full spawnable-worker map,
# independent of whether the routed session is top-level or a sub-agent.
_catalog_sid = catalog_session_id or session_id
live_catalog: dict[str, list[_RunnerModel]] | None = None
if _catalog_sid and runner_client is not None:
live_catalog = await _fetch_runner_catalog(_catalog_sid, runner_client)
# NOTE: we do NOT filter incompatible (harness, model) pairs out of the
# candidate set here. The external router (task_v0) enforces a required
# model set and 400s if any required model is missing, so dropping e.g.
@@ -725,15 +888,25 @@ async def route_session_harness(
# _redirect_incompatible_pick.
harness_models: dict[str, list[str]] = {}
harness_catalog: dict[str, list[_RunnerModel]] = {}
if live_catalog:
for worker_name, worker_catalog in live_catalog.items():
harness = _WORKER_NAME_TO_HARNESS.get(worker_name)
if harness is None or harness not in _AUTO_ROUTING_HARNESSES:
continue
if worker_catalog:
# First worker wins for a given harness id (dedupe).
harness_catalog.setdefault(harness, worker_catalog)
harness_models.setdefault(harness, [entry.id for entry in worker_catalog])
if candidate_models is not None:
# Create-time: the caller supplies the candidate set (the static
# task_v1 arms). No live catalog, so harness_catalog stays empty and
# the post-routing wire-compat redirect is a no-op.
harness_models = {h: list(models) for h, models in candidate_models.items() if models}
else:
_catalog_sid = catalog_session_id or session_id
live_catalog: dict[str, list[_RunnerModel]] | None = None
if _catalog_sid and runner_client is not None:
live_catalog = await _fetch_runner_catalog(_catalog_sid, runner_client)
if live_catalog:
for worker_name, worker_catalog in live_catalog.items():
harness = _WORKER_NAME_TO_HARNESS.get(worker_name)
if harness is None or harness not in _AUTO_ROUTING_HARNESSES:
continue
if worker_catalog:
# First worker wins for a given harness id (dedupe).
harness_catalog.setdefault(harness, worker_catalog)
harness_models.setdefault(harness, [entry.id for entry in worker_catalog])
if not harness_models:
return None, None, None, "No discovered routable harnesses are available on this runner."
+303
View File
@@ -0,0 +1,303 @@
"""Server-side subagent routing policy: family constraints + routing override gate.
This module implements the policy for native-subagent routing decisions. It:
1. Routes the spawn based on the Task prompt (delegates to routing_client the
same way route_turn/route_session_harness do).
2. Applies family constraints (a spawn's routed arm must stay servable + within
allowed cross-family policy).
3. Respects the per-session subagent_routing_override toggle (per-call gate).
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
pass
from omnigent.server.routing_contract import (
SubagentRouteDecision,
)
_logger = logging.getLogger(__name__)
async def resolve_subagent_route(
session_id: str,
req: Any, # SubagentRouteRequest (from W2·3 transport)
*,
subagent_routing_override: str | None = None,
cost_control_mode: str | None = None,
parent_cost_control_mode: str | None = None,
auto_harness: bool = False,
caps: Any = None,
available_models: dict[str, list[str]] | None = None,
_catalog: Mapping[str, list[str]] | None = None,
) -> SubagentRouteDecision:
"""Decide what happens to one native-subagent spawn.
This is the server-side policy called via the relay endpoint
(POST /v1/sessions/{session_id}/hooks/route-subagent). It returns a
decision that the hook script enforces.
Family constraints (INTELLIGENT_ROUTING_PLAN §12):
- Rule-0: a spawn's routed arm must be servable on the spawn's harness
OR on a family-paired harness when the session is in auto mode.
- Non-auto sessions: Claude Codex, Codex Claude. Only auto
(Smart Routing) may cross families.
- Same-harness subagents: routed model's family matches request harness's
family rewrite (same harness). Otherwise if auto redirect. Else deny.
Subagent routing override (decision 5):
- "off" allow unchanged (advisory, per-call gate).
- "on" / absent route (inherit from session cost_control_mode).
:param session_id: Parent session/conversation identifier.
:param req: Spawn request (harness, task_name, prompt, fork, parent_model).
:param subagent_routing_override: Session's subagent_routing_override.
:param cost_control_mode: Session's cost_control_mode_override.
:param parent_cost_control_mode: Parent session's cost_control_mode.
:param auto_harness: True when session may cross harness families.
:param caps: RuntimeCaps-shaped object. None reads process-global caps.
:param available_models: Candidate harness models map. None derives it.
:param _catalog: Live per-session model catalog (reserved for W2·3 transport).
:returns: SubagentRouteDecision (action, rationale, model, harness, etc.).
"""
if caps is None:
from omnigent.runtime import get_caps
caps = get_caps()
# Per-call subagent routing override gate (INTELLIGENT_ROUTING_PLAN §12 decision 5).
# "off" → allow unchanged; "on" → route (force on); absent/None → inherit.
if subagent_routing_override == "off":
_logger.info(
"route-subagent: subagent routing disabled for session=%s harness=%s",
session_id,
getattr(req, "harness", "unknown"),
)
return SubagentRouteDecision(
action="allow",
rationale="Subagent routing disabled; spawn allowed unchanged",
)
# Check if routing is enabled at all (session-level gate).
# Override "on" forces routing on; otherwise inherit from cost_control_mode.
routing_on = (
subagent_routing_override == "on"
or cost_control_mode == "on"
or parent_cost_control_mode == "on"
)
if not routing_on:
return SubagentRouteDecision(
action="allow",
rationale="Smart Routing not enabled; spawn allowed unchanged",
)
# ── Fork exemption ──
if getattr(req, "fork", False):
return SubagentRouteDecision(
action="allow",
rationale="Fork keeps the parent's model; forks are not routed",
model=getattr(req, "parent_model", None),
)
# ── Routing task + client check ──
task = _routing_task(req)
if task is None:
return SubagentRouteDecision(
action="allow",
rationale=(
"No routable signal (encrypted prompt, no task name); "
"subagent inherits the session model"
),
model=getattr(req, "parent_model", None),
)
client = getattr(caps, "routing_client", None)
if client is None:
return SubagentRouteDecision(
action="allow",
rationale="Routing unavailable (no routing client); spawn allowed unchanged",
)
# ── Build candidate models + call router ──
cross_harness = auto_harness
candidates = available_models # Available models pre-computed by relay/caller
if not candidates:
# If not pre-computed, this policy can't build them without importing
# the W2·3 transport helpers. This is a contract expectation: the relay
# handler computes candidates and passes them.
return SubagentRouteDecision(
action="allow",
rationale="No candidate models provided; spawn allowed unchanged",
model=getattr(req, "parent_model", None),
)
try:
result = await client.route(task, candidates)
except Exception: # noqa: BLE001 — router outages are advisory
_logger.warning(
"route-subagent: router call failed for session=%s",
session_id,
exc_info=True,
)
result = None
if result is None or not getattr(result, "model", None):
detail = _opt_str(getattr(client, "last_error", None)) or "router returned no verdict"
return SubagentRouteDecision(
action="allow",
rationale=f"Routing unavailable ({detail}); spawn allowed unchanged",
model=req.parent_model,
)
# ── Apply family constraints ──
return _decision_from_result(req, result, candidates, cross_harness)
def _routing_task(req: Any) -> str | None:
"""Return the task text the router should score.
:param req: Spawn request (duck-typed).
:returns: Task text, or None when the spawn carries no signal to score.
"""
_PROMPT_CAP = 4000
prompt = getattr(req, "prompt", None)
if prompt:
return str(prompt)[:_PROMPT_CAP]
# Codex encrypts the spawn message, so task_name is the only signal.
task_name = getattr(req, "task_name", "")
if task_name:
return str(task_name)[:_PROMPT_CAP]
return None
def _opt_str(value: Any) -> str | None:
"""Coerce value to string or None."""
return value if isinstance(value, str) and value else None
def _counterpart_harness(harness: str) -> str | None:
"""Return the cross-family counterpart harness, or None."""
_COUNTERPART = {
"claude-sdk": "codex",
"claude-native": "codex-native",
"codex": "claude-sdk",
"codex-native": "claude-native",
}
return _COUNTERPART.get(harness)
def _target_harness(
req_harness: str,
picked_harness: str | None,
picked_family: str | None,
) -> str:
"""Name the harness a picked model should run on.
If the picked model's family matches the request harness's family,
keep the request harness. Otherwise try the counterpart. Fall back to
the picked harness or counterpart if known.
"""
from omnigent.server.smart_routing import _HARNESS_FAMILY
if picked_family is not None and picked_family == _HARNESS_FAMILY.get(req_harness):
return req_harness
counterpart = _counterpart_harness(req_harness)
if counterpart is not None and _HARNESS_FAMILY.get(counterpart) == picked_family:
return counterpart
return picked_harness or counterpart or req_harness
def _decision_from_result(
req: Any, # SubagentRouteRequest
result: Any,
candidates: Mapping[str, list[str]],
cross_harness: bool,
) -> SubagentRouteDecision:
"""Translate a router result into a decision, applying family constraints.
The router picked a model. Check it's in the offered set, then apply
family constraints:
- If the model's family matches the request harness's family, rewrite
(same harness).
- If cross-family is allowed (auto harness), redirect to the other family.
- Otherwise deny.
"""
from omnigent.server.smart_routing import _HARNESS_FAMILY, _bare_id
model = getattr(result, "model", None)
rationale = getattr(result, "rationale", "") or ""
# Only report a raw pick that actually differs from resolved model
# (a prefix-only spelling difference is the same arm).
raw = _opt_str(getattr(result, "raw_model", None))
raw_model = (
raw if raw and model and _bare_id(raw) != _bare_id(model) else None
)
# Ensure the picked model is in the offered set.
offered = {m for models in candidates.values() for m in models}
if offered and model not in offered:
return SubagentRouteDecision(
action="deny",
rationale=f"Router picked {model}, which this harness cannot run",
raw_model=raw_model or model,
)
# Determine the family of the picked model.
picked_harness = _opt_str(getattr(result, "harness", None))
family = _HARNESS_FAMILY.get(picked_harness) if picked_harness else None
if family is None:
# Infer family from which harness in candidates has this model.
for harness_id, models in candidates.items():
if model in models:
family = _HARNESS_FAMILY.get(harness_id)
break
# Apply family constraints: determine target harness and action.
req_harness = getattr(req, "harness", "unknown")
target = _target_harness(req_harness, picked_harness, family)
if target == req_harness:
# Same harness: rewrite (or allow if it's the parent model).
req_parent = getattr(req, "parent_model", None)
if req_parent is not None and model == req_parent:
return SubagentRouteDecision(
action="allow",
rationale=rationale or "Router kept the parent model",
model=model,
raw_model=raw_model,
)
return SubagentRouteDecision(
action="rewrite",
rationale=rationale or f"Router selected {model}",
model=model,
raw_model=raw_model,
)
# Cross-harness: only allowed if auto_harness is True. Otherwise deny.
if not cross_harness:
msg = f"Router selected {target}/{model}, but cross-family routing not allowed"
return SubagentRouteDecision(
action="deny",
rationale=msg,
raw_model=raw_model,
)
return SubagentRouteDecision(
action="redirect",
rationale=rationale or f"Router selected {target}/{model}",
model=model,
harness=target,
raw_model=raw_model,
)
@@ -0,0 +1,295 @@
"""Runner-side loopback endpoint for in-harness subagent routing.
This module serves the runner's loopback HTTP endpoint that harness
``PreToolUse`` hooks (Claude ``Agent``/``Task``, Codex ``spawn_agent``) call
before a native subagent spawn. The hook subprocess POSTs a routing request,
and the runner forwards it to the server's policy resolver via the server
relay route (:data:`omnigent.server.routing_contract.SUBAGENT_SERVER_RELAY_PATH`).
The endpoint is advertised to hook scripts via ``subagent_router.json`` in the
session bridge directory, following the same rendezvous pattern as
``tool_relay.json``.
**Architecture:**
- Hook subprocess POST /v1/sessions/{id}/route-subagent loopback endpoint (this module)
- Loopback endpoint POST /v1/sessions/{id}/hooks/route-subagent server relay (W2·4)
- Server relay policy resolver (W2·4,
:func:`omnigent.server.subagent_routing_policy.resolve_subagent_route`)
- Policy resolver :class:`omnigent.server.routing_contract.SubagentRouteDecision`
- Loopback endpoint (returns decision JSON) hook subprocess
**Timeout budget:**
1. Harness hook timeout 40s (Claude native hook entry, codex spawn hook)
2. Hook script HTTP request 30s (defined in ``omnigent.inner.hook_scripts.subagent_router``)
3. Runner loopback relay wait 20s (this module's :data:`RELAY_TIMEOUT_S`)
4. Server relay hop 15s (the relay handler in ``routes/sessions/routes_hooks.py``)
The loopback endpoint is fail-open: any transport failure, authentication error,
timeout, or unparseable response allows the spawn unchanged (routing is an
optimization, never a gate that blocks legitimate spawns).
"""
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from http import HTTPStatus
from pathlib import Path
from typing import Any
from urllib.parse import unquote
from omnigent.server.routing_contract import (
SUBAGENT_SERVER_RELAY_PATH,
SubagentRouteDecision,
)
_logger = logging.getLogger(__name__)
#: Cap on the task-name field parsed off a spawn payload, so a pathological
#: hook body can't balloon a decision row.
_TASK_NAME_CAP = 200
@dataclass(frozen=True)
class SubagentRouteRequest:
"""One native-subagent spawn awaiting a routing verdict.
The wire shape the hook subprocess POSTs to the loopback and the server
relay forwards to the policy. ``build_route_request`` in
:mod:`omnigent.inner.hook_scripts.subagent_router` produces the matching
JSON.
:param harness: Requesting harness id, e.g. ``"claude-native"``.
:param task_name: Subagent type / task name from the spawn payload.
:param prompt: Raw task text. ``None`` on codex (its spawn message is
encrypted in hook payloads).
:param fork: ``True`` when the spawn forks the parent session.
:param parent_model: Model the parent session runs on, when known.
"""
harness: str
task_name: str = ""
prompt: str | None = None
fork: bool = False
parent_model: str | None = None
@classmethod
def from_payload(cls, payload: Mapping[str, Any]) -> SubagentRouteRequest:
"""Parse a loopback/relay request body.
:param payload: Decoded JSON object from the hook script.
:returns: Parsed request.
:raises ValueError: If ``harness`` is missing or not a string.
"""
harness = payload.get("harness")
if not isinstance(harness, str) or not harness.strip():
raise ValueError("route-subagent body requires a non-empty 'harness' string")
task_name = payload.get("task_name")
prompt = payload.get("prompt")
parent_model = payload.get("parent_model")
return cls(
harness=harness.strip(),
task_name=task_name[:_TASK_NAME_CAP] if isinstance(task_name, str) else "",
prompt=prompt if isinstance(prompt, str) and prompt else None,
fork=bool(payload.get("fork")),
parent_model=(
parent_model if isinstance(parent_model, str) and parent_model else None
),
)
def auto_harness_session(conv: Any, parent: Any = None) -> bool:
"""Report whether a session may cross harness families for a subagent spawn.
True only for a session in Smart Routing (auto) harness mode, or a child
of one: those are the sessions whose harness the router owns. Everyone
else is pinned to the family they started on, so a codex session never
gets Claude children and vice versa. In v2 the auto mode is carried by the
conversation's ``harness_override == "auto"`` sentinel (there is no label).
:param conv: Conversation row for the session, or ``None``.
:param parent: Conversation row for its parent, when known.
:returns: ``True`` when cross-family picks are allowed.
"""
for row in (conv, parent):
if row is not None and getattr(row, "harness_override", None) == "auto":
return True
return False
#: Seconds the loopback handler waits for the server relay response.
#: Hop 3 of the timeout budget in the module docstring.
RELAY_TIMEOUT_S = 20.0
class SubagentRouteHandler:
"""Handles POST requests to the loopback endpoint.
Bound to :data:`SUBAGENT_LOOPBACK_PATH` by the runner; decodes the hook
request, forwards to the server relay, and returns the decision.
:param session_id: Omnigent session id this endpoint serves.
:param server_relay_url: Base URL of the server relay (e.g., ``http://127.0.0.1:6868``).
:param bearer_token: Token hook requests must present in the Authorization header.
:param loop: Event loop that owns the server relay client.
:param relay_client: Async HTTP client for calling the server relay.
"""
def __init__(
self,
session_id: str,
server_relay_url: str,
bearer_token: str,
loop: asyncio.AbstractEventLoop,
relay_client: Any,
timeout_s: float = RELAY_TIMEOUT_S,
):
self.session_id = session_id
self.server_relay_url = server_relay_url
self.bearer_token = bearer_token
self.loop = loop
self.relay_client = relay_client
self.timeout_s = timeout_s
async def handle_route_request(self, request_body: dict[str, Any]) -> tuple[int, str]:
"""
Handle a routing request from a hook subprocess.
Decodes the request, calls the server relay, and returns the decision
or an error response. On any failure, returns a 500 status so the
hook subprocess can detect and fail-open.
:param request_body: Parsed JSON request body from the hook.
:returns: Tuple of (HTTP status code, JSON response body).
"""
try:
# Extract session id from the request (it should match the endpoint's session id).
req_session_id = request_body.get("session_id", "")
if req_session_id and req_session_id != self.session_id:
_logger.warning(
"subagent route request for session %s does not match endpoint session %s",
req_session_id,
self.session_id,
)
msg = f"request session_id {req_session_id} != endpoint session {self.session_id}"
return HTTPStatus.FORBIDDEN, json.dumps(
{
"error": "session mismatch",
"rationale": msg,
}
)
# Build the server relay URL using the frozen path template.
relay_path = SUBAGENT_SERVER_RELAY_PATH.format(session_id=unquote(self.session_id))
relay_url = f"{self.server_relay_url}{relay_path}"
# Call the server relay.
decision = await self._call_server_relay(relay_url, request_body)
if decision is None:
# Relay error; return 500 so the hook fails open.
return HTTPStatus.INTERNAL_SERVER_ERROR, json.dumps(
{"error": "relay failed", "rationale": "server relay did not respond"}
)
# Return the decision payload.
if isinstance(decision, SubagentRouteDecision):
payload = decision.to_payload()
else:
payload = decision
return HTTPStatus.OK, json.dumps(payload)
except Exception as e: # noqa: BLE001,RUF100
_logger.exception("subagent route handler error: %s", e)
return HTTPStatus.INTERNAL_SERVER_ERROR, json.dumps(
{"error": "handler error", "rationale": str(e)}
)
async def _call_server_relay(
self,
relay_url: str,
request_body: dict[str, Any],
) -> SubagentRouteDecision | None:
"""
Call the server relay and decode the decision.
:param relay_url: Server relay URL (already formatted with session_id).
:param request_body: Hook request body to forward.
:returns: Decoded decision, or ``None`` on failure.
"""
try:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.bearer_token}",
}
async with asyncio.timeout(self.timeout_s):
resp = await self.relay_client.post(
relay_url,
json=request_body,
headers=headers,
)
if resp.status_code != HTTPStatus.OK:
_logger.warning(
"server relay returned %d for session %s",
resp.status_code,
self.session_id,
)
return None
payload = resp.json()
# Deserialize the decision. The relay returns the frozen shape.
return SubagentRouteDecision(
action=payload.get("action", "allow"),
rationale=payload.get("rationale", ""),
model=payload.get("model"),
harness=payload.get("harness"),
raw_model=payload.get("raw_model"),
decision_id=payload.get("decision_id"),
)
except asyncio.TimeoutError:
_logger.warning("server relay timeout for session %s", self.session_id)
return None
except Exception as e: # noqa: BLE001
_logger.warning("server relay call failed: %s", e)
return None
def write_loopback_advertisement(
bridge_dir: Path,
url: str,
token: str,
session_id: str | None = None,
) -> Path:
"""
Advertise the loopback endpoint to hook scripts.
Writes a JSON file (``subagent_router.json``) into the bridge directory
so hook scripts can discover the endpoint. Follows the rendezvous pattern
used by ``tool_relay.json``.
:param bridge_dir: Session bridge directory (created if missing).
:param url: Loopback endpoint base URL (e.g., ``http://127.0.0.1:12345``).
:param token: Bearer token hook scripts must present.
:param session_id: Session id (included in advertisement so hooks know which session).
:returns: Path of the written advertisement file.
"""
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
path = bridge_dir / "subagent_router.json"
payload: dict[str, Any] = {
"url": url,
"token": token,
}
if session_id is not None:
payload["session_id"] = session_id
path.write_text(json.dumps(payload), encoding="utf-8")
# Restrict permissions to owner since the token is in the file.
path.chmod(0o600)
return path
+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
+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 this host resolves AI-Gateway-backed inference, e.g.
``{"claude-native": True, "codex": False}``. A family that could not
be evaluated is omitted. ``None`` when the host has never reported it
unknown, never "nothing 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(),
)
)
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
. "$(cd "$(dirname "$0")" && pwd)/dev-env.sh"
# nvm's lazy-load shim breaks in non-interactive shells; use the binary directly.
PATH="$HOME/.nvm/versions/node/v24.14.0/bin:$PATH"
cd "$WORKTREE/web"
# No `--` before the flags: `pnpm run dev -- --port N` makes vite treat
# `--port N` as post-`--` app args and ignore it, so the dev server drifts to
# a default port (5173/5174) instead of ROUTING_FRONTEND_PORT. --strictPort
# fails loudly if the port is taken rather than silently picking another.
OMNIGENT_URL="http://localhost:$ROUTING_SERVER_PORT" exec pnpm run dev --port "$ROUTING_FRONTEND_PORT" --strictPort
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"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Barrier-1 apply check (plan 4e barrier 1, item 3).
#
# Proves the two hardest layers — claude /model injection and codex settings
# push — apply a model to a RUNNING native session with NO router involved,
# before any routing gate exists to reach them through. Wave 0 writes this so
# the check exists before wave 1 finishes; no wave-1 stream owns it.
#
# It pins a hardcoded model onto a live claude pane (R2: tmux pane capture) and
# a live codex session (R3: config.toml + newest rollout turn_context), then
# asserts the process actually runs that model. This is a scaffold with the
# assertion points marked TODO(barrier-1): the lead fills the exact model ids and
# session-bring-up against the wave-1 apply layer once streams 5/6 land, using
# recipes R0/R2/R3 from designs/CUJ_STATUS.md §1. It is deliberately not wired to
# a live stack at wave-0 time — it documents and enforces the check's shape.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=/dev/null
[ -f "$HERE/dev-env.sh" ] && source "$HERE/dev-env.sh"
# Target models come from the environment so no model id is hardcoded here
# (repo no-hardcoded-models guard). The lead exports these at barrier-1 time
# from a task_v1 arm in omnigent/model_fallbacks.py, e.g.
# BARRIER1_CLAUDE_MODEL=databricks-<claude arm> (a TASK_V1_ARMS["claude"] id)
# BARRIER1_CODEX_MODEL=databricks-<codex arm> (a TASK_V1_ARMS["codex"] id)
CLAUDE_MODEL="${BARRIER1_CLAUDE_MODEL:-}"
CODEX_MODEL="${BARRIER1_CODEX_MODEL:-}"
[ -n "$CLAUDE_MODEL" ] || { echo "set BARRIER1_CLAUDE_MODEL (a claude task_v1 arm)"; exit 2; }
[ -n "$CODEX_MODEL" ] || { echo "set BARRIER1_CODEX_MODEL (a codex task_v1 arm)"; exit 2; }
echo "barrier-1 apply check — no router in the loop"
echo " claude target: $CLAUDE_MODEL"
echo " codex target: $CODEX_MODEL"
fail() { echo "BARRIER-1 FAIL: $*" >&2; exit 1; }
# --- claude pane (R2) ---------------------------------------------------------
# TODO(barrier-1): with streams 5/6 landed, bring up a claude-native session
# (R0), PATCH model_override=$CLAUDE_MODEL, and assert via R2 (tmux capture of
# the runner's pane) that the status bar shows the target and the transcript
# holds exactly one /model injection. Until then this is a documented skip.
echo " [skip] claude pane assertion — fill against wave-1 stream 5 (R2)"
# --- codex session (R3) -------------------------------------------------------
# TODO(barrier-1): bring up a codex-native session (R0), PATCH
# model_override=$CODEX_MODEL, and assert via R3 that config.toml and the newest
# rollout turn_context both name the target model.
echo " [skip] codex session assertion — fill against wave-1 stream 6 (R3)"
echo "barrier-1 apply check: scaffold OK (assertions pending wave-1 apply layer)"
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# One-shot verification for create-time Smart Routing.
#
# Proves the PR's contract end-to-end: Smart Routing is decided ONCE at session
# create (never per-turn), for every routable harness — the native TUIs
# (claude-native / codex-native), the in-process claude-sdk agents (polly /
# debby), and the cross-family "auto" harness. A session created WITHOUT a
# routing prompt is not routed.
#
# What it checks
# 1. Unit suites (routing_create, resolve_route, the model_override /
# child-session integration tests) pass.
# 2. Against a running local stack: a create carrying a smart_routing_message
# pins a SERVABLE model (databricks-<arm> / system.ai.glm-5-2), for each
# harness — proving create-time routing + servable-spelling resolution.
# 3. A create WITHOUT a smart_routing_message is NOT routed (model_override
# stays null) — proving there is no per-turn routing fallback.
#
# Usage
# scripts/verify_smart_routing.sh # unit tests + live checks
# SKIP_LIVE=1 scripts/verify_smart_routing.sh # unit tests only
# ROUTING_SERVER=http://localhost:6868 scripts/verify_smart_routing.sh
#
# Requires: a stack brought up per LOCAL_SETUP.md (server + host, routing
# profile + databricks-sdk configured). See LOCAL_SETUP.md if the live checks
# report the host is not gateway-backed or the router 401s.
set -uo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
cd "$HERE"
SERVER="${ROUTING_SERVER:-http://localhost:6868}"
pass=0
fail=0
ok() { echo "PASS $*"; pass=$((pass + 1)); }
bad() { echo "FAIL $*" >&2; fail=$((fail + 1)); }
note() { echo " $*"; }
echo "== 1. Unit suites =="
if uv run --no-sync python -m pytest -q \
tests/server/test_routing_create.py \
tests/server/test_resolve_route.py \
tests/server/integration/test_sessions_model_override.py \
>/tmp/verify_smart_routing_pytest.log 2>&1; then
ok "unit suites (routing_create + resolve_route + model_override/child)"
else
bad "unit suites — see /tmp/verify_smart_routing_pytest.log"
tail -20 /tmp/verify_smart_routing_pytest.log >&2
fi
if [ "${SKIP_LIVE:-}" = "1" ]; then
echo ""
echo "== live checks skipped (SKIP_LIVE=1) =="
echo "== $pass passed, $fail failed =="
[ "$fail" -eq 0 ] || exit 1
exit 0
fi
echo ""
echo "== 2. Live create-time routing ($SERVER) =="
if ! curl -sf "$SERVER/v1/hosts" >/dev/null 2>&1; then
bad "server not reachable at $SERVER — bring the stack up (LOCAL_SETUP.md) or set SKIP_LIVE=1"
echo "== $pass passed, $fail failed =="
exit 1
fi
# Discover a routable agent id per harness from the live catalog (no hardcoded
# ids): the native UI wrappers and any claude-sdk brain agent (polly / debby).
_agent_for() { # $1 = harness
curl -s "$SERVER/v1/agents" 2>/dev/null | python3 -c "
import sys, json
want = sys.argv[1]
for a in json.load(sys.stdin).get('data', []):
if a.get('harness') == want:
print(a['id']); break
" "$1"
}
CODEX_AGENT="$(_agent_for codex-native)"
CLAUDE_AGENT="$(_agent_for claude-native)"
SDK_AGENT="$(_agent_for claude-sdk)"
# Create a session and echo its persisted (harness, model_override).
_create() { # $1=agent_id $2=harness_override $3=smart_routing_message(optional)
local agent="$1" harness="$2" msg="${3:-}"
python3 - "$SERVER" "$agent" "$harness" "$msg" <<'PY'
import sys, json, urllib.request
server, agent, harness, msg = sys.argv[1:5]
body = {"agent_id": agent, "harness_override": harness, "cost_control_mode_override": "on"}
if msg:
body["smart_routing_message"] = msg
req = urllib.request.Request(
f"{server}/v1/sessions", data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"}, method="POST",
)
try:
d = json.load(urllib.request.urlopen(req, timeout=30))
except Exception as e: # noqa: BLE001
print(f"ERR {e}"); sys.exit(0)
print(f"{d.get('harness')}\t{d.get('model_override')}")
PY
}
# A servable model id is prefixed (databricks-*) or the glm system.ai. spelling.
_is_servable() { case "$1" in databricks-*|system.ai.*) return 0;; *) return 1;; esac; }
P_BUGFIX='Fix the trailing-whitespace trimming bug in the config loader and add a regression test.'
P_TRIVIAL='What testing framework does this project use?'
# --- codex-native: create with prompt routes a servable codex arm ---
if [ -n "$CODEX_AGENT" ]; then
IFS=$'\t' read -r h m <<<"$(_create "$CODEX_AGENT" codex-native "$P_TRIVIAL")"
if [ "$h" = "codex-native" ] && _is_servable "$m"; then
ok "codex-native create routed a servable model ($m)"
else
bad "codex-native create: harness=$h model=$m (want codex-native + servable)"
fi
else
note "skip codex-native — no codex-native agent registered"
fi
# --- claude-native: create with prompt routes a servable claude arm ---
if [ -n "$CLAUDE_AGENT" ]; then
IFS=$'\t' read -r h m <<<"$(_create "$CLAUDE_AGENT" claude-native "$P_BUGFIX")"
if [ "$h" = "claude-native" ] && _is_servable "$m"; then
ok "claude-native create routed a servable model ($m)"
else
bad "claude-native create: harness=$h model=$m (want claude-native + servable)"
fi
else
note "skip claude-native — no claude-native agent registered"
fi
# --- claude-sdk (polly/debby): create with prompt routes a servable claude arm ---
if [ -n "$SDK_AGENT" ]; then
IFS=$'\t' read -r h m <<<"$(_create "$SDK_AGENT" claude-sdk "$P_BUGFIX")"
if [ "$h" = "claude-sdk" ] && _is_servable "$m"; then
ok "claude-sdk create routed a servable model ($m)"
else
bad "claude-sdk create: harness=$h model=$m (want claude-sdk + servable)"
fi
# --- auto: cross-family, picks harness + servable model ---
IFS=$'\t' read -r h m <<<"$(_create "$SDK_AGENT" auto "$P_TRIVIAL")"
if { [ "$h" = "claude-native" ] || [ "$h" = "codex-native" ]; } && _is_servable "$m"; then
ok "auto create routed harness+servable model ($h / $m)"
else
bad "auto create: harness=$h model=$m (want a native harness + servable)"
fi
# --- NO prompt: not routed (proves per-turn routing is gone) ---
IFS=$'\t' read -r h m <<<"$(_create "$SDK_AGENT" claude-sdk "")"
if [ "$m" = "None" ] || [ -z "$m" ]; then
ok "create WITHOUT a routing prompt is not routed (model_override=$m)"
else
bad "create without prompt got model_override=$m (expected none — no per-turn routing)"
fi
else
note "skip claude-sdk / auto — no claude-sdk agent registered"
fi
echo ""
echo "== $pass passed, $fail failed =="
[ "$fail" -eq 0 ] || exit 1
@@ -0,0 +1,122 @@
"""Tests for the hosts.gateway_inference migration (66b439064d06).
Verifies that at head the ``hosts.gateway_inference`` column exists, that the
host store round-trips a per-family gateway-inference map through it (upsert on
connect and the live readiness refresh), and that upgradedowngrade round-trips
(the column is dropped on downgrade, back to the prior revision).
"""
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,
)
from omnigent.stores.host_store import HostStore
# The revision just below 66b439064d06 in the chain; downgrade targets it.
_PRIOR_REVISION = "c4d5e6f7a8b9"
@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_present_at_head(db_engine: Engine) -> None:
"""At head the nullable ``hosts.gateway_inference`` column exists."""
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_host_store_round_trips_gateway_inference(db_uri: str) -> None:
"""upsert_on_connect persists the per-family map and reads it back."""
store = HostStore(db_uri)
gw = {"claude-native": True, "codex": False}
created = store.upsert_on_connect(
host_id="bdda8ba7e34130318b54dd872eb160af",
name="test-laptop",
user_id="alice@example.com",
gateway_inference=gw,
)
assert created.gateway_inference == gw
# Re-read through get_host: the JSON column decodes back to the map.
fetched = store.get_host("bdda8ba7e34130318b54dd872eb160af")
assert fetched is not None
assert fetched.gateway_inference == gw
def test_host_store_none_gateway_inference_is_unknown(db_uri: str) -> None:
"""A host that reports nothing stores NULL, read back as None (unknown)."""
store = HostStore(db_uri)
created = store.upsert_on_connect(
host_id="aaaa8ba7e34130318b54dd872eb160af",
name="older-host",
user_id="alice@example.com",
gateway_inference=None,
)
assert created.gateway_inference is None
fetched = store.get_host("aaaa8ba7e34130318b54dd872eb160af")
assert fetched is not None
assert fetched.gateway_inference is None
def test_update_harness_readiness_writes_gateway_inference(db_uri: str) -> None:
"""The live readiness refresh updates the stored gateway-inference map."""
store = HostStore(db_uri)
host_id = "cccc8ba7e34130318b54dd872eb160af"
store.upsert_on_connect(
host_id=host_id,
name="laptop",
user_id="alice@example.com",
gateway_inference={"claude-native": False, "codex": False},
)
store.update_harness_readiness(
host_id,
{"claude-native": True, "codex": True},
{"claude-native": True, "codex": True},
)
fetched = store.get_host(host_id)
assert fetched is not None
assert fetched.gateway_inference == {"claude-native": True, "codex": True}
def test_downgrade_drops_gateway_inference_column(tmp_path: Path) -> None:
"""Downgrade to the prior revision removes the column (round-trip)."""
db_path = tmp_path / "downgrade.db"
uri = f"sqlite:///{db_path}"
engine = get_or_create_engine(uri)
# Sanity: head state before downgrade carries the column.
columns = {c["name"] for c in sa.inspect(engine).get_columns("hosts")}
assert "gateway_inference" in columns
config = _build_alembic_config(uri)
with engine.begin() as conn:
config.attributes["connection"] = conn
command.downgrade(config, _PRIOR_REVISION)
remaining = {c["name"] for c in sa.inspect(engine).get_columns("hosts")}
assert "gateway_inference" not in remaining
# A sibling column survives — downgrade dropped only the new column.
assert "configured_harnesses" in remaining
engine.dispose()
clear_engine_cache()
+39
View File
@@ -242,14 +242,53 @@ def test_hello_frame_configured_harnesses_round_trip() -> None:
assert decoded.configured_harnesses == {"claude-sdk": True, "codex": "needs-auth"}
def test_hello_frame_gateway_inference_round_trip() -> None:
"""Verify the hello frame's gateway_inference map survives encode → decode.
The False must survive as well as the True the web gates the Smart
Routing option on it, and False is the actionable "hide the option" value
(plan 3f).
"""
original = HostHelloFrame(
version="0.1.0",
frame_protocol_version=1,
name="corey-laptop",
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_legacy_payload_gateway_inference_is_none() -> None:
"""A hello payload with no gateway_inference key decodes to None (unknown).
An older host that never reports the map must read as "unknown" never {}
or a raise so the server can tell it apart from "nothing gateway-backed".
"""
encoded = json.dumps(
{
"kind": "host.hello",
"version": "0.1.0",
"frame_protocol_version": 1,
"name": "old-host",
}
)
decoded = decode_host_frame(encoded)
assert isinstance(decoded, HostHelloFrame)
assert decoded.gateway_inference is None
def test_harness_readiness_frame_round_trip() -> None:
"""Verify a live readiness refresh survives encode and decode."""
original = HostHarnessReadinessFrame(
configured_harnesses={"pi": True, "codex": "needs-auth"},
gateway_inference={"codex": True},
)
decoded = decode_host_frame(encode_host_frame(original))
assert isinstance(decoded, HostHarnessReadinessFrame)
assert decoded.configured_harnesses == {"pi": True, "codex": "needs-auth"}
assert decoded.gateway_inference == {"codex": True}
def test_harness_readiness_frame_rejects_unknown_availability() -> None:
+166
View File
@@ -0,0 +1,166 @@
"""Directed tests for Claude Code's model vocabulary translation.
Covers the four routing traps the vocabulary layer exists to close: a
catalog id maps to a family alias, an unknown id fails open (``None``), a
drifted / unpinned family is untranslatable, and a custom-slot id is spoken
byte-exactly. See ``omnigent/claude_model_vocabulary.py``.
"""
from __future__ import annotations
import pytest
from omnigent.claude_model_vocabulary import (
claude_model_alias,
claude_model_command_arg,
model_vocabulary_env,
normalized_model_id,
)
# A ucode-style launch pinning: each family alias mapped to a gateway id,
# plus the extra picker slot holding the newer sonnet generation.
_PINNED_ENV = {
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-sonnet-4-6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "databricks-claude-haiku-4-5",
"ANTHROPIC_CUSTOM_MODEL_OPTION": "databricks-claude-sonnet-5",
}
@pytest.mark.parametrize(
("model", "expected"),
[
# Catalog id -> the alias whose pin resolves to exactly that id.
("databricks-claude-opus-4-8", "opus"),
("databricks-claude-sonnet-4-6", "sonnet"),
("databricks-claude-haiku-4-5", "haiku"),
# Pinned to the custom slot, and the Agent tool enum has no slot for
# it: "sonnet" resolves to the pinned 4-6, so stepping down would run
# a model nobody asked for. The alias surface declines.
("databricks-claude-sonnet-5", None),
# Prefix + context-suffix are normalized away before comparison.
("claude-opus-4-8[1m]", "opus"),
# An alias already: passed through.
("sonnet", "sonnet"),
# Not a Claude id at all.
("databricks-gpt-5-5", None),
],
)
def test_alias_translation_under_a_pinned_launch(model: str, expected: str | None) -> None:
assert claude_model_alias(model, _PINNED_ENV) == expected
def test_alias_from_the_id_when_nothing_is_pinned() -> None:
"""A direct Anthropic login accepts the family alias as-is."""
assert claude_model_alias("databricks-claude-sonnet-5", {}) == "sonnet"
assert claude_model_alias("mystery-model", {}) is None
def test_unpinned_family_is_untranslatable() -> None:
"""An unpinned alias resolves to a vendor id the gateway rejects."""
env = {"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8"}
assert claude_model_alias("databricks-claude-sonnet-5", env) is None
assert claude_model_alias("databricks-claude-opus-4-8", env) == "opus"
def test_alias_requires_the_pin_to_match_the_routed_id() -> None:
"""A drifted family pin must not be spoken as its alias."""
env = {"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5"}
assert claude_model_alias("databricks-claude-opus-4-8", env) is None
assert claude_model_alias("databricks-claude-opus-5", env) == "opus"
# A launch whose family pins drifted a generation ahead of the routed id, with
# and without the extra picker slot holding that exact id.
_DRIFTED_ENV = {
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-sonnet-5",
}
_DRIFTED_WITH_SLOT = {
**_DRIFTED_ENV,
"ANTHROPIC_CUSTOM_MODEL_OPTION": "databricks-claude-opus-4-8",
}
@pytest.mark.parametrize(
("model", "env", "expected"),
[
# Drifted pins and no custom slot: nothing spells the routed id, so
# the caller must skip the switch (fail open).
("databricks-claude-opus-4-8", _DRIFTED_ENV, None),
# The slot holds it exactly, so that is the ``/model`` arg.
("databricks-claude-opus-4-8", _DRIFTED_WITH_SLOT, "databricks-claude-opus-4-8"),
# ``/model`` compares the custom slot byte-exactly, so the arg is the
# env's own spelling even when the routed id differs in prefix or case.
("system.ai.claude-opus-4-8", _DRIFTED_WITH_SLOT, "databricks-claude-opus-4-8"),
# A pinned launch prefers the custom slot's exact id over stepping down
# to the family alias.
("databricks-claude-sonnet-5", _PINNED_ENV, "databricks-claude-sonnet-5"),
# A family pin that already matches resolves to the bare alias.
("databricks-claude-sonnet-4-6", _PINNED_ENV, "sonnet"),
# Not a Claude id at all.
("databricks-gpt-5-5", _PINNED_ENV, None),
# Empty / whitespace-only input is never a spellable model.
("", _PINNED_ENV, None),
(" ", _PINNED_ENV, None),
],
)
def test_command_arg_spells_the_routed_model_or_nothing(
model: str, env: dict[str, str], expected: str | None
) -> None:
assert claude_model_command_arg(model, env) == expected
def test_model_vocabulary_env_rebuilds_the_pinning_from_picker_rows() -> None:
"""Picker rows are the launch env's pinning read back out."""
env = model_vocabulary_env(
[
{"id": "opus", "model": "databricks-claude-opus-5"},
{"id": "sonnet", "model": "databricks-claude-sonnet-5"},
{"id": "sonnet_5", "model": "databricks-claude-opus-4-8"},
{"id": "haiku"},
"not-a-row",
]
)
assert env == {
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-sonnet-5",
"ANTHROPIC_CUSTOM_MODEL_OPTION": "databricks-claude-opus-4-8",
}
# The rebuilt env speaks the same vocabulary the executor would.
assert claude_model_command_arg("databricks-claude-opus-4-8", env) == (
"databricks-claude-opus-4-8"
)
assert model_vocabulary_env([]) == {}
def test_model_vocabulary_env_ignores_rows_that_pin_nothing() -> None:
"""A direct Claude login's curated rows restate their own key."""
assert (
model_vocabulary_env(
[
{"id": "opus", "model": "opus", "displayName": "Opus"},
{"id": "sonnet_5", "model": "sonnet_5", "displayName": "Sonnet 5"},
]
)
== {}
)
def test_normalized_model_id_strips_prefix_and_context_suffix() -> None:
assert normalized_model_id("databricks-claude-sonnet-5") == "claude-sonnet-5"
assert normalized_model_id("system.ai.claude-sonnet-5") == "claude-sonnet-5"
assert normalized_model_id("Claude-Opus-4-8[1M]") == "claude-opus-4-8"
def test_catalog_prefixes_match_the_routing_contract() -> None:
"""This module duplicates the prefix list to stay stdlib-only; keep it equal.
Anchored on the frozen wave-0 contract (``routing_contract.MODEL_ID_PREFIXES``)
rather than ``smart_routing`` so this test does not depend on another
concurrent stream having landed its re-export.
"""
from omnigent.claude_model_vocabulary import _CATALOG_PREFIXES
from omnigent.server.routing_contract import MODEL_ID_PREFIXES
assert _CATALOG_PREFIXES == MODEL_ID_PREFIXES
+165 -2
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)
@@ -877,13 +885,168 @@ 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),
# accepted if the CLI ever pops a confirmation dialog. The arg is the
# session's alias, not the raw catalog id ``/model`` would ignore.
("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.
``/model`` takes the custom slot's exact id, so a routed model parked
there is spelled precisely instead of stepping down to its family alias.
"""
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. The switch is declined instead.
"""
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,
+603
View File
@@ -0,0 +1,603 @@
"""Directed tests for codex subagent-routing hooks generation + trust.
Covers wave-1 stream 7 (plan 2c): the ``PreToolUse`` route-subagent hook
generation, the user-hooks merge, the ``python -I`` isolation trap, the
version-probe "unparseable = supported" rule, and the trust handshake's
module-scoped filtering. The cut enforcement stack (canary / audit / watcher /
banner, plan 3b) is deliberately NOT generated, and this suite asserts its
absence.
"""
from __future__ import annotations
import asyncio
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 (
_MIN_ROUTER_HOOK_CODEX_VERSION,
CODEX_ROUTER_DIR_ENV_VAR,
CODEX_ROUTER_SESSION_ID_ENV_VAR,
_codex_router_hooks_supported,
_CodexAppServerSession,
_populate_codex_home_config,
codex_router_bridge_dir,
codex_router_hooks_settings,
codex_router_session_id,
merge_codex_user_hooks,
trust_codex_router_hooks,
write_codex_router_hooks_file,
)
from omnigent.server.routing_contract import SUBAGENT_LOOPBACK_PATH
_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
# ── hook generation ─────────────────────────────────────────────────────
def test_router_hooks_settings_registers_only_the_route_gate(tmp_path: Path) -> None:
"""One PreToolUse gate; NO canary / audit (the cut stack, plan 3b)."""
payload = codex_router_hooks_settings(
tmp_path / "bridge",
session_id="conv_abc",
python_executable="/usr/bin/python3",
)
hooks = payload["hooks"]
# Only the route-subagent gate — the SessionStart canary and the
# SubagentStart audit belong to the cut enforcement stack.
assert set(hooks) == {"PreToolUse"}
assert "SessionStart" not in hooks
assert "SubagentStart" not in hooks
(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"
assert "route-subagent" in pre_hook["command"]
assert "--session-id conv_abc" in pre_hook["command"]
assert "--harness codex" in pre_hook["command"]
# Assert on the split argv: shlex.join shell-quotes the bridge dir and the
# loopback path (the ``{session_id}`` braces are shell-special), so the raw
# command string carries quotes the argv does not.
argv = shlex.split(pre_hook["command"])
assert argv[argv.index("--bridge-dir") + 1] == str(tmp_path / "bridge")
# The frozen loopback path from the contract is threaded through verbatim.
assert argv[argv.index("--loopback-path") + 1] == SUBAGENT_LOOPBACK_PATH
def test_router_hook_timeout_brackets_the_request_budget(tmp_path: Path) -> None:
"""Codex's kill is the outermost bound: just above the hook's budget."""
payload = codex_router_hooks_settings(tmp_path, python_executable="/usr/bin/python3")
(pre_hook,) = payload["hooks"]["PreToolUse"][0]["hooks"]
request_budget = codex_executor._CODEX_ROUTER_HOOK_REQUEST_TIMEOUT_SECONDS
assert request_budget < pre_hook["timeout"] < 2 * request_budget
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")
command = payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
assert "--session-id" not in command
def test_router_hook_carries_native_harness_label(tmp_path: Path) -> None:
payload = codex_router_hooks_settings(
tmp_path, harness="codex-native", python_executable="/usr/bin/python3"
)
command = payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
assert "--harness codex-native" in command
# ── python -I isolation trap (0d) ─────────────────────────────────────────
def test_router_hook_command_runs_python_isolated(tmp_path: Path) -> None:
"""The 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.
"""
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_isolated_python_ignores_a_shadowing_workspace(tmp_path: Path) -> None:
"""The generated ``-I -m`` command reaches the real installed package.
The end-to-end proof of the isolation flag: runs the real generated
argv from a workspace that shadows the installed package with a decoy
``omnigent/`` dir exactly the live failure. The decoy's ``__init__``
raises on import, so if cwd were on ``sys.path`` (no ``-I``) the process
would execute it. With ``-I`` Python skips cwd and imports the real
editable package instead, so the decoy sentinel never appears.
The wave-2 hook script module (``hook_scripts.codex_router_hook``) does
not exist in this wave, so the run ends on that submodule being missing
but crucially NOT on the decoy. That distinction is the whole proof:
the decoy sentinel in stderr means cwd shadowed the package; its absence
means ``-I`` did its job.
"""
workspace = tmp_path / "workspace"
decoy = workspace / "omnigent"
decoy.mkdir(parents=True)
sentinel = "DECOY_PACKAGE_IMPORTED"
(decoy / "__init__.py").write_text(f"raise AssertionError({sentinel!r})\n")
command = codex_router_hooks_settings(
tmp_path / "bridge", session_id="conv_abc", python_executable=sys.executable
)["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
result = subprocess.run(
shlex.split(command),
cwd=str(workspace),
capture_output=True,
text=True,
timeout=120,
)
# -I kept cwd off sys.path, so the decoy package was never imported.
assert sentinel not in result.stderr, result.stderr
assert "decoy" not in result.stderr.lower(), result.stderr
# Control: WITHOUT -I the same argv executes the decoy and raises.
argv = shlex.split(command)
assert argv[1] == "-I"
no_isolation = [argv[0], *argv[2:]]
control = subprocess.run(
no_isolation,
cwd=str(workspace),
capture_output=True,
text=True,
timeout=120,
)
assert sentinel in control.stderr, control.stderr
# ── user-hooks merge (no clobber) ─────────────────────────────────────────
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
# Omnigent's route gate stays first so it gates before user hooks.
assert pre[0]["matcher"] == r".*spawn_agent"
assert pre[1]["hooks"][0]["command"] == "user-pre"
# Events the user declares alone are added wholesale.
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")
# A bad user file must never break routing — payload is unchanged.
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",
)
# The symlink is replaced by a real merged file.
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 on disk is untouched.
assert json.loads((source / "hooks.json").read_text()) == _USER_HOOKS
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_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)
# The generated file will own hooks.json — no symlink shadowing it.
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()
# ── env discovery ─────────────────────────────────────────────────────────
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
# ── version probe: unparseable = supported (0d) ────────────────────────────
def test_unparseable_codex_version_counts_as_supported() -> None:
"""A ``None`` version (probe could not parse) is treated as supported.
A flaky ``codex --version`` must never silently disable routing that
would wedge a terminal on a spawn prompt no subagent can answer. A
genuinely old codex fails loudly later at the trust gate instead.
"""
assert _codex_router_hooks_supported(None) is True
def test_supported_and_too_old_codex_versions() -> None:
assert _codex_router_hooks_supported(_MIN_ROUTER_HOOK_CODEX_VERSION) is True
assert _codex_router_hooks_supported((0, 200, 0)) is True
major, minor, patch = _MIN_ROUTER_HOOK_CODEX_VERSION
assert _codex_router_hooks_supported((major, minor - 1, patch)) is False
# ── trust handshake: module-scoped filtering ───────────────────────────────
class _RecordingRequest:
"""A stub app-server ``request`` returning scripted ``hooks/list`` data."""
def __init__(self, listings: list[list[dict[str, Any]]], cwd: str) -> None:
self._listings = listings
self._cwd = cwd
self._list_calls = 0
self.batch_write_payloads: list[dict[str, Any]] = []
async def __call__(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
if method == "hooks/list":
hooks = self._listings[min(self._list_calls, len(self._listings) - 1)]
self._list_calls += 1
return {"result": {"data": [{"cwd": self._cwd, "hooks": hooks}]}}
if method == "config/batchWrite":
self.batch_write_payloads.append(params)
return {"result": {}}
raise AssertionError(f"unexpected method {method!r}")
_ROUTER_CMD = "/venv/bin/python -I -m omnigent.inner.hook_scripts.codex_router_hook route-subagent"
_POLICY_CMD = "/venv/bin/python -I -m omnigent.codex_native_hook evaluate-policy"
_USER_CMD = "user-pre"
def test_trust_only_touches_the_routing_hook_module() -> None:
"""The handshake trusts ONLY hooks whose command runs the router module.
A user-contributed hook and the native policy hook share the merged
hooks.json; the routing trust pass must leave both alone (the policy hook
has its own trust pass; a user hook is never auto-trusted by Omnigent).
"""
cwd = "/home/user/repo"
untrusted_router = {
"key": "router-key",
"currentHash": "h-router",
"trustStatus": "untrusted",
"command": _ROUTER_CMD,
}
policy = {
"key": "policy-key",
"currentHash": "h-policy",
"trustStatus": "untrusted",
"command": _POLICY_CMD,
}
user = {
"key": "user-key",
"currentHash": "h-user",
"trustStatus": "untrusted",
"command": _USER_CMD,
}
trusted_router = {**untrusted_router, "trustStatus": "trusted"}
request = _RecordingRequest(
listings=[
[untrusted_router, policy, user], # first hooks/list
[trusted_router, policy, user], # re-list after batchWrite
],
cwd=cwd,
)
still = asyncio.run(trust_codex_router_hooks(request, cwd=cwd))
assert still == []
# Exactly one batchWrite, carrying ONLY the router hook's key.
assert len(request.batch_write_payloads) == 1
written = request.batch_write_payloads[0]["edits"][0]["value"]
assert set(written) == {"router-key"}
assert written["router-key"] == {"trusted_hash": "h-router"}
def test_trust_noop_when_no_routing_hook_registered() -> None:
cwd = "/home/user/repo"
request = _RecordingRequest(
listings=[[{"key": "policy-key", "trustStatus": "untrusted", "command": _POLICY_CMD}]],
cwd=cwd,
)
assert asyncio.run(trust_codex_router_hooks(request, cwd=cwd)) == []
# Nothing to trust → no batchWrite issued.
assert request.batch_write_payloads == []
def test_trust_noop_when_routing_hook_already_trusted() -> None:
cwd = "/home/user/repo"
request = _RecordingRequest(
listings=[[{"key": "router-key", "trustStatus": "trusted", "command": _ROUTER_CMD}]],
cwd=cwd,
)
assert asyncio.run(trust_codex_router_hooks(request, cwd=cwd)) == []
assert request.batch_write_payloads == []
def test_trust_reports_keys_still_untrusted_after_write() -> None:
"""A hook still untrusted after the write is reported, never raised."""
cwd = "/home/user/repo"
untrusted = {
"key": "router-key",
"currentHash": "h-router",
"trustStatus": "untrusted",
"command": _ROUTER_CMD,
}
request = _RecordingRequest(listings=[[untrusted], [untrusted]], cwd=cwd)
still = asyncio.run(trust_codex_router_hooks(request, cwd=cwd))
assert still == ["router-key"]
# ── SDK app-server integration: generation + trust wiring ──────────────────
class _HooksSnapshot:
def __init__(self, path: Path) -> None:
self.is_symlink = path.is_symlink()
self.payload: dict[str, Any] | None = None
if path.is_file():
self.payload = json.loads(path.read_text())
class _FakeProc:
"""Minimal stand-in for the launched codex app-server subprocess.
``returncode`` is preset to 0 so ``close()``'s process-tree teardown
short-circuits (the fake never really ran).
"""
returncode: int | None = 0
pid = -1
def __init__(self) -> None:
self.stdin = None
def _run_start(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
*,
env: dict[str, str],
version: tuple[int, int, int] | None = (0, 145, 0),
) -> tuple[_HooksSnapshot, list[tuple[str, dict[str, Any]]]]:
"""Drive ``_CodexAppServerSession.start`` with codex fully stubbed.
The launch is stubbed to *succeed* (a fake proc + no-op reader/stderr
loops) so the post-launch trust handshake actually runs and can be
observed. hooks.json is snapshotted from the private home right after
``start()`` returns and before ``close()`` tears the home down.
"""
source = _write_user_home(tmp_path, hooks=_USER_HOOKS)
workspace = tmp_path / "work"
workspace.mkdir()
requests: list[tuple[str, dict[str, Any]]] = []
async def fake_version(_codex_path: str) -> tuple[int, int, int] | None:
return version
async def fake_exec(*argv: str, **kwargs: Any) -> _FakeProc:
return _FakeProc()
async def noop_loop(self: Any) -> None:
return None
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, "_codex_cli_version", fake_version)
monkeypatch.setattr(codex_executor, "_create_subprocess_exec", fake_exec)
# No real subprocess, so the reader / stderr loops have nothing to read.
monkeypatch.setattr(_CodexAppServerSession, "_reader_loop", noop_loop, raising=True)
monkeypatch.setattr(_CodexAppServerSession, "_stderr_loop", noop_loop, raising=True)
session = _CodexAppServerSession(
codex_path="/bin/echo",
cwd=str(workspace),
env=env,
tool_executor=None,
)
async def fake_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
requests.append((method, params))
if method == "hooks/list":
router = {
"key": "router-key",
"currentHash": "h-router",
"trustStatus": "trusted",
"command": _ROUTER_CMD,
}
return {"result": {"data": [{"cwd": str(workspace), "hooks": [router]}]}}
return {"result": {}}
monkeypatch.setattr(session, "_request", fake_request)
async def drive() -> _HooksSnapshot:
await session.start()
assert session._codex_home_dir is not None, "start() left no private home"
snapshot = _HooksSnapshot(session._codex_home_dir / "hooks.json")
await session.close()
return snapshot
snapshot = asyncio.run(drive())
return snapshot, requests
def test_start_generates_merged_hooks_and_trusts_when_routing_on(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
bridge = tmp_path / "bridge"
bridge.mkdir()
hooks, requests = _run_start(
tmp_path,
monkeypatch,
env={
CODEX_ROUTER_DIR_ENV_VAR: str(bridge),
CODEX_ROUTER_SESSION_ID_ENV_VAR: "conv_abc",
},
)
# A merged regular file (route gate + user hooks), not a symlink.
assert hooks.payload is not None
assert not hooks.is_symlink
pre = hooks.payload["hooks"]["PreToolUse"]
assert len(pre) == 2
assert "--session-id conv_abc" in pre[0]["hooks"][0]["command"]
assert hooks.payload["hooks"]["Stop"][0]["hooks"][0]["command"] == "user-stop"
# The trust handshake ran after initialize and before the first turn.
methods = [m for m, _ in requests]
assert methods[0] == "initialize"
assert "hooks/list" in methods
def test_start_keeps_symlinked_hooks_when_routing_off(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
hooks, requests = _run_start(tmp_path, monkeypatch, env={})
# No routing endpoint advertised → user's hooks.json symlinked untouched.
assert hooks.is_symlink
# No trust handshake when routing is off.
assert "hooks/list" not in [m for m, _ in requests]
def test_start_skips_hook_generation_on_too_old_codex(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A codex below the trust-protocol floor generates no hooks file.
It would only be silently discarded; the user's hooks.json is symlinked
in instead so it is not left missing.
"""
bridge = tmp_path / "bridge"
bridge.mkdir()
major, minor, patch = _MIN_ROUTER_HOOK_CODEX_VERSION
hooks, requests = _run_start(
tmp_path,
monkeypatch,
env={CODEX_ROUTER_DIR_ENV_VAR: str(bridge)},
version=(major, minor - 1, patch),
)
assert hooks.is_symlink
assert "hooks/list" not in [m for m, _ in requests]
def test_start_generates_hooks_when_codex_version_unparseable(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An unparseable version still generates + trusts (0d: never wedge)."""
bridge = tmp_path / "bridge"
bridge.mkdir()
hooks, requests = _run_start(
tmp_path,
monkeypatch,
env={CODEX_ROUTER_DIR_ENV_VAR: str(bridge)},
version=None,
)
assert hooks.payload is not None
assert not hooks.is_symlink
assert "hooks/list" in [m for m, _ in requests]
@@ -0,0 +1,204 @@
"""Directed tests for the native-Codex model apply layer (Smart Routing).
Covers the two pieces the rebuild adds on top of main's existing
``thread/settings/update`` push:
1. ``write_codex_config_model`` mirrors an Omnigent-initiated model switch
into the session's ``config.toml`` so the forwarder's model mirror
(``_refresh_model_from_config`` ``_sync_model_change``) and the cost-gate
hook stay consistent. Without it, the next ``turn/started`` re-reads the
stale launch model and silently reverts the switch.
2. ``_served_codex_model`` applies the glm gateway spelling
(``glm-5-2`` ``system.ai.glm-5-2``) at apply time. The routing seam still
records the bare arm; only the served id sent to codex changes.
Fast and offline: no live codex, no app-server, no network.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from omnigent.codex_native_bridge import (
codex_home_for_bridge_dir,
read_codex_config_model,
write_codex_config_model,
)
from omnigent.inner.codex_native_executor import _model_effort_overrides, _served_codex_model
from omnigent.inner.executor import ExecutorConfig
from omnigent.server.routing_contract import SERVABLE_ALIASES
# The glm arm and the spelling the gateway serves it under, taken from the
# frozen contract pin so this test tracks the real wiring rather than a copy.
_GLM_ARM, _GLM_SERVED = next(iter(SERVABLE_ALIASES.items()))
@pytest.fixture
def bridge_dir(tmp_path: Path) -> Path:
"""A bridge dir whose per-session ``CODEX_HOME`` exists but has no config yet."""
home = codex_home_for_bridge_dir(tmp_path)
home.mkdir(parents=True, exist_ok=True)
return tmp_path
def _config_path(bridge_dir: Path) -> Path:
"""Return the session's ``config.toml`` path."""
return codex_home_for_bridge_dir(bridge_dir) / "config.toml"
# ── 1. write_codex_config_model round-trips a model into config.toml ─────────
def test_write_codex_config_model_creates_file_when_absent(bridge_dir: Path) -> None:
"""A first switch writes a fresh ``config.toml`` with the top-level model."""
assert write_codex_config_model(bridge_dir, "gpt-5.6-luna") is True
assert read_codex_config_model(bridge_dir) == "gpt-5.6-luna"
def test_write_codex_config_model_upserts_existing_model_key(bridge_dir: Path) -> None:
"""A switch overwrites the existing top-level ``model`` line in place."""
_config_path(bridge_dir).write_text('model_provider = "databricks"\nmodel = "gpt-5.5"\n')
assert write_codex_config_model(bridge_dir, "gpt-5.6-luna") is True
# The model was replaced, and the sibling top-level key survived.
assert read_codex_config_model(bridge_dir) == "gpt-5.6-luna"
body = _config_path(bridge_dir).read_text()
assert 'model_provider = "databricks"' in body
assert body.count("\nmodel = ") + int(body.startswith("model = ")) == 1
def test_write_codex_config_model_inserts_when_no_model_key(bridge_dir: Path) -> None:
"""A config with other keys but no ``model`` gains one without loss."""
_config_path(bridge_dir).write_text('model_reasoning_effort = "medium"\n')
assert write_codex_config_model(bridge_dir, "gpt-5.6-luna") is True
assert read_codex_config_model(bridge_dir) == "gpt-5.6-luna"
assert 'model_reasoning_effort = "medium"' in _config_path(bridge_dir).read_text()
def test_write_codex_config_model_only_touches_top_level_table(bridge_dir: Path) -> None:
"""A ``model`` inside a ``[section]`` is not mistaken for the top-level key.
The upsert stops at the first table header, so a nested ``model`` key is
left alone and a new top-level one is prepended.
"""
_config_path(bridge_dir).write_text('[some_provider]\nmodel = "nested-should-not-change"\n')
assert write_codex_config_model(bridge_dir, "gpt-5.6-luna") is True
assert read_codex_config_model(bridge_dir) == "gpt-5.6-luna"
assert 'model = "nested-should-not-change"' in _config_path(bridge_dir).read_text()
def test_write_codex_config_model_best_effort_on_unwritable(tmp_path: Path) -> None:
"""An unwritable path returns ``False`` rather than raising.
The live thread already runs the new model, so a mirror-write failure must
not sink the turn.
"""
# codex-home is a *file*, so codex_home/config.toml can't be created.
clash = tmp_path
(codex_home_for_bridge_dir(clash)).write_text("not a directory")
assert write_codex_config_model(clash, "gpt-5.6-luna") is False
# ── 2. the glm served spelling is applied for a glm pick ─────────────────────
def test_served_codex_model_applies_glm_gateway_spelling() -> None:
"""The bare glm arm resolves to the gateway's served route spelling."""
assert _served_codex_model(_GLM_ARM) == _GLM_SERVED
def test_served_codex_model_strips_catalog_prefix_before_alias() -> None:
"""A prefixed catalog glm id still maps to the served spelling."""
assert _served_codex_model(f"databricks-{_GLM_ARM}") == _GLM_SERVED
def test_served_codex_model_is_idempotent() -> None:
"""Applying the alias to the already-served id is a no-op (safe to re-run)."""
assert _served_codex_model(_GLM_SERVED) == _GLM_SERVED
def test_served_codex_model_passes_non_aliased_models_through() -> None:
"""A non-glm model is returned unchanged — no spurious substitution."""
assert _served_codex_model("gpt-5.6-luna") == "gpt-5.6-luna"
def test_model_effort_overrides_applies_glm_spelling() -> None:
"""A glm pick reaches ``thread/settings/update`` under the served spelling.
This is the wire-level proof: the model codex is told to run is the served
route, so the codex turn does not 400 on the chat-completions-only endpoint.
"""
config = ExecutorConfig(model=_GLM_ARM)
overrides = _model_effort_overrides(config)
assert overrides["model"] == _GLM_SERVED
def test_model_effort_overrides_leaves_non_glm_model_untouched() -> None:
"""A non-glm pick is sent verbatim (no served-alias rewrite)."""
config = ExecutorConfig(model="gpt-5.6-luna")
overrides = _model_effort_overrides(config)
assert overrides["model"] == "gpt-5.6-luna"
def test_model_effort_overrides_empty_when_no_model() -> None:
"""No pinned model → empty overrides, so the thread keeps its launch model."""
assert _model_effort_overrides(ExecutorConfig(model=None)) == {}
assert _model_effort_overrides(None) == {}
# ── 3. a mirrored switch is NOT reverted by a subsequent config re-read ──────
def test_mirrored_switch_survives_config_reread(bridge_dir: Path) -> None:
"""The revert trap: a switch written to config.toml reads back identically.
The forwarder re-reads ``config.toml`` at every ``turn/started``
(``_refresh_model_from_config`` ``read_codex_config_model``) and mirrors
the result to ``conv.model_override``. Before this writer existed,
``thread/settings/update`` changed only the live thread, so that re-read
saw the STALE launch model and mirrored it back silently reverting the
switch. Writing the accepted model into config.toml makes the re-read
return the switched model, so no revert event is produced.
"""
# Launch pin: the session started on luna.
_config_path(bridge_dir).write_text('model = "gpt-5.6-luna"\n')
assert read_codex_config_model(bridge_dir) == "gpt-5.6-luna"
# Omnigent switches the running thread to the served glm route and mirrors
# it (exactly what the executor does after a successful settings update).
switched = _served_codex_model(_GLM_ARM)
assert write_codex_config_model(bridge_dir, switched) is True
# The forwarder's next turn/started re-read now returns the switched model,
# so _sync_model_change would post the switched value, never the stale one.
assert read_codex_config_model(bridge_dir) == switched
assert read_codex_config_model(bridge_dir) != "gpt-5.6-luna"
def test_glm_switch_round_trips_as_served_spelling(bridge_dir: Path) -> None:
"""A glm switch persists under the served spelling end to end.
The executor mirrors ``_model_effort_overrides``' model (already the served
spelling) into config.toml, so a re-read returns ``system.ai.glm-5-2``
the id codex actually serves not the bare arm.
"""
overrides = _model_effort_overrides(ExecutorConfig(model=_GLM_ARM))
mirrored = overrides["model"]
assert isinstance(mirrored, str)
assert write_codex_config_model(bridge_dir, mirrored) is True
assert read_codex_config_model(bridge_dir) == _GLM_SERVED
+391
View File
@@ -0,0 +1,391 @@
"""Tests for subagent routing hooks and transport."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest import mock
from omnigent.inner.hook_scripts import subagent_router
from omnigent.server.routing_contract import SubagentRouteDecision
class TestSubagentRouterClient:
"""Test the shared routing client logic."""
def test_discover_router_dir_explicit(self):
"""Test explicit directory discovery."""
with tempfile.TemporaryDirectory() as tmpdir:
result = subagent_router.discover_router_dir(tmpdir)
assert result == Path(tmpdir)
def test_discover_router_dir_env_var(self):
"""Test discovery via environment variable."""
with tempfile.TemporaryDirectory() as tmpdir:
with mock.patch.dict(os.environ, {subagent_router.ROUTER_DIR_ENV_VAR: tmpdir}):
result = subagent_router.discover_router_dir()
assert result == Path(tmpdir)
def test_discover_router_dir_fallback_bridge_env(self):
"""Test fallback to bridge dir environment variable."""
with tempfile.TemporaryDirectory() as tmpdir:
with mock.patch.dict(os.environ, {subagent_router.BRIDGE_DIR_ENV_VAR: tmpdir}):
result = subagent_router.discover_router_dir()
assert result == Path(tmpdir)
def test_discover_router_dir_none(self):
"""Test when no directory is found."""
with mock.patch.dict(
os.environ,
{
subagent_router.ROUTER_DIR_ENV_VAR: "",
subagent_router.BRIDGE_DIR_ENV_VAR: "",
},
clear=False,
):
result = subagent_router.discover_router_dir(None)
assert result is None
def test_read_router_endpoint_valid(self):
"""Test reading a valid endpoint advertisement."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
adv_file = tmpdir_path / subagent_router.ADVERTISEMENT_FILE
payload = {
"url": "http://127.0.0.1:12345",
"token": "test-token-abc123",
"session_id": "test-session",
}
adv_file.write_text(json.dumps(payload))
result = subagent_router.read_router_endpoint(tmpdir_path)
assert result is not None
assert result.url == "http://127.0.0.1:12345"
assert result.token == "test-token-abc123"
assert result.session_id == "test-session"
def test_read_router_endpoint_missing_file(self):
"""Test when advertisement file is missing."""
with tempfile.TemporaryDirectory() as tmpdir:
result = subagent_router.read_router_endpoint(tmpdir)
assert result is None
def test_read_router_endpoint_malformed_json(self):
"""Test with malformed JSON."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
adv_file = tmpdir_path / subagent_router.ADVERTISEMENT_FILE
adv_file.write_text("not valid json {")
result = subagent_router.read_router_endpoint(tmpdir_path)
assert result is None
def test_read_router_endpoint_missing_fields(self):
"""Test with missing required fields."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
adv_file = tmpdir_path / subagent_router.ADVERTISEMENT_FILE
payload = {"url": "http://127.0.0.1:12345"} # Missing token
adv_file.write_text(json.dumps(payload))
result = subagent_router.read_router_endpoint(tmpdir_path)
assert result is None
def test_read_router_endpoint_unsafe_url_https(self):
"""Test that HTTPS URLs are rejected."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
adv_file = tmpdir_path / subagent_router.ADVERTISEMENT_FILE
payload = {
"url": "https://127.0.0.1:12345", # HTTPS not allowed
"token": "test-token",
}
adv_file.write_text(json.dumps(payload))
result = subagent_router.read_router_endpoint(tmpdir_path)
assert result is None
def test_read_router_endpoint_unsafe_url_non_loopback(self):
"""Test that non-loopback URLs are rejected."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
adv_file = tmpdir_path / subagent_router.ADVERTISEMENT_FILE
payload = {
"url": "http://example.com:12345", # Not loopback
"token": "test-token",
}
adv_file.write_text(json.dumps(payload))
result = subagent_router.read_router_endpoint(tmpdir_path)
assert result is None
def test_read_router_endpoint_ipv6_loopback(self):
"""Test IPv6 loopback is accepted."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
adv_file = tmpdir_path / subagent_router.ADVERTISEMENT_FILE
payload = {
"url": "http://[::1]:12345",
"token": "test-token",
}
adv_file.write_text(json.dumps(payload))
result = subagent_router.read_router_endpoint(tmpdir_path)
assert result is not None
def test_spawn_task_name_with_default_keys(self):
"""Test extracting task name with default keys."""
tool_input = {
"prompt": "some prompt",
"subagent_type": "test-agent",
}
result = subagent_router.spawn_task_name(tool_input)
assert result == "test-agent"
def test_spawn_task_name_with_custom_keys(self):
"""Test extracting task name with custom keys."""
tool_input = {
"prompt": "some prompt",
"task_name": "custom-task",
}
result = subagent_router.spawn_task_name(tool_input, ("task_name", "agent_name"))
assert result == "custom-task"
def test_spawn_task_name_missing(self):
"""Test when task name is not found."""
tool_input = {"prompt": "some prompt"}
result = subagent_router.spawn_task_name(tool_input)
assert result is None
def test_is_fork_spawn_true(self):
"""Test detecting a fork spawn."""
tool_input = {"subagent_type": "fork"}
result = subagent_router.is_fork_spawn(tool_input)
assert result is True
def test_is_fork_spawn_suffix(self):
"""Test detecting a fork spawn by suffix."""
tool_input = {"task_name": "my_task_fork"}
result = subagent_router.is_fork_spawn(tool_input, ("task_name",))
assert result is True
def test_is_fork_spawn_false(self):
"""Test a non-fork spawn."""
tool_input = {"subagent_type": "general-purpose"}
result = subagent_router.is_fork_spawn(tool_input)
assert result is False
def test_build_route_request(self):
"""Test building a routing request."""
tool_input = {
"prompt": "tell me a story",
"subagent_type": "storyteller",
"model": "claude-opus",
}
result = subagent_router.build_route_request(
tool_input,
harness="claude-native",
parent_model="databricks-claude-opus-4-8",
)
assert result["harness"] == "claude-native"
assert result["task_name"] == "storyteller"
assert result["prompt"] == "tell me a story"
assert result["parent_model"] == "databricks-claude-opus-4-8"
assert result["fork"] is False
def test_build_route_request_without_prompt(self):
"""Test building a routing request without prompt (encrypted spawn)."""
tool_input = {
"prompt": "tell me a story",
"task_name": "task1",
}
result = subagent_router.build_route_request(
tool_input,
harness="codex-native",
include_prompt=False,
)
assert result["prompt"] is None
def test_decision_to_allow_output(self):
"""Test converting a decision to hook allow output."""
tool_input = {
"prompt": "test",
"model": "claude-sonnet-5",
}
result = subagent_router.decision_to_allow_output(
tool_input,
model="databricks-claude-opus-4-8",
reason="routing selected this model",
)
assert result["hookSpecificOutput"]["permissionDecision"] == "allow"
model_out = result["hookSpecificOutput"]["updatedInput"]["model"]
assert model_out == "databricks-claude-opus-4-8"
reason_out = result["hookSpecificOutput"]["permissionDecisionReason"]
assert reason_out == "routing selected this model"
def test_decision_to_deny_output(self):
"""Test converting a decision to hook deny output."""
result = subagent_router.decision_to_deny_output("spawn not allowed")
assert result["hookSpecificOutput"]["permissionDecision"] == "deny"
assert result["hookSpecificOutput"]["permissionDecisionReason"] == "spawn not allowed"
class TestSubagentRouteDecision:
"""Test the frozen decision shape."""
def test_to_payload(self):
"""Test serializing a decision to payload."""
decision = SubagentRouteDecision(
action="rewrite",
model="databricks-claude-opus-4-8",
harness="claude-native",
raw_model="claude-opus-4-8",
rationale="cost optimization",
decision_id="dec-123",
)
payload = decision.to_payload()
assert payload["action"] == "rewrite"
assert payload["model"] == "databricks-claude-opus-4-8"
assert payload["harness"] == "claude-native"
assert payload["raw_model"] == "claude-opus-4-8"
assert payload["rationale"] == "cost optimization"
assert payload["decision_id"] == "dec-123"
def test_to_payload_minimal(self):
"""Test serializing a minimal decision."""
decision = SubagentRouteDecision(
action="allow",
rationale="no routing",
)
payload = decision.to_payload()
assert payload["action"] == "allow"
assert payload["model"] is None
assert payload["harness"] is None
assert payload["rationale"] == "no routing"
class TestHookScriptImportSafety:
"""Test that hook scripts are import-safe under python -I."""
def test_claude_hook_import_isolated(self):
"""Test that claude hook can be imported in isolated mode."""
# Create a temporary workspace with a shadowing omnigent dir.
with tempfile.TemporaryDirectory() as tmpdir:
shadow_dir = Path(tmpdir) / "omnigent"
shadow_dir.mkdir()
# Create a fake __init__.py to make it a package.
(shadow_dir / "__init__.py").write_text("")
# Run the hook script in isolated mode from the shadow workspace.
result = subprocess.run(
[
sys.executable,
"-I",
"-m",
"omnigent.inner.hook_scripts.claude_router_hook",
"--help",
],
cwd=tmpdir,
capture_output=True,
text=True,
)
# Should succeed (exit 0) despite the shadowing.
assert result.returncode == 0 or "usage" in result.stderr or result.stdout == ""
def test_codex_hook_import_isolated(self):
"""Test that codex hook can be imported in isolated mode."""
# Create a temporary workspace with a shadowing omnigent dir.
with tempfile.TemporaryDirectory() as tmpdir:
shadow_dir = Path(tmpdir) / "omnigent"
shadow_dir.mkdir()
(shadow_dir / "__init__.py").write_text("")
# Run the hook script in isolated mode from the shadow workspace.
result = subprocess.run(
[
sys.executable,
"-I",
"-m",
"omnigent.inner.hook_scripts.codex_router_hook",
"--help",
],
cwd=tmpdir,
capture_output=True,
text=True,
)
# Should succeed (exit 0) despite the shadowing.
assert result.returncode == 0 or "usage" in result.stderr or result.stdout == ""
class TestCodexHookBehavior:
"""Test codex hook subprocess behavior."""
def test_codex_hook_fail_open_on_missing_input(self):
"""Test that codex hook fails open when given no input."""
result = subprocess.run(
[
sys.executable,
"-m",
"omnigent.inner.hook_scripts.codex_router_hook",
"route-subagent",
],
input="",
capture_output=True,
text=True,
)
# Should always return 0 (fail open).
assert result.returncode == 0
def test_codex_hook_fail_open_on_bad_json(self):
"""Test that codex hook fails open on malformed JSON."""
result = subprocess.run(
[
sys.executable,
"-m",
"omnigent.inner.hook_scripts.codex_router_hook",
"route-subagent",
],
input="not valid json {",
capture_output=True,
text=True,
)
assert result.returncode == 0
class TestClaudeHookBehavior:
"""Test claude hook subprocess behavior."""
def test_claude_hook_fail_open_on_missing_input(self):
"""Test that claude hook fails open when given no input."""
result = subprocess.run(
[
sys.executable,
"-m",
"omnigent.inner.hook_scripts.claude_router_hook",
"route-subagent",
],
input="",
capture_output=True,
text=True,
)
# Should always return 0 (fail open).
assert result.returncode == 0
def test_claude_hook_fail_open_on_bad_json(self):
"""Test that claude hook fails open on malformed JSON."""
result = subprocess.run(
[
sys.executable,
"-m",
"omnigent.inner.hook_scripts.claude_router_hook",
"route-subagent",
],
input="not valid json {",
capture_output=True,
text=True,
)
assert result.returncode == 0
@@ -688,26 +688,29 @@ async def test_smart_routing_overrides_orchestrator_model_for_child_session(
client: httpx.AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Smart routing wins over the orchestrator's model choice for child sessions.
"""Smart routing wins over the orchestrator's model choice for a child, AT CREATE.
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.
sys_session_send is forced to ``harness_override="auto"``. Routing happens
once at CREATE (there is no per-turn routing): the spawn's task text is
carried as ``smart_routing_message`` and the create-time router picks both
harness and model, which the create response reflects replacing the
orchestrator's choice.
"""
captured = _stub_runner_client(monkeypatch)
_stub_runner_client(monkeypatch)
# Stub route_session_harness to return a deterministic (harness, model)
# verdict, bypassing the real LLM. Forced-auto children route through this.
routed_model = "databricks-claude-haiku-4-5"
routed_harness = "claude-sdk"
# verdict, bypassing the real LLM. Forced-auto children route through this
# at create. The router returns its own harness spelling (claude-sdk);
# resolve_smart_routing_create maps it to the native spelling.
routed_model = "claude-sonnet-5"
served_model = "databricks-claude-sonnet-5"
async def _fake_route_session_harness(
*_: Any, **__: Any
) -> tuple[str, str, dict[str, Any], None]:
return (
routed_harness,
"claude-sdk",
routed_model,
{"model": routed_model, "rationale": "trivial task — cheap model suffices"},
None,
@@ -724,42 +727,28 @@ async def test_smart_routing_overrides_orchestrator_model_for_child_session(
assert parent_resp.status_code == 201, parent_resp.text
parent_id = parent_resp.json()["id"]
# Child session with a model the orchestrator chose (simulates
# sys_session_send with model="databricks-claude-opus-4-8"). The server
# forces harness_override="auto" for a routing-on parent's child.
# Child create simulates sys_session_send: the orchestrator's model is
# ignored (server forces auto), and the spawn's task text rides as
# smart_routing_message so the child routes AT CREATE.
child_resp = await client.post(
"/v1/sessions",
json={
"agent_id": agent["id"],
"parent_session_id": parent_id,
"model_override": "databricks-claude-opus-4-8",
"smart_routing_message": "what is 2+2?",
},
)
assert child_resp.status_code == 201, child_resp.text
child_id = child_resp.json()["id"]
child = child_resp.json()
# First message to the child — auto-harness routing should fire.
event_resp = await client.post(
f"/v1/sessions/{child_id}/events",
json={
"type": "message",
"data": {
"role": "user",
"content": [{"type": "input_text", "text": "what is 2+2?"}],
},
},
)
assert event_resp.status_code == 202, event_resp.text
assert captured.get("body") is not None, (
"Runner client was never POSTed to — _forward_event_to_runner did not run."
# The create-time router replaced the orchestrator's model with the routed,
# servable pick and set the native harness — no first message required.
assert child.get("model_override") == served_model, (
f"Create-time smart routing should have replaced the orchestrator's model "
f"with {served_model!r}; create response had {child.get('model_override')!r}."
)
assert captured["body"].get("model_override") == routed_model, (
f"Smart routing should have replaced the orchestrator's model with "
f"{routed_model!r}; runner body had "
f"{captured['body'].get('model_override')!r}."
)
assert captured["body"].get("harness_override") == routed_harness, (
f"Smart routing should have set the harness to {routed_harness!r}; "
f"runner body had {captured['body'].get('harness_override')!r}."
assert child.get("harness") == "claude-native", (
f"Create-time smart routing should have set the harness to 'claude-native'; "
f"create response had {child.get('harness')!r}."
)
+144
View File
@@ -0,0 +1,144 @@
"""Directed tests for the route-resolution seam (``resolve_route``, plan 3i).
The four-step chain strip prefix exact catalog match one fixed family
fallback honest decline has no live trigger on the reference workspace
(all five frozen arms resolve exactly there), so these synthetic-catalog unit
tests are the proof for the fallback / decline / spelling-pin / separator-safe
paths (plan 6c). Fallback and spelling targets are asserted against the
contract constants so the tests cannot drift from the owned fallback records.
"""
from __future__ import annotations
from omnigent.model_fallbacks import FAMILY_FALLBACK_ID, SERVABLE_ALIASES
from omnigent.server.smart_routing import ResolvedRoute, resolve_route
# A synthetic servable catalog: what a workspace's runner reports it can serve.
# Deliberately excludes opus and every codex arm except luna, so the fallback
# and decline branches fire without a live workspace.
_CLAUDE_SONNET = "databricks-claude-sonnet-5"
_CODEX_LUNA = "databricks-gpt-5-6-luna"
_GLM_ARM = "glm-5-2"
_GLM_SERVED = "system.ai.glm-5-2"
# ── Step 2: exact catalog match ──────────────────────────────────────────────
def test_exact_match_returns_pick_verbatim() -> None:
"""A served arm resolves to itself; raw_model equals the pick (no drift)."""
route = resolve_route("databricks-claude-sonnet-5", servable=[_CLAUDE_SONNET, _CODEX_LUNA])
assert route == ResolvedRoute(
model=_CLAUDE_SONNET,
harness="claude-native",
raw_model="databricks-claude-sonnet-5",
)
def test_exact_match_recovers_prefixed_catalog_id_from_bare_pick() -> None:
"""The router's bare pick maps back to the workspace's prefixed catalog id."""
route = resolve_route("claude-sonnet-5", servable=[_CLAUDE_SONNET, _CODEX_LUNA])
assert route is not None
assert route.model == _CLAUDE_SONNET # exact catalog spelling applied
assert route.raw_model == "claude-sonnet-5" # the router's pick, verbatim
assert route.harness == "claude-native"
def test_gpt_arm_exact_match_lands_on_codex_native() -> None:
"""A served gpt arm keeps its codex-native harness."""
route = resolve_route(_CODEX_LUNA, servable=[_CLAUDE_SONNET, _CODEX_LUNA])
assert route is not None
assert route.model == _CODEX_LUNA
assert route.harness == "codex-native"
assert route.raw_model == _CODEX_LUNA
# ── glm gateway spelling pin (a spelling, not a substitution) ─────────────────
def test_glm_spelling_pin_applied_on_the_way_out() -> None:
"""A served glm arm is applied under its system.ai route; raw stays the arm."""
route = resolve_route(_GLM_ARM, servable=[_GLM_ARM, _CODEX_LUNA])
assert route is not None
# The pin from SERVABLE_ALIASES is applied on the way out...
assert route.model == SERVABLE_ALIASES[_GLM_ARM] == _GLM_SERVED
# ...but raw_model records the ARM, not the served spelling — it is a
# spelling, not a different pick (their bare ids are identical).
assert route.raw_model == _GLM_ARM
assert route.harness == "codex-native"
# ── Step 3: family fallback ───────────────────────────────────────────────────
def test_claude_family_fallback_unservable_opus_to_sonnet() -> None:
"""An unservable opus pick falls back to the sonnet id; raw stays opus."""
route = resolve_route("claude-opus-4-8", servable=[_CLAUDE_SONNET, _CODEX_LUNA])
assert route is not None
assert route.model == FAMILY_FALLBACK_ID["claude"] == _CLAUDE_SONNET
assert route.raw_model == "claude-opus-4-8" # chip shows what the router said
assert route.harness == "claude-native"
def test_gpt_family_fallback_unservable_sol_to_luna() -> None:
"""An unservable gpt arm (sol) falls back to luna; raw stays sol."""
route = resolve_route("gpt-5-6-sol", servable=[_CLAUDE_SONNET, _CODEX_LUNA])
assert route is not None
assert route.model == FAMILY_FALLBACK_ID["gpt"] == _CODEX_LUNA
assert route.raw_model == "gpt-5-6-sol"
assert route.harness == "codex-native"
def test_glm_family_fallback_to_luna_stays_on_codex() -> None:
"""An unservable glm pick falls back to luna and never leaves codex-native."""
route = resolve_route(_GLM_ARM, servable=[_CODEX_LUNA])
assert route is not None
assert route.model == FAMILY_FALLBACK_ID["glm"] == _CODEX_LUNA
assert route.raw_model == _GLM_ARM
assert route.harness == "codex-native"
# ── Step 4: honest decline ────────────────────────────────────────────────────
def test_decline_when_nothing_servable() -> None:
"""No servable arm and no servable fallback → None (caller keeps default)."""
assert resolve_route("claude-opus-4-8", servable=[]) is None
def test_decline_when_family_fallback_not_served() -> None:
"""Claude pick with only codex served: sonnet fallback missing → decline."""
assert resolve_route("claude-opus-4-8", servable=[_CODEX_LUNA]) is None
def test_decline_on_empty_pick() -> None:
"""A blank router pick declines rather than guessing."""
assert resolve_route(" ", servable=[_CLAUDE_SONNET]) is None
def test_decline_on_unknown_family() -> None:
"""A pick whose family has no fallback entry declines when not served exactly."""
assert resolve_route("mystery-model-9", servable=[_CLAUDE_SONNET, _CODEX_LUNA]) is None
# ── Separator-safe prefix stripping (oracle 0d trap) ──────────────────────────
def test_prefix_without_trailing_separator_is_separator_safe() -> None:
"""A prefix configured without its trailing separator must not corrupt ids.
With ``prefixes=("system.ai",)`` (no trailing dot), stripping leaves a
leading ``.`` that the separator-safe rule drops. The router's bare pick
then exact-matches the workspace's ``system.ai.``-prefixed catalog id. Were
the leading separator NOT dropped, the bare ids would differ and this would
fall through to a fallback/decline instead of an exact match.
"""
route = resolve_route(
"claude-sonnet-5",
servable=["system.ai.claude-sonnet-5"],
prefixes=("system.ai",),
)
assert route is not None
assert route.model == "system.ai.claude-sonnet-5" # exact catalog id applied
assert route.raw_model == "claude-sonnet-5"
assert route.harness == "claude-native"
+182
View File
@@ -0,0 +1,182 @@
"""Directed unit tests for the per-request routing backend seam (plan 2f/7h).
The ``RoutingBackend`` wraps main's two clients and, on every ``route()`` call,
consults ``RuntimeCaps.routing_backend_predicate`` to choose which one answers:
``True`` the external AI Gateway client, ``False`` the LLM judge, ``None``
(no deployment binding) the OSS default. These tests use fakes for the two
clients and assert which one's ``route()`` ran — fast, no network.
"""
from __future__ import annotations
import pytest
from omnigent.server.routing_backend import RoutingBackend
from omnigent.server.smart_routing import RoutingResult
_MESSAGE = "refactor this module"
_MODELS: dict[str, list[str]] = {"self": ["model-a", "model-b"]}
class _FakeClient:
"""A ``RoutingClient`` fake that records its calls and returns a marker."""
def __init__(self, name: str, *, last_error: str | None = None) -> None:
self.name = name
self.calls: list[tuple[str, dict[str, list[str]]]] = []
self.last_error = last_error
async def route(
self,
message: str,
available_models: dict[str, list[str]],
) -> RoutingResult | None:
self.calls.append((message, available_models))
return RoutingResult(model=self.name, rationale=f"picked by {self.name}")
async def _route(backend: RoutingBackend) -> RoutingResult | None:
return await backend.route(_MESSAGE, _MODELS)
# ── The five required cases ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_predicate_true_routes_to_external() -> None:
external = _FakeClient("external")
judge = _FakeClient("judge")
backend = RoutingBackend(external=external, judge=judge, predicate=lambda: True)
result = await _route(backend)
assert result is not None and result.model == "external"
assert external.calls and not judge.calls
@pytest.mark.asyncio
async def test_predicate_false_routes_to_judge() -> None:
external = _FakeClient("external")
judge = _FakeClient("judge")
backend = RoutingBackend(external=external, judge=judge, predicate=lambda: False)
result = await _route(backend)
assert result is not None and result.model == "judge"
assert judge.calls and not external.calls
@pytest.mark.asyncio
async def test_predicate_none_with_external_present_uses_external() -> None:
external = _FakeClient("external")
judge = _FakeClient("judge")
backend = RoutingBackend(external=external, judge=judge, predicate=None)
result = await _route(backend)
assert result is not None and result.model == "external"
assert external.calls and not judge.calls
@pytest.mark.asyncio
async def test_predicate_none_with_only_judge_uses_judge() -> None:
judge = _FakeClient("judge")
backend = RoutingBackend(external=None, judge=judge, predicate=None)
result = await _route(backend)
assert result is not None and result.model == "judge"
assert judge.calls
@pytest.mark.asyncio
async def test_predicate_true_without_external_falls_back_to_judge() -> None:
judge = _FakeClient("judge")
backend = RoutingBackend(external=None, judge=judge, predicate=lambda: True)
result = await _route(backend)
assert result is not None and result.model == "judge"
assert judge.calls
# ── Invariants the seam must also hold ───────────────────────────────────────
@pytest.mark.asyncio
async def test_predicate_false_without_judge_falls_back_to_external() -> None:
# A flag-off workspace with only the gateway configured still routes:
# never returns "unavailable" just because the requested backend is absent.
external = _FakeClient("external")
backend = RoutingBackend(external=external, judge=None, predicate=lambda: False)
result = await _route(backend)
assert result is not None and result.model == "external"
assert external.calls
@pytest.mark.asyncio
async def test_no_backends_configured_returns_none() -> None:
backend = RoutingBackend(external=None, judge=None, predicate=lambda: True)
assert await _route(backend) is None
@pytest.mark.asyncio
async def test_predicate_raising_uses_default_backend() -> None:
def _boom() -> bool:
raise RuntimeError("flag service down")
external = _FakeClient("external")
judge = _FakeClient("judge")
backend = RoutingBackend(external=external, judge=judge, predicate=_boom)
result = await _route(backend)
# A predicate hiccup degrades to the default (external present → external),
# it never crashes routing.
assert result is not None and result.model == "external"
assert external.calls and not judge.calls
@pytest.mark.asyncio
async def test_predicate_evaluated_per_request() -> None:
# The flag is read on every route(), not cached at construction (7h).
external = _FakeClient("external")
judge = _FakeClient("judge")
flag = {"on": True}
backend = RoutingBackend(external=external, judge=judge, predicate=lambda: flag["on"])
await _route(backend)
flag["on"] = False
await _route(backend)
assert len(external.calls) == 1
assert len(judge.calls) == 1
@pytest.mark.asyncio
async def test_last_error_reflects_last_used_backend() -> None:
external = _FakeClient("external", last_error="router 401")
judge = _FakeClient("judge") # no meaningful last_error
backend = RoutingBackend(external=external, judge=judge, predicate=lambda: True)
assert backend.last_error is None # before any route()
await _route(backend)
assert backend.last_error == "router 401"
@pytest.mark.asyncio
async def test_last_error_none_when_judge_has_no_attribute() -> None:
# The judge (LLMRoutingClient) has no ``last_error``; reading through it
# must degrade to None rather than raise.
class _NoErrClient:
async def route(
self, message: str, available_models: dict[str, list[str]]
) -> RoutingResult | None:
return None
backend = RoutingBackend(external=None, judge=_NoErrClient(), predicate=lambda: False)
await _route(backend)
assert backend.last_error is None
+239
View File
@@ -0,0 +1,239 @@
"""Unit tests for create-time routing resolvers (wave-2 stream 2)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from omnigent.server.routing_contract import ResolvedRoute
from omnigent.server.routing_create import (
resolve_fixed_native_model_routing,
resolve_smart_routing_create,
)
from omnigent.server.routing_decision_store import build_decision, decision_from_route
class TestResolveSmartRoutingCreate:
"""Tests for Smart Routing harness create resolver."""
async def test_empty_message_returns_none(self) -> None:
"""Empty routing message should return None for all fields."""
result = await resolve_smart_routing_create("")
assert result == (None, None, None, None)
async def test_whitespace_message_returns_none(self) -> None:
"""Whitespace-only message should return None."""
result = await resolve_smart_routing_create(" \n \t ")
assert result == (None, None, None, None)
async def test_delegates_to_route_session_harness(self) -> None:
"""Delegates to route_session_harness, then maps harness + resolves model.
The router returns a bare arm id under its own harness spelling
(``codex`` / ``claude-sdk``). The resolver maps the harness to native
and translates the pick to the servable spelling via resolve_route.
"""
with patch("omnigent.server.smart_routing.route_session_harness") as mock_route:
mock_route.return_value = (
"claude-sdk", # the router's own harness spelling
"claude-opus-4-8", # the router's bare pick
{"model": "claude-opus-4-8", "rationale": "test"},
None,
)
harness, model, verdict, error = await resolve_smart_routing_create(
"hello world",
session_id="sess-123",
catalog_session_id="cat-456",
runner_client=MagicMock(),
)
assert harness == "claude-native" # mapped to native spelling
assert model == "databricks-claude-opus-4-8" # resolved to servable
assert verdict is not None and verdict["raw_model"] == "claude-opus-4-8"
assert error is None
mock_route.assert_called_once()
async def test_router_error_returns_error_string(self) -> None:
"""Router error should be propagated as error field."""
with patch("omnigent.server.smart_routing.route_session_harness") as mock_route:
mock_route.return_value = (
None,
None,
None,
"Router unavailable",
)
result = await resolve_smart_routing_create("test message")
assert result[3] == "Router unavailable"
async def test_router_harness_mapped_to_native_spelling(self) -> None:
"""The router's claude-sdk / codex pick becomes the native TUI spelling.
route_session_harness returns the harness spelling the router keys on
(claude-sdk / codex); a Smart Routing session launches the native
wrapper, so the resolver must surface claude-native / codex-native.
"""
for router_harness, expected_native, model in (
("claude-sdk", "claude-native", "databricks-claude-opus-4-8"),
("codex", "codex-native", "databricks-gpt-5-6-sol"),
):
with patch("omnigent.server.smart_routing.route_session_harness") as mock_route:
mock_route.return_value = (
router_harness,
model,
{"model": model, "rationale": "test"},
None,
)
harness, got_model, _verdict, error = await resolve_smart_routing_create(
"route me"
)
assert harness == expected_native, (router_harness, harness)
assert got_model == model
assert error is None
class TestResolveFixedNativeModelRouting:
"""Tests for fixed-harness model routing resolver."""
async def test_empty_message_returns_none(self) -> None:
"""Empty routing message should return None for all fields."""
result = await resolve_fixed_native_model_routing("claude-native", "")
assert result == (None, None, None)
async def test_routes_only_model_not_harness(self) -> None:
"""Should route only the model for a fixed harness."""
with patch("omnigent.server.smart_routing.route_session_harness") as mock_route:
mock_route.return_value = (
"claude-sdk", # Router might suggest a different harness
"databricks-claude-opus-4-8",
{"model": "databricks-claude-opus-4-8", "rationale": "test"},
None,
)
result = await resolve_fixed_native_model_routing(
"claude-native",
"hello world",
session_id="sess-123",
)
# Should return the model even though router suggested a different harness
assert result[0] == "databricks-claude-opus-4-8"
assert result[1] is not None # verdict
assert result[2] is None # no error
async def test_claude_sdk_routes_to_servable_claude_arm(self) -> None:
"""The in-process claude-sdk harness (polly / debby) routes at create.
It routes over the claude family arms and resolves the bare router pick
to its servable databricks- spelling, exactly like the native path.
"""
with patch("omnigent.server.smart_routing.route_session_harness") as mock_route:
mock_route.return_value = (
"claude-sdk",
"claude-sonnet-5", # bare router pick
{"model": "claude-sonnet-5", "rationale": "trivial"},
None,
)
model, verdict, error = await resolve_fixed_native_model_routing(
"claude-sdk",
"what testing framework does this project use?",
session_id="sess-sdk",
)
assert model == "databricks-claude-sonnet-5" # servable spelling
assert verdict is not None and verdict["raw_model"] == "claude-sonnet-5"
assert error is None
async def test_unknown_harness_declines(self) -> None:
"""A harness with no candidate arms declines with an error, never crashes."""
model, verdict, error = await resolve_fixed_native_model_routing(
"pi-native",
"route me",
)
assert model is None
assert verdict is None
assert error is not None and "pi-native" in error
async def test_router_unavailable_fails_open(self) -> None:
"""Router unavailable should fail open with error string."""
with patch("omnigent.server.smart_routing.route_session_harness") as mock_route:
mock_route.return_value = (
None,
None,
None,
"Router not configured",
)
result = await resolve_fixed_native_model_routing(
"claude-native",
"test message",
)
assert result[0] is None
assert result[1] is None
# Should pass through the error from route_session_harness
assert "Router not configured" in (result[2] or "")
class TestRoutingDecisionPersistence:
"""Tests for building and persisting routing decisions."""
def test_build_decision_creates_valid_data(self) -> None:
"""Should build a valid RoutingDecisionData."""
decision = build_decision(
model="databricks-claude-opus-4-8",
rationale="test rationale",
harness="claude-native",
scope="session",
applied=True,
)
assert decision.model == "databricks-claude-opus-4-8"
assert decision.rationale == "test rationale"
assert decision.harness == "claude-native"
assert decision.scope == "session"
assert decision.applied is True
assert decision.decision_id is not None
def test_decision_from_route_applied(self) -> None:
"""Should create an applied decision when route resolves."""
class MockResult:
model = "databricks-claude-opus-4-8"
rationale = "test"
harness = "claude-native"
resolved = ResolvedRoute(
model="databricks-claude-opus-4-8",
harness="claude-native",
raw_model=None,
)
decision = decision_from_route(
MockResult(),
resolved,
scope="session",
)
assert decision.model == "databricks-claude-opus-4-8"
assert decision.applied is True
assert decision.harness == "claude-native"
def test_decision_from_route_declined(self) -> None:
"""Should create an unapplied decision when route declines."""
class MockResult:
model = "databricks-claude-opus-4-8"
rationale = "test"
harness = "claude-native"
decision = decision_from_route(
MockResult(),
None, # Declined
scope="session",
)
assert decision.model == "databricks-claude-opus-4-8"
assert decision.applied is False
assert decision.harness is None
+372
View File
@@ -0,0 +1,372 @@
"""Directed tests for the main-agent routing-decision writer/reader (plan 2d).
Covers the behavior inventory for decision persistence:
* a fallback stamps ``raw_model`` distinct from ``model`` (honest chip);
* a prefix/spelling-only difference is the same arm and drops ``raw_model``;
* session-scope vs turn-scope persist with the right ``scope``;
* an honest decline (``applied=false``) persists with no pin;
* reading back returns the latest decision;
* the routed ``model_override`` round-trips (write + read back).
A self-contained fake store keeps the suite fast and DB-free the real store
needs a database, and these behaviors are pure store-contract logic (``append``
+ ``list_items(type=, order=)`` + ``update_conversation`` + ``get_conversation``).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import pytest
from omnigent.entities.conversation import (
ConversationItem,
NewConversationItem,
)
from omnigent.entities.pagination import PagedList
from omnigent.server.routing_contract import ResolvedRoute
from omnigent.server.routing_decision_store import (
apply_routed_model_override,
build_decision,
decision_from_route,
latest_decision,
persist_decision,
routed_model_override,
)
# Servable arms + a couple of spellings used across the cases.
CLAUDE_OPUS = "databricks-claude-opus-4-8"
CLAUDE_SONNET = "databricks-claude-sonnet-5" # the claude family fallback
GLM_ARM = "glm-5-2"
GLM_SERVABLE = "system.ai.glm-5-2" # the gateway spelling pin for glm
# ── Fakes ────────────────────────────────────────────────────────────────────
@dataclass
class _FakeConversation:
id: str
model_override: str | None = None
harness_override: str | None = None
cost_control_mode_override: str | None = None
@dataclass
class _FakeStore:
"""A faithful, in-memory subset of ``ConversationStore``.
Implements only what the module touches: ``append`` (assigns ids +
preserves order), ``list_items`` (``type`` filter + ``asc``/``desc`` +
``limit``), ``update_conversation`` (``model_override``), and
``get_conversation``.
"""
convs: dict[str, _FakeConversation] = field(default_factory=dict)
items: dict[str, list[ConversationItem]] = field(default_factory=dict)
_seq: int = 0
def create(self, conversation_id: str) -> _FakeConversation:
conv = _FakeConversation(id=conversation_id)
self.convs[conversation_id] = conv
return conv
def append(
self, conversation_id: str, items: list[NewConversationItem]
) -> list[ConversationItem]:
persisted: list[ConversationItem] = []
bucket = self.items.setdefault(conversation_id, [])
for new in items:
self._seq += 1
item = ConversationItem(
id=f"item_{self._seq}",
type=new.type,
status="completed",
response_id=new.response_id,
created_at=self._seq,
data=new.data,
)
bucket.append(item)
persisted.append(item)
return persisted
def list_items(
self,
conversation_id: str,
limit: int = 100,
after: str | None = None,
before: str | None = None,
order: str = "asc",
type: str | None = None,
) -> PagedList[ConversationItem]:
rows = list(self.items.get(conversation_id, []))
if type is not None:
rows = [row for row in rows if row.type == type]
if order == "desc":
rows = list(reversed(rows))
rows = rows[:limit]
return PagedList(
data=rows,
first_id=rows[0].id if rows else None,
last_id=rows[-1].id if rows else None,
has_more=False,
)
def update_conversation(
self,
conversation_id: str,
model_override: str | None = None,
**_: Any,
) -> _FakeConversation | None:
conv = self.convs.get(conversation_id)
if conv is None:
return None
if model_override is not None:
conv.model_override = model_override
return conv
def get_conversation(self, conversation_id: str) -> _FakeConversation | None:
return self.convs.get(conversation_id)
@dataclass
class _FakeRouteResult:
"""A stand-in for main's ``RoutingResult`` (structural ``RouteResult``)."""
model: str
rationale: str
harness: str | None = None
# ── build_decision: raw_model honesty ────────────────────────────────────────
def test_fallback_stamps_raw_model_distinct_from_model() -> None:
"""A family fallback applied a different arm than the router picked, so the
chip must show both: raw_model (the pick) != model (what ran)."""
record = build_decision(
model=CLAUDE_SONNET,
rationale="fell back to the family default",
harness="claude-native",
raw_model=CLAUDE_OPUS,
applied=True,
)
assert record.model == CLAUDE_SONNET
assert record.raw_model == CLAUDE_OPUS
assert record.raw_model != record.model
assert record.applied is True
def test_spelling_only_difference_drops_raw_model() -> None:
"""A gateway spelling pin (glm-5-2 -> system.ai.glm-5-2) is the SAME arm, so
it is not a substitution and earns no raw_model."""
record = build_decision(
model=GLM_SERVABLE,
rationale="glm served under its system.ai route",
harness="codex-native",
raw_model=GLM_ARM,
applied=True,
)
assert record.model == GLM_SERVABLE
assert record.raw_model is None
def test_build_decision_generates_decision_id_when_absent() -> None:
"""Every persisted row carries a stable join key even when the caller omits
one."""
record = build_decision(model=CLAUDE_OPUS, rationale="deep reasoning")
assert record.decision_id
explicit = build_decision(model=CLAUDE_OPUS, rationale="x", decision_id="rd_fixed")
assert explicit.decision_id == "rd_fixed"
# ── scope: session vs turn ────────────────────────────────────────────────────
async def test_session_scope_decision_persists_with_session_scope() -> None:
store = _FakeStore()
store.create("conv_s")
record = build_decision(model=CLAUDE_OPUS, rationale="session pick", scope="session")
await persist_decision("conv_s", store, record)
read = latest_decision("conv_s", store)
assert read is not None
assert read.scope == "session"
async def test_turn_scope_decision_persists_with_turn_scope() -> None:
store = _FakeStore()
store.create("conv_t")
# "turn" is the default scope.
record = build_decision(model=CLAUDE_OPUS, rationale="turn pick")
await persist_decision("conv_t", store, record)
read = latest_decision("conv_t", store)
assert read is not None
assert read.scope == "turn"
# ── honest decline: applied=false, no pin ─────────────────────────────────────
async def test_honest_decline_persists_unapplied_with_no_pin() -> None:
"""resolve_route returned None: the workspace serves neither the pick nor the
family fallback. The record keeps the router's would-have pick, records
applied=false, and writes no harness/raw pin."""
result = _FakeRouteResult(model="databricks-kimi-k2-6", rationale="would have picked kimi")
record = decision_from_route(result, None, scope="turn")
assert record.applied is False
assert record.model == "databricks-kimi-k2-6" # the would-have pick, kept
assert record.harness is None
assert record.raw_model is None
store = _FakeStore()
store.create("conv_d")
await persist_decision("conv_d", store, record)
read = latest_decision("conv_d", store)
assert read is not None
assert read.applied is False
assert read.model == "databricks-kimi-k2-6"
async def test_decline_falls_back_to_default_model_when_pick_empty() -> None:
"""A decline with an empty pick keeps the record valid (non-empty model
invariant) by naming the session default never an invented id."""
result = _FakeRouteResult(model="", rationale="no pick")
record = decision_from_route(result, None, scope="turn", default_model=CLAUDE_SONNET)
assert record.applied is False
assert record.model == CLAUDE_SONNET
# ── decision_from_route: resolved (applied) path ──────────────────────────────
def test_decision_from_resolved_route_is_applied() -> None:
result = _FakeRouteResult(
model=CLAUDE_OPUS, rationale="deep reasoning", harness="claude-native"
)
resolved = ResolvedRoute(model=CLAUDE_OPUS, harness="claude-native", raw_model=CLAUDE_OPUS)
record = decision_from_route(result, resolved, scope="session")
assert record.applied is True
assert record.model == CLAUDE_OPUS
assert record.harness == "claude-native"
assert record.scope == "session"
# raw == model → same arm → no separate raw pick.
assert record.raw_model is None
def test_decision_from_resolved_route_keeps_fallback_raw_model() -> None:
"""When the resolution fell back, the resolved.raw_model (the pick) rides
onto the record as a distinct arm."""
result = _FakeRouteResult(model=CLAUDE_OPUS, rationale="fell back", harness="claude-native")
resolved = ResolvedRoute(model=CLAUDE_SONNET, harness="claude-native", raw_model=CLAUDE_OPUS)
record = decision_from_route(result, resolved)
assert record.applied is True
assert record.model == CLAUDE_SONNET
assert record.raw_model == CLAUDE_OPUS
# ── read-back: latest wins ────────────────────────────────────────────────────
async def test_latest_decision_returns_the_newest() -> None:
store = _FakeStore()
store.create("conv_l")
await persist_decision("conv_l", store, build_decision(model=CLAUDE_OPUS, rationale="first"))
await persist_decision(
"conv_l", store, build_decision(model=CLAUDE_SONNET, rationale="second")
)
read = latest_decision("conv_l", store)
assert read is not None
assert read.model == CLAUDE_SONNET
assert read.rationale == "second"
def test_latest_decision_none_when_no_decisions() -> None:
store = _FakeStore()
store.create("conv_empty")
assert latest_decision("conv_empty", store) is None
async def test_latest_decision_ignores_other_item_types() -> None:
"""The read-back is a point read on the routing_decision type — a later
message item must not shadow the routing decision."""
store = _FakeStore()
store.create("conv_mixed")
await persist_decision(
"conv_mixed", store, build_decision(model=CLAUDE_OPUS, rationale="routed")
)
# A non-routing item appended afterwards.
from omnigent.entities.conversation import MessageData
store.append(
"conv_mixed",
[
NewConversationItem(
type="message",
response_id="resp_x",
data=MessageData(
role="assistant",
content=[{"type": "output_text", "text": "hi"}],
agent="worker",
),
)
],
)
read = latest_decision("conv_mixed", store)
assert read is not None
assert read.model == CLAUDE_OPUS
async def test_persist_returns_item_id() -> None:
store = _FakeStore()
store.create("conv_id")
item_id = await persist_decision(
"conv_id", store, build_decision(model=CLAUDE_OPUS, rationale="x")
)
assert item_id == "item_1"
async def test_persist_failure_does_not_raise() -> None:
"""A store append failure is best-effort: logged, not raised, and the id is
None. The routing turn must never die on a persist error."""
class _BoomStore:
def append(
self, conversation_id: str, items: list[NewConversationItem]
) -> list[ConversationItem]:
raise RuntimeError("db down")
item_id = await persist_decision(
"conv_boom", _BoomStore(), build_decision(model=CLAUDE_OPUS, rationale="x")
)
assert item_id is None
# ── routed model override round-trip ──────────────────────────────────────────
def test_routed_model_override_round_trip() -> None:
store = _FakeStore()
store.create("conv_ov")
assert routed_model_override("conv_ov", store) is None
apply_routed_model_override("conv_ov", store, CLAUDE_OPUS)
assert routed_model_override("conv_ov", store) == CLAUDE_OPUS
# A re-route repins to the new model.
apply_routed_model_override("conv_ov", store, CLAUDE_SONNET)
assert routed_model_override("conv_ov", store) == CLAUDE_SONNET
def test_routed_model_override_missing_conversation_is_none() -> None:
store = _FakeStore()
assert routed_model_override("conv_absent", store) is None
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,406 @@
"""Unit tests for subagent routing policy."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from omnigent.server.routing_contract import SubagentRouteDecision
from omnigent.server.subagent_routing_policy import resolve_subagent_route
from omnigent.server.subagent_routing_transport import SubagentRouteRequest
@pytest.fixture
def mock_caps():
"""Mock RuntimeCaps with a routing client."""
caps = MagicMock()
client = AsyncMock()
caps.routing_client = client
return caps
@pytest.mark.asyncio
async def test_subagent_routing_override_off(mock_caps):
"""When override is 'off', spawn is allowed unchanged (advisory gate)."""
req = SubagentRouteRequest(
harness="claude-native",
task_name="test-task",
prompt="test prompt",
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
subagent_routing_override="off",
cost_control_mode="on",
auto_harness=False,
caps=mock_caps,
)
assert decision.action == "allow"
assert "disabled" in decision.rationale.lower()
# Router should NOT be called
assert not mock_caps.routing_client.route.called
@pytest.mark.asyncio
async def test_subagent_routing_override_on_inherits_cost_control(mock_caps):
"""When override is 'on', routing is enabled (inherits cost_control_mode)."""
mock_caps.routing_client.route = AsyncMock(
return_value=MagicMock(
model="databricks-claude-sonnet-5",
rationale="test pick",
)
)
req = SubagentRouteRequest(
harness="claude-native",
task_name="test-task",
prompt="test prompt",
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
subagent_routing_override="on",
cost_control_mode="off", # Off at session level
auto_harness=False,
caps=mock_caps,
available_models={"claude-native": ["databricks-claude-sonnet-5"]},
)
# Should route because override is "on"
assert mock_caps.routing_client.route.called
assert decision.action == "rewrite"
@pytest.mark.asyncio
async def test_subagent_routing_disabled_by_cost_control(mock_caps):
"""When cost_control_mode is off and override is not 'on', no routing."""
req = SubagentRouteRequest(
harness="claude-native",
task_name="test-task",
prompt="test prompt",
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
subagent_routing_override=None, # Will inherit
cost_control_mode="off",
auto_harness=False,
caps=mock_caps,
)
assert decision.action == "allow"
assert not mock_caps.routing_client.route.called
@pytest.mark.asyncio
async def test_fork_exemption(mock_caps):
"""Forks are not routed; they keep the parent model."""
req = SubagentRouteRequest(
harness="claude-native",
task_name="test-task",
prompt="test prompt",
fork=True,
parent_model="databricks-claude-opus-4-8",
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
cost_control_mode="on",
auto_harness=False,
caps=mock_caps,
)
assert decision.action == "allow"
assert decision.model == "databricks-claude-opus-4-8"
assert "fork" in decision.rationale.lower()
assert not mock_caps.routing_client.route.called
@pytest.mark.asyncio
async def test_no_routable_signal_codex_unnamed(mock_caps):
"""Codex spawns with no task_name and no prompt signal → inherit model."""
req = SubagentRouteRequest(
harness="codex-native",
task_name="", # No task name
prompt=None, # No prompt (codex encrypts it)
parent_model="databricks-gpt-5-6-luna",
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
cost_control_mode="on",
auto_harness=False,
caps=mock_caps,
)
assert decision.action == "allow"
assert decision.model == "databricks-gpt-5-6-luna"
assert "no routable signal" in decision.rationale.lower()
assert not mock_caps.routing_client.route.called
@pytest.mark.asyncio
async def test_no_routing_client(mock_caps):
"""No routing client → allow unchanged (advisory)."""
mock_caps.routing_client = None
req = SubagentRouteRequest(
harness="claude-native",
task_name="test-task",
prompt="test prompt",
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
cost_control_mode="on",
auto_harness=False,
caps=mock_caps,
)
assert decision.action == "allow"
assert "no routing client" in decision.rationale.lower()
@pytest.mark.asyncio
async def test_router_call_fails(mock_caps):
"""Router outage → allow unchanged (advisory, fail-open)."""
mock_caps.routing_client.route = AsyncMock(side_effect=RuntimeError("Connection failed"))
req = SubagentRouteRequest(
harness="claude-native",
task_name="test-task",
prompt="test prompt",
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
cost_control_mode="on",
auto_harness=False,
caps=mock_caps,
available_models={"claude-native": ["databricks-claude-sonnet-5"]},
)
assert decision.action == "allow"
assert "unavailable" in decision.rationale.lower()
@pytest.mark.asyncio
async def test_router_returns_no_model(mock_caps):
"""Router verdict missing model → allow unchanged."""
mock_caps.routing_client.route = AsyncMock(
return_value=MagicMock(model=None, rationale="no pick")
)
req = SubagentRouteRequest(
harness="claude-native",
task_name="test-task",
prompt="test prompt",
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
cost_control_mode="on",
auto_harness=False,
caps=mock_caps,
available_models={"claude-native": ["databricks-claude-sonnet-5"]},
)
assert decision.action == "allow"
assert "unavailable" in decision.rationale.lower()
@pytest.mark.asyncio
async def test_same_family_rewrite(mock_caps):
"""Claude parent → Claude routed model → rewrite (same harness)."""
mock_caps.routing_client.route = AsyncMock(
return_value=MagicMock(
model="databricks-claude-opus-4-8",
harness="claude-native",
rationale="escalate to opus",
)
)
req = SubagentRouteRequest(
harness="claude-native",
task_name="complex-task",
prompt="complex prompt" * 100,
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
cost_control_mode="on",
auto_harness=False,
caps=mock_caps,
available_models={
"claude-native": [
"databricks-claude-sonnet-5",
"databricks-claude-opus-4-8",
],
},
)
assert decision.action == "rewrite"
assert decision.model == "databricks-claude-opus-4-8"
assert decision.harness is None # Same harness not named
@pytest.mark.asyncio
async def test_same_family_allow_parent_model(mock_caps):
"""Claude parent → router picks parent model → allow (no change)."""
parent_model = "databricks-claude-sonnet-5"
mock_caps.routing_client.route = AsyncMock(
return_value=MagicMock(
model=parent_model,
harness="claude-native",
rationale="keep parent",
)
)
req = SubagentRouteRequest(
harness="claude-native",
task_name="trivial-task",
prompt="hi",
parent_model=parent_model,
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
cost_control_mode="on",
auto_harness=False,
caps=mock_caps,
available_models={
"claude-native": ["databricks-claude-sonnet-5"],
},
)
assert decision.action == "allow"
assert decision.model == parent_model
@pytest.mark.asyncio
async def test_cross_family_redirect_auto_allowed(mock_caps):
"""Auto harness: Claude parent → Codex pick → redirect (cross-family)."""
mock_caps.routing_client.route = AsyncMock(
return_value=MagicMock(
model="databricks-gpt-5-6-luna",
harness="codex-native",
rationale="delegate to codex",
)
)
req = SubagentRouteRequest(
harness="claude-native",
task_name="test-task",
prompt="test prompt",
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
cost_control_mode="on",
auto_harness=True, # Auto mode allows cross-family
caps=mock_caps,
available_models={
"claude-native": ["databricks-claude-sonnet-5"],
"codex-native": ["databricks-gpt-5-6-luna"],
},
)
assert decision.action == "redirect"
assert decision.model == "databricks-gpt-5-6-luna"
assert decision.harness == "codex-native"
@pytest.mark.asyncio
async def test_cross_family_deny_non_auto(mock_caps):
"""Non-auto Claude session: cross-family pick → deny."""
mock_caps.routing_client.route = AsyncMock(
return_value=MagicMock(
model="databricks-gpt-5-6-luna",
harness="codex-native",
rationale="delegate to codex",
)
)
req = SubagentRouteRequest(
harness="claude-native",
task_name="test-task",
prompt="test prompt",
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
cost_control_mode="on",
auto_harness=False, # Not auto → no cross-family
caps=mock_caps,
available_models={
"claude-native": ["databricks-claude-sonnet-5"],
"codex-native": ["databricks-gpt-5-6-luna"],
},
)
assert decision.action == "deny"
assert "cross-family" in decision.rationale.lower()
@pytest.mark.asyncio
async def test_unoffered_model_deny(mock_caps):
"""Router picks a model not in the offered set → deny."""
mock_caps.routing_client.route = AsyncMock(
return_value=MagicMock(
model="databricks-claude-opus-5", # Not in offered set
harness="claude-native",
rationale="pick opus5",
)
)
req = SubagentRouteRequest(
harness="claude-native",
task_name="test-task",
prompt="test prompt",
)
decision = await resolve_subagent_route(
session_id="test-session",
req=req,
cost_control_mode="on",
auto_harness=False,
caps=mock_caps,
available_models={
"claude-native": ["databricks-claude-sonnet-5"],
},
)
assert decision.action == "deny"
assert "cannot run" in decision.rationale.lower()
def test_decision_to_payload():
"""SubagentRouteDecision.to_payload() serializes correctly."""
decision = SubagentRouteDecision(
action="rewrite",
rationale="test",
model="databricks-claude-sonnet-5",
harness=None,
raw_model=None,
decision_id="test-id",
)
payload = decision.to_payload()
assert payload["action"] == "rewrite"
assert payload["rationale"] == "test"
assert payload["model"] == "databricks-claude-sonnet-5"
assert payload["harness"] is None
assert payload["decision_id"] == "test-id"
+42
View File
@@ -4592,6 +4592,48 @@ def test_read_launch_model_returns_none_for_missing_bridge_dir(
assert read_launch_model(tmp_path / "nonexistent") is None
def test_model_env_round_trips_the_launch_vocabulary(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The runner reads the alias pinning from here, not from its own env."""
from omnigent.claude_native_bridge import read_model_env
root = tmp_path / "root"
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", root)
bridge_dir = prepare_bridge_dir(
"conv_abc",
workspace=tmp_path,
launch_model="databricks-claude-opus-4-8",
launch_env={
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8",
"ANTHROPIC_CUSTOM_MODEL_OPTION": "databricks-claude-sonnet-5",
# Unrelated launch env must not be persisted.
"ANTHROPIC_BASE_URL": "https://example.invalid",
},
)
assert read_model_env(bridge_dir) == {
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8",
"ANTHROPIC_CUSTOM_MODEL_OPTION": "databricks-claude-sonnet-5",
}
def test_model_env_is_empty_without_a_ucode_launch(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No ``launch_env`` -> no ``model_env`` record; reads stay empty."""
from omnigent.claude_native_bridge import read_model_env
root = tmp_path / "root"
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", root)
bridge_dir = prepare_bridge_dir("conv_abc", workspace=tmp_path)
assert read_model_env(bridge_dir) == {}
assert read_model_env(tmp_path / "nonexistent") == {}
# ── _hook_record_from_jsonl_record: task/todo event parsing ──────────────────
+259
View File
@@ -0,0 +1,259 @@
"""Directed unit tests for the per-family gateway-inference signal (plan 3f).
Covers the two family checks (gateway config -> True; non-gateway -> False; a
raising check -> family omitted, unknown not False) and that the map fans the
single per-family result out over every accepted harness spelling.
"""
from __future__ import annotations
from typing import Any
import pytest
from omnigent import claude_native, codex_native_app_server, gateway_inference
from omnigent.claude_native import ClaudeNativeUcodeConfig
from omnigent.codex_native_app_server import NativeCodexLaunch
from omnigent.gateway_inference import (
_codex_launch_base_url,
_is_databricks_ai_gateway_url,
claude_gateway_inference_backed,
codex_gateway_inference_backed,
gateway_inference_map,
)
from omnigent.inner import codex_executor, databricks_executor
from omnigent.server.routing_contract import (
CLAUDE_GATEWAY_HARNESSES,
CODEX_GATEWAY_HARNESSES,
)
_GATEWAY_CODEX_URL = "https://example.cloud.databricks.com/ai-gateway/codex/v1"
_GATEWAY_ANTHROPIC_URL = "https://example.cloud.databricks.com/ai-gateway/anthropic"
def _stub_claude(
monkeypatch: pytest.MonkeyPatch,
config: ClaudeNativeUcodeConfig | None,
) -> list[dict[str, Any]]:
calls: list[dict[str, Any]] = []
def _resolve(**kwargs: Any) -> ClaudeNativeUcodeConfig | None:
calls.append(kwargs)
return config
monkeypatch.setattr(claude_native, "resolve_native_claude_config", _resolve)
return calls
def _stub_codex(monkeypatch: pytest.MonkeyPatch, launch: NativeCodexLaunch) -> None:
monkeypatch.setattr(
codex_native_app_server,
"resolve_native_codex_launch",
lambda **_kwargs: launch,
)
# ── claude family check ──────────────────────────────────────────────────
def test_claude_gateway_backed_for_gateway_env_with_helper(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = _stub_claude(
monkeypatch,
ClaudeNativeUcodeConfig(
env={"ANTHROPIC_BASE_URL": _GATEWAY_ANTHROPIC_URL},
api_key_helper="databricks auth token --profile dev",
),
)
assert claude_gateway_inference_backed() is True
# Called config-only against the v2 signature (spec-less; no refresh_models).
assert calls == [{"spec": None}]
def test_claude_not_gateway_backed_without_api_key_helper(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_claude(
monkeypatch,
ClaudeNativeUcodeConfig(env={"ANTHROPIC_BASE_URL": _GATEWAY_ANTHROPIC_URL}),
)
assert claude_gateway_inference_backed() is False
def test_claude_not_gateway_backed_for_bedrock(monkeypatch: pytest.MonkeyPatch) -> None:
# Bedrock pins ANTHROPIC_BEDROCK_BASE_URL (not ANTHROPIC_BASE_URL) and no helper.
_stub_claude(
monkeypatch,
ClaudeNativeUcodeConfig(
env={
"ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com",
"CLAUDE_CODE_USE_BEDROCK": "1",
},
),
)
assert claude_gateway_inference_backed() is False
def test_claude_not_gateway_backed_for_cli_login(monkeypatch: pytest.MonkeyPatch) -> None:
_stub_claude(monkeypatch, None)
assert claude_gateway_inference_backed() is False
# ── codex family check ───────────────────────────────────────────────────
def test_codex_gateway_backed_for_databricks_profile(monkeypatch: pytest.MonkeyPatch) -> None:
# _codex_launch_base_url imports _databricks_gateway_host from its home module
# at call time, so patch it there.
monkeypatch.setattr(
databricks_executor,
"_databricks_gateway_host",
lambda _profile: "https://example.cloud.databricks.com/",
)
_stub_codex(
monkeypatch,
NativeCodexLaunch(config_overrides=[], model=None, profile="dev"),
)
assert codex_gateway_inference_backed() is True
def test_codex_not_gateway_backed_for_non_databricks_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
overrides = codex_executor._provider_codex_config_overrides(
model="qwen/qwen3.7-plus",
base_url="https://openrouter.ai/api/v1",
auth_command="printf %s sk-test",
wire_api="chat",
)
_stub_codex(
monkeypatch,
NativeCodexLaunch(config_overrides=overrides, model=None, profile=None),
)
assert codex_gateway_inference_backed() is False
def test_codex_not_gateway_backed_for_cli_login(monkeypatch: pytest.MonkeyPatch) -> None:
_stub_codex(
monkeypatch,
NativeCodexLaunch(config_overrides=[], model=None, profile=None),
)
assert codex_gateway_inference_backed() is False
def test_codex_not_gateway_backed_when_profile_has_no_host(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(databricks_executor, "_databricks_gateway_host", lambda _profile: None)
_stub_codex(
monkeypatch,
NativeCodexLaunch(config_overrides=[], model=None, profile="dev"),
)
assert codex_gateway_inference_backed() is False
def test_codex_not_gateway_backed_for_non_codex_gateway_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# A gateway URL whose path is the Anthropic surface, not /codex/v1.
overrides = codex_executor._provider_codex_config_overrides(
model=None,
base_url=_GATEWAY_ANTHROPIC_URL,
auth_command="printf %s token",
wire_api="responses",
)
_stub_codex(
monkeypatch,
NativeCodexLaunch(config_overrides=overrides, model=None, profile=None),
)
assert codex_gateway_inference_backed() is False
# ── the launch-base-url derivation (private helper) ──────────────────────
def test_launch_base_url_extracts_generated_databricks_override() -> None:
overrides = codex_executor._databricks_codex_config_overrides(
model="databricks-gpt-5-5",
base_url=_GATEWAY_CODEX_URL,
auth_command="databricks auth token --profile dev",
)
launch = NativeCodexLaunch(config_overrides=overrides, model=None, profile=None)
assert _codex_launch_base_url(launch) == _GATEWAY_CODEX_URL
def test_launch_base_url_none_for_cli_config_provider_name_only() -> None:
# A cli-config entry pins only a provider name; its table lives in the
# user's ~/.codex/config.toml, which this process never reads.
launch = NativeCodexLaunch(
config_overrides=['model_provider="my_custom"'],
model=None,
profile=None,
)
assert _codex_launch_base_url(launch) is None
def test_is_databricks_ai_gateway_url_rejects_lookalike() -> None:
assert _is_databricks_ai_gateway_url(_GATEWAY_CODEX_URL) is True
# Trusted-suffix look-alike (ends in .evil.test) must be rejected.
assert (
_is_databricks_ai_gateway_url("https://ai-gateway.cloud.databricks.com.evil.test/codex/v1")
is False
)
# http (not https) is rejected.
assert _is_databricks_ai_gateway_url("http://x.ai-gateway.cloud.databricks.com/codex/v1") is (
False
)
# ── the fan-out map ──────────────────────────────────────────────────────
def test_gateway_inference_map_fans_out_over_every_spelling(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(gateway_inference, "claude_gateway_inference_backed", lambda: True)
monkeypatch.setattr(gateway_inference, "codex_gateway_inference_backed", lambda: False)
result = gateway_inference_map()
assert result == {
**dict.fromkeys(CLAUDE_GATEWAY_HARNESSES, True),
**dict.fromkeys(CODEX_GATEWAY_HARNESSES, False),
}
# Every accepted spelling of both families is covered.
assert set(result) == {
"claude-native",
"native-claude",
"codex",
"codex-native",
"native-codex",
}
def test_gateway_inference_map_omits_a_family_whose_check_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _boom() -> bool:
raise RuntimeError("no databricks config")
monkeypatch.setattr(gateway_inference, "claude_gateway_inference_backed", _boom)
monkeypatch.setattr(gateway_inference, "codex_gateway_inference_backed", lambda: True)
result = gateway_inference_map()
# Unknown (raised) is omitted, never reported False.
assert result == dict.fromkeys(CODEX_GATEWAY_HARNESSES, True)
assert not any(spelling in result for spelling in CLAUDE_GATEWAY_HARNESSES)
+336
View File
@@ -0,0 +1,336 @@
"""Unit tests for CLI-side smart routing."""
from unittest.mock import MagicMock, Mock, patch
import click
import httpx
import pytest
from omnigent.smart_routing_cli import (
AUTO_HARNESS,
RoutingDecision,
_gateway_state,
check_smart_routing_available,
create_smart_routing_session,
known_host_id,
smart_routing_families,
)
class TestSmartRoutingFamilies:
"""Test smart_routing_families function."""
def test_fixed_harness_returns_single_family(self):
"""Fixed harness route checks only that harness."""
result = smart_routing_families("claude-native")
assert result == ("claude-native",)
def test_fixed_codex_harness_returns_single_family(self):
"""Fixed codex harness route checks only that harness."""
result = smart_routing_families("codex-native")
assert result == ("codex-native",)
def test_auto_harness_returns_both_families(self):
"""Auto route checks both families."""
result = smart_routing_families(AUTO_HARNESS)
assert set(result) == {"claude-native", "codex-native"}
def test_none_harness_returns_both_families(self):
"""None harness (auto route) checks both families."""
result = smart_routing_families(None)
assert set(result) == {"claude-native", "codex-native"}
class TestCheckSmartRoutingAvailable:
"""Test check_smart_routing_available function."""
def test_raises_when_server_disabled_routing(self):
"""Raises error when server reports smart_routing_enabled: false."""
with patch("omnigent.smart_routing_cli._get_json") as mock_get:
mock_get.return_value = {"smart_routing_enabled": False}
with pytest.raises(click.ClickException, match="not enabled"):
check_smart_routing_available(
base_url="http://localhost:6868",
harnesses=["claude-native"],
)
def test_raises_when_server_returns_no_info(self):
"""Raises error when server returns no smart_routing_enabled field."""
with patch("omnigent.smart_routing_cli._get_json") as mock_get:
mock_get.return_value = {}
with pytest.raises(click.ClickException, match="not enabled"):
check_smart_routing_available(
base_url="http://localhost:6868",
harnesses=["claude-native"],
)
def test_passes_with_no_host_id(self):
"""Passes preflight with no host_id (skips gateway check)."""
with patch("omnigent.smart_routing_cli._get_json") as mock_get:
mock_get.return_value = {"smart_routing_enabled": True}
# Should not raise
check_smart_routing_available(
base_url="http://localhost:6868",
harnesses=["claude-native"],
host_id=None,
)
def test_raises_when_gateway_false_for_harness(self):
"""Raises error when gateway_inference map has false for harness."""
with patch("omnigent.smart_routing_cli._get_json") as mock_get:
with patch("omnigent.smart_routing_cli._gateway_inference_for_host") as mock_gw:
mock_get.return_value = {"smart_routing_enabled": True}
mock_gw.return_value = {"claude-native": False, "codex-native": True}
with pytest.raises(click.ClickException, match="unavailable for claude-native"):
check_smart_routing_available(
base_url="http://localhost:6868",
harnesses=["claude-native"],
host_id="host_abc123",
)
def test_passes_when_gateway_true_for_harness(self):
"""Passes preflight when gateway_inference map has true for harness."""
with patch("omnigent.smart_routing_cli._get_json") as mock_get:
with patch("omnigent.smart_routing_cli._gateway_inference_for_host") as mock_gw:
mock_get.return_value = {"smart_routing_enabled": True}
mock_gw.return_value = {"claude-native": True, "codex-native": True}
# Should not raise
check_smart_routing_available(
base_url="http://localhost:6868",
harnesses=["claude-native"],
host_id="host_abc123",
)
def test_passes_when_gateway_unknown_for_harness(self):
"""Passes preflight when gateway_inference map has no entry (unknown)."""
with patch("omnigent.smart_routing_cli._get_json") as mock_get:
with patch("omnigent.smart_routing_cli._gateway_inference_for_host") as mock_gw:
mock_get.return_value = {"smart_routing_enabled": True}
mock_gw.return_value = {"codex-native": True} # claude-native absent
# Should not raise
check_smart_routing_available(
base_url="http://localhost:6868",
harnesses=["claude-native"],
host_id="host_abc123",
)
def test_passes_when_gateway_map_none(self):
"""Passes preflight when gateway_inference map is None (unknown)."""
with patch("omnigent.smart_routing_cli._get_json") as mock_get:
with patch("omnigent.smart_routing_cli._gateway_inference_for_host") as mock_gw:
mock_get.return_value = {"smart_routing_enabled": True}
mock_gw.return_value = None
# Should not raise
check_smart_routing_available(
base_url="http://localhost:6868",
harnesses=["claude-native"],
host_id="host_abc123",
)
class TestCreateSmartRoutingSession:
"""Test create_smart_routing_session function."""
def test_creates_session_with_routing_contract(self):
"""Creates session with correct routing contract fields."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "conv_abc123",
"harness": "claude-native",
"model_override": "databricks-claude-opus-4-8",
}
with patch("omnigent.smart_routing_cli.httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client.__enter__.return_value = mock_client
mock_client.post.return_value = mock_response
mock_client_cls.return_value = mock_client
decision = create_smart_routing_session(
base_url="http://localhost:6868",
prompt="test prompt",
harness="claude-native",
host_id="host_abc123",
workspace="/home/user",
)
# Verify request body
call_args = mock_client.post.call_args
assert call_args[0][0] == "/v1/sessions"
body = call_args[1]["json"]
assert body["cost_control_mode_override"] == "on"
assert body["smart_routing_message"] == "test prompt"
assert body["host_id"] == "host_abc123"
assert body["workspace"] == "/home/user"
# Verify decision
assert decision.session_id == "conv_abc123"
assert decision.harness == "claude-native"
assert decision.model == "databricks-claude-opus-4-8"
assert decision.notice is None
def test_returns_unavailable_on_create_error(self):
"""Returns unavailable decision on create failure."""
with patch("omnigent.smart_routing_cli.httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client.__enter__.return_value = mock_client
mock_client.post.side_effect = httpx.ConnectError("connection failed")
mock_client_cls.return_value = mock_client
decision = create_smart_routing_session(
base_url="http://localhost:6868",
prompt="test prompt",
harness="claude-native",
)
assert decision.session_id is None
assert decision.harness is None
assert decision.model is None
assert "unavailable" in decision.notice.lower()
def test_sends_no_harness_override_for_fixed_harness(self):
"""Does not send harness_override for fixed harness."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "conv_abc123",
"harness": "claude-native",
"model_override": "databricks-claude-opus-4-8",
}
with patch("omnigent.smart_routing_cli.httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client.__enter__.return_value = mock_client
mock_client.post.return_value = mock_response
mock_client_cls.return_value = mock_client
create_smart_routing_session(
base_url="http://localhost:6868",
prompt="test prompt",
harness="claude-native",
)
call_args = mock_client.post.call_args
body = call_args[1]["json"]
assert "harness_override" not in body
def test_sends_auto_harness_override_when_none(self):
"""Sends harness_override: 'auto' when harness is None."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "conv_abc123",
"harness": "codex-native",
"model_override": "databricks-gpt-5-6-sol",
}
with patch("omnigent.smart_routing_cli.httpx.Client") as mock_client_cls:
mock_client = MagicMock()
mock_client.__enter__.return_value = mock_client
mock_client.post.return_value = mock_response
mock_client_cls.return_value = mock_client
create_smart_routing_session(
base_url="http://localhost:6868",
prompt="test prompt",
harness=None,
)
call_args = mock_client.post.call_args
body = call_args[1]["json"]
assert body["harness_override"] == AUTO_HARNESS
class TestKnownHostId:
"""Test known_host_id function."""
def test_returns_none_when_host_not_in_list(self):
"""Returns None when host_id is not in the hosts list."""
with patch("omnigent.smart_routing_cli._get_json") as mock_get:
mock_get.return_value = {
"hosts": [
{"host_id": "host_xyz789"},
]
}
result = known_host_id(
base_url="http://localhost:6868",
host_id="host_abc123",
)
assert result is None
def test_returns_host_id_when_found(self):
"""Returns host_id when it is in the hosts list."""
with patch("omnigent.smart_routing_cli._get_json") as mock_get:
mock_get.return_value = {
"hosts": [
{"host_id": "host_abc123", "gateway_inference": {"claude-native": True}},
]
}
result = known_host_id(
base_url="http://localhost:6868",
host_id="host_abc123",
)
assert result == "host_abc123"
def test_returns_none_when_host_id_is_none(self):
"""Returns None when host_id input is None."""
result = known_host_id(
base_url="http://localhost:6868",
host_id=None,
)
assert result is None
class TestGatewayState:
"""Test _gateway_state function."""
def test_returns_value_for_canonical_harness(self):
"""Returns gateway state value for canonical harness name."""
gateway = {"claude-native": True, "codex-native": False}
result = _gateway_state(gateway, "claude-native")
assert result is True
def test_returns_value_for_alternate_spelling(self):
"""Returns gateway state value when alternate spelling is used."""
gateway = {"native-claude": True}
# Assuming canonicalize_harness maps native-claude to claude-native
result = _gateway_state(gateway, "native-claude")
# Should find it under native-claude if canonicalize_harness doesn't normalize it
assert result is True
def test_returns_none_when_not_found(self):
"""Returns None when harness is not in gateway map."""
gateway = {"codex-native": True}
result = _gateway_state(gateway, "claude-native")
assert result is None
class TestRoutingDecision:
"""Test RoutingDecision dataclass."""
def test_creates_decision_with_all_fields(self):
"""Creates decision with all fields populated."""
decision = RoutingDecision(
session_id="conv_abc123",
harness="claude-native",
model="databricks-claude-opus-4-8",
notice=None,
)
assert decision.session_id == "conv_abc123"
assert decision.harness == "claude-native"
assert decision.model == "databricks-claude-opus-4-8"
assert decision.notice is None
def test_creates_decision_with_notice(self):
"""Creates decision with notice when routing unavailable."""
decision = RoutingDecision(
session_id=None,
harness=None,
model=None,
notice="omnigent: Smart Routing was unavailable (reason)",
)
assert decision.session_id is None
assert decision.notice is not None
+53 -1
View File
@@ -1,4 +1,4 @@
import { type ReactNode, useState } from "react";
import { type ReactNode, useMemo, useState } from "react";
import {
Select,
@@ -8,6 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
// Sentinel Select values for the Model row. Radix requires a non-empty value,
// so the two "no explicit model" choices ride on reserved tokens rather than
@@ -132,3 +133,54 @@ export function DescribedSelect({
</Select>
);
}
/**
* Model select for a harness, with Smart Routing support.
*
* Renders options for model selection, including the Smart Routing
* model-per-turn option when enabled. When Smart Routing is not available
* for the harness, shows only DEFAULT and specific model options.
*
* @param value Selected model value (DEFAULT, SMART, or a model id).
* @param onValueChange Selection callback.
* @param models Available model options.
* @param harness The harness being configured (for smart routing gating).
* @param smartRoutingAvailable Whether Smart Routing is available for this harness.
* @param testId Trigger test id.
* @param ariaLabel Accessible name for the trigger.
*/
export function RoutingModelSelect({
value,
onValueChange,
models,
harness,
smartRoutingAvailable,
testId,
ariaLabel,
}: {
value: string;
onValueChange: (value: string) => void;
models: readonly { value: string; label: string; description?: string }[];
harness?: string;
smartRoutingAvailable?: boolean;
testId: string;
ariaLabel: string;
}): JSX.Element {
const items = useMemo(() => {
const baseItems: typeof models = [
{ value: MODEL_SELECT_DEFAULT, label: "Default", description: "Use the harness's configured model" },
];
if (smartRoutingAvailable) {
baseItems.push({
value: MODEL_SELECT_SMART,
label: "Smart Routing",
description: "Let the router pick the best model per turn",
});
}
return [...baseItems, ...models];
}, [models, smartRoutingAvailable]);
return <DescribedSelect value={value} onValueChange={onValueChange} options={items} testId={testId} ariaLabel={ariaLabel} />;
}
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { RoutingDecisionCard } from "./RoutingDecisionCard";
import type { RoutingDecisionItem } from "@/lib/conversationItems";
describe("RoutingDecisionCard", () => {
it("renders the model shorthand", () => {
const item: RoutingDecisionItem = {
id: "item_123",
type: "routing_decision",
responseId: "resp_123",
model: "databricks-claude-opus-4-8",
applied: true,
rationale: "Prompt indicates complex reasoning",
};
render(<RoutingDecisionCard item={item} />);
expect(screen.getByText(/opus/)).toBeInTheDocument();
});
it("displays the rationale", () => {
const item: RoutingDecisionItem = {
id: "item_123",
type: "routing_decision",
responseId: "resp_123",
model: "databricks-claude-sonnet-5",
applied: true,
rationale: "Rule-based fallback",
};
render(<RoutingDecisionCard item={item} />);
expect(screen.getByText(/Rule-based fallback/)).toBeInTheDocument();
});
it("truncates long rationales", () => {
const longRationale = "A".repeat(60);
const item: RoutingDecisionItem = {
id: "item_123",
type: "routing_decision",
responseId: "resp_123",
model: "databricks-gpt-5-6-sol",
applied: false,
rationale: longRationale,
};
const { getByText } = render(<RoutingDecisionCard item={item} />);
expect(getByText(/A{40}…/)).toBeInTheDocument();
});
it("includes agent name when present", () => {
const item: RoutingDecisionItem = {
id: "item_123",
type: "routing_decision",
responseId: "resp_123",
model: "databricks-claude-sonnet-5",
applied: true,
rationale: "Test",
agent: "Explore",
};
render(<RoutingDecisionCard item={item} />);
const card = screen.getByTestId("routing-decision-card");
expect(card).toHaveAttribute("aria-label", expect.stringContaining("Explore"));
});
});
@@ -0,0 +1,47 @@
// Session-level routing decision card: renders a `routing_decision` item as a
// structured card displaying the model choice, applied status, rationale, and
// scope (session-level vs. per-turn vs. sub-agent).
import { BrainIcon } from "lucide-react";
import { shortModelName } from "@/components/CostRoutingControl";
import type { RoutingDecisionItem } from "@/lib/conversationItems";
import { cn } from "@/lib/utils";
interface RoutingDecisionCardProps {
item: RoutingDecisionItem;
/** CSS class for the card container. */
className?: string;
}
/**
* Render a routing decision as a muted chip or card.
*
* Session-level decisions appear once under the session's first message;
* per-turn and sub-agent decisions appear inline under their triggering message.
* The card always shows the model choice, applied status, and rationale.
*/
export function RoutingDecisionCard({ item, className }: RoutingDecisionCardProps) {
const scopeLabel = item.agent ? `sub-agent (${item.agent})` : "session";
const statusLabel = item.applied ? "applied" : "would pick";
return (
<div
className={cn(
"inline-flex items-center gap-2 rounded-full border border-border bg-muted/40 px-2.5 py-1 text-xs",
className,
)}
data-testid="routing-decision-card"
role="status"
aria-label={`Routing decision: ${statusLabel} ${shortModelName(item.model)} (${scopeLabel})`}
>
<BrainIcon className="size-3 shrink-0 text-muted-foreground" />
<span className="font-medium text-foreground">{shortModelName(item.model)}</span>
{item.rationale && (
<span className="text-muted-foreground" title={item.rationale}>
· {item.rationale.slice(0, 40)}
{item.rationale.length > 40 ? "…" : ""}
</span>
)}
</div>
);
}
+21
View File
@@ -91,6 +91,27 @@ function useHarnessCatalog<T>(select: (c: HarnessCatalog) => T, fallback: T): T
*/
export const AUTO_HARNESS_ID = "auto";
/**
* Client-only sentinel for the TOP-LEVEL "Smart Routing" harness pick (its own
* section above the Harnesses group, no bundle agent). Distinct from
* :data:`AUTO_HARNESS_ID` so the picker can track the selection locally; the
* create call still sends ``harness_override: AUTO_HARNESS_ID`` (the server
* routes both harness + model at create and rebinds to the wrapper it picks).
*/
export const AUTO_NATIVE_HARNESS_ID = "auto-native";
/**
* Sub-description shown under the top-level Smart Routing row / on its chip.
*/
export const AUTO_HARNESS_DESCRIPTION = "Harness and model picked per task by smart routing";
/**
* User-facing name for the Smart Routing feature. Centralized so the toggle,
* the model-dropdown option, and the "switched to <default>" notices in
* :mod:`smartRoutingAvailability` can't drift on wording.
*/
export const SMART_ROUTING_LABEL = "Smart Routing";
export function useBrainHarnessLabels(smartRoutingEnabled = false): Record<string, string> {
const base = useHarnessCatalog((c) => c.labels, BRAIN_HARNESS_LABELS);
if (!smartRoutingEnabled) return base;
@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import {
hostBacksHarnessWithGateway,
smartRoutingUnavailableReason,
SMART_ROUTING_ARMS,
} from "./smartRoutingAvailability";
describe("hostBacksHarnessWithGateway", () => {
it("returns true when gateway_inference is missing", () => {
expect(hostBacksHarnessWithGateway({}, "claude-native")).toBe(true);
expect(hostBacksHarnessWithGateway(null, "claude-native")).toBe(true);
expect(hostBacksHarnessWithGateway(undefined, "claude-native")).toBe(true);
});
it("returns true when gateway_inference is null", () => {
expect(hostBacksHarnessWithGateway({ gateway_inference: null }, "claude-native")).toBe(true);
});
it("returns true when the harness is not in gateway_inference", () => {
expect(
hostBacksHarnessWithGateway({ gateway_inference: { "codex-native": true } }, "claude-native"),
).toBe(true);
});
it("returns false when gateway_inference explicitly sets harness to false", () => {
expect(
hostBacksHarnessWithGateway({ gateway_inference: { "claude-native": false } }, "claude-native"),
).toBe(false);
});
it("returns true when gateway_inference sets harness to true", () => {
expect(
hostBacksHarnessWithGateway({ gateway_inference: { "claude-native": true } }, "claude-native"),
).toBe(true);
});
it("handles independent family gating", () => {
const host = {
gateway_inference: { "claude-native": true, "codex-native": false },
};
expect(hostBacksHarnessWithGateway(host, "claude-native")).toBe(true);
expect(hostBacksHarnessWithGateway(host, "codex-native")).toBe(false);
});
});
describe("smartRoutingUnavailableReason", () => {
it("returns routing-disabled when routing is off", () => {
const result = smartRoutingUnavailableReason({
routingEnabled: false,
wrappersRegistered: true,
unreadyHarnesses: [],
});
expect(result).toEqual({ kind: "routing-disabled" });
});
it("returns wrappers-missing when wrappers aren't registered", () => {
const result = smartRoutingUnavailableReason({
routingEnabled: true,
wrappersRegistered: false,
unreadyHarnesses: [],
});
expect(result).toEqual({ kind: "wrappers-missing" });
});
it("returns harnesses-unready when some harnesses are unready", () => {
const result = smartRoutingUnavailableReason({
routingEnabled: true,
wrappersRegistered: true,
unreadyHarnesses: ["claude-native"],
});
expect(result).toEqual({
kind: "harnesses-unready",
harnesses: ["claude-native"],
});
});
it("returns not-gateway-backed when harnesses aren't gateway-backed", () => {
const result = smartRoutingUnavailableReason({
routingEnabled: true,
wrappersRegistered: true,
unreadyHarnesses: [],
notGatewayBackedHarnesses: ["codex-native"],
});
expect(result).toEqual({
kind: "not-gateway-backed",
harnesses: ["codex-native"],
});
});
it("returns null when Smart Routing is available", () => {
const result = smartRoutingUnavailableReason({
routingEnabled: true,
wrappersRegistered: true,
unreadyHarnesses: [],
notGatewayBackedHarnesses: [],
});
expect(result).toBeNull();
});
it("returns null when notGatewayBackedHarnesses is omitted", () => {
const result = smartRoutingUnavailableReason({
routingEnabled: true,
wrappersRegistered: true,
unreadyHarnesses: [],
});
expect(result).toBeNull();
});
});
+114
View File
@@ -0,0 +1,114 @@
// Why top-level Smart Routing can (or can't) be offered on the new-chat
// landing, and how to say so.
//
// The landing drops a Smart Routing pick it can't honour. Four different
// conditions cause that, and they need different words: a server with routing
// switched off is not a host missing a CLI, neither is a deployment whose
// native wrapper agents aren't registered, and neither is a host whose CLI runs
// off something other than the workspace AI gateway.
import { SMART_ROUTING_LABEL } from "@/lib/agentLabels";
import { nativeCodingAgentForHarness } from "@/lib/nativeCodingAgents";
/**
* The two native harnesses Smart Routing picks between. Both wrapper agents
* must be registered and both CLIs ready a router with one arm is just that
* arm.
*/
export const SMART_ROUTING_ARMS = ["claude-native", "codex-native"] as const;
/** Why Smart Routing is unavailable, in the shape the notice needs. */
export type SmartRoutingUnavailableCause =
/** The server has routing switched off (`smart_routing_enabled: false`). */
| { kind: "routing-disabled" }
/** A native wrapper agent Smart Routing binds isn't registered. */
| { kind: "wrappers-missing" }
/** Ready-to-run arms are missing on the selected host. */
| { kind: "harnesses-unready"; harnesses: string[] }
/**
* Arms whose family the host doesn't back with the workspace AI gateway. The
* routing apply layer rewrites the model through the gateway, so a CLI
* pointed anywhere else can't be routed even with the CLI installed.
*/
| { kind: "not-gateway-backed"; harnesses: string[] };
/**
* Classify why Smart Routing can't be offered, most-fundamental cause first
* routing being off makes the host's CLIs irrelevant, and a CLI that isn't
* installed makes its inference config irrelevant.
*
* @param inputs - The four independent conditions, read off the server flags,
* the agent list, and the selected host.
* @returns The cause, or ``null`` when Smart Routing is available.
*/
export function smartRoutingUnavailableReason(inputs: {
routingEnabled: boolean;
wrappersRegistered: boolean;
unreadyHarnesses: readonly string[];
notGatewayBackedHarnesses?: readonly string[];
}): SmartRoutingUnavailableCause | null {
if (!inputs.routingEnabled) return { kind: "routing-disabled" };
if (!inputs.wrappersRegistered) return { kind: "wrappers-missing" };
if (inputs.unreadyHarnesses.length > 0) {
return { kind: "harnesses-unready", harnesses: [...inputs.unreadyHarnesses] };
}
const notBacked = inputs.notGatewayBackedHarnesses ?? [];
if (notBacked.length > 0) {
return { kind: "not-gateway-backed", harnesses: [...notBacked] };
}
return null;
}
/**
* Whether *host* backs *harness*'s inference with the workspace AI gateway.
*
* Unknown reads as backed: an older host build (or a server that predates the
* field) reports nothing, and a sandbox has no host row at all. Gating those
* away would hide Smart Routing on every deployment that can't yet answer
* only an explicit ``false`` from the host is a reason to withhold it.
*/
export function hostBacksHarnessWithGateway(
host: { gateway_inference?: Record<string, boolean> | null } | null | undefined,
harness: string,
): boolean {
return host?.gateway_inference?.[harness] !== false;
}
/** Display name for an arm, e.g. ``"Codex"``; the raw id if it isn't native. */
function armLabel(harness: string): string {
return nativeCodingAgentForHarness(harness)?.displayName ?? harness;
}
/** ``"Claude Code and Codex"`` — the arms, in wire order. */
function armList(harnesses: readonly string[]): string {
const labels = harnesses.map(armLabel);
if (labels.length === 0) return SMART_ROUTING_ARMS.map(armLabel).join(" and ");
if (labels.length === 1) return labels[0]!;
return `${labels.slice(0, -1).join(", ")} and ${labels.at(-1)}`;
}
/**
* One sentence for the landing's downgrade notice: what took Smart Routing
* away, and what runs instead.
*
* @param cause - From {@link smartRoutingUnavailableReason}.
* @param context - Host the arms were checked on (omitted for a sandbox) and
* the agent the pick fell back to.
*/
export function smartRoutingDroppedMessage(
cause: SmartRoutingUnavailableCause,
context: { hostName?: string | null; fallbackAgentName?: string | null } = {},
): string {
const on = context.hostName ? ` on ${context.hostName}` : "";
const to = context.fallbackAgentName ?? "the default agent";
switch (cause.kind) {
case "routing-disabled":
return `${SMART_ROUTING_LABEL} is turned off on this server — switched to ${to}.`;
case "wrappers-missing":
return `${SMART_ROUTING_LABEL} needs the ${armList(SMART_ROUTING_ARMS)} agents registered on this server — switched to ${to}.`;
case "harnesses-unready":
return `${SMART_ROUTING_LABEL} needs ${armList(cause.harnesses)} ready${on} — switched to ${to}.`;
case "not-gateway-backed":
return `${SMART_ROUTING_LABEL} needs ${armList(cause.harnesses)} running on the workspace AI gateway${on} — switched to ${to}.`;
}
}
+55
View File
@@ -1002,6 +1002,61 @@ describe("NewChatLandingScreen", () => {
expect(screen.queryByTestId("new-chat-landing-harness-copilot")).toBeNull();
});
it("offers Smart Routing as an Agent Harness option for a claude-sdk agent when enabled", () => {
// polly (claude-sdk, an overridable brain harness, not a native wrapper) is
// create-time routable: Smart Routing is the first option in the Agent
// Harness dropdown (NOT a separate toggle) when routing is enabled.
mockPollyWithBrainReadiness();
renderLanding({ smart_routing_enabled: true });
openAgentConfig("a_polly");
openSelect("new-chat-landing-config-harness");
expect(screen.getByTestId("new-chat-landing-harness-smart-routing")).toBeTruthy();
});
it("omits Smart Routing from the Agent Harness dropdown when routing is disabled", () => {
// Same agent, routing off → no Smart Routing option (and no separate toggle
// anywhere).
mockPollyWithBrainReadiness();
renderLanding({ smart_routing_enabled: false });
openAgentConfig("a_polly");
openSelect("new-chat-landing-config-harness");
expect(screen.queryByTestId("new-chat-landing-harness-smart-routing")).toBeNull();
// The old standalone toggle testid must not exist on any surface.
expect(screen.queryByTestId("new-chat-landing-config-smart-routing")).toBeNull();
});
it("offers a top-level Smart Routing harness row when routing is available", () => {
// Default mocks register both native wrappers on an online host with no
// gateway_inference override (→ backed). With routing on, the top-level
// Smart Routing row appears above the Harnesses group.
renderLanding({ smart_routing_enabled: true });
fireEvent.pointerDown(screen.getByTestId("new-chat-landing-agent-select"), { button: 0 });
expect(screen.getByTestId("new-chat-landing-harness-smart-routing")).toBeTruthy();
});
it("hides the top-level Smart Routing row when the server has routing disabled", () => {
renderLanding({ smart_routing_enabled: false });
fireEvent.pointerDown(screen.getByTestId("new-chat-landing-agent-select"), { button: 0 });
expect(screen.queryByTestId("new-chat-landing-harness-smart-routing")).toBeNull();
});
it("selecting top-level Smart Routing binds a placeholder and shows it on the chip", async () => {
// Selecting the top-level row binds a placeholder native wrapper (so create
// has a concrete agent_id) and surfaces "Smart Routing" on the composer
// chip rather than the placeholder's name. The create-payload shape
// (harness_override:"auto" + smart_routing_message, no placeholder model)
// is covered server-side in tests/server/test_routing_create.py and proven
// end-to-end; here we assert the picker's client-side selection contract.
renderLanding({ smart_routing_enabled: true });
fireEvent.pointerDown(screen.getByTestId("new-chat-landing-agent-select"), { button: 0 });
fireEvent.click(screen.getByTestId("new-chat-landing-harness-smart-routing"));
await waitFor(() =>
expect(screen.getByTestId("new-chat-landing-agent-select").textContent).toContain(
"Smart Routing",
),
);
});
it("seeds the working directory from the host's most-recent path", async () => {
renderLanding();
// host_1's recent ("/Users/corey/repo") seeds the field; the chip shows
+317 -141
View File
@@ -46,7 +46,6 @@ import {
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
CLAUDE_NATIVE_EFFORTS,
ConfigRow,
@@ -111,8 +110,18 @@ import { readLastHarness, writeLastHarness } from "@/lib/harnessPreferences";
import { readHideUnconfiguredHarnesses } from "@/lib/harnessVisibilityPreferences";
import { readDefaultBaseBranch } from "@/lib/baseBranchPreferences";
import { readHarnessOptions, writeHarnessOption } from "@/lib/modePreferences";
import { AUTO_HARNESS_ID, useBrainHarnessLabels } from "@/lib/agentLabels";
import {
AUTO_HARNESS_ID,
AUTO_NATIVE_HARNESS_ID,
SMART_ROUTING_LABEL,
useBrainHarnessLabels,
} from "@/lib/agentLabels";
import { CLAUDE_NATIVE_MODELS } from "@/lib/claudeNativeModels";
import {
SMART_ROUTING_ARMS,
hostBacksHarnessWithGateway,
smartRoutingUnavailableReason,
} from "@/lib/smartRoutingAvailability";
import { partitionAgentsByKind, sortAgentsForDisplay } from "@/lib/agentGrouping";
import { cn } from "@/lib/utils";
import {
@@ -842,6 +851,9 @@ export function AgentHarnessPicker({
onSelectPending,
onCreateCustomAgent,
sandboxSelected,
autoHarnessAvailable = false,
autoHarnessActive = false,
onSelectAutoHarness,
allowCreateCustomAgent = true,
onOpenChange,
dropdownModal = true,
@@ -862,6 +874,14 @@ export function AgentHarnessPicker({
onSelectPending: () => void;
onCreateCustomAgent: () => void;
sandboxSelected: boolean;
/** Whether the top-level Smart Routing row is offered (routing enabled, both
* native wrappers registered, and the host backs both on the AI gateway). */
autoHarnessAvailable?: boolean;
/** Whether Smart Routing is the current pick. It rides a placeholder agent,
* so the placeholder's own row is NOT highlighted while this is active. */
autoHarnessActive?: boolean;
/** Select the top-level Smart Routing harness. */
onSelectAutoHarness?: () => void;
/** Whether to offer the "Create custom agent" action. Defaults true; an
* embedder that only picks an existing agent (e.g. project settings) can
* hide it since it has no interactive create flow. */
@@ -940,7 +960,9 @@ export function AgentHarnessPicker({
// Each entry is a plain selectable row — selecting commits the pick and
// closes the menu. Run-config knobs moved to the gear-icon config modal.
const renderEntry = (agent: AvailableAgent): ReactNode => {
const active = agent.id === effectiveAgentId;
// Smart Routing binds a placeholder agent for the create call, so its own
// row must NOT light up while top-level Smart Routing is the active pick.
const active = !autoHarnessActive && agent.id === effectiveAgentId;
return (
<DropdownMenuItem
key={agent.id}
@@ -1136,8 +1158,26 @@ export function AgentHarnessPicker({
</div>
) : (
<>
{/* Harnesses group first the native terminal CLIs (Claude Code is
the default), so the most-used picks lead. Ready-to-use harnesses
{/* Smart Routing sits in its own unlabeled group ABOVE the
harnesses: it routes over them rather than being one of them. */}
{autoHarnessAvailable && (
<>
<DropdownMenuItem
data-testid="new-chat-landing-harness-smart-routing"
data-active={autoHarnessActive ? "true" : undefined}
onSelect={() => {
onSelectAutoHarness?.();
setOpen(false);
}}
className="items-center gap-2 rounded-sm px-2 py-1.5 text-13 data-[active=true]:bg-accent/60 data-[active=true]:text-foreground"
>
<span className="flex-1 truncate">{SMART_ROUTING_LABEL}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* Harnesses group the native terminal CLIs (Claude Code is the
default), so the most-used picks lead. Ready-to-use harnesses
list inline; "needs setup" ones fold into a "More" group. */}
{(readyHarnessEntries.length > 0 || moreHarnessEntries.length > 0) && (
<>
@@ -1331,6 +1371,11 @@ function HarnessConfigModal({
// otherwise a stale costControlMode="on" (e.g. server later disabled the
// flag) would select the __smart__ sentinel with no matching Select item.
const smartRoutingOn = smartRoutingEligible && draftRouting === "on";
// Top-level Smart Routing (the placeholder-bound "auto" harness): the router
// owns harness + model + effort, so this modal hides Model and Effort and
// shows only Permissions (frozen to Default) — none of the per-model knobs
// apply to a pick the router makes per session.
const autoNativeSelected = draftHarness === AUTO_NATIVE_HARNESS_ID;
const modelValue = smartRoutingOn ? MODEL_SELECT_SMART : draftModel || MODEL_SELECT_DEFAULT;
const onModelChange = (value: string) => {
if (value === MODEL_SELECT_SMART) {
@@ -1378,12 +1423,19 @@ function HarnessConfigModal({
setCursorExecMode(draftCursor);
if (entryHarness) writeHarnessOption(entryHarness, { mode: draftCursor });
} else if (brainDefault) {
// Picking the spec default clears the override so the session tracks it.
setPickedHarness(draftHarness === brainDefault ? null : draftHarness, agent.id);
// Smart Routing is an option in the Agent Harness dropdown: when it's on,
// the router owns the harness, so clear any concrete override. Otherwise
// picking the spec default clears the override (session tracks it); a
// non-default pick persists.
if (smartRoutingOn) {
setPickedHarness(null, agent.id);
} else {
setPickedHarness(draftHarness === brainDefault ? null : draftHarness, agent.id);
}
}
// Smart Routing is offered on Claude (Model dropdown) and other routable
// agents (standalone toggle), so commit it for every eligible agent — not
// just the Claude branch above.
// Smart Routing is offered on every routable agent (as the first Model
// option for the native harnesses, and as an Agent Harness option for
// bundle agents), so commit its on/off state for each eligible agent.
if (smartRoutingEligible) setCostControlMode(draftRouting);
onOpenChange(false);
};
@@ -1408,99 +1460,106 @@ function HarnessConfigModal({
</DialogHeader>
<div className="flex flex-col gap-5 py-1">
{/* Smart Routing as a standalone toggle, first, for routable agents
that have no Model dropdown to fold it into (Codex, bundle agents, ).
Claude offers it as a Model option instead, so it's excluded here. */}
{smartRoutingEligible && !hasPermission && (
<ConfigRow label="Smart Routing" description="Auto-pick the model per turn by task">
<div className="flex h-8 items-center justify-end">
<Switch
size="sm"
checked={smartRoutingOn}
data-testid="new-chat-landing-config-smart-routing"
aria-label="Smart Routing"
onCheckedChange={(next) => setDraftRouting(next ? "on" : "off")}
/>
</div>
</ConfigRow>
)}
{hasPermission && (
<>
<ConfigRow label="Model" description="Underlying LLM">
<Select value={modelValue} onValueChange={onModelChange}>
<SelectTrigger
className="w-full"
data-testid="new-chat-landing-config-model"
aria-label="Model"
>
<SelectValue />
</SelectTrigger>
<SelectContent
position="popper"
align="start"
className="[&_[data-slot=select-item]]:pl-2.5"
>
{smartRoutingEligible && (
<SelectItem value={MODEL_SELECT_SMART}>Smart Routing</SelectItem>
)}
<SelectItem value={MODEL_SELECT_DEFAULT}>Default</SelectItem>
{claudeModelOptions.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.displayName}
</SelectItem>
))}
{claudeModelsLoading && (
<div className="px-2.5 py-1 text-xs text-muted-foreground">
Loading models
</div>
)}
{!claudeModelsLoading && claudeModelOptions.length === 0 && (
<div className="px-2.5 py-1 text-xs text-muted-foreground">
Models unavailable
</div>
)}
</SelectContent>
</Select>
</ConfigRow>
{/* Model + Effort are hidden for top-level Smart Routing the
router owns them per session, so there is nothing to configure. */}
{!autoNativeSelected && (
<ConfigRow label="Model" description="Underlying LLM">
<Select value={modelValue} onValueChange={onModelChange}>
<SelectTrigger
className="w-full"
data-testid="new-chat-landing-config-model"
aria-label="Model"
>
<SelectValue />
</SelectTrigger>
<SelectContent
position="popper"
align="start"
className="[&_[data-slot=select-item]]:pl-2.5"
>
{smartRoutingEligible && (
<SelectItem value={MODEL_SELECT_SMART}>Smart Routing</SelectItem>
)}
<SelectItem value={MODEL_SELECT_DEFAULT}>Default</SelectItem>
{claudeModelOptions.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.displayName}
</SelectItem>
))}
{claudeModelsLoading && (
<div className="px-2.5 py-1 text-xs text-muted-foreground">
Loading models
</div>
)}
{!claudeModelsLoading && claudeModelOptions.length === 0 && (
<div className="px-2.5 py-1 text-xs text-muted-foreground">
Models unavailable
</div>
)}
</SelectContent>
</Select>
</ConfigRow>
)}
<ConfigRow label="Effort" description="Reasoning depth vs. speed">
<Select
value={draftEffort || EFFORT_SELECT_NONE}
onValueChange={(v) => setDraftEffort(v === EFFORT_SELECT_NONE ? "" : v)}
// Smart Routing picks the model + effort per turn, so an
// explicit effort can't apply — freeze it to Default.
disabled={smartRoutingOn}
>
<SelectTrigger
className="w-full"
data-testid="new-chat-landing-config-effort"
aria-label="Reasoning effort"
{!autoNativeSelected && (
<ConfigRow label="Effort" description="Reasoning depth vs. speed">
<Select
value={draftEffort || EFFORT_SELECT_NONE}
onValueChange={(v) => setDraftEffort(v === EFFORT_SELECT_NONE ? "" : v)}
// Smart Routing picks the model + effort per turn, so an
// explicit effort can't apply — freeze it to Default.
disabled={smartRoutingOn}
>
<SelectValue />
</SelectTrigger>
<SelectContent
position="popper"
align="start"
className="[&_[data-slot=select-item]]:pl-2.5"
>
<SelectItem value={EFFORT_SELECT_NONE}>Default</SelectItem>
{CLAUDE_NATIVE_EFFORTS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
</ConfigRow>
<SelectTrigger
className="w-full"
data-testid="new-chat-landing-config-effort"
aria-label="Reasoning effort"
>
<SelectValue />
</SelectTrigger>
<SelectContent
position="popper"
align="start"
className="[&_[data-slot=select-item]]:pl-2.5"
>
<SelectItem value={EFFORT_SELECT_NONE}>Default</SelectItem>
{CLAUDE_NATIVE_EFFORTS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
</ConfigRow>
)}
<ConfigRow label="Permissions" description="What the agent can do without asking">
<DescribedSelect
value={draftPermission}
onValueChange={setDraftPermission}
options={CLAUDE_NATIVE_PERMISSION_MODES}
testId="new-chat-landing-config-permission"
ariaLabel="Permissions"
/>
{autoNativeSelected ? (
// The routed harness's own permission default applies; the
// placeholder's is meaningless. Show a grayed-out "Default".
<Select value="default" disabled>
<SelectTrigger
className="w-full"
data-testid="new-chat-landing-config-permission"
aria-label="Permissions"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
</SelectContent>
</Select>
) : (
<DescribedSelect
value={draftPermission}
onValueChange={setDraftPermission}
options={CLAUDE_NATIVE_PERMISSION_MODES}
testId="new-chat-landing-config-permission"
ariaLabel="Permissions"
/>
)}
</ConfigRow>
</>
)}
@@ -1520,6 +1579,11 @@ function HarnessConfigModal({
align="start"
className="[&_[data-slot=select-item]]:pl-2.5"
>
{/* Smart Routing first, above Default same layout as the
Claude Code dropdown, so every routable harness reads alike. */}
{smartRoutingEligible && (
<SelectItem value={MODEL_SELECT_SMART}>Smart Routing</SelectItem>
)}
<SelectItem value={MODEL_SELECT_DEFAULT}>
{defaultModelLabel(modelOptions, modelDisplay)}
</SelectItem>
@@ -1598,7 +1662,21 @@ function HarnessConfigModal({
{!hasPermission && !hasApproval && !hasCursor && brainDefault && (
<ConfigRow label="Agent Harness" description="Underlying coding harness">
<Select value={draftHarness ?? brainDefault} onValueChange={setDraftHarness}>
<Select
// Smart Routing is an Agent Harness option (not a separate
// toggle): selecting it turns routing on and the router owns
// the harness + model; any other value is a concrete brain
// harness with routing off.
value={smartRoutingOn ? MODEL_SELECT_SMART : (draftHarness ?? brainDefault)}
onValueChange={(value) => {
if (value === MODEL_SELECT_SMART) {
setDraftRouting("on");
} else {
setDraftRouting("off");
setDraftHarness(value);
}
}}
>
<SelectTrigger
className="w-full"
data-testid="new-chat-landing-config-harness"
@@ -1611,6 +1689,16 @@ function HarnessConfigModal({
align="start"
className="[&_[data-slot=select-item]]:pl-2.5"
>
{/* Smart Routing first, above the concrete harnesses same
layout as the Claude Code / Codex Model dropdowns. */}
{smartRoutingEligible && (
<SelectItem
value={MODEL_SELECT_SMART}
data-testid="new-chat-landing-harness-smart-routing"
>
{SMART_ROUTING_LABEL}
</SelectItem>
)}
{brainEntries.map(([id, label]) => (
<SelectItem key={id} value={id} data-testid={`new-chat-landing-harness-${id}`}>
<span className="flex items-center gap-2">
@@ -1792,7 +1880,11 @@ export function NewChatLandingScreen() {
// collapsed badge). OFF → the composer/picker fall back to the original
// "run omni setup" guidance, so a disabled flag is a no-op on the UI.
const harnessInstallEnabled = info !== "loading" && info.harness_install_enabled;
const brainHarnessLabels = useBrainHarnessLabels(smartRoutingEnabled);
// No "Auto" prepend: Smart Routing is offered as its own explicit option in
// each picker (the top-level harness section, the native Model dropdowns, and
// the bundle-agent Agent Harness dropdown), so the old "Auto" brain entry
// would be a redundant second way to say the same thing.
const brainHarnessLabels = useBrainHarnessLabels();
// Provider-named label for the sandbox option (e.g. "Modal Sandbox"),
// falling back to the generic "New Sandbox" when the server names no
// provider.
@@ -2240,9 +2332,71 @@ export function NewChatLandingScreen() {
const supportsApprovalMode = nativeAgentHasCapability(selectedAgent, "approvalMode");
const supportsCursorMode = nativeAgentHasCapability(selectedAgent, "cursorMode");
const hideUnconfiguredHarnesses = useMemo(() => readHideUnconfiguredHarnesses(), []);
// Smart Routing (per-session model selection) is superseded by the Auto
// harness which handles both harness + model. Hide it entirely for now.
const smartRoutingEligible = false;
// Smart Routing is offered as a Model choice on the selected agent, decided
// once at create (never per turn). Eligible for three kinds of agent:
// 1. Claude Code (claude-native) — gated on the host backing that family
// via the workspace AI gateway (the apply layer rewrites the model
// through it).
// 2. Codex (codex-native) — same, gated on its own family.
// 3. an in-process brain-harness bundle agent (polly / debby, claude-sdk):
// agent-specific, no native wrapper.
// Each native family gates on its OWN inference, so a host whose Claude Code
// runs off something else can still offer routing on Codex, and vice versa.
// The selected native harness (null for non-native agents), used both by the
// Smart Routing eligibility below and to persist/seed harness-specific knobs.
const selectedNativeHarness = nativeCodingAgentForAvailableAgent(selectedAgent)?.harness ?? null;
const selectedHost = allHosts.find((h) => h.host_id === selectedHostId);
// Warn-only readiness host: only meaningful when a connected host is selected
// (a sandbox provisions its own tooling); selection stays allowed either way.
const harnessWarningHost = !sandboxSelected ? selectedHost : undefined;
const smartRoutingEligible =
smartRoutingEnabled &&
((selectedNativeHarness === "claude-native" &&
hostBacksHarnessWithGateway(harnessWarningHost, "claude-native")) ||
(selectedNativeHarness === "codex-native" &&
hostBacksHarnessWithGateway(harnessWarningHost, "codex-native")) ||
// Bundle agent with an overridable brain harness (polly / debby); not a
// native wrapper. Routes at create via its claude-sdk family arms.
(selectedNativeHarness == null &&
selectedAgent?.harness != null &&
selectedAgent.harness in brainHarnessLabels));
// ── Top-level Smart Routing harness (its own section above Harnesses) ──────
// The native-wrapper agents the router picks between; both must be registered
// for the top-level row to route across them.
const smartRoutingWrappers = useMemo(() => {
const byHarness = (harness: string) =>
harnessEntries.find((a) => nativeCodingAgentForAvailableAgent(a)?.harness === harness);
return { claude: byHarness("claude-native"), codex: byHarness("codex-native") };
}, [harnessEntries]);
// Why the top-level row can't be offered, or null when it can. Needs routing
// on, BOTH wrappers registered, and the host backing BOTH families on the
// gateway (the five-arm menu routes across both).
const smartRoutingHarnessUnavailable = smartRoutingUnavailableReason({
routingEnabled: smartRoutingEnabled,
wrappersRegistered: smartRoutingWrappers.claude != null && smartRoutingWrappers.codex != null,
unreadyHarnesses: SMART_ROUTING_ARMS.filter((h) =>
harnessUnconfiguredOnHost(h, harnessWarningHost),
),
notGatewayBackedHarnesses: SMART_ROUTING_ARMS.filter(
(h) => !hostBacksHarnessWithGateway(harnessWarningHost, h),
),
});
const smartRoutingHarnessAvailable = smartRoutingHarnessUnavailable === null;
const smartRoutingHarnessSelected = pickedHarness === AUTO_NATIVE_HARNESS_ID;
// Pick top-level Smart Routing. Create needs a concrete agent_id, so bind the
// Claude wrapper as a placeholder — the server routes from the message and
// rebinds to the wrapper it picks (which is why the placeholder row's
// highlight is suppressed while this sentinel is active).
const handleSelectSmartRoutingHarness = useCallback(() => {
const placeholder = smartRoutingWrappers.claude;
if (placeholder == null) return;
setPickedAgentId(placeholder.id);
writeLastAgentId(placeholder.id);
setPickedHarness(AUTO_NATIVE_HARNESS_ID);
writeLastHarness(placeholder.id, AUTO_NATIVE_HARNESS_ID);
_setCostControlMode("on");
}, [smartRoutingWrappers]);
// Whether the gear config modal has anything to show for the selected agent
// (drives the gear icon's visibility). Bundle agents with an overridable
// brain harness qualify, as does any routing-eligible agent — Smart Routing
@@ -2282,8 +2436,9 @@ export function NewChatLandingScreen() {
{ label: "Permissions", value: permissionValue },
];
}
// Non-Claude routable agents surface Smart Routing as a standalone toggle,
// so reflect it here when on (Claude folds it into Model above).
// Bundle agents (polly / debby) surface Smart Routing as an Agent Harness
// option, so reflect it in the tooltip when on (the native harnesses fold
// it into their Model dropdown, handled above).
const routingRow: { label: string; value: string }[] =
smartRoutingEligible && routingOn ? [{ label: "Smart Routing", value: "On" }] : [];
if (supportsApprovalMode) {
@@ -2358,10 +2513,6 @@ export function NewChatLandingScreen() {
setBypassSandbox(false);
setCostControlMode(null);
}, [effectiveAgentId, setCostControlMode]);
// The selected native harness, used to persist/seed its option knobs (mode /
// model / effort), which are harness-specific. null for non-native agents,
// which have no knobs to remember.
const selectedNativeHarness = nativeCodingAgentForAvailableAgent(selectedAgent)?.harness ?? null;
// Seed the harness's knobs from the user's last picks when the selected
// harness changes (including the first mount), so a returning user starts a
// new session on the options they used last for that harness instead of the
@@ -2417,12 +2568,6 @@ export function NewChatLandingScreen() {
// (the runner injects the text verbatim), so the landing composer must
// not intercept them — no skills menu, no slash_command routing.
const isNativeTerminalAgent = isNativeCodingAgent(selectedAgent);
const selectedHost = allHosts.find((h) => h.host_id === selectedHostId);
// Warn-only readiness signal for the agent picker: only meaningful when
// a connected host is selected (a sandbox provisions its own tooling).
// Selection stays allowed — the host re-checks at launch and the create
// call surfaces a specific error if the harness really can't run.
const harnessWarningHost = !sandboxSelected ? selectedHost : undefined;
const selectedAgentUnconfigured = harnessUnconfiguredOnHost(
selectedAgent?.harness,
harnessWarningHost,
@@ -2803,7 +2948,13 @@ export function NewChatLandingScreen() {
// The trigger label is just the agent name; the run-config knobs live in
// the picker's per-entry submenu, so duplicating their values here would be
// redundant.
const agentLabel = selectedAgent ? selectedAgent.display_name : "Select agent";
// Top-level Smart Routing rides a placeholder wrapper agent, so show the
// feature name on the chip rather than the placeholder's display name.
const agentLabel = smartRoutingHarnessSelected
? SMART_ROUTING_LABEL
: selectedAgent
? selectedAgent.display_name
: "Select agent";
// Wrap the harness setter so every explicit pick is persisted to
// localStorage. The caller can pass an explicit `agentId` for the
@@ -2902,6 +3053,16 @@ export function NewChatLandingScreen() {
// straight to that dir, which also sidesteps the "branch already
// exists" guard.
const agent = agentList.find((a) => a.id === effectiveAgentId);
// Prepend each "@"-tagged path as an attachment marker on its own line —
// the same wording the native executors emit and that title-seeding
// strips. The runner, rooted at this workspace, reads the on-disk file
// from the marker; no upload happens. Folders carry a trailing "/".
// Computed up front because Smart Routing's `smart_routing_message` in
// the create body reads it — a later `const` would be in the temporal
// dead zone and throw when routing is on.
const initialPrompt =
buildMentionPreamble(mentionedItems, selectedAgent?.harness ?? null) +
sanitizeInitialPrompt(message);
const nativeAgent = nativeCodingAgentForAvailableAgent(agent);
const nativeLabels = nativeWrapperLabelsForAgent(agent);
const agentSupportsPermissionMode = nativeAgentHasCapability(agent, "permissionMode");
@@ -2989,13 +3150,17 @@ export function NewChatLandingScreen() {
: undefined,
}),
// Native-wrapper labels + codex bypass + the born-filed project
// label (see `createLabels` above).
labels: createLabels,
// label (see `createLabels` above). Top-level Smart Routing binds
// only a PLACEHOLDER wrapper, so its labels / launch args / model
// would describe a CLI the router may not pick — omit them; the
// server stamps the routed wrapper's labels once it rebinds.
labels: smartRoutingHarnessSelected ? undefined : createLabels,
// Permission / approval / cursor mode → CLI flag pair, persisted as
// terminal_launch_args. Omitted for the default and non-native agents.
terminal_launch_args:
agentSupportsPermissionMode &&
permissionMode !== CLAUDE_NATIVE_DEFAULT_PERMISSION_MODE
terminal_launch_args: smartRoutingHarnessSelected
? undefined
: agentSupportsPermissionMode &&
permissionMode !== CLAUDE_NATIVE_DEFAULT_PERMISSION_MODE
? ["--permission-mode", permissionMode]
: agentSupportsApprovalMode && approvalMode !== CODEX_NATIVE_DEFAULT_APPROVAL_MODE
? (CODEX_NATIVE_APPROVAL_MODES.find((m) => m.value === approvalMode)?.args ?? [])
@@ -3005,26 +3170,41 @@ export function NewChatLandingScreen() {
// Model + reasoning effort, persisted on the session row before
// the runner launches. Claude and Codex read model_override at
// terminal launch; an unselected ("") knob is omitted so the
// harness keeps its own configured/default model.
// harness keeps its own configured/default model. Omitted for
// top-level Smart Routing (the router owns the model).
model_override:
!smartRoutingHarnessSelected &&
(agentSupportsPermissionMode || nativeAgent?.harness === "codex-native") &&
pickedModel
? pickedModel
: undefined,
reasoning_effort:
agentSupportsPermissionMode && pickedEffort ? pickedEffort : undefined,
// Smart routing toggle — server-side. The "Auto" harness always
// routes (harness + model), so send "on" to keep the persisted
// state consistent with the lit routing icon. Otherwise only send
// it when routing is eligible for the effective harness, so a stale
// "on" can't ride along invisibly with no control to clear it.
cost_control_mode_override:
pickedHarness === AUTO_HARNESS_ID
? "on"
: smartRoutingEligible
? (costControlMode ?? undefined)
: undefined,
harness_override: pickedHarness ?? undefined,
!smartRoutingHarnessSelected && agentSupportsPermissionMode && pickedEffort
? pickedEffort
: undefined,
// Smart Routing (server-side) is on when the top-level Smart
// Routing harness is picked, or when routing is eligible + toggled
// on for the selected agent (a native harness's Smart Routing model
// pick, or a bundle agent's Smart Routing harness option — both set
// costControlMode="on"). Send "on" then; otherwise omit so a stale
// "on" can't ride along with no control to clear it.
cost_control_mode_override: smartRoutingHarnessSelected || routingOn ? "on" : undefined,
// Top-level Smart Routing sends the "auto" sentinel; the server
// tells it apart from a bundle-agent pick by the bound agent being
// a native wrapper, and routes both harness + model at create.
// A bundle agent with Smart Routing on (polly / debby, no explicit
// harness pick) must still name its own harness so the server's
// create-time routing fires — the router picks the model within
// that family, not a different harness.
harness_override: smartRoutingHarnessSelected
? AUTO_HARNESS_ID
: (pickedHarness ?? (routingOn ? (agent?.harness ?? undefined) : undefined)),
// Send the initial prompt whenever routing is on so the create-time
// router has the task to analyze — top-level Smart Routing, a
// native Smart Routing model pick, or a bundle agent's Smart
// Routing harness option all route at create.
smart_routing_message:
smartRoutingHarnessSelected || routingOn ? initialPrompt : undefined,
}),
});
if (!res.ok) {
@@ -3065,13 +3245,6 @@ export function NewChatLandingScreen() {
// loads from the session id and never reads the sidebar cache.
void queryClient.refetchQueries({ queryKey: ["conversations"] });
void queryClient.invalidateQueries({ queryKey: ["directory-sessions"] });
// Prepend each "@"-tagged path as an attachment marker on its own line —
// the same wording the native executors emit and that title-seeding
// strips. The runner, rooted at this workspace, reads the on-disk file
// from the marker; no upload happens. Folders carry a trailing "/".
const initialPrompt =
buildMentionPreamble(mentionedItems, selectedAgent?.harness ?? null) +
sanitizeInitialPrompt(message);
// A first message matching one of the agent's bundled skills is
// handed off as a structured invocation so ChatPage auto-sends it
// as a `slash_command` event (server resolves the skill) instead
@@ -3440,6 +3613,9 @@ export function NewChatLandingScreen() {
onSelectPending={handleSelectPending}
onCreateCustomAgent={() => setCreateAgentOpen(true)}
sandboxSelected={sandboxSelected}
autoHarnessAvailable={smartRoutingHarnessAvailable}
autoHarnessActive={smartRoutingHarnessSelected}
onSelectAutoHarness={handleSelectSmartRoutingHarness}
/>
{/* Gear opens the selected agent's run-config modal. Hidden
when the selected agent has no knobs to configure. Hovering