Smart Routing MVP: per-task model and harness routing (#4074)

* feat(telemetry): routing decision and setting-change events

Routing needs to be answerable after the fact: which arm the router
picked, whether it was applied, and what the user changed. Adds
``RoutingDecisionEvent`` and ``RoutingSettingChangedEvent`` plus a
``model_labels`` helper that reduces a model id to a family/tier pair, so
records stay useful without carrying raw model ids.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(sessions): persist routing decisions and session warnings

A routing decision has to survive the turn that produced it, so the UI
can show what the router chose and — crucially — whether it was actually
applied. Adds ``RoutingDecisionData`` to the conversation entity with
store support, and a ``session_warnings`` module for the non-fatal
routing conditions a session needs to surface (router unreachable,
verdict not applied) without failing the turn.

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(routing): session-start smart routing core

Adds the server-side routing core behind Smart Routing: an external
``task_v1`` route-options seam that offers the router the frozen arm menu
its scenario requires, maps a pick back onto a servable catalog id via
nearest-cost substitution, and derives the harness that can actually run
it. Routing settings become one value object on ``RuntimeCaps`` so every
consumer reads the same knobs instead of re-parsing config. Databricks
model discovery resolves catalog spellings deterministically so the same
endpoint is named the same way on every path.

Reconciled against main's catalog-driven routing:

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(server): route sessions at start and expose the decision

Wires the routing core into session lifecycle. A session created in
Smart Routing mode is routed once, at start, from the first user message:
the verdict picks the harness and the model before the runner launches,
and pre-launch host model options supply the candidate catalog when no
runner exists yet. Later turns never re-route — a session's harness is
settled once so a conversation cannot change identity underneath the
user.

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(claude): apply a routed model to Claude Code

A routed arm only matters if the harness actually runs it. Adds a Claude
model vocabulary that maps between router arm ids, catalog spellings, and
the ``/model`` names Claude Code accepts, and pins the CLI's family
aliases to the frozen task_v1 Claude arms at launch so the first turn's
switch can reach whatever the router picked.

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(codex): apply a routed model to Codex

The Codex side of the apply layer: the native app server and executor
accept a routed model override and enforce it on the session they launch,
so a verdict that names a GLM/Kimi delegate arm reaches the CLI instead
of being dropped for the harness default.

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(routing): route sub-agent spawns from harness hooks

Sub-agents spawned by a native CLI never pass through the server's
session-create path, so they were unroutable. Adds hook scripts the
Claude and Codex CLIs invoke at spawn time, plus a runner-side router
that answers them, so a spawned child is routed on its own task text and
launched on the chosen model.

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(web): surface routing decisions and Smart Routing controls

Adds the Smart Routing harness option to new-chat, a routing chip that
shows the routed model on the session, a sub-agent routing row, and a
warning banner for the non-fatal routing conditions the server reports.

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test(routing): cover the routing apply layer end to end

Adds the remaining routing coverage: the CLI's routing-client build, the
native Smart Routing create path, an end-to-end routing integration test,
and the discovery/override unit tests. Also updates the existing native
bridge, forwarder, and launch-arg tests for the model-override plumbing.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs(routing): record the routing design and verification state

Captures the plan the implementation followed, the per-CUJ verification
status, and the observed live-model state the harness bar list is derived
from — the gateway rejections that catalog metadata does not advertise.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: registry stamps — rebased-tree battery green, session-start verified live

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: re-sync CUJ walkthrough with the rebased tree

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(routing): offer Smart Routing only where the apply layer can work

Smart Routing rewrites a launch's model through the Databricks AI Gateway,
so a host whose claude-native or codex inference resolves anywhere else
(Bedrock, a plain API key, the vendor CLI's own login) got an option that
could never take effect. Gate each surface on the fact that decides it.

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs(routing): record the gateway-backed availability decision

Plan §10 gains decision 9 (Smart Routing offered only where the apply
layer can work, with the per-surface rule and the absent-means-unknown
compatibility contract), and §8 gains the two follow-ups it defers: a
liveness probe, and moving the routes:select call host-side so routing
auth/workspace always matches the host's inference.

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: rewrite the CUJ walkthrough in simplified technical English

Rewrite designs/CUJ_IMPLEMENTATION.md in ASD-STE100-inspired Simplified
Technical English so every sentence parses one way only: active voice with a
named actor, simple tenses, one statement per sentence, noun clusters of at
most three words, and lists for any sequence of three or more steps. Add a
six-term glossary (arm, seam, pane, rollout, canary, spelling) to the intro.
Remove the hard 80-column wrapping so each paragraph is one soft-wrapped line.

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: stamp the gateway-inference positive half

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: keep the routing design docs local-only

The four routing design documents (plan, test registry, CUJ walkthrough,
live model state) stay on disk for local reference but leave version
control — they are working notes, not reviewable deliverables.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(routing): serve turn routing the launch-exact claude vocabulary

Two claude-path defects from the live verification round.

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(routing): no substitution arrow for prefix-only subagent raw picks

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): float the session warning banner over the chat

The session warning strip rendered in-flow between the chat header and
<main>, so a warning arriving mid-session pushed the whole conversation
down. Render it as an overlay instead, on the same positioning contract
as the chat header: anchored inside the chat column, below the header,
stopping short of the workspace panel via --workspace-panel-offset, and
transparent to pointer events outside its own rows so the chat stays
scrollable. Multiple warnings stack downward inside the overlay.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(routing): gate the codex canary check on a real turn, clear it per launch

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

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(routing): clear the codex spawn audit per launch too

Same staleness class as the canary (51e36c8c): the audit is reconciled
against the routing decisions *this* launch's endpoint relayed, so a line
left by a previous launch — whose approving decision lives in that
launch's router — reads as a spawn the router never approved. The
per-workspace bridge dir is reused across launches, so `clear_bridge_state`
now drops the audit alongside the canary.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(routing): apply the glm arm under the gateway's model route

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: track the routing design docs again

Re-adds the plan (with the decision log), the test registry, the
enumerated CUJ walkthrough, and the codex model-state notes, all
current as of the post-verification state.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(routing): route the model at create time for a fixed native harness

A native terminal launches with the session row and its turns originate in
the TUI, so the server never sees the first message pre-inference — the turn
gate that routes a plain claude/codex session never fires for a CLI-driven
one. Create-time routing existed only on the `harness_override: "auto"` path,
which picks harness AND model.

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(cli): route the model (and harness) before a native TUI launch

Smart Routing was web-only: a CLI user who wanted the server to pick a
model had to start the session in the browser. Add the two launch surfaces
Bryan asked for, both of which route *before* anything starts — the harness
pick is physical (a session is a live claude/codex process) and the model is
applied as a launch flag, so there is nothing to change after the fact.

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

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

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(cli): resolve the claude agent name from harness_plugins on this branch

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: PR rewrite plan — cut list, commit series, CLI integration

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* chore: track the isolated dev-stack scripts the test registry references

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: cover the glm gateway-route fix

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: cover the CLI smart-routing entry points

`omnigent claude|codex --smart-routing -p` (tier 2) and `omnigent run
--smart-routing -p` (tier 3) were undocumented. Record the fourth surface:
CUJ_IMPLEMENTATION.md gains §6 (commands and tiers, prompt delivery,
preflight, the create-time MODEL route for a fixed native harness, the
create the CLI drives, rejected combinations, the routed launch, decision
persistence, and the agent-name import fix), and known-open moves to §7.

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: track the PR review fix list (rounds 1-2, all items addressed)

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: high-level routing system map for slimming iteration

Add designs/ROUTING_OVERVIEW.md: a one-altitude map of the Smart Routing
feature — the four user journeys, the fifteen subsystems with size and
rewrite fate, the invariants that must survive any cut, and the five open
decisions. Written in ASD-STE100 style with block IDs so the slimming
pass can cut and keep by reference.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: fold Bryan's critique decisions into the rewrite plan

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: fold the model-resolution rulings into the plans; STE pass on the rewrite plan

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: finish the STE pass, restructure 3i to the three rulings, pin the fallback-id assumptions

Reconciles the fold-agent's late completion (it amended 0baeea1c
locally; this lands the same tree as a follow-up commit instead of a
force-push). The whole plan now meets the STE caps, 3i lists Bryan's
three rulings as ruled (pi had been displaced by a mechanism bullet),
and the open-assumption list grows to three: glm declines with no
fallback; terra is today only a pi-exclusion entry, so the code must
add it as a servable target; sonnet pins to databricks-claude-sonnet-5.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: luna is the gpt+glm fallback, sonnet follows the alias pin; add verification criteria (6c-6e)

Bryan's final fallback rulings (2026-08-01): the gpt and glm families
both fall back to luna (databricks-gpt-5-6-luna, itself a frozen arm,
so a glm fallback never leaves the codex harness), and the claude
fallback is whatever the sonnet alias pin resolves to rather than a
hardcoded id. Terra is out; glm no longer declines. No open
assumptions remain in the plan.

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: switch the plan to a from-scratch rewrite (7g)

Bryan chose a complete rewrite from scratch (2026-08-02) to keep the
new code as clean as possible, reversing the plan's earlier 'assemble,
do not re-implement' constraint.

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: request-time managed flag, parallel wave plan, and four scope reversals

Bryan's review of the rewrite plan (2026-08-02) produced five changes.

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

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

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: make the rewrite plan readable without session context

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: clear the last session-only references from the plan

3g was still written as "rewrite, not transplant" against a suite the
fleet never sees, and it cited a commit's method rather than a rule.
It now states the rule directly: start from the behavior inventory in
CUJ_STATUS.md section 2, one test per behavior, coverage as the gate.
The reference suite is described as what not to copy and why.

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: close the cold-read audit's blockers on the rewrite plan

A subagent with no context from this session read the plan as an
executor would and found that its load-bearing inputs are unreachable
from the branch it tells you to start on. Confirmed and fixed.

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

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: add LOCAL_SETUP.md; drop the stray npm lockfile

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

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: record the personal CLI setup and the provider topology

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

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

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

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* Trim routing PR: cut enforcement/telemetry/machinery, fix GLM effort + blank page

Wave-1 trim of the routing reference implementation, plus two live-caught
bug fixes and test trims from a parallel cleanup pass.

Cuts (per designs/PR_REWRITE_PLAN.md §3):
- Enforcement stack: canary, watcher, spawn-audit, warning banner,
  session_warnings (3b). Hook generation + trust handshake kept.
- Routing telemetry: telemetry/routing.py, model_labels.py (3e).
- Fork-spawn exemption from the hook script (3d).
- Model-resolution machinery in smart_routing.py: MODEL_LISTS cost-ladder
  (_cost_position, _ARM_SUBSTITUTES) replaced by a fixed per-family
  fallback (claude->sonnet, gpt/glm->luna) + honest decline (3i). The
  static infer_models catalog is kept: subagent_routing.py consumes it.

Fixes:
- GLM reasoning effort: GLM rejects xhigh; a routed GLM codex turn now
  clamps effort to medium at every config-write and thread-settings point
  (clamp_effort_for_model / effort_for_model_switch). Locked down in
  tests/test_reasoning_effort.py.
- Blank-page crash: chipPendingBeforeRegion indexed past a shortened block
  array on a stale cache (session switch / history reload), reading
  undefined.type and unmounting ChatPage. Guarded + regression-tested.

Tests trimmed to the surviving surface; suites collect clean (2277) and
the core routing sets pass (266).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* Remove unused `act` import left by the warning-banner test cut

The enforcement/banner cut removed the AppShell test cases that used
`act`, but left the import — oxlint (a pre-commit + CI gate) fails on it.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* Substitute an unservable arm within its model tier before the family fallback

task_v1's frozen arms name a model *tier* (claude-opus-4-8 is the opus tier,
gpt-5-6-sol the sol tier), not a specific servable id. When the workspace
serves a different model of the same tier — claude-opus-5 for a
claude-opus-4-8 pick — that model is the arm the router meant, so
substitute_model now applies it (highest version within the tier) ahead of the
family fallback. Only when no same-tier model is servable does it fall to the
per-family fallback, then decline. Still no cost walk: an unservable pick never
slides down to a cheaper tier.

Adds _model_tier (the id's last alphabetic segment, None for a bare generation
id like gpt-5-5) and _version_key (numeric version, higher = newer) to rank
within a tier.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* Route unnamed codex subagent spawns on a placeholder instead of inheriting

Codex encrypts the spawn message, so an unnamed codex spawn carries no prompt
to route on. It previously fell through to allow-on-the-parent-model ("No
routable signal … inherits the session model"). Route it on a fixed
"Codex subagent task" placeholder instead, so it lands on the router's floor
arm rather than the parent's possibly-expensive model — matching ucode PR 251's
default_task_label. Precedence is unchanged: a real prompt (claude) wins, then
task_name/agent_name, then the placeholder.

Tradeoff, recorded honestly: every unnamed spawn scores the same placeholder
and so gets the same floor arm — a cheap sensible default, not per-spawn
routing. A named spawn still routes on its task_name; empirically that field
has been null on every observed codex spawn, so the placeholder is the whole
fix in practice. Per-prompt codex subagent routing is not reachable while the
message is encrypted.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: design plan for in-harness first-message routing (follow-up)

Route the main agent's model on the FIRST real user message via a
UserPromptSubmit hook + loopback callback (the route-subagent pattern),
so a bare `omni codex` / `omni claude` launch still routes, and web UI
and TUI share one mechanism. Marker = conv.model_override (authoritative,
existing cadence semantics) + a bridge-dir fast-skip file. Apply reuses
the verified composer forward path: thread/settings/update-then-turn/start
for codex, locked /model-injection-then-send-keys for claude
(block-and-replay). Cross-harness selection stays outside; create-time
routing stays for prompt-ful launches and composes via the marker.

Grounded in LIVE_MODEL_STATE.md probes and the official Claude Code hook
docs (block erases the prompt and injected input then proceeds; no hook
output can change the model; 30s synchronous timeout). Four spikes
ordered before any product code. Not part of the trim PR.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: record the conservative ruling on in-harness routing

Bryan's decision (2026-08-03): keep both paths. The server/create-time
path is the UI path and stays as the primary; the in-harness hook is
additive, covering only what the server cannot see (a prompt typed into
the TUI on a bare launch). One decision seam, three triggers, arbitrated
by model_override so exactly one fires per session. The outside path also
stays because it shares route_session_harness with cross-harness
selection - it is the cross-harness code, not a parallel implementation.

The maximal collapse (hook as sole trigger, CLI tier-2 entry machinery
deleted) is recorded as a deferred phase gated on determinism evidence
from the spikes plus live use, requiring an explicit go.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* Point a routed spawn at a tool the session actually has, and say why

The redirect told the model to "Use sys_session_send with args.harness=,
args.model=" — parameters that do not exist on the tool it holds. Those are
sys_session_send's named-spawn mode, which ToolManager only advertises for a
spec with declared sub-agents; the native harnesses declare none, so their
send tool exposes only {args, session_id} and the instruction was
unfollowable. Matrix row A-sub recorded the result: the model read the deny
and abandoned the spawn.

Name sys_session_create instead, which a spawn:True harness does hold (both
claude-native and codex-native set it) and whose schema really does take
model, message, and agent_id. Lead with the user's own choice to enable Smart
Routing and state that the sub-task is approved, so the deny reads as an
authorized re-route rather than a refusal, and close with the concrete call to
make. The same instruction now backs the deny branch when the verdict names a
model, instead of a bare "Spawn denied by Omnigent smart routing."

The redirect tests assert the properties that matter — denies, names the
routed model, names sys_session_create, never names sys_session_send — rather
than pinning the prose.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: register the bundle-agent and GLM-subagent CUJs (2.11, 2.12)

Two new surfaces enter the registry per Bryan. 2.11: Smart Routing on
bundle agents (debby/polly) reaches routing only through the gear
config's brain-harness override - a different code path from the native
Model row, previously untested; rows cover the menu render, the right
model/harness selection, and the live apply. 2.12: codex GLM subagents,
which ucode PR 251 explicitly skips; rows track the three blockers
(static candidates, placeholder floor-arm, and the effort wall) with
the sys_session_create child path recorded as already working.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): keep the bundle-agent harness row visible under Smart Routing

Two bugs in the debby/polly gear-config flow when Smart Routing is picked
as the brain harness:

- Picking Smart Routing unmounted the Agent Harness dropdown that made the
  pick (it was gated on !autoRouting), leaving a lone locked Permissions
  row with no way to read the pick back or switch away without Cancel.
  The row now stays rendered, ordered above Permissions, and the gear
  tooltip mirrors both rows.
- A remembered fully-auto pick had no degrade path when the server turns
  smart routing off: the modal showed a blank harness select while the
  create still sent harness_override "auto". The bundle flavor now drops
  the pick quietly, matching the top-level auto-native rule, and keeps the
  stored pick in case routing returns.

Adds 15 vitest cases on real debby/polly (claude-sdk) fixtures covering
menu shape, pick persistence, payloads, per-agent memory, and the
degrade; updates the one existing test that encoded the unmount bug.
NewChatDialog.test.tsx 228/228; shell suite 1754 pass; tsc/oxlint/
prettier clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: flip the §2.11 bundle-agent rows to vitest-backed

The gear-config menu bugs are fixed and covered (1f99705f); the two render
rows move to 🟡 pending a user eyeball, and the first-turn row records the
payload half as vitest-verified with the live end-to-end still owed.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: record the spawn-family policy in the GLM-subagent CUJ section

Subagent spawns stay within the parent harness family; GLM is
codex-family (all codex subagents may spawn gpt and glm arms when smart
routing is on); the auto harness alone spawns cross-family.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: let codex sessions spawn GLM subagents

GLM belongs to the codex spawn family: with smart routing on, every
codex spawn may target both the gpt arms and glm-5-2 (the auto harness
alone spawns cross-family; claude parents stay claude-only). Three
layers had to move:

- Catalog: databricks-glm-5-2 joins _CURRENT_GENERATION_MODELS[gpt], so
  infer_models offers it and a routed glm pick resolves exactly instead
  of substituting down to luna (this also removes the create-path C1
  substitution arrow). Since no discovery listing ever advertises glm, a
  live catalog row would still hide it — candidate_models now tops up
  known-unadvertised arms for the gpt family only, nested spawns
  included, without widening multi-model harnesses like pi.
- Vocabulary: codex's spawn_agent validates model ids client-side
  against a closed enum of its own slugs, which silently killed EVERY
  catalog-id rewrite, not just glm. New codex_model_vocabulary maps
  catalog ids to codex slugs (databricks-gpt-5-6-luna -> gpt-5.6-luna)
  and clamps spawn effort in agreement with clamp_effort_for_model; the
  router hook rewrites through it and falls open when no slug exists.
- Catalog file: glm has no codex slug at all, so the executor reads the
  installed CLI's own catalog (codex debug models, cached per binary and
  CODEX_HOME per host process) and writes the session's private
  model_catalog_json with a glm entry cloned from the cheapest arm,
  carrying its own low/medium/high effort ladder — codex then clamps an
  inherited xhigh instead of refusing the spawn. Every failure path
  leaves codex on its bundled catalog.

Live-proven on the local stack: a native spawn_agent glm subagent off an
xhigh codex parent ran at system.ai.glm-5-2/medium and completed, with a
luna sibling in the same turn unaffected. Family policy pinned by tests
in both directions and both modes.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: give codex spawn routing a real signal and honor explicit asks

Live verification exposed that no codex spawn could ever land glm even
with it offered: this codex's spawn_agent has no task-name field, the
spawn message was withheld from the router on a disproven encryption
premise, and an explicit model in the spawn arguments was overridden by
the placeholder-scored default. Every spawn therefore routed on the
19-char placeholder and landed the default arm (verified live: three
spawns, including one explicitly asking for system.ai.glm-5-2, all ran
gpt-5.6-sol).

- The codex hook now forwards the spawn message (plaintext in hook
  payloads — measured) as the routing prompt via a new prompt_keys seam,
  so the router scores the actual task and can pick delegate arms.
- The hook also forwards an explicit spawn model as requested_model. The
  server honors the ask when it is an arm the spawn's own harness could
  have been routed to (bare-arm match, so any spelling lands the
  servable one); a cross-family or unoffered ask is routed over and
  recorded truthfully as attempted_override. The honor is restricted to
  the requesting harness's candidate row because a rewrite runs
  in-place — an auto-harness session must not hand codex a claude arm.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: carry requested_model across the runner relay hop

The relay resolver rebuilds the route-subagent body field by field, so
the new requested_model never reached the server: live, a spawn that
explicitly asked for system.ai.glm-5-2 was routed to luna with no
attempted_override recorded. The relay test now pins every routing
input surviving the hop.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: close the §2.12 GLM-subagent rows with live evidence

All four layers verified on the shipping path 2026-08-04: glm in the
live spawn menus, exact in-family resolution, and a live glm subagent
(turn_context system.ai.glm-5-2/medium off an xhigh parent). Records the
two extra layers live testing surfaced: message-as-signal (spawn_agent
has no task-name field here) and honoring explicit in-family model asks.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): scope a bundle agent's Smart Routing brain to that agent

Picking Smart Routing as Debby/Polly's brain-harness renamed the whole
composer selection — chip, tooltip, and modal title all flipped to
"Smart Routing" as if the top-level auto harness had been picked, and
re-clicking the agent's own row silently dropped the brain. The two
flavors share no state (auto vs auto-native sentinels, per-agent
memory), but the derived autoRoutingSelected union was used for
identity, not just row gating.

Identity readers (agentLabel, triggerTooltip, configSummary, modal
title) now key on smartRoutingHarnessSelected alone; the union keeps
its one honest reader (the routing-seed skip) and a comment stating the
rule. The bundle modal shows the Agent Harness row alone (locked
Permissions belongs to the top-level flavor whose creates actually send
permission fields), the permission-reset effect and handleSelectAgent
key on the top-level sentinel only, and create payloads are
byte-identical in all four flavor combinations.

Tests: 292 pass across the three NewChatDialog suites — includes a new
"Smart Routing flavors are scoped separately" describe (mixed fixture)
pinning both leak directions, plain-create isolation, and the brain
surviving a re-pick; the old chip test that encoded the leak now pins
the fix; the two locked-Permissions tests moved to the top-level
flavor's describe. tsc/oxlint/prettier clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: add CUJ_MASTER.md, the consolidated routing CUJ registry

One doc merging the full CUJ_STATUS registry (matrix, recipes, tiers,
all section areas), the v4 in-harness routing phases (phase 1 landed
with evidence; phase 2 blockers), tonight's six live-feedback rows, and
a new adversarial section: 23 Breakage CUJs (X1-X23) grounding how this
setup fails for other people — missing/old CLIs, non-AIGW credentials,
router timeouts vs the hook ladder, hook-merge precedence, shared
bridge roots across worktrees, and the static glm fallback offering an
arm a workspace may not serve. Includes stack bring-up with a pinned
random-port convention, the R11 bare-launch recipe, a 112-row registry,
and a revisit list split by needs-human vs headless.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): honest subagent-routing display — fresh reads and gated chips

The gear modal's Subagent routing row could show Inherit while "on"
was stored: the override hydrates only at session bind (no SSE event
carries it, the session query never refetches), and the modal seeded
its draft once per open — so the row displayed a stale value and Save
could PATCH a value the user never picked. The row now holds a pick
that reads through to the live store value until touched, save() writes
only a pick that still differs from a fresh store read, opening the
gear re-reads the two override switches (refreshSessionOverrides — slim
snapshot only, so it cannot trigger the sticky-model PATCH), and a
session switch under an open modal re-seeds instead of writing the old
session's drafts onto the new one.

Per the user's ruling, native_subagent routing chips now render only
when the override is explicitly "on": on Inherit (or off) the chip
would advertise a setting the user didn't choose. Display gate only —
the decision rows stay persisted as the audit trail, and an inheriting
session's spawns are still routed server-side. Flip-side caveat,
deliberate: toggling the setting retro-hides/reveals historical chips.

453 tests pass across the three touched suites (display/write matrix,
stale-under-open-modal regression proven failing pre-fix, chip-gate
scope table); tsc/oxlint/prettier clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test(web): unit-cover the sub-agent routing chip gate

Pins stripGatedSubagentRoutingChips at the unit level alongside the
composer-level coverage: explicit "on" keeps spawn chips, Inherit hides
them while the session's own (and legacy scope-less) decisions stay.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: gate Smart Routing per harness on AI-Gateway backing

A harness whose CLI runs off a personal subscription (ChatGPT codex,
Bedrock claude) cannot run a routed pick — routing rewrites the launch
model to a gateway catalog id. Verified across four mocked credential
states (neither/claude-only/codex-only/both backed) and closed the
holes where routing could still be reached:

- gateway_inference: gateway_inference_state / not_gateway_backed read
  a host's reported map under any harness spelling; unknown (older
  host, unevaluable family) never gates.
- server create: the auto path refuses to route when either arm is
  unbacked (no safe half-menu — the pick lands after the create
  commits), and an explicit routing-on create pinned to an unbacked
  native harness 400s with the way out named, instead of minting a
  session whose routing silently never applies. Children and subagent
  sessions stay with their parents' spawn/turn gates.
- CLI preflight: --smart-routing consulted only the server's host row
  and silently proceeded when no host had registered — pinning a
  databricks model onto a ChatGPT-backed pane. The launch always runs
  on this machine, so the local gateway-inference map is now the
  authoritative first gate, with the host row as fallback; the two
  failure modes get distinct messages (no routing model configured vs
  not AI-Gateway-backed).

328 tests pass across the CLI/gateway/create/routing suites, including
a parametrized A-D truth table over both arms and the auto route.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): require gateway backing for the bundle-agent Smart Routing brain

The Debby/Polly Agent Harness menu offered Smart Routing whenever the
server flag was on, even when this host backs only one model family
with the AI Gateway — the router could then land the session's work on
an arm that cannot run its routed model (a codex pane on a ChatGPT
subscription). The auto option now requires both families
gateway-backed, mirroring the server-side create gate. Gateway backing
only: unlike the top-level harness row, the bundle brain routes across
SDK harnesses, so native wrappers/CLIs are deliberately not required.
The gate drops only the OPTIONS entry — membership checks and the
summary label for an existing pick keep the unfiltered map, so a saved
pick still reads back honestly.

235 NewChatDialog tests pass, including the new offers/hides matrix per
gateway state; tsc/oxlint/prettier clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: make cross-harness spawn redirects actionable in native sessions

An auto-harness claude session's redirected spawn was denied with an
instruction naming sys_session_create — a tool the model could not find
(claude spells MCP tools mcp__omnigent__<tool>, schemas are deferred
behind tool search, and no allowlist pre-approved them), so it treated
the deny reason as prompt injection and refused. The omnigent MCP was
attached all along; the actuation was unreachable.

- The deny/redirect reason now names the requesting harness's own
  spelling (claude: mcp__omnigent__sys_session_create; codex: the bare
  name plus its omnigent.<tool> display form — verified empirically
  against codex-cli 0.145: the flattened omnigentsys_session_create is
  log-only and not callable), notes the tools come from the attached
  omnigent server and may need a tool search, and degrades gracefully —
  when the session's relay does not advertise the spawn tool, it tells
  the model to do the sub-task itself instead of naming a tool that is
  not there.
- Auto-harness claude launches (label or harness_override 'auto', both
  metadata loaders) add --append-system-prompt with the routing note and
  an --allowedTools list of the four redirect-loop tools
  (sys_session_create/sys_agent_list/sys_session_send/sys_read_inbox —
  the inbox read was live-proven required to close the loop); pinned
  launches stay byte-identical, pinned sessions never see redirects.
- Auto-harness codex launches get the note as developer_instructions
  (through the reversible sidecar sync) and per-tool
  approval_mode=approve tables in the generated mcp_servers section.

Live-proven on the incident's exact shape: auto-harness claude parent,
spawn redirected to gpt-5-6-sol/codex-native, model called
sys_agent_list then sys_session_create, child session created on the
codex arm with parent linkage, result returned via the inbox, parent
reported it. Control session (pinned) carried neither flag. 229 tests
pass across the five touched suites.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: record the e2e sweep's evidence across the CUJ_MASTER registry

Overnight sweep on both stacks: 9/9 create matrix exact (the C1 glm
arrow is gone), GLM subagent rows live-proven including the effort
clamp firing, cross-harness redirect actuation end to end, codex
bare-launch 8/8 including crash durability, gating row 65 closed live,
1,627 pytest + 1,446 vitest with only the two accepted baseline
failures. Registry corrections from false greens the sweep caught:
deleting the routing block does not disable routing (only
provider:none does), the audit/canary rows are unreproducible since the
machinery was cut, the turn-path fail-open is silent, and several
recipe spellings fixed.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: switch claude models via the picker, never the global-default arg form

Every routed claude-native switch (and the web model picker) typed
'/model <arg>' + Enter into the pane — claude's arg form saves that
model as the user's GLOBAL default in ~/.claude/settings.json, caught
live rewriting the file during the e2e sweep. Ported the v4 actuator:
inject_model_selection submits bare /model, polls for the picker, walks
the cursor onto the target row, and presses 's' (session-only — proven
to leave the file byte-identical; Enter and digit keys both save the
default and are never sent), resolving exact catalog-id matches across
all rows before any alias match so a workspace serving two generations
of one tier lands the right row. auto_confirm's fixed 0.3s sleep is
now a dialog poll with a deadline.

The web path needed more than the executor's targets, caught live: the
picker dropdown sends tier ids, and this workspace serves two Opus
generations — 'opus' alias-matched the wrong row and the custom slot
(labelled by display name) was unreachable. Targets now come from the
session's resolved launch-config env (alias pins + custom slot + slot
name) merged under the bridge record; both cases verified live
('opus' -> Opus 4.8, the custom tier -> Opus 5, each session-only).

Live proof on the running stack, no restart (runners spawn per session
from disk): a routed opus-5 -> sonnet-5 switch and two web switches,
panes showing bare /model + 'for this session only', zero 'saved as
your default' lines in full scrollback, and ~/.claude/settings.json
md5-identical throughout. 640 tests pass across the seven touched
suites, including a tripwire that fails if the arg form ever returns.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: let a spec hand its brain harness to Smart Routing

A spec that pins executor.config.harness also pins the family its
sub-agents are routed within, so a two-headed agent loses the head that
lives in the other family: debby's `gpt` sub-agent, declared on codex,
was rerouted onto claude-sdk and both heads answered as Claude.

Add executor.config.smart_routing_harness: auto, which opts a spec out of
its own pin for a Smart Routing session and converges on the "auto"
sentinel path the brain-harness picker already offers by hand. Gated to
Smart Routing creates only, and never over a client's explicit harness or
model pick, so a spec carrying the key is inert with routing off.

Set it on debby and polly, whose sub-agents span harness families.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: two-state subagent routing, stamped at create — Inherit is gone

Per the user's ruling: a session that starts with Smart Routing routes
the subagents it spawns; everything else is Default, meaning whatever
the harness natively does. The tri-state inherit (unset resolving to
the session's own cost-control state) produced displays the user never
picked and a chip gate that disagreed with behavior.

- subagent_routing_enabled is now exactly override == "on"; the spawn
  gate reads one explicit switch instead of re-deriving parent state.
- The server create handler stamps "on" once, for every path that
  starts routed: top-level auto harness, bundle-agent auto brain, fixed
  native harness with routing on, CLI --smart-routing (including v4's
  bare in-harness creates, which send cost_control on), and children of
  a routed parent. Unrouted creates store nothing; an explicit caller
  value always wins; only "on" is ever stamped so ordinary creates
  cost no extra write.
- One-time data migration stamps "on" onto existing rows exactly
  where the old inherit rule resolved to routed (146 of 158 live rows),
  so sessions in flight keep routing their spawns across the deploy;
  downgrade is a documented no-op.
- The gear row offers exactly two options — Smart Routing / Default —
  reading through to the stored value; a legacy null displays Default
  and re-picking it writes nothing. PATCH keeps accepting explicit null
  as an API-level clear; the UI never sends it. The chip gate's logic
  is unchanged and is now an exact mirror of behavior.

181 python + 642 web tests pass across the touched suites (stamp
matrix, migration up/down, two-option UI, PATCH back-compat);
tsc/oxlint/prettier and ruff clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: the router always decides a requested-model spawn — honor only on match

A spawn naming a model bypassed routing entirely ('honored — it is a
routable arm'), so the parent model's habit of writing a model field
starved the delegate arms: a dry-run subtask that the router scores to
glm ran on sol because the router was never asked. Per the user's
ruling, the requested model never short-circuits: the router is always
called, 'honored' appears only when its pick matches the ask (bare-id
normalized, [1m] folded), and a mismatch applies the router's pick with
the ask recorded as attempted_override — struck through on the chip
next to the applied model — and named in the codex parent's notice so
it does not silently re-spawn.

Claude-side asks now resolve through the session's alias pins before
comparison (a bare 'opus' never matched its own pinned arm and logged a
spurious override on every named spawn); inherit/default sentinels
carry no ask. The sys_session_send path's raw string compare gets the
same normalizer (a servable-alias respelling is not an override). On
router outage the spawn still runs on the ask (fail-open unchanged)
and the record now says so.

Accepted cost, signed off: an explicit ask — including a user-authored
'use glm' — is honored only when the router independently lands the
same arm; task_v1 exposes no requested-model input (live-probed: config
hints ignored, narrowed menus rejected). Follow-ups if wanted: a
requested_model field in the routing proto, or a session-level pin.

197 python + 40 web tests across the touched suites; live-probed
against the real router with match, mismatch, and no-ask shapes.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: session Smart Routing is a create-time choice; the gear keeps one knob

Custom/SDK agents (Polly, Debby, and any non-native agent session)
lose the in-session Smart Routing toggle. It was already a near-no-op
for the session's own turns — the first routed turn pins
model_override, after which the toggle changed nothing — and its only
live effect was gating child spawns through a field the visible
Subagent routing row did not control. Per the user's ruling, Smart
Routing for a session's own turns happens once, at session start.

The Subagent routing row (identical copy, options, and testids to
native sessions) is now the single in-session routing control, and the
three server-side child-spawn gates (_force_auto_for_child, the SDK and
native parent-routing turn gates) plus the child create-stamp's parent
clause read the subagent-routing switch instead of parent cost-control.
Behavior-identical for every existing row via the create-stamp and the
e6f7a8b9c0d1 backfill (live DB verified: zero stranded cc-on/sr-unset
rows) — and picking Default now genuinely stops a bundle's spawns from
being routed, which the old pair of knobs never delivered.
isSubagentRoutingSession widens to all non-native top-level agent
sessions (their spawns go through the create path, which is
harness-independent), closing the pi-brain gap where the row vanished
mid-session. The gear tooltip drops its standalone Smart Routing line,
matching native.

189 python + 293 web tests across the touched suites, including
gate-flip cases proven to fail against the reverted server edits; full
web suite unchanged at 5005 passing.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* spike: codex UserPromptSubmit routing probe (S1/S2 scaffolding)

A marker-gated spike-userprompt subcommand on the codex policy hook:
logs every UserPromptSubmit payload to the bridge dir, and (behind a
one-shot marker file) fires thread/settings/update on the live thread
via the app-server websocket, optionally blocking the prompt. Inert
without the marker files. Kept as the working reference for the real
route-turn hook: the ws:// client framing, the second-command-per-event
wiring, and the trusted-module trick are all proven here.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: record the spike verdicts - Variant B disproven, Variant A verified

S1 FAIL, 3 runs with a bogus-model positive control: codex binds the
turn model at turn/start and writes turn_context before UserPromptSubmit
runs, so an in-window thread/settings/update only lands on the NEXT
turn. Variant A (block -> settings update -> replay) was then verified
end-to-end on codex: clean 1.08s abort, routed turn_context on the
replay, re-entrancy marker held, and the forwarder self-pins
model_override off thread_settings_applied.

S2 PASS: UserPromptSubmit fires for turn/start RPC turns with payloads
byte-identical to TUI-typed input; payload carries prompt + LIVE model
+ codex thread id (not the omnigent session id). S4 PASS: full hook
chain 0.37-0.78s; the settings call 26-77ms, wide margin under the 30s
budget. New trap recorded: never read the live model from config.toml
(stale on every read during the spike); take it from the hook payload.
S3 (claude block-and-replay UX) is the only spike still open.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* spike: claude UserPromptSubmit block-and-replay probe (S3 scaffolding)

Marker-gated spike-userprompt subcommand on the claude policy hook plus a
second UserPromptSubmit command in the bridge's settings generation. Inert
without the marker file. Kept as the working reference for the real
route-turn hook on claude: it is what proved the block leaves a clean
slate and the bracketed-paste replay is byte-exact.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: S3 passes - claude block-and-replay verified, all spikes closed

Block is cleaner than documented: input erased, reason shown, and nothing
persists (transcript logs only an informational preventContinuation row -
no user row, no model call; the omnigent conversation records nothing for
the blocked prompt). Replay is byte-exact including a real multi-line
prompt, submitted as one turn by the existing bracketed-paste injector.
The replay's fresh UserPromptSubmit no-ops on the consumed marker, and
/model does not fire UserPromptSubmit so the switch cannot self-trigger.
Three routed turns landed three different arms. Visible gap ~3-4s, the
/model settle dominating. No turn-2 fallback needed.

Records the actuator spec (poll for the Switch model? dialog, settle on
context.json - never fixed sleeps) and four claude-specific findings: the
hook payload has no model field, /model <arg> rewrites the user's GLOBAL
default (product blocker for the actuator, needs a decision), the /model
echo can make a weak model refuse the replayed prompt, and this
deployment's /model vocabulary is full catalog ids rather than bare
aliases. Also flags a pre-existing defect that bites the current branch
independently: inject_slash_command(auto_confirm=True) confirms the switch
dialog after a fixed 0.3s sleep, but the dialog took 1.861s with cached
history - the Enter is dropped and the next injection times out.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: in-harness first-message routing for codex (phase 1)

A bare 'omni codex' launch now routes on its first prompt, wherever that
prompt comes from (TUI-typed or RPC-delivered) — the spike-verified
block-and-replay variant, productionized:

- omnigent/runner/turn_routing.py: the decision seam (wire types, the
  route-once policy, loopback relay with advertisement + live-pid check,
  and the runner-side replay that waits on the hook's done-marker and the
  blocked turn clearing before redelivering through the normal events
  path, which re-checks the gate and records no second decision).
- codex hook 'route-turn' subcommand: fast-skip on the marker, POST to
  the loopback, thread/settings/update + config mirror, then block.
- POST /v1/sessions/{id}/hooks/route-turn mirroring route-subagent,
  reusing route_turn / catalog / decision-chip plumbing.
- Registered as a second UserPromptSubmit command in the trusted policy
  hook module; started/torn down beside the subagent router at launch.
- write_advertisement/read_router_endpoint gain a filename kwarg so the
  loopback plumbing is shared with subagent routing, not copied.

The route-once gate is the routing-decision label, not model_override:
the codex forwarder mirrors config.toml's stale model into
model_override at the first turn/started, beating the hook, so presence
can't distinguish a real pin from the mirror. Residual gap (documented
in already_routed): a manual pin with Smart Routing on gets hook-routed
once; closing it needs pin provenance, left for phase 2.

Live-verified on the :64688 stack: trivial->luna, sprawling->sol, one
decision row and one user turn each; second turn fast-skips with zero
network. Spike scaffolding (spike-userprompt) removed. 96+69 tests pass
under the sanitized env run.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: make the blocked first prompt durable across runner crashes

Between the hook's block and the replay delivery the prompt existed
only as an in-memory asyncio task — a runner crash in that window lost
it forever while the decision chip, model_override pin, and done-marker
all said routing succeeded (exactly the dead-session shape reported
from live testing, reproduced with a SIGKILL at the marker write).

The relay resolver now writes turn_replay_pending.json before handing
the verdict back (on disk before the hook can block), clears it on
delivery or when the hook is known to have fallen open, and keeps it on
a failed delivery. On the next launch schedule_pending_replay_recovery
drains a leftover record: it requires the marker (proof the hook
blocked), waits for the relaunched thread, and only delivers after
confirming via the item history that the prompt never ran — an
unreadable session leaves the record for a later launch rather than
risking a double-run. A session_id match guards forks sharing a bridge
dir. Adds a turn_routing.log hook trace for diagnosability.

Live-proven on the spike stack: four fresh sessions routed on their
first prompt with turn-2 fast-skips, plus a crash-recovery run
(SIGKILL at the marker; relaunch recovered and replayed the prompt on
the routed model, record cleared). Investigation of the reported dead
sessions showed no prompt ever reached them (no UserPromptSubmit, no
events, empty rollouts) — the durability gap was the adjacent real
defect. 101 tests pass across the turn-routing and codex hook suites.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: in-harness first-message routing for claude (phase 2)

A bare 'omni claude --smart-routing' launch now routes on its first
typed prompt, mirroring codex phase 1 through the same turn_routing
seam: claude-native joins _TURN_HOOK_HARNESSES, the claude hook gains a
route-turn subcommand (marker fast-skip, loopback POST with the live
model read from context.json, block), and the runner performs the model
switch inside the replay via _apply_routed_model — the composer gate
only forwards model_override in-band when it just routed, so a
hook-routed replay previously arrived with no model and ran on the
launch model.

The switch actuator drives the /model PICKER instead of '/model <arg>':
sandbox-proven that the arg form saves the pick as the user's GLOBAL
default in settings.json, while walking the picker with arrows and
pressing 's' switches 'for this session only' with the file
md5-identical across idle soak and clean exit (digit keys also save the
default and are never sent). inject_model_selection resolves exact
catalog-id matches across all rows before any alias match — a workspace
serving two opus generations otherwise lands the wrong row. The routed
composer path switches through the same picker, closing the global
default rewrite on every routed turn; auto_confirm's fixed sleep is
replaced by a dialog poll with a deadline.

CLI: --smart-routing without -p now creates the bare routed session
(cost_control on, no create-time route) and launches the TUI for
harnesses with in-harness routing; auto/no-harness still requires -p.
Spike scaffolding (spike-userprompt) deleted.

Live-proven on an isolated stack: five bare claude launches, trivial
prompts routing to sonnet-5 and a narrow task escalating to opus-4-8
(the pane held opus-4-8 AND opus-5 rows — the id-first matcher picked
right), one decision and one user message each, second prompts
fast-skipping with zero network, and ~/.claude/settings.json
md5-unchanged after every run. 364 tests pass across the touched
suites.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: drop the vestigial turn_router_dir kwarg that broke claude launches

A merge-resolution leftover passed turn_router_dir to
augment_claude_args, whose merged signature never gained the parameter
(the claude route-turn hook registers via bridge_dir and self-gates on
the advertisement at fire time) — every claude-native launch on this
branch died with a TypeError before the pane existed. Caught by the e2e
sweep's bare-launch row.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: apply the routed model to the codex thread in codex's own slug

The route-turn actuator sent thread/settings/update the raw catalog id
(databricks-gpt-5-6-luna). The turn ran — the gateway serves the id —
but codex has no catalog metadata for that spelling, so the pane warned
'Model metadata not found, defaulting to fallback' and /model kept
highlighting the launch slug, which reads as routing not working.

New codex_model_vocabulary (shaped like claude_model_vocabulary):
comparable_model_id folds catalog prefixes, the [1m] suffix, and
dot/dash spelling; codex_model_slug resolves the routed id against
codex's live model/list rows, so codex stays the vocabulary authority
with no hardcoded table. The actuator lists models on the client it
already holds, sends the matched slug, and mirrors the same spelling
into config.toml so the forwarder cannot flip-flop between spellings;
model/list failure or an unmatched id falls back to the id verbatim.
The decision row keeps the catalog id.

Live-proven: thread_settings_applied carries gpt-5.6-luna, zero
catalog-id spellings in the rollout, /model shows the routed row as
(current), no metadata warning for the routed model, one decision,
turn-2 fast-skip. 122 tests across the four touched suites.

Known siblings left for follow-up: thread/start still passes the
catalog id (the remaining launch-model metadata warning), and the
codex spawn path injects catalog ids verbatim.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: gateway backing selects the router; the chip discloses the source

Gateway inference stops being a hide gate and becomes a source
selector. Every Smart Routing surface stays available; the AIGW
conditions decide which router answers each decision: the external
task_v1 client when it is configured and every family the decision
involves is AI-Gateway-backed, else the built-in judge
(LLMRoutingClient) when the server has one, else today's errors —
now reworded to name the real neither-source cause.

- New routing_backend seam: RoutingBackends holds both clients;
  select_router picks per decision; caps carry both (routing_client
  stays the primary for un-migrated readers). The CLI builds both, so
  a Databricks deployment keeps its judge as the fallback.
- Off-gateway decisions never see the static databricks-* tables:
  allow_static_fallback gates the infer_models fallback/top-up, and the
  route declines rather than offer an id the pane cannot run (the two
  hazard tests pin this seam-first).
- Decisions persist router_source ('databricks-aigw' | 'oss-llm');
  /v1/info exposes smart_routing_sources; older servers degrade to
  both-mirror-smart_routing_enabled in the CLI and web alike.
- The chip carries a small Databricks mark only when the AI Gateway
  router answered ('Routed by the Databricks AI Gateway'); OSS and
  legacy rows carry none; pickers are never branded.
- CLI preflight on an off-gateway family with a judge available prints
  one informational downgrade line and proceeds instead of erroring.
- Setup doc and routing overview updated to the source-table semantics.

696 python + 336 web tests across the touched suites (21-test selector
truth table, the create-refusal splits, the /v1/info matrix, badge
render cases); ruff/tsc/oxlint/prettier clean. The 9 wider-run
failures are pre-existing snapshot-cache pollution, reproduced
identically on the clean parent.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: apply the routing test-suite overhaul and refresh the CUJ registry

Registry (designs/CUJ_MASTER.md): 4 rows + 1 recipe cut as fixed or
contradicted; the spawn-audit/canary rows retired-with-reason (the
machinery went with 484f7300 — deliberately out of scope, named in the
PR); row 95 re-entered as a picker regression row; ~22 rows updated to
today's ground truth (codex slug comparisons via comparable_model_id,
strict adherence, the spec-declared auto brain, the deleted standalone
toggle, source-selector semantics); 19 new rows in area O covering the
create-stamp matrix through the off-gateway static-menu decline.

Suites: the turn-gate tests renamed test_turn_routing_enabled_* so they
stop reading as the two-state spawn gate; the matching-ask pair and
five integration duplicates folded into their parametrized seam tests
with per-item duplication proof (122 -> 119 cases, no coverage lost).

25 new targeted cases: an AST-based guard module pinning that no claude
routing path builds '/model <arg>', the switch path holds no fixed
sleeps, the picker reads only the user settings file, and cursor/kiro
remain the only (documented) arg-form senders; hook-settings cases
pinning both routing hooks' timeouts above their script budgets and
coexistence with the policy hooks; the turn-routing timeout ladder
strictly decreasing and the router client inside the hook budget; the
two-concurrent-first-prompts and manual-pin-routed-once gaps pinned as
recorded decisions; migration edge cases (unparseable blobs, dangling
parents, idempotent re-upgrade).

744 + 364 + 192 sanitized pytest passes across the routing slice; web
suites re-confirmed green as baseline; ruff and pre-commit clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: add the e2e routing CUJ suite behind a mocked router

Five end-to-end CUJs — claude and codex from session start (API) and
from a typed first message (TUI), plus the auto-harness cross-family
redirect — each asserting the routing artifacts (decision rows and
their router_source, the pinned model, marker files, thread settings in
codex's own slug, pane state, message counts) and never answer content.

Two properties make it CI-shaped. The routing API is mocked: a
deterministic routes:select service replays the live router's own rule
traces (trivial -> cheapest arm, delegate-class -> glm, crosscutting ->
default/escalate) and keeps the real contract honest by rejecting a
narrowed menu exactly as staging does — proven against the real
ExternalRoutingClient over HTTP, not a hand-written body. And subagent
spawns are asserted as issued-and-routed rather than awaited, so no
test waits on a child's output or an inbox return.

21 pass in ~5 minutes; the suite is opt-in (smart_routing marker plus
OMNIGENT_E2E_SMART_ROUTING=1) and skips with a named reason when the
CLIs, tmux, or a provider config are absent. Each test boots its own
ephemeral server, host, temp DB and temp config home; the developer's
settings files are left untouched, which CUJs 1/3/5 assert by digest.

The CLIs are launched with their trust-bypass flag through
terminal_launch_args (the pattern tests/e2e/test_comment_tools_claude_native.py
already uses) because a fresh temp workspace otherwise blocks the input
box on a trust dialog before any hook can fire.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* chore: remove development-session scaffolding from the PR

Working docs (CUJ registries, plan documents, session setup notes),
the personal dev scripts (dev-env/run-server/run-host/run-frontend and
the routing-API probe), and their allowlist rows were session tooling,
not product: several named internal staging workspaces and proxy
endpoints, and none of them belong in a public repo. A test fixture's
profile string is generified for the same reason. The user-facing
routing documentation moves to the omnigent-site docs (PR #446 there).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: settle the rebase against main's session-routes and model-picker work

Main split the session routes into explicit imports and grew a
host-resolved Codex launch-model catalog while this branch was out; the
replay needed both re-applied by hand.

- Import the names the routing paths use explicitly (`_logger`,
  `_get_runner_client`, `_spawn_gateway_backed`, the validators) now that
  `routes_hooks` / `routes_core` no longer star-import them.
- Keep the pre-existing `native_policy_not_enforced` banner: the trim
  commit dropped its server half, but the runner still reports the
  degrade reason, and main re-exports the helpers.
- Codex's Model row now carries the host's real catalog alongside the
  Smart Routing sentinel instead of replacing it, with the resolved
  default label back via a `defaultLabel` prop on `RoutingModelSelect`.
- Refresh the tests those two changes made stale, and re-apply the hook
  timeout the dropped merge commits had fixed in place.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: apply the external-review fixes and drop both new migrations

- Turn dedup compares decoded user-message text, not a JSON dump
- A no-op model pick is terminal: pinned and recorded without replay
- Child sessions route once; follow-ups cannot flip harness_override
- The turn marker is scoped to {session, decision}; the claude hook
  reads the live session id, so /clear cannot reuse a stale marker
- Hook relays require LEVEL_EDIT; rationales log at DEBUG
- The turn router registers only when routing is enabled; codex model
  catalog population runs off the event loop with a 60s failure TTL;
  hook timeouts sit 10s above the inner HTTP timeout
- gateway_inference moves off the hosts table onto the host connect
  handshake, held in server memory (unknown-is-backed until a host
  re-reports); both alembic migrations are deleted — the PR adds zero
  migrations
- Routing availability checks unified on the routing_backend helpers

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: gate the router's ambient-credential tests on the databricks extra

The new ambient workspace-credential tests patch
``databricks.sdk.config.Config``, but ``tests/server`` runs on a lean CI
lane that neither installs the ``databricks`` extra nor deselects marked
tests, so all eight failed collection with ``ModuleNotFoundError: No
module named 'databricks'``.

Mark them the way the repo already gates SDK-coupled tests, and list
``tests/server/test_smart_routing.py`` on the databricks lane — a marked
test in a path that lane does not cover would otherwise run nowhere.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* revert: switch claude models with `/model <id>`, not the picker

Switching a live claude-native pane through Claude Code's interactive
`/model` picker took ~530 lines of tmux screen-scraping to avoid one side
effect: the argument form also saves the pick as the person's global
default in `~/.claude/settings.json`. The repo owner has accepted that
write, and an external review found the picker path fragile in ways the
argument form has no equivalent of — a 5s server forward budget against a
~35s worst-case automation whose result was discarded, an applied-check
that could return before the ~1.9s "Switch model?" dialog rendered, a
next-message-swallowed-by-dialog hazard, no busy-pane gate, no scroll
handling, and no concurrency lock.

So every claude model-switch call site goes back to injecting the text
`/model <id>` plus Enter through `inject_slash_command`, with
`auto_confirm=True` so the cache-invalidation dialog is still answered:

- the web/API `model_change` endpoint (`runner/app.py`),
- the first-message turn-routing switch (`runner/turn_routing.py`),
- the per-turn executor switch (`inner/claude_native_executor.py`).

Fail-open semantics are unchanged: a failed injection is logged and the
turn still runs on the pane's current model.

Deleted with their last caller: `inject_model_selection`, the picker's
open/apply poll ladders, the row regex and row scanner, the row-matching
and row-picking helpers, the session-only key, and the two runner-side
target-spelling resolvers. Kept: `inject_slash_command` and the polling
`_confirm_tui_dialog` (shared with `/effort`, and a real improvement over
the fixed 0.3s sleep it replaced), plus a single picker-footer string the
pane-readiness gate uses to notice a picker the person opened by hand.

The AST guards that forbade the argument form are gone; the "omnigent
never writes the user's settings file" and "no fixed sleeps on the switch
path" guards stay, since both still guard live code. The e2e settings
guard now compares everything in `~/.claude/settings.json` except the
`model` key Claude Code itself moves.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): one routing chip per pick, hydrate the gear modal's Model row

A Smart Routing create routes twice: once at create time (recorded as a
`session`-scope chip) and again on the session's first turn (a `turn`-scope
chip). Both land before the user's message, both resolve to the same model and
harness, and both render the identical "Smart routing · applied · claude-native"
card — one above the message, one below. The transcript opened on a duplicate.

Collapse them in the block walker: a `session` chip whose next content block is
a `turn` chip with the same model, harness, applied flag, and agent renders
nothing, and the turn chip (the one that pairs below the message) stands for the
pair. Both rows stay persisted as the audit trail, and a create-time pick the
turn CHANGES — or a failed create-time route, recorded as an unapplied
`"unavailable"` row — still renders its own chip, because those two chips say
different things.

Also in the gear modal, the Model row rendered blank on a routed session.
Routing pins the router's fully-qualified pick (`databricks-claude-opus-4-8`),
which the harness catalog carries only under an alias (`opus`) — so no option
declared the Select's value and Radix fell back to its empty placeholder. The
live model now rides as its own option, labelled exactly as the status label
below the composer. An untouched row still submits nothing: `save` re-pins only
a draft that actually changed.

Three review findings:

- `useSession` asks for `refresh_state=true` on every fetch again. Narrowing it
  to the cache-cold fetch meant an invalidation refetch — how switching a
  session's agent reloads the snapshot — came back off the runner's process
  cache, leaving the PREVIOUS agent's model catalog on screen until a hard
  reload.
- Drop the 30s snapshot poll every open session ran. Its only consumer was the
  session warning banner, which the enforcement-stack trim removed; nothing
  reads a field the poll refreshes, so the poll and its opt-in options go with
  it. That also makes the unconditional refresh above safe — nothing re-asks
  often enough to thrash the runner's caches.
- `refreshSessionOverrides` no longer fetches through the query client. It reads
  two plain DB columns, but writing the reply into the shared `["session", id]`
  cache replaced every other surface's refreshed snapshot with an unrefreshed
  one, dropping the `model_options` the model picker renders from.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: scope the codex routing extras to the sessions that need them

Three session classes now decide what a codex home carries: a plain
session gets a byte-identical pre-routing home (bundled catalog,
symlinked hooks.json, no spawn gate, no extra tool approvals); a
pinned-harness Smart Routing session adds only the extended model
catalog; an auto-harness session that routes to codex adds the spawn
gate and the cross-session tool approvals. The subagent router
endpoint starts only where something consumes it. The catalog probe
validates its payload and holds a lock across concurrent boots.
Dispatch validation accepts gpt substrings again and localizes
glm/kimi ids mechanically. The codex env filter now lets the router
and catalog launch signals through — the SDK-codex hook path was
silently dead without them.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: keep pinned codex launches free of routed-spawn extras

The runner passed `developer_instructions` to `build_codex_native_server`
for every codex terminal (with a `None` value on pinned sessions), which
changed the launch call shape for sessions Smart Routing does not own.
Pass the kwarg only for auto-harness sessions.

The claude-native launch-args tests handed a raw `tmp_path` to
`augment_claude_args`, which validates the bridge dir against the real
bridge root; point the bridge root at the test temp dir the way the
bridge's own tests do so the tests pass under any TMPDIR.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: satisfy the type and hardcoded-model gates

pyrefly on the pre-commit gate rejected five shapes the routing work
introduced: an inferred `dict[str, int | str]` hook literal that could
not take the route-turn entry, two `Awaitable` resolver results handed to
`asyncio.run_coroutine_threadsafe` (which takes coroutines only), and two
locals — `_parent_conv`, `_auto_harness` — read on paths where only a
narrower branch had assigned them. It also flagged the create path
rebinding `conv` from `get_conversation` without a `None` check, which
made every later attribute read an error; it now raises the same
`INTERNAL_ERROR` its sibling label writes do.

The router's static model tables moved to `omnigent/model_fallbacks.py`
as owned `StaticModelFallback` records — the repo's only sanctioned home
for a static model id, per the `no-hardcoded-models` lint. Ids that are
composed from the gateway's model-route prefix (GLM's `system.ai.`
spelling) are now spelled that way instead of restated.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: cover the Smart Routing UI in the Playwright suite

The web changes add user-visible routing surfaces with no e2e_ui coverage,
which the E2E UI Required gate flags. Two specs, following the suite's
established stub patterns:

- `start_session/test_smart_routing.py` — the landing picker's Smart
  Routing row (create sends `harness_override: "auto"` +
  `smart_routing_message`, and none of the placeholder wrapper's knobs),
  Smart Routing as the gear modal's Model choice (create sends
  `cost_control_mode_override: "on"`, no pinned model), and the negative
  gate: a server with routing off offers neither.
- `chat/test_smart_routing_session.py` — a routed session's two audit
  rows (create-time `session` chip + first-turn `turn` chip) render as ONE
  chip with the Databricks mark, and the session gear modal's Model row
  names the router's fully-qualified pick instead of rendering blank.

Both run against the suite's spawned server with `/v1/info`, `/v1/hosts`
and `/v1/agents` stubbed, so neither needs gateway credentials.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: match the gateway's trusted parents on DNS labels

The AI Gateway trust check compared the parsed hostname against
dot-prefixed domain suffixes with `str.endswith`. Correct as written (the
leading dot is what rejects `evilcloud.databricks.com`), but the safety
rests on a spelling convention in a constant, and a string-suffix test on
a domain literal is exactly the shape static analysis flags as incomplete
URL sanitization.

Compare whole DNS labels from the right instead, requiring at least one
label of the host's own in front of the parent domain. Same verdicts,
with the boundary now structural, and tests pinning both look-alike
classes: a trusted domain that only appears mid-host, and a label that
merely ends in one (`evilcloud.databricks.com`,
`ai-gateway.notazuredatabricks.net`).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: stop the routing hook's codex floor from blocking every launch

Raising `_CODEX_MIN_VERSION` to 0.145.0 for the routing PreToolUse hook
made `harness_cli_installed("openai")` report `version-too-low` on
0.137–0.144, which makes `harness_is_configured("codex")` false, which
makes the host refuse EVERY codex launch — plain sessions included — with
a misleading "run omni setup". CI pins codex 0.139.0, so the e2e lane
failed on it too.

Restore 0.137.0 as the launch floor and enforce 0.145.0 only where the
spawn gate is actually registered: both codex hook writers now probe
`codex --version` and, on an older CLI, log one line and drop the routing
bridge dir so no hooks are generated at all. Routing no-ops instead of
blocking, and the user's hooks.json stays symlinked.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: confirm the /effort dialog instead of hanging on its title

`inject_slash_command(auto_confirm=True)` polled `capture-pane` for the
hardcoded "Switch model?" and sent Enter only on a match. The web UI's
effort change injects `/effort <level>`, whose confirmation dialog is not
titled that — so it never matched, the dialog stayed open, the change never
committed and the pane was wedged for the next injection. The no-dialog
case also spent the whole 4s poll budget where the previous code spent
0.3s.

Make the hint a per-command parameter and keep an unconditional confirm
Enter as the floor, which is what the code did before the poll was
introduced: on the no-dialog case it lands on an empty prompt and is a
no-op. The three `/model` sites pass the title they know and keep their
fast path; `/effort` passes none, settles briefly and confirms blind.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: keep the spawn-routing apparatus off plain claude sessions

claude-native passed `auto_harness=True` hardcoded and the SDK path started
the router for every claude session, so a plain claude session carried a
loopback HTTP server, its thread, a bearer token on disk, and a `Task`
PreToolUse hook — a subprocess cold start on native, in-process on the SDK —
on every spawn, with a 30-40s worst case when the endpoint is wedged. All of
it for a verdict the server would never route.

Gate both starts on the session's routing class, the same one the codex
paths already read. A plain claude session now gets no router, no hook and
no token file, matching plain codex; a routed session (pinned or auto —
claude routes spawns in both) keeps everything, and the per-spawn
server-side gate stays as defense in depth.

Accepted consequence: the class is stamped at create, so flipping the gear's
Subagent-routing toggle on for a plain-created claude session is inert until
the session is recreated. That matches the stamped-at-create design the codex
paths already follow.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: stop plain launches from displacing the model picker slot

`claude_config_with_launch_model_pinned` ran on every claude-native launch.
Whenever the launch model is an exact id no family alias points at — a user
picking an older generation of a family the workspace still serves — it
overwrote `ANTHROPIC_CUSTOM_MODEL_OPTION`, taking the workspace's own picker
row with it.

The slot exists so a routed session can return to the model routing picked
for it. Nothing re-picks the launch model on a plain session, so gate the pin
to routed launches and leave a plain launch's env untouched.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: restore main's spawn-env secret-leak canary

The trim commit deleted this file by name collision with the routing
spawn-audit canary; it is main's own guard for clean_agent_env and was
never part of this PR's machinery.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: keep the router rendezvous out of logs

The subagent- and turn-router startup logs printed the handle's url, and
the hook's rejection diagnostics echoed the url read out of the
advertisement. Both values travel with the bearer token that authorizes
the loopback endpoint, so a log line was enough to point a reader at the
secret's neighbourhood; static analysis flagged the four sites as
clear-text logging of sensitive data.

Drop the url from all four: the session id and the bridge directory (or
the advertisement's file name) identify the rendezvous well enough, and
the advertisement itself is on disk for anyone debugging it.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: confirm an effort dialog that renders after the blind Enter

A command whose dialog text we cannot recognise — ``/effort`` — settled
0.3s and then Entered blind. On a warm session the confirmation renders
about 1.9s in, so that Enter landed on an idle prompt and the dialog that
arrived afterwards stayed open: the person's next message was typed into
the modal and swallowed.

Keep the blind Enter as the fast path, then keep watching the pane for a
dialog until the confirm timeout and Enter again if one turns up. With no
dialog text to match on, the watch uses a structural signal — a framed
menu of at least two numbered choices with one selected — which also
recognises the ``/model`` picker and steps around a composer draft that
merely starts with ``2. ``. A dialog already showing at the settle skips
the watch, so the common cases still cost one capture.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: derive claude launch routing state through the shared class

Both claude-native launch-metadata builders hand-derived
``routing_enabled`` from ``cost_control_mode_override`` alone, while
``routing_class_from_snapshot`` deliberately ORs in the auto-harness
signal. A sub-agent child of a routed parent is created with
``harness_override="auto"`` and the auto-harness label but no
cost-control stamp, so it launched ``routing_enabled=False`` with
``auto_harness=True``: no pinned arms, no launch-model pin, no turn
router and no subagent router — yet still carrying the routed-spawn
system-prompt note and the four pre-approved ``sys_*`` tools. Claude was
told to hand its spawns to a hook nothing answered.

Route both builders through ``routing_class_from_snapshot`` so the class
is derived in one place, and require the spawn router to have actually
started before the note and pre-approvals go onto the argv.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: stop offering subagent routing where it cannot work

The create path stamped ``subagent_routing_override="on"`` on every
session that started on Smart Routing, and the gear offered the
Subagent-routing select to every native Claude/Codex session. On a
session pinned to codex neither is real: spawn routing there needs the
generated ``hooks.json`` and the routed-spawn tool pre-approvals that
only an auto-harness launch installs, so the switch read "on" with
nothing consuming it. The same went for a plain native session of either
family, whose apparatus is fixed at create.

Leave the stamp off for a pinned codex create, and hide the row wherever
the session's class has no spawn-routing machinery — a claude-family
routed session and any auto-harness session keep both. Non-native
SDK/bundle sessions are untouched: their children go through the
session-create path, which re-reads the switch per spawn.

Subagent routing is now launch-time-fixed for codex.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: make the model switch land once, or say why it did not

Three faults left over from reverting the interactive ``/model`` picker.

The web/API model-change handler typed the resolved catalog id straight
into ``/model``, which takes only the pane's own picker vocabulary. An id
outside it left the pane on its old model while the handler reported
success. Translate through ``claude_model_command_arg`` like the routed
turn path and the executor already do, and fail with a clear 503 when the
picker has no spelling for the model.

A routed first message switched twice. The turn router blocks the prompt,
types the switch and replays the prompt with the same override, but the
executor seeded its baseline from ``launch_model`` — written once at
bridge prepare — so the replay compared against the pre-switch model and
typed a second, redundant ``/model``. Seed from the live statusLine model
instead, and compare normalized.

A dropped forward was invisible. The PATCH persisted ``model_override``
and discarded the forward's result, so on a native pane — where the
injection is the only thing that moves the model — the row and picker
claimed a model the terminal was never on. Publish a visible notice and
log the reason. The forward budget also went up: the ``/model`` and
``/effort`` injectors can legitimately spend ~5s waiting on the pane and
its confirm dialog, which the old 5s budget would have reported as a
failure.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: clear the routing punch list's small residuals

- The install and credential routes recorded ``gateway_inference`` straight
  off the host's RPC reply, so a host answering with anything other than a
  string→bool object 500'd them inside ``dict(...)``. Decode through the
  same tolerant reader the tunnel path uses, where a non-mapping is
  "unknown".
- Reworded the routing docstrings that cited design documents no longer in
  the repo; the behaviour they described is stated inline, and the e2e
  suite in tests/e2e/routing/ is the executable reference.
- ``routing_enabled(caps=)`` read the routing backends directly, which
  misses the managed arm where only a policy-LLM factory is registered and
  the routing client arrives later. It goes through ``routing_available``
  now, the same gate the rest of the server uses.
- The codex model-catalog cache was keyed on binary path plus codex home,
  so an in-place upgrade (same path, new bytes) served the previous
  codex's catalog for the life of the host process. The binary's mtime and
  size are part of the key now.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: match the gear's comments to the narrowed subagent gate

The two comments still described the old "every native Claude/Codex
session" rule. Say which classes carry the apparatus and which the row is
hidden for.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: pin that the late-dialog Enter only answers our own dialog

The extra Enter is scoped to a dialog that appeared after the settle, so a
menu already open when the command was injected — a live permission
prompt, say — still takes only the single blind Enter this seam always
sent. That property is what makes widening the confirm window safe, so it
gets a test and a note rather than living in the reviewer's head.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: answer the effort dialog by name, not by shape

The effort confirm watch Entered on any dialog that turned up during its
4s poll, so a ``/model`` picker the person opened by hand — or a tool
permission prompt that rendered mid-turn — took the Enter too: the first
silently rewrites their global default model, the second silently
approves the tool.

Claude Code titles both cache-invalidation confirmations from one
component, so ``/effort`` has a title to poll for just like ``/model``:
"Change effort level?". Pass it as the effort call's ``confirm_hint`` and
drop the shape-matching watch — ``auto_confirm`` now requires a hint. The
timeout Enter stays, so a title that drifts in a future release does not
wedge the pane, but is withheld when the pane shows a picker or a
permission prompt. The readiness gate learns the effort title too, so an
open effort dialog no longer reads as "an injection may land".

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: suppress the codex subagent stamp only where it is inert

The create-time subagent_routing_override stamp was skipped for anything
whose harness family is "gpt". That also caught an SDK/bundle agent whose
brain is codex or openai-agents — and those spawn their children through
the session-create path, which re-reads the switch per spawn, so the
stamp is exactly what gives them default child routing. Skipping it took
that away, and disagreed with the gear, which offers the row on every
non-native session.

Suppress only where the switch really has nothing behind it: a NATIVE
codex terminal, whose spawn routing comes from the hooks.json and
tool pre-approvals an auto-harness launch installs. The server and the
gear now agree class by class: native pinned-codex hides the row and
writes no stamp; a codex-brained bundle keeps both.

The old fixture had no spec harness, so it never reached the family
check; the new case pins a codex-brained bundle on both sides.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: clear the routing punch list's last three residuals

- The "terminal was not switched" banner fired on stopped and detached
  native sessions too, where nothing was running to diverge from: the
  relaunch reads model_override off the row. Surface it only when a runner
  actually answered and refused, which is the reachability the /health
  liveness field reports.
- Add the credential route the tolerance test the install route got: a
  host reply whose gateway_inference is a list must read as "unknown", not
  500 with the credential already written. The install test never proved
  that — its garbled value was dropped by the fixture before it reached
  the frame — so both now inject at the proxy's return, past the decoder
  that would otherwise normalise it away.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: drive the gateway-flip repush through the readiness loop

Upstream moved readiness refresh into its own task; the flip test now
exercises that loop directly instead of the removed tunnel helper.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: log nothing that addresses the router rendezvous

The redaction kept the session id and bridge path, which still name the
loopback endpoint whose advertisement carries the bearer token. The
start-up lines and the marker-failure notice now carry no values at all.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: make routing fail open in seconds, not in half a minute

Routing was already advisory everywhere it mattered, but the budgets meant
a wedged router still stalled the work it was supposed to get out of the
way of: a subagent spawn sat behind a 30s request inside a 40s hook kill,
and a first typed prompt sat behind 25s inside 45s. A fail-open that takes
that long is blocking in practice — the user cannot tell it apart from a
hang, and the turn they were promised runs no sooner for the wait.

Retune every routing ladder around one number: the routing call itself gets
5s, sized from the observed round trip (healthy routes:select answers in
~1.4-3s; the slowest sample on record was a gateway 500, not a verdict).
Each hop above it takes one more second, out to the harness-registered kill
at 15s (spawn gate 12s), which is now the only budget above single digits.
One attempt, no retry: a second try on an interactive path only doubles the
stall.

Two budgets on these paths were unbounded rather than merely long. The
built-in judge inherited the server `llm:` block's 300s request timeout,
multiplied by every configured fallback model, so picking the OSS router as
the source turned a fail-open into a multi-minute hang; it now shares the
external router's 5s. And the stale native model-options refresh, awaited
only to sharpen a routing candidate list, retries a booting runner for
~30s; routing now waits 3s for it and lets the single-flight finish filling
the cache on its own.

The CLI's preflight reads move off the create's 60s read budget too. They
answer in milliseconds and every failure already degrades to "unknown",
which does not gate, so there was nothing to win by waiting. The create's
own budget is left alone: that one is a session create, not a routing call.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: stop a routing outage from 500ing the turn it was routing

`route_turn` was the one routing seam that let its failure out. Its two
callers on the message path did not guard it, so a client that raised
instead of declining — a gateway 500 surfacing as HTTPStatusError, a read
timeout, a garbled body, a 401 — propagated to `POST /v1/sessions/{id}/
events` as a 500. By then the user's message had already been persisted, so
the turn was not merely unrouted: it was persisted and abandoned. Its
sibling `route_session_harness` has always returned an `error` string for
exactly this, which is what made the asymmetry easy to miss.

Add `route_turn_or_decline` as the turn path's fail-open boundary, in the
same `(model, verdict, error)` shape, and take the visible half of failing
open with it: the declined `routing_decision` card the auto-harness path
already emitted ("unavailable", applied=False) now covers the turn and the
native-pane paths too, so a session does not quietly ignore the toggle the
user turned on.

A failure deliberately does NOT stamp the routing-decision label. That label
is the route-once gate, so claiming it would turn one outage into the reason
the session never routes again — the failure is a card, not a decision.

Everything else audited on the routing paths was already fail-open and stays
untouched: the CLI's routed create and its auto-harness fallback, the
create-time server paths, the spawn-gate relay, both first-message hooks,
the loopback relays, both clients, and the model-switch application step.
The precondition gates that decline before anything starts are also left
alone — those are config rejections the owner asked for, not call failures.

Regression coverage for both properties (work proceeds, budget respected)
across gateway 500 / timeout / malformed body / 401 / unreachable relay, at
every call site: the SDK turn path, the native pane path, the spawn relay,
the first-message relay, both create paths, both hook scripts, both clients,
and the CLI's non-routing-400 fallback notice. Timing assertions are against
the ladder constants, never a wall clock.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: give a child spawn's failed route the same visible decline

`route_session_harness` returns its reason as an `error` string, and the
child-spawn branch of the message path unpacked it into `_route_err` and
then never read it. So the last routing path that could not route left no
card at all: the spawn ran on whatever the orchestrator had asked for, which
is right, but from the transcript "the router was down" and "the router had
no opinion" were the same thing.

Emit the same "unavailable" card the auto-harness and turn paths emit. Set
last, after the branch's own pin and publish, so nothing upstream can pin or
announce the placeholder — and leave the route-once label unclaimed, because
a child routes per spawn and `_child_routed_before` reads that label, so
stamping it on a failure would stop the child from ever being routed again.

The flag is renamed `_route_failed` now that both branches set it.

Also covers the bounded catalog wait: a stale-catalog refetch that never
finishes serves the stale vocabulary within `_ROUTING_CATALOG_WAIT_S` and
leaves the single-flight running to fill the cache for the next turn.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: let a pinned Smart Routing codex session actually spawn

Suppressing the create-time subagent-routing stamp for a native pinned-codex
session was justified on the theory that the switch would be inert there. It
was worse than inert: the pinned class was also withheld the spawn-routing
advertisement, and on codex that advertisement is what turns on the generated
``hooks.json`` ``spawn_agent`` gate AND the four routed-spawn tool
pre-approvals. A pinned Smart Routing codex session therefore had no spawn gate
and no pre-approved cross-session spawn tools, so its spawns did not merely go
unrouted — they stalled on an approval prompt nobody was watching.

Stamp every routed create again, and start the endpoint for a routed
codex-native launch whether or not the harness was auto-picked, which brings
the gate and the approvals with it. The codex SDK arm keeps the auto-harness
requirement: its spawns go through the session-create path, which already
routes off the stamped switch, so an in-harness gate would only add a round
trip. Plain sessions still get none of it.

What separates pinned from auto-harness is not whether spawns route but where
they may land: ``cross_harness`` stays ``auto_harness_session``, so a pinned
codex spawn is offered codex arms only and a claude pick is denied. The web
predicate now shows the gear's Subagent-routing row for exactly the classes the
server stamps.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: collapse a repeated routing verdict into one chip again

A Smart Routing create records its pick as a session-scope chip and the first
turn records the identical pick as a turn-scope chip; only the turn chip should
render. The pairing test asked whether the two decisions were ADJACENT, using
the same neighbour walk that decides where a chip sits relative to the message
it routes. That walk steps over exactly the blocks allowed between a chip and
its message, so anything else a booting session emitted between the two
decisions — narration, an earlier message, a whole finished response — read as
"unrelated" and both chips rendered.

Pair them by decision order instead: the next routing decision anywhere later,
across intervening blocks and turn-group boundaries. A turn chip that CHANGED
the pick, a declined create-time route followed by an applied one, and a spawn's
deny-then-honor pair all still render as two — the first two because the
verdicts differ, the last because a subagent-scope decision is never the
supersessor.

The incremental path had its own hole: the create chip is finalized into the
cached prefix frames before the turn chip exists, and the drop was computed only
from the walk's resume point, so a chip already in the prefix could never be
removed. The verdict set is now resolved over the whole transcript and
remembered on the cache, and a disagreement over the prefix forces the single
rebuild that removes the stale chip.

For the record, the resource_event in the reported transcript is not the
mechanism: an unknown item type yields no block from itemsToBlocks and
session_resource_created adds none on the live path, so it never separated the
two. The wire rows are kept as a funnel regression test regardless.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: keep a pinned session's spawns in its own harness family

A pinned Smart Routing codex session spawned a claude child and the router
pinned it to claude-sonnet-5: the in-harness spawn gate holds the in-family
line (candidate_models(cross_harness=False)) but the child-session route on
the native-terminal dispatch path had no such rule. It routed whatever
family the child's own pane ran, so an orchestrator that named another
family's wrapper agent got a cross-family spawn blessed by routing —
against the standing ruling that only an auto-harness session may cross.

The native child path now asks the same predicate the spawn gate does
(auto_harness_session(conv, parent)) and, for a pinned parent whose child
runs another family's CLI, routes nothing: no pin, no in-band /model, and a
declined chip naming the rule. The spawn itself still runs, on its CLI's
own model.

Also resolve a native pane's family from the terminal it is actually
running rather than an unresolved "auto" sentinel. The sentinel carries no
family, so a forced-auto child was offered every model its gateway serves
and could be pinned to one its running CLI cannot speak.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: render one routing chip per spawn, not two

One spawn produces two decisions — the in-harness gate sizes the task, then
the child session it created routes its own first message — and the
transcript showed both: one chip labelled "Session" (the gate row carries no
agent name) and one naming the spawned agent, with the same rationale. To
the owner that is one decision about one spawn.

The pair now collapses onto the child-session row, which is the informative
one: it names the spawned agent and the arm that actually ran, keeping the
gate's own pick visible as the router's raw verdict when a tier
substitution moved it (opus-4-8 -> opus-5). The two rows share no spawn id
— different decision ids, no agent on the gate row, minutes apart — so the
pairing key is the verdict: the same non-empty rationale AND the child
running the arm the gate picked. A deny-then-honor pair, two independent
spawns, and two genuinely different verdicts all still render as two chips.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: name the cause on a routing decline that had none

A live decline read "Routing unavailable (router request failed: )" — a
dangling colon with the reason missing. httpx's timeouts stringify to the
empty string, so the exception the fail-open budget produces most often was
also the one that said nothing. Every routing failure string now falls back
to the exception class ("router request failed: ReadTimeout"), which is what
a 5s budget firing looks like.

The subagent gate had a second way to lose the cause: a client that raises
before it can record its own last_error left the chip saying only "router
returned no verdict", with the real failure in the server log alone. It now
carries the raised cause when the client reported none.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

---------

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
This commit is contained in:
Bryan Qiu
2026-08-05 15:34:54 -07:00
committed by GitHub
parent db1d99e9f1
commit b268130340
180 changed files with 42695 additions and 1585 deletions
+7 -5
View File
@@ -133,12 +133,14 @@ jobs:
--ignore=tests/runner
--ignore=tests/stores
dist: worksteal
# Databricks-coupled tests (Lakebase token engine, psycopg). This is
# the only lane that installs the `databricks` extra; the
# @pytest.mark.databricks marker keeps these tests off the lean lanes
# (which run -m "not databricks") and selects them here.
# Databricks-coupled tests (Lakebase token engine, psycopg, the
# router's ambient workspace-credential chain). This is the only lane
# that installs the `databricks` extra; the @pytest.mark.databricks
# marker keeps these tests off the lean lanes (which run
# -m "not databricks") and selects them here. Paths carrying marked
# tests must be listed here or those tests run nowhere.
- group: databricks
paths: tests/db tests/deploy
paths: tests/db tests/deploy tests/server/test_smart_routing.py
extra: databricks
markexpr: databricks
# Slack integration (integrations/slack). Its tests live outside the
+1
View File
@@ -85,3 +85,4 @@ omnigent/server/static/web-ui/
# reason — `bundle deploy` must be able to sync it to the app source folder.
# DAB local state directory (created by `databricks bundle deploy`).
deploy/databricks/.databricks/
web/package-lock.json
+26 -36
View File
@@ -273,6 +273,30 @@ def _build_local_llm_routing_client(
return LLMRoutingClient(policy_client)
def _build_routing(
cfg: dict[str, Any],
server_llm: Any, # type: ignore[explicit-any] # LLMConfig | None
) -> tuple[Any, Any]: # type: ignore[explicit-any] # (RoutingClient | None, RoutingSettings)
"""Build the routing client and settings from the ``routing:`` block.
Reuses the CLI's parser and builder so a Docker deployment honours the
same ``routing.*`` keys (router name, selection model, model prefixes) a
local server does.
:param cfg: The parsed server config mapping.
:param server_llm: The parsed server-level ``LLMConfig``, used for the
built-in judge when no external router is configured.
:returns: ``(routing_client, routing_settings)`` for ``RuntimeCaps``.
"""
from omnigent.cli import _build_external_routing_client, parse_routing_settings
routing_cfg = cfg.get("routing")
settings = parse_routing_settings(routing_cfg)
if isinstance(routing_cfg, dict) and routing_cfg.get("provider") == "external":
return _build_external_routing_client(routing_cfg, settings), settings
return _build_local_llm_routing_client(server_llm), settings
def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
"""Resolve config if needed, wire the stores, and build the app.
@@ -340,47 +364,13 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
server_llm = parse_server_llm(cfg.get("llm"))
routing_cfg = cfg.get("routing")
if isinstance(routing_cfg, dict) and routing_cfg.get("provider") == "external":
from omnigent.server.smart_routing import ExternalRoutingClient, _bearer_auth
base_url = (routing_cfg.get("base_url") or "").strip()
router_name = (routing_cfg.get("router_name") or "").strip()
api_key_raw = (routing_cfg.get("api_key") or "").strip()
profile = (routing_cfg.get("profile") or "").strip()
raw_prefixes = routing_cfg.get("model_prefix")
if isinstance(raw_prefixes, str):
raw_prefixes = [raw_prefixes]
model_prefixes = (
[p.strip() for p in raw_prefixes if isinstance(p, str) and p.strip()]
if isinstance(raw_prefixes, list)
else []
)
if base_url and router_name:
auth = None
databricks_profile: str | None = None
if api_key_raw:
from omnigent.spec import expand_env_vars
auth = _bearer_auth(expand_env_vars({"api_key": api_key_raw})["api_key"])
elif profile:
databricks_profile = profile
routing_client = ExternalRoutingClient(
base_url=base_url,
router_name=router_name,
auth=auth,
databricks_profile=databricks_profile,
model_prefixes=model_prefixes,
)
else:
routing_client = None
else:
routing_client = _build_local_llm_routing_client(server_llm)
routing_client, routing_settings = _build_routing(cfg, server_llm)
caps = RuntimeCaps(
default_policies=parse_default_policies(cfg.get("policies")),
llm=server_llm,
routing_client=routing_client,
routing_settings=routing_settings,
)
init_runtime(
+3
View File
@@ -36,6 +36,9 @@ executor:
type: omnigent
config:
harness: claude-sdk
# A pinned brain also pins the family her heads are routed within, which
# pulls the `gpt` head off codex onto Claude. Route the brain instead.
smart_routing_harness: auto
prompt: |
You are Debby, a brainstorming partner with two heads. You never answer a
+3
View File
@@ -30,6 +30,9 @@ executor:
context_window: 1000000
config:
harness: claude-sdk
# A pinned brain also pins the family its workers are routed within, which
# strands the codex / pi sub-agents. Route the brain instead.
smart_routing_harness: auto
prompt: |
You are polly, a multi-agent CODING orchestrator. You are the tech lead, not
+205
View File
@@ -0,0 +1,205 @@
"""Claude Code's model vocabulary, and how to speak it.
Omnigent routes to servable catalog ids (``databricks-claude-sonnet-5``),
but two Claude Code surfaces accept only the family *aliases*:
* the ``Agent`` / ``Task`` tool's ``model`` parameter — a closed enum
(``sonnet``, ``opus``, ``haiku``, ``fable``), so a catalog id fails
schema validation and the spawn dies before it starts;
* the ``/model`` slash command — an alias (or the custom slot's exact id)
resolves offline with no validation; ANY other value, catalog id or
canonical vendor id alike, is accepted only if a live one-token request
to the configured endpoint succeeds, so it depends on the gateway
answering mid-turn and fails as a network error otherwise.
Claude Code resolves each alias to a concrete id via the workspace's
``ANTHROPIC_DEFAULT_*_MODEL`` env (set by omnigent's launch config), so
inverting that mapping is exact — and only exact: a family segment alone
is not enough, because a workspace serving two generations of a family
pins the alias to the newer one, and speaking the alias would run a model
nobody routed to. Both surfaces fail OPEN on an id with no accepted
spelling: skip the switch rather than send something the CLI drops.
``--model`` at launch is a different contract: it takes any string
verbatim, so a session STARTS on an exact id without needing a pin.
Stdlib-only so hook subprocesses can import it on the spawn path.
"""
from __future__ import annotations
import os
import re
from collections.abc import Iterable, Mapping
from typing import Any
#: Family aliases both surfaces accept, longest-lived family first.
CLAUDE_MODEL_ALIASES: tuple[str, ...] = ("fable", "opus", "sonnet", "haiku")
#: Alias → env var Claude Code reads to pin that alias to one model id.
ALIAS_MODEL_ENV_VARS: dict[str, str] = {
"fable": "ANTHROPIC_DEFAULT_FABLE_MODEL",
"opus": "ANTHROPIC_DEFAULT_OPUS_MODEL",
"sonnet": "ANTHROPIC_DEFAULT_SONNET_MODEL",
"haiku": "ANTHROPIC_DEFAULT_HAIKU_MODEL",
}
#: Extra picker slot pinned to one exact id. ``/model`` accepts that id
#: offline, compared BYTE-EXACTLY (case included) against this value — so
#: translation returns the env's own spelling, never the caller's. The
#: Agent tool's enum has no such slot, so only ``/model`` uses it.
CUSTOM_MODEL_OPTION_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION"
#: Display name Claude Code labels the custom slot's ``/model`` picker row
#: with, e.g. ``"Sonnet 5"``. Cosmetic — the slot's id is what ``/model``
#: takes — so it is not part of the vocabulary below.
CUSTOM_MODEL_OPTION_NAME_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"
#: Launch-env keys that define this session's model vocabulary.
MODEL_VOCABULARY_ENV_VARS: tuple[str, ...] = (
*ALIAS_MODEL_ENV_VARS.values(),
CUSTOM_MODEL_OPTION_ENV_VAR,
)
#: Catalog prefixes stripped before comparing ids. Must equal
#: :data:`omnigent.server.smart_routing.MODEL_ID_PREFIXES` (asserted by
#: ``test_catalog_prefixes_match_the_routing_defaults``); duplicated because
#: this module stays stdlib-only for hook subprocesses, which also means it
#: cannot honour a deployment's ``routing.model_prefix`` override.
_CATALOG_PREFIXES: tuple[str, ...] = ("databricks-", "system.ai.")
_SEGMENT_RE = re.compile(r"[^a-z0-9]+")
def normalized_model_id(model: str) -> str:
"""Lower-case a model id, dropping catalog prefix and ``[1m]`` suffix.
:param model: Any model id or alias.
:returns: The comparable bare id, e.g. ``"claude-sonnet-5"``.
"""
bare = model.strip().lower().removesuffix("[1m]")
for prefix in _CATALOG_PREFIXES:
if bare.startswith(prefix):
return bare[len(prefix) :]
return bare
def alias_pins(env: Mapping[str, str] | None = None) -> dict[str, str]:
"""Read the session's alias → model-id pinning.
:param env: Environment mapping. ``None`` reads :data:`os.environ`.
:returns: Alias → pinned model id, for the aliases that are pinned.
"""
environ = os.environ if env is None else env
pins: dict[str, str] = {}
for alias, env_var in ALIAS_MODEL_ENV_VARS.items():
pinned = environ.get(env_var, "").strip()
if pinned:
pins[alias] = pinned
return pins
def model_vocabulary_env(options: Iterable[Mapping[str, Any]]) -> dict[str, str]:
"""Rebuild a session's model vocabulary from its picker rows.
The native model picker's rows ARE the launch env's pinning read back
out: a row keyed by a family alias is that alias's pin, and any other
row occupies the single custom slot. This lets a process that never
saw the terminal's env (the server) ask
:func:`claude_model_command_arg` the same question the executor will.
Rows that only restate their own key (a direct Claude login's curated
``opus`` / ``sonnet`` rows) pin nothing — Claude resolves those
itself — so they are skipped rather than read as a pin onto an alias.
:param options: Picker rows, e.g.
``[{"id": "opus", "model": "databricks-claude-opus-5"}]``.
:returns: A vocabulary env mapping, e.g.
``{"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5"}``.
Empty when the rows pin no concrete model ids.
"""
env: dict[str, str] = {}
for option in options:
if not isinstance(option, Mapping):
continue
row_id = option.get("id")
model = option.get("model")
if not isinstance(model, str) or not model.strip():
continue
if model.strip().lower() in CLAUDE_MODEL_ALIASES or model == row_id:
continue
key = ALIAS_MODEL_ENV_VARS.get(row_id if isinstance(row_id, str) else "")
if key is None:
key = CUSTOM_MODEL_OPTION_ENV_VAR
env.setdefault(key, model.strip())
return env
def claude_model_alias(
model: str,
env: Mapping[str, str] | None = None,
) -> str | None:
"""Translate a servable model id into Claude's alias vocabulary.
An exact hit on the pinning is authoritative. The id's own family
segment names the alias only when NOTHING is pinned at all (a direct
Anthropic login, where the alias resolves to the vendor's own model
of that family). Once this session pins aliases, a family segment is
not enough: an unpinned alias resolves to a canonical vendor id the
gateway rejects, and a MISMATCHED pin is worse — the alias resolves
to the pinned id, so the pane runs a model nobody routed to while
the record claims the routed one (workspace serving both
``claude-opus-4-8`` and ``claude-opus-5``, ``opus`` pinned to the
latter, ``claude-opus-4-8`` routed).
:param model: Model id from a routing decision, or an alias already.
:param env: Environment mapping holding the alias pinning. ``None``
reads :data:`os.environ` — a hook subprocess inherits the CLI's.
:returns: An accepted alias, or ``None`` when the id maps to nothing
Claude would accept; callers must then leave the model alone.
"""
if not isinstance(model, str) or not model.strip():
return None
candidate = model.strip().lower()
if candidate in CLAUDE_MODEL_ALIASES:
return candidate
pins = alias_pins(env)
normalized = normalized_model_id(model)
for alias, pinned in pins.items():
if normalized_model_id(pinned) == normalized:
return alias
if pins:
# Every pinned alias was compared exactly above, so reaching here
# means the routed id is not what any alias resolves to.
return None
segments = set(_SEGMENT_RE.split(normalized))
for alias in CLAUDE_MODEL_ALIASES:
if alias in segments:
return alias
return None
def claude_model_command_arg(
model: str,
env: Mapping[str, str] | None = None,
) -> str | None:
"""Translate a model id into a ``/model`` argument.
Same alias vocabulary as :func:`claude_model_alias`, except the extra
picker slot: ``/model`` takes that exact id, so a routed model pinned
there is applied precisely instead of stepping down to its family
alias.
:param model: Model id from a routing decision, or an alias already.
:param env: Environment mapping holding the session's pinning.
``None`` reads :data:`os.environ`.
:returns: The ``/model`` argument, or ``None`` when the id maps to
nothing the command accepts (the caller must skip the switch —
an unaccepted value silently keeps the current model).
"""
if not isinstance(model, str) or not model.strip():
return None
environ = os.environ if env is None else env
custom = environ.get(CUSTOM_MODEL_OPTION_ENV_VAR, "").strip()
if custom and normalized_model_id(custom) == normalized_model_id(model):
return custom
return claude_model_alias(model, env)
+163 -12
View File
@@ -30,8 +30,8 @@ from omnigent.json_types import JsonObject as _JsonObject
if sys.platform != "win32":
import termios
import tty
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import dataclass
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
@@ -72,6 +72,10 @@ from omnigent._wrapper_labels import (
WRAPPER_LABEL_KEY as _WRAPPER_LABEL_KEY,
)
from omnigent.claude_launcher import resolve_claude_launch
from omnigent.claude_model_vocabulary import (
CUSTOM_MODEL_OPTION_ENV_VAR,
CUSTOM_MODEL_OPTION_NAME_ENV_VAR,
)
from omnigent.claude_native_bridge import (
BRIDGE_ID_LABEL_KEY,
augment_claude_args,
@@ -213,8 +217,8 @@ _UCODE_CLAUDE_TIER_TO_ENV: dict[str, str] = {
# workspace's existing default Sonnet (4.6). This keeps the default Sonnet
# unchanged and adds the newer generation as a separate, explicit choice.
# See https://code.claude.com/docs/en/model-config#custom-model-options
_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV = "ANTHROPIC_CUSTOM_MODEL_OPTION"
_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV = "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"
_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV = CUSTOM_MODEL_OPTION_ENV_VAR
_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV = CUSTOM_MODEL_OPTION_NAME_ENV_VAR
_UCODE_CLAUDE_CUSTOM_TIER = "sonnet_5"
_UCODE_CLAUDE_CUSTOM_TIER_LABEL = "Sonnet 5"
_CLAUDE_NATIVE_STATIC_MODEL_OPTIONS: tuple[tuple[str, str], ...] = (
@@ -359,11 +363,20 @@ class ClaudeNativeUcodeConfig:
``apiKeyHelper`` once ``CLAUDE_CODE_USE_BEDROCK=1``).
:param model: Optional model id from ucode state, e.g.
``"databricks-claude-opus-4-7"``.
:param routable_models: Every Claude id this endpoint serves, newest
first, e.g. ``("databricks-claude-opus-5",
"databricks-claude-opus-4-8")``. A superset of the aliases in
``env``, which only pin the newest of each family: an older
generation is still launchable (``--model`` takes an exact id),
so a router may pick it. Empty when the endpoint's catalog was
not enumerated (cached ucode state, managed settings, a
non-Databricks provider).
"""
env: dict[str, str]
api_key_helper: str | None = None
model: str | None = None
routable_models: tuple[str, ...] = ()
def _serves_canonical_anthropic_ids(claude_config: ClaudeNativeUcodeConfig) -> bool:
@@ -446,6 +459,110 @@ def resolve_claude_native_model_selection(
return family_match
def claude_config_with_routed_arms_pinned(
claude_config: ClaudeNativeUcodeConfig | None,
routed_arms: Sequence[str],
) -> ClaudeNativeUcodeConfig | None:
"""Repoint Claude Code's family aliases at the router's frozen arms.
The terminal launches before the first turn decision, so ``/model`` can
only reach ids this env spells. Pinning each alias to its family's routed
arm makes turn one's ``/model opus`` land on the router's pick; arms with
no servable spelling keep the discovery-derived pin.
:param claude_config: Resolved provider config for the terminal, or
``None`` (Claude's own login pins nothing).
:param routed_arms: Arm ids the router may select, in router or catalog
vocabulary, e.g. ``("claude-opus-4-8", "claude-sonnet-5")``.
:returns: ``claude_config`` itself when no pin changes, otherwise a copy
with the alias env repointed.
"""
from omnigent.claude_model_vocabulary import normalized_model_id
if claude_config is None or not routed_arms:
return claude_config
servable = {normalized_model_id(m): m for m in reversed(claude_config.routable_models)}
env = dict(claude_config.env)
repinned: dict[str, str] = {}
for arm in routed_arms:
normalized = normalized_model_id(arm)
model_id = servable.get(normalized)
if model_id is None:
continue
tier = next(
(family for family in _UCODE_CLAUDE_TIER_TO_ENV if family in normalized.split("-")),
None,
)
if tier is None:
continue
env_var = _UCODE_CLAUDE_TIER_TO_ENV[tier]
if env.get(env_var) == model_id:
continue
env[env_var] = model_id
repinned[tier] = model_id
if not repinned:
return claude_config
_logger.info("native-claude: pinned routed arms onto family aliases: %s", repinned)
return replace(claude_config, env=env)
def claude_config_with_launch_model_pinned(
claude_config: ClaudeNativeUcodeConfig | None,
launch_model: str | None,
) -> ClaudeNativeUcodeConfig | None:
"""Pin an exact launch model into Claude Code's custom picker slot.
The four family aliases are pinned to the NEWEST model each family
serves, so a session launched on an older generation of a family it
still serves (Smart Routing picking ``claude-opus-4-8`` while
``opus`` resolves to ``claude-opus-5``) has no spelling of its own
model: ``/model`` would take the alias and silently move the pane to
the newer one. Claude Code's one extra picker slot takes an exact id,
so parking the launch model there gives the session a spelling for
the model it actually runs — and a picker row the user can return to.
:param claude_config: Resolved provider config for the terminal, or
``None`` (Claude's own login pins nothing).
:param launch_model: The model this terminal launches with, e.g.
``"databricks-claude-opus-4-8"``. Family aliases and ids already
covered by a pin need no slot.
:returns: The config to launch with — ``claude_config`` itself when
no slot change is needed, otherwise a copy with the custom-option
env set.
"""
from omnigent.claude_model_vocabulary import (
claude_model_command_arg,
normalized_model_id,
)
if claude_config is None or not launch_model or not launch_model.strip():
return claude_config
model = launch_model.strip()
if model in _UCODE_CLAUDE_TIER_TO_ENV or model == _UCODE_CLAUDE_CUSTOM_TIER:
return claude_config
if claude_model_command_arg(model, claude_config.env) is not None:
# Already speakable: an alias pinned to exactly this id, or the
# custom slot already holding it.
return claude_config
normalized = normalized_model_id(model)
tier = next(
(family for family in _UCODE_CLAUDE_TIER_TO_ENV if family in normalized.split("-")),
None,
)
env = dict(claude_config.env)
displaced = env.get(_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV)
env[_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV] = model
env[_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV] = (
_claude_model_display_name(tier, model) if tier is not None else model
)
_logger.info(
"native-claude: pinned launch model %s into the custom picker slot%s",
model,
f" (displacing {displaced})" if displaced else "",
)
return replace(claude_config, env=env)
def _claude_model_display_name(tier: str, model_id: str) -> str:
"""Build a friendly family/version label from a routable model id."""
normalized = model_id.lower().removesuffix("[1m]")
@@ -621,6 +738,7 @@ def run_claude_native(
extra_args: tuple[str, ...] | None = None,
claude_args: tuple[str, ...] | None = None,
resume_picker: bool = False,
prompt: str | None = None,
command: str = _DEFAULT_CLAUDE_COMMAND,
use_claude_config: bool = False,
auto_open_conversation: bool = False,
@@ -640,6 +758,11 @@ def run_claude_native(
:param resume_picker: ``True`` runs the claude-native picker
once the server is reachable; ``False`` keeps the existing
``session_id``-or-fresh-session behavior.
:param prompt: Optional first prompt for the TUI, e.g.
``"review the last commit"``. Delivered as Claude Code's
positional prompt argument, so a multi-line prompt survives
intact (one argv entry — never a tmux paste). ``None`` starts
the TUI empty.
:param command: Executable to run in the terminal resource,
e.g. ``"claude"``. Kept off the public CLI surface so v0
always exposes Claude Code, while tests can supply a fake
@@ -669,6 +792,11 @@ def run_claude_native(
_preflight_local_tools(resolved_command)
startup_profiler.mark("local tools ready")
sanitized_args = _strip_resume_from_claude_args(claude_args)
# Claude Code takes the initial prompt as a positional argument, so it
# rides along with the launch args (persisted for the runner on the remote
# path). One argv entry keeps newlines and quotes intact.
if prompt and prompt.strip():
sanitized_args = (*sanitized_args, prompt)
startup_profiler.mark("claude args normalized")
# Resolve the launch config across all offerings: a configured provider
# (configure harnesses), the Databricks ucode profile, or Claude's own
@@ -1724,18 +1852,21 @@ def _ucode_config_for_profile(
agent_state.auth_refresh_interval_ms or _DEFAULT_UCODE_AUTH_REFRESH_INTERVAL_MS
)
claude_models = dict(workspace_state.claude_models)
routable_models: tuple[str, ...] = ()
if refresh_models:
live_models: dict[str, str] | None = None
try:
from omnigent.databricks_model_discovery import (
discover_databricks_claude_models,
discover_databricks_claude_catalog,
)
from omnigent.runtime.credentials.databricks import (
resolve_databricks_workspace,
)
creds = resolve_databricks_workspace(profile)
live_models = discover_databricks_claude_models(creds.host, creds.token)
live_catalog = discover_databricks_claude_catalog(creds.host, creds.token)
live_models = live_catalog.families
routable_models = live_catalog.model_ids
except Exception: # noqa: BLE001 — cached ucode state is the launch fallback
_logger.warning(
"native-claude: live Databricks model discovery failed for profile %r; "
@@ -1746,6 +1877,9 @@ def _ucode_config_for_profile(
if live_models is not None:
if not workspace_state.fable_enabled:
live_models.pop("fable", None)
routable_models = tuple(
model_id for model_id in routable_models if "fable" not in model_id.lower()
)
if not live_models:
raise click.ClickException(
f"Databricks profile {profile!r} exposes no Claude model services. "
@@ -1759,6 +1893,13 @@ def _ucode_config_for_profile(
_CLAUDE_CODE_API_KEY_HELPER_TTL_ENV: str(refresh_interval_ms),
_CLAUDE_CODE_USE_GATEWAY_ENV: "1",
_CLAUDE_CODE_CUSTOM_HEADERS_ENV: _DATABRICKS_CODING_AGENT_HEADER,
# The gateway allowlists beta flags and 400s the whole request
# ("invalid beta flag") on one it does not know, failing the turn
# rather than the feature. This env var is the only client-side way to
# drop them: the CLI computes ``anthropic-beta`` itself and ignores
# ANTHROPIC_CUSTOM_HEADERS. Tool search rides on a rejected flag
# (``advanced-tool-use``), so it was never reachable here anyway.
_CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS_ENV: "1",
}
# Pin each Claude Code model-tier alias to the corresponding Databricks
# gateway model ID so that the /model picker natively shows gateway model
@@ -1812,6 +1953,7 @@ def _ucode_config_for_profile(
model=default_model
or configured_default
or model_catalog.resolve_catalog_model("databricks", family="claude").model_id,
routable_models=routable_models,
)
@@ -1979,6 +2121,8 @@ def _bedrock_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcode
def _native_claude_config_from_entry(
entry: ProviderEntry,
*,
refresh_models: bool = True,
) -> ClaudeNativeUcodeConfig | None:
"""Map a resolved provider entry to a native Claude launch config.
@@ -1991,6 +2135,8 @@ def _native_claude_config_from_entry(
Claude Enterprise seat) — intentional, not a fallback to ucode.
:param entry: The resolved provider entry.
:param refresh_models: Forwarded to the ucode path's model discovery; pass
``False`` for a network-free lookup.
:returns: The launch config, or ``None`` to use Claude's own login.
"""
from omnigent.onboarding.provider_config import (
@@ -2007,7 +2153,7 @@ def _native_claude_config_from_entry(
return _bedrock_config_for_native_claude(entry)
if entry.kind == DATABRICKS_KIND:
_logger.info("native-claude routing: Databricks ucode profile %r", entry.profile)
return _ucode_config_for_profile(entry.profile)
return _ucode_config_for_profile(entry.profile, refresh_models=refresh_models)
_logger.info("native-claude routing: Claude CLI login (subscription provider %r)", entry.name)
return None
@@ -2015,6 +2161,7 @@ def _native_claude_config_from_entry(
def resolve_native_claude_config(
*,
spec: AgentSpec | None,
refresh_models: bool = True,
) -> ClaudeNativeUcodeConfig | None:
"""Resolve the native Claude Code launch config across all offerings.
@@ -2038,6 +2185,9 @@ def resolve_native_claude_config(
:param spec: The agent spec, or ``None`` for the bare ``omnigent
claude`` launch.
:param refresh_models: Query Databricks for the workspace's current Claude
model services while resolving the ucode config. Capability checks that
only need the routing shape pass ``False`` to stay network-free.
:returns: The launch config, or ``None`` to use Claude's own login.
"""
from omnigent.onboarding.detected import effective_config_with_detected
@@ -2055,18 +2205,18 @@ def resolve_native_claude_config(
if spec is not None:
entry = _resolve_provider_for_build(spec, harness_type="claude-sdk")
if entry is not None:
return _native_claude_config_from_entry(entry)
return _ucode_config_for_profile(spec.executor.profile)
return _native_claude_config_from_entry(entry, refresh_models=refresh_models)
return _ucode_config_for_profile(spec.executor.profile, refresh_models=refresh_models)
# 2. Spec-less (omnigent claude): explicit default wins first.
explicit = load_config()
entry = default_provider_for_harness(explicit, "claude-sdk")
if entry is not None:
return _native_claude_config_from_entry(entry)
return _native_claude_config_from_entry(entry, refresh_models=refresh_models)
# A global databricks auth block → ucode.
global_auth = _load_global_auth()
if isinstance(global_auth, DatabricksAuth):
return _ucode_config_for_profile(global_auth.profile)
return _ucode_config_for_profile(global_auth.profile, refresh_models=refresh_models)
if global_auth is not None:
# A global api_key auth: let Claude's own login handle it (parity
# with the subscription path); the in-process harness would inject
@@ -2075,7 +2225,7 @@ def resolve_native_claude_config(
# 3. Ambient detection (first run without configure).
entry = default_provider_for_harness(effective_config_with_detected(explicit), "claude-sdk")
if entry is not None:
return _native_claude_config_from_entry(entry)
return _native_claude_config_from_entry(entry, refresh_models=refresh_models)
_logger.info(
"native-claude routing: Claude CLI login (no provider configured for the Claude "
"harness, no Databricks profile). Run `omnigent setup --no-internal-beta` to route "
@@ -3529,6 +3679,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,
+305 -19
View File
@@ -31,6 +31,7 @@ import asyncio
import contextlib
import hashlib
import json
import logging
import os
import queue
import re
@@ -42,7 +43,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 +53,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
@@ -63,11 +65,16 @@ if TYPE_CHECKING:
from omnigent.inner.bundle_skills import claude_native_skill_args
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
from omnigent.inner.hook_scripts.subagent_router import (
AGENT_TOOL_MATCHER as CLAUDE_SUBAGENT_TOOL_MATCHER,
)
from omnigent.inner.os_env import OSEnvironment, create_os_environment
from omnigent.reasoning_effort import CLAUDE_EFFORTS
from omnigent.tools.base import Tool, ToolContext
from omnigent.tools.builtins.os_env import build_os_env_tools
_logger = logging.getLogger(__name__)
BRIDGE_DIR_ENV_VAR = "HARNESS_CLAUDE_NATIVE_BRIDGE_DIR"
REQUEST_SESSION_ID_ENV_VAR = "HARNESS_CLAUDE_NATIVE_REQUEST_SESSION_ID"
BRIDGE_ID_LABEL_KEY = "omnigent.claude_native.bridge_id"
@@ -167,6 +174,34 @@ _PASTED_PLACEHOLDER_PREFIX = "[Pasted text"
# whether the draft is rendered in the input box. Short enough to fit
# on the prompt row of a default 80-column detached pane.
_DRAFT_NEEDLE_MAX_CHARS = 24
# Footer Claude Code's interactive ``/model`` picker renders while it is open.
# Omnigent never drives that picker — it switches with ``/model <id>`` — but a
# picker the person opened by hand covers the input box, so an injection would
# be lost; the readiness gate treats it as "not ready".
_MODEL_PICKER_OPEN_HINT = "use this session only"
# Titles of the confirmation dialog Claude Code pops when a switch invalidates
# the prompt cache — one component, titled for what is being switched. It only
# appears on a session with history, and it took ~1.9s to render on a warm
# session, so it is polled for rather than slept past. Public because the
# injection sites live in other modules and pass one as their ``confirm_hint``.
SWITCH_MODEL_DIALOG_HINT = "Switch model?"
EFFORT_DIALOG_HINT = "Change effort level?"
_CONFIRM_DIALOG_HINTS = (SWITCH_MODEL_DIALOG_HINT, EFFORT_DIALOG_HINT)
# Surfaces a confirm Enter must never land on: they are never a slash command's
# own confirmation, and their default answer commits something the person did
# not ask for — the ``/model`` picker writes a new global default into
# ``~/.claude/settings.json``, and a tool permission prompt approves the tool.
# Every Claude Code permission prompt is titled "Do you want to …"; the second
# signature catches the remembered-approval row of the wider ones.
_FOREIGN_DIALOG_HINTS = (
_MODEL_PICKER_OPEN_HINT,
"Do you want to ",
"Yes, and don't ask again",
)
# Seconds to wait for a confirmation dialog before concluding none appears.
# Bounds the common no-dialog case (a fresh session never pops one) while
# still covering the slow warm-session render.
_CONFIRM_DIALOG_TIMEOUT_S = 4.0
# When Claude Code's input prompt never renders (it failed to boot), the
# readiness gate attaches the tail of the captured pane to its error so
# the real cause — often Claude Code's own startup crash, e.g. a
@@ -308,11 +343,17 @@ def _trusted_parent_for_bridge_dir(target: Path) -> Path:
if target.is_relative_to(acp_root):
return _absolute_syntactic_path(acp_root.parent.parent)
# The subagent router's per-session dirs sit beside the native bridges
# ($TMPDIR/omnigent-<uid>/subagent-router), so trust the same parent.
router_root = _absolute_syntactic_path(subagent_router_bridge_root())
if target.is_relative_to(router_root):
return _absolute_syntactic_path(router_root.parent.parent)
raise RuntimeError(
f"bridge dir {target!s} is not under an allowed bridge root "
f"({claude_root!s}, {codex_root!s}, {pi_root!s}, {cursor_root!s}, "
f"{antigravity_root!s}, {qwen_root!s}, {hermes_root!s}, {opencode_root!s}, "
f"{kiro_root!s}, {acp_root!s})"
f"{kiro_root!s}, {acp_root!s}, {router_root!s})"
)
@@ -748,6 +789,31 @@ def _ensure_secure_dir(target: Path) -> None:
os.chmod(ancestor, 0o700)
def ensure_secure_dir(target: Path) -> None:
"""Public alias for :func:`_ensure_secure_dir`.
The subagent router (``omnigent.runner.subagent_routing``) writes a
bearer-token advertisement under its own uid-scoped temp root and needs
the same ancestor hardening the bridges use.
:param target: Directory path to ensure, e.g. a router advertisement dir.
:raises RuntimeError: If validation fails for any ancestor.
"""
_ensure_secure_dir(target)
def subagent_router_bridge_root() -> Path:
"""Root for the subagent router's own advertisement directories.
Shares the uid-scoped temp parent with claude-native
(``$TMPDIR/omnigent-<uid>/subagent-router``) so per-session router dirs
pass the :func:`_trusted_parent_for_bridge_dir` secure-root check.
:returns: The subagent-router root directory (not created here).
"""
return _BRIDGE_ROOT_PARENT / "subagent-router"
def acp_mcp_bridge_root() -> Path:
"""Bridge root for the headless ACP harnesses' Omnigent-MCP relay.
@@ -833,6 +899,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.
@@ -847,6 +914,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
@@ -866,6 +938,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
@@ -1037,6 +1116,28 @@ def read_launch_model(bridge_dir: Path) -> str | None:
return model if isinstance(model, str) and model else None
def read_model_env(bridge_dir: Path) -> dict[str, str]:
"""
Read the launch env keys defining this session's model vocabulary.
:param bridge_dir: Bridge directory path.
:returns: ``{env var: model id}`` for the pinned aliases and custom
model option; empty when the session predates the record or ran
without a ucode profile.
"""
config = _read_json_file(bridge_dir / _CONFIG_FILE)
if not isinstance(config, dict):
return {}
model_env = config.get("model_env")
if not isinstance(model_env, dict):
return {}
return {
str(key): str(value)
for key, value in model_env.items()
if isinstance(key, str) and isinstance(value, str) and value
}
def read_bridge_id(bridge_dir: Path) -> str | None:
"""
Read the opaque bridge id from bridge config.
@@ -1146,6 +1247,8 @@ def build_hook_settings(
launch_model: str | None = None,
launch_permission_mode: str | None = None,
launch_effort: str | None = None,
subagent_router_dir: Path | None = None,
turn_routing: bool = False,
) -> _JsonObject:
"""
Build invocation-local Claude Code hook settings.
@@ -1174,6 +1277,16 @@ def build_hook_settings(
for the same re-exec hardening.
:param launch_effort: Effective launch effort from ``--effort``.
Mirrored into ``effortLevel`` for restart/re-exec parity.
:param subagent_router_dir: Directory where the runner advertises its
``route-subagent`` endpoint (``subagent_router.json``). When set,
a ``PreToolUse`` hook routes native subagent spawns; ``None``
leaves spawns unrouted.
:param turn_routing: ``True`` when the session launched with Smart
Routing on, which registers the ``UserPromptSubmit`` first-message
routing hook. ``False`` omits it: the hook would otherwise put a
routing round trip (25s worst case on a degraded server) in front of
every prompt of every native session, to be told every time that the
session does not route.
:returns: JSON-serializable Claude settings fragment.
"""
python = python_executable or sys.executable
@@ -1260,6 +1373,8 @@ def build_hook_settings(
# publish live token deltas to the web UI.
"MessageDisplay": [{"hooks": [message_display_hook]}],
}
if turn_routing:
hooks["UserPromptSubmit"].append({"hooks": [_claude_route_turn_hook(bridge_dir, python)]})
if ap_server_url:
_write_json_file(
bridge_dir / _PERMISSION_HOOK_FILE,
@@ -1358,6 +1473,36 @@ def build_hook_settings(
# server-side. Covers both web-UI-injected and direct-terminal
# prompts, since both fire UserPromptSubmit.
hooks["UserPromptSubmit"].append({"hooks": [evaluate_policy_hook]})
if subagent_router_dir is not None:
# Route natively spawned subagents (the Task/Agent tool) through
# the runner's route-subagent endpoint. Settings-level hooks also
# apply to nested spawns, so a routed subagent's own spawns are
# routed too. The script fails open — an unreachable endpoint
# emits no output and the spawn proceeds unchanged.
router_command_parts = [
python,
"-I",
"-m",
"omnigent.inner.hook_scripts.claude_router_hook",
"--bridge-dir",
str(bridge_dir),
"--router-dir",
str(subagent_router_dir),
]
from omnigent.inner.hook_scripts.subagent_router import HOOK_TIMEOUT_S
router_hook: _JsonObject = {
"type": "command",
"command": shlex.join(router_command_parts),
# Outermost hop of the routing timeout budget documented in
# ``omnigent.runner.subagent_routing``: derived from the hook
# script's own request budget so it always exceeds it and the
# script's fail-open branch runs before Claude kills it.
"timeout": int(HOOK_TIMEOUT_S),
}
hooks.setdefault("PreToolUse", []).append(
{"matcher": CLAUDE_SUBAGENT_TOOL_MATCHER, "hooks": [router_hook]}
)
settings: _JsonObject = {"hooks": hooks}
if launch_model:
settings["model"] = launch_model
@@ -1385,6 +1530,45 @@ def build_hook_settings(
return settings
def _claude_route_turn_hook(bridge_dir: Path, python: str) -> _JsonObject:
"""
Build the ``UserPromptSubmit`` entry for first-message model routing.
A no-op (exit 0, no output) unless the runner has advertised a
``route-turn`` endpoint in *bridge_dir* and nothing has routed this
session yet. When it does route it blocks the prompt and the runner
replays it, which applies the routed model on the way in. See
:mod:`omnigent.runner.turn_routing`.
:param bridge_dir: Bridge directory holding both the endpoint
advertisement and the hook's fast-skip marker.
:param python: Python executable to run the hook module with.
:returns: One Claude settings command-hook entry.
"""
from omnigent.runner.turn_routing import HARNESS_HOOK_TIMEOUT_S
return {
"type": "command",
"command": shlex.join(
[
python,
"-I",
"-m",
"omnigent.claude_native_hook",
"route-turn",
"--bridge-dir",
str(bridge_dir),
"--harness",
"claude-native",
]
),
# Outermost hop of the timeout ladder in ``omnigent.runner.turn_routing``:
# it must exceed the hook script's own request budget so the script's
# fail-open branch runs before Claude kills it.
"timeout": HARNESS_HOOK_TIMEOUT_S,
}
def url_component(value: str) -> str:
"""
Percent-encode one URL path component.
@@ -1422,6 +1606,8 @@ def augment_claude_args(
skills_filter: str | list[str] = "all",
append_system_prompt: str | None = None,
allowed_tools: tuple[str, ...] = (),
subagent_router_dir: Path | None = None,
turn_routing: bool = False,
) -> list[str]:
"""
Return Claude CLI args with Omnigent MCP/hook/skill injection.
@@ -1461,6 +1647,14 @@ def augment_claude_args(
append through Claude Code's native ``--append-system-prompt`` flag.
:param allowed_tools: Optional narrowly scoped Claude tool names to merge
into ``--allowedTools`` without replacing the user's allowlist.
:param subagent_router_dir: Directory advertising the runner's
``route-subagent`` endpoint, threaded to
:func:`build_hook_settings` so native ``Task`` spawns are routed.
``None`` leaves them unrouted.
:param turn_routing: ``True`` when the session launched with Smart
Routing on, threaded to :func:`build_hook_settings` so the
``UserPromptSubmit`` first-message routing hook is registered.
``False`` keeps every prompt off the routing round trip.
:returns: Augmented argument list for the terminal resource.
"""
mcp_config = build_mcp_config(bridge_dir, python_executable=python_executable)
@@ -1473,6 +1667,8 @@ def augment_claude_args(
launch_model=_arg_value(claude_args, "--model"),
launch_permission_mode=_arg_value(claude_args, "--permission-mode"),
launch_effort=_arg_value(claude_args, "--effort"),
subagent_router_dir=subagent_router_dir,
turn_routing=turn_routing,
)
args = _merge_disallowed_tools(list(claude_args), _OMNIGENT_DISALLOWED_TOOLS)
args = _merge_allowed_tools(args, allowed_tools)
@@ -2834,6 +3030,7 @@ def inject_slash_command(
command: str,
timeout_s: float = _TMUX_READY_TIMEOUT_S,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""
Type a Claude Code slash command into the tmux pane and submit it.
@@ -2843,18 +3040,21 @@ def inject_slash_command(
:param command: Single-line slash command including the leading
``/``, e.g. ``"/effort high"``.
:param timeout_s: Seconds to wait for ``tmux.json``, e.g. ``30.0``.
:param auto_confirm: If ``True``, send an extra ``Enter`` after a
short delay to accept the default option of any TUI confirmation
dialog that the command may pop (e.g. ``/effort`` / ``/model``
prompt when switching invalidates the prompt cache). HACK —
the chat UI has no way to render the CLI's TUI dialog, so
without this the command silently stalls. Assumes the default
option is "accept" (true today for effort + model). When no
dialog appears, the extra Enter falls on an empty prompt and is
a no-op. Callers that don't trigger confirmations should leave
this ``False``.
:param auto_confirm: If ``True``, accept the default option of the TUI
confirmation dialog the command pops (e.g. ``/effort`` when
switching invalidates the prompt cache). HACK — the chat UI has no
way to render the CLI's TUI dialog, so without this the command
silently stalls. Assumes the default option is "accept" (true today
for effort + model). Callers that don't trigger confirmations should
leave this ``False``.
:param confirm_hint: Text this command's dialog renders, e.g.
:data:`SWITCH_MODEL_DIALOG_HINT`. Required with *auto_confirm*: the
dialog is polled for by its own title so a late render (~1.9s on a
session with cached history) still gets its Enter, and so the Enter
cannot answer a dialog that is not ours.
:raises ValueError: If *command* is empty, does not start with
``/``, or contains a newline.
``/``, contains a newline, or *auto_confirm* is set without a
*confirm_hint*.
:raises RuntimeError: If the tmux target is not advertised in
time, or if a ``tmux send-keys`` invocation fails.
"""
@@ -2862,6 +3062,11 @@ def inject_slash_command(
raise ValueError(f"slash command must start with '/'; got {command!r}")
if "\n" in command:
raise ValueError("slash command must be a single line")
dialog_hint: str | None = None
if auto_confirm:
if not confirm_hint:
raise ValueError("auto_confirm needs the confirm_hint its dialog renders")
dialog_hint = confirm_hint
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
# ``C-u`` clears any draft the user is mid-typing; otherwise the
# paste below concatenates with their text and Enter submits
@@ -2871,12 +3076,60 @@ def inject_slash_command(
# ``-l`` pastes ``/`` and spaces literally; trailing Enter submits.
_run_tmux(info["socket_path"], "send-keys", "-l", "-t", info["tmux_target"], command)
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Enter")
if auto_confirm:
# Give the TUI time to render its confirmation dialog before
# the auto-Enter arrives; otherwise the keystroke races the
# prompt and gets dropped.
time.sleep(0.3)
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Enter")
if dialog_hint is not None:
_confirm_tui_dialog(info["socket_path"], info["tmux_target"], hint=dialog_hint)
def _confirm_tui_dialog(
socket_path: str,
tmux_target: str,
*,
hint: str,
timeout_s: float = _CONFIRM_DIALOG_TIMEOUT_S,
) -> bool:
"""
Accept the TUI confirmation dialog titled *hint*.
The dialog is polled for rather than slept past: a fixed 0.3s sleep dropped
the Enter on a warm session, where the dialog takes ~1.9s to render, and
left it open to swallow the person's next message. Polling for the
command's own title — not for "a dialog" — is also what keeps the Enter off
a surface that is not ours, e.g. a ``/model`` picker the person opened by
hand or a permission prompt that rendered mid-turn.
On timeout the Enter is still sent, so a dialog whose title drifted in a
Claude Code release does not sit open forever wedging the pane. It is
withheld only when the pane shows a :data:`_FOREIGN_DIALOG_HINTS` surface,
where taking the default answer would commit something unasked-for.
:param socket_path: Absolute path to the tmux socket.
:param tmux_target: tmux pane target string, e.g. ``"main"``.
:param hint: Text the dialog renders, e.g.
:data:`SWITCH_MODEL_DIALOG_HINT`.
:param timeout_s: Seconds to watch for the dialog, e.g. ``4.0``.
:returns: ``True`` when the dialog was seen and confirmed, ``False`` when
the watch timed out.
"""
deadline = time.monotonic() + timeout_s
while True:
pane = _capture_pane(socket_path, tmux_target)
if hint in pane:
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
return True
if time.monotonic() >= deadline:
break
time.sleep(_CLAUDE_READY_POLL_INTERVAL_S)
foreign = next((text for text in _FOREIGN_DIALOG_HINTS if text in pane), None)
if foreign is not None:
_logger.warning(
"claude-native: %r never rendered and the pane shows another surface "
"(%r); withholding the confirm Enter",
hint,
foreign,
)
return False
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
return False
def display_cost_approval_popup(
@@ -3056,6 +3309,39 @@ def _capture_pane(socket_path: str, tmux_target: str) -> str:
return proc.stdout if proc.returncode == 0 else ""
def claude_pane_ready(bridge_dir: Path) -> bool:
"""
Report whether the Claude pane is showing a usable input box right now.
"Usable" means the TUI is back at a mounted chat input with no ``/model``
picker or confirmation dialog on top of it — the state an injection needs
to land, and the settle signal after a model switch.
It is also the claude-native answer to "has the blocked prompt cleared?"
for first-message routing: a blocked ``UserPromptSubmit`` starts no turn
and persists nothing, so there is no turn id to wait out, and a mounted
input box with nothing on top of it is what says the replay may land.
Never raises: an unadvertised pane or a torn capture is "not ready yet".
:param bridge_dir: Bridge directory path.
:returns: ``True`` when the pane renders the chat input box.
"""
payload = _read_json_file(bridge_dir / _TMUX_FILE)
if not isinstance(payload, dict):
return False
socket_path = payload.get("socket_path")
tmux_target = payload.get("tmux_target")
if not isinstance(socket_path, str) or not isinstance(tmux_target, str):
return False
pane = _capture_pane(socket_path, tmux_target)
if _MODEL_PICKER_OPEN_HINT in pane:
return False
if any(text in pane for text in _CONFIRM_DIALOG_HINTS):
return False
return _claude_prompt_rendered(pane)
def _claude_prompt_rendered(pane: str) -> bool:
"""
Return whether Claude Code's input prompt is rendered in a pane.
+183
View File
@@ -186,6 +186,8 @@ def main(argv: list[str] | None = None) -> int:
return _main_ask_user_question(raw_argv[1:])
if raw_argv and raw_argv[0] == "evaluate-policy":
return _main_evaluate_policy(raw_argv[1:])
if raw_argv and raw_argv[0] == "route-turn":
return _main_route_turn(raw_argv[1:])
# Backwards compat: older bridge dirs may still reference the
# pre-tool-use subcommand before the terminal is restarted.
if raw_argv and raw_argv[0] == "pre-tool-use":
@@ -1139,5 +1141,186 @@ def _parse_headers(raw: str | None) -> dict[str, str]:
return {str(key): str(value) for key, value in parsed.items()}
def _main_route_turn(argv: list[str]) -> int:
"""
Route the model this session runs on, from its first real prompt.
The in-harness half of first-message routing (see
:mod:`omnigent.runner.turn_routing`), registered as an extra
``UserPromptSubmit`` command alongside the forwarder's status hook and
the policy gate. On every prompt submit, in order:
1. Fast skip on ``<bridge_dir>/turn_routing_done`` **when it names this
session** — no output, no network. The authoritative gate is the
endpoint's routing-decision check; this file only saves the round
trip, and a ``/clear`` rotation hands the same bridge dir to a new
conversation whose first message must still be able to route.
2. POST ``{session_id, prompt, harness, model}`` to the advertised
loopback ``route-turn`` endpoint. Claude's hook payload carries no
model, so ``model`` is the live one from ``context.json`` (the
statusLine snapshot) — never a config file, which reports the
launch model.
3. On a routed verdict: write the marker and BLOCK the prompt. The
hook does **not** touch the model itself — the pane is frozen
waiting on this very subprocess, so keystrokes sent from here would
queue behind the block. The runner replays the prompt through the
normal turn path, which applies the routed model under the pane's
inject lock and then delivers the text.
Fails open everywhere: an absent advertisement, an unreachable
endpoint or an unroutable verdict all exit ``0`` with no output, and
the prompt runs untouched on the current model.
:param argv: CLI argv after the ``route-turn`` subcommand, e.g.
``["--bridge-dir", "/tmp/x", "--harness", "claude-native"]``.
:returns: Process exit code. Always ``0`` — the block is expressed via
the JSON on stdout, never via the exit code.
"""
from omnigent.runner.turn_routing import (
ADVERTISEMENT_FILE,
HOOK_REQUEST_TIMEOUT_S,
ROUTE_PATH_TEMPLATE,
turn_routing_marker_present,
)
parser = argparse.ArgumentParser(prog="python -m omnigent.claude_native_hook route-turn")
parser.add_argument("--bridge-dir", required=True)
parser.add_argument("--harness", default="claude-native")
args = parser.parse_args(argv)
bridge_dir = Path(args.bridge_dir)
try:
payload = json.loads(sys.stdin.read() or "{}")
except json.JSONDecodeError:
return 0
if not isinstance(payload, dict):
return 0
prompt = payload.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
return 0
from omnigent.inner.hook_scripts.subagent_router import read_router_endpoint
endpoint = read_router_endpoint(bridge_dir, filename=ADVERTISEMENT_FILE)
if endpoint is None:
return 0
# The bridge's ACTIVE session wins over the advertisement's, which is
# written once at launch and goes stale the moment ``/clear`` re-keys this
# pane onto a new conversation. Same source the permission hook reads for
# the same reason — approvals and routing both have to follow rotations.
# Reading the stale id instead made the new conversation ask (and skip) as
# the superseded one.
session_id = read_active_session_id(bridge_dir) or endpoint.session_id
if not session_id:
return 0
# The marker is checked here, after the session id is known, because it is
# scoped to a session: a ``/clear`` rotation hands this same bridge dir to
# a NEW conversation, whose first message must still be able to route.
# Still zero network on the fast path.
if turn_routing_marker_present(bridge_dir, session_id):
return 0
body = {
"harness": args.harness,
"prompt": prompt,
# Claude's payload has no turn id the runner could match a replay
# against, and a blocked prompt starts no turn at all.
"turn_id": None,
"model": read_claude_status_model(bridge_dir),
}
url = endpoint.url + ROUTE_PATH_TEMPLATE.format(session_id=url_component(session_id))
decision = _route_turn_post(url, endpoint.token, body, HOOK_REQUEST_TIMEOUT_S)
if decision is None:
return 0
model = decision.get("model")
if decision.get("action") != "route" or not isinstance(model, str) or not model:
if decision.get("terminal"):
# Nothing will route this session again, so stop asking. Covers the
# no-op verdict too (the pick equals the live model): terminal and
# unblocking, so the prompt runs where it already was.
_write_turn_routing_marker(bridge_dir, session_id, decision)
return 0
# The marker is what tells the runner "this prompt was dropped, you owe
# it a replay", so a marker we could not write means we must not block.
if not _write_turn_routing_marker(bridge_dir, session_id, decision):
return 0
sys.stdout.write(
json.dumps(
{
"decision": "block",
"reason": f"Smart Routing selected {model}; rerunning your message on it.",
}
)
)
sys.stdout.flush()
return 0
def _write_turn_routing_marker(
bridge_dir: Path, session_id: str, decision: dict[str, object]
) -> bool:
"""
Write the session-scoped turn-routing marker file.
:param bridge_dir: Native Claude bridge directory.
:param session_id: Session the verdict belongs to — the conversation a
later ``/clear`` rotation creates must not fast-skip on it.
:param decision: The verdict, for its ``decision_id``.
:returns: ``True`` when the marker is on disk.
"""
from omnigent.runner.turn_routing import write_turn_routing_marker
decision_id = decision.get("decision_id")
if write_turn_routing_marker(
bridge_dir,
session_id=session_id,
decision_id=decision_id if isinstance(decision_id, str) else None,
):
return True
print(
"omnigent claude route-turn hook: could not write the turn marker",
file=sys.stderr,
)
return False
def _route_turn_post(
url: str,
token: str,
body: dict[str, object],
timeout: float,
) -> dict[str, object] | None:
"""
POST one JSON body to the loopback ``route-turn`` endpoint.
Uses :mod:`urllib` rather than the module's ``httpx`` import so the
call stays available to a ``python -I`` hook whose interpreter may not
resolve site packages the same way the CLI's does.
:param url: Fully-qualified loopback URL.
:param token: Bearer token from the advertisement.
:param body: Request body.
:param timeout: Socket timeout in seconds.
:returns: The decoded response object, or ``None`` on any transport or
decode failure (callers treat that as "allow unrouted").
"""
import urllib.error
import urllib.request
req = urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
decoded = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
return None
return decoded if isinstance(decoded, dict) else None
if __name__ == "__main__":
raise SystemExit(main())
+585 -24
View File
@@ -79,7 +79,8 @@ 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 LLMRoutingClient
from omnigent.smart_routing_cli import RoutingDecision
from omnigent.spec.types import LLMConfig
from omnigent.update_check import _InstalledWheelInfo
@@ -98,17 +99,22 @@ def _load_config(path: str | None) -> dict[str, Any]: # type: ignore[explicit-a
def _parse_model_prefixes(
raw: object,
) -> list[str]:
) -> list[str] | None:
"""Normalize the ``model_prefix`` config into a list of prefixes.
Accepts a single string (``"databricks-"``) or a list
(``["databricks-", "system.ai."]``); blanks are dropped. Returns an
empty list when unset, so catalog ids are sent verbatim.
(``["databricks-", "system.ai."]``); blanks are dropped.
:returns: The configured prefixes an explicit empty list is honoured as
"this catalog carries no prefix" or ``None`` when the key is absent or
malformed, leaving :data:`MODEL_ID_PREFIXES` in place.
"""
if raw is None:
return None
if isinstance(raw, str):
raw = [raw]
if not isinstance(raw, list):
return []
return None
return [p.strip() for p in raw if isinstance(p, str) and p.strip()]
@@ -121,9 +127,129 @@ def _routing_config_text(routing_cfg: Mapping[str, object], key: str) -> str:
raise click.ClickException(f"routing.{key} must be a string")
def parse_routing_settings(
routing_cfg: Any, # type: ignore[explicit-any] # parsed YAML block
) -> Any: # type: ignore[explicit-any] # RoutingSettings
"""Parse the ``routing:`` block into the shared ``RoutingSettings``.
This is the only place ``routing.*`` config is read; every consumer
(the routing clients, the subagent router) reads the dataclass off
``RuntimeCaps`` instead.
:param routing_cfg: The parsed ``routing:`` mapping, or ``None``.
:returns: A :class:`~omnigent.server.smart_routing.RoutingSettings`;
all-defaults when the block is absent or malformed.
"""
from omnigent.server.smart_routing import (
DEFAULT_ROUTER_NAME,
MODEL_ID_PREFIXES,
RoutingSettings,
parse_routing_tables,
)
if not isinstance(routing_cfg, dict):
return RoutingSettings()
router_name = (routing_cfg.get("router_name") or "").strip() or DEFAULT_ROUTER_NAME
selection_model = (routing_cfg.get("selection_model") or "").strip() or None
prefixes = _parse_model_prefixes(routing_cfg.get("model_prefix"))
return RoutingSettings(
router_name=router_name,
selection_model=selection_model,
# Only an absent key falls back: ``model_prefix: []`` means bare ids.
model_prefixes=MODEL_ID_PREFIXES if prefixes is None else tuple(prefixes),
# The arm menu / alias / effort tables a deployment fronting a different
# catalog overrides; absent keys keep the built-in defaults.
**parse_routing_tables(routing_cfg),
)
# Databricks workspaces serve the routing API under this path.
_AIGW_ROUTING_PATH = "/ai-gateway/routing/v1"
def _databricks_provider_profile(
cfg: Any, # type: ignore[explicit-any] # parsed server config
) -> str | None:
"""Return the profile of the config's Databricks provider, if any.
Reads the server ``--config`` first and falls back to the global
``providers:`` block, which is where most deployments declare their
workspace. A ``default:``-flagged entry wins so a workspace that also
declares a secondary Databricks provider still routes against the primary.
:param cfg: The parsed server ``--config`` mapping.
:returns: The Databricks profile name, or ``None`` when the deployment
declares no ``kind: databricks`` provider.
"""
providers = cfg.get("providers") if isinstance(cfg, dict) else None
if not isinstance(providers, dict):
from omnigent.onboarding.provider_config import load_config as load_provider_config
providers = load_provider_config().get("providers")
if not isinstance(providers, dict):
return None
matches: list[tuple[bool, str]] = []
for entry in providers.values():
if not isinstance(entry, dict) or entry.get("kind") != "databricks":
continue
profile = entry.get("profile")
if isinstance(profile, str) and profile.strip():
matches.append((bool(entry.get("default")), profile.strip()))
if not matches:
return None
matches.sort(key=lambda m: not m[0])
return matches[0][1]
def _build_default_databricks_routing_client(
cfg: Any, # type: ignore[explicit-any] # parsed server config
settings: Any, # type: ignore[explicit-any] # RoutingSettings
) -> Any | None: # type: ignore[explicit-any] # ExternalRoutingClient | None
"""Route through the workspace's AI Gateway when no ``routing:`` block exists.
A Databricks-backed deployment gets smart routing without extra
config: the client points at that workspace's routing API and authenticates
with the same profile. Returns ``None`` for any other deployment, so the
built-in judge stays the fallback.
:param cfg: The parsed server ``--config`` mapping.
:param settings: The parsed routing settings (all defaults here).
:returns: A configured client, or ``None`` when there is no Databricks
provider or its workspace host can't be resolved.
"""
profile = _databricks_provider_profile(cfg)
if profile is None:
return None
from omnigent.runtime.credentials.databricks import resolve_databricks_workspace
try:
host = resolve_databricks_workspace(profile).host.rstrip("/")
except Exception: # noqa: BLE001 — unresolvable workspace just means no routing
logging.getLogger(__name__).info(
"routing: could not resolve workspace host for Databricks profile %r; "
"leaving smart routing off",
profile,
)
return None
if not host:
return None
from omnigent.server.smart_routing import ExternalRoutingClient
return ExternalRoutingClient(
base_url=host + _AIGW_ROUTING_PATH,
router_name=settings.router_name,
databricks_profile=profile,
model_prefixes=list(settings.model_prefixes),
selection_model=settings.selection_model,
menus=settings.menus,
servable_aliases=settings.servable_aliases,
)
def _build_external_routing_client(
routing_cfg: Mapping[str, object],
) -> ExternalRoutingClient | None:
routing_cfg: Any, # type: ignore[explicit-any] # parsed YAML block
settings: Any = None, # type: ignore[explicit-any] # RoutingSettings | None
) -> Any | None: # type: ignore[explicit-any] # ExternalRoutingClient | None
"""Build an :class:`ExternalRoutingClient` from the ``routing:`` config.
Requires ``base_url`` + ``router_name``. Auth mirrors the ``llm:`` block:
@@ -137,14 +263,18 @@ def _build_external_routing_client(
:param routing_cfg: The parsed ``routing:`` mapping (a dict with
``provider == "external"``, per the caller).
:param settings: The parsed routing settings, supplying the extraction
model, scenario menus, and model prefixes. ``None`` parses them from
*routing_cfg*.
:returns: A configured client, or ``None`` when required config is
missing (a warning is logged; routing stays off rather than raising).
"""
if settings is None:
settings = parse_routing_settings(routing_cfg)
base_url = _routing_config_text(routing_cfg, "base_url")
router_name = _routing_config_text(routing_cfg, "router_name")
api_key = _routing_config_text(routing_cfg, "api_key")
profile = _routing_config_text(routing_cfg, "profile")
model_prefixes = _parse_model_prefixes(routing_cfg.get("model_prefix"))
if not base_url or not router_name:
click.echo(
@@ -176,7 +306,10 @@ def _build_external_routing_client(
router_name=router_name,
auth=auth,
databricks_profile=databricks_profile,
model_prefixes=model_prefixes,
model_prefixes=list(settings.model_prefixes),
selection_model=settings.selection_model,
menus=settings.menus,
servable_aliases=settings.servable_aliases,
)
@@ -205,6 +338,46 @@ def _build_local_llm_routing_client(
return LLMRoutingClient(policy_client)
def _build_routing_backends(
cfg: Any, # type: ignore[explicit-any] # parsed server config
server_llm: Any, # type: ignore[explicit-any] # LLMConfig | None
settings: Any, # type: ignore[explicit-any] # RoutingSettings
) -> Any: # type: ignore[explicit-any] # RoutingBackends
"""Build BOTH routing backends from configuration alone — no opt-in env needed.
They are not alternatives. The external client's picks are AI Gateway catalog
ids, so a harness whose inference runs off something else is served by the
built-in judge instead (see :mod:`omnigent.server.routing_backend`).
An explicit ``routing:`` block chooses the external side by ``provider``:
* ``external`` call an external ``routes:select`` service.
* ``none`` opt out of routing entirely; neither backend.
* anything else no external side, the built-in judge only.
With no ``routing:`` block at all, a Databricks-backed deployment gets its
own workspace AI Gateway as the external side. Managed deployments override
``RuntimeCaps.routing_backends`` themselves.
:param cfg: The parsed server ``--config`` mapping.
:param server_llm: The parsed server-level ``LLMConfig``, or ``None``.
:param settings: The parsed routing settings.
:returns: The pair; both sides may be ``None`` (routing off).
"""
from omnigent.server.routing_backend import RoutingBackends
routing_cfg = cfg.get("routing")
provider = routing_cfg.get("provider") if isinstance(routing_cfg, dict) else None
if provider == "none":
return RoutingBackends()
external: Any = None # type: ignore[explicit-any]
if provider == "external":
external = _build_external_routing_client(routing_cfg, settings)
elif not isinstance(routing_cfg, dict):
external = _build_default_databricks_routing_client(cfg, settings)
return RoutingBackends(external=external, local=_build_local_llm_routing_client(server_llm))
def _server_uvicorn_log_config(
log_path: Path | None = None,
*,
@@ -3569,26 +3742,18 @@ 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.
routing_cfg = cfg.get("routing")
routing_client: ExternalRoutingClient | LLMRoutingClient | None
if isinstance(routing_cfg, dict) and routing_cfg.get("provider") == "external":
routing_client = _build_external_routing_client(routing_cfg)
else:
routing_client = _build_local_llm_routing_client(server_llm)
routing_settings = parse_routing_settings(cfg.get("routing"))
routing_backends = _build_routing_backends(cfg, server_llm, routing_settings)
caps = RuntimeCaps(
execution_timeout=int(effective_timeout),
default_policies=parse_default_policies(cfg.get("policies")),
llm=server_llm,
routing_client=routing_client,
# The primary stays the single "is routing configured" answer for every
# legacy consumer; the pair is what a per-call selection reads.
routing_client=routing_backends.any(),
routing_backends=routing_backends,
routing_settings=routing_settings,
)
init_runtime(
conversation_store=conversation_store,
@@ -6034,6 +6199,11 @@ _RESUME_HELP = (
)
_CONTINUE_HELP = "Continue the most recent conversation for this agent."
_NO_SESSION_HELP = "Use a fresh temporary local session store for this run."
_SMART_ROUTING_HELP = (
"Let the server pick the model for this launch (and the harness too, "
"unless --harness pins one). Requires -p, except with --harness "
"claude-native / codex-native, which route on your first typed message."
)
_FORK_HELP = "Fork an existing session by id and open the REPL on the fork."
_LOG_HELP = "Write a JSON dump of the conversation to ~/.omnigent/logs/ on exit."
@@ -6319,12 +6489,14 @@ _NATIVE_TERMINAL_DISPATCH_SPECS: dict[str, _NativeTerminalDispatchSpec] = {
module="omnigent.claude_native",
function="run_claude_native",
args_param="extra_args",
prompt_param="prompt",
),
"codex": _NativeTerminalDispatchSpec(
module="omnigent.codex_native",
function="run_codex_native",
args_param="extra_args",
model_strategy="first_class",
prompt_param="prompt",
),
"pi": _NativeTerminalDispatchSpec(
module="omnigent.pi_native",
@@ -6505,6 +6677,359 @@ def _dispatch_native_terminal_harness(
return True
# ── Smart Routing (route before launch) ──────────────────────────────────
# Only a launch with no harness able to route from inside itself needs the text
# up front: the cross-harness route has to pick a harness before one exists to
# hook, and a harness without the first-message hook can never route in-session.
_SMART_ROUTING_NEEDS_PROMPT = (
"--smart-routing needs the text to route up front here: this launch has no "
'harness that can route from inside itself, so pass -p "<prompt>", start the '
"session from the web UI, or pin --harness claude-native / --harness "
"codex-native, which route on the first message you type."
)
#: Fail-open harness for a tier-3 route that returned nothing usable.
_SMART_ROUTING_FALLBACK_HARNESS = "claude-native"
def _smart_routing_capable_harness(harness: str | None) -> str | None:
"""
Canonical native harness for *harness*, when Smart Routing can launch it.
A routed launch has to carry the prompt into the TUI, so only native
terminal harnesses whose dispatch spec accepts a prompt qualify.
:param harness: Requested harness (canonical or alias), e.g.
``"claude-native"``. Bare ``"claude"`` canonicalizes to the SDK
harness, which is not routable here.
:returns: The canonical harness id, or ``None`` when it is not routable.
"""
from omnigent.native_coding_agents import native_coding_agent_for_harness
native = native_coding_agent_for_harness(harness)
if native is None:
return None
spec = _NATIVE_TERMINAL_DISPATCH_SPECS.get(native.key)
if spec is None or spec.prompt_param is None:
return None
return native.harness
def _require_smart_routing_prompt(
prompt: str | None, *, in_harness_routing: bool = True
) -> str | None:
"""
Resolve the text a routed launch starts from, rejecting an unroutable one.
A harness that hooks its own first typed message can be launched bare the
hook routes whatever the user types so a missing prompt is fine there.
Any other launch has to route before a harness exists to hook, and needs the
text up front.
:param prompt: The ``-p`` text, or ``None``.
:param in_harness_routing: Whether this launch's harness routes its own
first message (see
:func:`omnigent.runner.turn_routing.supports_in_harness_turn_routing`).
Defaults to ``True`` for the per-harness ``--smart-routing``
subcommands, which exist only on harnesses carrying that hook.
:returns: The prompt, or ``None`` when the harness routes in-session.
:raises click.UsageError: When there is no text and no in-harness route.
"""
if prompt is not None and prompt.strip():
return prompt
if in_harness_routing:
return None
raise click.UsageError(_SMART_ROUTING_NEEDS_PROMPT)
def _reject_smart_routing_resume(*, resuming: bool, flag: str = "--resume") -> None:
"""
Reject ``--smart-routing`` combined with a resume.
Routing happens when the session is created, so a routed launch is always a
new session; resuming one would silently ignore the routing request.
:param resuming: ``True`` when the invocation targets an existing session.
:param flag: The flag to name in the error, e.g. ``"--continue"``.
:returns: None when the combination is fine.
:raises click.ClickException: When *resuming* is ``True``.
"""
if not resuming:
return
raise click.ClickException(
f"--smart-routing routes a new session, so it cannot be combined with {flag}. "
f"Drop {flag} to route, or drop --smart-routing to reopen the existing session "
"on its own model."
)
def _with_routed_model_arg(args: tuple[str, ...], model: str | None) -> tuple[str, ...]:
"""
Append ``--model <routed>`` to a wrapper's pass-through args.
A ``--model`` the user typed themselves wins: they asked for that model
explicitly, and routing is a default-filling service.
:param args: The wrapper's pass-through args, e.g. ``("--verbose",)``.
:param model: Routed model id, or ``None`` to leave *args* alone.
:returns: The args, with the routed model appended when appropriate.
"""
if not model:
return args
if any(arg == "--model" or arg.startswith("--model=") for arg in args):
return args
return (*args, "--model", model)
def _smart_routing_decision(
*,
server: str,
prompt: str | None,
harness: str | None,
) -> RoutingDecision:
"""
Preflight Smart Routing, then create the routed session for *prompt*.
Preflight failures raise (a pick that cannot be applied is worse than no
pick); a create the server rejects comes back as a decision with no session
whose notice is printed here, so the caller only has to launch a plain
wrapper session.
A ``None`` *prompt* creates the session with Smart Routing on but nothing
routed: the harness's own first-message hook picks the model once the user
types, which is what the stderr line reports.
:param server: Resolved Omnigent server base URL.
:param prompt: The text to route (also the TUI's initial input), or ``None``
to leave the pick to the harness's in-session hook.
:param harness: Canonical harness to pin, or ``None`` to route the harness
too.
:returns: The routed session and pick to launch on.
:raises click.ClickException: When Smart Routing is unavailable.
"""
from omnigent.smart_routing_cli import (
check_smart_routing_available,
create_smart_routing_session,
known_host_id,
smart_routing_families,
)
# The session must be bound to the host it will run on: the server builds
# the router's candidate model catalog from that host's model-options
# frames, so the daemon has to be connected before we create (the wrapper
# ensures it again on attach; the call is idempotent).
host_id: str | None
try:
from omnigent.host.identity import load_or_create_host_identity
_ensure_host_daemon(server)
host_id = known_host_id(base_url=server, host_id=load_or_create_host_identity().host_id)
except (OSError, ValueError):
# No host identity yet — the per-host gate has nothing to read, which
# is the same "unknown does not gate" case as an older host.
host_id = None
check_smart_routing_available(
base_url=server,
harnesses=smart_routing_families(harness),
host_id=host_id,
)
decision = create_smart_routing_session(
base_url=server,
prompt=prompt,
harness=harness,
host_id=host_id,
# The server requires a workspace with a host_id, and this is the cwd
# the wrapper will attach in.
workspace=str(Path.cwd().resolve()) if host_id is not None else None,
)
if decision.notice is not None:
click.echo(decision.notice, err=True)
elif prompt is None:
click.echo(
"omnigent: Smart Routing is on for this session; your first message picks the model.",
err=True,
)
elif decision.model is not None:
picked = (
f"{decision.harness} on {decision.model}"
if decision.harness is not None
else decision.model
)
click.echo(f"omnigent: Smart Routing picked {picked}.", err=True)
return decision
def _dispatch_smart_routing(
*,
harness: str | None,
server: str | None,
prompt: str | None,
model: str | None,
auto_open_conversation: bool,
) -> None:
"""
Create the routed session, then attach its native TUI wrapper to it.
Tier 2 (*harness* given) routes the model only and keeps the requested
harness. Tier 3 (*harness* ``None``) routes both, and the wrapper is chosen
from the harness the server bound falling back to
:data:`_SMART_ROUTING_FALLBACK_HARNESS` (with a notice) when the create
resolved nothing, or a harness the CLI cannot hand a prompt to.
Without a *prompt*, a *harness* that routes its own first typed message is
launched bare: the session is created with Smart Routing on, and the TUI
starts with no initial input and no routed ``--model`` (there is no
create-time pick the harness's hook applies one in-session). The auto
route cannot do this, because a harness has to be chosen before one exists
to hook.
The wrapper always attaches to the created session rather than bundling its
own, so the routed model, the decision card, and the wrapper labels the
server wrote at create are the ones the launch runs on. When the create
failed entirely, the wrapper starts a plain session instead the launch is
never blocked.
:param harness: Canonical native harness to pin, or ``None`` for the auto
route.
:param server: ``--server`` value (or its config default), or ``None``.
:param prompt: The routed prompt (also the TUI's initial input), or ``None``
to let *harness* route its own first message.
:param model: ``--model`` fallback used when routing returns no model.
:param auto_open_conversation: Whether to open the web conversation.
:returns: None once the TUI attach ends.
:raises click.ClickException: When Smart Routing is unavailable.
:raises click.UsageError: When there is no prompt and no in-harness route.
"""
from omnigent.runner.turn_routing import supports_in_harness_turn_routing
prompt = _require_smart_routing_prompt(
prompt, in_harness_routing=supports_in_harness_turn_routing(harness)
)
server = _ensure_backend(server)
decision = _smart_routing_decision(server=server, prompt=prompt, harness=harness)
launch_harness = harness or _smart_routing_capable_harness(decision.harness)
if launch_harness is None:
launch_harness = _SMART_ROUTING_FALLBACK_HARNESS
click.echo(
f"omnigent: Smart Routing did not resolve a launchable harness; "
f"launching {launch_harness}.",
err=True,
)
routed_model = decision.model or model
_dispatch_native_terminal_harness(
harness=launch_harness,
server=server,
model=routed_model,
# A routed model is an explicit request, so wrappers that only take a
# model when the user asked for one still receive it.
model_from_cli=routed_model is not None,
prompt=prompt,
system_prompt=None,
tools=None,
log=False,
debug_events=False,
# Attach to the routed session; ``None`` (create failed) lets the
# wrapper start its own.
resume_conversation_id=decision.session_id,
resume_picker=False,
resume_latest=False,
fork_session_id=None,
ephemeral=False,
auto_open_conversation=auto_open_conversation,
)
def _run_smart_routing(
*,
target: str | None,
harness: str | None,
prompt: str | None,
server: str | None,
model: str | None,
resume_conversation_id: str | None,
resume_picker: bool,
resume_latest: bool,
auto_open_conversation: bool,
system_prompt: str | None = None,
tools: str | None = None,
log: bool = False,
debug_events: bool = False,
fork_session_id: str | None = None,
ephemeral: bool = False,
) -> None:
"""
Handle ``omnigent run --smart-routing``: validate, then route and launch.
``--harness`` (a native terminal harness) pins the harness and routes the
model; ``--harness auto`` or no ``--harness`` at all routes both, and always
needs ``-p`` (the harness is picked before one exists to route in). An AGENT
is rejected: a routed session is a native TUI, and an agent spec's
prompt/tools are never consulted there.
:param target: The AGENT argument as the user passed it, or ``None``.
:param harness: The ``--harness`` value the user passed, or ``None``.
:param prompt: The ``-p`` text, or ``None`` allowed only when
``--harness`` pins a harness that routes its own first message.
:param server: ``--server`` value or its config default.
:param model: ``--model`` fallback for an unrouted launch.
:param resume_conversation_id: ``--resume <id>`` target, rejected when set.
:param resume_picker: ``--resume`` with no value, rejected when set.
:param resume_latest: ``--continue``, rejected when set.
:param auto_open_conversation: Whether to open the web conversation.
:param system_prompt: ``--system-prompt`` value, rejected when set.
:param tools: ``--tools`` value, rejected when set.
:param log: ``--log``, rejected when set.
:param debug_events: ``--debug-events``, rejected when set.
:param fork_session_id: ``--fork`` value, rejected when set.
:param ephemeral: ``--no-session``, rejected when set.
:returns: None once the TUI attach ends.
:raises click.ClickException: On a rejected combination, or when Smart
Routing is unavailable.
"""
# The same REPL-only options the plain native dispatch rejects: a routed
# launch is still a TUI attach, so they would be silently dropped.
repl_only = [
flag
for flag, active in (
("--system-prompt", system_prompt is not None),
("--tools", tools is not None),
("--log", log),
("--debug-events", debug_events),
("--fork", fork_session_id is not None),
("--no-session", ephemeral),
)
if active
]
if repl_only:
raise click.ClickException(
"--smart-routing launches a native harness TUI; the REPL-only option(s) "
f"{', '.join(repl_only)} have no effect there — remove them."
)
_reject_smart_routing_resume(resuming=resume_conversation_id is not None or resume_picker)
_reject_smart_routing_resume(resuming=resume_latest, flag="--continue")
if target is not None:
raise click.ClickException(
"--smart-routing launches a native harness TUI, so it takes no AGENT. "
"Drop the AGENT to route the harness and model, or pass "
"`--harness claude-native` to route the model only."
)
requested: str | None = None
if harness is not None and harness != "auto":
requested = _smart_routing_capable_harness(harness)
if requested is None:
raise click.ClickException(
f"--smart-routing does not support --harness {harness!r}. Use a native "
"terminal harness that accepts a prompt (claude-native, codex-native, "
"kiro-native), or `--harness auto` to route the harness too."
)
_dispatch_smart_routing(
harness=requested,
server=server,
prompt=prompt,
model=model,
auto_open_conversation=auto_open_conversation,
)
def _reject_agent_with_native_terminal_harness(harness: str) -> None:
"""
Reject ``run AGENT --harness <x>-native``: native harnesses own their TUI.
@@ -6982,6 +7507,13 @@ def attach(
help="Client-side tool set name (e.g. 'coding') for shell access.",
)
@click.option("--harness", default=None, help=_RUN_HARNESS_HELP)
@click.option(
"--smart-routing",
"smart_routing",
is_flag=True,
default=False,
help=_SMART_ROUTING_HELP,
)
@click.option(
"--from-openclaw",
"from_openclaw",
@@ -7056,6 +7588,7 @@ def run(
target: str | None,
tools: str | None,
harness: str | None,
smart_routing: bool,
from_openclaw: str | None,
model: str | None,
prompt: str | None,
@@ -7086,6 +7619,8 @@ def run(
Examples:
omnigent run --harness claude-sdk
omnigent run --harness codex -p "review the last commit"
omnigent run --smart-routing -p "review the last commit"
omnigent run --harness claude-native --smart-routing -p "fix the flaky test"
omnigent run --from-openclaw "Gemini CLI" -p "review the last commit"
omnigent run examples/hello_world.yaml
omnigent run examples/hello_world.yaml --harness codex --model gpt-5.4-mini
@@ -7110,6 +7645,32 @@ def run(
model_from_cli = model_source is click.core.ParameterSource.COMMANDLINE
harness_source = click.get_current_context().get_parameter_source("harness")
harness_from_cli = harness_source is not None and harness_source.name == "COMMANDLINE"
# Smart Routing owns the whole launch: it routes before anything is
# created, then execs a native TUI wrapper. Handle it here, before the
# default-agent / first-run resolution below can substitute an agent the
# routed launch would have to reject.
if smart_routing:
_smart_routing_cfg = _load_effective_config()
_smart_routing_resume = _split_resume_value(resume)
_run_smart_routing(
target=target,
harness=harness if harness_from_cli else None,
prompt=prompt,
server=server if server_from_cli else _smart_routing_cfg.get("server"),
model=model if model_from_cli else None,
resume_conversation_id=_smart_routing_resume.conversation_id,
resume_picker=_smart_routing_resume.picker,
resume_latest=resume_latest,
auto_open_conversation=_resolve_auto_open_conversation_from_config(_smart_routing_cfg),
system_prompt=system_prompt,
tools=tools,
log=log,
debug_events=debug_events,
fork_session_id=fork_session_id,
ephemeral=ephemeral,
)
return
acp_agent: AcpAgentEntry | None = None
if from_openclaw is not None:
if target is not None:
+95 -1
View File
@@ -74,6 +74,14 @@ def register_native_commands(cli: click.Group) -> None:
)
_resolve_harness_startup_args = _late_bound(lambda: _cli._resolve_harness_startup_args)
_split_resume_value = _late_bound(lambda: _cli._split_resume_value)
_reject_smart_routing_resume = _late_bound(lambda: _cli._reject_smart_routing_resume)
_require_smart_routing_prompt = _late_bound(lambda: _cli._require_smart_routing_prompt)
_smart_routing_decision = _late_bound(lambda: _cli._smart_routing_decision)
_with_routed_model_arg = _late_bound(lambda: _cli._with_routed_model_arg)
from omnigent.runner.turn_routing import (
supports_in_harness_turn_routing as _supports_in_harness_turn_routing,
)
@cli.command(
context_settings={
@@ -155,6 +163,22 @@ def register_native_commands(cli: click.Group) -> None:
"flag will be removed in a future release."
),
)
@click.option(
"-p",
"--prompt",
default=None,
help="Open the Claude Code TUI with this as its initial prompt.",
)
@click.option(
"--smart-routing",
"smart_routing",
is_flag=True,
default=False,
help=(
"Let the server pick the model for this launch. With -p the pick "
"happens up front; without it, your first typed message picks it."
),
)
@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED)
def claude(
server: str | None,
@@ -164,6 +188,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 +199,8 @@ def register_native_commands(cli: click.Group) -> None:
# :param use_claude_config: When True, skip ucode/Databricks auth and use
# existing Claude config.
# :param profile_startup: When True, print startup timing marks.
# :param prompt: Optional initial TUI prompt.
# :param smart_routing: When True, route the model from ``prompt``.
# :param claude_args: Pass-through args for ``claude``.
"""Launch Claude Code with Omnigent.
@@ -182,8 +210,17 @@ def register_native_commands(cli: click.Group) -> None:
omnigent claude --resume conv_abc123
omnigent claude --resume # interactive picker
omnigent claude --server https://<app>.databricksapps.com
omnigent claude --smart-routing -p "fix the flaky test"
"""
_reject_native_on_windows("claude")
if smart_routing:
# Validate before any side effects (daemon spawn, server discovery)
# so an unroutable invocation fails instantly. This harness hooks
# its own first prompt, so a bare launch routes on what gets typed.
prompt = _require_smart_routing_prompt(
prompt,
in_harness_routing=_supports_in_harness_turn_routing("claude-native"),
)
startup_profiler = StartupProfiler.from_env(
name="omnigent claude",
env_var=_CLAUDE_STARTUP_PROFILE_ENV_VAR,
@@ -211,6 +248,12 @@ def register_native_commands(cli: click.Group) -> None:
"--session and --resume are mutually exclusive; "
"prefer --resume (--session is deprecated).",
)
if smart_routing:
_reject_smart_routing_resume(
resuming=choice.picker
or choice.conversation_id is not None
or session_id is not None
)
startup_profiler.mark("arguments validated")
# Ensure the host daemon (local when ``--server`` is omitted/empty,
@@ -243,11 +286,22 @@ def register_native_commands(cli: click.Group) -> None:
explicit=claude_command,
cfg=cfg,
)
extra_args = _resolve_harness_startup_args(cfg, "claude-native", claude_args)
if smart_routing:
# Routing creates the session (that is where the model is picked and
# the decision card is written), so attach to it instead of letting
# the wrapper bundle a fresh one.
decision = _smart_routing_decision(
server=server, prompt=prompt, harness="claude-native"
)
extra_args = _with_routed_model_arg(extra_args, decision.model)
resolved_session_id = decision.session_id or resolved_session_id
run_claude_native(
server=server,
session_id=resolved_session_id,
resume_picker=choice.picker,
extra_args=_resolve_harness_startup_args(cfg, "claude-native", claude_args),
extra_args=extra_args,
prompt=prompt,
use_claude_config=use_claude_config,
auto_open_conversation=auto_open_conversation,
startup_profiler=startup_profiler,
@@ -298,6 +352,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",
"smart_routing",
is_flag=True,
default=False,
help=(
"Let the server pick the model for this launch. With -p the pick "
"happens up front; without it, your first typed message picks it."
),
)
@click.argument("codex_args", nargs=-1, type=click.UNPROCESSED)
def codex(
server: str | None,
@@ -305,6 +369,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 +378,7 @@ def register_native_commands(cli: click.Group) -> None:
# :param session_id: Legacy ``--session`` id; mutually exclusive with ``--resume``.
# :param model: Codex model id.
# :param prompt: Optional first prompt.
# :param smart_routing: When True, route the model from ``prompt``.
# :param codex_args: Pass-through args for ``codex`` before ``resume``.
"""Launch Codex with Omnigent.
@@ -322,14 +388,31 @@ def register_native_commands(cli: click.Group) -> None:
omnigent codex --resume conv_abc123
omnigent codex --resume # interactive picker
omnigent codex --server https://<app>.databricksapps.com
omnigent codex --smart-routing -p "fix the flaky test"
"""
_reject_native_on_windows("codex")
model_source = click.get_current_context().get_parameter_source("model")
model_from_cli = model_source is click.core.ParameterSource.COMMANDLINE
if smart_routing:
# Validate before any side effects (daemon spawn, server discovery)
# so an unroutable invocation fails instantly. This harness hooks
# its own first prompt, so a bare launch routes on what gets typed.
prompt = _require_smart_routing_prompt(
prompt,
in_harness_routing=_supports_in_harness_turn_routing("codex-native"),
)
choice = _split_resume_value(resume)
if session_id is not None and (choice.picker or choice.conversation_id is not None):
raise click.UsageError(
"--session and --resume are mutually exclusive; "
"prefer --resume (--session is deprecated).",
)
if smart_routing:
_reject_smart_routing_resume(
resuming=choice.picker
or choice.conversation_id is not None
or session_id is not None
)
from omnigent.codex_native import run_codex_native
from omnigent.harness_startup_config import resolve_harness_command
@@ -357,6 +440,17 @@ def register_native_commands(cli: click.Group) -> None:
explicit=None,
cfg=cfg,
)
if smart_routing:
decision = _smart_routing_decision(
server=server, prompt=prompt, harness="codex-native"
)
# Codex takes the model first-class. A routed model beats the
# configured default (the user asked to route) but never an
# explicit ``--model``.
if decision.model is not None and not model_from_cli:
model = decision.model
# Attach to the routed session — routing created it.
resolved_session_id = decision.session_id or resolved_session_id
run_codex_native(
server=server,
session_id=resolved_session_id,
+182
View File
@@ -0,0 +1,182 @@
"""Codex's model vocabulary, and how to speak it.
Omnigent routes to servable catalog ids (``databricks-gpt-5-6-luna``), but
codex names the same model ``gpt-5.6-luna`` — the version segment is dotted
where the catalog hyphenates it. Two paths need the translation, and they need
it from opposite directions:
**Spawns** (``spawn_agent``). Codex validates ``model`` **client-side**,
against its own bundled catalog, before any request leaves the CLI. A catalog
id is rejected outright (probed live on codex 0.145.0)::
Unknown model `databricks-gpt-5-6-luna` for spawn_agent.
Available models: gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.2
The same validation caps the effort per model, again client-side::
Reasoning effort `xhigh` is not supported for model `system.ai.glm-5-2`.
Supported reasoning efforts: low, medium, high
so a session default of ``xhigh`` kills a GLM spawn unless the spawn's own
``reasoning_effort`` is clamped alongside its model. Models outside codex's
bundled catalog (GLM) have no slug until the session's codex-home extends the
catalog — see :data:`EXTENDED_CATALOG_MODELS`. :func:`codex_spawn_model`
returns ``None`` for anything else, and the caller falls open rather than
sending a value the CLI drops.
**Turns** (``thread/setModel`` on a live thread). Here codex is its own
vocabulary authority: the live ``model/list`` response IS the mapping, so
:func:`codex_model_slug` hardcodes no model id. The gateway serves either
spelling, so a thread switched onto a catalog id still RUNS; codex just warns
"Model metadata for databricks-gpt-5-6-luna not found. Defaulting to fallback
metadata" and leaves ``/model`` pointing at the launch slug, which reads as
"routing did nothing". Extended-catalog rows (``system.ai.glm-5-2``) are
listed under the catalog spelling, so they translate to themselves. An id no
row matches is returned verbatim — the turn still runs, which beats skipping
the switch.
Stdlib-only so hook subprocesses can import it on the spawn/routing paths.
"""
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from typing import Any
#: Catalog prefixes stripped before comparing ids. Same list as
#: :data:`omnigent.claude_model_vocabulary._CATALOG_PREFIXES`, and equal to
#: :data:`omnigent.server.smart_routing.MODEL_ID_PREFIXES` (both asserted by
#: ``tests/test_codex_model_vocabulary.py``); duplicated because this module
#: stays stdlib-only for hook subprocesses, which also means it cannot honour
#: a deployment's ``routing.model_prefix`` override.
#: The prefix a gateway model ROUTE carries, as opposed to a serving
#: endpoint's ``databricks-``; the extended catalog's ids are spelled with it.
_MODEL_ROUTE_PREFIX = "system.ai."
_CATALOG_PREFIXES: tuple[str, ...] = ("databricks-", _MODEL_ROUTE_PREFIX)
#: A bare gpt id, split into family, version digits, and optional tier —
#: ``gpt-5-6-luna`` → ``("gpt", "5", "6", "luna")``. Codex spells the
#: version with a dot and keeps the tier hyphenated.
_GPT_ID_RE = re.compile(r"^(gpt|codex)-(\d+)-(\d+)(?:-([a-z0-9]+))?$")
#: Models the gateway serves that codex's bundled catalog does not carry, so
#: omnigent adds them to the session's own catalog (``model_catalog_json``)
#: to make them spawnable. Bare id → the exact slug the entry is written
#: under, which is also the id the gateway serves the model as.
_GLM_ARM = "glm-5-2"
EXTENDED_CATALOG_MODELS: dict[str, str] = {_GLM_ARM: f"{_MODEL_ROUTE_PREFIX}{_GLM_ARM}"}
#: Efforts each extended model's catalog entry declares. Codex refuses any
#: other value for that model, so this is both the entry's ladder and the
#: clamp the spawn hook applies. Cheapest-safe fallback first.
EXTENDED_MODEL_EFFORTS: dict[str, tuple[str, ...]] = {_GLM_ARM: ("low", "medium", "high")}
#: Effort an extended model falls back to when the session asks for one its
#: ladder bars. Must agree with
#: :data:`omnigent.reasoning_effort._MODEL_EFFORT_FALLBACK` (asserted by
#: ``test_codex_effort_clamp_matches_the_runtime_clamp``).
EXTENDED_MODEL_DEFAULT_EFFORT: dict[str, str] = {_GLM_ARM: "medium"}
def bare_model_id(model: str) -> str:
"""Strip a catalog prefix and fold case, keeping codex's punctuation.
:param model: Any model id, e.g. ``"databricks-gpt-5-6-luna"``.
:returns: The bare id, e.g. ``"gpt-5-6-luna"``. A codex slug keeps its
dotted version (``"gpt-5.6-luna"``); use :func:`comparable_model_id`
to fold the two spellings together.
"""
bare = model.strip().lower().removesuffix("[1m]")
for prefix in _CATALOG_PREFIXES:
if bare.startswith(prefix):
return bare[len(prefix) :]
return bare
def comparable_model_id(model: str) -> str:
"""Fold a model id to the spelling codex ids compare in.
Comparison only, never a value to send anywhere: codex writes version
numbers with dots (``gpt-5.6-luna``) where the catalog writes dashes
(``databricks-gpt-5-6-luna``), and the prefix/case folding is the shared
catalog rule.
:param model: Any model id, catalog or codex spelling.
:returns: The comparable bare id, e.g. ``"gpt-5-6-luna"``.
"""
return bare_model_id(model).replace(".", "-")
def codex_spawn_model(model: str) -> str | None:
"""Translate a servable model id into codex's ``spawn_agent`` slug.
:param model: Servable catalog id, e.g. ``"databricks-gpt-5-6-luna"``.
:returns: The slug codex's spawn tool accepts, e.g.
``"gpt-5.6-luna"``; ``None`` when the id has no slug in codex's
catalog (Kimi), so the caller can fall open instead of sending a
value the CLI rejects.
"""
bare = comparable_model_id(model)
extended = EXTENDED_CATALOG_MODELS.get(bare)
if extended is not None:
return extended
match = _GPT_ID_RE.match(bare)
if match is None:
return None
family, major, minor, tier = match.groups()
slug = f"{family}-{major}.{minor}"
return f"{slug}-{tier}" if tier else slug
def clamp_spawn_effort(effort: str | None, model: str | None) -> str | None:
"""Coerce a spawn's ``reasoning_effort`` to one *model* accepts.
Codex validates the pairing client-side, so an effort outside the
model's ladder fails the spawn rather than degrading it. A model with no
declared ladder keeps whatever the caller asked for.
:param effort: The spawn's requested effort, or ``None`` when it named
none (codex then applies the model's catalog default, which is
already inside the ladder — nothing to clamp).
:param model: The spawn's model, after translation.
:returns: The effort to send, or ``None`` to leave it unset.
"""
if effort is None or model is None:
return effort
bare = comparable_model_id(model)
supported = EXTENDED_MODEL_EFFORTS.get(bare)
if supported is None or effort in supported:
return effort
return EXTENDED_MODEL_DEFAULT_EFFORT.get(bare, effort)
def codex_model_slug(
model: str,
options: Iterable[Mapping[str, Any]], # type: ignore[explicit-any] # raw model/list rows
) -> str:
"""Translate a routed model id into codex's own spelling.
:param model: Model id from a routing decision, e.g.
``"databricks-gpt-5-6-luna"``.
:param options: Raw ``model/list`` rows, e.g.
``[{"id": "gpt-5.6-luna", "model": "gpt-5.6-luna"}]``.
:returns: The matching row's ``id``, or *model* verbatim when no row
names the same model (an empty catalog included).
"""
if not isinstance(model, str) or not model.strip():
return model
target = comparable_model_id(model)
for option in options:
if not isinstance(option, Mapping):
continue
slug = option.get("id")
if not isinstance(slug, str) or not slug.strip():
continue
# ``model`` is the servable id behind the row when codex reports one
# separately from its own slug; matching either side keeps the
# translation working whichever spelling the deployment lists.
for spelling in (slug, option.get("model")):
if isinstance(spelling, str) and comparable_model_id(spelling) == target:
return slug.strip()
return model
+434 -116
View File
@@ -13,7 +13,7 @@ import socket
import sys
import tempfile
import uuid
from collections.abc import AsyncIterator, Sequence
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, TypeAlias, cast
@@ -40,6 +40,7 @@ from omnigent.codex_native_process_registry import (
)
from omnigent.inner import _proc
from omnigent.inner.codex_executor import (
_CODEX_ROUTER_HOOK_MODULE,
_clean_codex_env,
_codex_cli_version,
_codex_home_config_source_from_env,
@@ -49,7 +50,13 @@ from omnigent.inner.codex_executor import (
_find_codex_cli,
_populate_codex_home_config,
_provider_codex_config_overrides,
codex_extended_catalog_requested,
codex_router_bridge_dir,
codex_router_hooks_settings,
codex_router_session_id,
codex_routing_hook_skip_reason,
materialize_codex_provider_config,
write_codex_hooks_file,
)
from omnigent.inner.databricks_executor import _databricks_gateway_host
@@ -57,6 +64,9 @@ _logger = logging.getLogger(__name__)
CodexMessage: TypeAlias = _JsonObject
CodexParams: TypeAlias = _JsonObject
# A bound app-server JSON-RPC request coroutine (``client.request`` or the
# SDK executor's ``_request``), so the trust helpers work over either transport.
CodexRequestFn = Callable[[str, CodexParams], Awaitable[CodexMessage]]
_CONNECT_RETRY_DELAY_SECONDS = 0.05
_CONNECT_TIMEOUT_SECONDS = 10.0
@@ -95,9 +105,9 @@ _TRUSTED_HOOK_STATUSES = frozenset({"trusted", "managed"})
# warning rather than crash startup on an un-trustable hook.
_MIN_POLICY_HOOK_CODEX_VERSION = (0, 129, 0)
# Minimum codex CLI version that accepts ``--dangerously-bypass-hook-trust``.
# Added in openai/codex PR #21768, shipped in rust-v0.131.0 (2026-05-18).
# Below this the flag is unknown and codex exits immediately with an error,
# so we skip it and fall back to the old behaviour (trust prompt may appear).
# Older binaries exit immediately on the unknown flag, so below this floor
# (including a version we could not parse) the flag is omitted and the
# interactive trust prompt may appear instead.
_MIN_BYPASS_HOOK_TRUST_CODEX_VERSION = (0, 131, 0)
@@ -180,9 +190,45 @@ def _remove_toml_table(text: str, table_name: str) -> str:
return "".join(kept).rstrip()
#: Omnigent tools the framework calls on every session's behalf, pre-approved
#: so codex never raises an interactive prompt for them. The rename keeps a
#: session's title current, which the framework does unprompted on any session.
_FRAMEWORK_APPROVED_TOOLS: tuple[str, ...] = ("sys_session_rename",)
#: Additionally pre-approved for an auto-harness Smart Routing session, whose
#: spawns the router may move onto the counterpart harness family: these four
#: carry out that cross-harness redirect end to end — discover the agent, start
#: the routed child, deliver the task, collect its result. Without the last one
#: the redirect stalls on an approval prompt nobody is watching. A plain or
#: pinned session can never receive a redirect, so it gets none of them and its
#: approval surface stays a plain codex session's. Mirrors the claude-native
#: ``_ROUTED_SPAWN_ALLOWED_TOOLS`` gate.
_ROUTED_SPAWN_APPROVED_TOOLS: tuple[str, ...] = (
"sys_session_create",
"sys_agent_list",
"sys_session_send",
"sys_read_inbox",
)
def framework_approved_tools(*, routed_spawns: bool) -> tuple[str, ...]:
"""
Name the Omnigent tools this session pre-approves in codex.
:param routed_spawns: ``True`` for an auto-harness Smart Routing session,
which also needs the cross-harness redirect toolkit.
:returns: Tool names, in the order their approval tables are written.
"""
if not routed_spawns:
return _FRAMEWORK_APPROVED_TOOLS
return (*_FRAMEWORK_APPROVED_TOOLS, *_ROUTED_SPAWN_APPROVED_TOOLS)
def _codex_mcp_server_config_section(
bridge_dir: Path,
python_executable: str | None = None,
*,
routed_spawns: bool = False,
) -> str:
"""
Build the generated Codex MCP server TOML section.
@@ -192,8 +238,10 @@ def _codex_mcp_server_config_section(
:param python_executable: Python executable for serve-mcp, e.g.
``"/path/to/.venv/bin/python"``. ``None`` uses
:data:`sys.executable`.
:param routed_spawns: ``True`` for an auto-harness Smart Routing session,
which pre-approves the cross-harness redirect tools too.
:returns: TOML text for ``[mcp_servers.omnigent]`` and its
framework-managed rename-tool approval.
framework-managed tool approvals.
"""
python = python_executable or sys.executable
args = [
@@ -205,15 +253,23 @@ def _codex_mcp_server_config_section(
str(bridge_dir),
]
args_toml = ", ".join(json.dumps(a) for a in args)
approvals = "\n".join(
f'[mcp_servers.omnigent.tools.{tool}]\napproval_mode = "approve"\n'
for tool in framework_approved_tools(routed_spawns=routed_spawns)
)
return (
f"[mcp_servers.omnigent]\n"
f"command = {json.dumps(python)}\n"
f"args = [{args_toml}]\n\n"
"[mcp_servers.omnigent.tools.sys_session_rename]\n"
'approval_mode = "approve"\n'
f"{approvals}"
)
# Top-level ``model_reasoning_effort = "<value>"`` line, capturing the value so
# it can be clamped to one the pinned model accepts. Tolerates a trailing comment.
_EFFORT_KEY_RE = re.compile(r'^(\s*model_reasoning_effort\s*=\s*")([^"]*)("\s*(?:#.*)?)$')
def _pin_codex_config_model(codex_home: Path, model: str) -> None:
"""
Write *model* as the top-level ``model`` key in the session config.toml.
@@ -230,6 +286,8 @@ def _pin_codex_config_model(codex_home: Path, model: str) -> None:
:param codex_home: Private per-session ``CODEX_HOME`` directory.
:param model: Validated model id to pin.
"""
from omnigent.reasoning_effort import clamp_effort_for_model
config_path = codex_home / "config.toml"
# Same symlink-materialization dance as the MCP injection: never edit
# the user's real config.toml through the link.
@@ -250,7 +308,15 @@ def _pin_codex_config_model(codex_home: Path, model: str) -> None:
if re.match(r"^model\s*=", line):
lines[i] = pin_line
replaced = True
break
continue
# The config copies the user's default effort (e.g. xhigh), which the
# pinned model may reject (GLM has no xhigh). Clamp it to a value the
# model accepts rather than 400 the turn.
effort_match = _EFFORT_KEY_RE.match(line)
if effort_match:
clamped = clamp_effort_for_model(effort_match.group(2), model)
if clamped and clamped != effort_match.group(2):
lines[i] = f"{effort_match.group(1)}{clamped}{effort_match.group(3)}"
if not replaced:
lines.insert(0, pin_line)
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -323,6 +389,8 @@ def _inject_mcp_server_config(
codex_home: Path,
bridge_dir: Path,
python_executable: str | None = None,
*,
routed_spawns: bool = False,
) -> None:
"""
Upsert Omnigent MCP server config into ``config.toml``.
@@ -338,6 +406,8 @@ def _inject_mcp_server_config(
and ``tool_relay.json``.
:param python_executable: Python executable for serve-mcp.
``None`` uses :data:`sys.executable`.
:param routed_spawns: ``True`` for an auto-harness Smart Routing session,
which pre-approves the cross-harness redirect tools too.
:returns: None.
"""
config_path = codex_home / "config.toml"
@@ -355,7 +425,9 @@ def _inject_mcp_server_config(
else:
existing = ""
updated = _remove_toml_table(existing, "mcp_servers.omnigent")
section = _codex_mcp_server_config_section(bridge_dir, python_executable)
section = _codex_mcp_server_config_section(
bridge_dir, python_executable, routed_spawns=routed_spawns
)
rendered = f"{updated}\n\n{section}" if updated else section
config_path.write_text(rendered, encoding="utf-8")
@@ -801,6 +873,7 @@ class CodexNativeAppServer:
process_owner_lock: CodexNativeProcessOwnerLock | None = None
codex_cli_version: tuple[int, int, int] | None = None
trust_project: bool = False
router_hooks_registered: bool = False
async def start(self) -> None:
"""
@@ -813,16 +886,61 @@ class CodexNativeAppServer:
if self.listen_url is None or self.listen_url.startswith("unix://"):
with contextlib.suppress(FileNotFoundError):
self.socket_path.unlink()
_populate_codex_home_config(
# Native policy enforcement needs codex's hook-trust protocol
# (``currentHash`` / ``trustStatus`` in ``hooks/list``), added in
# codex 0.129. Below that the hook can never be trusted, so
# registering it would only fail at the trust gate. Probed before
# the home is populated: on an unsupported codex no hooks file is
# generated at all, so the user's hooks.json must still be
# symlinked in rather than left missing. A version we cannot parse
# (``None``) is treated as supported so a flaky probe never
# silently disables enforcement — a genuine trust failure is then
# caught below.
codex_version = await _codex_cli_version(self.codex_path)
self.codex_cli_version = codex_version
policy_hooks_supported = (
codex_version is None or codex_version >= _MIN_POLICY_HOOK_CODEX_VERSION
)
# When the runner advertises a route-subagent endpoint, the generated
# hooks file owns hooks.json, so the user's copy is merged in rather
# than symlinked over. The runner advertises it for auto-harness Smart
# Routing sessions only, so its presence is also this session class's
# signature — see ``ensure_session_router_quietly``.
router_bridge_dir = codex_router_bridge_dir(self.env)
if router_bridge_dir is not None:
# A CLI too old for the spawn gate gets no routing hooks at all, so
# routing no-ops instead of blocking the launch. Everything keyed
# off the advertisement below (generated hooks.json, the routed-spawn
# tool pre-approvals) then falls back to the plain shape.
skip_reason = codex_routing_hook_skip_reason(codex_version)
if skip_reason is not None:
_logger.warning("%s", skip_reason)
router_bridge_dir = None
self.router_hooks_registered = router_bridge_dir is not None and policy_hooks_supported
routed_spawns = router_bridge_dir is not None
config_source = _codex_home_config_source_from_env()
# Off the loop: this copies/symlinks a home AND (on a Smart Routing
# session) shells out to ``codex debug models`` with a 10s timeout. Run
# inline it stalled every other session sharing this event loop for that
# long — which is also why a plain session must never reach the probe.
await asyncio.to_thread(
_populate_codex_home_config,
self.codex_home,
_codex_home_config_source_from_env(),
config_source,
inject_hooks=self.router_hooks_registered,
extend_model_catalog=codex_extended_catalog_requested(self.env),
)
if self.trust_project:
_trust_codex_project(self.codex_home, self.cwd)
# Write the MCP server config into config.toml so the app-server
# discovers it at config load. The -c overrides may not be honored
# by `codex app-server`, so we write directly to the file.
_inject_mcp_server_config(self.codex_home, self.bridge_dir, self.python_executable)
_inject_mcp_server_config(
self.codex_home,
self.bridge_dir,
self.python_executable,
routed_spawns=routed_spawns,
)
if self.pinned_model:
_pin_codex_config_model(self.codex_home, self.pinned_model)
_sync_codex_developer_instructions(
@@ -833,18 +951,7 @@ class CodexNativeAppServer:
self.codex_home,
self.config_overrides,
)
# Native policy enforcement needs codex's hook-trust protocol
# (``currentHash`` / ``trustStatus`` in ``hooks/list``), added in
# codex 0.129. Below that the hook can never be trusted, so
# registering it would only fail at the trust gate. Detect the
# version up front; below the minimum we skip registration and
# degrade to "no enforcement" with a surfaced reason. A version we
# cannot parse (``None``) is treated as supported so a flaky probe
# never silently disables enforcement — a genuine trust failure is
# then caught below.
codex_version = await _codex_cli_version(self.codex_path)
self.codex_cli_version = codex_version
if codex_version is not None and codex_version < _MIN_POLICY_HOOK_CODEX_VERSION:
if codex_version is not None and not policy_hooks_supported:
self._disable_policy_hook(
f"Codex CLI {_format_codex_version(codex_version)} is older than "
f"{_format_codex_version(_MIN_POLICY_HOOK_CODEX_VERSION)}; upgrade "
@@ -858,7 +965,17 @@ class CodexNativeAppServer:
# ap_server_url the hook is still registered + trusted but
# no-ops.
_write_codex_policy_hooks_file(
self.codex_home, self.bridge_dir, self.python_executable
self.codex_home,
self.bridge_dir,
self.python_executable,
router_bridge_dir=router_bridge_dir,
router_session_id=codex_router_session_id(self.env),
user_hooks_source=config_source / _CODEX_HOOKS_FILE,
# The runner only advertises a route-turn endpoint for a
# session that launched with Smart Routing on, so its presence
# is the switch for the first-message routing hook. Same
# rendezvous-as-switch shape as the subagent router above.
turn_routing=_turn_router_advertised(self.bridge_dir),
)
if self.ap_server_url:
write_policy_hook_config(
@@ -908,6 +1025,13 @@ class CodexNativeAppServer:
self._stderr_loop(),
name="codex-native-app-server-stderr",
)
# Ordering invariant: hooks.json is written before the spawn above,
# and the trust handshake must complete before the first turn — codex
# resolves trust when it dispatches a hook, so trust landing after the
# spawn is fine, but a turn started before it runs unhooked. The
# handshake cannot precede the spawn (``hooks/list`` is an app-server
# RPC), so callers must not launch the TUI or dispatch a turn until
# ``start()`` returns.
# Readiness failure (the app-server never came up) is fatal and
# tears down the subprocess so it is not orphaned. Policy-hook
# trust, by contrast, is best-effort: a trust failure degrades the
@@ -957,6 +1081,18 @@ class CodexNativeAppServer:
await client.connect()
try:
await trust_native_policy_hooks(client, cwd=str(self.cwd))
# Routing hooks live in the same generated file but under a
# different module, so they need their own trust pass. Best
# effort: a routing-trust failure must not disable the policy
# gate, so it is logged instead of raised.
if self.router_hooks_registered:
try:
await trust_codex_router_hooks(client.request, cwd=str(self.cwd))
except Exception: # noqa: BLE001 - routing trust never blocks startup
_logger.warning(
"codex subagent-routing hook trust failed; routing will not be enforced",
exc_info=True,
)
except RuntimeError as exc:
raise RuntimeError(f"{exc}{self._codex_config_error_hint()}") from exc
finally:
@@ -1115,21 +1251,56 @@ def _codex_policy_hook_command(bridge_dir: Path, python_executable: str | None)
"""
Build the shell command codex runs for the policy hook.
Runs python in isolated mode (``-I``): codex executes hooks with the
session's workspace as cwd, and ``-m`` would otherwise put that
workspace first on ``sys.path``. A workspace holding a directory named
like one of our packages (the omnigent checkout itself, most obviously)
then shadows the installed one and the hook dies on an import error
that codex discards — a silent fail-open. Mirrors the ``-I`` the
bridge's MCP server command already uses.
:param bridge_dir: Native Codex bridge directory passed to the hook
via ``--bridge-dir``.
:param python_executable: Python executable to run, e.g.
``"/path/to/python"``. ``None`` uses :data:`sys.executable`.
:returns: A shell-escaped command string, e.g.
``"/path/python -m omnigent.codex_native_hook evaluate-policy
``"/path/python -I -m omnigent.codex_native_hook evaluate-policy
--bridge-dir /home/u/.omnigent/codex-native/abc"``.
"""
python = python_executable or sys.executable
return shlex.join(
[python, "-m", _POLICY_HOOK_MODULE, "evaluate-policy", "--bridge-dir", str(bridge_dir)]
[
python,
"-I",
"-m",
_POLICY_HOOK_MODULE,
"evaluate-policy",
"--bridge-dir",
str(bridge_dir),
]
)
def _codex_policy_hooks_settings(bridge_dir: Path, python_executable: str | None) -> _JsonObject:
def _turn_router_advertised(bridge_dir: Path) -> bool:
"""
Report whether the runner advertised a ``route-turn`` endpoint here.
:param bridge_dir: Native Codex bridge directory.
:returns: ``True`` when a usable ``turn_router.json`` is present, i.e. the
session launched with Smart Routing on.
"""
from omnigent.inner.hook_scripts.subagent_router import read_router_endpoint
from omnigent.runner.turn_routing import ADVERTISEMENT_FILE
return read_router_endpoint(bridge_dir, filename=ADVERTISEMENT_FILE) is not None
def _codex_policy_hooks_settings(
bridge_dir: Path,
python_executable: str | None,
*,
turn_routing: bool = False,
) -> _JsonObject:
"""
Build the ``hooks.json`` payload registering the policy hook.
@@ -1144,115 +1315,131 @@ def _codex_policy_hooks_settings(bridge_dir: Path, python_executable: str | None
:param bridge_dir: Native Codex bridge directory.
:param python_executable: Python executable for the hook command.
:param turn_routing: ``True`` when the runner advertised a ``route-turn``
endpoint for this session, i.e. it launched with Smart Routing on.
``False`` leaves the first-message routing hook unregistered, so a
session that will never route pays no per-prompt round trip.
:returns: A ``hooks.json``-shaped dict.
"""
hook = {
hook: _JsonObject = {
"type": "command",
"command": _codex_policy_hook_command(bridge_dir, python_executable),
"timeout": _POLICY_HOOK_TIMEOUT_SECONDS,
}
prompt_submit: list[_JsonObject] = [hook]
if turn_routing:
prompt_submit.append(_codex_route_turn_hook(bridge_dir, python_executable))
return {
"hooks": {
"PreToolUse": [{"hooks": [hook]}],
"PostToolUse": [{"hooks": [hook]}],
"UserPromptSubmit": [{"hooks": [hook]}],
"UserPromptSubmit": [{"hooks": prompt_submit}],
}
}
def _merge_user_hooks(policy_payload: _JsonObject, user_hooks_path: Path) -> _JsonObject:
def _codex_route_turn_hook(bridge_dir: Path, python_executable: str | None) -> _JsonObject:
"""
Merge user-declared hooks into the policy hooks payload.
Build the ``UserPromptSubmit`` entry for first-message model routing.
When a symlinked ``hooks.json`` exists in the private ``CODEX_HOME``
(the user's real ``~/.codex/hooks.json``), its hook entries are
appended after Omnigent's policy hooks for each shared event, and any
events declared only by the user are added wholesale. This preserves
all user hooks while keeping the Omnigent policy hooks in first
position so they always run before user hooks.
A second command alongside the policy gate rather than a module of its
own: codex trusts hooks by command, and the trust pass filters on
:data:`_POLICY_HOOK_MODULE`, so keeping the subcommand there rides the
existing handshake. It no-ops (exit 0, no output) unless the runner has
advertised a ``route-turn`` endpoint and nothing has pinned the
session's model yet; when it does route, it blocks the prompt and the
runner replays it on the routed model. See
:mod:`omnigent.runner.turn_routing`.
:param policy_payload: The ``hooks.json``-shaped dict built by
:func:`_codex_policy_hooks_settings`.
:param user_hooks_path: Path to the user's real ``hooks.json``; must
be readable.
:returns: Merged payload, or *policy_payload* unchanged on any read
or parse error (best-effort — policy enforcement must never fail
because the user's hooks file is malformed).
:param bridge_dir: Native Codex bridge directory, holding both the
endpoint advertisement and the marker file.
:param python_executable: Python executable for the hook command.
:returns: One ``hooks.json`` command-hook entry.
"""
try:
decoded: object = json.loads(user_hooks_path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return policy_payload
user_data = _string_object_dict(decoded)
user_hooks = _string_object_dict(user_data.get("hooks")) if user_data is not None else None
if not user_hooks:
return policy_payload
policy_hooks = _string_object_dict(policy_payload.get("hooks"))
if policy_hooks is None:
return policy_payload
merged: _JsonObject = dict(policy_payload)
merged_hooks: _JsonObject = dict(policy_hooks)
merged["hooks"] = merged_hooks
for event, entries in user_hooks.items():
user_entries = _object_list(entries)
if user_entries is None:
continue
existing_entries = _object_list(merged_hooks.get(event))
if existing_entries is not None:
merged_hooks[event] = existing_entries + user_entries
else:
merged_hooks[event] = user_entries
return merged
from omnigent.runner.turn_routing import HARNESS_HOOK_TIMEOUT_S
return {
"type": "command",
"command": shlex.join(
[
python_executable or sys.executable,
"-I",
"-m",
_POLICY_HOOK_MODULE,
"route-turn",
"--bridge-dir",
str(bridge_dir),
"--harness",
"codex-native",
]
),
"timeout": HARNESS_HOOK_TIMEOUT_S,
}
def _write_codex_policy_hooks_file(
codex_home: Path, bridge_dir: Path, python_executable: str | None
codex_home: Path,
bridge_dir: Path,
python_executable: str | None,
*,
router_bridge_dir: Path | None = None,
router_session_id: str | None = None,
user_hooks_source: Path | None = None,
turn_routing: bool = False,
) -> None:
"""
Write ``hooks.json`` into the private CODEX_HOME (atomically).
When ``_populate_codex_home_config`` has symlinked the user's
``hooks.json`` into the private home, its entries are merged into the
policy hooks payload before the file is written so user hooks fire
alongside Omnigent's policy hooks. The symlink is replaced by a
regular merged file.
This file is the only ``hooks.json`` codex loads, so the policy hooks,
the subagent-routing hooks and the user's own hooks all go through the
shared :func:`write_codex_hooks_file` into one payload — written
separately, whichever ran last would erase the other.
:param codex_home: Private per-session ``CODEX_HOME`` directory.
:param bridge_dir: Native Codex bridge directory for the hook command.
:param python_executable: Python executable for the hook command.
:param router_bridge_dir: Directory advertising the route-subagent
endpoint. ``None`` leaves native subagent spawns unrouted.
:param router_session_id: Session id baked into the routing hook
commands.
:param user_hooks_source: The user's real ``hooks.json`` to merge when
the private home holds no symlink to it (the routing path unlinks
it before this runs).
:param turn_routing: ``True`` when the session launched with Smart Routing
on, which registers the ``UserPromptSubmit`` first-message routing
hook.
:returns: None.
"""
codex_home.mkdir(mode=0o700, parents=True, exist_ok=True)
path = codex_home / _CODEX_HOOKS_FILE
payload = _codex_policy_hooks_settings(bridge_dir, python_executable)
if path.is_symlink() and path.exists():
payload = _merge_user_hooks(payload, path.resolve())
path.unlink()
fd, tmp_name = tempfile.mkstemp(prefix=f"{_CODEX_HOOKS_FILE}.", dir=str(codex_home))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, sort_keys=True)
handle.write("\n")
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
payloads: list[Mapping[str, object]] = [
_codex_policy_hooks_settings(bridge_dir, python_executable, turn_routing=turn_routing)
]
if router_bridge_dir is not None:
payloads.append(
codex_router_hooks_settings(
router_bridge_dir,
session_id=router_session_id,
harness="codex-native",
python_executable=python_executable,
)
)
_ = write_codex_hooks_file(codex_home, payloads, user_hooks_source=user_hooks_source)
def _our_policy_hooks_from_list(listed: _JsonObject, cwd: str) -> list[_JsonObject]:
def _our_hooks_from_list(listed: _JsonObject, cwd: str, module: str) -> list[_JsonObject]:
"""
Extract *our* policy hooks for *cwd* from a ``hooks/list`` response.
Extract the hooks for *cwd* whose command runs *module*.
Filters to hooks whose command references :data:`_POLICY_HOOK_MODULE`
so the trust step never touches hooks the user's symlinked
``config.toml`` might declare.
Filtering by module keeps the trust step from ever touching hooks the
user's own ``hooks.json`` contributed to the merged file.
:param listed: Parsed ``hooks/list`` response envelope, with
``result.data`` a list of ``{cwd, hooks: [...]}`` entries.
:param cwd: The cwd whose hook set to read, e.g.
``"/home/user/repo"``.
:returns: The matching Omnigent hook metadata dicts (possibly
empty), each with ``key``, ``currentHash``, ``trustStatus``.
:param module: Hook-script module marker, e.g.
``"omnigent.codex_native_hook"``.
:returns: The matching hook metadata dicts (possibly empty), each
with ``key``, ``currentHash``, ``trustStatus``.
"""
result = _string_object_dict(listed.get("result"))
if result is None:
@@ -1266,11 +1453,23 @@ def _our_policy_hooks_from_list(listed: _JsonObject, cwd: str) -> list[_JsonObje
hook
for raw_hook in hooks
if (hook := _string_object_dict(raw_hook)) is not None
and _POLICY_HOOK_MODULE in str(hook.get("command", ""))
and module in str(hook.get("command", ""))
]
return []
def _our_policy_hooks_from_list(listed: _JsonObject, cwd: str) -> list[_JsonObject]:
"""
Extract *our* policy hooks for *cwd* from a ``hooks/list`` response.
:param listed: Parsed ``hooks/list`` response envelope.
:param cwd: The cwd whose hook set to read, e.g.
``"/home/user/repo"``.
:returns: The matching Omnigent policy-hook metadata dicts.
"""
return _our_hooks_from_list(listed, cwd, _POLICY_HOOK_MODULE)
def _hooks_list_diagnostics(listed: _JsonObject, cwd: str) -> str:
"""
Summarize a ``hooks/list`` response for a discovery-failure error.
@@ -1340,6 +1539,104 @@ def _untrusted_hook_detail(hooks: Sequence[_JsonObject]) -> str:
)
async def _persist_hook_trust(request: CodexRequestFn, untrusted: Sequence[_JsonObject]) -> None:
"""
Write ``hooks.state.<key>.trusted_hash`` for each untrusted hook.
Persisted trust is the *only* mechanism that makes a hook run under
``codex app-server``: the ``--dangerously-bypass-hook-trust`` CLI flag
is honored by the interactive/exec paths only, so app-server threads
silently skip anything left ``untrusted``.
:param request: Bound app-server JSON-RPC request coroutine, e.g.
``client.request``.
:param untrusted: Hook metadata dicts from ``hooks/list`` carrying
``key`` and ``currentHash``.
:returns: None.
"""
trust_value = {
str(h["key"]): {"trusted_hash": h["currentHash"]}
for h in untrusted
if h.get("key") and h.get("currentHash")
}
if not trust_value:
return
await request(
"config/batchWrite",
{
"edits": [
{
"keyPath": "hooks.state",
"mergeStrategy": "upsert",
"value": trust_value,
}
],
"reloadUserConfig": True,
},
)
async def trust_codex_router_hooks(request: CodexRequestFn, *, cwd: str) -> list[str]:
"""
Trust the generated subagent-routing hooks so codex runs them.
Codex skips untrusted hooks without a word, which for the routing gate
is a fail-open, and app-server threads honor persisted trust only (the
``--dangerously-bypass-hook-trust`` flag covers the interactive /
``exec`` paths, not this one), so the handshake is the only way in.
The routing gate (``PreToolUse`` on the spawn tool) lives in the same
generated ``hooks.json`` as the policy hook but under a different
module, so the policy trust pass leaves it ``untrusted``. Same
``hooks/list`` → ``config/batchWrite`` flow, but best-effort: a
routing-trust failure must not disable policy enforcement, so it is
reported instead of raised.
:param request: Bound app-server JSON-RPC request coroutine, e.g.
``client.request`` (or the SDK executor's ``_request``).
:param cwd: The session cwd the hooks are scoped to, e.g.
``"/home/user/repo"``.
:returns: Keys of routing hooks still untrusted afterwards; empty when
every routing hook is trusted (or none are registered).
"""
listed = await request("hooks/list", {"cwds": [cwd]})
ours = _our_hooks_from_list(listed, cwd, _CODEX_ROUTER_HOOK_MODULE)
if not ours:
_logger.info(
"codex subagent-routing hooks: none discovered for cwd %s (%s)",
cwd,
_hooks_list_diagnostics(listed, cwd),
)
return []
untrusted = [h for h in ours if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES]
if not untrusted:
_logger.info(
"codex subagent-routing hooks: all %d already trusted for cwd %s", len(ours), cwd
)
return []
await _persist_hook_trust(request, untrusted)
relisted = await request("hooks/list", {"cwds": [cwd]})
still_untrusted = [
h
for h in _our_hooks_from_list(relisted, cwd, _CODEX_ROUTER_HOOK_MODULE)
if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES
]
if still_untrusted:
_logger.warning(
"codex subagent-routing hooks still untrusted after config/batchWrite; "
"native subagent routing will NOT be enforced: %s",
_untrusted_hook_detail(still_untrusted),
)
return [str(h.get("key")) for h in still_untrusted]
_logger.info(
"codex subagent-routing hooks trusted (%d of %d newly): %s",
len(untrusted),
len(ours),
", ".join(sorted(str(h.get("eventName")) for h in ours)),
)
return []
async def trust_native_policy_hooks(client: CodexAppServerClient, *, cwd: str) -> None:
"""
Trust the Omnigent policy hook so codex actually runs it.
@@ -1370,24 +1667,7 @@ async def trust_native_policy_hooks(client: CodexAppServerClient, *, cwd: str) -
untrusted = [h for h in ours if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES]
if not untrusted:
return
trust_value = {
str(h["key"]): {"trusted_hash": h["currentHash"]}
for h in untrusted
if h.get("key") and h.get("currentHash")
}
await client.request(
"config/batchWrite",
{
"edits": [
{
"keyPath": "hooks.state",
"mergeStrategy": "upsert",
"value": trust_value,
}
],
"reloadUserConfig": True,
},
)
await _persist_hook_trust(client.request, untrusted)
relisted = await client.request("hooks/list", {"cwds": [cwd]})
still_untrusted = [
h
@@ -1622,6 +1902,44 @@ def codex_session_meta_model_provider(launch: NativeCodexLaunch) -> str:
return "openai"
def native_codex_launch_base_url(launch: NativeCodexLaunch) -> str | None:
"""Inference base URL a resolved launch pins, or None when it defers to Codex's own login.
Mirrors how the launch is actually applied: the Databricks-profile branch of
:func:`build_native_codex_app` derives the base URL from the profile host,
while a generic provider carries it inside the generated
``model_providers.…`` config override.
:param launch: Resolved native-Codex launch, e.g. one returned by
:func:`resolve_native_codex_launch`.
:returns: The base URL the launch routes through, or ``None`` when the
launch pins none.
"""
if launch.profile is not None:
host = _databricks_gateway_host(launch.profile)
if not host:
return None
return _databricks_codex_base_url(host.rstrip("/"))
for override in launch.config_overrides:
_, sep, table = override.partition("=")
if not sep or not override.startswith("model_providers."):
continue
marker = "base_url="
index = table.find(marker)
if index < 0:
continue
decoder = json.JSONDecoder()
try:
base_url, _ = decoder.raw_decode(table[index + len(marker) :])
except ValueError:
continue
if isinstance(base_url, str):
return base_url
# A cli-config entry pins only a provider *name*; its table lives in the
# user's ~/.codex/config.toml, which this process does not read.
return None
def _codex_provider_launch(entry: ProviderEntry, model: str | None) -> NativeCodexLaunch | None:
"""Build a native-Codex launch that routes through a single provider entry.
+66 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import secrets
import sys
import tempfile
@@ -34,6 +35,9 @@ MCP_STARTUP_CANCELLED = "cancelled"
MCP_STARTUP_STATES = frozenset(
{MCP_STARTUP_STARTING, MCP_STARTUP_READY, MCP_STARTUP_FAILED, MCP_STARTUP_CANCELLED}
)
# Top-level ``model_reasoning_effort = "<value>"`` line, capturing the value so
# a model switch can clamp it to one the new model accepts (GLM has no xhigh).
_EFFORT_KEY_RE = re.compile(r'^(\s*model_reasoning_effort\s*=\s*")([^"]*)("\s*(?:#.*)?)$')
# Must match ``_CONFIG_FILE`` in ``claude_native_bridge.py`` because
# ``serve-mcp`` reads this filename for the token.
_MCP_CONFIG_FILE = "bridge.json"
@@ -341,6 +345,63 @@ def read_codex_config_model(bridge_dir: Path) -> str | None:
return model if isinstance(model, str) and model else None
def write_codex_config_model(bridge_dir: Path, model: str) -> bool:
"""
Upsert the top-level ``model`` key in this session's Codex ``config.toml``.
Companion writer to :func:`read_codex_config_model`, used when Omnigent
itself switches the running thread's model (web picker / intelligent
routing via ``thread/settings/update``). That RPC changes the live thread
but does NOT touch ``config.toml`` — while the forwarder's mirror and the
cost-gate hook both treat ``config.toml`` as the source of truth. Without
this write, the next ``turn/started`` re-reads the stale launch model and
mirrors it back to Omnigent as an ``external_model_change``, silently
reverting the switch. Writing the same top-level key an in-TUI ``/model``
writes keeps every reader consistent; a later in-TUI switch simply
overwrites it (last-wins, as for user switches).
Best-effort: an unreadable/unwritable file returns ``False`` — the live
thread already runs the new model, so failing the turn over a mirror
file would be worse than a temporarily stale mirror.
:param bridge_dir: The session's native-Codex bridge directory.
:param model: Model id to record, e.g. ``"gpt-5.6-luna"``.
:returns: ``True`` when the file was updated.
"""
from omnigent.reasoning_effort import clamp_effort_for_model
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
continue
# The config keeps the launch model's effort (e.g. the user's
# xhigh default), which the switched-to model may reject (GLM has
# no xhigh). Clamp it to a value the new model accepts so the next
# turn does not 400 on reasoning.effort.
effort_match = _EFFORT_KEY_RE.match(line)
if effort_match:
clamped = clamp_effort_for_model(effort_match.group(2), model)
if clamped and clamped != effort_match.group(2):
lines[i] = f"{effort_match.group(1)}{clamped}{effort_match.group(3)}"
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.
@@ -386,7 +447,11 @@ def clear_bridge_state(bridge_dir: Path) -> None:
:param bridge_dir: Native Codex bridge directory.
:returns: None.
"""
for name in (_STATE_FILE, _STARTUP_ERROR_FILE, _MCP_STARTUP_FILE):
for name in (
_STATE_FILE,
_STARTUP_ERROR_FILE,
_MCP_STARTUP_FILE,
):
try:
(bridge_dir / name).unlink()
except FileNotFoundError:
+39 -13
View File
@@ -383,6 +383,12 @@ class _CodexForwarderState:
model: str | None = None
posted_model: str | None = None
# The running thread's authoritative model, from a live
# ``thread/settings/updated``; beats a stale config.toml re-read.
settings_model: str | None = None
# The config.toml model as of the last _refresh_model_from_config read,
# so the refresh can tell an unchanged file from a rewritten one.
last_config_model: str | None = None
effort: str | None = None
posted_effort: str | None = None
posted_effort_known: bool = False
@@ -463,6 +469,13 @@ class _CodexForwarderState:
self._note_effort_fields(settings)
self._note_collaboration_mode_fields(settings)
self._note_approval_mode_fields(settings)
# Live thread settings are the running process's truth: remember
# the model so a stale config.toml re-read at the next
# turn/started cannot roll the mirror back (see
# _refresh_model_from_config).
model = settings.get("model")
if isinstance(model, str) and model:
self.settings_model = model
def record_completed_plan(self, params: _JsonObject) -> None:
"""
@@ -2727,26 +2740,39 @@ async def _maybe_handle_codex_request(
def _refresh_model_from_config(bridge_dir: Path, forwarder_state: _CodexForwarderState) -> None:
"""
Update the forwarder's known model from this session's ``config.toml``.
Update the forwarder's known model from config.toml and thread settings.
Reads the source-of-truth model via the shared
:func:`~omnigent.codex_native_bridge.read_codex_config_model` (the
``model`` key an in-TUI ``/model`` writes see that function for why
config.toml is the source of truth and its caveats) and stores it on
``forwarder_state.model`` so a following ``_sync_model_change`` mirrors
it to Omnigent as ``model_override``. This mirror is a fallback to the codex
hook, which stamps the live model onto the evaluation request at gate
time; the gate prefers the hook's value. No-op when the model can't be
determined, leaving the prior value.
Reads the ``model`` key an in-TUI ``/model`` writes via the shared
:func:`~omnigent.codex_native_bridge.read_codex_config_model` and stores
the freshest value on ``forwarder_state.model`` so a following
``_sync_model_change`` mirrors it to Omnigent as ``model_override``. This
mirror is a fallback to the codex hook, which stamps the live model onto
the evaluation request at gate time; the gate prefers the hook's value.
Precedence: a config.toml value that CHANGED since the last read wins
(an in-TUI ``/model`` or the executor's mirror write — the freshest
signal). An unchanged config defers to the last live
``thread/settings/updated`` model when one was seen: an
Omnigent-initiated ``thread/settings/update`` switches the running
thread without touching config.toml, so re-adopting the stale file
would revert a routed model one turn after it applied. No-op when
nothing is known, leaving the prior value.
:param bridge_dir: The session's native-Codex bridge directory.
:param forwarder_state: Mutable forwarder state whose ``model`` is
updated in place.
:returns: None.
"""
model = read_codex_config_model(bridge_dir)
if model:
forwarder_state.model = model
config_model = read_codex_config_model(bridge_dir)
config_changed = bool(config_model) and config_model != forwarder_state.last_config_model
if config_model:
forwarder_state.last_config_model = config_model
if config_changed:
forwarder_state.model = config_model
elif forwarder_state.settings_model:
forwarder_state.model = forwarder_state.settings_model
elif config_model:
forwarder_state.model = config_model
async def _sync_model_change(
+323
View File
@@ -16,6 +16,7 @@ import json
import sys
import urllib.parse
from pathlib import Path
from typing import TYPE_CHECKING
from omnigent.codex_native_bridge import (
read_bridge_state,
@@ -32,6 +33,9 @@ from omnigent.native_policy_hook import (
relay_policy_evaluate_url,
)
if TYPE_CHECKING:
from omnigent.codex_native_app_server import CodexAppServerClient
# Budget for the policy evaluation POST. Normally a quick
# request/reply, but a TOOL_CALL ASK now parks server-side (URL-based
# elicitation) until a human resolves it via the approve URL, so the
@@ -55,6 +59,8 @@ def main(argv: list[str] | None = None) -> int:
raw_argv = sys.argv[1:] if argv is None else argv
if raw_argv and raw_argv[0] == "evaluate-policy":
return _main_evaluate_policy(raw_argv[1:])
if raw_argv and raw_argv[0] == "route-turn":
return _main_route_turn(raw_argv[1:])
print(
f"omnigent codex hook: unknown subcommand {raw_argv[:1]!r}",
file=sys.stderr,
@@ -204,5 +210,322 @@ def _parse_evaluate_policy_args(argv: list[str]) -> argparse.Namespace:
return parser.parse_args(argv)
def _main_route_turn(argv: list[str]) -> int:
"""
Route the model this session runs on, from its first real prompt.
The in-harness half of first-message routing (see
:mod:`omnigent.runner.turn_routing`), registered as a second
``UserPromptSubmit`` command alongside the policy gate. On every
prompt submit, in order:
1. Fast skip on ``<bridge_dir>/turn_routing_done`` **when it names this
session** — no output, no network. The authoritative gate is the
endpoint's routing-decision check; this file only saves the round
trip, and a marker another conversation in the same bridge dir wrote
is not ours to skip on.
2. POST ``{session_id, prompt, harness, turn_id, model}`` to the
advertised loopback ``route-turn`` endpoint. ``model`` comes from
the hook payload, which tracks the LIVE thread model —
``config.toml`` reports the stale launch model.
3. On a routed verdict: switch the thread with
``thread/settings/update`` (codex binds the turn's model before
this hook runs, so the switch lands from the next turn), write the
marker, and BLOCK the prompt. The runner then replays it as a
normal user turn, which runs on the routed model.
Fails open everywhere: an absent advertisement, an unreachable
endpoint, an unroutable verdict or a failed switch all exit ``0`` with
no output, and the prompt runs untouched on the current model.
:param argv: CLI argv after the ``route-turn`` subcommand, e.g.
``["--bridge-dir", "/tmp/x", "--harness", "codex-native"]``.
:returns: Process exit code. Always ``0`` — the block is expressed via
the JSON on stdout, never via the exit code.
"""
from omnigent.runner.turn_routing import (
ADVERTISEMENT_FILE,
HOOK_REQUEST_TIMEOUT_S,
ROUTE_PATH_TEMPLATE,
trace_turn_routing,
turn_routing_marker_present,
)
parser = argparse.ArgumentParser(prog="python -m omnigent.codex_native_hook route-turn")
parser.add_argument("--bridge-dir", required=True)
parser.add_argument("--harness", default="codex-native")
args = parser.parse_args(argv)
bridge_dir = Path(args.bridge_dir)
# Every prompt submit is traced, including the ones that fall open. A
# session that "just never routed" is otherwise indistinguishable from
# one the harness never fired the hook for at all.
raw = sys.stdin.read()
try:
payload = json.loads(raw or "{}")
except json.JSONDecodeError:
trace_turn_routing(bridge_dir, "fail-open", "malformed hook payload")
return 0
if not isinstance(payload, dict):
trace_turn_routing(bridge_dir, "fail-open", "hook payload is not an object")
return 0
prompt = payload.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
trace_turn_routing(bridge_dir, "skip", "no prompt text on this submit")
return 0
from omnigent.inner.hook_scripts.subagent_router import read_router_endpoint
endpoint = read_router_endpoint(bridge_dir, filename=ADVERTISEMENT_FILE)
if endpoint is None:
trace_turn_routing(bridge_dir, "fail-open", f"no usable {ADVERTISEMENT_FILE}")
return 0
state = read_bridge_state(bridge_dir)
session_id = endpoint.session_id or (state.session_id if state is not None else None)
if not session_id:
trace_turn_routing(bridge_dir, "fail-open", "no session id to route")
return 0
# The marker is checked here, after the session id is known, because it is
# scoped to a session: this bridge dir is shared with whichever
# conversation a ``/clear`` rotation or a fork left behind, and their
# verdict is not ours. Still zero network on the fast path.
if turn_routing_marker_present(bridge_dir, session_id):
trace_turn_routing(bridge_dir, "skip", "marker present")
return 0
body = {
"harness": args.harness,
"prompt": prompt,
"turn_id": _payload_str(payload, "turn_id"),
# The payload's model tracks thread/settings/update; config.toml does not.
"model": _payload_str(payload, "model"),
}
url = endpoint.url + ROUTE_PATH_TEMPLATE.format(
session_id=urllib.parse.quote(session_id, safe="")
)
decision = _post_json(url, endpoint.token, body, HOOK_REQUEST_TIMEOUT_S)
if decision is None:
# Not the endpoint URL: it comes out of the advertisement that also
# holds the bearer token, and this trace is world-readable stderr.
trace_turn_routing(bridge_dir, "fail-open", "no verdict from the turn router")
return 0
model = decision.get("model")
if decision.get("action") != "route" or not isinstance(model, str) or not model:
rationale = decision.get("rationale")
trace_turn_routing(
bridge_dir,
"allow",
f"{rationale if isinstance(rationale, str) else ''} "
f"(terminal={bool(decision.get('terminal'))})",
)
if decision.get("terminal"):
# Nothing will route this session again, so stop asking. Covers the
# no-op verdict too (the pick equals the live model): terminal and
# unblocking, so the prompt runs where it already was.
_write_marker(bridge_dir, session_id, decision)
return 0
if not _apply_thread_model(bridge_dir, model):
# No marker: the prompt is about to run, and the marker is what
# tells the runner to replay it. Writing one here would replay a
# prompt that already ran. The server-side pin still keeps the
# next prompt from re-routing.
trace_turn_routing(bridge_dir, "fail-open", f"could not switch to {model}")
print(
f"omnigent codex route-turn hook: could not switch to {model}; "
"letting the prompt run on the current model",
file=sys.stderr,
)
return 0
# Marker after the switch and before the block, so its presence means
# both "the routed model is applied" and "this prompt was dropped, you
# owe it a replay".
if not _write_marker(bridge_dir, session_id, decision):
trace_turn_routing(bridge_dir, "fail-open", "could not write the block marker")
return 0
trace_turn_routing(bridge_dir, "route", f"blocked and switched to {model}")
sys.stdout.write(
json.dumps(
{
"decision": "block",
"reason": f"Smart Routing selected {model}; rerunning your message on it.",
}
)
)
return 0
def _payload_str(payload: dict[str, object], key: str) -> str | None:
"""
Read an optional string field from a hook payload.
:param payload: Decoded hook payload.
:param key: Field name, e.g. ``"turn_id"``.
:returns: The value, or ``None`` when absent or not a non-empty string.
"""
value = payload.get(key)
return value if isinstance(value, str) and value else None
def _write_marker(bridge_dir: Path, session_id: str, decision: dict[str, object]) -> bool:
"""
Write the session-scoped turn-routing marker file.
:param bridge_dir: Native Codex bridge directory.
:param session_id: Session the verdict belongs to — a later conversation
sharing this dir must not fast-skip on it.
:param decision: The verdict, for its ``decision_id``.
:returns: ``True`` when the marker is on disk.
"""
from omnigent.runner.turn_routing import write_turn_routing_marker
decision_id = decision.get("decision_id")
if write_turn_routing_marker(
bridge_dir,
session_id=session_id,
decision_id=decision_id if isinstance(decision_id, str) else None,
):
return True
print(
f"omnigent codex route-turn hook: could not write the marker in {bridge_dir}",
file=sys.stderr,
)
return False
def _post_json(
url: str,
token: str,
body: dict[str, object],
timeout: float,
) -> dict[str, object] | None:
"""
POST one JSON body to the loopback endpoint.
:param url: Fully-qualified loopback URL.
:param token: Bearer token from the advertisement.
:param body: Request body.
:param timeout: Socket timeout in seconds.
:returns: The decoded response object, or ``None`` on any transport or
decode failure (callers treat that as "allow unrouted").
"""
import urllib.error
import urllib.request
request = urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as resp:
decoded = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
return None
return decoded if isinstance(decoded, dict) else None
def _apply_thread_model(bridge_dir: Path, model: str) -> bool:
"""
Switch the live Codex thread onto *model*.
``thread/settings/update`` is the thread-level switch (the same one the
web picker drives through the executor); the app-server accepts a
second concurrent client while a turn is in flight, so the hook can
fire it from inside its own synchronous window. The accepted switch is
mirrored into ``config.toml`` the way the executor does, so the
cost-budget gate reads the routed model rather than the launch one.
The routed catalog id is translated into codex's own spelling first (see
:mod:`omnigent.codex_model_vocabulary`) — codex serves either, but only
recognizes its own, so an untranslated switch runs the right model while
the TUI warns about missing metadata and ``/model`` keeps highlighting
the launch slug.
:param bridge_dir: Native Codex bridge directory.
:param model: Routed model id, e.g. ``"databricks-gpt-5-6-luna"``.
:returns: ``True`` when Codex accepted the switch.
"""
import asyncio
from omnigent.codex_model_vocabulary import codex_model_slug
from omnigent.codex_native_app_server import client_for_transport
from omnigent.codex_native_bridge import write_codex_config_model
from omnigent.runner.turn_routing import SETTINGS_UPDATE_TIMEOUT_S
state = read_bridge_state(bridge_dir)
if state is None:
return False
# The spelling codex accepted, mirrored into config.toml below so the
# file and the live thread never disagree about the model.
applied = model
async def _switch() -> None:
nonlocal applied
client = client_for_transport(state.socket_path, client_name="omnigent-route-turn-hook")
await client.connect()
try:
applied = codex_model_slug(model, await _list_codex_models(client))
await client.request(
"thread/settings/update",
{"threadId": state.thread_id, "model": applied},
)
finally:
await client.close()
try:
asyncio.run(asyncio.wait_for(_switch(), timeout=SETTINGS_UPDATE_TIMEOUT_S))
except Exception as exc: # noqa: BLE001 - any failure means "leave the model alone"
print(
f"omnigent codex route-turn hook: thread/settings/update failed: {exc}",
file=sys.stderr,
)
return False
if not write_codex_config_model(bridge_dir, applied):
print(
f"omnigent codex route-turn hook: could not mirror {applied} into config.toml",
file=sys.stderr,
)
return True
async def _list_codex_models(client: CodexAppServerClient) -> list[dict[str, object]]:
"""
Read this session's codex model catalog over an open app-server client.
Hidden rows are included: they are still switchable, and translating a
routed model beats sending a spelling codex has no metadata for.
:param client: Connected app-server client.
:returns: Raw ``model/list`` rows, empty when the call fails (the
caller then applies the routed id verbatim).
"""
rows: list[dict[str, object]] = []
cursor: str | None = None
try:
while True:
params: dict[str, object] = {"includeHidden": True}
if cursor is not None:
params["cursor"] = cursor
response = await client.request("model/list", params)
result = response.get("result")
if not isinstance(result, dict):
break
rows.extend(row for row in result.get("data") or () if isinstance(row, dict))
cursor = result.get("nextCursor")
if not isinstance(cursor, str) or not cursor:
break
except Exception as exc: # noqa: BLE001 - an unreadable catalog means "no translation"
print(
f"omnigent codex route-turn hook: model/list failed: {exc}",
file=sys.stderr,
)
return rows
if __name__ == "__main__":
raise SystemExit(main())
+89
View File
@@ -0,0 +1,89 @@
"""Canonical predicate for recognizing a Databricks AI Gateway base URL.
Several surfaces need the same answer — pi-native rewrites a gateway Codex
base URL to the Anthropic surface, and host-side routing capability checks ask
whether a resolved harness launch is gateway-backed. Keeping one predicate here
means a look-alike host is rejected identically everywhere.
"""
from __future__ import annotations
from typing import Final
from urllib.parse import urlparse
# Trusted parent domains for a Databricks-owned host. The AI Gateway lives
# under a per-workspace subdomain of one of these (the canonical form is
# ``<workspace>.ai-gateway.cloud.databricks.com``); the Azure / GCP control
# planes serve workspaces under their own parent domains. Written with the
# leading "." for readability — the match is on whole DNS labels
# (:func:`_under_trusted_domain`), never on a string suffix, so neither
# ``evilcloud.databricks.com`` nor ``....cloud.databricks.com.evil.test`` can
# pass as one of these.
DATABRICKS_TRUSTED_HOST_SUFFIXES: Final[tuple[str, ...]] = (
".cloud.databricks.com", # AWS workspaces + ai-gateway (incl. *.staging.cloud.databricks.com)
".azuredatabricks.net", # Azure Databricks
".gcp.databricks.com", # GCP Databricks
)
# A genuine AI Gateway host carries the ``ai-gateway`` DNS label; we require it
# (alongside a trusted suffix) so a non-gateway Databricks host isn't routed as
# the gateway's Anthropic surface.
DATABRICKS_AI_GATEWAY_LABEL: Final[str] = "ai-gateway"
def _under_trusted_domain(hostname: str) -> bool:
"""Whether *hostname* is a subdomain of a trusted Databricks parent domain.
Compares whole DNS labels from the right, so the parent must be an exact
label-wise suffix with at least one label of its own in front of it. A
string-suffix test would be looser in both directions.
:param hostname: Lower-cased hostname from a parsed URL, e.g.
``"wkspc.ai-gateway.cloud.databricks.com"``.
:returns: ``True`` when a trusted parent domain owns *hostname*.
"""
labels = hostname.split(".")
for parent in DATABRICKS_TRUSTED_HOST_SUFFIXES:
parent_labels = parent.strip(".").split(".")
if len(labels) > len(parent_labels) and labels[-len(parent_labels) :] == parent_labels:
return True
return False
def is_databricks_ai_gateway_url(base_url: str) -> bool:
"""Return ``True`` only for a genuine Databricks AI Gateway base URL.
Two URL shapes are accepted:
1. **Dedicated AI Gateway subdomain** — ``ai-gateway`` is a full DNS label
in the hostname (e.g. ``<id>.ai-gateway.cloud.databricks.com``). Used by
the standard ``isaac configure codex`` setup.
2. **Workspace-hosted gateway** — the hostname is a plain Databricks
workspace (under a trusted parent domain) and the path starts with
``/ai-gateway/`` (e.g. ``<workspace>.cloud.databricks.com/ai-gateway/...``).
Used by ucode / Codex app profile setups.
Both cases require ``https`` and a hostname a trusted Databricks-owned
parent domain owns label-for-label, to prevent token forwarding to a
look-alike host.
:param base_url: An inference base URL, e.g. the codex provider table's
``base_url``.
:returns: ``True`` iff the URL is an https Databricks AI Gateway endpoint.
"""
parsed = urlparse(base_url)
if parsed.scheme != "https":
return False
hostname = parsed.hostname
if not hostname:
return False
hostname = hostname.lower()
if not _under_trusted_domain(hostname):
return False
# Shape 1: ``ai-gateway`` is a full DNS label in the hostname.
labels = hostname.split(".")
if DATABRICKS_AI_GATEWAY_LABEL in labels:
return True
# Shape 2: workspace hostname + /ai-gateway/ path prefix.
path = parsed.path or ""
return path.startswith("/ai-gateway/")
+177 -44
View File
@@ -4,6 +4,9 @@ from __future__ import annotations
import logging
import re
import warnings
from collections.abc import Iterable
from dataclasses import dataclass
import httpx
@@ -22,29 +25,92 @@ _MAX_PAGES = 100
_HTTP_TIMEOUT_S = 10.0
#: Catalog spellings the same endpoint can be served under. Ordered by
#: preference: a workspace exposing both keeps the ``databricks-`` id, so every
#: consumer (routing candidates, the model picker, the launch alias pins) names
#: a model the same way no matter which listing answered.
_CATALOG_SPELLINGS: tuple[str, ...] = ("databricks-", _SYSTEM_MODEL_PREFIX)
def _bare_model_id(model_id: str) -> str:
"""Strip the catalog spelling so ids compare across vocabularies."""
lowered = model_id.lower()
for prefix in _CATALOG_SPELLINGS:
if lowered.startswith(prefix):
return lowered[len(prefix) :]
return lowered
def _natural_model_key(model_id: str) -> tuple[tuple[int, str | int], ...]:
"""Return a comparison key that orders numeric model versions naturally."""
"""Return a comparison key that orders numeric model versions naturally.
Keyed on the bare id so the catalog spelling never outranks the version.
"""
return tuple(
(1, int(part)) if part.isdigit() else (0, part)
for part in re.split(r"(\d+)", model_id.lower())
for part in re.split(r"(\d+)", _bare_model_id(model_id))
if part
)
def _prefer_databricks_spelling(model_ids: Iterable[str]) -> list[str]:
"""Collapse duplicate spellings of one model onto the preferred one.
:param model_ids: Catalog ids from one or more listings, possibly naming
the same endpoint under two spellings.
:returns: One id per model, sorted, with ``databricks-`` winning ties.
"""
best: dict[str, str] = {}
for model_id in model_ids:
bare = _bare_model_id(model_id)
current = best.get(bare)
if current is None or _spelling_rank(model_id) < _spelling_rank(current):
best[bare] = model_id
return sorted(best.values())
def _spelling_rank(model_id: str) -> int:
"""Rank a catalog spelling; lower wins."""
lowered = model_id.lower()
for rank, prefix in enumerate(_CATALOG_SPELLINGS):
if lowered.startswith(prefix):
return rank
return len(_CATALOG_SPELLINGS)
def _claude_family_of(model_id: str, *, marker: str) -> str | None:
"""Return the Claude family *model_id* belongs to, if any."""
_, separator, suffix = model_id.lower().partition(marker)
if not separator:
return None
segments = suffix.split("-")
return next((family for family in CLAUDE_MODEL_FAMILIES if family in segments), None)
def _models_by_claude_family(model_ids: list[str], *, marker: str) -> dict[str, str]:
"""Select the newest model id for every Claude family in *model_ids*."""
result: dict[str, str] = {}
for family in CLAUDE_MODEL_FAMILIES:
candidates = []
for model_id in model_ids:
_, separator, suffix = model_id.lower().partition(marker)
if separator and family in suffix.split("-"):
candidates.append(model_id)
candidates = [
model_id
for model_id in model_ids
if _claude_family_of(model_id, marker=marker) == family
]
if candidates:
result[family] = max(candidates, key=_natural_model_key)
return result
def _all_claude_models(model_ids: list[str], *, marker: str) -> tuple[str, ...]:
"""Keep every Claude-family id in *model_ids*, newest first per family."""
claude_ids = [
model_id
for model_id in model_ids
if _claude_family_of(model_id, marker=marker) is not None
]
return tuple(sorted(claude_ids, key=_natural_model_key, reverse=True))
def _list_model_service_ids(
client: httpx.Client,
workspace_url: str,
@@ -126,6 +192,92 @@ def _list_anthropic_gateway_ids(
]
@dataclass(frozen=True)
class DatabricksClaudeCatalog:
"""Every Claude endpoint a workspace serves, plus the family picks.
:param families: Family alias → newest routable id, e.g.
``{"opus": "system.ai.claude-opus-5"}``. What the launch env pins
each Claude Code alias to.
:param model_ids: Every Claude-family id the workspace serves, newest
first, e.g. ``("system.ai.claude-opus-5",
"system.ai.claude-opus-4-8")``. A superset of ``families``: an
older generation is still servable and still routable, it just
does not own an alias.
"""
families: dict[str, str]
model_ids: tuple[str, ...]
def discover_databricks_claude_catalog(
workspace_url: str,
token: str,
*,
transport: httpx.BaseTransport | None = None,
) -> DatabricksClaudeCatalog:
"""Discover every Claude endpoint a Databricks workspace serves.
Both listings are consulted, because a workspace can serve the same
endpoint under both spellings (``system.ai.claude-opus-5`` from Unity
Catalog model services, ``databricks-claude-opus-5`` from the Anthropic AI
Gateway) and answering with whichever listing happened to succeed makes the
catalog nondeterministic. Duplicates collapse onto the ``databricks-``
spelling so every consumer names a model the same way.
The gateway listing is therefore issued even when Unity Catalog already
named Claude models — short-circuiting on the UC hit would cost one HTTP
round trip less per launch, but UC only ever spells ids ``system.ai.``, so
the spelling a consumer sees would depend on whether the (transiently
failing) UC call answered.
:param workspace_url: Workspace origin, e.g. ``"https://example.com"``.
:param token: Workspace bearer token.
:param transport: Optional HTTP transport used by tests.
:returns: The workspace's Claude catalog. Empty ``families`` with empty
``model_ids`` is authoritative: the model-services listing answered
successfully and no Claude models are exposed.
:raises httpx.HTTPError: When the primary listing fails and the fallback
cannot compensate (it fails too, or exposes no Claude models).
:raises ValueError: Same contract for malformed responses.
"""
headers = {"Authorization": f"Bearer {token}"}
primary_error: Exception | None = None
gateway_error: Exception | None = None
model_service_ids: list[str] = []
gateway_ids: list[str] = []
with httpx.Client(transport=transport, timeout=_HTTP_TIMEOUT_S) as client:
try:
model_service_ids = _list_model_service_ids(client, workspace_url, headers)
except (httpx.HTTPError, ValueError) as exc:
primary_error = exc
try:
gateway_ids = _list_anthropic_gateway_ids(client, workspace_url, headers)
except (httpx.HTTPError, ValueError) as exc:
gateway_error = exc
if primary_error is not None and gateway_error is not None:
raise gateway_error from primary_error
merged = _prefer_databricks_spelling([*model_service_ids, *gateway_ids])
models = _models_by_claude_family(merged, marker="claude-")
if models:
return DatabricksClaudeCatalog(
families=models,
model_ids=_all_claude_models(merged, marker="claude-"),
)
if primary_error is not None:
# Neither listing named a Claude model and the authoritative one failed
# — an empty result here is NOT authoritative (e.g. a transient UC 503
# plus an unused legacy gateway). Surface the primary failure so callers
# fall back to cached models instead of treating the workspace as having
# none.
raise primary_error
# A successful permission-aware UC listing is authoritative even when the
# compatibility endpoint is not enabled.
return DatabricksClaudeCatalog(families={}, model_ids=())
def discover_databricks_claude_models(
workspace_url: str,
token: str,
@@ -134,46 +286,27 @@ def discover_databricks_claude_models(
) -> dict[str, str]:
"""Discover the live Claude family mapping for a Databricks workspace.
Unity Catalog model services are authoritative when they expose Claude
models. The Anthropic AI Gateway model-list endpoint is the compatibility
fallback for workspaces that have not moved to model services yet.
.. deprecated:: 0.8.0
Use :func:`discover_databricks_claude_catalog` and read its
``families``, which also carries every servable id. Removed in
``v0.10.0``.
:param workspace_url: Workspace origin, e.g. ``"https://example.com"``.
:param token: Workspace bearer token.
:param transport: Optional HTTP transport used by tests.
:returns: Family aliases mapped to routable model ids. An empty mapping is
authoritative: at least one endpoint answered successfully and no
Claude models are exposed.
:raises httpx.HTTPError: When the primary listing fails and the fallback
cannot compensate (it fails too, or exposes no Claude models).
:raises ValueError: Same contract for malformed responses.
authoritative: the listing answered and no Claude models are exposed.
:raises httpx.HTTPError: Same contract as the catalog lookup.
:raises ValueError: Same contract as the catalog lookup.
"""
headers = {"Authorization": f"Bearer {token}"}
primary_error: Exception | None = None
with httpx.Client(transport=transport, timeout=_HTTP_TIMEOUT_S) as client:
try:
model_service_ids = _list_model_service_ids(client, workspace_url, headers)
except (httpx.HTTPError, ValueError) as exc:
primary_error = exc
else:
models = _models_by_claude_family(model_service_ids, marker="claude-")
if models:
return models
try:
gateway_ids = _list_anthropic_gateway_ids(client, workspace_url, headers)
except (httpx.HTTPError, ValueError) as exc:
if primary_error is not None:
raise exc from primary_error
# A successful permission-aware UC listing is authoritative even
# when the compatibility endpoint is not enabled.
return {}
gateway_models = _models_by_claude_family(gateway_ids, marker="databricks-claude-")
if not gateway_models and primary_error is not None:
# The gateway answered but routes no Claude models, and the primary
# listing failed — an empty result here is NOT authoritative (e.g. a
# transient UC 503 plus an unused legacy gateway). Surface the primary
# failure so callers fall back to cached models instead of treating
# the workspace as having none.
raise primary_error
return gateway_models
warnings.warn(
"discover_databricks_claude_models() is deprecated and will be removed in "
"v0.10.0; call discover_databricks_claude_catalog() and read .families.",
DeprecationWarning,
stacklevel=2,
)
return discover_databricks_claude_catalog(
workspace_url,
token,
transport=transport,
).families
+42
View File
@@ -118,6 +118,14 @@ class Conversation:
``PATCH /v1/sessions/{id}`` (the web "Cost Optimized"
toggle). Read by the cost-control advisor pipeline at turn
start; mirrors the persistence shape of ``model_override``.
:param subagent_routing_override: Per-session subagent-routing
switch, two-state: ``"on"`` routes native/SDK subagent spawns,
and ``"off"`` or ``None`` (unset) both leave them on the parent's
model. A session created on Smart Routing is stamped ``"on"`` by
the create route, so unset reads as Default and inherits nothing.
Mutable via ``PATCH /v1/sessions/{id}`` at any time; read per
spawn by the route-subagent relay, so a change takes effect on
the next spawn.
:param harness_override: Per-session harness override for the
bound agent's brain, e.g. ``"pi"`` or ``"openai-agents"``.
``None`` means use the harness declared in the agent spec
@@ -213,6 +221,7 @@ class Conversation:
reasoning_effort: str | None = None
model_override: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
harness_override: str | None = None
sub_agent_name: str | None = None
external_session_id: str | None = None
@@ -532,6 +541,33 @@ class RoutingDecisionData(BaseModel):
:param rationale: The router's one-line explanation, shown as muted
secondary text, e.g. ``"Multi-file refactor needs deep
reasoning."``.
:param harness: Harness the decision applies to, e.g.
``"claude-native"`` or ``"codex"``. ``None`` when the decision
picked a model only (no harness dimension).
:param scope: What the decision governs — ``"session"`` (auto-harness
session routing), ``"turn"`` (per-turn routing), ``"child_session"``
(an Omnigent-spawned sub-agent) or ``"native_subagent"`` (a Task /
``spawn_agent`` spawn routed inside the harness). Defaults to
``"turn"`` so rows persisted before this field deserialize.
:param decision_id: Router decision identifier, e.g.
``"3f1c…"``. Correlates the transcript item with the routing
telemetry event and the child-sessions API row. ``None`` for
decisions made before decision ids existed.
:param raw_model: The router-vocabulary pick before resolution to a
servable catalog id, e.g. ``"gpt-5-6-sol"``. ``None`` when the
pick needed no resolution.
:param attempted_override: Model the spawning agent asked for and the
router overrode, e.g. ``"databricks-gpt-5-5"`` — an LLM-supplied
``args.model`` on a child session, or a native spawn's own
``requested_model``. ``None`` when nothing was asked for, or when
the router's pick names the same arm as the ask.
:param router_source: Which router produced the decision —
``"databricks-aigw"`` for the external AI-Gateway ``task_v1``
service, ``"oss-llm"`` for the built-in judge. Deliberately a
plain ``str`` rather than a ``Literal``: a source added later
must still round-trip through stored rows and the wire instead
of failing validation. ``None`` on rows written before the
field existed.
"""
model: str
@@ -541,6 +577,12 @@ class RoutingDecisionData(BaseModel):
#: item is being mirrored into the parent's transcript, e.g. ``"claude_code"``.
#: ``None`` for session-local routing decisions (the usual case).
agent: str | None = None
harness: str | None = None
scope: Literal["session", "turn", "child_session", "native_subagent"] = "turn"
decision_id: str | None = None
raw_model: str | None = None
attempted_override: str | None = None
router_source: str | None = None
@field_validator("model")
@classmethod
+153
View File
@@ -0,0 +1,153 @@
"""Host-side checks for whether a harness family's inference is AI-Gateway-backed.
Smart Routing's apply layer can only rewrite a launch's model when the launch
resolves through the Databricks AI Gateway — that is where the routable model
catalog lives. These checks answer that question per harness family from config
resolution alone: no process launch, no network round-trip, so the host can
report the answer alongside harness readiness on every registration.
"""
from __future__ import annotations
import logging
from collections.abc import Iterable, Mapping
from typing import Final
_logger = logging.getLogger(__name__)
# Every spelling the Claude family travels under on the wire.
CLAUDE_GATEWAY_HARNESSES: Final[tuple[str, ...]] = ("claude-native", "native-claude")
# Every spelling the Codex family travels under on the wire.
CODEX_GATEWAY_HARNESSES: Final[tuple[str, ...]] = ("codex", "codex-native", "native-codex")
# The AI Gateway serves Codex/OpenAI-Responses under this path suffix; both
# gateway URL shapes (dedicated subdomain and workspace-hosted) end with it.
_CODEX_GATEWAY_PATH_SUFFIX = "/codex/v1"
def claude_gateway_inference_backed() -> bool:
"""Whether a claude-native launch on this host resolves gateway-backed inference.
A gateway-backed launch pins ``ANTHROPIC_BASE_URL`` and delivers its bearer
token through Claude Code's ``apiKeyHelper``. The Bedrock path sets
``ANTHROPIC_BEDROCK_BASE_URL`` with no helper, and a subscription / CLI
login resolves no config at all — neither is routable.
:returns: ``True`` iff the resolved config is AI-Gateway-backed.
"""
from omnigent.claude_native import resolve_native_claude_config
config = resolve_native_claude_config(spec=None, refresh_models=False)
if config is None:
return False
return bool(config.env.get("ANTHROPIC_BASE_URL")) and bool(config.api_key_helper)
def codex_gateway_inference_backed() -> bool:
"""Whether a codex-native launch on this host resolves gateway-backed inference.
:returns: ``True`` iff the resolved launch routes through an AI Gateway
Codex base URL.
"""
from omnigent.codex_native_app_server import (
native_codex_launch_base_url,
resolve_native_codex_launch,
)
from omnigent.databricks_ai_gateway import is_databricks_ai_gateway_url
base_url = native_codex_launch_base_url(resolve_native_codex_launch(model=None))
if not base_url:
return False
if not is_databricks_ai_gateway_url(base_url):
return False
return base_url.rstrip("/").endswith(_CODEX_GATEWAY_PATH_SUFFIX)
def gateway_inference_map() -> dict[str, bool]:
"""Per-harness map of whether this host's inference for that family is gateway-backed.
Each family is evaluated once and the result fanned out over every spelling
that family travels under. A family whose check raises is omitted rather
than reported as ``False``, so the server can tell "not gateway-backed"
apart from "could not tell".
:returns: Harness spelling → gateway-backed flag, omitting unevaluable
families.
"""
result: dict[str, bool] = {}
for family, spellings, check in (
("claude", CLAUDE_GATEWAY_HARNESSES, claude_gateway_inference_backed),
("codex", CODEX_GATEWAY_HARNESSES, codex_gateway_inference_backed),
):
try:
backed = check()
except Exception: # noqa: BLE001 — an unevaluable family is omitted, not False
_logger.warning(
"gateway-inference check for the %s family failed; omitting it",
family,
exc_info=True,
)
continue
for spelling in spellings:
result[spelling] = backed
return result
def gateway_inference_state(
gateway: Mapping[str, object] | None,
harness: str,
) -> bool | None:
"""Read *harness*'s gateway-backed flag out of a reported map.
:param gateway: A host's ``gateway_inference`` map, or ``None``.
:param harness: Harness id in any spelling, e.g. ``"native-codex"``.
:returns: The reported flag, or ``None`` when the map says nothing about
this harness — an older host, a family whose check could not run, or a
host that has not registered yet. Unknown is not "unavailable".
"""
if not gateway:
return None
for key in _family_spellings(harness):
value = gateway.get(key)
if isinstance(value, bool):
return value
return None
def _family_spellings(harness: str) -> tuple[str, ...]:
"""Every key a host may have reported *harness*'s family under.
:func:`gateway_inference_map` fans one family verdict out over all of its
spellings, but a caller holds only one — and the reversed aliases
(``native-codex``) never canonicalize back. Look the family up instead, so
any spelling finds the entry.
:param harness: Harness id in any spelling, e.g. ``"native-codex"``.
:returns: The family's spellings, or just *harness* when it is in neither.
"""
from omnigent.harness_aliases import canonicalize_harness
canonical = canonicalize_harness(harness) or harness
for spellings in (CLAUDE_GATEWAY_HARNESSES, CODEX_GATEWAY_HARNESSES):
if canonical in spellings or harness in spellings:
return spellings
return (canonical, harness)
def not_gateway_backed(
gateway: Mapping[str, object] | None,
harnesses: Iterable[str],
) -> list[str]:
"""Which of *harnesses* the map explicitly reports as not gateway-backed.
Smart Routing's apply layer rewrites the launch model through the AI
Gateway, so these are the harnesses a routed pick could not reach. Only an
explicit ``False`` counts: unknown keeps every option.
:param gateway: A host's ``gateway_inference`` map, or ``None``.
:param harnesses: Harness ids to check, e.g.
``("claude-native", "codex-native")``.
:returns: The not-backed ids, in the order given.
"""
return [harness for harness in harnesses if gateway_inference_state(gateway, harness) is False]
+22 -2
View File
@@ -26,6 +26,7 @@ from websockets.exceptions import InvalidStatus, InvalidURI
from omnigent._platform import IS_POSIX, 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 import HOST_FATAL_EXIT_CODE
@@ -1852,6 +1853,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:
@@ -1865,6 +1867,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:
@@ -1966,6 +1969,7 @@ class HostProcess:
request_id=frame.request_id,
status="ok",
configured_harnesses=configured_harness_map(),
gateway_inference=gateway_inference_map(),
)
def _handle_detect_credentials(
@@ -2203,6 +2207,9 @@ class HostProcess:
request_id=frame.request_id,
status="ok",
models=models,
# The picker names the newest model of each family; the endpoint
# serves older generations too, and a launch takes an exact id.
routable_models=list(config.routable_models) if config is not None else [],
)
@staticmethod
@@ -2652,6 +2659,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,
@@ -2660,6 +2668,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,
)
@@ -2722,6 +2731,10 @@ class HostProcess:
:returns: None. Runs until cancelled when the connection ends.
"""
configured = initial
# Gateway-backing baseline, recomputed with readiness: a flip alone
# (same binaries, new credentials) must reach the server without a
# reconnect.
gateway = await asyncio.to_thread(gateway_inference_map)
loop = asyncio.get_running_loop()
next_quick = loop.time() + HARNESS_READINESS_REFRESH_INTERVAL_S
next_full = loop.time() + HARNESS_READINESS_FULL_REFRESH_INTERVAL_S
@@ -2738,12 +2751,19 @@ class HostProcess:
if not refresh_full:
continue
latest = await asyncio.to_thread(configured_harness_map)
latest_gateway = await asyncio.to_thread(gateway_inference_map)
next_full = now + HARNESS_READINESS_FULL_REFRESH_INTERVAL_S
if latest != configured:
if latest != configured or latest_gateway != gateway:
await ws.send(
encode_host_frame(HostHarnessReadinessFrame(configured_harnesses=latest))
encode_host_frame(
HostHarnessReadinessFrame(
configured_harnesses=latest,
gateway_inference=latest_gateway,
)
)
)
configured = latest
gateway = latest_gateway
async def _handle_raw_message(
self, ws: websockets.asyncio.client.ClientConnection, raw: str
+79 -2
View File
@@ -98,6 +98,12 @@ class HostHelloFrame:
treat ``None`` as "nothing is configured". Changes arrive in
:class:`HostHarnessReadinessFrame`; launch-time checks remain
authoritative.
:param gateway_inference: Per-harness flag for whether that family's
launch on this host resolves AI-Gateway-backed inference, e.g.
``{"claude-native": True, "codex": False}`` (see
``omnigent.gateway_inference``). A family that could not be evaluated
is omitted. ``None`` means unknown (an older host that doesn't report
it) never treat it as "nothing is gateway-backed".
"""
version: str
@@ -105,6 +111,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
@@ -115,9 +122,16 @@ class HostHarnessReadinessFrame:
:param configured_harnesses: Current launch readiness keyed by every
accepted harness spelling. Sent only when the map changes.
:param gateway_inference: Per-harness flag for whether that family's
launch on this host resolves AI-Gateway-backed inference, e.g.
``{"claude-native": True, "codex": False}`` (see
``omnigent.gateway_inference``). A family that could not be evaluated
is omitted. ``None`` means unknown (an older host that doesn't report
it) never treat it as "nothing is gateway-backed".
"""
configured_harnesses: dict[str, HarnessAvailability]
gateway_inference: dict[str, bool] | None = None
@dataclass
@@ -631,6 +645,12 @@ class HostInstallHarnessResultFrame:
after the install attempt, e.g. ``{"claude-native": True,
"codex-native": "needs-auth"}``. ``None`` when the install could
not run (the server keeps its prior readiness view).
:param gateway_inference: Per-harness flag for whether that family's
launch on this host resolves AI-Gateway-backed inference, e.g.
``{"claude-native": True, "codex": False}`` (see
``omnigent.gateway_inference``). A family that could not be evaluated
is omitted. ``None`` means unknown (an older host that doesn't report
it) never treat it as "nothing is gateway-backed".
:param error: Why the install failed, e.g. ``"npm not found"`` or
``"install timed out"``. ``None`` on success.
"""
@@ -638,6 +658,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
@@ -699,6 +720,12 @@ class HostStoreSecretResultFrame:
otherwise (paired with a non-secret ``error``).
:param configured_harnesses: Readiness recomputed after the write, e.g.
``{"claude-native": True}``. ``None`` when the write could not run.
:param gateway_inference: Per-harness flag for whether that family's
launch on this host resolves AI-Gateway-backed inference, e.g.
``{"claude-native": True, "codex": False}`` (see
``omnigent.gateway_inference``). A family that could not be evaluated
is omitted. ``None`` means unknown (an older host that doesn't report
it) never treat it as "nothing is gateway-backed".
:param error: Non-secret failure reason, e.g. ``"a gateway requires a
base_url"``. ``None`` on success.
"""
@@ -706,6 +733,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
@@ -804,12 +832,21 @@ class HostModelOptionsFrame:
@dataclass
class HostModelOptionsResultFrame:
"""Host → server: pre-launch model choices resolved on that machine."""
"""Host → server: pre-launch model choices resolved on that machine.
:param models: Picker rows the harness can be launched/switched onto
by name, e.g. ``[{"id": "opus", "model": "…-opus-5"}]``.
:param routable_models: Every model id the harness's endpoint serves,
including generations no picker row names launchable exactly
(``--model``) even without a row, so a router may pick one.
Empty when the harness cannot enumerate its endpoint.
"""
request_id: str
status: str
models: list[_JsonObject] = field(default_factory=list)
error: str | None = None
routable_models: list[str] = field(default_factory=list)
HostFrame = (
@@ -892,6 +929,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,
}
@@ -901,6 +939,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):
@@ -1108,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,
}
)
@@ -1132,6 +1172,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,
}
)
@@ -1189,6 +1230,7 @@ def encode_host_frame(frame: HostFrame) -> str:
"status": frame.status,
"models": frame.models,
"error": frame.error,
"routable_models": frame.routable_models,
}
)
raise TypeError(f"unknown host frame type: {type(frame).__name__}")
@@ -1328,6 +1370,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"),
)
@@ -1345,7 +1388,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:
@@ -1686,6 +1732,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"),
)
@@ -1718,6 +1765,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"),
)
@@ -1811,11 +1859,17 @@ def _decode_model_options_result(msg: _JsonObject) -> HostModelOptionsResultFram
models = msg.get("models", [])
if not isinstance(models, list) or not all(isinstance(model, dict) for model in models):
raise ValueError("frame field must be a list of JSON objects: 'models'")
# Absent from hosts older than the routable-catalog field; the picker rows
# alone remain a valid answer.
routable = msg.get("routable_models", [])
if not isinstance(routable, list) or not all(isinstance(model, str) for model in routable):
raise ValueError("frame field must be a list of strings: 'routable_models'")
return HostModelOptionsResultFrame(
request_id=_required_str(msg, "request_id"),
status=_required_str(msg, "status"),
models=models,
error=_optional_nullable_str(msg, "error"),
routable_models=routable,
)
@@ -1899,6 +1953,29 @@ 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.
Public because the install / credential HTTP routes read the same field
straight off an RPC reply body rather than a decoded frame, and a host that
answers with a non-mapping must not 500 them either.
: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.
+94 -17
View File
@@ -8,13 +8,17 @@ import os
from collections.abc import AsyncIterator
from pathlib import Path
from omnigent.claude_model_vocabulary import claude_model_command_arg, normalized_model_id
from omnigent.claude_native_bridge import (
BRIDGE_DIR_ENV_VAR,
REQUEST_SESSION_ID_ENV_VAR,
SWITCH_MODEL_DIALOG_HINT,
inject_slash_command,
inject_user_message,
read_active_session_id,
read_claude_status_model,
read_launch_model,
read_model_env,
)
from omnigent.inner.executor import (
EnqueuedContent,
@@ -160,22 +164,27 @@ 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:
# Accepted trade-off: ``/model <id>`` also saves the
# pick as the person's global default for new Claude
# sessions. Runs to completion before the message
# inject below (same lock), so its confirm Enter can't
# race the message.
await asyncio.to_thread(
inject_slash_command,
self._bridge_dir,
command=f"/model {wanted_model}",
# 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
# confirm Enter can't race the message; a no-op on the
# gateway pane, which switches inline with no dialog.
command=f"/model {wanted_model_arg}",
auto_confirm=True,
confirm_hint=SWITCH_MODEL_DIALOG_HINT,
)
# ``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,15 +196,78 @@ class ClaudeNativeExecutor(Executor):
return
yield TurnComplete(response=None)
def _model_command_arg(self, wanted_model: str | None) -> str | None:
"""
Return the ``/model`` argument for this turn, or ``None`` to skip.
Two gates: the switch must be needed at all
(:meth:`_should_switch_model`), and the routed catalog id must
translate into vocabulary ``/model`` accepts the session's
family aliases, or the exact id of its custom picker slot. The
pinning comes from the terminal's launch env, recorded in the
bridge config because this process doesn't share that env.
An untranslatable id fails open: the message still goes in, on
the current model, with a warning. Typing a value the CLI won't
take leaves the pane on its old model while reporting success.
:param wanted_model: The turn's routed model, or ``None``.
:returns: A ``/model`` argument, or ``None`` when no switch
should be typed.
"""
if wanted_model is None:
_logger.info("claude-native: turn carries no routed model; not typing /model")
return None
if not self._should_switch_model(wanted_model):
_logger.info(
"claude-native: skipping /model — pane is already on %s",
wanted_model,
)
return None
env = read_model_env(self._bridge_dir) or None
wanted_arg = claude_model_command_arg(wanted_model, env)
if wanted_arg is None:
_logger.warning(
"claude-native: skipping /model — routed model %r has no spelling this "
"session accepts (pins=%s); sending the turn on the current model",
wanted_model,
sorted(env or ()),
)
return None
if (
self._applied_model is not None
and claude_model_command_arg(self._applied_model, env) == wanted_arg
):
# Resolves to the model the pane is already on, so the switch
# would be a pointless prompt (and can pop a confirm dialog).
_logger.info(
"claude-native: skipping /model — %r resolves to %r, already applied",
wanted_model,
wanted_arg,
)
return None
_logger.info(
"claude-native: typing /model %s for routed model %s",
wanted_arg,
wanted_model,
)
return wanted_arg
def _should_switch_model(self, wanted_model: str | None) -> bool:
"""
Return whether this turn must type ``/model`` before the message.
Only switches when routing named a model AND it differs from the
model the pane is already on. The baseline is tracked per turn in
``_applied_model``, seeded lazily from the spawn ``launch_model``
so turn 1's routed pick is compared against what Claude actually
booted with not blindly re-issued.
``_applied_model``, seeded lazily so turn 1's routed pick is compared
against what the pane is actually on not blindly re-issued.
The LIVE model (the statusLine capture) is the seed, falling back to
the launch model. ``launch_model`` alone was wrong for a routed first
message: the turn router blocks the prompt, switches the pane itself
and then replays the prompt with the same override, so a baseline
frozen at bridge-prepare time still named the pre-switch model and the
replay typed a second, redundant ``/model``.
:param wanted_model: The turn's routed model, or ``None`` when the
turn carries no override (routing off / already-pinned session).
@@ -204,12 +276,17 @@ class ClaudeNativeExecutor(Executor):
if not wanted_model:
return False
if self._applied_model is None:
# First turn: compare against the spawn model. read_launch_model
# is best-effort (None when no ucode profile was active); an
# unknown baseline means we switch to be safe — a redundant
# ``/model`` to the current model is a harmless no-op.
self._applied_model = read_launch_model(self._bridge_dir)
return wanted_model != self._applied_model
# Both reads are best-effort (no statusLine capture yet, no ucode
# profile); an unknown baseline means we switch to be safe — a
# redundant ``/model`` to the current model is a harmless no-op.
self._applied_model = read_claude_status_model(self._bridge_dir) or read_launch_model(
self._bridge_dir
)
if self._applied_model is None:
return True
# The statusLine reports a display spelling ("Sonnet 5") where routing
# names a catalog id, so compare normalized.
return normalized_model_id(wanted_model) != normalized_model_id(self._applied_model)
def _bridge_dir_from_env() -> Path:
+60
View File
@@ -46,6 +46,7 @@ from omnigent import model_catalog
from omnigent._platform import resolve_cli_binary, stable_user_id
from omnigent.inner import _proc
from omnigent.inner.bundle_skills import ensure_bundle_plugin_manifest
from omnigent.inner.hook_scripts import subagent_router
from omnigent.json_types import JsonObject as _JsonObject
from omnigent.llms._usage_observer import notify_from_dict as _notify_usage_from_dict
from omnigent.llms.adapters._content import parse_data_uri as _parse_replay_data_uri
@@ -1931,6 +1932,63 @@ class ClaudeSDKExecutor(Executor):
return str(metadata["session_id"])
return "default"
def _install_subagent_router_hook(
self,
sdk: _ClaudeSDK,
options: Any, # type: ignore[explicit-any] # ClaudeAgentOptions — avoid a hard sdk import
model: str | None,
) -> None:
"""
Register the in-process subagent-routing ``PreToolUse`` hook.
The claude-agent-sdk runs hook callbacks in this process, so the
native hook script's decision logic is imported instead of
subprocessed. No-op unless the runner advertises a
``route-subagent`` endpoint, so unrouted sessions register nothing.
:param sdk: The ``claude_agent_sdk`` module (or a test double).
:param options: ``ClaudeAgentOptions`` to mutate.
:param model: Model this session runs on, sent as the spawn's
parent model.
"""
hook_matcher_cls = getattr(sdk, "HookMatcher", None)
if hook_matcher_cls is None:
return
router_dir = subagent_router.discover_router_dir()
if subagent_router.read_router_endpoint(router_dir) is None:
return
async def route_spawn(
payload: Any, # type: ignore[explicit-any] # HookInput TypedDict
tool_use_id: str | None, # noqa: ARG001 -- HookCallback signature
context: Any, # type: ignore[explicit-any] # HookContext # noqa: ARG001 -- HookCallback signature
) -> dict[str, Any]: # type: ignore[explicit-any] # HookJSONOutput
if not isinstance(payload, dict):
return {}
output = await asyncio.to_thread(
subagent_router.route_pre_tool_use,
payload,
harness="claude-sdk",
router_dir=router_dir,
parent_model=model,
)
return output or {}
hooks = dict(getattr(options, "hooks", None) or {})
entries = list(hooks.get("PreToolUse") or [])
entries.append(
hook_matcher_cls(
matcher=subagent_router.AGENT_TOOL_MATCHER,
# Strictly outside the router call's own HTTP budget: equal
# numbers let the SDK cancel the hook at the same instant its
# request gives up, so the fail-open branch never ran.
timeout=subagent_router.HOOK_TIMEOUT_S,
hooks=[route_spawn],
)
)
hooks["PreToolUse"] = entries
options.hooks = hooks
async def _can_use_tool_for_permission(
self,
tool_name: str,
@@ -2367,6 +2425,8 @@ class ClaudeSDKExecutor(Executor):
):
options.can_use_tool = self._can_use_tool_gate
self._install_subagent_router_hook(sdk, options, model)
# Log the full configuration for debugging
logger.info(
"ClaudeSDKExecutor: model=%s, gateway=%s, base_url=%s, tools=%d, thinking=%r",
+724 -4
View File
@@ -9,23 +9,42 @@ from __future__ import annotations
import asyncio
import base64
import copy
import json
import logging
import os
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, MutableMapping
from collections.abc import (
AsyncIterator,
Awaitable,
Callable,
Iterable,
Mapping,
MutableMapping,
Sequence,
)
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol, TypeAlias
from typing import Any, Protocol, TypeAlias, cast
from omnigent import model_catalog
from omnigent._platform import resolve_cli_binary
from omnigent.codex_model_vocabulary import (
EXTENDED_CATALOG_MODELS,
EXTENDED_MODEL_DEFAULT_EFFORT,
EXTENDED_MODEL_EFFORTS,
)
from omnigent.inner.agent_env import clean_agent_env, declared_passthrough
from omnigent.llms._usage_observer import notify_from_dict as _notify_usage_from_dict
from omnigent.model_fallbacks import CODEX_CATALOG_CLONE_SOURCE_SLUG
from omnigent.reasoning_effort import CODEX_EFFORTS, EFFORT_ALIASES, validate_effort
from omnigent.spec.types import RetryPolicy
@@ -53,6 +72,8 @@ from .executor import (
TurnComplete,
classify_tool_result,
)
from .hook_scripts.subagent_router import HOOK_TIMEOUT_HEADROOM_S as _ROUTER_HOOK_HEADROOM_S
from .hook_scripts.subagent_router import REQUEST_TIMEOUT_S as _ROUTER_REQUEST_TIMEOUT_S
logger = logging.getLogger(__name__)
@@ -105,6 +126,9 @@ _STREAM_READ_CHUNK_SIZE = 65536
# to running sessions without any action from Omnigent.
_CODEX_HOME_SYMLINK_FILES = ("auth.json",)
_CODEX_HOME_GLOBAL_INSTRUCTION_FILES = ("AGENTS.md", "AGENTS.override.md", "hooks.json")
# Name of the hooks file inside a CODEX_HOME. Symlinked from the user's home
# by default; generated as a merged regular file when subagent routing is on.
_CODEX_HOOKS_FILENAME = "hooks.json"
# Files copied (not symlinked) from the real CODEX_HOME into the per-session
# temp home. config.toml is intentionally copied so that an in-TUI ``/model``
@@ -391,6 +415,11 @@ def _clean_codex_env(extra_allow: Iterable[str] = ()) -> dict[str, str]:
auth (``auth.json``) rather than a developer API key that would charge
separately.
The filtered dict is also the executor's own view of its launch, not just
the subprocess env: the app-server session reads Omnigent's per-session
codex signals back out of it, so those names have to survive the filter
(see :data:`_CODEX_OMNIGENT_LAUNCH_ENV_VARS`).
:returns: Filtered environment dict.
"""
return clean_agent_env(
@@ -399,6 +428,7 @@ def _clean_codex_env(extra_allow: Iterable[str] = ()) -> dict[str, str]:
"PYTHONUTF8",
"DATABRICKS_BEARER", # explicit CI/integration bearer used by auth.command
"DATABRICKS_CODEX_TOKEN", # env_key in ~/.codex/config.toml's DB provider
*_CODEX_OMNIGENT_LAUNCH_ENV_VARS,
),
deny_exact=_CODEX_ENV_DENY_EXACT,
extra_allowed=extra_allow,
@@ -682,6 +712,8 @@ def _populate_codex_home_config(
source_dir: Path,
*,
minimal_config: bool | None = None,
inject_hooks: bool = False,
extend_model_catalog: bool = False,
) -> None:
"""
Bridge user config files from the real ``CODEX_HOME`` into the temp one.
@@ -714,6 +746,15 @@ 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 inject_hooks: Skip the ``hooks.json`` symlink because the caller
generates a merged regular file (user hooks + Omnigent hooks) at that
path instead see :func:`write_codex_hooks_file`. Left ``False`` when
no hooks are injected, so the user's file stays symlinked and a
mid-session edit to it still takes effect.
:param extend_model_catalog: Replace codex's bundled model catalog with
its own catalog plus the gateway-only arms. Costs a ``codex debug
models`` probe, so it is reserved for Smart Routing sessions whose
turns/spawns can land on such an arm.
"""
if not source_dir.is_dir():
return
@@ -727,6 +768,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 inject_hooks:
# 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():
@@ -790,6 +835,14 @@ def _populate_codex_home_config(
shutil.copy2(source_file, dest_path)
if filename == "config.toml":
_normalize_copied_codex_effort(dest_path)
if extend_model_catalog:
# Routed turns and spawns can land on an arm codex's bundled
# catalog has no entry for, which it then refuses client-side.
catalog_path = write_codex_model_catalog(
target_dir, codex_path=_find_codex_cli(), source_home=source_dir
)
if catalog_path is not None:
set_codex_model_catalog_path(dest_path, catalog_path)
def materialize_codex_provider_config(
@@ -855,6 +908,629 @@ def materialize_codex_provider_config(
return argv_overrides
# Bridge directory holding the ``subagent_router.json`` advertisement. Its
# presence in the codex process env is what turns generated routing hooks on:
# without an endpoint to ask there is nothing to enforce, so the user's
# ``hooks.json`` keeps being symlinked untouched.
CODEX_ROUTER_DIR_ENV_VAR = "OMNIGENT_CODEX_SUBAGENT_ROUTER_DIR"
# Session the spawns belong to, baked into the generated hook commands.
CODEX_ROUTER_SESSION_ID_ENV_VAR = "OMNIGENT_CODEX_SUBAGENT_ROUTER_SESSION_ID"
_CODEX_ROUTER_HOOK_MODULE = "omnigent.inner.hook_scripts.codex_router_hook"
# Codex flattens the spawn tool name (``collaborationspawn_agent`` on
# 0.145.x), so the matcher is a regex suffix and never a bare literal.
_CODEX_SPAWN_AGENT_MATCHER = r".*spawn_agent"
# Kept just above the hook's own request budget so codex's kill is the
# outermost bound: the hook fails open on its timeout, codex only steps in
# if the hook itself wedged.
_CODEX_ROUTER_HOOK_TIMEOUT_SECONDS = int(_ROUTER_REQUEST_TIMEOUT_S + _ROUTER_HOOK_HEADROOM_S)
# Codex release the ``PreToolUse`` spawn gate is verified against. Older CLIs
# spell the flattened spawn tool name differently (or lack ``PreToolUse``
# entirely), so the matcher above never fires and routing silently no-ops.
# Checked here, at the registration site, rather than as a launch floor: an
# older codex must still launch, just without the spawn gate.
_CODEX_ROUTING_HOOK_MIN_VERSION = (0, 145, 0)
def codex_routing_hook_skip_reason(codex_cli_version: tuple[int, int, int] | None) -> str | None:
"""
Explain why the routing spawn gate cannot be registered, if it cannot.
An unparseable version (``None``) counts as supported, matching the
policy-hook gate: a flaky ``codex --version`` probe must not silently
drop routing when the CLI is probably new enough.
:param codex_cli_version: Parsed ``codex --version``, e.g. ``(0, 139, 0)``.
:returns: A log-ready reason, or ``None`` when the hook may be registered.
"""
if codex_cli_version is None or codex_cli_version >= _CODEX_ROUTING_HOOK_MIN_VERSION:
return None
spelled = ".".join(str(part) for part in codex_cli_version)
minimum = ".".join(str(part) for part in _CODEX_ROUTING_HOOK_MIN_VERSION)
return (
f"codex {spelled} predates PreToolUse hooks (need >= {minimum}); "
"smart routing spawn gate disabled"
)
def _codex_router_hook_command(
subcommand: str,
bridge_dir: Path,
*,
session_id: str | None,
python_executable: str | None,
extra_args: Iterable[str] = (),
) -> str:
"""
Build the shell command codex runs for one routing hook event.
Runs python in isolated mode (``-I``). Codex executes hooks with the
session's workspace as cwd, and ``-m`` would otherwise put that
workspace first on ``sys.path``: a workspace containing a directory
named ``omnigent`` (any checkout of this project) shadows the installed
package, the hook dies on ``ModuleNotFoundError``, and codex discards
the failure the routing gate silently fails open.
:param subcommand: Hook-script subcommand, e.g. ``"route-subagent"``.
:param bridge_dir: Session bridge directory holding the router
advertisement.
:param session_id: Omnigent session id, or ``None`` when the
advertisement is expected to carry it.
:param python_executable: Python to run; ``None`` uses
:data:`sys.executable`.
:param extra_args: Extra flags, e.g. ``("--harness", "codex-native")``.
:returns: A shell-escaped command string.
"""
argv = [
python_executable or sys.executable,
"-I",
"-m",
_CODEX_ROUTER_HOOK_MODULE,
subcommand,
"--bridge-dir",
str(bridge_dir),
]
if session_id:
argv.extend(["--session-id", session_id])
argv.extend(extra_args)
return shlex.join(argv)
def codex_router_hooks_settings(
bridge_dir: Path,
*,
session_id: str | None = None,
harness: str = "codex",
python_executable: str | None = None,
) -> dict[str, Any]:
"""
Build the Omnigent half of a routing ``hooks.json`` payload.
One event: a ``PreToolUse`` gate on the spawn tool (matched by regex
because codex flattens the name) that asks the runner which model the
spawn may use and rewrites / denies accordingly.
:param bridge_dir: Session bridge directory.
:param session_id: Omnigent session id baked into the commands.
:param harness: Harness label sent to the endpoint, e.g. ``"codex"``.
:param python_executable: Python for the hook commands.
:returns: A ``hooks.json``-shaped dict.
"""
def hook(subcommand: str, timeout: int, extra_args: Iterable[str] = ()) -> dict[str, Any]:
return {
"type": "command",
"command": _codex_router_hook_command(
subcommand,
bridge_dir,
session_id=session_id,
python_executable=python_executable,
extra_args=extra_args,
),
"timeout": timeout,
}
return {
"hooks": {
"PreToolUse": [
{
"matcher": _CODEX_SPAWN_AGENT_MATCHER,
"hooks": [
hook(
"route-subagent",
_CODEX_ROUTER_HOOK_TIMEOUT_SECONDS,
("--harness", harness),
)
],
}
],
}
}
def merge_codex_user_hooks(payload: dict[str, Any], user_hooks_path: Path) -> dict[str, Any]:
"""
Merge the user's ``hooks.json`` entries into a generated payload.
Omnigent's entries stay in first position per event so the routing
gate runs before user hooks; events the user declares alone are added
wholesale. A missing or malformed user file leaves *payload*
unchanged routing must not break because the user's hooks file is
bad.
:param payload: Payload from :func:`codex_router_hooks_settings`.
:param user_hooks_path: The user's real ``hooks.json``.
:returns: The merged payload.
"""
try:
user_data = json.loads(user_hooks_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return payload
user_hooks = user_data.get("hooks", {}) if isinstance(user_data, dict) else {}
if not isinstance(user_hooks, dict) or not user_hooks:
return payload
return merge_codex_hook_payloads([payload, {"hooks": user_hooks}])
def merge_codex_hook_payloads(payloads: Iterable[Mapping[str, Any]]) -> dict[str, Any]:
"""
Merge ``hooks.json``-shaped payloads, earlier ones first per event.
Codex loads exactly one hooks file per ``CODEX_HOME``, so every
generator (policy hooks, routing hooks, the user's own hooks) has to
share a single payload; order decides which hook gates first.
:param payloads: Payloads to merge, most privileged first.
:returns: The merged payload.
"""
merged_hooks: dict[str, Any] = {}
for payload in payloads:
hooks = payload.get("hooks") or {}
if not isinstance(hooks, Mapping):
continue
for event, entries in hooks.items():
if not isinstance(entries, list):
continue
existing = merged_hooks.get(event)
merged_hooks[event] = list(existing) + list(entries) if existing else list(entries)
return {"hooks": merged_hooks}
def write_codex_hooks_file(
codex_home: Path,
payloads: Sequence[Mapping[str, Any]],
*,
user_hooks_source: Path | None = None,
) -> Path:
"""
Write the private CODEX_HOME's single ``hooks.json`` (atomically).
The one writer for every hook generator: *payloads* are merged in
order (Omnigent's stay in first position per event) and the user's
hooks are appended last. A symlink to the user's file is replaced by
the merged regular file, and is the merge source when
*user_hooks_source* is not given.
:param codex_home: Private per-session ``CODEX_HOME``.
:param payloads: ``hooks.json``-shaped payloads, most privileged first.
:param user_hooks_source: The user's real ``hooks.json`` to merge.
:returns: Path of the written file.
"""
codex_home.mkdir(mode=0o700, parents=True, exist_ok=True)
path = codex_home / _CODEX_HOOKS_FILENAME
payload = merge_codex_hook_payloads(payloads)
merge_source = user_hooks_source
if merge_source is None and path.is_symlink() and path.exists():
merge_source = path.resolve()
if merge_source is not None and merge_source.is_file():
payload = merge_codex_user_hooks(payload, merge_source)
if path.is_symlink() or path.exists():
path.unlink()
fd, tmp_name = tempfile.mkstemp(prefix=f"{_CODEX_HOOKS_FILENAME}.", dir=str(codex_home))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, sort_keys=True)
handle.write("\n")
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
return path
def write_codex_router_hooks_file(
codex_home: Path,
bridge_dir: Path,
*,
session_id: str | None = None,
harness: str = "codex",
python_executable: str | None = None,
user_hooks_source: Path | None = None,
) -> Path:
"""
Write a ``hooks.json`` holding only the routing hooks (plus user hooks).
Used by harnesses that register no other hooks; the native app-server
merges the routing payload with its policy hooks instead.
:param codex_home: Private per-session ``CODEX_HOME``.
:param bridge_dir: Session bridge directory.
:param session_id: Omnigent session id baked into the hook commands.
:param harness: Harness label sent to the endpoint.
:param python_executable: Python for the hook commands.
:param user_hooks_source: The user's real ``hooks.json`` to merge.
:returns: Path of the written file.
"""
return write_codex_hooks_file(
codex_home,
[
codex_router_hooks_settings(
bridge_dir,
session_id=session_id,
harness=harness,
python_executable=python_executable,
)
],
user_hooks_source=user_hooks_source,
)
def codex_router_bridge_dir(env: Mapping[str, str] | None = None) -> Path | None:
"""
Read the routing bridge directory from a process environment.
:param env: Environment to read; ``None`` uses :data:`os.environ`.
:returns: Bridge directory, or ``None`` when routing is off for this
session (no endpoint advertised, so nothing to enforce).
"""
source = os.environ if env is None else env
raw = (source.get(CODEX_ROUTER_DIR_ENV_VAR) or "").strip()
return Path(raw) if raw else None
def codex_router_session_id(env: Mapping[str, str] | None = None) -> str | None:
"""
Read the routing session id from a process environment.
:param env: Environment to read; ``None`` uses :data:`os.environ`.
:returns: Session id, or ``None`` when unset.
"""
source = os.environ if env is None else env
return (source.get(CODEX_ROUTER_SESSION_ID_ENV_VAR) or "").strip() or None
# Set for a session whose turns or spawns can land on a gateway arm codex's
# bundled catalog has no entry for, i.e. any Smart Routing session. Carried in
# the process env (not a launch argument) so the wrapped executor and the native
# app-server read the same signal, and absent everywhere else — a plain codex
# session keeps codex's own catalog and never pays the probe.
CODEX_EXTENDED_CATALOG_ENV_VAR = "OMNIGENT_CODEX_EXTENDED_MODEL_CATALOG"
def codex_extended_catalog_env(enabled: bool) -> dict[str, str]:
"""
Build the env that asks a codex process for the extended model catalog.
:param enabled: ``True`` for a Smart Routing session (pinned or
auto-harness).
:returns: Env-var overrides, empty when the catalog stays codex's own.
"""
return {CODEX_EXTENDED_CATALOG_ENV_VAR: "1"} if enabled else {}
def codex_extended_catalog_requested(env: Mapping[str, str] | None = None) -> bool:
"""
Report whether this codex process should extend the model catalog.
:param env: Environment to read; ``None`` uses :data:`os.environ`.
:returns: ``True`` when the launch asked for the extended catalog.
"""
source = os.environ if env is None else env
return (source.get(CODEX_EXTENDED_CATALOG_ENV_VAR) or "").strip() == "1"
#: Omnigent's own per-session signals for a codex launch: the subagent-router
#: rendezvous, its session id, and the extended-catalog request. The runner sets
#: them in the harness process env, and the executor reads them back out of
#: ``_clean_codex_env``'s filtered copy — so they must be allowed through it or
#: both features silently never engage on the wrapped ``codex`` harness.
_CODEX_OMNIGENT_LAUNCH_ENV_VARS: tuple[str, ...] = (
CODEX_ROUTER_DIR_ENV_VAR,
CODEX_ROUTER_SESSION_ID_ENV_VAR,
CODEX_EXTENDED_CATALOG_ENV_VAR,
)
# Catalog file written into the private codex-home, naming the models the
# session's ``spawn_agent`` may target. See :func:`extended_model_catalog`.
_CODEX_MODEL_CATALOG_FILENAME = "model_catalog.json"
# Entry a gateway-only model is cloned from: the cheapest current arm, so an
# unset field inherits a sane current-generation value rather than a frozen one.
_CATALOG_CLONE_SOURCE_SLUG = CODEX_CATALOG_CLONE_SOURCE_SLUG
def extended_model_catalog(
catalog: dict[str, Any],
*,
clone_source: str = _CATALOG_CLONE_SOURCE_SLUG,
) -> dict[str, Any] | None:
"""
Add the routed arms codex's own catalog has no entry for.
Codex validates ``spawn_agent``'s ``model`` against this catalog before
the request leaves the CLI, so an arm absent from it cannot be spawned
however servable the gateway makes it that is what blocked GLM
subagents. Each missing arm is cloned from *clone_source* and re-slugged
to the id the gateway serves it as, with its own effort ladder so codex
clamps the spawn instead of refusing it.
:param catalog: ``codex debug models`` output, i.e.
``{"models": [{"slug": ..., ...}, ...]}``.
:param clone_source: Slug whose entry supplies every field the added
arms do not override.
:returns: A new catalog including the added arms, or ``None`` when
*catalog* is unusable or has nothing to add (so the caller can leave
codex on its own bundled catalog).
"""
models = catalog.get("models")
if not isinstance(models, list) or not models:
return None
by_slug = {m.get("slug"): m for m in models if isinstance(m, dict)}
template = by_slug.get(clone_source)
if template is None:
return None
added: list[dict[str, Any]] = []
for bare, slug in EXTENDED_CATALOG_MODELS.items():
if slug in by_slug:
continue
efforts = EXTENDED_MODEL_EFFORTS.get(bare, ())
entry = copy.deepcopy(template)
entry.update(
{
"slug": slug,
"display_name": slug.rsplit(".", 1)[-1],
"visibility": "list",
"default_reasoning_level": EXTENDED_MODEL_DEFAULT_EFFORT.get(bare, "medium"),
"supported_reasoning_levels": [
level
for level in template.get("supported_reasoning_levels", [])
if isinstance(level, dict) and level.get("effort") in efforts
],
# Upsell/nux metadata describes the cloned arm, not this one.
"availability_nux": None,
"upgrade": None,
}
)
added.append(entry)
if not added:
return None
return {**catalog, "models": [*models, *added]}
# Cached ``codex debug models`` result, keyed by (binary, CODEX_HOME). The
# catalog is a property of the installed CLI, not of a session, so a successful
# probe is paid once per host process rather than once per session.
_MODEL_CATALOG_CACHE: dict[tuple[str, str, int, int], dict[str, Any]] = {}
# Failures are cached only briefly, keyed the same way and holding the
# monotonic time the negative expires. Caching them forever turned one
# transient 10s timeout — a loaded host, a cold binary — into "this host has no
# catalog" for the life of the process, silently dropping the gateway-only arms
# from every later session's ``spawn_agent``. Caching them not at all would pay
# the full timeout per session on a genuinely broken CLI.
_MODEL_CATALOG_FAILURE_TTL_S = 60.0
_MODEL_CATALOG_FAILURES: dict[tuple[str, str, int, int], float] = {}
# Both caches are host-process globals reached from worker threads (every
# caller populates a codex home through ``asyncio.to_thread``), and the probe
# they memoize is a ~10 s subprocess. Held across the probe so two sessions
# booting together pay it once: the loser waits for the winner's result instead
# of shelling out again, which is also what keeps the dict mutations atomic.
_MODEL_CATALOG_LOCK = threading.Lock()
def _model_catalog_cache_key(codex_path: str, source_home: Path) -> tuple[str, str, int, int]:
"""
Key the catalog cache so an in-place codex upgrade re-probes.
The catalog IS the installed binary's, and `npm i -g @openai/codex` (or a
Homebrew upgrade) replaces it at the same path so path plus home alone
served the old codex's models for the rest of the host process. The
binary's mtime and size are in the key too; an unreadable path degrades to
a sentinel, which just means "cache as before".
:param codex_path: The codex binary.
:param source_home: ``CODEX_HOME`` the probe resolves config from.
:returns: The cache key.
"""
try:
stat = os.stat(codex_path)
except OSError:
return (codex_path, str(source_home), -1, -1)
return (codex_path, str(source_home), stat.st_mtime_ns, stat.st_size)
def _valid_model_catalog(catalog: object) -> bool:
"""
Report whether a probe result is shaped like a codex model catalog.
``model_catalog_json`` REPLACES codex's bundled catalog, so a
half-readable payload would not degrade the session it would narrow or
empty the set of models codex will accept. Validated before it is allowed
to become that file: anything unexpected is treated as a probe failure and
the session keeps codex's own catalog.
:param catalog: Decoded ``codex debug models`` output.
:returns: ``True`` when every entry carries a usable ``slug``.
"""
if not isinstance(catalog, dict):
return False
models = catalog.get("models")
if not isinstance(models, list) or not models:
return False
for entry in models:
if not isinstance(entry, dict):
return False
slug = entry.get("slug")
if not isinstance(slug, str) or not slug.strip():
return False
return True
def read_codex_model_catalog(
codex_path: str,
source_home: Path,
*,
timeout: float = 10.0,
) -> dict[str, Any] | None:
"""
Ask the codex CLI for its own model catalog, once per host process.
Read from the CLI rather than pinned in this repo so the catalog tracks
whatever codex version is installed: it is ~300 kB of vendor metadata
(per-model prompts included) that a pinned copy would silently freeze.
Blocking (it shells out with a timeout), so callers on the event loop must
reach it through a thread see :func:`write_codex_model_catalog`.
:param codex_path: The codex binary.
:param source_home: ``CODEX_HOME`` to resolve config from.
:param timeout: Seconds to wait; a slow probe must not delay session boot.
:returns: ``{"models": [...]}``, or ``None`` on any failure.
"""
cache_key = _model_catalog_cache_key(codex_path, source_home)
with _MODEL_CATALOG_LOCK:
cached = _MODEL_CATALOG_CACHE.get(cache_key)
if cached is not None:
return cached
failed_until = _MODEL_CATALOG_FAILURES.get(cache_key)
if failed_until is not None:
if time.monotonic() < failed_until:
return None
del _MODEL_CATALOG_FAILURES[cache_key]
catalog = _probe_codex_model_catalog(codex_path, source_home, timeout=timeout)
if catalog is None:
_MODEL_CATALOG_FAILURES[cache_key] = time.monotonic() + _MODEL_CATALOG_FAILURE_TTL_S
return None
_MODEL_CATALOG_CACHE[cache_key] = catalog
return catalog
def _probe_codex_model_catalog(
codex_path: str,
source_home: Path,
*,
timeout: float,
) -> dict[str, Any] | None:
"""Run ``codex debug models``, returning ``None`` on any failure."""
try:
completed = subprocess.run(
[codex_path, "debug", "models"],
capture_output=True,
text=True,
timeout=timeout,
env={**os.environ, "CODEX_HOME": str(source_home)},
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
logger.warning("could not read the codex model catalog (%s)", exc)
return None
if completed.returncode != 0:
logger.warning(
"codex debug models exited %s: %s", completed.returncode, completed.stderr[:200]
)
return None
try:
catalog = json.loads(completed.stdout)
except ValueError as exc:
logger.warning("could not parse the codex model catalog (%s)", exc)
return None
if not _valid_model_catalog(catalog):
logger.warning(
"codex debug models returned an unusable catalog; keeping codex's bundled one"
)
return None
return cast(dict[str, Any], catalog)
def write_codex_model_catalog(
target_dir: Path,
*,
codex_path: str | None,
source_home: Path,
) -> Path | None:
"""
Give the session a model catalog its ``spawn_agent`` can route across.
``model_catalog_json`` REPLACES codex's bundled catalog rather than
merging into it (probed: a one-entry file leaves ``spawn_agent`` with
exactly that one model), so the file is codex's own catalog plus the
gateway-only arms never a hand-written list.
Every failure returns ``None`` and leaves the session on codex's bundled
catalog: a spawn that cannot reach GLM beats a session that will not
start.
:param target_dir: The per-session private ``CODEX_HOME``.
:param codex_path: The codex binary, or ``None`` when unresolved.
:param source_home: ``CODEX_HOME`` the probe should resolve config from.
:returns: The written catalog path, or ``None`` when nothing was written.
"""
if codex_path is None:
return None
catalog = read_codex_model_catalog(codex_path, source_home)
if catalog is None:
return None
extended = extended_model_catalog(catalog)
if extended is None:
return None
path = target_dir / _CODEX_MODEL_CATALOG_FILENAME
try:
path.write_text(json.dumps(extended), encoding="utf-8")
except OSError as exc:
logger.warning("could not write %s (%s)", path, exc)
return None
return path
# ``model_catalog_json`` assignment appended to the private config copy. A
# top-level key, so it goes before the first table header.
_CATALOG_KEY_RE = re.compile(r"^\s*model_catalog_json\s*=")
def set_codex_model_catalog_path(config_path: Path, catalog_path: Path) -> bool:
"""
Point the session's private ``config.toml`` at *catalog_path*.
:param config_path: The copied ``config.toml`` inside the private home.
:param catalog_path: Catalog written by
:func:`write_codex_model_catalog`.
:returns: ``True`` when the key was written, ``False`` when the config
already sets one (the user's choice wins) or the write failed.
"""
try:
lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
except OSError as exc:
logger.warning("could not read %s (%s)", config_path, exc)
return False
for line in lines:
if line.lstrip().startswith("["):
break
if _CATALOG_KEY_RE.match(line):
return False
assignment = f"model_catalog_json = {json.dumps(str(catalog_path))}\n"
# Before the first table header, so the key stays top-level.
insert_at = next(
(i for i, line in enumerate(lines) if line.lstrip().startswith("[")), len(lines)
)
lines.insert(insert_at, assignment)
try:
config_path.write_text("".join(lines), encoding="utf-8")
except OSError as exc:
logger.warning("could not write %s (%s)", config_path, exc)
return False
return True
# Top-level ``model_reasoning_effort = "<value>"`` assignment, tolerating
# leading whitespace and a trailing comment. Only applied to lines *before*
# the first table header so keys inside ``[profiles.*]`` etc. are never
@@ -1428,14 +2104,44 @@ class _CodexAppServerSession:
# definitions) from ``$CODEX_HOME``; without this step a freshly-
# created temp dir has neither, causing 401 Unauthorized errors
# for subscription-authenticated users.
_populate_codex_home_config(
config_source = _codex_home_config_source_from_env()
# When the runner advertises a subagent-routing endpoint, the user's
# hooks.json is merged into a generated file registering the routing
# hooks instead of being symlinked in untouched. Only an auto-harness
# Smart Routing session gets that endpoint, so a plain or pinned session
# keeps the symlink — and with it mid-session edits to the user's file.
router_bridge_dir = codex_router_bridge_dir(self._env)
if router_bridge_dir is not None:
# Probed only on the routing path so a plain session never pays the
# subprocess. A CLI too old for the spawn gate drops the hooks and
# keeps the symlinked home, so routing no-ops instead of blocking.
skip_reason = codex_routing_hook_skip_reason(
await _codex_cli_version(self._codex_path)
)
if skip_reason is not None:
logger.warning("%s", skip_reason)
router_bridge_dir = None
# Off the loop: this copies/symlinks a home AND (on the routing path)
# shells out to ``codex debug models`` with a 10s timeout. Run inline it
# stalled every other session sharing this event loop for that long.
await asyncio.to_thread(
_populate_codex_home_config,
self._codex_home_dir,
_codex_home_config_source_from_env(),
config_source,
inject_hooks=router_bridge_dir is not None,
extend_model_catalog=codex_extended_catalog_requested(self._env),
)
self._codex_config_overrides = materialize_codex_provider_config(
self._codex_home_dir,
self._codex_config_overrides,
)
if router_bridge_dir is not None:
write_codex_router_hooks_file(
self._codex_home_dir,
router_bridge_dir,
session_id=codex_router_session_id(self._env),
user_hooks_source=config_source / _CODEX_HOOKS_FILENAME,
)
# Override CODEX_HOME so Codex stores its data (including conversation
# history) in a private temp directory rather than the user's ~/.codex/.
# This prevents subagent sessions from polluting the user's Codex history.
@@ -1468,6 +2174,20 @@ class _CodexAppServerSession:
},
)
self._started = True
if router_bridge_dir is not None:
# App-server threads run persisted-trusted hooks only, so the
# routing hooks need the trust handshake to be enforced.
# Imported here: the app-server module imports this one.
from omnigent.codex_native_app_server import trust_codex_router_hooks
try:
await trust_codex_router_hooks(self._request, cwd=self._cwd or os.getcwd())
except Exception: # noqa: BLE001 - never block session startup
logger.warning(
"codex subagent-routing hook trust failed; "
"routing will not be enforced for this session",
exc_info=True,
)
except Exception:
await self.close()
raise
+22 -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 (
@@ -39,7 +40,7 @@ from omnigent.inner.native_attachments import (
parse_data_uri,
unresolved_attachment_marker,
)
from omnigent.reasoning_effort import CODEX_EFFORTS, validate_effort
from omnigent.reasoning_effort import CODEX_EFFORTS, effort_for_model_switch, validate_effort
_logger = logging.getLogger(__name__)
@@ -301,6 +302,20 @@ class CodexNativeExecutor(Executor):
**settings_overrides,
},
)
# Mirror the accepted switch into config.toml —
# the file the forwarder's model mirror and the
# cost-gate hook read. thread/settings/update does
# not write it, so without this the stale launch
# model is mirrored back at the next turn/started
# and silently reverts the switch.
switched_model = settings_overrides.get("model")
if isinstance(switched_model, str) and switched_model:
if not write_codex_config_model(self._bridge_dir, switched_model):
_logger.warning(
"Failed to mirror codex model switch into "
"config.toml: model=%s",
switched_model,
)
turn_params: dict[str, object] = {
"threadId": state.thread_id,
"input": input_items,
@@ -364,6 +379,12 @@ def _model_effort_overrides(config: ExecutorConfig | None) -> dict[str, object]:
# current effort rather than failing the whole dispatch.
_logger.warning("Ignoring unsupported codex reasoning effort: %r", raw_effort)
effort = None
model_str = model if isinstance(model, str) and model else None
# A model switch inherits config.toml's effort (the user's xhigh default),
# which the switched-to model may reject (GLM has no xhigh). Guard the live
# turn: clamp an explicit effort, and when none was requested but the model
# caps below the codex default, send that ceiling so the turn does not 400.
effort = effort_for_model_switch(effort, model_str)
if effort:
overrides["effort"] = effort
return overrides
@@ -0,0 +1,41 @@
"""Claude Code ``PreToolUse`` hook that routes native subagent spawns.
Registered by ``build_hook_settings`` on the ``Task|Agent`` matcher and
run as a subprocess per spawn. Reads the hook payload from stdin, asks
the runner's ``route-subagent`` endpoint what to do, and writes the
decision to stdout.
Always exits ``0``: routing must never be the reason a spawn fails. When
the endpoint is unadvertised, unreachable, or answers ``allow``, the hook
emits nothing and Claude proceeds unchanged.
"""
from __future__ import annotations
import sys
from omnigent.inner.hook_scripts.subagent_router import run_route_subagent_main
_HARNESS = "claude-native"
_LABEL = "omnigent claude router hook"
_PROG = "omnigent-claude-router-hook"
def main(argv: list[str] | None = None) -> int:
"""
Run the hook.
:param argv: Command-line arguments, excluding the program name.
``None`` uses :data:`sys.argv`.
:returns: Always ``0`` so a routing failure never blocks a spawn.
"""
return run_route_subagent_main(
list(sys.argv[1:] if argv is None else argv),
prog=_PROG,
harness=_HARNESS,
label=_LABEL,
)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,202 @@
"""Codex hook entrypoint for deterministic subagent routing.
Registered in the per-session private ``CODEX_HOME`` ``hooks.json``
generated by :mod:`omnigent.inner.codex_executor`. One subcommand:
- ``route-subagent`` (``PreToolUse``, matcher ``.*spawn_agent``) asks
the runner's ``route-subagent`` endpoint what model the spawn may use
and rewrites / denies accordingly.
Stdlib-only and short-lived: ``PreToolUse`` blocks the spawn.
The spawn ``message`` arrives in the hook payload in plaintext and is the
only real routing signal this codex's ``spawn_agent`` has no task-name
field, so without it every spawn scores the same placeholder and lands the
router's default arm. An explicit ``model`` in the spawn arguments is
forwarded as ``requested_model`` so the server can honor an in-family ask.
A rewrite echoes the rest of ``tool_input`` back untouched, but ``model``
and ``reasoning_effort`` are both rewritten: codex validates each against
its own model catalog *before* the request leaves the CLI, so a servable
catalog id or an out-of-ladder effort fails the spawn outright rather than
degrading it. See :mod:`omnigent.codex_model_vocabulary`.
"""
from __future__ import annotations
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any
from omnigent.codex_model_vocabulary import clamp_spawn_effort, codex_spawn_model
from omnigent.inner.hook_scripts.subagent_router import (
run_route_subagent_main,
)
# Codex flattens MCP-ish tool names, so the spawn tool arrives as
# ``collaborationspawn_agent`` on 0.145.x. Both the hooks.json matcher
# and this check must stay suffix-based rather than a bare literal.
SPAWN_AGENT_TOOL_SUFFIX = "spawn_agent"
# Harness label sent to the endpoint when argv names none.
DEFAULT_HARNESS = "codex"
_LABEL = "omnigent codex router hook"
_MODULE = "omnigent.inner.hook_scripts.codex_router_hook"
def main(argv: list[str] | None = None) -> int:
"""
Dispatch a codex router-hook subcommand.
:param argv: Argv override excluding the program name. ``None`` reads
:data:`sys.argv`.
:returns: Process exit code. Always ``0`` verdicts travel on stdout
so a hook failure never wedges codex.
"""
raw_argv = sys.argv[1:] if argv is None else argv
command = raw_argv[0] if raw_argv else ""
if command == "route-subagent":
return run_route_subagent_main(
raw_argv[1:],
prog=_prog(command),
harness=DEFAULT_HARNESS,
label=_LABEL,
**ROUTE_SEAMS,
)
print(f"{_LABEL}: unknown subcommand {command!r}", file=sys.stderr)
return 0
def _prog(command: str) -> str:
return f"python -m {_MODULE} {command}"
def is_spawn_agent_tool(tool_name: Any) -> bool: # type: ignore[explicit-any] # hook payloads are untrusted JSON
"""
Report whether a hook payload names codex's subagent-spawn tool.
:param tool_name: ``tool_name`` from the hook payload, e.g.
``"collaborationspawn_agent"``.
:returns: ``True`` when the (flattened) name ends in
``spawn_agent``.
"""
if not isinstance(tool_name, str):
return False
return tool_name.strip().lower().endswith(SPAWN_AGENT_TOOL_SUFFIX)
def spawn_model_translator(
bridge_dir: str | Path | None,
) -> Callable[[str], str | None]:
"""
Build the model translator for codex's spawn tool.
Codex's ``spawn_agent`` validates ``model`` against its own catalog
before the request leaves the CLI, so a servable catalog id has to be
spelled in codex's slugs — see :mod:`omnigent.codex_model_vocabulary`.
:param bridge_dir: Unused; the vocabulary is a property of the CLI, not
of the session (accepted so this matches the translator-factory
seam).
:returns: Callable mapping a servable id to a slug codex accepts.
"""
del bridge_dir
return codex_spawn_model
def finalize_spawn_input(
output: dict[str, Any] | None, # type: ignore[explicit-any] # hook output JSON
tool_input: dict[str, Any] | None = None, # type: ignore[explicit-any] # hook payload JSON
) -> dict[str, Any] | None: # type: ignore[explicit-any] # hook output JSON
"""
Clamp the rewritten spawn's effort, then announce the routed model.
Two things only a rewrite needs. The effort clamp comes first because
codex refuses a model/effort pairing outside the model's ladder, so a
session default of ``xhigh`` would fail a GLM spawn the router just
approved. The notice comes second because a rewrite is otherwise
invisible: codex reports no model change of its own, so the top-level
``systemMessage`` (alongside, not inside, ``hookSpecificOutput``) is the
only place the decision shows up. When the spawn named a model of its
own and the router picked another, the notice says so otherwise the
parent reads its own ask back and never learns it was substituted.
:param output: Hook output from ``decision_to_hook_output``, or
``None`` for "no opinion".
:param tool_input: The spawn's incoming ``tool_input``, whose ``model``
is the ask the routed model replaced.
:returns: *output* with the effort clamped and a ``systemMessage``,
when it rewrote the spawn's model; otherwise *output* unchanged.
"""
if not isinstance(output, dict):
return output
hook_output = output.get("hookSpecificOutput")
if not isinstance(hook_output, dict) or hook_output.get("permissionDecision") != "allow":
return output
updated_input = hook_output.get("updatedInput")
if not isinstance(updated_input, dict):
return output
model = updated_input.get("model")
if not isinstance(model, str) or not model:
return output
effort = updated_input.get("reasoning_effort")
clamped = clamp_spawn_effort(effort if isinstance(effort, str) and effort else None, model)
if clamped != effort and clamped is not None:
hook_output = {
**hook_output,
"updatedInput": {**updated_input, "reasoning_effort": clamped},
}
output = {**output, "hookSpecificOutput": hook_output}
return {**output, "systemMessage": _routing_notice(tool_input, model)}
def _routing_notice(
tool_input: dict[str, Any] | None, # type: ignore[explicit-any] # hook payload JSON
model: str,
) -> str:
"""
Word the TUI notice for a rewritten spawn.
:param tool_input: The spawn's incoming ``tool_input``, when available.
:param model: The slug the spawn now runs on.
:returns: The ``systemMessage`` text.
"""
asked = (tool_input or {}).get("model")
if isinstance(asked, str) and asked and asked != model:
return f"Using Smart Routing. Requested {asked}; routing to {model}."
return f"Using Smart Routing. Routing to {model}."
def _payload_model(payload: dict[str, Any]) -> str | None: # type: ignore[explicit-any] # hook payloads are untrusted JSON
"""
Extract the parent session's model from a hook payload.
:param payload: Codex hook payload.
:returns: Model id, or ``None`` when the payload carries none.
"""
model = payload.get("model")
return model if isinstance(model, str) and model else None
#: How codex differs from the claude-native default: its own spawn-tool
#: name, its task-name keys, the ``message`` prompt key (delivered plaintext
#: in hook payloads — measured, not encrypted as once assumed), its own
#: spawn-model vocabulary, and the effort clamp plus TUI routing notice a
#: rewrite needs. ``requested_model_resolver_factory`` stays unset: codex
#: spells the ask in slugs the server's bare-id comparison already lands, so
#: the ask is forwarded verbatim.
ROUTE_SEAMS: dict[str, Any] = { # type: ignore[explicit-any] # route_pre_tool_use seams
"tool_matcher": is_spawn_agent_tool,
"task_keys": ("task_name", "agent_name"),
"include_prompt": True,
"prompt_keys": ("message",),
"parent_model_resolver": _payload_model,
"model_translator_factory": spawn_model_translator,
"requested_model_resolver_factory": None,
"post_process": finalize_spawn_input,
}
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,979 @@
"""Shared subagent-routing decision logic for harness hooks.
Stdlib-only on purpose (bar the equally light
:mod:`omnigent.claude_model_vocabulary`): the Claude-native hook runs as
a per-spawn subprocess (``python -I -m
omnigent.inner.hook_scripts.claude_router_hook``) and blocks the spawn,
so importing anything heavier would show up as spawn latency. The
claude-agent-sdk executor imports the same functions for its in-process
``PreToolUse`` callback, so both paths map decisions identically.
The runner advertises its ``route-subagent`` endpoint by writing
``subagent_router.json`` (``{"url": ..., "token": ..., "pid": ...}``) into
the session bridge directory. A missing, malformed, non-loopback or
dead-pid advertisement means the router is unreachable: the hook allows
the spawn unchanged and emits nothing.
Every ``explicit-any`` type-ignore below marks the same thing hook
payloads, request bodies and hook outputs are untrusted JSON with no
schema this process can import so the per-site justifications are
omitted.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from omnigent.claude_model_vocabulary import (
CLAUDE_MODEL_ALIASES,
alias_pins,
claude_model_alias,
normalized_model_id,
)
# Advertisement file written by the runner's subagent-routing endpoint,
# mirroring the ``tool_relay.json`` discovery pattern.
ADVERTISEMENT_FILE = "subagent_router.json"
# Claude-native bridge config, read for the session id / launch model.
_BRIDGE_CONFIG_FILE = "bridge.json"
# Per-turn relay advertisement listing the Omnigent tools this session holds.
# Same filename as ``claude_native_bridge._TOOL_RELAY_FILE``; duplicated
# because this module is stdlib-only and must not import the bridge.
_TOOL_RELAY_FILE = "tool_relay.json"
# MCP server the bridge registers the Omnigent tools under. Must match
# ``claude_native_bridge._MCP_SERVER_NAME`` (and the codex-native
# ``[mcp_servers.omnigent]`` table), which the same no-import rule forbids
# reading directly.
_MCP_SERVER_NAME = "omnigent"
# Harnesses whose tool list spells an MCP tool ``mcp__<server>__<tool>``.
_MCP_PREFIXING_HARNESSES = frozenset({"claude-sdk", "claude_sdk", "claude-native"})
# Harnesses that address an MCP tool by its bare name plus a separate
# server/namespace field, so no prefixed spelling can be quoted at them.
# Codex flattens the pair for its own logs and hook payloads
# (``omnigentsys_session_create``), but that spelling is not callable.
_MCP_NAMESPACED_HARNESSES = frozenset({"codex", "codex-native"})
# Omnigent tools a routed spawn needs. Named in the deny reason only when the
# session's relay actually advertises the create tool.
_SPAWN_TOOL = "sys_session_create"
_AGENT_LIST_TOOL = "sys_agent_list"
# Explicit advertisement directory. Set for harnesses that have no
# claude-native bridge dir (e.g. the claude-agent-sdk executor).
ROUTER_DIR_ENV_VAR = "OMNIGENT_SUBAGENT_ROUTER_DIR"
# Session the spawn belongs to, when the harness knows it out of band.
SESSION_ID_ENV_VAR = "OMNIGENT_SUBAGENT_ROUTER_SESSION_ID"
# Claude-native bridge discovery, already exported to the harness.
BRIDGE_DIR_ENV_VAR = "HARNESS_CLAUDE_NATIVE_BRIDGE_DIR"
NATIVE_SESSION_ID_ENV_VAR = "HARNESS_CLAUDE_NATIVE_REQUEST_SESSION_ID"
# Claude Code's subagent-spawn tool. ``Agent`` is the current name;
# ``Task`` was renamed to it in CLI 2.1.63 and still works as an alias,
# so both are matched. Also used verbatim as the settings/SDK matcher —
# Claude Code reads a pipe-separated list as exact alternatives.
AGENT_TOOL_NAMES = ("Agent", "Task")
AGENT_TOOL_MATCHER = "|".join(AGENT_TOOL_NAMES)
# Hosts an advertised router URL may name. The advertisement lives in an
# agent-writable directory, so anything else is a self-approval or
# exfiltration target rather than our own runner.
#
# Advisory only: these checks stop an off-box exfiltration target and a
# stale port, not a same-uid agent, which can bind its own loopback port
# and advertise a live pid. Routing is an advisory gate, not a sandbox.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"})
# Hop 2 of the routing timeout budget documented in
# ``omnigent.runner.subagent_routing``: larger than the runner's own wait so
# the runner answers first, smaller than the harness's hook timeout so this
# script's fail-open branch can run.
#
# Single digits on purpose. This gate holds the parent agent's spawn tool open
# until it answers, so a fail-open that takes half a minute stalls the agent as
# surely as an error would; the routing call inside it runs on a 5s budget
# (``omnigent.server.smart_routing.ROUTING_REQUEST_TIMEOUT_S``).
REQUEST_TIMEOUT_S = 8.0
# Headroom hop 1 adds over hop 2. Enough for interpreter start-up and the
# script's fail-open branch, and no more — every second here is a second the
# harness waits after the request has already given up.
HOOK_TIMEOUT_HEADROOM_S = 4.0
# Hop 1: the budget a harness registers on its own spawn hook. STRICTLY larger
# than ``REQUEST_TIMEOUT_S`` — a hook killed at the same instant its HTTP call
# gives up never reaches its fail-open branch, and the harness sees a dead hook
# instead of "no opinion". The claude-native and codex entries derive the same
# headroom; the in-process claude-sdk hook reads this constant.
HOOK_TIMEOUT_S = REQUEST_TIMEOUT_S + HOOK_TIMEOUT_HEADROOM_S
# ``tool_input`` keys naming the requested subagent, in preference order.
# Claude Code sends ``subagent_type``; codex sends ``task_name`` /
# ``agent_name``.
DEFAULT_TASK_KEYS: tuple[str, ...] = ("subagent_type",)
# Flags every harness hook entrypoint accepts. None is required: a hook
# misconfiguration must degrade to "no opinion", not an argparse exit.
STANDARD_HOOK_FLAGS: tuple[str, ...] = (
"--bridge-dir",
"--router-dir",
"--session-id",
"--harness",
)
@dataclass(frozen=True)
class RouterEndpoint:
"""Advertised ``route-subagent`` endpoint."""
url: str
token: str
session_id: str | None = None
def discover_router_dir(bridge_dir: str | Path | None = None) -> Path | None:
"""
Locate the directory holding the router advertisement.
:param bridge_dir: Explicit directory, e.g. the ``--router-dir`` /
``--bridge-dir`` argv value. ``None`` falls back to
:data:`ROUTER_DIR_ENV_VAR` then :data:`BRIDGE_DIR_ENV_VAR`.
:returns: Directory path, or ``None`` when nothing advertises one.
"""
if bridge_dir:
return Path(bridge_dir)
for env_var in (ROUTER_DIR_ENV_VAR, BRIDGE_DIR_ENV_VAR):
raw = os.environ.get(env_var, "").strip()
if raw:
return Path(raw)
return None
def read_router_endpoint(
router_dir: str | Path | None,
*,
filename: str = ADVERTISEMENT_FILE,
) -> RouterEndpoint | None:
"""
Read the advertised endpoint.
The advertisement lives in an agent-writable directory, so it is
validated before anything is sent to it: the URL must be plain ``http``
on a loopback address, and the advertising process must still be alive
(a stale entry's port can be re-bound by another local process). Either
check failing means "router unreachable".
:param router_dir: Directory containing *filename*.
:param filename: Advertisement file name. Defaults to
:data:`ADVERTISEMENT_FILE`; the codex ``route-turn`` hook passes
its own so both endpoints share this validation.
:returns: Endpoint, or ``None`` when the advertisement is absent,
malformed, or fails validation.
"""
if router_dir is None:
return None
try:
raw = (Path(router_dir) / filename).read_text(encoding="utf-8")
payload = json.loads(raw)
except (OSError, ValueError):
return None
if not isinstance(payload, dict):
return None
url = payload.get("url")
token = payload.get("token")
if not isinstance(url, str) or not url or not isinstance(token, str) or not token:
return None
# The rejected URL itself is never echoed: the advertisement it came from
# also carries the bearer token, so anything derived from it stays out of
# logs. The file name plus the reason is enough to find it on disk.
if not _is_loopback_url(url):
_diagnose(f"ignoring the router advertised in {filename}: not plain http on loopback")
return None
if not _advertiser_alive(payload.get("pid")):
_diagnose(f"ignoring the router advertised in {filename}: advertiser pid not alive")
return None
session_id = payload.get("session_id")
return RouterEndpoint(
url=url.rstrip("/"),
token=token,
session_id=session_id if isinstance(session_id, str) and session_id else None,
)
def _diagnose(message: str) -> None:
"""Print a routing diagnostic to stderr.
The hook has no logger (stdlib-only, runs as a short-lived subprocess)
and its stdout is the harness's hook protocol, so stderr is the only
channel a user can see why routing silently fell open.
"""
print(f"omnigent subagent router: {message}", file=sys.stderr)
def _is_loopback_url(url: str) -> bool:
"""Report whether *url* is plain HTTP on a loopback address."""
try:
parsed = urllib.parse.urlsplit(url)
host = parsed.hostname
except ValueError:
return False
return parsed.scheme == "http" and host in _LOOPBACK_HOSTS
def _advertiser_alive(pid: Any) -> bool: # type: ignore[explicit-any]
"""Report whether the advertised runner process still exists.
The runner always writes ``pid``, so a missing or malformed one means
the advertisement was not written by us rejected rather than
trusted. On Windows ``os.kill(pid, 0)`` is unreliable, so the liveness
probe itself is skipped there and the pid's presence is all that is
checked. The probe is also blind inside a PID-namespaced sandbox
(``--unshare-pid``), where the runner's pid is simply not visible; a
hook running there sees "unreachable" and falls open on the inherited
model.
"""
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
return False
if not hasattr(os, "getuid"):
return True
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except OSError:
# EPERM: alive but owned by someone else. That is not our router
# either, but the bearer token still gates the request.
return True
return True
def resolve_session_id(
endpoint: RouterEndpoint,
*,
bridge_dir: str | Path | None = None,
) -> str | None:
"""
Resolve the Omnigent session the spawn belongs to.
:param endpoint: Advertised endpoint, which may carry the session id.
:param bridge_dir: Claude-native bridge directory, read as a last
resort (``bridge.json`` tracks the active session across
``/clear`` rotations).
:returns: Session id, e.g. ``"conv_abc123"``, or ``None``.
"""
if endpoint.session_id:
return endpoint.session_id
for env_var in (SESSION_ID_ENV_VAR, NATIVE_SESSION_ID_ENV_VAR):
raw = os.environ.get(env_var, "").strip()
if raw:
return raw
config = _read_bridge_config(bridge_dir)
for key in ("active_session_id", "conversation_id"):
value = config.get(key)
if isinstance(value, str) and value:
return value
return None
def resolve_parent_model(bridge_dir: str | Path | None) -> str | None:
"""
Resolve the model the parent session runs on.
:param bridge_dir: Claude-native bridge directory whose
``bridge.json`` records the launch model.
:returns: Gateway model name, or ``None`` when unknown.
"""
model = _read_bridge_config(bridge_dir).get("launch_model")
return model if isinstance(model, str) and model else None
def resolve_model_vocabulary_env(bridge_dir: str | Path | None) -> Mapping[str, str] | None:
"""
Resolve the session's alias pinning for model translation.
:param bridge_dir: Claude-native bridge directory whose
``bridge.json`` records the launch env's model keys.
:returns: The recorded ``{env var: model id}`` mapping, or ``None``
to fall back to this process's environment (a hook subprocess
inherits the CLI's).
"""
model_env = _read_bridge_config(bridge_dir).get("model_env")
if not isinstance(model_env, dict):
return None
resolved = {
str(key): str(value)
for key, value in model_env.items()
if isinstance(key, str) and isinstance(value, str) and value
}
return resolved or None
def _read_bridge_config(
bridge_dir: str | Path | None,
) -> dict[str, Any]: # type: ignore[explicit-any]
if bridge_dir is None:
return {}
try:
config = json.loads((Path(bridge_dir) / _BRIDGE_CONFIG_FILE).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return config if isinstance(config, dict) else {}
def is_agent_tool(tool_name: Any) -> bool: # type: ignore[explicit-any]
"""
Report whether a hook payload names the subagent-spawn tool.
:param tool_name: ``tool_name`` from the hook payload.
:returns: ``True`` for Claude Code's ``Task`` / ``Agent`` tool.
"""
return isinstance(tool_name, str) and tool_name in AGENT_TOOL_NAMES
def spawn_task_name(
tool_input: dict[str, Any], # type: ignore[explicit-any]
task_keys: Sequence[str] = DEFAULT_TASK_KEYS,
) -> str:
"""
Read the requested subagent's name out of a spawn's ``tool_input``.
:param tool_input: ``tool_input`` from the hook payload.
:param task_keys: Keys to try, in preference order.
:returns: The name, or ``""`` when the spawn names none (the server
supplies the placeholder task; the hook does not invent one).
"""
for key in task_keys:
value = tool_input.get(key)
if isinstance(value, str) and value:
return value
return ""
def build_route_request(
tool_input: dict[str, Any], # type: ignore[explicit-any]
*,
harness: str,
parent_model: str | None = None,
task_keys: Sequence[str] = DEFAULT_TASK_KEYS,
include_prompt: bool = True,
prompt_keys: Sequence[str] = ("prompt",),
requested_model_resolver: Callable[[str], str | None] | None = None,
) -> dict[str, Any]: # type: ignore[explicit-any]
"""
Build the ``route-subagent`` request body.
:param tool_input: ``tool_input`` from the hook payload.
:param harness: Requesting harness, e.g. ``"claude-native"``.
:param parent_model: Model the parent session runs on, when known.
:param task_keys: ``tool_input`` keys naming the subagent, in
preference order.
:param include_prompt: ``False`` sends ``prompt: null``, for harnesses
whose spawn payload carries no usable task text.
:param prompt_keys: ``tool_input`` keys carrying the task text, in
preference order (codex spells it ``message``).
:param requested_model_resolver: Turns the spawn tool's own ``model``
spelling into a catalog id the server can compare. ``None``
forwards it verbatim, which is what codex's slugs need.
:returns: JSON-serializable request body.
"""
prompt = None
if include_prompt:
for key in prompt_keys:
value = tool_input.get(key)
if isinstance(value, str) and value:
prompt = value
break
requested = tool_input.get("model")
requested = requested if isinstance(requested, str) and requested else None
if requested is not None and requested_model_resolver is not None:
requested = requested_model_resolver(requested)
return {
"harness": harness,
"task_name": spawn_task_name(tool_input, task_keys),
"prompt": prompt,
"parent_model": parent_model,
# The model the spawning agent asked for, if any. The router still
# decides; the ask only shows up in the rationale and, on a mismatch,
# as the recorded ``attempted_override``.
"requested_model": requested,
}
def request_decision(
endpoint: RouterEndpoint,
session_id: str,
body: dict[str, Any], # type: ignore[explicit-any]
*,
timeout: float = REQUEST_TIMEOUT_S,
) -> dict[str, Any] | None: # type: ignore[explicit-any]
"""
POST one routing request to the runner.
:param endpoint: Advertised endpoint.
:param session_id: Omnigent session id.
:param body: Request body from :func:`build_route_request`.
:param timeout: Socket timeout in seconds.
:returns: Decoded decision, or ``None`` on any transport / decode
failure (callers treat that as "allow unchanged").
"""
url = f"{endpoint.url}/v1/sessions/{urllib.parse.quote(session_id, safe='')}/route-subagent"
# Loopback runner URL read from the owner-only bridge dir.
req = urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {endpoint.token}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
return None
return payload if isinstance(payload, dict) else None
def _allow_with_model(
tool_input: dict[str, Any], # type: ignore[explicit-any]
model: str,
reason: str,
) -> dict[str, Any]: # type: ignore[explicit-any]
output: dict[str, Any] = { # type: ignore[explicit-any]
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {**tool_input, "model": model},
}
if reason:
output["permissionDecisionReason"] = reason
return {"hookSpecificOutput": output}
def _deny(reason: str) -> dict[str, Any]: # type: ignore[explicit-any]
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
def claude_model_translator(
bridge_dir: str | Path | None,
) -> Callable[[str], str | None]:
"""
Build the model translator for Claude's spawn tool.
:param bridge_dir: Directory whose ``bridge.json`` records the
session's alias pinning.
:returns: Callable mapping a servable id to an accepted alias.
"""
# Claude's Agent/Task ``model`` is a closed enum of family aliases, so a
# catalog id ("databricks-claude-sonnet-5") fails its schema and the
# spawn dies. Same vocabulary as ``/model``.
vocabulary_env = resolve_model_vocabulary_env(bridge_dir)
return lambda model: claude_model_alias(model, vocabulary_env)
def claude_requested_model_resolver(
bridge_dir: str | Path | None,
) -> Callable[[str], str | None]:
"""
Build the ask normalizer for Claude's spawn tool.
Claude spells the Agent/Task ``model`` as a family alias, so forwarding
it verbatim would compare ``"opus"`` against a catalog id and report
every honored ask as overridden. Resolve the alias through the same
pinning :func:`claude_model_translator` inverts, so the comparison is
between two catalog ids.
:param bridge_dir: Directory whose ``bridge.json`` records the
session's alias pinning.
:returns: Callable mapping a spawn's ``model`` to a catalog id, or
``None`` when the spelling names no concrete model (``"inherit"``,
``"default"``, an unpinned alias) the body then claims no ask.
"""
pins = alias_pins(resolve_model_vocabulary_env(bridge_dir))
def resolve(model: str) -> str | None:
candidate = model.strip().lower()
if candidate in CLAUDE_MODEL_ALIASES:
return pins.get(candidate)
# A catalog id already compares; anything else (``inherit``,
# ``default``, an unknown label) names no model, and claiming it as an
# ask would report a phantom override.
return model if normalized_model_id(model) != candidate else None
return resolve
# Opening clause of every routed-spawn instruction. Naming the user's own
# choice, and framing the block as an approved re-route rather than a refusal,
# is what gets the model to follow through: a terse denial reads as a dead end
# and the model abandons the spawn instead of retrying (matrix row A-sub).
_SMART_ROUTING_PREAMBLE = (
"Databricks Smart Routing is enabled for this session: the user chose to "
"have each task run on the model best suited to it, and Smart Routing "
"picked the model for this one."
)
def mcp_tool_name(bare: str, harness: str | None) -> str:
"""
Spell an Omnigent tool the way *harness* advertises it.
:param bare: Omnigent tool name, e.g. ``"sys_session_create"``.
:param harness: Requesting harness, e.g. ``"claude-native"``. ``None``
or an unrecognized value keeps the bare name an invented prefix
is worse than the name the agent spec already documents.
:returns: The name to quote at the model.
"""
if harness in _MCP_PREFIXING_HARNESSES:
return f"mcp__{_MCP_SERVER_NAME}__{bare}"
return bare
def advertised_relay_tools(bridge_dir: str | Path | None) -> frozenset[str]:
"""
Read the Omnigent tool names this session's relay advertises.
Defensive like :func:`read_router_endpoint`: any failure reads as
"availability unknown" (an empty set), which callers treat as "assume
the tool is there" rather than degrading a working instruction.
:param bridge_dir: Directory containing :data:`_TOOL_RELAY_FILE`.
:returns: Advertised tool names, or an empty set when unknown.
"""
if bridge_dir is None:
return frozenset()
try:
payload = json.loads((Path(bridge_dir) / _TOOL_RELAY_FILE).read_text(encoding="utf-8"))
except (OSError, ValueError):
return frozenset()
if not isinstance(payload, dict):
return frozenset()
tools = payload.get("tools")
if not isinstance(tools, list):
return frozenset()
return frozenset(
name
for spec in tools
if isinstance(spec, dict) and isinstance((name := spec.get("name")), str) and name
)
def _mcp_discovery_note(harness: str | None) -> str:
"""Explain where the named tools live and how to find them.
Both native harnesses defer MCP schemas (Claude Code's tool search,
codex's ``tool_search``), so the tool is absent from the up-front list
and the model concludes it does not exist. Naming the server it comes
from is what turns "no such tool" into a lookup.
"""
if harness in _MCP_NAMESPACED_HARNESSES:
return (
f"Both come from the `{_MCP_SERVER_NAME}` MCP server already attached to "
"this session, where they appear as "
f"`{_MCP_SERVER_NAME}.{_SPAWN_TOOL}` / `{_MCP_SERVER_NAME}.{_AGENT_LIST_TOOL}` "
"— call them by the bare names above with the server as the namespace, and "
"do not run the two words together into one identifier. If they are not in "
"your up-front tool list your MCP schemas are deferred: search your tools "
"for the name instead of concluding it does not exist."
)
return (
f"Both are MCP tools from the `{_MCP_SERVER_NAME}` server already attached to "
"this session, not new tools you need to install. If they are not in your "
"up-front tool list your MCP schemas are deferred: search your tools for the "
"name instead of concluding it does not exist."
)
def _no_spawn_tool_instruction(model: str, picked: str) -> str:
"""Deny reason for a session whose relay advertises no spawn tool.
Names no tool at all: pointing at a tool the session does not hold is
what made the model refuse in the first place.
"""
del model
return (
f"{_SMART_ROUTING_PREAMBLE} It selected {picked} for this sub-task, which "
"your built-in spawn tool cannot launch, and this session holds no Omnigent "
"tool that can start one either. This is not an error and the sub-task is "
"approved — do the work yourself on your current model instead of spawning, "
"then continue."
)
def routed_spawn_instruction(
model: str,
harness: str | None = None,
*,
requesting_harness: str | None = None,
available_tools: frozenset[str] = frozenset(),
) -> str:
"""
Build the instruction telling the model how to start a routed sub-task.
Names ``sys_session_create`` the tool a routed harness actually holds
(``spawn: True`` grants it) with parameters that exist on its schema,
spelled the way *requesting_harness* advertises it. The previous wording
named the bare tool at every harness, so a Claude session (which sees
``mcp__omnigent__sys_session_create``) reported the tool as nonexistent
and dropped the sub-task.
:param model: Model Smart Routing selected, e.g. ``"databricks-glm-5-2"``.
:param harness: Harness it runs on, when the pick crosses harnesses.
:param requesting_harness: Harness whose model reads this, which decides
the tool spelling. ``None`` falls back to bare names.
:param available_tools: Omnigent tools the session's relay advertises.
Empty means "unknown", which assumes the spawn tool is there.
:returns: Deny reason that reads as an approved, actionable re-route.
"""
picked = f"{model} (harness {harness})" if harness else model
if available_tools and _SPAWN_TOOL not in available_tools:
return _no_spawn_tool_instruction(model, picked)
create = mcp_tool_name(_SPAWN_TOOL, requesting_harness)
agent_list = mcp_tool_name(_AGENT_LIST_TOOL, requesting_harness)
return (
f"{_SMART_ROUTING_PREAMBLE} It selected {picked} for this sub-task, "
"which your built-in spawn tool cannot launch. This is not an error "
f"and the sub-task is approved — start it with {create} "
"instead, which does accept the routed model: "
f'{create}(agent_id="<id from {agent_list}>", '
f'model="{model}", message="<the task you were about to spawn>"). '
f"{_mcp_discovery_note(requesting_harness)} Make that call now, then continue."
)
def smart_routing_spawn_note(harness: str) -> str:
"""
Build the launch-time system-prompt note about routed spawns.
Lives next to :func:`routed_spawn_instruction` so the note and the deny
reason name the same tool in the same spelling a session told about
one tool and denied with another is back to refusing the sub-task.
:param harness: Harness being launched, e.g. ``"claude-native"``.
:returns: Instructions to append to the session's system prompt.
"""
create = mcp_tool_name(_SPAWN_TOOL, harness)
agent_list = mcp_tool_name(_AGENT_LIST_TOOL, harness)
return (
"Databricks Smart Routing is enabled for this session: each sub-task may be "
"placed on a different model than yours. When your built-in spawn tool "
f"(Agent/Task/spawn_agent) is denied with an instruction naming {create}, "
"that is an approved re-route, not an error or a permission problem — make "
f"the {create} call as instructed, using {agent_list} for the agent id. "
f"{_mcp_discovery_note(harness)}"
)
def redirect_reason(
harness: str,
model: str,
*,
requesting_harness: str | None = None,
available_tools: frozenset[str] = frozenset(),
) -> str:
"""
Build the cross-harness redirect instruction shown to the model.
:param harness: Harness the router picked, e.g. ``"codex"``.
:param model: Model the router picked.
:param requesting_harness: Harness whose model reads this.
:param available_tools: Omnigent tools the session's relay advertises.
:returns: Deny reason telling the model how to respawn correctly.
"""
return routed_spawn_instruction(
model,
harness,
requesting_harness=requesting_harness,
available_tools=available_tools,
)
def decision_to_hook_output(
decision: dict[str, Any], # type: ignore[explicit-any]
tool_input: dict[str, Any], # type: ignore[explicit-any]
*,
model_translator: Callable[[str], str | None] | None = None,
requesting_harness: str | None = None,
available_tools: frozenset[str] = frozenset(),
) -> dict[str, Any] | None: # type: ignore[explicit-any]
"""
Map a ``route-subagent`` decision to Claude ``PreToolUse`` output.
:param decision: Decoded endpoint response.
:param tool_input: Original ``tool_input``, preserved on rewrite.
:param model_translator: Converts the decision's servable model id
into the spawn tool's own ``model`` vocabulary, returning ``None``
when it maps to nothing the tool accepts (the spawn is then
allowed unchanged a degraded model beats a dead spawn, and an
unacceptable value beats neither). ``None`` injects the id as-is,
which is what codex's ``spawn_agent`` expects.
:param requesting_harness: Harness whose model reads a deny reason,
which decides how the Omnigent tools are spelled.
:param available_tools: Omnigent tools the session's relay advertises,
so a deny never names one the session does not hold.
:returns: Hook output, or ``None`` for "no opinion" (allow the spawn
unchanged with no emitted decision).
"""
action = decision.get("action")
model = decision.get("model")
rationale = decision.get("rationale")
rationale = rationale if isinstance(rationale, str) else ""
if action == "rewrite" and isinstance(model, str) and model:
if model_translator is None:
return _allow_with_model(tool_input, model, rationale)
translated = model_translator(model)
if translated is None:
return None
if translated != model:
rationale = f"{rationale} (applied as {translated!r})".strip()
return _allow_with_model(tool_input, translated, rationale)
if action == "redirect":
harness = decision.get("harness")
if isinstance(harness, str) and harness and isinstance(model, str) and model:
return _deny(
redirect_reason(
harness,
model,
requesting_harness=requesting_harness,
available_tools=available_tools,
)
)
# A redirect without a target can't be followed — fail open.
return None
if action == "deny":
# A bare "denied" leaves the model nowhere to go, so it drops the
# sub-task. When the verdict names a model, hand back the same
# actionable re-route the redirect path uses.
if isinstance(model, str) and model:
return _deny(
routed_spawn_instruction(
model,
requesting_harness=requesting_harness,
available_tools=available_tools,
)
)
return _deny(
rationale
or (
f"{_SMART_ROUTING_PREAMBLE} It could not place this sub-task on "
"a model your built-in spawn tool can run. Start it with "
f"{mcp_tool_name(_SPAWN_TOOL, requesting_harness)} instead, or "
"continue the work yourself."
)
)
return None
def route_pre_tool_use(
payload: dict[str, Any], # type: ignore[explicit-any]
*,
harness: str,
router_dir: str | Path | None = None,
bridge_dir: str | Path | None = None,
parent_model: str | None = None,
session_id: str | None = None,
timeout: float = REQUEST_TIMEOUT_S,
tool_matcher: Callable[[Any], bool] = is_agent_tool, # type: ignore[explicit-any]
task_keys: Sequence[str] = DEFAULT_TASK_KEYS,
include_prompt: bool = True,
prompt_keys: Sequence[str] = ("prompt",),
parent_model_resolver: Callable[[dict[str, Any]], str | None] | None = None, # type: ignore[explicit-any]
model_translator_factory: Callable[[str | Path | None], Callable[[str], str | None]]
| None = claude_model_translator,
requested_model_resolver_factory: Callable[[str | Path | None], Callable[[str], str | None]]
| None = claude_requested_model_resolver,
post_process: Callable[[dict[str, Any] | None, dict[str, Any]], dict[str, Any] | None] # type: ignore[explicit-any]
| None = None,
) -> dict[str, Any] | None: # type: ignore[explicit-any]
"""
Route one ``PreToolUse`` payload end to end.
:param payload: ``PreToolUse`` hook payload.
:param harness: Requesting harness, e.g. ``"claude-sdk"``.
:param router_dir: Advertisement directory; ``None`` discovers it.
:param bridge_dir: Claude-native bridge directory for session-id
fallback.
:param parent_model: Model the parent session runs on, when known.
:param session_id: Session baked into the hook command, used when the
advertisement carries none.
:param timeout: Socket timeout in seconds.
:param tool_matcher: Recognizes the harness's spawn tool by name.
:param task_keys: ``tool_input`` keys naming the subagent.
:param include_prompt: ``False`` withholds the spawn prompt.
:param prompt_keys: ``tool_input`` keys carrying the task text.
:param parent_model_resolver: Derives the parent model from the
payload; ``None`` reads the bridge config instead.
:param model_translator_factory: Builds the decision-model translator
for this harness's spawn tool. ``None`` injects the routed id
verbatim, which is what codex's ``spawn_agent`` expects.
:param requested_model_resolver_factory: Builds the normalizer for the
spawn's own ``model`` ask. ``None`` forwards it verbatim, which is
what codex's slugs need.
:param post_process: Last pass over the hook output, given the
incoming ``tool_input`` too, e.g. codex's routed-model notice.
:returns: Hook output, or ``None`` for "no opinion" every failure
lands here so a spawn is never blocked by routing infrastructure.
"""
if not tool_matcher(payload.get("tool_name")):
return None
tool_input = payload.get("tool_input")
if not isinstance(tool_input, dict):
return None
discovered_dir = discover_router_dir(router_dir)
endpoint = read_router_endpoint(discovered_dir)
if endpoint is None:
return None
resolved_session = (
endpoint.session_id
or session_id
or resolve_session_id(endpoint, bridge_dir=bridge_dir or router_dir)
)
if not resolved_session:
return None
if parent_model is None:
parent_model = (
parent_model_resolver(payload)
if parent_model_resolver is not None
else resolve_parent_model(bridge_dir)
)
body = build_route_request(
tool_input,
harness=harness,
parent_model=parent_model,
task_keys=task_keys,
include_prompt=include_prompt,
prompt_keys=prompt_keys,
requested_model_resolver=(
requested_model_resolver_factory(bridge_dir or router_dir)
if requested_model_resolver_factory is not None
else None
),
)
decision = request_decision(endpoint, resolved_session, body, timeout=timeout)
if decision is None:
return None
translator = (
model_translator_factory(bridge_dir or router_dir)
if model_translator_factory is not None
else None
)
output = decision_to_hook_output(
decision,
tool_input,
model_translator=translator,
requesting_harness=harness,
available_tools=advertised_relay_tools(bridge_dir or discovered_dir),
)
return post_process(output, tool_input) if post_process is not None else output
def read_stdin_payload(
label: str,
) -> dict[str, Any] | None: # type: ignore[explicit-any]
"""
Read one hook payload from stdin.
:param label: Diagnostic prefix, e.g. ``"omnigent codex router hook"``.
:returns: Decoded object, or ``None`` when stdin is empty, malformed,
or not a JSON object (a diagnostic goes to stderr).
"""
try:
payload = json.loads(sys.stdin.read() or "{}")
except ValueError as exc:
print(f"{label}: malformed JSON: {exc}", file=sys.stderr)
return None
if not isinstance(payload, dict):
print(f"{label}: expected JSON object", file=sys.stderr)
return None
return payload
def hook_arg_parser(
prog: str,
*,
extra_flags: Sequence[str] = (),
) -> argparse.ArgumentParser:
"""
Build the argument parser shared by the harness hook entrypoints.
:param prog: Program label for usage text.
:param extra_flags: Flags beyond :data:`STANDARD_HOOK_FLAGS`.
:returns: Parser whose every flag is optional and defaults to ``None``.
"""
parser = argparse.ArgumentParser(prog=prog)
for flag in (*STANDARD_HOOK_FLAGS, *extra_flags):
parser.add_argument(flag, default=None)
return parser
def parse_hook_args(
prog: str,
argv: Sequence[str],
*,
extra_flags: Sequence[str] = (),
) -> argparse.Namespace:
"""
Parse a hook entrypoint's arguments, tolerating anything unexpected.
Unknown flags are dropped rather than raising ``SystemExit(2)``: a
stale generated hook command must not turn into a failed spawn.
:param prog: Program label for usage text.
:param argv: Arguments after the subcommand, if any.
:param extra_flags: Flags beyond :data:`STANDARD_HOOK_FLAGS`.
:returns: Parsed namespace.
"""
args, _unknown = hook_arg_parser(prog, extra_flags=extra_flags).parse_known_args(list(argv))
return args
def run_route_subagent_main(
argv: Sequence[str],
*,
prog: str,
harness: str,
label: str | None = None,
**route_kwargs: Any, # type: ignore[explicit-any]
) -> int:
"""
Run a hook entrypoint's spawn-routing body.
:param argv: Arguments after the subcommand, if any.
:param prog: Program label for usage text.
:param harness: Requesting harness, used when argv names none.
:param label: Diagnostic prefix; ``None`` uses *prog*.
:param route_kwargs: Per-harness seams for
:func:`route_pre_tool_use`.
:returns: Always ``0`` so a routing failure never blocks a spawn.
"""
args = parse_hook_args(prog, argv)
payload = read_stdin_payload(label or prog)
if payload is None:
return 0
output = route_pre_tool_use(
payload,
harness=args.harness or harness,
router_dir=args.router_dir or args.bridge_dir,
bridge_dir=args.bridge_dir,
session_id=args.session_id,
**route_kwargs,
)
if output is not None:
sys.stdout.write(json.dumps(output))
return 0
+8 -7
View File
@@ -54,7 +54,7 @@ from omnigent.model_metadata import (
ModelMetadata,
ModelWireAPI,
)
from omnigent.model_override import model_family_mismatch
from omnigent.model_override import is_codex_compatible_model, model_family_mismatch
from omnigent.model_resolver import (
ModelResolution,
ModelResolutionError,
@@ -314,19 +314,20 @@ def clear_model_catalog_cache() -> None:
def model_family_token(model_id: str) -> str:
"""Tag a model id with its vendor family.
"""Tag a model id with the harness family that can serve it.
Mirrors the token rule in
Shares the token rule with
:func:`omnigent.model_override.model_family_mismatch`: Claude ids
contain ``"claude"``; GPT ids contain ``"gpt"`` or ``"codex"``.
contain ``"claude"``; the ``"openai"`` token covers every
codex-compatible id (gpt/codex plus the GLM and Kimi families, which
serve on the same Responses wire).
:param model_id: Model id, e.g. ``"databricks-claude-opus-4-8"``.
:returns: ``"claude"``, ``"openai"``, or ``"other"``.
"""
lower = model_id.lower()
if "claude" in lower:
if "claude" in model_id.lower():
return "claude"
if "gpt" in lower or "codex" in lower:
if is_codex_compatible_model(model_id):
return "openai"
return "other"
+137
View File
@@ -56,3 +56,140 @@ _STATIC_MODEL_FALLBACKS = {
def static_model_fallback(provider_kind: str, cli: str) -> StaticModelFallback | None:
"""Return the owned fallback for a provider kind and CLI, if registered."""
return _STATIC_MODEL_FALLBACKS.get((provider_kind, cli))
# ── Smart Routing ───────────────────────────────────────────────────────────
#
# The router's static tables. A live per-session catalog wins wherever one is
# in reach (``omnigent.server.smart_routing.fetch_runner_models``) and a
# deployment's ``routing.*`` settings override each table wholesale; these are
# what a router that can reach neither falls back to.
_SMART_ROUTING_FALLBACKS: dict[str, StaticModelFallback] = {
"claude_ladder": StaticModelFallback(
model_ids=(
"databricks-claude-haiku-4-5",
"databricks-claude-sonnet-4-6",
"databricks-claude-sonnet-5",
"databricks-claude-opus-4-8",
),
owner="Smart Routing (omnigent.server.smart_routing)",
provenance="AI Gateway Claude serving endpoints, cheapest → most powerful",
discovery_gap=(
"the router picks before a session's live model catalog is reachable, "
"and a gateway listing ranks models by neither cost nor capability"
),
),
"gpt_ladder": StaticModelFallback(
model_ids=(
"databricks-gpt-5-4-nano",
"databricks-gpt-5-4-mini",
"databricks-gpt-5-4",
"databricks-gpt-5-5",
),
owner="Smart Routing (omnigent.server.smart_routing)",
provenance="AI Gateway GPT serving endpoints, cheapest → most powerful",
discovery_gap=(
"the router picks before a session's live model catalog is reachable, "
"and a gateway listing ranks models by neither cost nor capability"
),
),
"pi_ladder": StaticModelFallback(
model_ids=(
"databricks-gpt-5-4-nano",
"databricks-claude-haiku-4-5",
"databricks-gpt-5-4-mini",
"databricks-claude-sonnet-4-6",
"databricks-claude-sonnet-5",
"databricks-gpt-5-4",
"databricks-gpt-5-5",
"databricks-claude-opus-4-8",
),
owner="Smart Routing (omnigent.server.smart_routing)",
provenance="the Claude and GPT ladders interleaved by cost, for multi-model pi",
discovery_gap=(
"the router picks before a session's live model catalog is reachable, "
"and a gateway listing ranks models by neither cost nor capability"
),
),
"current_generation_gpt": StaticModelFallback(
model_ids=(
"databricks-glm-5-2",
"databricks-gpt-5-6-luna",
"databricks-gpt-5-6-sol",
),
owner="Smart Routing (omnigent.server.smart_routing)",
provenance="the external router's own current arms, offered so a pick keeps its endpoint",
discovery_gap=(
"the router picks before a session's live model catalog is reachable, "
"and a gateway listing ranks models by neither cost nor capability"
),
),
"task_v1_claude_arms": StaticModelFallback(
model_ids=("claude-opus-4-8", "claude-sonnet-5"),
owner="Smart Routing (omnigent.server.smart_routing)",
provenance="the task_v1 router's Claude arm menu, which it requires in full",
discovery_gap="the router's arm menu is part of its request contract, not a catalog",
),
"task_v1_codex_arms": StaticModelFallback(
model_ids=("glm-5-2", "gpt-5-6-sol", "gpt-5-6-luna"),
owner="Smart Routing (omnigent.server.smart_routing)",
provenance="the task_v1 router's codex arm menu, which it requires in full",
discovery_gap="the router's arm menu is part of its request contract, not a catalog",
),
"family_fallbacks": StaticModelFallback(
model_ids=("claude-sonnet-5", "gpt-5-6-luna"),
owner="Smart Routing (omnigent.server.smart_routing)",
provenance="one arm per family (claude, gpt), both frozen members of the task_v1 menus",
discovery_gap="a workspace that serves no endpoint for the picked arm needs a pinned one",
),
"pi_excluded": StaticModelFallback(
model_ids=(
"databricks-claude-haiku-4-5",
"databricks-gpt-5-5",
"databricks-gpt-5-5-pro",
"databricks-gpt-5-6-luna",
"databricks-gpt-5-6-terra",
"databricks-gpt-5-6-sol",
),
owner="Smart Routing (omnigent.server.smart_routing)",
provenance="probed: pi's own gateway 400s on each of these",
discovery_gap="a gateway listing advertises these without pi's request-shape limits",
),
"codex_catalog_clone_source": StaticModelFallback(
model_ids=("gpt-5.6-luna",),
owner="Codex extended catalog (omnigent.inner.codex_executor)",
provenance="codex's own bundled catalog slug for the cheapest current arm",
discovery_gap="codex's bundled catalog carries no entry for a gateway-only arm to clone",
),
}
#: Claude serving endpoints the router ranks, cheapest → most powerful.
SMART_ROUTING_CLAUDE_LADDER = _SMART_ROUTING_FALLBACKS["claude_ladder"].model_ids
#: GPT serving endpoints the router ranks, cheapest → most powerful.
SMART_ROUTING_GPT_LADDER = _SMART_ROUTING_FALLBACKS["gpt_ladder"].model_ids
#: Both ladders interleaved by cost, for the multi-model pi harness.
SMART_ROUTING_PI_LADDER = _SMART_ROUTING_FALLBACKS["pi_ladder"].model_ids
#: The router's own current gpt-family arms (GLM included), offered as
#: candidates so a routed arm resolves to its own endpoint.
SMART_ROUTING_CURRENT_GENERATION_GPT = _SMART_ROUTING_FALLBACKS["current_generation_gpt"].model_ids
#: The ``task_v1`` router's Claude arm menu, most powerful first.
SMART_ROUTING_TASK_V1_CLAUDE_ARMS = _SMART_ROUTING_FALLBACKS["task_v1_claude_arms"].model_ids
#: The ``task_v1`` router's codex arm menu.
SMART_ROUTING_TASK_V1_CODEX_ARMS = _SMART_ROUTING_FALLBACKS["task_v1_codex_arms"].model_ids
#: One fixed fallback arm per family, ordered ``(claude, gpt)``.
SMART_ROUTING_FAMILY_FALLBACKS = _SMART_ROUTING_FALLBACKS["family_fallbacks"].model_ids
#: Models pi's own gateway rejects, so the router may not pick them under pi.
SMART_ROUTING_PI_EXCLUDED = _SMART_ROUTING_FALLBACKS["pi_excluded"].model_ids
#: The codex catalog entry a gateway-only arm is cloned from.
CODEX_CATALOG_CLONE_SOURCE_SLUG = _SMART_ROUTING_FALLBACKS["codex_catalog_clone_source"].model_ids[
0
]
+79 -30
View File
@@ -75,10 +75,11 @@ def validate_model_override(value: str) -> str:
_CLAUDE_FAMILY_HARNESSES: frozenset[str] = frozenset(
{"claude-native", "native-claude", "claude-sdk", "claude_sdk"}
)
# CODEX_CANONICAL_HARNESSES stays single-vendor (GPT-only): the gateway serves
# codex over the Anthropic-incompatible Responses wire, and codex >= 0.137
# dropped the chat/completions wire that was the only path to Claude — so a
# codex x Claude dispatch is genuinely broken and must fail loud here.
# CODEX_CANONICAL_HARNESSES is restricted to the codex-compatible families
# (see is_codex_compatible_model): the gateway serves codex over the
# Anthropic-incompatible Responses wire, and codex >= 0.137 dropped the
# chat/completions wire that was the only path to Claude — so a codex x Claude
# dispatch is genuinely broken and must fail loud here.
# openai-agents (and its "openai-agents-sdk" / "agents_sdk" spellings) is
# intentionally not included: a live SDK probe completed a Claude
# tool-calling turn on the gateway over the chat wire, so the harness is
@@ -109,15 +110,54 @@ _ANTIGRAVITY_FAMILY_HARNESSES: frozenset[str] = frozenset(
_DATABRICKS_GATEWAY_PREFIX = "databricks-"
# Vendor tokens matched anywhere in the id — the long-standing rule for the
# OpenAI family, kept verbatim so every shape it already accepted still
# dispatches: ``chatgpt-4o-latest`` (the token is glued to a prefix) and
# ``gpt4o`` (glued to its generation) are real OpenAI ids that a per-segment
# match rejects outright.
_CODEX_SUBSTRING_TOKENS: tuple[str, ...] = ("gpt", "codex")
# Tokens matched per segment (``-``/``_``/``.``/``/`` separated) with an
# optional trailing generation number, so ``system.ai.glm-5-2`` and
# ``kimi-k2-instruct`` match while an unrelated endpoint name that merely
# contains the letters (``glmqlfit-eval``) does not. Only the families added
# for codex-compatible routing: three letters inside an arbitrary endpoint name
# is far likelier to be a coincidence than "gpt" is.
_CODEX_COMPATIBLE_SEGMENT_TOKENS: tuple[str, ...] = ("glm", "kimi")
_ID_SEGMENT_SPLIT = re.compile(r"[^a-z0-9]+")
def is_codex_compatible_model(model: str) -> bool:
"""Report whether *model* can run on a codex harness.
GPT/codex ids are matched as substrings and GLM/Kimi ids per segment
see the token tables above for why the two families are read differently.
:param model: Model id in any vocabulary, e.g. ``"databricks-glm-5-2"``.
:returns: ``True`` for the GPT/codex, GLM, and Kimi families.
"""
lower = model.lower()
if any(token in lower for token in _CODEX_SUBSTRING_TOKENS):
return True
segments = _ID_SEGMENT_SPLIT.split(lower)
return any(
re.fullmatch(rf"{token}\d*", segment)
for segment in segments
for token in _CODEX_COMPATIBLE_SEGMENT_TOKENS
)
def model_family_mismatch(harness: str, model: str) -> str | None:
"""
Return a rejection reason when *model*'s family cannot run on *harness*.
Family is detected by vendor token: Claude ids contain ``"claude"``
(``databricks-claude-opus-4-8``), GPT ids contain ``"gpt"`` or
``"codex"`` (``databricks-gpt-5-4``). Single-vendor harnesses reject
the other family and ids whose family cannot be determined failing
loud at dispatch beats an opaque harness/gateway error after spawn.
(``databricks-claude-opus-4-8``); codex-compatible ids name gpt,
codex, glm, or kimi (``databricks-gpt-5-4``, ``system.ai.glm-5-2``).
Single-vendor harnesses reject the other family and ids whose family
cannot be determined failing loud at dispatch beats an opaque
harness/gateway error after spawn.
The Gemini-native ``antigravity`` harness rejects the Claude/GPT
families and any ``databricks-`` gateway id (it has no gateway path),
but accepts Gemini shapes and bare/ambiguous ids the SDK may honor.
@@ -132,20 +172,23 @@ def model_family_mismatch(harness: str, model: str) -> str | None:
canon = canonicalize_harness(harness)
lower = model.lower()
is_claude = "claude" in lower
# Antigravity's reject-list stays the narrow GPT/codex rule: GLM and Kimi
# ids carry no Gemini-native verdict, so they are not newly excluded here.
is_gpt = "gpt" in lower or "codex" in lower
if canon in _CLAUDE_FAMILY_HARNESSES and not is_claude:
return (
f"harness {canon!r} only runs Claude models (id containing "
f"'claude'); got {model!r}. Use the codex worker for GPT models "
"or the pi / openai-agents worker for any other gateway model."
)
if canon in CODEX_CANONICAL_HARNESSES and not is_gpt:
return (
f"harness {canon!r} only runs GPT models (id containing 'gpt' "
f"or 'codex'); got {model!r}. Use the claude_code worker for "
"Claude models or the pi / openai-agents worker for any other "
f"'claude'); got {model!r}. Use the codex worker for GPT / GLM / "
"Kimi models or the pi / openai-agents worker for any other "
"gateway model."
)
if canon in CODEX_CANONICAL_HARNESSES and not is_codex_compatible_model(model):
return (
f"harness {canon!r} only runs codex-compatible models (id naming "
f"'gpt', 'codex', 'glm', or 'kimi'); got {model!r}. Use the "
"claude_code worker for Claude models or the pi / openai-agents "
"worker for any other gateway model."
)
if canon in _ANTIGRAVITY_FAMILY_HARNESSES and (
is_claude or is_gpt or lower.startswith(_DATABRICKS_GATEWAY_PREFIX)
):
@@ -157,9 +200,14 @@ def model_family_mismatch(harness: str, model: str) -> str | None:
return None
# Bare canonical vendor ids ("claude-opus-4-8", "gpt-5-4"); slash/colon/
# bracket/vendor-prefixed shapes have no mechanical gateway counterpart.
_MECHANICAL_VENDOR_ID_RE = re.compile(r"^(?:claude|gpt)-[a-z0-9][a-z0-9.-]*$")
# Bare canonical vendor ids ("claude-opus-4-8", "gpt-5-4", "glm-5-2",
# "kimi-k2-instruct"); slash/colon/bracket/vendor-prefixed shapes have no
# mechanical gateway counterpart. The GLM/Kimi families belong here for the
# same reason the others do: they are dispatchable on codex, so a caller may
# name a bare one, and leaving them out persisted the bare id verbatim onto a
# gateway-backed child that can only serve the prefixed spelling — an opaque
# failure at the CLI instead of a mechanical localization.
_MECHANICAL_VENDOR_ID_RE = re.compile(r"^(?:claude|gpt|glm|kimi)-[a-z0-9][a-z0-9.-]*$")
_DATABRICKS_MODEL_PREFIX = "databricks-"
@@ -180,8 +228,9 @@ def canonical_model_spelling(model: str) -> str:
:param model: A model id, e.g. ``"databricks-claude-haiku-4-5"``.
:returns: The bare canonical id (``"claude-haiku-4-5"``) when the
prefix is mechanical; otherwise *model* unchanged (slash/colon/
bracket shapes and non-claude/gpt families have no mechanical
gateway counterpart).
bracket shapes, and families outside
:data:`_MECHANICAL_VENDOR_ID_RE`, have no mechanical gateway
counterpart).
"""
if model.startswith(_DATABRICKS_MODEL_PREFIX):
bare = model[len(_DATABRICKS_MODEL_PREFIX) :]
@@ -205,17 +254,17 @@ def normalize_model_for_provider(model: str, provider_kind: str | None) -> str:
order-independent checking first keeps error text quoting exactly
what the caller sent). Two transforms, both prefix-mechanical:
- Databricks-gateway child + bare canonical claude/gpt id
prepend ``databricks-`` (``claude-opus-4-8``
``databricks-claude-opus-4-8``).
- Vendor-direct child (API key / CLI subscription) +
``databricks-``-prefixed claude/gpt id strip the prefix
- Databricks-gateway child + a bare canonical id of a localizable
family (claude / gpt / glm / kimi) prepend ``databricks-``
(``claude-opus-4-8`` ``databricks-claude-opus-4-8``).
- Vendor-direct child (API key / CLI subscription) + a
``databricks-``-prefixed id of one strip the prefix
(``databricks-gpt-5-4`` ``gpt-5-4``).
Anything non-mechanical (slash/colon/bracket shapes, non-claude/gpt
families, gateway/local/unknown provider kinds) passes through
unchanged the existing fail-loud harness/gateway error remains
the safety net for genuinely unroutable ids.
Anything non-mechanical (slash/colon/bracket shapes, families outside
:data:`_MECHANICAL_VENDOR_ID_RE`, gateway/local/unknown provider kinds)
passes through unchanged the existing fail-loud harness/gateway error
remains the safety net for genuinely unroutable ids.
:param model: A model id that already passed
:func:`validate_model_override`, e.g. ``"claude-sonnet-4-6"``.
+8 -2
View File
@@ -99,7 +99,11 @@ KIRO_KEY = "kiro"
# before 2026-06-01. The first Claude Code release after the cutoff is
# 2.1.161, so use that as the supported floor.
# - codex: native policy hook requires >= 0.129.0, but that shipped before
# 2026-06-01. The first Codex release after the cutoff is 0.137.0.
# 2026-06-01. The first Codex release after the cutoff is 0.137.0. The
# subagent-router ``PreToolUse`` hook needs 0.145.0, but that is enforced
# where the hook is registered
# (``codex_native_app_server._CODEX_ROUTING_HOOK_MIN_VERSION``) so an older
# CLI loses only smart-routing spawn gating, not the ability to launch.
# - cursor: Cursor's CLI uses ``YYYY.MM.DD[-build]`` date versions. Default
# to the day after 2026-06-01 so we don't support stale pre-June builds.
# - kimi: first ``kimi-cli`` release after 2026-06-01 is 1.47.0
@@ -169,7 +173,9 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
status_args=("login", "status"),
# The native Codex policy hook requires ``codex >= 0.129.0``;
# anything older silently disables tool-call enforcement. Setup
# enforces the same floor up-front.
# enforces the same floor up-front. Smart Routing's spawn hook wants
# 0.145.0, but it is gated at its own registration site so it degrades
# to "no spawn gate" instead of blocking every codex launch.
min_version=_CODEX_MIN_VERSION,
),
PI_KEY: HarnessInstallSpec(
+11 -54
View File
@@ -31,6 +31,11 @@ from typing import TYPE_CHECKING, NotRequired, TypeAlias, TypedDict, TypeGuard
from urllib.parse import urlparse
from omnigent import model_catalog
from omnigent.databricks_ai_gateway import (
DATABRICKS_AI_GATEWAY_LABEL,
DATABRICKS_TRUSTED_HOST_SUFFIXES,
is_databricks_ai_gateway_url,
)
from omnigent.model_metadata import ModelWireAPI
from omnigent.model_override import normalize_model_for_provider
from omnigent.onboarding.provider_config import (
@@ -89,22 +94,12 @@ _DATABRICKS_ANTHROPIC_GATEWAY_PATH = "/ai-gateway/anthropic"
_DATABRICKS_GATEWAY_CODEX_SUFFIX = "/codex/v1"
_DATABRICKS_GATEWAY_ANTHROPIC_SUFFIX = "/anthropic"
# Trusted parent domain suffixes for a Databricks-owned host. The AI Gateway
# lives under a per-workspace subdomain of one of these (the canonical form is
# ``<workspace>.ai-gateway.cloud.databricks.com``); the Azure / GCP control
# planes serve workspaces under their own parent domains. We anchor on the
# leading "." so a look-alike like ``...cloud.databricks.com.evil.test`` (which
# ends in ``.evil.test``) is rejected.
_DATABRICKS_TRUSTED_HOST_SUFFIXES = (
".cloud.databricks.com", # AWS workspaces + ai-gateway (incl. *.staging.cloud.databricks.com)
".azuredatabricks.net", # Azure Databricks
".gcp.databricks.com", # GCP Databricks
)
# A genuine AI Gateway host carries the ``ai-gateway`` DNS label; we require it
# (alongside a trusted suffix) so a non-gateway Databricks host isn't routed as
# the gateway's Anthropic surface.
_DATABRICKS_AI_GATEWAY_LABEL = "ai-gateway"
# Aliases for the canonical Databricks AI Gateway predicate and its constants,
# which live in :mod:`omnigent.databricks_ai_gateway` so every surface that must
# recognize the gateway agrees.
_DATABRICKS_TRUSTED_HOST_SUFFIXES = DATABRICKS_TRUSTED_HOST_SUFFIXES
_DATABRICKS_AI_GATEWAY_LABEL = DATABRICKS_AI_GATEWAY_LABEL
_is_databricks_ai_gateway_url = is_databricks_ai_gateway_url
class _PiModelEntry(TypedDict):
@@ -146,44 +141,6 @@ def _is_str_object_dict(value: object) -> TypeGuard[dict[str, object]]:
return isinstance(value, dict) and all(isinstance(key, str) for key in value)
def _is_databricks_ai_gateway_url(base_url: str) -> bool:
"""Return ``True`` only for a genuine Databricks AI Gateway base URL.
Two URL shapes are accepted:
1. **Dedicated AI Gateway subdomain** ``ai-gateway`` is a full DNS label
in the hostname (e.g. ``<id>.ai-gateway.cloud.databricks.com``). Used by
the standard ``isaac configure codex`` setup.
2. **Workspace-hosted gateway** the hostname is a plain Databricks
workspace (ends with a trusted suffix) and the path starts with
``/ai-gateway/`` (e.g. ``<workspace>.cloud.databricks.com/ai-gateway/...``).
Used by ucode / Codex app profile setups.
Both cases require ``https`` and a hostname ending with a trusted
Databricks-owned domain suffix to prevent token-forwarding attacks.
:param base_url: The codex provider table's ``base_url``.
:returns: ``True`` iff the URL is an https Databricks AI Gateway endpoint.
"""
parsed = urlparse(base_url)
if parsed.scheme != "https":
return False
hostname = parsed.hostname
if not hostname:
return False
hostname = hostname.lower()
trusted = any(hostname.endswith(suffix) for suffix in _DATABRICKS_TRUSTED_HOST_SUFFIXES)
if not trusted:
return False
# Shape 1: ``ai-gateway`` is a full DNS label in the hostname.
labels = hostname.split(".")
if _DATABRICKS_AI_GATEWAY_LABEL in labels:
return True
# Shape 2: workspace hostname + /ai-gateway/ path prefix.
path = parsed.path or ""
return path.startswith("/ai-gateway/")
def _databricks_workspace_url_for_gateway(
base_url: str,
*,
+122 -1
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
from collections.abc import Iterable
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from omnigent.llms.errors import PermanentLLMError
@@ -47,6 +49,125 @@ def unsupported_effort_message(effort: str, provider: str, supported: Iterable[s
)
# Some models served through a harness reject the harness's full effort ladder.
# GLM on the codex/Responses wire accepts only up to ``high`` (no ``xhigh``), so
# a user default of ``xhigh``/``max`` 400s the turn. Map such a model to the
# effort to use instead of failing — GLM falls back to ``medium``.
#
# These are probed facts about ONE gateway's serving, not properties of the
# effort ladders themselves, so a deployment whose gateway caps other models
# overrides them via ``routing.effort_caps`` (see
# :class:`~omnigent.server.smart_routing.RoutingSettings`). The provider ladders
# above stay frozen: they are the wire APIs' own vocabularies.
_MODEL_EFFORT_FALLBACK: Mapping[str, str] = MappingProxyType({"glm-5-2": "medium"})
# Efforts a fallback model cannot accept, so a pinned high value coerces down.
_MODEL_EFFORT_UNSUPPORTED: Mapping[str, frozenset[str]] = MappingProxyType(
{"glm-5-2": frozenset({"xhigh", "max"})}
)
@dataclass(frozen=True)
class ModelEffortCaps:
"""Per-model effort ceilings a deployment's gateway imposes.
:param fallback: Bare model id the effort to use when the requested one
is barred. Also the effort a switch onto that model sends when the
caller asked for none.
:param unsupported: Bare model id the efforts that model's backend
rejects outright.
"""
# default_factory, not default: a mapping is unhashable and dataclasses
# rejects an unhashable default outright.
fallback: Mapping[str, str] = field(default_factory=lambda: _MODEL_EFFORT_FALLBACK)
unsupported: Mapping[str, frozenset[str]] = field(
default_factory=lambda: _MODEL_EFFORT_UNSUPPORTED
)
#: The caps every deployment gets unless its ``routing:`` block overrides them.
DEFAULT_MODEL_EFFORT_CAPS = ModelEffortCaps()
def model_effort_caps(caps: ModelEffortCaps | None = None) -> ModelEffortCaps:
"""Resolve which effort caps apply, defaulting to this deployment's.
``None`` reads :class:`~omnigent.server.smart_routing.RoutingSettings` off
the process caps, so a managed gateway that caps a different model set is
honoured without every caller threading the value. Outside a server process
(the runner holds no routing settings) that read yields the defaults, which
are the frozen tables above so runner-side clamping is unchanged.
:param caps: Explicit caps, or ``None`` to read the deployment's.
:returns: The caps to clamp with; never ``None``.
"""
if caps is not None:
return caps
try:
from omnigent.server.smart_routing import routing_settings
except ImportError: # pragma: no cover — a build without the server extra
return DEFAULT_MODEL_EFFORT_CAPS
return routing_settings().model_effort_caps or DEFAULT_MODEL_EFFORT_CAPS
def _bare_model(model: str) -> str:
"""Strip a catalog/gateway prefix and fold to the comparison spelling."""
bare = model.rsplit("/", 1)[-1]
for prefix in ("databricks-", "system.ai."):
if bare.startswith(prefix):
bare = bare[len(prefix) :]
return bare.replace(".", "-").lower()
def clamp_effort_for_model(
effort: str | None,
model: str | None,
*,
caps: ModelEffortCaps | None = None,
) -> str | None:
"""Coerce *effort* to one *model* accepts, keeping the user's pick otherwise.
A model whose backend rejects a high effort (e.g. GLM has no ``xhigh``)
falls back to a supported value rather than 400-ing the turn. Any other
model, or an already-accepted effort, is returned unchanged.
:param caps: Effort ceilings to clamp against; ``None`` uses this
deployment's (see :func:`model_effort_caps`).
"""
if effort is None or model is None:
return effort
resolved = model_effort_caps(caps)
key = _bare_model(model)
unsupported = resolved.unsupported.get(key)
if unsupported is not None and effort in unsupported:
return resolved.fallback.get(key, effort)
return effort
def effort_for_model_switch(
effort: str | None,
model: str | None,
*,
caps: ModelEffortCaps | None = None,
) -> str | None:
"""Effort to send when switching to *model*, guarding a rejected default.
Like :func:`clamp_effort_for_model` for an explicit *effort*. When *effort*
is ``None`` (no effort requested), a model that caps its ladder still needs
guarding, because the switched-to thread inherits the config's default
(which may be too high): return that model's fallback so the live turn does
not 400. A model with no cap and no requested effort returns ``None``.
:param caps: Effort ceilings to clamp against; ``None`` uses this
deployment's (see :func:`model_effort_caps`).
"""
if effort is not None:
return clamp_effort_for_model(effort, model, caps=caps)
if model is None:
return None
return model_effort_caps(caps).fallback.get(_bare_model(model))
def validate_effort(effort: object, provider: str, supported: Iterable[str]) -> str | None:
"""Validate *effort* against *supported*, returning a string or None.
+180 -6
View File
@@ -54,6 +54,7 @@ from omnigent.harness_aliases import (
is_native_harness,
native_terminal_name,
)
from omnigent.harness_availability import CODEX_CANONICAL_HARNESSES
from omnigent.harness_plugins import load_object, model_env_keys, spawn_env_builders
from omnigent.inner.native_attachments import has_unresolved_file_id, resolve_file_id_block
from omnigent.json_types import JsonObject as _JsonObject
@@ -135,6 +136,14 @@ from omnigent.runner.session_init_protocol import (
RunnerSessionInitEnvelope,
parse_runner_session_init_envelope,
)
from omnigent.runner.subagent_routing import (
PLAIN_SESSION,
SessionRoutingClass,
forget_session_routing_class,
remember_session_routing_class,
routing_class_from_snapshot,
session_routing_class,
)
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager, NoLiveHarnessError
from omnigent.runtime.prompt import (
SHARED_SESSION_AUTHORSHIP_INSTRUCTION,
@@ -614,6 +623,23 @@ class _SessionInitContext:
"""Return server-supplied labels, or ``None`` on the legacy path."""
return self.envelope.snapshot.labels if self.envelope is not None else None
@property
def routing_class(self) -> SessionRoutingClass:
"""Return the session's Smart Routing class.
The legacy path carries no snapshot, so it reads as plain a
session whose routing state cannot be established must not pay any
routing-path cost.
"""
if self.envelope is None:
return PLAIN_SESSION
snapshot = self.envelope.snapshot
return routing_class_from_snapshot(
cost_control_mode=snapshot.cost_control_mode_override,
harness_override=snapshot.harness_override,
labels=snapshot.labels,
)
# Language constant the omnigent YAML translator stamps on callable-backed
# tools (omnigent/spec/omnigent.py:OMNIGENT_TOOL_LANGUAGE). Duplicated rather
@@ -2600,6 +2626,14 @@ def create_runner_app(
},
)
# Stamp the session's Smart Routing class before anything reads it: the
# spawn env is rebuilt on every harness respawn, long after this
# envelope is gone, and on the codex family the class decides whether
# the session gets the extended model catalog and the spawn-routing
# endpoint at all.
_routing_class = init_context.routing_class
remember_session_routing_class(session_id, _routing_class)
spec: AgentSpec | None = None
spec_entry: _SpecEntry | None = None
if spec_resolver is not None:
@@ -2646,11 +2680,18 @@ def create_runner_app(
if _start_verdict.data is not None:
_apply_sandbox_override_from_verdict(spec, _start_verdict.data)
await _ensure_session_subagent_router(
session_id,
harness_name,
server_client=server_client,
routing_class=_routing_class,
)
spawn_env = _build_spawn_env_from_spec(
spec,
harness_name,
workdir=_resolved_spec_workdir(spec_entry),
cwd=await _session_runtime_cwd(session_id),
session_id=session_id,
)
if spawn_env is None:
spawn_env = await _resolve_native_spawn_env(
@@ -3193,6 +3234,14 @@ def create_runner_app(
session_id=session_id,
)
# The SDK harnesses' router is started here (not by a terminal launch
# path), so this is its only teardown: without it the session leaks an
# HTTP server, its thread, and a live bearer token on disk.
from omnigent.runner.subagent_routing import shutdown_session_router
await asyncio.to_thread(shutdown_session_router, session_id)
forget_session_routing_class(session_id)
_session_spec_cache.pop(session_id, None)
_session_skills_cache.pop(session_id, None)
_session_cursor_model_names.pop(session_id, None)
@@ -3961,6 +4010,7 @@ def create_runner_app(
effort: str | None,
) -> Response:
from omnigent.claude_native_bridge import (
EFFORT_DIALOG_HINT,
bridge_dir_for_bridge_id,
inject_slash_command,
)
@@ -3975,12 +4025,16 @@ def create_runner_app(
bridge_dir = bridge_dir_for_bridge_id(bridge_id)
command = f"/effort {effort}"
try:
# An effort switch invalidates the prompt cache on a session with
# history, so Claude Code asks to confirm; the chat UI cannot render
# that TUI dialog, so answer it by its own title.
await asyncio.to_thread(
inject_slash_command,
bridge_dir,
command=command,
timeout_s=1.0,
auto_confirm=True,
confirm_hint=EFFORT_DIALOG_HINT,
)
except (RuntimeError, ValueError) as exc:
return JSONResponse(
@@ -3998,12 +4052,15 @@ def create_runner_app(
conv_id: str,
model: str | None,
) -> Response:
from omnigent.claude_model_vocabulary import claude_model_command_arg
from omnigent.claude_native import (
resolve_claude_native_model_selection,
)
from omnigent.claude_native_bridge import (
SWITCH_MODEL_DIALOG_HINT,
bridge_dir_for_bridge_id,
inject_slash_command,
read_model_env,
)
if model is None or not model.strip():
@@ -4014,18 +4071,47 @@ def create_runner_app(
)
bridge_dir = bridge_dir_for_bridge_id(bridge_id)
selected_model = model.strip()
resolved_model = resolve_claude_native_model_selection(
selected_model,
await _resolve_session_claude_launch_config(conv_id),
claude_config = await _resolve_session_claude_launch_config(conv_id)
resolved_model = (
resolve_claude_native_model_selection(selected_model, claude_config) or selected_model
)
command = f"/model {resolved_model}"
# ``/model`` takes only this session's own picker vocabulary — its
# family aliases and its one custom slot. Typing a bare catalog id
# outside it leaves the pane on its old model while this handler
# reports success, so fail loud instead. Same translation the routed
# turn path and the executor apply.
env = read_model_env(bridge_dir) or None
model_arg = claude_model_command_arg(resolved_model, env)
if model_arg is None:
_logger.warning(
"claude-native model change: %r has no spelling session=%s accepts (pins=%s)",
resolved_model,
conv_id,
sorted(env or ()),
)
return JSONResponse(
status_code=503,
content={
"error": "claude_native_model_unsupported",
"detail": (
f"This Claude terminal cannot switch to {resolved_model}: "
"its /model picker has no spelling for that model."
),
},
)
command = f"/model {model_arg}"
try:
# Accepted trade-off: ``/model <id>`` also saves the pick as the
# person's global default in ``~/.claude/settings.json``. Driving
# the interactive picker instead avoided that write but needed
# ~35s of fragile tmux automation, so the write stands.
await asyncio.to_thread(
inject_slash_command,
bridge_dir,
command=command,
timeout_s=1.0,
auto_confirm=True,
confirm_hint=SWITCH_MODEL_DIALOG_HINT,
)
except (RuntimeError, ValueError) as exc:
return JSONResponse(
@@ -5124,6 +5210,7 @@ def create_runner_app(
workdir=cached_spec_workdir,
cwd=await _session_runtime_cwd(conv),
model_override=cast(str | None, msg_body.get("model_override")),
session_id=conv,
)
from omnigent.runtime.prompt import build_instructions
@@ -5155,6 +5242,19 @@ def create_runner_app(
"role": "user",
"model": msg_body.get("model", ""),
}
# The routed model rides in-band on the forwarded message. This body is
# built field by field (not copied), so it must be threaded explicitly:
# the harness forwards it onto CreateResponseRequest.model_override and
# the executor adapter into ExecutorConfig.model, which is how a native
# terminal learns to switch models for this turn.
_model_override = msg_body.get("model_override")
if isinstance(_model_override, str) and _model_override:
harness_body["model_override"] = _model_override
_logger.info(
"_run_turn_bg: conv=%s received model_override=%s (forwarding to harness)",
conv,
_model_override,
)
if _session_histories[conv]:
history = _session_histories[conv]
if any("created_by" in item for item in history):
@@ -5932,7 +6032,8 @@ def create_runner_app(
body = await request.json()
body_type = body.get("type") if isinstance(body, dict) else None
_logger.info(
"post_session_events: conv=%s type=%s active=%s buffer_len=%d content_types=%s",
"post_session_events: conv=%s type=%s active=%s buffer_len=%d content_types=%s "
"model_override=%s",
conversation_id,
body_type,
conversation_id in _active_turns,
@@ -5940,6 +6041,7 @@ def create_runner_app(
[b.get("type") for b in body.get("content", []) if isinstance(b, dict)]
if isinstance(body, dict)
else "N/A",
body.get("model_override") if isinstance(body, dict) else None,
)
if body_type == "message" or body_type is None:
if not isinstance(body, dict):
@@ -8679,7 +8781,12 @@ async def _resolve_harness_config(
harness = harness_override or spec.executor.config.get("harness") or spec.executor.type
harness = canonicalize_harness(harness) or harness
spawn_env = _build_spawn_env_from_spec(
spec, harness, cwd=cwd, workdir=workdir, model_override=model_override
spec,
harness,
cwd=cwd,
workdir=workdir,
model_override=model_override,
session_id=session_id,
)
return harness, spawn_env
@@ -8729,6 +8836,51 @@ class _ModelCopyValue(Protocol):
def model_copy(self, *, update: Mapping[str, object]) -> object: ...
async def _ensure_session_subagent_router(
session_id: str,
harness: str | None,
*,
server_client: httpx.AsyncClient | None,
routing_class: SessionRoutingClass | None = None,
) -> None:
"""Start this session's subagent-routing endpoint.
Only for the SDK harness families: the native terminals know their own
bridge directory and start the router from their launch paths, where
the harness's hooks are also pointed at it.
Started for Smart Routing sessions only: a plain session must not carry
the loopback server, its on-disk bearer token, or an in-process hook on
every ``Task`` for a verdict the server never routes. On the codex SDK
arm the advertisement also turns generated hooks and the routed-spawn
tool pre-approvals on, and those spawns already route through
session-create, so there it takes auto-harness.
Never raises: ``ensure_session_router_quietly`` owns the bridge-dir
resolution too, so a hostile or pre-existing ``$TMPDIR`` root cannot
fail session creation for harnesses that do not even use routing.
:param session_id: Session/conversation identifier.
:param harness: Canonical harness name, e.g. ``"claude-sdk"``.
:param server_client: Runnerserver client the relay forwards on.
``None`` (in-process tests) skips the start.
:param routing_class: The session's Smart Routing class. ``None``
reads whatever was stamped at session init, which for an unknown
session is the plain class.
"""
from omnigent.runner.subagent_routing import ensure_session_router_quietly
if is_native_harness(harness):
return
resolved = routing_class if routing_class is not None else session_routing_class(session_id)
ensure_session_router_quietly(
session_id,
server_client=server_client,
harness=harness,
routing_class=resolved,
)
def _build_spawn_env_from_spec(
spec: AgentSpec,
harness: str,
@@ -8736,6 +8888,7 @@ def _build_spawn_env_from_spec(
cwd: Path | None = None,
workdir: Path | None = None,
model_override: str | None = None,
session_id: str | None = None,
) -> dict[str, str] | None:
"""Build spawn-env from spec — mirrors workflow.py's helpers.
@@ -8743,6 +8896,8 @@ def _build_spawn_env_from_spec(
:param harness: Canonical harness name, e.g. ``"claude-sdk"``.
:param cwd: Runtime working directory for harnesses that need it.
:param workdir: Bundle workdir, threaded to the builders.
:param session_id: Session/conversation id, used to hand the harness
this session's subagent-routing endpoint. ``None`` omits it.
:param model_override: The per-session ``/model`` override, e.g.
``"claude-sonnet-4-6"``, or ``None``. When set, it overrides the
``HARNESS_<H>_MODEL`` the builder baked in (spec model / provider
@@ -8829,6 +8984,25 @@ def _build_spawn_env_from_spec(
except ImportError:
return None
# Point the harness process at this session's subagent-routing endpoint
# when one is running (started at session init). Scoped to *harness* so a
# codex executor beneath a claude session never sees the codex router vars
# carrying the parent's session id. Empty when the session has no router.
if env is not None and session_id:
from omnigent.runner.subagent_routing import session_router_env
env.update(session_router_env(session_id, harness))
if harness in CODEX_CANONICAL_HARNESSES:
# A Smart Routing turn or spawn can land on a gateway arm codex's
# bundled catalog has no entry for, so the session replaces that
# catalog. Plain sessions get nothing here and never pay the
# ``codex debug models`` probe.
from omnigent.inner.codex_executor import codex_extended_catalog_env
env.update(
codex_extended_catalog_env(session_routing_class(session_id).routing_enabled)
)
# Per-session ``/model`` override wins over everything the builder baked
# into HARNESS_<H>_MODEL. Without this, `/model` is recorded in the
# readout but the turn still uses the provider/catalog default.
+447 -25
View File
@@ -21,7 +21,7 @@ import urllib.parse
import uuid
from collections.abc import Awaitable, Callable, Mapping, MutableMapping
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
from typing import TYPE_CHECKING, Any, NamedTuple, Protocol
from omnigent.json_types import JsonObject as _JsonObject
@@ -34,6 +34,8 @@ if TYPE_CHECKING:
from omnigent.opencode_native_app_server import OpenCodeNativeServer
from omnigent.opencode_native_client import OpenCodeClient, OpenCodeSession
from omnigent.opencode_native_forwarder import OpenCodeNativeForwarder
from omnigent.runner.subagent_routing import SubagentRouter
from omnigent.runner.turn_routing import TurnRouter
from omnigent.spec.types import MCPServerConfig
import click
@@ -406,6 +408,19 @@ class _CodexNativeLaunchConfig:
``--dangerously-bypass-approvals-and-sandbox`` and aligns the
app-server threads (no approval prompts, no command sandbox). Default
``False``. See issue #657.
:param auto_harness: ``True`` when the session started in Smart Routing's
auto-harness mode (``omnigent.routing.auto_harness`` label or a
``harness_override`` of ``"auto"``), so the router may re-route its
spawns onto the Claude family. Only then are the routed-spawn developer
instructions installed, which is the only cross-family framing a
pinned session's spawns stay on codex.
:param routing_enabled: ``True`` when the session launched with Smart
Routing on (pinned or auto-harness). Gates the first-message
turn-routing endpoint, whose advertisement in turn gates the
``UserPromptSubmit`` routing hook; the extended model catalog a routed
turn may need; and the spawn-routing endpoint, whose advertisement
gates the generated ``spawn_agent`` hook and the routed-spawn tool
pre-approvals a routed spawn cannot run without.
"""
workspace: Path
@@ -417,6 +432,8 @@ class _CodexNativeLaunchConfig:
fork_source_external_id: str | None
fork_carry_history: bool
bypass_sandbox: bool
auto_harness: bool = False
routing_enabled: bool = False
@dataclasses.dataclass(frozen=True)
@@ -469,6 +486,180 @@ class _KiroNativeLaunchConfig:
model_override: str | None = None
class _NativeRouterLaunch(NamedTuple):
"""What a native launch site needs back from the router start.
:param advertised_dir: Directory to point the harness's hooks at, or
``None`` when no endpoint is running.
:param router: The handle to hand back to
:func:`_shutdown_session_router_async`, so a delayed teardown from
this launch cannot close a router a re-create has since installed.
"""
advertised_dir: Path | None
router: SubagentRouter | None
def _start_subagent_router_for_native_session(
session_id: str,
*,
bridge_dir: Path,
harness: str,
server_client: httpx.AsyncClient | None,
routing_enabled: bool,
auto_harness: bool,
) -> _NativeRouterLaunch:
"""Start the subagent-routing endpoint for a native session.
Native harnesses enforce routing through hooks configured at terminal
launch, so the endpoint has to be live (and advertised in the bridge
dir the hooks read) before the CLI starts.
Installed for Smart Routing sessions only, on both families: a plain
session launches like a plain one, with no loopback server, no bearer
token on disk and no spawn hook on its argv. On the codex family the
advertisement additionally turns on a generated ``hooks.json`` and the
routed-spawn tool pre-approvals which is why a pinned Smart Routing
codex session needs it too: without them its spawn tools are neither
gated nor pre-approved, so the spawn stalls on an approval prompt
nobody is watching. See ``ensure_session_router_quietly``.
:param session_id: Session/conversation identifier.
:param bridge_dir: Session bridge directory the hooks discover.
:param harness: Harness the router is being installed for; logged on
failure.
:param server_client: Runnerserver client the relay forwards on.
:param routing_enabled: Whether the session launched with Smart Routing
on. The gate on both families. Stamped at create, so a plain
session stays plain even if the gear's subagent-routing toggle is
flipped mid-session.
:param auto_harness: Whether Smart Routing also owns this session's
harness, so its spawns may cross families. Not required for the
endpoint; it decides what the router may offer.
:returns: The advertisement directory to point hooks at (``None`` when
the endpoint could not start) paired with the router handle.
"""
from omnigent.runner.subagent_routing import (
SessionRoutingClass,
ensure_session_router_quietly,
)
router = ensure_session_router_quietly(
session_id,
bridge_dir=bridge_dir,
server_client=server_client,
harness=harness,
routing_class=SessionRoutingClass(
routing_enabled=routing_enabled,
auto_harness=auto_harness,
),
)
return _NativeRouterLaunch(bridge_dir if router is not None else None, router)
def _start_turn_router_for_native_session(
session_id: str,
*,
bridge_dir: Path,
harness: str,
server_client: httpx.AsyncClient | None,
routing_enabled: bool,
) -> TurnRouter | None:
"""Start the first-message turn-routing endpoint for a native session.
Installed only for a session that launched with Smart Routing on. The
advertisement it writes is also the switch the harness launch reads to
decide whether to register the ``UserPromptSubmit`` routing hook at all,
so an unrouted session's prompts never pay the round trip.
:param session_id: Session/conversation identifier.
:param bridge_dir: Session bridge directory the hook discovers.
:param harness: Harness the endpoint is being installed for.
:param server_client: Runnerserver client the relay forwards on, and
the replay delivers through.
:param routing_enabled: The session's launch-time Smart Routing state.
:returns: The router handle, or ``None`` when routing is off for this
session or the endpoint could not start.
"""
from omnigent.runner.turn_routing import ensure_session_turn_router
return ensure_session_turn_router(
session_id,
bridge_dir=bridge_dir,
server_client=server_client,
harness=harness,
routing_enabled=routing_enabled,
)
def _recover_pending_turn_replay(
session_id: str,
*,
bridge_dir: Path,
server_client: httpx.AsyncClient | None,
) -> None:
"""Redeliver a routed prompt a previous launch blocked but never replayed.
Fire-and-forget and best effort: no pending record (the normal case) is
a no-op, and a recovery that cannot run must never fail a launch.
:param session_id: Session/conversation identifier.
:param bridge_dir: Session bridge directory holding the pending record.
:param server_client: Runnerserver client the prompt is delivered on.
:returns: None.
"""
from omnigent.runner.turn_routing import schedule_pending_replay_recovery
try:
schedule_pending_replay_recovery(
session_id,
bridge_dir=bridge_dir,
server_client=server_client,
)
except Exception: # noqa: BLE001 - a recovery must not take the launch down
_logger.warning(
"turn-routing replay recovery could not start for session=%s",
session_id,
exc_info=True,
)
async def _shutdown_session_turn_router_async(
session_id: str, router: TurnRouter | None = None
) -> None:
"""Tear down a session's turn-routing endpoint off the event loop.
:param session_id: Session/conversation identifier.
:param router: Handle this launch started, so a late teardown cannot
close the endpoint a re-created terminal has since installed.
:returns: None.
"""
from omnigent.runner.turn_routing import shutdown_session_turn_router
await asyncio.to_thread(shutdown_session_turn_router, session_id, router)
async def _shutdown_session_router_async(
session_id: str, router: SubagentRouter | None = None
) -> None:
"""Tear down a session's subagent-routing endpoint off the event loop.
``shutdown_session_router`` joins the router's serving thread, so
calling it inline would block the loop for up to the shutdown poll
interval. A session with no router is a no-op.
:param session_id: Session/conversation identifier.
:param router: Handle this launch started. Passing it scopes the
teardown to that router, so a forwarder whose ``finally`` runs
after a terminal re-create does not close the new session's live
endpoint.
:returns: None.
"""
from omnigent.runner.subagent_routing import shutdown_session_router
await asyncio.to_thread(shutdown_session_router, session_id, router)
def _required_runner_env(name: str) -> str:
"""
Return a required runner environment variable.
@@ -776,6 +967,7 @@ async def _codex_native_launch_config(
# Fork directives stamped on a clone at fork time. Only consulted when
# the clone has no external_session_id of its own yet (see the
# fork-source branch in _auto_create_codex_terminal); inert otherwise.
from omnigent.runner.subagent_routing import routing_class_from_snapshot
from omnigent.stores.conversation_store import (
CODEX_NATIVE_BYPASS_SANDBOX_LABEL_KEY,
FORK_CARRY_HISTORY_LABEL_KEY,
@@ -786,6 +978,8 @@ async def _codex_native_launch_config(
fork_source_id: str | None = None
fork_source_external_id: str | None = None
fork_carry_history = False
_harness_override = snapshot.get("harness_override")
_cost_control = snapshot.get("cost_control_mode_override")
# DANGEROUS opt-in: full approval/sandbox bypass, stored as a plain
# conversation label ("1" to enable). Read here so the runner applies
# it at launch; any other value (incl. absent) leaves the normal stance.
@@ -800,6 +994,13 @@ async def _codex_native_launch_config(
fork_source_external_id = _fse
fork_carry_history = labels.get(FORK_CARRY_HISTORY_LABEL_KEY) == "1"
bypass_sandbox = labels.get(CODEX_NATIVE_BYPASS_SANDBOX_LABEL_KEY) == "1"
# One derivation of the session's Smart Routing class, shared with the SDK
# codex path, so "pinned" and "auto-harness" mean the same on both.
routing_class = routing_class_from_snapshot(
cost_control_mode=_cost_control if isinstance(_cost_control, str) else None,
harness_override=_harness_override if isinstance(_harness_override, str) else None,
labels=labels if isinstance(labels, dict) else None,
)
return _CodexNativeLaunchConfig(
workspace=_codex_session_workspace(session_workspace),
policy_server_url=_required_runner_env("RUNNER_SERVER_URL"),
@@ -810,6 +1011,8 @@ async def _codex_native_launch_config(
fork_source_external_id=fork_source_external_id,
fork_carry_history=fork_carry_history,
bypass_sandbox=bypass_sandbox,
auto_harness=routing_class.auto_harness,
routing_enabled=routing_class.routing_enabled,
)
@@ -3500,6 +3703,7 @@ async def _auto_create_codex_terminal(
prepare_bridge_dir,
socket_path_for_bridge_dir,
)
from omnigent.inner.codex_executor import codex_extended_catalog_env
from omnigent.inner.datamodel import OSEnvSpec, TerminalEnvSpec
launch_config = await _codex_native_launch_config(
@@ -3734,6 +3938,18 @@ async def _auto_create_codex_terminal(
launch_config.policy_server_url, bearer_token=_policy_auth_token
)
# Symmetric with the claude-native arm: an auto-harness session landing on
# codex can have its spawns re-routed onto the Claude family, so it needs
# the same "a denied spawn is an approved re-route" framing. Routed through
# ``developer_instructions`` (whose sidecar base keeps a resume reversible),
# never by editing config.toml here.
# Passed only for auto-harness sessions so a pinned or plain codex launch
# keeps main's kwargs exactly.
routed_spawn_extras: dict[str, str] = {}
if launch_config.auto_harness:
from omnigent.inner.hook_scripts.subagent_router import smart_routing_spawn_note
routed_spawn_extras["developer_instructions"] = smart_routing_spawn_note("codex-native")
app_server = build_codex_native_server(
socket_path=socket_path,
codex_home=codex_home,
@@ -3749,6 +3965,41 @@ async def _auto_create_codex_terminal(
# This TUI runs detached for the web UI, so trust the runner-selected
# workspace in the session-private config instead of blocking forever.
trust_project=True,
**routed_spawn_extras,
)
# Generate routing hooks.json (and bypass codex's hook-trust prompt): the
# app-server reads the endpoint out of its own process env at start, and
# the server decides per spawn whether to route. Any Smart Routing session,
# pinned or auto — the advertisement is also what makes this session's
# codex-home diverge from a plain one (generated hooks.json, routed-spawn
# tool pre-approvals), and a pinned session cannot spawn without them.
_codex_router_dir, _codex_router = _start_subagent_router_for_native_session(
session_id,
bridge_dir=bridge_dir,
harness="codex-native",
server_client=server_client,
routing_enabled=launch_config.routing_enabled,
auto_harness=launch_config.auto_harness,
)
if _codex_router_dir is not None:
from omnigent.runner.subagent_routing import router_env
app_server.env.update(router_env(session_id, _codex_router_dir, harness="codex-native"))
# A routed turn can land on an arm codex's bundled catalog has no entry for
# (GLM), which its own client-side validation then refuses — so a Smart
# Routing session (pinned or auto) gets the extended catalog. A plain
# session keeps codex's bundled catalog and never pays the probe.
app_server.env.update(codex_extended_catalog_env(launch_config.routing_enabled))
# First-message model routing. Advertised in the same bridge dir the
# ``UserPromptSubmit`` hook is pointed at (so the hook needs no env of
# its own), and live before the app-server starts because the hook can
# fire on the very first prompt.
_codex_turn_router = _start_turn_router_for_native_session(
session_id,
bridge_dir=bridge_dir,
harness="codex-native",
server_client=server_client,
routing_enabled=launch_config.routing_enabled,
)
app_server.listen_url = codex_ws_url
await app_server.start()
@@ -3836,11 +4087,19 @@ async def _auto_create_codex_terminal(
# Omnigent provisions the private CODEX_HOME and vets
# hook sources itself; skip the interactive trust prompt
# that headless sub-agents can never answer.
# Gated on version: the flag was added in 0.140.0; on
# older binaries it causes an immediate exit error.
#
# Requires a *positively parsed* version, unlike the
# hooks-file gate in ``codex_native_app_server``, which
# treats an unknown version as supported. The two differ
# because their failure modes do: an unsupported hooks
# file is ignored by codex and caught downstream at the
# trust check, whereas an unknown CLI flag aborts argv
# parsing — so a transient ``codex --version`` hiccup on a
# pre-0.131 codex would turn a recoverable trust prompt
# into a dead terminal.
bypass_hook_trust=(
app_server.codex_cli_version is None
or app_server.codex_cli_version >= _MIN_BYPASS_HOOK_TRUST_CODEX_VERSION
app_server.codex_cli_version is not None
and app_server.codex_cli_version >= _MIN_BYPASS_HOOK_TRUST_CODEX_VERSION
),
),
env=codex_terminal_env(app_server),
@@ -3885,6 +4144,8 @@ async def _auto_create_codex_terminal(
codex_home=codex_home,
event_client=event_client,
routing_summary=_codex_launch.summary,
subagent_router=_codex_router,
turn_router=_codex_turn_router,
)
if launch_config.external_session_id is None
else _codex_forward_known_thread(
@@ -3892,12 +4153,23 @@ async def _auto_create_codex_terminal(
bridge_dir=bridge_dir,
codex_ws_url=codex_ws_url,
thread_id=launch_config.external_session_id,
subagent_router=_codex_router,
turn_router=_codex_turn_router,
)
),
name=f"codex-forwarder-{session_id}",
)
_register_auto_forwarder_task(session_id, _forwarder_task)
# A prompt a previous launch blocked for routing but never got to replay
# exists nowhere else: the block consumed it and the marker stops the hook
# from ever asking again. Drain it once this launch's thread is live.
_recover_pending_turn_replay(
session_id,
bridge_dir=bridge_dir,
server_client=server_client,
)
# Start the relay now (into codex's serve-mcp bridge dir) so tool_relay.json
# is on disk and the relay recorded before codex connects on its first turn:
# the first-turn `_ensure_comment_relay_started` then fast-paths, avoiding
@@ -3921,6 +4193,8 @@ async def _codex_discover_thread_and_forward(
codex_home: Path,
event_client: CodexAppServerClient,
routing_summary: str,
subagent_router: SubagentRouter | None = None,
turn_router: TurnRouter | None = None,
) -> None:
"""
Adopt the fresh Codex TUI's thread, then mirror it into the Omnigent session.
@@ -3945,6 +4219,11 @@ async def _codex_discover_thread_and_forward(
routing (provider / profile / model, or the login-fallback state),
threaded into the startup-timeout error so hosted users can diagnose
without runner-log access (see #2745).
:param subagent_router: Router this terminal launch started, torn down
in the ``finally``. Passed so a late teardown cannot close the
endpoint a re-created terminal has since installed.
:param turn_router: First-message routing endpoint this launch
started, torn down alongside the subagent one.
"""
from omnigent.codex_native_bridge import (
CodexNativeBridgeState,
@@ -4063,6 +4342,8 @@ async def _codex_discover_thread_and_forward(
if leftover_app_server is not None:
with contextlib.suppress(Exception):
await leftover_app_server.close()
await _shutdown_session_router_async(session_id, subagent_router)
await _shutdown_session_turn_router_async(session_id, turn_router)
async def _codex_forward_known_thread(
@@ -4071,6 +4352,8 @@ async def _codex_forward_known_thread(
bridge_dir: Path,
codex_ws_url: str,
thread_id: str,
subagent_router: SubagentRouter | None = None,
turn_router: TurnRouter | None = None,
) -> None:
"""
Forward a runner-owned Codex terminal that resumes an existing thread.
@@ -4081,6 +4364,11 @@ async def _codex_forward_known_thread(
``"ws://127.0.0.1:9876"``.
:param thread_id: Existing Codex app-server thread id, e.g.
``"thread_abc123"``.
:param subagent_router: Router this terminal launch started, torn down
in the ``finally``. Passed so a late teardown cannot close the
endpoint a re-created terminal has since installed.
:param turn_router: First-message routing endpoint this launch
started, torn down alongside the subagent one.
:returns: None. Runs until cancelled or the app-server connection
closes.
"""
@@ -4109,6 +4397,8 @@ async def _codex_forward_known_thread(
if leftover_app_server is not None:
with contextlib.suppress(Exception):
await leftover_app_server.close()
await _shutdown_session_router_async(session_id, subagent_router)
await _shutdown_session_turn_router_async(session_id, turn_router)
async def _run_antigravity_reader(
@@ -5409,6 +5699,44 @@ def _ensure_orchestrator_skills_in_bundle(
)
#: Omnigent MCP tools an auto-harness Claude session must be able to call
#: without an interactive prompt: the two the cross-harness redirect names, the
#: one that delivers the sub-task, and the one that collects its result. The
#: native path passes no allowlist otherwise, so Claude Code's "don't ask mode"
#: denies them outright ("Permission to use mcp__omnigent__sys_read_inbox has
#: been denied"). Narrower than the SDK arm, which pre-approves every Omnigent
#: tool in ``auto`` / ``bypassPermissions``.
_ROUTED_SPAWN_ALLOWED_TOOLS: tuple[str, ...] = (
"mcp__omnigent__sys_session_create",
"mcp__omnigent__sys_agent_list",
"mcp__omnigent__sys_session_send",
"mcp__omnigent__sys_read_inbox",
)
def _routed_spawn_launch_args(
auto_harness: bool, *, router_started: bool = True
) -> tuple[str | None, tuple[str, ...]]:
"""
Resolve the routed-spawn additions to a Claude terminal's argv.
:param auto_harness: ``True`` for a session whose spawns the router may
move across harness families.
:param router_started: ``False`` when the spawn router did not come up, so
nothing would honour the note or need the pre-approvals. Instructing
Claude to hand its spawns to a router that is not there would only
make it argue with a hook that never answers.
:returns: ``(append_system_prompt, allowed_tools)`` for
:func:`augment_claude_args`. ``(None, ())`` leaves the argv exactly as
a pinned session's, which is the point of the gate.
"""
if not auto_harness or not router_started:
return None, ()
from omnigent.inner.hook_scripts.subagent_router import smart_routing_spawn_note
return smart_routing_spawn_note("claude-native"), _ROUTED_SPAWN_ALLOWED_TOOLS
@dataclasses.dataclass(frozen=True)
class _ClaudeSessionLaunchMetadata:
"""Persisted values consumed by Claude terminal launch."""
@@ -5419,12 +5747,25 @@ class _ClaudeSessionLaunchMetadata:
external_session_id: str | None = None
fork_source_external_id: str | None = None
fork_carry_history: bool = False
#: Both routing fields come from ``routing_class_from_snapshot``, so an
#: auto-harness session always reads as routing-enabled too. Deriving them
#: separately was the bug: a sub-agent child of a routed parent carries the
#: auto-harness label but no ``cost_control_mode_override``, and it launched
#: with the routed-spawn note and tool pre-approvals but no router, no
#: pinned arms and no launch-model pin.
routing_enabled: bool = False
#: Session started in Smart Routing's auto-harness mode, so the router may
#: place its subagents on the counterpart harness family. Only these
#: sessions get the routed-spawn system-prompt note and tool pre-approval;
#: a pinned session's argv stays byte-identical.
auto_harness: bool = False
def _claude_launch_metadata_from_envelope(
session_init: RunnerSessionInitEnvelope,
) -> _ClaudeSessionLaunchMetadata:
"""Project Claude launch metadata without server callbacks."""
from omnigent.runner.subagent_routing import routing_class_from_snapshot
from omnigent.stores.conversation_store import (
FORK_CARRY_HISTORY_LABEL_KEY,
FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY,
@@ -5432,7 +5773,14 @@ def _claude_launch_metadata_from_envelope(
snapshot = session_init.snapshot
fork_source = snapshot.labels.get(FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY)
routing_class = routing_class_from_snapshot(
cost_control_mode=snapshot.cost_control_mode_override,
harness_override=snapshot.harness_override,
labels=snapshot.labels,
)
return _ClaudeSessionLaunchMetadata(
routing_enabled=routing_class.routing_enabled,
auto_harness=routing_class.auto_harness,
reasoning_effort=snapshot.reasoning_effort,
model_override=snapshot.model_override,
terminal_launch_args=snapshot.terminal_launch_args,
@@ -5449,6 +5797,7 @@ async def _load_legacy_claude_launch_metadata(
session_id: str,
) -> _ClaudeSessionLaunchMetadata:
"""Fetch Claude launch metadata for servers predating the init envelope."""
from omnigent.runner.subagent_routing import routing_class_from_snapshot
from omnigent.stores.conversation_store import (
FORK_CARRY_HISTORY_LABEL_KEY,
FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY,
@@ -5476,7 +5825,16 @@ async def _load_legacy_claude_launch_metadata(
labels = snapshot.get("labels")
labels = labels if isinstance(labels, dict) else {}
fork_source = labels.get(FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY)
cost_control_mode = snapshot.get("cost_control_mode_override")
harness_override = snapshot.get("harness_override")
routing_class = routing_class_from_snapshot(
cost_control_mode=cost_control_mode if isinstance(cost_control_mode, str) else None,
harness_override=harness_override if isinstance(harness_override, str) else None,
labels={str(key): str(value) for key, value in labels.items()},
)
metadata = _ClaudeSessionLaunchMetadata(
routing_enabled=routing_class.routing_enabled,
auto_harness=routing_class.auto_harness,
reasoning_effort=effort if isinstance(effort, str) and effort else None,
model_override=(
model_override if isinstance(model_override, str) and model_override else None
@@ -5712,6 +6070,8 @@ async def _auto_create_claude_terminal(
from omnigent.claude_launcher import resolve_claude_launch
from omnigent.claude_native import (
build_native_claude_terminal_env,
claude_config_with_launch_model_pinned,
claude_config_with_routed_arms_pinned,
resolve_claude_native_model_selection,
resolve_native_claude_config,
)
@@ -5927,24 +6287,40 @@ async def _auto_create_claude_terminal(
"and that the secret resolves in this process.",
exc_info=True,
)
if record_launch_config is not None:
record_launch_config(session_id, claude_config)
_logger.info(
"Claude terminal provider config resolved: session=%s configured=%s "
"env_keys=%s api_key_helper_set=%s model_set=%s",
session_id,
claude_config is not None,
sorted(claude_config.env) if claude_config is not None else [],
bool(claude_config.api_key_helper) if claude_config is not None else False,
bool(claude_config.model) if claude_config is not None else False,
)
# A routed session's turn-1 ``/model`` can only reach ids this launch env
# spells, so point the family aliases at the router's frozen arms before the
# launch model is derived from them.
if launch_metadata.routing_enabled:
from omnigent.server.smart_routing import task_v1_claude_arms
claude_config = claude_config_with_routed_arms_pinned(claude_config, task_v1_claude_arms())
launch_model = resolve_claude_native_model_selection(
session_model_override
or _claude_native_model_from_spec(agent_spec)
or (claude_config.model if claude_config is not None else None),
claude_config,
)
# Give an exact launch model (a Smart Routing pick is resolved before the
# terminal exists) a spelling of its own in the picker, so a later
# ``/model`` can return to it instead of stepping onto whatever the family
# alias points at. Recorded below, so the picker and the launch agree.
# Routed launches only: the pin writes ``ANTHROPIC_CUSTOM_MODEL_OPTION``,
# and on a plain session that displaces the workspace's own picker row for
# no gain — nothing later re-picks the launch model there.
if launch_metadata.routing_enabled:
claude_config = claude_config_with_launch_model_pinned(claude_config, launch_model)
if record_launch_config is not None:
record_launch_config(session_id, claude_config)
_logger.info(
"Claude terminal provider config resolved: session=%s configured=%s "
"env_keys=%s api_key_helper_set=%s model_set=%s launch_model=%s",
session_id,
claude_config is not None,
sorted(claude_config.env) if claude_config is not None else [],
bool(claude_config.api_key_helper) if claude_config is not None else False,
bool(claude_config.model) if claude_config is not None else False,
launch_model,
)
base_claude_args = _build_claude_native_base_args(
reasoning_effort=session_effort,
# Precedence: per-session ``/model`` override > agent-spec pin
@@ -5964,6 +6340,42 @@ async def _auto_create_claude_terminal(
# has the spec resolver) expose a bundle's ``skills/`` to Claude Code
# via ``--plugin-dir`` — the CLI mirror of the SDK plugin wiring.
# ``api_key_helper`` (ucode) registers Claude's gateway token command.
# Gate natively spawned subagents (the Task/Agent tool): start the loopback
# endpoint in the bridge dir the PreToolUse hook already discovers. Smart
# Routing sessions only — a plain session would otherwise carry a loopback
# server, a bearer token on disk and a hook subprocess on every spawn for a
# verdict the server never routes. Claude routes spawns whether or not the
# harness is auto-picked, so ``auto_harness`` is not required here.
subagent_router_dir, _subagent_router = _start_subagent_router_for_native_session(
session_id,
bridge_dir=bridge_dir,
harness="claude-native",
server_client=server_client,
routing_enabled=launch_metadata.routing_enabled,
auto_harness=launch_metadata.auto_harness,
)
# First-message model routing. Advertised in the same bridge dir the
# ``UserPromptSubmit`` hook is pointed at (so the hook needs no env of its
# own), and live before the terminal launches because the hook can fire on
# the very first prompt the user types.
_claude_turn_router = _start_turn_router_for_native_session(
session_id,
bridge_dir=bridge_dir,
harness="claude-native",
server_client=server_client,
routing_enabled=launch_metadata.routing_enabled,
)
# Crash recovery for a blocked-but-never-replayed prompt is not wired here:
# the pending record on disk is the seam if it ever is, and the recovery's
# default readiness probe waits on a codex bridge thread.
# Only an auto-harness session's spawns can be re-routed across harness
# families, so only it needs the routed-spawn note and the pre-approval for
# the three Omnigent tools that carry out the re-route. A pinned session's
# argv must stay byte-identical.
routed_spawn_note, routed_spawn_tools = _routed_spawn_launch_args(
launch_metadata.auto_harness,
router_started=subagent_router_dir is not None,
)
claude_args = augment_claude_args(
base_claude_args,
bridge_dir=bridge_dir,
@@ -5973,6 +6385,12 @@ async def _auto_create_claude_terminal(
agent_name=agent_name,
skills_filter=skills_filter,
api_key_helper=claude_config.api_key_helper if claude_config is not None else None,
subagent_router_dir=subagent_router_dir,
append_system_prompt=routed_spawn_note,
allowed_tools=routed_spawn_tools,
# The route-turn hook is registered only when this session can
# actually route; otherwise every submit would pay its round trip.
turn_routing=_claude_turn_router is not None,
)
# Let a registered launcher plugin (e.g. Databricks' isaac) rewrite the
@@ -6116,15 +6534,19 @@ async def _auto_create_claude_terminal(
from omnigent.claude_native_forwarder import supervise_forwarder
async def _supervise_bridge() -> None:
await supervise_forwarder(
base_url=server_url,
headers=_runner_headers,
session_id=session_id,
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=resume_external_session_id is not None,
auth=_runner_auth,
)
try:
await supervise_forwarder(
base_url=server_url,
headers=_runner_headers,
session_id=session_id,
bridge_dir=bridge_dir,
agent_name="claude-native-ui",
start_at_end=resume_external_session_id is not None,
auth=_runner_auth,
)
finally:
await _shutdown_session_router_async(session_id, _subagent_router)
await _shutdown_session_turn_router_async(session_id, _claude_turn_router)
_forwarder_task = asyncio.create_task(
_supervise_bridge(),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -8,10 +8,18 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from omnigent.server.smart_routing import RoutingClient
from omnigent.server.routing_backend import RoutingBackends
from omnigent.server.smart_routing import RoutingClient, RoutingSettings
from omnigent.spec.types import LLMConfig, PolicySpec
def _default_routing_settings() -> RoutingSettings:
"""Build the default :class:`RoutingSettings` (imported lazily)."""
from omnigent.server.smart_routing import RoutingSettings
return RoutingSettings()
@dataclass
class RuntimeCaps:
"""
@@ -56,6 +64,21 @@ class RuntimeCaps:
LLM call is billed to the request caller rather than a
static service-level credential. ``None`` falls back to the
``llm``-config-resolved connection.
:param routing_client: This deployment's primary/default routing
client the same object as ``routing_backends.any()`` when both
are set. Every legacy consumer reads it, so it stays the single
answer to "is routing configured at all".
:param routing_backends: The external and built-in routing clients
as a pair, so a call whose harness is not AI-Gateway-backed can
still be served by the built-in judge
(:func:`~omnigent.server.routing_backend.select_router`).
Managed deployments that supply their own client should set this
explicitly: absent it the pair is derived from
:attr:`routing_client` by ``isinstance``, which classifies an
unrecognized client as the OSS judge. That default is safe it
costs only a badge on the decision chip, whereas claiming a
custom client is gateway-backed would promise reachability
nobody verified.
"""
execution_timeout: int = 7200
@@ -78,3 +101,13 @@ 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
# Both routing backends, so each call can pick the one that can serve it:
# the external client's picks are AI-Gateway catalog ids, so it only serves
# gateway-backed harnesses, while the built-in judge serves any. ``None``
# derives the pair from ``routing_client`` by type.
routing_backends: RoutingBackends | None = None
# Routing knobs parsed from the ``routing:`` block of the server --config
# YAML (router name, extraction model, scenario menus, subagent fail mode).
# Always present so consumers read one value object instead of re-parsing
# config; the defaults describe an unconfigured deployment.
routing_settings: RoutingSettings = field(default_factory=_default_routing_settings)
+17 -9
View File
@@ -1691,7 +1691,7 @@ def create_app(
return {"version": _server_version()}
@app.get("/v1/info")
async def info() -> dict[str, bool | str | list[str] | None]:
async def info() -> dict[str, bool | str | list[str] | dict[str, bool] | None]:
"""Runtime capabilities probe for the SPA + CLI.
Returned at app boot by the frontend (and by ``omnigent
@@ -1777,19 +1777,26 @@ def create_app(
# server_version is the installed omnigent package version (same
# source as /api/version), surfaced so the web UI can show it in the
# session info popover alongside the per-session host version.
# smart_routing_enabled: true when the server can route — either
# a RoutingClient is configured (a server llm: block, or a
# routing.provider=external block) or the managed deployment registered
# a policy_llm_connection_factory (which means it has LLM capability
# and will supply its own RoutingClient).
# smart_routing_enabled: true when the server can route from ANY source
# — a configured RoutingClient (a server llm: block, routing.provider=
# external, or an explicit routing_backends pair) or a managed
# deployment's policy_llm_connection_factory.
# smart_routing_sources names WHICH router can answer: "external" is the
# workspace AI-Gateway task_v1 client, "oss" the built-in judge. A
# harness whose inference is not gateway-backed can only be served by
# the built-in one, so the SPA and the CLI read this to pick a source
# instead of hiding the surface.
# Both come from one helper, so the flag can never claim routing is off
# for a deployment whose sources say a router would answer.
try:
from omnigent.runtime._globals import _caps
from omnigent.server.routing_backend import routing_available, routing_sources
smart_routing_enabled = _caps is not None and (
_caps.routing_client is not None or _caps.policy_llm_connection_factory is not None
)
smart_routing_enabled = routing_available(_caps)
smart_routing_sources = routing_sources(_caps)
except ImportError:
smart_routing_enabled = False
smart_routing_sources = {"external": False, "oss": False}
# harness_install_enabled gates the web UI's "Install" action for a
# missing, npm-installable harness on a connected host. Off by default
# (OMNIGENT_HARNESS_INSTALL_ENABLED=1 opts in) while the feature rolls
@@ -1830,6 +1837,7 @@ def create_app(
"public_sharing_enabled": public_sharing_enabled,
"server_version": _server_version(),
"smart_routing_enabled": smart_routing_enabled,
"smart_routing_sources": smart_routing_sources,
"harness_install_enabled": harness_install_enabled,
"installable_harnesses": installable_harnesses,
"dictation_available": dictation_available,
+56 -4
View File
@@ -9,6 +9,13 @@ which hosts are live *here*.
Simpler than :class:`TunnelRegistry` because the host tunnel
carries only control frames (launch/stop runner), not HTTP
request/response traffic. No per-request reassembly queues needed.
The registry also holds what connected hosts *report* about themselves and
nothing persists today the per-family gateway-inference map (see
:mod:`omnigent.gateway_inference`). It is delivered on the connect handshake, so
a replica that has never seen a host simply knows nothing about it, and the
readers' unknown-is-backed rule covers that window until the host reconnects and
re-reports.
"""
from __future__ import annotations
@@ -17,6 +24,7 @@ import asyncio
import logging
import threading
import time
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, Protocol
@@ -224,8 +232,8 @@ class HostConnection:
:param pending_installs: Per-``request_id`` futures for in-flight
``host.install_harness`` requests. Resolved when the host sends
``host.install_harness_result``. Values carry the result fields
(``status``, ``configured_harnesses``, ``error``). Same ``Any``
typing rationale as ``pending_stats``.
(``status``, ``configured_harnesses``, ``gateway_inference``,
``error``). Same ``Any`` typing rationale as ``pending_stats``.
:param inflight_installs: Install tasks used to coalesce concurrent
install requests for the same harness family (a double-click, or
two spellings of one npm package) onto one in-flight install, so
@@ -235,8 +243,9 @@ class HostConnection:
:param pending_secret_writes: Per-``request_id`` futures for in-flight
``host.store_secret`` requests (a UI-driven harness credential write).
Resolved when the host sends ``host.store_secret_result``. Values carry
the result fields (``status``, ``configured_harnesses``, ``error``)
never the secret. Same ``Any`` typing rationale as ``pending_stats``.
the result fields (``status``, ``configured_harnesses``,
``gateway_inference``, ``error``) never the secret. Same ``Any``
typing rationale as ``pending_stats``.
:param credential_write_lock: Serializes credential writes to this host so
two overlapping requests (a double-click, or key + gateway in quick
succession) can't interleave the daemon's non-atomic
@@ -322,6 +331,13 @@ class HostRegistry:
# Keyed by (workspace_id, host_id) to mirror the hosts-table PK:
# one stable host_id can be live in more than one workspace.
self._hosts: dict[tuple[int, str], HostConnection] = {}
# Last gateway-inference map each host reported, keyed by canonical
# host_id alone: the map describes the machine's local config, so the
# same machine connected to two workspaces reports the same answer.
# Kept across a host disconnect (a tunnel flap shouldn't blank a known
# answer) and lost with the process, which is the point — a restarted
# server re-learns it from the reconnect handshake.
self._gateway_inference: dict[str, dict[str, bool]] = {}
def register(
self,
@@ -485,6 +501,42 @@ class HostRegistry:
return None
return conn.hello.installation_id
def record_gateway_inference(
self,
host_id: str,
gateway_inference: Mapping[str, bool] | None,
) -> None:
"""Store the gateway-inference map a host just reported.
Called for every frame that carries the map the connect handshake and
each readiness refresh so the server's view is delivered rather than
persisted. ``None`` (a host that cannot evaluate the map at all) clears
the entry back to unknown instead of recording "nothing is backed".
:param host_id: Host identifier, in any accepted spelling (see
:func:`_canonical_host_id`).
:param gateway_inference: Harness spelling gateway-backed flag, e.g.
``{"claude-native": True, "codex": False}``, or ``None``.
"""
key = _canonical_host_id(host_id)
with self._lock:
if gateway_inference is None:
self._gateway_inference.pop(key, None)
else:
self._gateway_inference[key] = dict(gateway_inference)
def gateway_inference(self, host_id: str) -> dict[str, bool] | None:
"""Return the gateway-inference map *host_id* last reported here.
:param host_id: Host identifier, in any accepted spelling.
:returns: A copy of the reported map, or ``None`` when this replica has
never had a report from the host unknown, which readers treat as
gateway-backed rather than unavailable.
"""
with self._lock:
reported = self._gateway_inference.get(_canonical_host_id(host_id))
return dict(reported) if reported is not None else None
def send_text(self, conn: HostConnection, data: str) -> None:
"""Enqueue a text frame for sending to the host.
@@ -0,0 +1,48 @@
"""One round-trip helper for ``host.model_options``.
Two callers ask a host which models a harness could launch with: the
``/v1/hosts/{id}/model-options`` route (which turns a failure into an HTTP
error) and the session-create routing path (which degrades to no
candidates). Only the failure handling differs, so the request-id /
future / frame / timeout / cleanup shape lives here once.
"""
from __future__ import annotations
import asyncio
import secrets
from typing import Any
from omnigent.host.frames import HostModelOptionsFrame, encode_host_frame
from omnigent.server.host_registry import HostConnection, HostRegistry
async def request_host_model_options(
*,
host_registry: HostRegistry,
host_conn: HostConnection,
harness: str,
timeout_s: float,
) -> dict[str, Any]:
"""
Send a ``host.model_options`` frame and await the host's result.
:param host_registry: Registry used to enqueue the outbound frame.
:param host_conn: Live host connection to query.
:param harness: Native harness id, e.g. ``"claude-native"``.
:param timeout_s: Seconds to wait for the result frame.
:returns: The result payload, e.g. ``{"status": "ok", "models": [...]}``.
:raises ConnectionError: The host connection dropped before the frame
could be enqueued.
:raises asyncio.TimeoutError: The host did not answer within
*timeout_s*.
"""
request_id = secrets.token_hex(8)
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
host_conn.pending_model_options[request_id] = future
frame = encode_host_frame(HostModelOptionsFrame(request_id=request_id, harness=harness))
try:
host_registry.send_text(host_conn, frame)
return await asyncio.wait_for(future, timeout=timeout_s)
finally:
host_conn.pending_model_options.pop(request_id, None)
@@ -630,6 +630,12 @@ _MAX_TERMINAL_LAUNCH_ARG_LEN = 4096
COST_CONTROL_OVERRIDE_VALUES = frozenset({"on", "off"})
# Per-session subagent-routing switch. Two-state: only ``"on"`` routes
# spawns, and ``"off"`` / absent both read as Default. Creates that start
# on Smart Routing are stamped ``"on"``, so absent is never an inherit.
SUBAGENT_ROUTING_OVERRIDE_VALUES = frozenset({"on", "off"})
_CHILD_PREVIEW_LIMIT = 150
@@ -711,6 +717,7 @@ def get_server_host_registry() -> HostRegistry | None:
__all__ = [
"COST_CONTROL_OVERRIDE_VALUES",
"SUBAGENT_ROUTING_OVERRIDE_VALUES",
"_ALLOWED_EVENT_TYPES",
"_ANTIGRAVITY_NATIVE_ELICITATION_HOOK_TIMEOUT_S",
"_APPROVAL_TYPE",
+197 -15
View File
@@ -67,6 +67,7 @@ from omnigent.runner.identity import (
token_bound_runner_id,
)
from omnigent.runner.routing import RunnerRouter
from omnigent.runner.subagent_routing import ROUTING_DECISION_LABEL_KEY
from omnigent.runner.transports.ws_tunnel.registry import TunnelRegistry
from omnigent.runtime import (
get_policy_store,
@@ -167,6 +168,7 @@ from omnigent.server.routes._sessions.common import ( # noqa: F401
_UI_ADDED_AGENT_TITLE_PREFIX,
_UPLOAD_READ_CHUNK_BYTES,
COST_CONTROL_OVERRIDE_VALUES,
SUBAGENT_ROUTING_OVERRIDE_VALUES,
_logger,
_managed_launch_tasks,
_model_options_cache,
@@ -1778,6 +1780,53 @@ def _validated_harness_override_executor_type(agent: Agent) -> None:
)
#: ``executor.config`` key by which a spec hands its brain harness to Smart
#: Routing. ``auto`` is the only accepted value.
SMART_ROUTING_HARNESS_CONFIG_KEY = "smart_routing_harness"
#: The ``harness_override`` sentinel meaning "the router picks the harness".
AUTO_HARNESS_SENTINEL = "auto"
def _validated_spec_smart_routing_harness(spec: AgentSpec) -> str | None:
"""
Read a spec's opt-in for routing its own brain harness.
A spec that pins ``executor.config.harness`` also pins the family every
sub-agent is routed within, so a cross-family sub-agent cannot stay on its
declared harness. ``smart_routing_harness: auto`` lets such a spec keep its
pin for a normal session and hand the harness to the router when Smart
Routing is on.
Validated on the same rules as :func:`_validated_harness_override`: the
value must be the ``"auto"`` sentinel, and only an ``executor.type:
omnigent`` spec has a swappable brain harness to give away.
:param spec: The bound agent's parsed spec.
:returns: ``"auto"`` when the spec opts in, else ``None``.
:raises OmnigentError: ``invalid_input`` for any other value, or for the
key on a non-omnigent executor type.
"""
from omnigent.spec._omnigent_compat import OMNIGENT_EXECUTOR_TYPE
value = spec.executor.config.get(SMART_ROUTING_HARNESS_CONFIG_KEY)
if value is None:
return None
key = f"executor.config.{SMART_ROUTING_HARNESS_CONFIG_KEY}"
if value != AUTO_HARNESS_SENTINEL:
raise OmnigentError(
f"invalid {key}: must be {AUTO_HARNESS_SENTINEL!r}, got {value!r}",
code=ErrorCode.INVALID_INPUT,
)
if spec.executor.type != OMNIGENT_EXECUTOR_TYPE:
raise OmnigentError(
f"{key} only applies to executor.type {OMNIGENT_EXECUTOR_TYPE!r} "
f"agents; this spec declares executor.type {spec.executor.type!r}",
code=ErrorCode.INVALID_INPUT,
)
return AUTO_HARNESS_SENTINEL
def _utc_day(epoch_seconds: int) -> str:
"""
Convert a Unix epoch timestamp to its UTC calendar day.
@@ -4791,7 +4840,7 @@ class _NativeTerminalEnsureOutcome:
"""
error: ErrorData | None
policy_notice: str | None
policy_notice: str | None = None
def _policy_notice_from_ensure_response(resp: httpx.Response) -> str | None:
@@ -4833,6 +4882,71 @@ def _publish_error_event(session_id: str, error: ErrorData) -> None:
session_stream.publish(session_id, event.model_dump())
#: Error code for a model change the terminal never applied.
_MODEL_CHANGE_NOT_APPLIED_CODE = "model_change_not_applied"
def _surface_model_change_forward_failure(
session_id: str,
model: str | None,
runner_result: _RunnerForwardResult | None,
) -> None:
"""
Publish a visible notice when a native pane never took a model change.
A PATCH persists ``model_override`` and then forwards the change to the
runner, which types ``/model`` into the terminal. On a native terminal that
injection is the ONLY thing that moves the model, so a dropped forward left
the row (and the picker) claiming a model the pane was never on, silently.
This does not roll the row back it makes the divergence visible.
Call only for native terminal sessions: every other harness re-reads the
persisted value at its next turn boundary, so a dropped forward there is
genuinely benign.
Silent when no runner answered at all. That session is stopped or detached,
and its relaunch reads ``model_override`` off the row, so nothing has
diverged the notice is for a session whose runner IS reachable and still
did not switch the pane.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc123"``.
:param model: The model that was persisted, or ``None`` when cleared.
:param runner_result: HTTP result from the forward, or ``None`` when no
runner was reachable.
:returns: None.
"""
if runner_result is None:
_logger.info(
"Model change for session=%s model=%r reached no runner; the launch will read it "
"off the row",
session_id,
model,
)
return
if 200 <= runner_result.status_code < 300:
return
reason = f"the runner returned status {runner_result.status_code}"
_logger.warning(
"Model change not applied to the terminal for session=%s model=%r: %s (body=%s)",
session_id,
model,
reason,
runner_result.body,
)
target = model or "its default model"
_publish_error_event(
session_id,
ErrorData(
source="execution",
code=_MODEL_CHANGE_NOT_APPLIED_CODE,
message=(
f"The terminal was not switched to {target}: {reason}. "
"It is still running on its previous model."
),
),
)
async def _persist_native_policy_notice(
session_id: str,
conversation_store: ConversationStore,
@@ -4933,10 +5047,19 @@ async def _forward_session_change_to_runner(
return await _facade._forward_session_change_to_runner(*args, **kwargs)
#: Forward budget for a control event the runner answers by driving the TUI.
#: The claude-native ``/model`` and ``/effort`` injectors wait up to 1s for the
#: tmux advertisement and then up to 4s for the confirmation dialog, so the
#: default 5s budget could time out on a legitimately-still-working injection
#: and report a failure that did not happen.
_TUI_INJECT_FORWARD_TIMEOUT_S = 20.0
async def _forward_session_change_to_runner_impl(
session_id: str,
runner_router: Any,
event: dict[str, Any],
timeout_s: float = 5.0,
) -> _RunnerForwardResult | None:
"""
Best-effort POST a control event to the bound runner.
@@ -4976,6 +5099,9 @@ async def _forward_session_change_to_runner_impl(
``{"type": "effort_change", "effort": "high"}``,
``{"type": "model_change", "model": "claude-opus-4-7"}``, or
``{"type": "compact"}``.
:param timeout_s: Request budget, e.g. ``5.0``. Callers whose event the
runner answers by driving the TUI pass
:data:`_TUI_INJECT_FORWARD_TIMEOUT_S`.
:returns: The runner's HTTP status/body, or ``None`` when no
runner client could be resolved or the POST failed at the
transport layer (in both cases the AP-side persisted value /
@@ -4992,7 +5118,7 @@ async def _forward_session_change_to_runner_impl(
resp = await runner_client.post(
f"/v1/sessions/{session_id}/events",
json=event,
timeout=5.0,
timeout=timeout_s,
)
except (httpx.HTTPError, ConnectionError):
_logger.exception(
@@ -5630,7 +5756,11 @@ async def _emit_server_routing_decision(
verdict: dict[str, Any],
*,
agent: str | None = None,
) -> None:
scope: str = "turn",
harness: str | None = None,
decision_id: str | None = None,
attempted_override: str | None = None,
) -> str | None:
"""Persist and publish a ``routing_decision`` transcript chip.
Called by the server-side routing path before the turn is forwarded
@@ -5640,15 +5770,38 @@ async def _emit_server_routing_decision(
:param agent: Sub-agent name to include when mirroring a child
session's routing decision into the parent's transcript.
:param scope: What the decision governs, e.g. ``"child_session"``.
:param harness: Harness the decision applies to, when it picked one.
:param decision_id: Decision identity shared with the child-sessions
API. ``None`` mints one.
:param attempted_override: Model the spawning agent asked for and the
router overrode an LLM-supplied ``args.model``, or a native
spawn's own ``requested_model``. ``None`` when nothing was asked
for, or when the pick names the same arm as the ask.
:returns: The decision id, so callers can join it onto the session
row, or ``None`` when the payload failed validation and no chip
was recorded.
"""
import uuid
rationale = verdict.get("rationale", "")
applied = verdict.get("applied", True)
resolved_decision_id = decision_id or str(uuid.uuid4())
raw_model = verdict.get("raw_model")
# Which router answered, so the chip can mark an AI-Gateway-routed decision.
router_source = verdict.get("router_source")
item_data: dict[str, Any] = {
"model": model,
"applied": bool(applied),
"rationale": rationale if isinstance(rationale, str) else "",
"scope": scope,
"harness": harness,
"decision_id": resolved_decision_id,
"raw_model": raw_model if isinstance(raw_model, str) and raw_model else None,
"attempted_override": attempted_override,
"router_source": (
router_source if isinstance(router_source, str) and router_source else None
),
}
if agent is not None:
item_data["agent"] = agent
@@ -5656,7 +5809,7 @@ async def _emit_server_routing_decision(
parsed_data = parse_item_data("routing_decision", item_data)
except (ValueError, TypeError):
_logger.warning("Server routing: failed to parse routing_decision data")
return
return None
routing_item = NewConversationItem(
type="routing_decision",
@@ -5685,6 +5838,7 @@ async def _emit_server_routing_decision(
},
},
)
return resolved_decision_id
@dataclass
@@ -7178,6 +7332,28 @@ def _validated_cost_control_mode_override(value: str | None) -> str | None:
)
def _validated_subagent_routing_override(value: str | None) -> str | None:
"""
Validate a caller-supplied per-session subagent-routing switch.
Two-state: ``"on"`` routes subagent spawns and ``"off"`` / unset both
read as Default. ``None`` from a PATCH clears the stored value, which
lands the session on Default rather than inheriting anything.
:param value: The candidate value, e.g. ``"on"``, or ``None`` when
the caller did not set / wants to clear the override.
:returns: The value unchanged when valid, or ``None``.
:raises OmnigentError: 400 (``invalid_input``) when *value* is
anything other than ``"on"``, ``"off"``, or ``None``.
"""
if value is None or value in SUBAGENT_ROUTING_OVERRIDE_VALUES:
return value
raise OmnigentError(
f"invalid subagent_routing_override: {value!r} (expected 'on', 'off', or null to clear)",
code=ErrorCode.INVALID_INPUT,
)
def _parse_session_create_metadata(metadata: str) -> SessionCreateMetadata:
"""
Parse the JSON metadata part from bundled session creation.
@@ -8141,6 +8317,7 @@ def _child_session_summary_from_conversation(
collapsed = " ".join(raw_prompt.split())
last_message_preview = collapsed[:_CHILD_PREVIEW_LIMIT] or None
routing_decision_id = conv.labels.get(ROUTING_DECISION_LABEL_KEY)
return ChildSessionSummary(
id=conv.id,
parent_session_id=parent_session_id,
@@ -8163,6 +8340,13 @@ def _child_session_summary_from_conversation(
# in-memory index that feeds the sidebar badge, so the Agents
# rail can flag a child that's awaiting user input.
pending_elicitations_count=pending_elicitations.count_for(conv.id),
# The model routing picked for this child, reported only when a
# decision actually produced it: a user-pinned model_override is not a
# routed model, and reporting one with a null decision id makes the
# two fields contradict each other. The decision is joined through a
# conversation label rather than a new column.
routed_model=conv.model_override if routing_decision_id is not None else None,
routing_decision_id=routing_decision_id,
)
@@ -8194,8 +8378,8 @@ async def _handle_advise_models_mcp(
"""
Server-side handler for ``sys_advise_models`` MCP tool calls.
Intercepts the call before the runner forward because
``RuntimeCaps.routing_client`` lives in the server process.
Intercepts the call before the runner forward because the deployment's
routing backends live in the server process.
:param rpc_id: The JSON-RPC request id.
:param conv: The :class:`Conversation` for this session.
@@ -8209,13 +8393,15 @@ async def _handle_advise_models_mcp(
rpc_id, json.dumps({"error": "tasks must be a list", "router_on": False})
)
from omnigent.server.routing_backend import backends_from_caps
caps = get_caps()
routing_client = caps.routing_client
routing_client = backends_from_caps(caps).any()
if routing_client is None:
return _mcp_tool_result(rpc_id, json.dumps({"router_on": False, "recommendations": []}))
from omnigent.model_catalog import spec_harness
from omnigent.server.smart_routing import fetch_runner_models
from omnigent.server.smart_routing import _WORKER_NAME_TO_HARNESS, fetch_runner_models
# Fetch live model catalog from the runner once; used below to populate
# per-agent model lists when the caller omits explicit models.
@@ -8247,12 +8433,6 @@ async def _handle_advise_models_mcp(
"_handle_advise_models_mcp: failed to load spec for agent=%s", conv.agent_id
)
_WORKER_HARNESS: dict[str, str] = {
"claude_code": "claude-sdk",
"codex": "codex",
"pi": "pi",
}
def _resolve_harness_for_worker(agent: str) -> str | None:
if spec is not None:
sub_agents = getattr(spec, "sub_agents", None) or []
@@ -8262,7 +8442,7 @@ async def _handle_advise_models_mcp(
if h:
return h
break
return _WORKER_HARNESS.get(agent)
return _WORKER_NAME_TO_HARNESS.get(agent)
recommendations: list[dict[str, Any]] = []
for task in tasks:
@@ -8954,6 +9134,8 @@ __all__ = [
"_validated_cost_control_mode_override",
"_validated_harness_override",
"_validated_harness_override_executor_type",
"_validated_spec_smart_routing_harness",
"_validated_subagent_routing_override",
"_wait_for_managed_runner_tunnel",
"_wait_for_runner_client",
"announce_hosts_changed",
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -262,6 +262,10 @@ def create_host_tunnel_router(
frame,
owner=tunnel_owner,
)
# Delivered on the handshake, never persisted: a replica that just
# started learns the host's gateway backing here, so a server
# restart converges as soon as each host reconnects.
host_registry.record_gateway_inference(host_id, frame.gateway_inference)
_logger.info(
"Host %s connected (version=%s, name=%s, runners=%s)",
host_id,
@@ -419,7 +423,9 @@ async def _receive_loop(
:param host_id: Host id for logging.
:param host_store: Persistent store receiving live readiness updates.
:param host_registry: Live host registry, so a frame only refreshes
liveness while ``conn`` is still the registered generation.
liveness while ``conn`` is still the registered generation; it also
receives the reported gateway-inference map (held in memory, never
persisted).
:param runner_exit_reports: Store for ``host.runner_exited``
reports; ``None`` drops them.
:param on_runner_exited: Callback fired with ``(runner_id, error)``
@@ -484,6 +490,10 @@ async def _receive_loop(
frame.configured_harnesses,
)
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
)
host_registry.record_gateway_inference(host_id, frame.gateway_inference)
if on_host_update is not None:
try:
await on_host_update(host_id, conn.owner)
@@ -628,6 +638,7 @@ async def _receive_loop(
{
"status": frame.status,
"configured_harnesses": frame.configured_harnesses,
"gateway_inference": frame.gateway_inference,
"error": frame.error,
}
)
@@ -640,6 +651,7 @@ async def _receive_loop(
{
"status": frame.status,
"configured_harnesses": frame.configured_harnesses,
"gateway_inference": frame.gateway_inference,
"error": frame.error,
}
)
@@ -672,6 +684,7 @@ async def _receive_loop(
{
"status": frame.status,
"models": frame.models,
"routable_models": frame.routable_models,
"error": frame.error,
}
)
+71 -35
View File
@@ -36,9 +36,9 @@ from omnigent.host.frames import (
HostInstallHarnessFrame,
HostLaunchRunnerFrame,
HostListDirFrame,
HostModelOptionsFrame,
HostStoreSecretFrame,
encode_host_frame,
optional_str_bool_map,
)
from omnigent.onboarding.harness_install import (
ui_credential_configurable_harnesses,
@@ -93,33 +93,28 @@ async def _proxy_model_options(
harness: str,
) -> dict[str, Any]:
"""Ask a host for the model catalog it would use for a new session."""
request_id = secrets.token_hex(8)
loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
host_conn.pending_model_options[request_id] = future
frame = encode_host_frame(
HostModelOptionsFrame(request_id=request_id, harness=harness),
)
from omnigent.server.routes._host_model_options import request_host_model_options
try:
try:
host_registry.send_text(host_conn, frame)
except ConnectionError as exc:
raise HTTPException(
status_code=502,
detail=f"host '{host_conn.host_id}' connection lost",
) from exc
try:
return await asyncio.wait_for(future, timeout=_MODEL_OPTIONS_TIMEOUT_S)
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=504,
detail=(
f"host '{host_conn.host_id}' did not resolve model options within "
f"{_MODEL_OPTIONS_TIMEOUT_S:.0f}s"
),
) from exc
finally:
host_conn.pending_model_options.pop(request_id, None)
return await request_host_model_options(
host_registry=host_registry,
host_conn=host_conn,
harness=harness,
timeout_s=_MODEL_OPTIONS_TIMEOUT_S,
)
except ConnectionError as exc:
raise HTTPException(
status_code=502,
detail=f"host '{host_conn.host_id}' connection lost",
) from exc
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=504,
detail=(
f"host '{host_conn.host_id}' did not resolve model options within "
f"{_MODEL_OPTIONS_TIMEOUT_S:.0f}s"
),
) from exc
async def _proxy_list_dir(
@@ -274,7 +269,9 @@ async def _proxy_install_harness(
:param harness: The UI harness identifier to install, e.g. ``"claude"``.
:returns: Dict with the result fields: ``status`` (``"ok"`` /
``"failed"``), ``configured_harnesses`` (the refreshed readiness map or
``None``), ``error`` (string or ``None``).
``None``), ``gateway_inference`` (the refreshed per-harness
AI-Gateway-backed inference map or ``None``), ``error`` (string or
``None``).
:raises HTTPException: 504 on timeout, 502 on connection drop.
"""
request_id = secrets.token_hex(8)
@@ -334,7 +331,8 @@ async def _proxy_store_secret(
:param host_registry: Server-side registry; used to enqueue the frame.
:param host_conn: Live host connection.
:param frame: The store-secret frame to forward (carries the secret).
:returns: Dict with ``status`` / ``configured_harnesses`` / ``error``.
:returns: Dict with ``status`` / ``configured_harnesses`` /
``gateway_inference`` / ``error``.
:raises HTTPException: 504 on timeout, 502 on connection drop.
"""
request_id = frame.request_id
@@ -563,7 +561,10 @@ def create_hosts_router(
information for online hosts.
:param request: The incoming request (for auth).
:returns: ``{"hosts": [...]}`` with host details.
:returns: ``{"hosts": [...]}`` with host details ``host_id``,
``name``, ``owner``, ``status``, ``sandbox_provider``,
``configured_harnesses``, and ``gateway_inference`` (``None`` when
no connected host has reported it to this replica).
"""
# require_user: unauthenticated callers 401. user_id is None
# only when auth is disabled entirely — there the single-user
@@ -601,6 +602,11 @@ def create_hosts_router(
# user-connectable machines.
"sandbox_provider": host.sandbox_provider,
"configured_harnesses": host.configured_harnesses,
# Held in memory from the host's connect handshake, not the
# hosts row. ``None`` means this replica has no report yet —
# emitted as-is so a client can tell "unknown" from "not
# gateway-backed".
"gateway_inference": host_registry.gateway_inference(host.host_id),
}
)
return {"hosts": result}
@@ -612,7 +618,8 @@ def create_hosts_router(
:param request: The incoming request (for auth).
:param host_id: Host identifier, e.g.
``"host_a1b2c3d4..."``.
:returns: Host details dict.
:returns: Host details dict the ``list_hosts`` fields (including
``gateway_inference``, ``None`` when unreported) plus ``runners``.
:raises HTTPException: 404 if the host does not exist.
"""
# require_user: with an auth provider configured, an
@@ -639,6 +646,9 @@ def create_hosts_router(
# server-managed sandbox host (e.g. "modal").
"sandbox_provider": host.sandbox_provider,
"configured_harnesses": host.configured_harnesses,
# Same semantics as list_hosts: reported on connect and held in
# memory, so ``None`` is "no report on this replica yet".
"gateway_inference": host_registry.gateway_inference(host.host_id),
"runners": [],
}
@@ -1231,8 +1241,10 @@ def create_hosts_router(
:param host_id: Host identifier, e.g. ``"host_a1b2c3d4..."``.
:param harness: Harness identifier to install, e.g. ``"claude"``.
:returns: ``{"object": "harness_install", "harness": ...,
"configured_harnesses": {...}}`` the host's refreshed readiness
map so the UI can flip the badge without a reconnect.
"configured_harnesses": {...}, "gateway_inference": {...} | None}``
the host's refreshed readiness map so the UI can flip the badge
without a reconnect, plus its refreshed gateway-inference map
(``None`` when the host didn't report one).
:raises HTTPException: 404 when the feature is disabled or the host is
unknown, 400 when the harness is not UI-installable, 403 when the
caller is not the host owner, 409 when the host is offline, 502 on
@@ -1299,10 +1311,22 @@ def create_hosts_router(
detail=f"host install failed: {result.get('error') or 'unknown error'}",
)
# An install can flip gateway backing (a freshly installed CLI now
# resolves the workspace gateway), so take the map the host just
# recomputed instead of waiting for its next readiness push.
# Decoded through the same tolerant reader the tunnel path uses: this
# is a host-supplied reply body, so a non-mapping is "unknown", not a
# 500 out of ``dict(...)``.
installed_gateway = optional_str_bool_map(result, "gateway_inference")
if installed_gateway is not None:
host_registry.record_gateway_inference(host.host_id, installed_gateway)
return {
"object": "harness_install",
"harness": harness,
"configured_harnesses": result.get("configured_harnesses") or {},
# Passed through as-is: ``None`` is "unknown", not "none backed".
"gateway_inference": installed_gateway,
}
@router.post("/hosts/{host_id}/harnesses/{harness}/credential")
@@ -1333,8 +1357,10 @@ def create_hosts_router(
:param harness: Harness being configured, e.g. ``"claude"``.
:param body: The credential payload (kind + secret / gateway / adopt).
:returns: ``{"object": "harness_credential", "harness": ...,
"configured_harnesses": {...}}`` refreshed readiness so the UI can
flip the badge without a reconnect.
"configured_harnesses": {...}, "gateway_inference": {...} | None}``
refreshed readiness so the UI can flip the badge without a
reconnect, plus the refreshed gateway-inference map (``None`` when
the host didn't report one).
:raises HTTPException: 404 when disabled or host unknown, 400 when the
harness isn't UI-configurable or the body is invalid, 403 when not
the owner, 409 when offline, 502 on host-side failure, 504 on
@@ -1398,10 +1424,20 @@ def create_hosts_router(
detail=f"host credential write failed: {result.get('error') or 'unknown error'}",
)
# Pointing a family at the workspace gateway is exactly what this write
# does, so record the recomputed map now rather than on the host's next
# readiness push.
# Same tolerant decode as the install route above.
written_gateway = optional_str_bool_map(result, "gateway_inference")
if written_gateway is not None:
host_registry.record_gateway_inference(host.host_id, written_gateway)
return {
"object": "harness_credential",
"harness": harness,
"configured_harnesses": result.get("configured_harnesses") or {},
# Passed through as-is: ``None`` is "unknown", not "none backed".
"gateway_inference": written_gateway,
}
@router.get("/hosts/{host_id}/credentials/detected")
+37 -2
View File
@@ -109,6 +109,7 @@ from omnigent.server.routes._sessions.common import (
set_server_runner_router,
)
from omnigent.server.routes._sessions.helpers import (
_TUI_INJECT_FORWARD_TIMEOUT_S,
SessionLiveness,
_agent_carries_cursor_fork_history,
_agent_carries_native_fork_history,
@@ -137,9 +138,11 @@ from omnigent.server.routes._sessions.helpers import (
_same_provider_family,
_session_status_from_cache,
_set_read_state,
_surface_model_change_forward_failure,
_title_content_from_item,
_validate_terminal_launch_args,
_validated_cost_control_mode_override,
_validated_subagent_routing_override,
)
from omnigent.server.routes._sessions.orchestration import (
_best_effort_stop,
@@ -1645,6 +1648,17 @@ def register_core_routes(
body.cost_control_mode_override
)
# Same presence-is-the-clear-signal rule for the subagent-routing
# switch: an explicit null returns the session to inheriting its
# main routing state.
clear_subagent_routing = (
"subagent_routing_override" in body.model_fields_set
and body.subagent_routing_override is None
)
subagent_routing_override = _validated_subagent_routing_override(
body.subagent_routing_override
)
# Native-terminal pass-through args: ``None`` leaves them
# unchanged; a provided list (including ``[]``) replaces the
# stored value wholesale (resume is last-write-wins, never an
@@ -1750,6 +1764,10 @@ def register_core_routes(
_unset_model_override=clear_model,
cost_control_mode_override=None if clear_cost_control else cost_control_mode_override,
_unset_cost_control_mode_override=clear_cost_control,
subagent_routing_override=(
None if clear_subagent_routing else subagent_routing_override
),
_unset_subagent_routing_override=clear_subagent_routing,
terminal_launch_args=terminal_launch_args,
archived=body.archived,
)
@@ -1792,12 +1810,19 @@ def register_core_routes(
session_id,
runner_router,
{"type": "effort_change", "effort": updated.reasoning_effort},
# Same TUI injection budget as the model change below: the
# ``/effort`` confirm dialog can render seconds after the
# command.
timeout_s=_TUI_INJECT_FORWARD_TIMEOUT_S,
)
if live_forward and (model_override is not None or clear_model):
await _forward_session_change_to_runner(
_model_forward = await _forward_session_change_to_runner(
session_id,
runner_router,
{"type": "model_change", "model": updated.model_override},
# The runner answers this by typing ``/model`` into the pane and
# confirming the dialog, which outlasts the default budget.
timeout_s=_TUI_INJECT_FORWARD_TIMEOUT_S,
)
# Append a durable [System: model changed to X] note for sessions
# whose history Omnigent writes. Gate on the wrapper label (NOT
@@ -1805,7 +1830,17 @@ def register_core_routes(
# polly/debby also carry) — see _persist_model_change_note for the
# full rationale. live_forward (== not silent) already excludes
# bind-time auto-applies, so only an explicit /model lands a note.
if not _is_native_terminal_session(updated):
if _is_native_terminal_session(updated):
# The injection is the only thing that moves a LIVE native
# pane's model, so a forward its runner refused must not pass as
# applied. A stopped session reaches no runner and stays quiet —
# its relaunch reads the override off the row.
_surface_model_change_forward_failure(
session_id,
updated.model_override,
_model_forward,
)
else:
await _persist_model_change_note(
session_id,
updated.model_override,
@@ -1490,6 +1490,9 @@ def register_events_routes(
author_attribution_required=(access.level is not None and access.level < LEVEL_OWNER),
runner_router=runner_router,
native_terminal_ready=native_terminal_ready,
# Read only for the gateway-backing check that decides which router
# serves this turn; absent, routing keeps its default posture.
host_store=getattr(request.app.state, "host_store", None),
)
if pending_background_title is not None:
pending_background_title.schedule()
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import json
from typing import Any, NamedTuple
@@ -58,6 +59,7 @@ from omnigent.server.routes._content_type import (
from omnigent.server.routes._sessions.common import (
_EVALUATE_HOOK_ELICITATION_ID_RE,
_TURN_ACTOR_LABEL,
_logger,
get_server_runner_router,
set_server_runner_router,
)
@@ -68,7 +70,9 @@ from omnigent.server.routes._sessions.helpers import (
_build_evaluation_context,
_claude_native_remember_host,
_client_supplied_hook_elicitation_id,
_emit_server_routing_decision,
_forward_session_change_to_runner,
_get_runner_client,
_native_ask_gate_lock,
_publish_policy_denied,
_structured_ask_user_question,
@@ -76,6 +80,7 @@ from omnigent.server.routes._sessions.helpers import (
from omnigent.server.routes._sessions.orchestration import (
_hold_native_ask_gate,
_publish_and_wait_for_harness_elicitation,
_spawn_gateway_backed,
_spawn_native_blocked_notice_forward,
)
from omnigent.server.schemas import (
@@ -1292,3 +1297,328 @@ def register_hooks_routes(
content=json.dumps(result.model_dump(exclude_none=True)),
media_type="application/json",
)
async def _route_subagent_catalog(session_id: str) -> dict[str, list[str]] | None:
"""
Fetch the session's live model catalog for subagent routing.
:param session_id: Parent session/conversation id.
:returns: Worker servable model ids, or ``None`` when the runner
is unreachable (callers fall back to the static table).
"""
from omnigent.server.smart_routing import fetch_runner_models
try:
runner_client = await _get_runner_client(
session_id, runner_router or get_server_runner_router()
)
if runner_client is None:
return None
return await fetch_runner_models(session_id, runner_client)
except Exception:
_logger.debug(
"route-subagent: live catalog unavailable for session=%s",
session_id,
exc_info=True,
)
return None
@router.post(
"/sessions/{session_id}/hooks/route-subagent",
# Internal runner relay — hidden from the public API reference.
include_in_schema=False,
response_model=None,
dependencies=[Depends(require_json_content_type)],
)
async def route_subagent_hook(
request: Request,
session_id: str,
) -> Response:
"""
Decide the model/harness a native subagent spawn may use.
The runner's loopback router (advertised to harness
``PreToolUse`` hooks via ``subagent_router.json``) relays here
because ``RuntimeCaps.routing_client`` only lives in the server
process. Request and response follow the frozen route-subagent
contract; every routed verdict also lands as a
``routing_decision`` transcript item.
The session's subagent-routing switch is two-state and re-read on
every call (it is togglable mid-session): only an explicit ``"on"``
routes, and every other session gets its spawn allowed unchanged
without calling the router. Sessions that start on Smart Routing
are stamped ``"on"`` at create, so nothing is inherited here.
Candidate models stay inside the session's own harness family
unless the session started in auto-harness mode.
:param request: FastAPI request body is the route-subagent
request JSON.
:param session_id: Parent session/conversation id from the path.
:returns: The route-subagent decision as JSON.
:raises OmnigentError: 400 when the body is not a JSON object or
omits ``harness``.
"""
from omnigent.runner.subagent_routing import (
SubagentRouteDecision,
SubagentRouteRequest,
auto_harness_session,
resolve_subagent_route,
store_persister,
subagent_routing_enabled,
)
from omnigent.server.smart_routing import AUTO_NATIVE_ROUTING_HARNESSES
user_id = _get_user_id(request, auth_provider)
# LEVEL_EDIT, like POST /events: a routed verdict mutates the session
# (a persisted ``routing_decision`` item, and on the sibling route-turn
# relay a ``model_override`` pin). A read-only viewer must not be able
# to steer somebody else's spawns.
await _require_access(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
try:
payload = await request.json()
except json.JSONDecodeError as exc:
raise OmnigentError(
f"Invalid JSON in route-subagent body: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if not isinstance(payload, dict):
raise OmnigentError(
"route-subagent body must be a JSON object.",
code=ErrorCode.INVALID_INPUT,
)
try:
route_request = SubagentRouteRequest.from_payload(payload)
except ValueError as exc:
raise OmnigentError(str(exc), code=ErrorCode.INVALID_INPUT) from exc
# A relayed spawn is evidence the harness ran its routing hook, but it
# is deliberately NOT turned into a clear here: the same warning code
# also carries the spawn-audit verdict ("started on a model the router
# never approved"), which a relay does not disprove — and every spawn
# that produces such a verdict is itself relayed, so clearing here
# wiped exactly the warnings the publisher had just raised (it only
# re-posts on a transition, so the wipe was permanent). The publisher
# owns the clear: its next check sees the canary and posts the repair.
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
parent = None
if conv is not None and conv.parent_conversation_id is not None:
parent = await asyncio.to_thread(
conversation_store.get_conversation, conv.parent_conversation_id
)
if conv is None or not subagent_routing_enabled(conv.subagent_routing_override):
# Allowed unchanged, and deliberately not persisted: an
# unrouted spawn is not a decision worth a transcript item.
_logger.info(
"route-subagent: subagent routing disabled for session=%s harness=%s",
session_id,
route_request.harness,
)
unrouted = SubagentRouteDecision(
action="allow",
rationale="subagent routing disabled for this session",
)
return Response(
content=json.dumps(unrouted.to_payload()),
media_type="application/json",
)
# Only a session started in auto-harness mode may be moved across
# harness families; everyone else is offered their own family, so a
# Claude Code session never gets a Codex suggestion.
cross_harness = auto_harness_session(conv, parent)
# Which families the spawn may land on decides which router can serve it:
# off the AI Gateway the built-in judge answers, from the live catalog
# alone (the static table's databricks-* ids are unreachable there).
gateway_backed = await _spawn_gateway_backed(
request,
conv,
(AUTO_NATIVE_ROUTING_HARNESSES if cross_harness else (route_request.harness,)),
)
# Offer the live catalog: the static table lags model generations, and
# a pick the workspace serves must not look unservable and get
# substituted down a tier.
catalog = await _route_subagent_catalog(session_id)
decision = await resolve_subagent_route(
session_id,
route_request,
caps=get_caps(),
catalog=catalog,
cross_harness=cross_harness,
gateway_backed=gateway_backed,
allow_static_fallback=gateway_backed,
persist=store_persister(session_id, conversation_store),
)
return Response(
content=json.dumps(decision.to_payload()),
media_type="application/json",
)
@router.post(
"/sessions/{session_id}/hooks/route-turn",
# Internal runner relay — hidden from the public API reference.
include_in_schema=False,
response_model=None,
dependencies=[Depends(require_json_content_type)],
)
async def route_turn_hook(
request: Request,
session_id: str,
) -> Response:
"""
Decide the model a session's first real prompt should run on.
The in-harness sibling of ``route-subagent``: a harness
``UserPromptSubmit`` hook relays here (through the runner's
loopback endpoint, advertised as ``turn_router.json``) because
``RuntimeCaps.routing_client`` only lives in the server process.
It closes the bare-launch gap a session started with no prompt,
whose first message is typed straight into the TUI and so is
invisible to the composer turn gate. Everything else about routing
is unchanged: same decision seam, same chip, same
``model_override`` pin, and that pin is what stops a session from
ever routing twice.
:param request: FastAPI request body is the route-turn request
JSON.
:param session_id: Session/conversation id from the path.
:returns: The route-turn decision as JSON.
:raises OmnigentError: 400 when the body is not a JSON object or
omits ``harness`` / ``prompt``.
"""
from omnigent.runner.turn_routing import (
TurnRouteRequest,
decision_scope,
resolve_turn_route,
)
from omnigent.server.routes._sessions.orchestration import (
_native_turn_catalog,
_publish_routed_model,
_stamp_routing_decision_label,
)
from omnigent.server.smart_routing import route_turn as _route_turn_seam
user_id = _get_user_id(request, auth_provider)
# LEVEL_EDIT, like POST /events: this route writes ``model_override``
# for the rest of the session and persists a decision item. LEVEL_READ
# let a read-only viewer repin somebody else's model.
await _require_access(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
try:
payload = await request.json()
except json.JSONDecodeError as exc:
raise OmnigentError(
f"Invalid JSON in route-turn body: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if not isinstance(payload, dict):
raise OmnigentError(
"route-turn body must be a JSON object.",
code=ErrorCode.INVALID_INPUT,
)
try:
route_request = TurnRouteRequest.from_payload(payload)
except ValueError as exc:
raise OmnigentError(str(exc), code=ErrorCode.INVALID_INPUT) from exc
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
parent = None
if conv is not None and conv.parent_conversation_id is not None:
parent = await asyncio.to_thread(
conversation_store.get_conversation, conv.parent_conversation_id
)
runner_client = None
catalog = None
if conv is not None:
try:
runner_client = await _get_runner_client(
session_id, runner_router or get_server_runner_router()
)
catalog = await _native_turn_catalog(session_id, conv, runner_client)
except Exception:
_logger.debug(
"route-turn: live catalog unavailable for session=%s",
session_id,
exc_info=True,
)
# This pane's own family decides which router can serve its first turn.
# A create off the AI Gateway now succeeds (the built-in judge answers),
# so this hook must make the same choice the composer path does.
turn_gateway_backed = (
await _spawn_gateway_backed(request, conv, (route_request.harness,))
if conv is not None
else True
)
async def _route(
harness: str | None, prompt: str
) -> tuple[str | None, dict[str, Any] | None]:
return await _route_turn_seam(
harness,
prompt,
session_id=session_id,
runner_client=runner_client,
catalog=catalog,
gateway_backed=turn_gateway_backed,
allow_static_fallback=turn_gateway_backed,
)
async def _pin(model: str) -> bool:
try:
await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
model_override=model,
)
except (OSError, ValueError):
_logger.warning(
"route-turn: could not pin model_override for session=%s",
session_id,
exc_info=True,
)
return False
_publish_routed_model(session_id, model)
return True
async def _persist(model: str, verdict: dict[str, Any]) -> None:
decision_id = await _emit_server_routing_decision(
session_id,
conversation_store,
model,
verdict,
scope=decision_scope(),
harness=route_request.harness,
)
await _stamp_routing_decision_label(session_id, conversation_store, decision_id)
decision = await resolve_turn_route(
session_id,
route_request,
conv=conv,
parent=parent,
route_turn=_route,
pin=_pin,
persist=_persist,
)
_logger.info(
"route-turn: session=%s harness=%s live_model=%s pinned=%s action=%s model=%s",
session_id,
route_request.harness,
route_request.model,
conv.model_override if conv is not None else None,
decision.action,
decision.model,
)
# The rationale paraphrases the user's prompt, so it stays off INFO —
# the same invariant ``omnigent.server.smart_routing`` keeps at each of
# its own three log sites.
_logger.debug("route-turn: session=%s rationale=%s", session_id, decision.rationale)
return Response(
content=json.dumps(decision.to_payload()),
media_type="application/json",
)
+198
View File
@@ -0,0 +1,198 @@
"""Which router answers a routing call — the AI Gateway's, or the built-in judge's.
Smart Routing has two possible backends. The external ``task_v1`` client rewrites
a launch's model to a Databricks AI Gateway catalog id, so it can only serve a
harness whose inference is gateway-backed. The built-in OSS judge
(:class:`~omnigent.server.smart_routing.LLMRoutingClient`) names models from the
candidate menu it is handed, so it serves any harness.
Gateway backing therefore selects the SOURCE rather than hiding the surface: an
off-gateway pane still routes, just with the built-in judge, and the decision
records which router answered so the UI can say so.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from collections.abc import Iterable
from omnigent.server.smart_routing import RoutingClient
#: Which router produced a decision. ``"databricks-aigw"`` is the external
#: ``task_v1`` service; ``"oss-llm"`` is the built-in judge.
RouterSource = Literal["databricks-aigw", "oss-llm"]
@dataclass(frozen=True)
class RoutingBackends:
"""The routing clients this deployment has, by source.
Both may be configured at once that is the normal Databricks posture, and
it is what lets an off-gateway pane fall back to the judge instead of losing
Smart Routing.
:param external: The external ``routes:select`` client, or ``None``.
:param local: The built-in judge, or ``None``.
"""
external: RoutingClient | None = None
local: RoutingClient | None = None
def any(self) -> RoutingClient | None:
"""The client to use when nothing is known about gateway backing.
The external client is preferred: it is the richer router, and every
legacy consumer that reads a single ``routing_client`` off the caps
expects the deployment's primary.
:returns: The external client, else the local one, else ``None``.
"""
return self.external or self.local
@dataclass(frozen=True)
class RouterChoice:
"""A selected router and the source name to stamp on its decision.
:param client: The routing client to call.
:param source: Which source *client* is, e.g. ``"databricks-aigw"``.
"""
client: RoutingClient
source: RouterSource
def select_router(
backends: RoutingBackends,
*,
gateway_backed: bool,
) -> RouterChoice | None:
"""Pick the router that can actually serve this call.
:param backends: The deployment's configured clients.
:param gateway_backed: Whether EVERY harness family involved in this call
resolves AI-Gateway-backed inference. False takes the external client
out of play its picks are gateway catalog ids the pane cannot reach.
:returns: The chosen client plus its source, or ``None`` when neither
backend can serve (today's "routing unavailable").
"""
if gateway_backed and backends.external is not None:
return RouterChoice(client=backends.external, source="databricks-aigw")
if backends.local is not None:
return RouterChoice(client=backends.local, source="oss-llm")
return None
def reported_gateway_inference(
host: Any, # type: ignore[explicit-any] # a Host row, or None for a sandbox
) -> dict[str, bool] | None:
"""The gateway-inference map *host* last reported over its tunnel.
Read from this replica's live :class:`~omnigent.server.host_registry.HostRegistry`,
which the host fills on its connect handshake nothing about gateway
backing is persisted, so a replica that has not seen the host (a fresh
process, another replica) simply reads unknown until it reconnects and
re-reports.
:param host: The session's target host row, or ``None``.
:returns: The reported map, or ``None`` when nothing has been reported here.
"""
host_id = getattr(host, "host_id", None) if host is not None else None
if not isinstance(host_id, str):
return None
from omnigent.server.routes._sessions.common import get_server_host_registry
registry = get_server_host_registry()
if registry is None:
return None
return registry.gateway_inference(host_id)
def gateway_backs_all(
host: Any, # type: ignore[explicit-any] # a Host row, or None for a sandbox
harnesses: Iterable[str],
) -> bool:
"""Whether *host* backs every one of *harnesses* with the workspace AI gateway.
Unknown reads as backed: a host that reports nothing, an older build, one
bound to another replica, or none bound at all all land here, and
withholding the external router there would downgrade every deployment that
cannot yet answer.
:param host: The session's target host, or ``None``.
:param harnesses: Harness ids to check, e.g. ``("claude-native",)``.
:returns: ``True`` unless the host explicitly reports one as not backed.
"""
from omnigent.gateway_inference import not_gateway_backed
return not not_gateway_backed(reported_gateway_inference(host), harnesses)
def backends_from_caps(caps: Any) -> RoutingBackends: # type: ignore[explicit-any] # RuntimeCaps-shaped
"""Read a deployment's routing backends off its caps.
Prefers the explicit :attr:`~omnigent.runtime.caps.RuntimeCaps.routing_backends`.
Absent it, the legacy single ``routing_client`` is classified by type an
:class:`~omnigent.server.smart_routing.ExternalRoutingClient` is the external
source, anything else is treated as the OSS judge. Misclassifying a custom
client as OSS costs only a badge on the chip, whereas the reverse would
promise gateway reachability nobody verified.
:param caps: A ``RuntimeCaps`` (or structural equivalent), or ``None``.
:returns: The configured backends; all-``None`` when nothing is set.
"""
if caps is None:
return RoutingBackends()
backends = getattr(caps, "routing_backends", None)
if isinstance(backends, RoutingBackends):
return backends
client = getattr(caps, "routing_client", None)
if client is None:
return RoutingBackends()
from omnigent.server.smart_routing import ExternalRoutingClient
if isinstance(client, ExternalRoutingClient):
return RoutingBackends(external=client)
return RoutingBackends(local=client)
def _managed_llm_capability(caps: Any) -> bool: # type: ignore[explicit-any] # RuntimeCaps-shaped
"""Whether a managed deployment registered its policy-LLM factory.
The factory means the deployment has LLM capability and supplies its own
:class:`~omnigent.server.smart_routing.RoutingClient` later, so routing is
available even though no client is on the caps yet.
"""
return caps is not None and getattr(caps, "policy_llm_connection_factory", None) is not None
def routing_sources(caps: Any) -> dict[str, bool]: # type: ignore[explicit-any] # RuntimeCaps
"""Report which routers this deployment can answer a routing call with.
:param caps: A ``RuntimeCaps`` (or structural equivalent), or ``None``.
:returns: ``{"external": ..., "oss": ...}`` ``"external"`` is the
workspace AI-Gateway ``task_v1`` client, ``"oss"`` the built-in judge
(or a managed factory that will supply one).
"""
backends = backends_from_caps(caps)
return {
"external": backends.external is not None,
"oss": backends.local is not None or _managed_llm_capability(caps),
}
def routing_available(caps: Any) -> bool: # type: ignore[explicit-any] # RuntimeCaps-shaped
"""Whether this deployment can route at all, from ANY source.
The single "is routing configured" gate. It is exactly "some source can
answer", so it can never disagree with :func:`routing_sources` — the drift
that let a deployment configuring only ``routing_backends`` report routing
off while the server routed anyway.
:param caps: A ``RuntimeCaps`` (or structural equivalent), or ``None``.
:returns: ``True`` when at least one router can answer.
"""
return any(routing_sources(caps).values())
+47
View File
@@ -792,6 +792,16 @@ class ChildSessionSummary(BaseModel):
a fanned-out sub-agent that needs attention is visible
without opening its chat. Mirrors
:attr:`SessionListItem.pending_elicitations_count`.
:param routed_model: Model this sub-agent runs on when one was pinned
for it, e.g. ``"databricks-claude-opus-4-8"``. Read from the
child's ``model_override`` — the field intelligent routing writes
when it picks a model for a spawned child. ``None`` when the child
inherits the parent/spec model.
:param routing_decision_id: Identifier of the routing decision that
produced :attr:`routed_model`, mirroring
``RoutingDecisionData.decision_id``. Read from the child's
``omnigent.routing.decision_id`` label, stamped when routing pins
the model. ``None`` when the child was not routed.
"""
id: str
@@ -812,6 +822,8 @@ class ChildSessionSummary(BaseModel):
last_task_error: dict[str, str] | None = None
last_message_preview: str | None = None
pending_elicitations_count: int = 0
routed_model: str | None = None
routing_decision_id: str | None = None
# ── Responses ───────────────────────────────────────────────────
@@ -1322,6 +1334,13 @@ class SessionCreateRequest(BaseModel):
default) defers to the spec default. Set by the web UI's
new-session "Cost Optimized" option; read by the cost-control
advisor pipeline at turn start.
:param subagent_routing_override: Optional per-session
subagent-routing switch to persist at create time: ``"on"``
routes subagent spawns, ``"off"`` leaves them unrouted.
``None`` (the default) lets the create route stamp ``"on"``
when the session starts on Smart Routing, and otherwise leaves
the session on Default. An explicit value always wins. Mutable
mid-session via ``PATCH /v1/sessions/{id}``.
:param harness_override: Optional per-session brain-harness
override to persist at create time, e.g. ``"pi"`` or
``"openai-agents"``. Set by the web UI's new-chat harness
@@ -1333,6 +1352,15 @@ class SessionCreateRequest(BaseModel):
the spec's declared harness. Create-time only — there is no
PATCH path, since the harness process spawns on the first
turn.
:param smart_routing_message: The user's first-message text, used to
route the harness at create time. Only read on the top-level
Smart Routing path (``harness_override: "auto"`` on a native
wrapper agent), where the terminal launches as soon as the
session row exists and so the harness must be decided before it.
Routing-only: not persisted or dispatched the client sends the
real message after the create returns. ``None`` everywhere else,
including the bundle-agent auto path, which routes on the first
message event instead.
"""
agent_id: str
@@ -1349,7 +1377,9 @@ class SessionCreateRequest(BaseModel):
model_override: str | None = None
reasoning_effort: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
harness_override: str | None = None
smart_routing_message: str | None = None
@model_validator(mode="after")
def _check_git_requires_host(self) -> SessionCreateRequest:
@@ -1692,6 +1722,13 @@ class SessionResponse(BaseModel):
applies). Set at create time or via
``PATCH /v1/sessions/{id}`` (the web "Cost Optimized"
toggle); read by the cost-control advisor pipeline.
:param subagent_routing_override: Per-session subagent-routing
switch, two-state: ``"on"`` routes subagent spawns, and ``"off"``
or ``None`` (unset) both leave them unrouted the in-session
"Subagent routing" row renders either as "Default". ``None`` on
a row created before this became explicit inherits nothing.
Stamped ``"on"`` at create for Smart Routing sessions; also set
via ``PATCH /v1/sessions/{id}``.
:param context_window: The model's context window size in tokens
as looked up server-side from litellm's registry (or from the
``AP_CONTEXT_WINDOW_OVERRIDE`` env var), e.g. ``200_000``.
@@ -1847,6 +1884,7 @@ class SessionResponse(BaseModel):
harness: str | None = None
model_override: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
context_window: int | None = None
last_total_tokens: int | None = None
total_cost_usd: float | None = None
@@ -1926,6 +1964,14 @@ class UpdateSessionRequest(BaseModel):
default; omitting the field leaves it unchanged (``"off"`` is
a real value here, so the field's *presence* — not a clear
alias is the clear signal, unlike ``model_override``).
:param subagent_routing_override: Per-session subagent-routing
switch: ``"on"`` routes subagent spawns, ``"off"`` leaves them
unrouted. Explicit JSON ``null`` clears the override, which lands
the session on Default (the same behavior as ``"off"`` nothing
is inherited); omitting the field leaves it unchanged (same
presence-is-the-clear-signal rule as
``cost_control_mode_override``). Effective on the next spawn, so
it can be changed at any point in a session.
:param external_session_id: Runtime-native session id captured
by a wrapper bridge (e.g. Claude Code's session uuid for
``omnigent claude`` sessions). Idempotent on same-value
@@ -1969,6 +2015,7 @@ class UpdateSessionRequest(BaseModel):
model_override: str | None = None
collaboration_mode: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
external_session_id: str | None = None
terminal_launch_args: list[str] | None = None
archived: bool | None = None
File diff suppressed because it is too large Load Diff
+494
View File
@@ -0,0 +1,494 @@
"""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.
A harness that routes its own first typed message needs no prompt up front:
the create then only turns Smart Routing on for the session (no
``smart_routing_message``), and the in-harness hook picks the model when the
user types.
Two rules shape everything here:
* **Preflight is a hard error.** Routing that no source on the server can
serve means the pick could not be applied say so and stop. A family
whose inference is not AI-Gateway-backed is not that case: it downgrades to the server's built-in
router, and only fails when there is no built-in router either.
* **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"}
#: Budget for the routed ``POST /v1/sessions``. Generous on the read because
#: this is a session CREATE, not a routing call: it validates the workspace on
#: the host, may cut a worktree, and resolves a pre-launch model catalog. A
#: short budget here would abandon creates that were merely slow and drop the
#: user into an unrouted session for no good reason.
_TIMEOUT = httpx.Timeout(10.0, read=60.0)
#: Budget for the preflight reads (``/v1/info``, ``/v1/hosts``). These ARE
#: routing calls, they answer in milliseconds on a healthy server, and every
#: failure already degrades to "unknown" — which does not gate — so there is
#: nothing to win by waiting. Keeping it short stops a wedged server from
#: stalling the launch before the session even exists.
_PREFLIGHT_TIMEOUT = httpx.Timeout(5.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 local_gateway_inference() -> dict[str, bool]:
"""
This machine's own per-harness AI-Gateway-backed map, or ``{}``.
A ``--smart-routing`` launch always runs its TUI on *this* machine, so the
local config resolution is the authoritative answer and it needs neither a
registered host row nor a server round-trip. Never raises: an unevaluable
map is "unknown", which does not gate.
:returns: Harness spelling gateway-backed flag, e.g.
``{"claude-native": True, "codex-native": False}``; ``{}`` when the
check could not run at all.
"""
from omnigent.gateway_inference import gateway_inference_map
try:
return gateway_inference_map()
except Exception: # noqa: BLE001 — an unevaluable map is unknown, not unavailable
return {}
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*.
Gate 1 is availability: the server must have at least one routing source
(``GET /v1/info`` ``smart_routing_sources``) the external AI-Gateway
router, the built-in one, or both. With neither, nothing can produce a pick.
Gates 2 and 3 are *source selection*, not availability. A family whose
inference is not AI-Gateway-backed cannot run a gateway-routed pick, so it
downgrades to the server's built-in router (one informational line, then
proceed) and only fails when the server has no built-in router either. The
answer comes from this machine's own config first
(:func:`local_gateway_inference`) the launch happens here, so the local
answer is authoritative and needs no host row and failing that from the
host row the server holds for this machine (``GET /v1/hosts``
``gateway_inference``), which is what an older CLI had to rely on.
An absent ``gateway_inference`` map or an absent entry in it is
*unknown*, not off-gateway: a family whose check could not run keeps every
option. An older server that omits ``smart_routing_sources`` degrades to its
``smart_routing_enabled`` answer for both sources, so it blocks nothing new.
: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 server-side 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")
sources = _routing_sources(info)
if not (sources["external"] or sources["oss"]):
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."
)
local = local_gateway_inference()
remote: dict[str, Any] | None = None
for harness in harnesses:
state = _gateway_state(local, harness)
if state is None:
# The local check said nothing about this family; fall back to the
# host row, which an older host build may still answer for.
if remote is None and host_id is not None:
remote = _gateway_inference_for_host(base_url=base_url, host_id=host_id) or {}
state = _gateway_state(remote or {}, harness)
if state is None or state is True:
continue
if sources["oss"]:
# Off the gateway the AI Gateway's router cannot be applied here,
# but the server's built-in one still answers.
click.echo(
f"{harness} is not AI-Gateway-backed on this host — routing with the "
"built-in router instead",
err=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 _routing_sources(info: dict[str, Any]) -> dict[str, bool]:
"""
Which routing sources the server can serve, from ``GET /v1/info``.
:param info: The decoded ``/v1/info`` payload, possibly ``{}``.
:returns: ``{"external": bool, "oss": bool}`` the AI-Gateway router and
the built-in one. A server that omits (or garbles) the field degrades to
its ``smart_routing_enabled`` answer for both: a server that can route
is assumed able to serve either source, so nothing new is blocked. The
web parser degrades the same way, so both surfaces read one older server
alike.
"""
enabled = info.get("smart_routing_enabled") is True
raw = info.get("smart_routing_sources")
if not isinstance(raw, dict):
return {"external": enabled, "oss": enabled}
return {"external": raw.get("external") is True, "oss": raw.get("oss") is True}
def create_smart_routing_session(
*,
base_url: str,
prompt: str | None,
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"``, the
``smart_routing_message`` (prompt-ful create only), 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.
Two modes, split by *prompt*:
* **With a prompt** the message triggers the create-time route, so the
response is expected to carry a model and a missing one earns a notice.
* **Without one** only the mode override is sent: Smart Routing is on for
the session, but nothing is routed yet the harness's own first-message
hook picks the model in-session, so an empty ``model_override`` is the
normal answer and carries no notice.
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. ``None`` creates a bare routed
session that routes nothing at create time.
: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",
}
if prompt is not None:
# The message is what routes at create time; a bare create only turns
# Smart Routing on and leaves the pick to the in-harness hook.
body["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")
# A bare create routes nothing yet, so an empty model is expected there and
# only a prompt-ful create that came back without one is worth reporting.
notice = (
None
if picked_model is not None or prompt is 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 — and a timeout is one
of those unreadable answers, which is why the budget is
:data:`_PREFLIGHT_TIMEOUT` rather than the create's.
: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=_PREFLIGHT_TIMEOUT
) as client:
resp = client.get(path)
if resp.status_code >= 400:
return {}
return _json_object(resp)
except httpx.HTTPError:
return {}
def _json_object(resp: httpx.Response) -> dict[str, Any]:
"""
Decode *resp* as a JSON object.
:param resp: The HTTP response.
:returns: The decoded object, or ``{}`` when the body is not one.
"""
try:
payload = resp.json()
except (json.JSONDecodeError, ValueError):
return {}
return payload if isinstance(payload, dict) else {}
def _clean_str(value: Any) -> str | None:
"""
Normalize a wire value to a non-empty string.
:param value: Raw JSON value, e.g. ``"codex-native"`` or ``None``.
:returns: The stripped string, or ``None`` when it is not usable.
"""
return value.strip() if isinstance(value, str) and value.strip() else None
+10 -1
View File
@@ -763,6 +763,8 @@ class ConversationStore(ABC):
_unset_model_override: bool = False,
cost_control_mode_override: str | None = None,
_unset_cost_control_mode_override: bool = False,
subagent_routing_override: str | None = None,
_unset_subagent_routing_override: bool = False,
harness_override: str | None = None,
_unset_harness_override: bool = False,
terminal_launch_args: list[str] | None = None,
@@ -772,7 +774,8 @@ class ConversationStore(ABC):
Update mutable fields on a conversation.
For ``reasoning_effort``, ``model_override``,
``cost_control_mode_override``, and ``harness_override``,
``cost_control_mode_override``, ``subagent_routing_override``,
and ``harness_override``,
``None`` means "leave unchanged". To explicitly clear them
back to ``None``, pass
the matching ``_unset_*`` flag.
@@ -796,6 +799,12 @@ class ConversationStore(ABC):
:param _unset_cost_control_mode_override: When ``True``, set
``cost_control_mode_override`` to ``None`` regardless of
the ``cost_control_mode_override`` param value.
:param subagent_routing_override: Per-session subagent-routing
switch, ``"on"`` or ``"off"``. ``None`` leaves unchanged.
:param _unset_subagent_routing_override: When ``True``, set
``subagent_routing_override`` to ``None`` regardless of the
``subagent_routing_override`` param value. Unset reads as
Default (the switch is two-state; nothing is inherited).
:param harness_override: Per-session brain-harness override,
e.g. ``"pi"``. ``None`` leaves unchanged. No ``_unset``
variant the override is set once at session create and
@@ -107,6 +107,7 @@ _SESSION_OVERRIDE_KEYS = (
"reasoning_effort",
"model_override",
"cost_control_mode_override",
"subagent_routing_override",
"harness_override",
)
@@ -116,7 +117,7 @@ def _encode_session_overrides(overrides: dict[str, str | None]) -> str | None:
Omits keys whose value is ``None`` and returns ``None`` when nothing is
set, so a session on all agent/spec defaults stores SQL ``NULL`` rather
than an empty object. Only the four :data:`_SESSION_OVERRIDE_KEYS` are
than an empty object. Only the :data:`_SESSION_OVERRIDE_KEYS` are
considered; any other keys in *overrides* are ignored.
:param overrides: Mapping of override key to value (missing / ``None``
@@ -132,7 +133,7 @@ def _encode_session_overrides(overrides: dict[str, str | None]) -> str | None:
def _decode_session_overrides(raw: str | None) -> dict[str, str | None]:
"""Unpack the ``session_overrides`` blob to a full override dict.
Every one of the four :data:`_SESSION_OVERRIDE_KEYS` is present in the
Every one of the :data:`_SESSION_OVERRIDE_KEYS` is present in the
result (unset keys read back as ``None``) so read-modify-write callers can
treat the dict uniformly regardless of which overrides were stored.
@@ -197,6 +198,7 @@ def _to_conversation(
reasoning_effort=overrides["reasoning_effort"],
model_override=overrides["model_override"],
cost_control_mode_override=overrides["cost_control_mode_override"],
subagent_routing_override=overrides["subagent_routing_override"],
harness_override=overrides["harness_override"],
sub_agent_name=meta.sub_agent_name if meta else None,
external_session_id=meta.external_session_id if meta else None,
@@ -2628,6 +2630,8 @@ class SqlAlchemyConversationStore(ConversationStore):
_unset_model_override: bool = False,
cost_control_mode_override: str | None = None,
_unset_cost_control_mode_override: bool = False,
subagent_routing_override: str | None = None,
_unset_subagent_routing_override: bool = False,
harness_override: str | None = None,
_unset_harness_override: bool = False,
terminal_launch_args: list[str] | None = None,
@@ -2651,6 +2655,11 @@ class SqlAlchemyConversationStore(ConversationStore):
switch, ``"on"`` or ``"off"``. ``None`` leaves unchanged.
:param _unset_cost_control_mode_override: When ``True``, clear
``cost_control_mode_override`` to ``None``.
:param subagent_routing_override: Per-session subagent-routing
switch, ``"on"`` or ``"off"``. ``None`` leaves unchanged.
:param _unset_subagent_routing_override: When ``True``, clear
``subagent_routing_override`` to ``None``, which reads as
Default (the switch is two-state; nothing is inherited).
:param harness_override: Per-session brain-harness override,
e.g. ``"pi"``. ``None`` leaves unchanged.
:param _unset_harness_override: When ``True``, clear
@@ -2700,6 +2709,12 @@ class SqlAlchemyConversationStore(ConversationStore):
elif cost_control_mode_override is not None:
overrides["cost_control_mode_override"] = cost_control_mode_override
overrides_changed = True
if _unset_subagent_routing_override:
overrides["subagent_routing_override"] = None
overrides_changed = True
elif subagent_routing_override is not None:
overrides["subagent_routing_override"] = subagent_routing_override
overrides_changed = True
if _unset_harness_override:
overrides["harness_override"] = None
overrides_changed = True
@@ -3479,7 +3494,8 @@ class SqlAlchemyConversationStore(ConversationStore):
creating_clone = cloned_agent_bundle_location is not None
# Model-family-bound overrides (reasoning_effort, model_override, and
# — same gate — harness_override) copy only when copy_model_settings.
# cost_control_mode_override is intentionally never carried onto a fork.
# The routing switches (cost_control_mode_override,
# subagent_routing_override) are intentionally never carried onto a fork.
fork_overrides = _encode_session_overrides(
{
"reasoning_effort": (
+7 -1
View File
@@ -297,7 +297,13 @@ 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,
)
return _row_to_host(row)
# Genuinely new host: plain INSERT.
+5 -1
View File
@@ -13,4 +13,8 @@ from __future__ import annotations
from omnigent.telemetry.client import emit, init_client, is_disabled
__all__ = ["emit", "init_client", "is_disabled"]
__all__ = [
"emit",
"init_client",
"is_disabled",
]
+3
View File
@@ -32,6 +32,8 @@ class SessionCreatedEvent:
:param agent_name: Agent name for known multi-agent orchestrators
(e.g. ``"polly"``, ``"debby"``); ``None`` for all other agents to
avoid leaking user-defined agent names.
:param routing_enabled: ``True`` when smart routing is on for this
session at creation time.
"""
installation_id: str | None
@@ -44,6 +46,7 @@ class SessionCreatedEvent:
is_fork: bool
is_sub_agent: bool
agent_name: str | None = None
routing_enabled: bool = False
@dataclass
+6 -4
View File
@@ -486,10 +486,12 @@ class ToolManager:
# Model awareness pairs with the dispatch grant: the per-worker
# listing exists to pick a valid ``args.model`` for send.
self._tools[SysListModelsTool.name()] = SysListModelsTool(spec=self._spec)
# Advise-models is capability-gated: expose it only when the server
# has a routing client configured. Hiding the tool prevents agents
# from probing router_on via a no-op call when routing is disabled.
if get_caps().routing_client is not None:
# Advise-models is capability-gated: expose it only when some router can
# answer. Hiding the tool prevents agents from probing router_on via a
# no-op call when routing is disabled.
from omnigent.server.routing_backend import routing_available
if routing_available(get_caps()):
self._tools[SysAdviseModelsTool.name()] = SysAdviseModelsTool()
# create: spawning OUTSIDE the declared list (existing agents
+130 -4
View File
@@ -503,6 +503,30 @@
"title": "Pending Elicitations Count",
"type": "integer"
},
"routed_model": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Model this sub-agent runs on when one was pinned for it, e.g. `\"databricks-claude-opus-4-8\"`. Read from the child's `model_override` \u2014 the field intelligent routing writes when it picks a model for a spawned child. `None` when the child inherits the parent/spec model.",
"title": "Routed Model"
},
"routing_decision_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Identifier of the routing decision that produced `routed_model`, mirroring `RoutingDecisionData.decision_id`. Read from the child's `omnigent.routing.decision_id` label, stamped when routing pins the model. `None` when the child was not routed.",
"title": "Routing Decision Id"
},
"session_name": {
"anyOf": [
{
@@ -3215,6 +3239,42 @@
"title": "Applied",
"type": "boolean"
},
"attempted_override": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Model the spawning agent asked for and the router overrode, e.g. `\"databricks-gpt-5-5\"` \u2014 an LLM-supplied `args.model` on a child session, or a native spawn's own `requested_model`. `None` when nothing was asked for, or when the router's pick names the same arm as the ask.",
"title": "Attempted Override"
},
"decision_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Router decision identifier, e.g. `\"3f1c\u2026\"`. Correlates the transcript item with the routing telemetry event and the child-sessions API row. `None` for decisions made before decision ids existed.",
"title": "Decision Id"
},
"harness": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Harness the decision applies to, e.g. `\"claude-native\"` or `\"codex\"`. `None` when the decision picked a model only (no harness dimension).",
"title": "Harness"
},
"model": {
"description": "The concrete brain model the router chose, e.g. `\"databricks-claude-opus-4-8\"`.",
"title": "Model",
@@ -3224,6 +3284,42 @@
"description": "The router's one-line explanation, shown as muted secondary text, e.g. `\"Multi-file refactor needs deep reasoning.\"`.",
"title": "Rationale",
"type": "string"
},
"raw_model": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "The router-vocabulary pick before resolution to a servable catalog id, e.g. `\"gpt-5-6-sol\"`. `None` when the pick needed no resolution.",
"title": "Raw Model"
},
"router_source": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Which router produced the decision \u2014 `\"databricks-aigw\"` for the external AI-Gateway `task_v1` service, `\"oss-llm\"` for the built-in judge. Deliberately a plain `str` rather than a `Literal`: a source added later must still round-trip through stored rows and the wire instead of failing validation. `None` on rows written before the field existed.",
"title": "Router Source"
},
"scope": {
"default": "turn",
"description": "What the decision governs \u2014 `\"session\"` (auto-harness session routing), `\"turn\"` (per-turn routing), `\"child_session\"` (an Omnigent-spawned sub-agent) or `\"native_subagent\"` (a Task / `spawn_agent` spawn routed inside the harness). Defaults to `\"turn\"` so rows persisted before this field deserialize.",
"enum": [
"session",
"turn",
"child_session",
"native_subagent"
],
"title": "Scope",
"type": "string"
}
},
"required": [
@@ -5207,6 +5303,18 @@
"description": "For sub-agent sessions, the sub-agent type name within the parent's spec tree, e.g. `\"summarizer\"`. `None` for top-level sessions.",
"title": "Sub Agent Name"
},
"subagent_routing_override": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Per-session subagent-routing switch, two-state: `\"on\"` routes subagent spawns, and `\"off\"` or `None` (unset) both leave them unrouted \u2014 the in-session \"Subagent routing\" row renders either as \"Default\". `None` on a row created before this became explicit inherits nothing. Stamped `\"on\"` at create for Smart Routing sessions; also set via `PATCH /v1/sessions/{id}`.",
"title": "Subagent Routing Override"
},
"terminal_launch_args": {
"anyOf": [
{
@@ -6568,6 +6676,18 @@
"title": "Silent",
"type": "boolean"
},
"subagent_routing_override": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Per-session subagent-routing switch: `\"on\"` routes subagent spawns, `\"off\"` leaves them unrouted. Explicit JSON `null` clears the override, which lands the session on Default (the same behavior as `\"off\"` \u2014 nothing is inherited); omitting the field leaves it unchanged (same presence-is-the-clear-signal rule as `cost_control_mode_override`). Effective on the next spawn, so it can be changed at any point in a session.",
"title": "Subagent Routing Override"
},
"terminal_launch_args": {
"anyOf": [
{
@@ -7096,7 +7216,7 @@
},
"/v1/hosts": {
"get": {
"description": "List all hosts owned by the authenticated user.\n\nReturns both online and offline hosts, with live runner\ninformation for online hosts.\n\n**Returns:** `{\"hosts\": [...]}` with host details.",
"description": "List all hosts owned by the authenticated user.\n\nReturns both online and offline hosts, with live runner\ninformation for online hosts.\n\n**Returns:** `{\"hosts\": [...]}` with host details \u2014 `host_id`, `name`, `owner`, `status`, `sandbox_provider`, `configured_harnesses`, and `gateway_inference` (`None` when no connected host has reported it to this replica).",
"operationId": "list_hosts_v1_hosts_get",
"responses": {
"200": {
@@ -7126,7 +7246,7 @@
},
"/v1/hosts/{host_id}": {
"get": {
"description": "Get details for a single host.\n\n**Returns:** Host details dict.\n\n**Raises**\n\n- `HTTPException` \u2014 404 if the host does not exist.",
"description": "Get details for a single host.\n\n**Returns:** Host details dict \u2014 the `list_hosts` fields (including `gateway_inference`, `None` when unreported) plus `runners`.\n\n**Raises**\n\n- `HTTPException` \u2014 404 if the host does not exist.",
"operationId": "get_host_v1_hosts__host_id__get",
"parameters": [
{
@@ -7470,7 +7590,7 @@
},
"/v1/hosts/{host_id}/harnesses/{harness}/credential": {
"post": {
"description": "Write a harness provider credential onto a connected host.\n\nBacks the Web UI setup dialog's \"Add a credential\" action so a user can\nconfigure a Claude / Codex / Pi credential on a connected host without a\nterminal. Owner-scoped, allowlisted, and gated behind\n`OMNIGENT_HARNESS_INSTALL_ENABLED` exactly like the install route\n(404 when disabled). The host daemon does the write with the same\nnon-interactive core the `omnigent setup` wizard uses.\n\nSecurity: the server is an authz'd pass-through \u2014 it validates\nownership + the allowlist and forwards the secret over the (TLS) tunnel;\nit never persists the secret or logs it. The secret rides in the request\nbody (not the URL), and the frame field is redaction-named so it never\nlands on a telemetry span.\n\n**Parameters**\n\n- `body` \u2014 The credential payload (kind + secret / gateway / adopt).\n\n**Returns:** `{\"object\": \"harness_credential\", \"harness\": ..., \"configured_harnesses\": {...}}` \u2014 refreshed readiness so the UI can flip the badge without a reconnect.\n\n**Raises**\n\n- `HTTPException` \u2014 404 when disabled or host unknown, 400 when the harness isn't UI-configurable or the body is invalid, 403 when not the owner, 409 when offline, 502 on host-side failure, 504 on timeout.",
"description": "Write a harness provider credential onto a connected host.\n\nBacks the Web UI setup dialog's \"Add a credential\" action so a user can\nconfigure a Claude / Codex / Pi credential on a connected host without a\nterminal. Owner-scoped, allowlisted, and gated behind\n`OMNIGENT_HARNESS_INSTALL_ENABLED` exactly like the install route\n(404 when disabled). The host daemon does the write with the same\nnon-interactive core the `omnigent setup` wizard uses.\n\nSecurity: the server is an authz'd pass-through \u2014 it validates\nownership + the allowlist and forwards the secret over the (TLS) tunnel;\nit never persists the secret or logs it. The secret rides in the request\nbody (not the URL), and the frame field is redaction-named so it never\nlands on a telemetry span.\n\n**Parameters**\n\n- `body` \u2014 The credential payload (kind + secret / gateway / adopt).\n\n**Returns:** `{\"object\": \"harness_credential\", \"harness\": ..., \"configured_harnesses\": {...}, \"gateway_inference\": {...} | None}` \u2014 refreshed readiness so the UI can flip the badge without a reconnect, plus the refreshed gateway-inference map (`None` when the host didn't report one).\n\n**Raises**\n\n- `HTTPException` \u2014 404 when disabled or host unknown, 400 when the harness isn't UI-configurable or the body is invalid, 403 when not the owner, 409 when offline, 502 on host-side failure, 504 on timeout.",
"operationId": "store_host_harness_credential_v1_hosts__host_id__harnesses__harness__credential_post",
"parameters": [
{
@@ -7536,7 +7656,7 @@
},
"/v1/hosts/{host_id}/harnesses/{harness}/install": {
"post": {
"description": "Install a missing, npm-installable harness CLI onto a host.\n\nBacks the Web UI's New Chat dialog \"Install\" action so a user can\ninstall a harness the connected host is missing without dropping to a\nterminal. Owner-scoped like the other host actions: only the host owner\nmay install onto it. Scoped to the UI-installable allowlist (claude,\ncodex, pi, opencode, qwen) \u2014 curl/brew and interactive-auth harnesses\nare refused. The whole route is gated behind\n`OMNIGENT_HARNESS_INSTALL_ENABLED` (default off): when disabled it\nreturns 404 so the feature is invisible until opted in.\n\nConcurrent requests for the same (host, harness) coalesce onto one\nin-flight install so a double-click can't fire two global npm installs.\n\n**Returns:** `{\"object\": \"harness_install\", \"harness\": ..., \"configured_harnesses\": {...}}` \u2014 the host's refreshed readiness map so the UI can flip the badge without a reconnect.\n\n**Raises**\n\n- `HTTPException` \u2014 404 when the feature is disabled or the host is unknown, 400 when the harness is not UI-installable, 403 when the caller is not the host owner, 409 when the host is offline, 502 on a host-side install failure, 504 on host timeout.",
"description": "Install a missing, npm-installable harness CLI onto a host.\n\nBacks the Web UI's New Chat dialog \"Install\" action so a user can\ninstall a harness the connected host is missing without dropping to a\nterminal. Owner-scoped like the other host actions: only the host owner\nmay install onto it. Scoped to the UI-installable allowlist (claude,\ncodex, pi, opencode, qwen) \u2014 curl/brew and interactive-auth harnesses\nare refused. The whole route is gated behind\n`OMNIGENT_HARNESS_INSTALL_ENABLED` (default off): when disabled it\nreturns 404 so the feature is invisible until opted in.\n\nConcurrent requests for the same (host, harness) coalesce onto one\nin-flight install so a double-click can't fire two global npm installs.\n\n**Returns:** `{\"object\": \"harness_install\", \"harness\": ..., \"configured_harnesses\": {...}, \"gateway_inference\": {...} | None}` \u2014 the host's refreshed readiness map so the UI can flip the badge without a reconnect, plus its refreshed gateway-inference map (`None` when the host didn't report one).\n\n**Raises**\n\n- `HTTPException` \u2014 404 when the feature is disabled or the host is unknown, 400 when the harness is not UI-installable, 403 when the caller is not the host owner, 409 when the host is offline, 502 on a host-side install failure, 504 on host timeout.",
"operationId": "install_host_harness_v1_hosts__host_id__harnesses__harness__install_post",
"parameters": [
{
@@ -7827,6 +7947,12 @@
},
"type": "array"
},
{
"additionalProperties": {
"type": "boolean"
},
"type": "object"
},
{
"type": "null"
}
+1
View File
@@ -432,6 +432,7 @@ markers = [
"min_runner_version(version): skip this test when the pinned runner/host under test is older than `version` (PEP 440 release-tuple comparison, so a `.devN` of X satisfies X). Used by the runner/host backwards-compat CI (Config 2); inert in normal runs (no OMNIGENT_COMPAT_RUNNER_VERSION set).",
"mock_only: tests/integration test that only works in mock-LLM mode (no --llm-api-key). Skipped by tests/integration/conftest.py when a real --llm-api-key is supplied (the real-LLM Integration jobs). Use for tests whose mock LLM is scripted with a fixed tool-call sequence — a real LLM cannot reproduce the scripted markers.",
"visual: UI diff visual-regression snapshot (pytest-playwright-visual-snapshot). Runs only in the pinned-runner gate (.github/workflows/ui-snapshot.yml); the main e2e_ui suite excludes it via -m 'not visual' since it runs on the unpinned ubuntu-latest.",
"smart_routing: end-to-end Smart Routing CUJ (tests/e2e/routing). Opt-in via OMNIGENT_E2E_SMART_ROUTING=1: launches a real omnigent host plus real claude/codex TUIs against a gateway. The routing service itself is an in-test mock, so no AI-Gateway task_v1 deployment is needed.",
"posix_only: test relies on POSIX-only behaviour (fork, PTY, tmux, Unix sockets, signals); auto-skipped on Windows by tests/conftest.py.",
"windows_only: test relies on Windows-only behaviour (Job Objects, cmd.exe); auto-skipped on POSIX by tests/conftest.py.",
]
+5 -1
View File
@@ -29,7 +29,11 @@ def test_external_builds_client() -> None:
assert client._url == "https://host/ai-gateway/routing/v1/routes:select"
assert client._router_name == "task_v0"
assert client._auth is None # no profile -> unauthenticated
assert client._model_prefixes == [] # no prefix -> catalog ids sent verbatim
# No prefix configured -> the module's shared catalog-prefix list, so the
# client and the server-side seam can't disagree about a catalog id.
from omnigent.server.smart_routing import MODEL_ID_PREFIXES
assert client._model_prefixes == list(MODEL_ID_PREFIXES)
def test_external_threads_model_prefix_scalar() -> None:
+30 -2
View File
@@ -5597,12 +5597,12 @@ def test_native_terminal_dispatch_specs_cover_registered_native_agents() -> None
(
"claude-native",
"omnigent.claude_native.run_claude_native",
{"extra_args": ("--model", "native-model")},
{"extra_args": ("--model", "native-model"), "prompt": None},
),
(
"codex-native",
"omnigent.codex_native.run_codex_native",
{"extra_args": (), "model": "native-model"},
{"extra_args": (), "model": "native-model", "prompt": None},
),
(
"pi-native",
@@ -5769,6 +5769,34 @@ def test_dispatch_native_terminal_harness_kiro_forwards_prompt(
assert captured["prompt"] == "review repo"
@pytest.mark.parametrize(
("harness", "target"),
[
("claude-native", "omnigent.claude_native.run_claude_native"),
("codex-native", "omnigent.codex_native.run_codex_native"),
],
)
def test_dispatch_native_terminal_harness_forwards_prompt_to_claude_and_codex(
monkeypatch: pytest.MonkeyPatch, harness: str, target: str
) -> None:
"""``run --harness claude-native -p`` is supported, not rejected.
Both wrappers deliver the text as the TUI's initial input, so a prompt is
no longer a REPL-only option for them. A multi-line prompt must arrive as
one value the wrappers put it on argv rather than pasting it.
"""
monkeypatch.setattr("omnigent.cli._ensure_backend", lambda _s: "http://localhost:0")
captured: dict[str, object] = {}
monkeypatch.setattr(target, lambda **kwargs: captured.update(kwargs))
handled = _dispatch_native_terminal_harness(
**_native_dispatch_kwargs(harness=harness, prompt="first line\nsecond line")
)
assert handled is True
assert captured["prompt"] == "first line\nsecond line"
@pytest.mark.parametrize(
("harness", "target", "args_param"),
[
+124
View File
@@ -0,0 +1,124 @@
"""Tests for how the server bootstrap assembles both routing backends.
Companion to ``test_build_routing_client.py``, which covers the individual
builders. Here the question is the PAIR: a Databricks deployment with an ``llm:``
block gets the AI Gateway *and* the built-in judge, so an off-gateway harness
still routes, and ``routing_client`` stays the pair's primary.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from omnigent.cli import _build_routing_backends
from omnigent.server.smart_routing import (
ExternalRoutingClient,
LLMRoutingClient,
RoutingSettings,
)
_LLM_BLOCK: dict[str, Any] = {"model": "databricks-claude-haiku-4-5"}
@pytest.fixture
def workspace() -> Any:
"""Resolve every Databricks profile to one workspace host."""
with patch(
"omnigent.runtime.credentials.databricks.resolve_databricks_workspace",
return_value=MagicMock(host="https://ws.example.invalid"),
) as resolve:
yield resolve
def _server_llm() -> Any:
from omnigent.spec import parse_server_llm
return parse_server_llm(_LLM_BLOCK)
@contextmanager
def _policy_client() -> Any:
"""Stub the policy-LLM plumbing so no real credential is resolved."""
with (
patch(
"omnigent.runtime.policies.builder._resolve_server_llm_connection",
return_value=None,
),
patch(
"omnigent.runtime.policies.builder._build_policy_llm_client",
return_value=MagicMock(),
),
):
yield
def test_a_databricks_deployment_gets_both_backends(workspace: Any) -> None:
del workspace
cfg = {"providers": {"ws": {"kind": "databricks", "profile": "ws", "default": True}}}
with _policy_client():
backends = _build_routing_backends(cfg, _server_llm(), RoutingSettings())
assert isinstance(backends.external, ExternalRoutingClient)
assert isinstance(backends.local, LLMRoutingClient)
# The primary every legacy consumer reads is the external client.
assert backends.any() is backends.external
def test_an_explicit_external_provider_plus_an_llm_block_gets_both() -> None:
cfg = {
"routing": {
"provider": "external",
"base_url": "https://host/ai-gateway/routing/v1",
"router_name": "task_v1",
}
}
with _policy_client():
backends = _build_routing_backends(cfg, _server_llm(), RoutingSettings())
assert isinstance(backends.external, ExternalRoutingClient)
assert isinstance(backends.local, LLMRoutingClient)
def test_provider_none_configures_neither_backend(workspace: Any) -> None:
del workspace
cfg = {
"routing": {"provider": "none"},
"providers": {"ws": {"kind": "databricks", "profile": "ws", "default": True}},
}
with _policy_client():
backends = _build_routing_backends(cfg, _server_llm(), RoutingSettings())
assert (backends.external, backends.local) == (None, None)
assert backends.any() is None
def test_an_llm_block_alone_configures_only_the_built_in_judge() -> None:
"""No Databricks provider and no ``routing:`` block means judge only."""
with (
patch("omnigent.cli._databricks_provider_profile", return_value=None),
_policy_client(),
):
backends = _build_routing_backends({}, _server_llm(), RoutingSettings())
assert backends.external is None
assert isinstance(backends.local, LLMRoutingClient)
assert backends.any() is backends.local
def test_a_databricks_deployment_without_an_llm_block_gets_only_the_gateway(
workspace: Any,
) -> None:
del workspace
cfg = {"providers": {"ws": {"kind": "databricks", "profile": "ws", "default": True}}}
backends = _build_routing_backends(cfg, None, RoutingSettings())
assert isinstance(backends.external, ExternalRoutingClient)
assert backends.local is None
def test_an_unusable_config_leaves_routing_off() -> None:
"""A default (non-external, non-none) provider with no ``llm:`` block."""
with patch("omnigent.cli._databricks_provider_profile", return_value=None):
backends = _build_routing_backends(
{"routing": {"provider": "judge"}}, None, RoutingSettings()
)
assert (backends.external, backends.local) == (None, None)
File diff suppressed because it is too large Load Diff
@@ -153,3 +153,35 @@ def test_select_artifact_store(
port=8000,
)
assert isinstance(_select_artifact_store(resolved), expected_type)
# ── routing wiring ────────────────────────────────────────────────────────
# A Docker deploy must honour its own `routing:` block rather than running on
# all-default knobs, so the settings that reach RuntimeCaps are the parsed ones.
def test_build_routing_carries_the_configured_settings() -> None:
from deploy.docker.entrypoint import _build_routing
cfg = {
"routing": {
"provider": "external",
"base_url": "https://host/ai-gateway/routing/v1",
"router_name": "task_v1",
"model_prefix": ["databricks-", "system.ai."],
}
}
client, settings = _build_routing(cfg, None)
assert settings.model_prefixes == ("databricks-", "system.ai.")
assert client is not None
assert client._model_prefixes == ["databricks-", "system.ai."]
def test_build_routing_defaults_without_a_routing_block() -> None:
from deploy.docker.entrypoint import _build_routing
from omnigent.server.smart_routing import RoutingSettings
client, settings = _build_routing({}, None)
assert client is None
assert settings == RoutingSettings()
+7
View File
@@ -0,0 +1,7 @@
"""End-to-end Smart Routing CUJ suite.
Opt-in (marker ``smart_routing`` + ``OMNIGENT_E2E_SMART_ROUTING=1``). The
routing service is a deterministic in-test mock speaking the real
``routes:select`` contract, so no AI-Gateway ``task_v1`` deployment is needed;
everything else server, host, ``claude`` / ``codex`` CLIs, panes is real.
"""
+505
View File
@@ -0,0 +1,505 @@
"""Scoring helpers shared by the routing CUJs.
Every assertion in this suite is a routing artifact a decision item, a
session field, a bridge-dir file, a pane capture never the text a model
answered with. Answers here run against a real gateway, so a slow or failed
generation must not be able to redden a routing test.
"""
from __future__ import annotations
import hashlib
import json
import subprocess
import time
from collections.abc import Callable, Iterable, Sequence
from pathlib import Path
from typing import Any, TypeVar
import httpx
_T = TypeVar("_T")
#: Poll cadence for every wait in this suite, in seconds.
POLL_INTERVAL_S = 0.5
def wait_for(
predicate: Callable[[], _T | None],
*,
timeout: float,
what: str,
interval: float = POLL_INTERVAL_S,
) -> _T:
"""Poll *predicate* until it returns something truthy.
:param predicate: Called repeatedly; a non-``None`` return ends the wait.
:param timeout: Max seconds to wait.
:param what: What is being waited for, used in the failure message.
:param interval: Seconds between polls.
:returns: The predicate's first non-``None`` value.
:raises AssertionError: When *timeout* elapses first.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if value is not None:
return value
time.sleep(interval)
raise AssertionError(f"timed out after {timeout}s waiting for {what}")
def settle(seconds: float) -> None:
"""Sleep *seconds*, for the negative checks that need a quiet window.
"Nothing else happened" can only be shown by waiting, so the few
assertions that need it say so explicitly instead of hiding a sleep.
:param seconds: How long to stay quiet.
"""
time.sleep(seconds)
# ── Decisions ───────────────────────────────────────────────────────────────
def decisions(
client: httpx.Client,
session_id: str,
*,
scope: str | None = None,
) -> list[dict[str, Any]]:
"""Return this session's ``routing_decision`` items, oldest first.
The session snapshot is the public read path for the decision rows the
chips and cards render from (the same rows the UI
same rows straight out of ``chat.db``).
:param client: HTTP client pointed at the routing server.
:param session_id: Session id, e.g. ``"conv_abc123"``.
:param scope: When given, keep only decisions of that scope, e.g.
``"native_subagent"``.
:returns: The decision payloads, each flattened to its ``data`` fields.
"""
resp = client.get(f"/v1/sessions/{session_id}")
resp.raise_for_status()
rows: list[dict[str, Any]] = []
for item in resp.json().get("items", []):
if item.get("type") != "routing_decision":
continue
data = item.get("data")
payload = dict(data) if isinstance(data, dict) else {}
payload["id"] = item.get("id")
rows.append(payload)
if scope is not None:
rows = [row for row in rows if row.get("scope") == scope]
return rows
def wait_for_decision(
client: httpx.Client,
session_id: str,
*,
scope: str,
timeout: float,
) -> dict[str, Any]:
"""Wait for the first decision of *scope* on *session_id*.
:param client: HTTP client pointed at the routing server.
:param session_id: Session id.
:param scope: Decision scope to wait for, e.g. ``"session"``.
:param timeout: Max seconds to wait.
:returns: The first matching decision payload.
:raises AssertionError: When none appears in time.
"""
return wait_for(
lambda: next(iter(decisions(client, session_id, scope=scope)), None),
timeout=timeout,
what=f"a {scope}-scope routing decision on {session_id}",
)
def user_messages(client: httpx.Client, session_id: str) -> list[dict[str, Any]]:
"""Return this session's user message items, oldest first.
Used by the first-message CUJs: a block-and-replay that leaks the blocked
prompt shows up as two user turns for one thing typed.
:param client: HTTP client pointed at the routing server.
:param session_id: Session id.
:returns: The user message items.
"""
resp = client.get(f"/v1/sessions/{session_id}")
resp.raise_for_status()
out = []
for item in resp.json().get("items", []):
data = item.get("data") if isinstance(item.get("data"), dict) else {}
if item.get("type") == "message" and (data or item).get("role") == "user":
out.append(item)
return out
def session_snapshot(client: httpx.Client, session_id: str) -> dict[str, Any]:
"""Return the session row as the API serializes it.
:param client: HTTP client pointed at the routing server.
:param session_id: Session id.
:returns: The session snapshot object.
"""
resp = client.get(f"/v1/sessions/{session_id}")
resp.raise_for_status()
payload = resp.json()
return payload if isinstance(payload, dict) else {}
# ── Model-id comparison ─────────────────────────────────────────────────────
def same_arm(left: str | None, right: str | None) -> bool:
"""Whether two model ids name the same arm.
Folds catalog prefixes, dots-vs-dashes and case, so a codex ``config.toml``
spelling ``gpt-5.6-luna``, a catalog ``databricks-gpt-5-6-luna`` and the
router's ``gpt-5-6-luna`` all compare equal. Mandatory: the codex apply
layer deliberately writes codex's own slug, so a byte comparison against
the router's vocabulary is a false red.
:param left: A model id, or ``None``.
:param right: A model id, or ``None``.
:returns: ``True`` when both name the same arm.
"""
from omnigent.codex_model_vocabulary import comparable_model_id
if not left or not right:
return False
return comparable_model_id(left) == comparable_model_id(right)
def arm_in(model: str | None, arms: Iterable[str]) -> bool:
"""Whether *model* names any arm in *arms*.
:param model: A model id, or ``None``.
:param arms: Arm ids to compare against.
:returns: ``True`` on a match.
"""
return any(same_arm(model, arm) for arm in arms)
# ── Bridge dirs and process truth ───────────────────────────────────────────
def claude_bridge_dir(session_id: str) -> Path:
"""Return the claude-native bridge dir for *session_id*.
:param session_id: Session id, which is also the bridge id.
:returns: The bridge directory path (may not exist yet).
"""
from omnigent.claude_native_bridge import bridge_dir_for_bridge_id
return bridge_dir_for_bridge_id(session_id)
def codex_bridge_dir(session_id: str) -> Path:
"""Return the codex-native bridge dir for *session_id*.
:param session_id: Session id, which is also the bridge id.
:returns: The bridge directory path (may not exist yet).
"""
from omnigent.codex_native_bridge import bridge_dir_for_bridge_id
return bridge_dir_for_bridge_id(session_id)
def wait_for_bridge_file(bridge_dir: Path, name: str, *, timeout: float) -> Path:
"""Wait for ``bridge_dir/name`` to appear.
:param bridge_dir: The session's bridge directory.
:param name: File name inside it, e.g. ``"tmux.json"``.
:param timeout: Max seconds to wait.
:returns: The existing path.
:raises AssertionError: When it never appears.
"""
target = bridge_dir / name
return wait_for(
lambda: target if target.exists() else None,
timeout=timeout,
what=f"{target}",
)
def tmux_target(bridge_dir: Path) -> tuple[str, str]:
"""Read the pane the harness advertised in ``tmux.json``.
:param bridge_dir: The session's bridge directory.
:returns: ``(socket_path, tmux_target)``.
:raises AssertionError: When the advertisement is missing or incomplete.
"""
raw = json.loads((bridge_dir / "tmux.json").read_text())
socket_path = raw.get("socket_path") or raw.get("tmux_socket")
target = raw.get("tmux_target")
assert socket_path and target, f"incomplete tmux.json in {bridge_dir}: {raw}"
return str(socket_path), str(target)
def wait_for_session_pane(
client: httpx.Client,
session_id: str,
*,
timeout: float,
) -> tuple[str, str]:
"""Wait for the session's running terminal and return its tmux pane.
The terminals resource is the harness-agnostic way to find a pane: only
claude-native publishes ``tmux.json`` into its bridge dir, while the
registry knows the socket and target for every native harness.
:param client: HTTP client pointed at the routing server.
:param session_id: Session id.
:param timeout: Max seconds to wait for a running terminal.
:returns: ``(socket_path, tmux_target)``.
:raises AssertionError: When no running terminal appears in time.
"""
def _look() -> tuple[str, str] | None:
resp = client.get(f"/v1/sessions/{session_id}/resources/terminals")
if resp.status_code != 200:
return None
for row in resp.json().get("data", []):
meta = row.get("metadata") or {}
socket_path, target = meta.get("tmux_socket"), meta.get("tmux_target")
if meta.get("running") and socket_path and target:
return str(socket_path), str(target)
return None
return wait_for(_look, timeout=timeout, what=f"a running terminal on {session_id}")
def capture_pane(socket_path: str, target: str, *, lines: int = 2000) -> str:
"""Capture a tmux pane's visible text plus recent scrollback.
:param socket_path: The tmux server socket the pane lives on.
:param target: The pane target, e.g. ``"omnigent-abc:0.0"``.
:param lines: How many scrollback lines to include.
:returns: The pane text, or ``""`` when the pane is gone.
"""
try:
out = subprocess.run(
["tmux", "-S", socket_path, "capture-pane", "-p", "-S", f"-{lines}", "-t", target],
capture_output=True,
text=True,
timeout=15,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return ""
return out.stdout
def wait_for_pane_text(
socket_path: str,
target: str,
needle: str,
*,
timeout: float,
) -> str:
"""Wait until *needle* shows up in the pane.
:param socket_path: The tmux server socket.
:param target: The pane target.
:param needle: Literal text to look for.
:param timeout: Max seconds to wait.
:returns: The pane capture containing *needle*.
:raises AssertionError: When it never appears.
"""
return _wait_for_pane(
socket_path,
target,
lambda text: needle in text,
timeout=timeout,
what=repr(needle),
)
def wait_for_pane_identifier(
socket_path: str,
target: str,
identifier: str,
*,
timeout: float,
) -> str:
"""Wait until *identifier* shows up in the pane, ignoring line wrapping.
A long MCP tool name (``mcp__omnigent__sys_session_create``) is routinely
broken across pane lines, so the comparison drops all whitespace on both
sides. Anything shorter would be a false red on a narrow pane.
:param socket_path: The tmux server socket.
:param target: The pane target.
:param identifier: The token to look for, e.g. a tool name.
:param timeout: Max seconds to wait.
:returns: The pane capture containing it.
:raises AssertionError: When it never appears.
"""
needle = "".join(identifier.split())
return _wait_for_pane(
socket_path,
target,
lambda text: needle in "".join(text.split()),
timeout=timeout,
what=repr(identifier),
)
def _wait_for_pane(
socket_path: str,
target: str,
matches: Callable[[str], bool],
*,
timeout: float,
what: str,
) -> str:
"""Poll a pane until *matches* accepts its contents.
On timeout the failure carries the pane's tail: "the text never appeared" and
"the pane died / is showing a dialog" are otherwise indistinguishable, and
that is the difference between a real routing bug and a launch problem.
:param socket_path: The tmux server socket.
:param target: The pane target.
:param matches: Predicate over the captured pane text.
:param timeout: Max seconds to wait.
:param what: What is being waited for, for the failure message.
:returns: The accepted pane capture.
:raises AssertionError: When *matches* never accepts.
"""
deadline = time.monotonic() + timeout
text = ""
while time.monotonic() < deadline:
text = capture_pane(socket_path, target)
if matches(text):
return text
time.sleep(POLL_INTERVAL_S)
raise AssertionError(
f"timed out after {timeout}s waiting for {what} in pane {target}.\n"
f"pane tail:\n{text[-3000:]}"
)
def codex_config_model(session_id: str) -> str | None:
"""Read the codex ``config.toml`` model for *session_id*.
Never the live model on its own (a running thread can be switched without
the file catching up), but it is the file the routing apply layer mirrors
the accepted switch into, in codex's own slug spelling.
:param session_id: Session id.
:returns: The top-level ``model`` value, or ``None``.
"""
from omnigent.codex_native_bridge import read_codex_config_model
return read_codex_config_model(codex_bridge_dir(session_id))
def wait_for_codex_config_arm(session_id: str, expected: str, *, timeout: float) -> str:
"""Wait until the session's ``config.toml`` names the *expected* arm.
A wait, not a read: the private ``CODEX_HOME`` is seeded from the user's own
config first and only then pinned to the session's model, so reading the file
the moment it appears sees the developer's default arm and not the routed
one. On timeout the failure names the value actually found, which is what
tells a real mis-apply apart from a slow one.
:param session_id: Session id.
:param expected: The routed model id the file must name, in any spelling.
:param timeout: Max seconds to wait.
:returns: The value found in ``config.toml``, in codex's own spelling.
:raises AssertionError: When the file never names *expected*.
"""
observed: str | None = None
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
observed = codex_config_model(session_id)
if observed and same_arm(observed, expected):
return observed
time.sleep(POLL_INTERVAL_S)
raise AssertionError(
f"config.toml still names {observed!r} after {timeout}s, not the routed "
f"{expected!r} (compared on the folded arm id, so a slug spelling is not "
"the cause)"
)
def rollout_turn_contexts(session_id: str) -> list[dict[str, Any]]:
"""Return the ``turn_context`` records from the session's newest rollout.
The rollout is what the codex process actually ran the only ground truth
for the live model, which ``config.toml`` can lag.
:param session_id: Session id.
:returns: The decoded ``turn_context`` payloads in file order; empty when
no rollout exists yet.
"""
from omnigent.codex_native_bridge import codex_home_for_bridge_dir
sessions_dir = codex_home_for_bridge_dir(codex_bridge_dir(session_id)) / "sessions"
rollouts = sorted(sessions_dir.rglob("rollout-*.jsonl"), key=lambda p: p.stat().st_mtime)
if not rollouts:
return []
contexts: list[dict[str, Any]] = []
for line in rollouts[-1].read_text(errors="replace").splitlines():
try:
record = json.loads(line)
except ValueError:
continue
if not isinstance(record, dict):
continue
payload = record.get("payload") if isinstance(record.get("payload"), dict) else record
if record.get("type") == "turn_context" or "turn_context" in record:
inner = record.get("turn_context")
contexts.append(inner if isinstance(inner, dict) else payload)
return contexts
# ── Isolation guards ────────────────────────────────────────────────────────
def claude_settings_apart_from_the_model() -> str | None:
"""Return the developer's ``~/.claude/settings.json`` minus its ``model`` key.
Routing writes nothing into the user's global Claude settings — hook
registration lives in the per-session bridge dir. Claude Code's own
response to ``/model <id>`` does rewrite the file's ``model`` key, which
is the accepted trade-off for the switch, so that one key is excluded and
everything else is compared before and after each claude CUJ.
:returns: A canonical rendering of the remaining settings, or ``None``
when the file does not exist.
"""
path = Path.home() / ".claude" / "settings.json"
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
# Unreadable is still comparable — hash the bytes.
return hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
if isinstance(payload, dict):
payload = {key: value for key, value in payload.items() if key != "model"}
return json.dumps(payload, sort_keys=True)
def grep_log(path: Path, needles: Sequence[str]) -> list[str]:
"""Return the lines of *path* containing any of *needles*.
:param path: A log file; a missing file yields no lines.
:param needles: Literal substrings to look for.
:returns: The matching lines.
"""
if not path.exists():
return []
return [
line
for line in path.read_text(errors="replace").splitlines()
if any(needle in line for needle in needles)
]
+433
View File
@@ -0,0 +1,433 @@
"""A deterministic in-test ``routes:select`` service.
Replaces the Databricks AI-Gateway ``task_v1`` router for the e2e routing CUJs.
It speaks the exact wire contract :class:`~omnigent.server.smart_routing.ExternalRoutingClient`
speaks the ``omnigent.api.routing.v1`` protos, snake_case JSON, one
``route_selection`` entry plus a ``rationale`` and reproduces the observed
task_v1 behaviour the CUJ matrix in this directory is scored against:
* **Scenario inference from the arms present**, not the harness tags: Claude
arms only ``cc``, Codex arms only ``codex``, both ``both``.
* **Each scenario requires its full menu.** A narrowed menu is a 400 carrying
the real router's message (``scenario 'codex' requires its full menu;
missing [...]``), so a client that stops injecting arms fails here the way it
fails against the gateway. Extra non-arm models are tolerated and ignored.
* **The harness tag is passthrough** echoed verbatim on the selection,
never read.
* Picks come only from ``route_options``, and the rationale is the same rule
trace the live router emits (captured verbatim from staging logs), so the
chips and decision cards under test carry production-shaped text.
The rule table is :func:`decide`; it is the whole routing policy and is unit
tested by ``test_mock_router.py``.
"""
from __future__ import annotations
import json
import re
import threading
from collections.abc import Iterator, Sequence
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
# ── The frozen task_v1 arms (mirrors smart_routing._TASK_V1_*_ARMS) ─────────
CLAUDE_ARMS: tuple[str, ...] = ("claude-opus-4-8", "claude-sonnet-5")
CODEX_ARMS: tuple[str, ...] = ("glm-5-2", "gpt-5-6-sol", "gpt-5-6-luna")
MENUS: dict[str, tuple[str, ...]] = {
"cc": CLAUDE_ARMS,
"codex": CODEX_ARMS,
"both": CLAUDE_ARMS + CODEX_ARMS,
}
#: Rule-0's cheapest arm per scenario. The ``cc`` menu has no luna, so the
#: claude-only scenario's cheapest arm is the sonnet pin.
CHEAPEST_ARM: dict[str, str] = {
"cc": "claude-sonnet-5",
"codex": "gpt-5-6-luna",
"both": "gpt-5-6-luna",
}
#: Rule-0's trivial cutoff, in characters (task_v1's own boundary).
TRIVIAL_MAX_CHARS = 300
#: Upper bound of task_v1's ``prompt_short`` bucket, in characters.
PROMPT_SHORT_MAX_CHARS = 1200
#: The path the client appends to ``routing.base_url``.
ROUTES_SELECT_PATH = "/routes:select"
#: Base path the mock serves under, so the configured ``base_url`` looks like
#: the gateway's (``.../ai-gateway/routing/v1``) rather than a bare host.
BASE_PATH = "/ai-gateway/routing/v1"
# Deterministic stand-ins for task_v1's "errors or code refs" regex family:
# stack traces, file paths, backticked symbols, code fences, CLI flags.
_CODE_MARKERS: tuple[re.Pattern[str], ...] = (
re.compile(r"`[^`\n]+`"),
re.compile(r"```"),
re.compile(r"\bTraceback\b"),
re.compile(r"\b\w+\.(py|ts|tsx|go|rs|java|rb|js)\b"),
re.compile(r"(?<!\w)--[a-z][a-z0-9-]+"),
re.compile(r"\b(def|class|import|function)\s+\w"),
re.compile(r"\bError\b|\bException\b"),
)
# Prompts that span surfaces / mix concerns fail ``not_crosscutting``, which is
# what sends the long CUJ prompts to the escalate/default arms.
_CROSSCUTTING_MARKERS: tuple[str, ...] = (
"across",
"every surface",
"open questions",
"brainstorm",
"end to end",
"everywhere",
"whole repo",
"migration",
)
def has_code_markers(prompt: str) -> bool:
"""Whether *prompt* carries an error or code reference.
:param prompt: The task prompt as sent in ``task.prompt``.
:returns: ``True`` when any code/error marker matches.
"""
return any(pattern.search(prompt) for pattern in _CODE_MARKERS)
def is_trivial(prompt: str) -> bool:
"""Whether rule-0 fires: short, no errors or code refs, easy.
:param prompt: The task prompt.
:returns: ``True`` when the trivial rule applies.
"""
return len(prompt) < TRIVIAL_MAX_CHARS and not has_code_markers(prompt)
def is_crosscutting(prompt: str) -> bool:
"""Whether *prompt* spans surfaces, failing ``not_crosscutting``.
:param prompt: The task prompt.
:returns: ``True`` when the prompt reads as crosscutting.
"""
lowered = prompt.lower()
return len(prompt) > PROMPT_SHORT_MAX_CHARS or any(
marker in lowered for marker in _CROSSCUTTING_MARKERS
)
def is_delegate_class(prompt: str) -> bool:
"""Whether ``not_crosscutting AND prompt_short`` both hold.
The delegate bucket: a narrow, well-specified, code-shaped task. On the
``codex`` menu this is what task_v1 hands down to ``glm-5-2``.
:param prompt: The task prompt.
:returns: ``True`` when the delegate conjunction holds.
"""
return has_code_markers(prompt) and not is_crosscutting(prompt)
def scenario_for(models: Sequence[str]) -> str | None:
"""Infer the router scenario from the model families on offer.
:param models: Bare model ids from ``route_options``.
:returns: ``"cc"``, ``"codex"``, ``"both"``, or ``None`` when no
recognized arm appears (the real router 400s with
"could not infer a scenario").
"""
offered = {m.lower() for m in models}
claude = bool(offered & set(CLAUDE_ARMS))
codex = bool(offered & set(CODEX_ARMS))
if claude and codex:
return "both"
if claude:
return "cc"
if codex:
return "codex"
return None
def missing_arms(scenario: str, models: Sequence[str]) -> list[str]:
"""Arms *scenario* requires that ``route_options`` did not offer.
:param scenario: An inferred scenario key.
:param models: Bare model ids from ``route_options``.
:returns: The missing arm ids, menu order preserved.
"""
offered = {m.lower() for m in models}
return [arm for arm in MENUS[scenario] if arm not in offered]
@dataclass(frozen=True)
class Verdict:
"""One routing decision: the arm and the rule trace behind it."""
model: str
rationale: str
def decide(prompt: str, scenario: str) -> Verdict:
"""Apply the rule table for *scenario* to *prompt*.
The single source of truth for what this mock routes. Rationales are the
live router's own rule traces, captured verbatim from staging.
:param prompt: The task prompt.
:param scenario: ``"cc"``, ``"codex"`` or ``"both"``.
:returns: The chosen arm and its rationale.
"""
if is_trivial(prompt):
arm = CHEAPEST_ARM[scenario]
return Verdict(
model=arm,
rationale=(
f"Routed to {arm} because trivial task "
f"(prompt<300, no errors/refs, llm easy) -> cheapest arm {arm}; "
"never escalate."
),
)
if is_delegate_class(prompt):
if scenario == "codex":
return Verdict(
model="glm-5-2",
rationale=(
"Routed to glm-5-2 because [not_crosscutting AND prompt_short] "
"all hold -> delegate down to glm-5-2."
),
)
# cc and both escalate a contained, code-referencing task.
return Verdict(
model="claude-opus-4-8",
rationale=(
"Routed to claude-opus-4-8 because [not_crosscutting AND "
"not_mixed_change AND low_ambiguity] all hold -> escalate up to "
"claude-opus-4-8."
),
)
if scenario == "cc":
return Verdict(
model="claude-sonnet-5",
rationale=(
"Routed to claude-sonnet-5 because [not_crosscutting AND "
"not_mixed_change AND low_ambiguity] not all hold -> default "
"claude-sonnet-5."
),
)
return Verdict(
model="gpt-5-6-sol",
rationale=(
"Routed to gpt-5-6-sol because [not_crosscutting AND prompt_short] "
"not all hold -> default gpt-5-6-sol."
),
)
class MenuRejected(Exception):
"""The offered ``route_options`` are not a scenario's full menu."""
def __init__(self, message: str) -> None:
"""
:param message: The router's own rejection text.
"""
super().__init__(message)
self.message = message
def select_route(body: dict[str, Any]) -> dict[str, Any]:
"""Answer one ``routes:select`` request.
:param body: The decoded snake_case request body.
:returns: The ``SelectRouteResponse`` body to serialize.
:raises MenuRejected: When no scenario can be inferred, or the inferred
scenario's menu is incomplete — the real router's two 400s.
"""
options = body.get("route_options") or []
models = [str(o.get("model") or "") for o in options if isinstance(o, dict)]
prompt = str((body.get("task") or {}).get("prompt") or "")
scenario = scenario_for(models)
if scenario is None:
raise MenuRejected("could not infer a scenario from the offered route_options")
absent = missing_arms(scenario, models)
if absent:
raise MenuRejected(
f"scenario {scenario!r} requires its full menu; missing [{', '.join(absent)}]"
)
verdict = decide(prompt, scenario)
# The tag is passthrough: echo whatever the caller attached to the picked
# option, exactly as the gateway does, so a client that trusted it breaks
# here too.
harness = next(
(
o.get("harness")
for o in options
if isinstance(o, dict) and str(o.get("model") or "").lower() == verdict.model
),
None,
)
selection: dict[str, Any] = {"route_option": {"model": verdict.model}, "params": {}}
if harness:
selection["route_option"]["harness"] = harness
return {"route_selection": [selection], "rationale": verdict.rationale}
@dataclass
class RecordedCall:
"""One request the mock served, for post-hoc assertions."""
prompt: str
offered: tuple[str, ...]
router_name: str | None
status: int
model: str | None
rationale: str | None
@dataclass
class MockRouter:
"""Handle on a running mock routing service."""
base_url: str
calls: list[RecordedCall] = field(default_factory=list)
_lock: threading.Lock = field(default_factory=threading.Lock)
def record(self, call: RecordedCall) -> None:
"""Append *call* to the served-request log.
:param call: The request/response pair just served.
"""
with self._lock:
self.calls.append(call)
def reset(self) -> None:
"""Clear the served-request log."""
with self._lock:
self.calls.clear()
def snapshot(self) -> list[RecordedCall]:
"""Return a stable copy of the served-request log.
:returns: The calls served so far, oldest first.
"""
with self._lock:
return list(self.calls)
def count(self) -> int:
"""Return how many requests the mock has served.
:returns: The served-request count.
"""
with self._lock:
return len(self.calls)
def calls_with(self, needle: str) -> list[RecordedCall]:
"""Return the served calls whose prompt contains *needle*.
The suite shares one mock across a session, and a pane from an earlier
CUJ can still be working while a later one runs. Counting only the calls
carrying this test's own prompt text keeps "exactly one routing call" and
"zero routing calls" honest instead of coupling them to whatever else is
alive.
:param needle: A distinctive fragment of the prompt under test.
:returns: The matching calls, oldest first.
"""
with self._lock:
return [call for call in self.calls if needle in call.prompt]
def _handler_class(router: MockRouter) -> type[BaseHTTPRequestHandler]:
"""Build the request handler bound to *router*.
:param router: Handle the served requests are recorded on.
:returns: A ``BaseHTTPRequestHandler`` subclass.
"""
class _Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_args: Any) -> None:
"""Silence the stdlib access log; pytest captures enough."""
def _reply(self, status: int, payload: dict[str, Any]) -> None:
raw = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_POST(self) -> None:
"""Serve ``routes:select``; 404 anything else."""
if not self.path.endswith(ROUTES_SELECT_PATH):
self._reply(404, {"error_code": "NOT_FOUND", "message": f"no route {self.path}"})
return
length = int(self.headers.get("Content-Length") or 0)
try:
body = json.loads(self.rfile.read(length).decode("utf-8"))
except ValueError:
self._reply(400, {"error_code": "BAD_REQUEST", "message": "body is not JSON"})
return
if not isinstance(body, dict):
self._reply(400, {"error_code": "BAD_REQUEST", "message": "body is not an object"})
return
offered = tuple(
str(o.get("model") or "")
for o in (body.get("route_options") or [])
if isinstance(o, dict)
)
prompt = str((body.get("task") or {}).get("prompt") or "")
router_name = (body.get("route_selector") or {}).get("router_name")
try:
payload = select_route(body)
except MenuRejected as exc:
router.record(
RecordedCall(
prompt=prompt,
offered=offered,
router_name=router_name,
status=400,
model=None,
rationale=None,
)
)
self._reply(400, {"error_code": "BAD_REQUEST", "message": exc.message})
return
router.record(
RecordedCall(
prompt=prompt,
offered=offered,
router_name=router_name,
status=200,
model=payload["route_selection"][0]["route_option"]["model"],
rationale=payload["rationale"],
)
)
self._reply(200, payload)
return _Handler
def serve_mock_router() -> Iterator[MockRouter]:
"""Run the mock routing service on a loopback port for the caller's scope.
:yields: The :class:`MockRouter` handle, whose ``base_url`` goes straight
into a server config's ``routing.base_url``.
"""
router = MockRouter(base_url="")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _handler_class(router))
httpd.daemon_threads = True
host, port = httpd.server_address[0], httpd.server_address[1]
router.base_url = f"http://{host}:{port}{BASE_PATH}"
thread = threading.Thread(target=httpd.serve_forever, name="mock-routes-select", daemon=True)
thread.start()
try:
yield router
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=5)
+635
View File
@@ -0,0 +1,635 @@
"""Fixtures for the e2e Smart Routing CUJ suite.
The stack each CUJ runs against:
* :func:`mock_router` the deterministic ``routes:select`` service
(``_mock_router``). No AI-Gateway ``task_v1`` dependency, so the routing
verdicts are fixed and the assertions can name exact arms and rationales.
* :func:`routing_server` a real ``omnigent server`` subprocess on a free
port with a temp sqlite DB, whose ``routing:`` block points at the mock.
Modeled on ``tests/e2e/conftest.py``'s ``live_server``: health poll before
yielding, log tail on timeout, SIGTERM teardown.
* :func:`routing_host` a real ``omnigent host`` daemon registered against
that server, under the developer's real ``$HOME`` (the ``claude`` / ``codex``
logins cannot be relocated) but an isolated ``OMNIGENT_CONFIG_HOME`` /
``OMNIGENT_DATA_DIR``. It never reads or writes ``~/.omnigent``.
The panes launched by these tests do real inference on the gateway, so no CUJ
asserts on answer content only on routing artifacts. That is deliberate: a
slow or failed generation must not be able to redden a routing test.
"""
from __future__ import annotations
import os
import shutil
import signal
import socket
import subprocess
import sys
import time
import uuid
from collections.abc import Callable, Iterator, Sequence
from pathlib import Path
from typing import Any
import httpx
import pytest
import yaml
from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN
from tests.e2e.routing._helpers import POLL_INTERVAL_S, wait_for
from tests.e2e.routing._mock_router import MockRouter, serve_mock_router
_REPO_ROOT = Path(__file__).resolve().parents[3]
#: Opt-in gate. This suite launches real ``claude`` / ``codex`` TUIs against a
#: real gateway, so it never runs in CI by accident.
_RUN_GATE_ENV = "OMNIGENT_E2E_SMART_ROUTING"
#: Second gate for the repeat-run reliability variants (CUJ 4's 5x nightly
#: form), which multiply an already slow suite.
RELIABILITY_GATE_ENV = "OMNIGENT_E2E_RELIABILITY"
#: Where the provider config for the host comes from. The routing worktree's
#: isolated dev config is the default; override to point at another workspace.
_PROVIDER_CONFIG_ENV = "OMNIGENT_E2E_ROUTING_PROVIDER_CONFIG"
#: Seconds to wait for the server's health endpoint.
_SERVER_HEALTH_TIMEOUT_S = 60.0
#: Seconds to wait for the host daemon to register.
_HOST_REGISTER_TIMEOUT_S = 60.0
#: Catalog prefixes this deployment's model ids carry and the router does not
#: expect. ``system.ai.`` keeps its trailing dot — without it ids strip to
#: ``.claude-opus-5`` and malformed names reach the router.
MODEL_PREFIXES: tuple[str, ...] = ("databricks-", "system.ai.")
#: Where :func:`routing_server` records its log path, so
#: :func:`routing_server_log` can hand it to a test without guessing at the
#: temp-dir naming the factory chose.
_server_log_path: list[Path] = []
@pytest.fixture(autouse=True, scope="session")
def _require_opt_in() -> None:
"""Skip the whole suite unless it was asked for explicitly."""
if os.environ.get(_RUN_GATE_ENV) != "1":
pytest.skip(
f"set {_RUN_GATE_ENV}=1 to run the Smart Routing e2e CUJs "
"(they launch real claude/codex TUIs and a real host daemon)"
)
def _free_port() -> int:
"""Return a free TCP port on loopback.
:returns: A port number nothing is currently listening on.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
@pytest.fixture(scope="session")
def mock_router() -> Iterator[MockRouter]:
"""Run the deterministic ``routes:select`` mock for the session.
:yields: The router handle; ``base_url`` goes into the server's
``routing.base_url``.
"""
yield from serve_mock_router()
@pytest.fixture(autouse=True)
def _reset_mock_router(mock_router: MockRouter) -> Iterator[None]:
"""Clear the mock's served-request log between tests.
Several CUJs count routing calls ("turn 2 must issue zero"), which only
means anything against a per-test baseline.
"""
mock_router.reset()
yield
def _provider_block() -> dict[str, Any]:
"""Read the ``providers:`` block the host needs for gateway-backed launches.
Smart Routing's apply layer can only rewrite a launch's model when the
launch resolves through the Databricks AI Gateway, so the host has to see a
databricks provider. The routing worktree's isolated dev config is the
canonical source (three canonical prompt classes: trivial, delegate-shaped, crosscutting).
:returns: The ``providers`` mapping.
:raises pytest.skip.Exception: When no provider config can be found.
"""
override = os.environ.get(_PROVIDER_CONFIG_ENV)
candidates = [Path(override)] if override else [_REPO_ROOT / ".omnigent-local" / "config.yaml"]
for path in candidates:
if not path.is_file():
continue
parsed = yaml.safe_load(path.read_text()) or {}
providers = parsed.get("providers")
if isinstance(providers, dict) and providers:
return providers
pytest.skip(
"no gateway provider config found "
f"(looked at {', '.join(str(c) for c in candidates)}); "
f"set {_PROVIDER_CONFIG_ENV} to a config.yaml carrying a databricks provider"
)
@pytest.fixture(scope="session")
def routing_server(
mock_router: MockRouter,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""Start an ``omnigent server`` whose router is the mock; yield its base URL.
A ``live_server`` variant: free port, temp sqlite, health poll before
yielding, server log tailed into the failure message, SIGTERM teardown. The
only routing source configured is ``external`` pointed at the mock there
is deliberately no server ``llm:`` block, so the built-in judge is
unavailable and every decision must come from the external client. That is
what makes ``router_source == "databricks-aigw"`` a real assertion rather
than a coin flip.
:param mock_router: The mock whose base URL becomes ``routing.base_url``.
:param tmp_path_factory: Pytest temp path factory.
:yields: The server base URL, e.g. ``"http://127.0.0.1:54321"``.
"""
port = _free_port()
root = tmp_path_factory.mktemp("routing_e2e_server")
db_path = root / "routing_e2e.db"
config_path = root / "server.yaml"
log_path = root / "server.log"
_server_log_path.append(log_path)
config_path.write_text(
yaml.safe_dump(
{
"routing": {
"provider": "external",
"base_url": mock_router.base_url,
"router_name": "task_v1",
"model_prefix": list(MODEL_PREFIXES),
}
},
sort_keys=False,
)
)
env = {**os.environ, "PYTHONPATH": str(_REPO_ROOT), "OMNIGENT_LOG_TO_STDERR": "1"}
log_handle = open(log_path, "w") # noqa: SIM115 — lives for the subprocess's lifetime
proc = subprocess.Popen(
[
sys.executable,
"-m",
"omnigent.cli",
"server",
"--port",
str(port),
"--database-uri",
f"sqlite:///{db_path}",
"--artifact-location",
str(root / "artifacts"),
"--config",
str(config_path),
],
env=env,
stdout=log_handle,
stderr=subprocess.STDOUT,
)
base_url = f"http://127.0.0.1:{port}"
def _tail() -> str:
return log_path.read_text(errors="replace")[-4000:] if log_path.exists() else ""
deadline = time.monotonic() + _SERVER_HEALTH_TIMEOUT_S
healthy = False
while time.monotonic() < deadline:
if proc.poll() is not None:
break
try:
if httpx.get(f"{base_url}/health", timeout=2).status_code == 200:
healthy = True
break
except httpx.HTTPError:
pass
time.sleep(POLL_INTERVAL_S)
if not healthy:
proc.kill()
proc.wait(timeout=5)
log_handle.close()
raise RuntimeError(
f"routing server did not start within {_SERVER_HEALTH_TIMEOUT_S}s.\n"
f"log at {log_path}:\n{_tail()}"
)
try:
yield base_url
finally:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
log_handle.close()
@pytest.fixture(scope="session")
def routing_server_log(routing_server: str) -> Path:
"""Return the routing server's log path.
Recipe R4 reads it for the routing gate lines (``route_turn skipped ``,
``harness=X cannot run Y``, ``routes:select returned ``).
:param routing_server: Forces the server to be up, which is what records
the path.
:returns: The ``server.log`` path.
"""
del routing_server
assert _server_log_path, "the routing server fixture did not record its log path"
return _server_log_path[-1]
@pytest.fixture(scope="session")
def routing_client(routing_server: str) -> Iterator[httpx.Client]:
"""HTTP client pointed at the routing server.
:param routing_server: The server base URL.
:yields: A client announcing itself as a first-party non-browser caller.
"""
with httpx.Client(
base_url=routing_server,
timeout=120,
headers={"Origin": OMNIGENT_INTERNAL_WS_ORIGIN},
) as client:
yield client
@pytest.fixture(scope="session")
def routing_host(
routing_server: str,
routing_client: httpx.Client,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""Spawn an ``omnigent host`` against the routing server; yield its host id.
The daemon inherits the real ``$HOME`` the ``claude`` and ``codex``
logins live there and cannot be relocated but its omnigent config home
and data dir are temporary, seeded with only the gateway ``providers:``
block. ``~/.omnigent/config.yaml`` is never read or written.
:param routing_server: Server URL the daemon registers with.
:param routing_client: Used to poll ``GET /v1/hosts``.
:param tmp_path_factory: Pytest temp path factory.
:yields: The registered host's ``host_id``.
"""
providers = _provider_block()
root = tmp_path_factory.mktemp("routing_e2e_host")
config_home = root / "config-home"
config_home.mkdir()
(config_home / "config.yaml").write_text(
yaml.safe_dump({"providers": providers}, sort_keys=False)
)
log_path = root / "host.log"
env = {
**os.environ,
"OMNIGENT_CONFIG_HOME": str(config_home),
"OMNIGENT_DATA_DIR": str(root / "data"),
"PYTHONPATH": str(_REPO_ROOT),
"OMNIGENT_LOG_TO_STDERR": "1",
}
# Claude Code refuses to start a nested session; the agent process running
# this suite may export the marker.
env.pop("CLAUDECODE", None)
log_handle = open(log_path, "w") # noqa: SIM115 — lives for the subprocess's lifetime
proc = subprocess.Popen(
[sys.executable, "-m", "omnigent.host._daemon_entry", "--server", routing_server],
env=env,
stdout=subprocess.DEVNULL,
stderr=log_handle,
)
def _online() -> str | None:
if proc.poll() is not None:
return None
resp = routing_client.get("/v1/hosts")
if resp.status_code != 200:
return None
online = [h for h in resp.json().get("hosts", []) if h.get("status") == "online"]
return str(online[0]["host_id"]) if online else None
try:
host_id = wait_for(
_online,
timeout=_HOST_REGISTER_TIMEOUT_S,
what="the host daemon to register",
)
except AssertionError as exc:
proc.kill()
proc.wait(timeout=5)
log_handle.close()
tail = log_path.read_text(errors="replace")[-3000:] if log_path.exists() else ""
pytest.skip(f"could not bring up an omnigent host: {exc}\nhost log tail:\n{tail}")
try:
yield host_id
finally:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
log_handle.close()
@pytest.fixture
def routing_arms(
routing_client: httpx.Client,
routing_host: str,
) -> Callable[..., None]:
"""Return a per-test guard on the arms a CUJ needs being gateway-backed.
A family whose inference is not AI-Gateway-backed cannot run the router's
picks, and the server correctly hides Smart Routing for it so a CUJ over
that family would be scoring the gate, not the routing. Unknown is not
"off the gateway": a host reporting nothing for a family keeps the gateway
router, so only an explicit ``false`` skips.
:param routing_client: Used to read ``GET /v1/hosts``.
:param routing_host: The host whose readiness is read.
:returns: ``require(*harnesses)``, which skips with a named reason.
"""
def _require(*harnesses: str) -> None:
resp = routing_client.get("/v1/hosts")
resp.raise_for_status()
row = next(
(h for h in resp.json().get("hosts", []) if h.get("host_id") == routing_host),
None,
)
assert row is not None, f"host {routing_host} vanished from /v1/hosts"
backing = row.get("gateway_inference") or {}
unbacked = [h for h in harnesses if backing.get(h) is False]
if unbacked:
pytest.skip(
f"not AI-Gateway-backed on this host: {', '.join(unbacked)}"
"Smart Routing is correctly hidden for those families, so this CUJ "
"would score the gate instead of the routing"
)
for harness in harnesses:
cli = {"claude-native": "claude", "codex-native": "codex"}.get(harness)
if cli is not None and shutil.which(cli) is None:
pytest.skip(f"{cli!r} CLI is not on PATH; {harness} cannot launch")
return _require
@pytest.fixture
def type_into_tui() -> Callable[[str, str, str], None]:
"""Return a helper that TYPES text into a tmux pane and presses Enter.
The TUI halves of this suite must type, not POST: a posted message enters
through the server's composer path, which routes on a different gate
entirely. Only a keystroke reaches the harness's ``UserPromptSubmit`` hook,
which is what the first-message CUJs are about.
``load-buffer`` + ``paste-buffer -p`` delivers the text as one bracketed
paste, so a multi-line prompt cannot be interpreted line-by-line as
several submissions; Enter is sent separately once the paste has landed.
:returns: ``type_into(socket_path, target, text)``.
"""
def _type_into(socket_path: str, target: str, text: str) -> None:
subprocess.run(
["tmux", "-S", socket_path, "load-buffer", "-b", "omni-routing-e2e", "-"],
input=text.encode("utf-8"),
check=True,
timeout=15,
)
subprocess.run(
[
"tmux",
"-S",
socket_path,
"paste-buffer",
"-p",
"-b",
"omni-routing-e2e",
"-t",
target,
],
check=True,
timeout=15,
)
# Let the TUI's input box absorb the paste before submitting it.
time.sleep(1.0)
subprocess.run(
["tmux", "-S", socket_path, "send-keys", "-t", target, "Enter"],
check=True,
timeout=15,
)
return _type_into
def create_routed_session(
client: httpx.Client,
*,
agent_name: str,
host_id: str,
workspace: Path,
message: str | None = None,
harness_override: str | None = None,
terminal_launch_args: Sequence[str] | None = None,
) -> dict[str, Any]:
"""Create a Smart Routing session the way the web UI's create does.
The routing contract is ``cost_control_mode_override="on"`` plus **no**
model or effort pin a pin silently disables routing. A
``smart_routing_message`` routes at create time; omitting it leaves the
pick to the harness's own first-message hook.
:param client: HTTP client pointed at the routing server.
:param agent_name: Built-in wrapper agent, e.g. ``"claude-native-ui"``.
:param host_id: Host to launch on.
:param workspace: Absolute workspace path on that host.
:param message: The first-message text to route on, or ``None`` for a bare
Smart Routing create.
:param harness_override: ``"auto"`` to let the router pick the harness too.
:param terminal_launch_args: Pass-through CLI args for the native launch,
e.g. :func:`bypass_args`.
:returns: The create response body.
"""
agents = client.get("/v1/agents")
agents.raise_for_status()
agent_id = next(
(a["id"] for a in agents.json()["data"] if a["name"] == agent_name),
None,
)
assert agent_id is not None, f"{agent_name!r} is not registered on the server"
body: dict[str, Any] = {
"agent_id": agent_id,
"host_id": host_id,
"workspace": str(workspace),
"cost_control_mode_override": "on",
}
if message is not None:
body["smart_routing_message"] = message
if harness_override is not None:
body["harness_override"] = harness_override
if terminal_launch_args:
body["terminal_launch_args"] = list(terminal_launch_args)
resp = client.post("/v1/sessions", json=body, timeout=120.0)
assert resp.status_code < 400, f"routed create failed {resp.status_code}: {resp.text[:2000]}"
payload = resp.json()
return payload if isinstance(payload, dict) else {}
def post_user_message(client: httpx.Client, session_id: str, text: str) -> None:
"""POST *text* into *session_id* as a user message event.
:param client: HTTP client pointed at the routing server.
:param session_id: Session id.
:param text: The raw message text; never wrapped, because ``task.prompt``
is the whole routing signal.
"""
resp = client.post(
f"/v1/sessions/{session_id}/events",
json={
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
},
timeout=60.0,
)
assert resp.status_code < 400, f"message POST failed {resp.status_code}: {resp.text[:1000]}"
def trusted_workspace(root: Path, name: str) -> Path:
"""Create a workspace dir the native CLIs will start in without prompting.
:param root: Parent temp dir.
:param name: Subdirectory name.
:returns: The created workspace path.
"""
workspace = root / name
workspace.mkdir(parents=True, exist_ok=True)
(workspace / "README.md").write_text("# routing e2e workspace\n")
return workspace
def prompts() -> dict[str, str]:
"""Return the canonical CUJ prompts, keyed by handle.
Held verbatim: the prompt IS the whole routing signal, so any wrapper or
paraphrase changes the answer.
:returns: ``{"trivial": ..., "delegate": ..., "crosscutting": ...}``.
"""
return {
"trivial": "hi",
"delegate": (
"The `parse_duration` helper in our config module returns None for values "
'like "1h30m" because its regex only matches a single unit group, so any '
"compound duration silently becomes None and the caller falls back to the "
"default timeout. Fix the parser to accept compound durations combining "
'days, hours, minutes, and seconds (e.g. "1h30m", "2d4h", "45s") and '
"return the total number of seconds as an int."
),
"escalate": (
"Add a --dry-run flag to our `deploy` CLI command. When passed, the command "
"should resolve the full deployment plan and print it as a human-readable "
"table, then exit 0 without calling the orchestration API or mutating any "
"state. Reuse the existing plan-resolution logic from the real run."
),
"crosscutting": (
"We need to rethink routing across every surface end to end: the server, "
"the runner, both native harnesses, the CLI entry points and the web chips. "
"There are many open questions and the migration touches everything, so "
"start by mapping what exists today and where the seams are, then propose "
"an ordering that keeps each step shippable on its own. "
)
* 4,
}
def unique_trivial_prompt(label: str) -> str:
"""Return a rule-0 prompt no other test in the session can collide with.
Rule-0 keys on length and the absence of error/code markers, so a unique
tail keeps the routing verdict identical while making the prompt findable in
the mock's served-request log — which is how the "exactly one routing call"
and "zero routing calls" assertions stay immune to a pane still working in
another CUJ.
:param label: Short marker naming the caller, e.g. ``"claude-turn-1"``.
:returns: A short, code-free prompt.
"""
return f"say hello and nothing else, run marker {label} {uuid.uuid4().hex[:8]}"
def spawn_prompt(*, task: str, tool_hint: str) -> str:
"""Build a message that reliably makes a coding CLI issue one spawn.
Whether a model *chooses* to delegate is judgement; this suite is about how
a spawn is ROUTED, so the instruction is explicit. The delegated task text
is what the router scores, so it is passed through verbatim.
:param task: The sub-task text to hand the subagent.
:param tool_hint: The harness's own spawn-tool name, e.g. ``"Task"``.
:returns: The message to send.
"""
return (
f"Use your {tool_hint} tool right now to start exactly one subagent, and do "
"no other work first. Give the subagent exactly this task, verbatim:\n\n"
f"{task}\n\n"
"Do not do the task yourself and do not wait for the subagent's answer — "
"issue the spawn, then reply with the single word DISPATCHED."
)
def bypass_args(harness: str) -> list[str]:
"""Launch args that get a native CLI past its first-run gates.
A fresh temp workspace is untrusted, and both CLIs block their input box on
a trust / approval dialog before any hook can fire which would make these
CUJs time out on a dialog rather than score routing. Passing the bypass flag
through ``terminal_launch_args`` keeps the developer's ``$HOME`` untouched;
the alternative (seeding trust entries into ``~/.claude.json``) mutates it.
:param harness: ``"claude-native"`` or ``"codex-native"``.
:returns: Args for the create's ``terminal_launch_args``.
"""
if harness == "claude-native":
return ["--dangerously-skip-permissions"]
return ["--dangerously-bypass-approvals-and-sandbox"]
def spawn_tool_for(harness: str) -> str:
"""Return the built-in spawn tool a harness advertises.
:param harness: ``"claude-native"`` or ``"codex-native"``.
:returns: The tool name to name in a prompt.
"""
return "Task" if harness == "claude-native" else "spawn_agent"
def require_tmux() -> None:
"""Skip when ``tmux`` is missing — the TUI halves cannot be driven without it."""
if shutil.which("tmux") is None:
pytest.skip("'tmux' is not on PATH; the TUI halves of this suite need it")
def sequence(values: Sequence[str]) -> str:
"""Join *values* for a readable assertion message.
:param values: Strings to join.
:returns: A comma-separated list, or ``"<none>"`` when empty.
"""
return ", ".join(values) if values else "<none>"
@@ -0,0 +1,203 @@
"""CUJ 5 — the auto harness: routing picks the family, and a spawn crosses it.
The web UI's top-level "Smart Routing" harness row, driven over the API. This
is the only scenario where cross-harness spawns are legal, so it is the only
place the redirect can be exercised end to end:
1. ``harness_override="auto"`` + ``cost_control_mode_override="on"`` +
``smart_routing_message``. The terminal launches with the session row, so a
native session's HARNESS cannot wait for a first message — the create routes
it over the ``both`` menu (all five arms) and rebinds the session to the
wrapper it picked.
2. Score the create: a concrete arm, the auto-harness label recorded durably
(it is what lets subagent routing offer the other family later), and Smart
Routing stamped on the row.
3. Send a message whose sub-task the ``both`` recipe places on the **counterpart**
family. The spawn is denied with an instruction naming
``mcp__omnigent__sys_session_create`` the tool a routed spawn actually
holds and the ``native_subagent`` decision records the cross-family arm.
**Nothing is awaited past the spawn.** The child conversation row is checked in
a short best-effort window only; the inbox return never is. A cross-harness
spawn's result arriving is a property of the child session, not of routing.
"""
from __future__ import annotations
from pathlib import Path
import httpx
import pytest
from omnigent.runner.subagent_routing import AUTO_HARNESS_LABEL_KEY
from tests.e2e.routing._helpers import (
arm_in,
claude_bridge_dir,
claude_settings_apart_from_the_model,
decisions,
same_arm,
session_snapshot,
tmux_target,
wait_for,
wait_for_bridge_file,
wait_for_decision,
wait_for_pane_identifier,
)
from tests.e2e.routing._mock_router import CLAUDE_ARMS, CODEX_ARMS, MockRouter, decide
from tests.e2e.routing.conftest import (
bypass_args,
create_routed_session,
post_user_message,
prompts,
require_tmux,
spawn_prompt,
trusted_workspace,
unique_trivial_prompt,
)
# The suite's default per-test cap is 300s, which this CUJ's own waits can
# legitimately exceed end to end (launch, a real turn, the spawn, the pane). The
# budget has to sit above the sum of them, or the harness fires first and the
# traceback points at a sleep instead of naming what never happened.
pytestmark = [pytest.mark.smart_routing, pytest.mark.timeout(900)]
#: The routed spawn's deny reason must name the tool the session actually holds,
#: in the spelling claude-native advertises it under.
_REDIRECT_TOOL = "mcp__omnigent__sys_session_create"
_SPAWN_TIMEOUT_S = 300.0
_PANE_TIMEOUT_S = 300.0
#: Best-effort window for the child conversation row. Short on purpose: the
#: child's existence is a nice-to-have signal, and waiting on it would turn this
#: CUJ into a test of child-session startup.
_CHILD_WINDOW_S = 45.0
def _cross_family_subtask() -> str:
"""Return a sub-task the ``both`` recipe places on the Codex family.
Short and code-free, so rule-0 fires and picks the scenario's cheapest arm —
which on any Codex-bearing scenario is a Codex arm, the counterpart of a
Claude session.
:returns: The sub-task text.
"""
return unique_trivial_prompt("auto-subtask")
def test_auto_harness_lands_an_arm_then_redirects_a_cross_family_spawn(
routing_client: httpx.Client,
routing_host: str,
routing_arms: object,
mock_router: MockRouter,
tmp_path: Path,
) -> None:
require_tmux()
# The auto route chooses between both families, so both must be backed.
routing_arms("claude-native", "codex-native") # type: ignore[operator]
settings_before = claude_settings_apart_from_the_model()
workspace = trusted_workspace(tmp_path, "auto_ws")
# A contained, code-referencing task: on the ``both`` menu the recipe
# escalates it to the Claude arm, which lands the session on claude-native
# and makes the Codex family the counterpart.
session_message = prompts()["escalate"]
created = create_routed_session(
routing_client,
# The auto create binds the claude-native built-in as a placeholder; the
# server rebinds it to whichever wrapper routing picks.
agent_name="claude-native-ui",
host_id=routing_host,
workspace=workspace,
message=session_message,
harness_override="auto",
terminal_launch_args=bypass_args("claude-native"),
)
session_id = created["id"]
# ── The create landed a concrete arm on a concrete harness ───────────────
decision = wait_for_decision(routing_client, session_id, scope="session", timeout=60.0)
expected = decide(session_message, "both")
assert decision["rationale"] == expected.rationale
assert decision["router_source"] == "databricks-aigw"
assert decision["applied"] is True
raw_pick = decision.get("raw_model") or decision["model"]
assert same_arm(raw_pick, expected.model), (
f"the auto create picked {raw_pick!r}, expected the {expected.model!r} arm"
)
assert decision["harness"] == "claude-native", (
f"the {expected.model!r} pick must land on claude-native, not {decision['harness']!r}"
)
create_calls = mock_router.calls_with("--dry-run")
assert create_calls, "the create issued no routing call"
for arm in (*CLAUDE_ARMS, *CODEX_ARMS):
assert arm in create_calls[0].offered, (
f"the auto create must offer the full both-scenario menu; {arm} was absent"
)
snapshot = session_snapshot(routing_client, session_id)
assert snapshot["cost_control_mode_override"] == "on"
assert (snapshot.get("labels") or {}).get(AUTO_HARNESS_LABEL_KEY) == "1", (
"the auto start must be recorded durably — it is what lets subagent "
"routing offer picks from the other harness family"
)
assert same_arm(snapshot.get("model_override"), decision["model"])
# ── The cross-family spawn: routed, denied, and named ────────────────────
subtask = _cross_family_subtask()
post_user_message(
routing_client,
session_id,
spawn_prompt(task=subtask, tool_hint="Task"),
)
spawn_decision = wait_for(
lambda: next(
iter(decisions(routing_client, session_id, scope="native_subagent")),
None,
),
timeout=_SPAWN_TIMEOUT_S,
what="a native_subagent routing decision for the cross-family spawn",
)
spawn_raw = spawn_decision.get("raw_model") or spawn_decision["model"]
assert same_arm(spawn_raw, decide(subtask, "both").model), (
f"the sub-task routed to {spawn_raw!r}, expected the both-scenario "
f"rule-0 arm {decide(subtask, 'both').model!r}"
)
assert arm_in(spawn_raw, CODEX_ARMS), (
f"the spawn was expected on the counterpart (Codex) family; got {spawn_raw!r}"
)
assert spawn_decision["router_source"] == "databricks-aigw"
# The deny is the actuation: it must name the tool the session holds, in the
# spelling claude-native advertises it under, or the model drops the sub-task.
bridge_dir = claude_bridge_dir(session_id)
wait_for_bridge_file(bridge_dir, "tmux.json", timeout=120.0)
socket_path, target = tmux_target(bridge_dir)
wait_for_pane_identifier(socket_path, target, _REDIRECT_TOOL, timeout=_PANE_TIMEOUT_S)
# ── Best effort only: the child row, never the child's result ────────────
def _child() -> dict[str, object] | None:
resp = routing_client.get("/v1/sessions", params={"limit": 100})
if resp.status_code != 200:
return None
for row in resp.json().get("data", []):
if row.get("parent_session_id") == session_id:
return row
return None
try:
child = wait_for(_child, timeout=_CHILD_WINDOW_S, what="a child conversation row")
except AssertionError:
child = None
if child is not None:
assert not arm_in(str(child.get("model_override") or ""), CLAUDE_ARMS), (
"the redirected child session must run the routed Codex arm, not a "
f"Claude one (model_override={child.get('model_override')!r})"
)
assert claude_settings_apart_from_the_model() == settings_before, (
"~/.claude/settings.json changed beyond its `model` key — routing must "
"confine its hook wiring to the session's bridge dir"
)
@@ -0,0 +1,196 @@
"""CUJ 3 — claude-native in-harness routing of the first message TYPED into the TUI.
A bare ``omnigent claude`` / bare web session starts with no prompt, so there is
nothing to route at create time. The first real user message triggers exactly
one routing call from inside the harness a ``UserPromptSubmit`` hook and the
routed model is applied *before* that message runs.
The prompt is **typed into the pane**, never POSTed. A posted message enters
through the server's composer path, which routes on a different gate entirely;
only a keystroke reaches the harness hook, so a POST here would score the
composer and quietly prove nothing about this CUJ.
What is scored:
* **Block-and-replay, once.** Claude's hook cannot retarget a turn in flight, so
it blocks the prompt, the model is switched while nothing is running, and the
runner replays the captured prompt. The block notice must appear **once**
twice means the replay itself was routed again.
* **The switch actually lands.** The apply layer types ``/model <id>``, so the
pane must report the switch before the replay runs. Claude Code answers that
form by also rewriting the ``model`` key in the developer's own
``~/.claude/settings.json`` an accepted trade-off so everything *else* in
that file must be unchanged.
* **One decision, one turn.** A leaked replay shows up as two user messages for
one thing typed.
* **The second prompt fast-skips.** Turn 2 must issue **zero** routing calls
the gate is the session's routing-decision label, with the bridge-dir marker
as a local fast skip.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import httpx
import pytest
from omnigent.runner.turn_routing import MARKER_FILE
from tests.e2e.routing._helpers import (
capture_pane,
claude_bridge_dir,
claude_settings_apart_from_the_model,
decisions,
same_arm,
settle,
tmux_target,
user_messages,
wait_for,
wait_for_bridge_file,
wait_for_pane_text,
)
from tests.e2e.routing._mock_router import MockRouter, decide
from tests.e2e.routing.conftest import (
bypass_args,
create_routed_session,
require_tmux,
trusted_workspace,
unique_trivial_prompt,
)
# The suite's default per-test cap is 300s; this CUJ's own waits (launch, a real
# turn, the spawn or the replay) can legitimately exceed it end to end. The budget
# sits above their sum so an internal wait names what never happened instead of
# the harness firing first on a sleep.
pytestmark = [pytest.mark.smart_routing, pytest.mark.timeout(900)]
#: The hook's own block notice, printed into the pane before the replay.
_BLOCK_NOTICE = "Smart Routing selected"
#: What Claude Code prints once ``/model <id>`` has been applied.
_MODEL_APPLIED_HINT = "Set model to"
#: Text claude's TUI shows once its input box is mounted. Typing before this is
#: the dropped-first-message race: claude flushes pending input on boot.
_PANE_READY_HINTS = ("for shortcuts", "Welcome to Claude", "")
_LAUNCH_TIMEOUT_S = 180.0
_ROUTE_TIMEOUT_S = 180.0
#: Quiet window used for the negative checks ("no second decision", "no second
#: routing call"). Comfortably past the hook's whole timeout ladder, whose
#: outermost hop is 45s.
_QUIET_WINDOW_S = 60.0
def _wait_for_pane_ready(socket_path: str, target: str) -> None:
"""Wait until claude's input box is mounted.
:param socket_path: The tmux server socket.
:param target: The pane target.
"""
wait_for(
lambda: (
True
if any(hint in capture_pane(socket_path, target) for hint in _PANE_READY_HINTS)
else None
),
timeout=_LAUNCH_TIMEOUT_S,
what="claude's input box to mount",
)
def test_claude_routes_the_first_typed_message_exactly_once(
routing_client: httpx.Client,
routing_host: str,
routing_arms: object,
mock_router: MockRouter,
type_into_tui: Callable[[str, str, str], None],
tmp_path: Path,
) -> None:
require_tmux()
routing_arms("claude-native") # type: ignore[operator]
settings_before = claude_settings_apart_from_the_model()
workspace = trusted_workspace(tmp_path, "claude_hook_ws")
# A BARE routed create: Smart Routing on, no prompt, so nothing is routed
# yet and the pick is left to the in-harness hook.
created = create_routed_session(
routing_client,
agent_name="claude-native-ui",
host_id=routing_host,
workspace=workspace,
message=None,
terminal_launch_args=bypass_args("claude-native"),
)
session_id = created["id"]
assert not decisions(routing_client, session_id), (
"a bare create must route nothing — the first typed message is the trigger"
)
bridge_dir = claude_bridge_dir(session_id)
wait_for_bridge_file(bridge_dir, "tmux.json", timeout=_LAUNCH_TIMEOUT_S)
socket_path, target = tmux_target(bridge_dir)
_wait_for_pane_ready(socket_path, target)
# ── Turn 1: typed, blocked, routed, replayed ────────────────────────────
first = unique_trivial_prompt("claude-turn-1")
type_into_tui(socket_path, target, first)
pane = wait_for_pane_text(socket_path, target, _BLOCK_NOTICE, timeout=_ROUTE_TIMEOUT_S)
assert pane.count(_BLOCK_NOTICE) == 1, (
f"the block notice appeared {pane.count(_BLOCK_NOTICE)} times; the replayed "
"prompt must not be routed again\n" + pane[-2000:]
)
decision = wait_for(
lambda: next(iter(decisions(routing_client, session_id, scope="turn")), None),
timeout=_ROUTE_TIMEOUT_S,
what="the in-harness turn routing decision",
)
expected = decide(first, "cc")
assert decision["rationale"] == expected.rationale
assert decision["router_source"] == "databricks-aigw"
raw_pick = decision.get("raw_model") or decision["model"]
assert same_arm(raw_pick, expected.model), (
f"the hook routed to {raw_pick!r}, expected the {expected.model!r} arm"
)
# The apply layer must have typed ``/model <id>`` and had it take effect.
wait_for_pane_text(socket_path, target, _MODEL_APPLIED_HINT, timeout=_ROUTE_TIMEOUT_S)
# The local fast-skip marker is written before the block.
wait_for_bridge_file(bridge_dir, MARKER_FILE, timeout=_ROUTE_TIMEOUT_S)
# One thing typed is one user turn: a leaked replay would show two.
turn_one_messages = wait_for(
lambda: msgs if (msgs := user_messages(routing_client, session_id)) else None,
timeout=_ROUTE_TIMEOUT_S,
what="the replayed prompt to land as a user message",
)
assert len(turn_one_messages) == 1, (
f"one typed prompt produced {len(turn_one_messages)} user messages — "
"the block-and-replay leaked the blocked prompt"
)
first_calls = mock_router.calls_with(first)
assert len(first_calls) == 1, (
f"turn 1 issued {len(first_calls)} routing calls for its own prompt, expected exactly 1"
)
# ── Turn 2: fast-skips, with zero network ───────────────────────────────
second = unique_trivial_prompt("claude-turn-2")
type_into_tui(socket_path, target, second)
settle(_QUIET_WINDOW_S)
assert len(decisions(routing_client, session_id)) == 1, (
"turn 2 produced a second routing decision; the session-level gate did not hold"
)
assert mock_router.calls_with(second) == [], (
"turn 2's prompt reached the router; the fast skip must be free"
)
assert claude_settings_apart_from_the_model() == settings_before, (
"~/.claude/settings.json changed beyond its `model` key — `/model <id>` "
"may move Claude Code's own default, nothing else"
)
@@ -0,0 +1,174 @@
"""CUJ 1 — claude-native, Smart Routing at session create, then a routed spawn.
The web UI's "Claude Code + Model = Smart Routing" path, driven over the API:
1. ``POST /v1/sessions`` with ``cost_control_mode_override="on"``, a
``smart_routing_message`` and **no** model pin exactly what the UI sends
(a pin silently disables routing). A ``claude-native`` session's turns originate in the TUI,
so the server has to route the model during the create or not at all.
2. Score the create: one ``session``-scope decision whose ``router_source`` is
``databricks-aigw`` (the external client answered this deployment has no
built-in judge configured), carrying the router's own rule trace, with the
pick applied to the session's ``model_override``.
3. POST a spawn-inducing message and score the ``native_subagent`` decision the
spawn produces. A ``cc`` session offers Claude arms only, so the spawn must
land on a Claude arm never a Codex one.
**Spawns are asserted, never awaited.** The bar is that the spawn was issued
and routed; the subagent's own answer is out of scope, so nothing here waits on
child output or an inbox return. Nor does anything assert on answer content:
the pane does real inference, and a slow generation must not be able to redden
a routing test.
"""
from __future__ import annotations
from pathlib import Path
import httpx
import pytest
from omnigent.server.smart_routing import models_in_family
from tests.e2e.routing._helpers import (
arm_in,
claude_settings_apart_from_the_model,
decisions,
same_arm,
session_snapshot,
wait_for,
wait_for_decision,
)
from tests.e2e.routing._mock_router import CLAUDE_ARMS, CODEX_ARMS, MockRouter, decide
from tests.e2e.routing.conftest import (
bypass_args,
create_routed_session,
post_user_message,
prompts,
require_tmux,
sequence,
spawn_prompt,
spawn_tool_for,
trusted_workspace,
unique_trivial_prompt,
)
# The suite's default per-test cap is 300s; this CUJ's own waits (launch, a real
# turn, the spawn or the replay) can legitimately exceed it end to end. The budget
# sits above their sum so an internal wait names what never happened instead of
# the harness firing first on a sleep.
pytestmark = [pytest.mark.smart_routing, pytest.mark.timeout(900)]
#: Seconds to wait for the pane to boot, run a turn and issue its spawn. Real
#: inference on a real CLI, so generous — but bounded, and nothing after the
#: spawn is waited on.
_SPAWN_TIMEOUT_S = 300.0
def test_claude_session_create_routes_the_model_and_then_the_spawn(
routing_client: httpx.Client,
routing_host: str,
routing_arms: object,
mock_router: MockRouter,
tmp_path: Path,
) -> None:
require_tmux()
routing_arms("claude-native") # type: ignore[operator]
settings_before = claude_settings_apart_from_the_model()
workspace = trusted_workspace(tmp_path, "claude_ui_ws")
# ── 1. The routed create ────────────────────────────────────────────────
# Unique but still rule-0 (short, no error/code markers), so the mock's
# served-request log can be filtered to this test's own calls even while a
# pane from another CUJ is alive.
create_message = unique_trivial_prompt("claude-create")
created = create_routed_session(
routing_client,
agent_name="claude-native-ui",
host_id=routing_host,
workspace=workspace,
message=create_message,
terminal_launch_args=bypass_args("claude-native"),
)
session_id = created["id"]
# ── 2. Score the create-time decision ───────────────────────────────────
decision = wait_for_decision(routing_client, session_id, scope="session", timeout=60.0)
expected = decide(create_message, "cc")
assert decision["rationale"] == expected.rationale, (
f"the decision carries {decision['rationale']!r}, not the router's rule trace"
)
assert decision["router_source"] == "databricks-aigw", (
"the external routes:select client answered, so the chip must disclose the "
f"gateway as the source; got {decision.get('router_source')!r}"
)
assert decision["applied"] is True
assert decision["harness"] == "claude-native"
# The raw pick is the router's vocabulary; ``model`` is what this deployment
# can actually serve. Both must name the sonnet arm the cc recipe chose.
raw_pick = decision.get("raw_model") or decision["model"]
assert same_arm(raw_pick, expected.model), (
f"router picked {raw_pick!r}, expected the {expected.model!r} arm"
)
assert models_in_family("claude-native", [decision["model"]]), (
f"a cc session was pinned to {decision['model']!r}, which is not a Claude arm"
)
snapshot = session_snapshot(routing_client, session_id)
assert snapshot["cost_control_mode_override"] == "on", (
"Smart Routing must stay stamped on the session row"
)
assert same_arm(snapshot.get("model_override"), decision["model"]), (
f"the routed model {decision['model']!r} was not pinned to the session "
f"(model_override={snapshot.get('model_override')!r})"
)
# Exactly one routing call served the create.
create_calls = mock_router.calls_with(create_message)
assert len(create_calls) == 1, (
f"the create issued {len(create_calls)} routing calls, expected 1"
)
assert "claude-opus-4-8" in create_calls[0].offered, (
"the client must inject the full cc menu, not just the servable catalog"
)
# ── 3. The spawn: issued and routed, never awaited ──────────────────────
task = prompts()["delegate"]
post_user_message(
routing_client,
session_id,
spawn_prompt(task=task, tool_hint=spawn_tool_for("claude-native")),
)
spawn_decision = wait_for(
lambda: next(
iter(decisions(routing_client, session_id, scope="native_subagent")),
None,
),
timeout=_SPAWN_TIMEOUT_S,
what="a native_subagent routing decision for the spawn",
)
# A cc session's spawn menu is Claude-only: the arm the router hands back is
# the one the spawn is rewritten onto, and it may never be a Codex arm.
assert not arm_in(spawn_decision["model"], CODEX_ARMS), (
f"a cc session spawned a Codex arm ({spawn_decision['model']!r}) — "
"the same-harness constraint was not applied"
)
spawn_raw = spawn_decision.get("raw_model") or spawn_decision["model"]
assert arm_in(spawn_raw, CLAUDE_ARMS), (
f"the spawn was routed to {spawn_raw!r}, which is not a Claude arm "
f"({sequence(CLAUDE_ARMS)})"
)
assert spawn_decision["rationale"] == decide(task, "cc").rationale, (
"the spawn decision must carry the router's trace for the spawn's own task text"
)
assert spawn_decision["router_source"] == "databricks-aigw"
# The spawn's own task text reached the router as a separate call.
assert mock_router.calls_with("parse_duration"), (
"the spawn's task text never reached the router"
)
# ── 4. Isolation: only Claude Code's own `model` key may have moved ──────
assert claude_settings_apart_from_the_model() == settings_before, (
"~/.claude/settings.json changed beyond its `model` key — routing must "
"confine its hook wiring to the session's bridge dir"
)
@@ -0,0 +1,282 @@
"""CUJ 4 — codex-native in-harness routing of the first message TYPED into the TUI.
The codex half of CUJ 3, plus the two things only codex can show:
* **The applied spelling is codex's own slug.** Codex serves either spelling but
recognizes only its own, so an untranslated switch runs the right model while
the TUI warns about missing metadata and ``/model`` keeps highlighting the
launch slug. The hook translates before ``thread/settings/update`` and mirrors
the accepted value into the session's ``config.toml`` — which must therefore
read ``gpt-5.6-luna``, not ``databricks-gpt-5-6-luna``.
* **``/model`` highlights the routed arm.** The picker reads the live thread, so
the routed arm showing up there is what proves the switch reached the process
rather than only the file.
The gate for the fast skip is the session's **routing-decision label**, not the
bridge-dir marker and not ``model_override``; the marker is a local optimization
that saves a round trip. Turn 2 must therefore issue zero routing calls.
The prompt is typed, never POSTed see CUJ 3's module docstring for why.
A 5x repeat variant burns in the block-and-replay handshake, whose failure mode
is a timing race. It multiplies an already slow suite, so it is gated behind
``OMNIGENT_E2E_RELIABILITY=1`` on top of the suite's own opt-in.
"""
from __future__ import annotations
import os
import subprocess
from collections.abc import Callable
from pathlib import Path
import httpx
import pytest
from omnigent.codex_model_vocabulary import codex_spawn_model
from omnigent.runner.subagent_routing import ROUTING_DECISION_LABEL_KEY
from omnigent.runner.turn_routing import MARKER_FILE
from tests.e2e.routing._helpers import (
capture_pane,
codex_bridge_dir,
decisions,
same_arm,
session_snapshot,
settle,
user_messages,
wait_for,
wait_for_bridge_file,
wait_for_codex_config_arm,
wait_for_pane_text,
wait_for_session_pane,
)
from tests.e2e.routing._mock_router import MockRouter, decide
from tests.e2e.routing.conftest import (
RELIABILITY_GATE_ENV,
bypass_args,
create_routed_session,
require_tmux,
trusted_workspace,
unique_trivial_prompt,
)
# The suite's default per-test cap is 300s; this CUJ's own waits (launch, a real
# turn, the spawn or the replay) can legitimately exceed it end to end. The budget
# sits above their sum so an internal wait names what never happened instead of
# the harness firing first on a sleep.
pytestmark = [pytest.mark.smart_routing, pytest.mark.timeout(900)]
_BLOCK_NOTICE = "Smart Routing selected"
#: Text codex's TUI shows once its composer is mounted.
_PANE_READY_HINTS = ("Ask Codex", "for shortcuts", "", "")
_LAUNCH_TIMEOUT_S = 180.0
_ROUTE_TIMEOUT_S = 180.0
_PICKER_TIMEOUT_S = 60.0
_QUIET_WINDOW_S = 60.0
def _wait_for_pane_ready(socket_path: str, target: str) -> None:
"""Wait until codex's composer is mounted.
:param socket_path: The tmux server socket.
:param target: The pane target.
"""
wait_for(
lambda: (
True
if any(hint in capture_pane(socket_path, target) for hint in _PANE_READY_HINTS)
else None
),
timeout=_LAUNCH_TIMEOUT_S,
what="codex's composer to mount",
)
def _open_model_picker(socket_path: str, target: str) -> None:
"""Type a bare ``/model`` into the pane and submit it.
:param socket_path: The tmux server socket.
:param target: The pane target.
"""
for args in (
("send-keys", "-t", target, "C-u"),
("send-keys", "-l", "-t", target, "/model"),
("send-keys", "-t", target, "Enter"),
):
subprocess.run(["tmux", "-S", socket_path, *args], check=True, timeout=15)
def _dismiss_model_picker(socket_path: str, target: str) -> None:
"""Close the picker without changing anything.
:param socket_path: The tmux server socket.
:param target: The pane target.
"""
subprocess.run(
["tmux", "-S", socket_path, "send-keys", "-t", target, "Escape"],
check=False,
timeout=15,
)
def _run_cuj(
routing_client: httpx.Client,
routing_host: str,
mock_router: MockRouter,
type_into_tui: Callable[[str, str, str], None],
workspace: Path,
) -> None:
"""Drive and score one full pass of the codex first-message CUJ.
Factored out so the reliability variant can repeat it without duplicating
the assertions.
:param routing_client: HTTP client pointed at the routing server.
:param routing_host: Host to launch on.
:param mock_router: The routing mock, for the call-count assertions.
:param type_into_tui: Keystroke helper.
:param workspace: Workspace for this pass (one per pass a fresh session
must not inherit another's bridge dir).
"""
created = create_routed_session(
routing_client,
agent_name="codex-native-ui",
host_id=routing_host,
workspace=workspace,
message=None,
terminal_launch_args=bypass_args("codex-native"),
)
session_id = created["id"]
assert not decisions(routing_client, session_id), (
"a bare create must route nothing — the first typed message is the trigger"
)
bridge_dir = codex_bridge_dir(session_id)
socket_path, target = wait_for_session_pane(
routing_client, session_id, timeout=_LAUNCH_TIMEOUT_S
)
_wait_for_pane_ready(socket_path, target)
# ── Turn 1: typed, blocked, routed, replayed ────────────────────────────
first = unique_trivial_prompt("codex-turn-1")
type_into_tui(socket_path, target, first)
pane = wait_for_pane_text(socket_path, target, _BLOCK_NOTICE, timeout=_ROUTE_TIMEOUT_S)
assert pane.count(_BLOCK_NOTICE) == 1, (
f"the block notice appeared {pane.count(_BLOCK_NOTICE)} times; the replayed "
"prompt must not be routed again\n" + pane[-2000:]
)
decision = wait_for(
lambda: next(iter(decisions(routing_client, session_id, scope="turn")), None),
timeout=_ROUTE_TIMEOUT_S,
what="the in-harness turn routing decision",
)
expected = decide(first, "codex")
assert decision["rationale"] == expected.rationale
assert decision["router_source"] == "databricks-aigw"
raw_pick = decision.get("raw_model") or decision["model"]
assert same_arm(raw_pick, expected.model), (
f"the hook routed to {raw_pick!r}, expected the {expected.model!r} arm"
)
# ── The applied spelling is codex's own slug ─────────────────────────────
applied = wait_for_codex_config_arm(session_id, decision["model"], timeout=_ROUTE_TIMEOUT_S)
slug = codex_spawn_model(decision["model"])
if slug is not None:
assert applied == slug, (
f"config.toml carries {applied!r}; thread/settings/update must apply "
f"codex's own slug {slug!r}, or /model keeps highlighting the launch model"
)
# ── /model highlights the routed arm ────────────────────────────────────
_open_model_picker(socket_path, target)
try:
wait_for_pane_text(socket_path, target, applied, timeout=_PICKER_TIMEOUT_S)
finally:
_dismiss_model_picker(socket_path, target)
wait_for_bridge_file(bridge_dir, MARKER_FILE, timeout=_ROUTE_TIMEOUT_S)
turn_one_messages = wait_for(
lambda: msgs if (msgs := user_messages(routing_client, session_id)) else None,
timeout=_ROUTE_TIMEOUT_S,
what="the replayed prompt to land as a user message",
)
assert len(turn_one_messages) == 1, (
f"one typed prompt produced {len(turn_one_messages)} user messages — "
"the block-and-replay leaked the blocked prompt"
)
first_calls = mock_router.calls_with(first)
assert len(first_calls) == 1, (
f"turn 1 issued {len(first_calls)} routing calls for its own prompt, expected exactly 1"
)
# ── The gate is the label, not the marker ───────────────────────────────
labels = session_snapshot(routing_client, session_id).get("labels") or {}
assert labels.get(ROUTING_DECISION_LABEL_KEY), (
"the routing-decision label is the authoritative gate and must be stamped; "
f"labels={labels}"
)
# ── Turn 2: fast-skips, with zero network ───────────────────────────────
second = unique_trivial_prompt("codex-turn-2")
type_into_tui(socket_path, target, second)
settle(_QUIET_WINDOW_S)
assert len(decisions(routing_client, session_id)) == 1, (
"turn 2 produced a second routing decision; the label gate did not hold"
)
assert mock_router.calls_with(second) == [], (
"turn 2's prompt reached the router; the fast skip must be free"
)
def test_codex_routes_the_first_typed_message_exactly_once(
routing_client: httpx.Client,
routing_host: str,
routing_arms: object,
mock_router: MockRouter,
type_into_tui: Callable[[str, str, str], None],
tmp_path: Path,
) -> None:
require_tmux()
routing_arms("codex-native") # type: ignore[operator]
_run_cuj(
routing_client,
routing_host,
mock_router,
type_into_tui,
trusted_workspace(tmp_path, "codex_hook_ws"),
)
@pytest.mark.nightly
@pytest.mark.skipif(
os.environ.get(RELIABILITY_GATE_ENV) != "1",
reason=(
f"repeat variant: set {RELIABILITY_GATE_ENV}=1 to burn in the "
"block-and-replay handshake across 5 passes"
),
)
@pytest.mark.parametrize("attempt", range(5))
def test_codex_first_typed_message_is_reliable(
routing_client: httpx.Client,
routing_host: str,
routing_arms: object,
mock_router: MockRouter,
type_into_tui: Callable[[str, str, str], None],
tmp_path: Path,
attempt: int,
) -> None:
require_tmux()
routing_arms("codex-native") # type: ignore[operator]
_run_cuj(
routing_client,
routing_host,
mock_router,
type_into_tui,
trusted_workspace(tmp_path, f"codex_hook_ws_{attempt}"),
)
@@ -0,0 +1,173 @@
"""CUJ 2 — codex-native, Smart Routing at session create, then a delegated spawn.
The codex half of CUJ 1, plus the process truth only codex can give:
1. A routed create over the ``codex`` scenario (Codex arms only) routes the
session's model before the terminal launches.
2. **Process truth.** The routed model is checked against the session's own
``config.toml`` and when the thread has run a turn the rollout's
``turn_context``, compared with :func:`~tests.e2e.routing._helpers.same_arm`
because the codex apply layer deliberately writes codex's own slug spelling
(``gpt-5.6-luna``) rather than the router's (``gpt-5-6-luna``). A byte
comparison here is a false red.
3. A delegate-class spawn narrow, code-shaped, short is what task_v1 hands
down to ``glm-5-2``, so the ``native_subagent`` decision must name that arm
and stay inside the Codex family.
4. No ``BAD_REQUEST`` anywhere in the server log: a routed model the gateway
cannot serve under the spelling we applied shows up there and nowhere else.
The spawn is asserted, never awaited.
"""
from __future__ import annotations
from pathlib import Path
import httpx
import pytest
from omnigent.server.smart_routing import models_in_family
from tests.e2e.routing._helpers import (
arm_in,
codex_bridge_dir,
decisions,
grep_log,
rollout_turn_contexts,
same_arm,
session_snapshot,
wait_for,
wait_for_bridge_file,
wait_for_codex_config_arm,
wait_for_decision,
)
from tests.e2e.routing._mock_router import CLAUDE_ARMS, MockRouter, decide
from tests.e2e.routing.conftest import (
bypass_args,
create_routed_session,
post_user_message,
prompts,
require_tmux,
spawn_prompt,
spawn_tool_for,
trusted_workspace,
unique_trivial_prompt,
)
# The suite's default per-test cap is 300s; this CUJ's own waits (launch, a real
# turn, the spawn or the replay) can legitimately exceed it end to end. The budget
# sits above their sum so an internal wait names what never happened instead of
# the harness firing first on a sleep.
pytestmark = [pytest.mark.smart_routing, pytest.mark.timeout(900)]
_SPAWN_TIMEOUT_S = 300.0
#: Seconds to wait for the launch to write the session's private ``CODEX_HOME``.
_CONFIG_TIMEOUT_S = 120.0
def test_codex_session_create_routes_the_model_and_delegates_the_spawn(
routing_client: httpx.Client,
routing_host: str,
routing_arms: object,
mock_router: MockRouter,
routing_server_log: Path,
tmp_path: Path,
) -> None:
require_tmux()
routing_arms("codex-native") # type: ignore[operator]
workspace = trusted_workspace(tmp_path, "codex_ui_ws")
# Trivial routes to the codex scenario's cheapest arm, which is the arm this
# workspace most reliably serves — the session model only has to be routed
# and runnable; the delegate arm is exercised by the spawn below. Unique so
# the mock's log can be filtered to this test's own calls.
create_message = unique_trivial_prompt("codex-create")
created = create_routed_session(
routing_client,
agent_name="codex-native-ui",
host_id=routing_host,
workspace=workspace,
message=create_message,
terminal_launch_args=bypass_args("codex-native"),
)
session_id = created["id"]
decision = wait_for_decision(routing_client, session_id, scope="session", timeout=60.0)
expected = decide(create_message, "codex")
assert decision["rationale"] == expected.rationale
assert decision["router_source"] == "databricks-aigw"
assert decision["applied"] is True
assert decision["harness"] == "codex-native"
raw_pick = decision.get("raw_model") or decision["model"]
assert same_arm(raw_pick, expected.model), (
f"router picked {raw_pick!r}, expected the {expected.model!r} arm"
)
assert models_in_family("codex-native", [decision["model"]]), (
f"a codex session was pinned to {decision['model']!r}, not a Codex arm"
)
snapshot = session_snapshot(routing_client, session_id)
assert snapshot["cost_control_mode_override"] == "on"
assert same_arm(snapshot.get("model_override"), decision["model"])
# ── Process truth: config.toml carries the routed model in codex's slug ──
bridge_dir = codex_bridge_dir(session_id)
wait_for_bridge_file(bridge_dir, "codex-home", timeout=_CONFIG_TIMEOUT_S)
wait_for_codex_config_arm(session_id, decision["model"], timeout=_CONFIG_TIMEOUT_S)
# ── The spawn: delegate-class, so task_v1 hands it down to glm ───────────
task = prompts()["delegate"]
post_user_message(
routing_client,
session_id,
spawn_prompt(task=task, tool_hint=spawn_tool_for("codex-native")),
)
spawn_decision = wait_for(
lambda: next(
iter(decisions(routing_client, session_id, scope="native_subagent")),
None,
),
timeout=_SPAWN_TIMEOUT_S,
what="a native_subagent routing decision for the delegated spawn",
)
assert not arm_in(spawn_decision["model"], CLAUDE_ARMS), (
f"a codex session spawned a Claude arm ({spawn_decision['model']!r}) — "
"the same-harness constraint was not applied"
)
spawn_raw = spawn_decision.get("raw_model") or spawn_decision["model"]
assert same_arm(spawn_raw, "glm-5-2"), (
f"the delegate-class spawn was routed to {spawn_raw!r}, expected glm-5-2"
)
assert spawn_decision["rationale"] == decide(task, "codex").rationale
assert spawn_decision["router_source"] == "databricks-aigw"
# ── The rollout is what the process actually ran ─────────────────────────
# Every turn the process ran must be on a model this session was actually
# routed to: the session arm, or a delegate arm a routed spawn switched the
# thread onto. Deliberately order-free — the parent's turns and the routed
# spawn's share one rollout, and which lands first is not a routing property,
# so pinning an index would test rollout bookkeeping instead. An un-routed
# model appearing here is the real failure, and so is any Claude arm: a codex
# thread must never leave its family. An empty rollout is not a failure —
# this CUJ never waits for a turn to finish.
routed_models = [
row["model"] for row in decisions(routing_client, session_id) if row.get("model")
]
for context in rollout_turn_contexts(session_id):
model = context.get("model")
if not isinstance(model, str) or not model:
continue
assert not arm_in(model, CLAUDE_ARMS), (
f"a rollout turn_context ran on {model!r} — a codex thread must never leave its family"
)
assert arm_in(model, routed_models), (
f"a rollout turn_context ran on {model!r}, which this session was never "
f"routed to (routed: {routed_models})"
)
# ── The gateway never rejected an applied spelling ──────────────────────
bad = grep_log(routing_server_log, ["BAD_REQUEST"])
assert not bad, "the server log carries BAD_REQUEST lines:\n" + "\n".join(bad[:5])
assert mock_router.calls_with("parse_duration"), (
"the spawn's task text never reached the router"
)
+160
View File
@@ -0,0 +1,160 @@
"""Proof that the mock router is honest to the ``task_v1`` contract.
The five CUJs assert exact arms and exact rationales, which is only meaningful
if the mock's rule table is itself pinned. This module pins it two ways:
* the rule table directly (:func:`~tests.e2e.routing._mock_router.decide`), and
* the real client against the real HTTP surface the request
:class:`~omnigent.server.smart_routing.ExternalRoutingClient` builds is
answered by the running mock, so the wire shape, the menu injection and the
menu rejection are exercised end to end without a gateway.
These are fast and need no server, host or CLI, so they run whenever the
suite's opt-in gate is set.
"""
from __future__ import annotations
import pytest
from omnigent.server.smart_routing import ExternalRoutingClient
from tests.e2e.routing._mock_router import (
CLAUDE_ARMS,
CODEX_ARMS,
MockRouter,
decide,
missing_arms,
scenario_for,
select_route,
)
from tests.e2e.routing.conftest import MODEL_PREFIXES, prompts
pytestmark = pytest.mark.smart_routing
def _client(mock_router: MockRouter) -> ExternalRoutingClient:
"""Build the production routing client pointed at the mock.
:param mock_router: The running mock.
:returns: A client configured exactly as the server's would be.
"""
return ExternalRoutingClient(
base_url=mock_router.base_url,
router_name="task_v1",
model_prefixes=list(MODEL_PREFIXES),
)
def test_scenario_is_inferred_from_the_arms_not_the_tags() -> None:
assert scenario_for(CLAUDE_ARMS) == "cc"
assert scenario_for(CODEX_ARMS) == "codex"
assert scenario_for([*CLAUDE_ARMS, *CODEX_ARMS]) == "both"
assert scenario_for(["gpt-5-5", "kimi-k2"]) is None
@pytest.mark.parametrize(
("scenario", "prompt_key", "expected_model", "expected_trace"),
[
("cc", "trivial", "claude-sonnet-5", "cheapest arm claude-sonnet-5; never escalate."),
("codex", "trivial", "gpt-5-6-luna", "cheapest arm gpt-5-6-luna; never escalate."),
("both", "trivial", "gpt-5-6-luna", "cheapest arm gpt-5-6-luna; never escalate."),
("codex", "delegate", "glm-5-2", "all hold -> delegate down to glm-5-2."),
("cc", "escalate", "claude-opus-4-8", "all hold -> escalate up to claude-opus-4-8."),
("both", "escalate", "claude-opus-4-8", "all hold -> escalate up to claude-opus-4-8."),
("cc", "crosscutting", "claude-sonnet-5", "not all hold -> default claude-sonnet-5."),
("codex", "crosscutting", "gpt-5-6-sol", "not all hold -> default gpt-5-6-sol."),
("both", "crosscutting", "gpt-5-6-sol", "not all hold -> default gpt-5-6-sol."),
],
)
def test_rule_table(
scenario: str, prompt_key: str, expected_model: str, expected_trace: str
) -> None:
verdict = decide(prompts()[prompt_key], scenario)
assert verdict.model == expected_model
assert verdict.rationale.startswith(f"Routed to {expected_model} because ")
assert verdict.rationale.endswith(expected_trace)
def test_a_narrowed_menu_is_rejected_with_the_routers_own_message() -> None:
assert missing_arms("codex", ["glm-5-2", "gpt-5-6-sol"]) == ["gpt-5-6-luna"]
with pytest.raises(Exception) as caught:
select_route(
{
"route_options": [{"model": "glm-5-2"}, {"model": "gpt-5-6-sol"}],
"task": {"prompt": "hi"},
}
)
assert "scenario 'codex' requires its full menu; missing [gpt-5-6-luna]" in str(caught.value)
def test_extra_non_arm_models_are_tolerated() -> None:
payload = select_route(
{
"route_options": [
*({"model": arm} for arm in CODEX_ARMS),
{"model": "gpt-5-5"},
{"model": "kimi-k2"},
],
"task": {"prompt": "hi"},
}
)
assert payload["route_selection"][0]["route_option"]["model"] == "gpt-5-6-luna"
def test_the_harness_tag_is_echoed_verbatim_never_read() -> None:
"""A nonsensical tag changes neither the pick nor the status."""
payload = select_route(
{
# Claude arms tagged codex and vice versa: the real router echoes the
# tag it was given and picks on the model alone.
"route_options": [{"model": arm, "harness": "codex"} for arm in CLAUDE_ARMS],
"task": {"prompt": "hi"},
}
)
selection = payload["route_selection"][0]["route_option"]
assert selection["model"] == "claude-sonnet-5"
assert selection["harness"] == "codex"
@pytest.mark.parametrize(
("catalog", "expected"),
[
({"claude-native": ["databricks-claude-sonnet-5"]}, "databricks-claude-sonnet-5"),
({"codex-native": ["databricks-gpt-5-6-luna"]}, "databricks-gpt-5-6-luna"),
],
)
async def test_real_client_round_trips_the_mock(
mock_router: MockRouter,
catalog: dict[str, list[str]],
expected: str,
) -> None:
"""The production client's own request is answered and resolved.
Proves the mock's wire shape against the code under test rather than
against a hand-written body: the client injects the scenario menu, sends
snake_case protos, and resolves the pick back onto a servable catalog id.
"""
result = await _client(mock_router).route("hi", catalog)
assert result is not None
assert result.model == expected
assert "cheapest arm" in result.rationale
served = mock_router.snapshot()[-1]
assert served.router_name == "task_v1"
# The client must have injected the full menu, not just the catalog row.
for arm in CLAUDE_ARMS if "claude-native" in catalog else CODEX_ARMS:
assert arm in served.offered
async def test_real_client_surfaces_a_menu_rejection(mock_router: MockRouter) -> None:
"""A non-task_v1 client sends no menu, and the mock rejects it as the gateway does."""
client = ExternalRoutingClient(
base_url=mock_router.base_url,
# Any name but task_v1 makes the seam skip menu injection, which is
# exactly the narrowed-menu case the real router 400s on.
router_name="task_v2",
model_prefixes=list(MODEL_PREFIXES),
)
result = await client.route("hi", {"codex-native": ["databricks-gpt-5-6-luna"]})
assert result is None
assert "requires its full menu" in (client.last_error or "")
assert mock_router.snapshot()[-1].status == 400
@@ -0,0 +1,151 @@
"""E2E: what a routed session shows in the chat surface.
Two user-visible consequences of Smart Routing owning a session:
1. **One chip per pick.** A routed create records the pick as a ``session``
chip, and the session's first turn routes again and records the same verdict
as a ``turn`` chip. Two audit rows, one decision so the transcript renders
a single ``routing_decision`` card (the turn chip, paired below the user
message it decided), carrying the Databricks mark when the AI-Gateway router
answered. Two cards saying the same thing above and below one message was
the bug.
2. **A named Model row.** The router pins its fully-qualified pick
(``databricks-claude-opus-4-8``), which the harness catalog carries only
under an alias or not at all. The gear modal's Model row must still name
the model the session is on instead of rendering blank (Radix falls back to
the placeholder for a value no item declares).
The transcript items are written straight into the spawned server's store (the
same seam :func:`tests.e2e_ui.conftest.seed_committed_turn` uses) so neither
test needs a router, a gateway credential, or a real turn. The claude-native
snapshot patch is shared with ``chat/test_claude_model_picker.py``.
"""
from __future__ import annotations
import httpx
from playwright.sync_api import Page, expect
from tests.e2e_ui.chat.test_claude_model_picker import _patch_session_as_claude_native
from tests.e2e_ui.conftest import seed_committed_turn
# The router's pick: an AI-Gateway serving-endpoint id. Deliberately absent
# from the claude-native alias catalog (opus / sonnet / haiku) — that mismatch
# is what used to blank the Model row.
_ROUTED_MODEL = "databricks-claude-opus-4-8"
def _seed_routed_first_turn(session_id: str, *, prompt: str, reply: str) -> None:
"""Seed the transcript a routed session's first turn leaves behind.
Persistence order mirrors the native path: the create-time ``session``
chip, then the first turn's ``turn`` chip, then the turn's messages.
:param session_id: Session to append to, e.g. ``"conv_abc123"``.
:param prompt: User message text.
:param reply: Assistant message text.
"""
from omnigent.entities import NewConversationItem
from omnigent.entities.conversation import parse_item_data
from omnigent.stores.conversation_store.sqlalchemy_store import (
SqlAlchemyConversationStore,
)
from tests.e2e_ui.conftest import _server_state
database_uri = _server_state.get("database_uri")
assert database_uri, "needs the spawned server's database (not --ui-base-url)"
decision = {
"model": _ROUTED_MODEL,
"applied": True,
"rationale": "Multi-file refactor needs deep reasoning.",
"harness": "claude-native",
"decision_id": "e2e-decision-1",
"router_source": "databricks-aigw",
}
store = SqlAlchemyConversationStore(str(database_uri))
store.append(
session_id,
[
NewConversationItem(
type="routing_decision",
response_id="routing_e2e_create",
data=parse_item_data("routing_decision", {**decision, "scope": "session"}),
),
NewConversationItem(
type="routing_decision",
response_id="routing_e2e_turn",
data=parse_item_data("routing_decision", {**decision, "scope": "turn"}),
),
],
)
seed_committed_turn(session_id, prompt=prompt, reply=reply)
def test_routed_session_renders_one_routing_chip(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""The create-time and first-turn picks render as ONE chip, marked Databricks.
:param page: Playwright page fixture.
:param seeded_session: ``(base_url, session_id)`` for a real server-backed
session; its transcript is seeded with a routed first turn.
:returns: None.
"""
base_url, session_id = seeded_session
_seed_routed_first_turn(session_id, prompt="refactor the auth module", reply="on it")
# Both audit rows really are in the transcript, so the single card below is
# the render collapsing them — not a seed that silently wrote one row.
items = httpx.get(f"{base_url}/v1/sessions/{session_id}/items", timeout=10.0)
items.raise_for_status()
decisions = [i for i in items.json()["data"] if i["type"] == "routing_decision"]
assert len(decisions) == 2, decisions
page.goto(f"{base_url}/c/{session_id}")
chip = page.get_by_test_id("routing-decision-card")
expect(chip).to_have_count(1, timeout=15_000)
expect(chip).to_contain_text("Smart routing")
# The pick itself, and who made it.
expect(chip).to_contain_text("opus")
expect(chip.get_by_test_id("routing-decision-source-databricks")).to_be_visible()
expect(chip.get_by_test_id("routing-decision-harness")).to_contain_text("claude-native")
# The turn chip is the survivor, so the scope reads as the turn's.
expect(chip).to_have_attribute("data-applied", "true")
def test_routed_session_config_modal_names_the_routed_model(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""The gear modal's Model row names the router's pick, not a blank row.
The pick is a gateway serving-endpoint id the claude-native alias catalog
has no entry for; the row carries it as its own option so the trigger reads
the model the session actually runs on.
:param page: Playwright page fixture.
:param seeded_session: ``(base_url, session_id)`` for a real server-backed
session; the browser snapshot is patched to claude-native.
:returns: None.
"""
base_url, session_id = seeded_session
_patch_session_as_claude_native(page, session_id, model_override=_ROUTED_MODEL)
page.goto(f"{base_url}/c/{session_id}")
gear = page.get_by_test_id("composer-config-gear")
expect(gear).to_be_visible(timeout=15_000)
gear.click()
model_row = page.get_by_test_id("composer-config-model")
expect(model_row).to_be_visible()
expect(model_row).to_contain_text(_ROUTED_MODEL)
# The row also OFFERS it, alongside the harness's own aliases — a pinned
# model that no option declares is what rendered blank.
model_row.click()
expect(page.locator(f'[role="option"][data-model-id="{_ROUTED_MODEL}"]')).to_have_count(1)
expect(page.locator('[role="option"][data-model-id="sonnet"]')).to_have_count(1)
@@ -0,0 +1,329 @@
"""E2E: Smart Routing on the new-chat landing screen.
Two independent ways to hand a new session to the router, both reachable from
the landing composer (``NewChatLandingScreen`` in
``web/src/shell/NewChatDialog.tsx``):
1. **Harness-level** a "Smart Routing" row above the Harnesses group in the
agent/harness picker. The router picks the native harness AND the model at
create time, so the create call sends ``harness_override: "auto"`` plus the
typed message (``smart_routing_message``) for the router to score, and none
of the placeholder wrapper's own knobs.
2. **Model-level** "Smart Routing" as an option in the gear-modal's Model
row on a routable native harness. The harness stays pinned and the router
picks the model per turn, so the create call sends
``cost_control_mode_override: "on"`` and no ``model_override``.
Both surfaces are gated: the server must report routing on with a configured
source (``GET /v1/info``), both native wrapper agents must be registered, and
their CLIs must be ready on the selected host. The e2e harness's tunneled
runner registers no host and the real server has routing off, so ``/v1/info``,
``/v1/hosts`` and ``/v1/agents`` are stubbed the same shape (and for the same
reasons) as ``test_start_session.py``, whose helpers this file reuses.
"""
from __future__ import annotations
import json
import re
from typing import Any
from playwright.async_api import Route, async_playwright, expect
from tests.e2e_ui.start_session.test_start_session import (
_HOST_ID,
_SESSIONS_RE,
_open_entry_config,
_run_in_fresh_loop,
_save_config,
_wait_until,
)
# The two native wrapper agents Smart Routing routes between. Both must be in
# the picker's Harnesses group or the row is withheld ("wrappers-missing").
_ROUTING_AGENTS_BODY = json.dumps(
{
"data": [
{
"id": "ag_claude_e2e",
"name": "claude-native-ui",
"display_name": "Claude Code",
"description": "Anthropic's coding agent",
"harness": "claude-native",
"skills": [],
},
{
"id": "ag_codex_e2e",
"name": "codex-native-ui",
"display_name": "Codex",
"description": "OpenAI's coding agent",
"harness": "codex-native",
"skills": [],
},
]
}
)
# One online host with no readiness or gateway-inference map: both read as
# "unknown", which the landing treats as ready/backed (only an explicit false
# withholds the row). A host that reported otherwise is the unavailable-notice
# path, covered by the unit tests in web/src/shell/NewChatDialog.test.tsx.
_ROUTING_HOSTS_BODY = json.dumps(
{
"hosts": [
{
"host_id": _HOST_ID,
"name": "e2e-host",
"owner": "e2e",
"status": "online",
}
]
}
)
def _info_body(*, routing: bool) -> str:
"""Stub body for ``GET /v1/info``.
:param routing: Whether the server advertises Smart Routing with the
external (AI-Gateway) router configured.
:returns: JSON body.
"""
return json.dumps(
{
"accounts_enabled": False,
"single_user": True,
"needs_setup": False,
"smart_routing_enabled": routing,
"smart_routing_sources": {"external": routing, "oss": False},
}
)
async def _register_routing_routes(
page,
*,
created_session_id: str,
create_bodies: list[dict[str, Any]],
routing: bool = True,
) -> None:
"""Install the host / agent / info / create stubs both tests share.
:param page: The Playwright page to install routes on.
:param created_session_id: Real pre-seeded session id the faked create
returns, so the post-send navigation lands somewhere real.
:param create_bodies: Sink the create ``POST /v1/sessions`` body lands in.
:param routing: Whether ``/v1/info`` advertises Smart Routing.
"""
async def handle_info(route: Route) -> None:
await route.fulfill(
status=200, content_type="application/json", body=_info_body(routing=routing)
)
async def handle_hosts(route: Route) -> None:
await route.fulfill(status=200, content_type="application/json", body=_ROUTING_HOSTS_BODY)
async def handle_agents(route: Route) -> None:
await route.fulfill(status=200, content_type="application/json", body=_ROUTING_AGENTS_BODY)
async def handle_events(route: Route) -> None:
# Swallow the auto-sent initial prompt so no real LLM turn runs.
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"queued": True, "item_id": "ci_e2e"}),
)
async def handle_sessions(route: Route) -> None:
if route.request.method == "POST":
create_bodies.append(route.request.post_data_json)
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"id": created_session_id}),
)
else:
await route.continue_()
async def handle_agent_scan(route: Route) -> None:
# Neutralize agent discovery so only the two stubbed wrappers feed the
# picker; an agent another test left behind could otherwise rank first
# and auto-select, opening the wrong agent's config modal.
await route.fulfill(
status=200, content_type="application/json", body=json.dumps({"data": []})
)
await page.route("**/v1/info", handle_info)
await page.route("**/v1/hosts", handle_hosts)
await page.route("**/v1/agents", handle_agents)
await page.route("**/v1/sessions/*/events", handle_events)
await page.route(_SESSIONS_RE, handle_sessions)
await page.route(re.compile(r"/v1/sessions\?.*kind=any"), handle_agent_scan)
# The landing needs a working directory before Send enables, and the
# stubbed host has no browsable filesystem.
await page.add_init_script(
f"""window.localStorage.setItem(
"omnigent:recent-workspaces",
JSON.stringify({{ {_HOST_ID}: ["/work/repo"] }})
);"""
)
def test_start_session_smart_routing_harness_row(seeded_session: tuple[str, str]) -> None:
"""The picker offers Smart Routing, and picking it routes the create.
The row is the harness-level entry: the router owns harness AND model, so
the create must send the ``auto`` sentinel plus the message to score, and
must NOT send the placeholder wrapper's model / launch args / labels.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_smart_routing_harness(base_url, session_id))
async def _drive_smart_routing_harness(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_routing_routes(
page, created_session_id=session_id, create_bodies=create_bodies
)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
await page.get_by_test_id("new-chat-landing-agent-select").click()
smart_row = page.get_by_test_id("new-chat-landing-harness-smart-routing")
await expect(smart_row).to_be_visible()
await expect(smart_row).to_contain_text("Smart Routing")
await smart_row.click()
# The trigger now names the router, not one of the harnesses it
# picks between.
await expect(page.get_by_test_id("new-chat-landing-agent-select")).to_contain_text(
"Smart Routing"
)
await page.get_by_test_id("new-chat-landing-input").fill("refactor the auth module")
await page.get_by_test_id("new-chat-landing-submit").click()
await _wait_until(lambda: len(create_bodies) == 1)
body = create_bodies[0]
assert body["harness_override"] == "auto", body
# The router scores the typed message at create time.
assert body["smart_routing_message"] == "refactor the auth module", body
# None of the placeholder wrapper's own knobs ride along — the
# router may pick the other harness entirely.
assert body.get("model_override") is None, body
assert body.get("terminal_launch_args") is None, body
assert body.get("labels") is None, body
finally:
await browser.close()
def test_start_session_smart_routing_model_option(seeded_session: tuple[str, str]) -> None:
"""Smart Routing is also a Model choice on a routable native harness.
Picking it in the gear modal leaves the harness pinned and hands the model
to the router per turn: the create sends ``cost_control_mode_override:
"on"`` and no ``model_override``.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_smart_routing_model_option(base_url, session_id))
async def _drive_smart_routing_model_option(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_routing_routes(
page, created_session_id=session_id, create_bodies=create_bodies
)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
# Pin Claude Code, then open its run-config modal.
await _open_entry_config(page, "ag_claude_e2e")
model = page.get_by_test_id("new-chat-landing-config-model")
await expect(model).to_be_visible()
await model.click()
await page.get_by_role("option", name="Smart Routing", exact=True).click()
await expect(model).to_contain_text("Smart Routing")
# The router picks the effort with the model, so the row is frozen.
await expect(page.get_by_test_id("new-chat-landing-config-effort")).to_be_disabled()
await _save_config(page)
await page.get_by_test_id("new-chat-landing-input").fill("fix the flaky test")
await page.get_by_test_id("new-chat-landing-submit").click()
await _wait_until(lambda: len(create_bodies) == 1)
body = create_bodies[0]
assert body["agent_id"] == "ag_claude_e2e", body
assert body["cost_control_mode_override"] == "on", body
# Per-turn routing owns the model, so nothing is pinned; the
# harness itself stays the one the user picked.
assert body.get("model_override") is None, body
assert body.get("harness_override") != "auto", body
finally:
await browser.close()
def test_start_session_hides_smart_routing_when_server_disables_it(
seeded_session: tuple[str, str],
) -> None:
"""Routing off on the server withholds both Smart Routing surfaces.
The negative half of the gate: the same stubs with
``smart_routing_enabled: false`` must leave the picker row absent and the
Model row without the router option, so a server that can't route never
offers a pick it would have to drop.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_smart_routing_disabled(base_url, session_id))
async def _drive_smart_routing_disabled(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_routing_routes(
page,
created_session_id=session_id,
create_bodies=create_bodies,
routing=False,
)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
await page.get_by_test_id("new-chat-landing-agent-select").click()
# The Harnesses group renders, so the picker is populated — only the
# routing row is missing.
await expect(
page.get_by_test_id("new-chat-landing-agent-ag_claude_e2e")
).to_be_visible()
await expect(
page.get_by_test_id("new-chat-landing-harness-smart-routing")
).to_have_count(0)
await page.get_by_test_id("new-chat-landing-agent-ag_claude_e2e").click()
await page.get_by_test_id("new-chat-landing-config-gear").click()
await page.get_by_test_id("new-chat-landing-config-model").click()
await expect(
page.get_by_role("option", name="Smart Routing", exact=True)
).to_have_count(0)
finally:
await browser.close()
@@ -0,0 +1,138 @@
"""
Tests for the routing-identity fields on ``RoutingDecisionData``.
The intelligent-routing MVP adds harness / scope / decision identity to
the routing-decision transcript item. Every field is defaulted so rows
persisted before they existed still deserialize.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from omnigent.entities.conversation import RoutingDecisionData, parse_item_data
_LEGACY_ROW = {
"model": "databricks-claude-opus-4-8",
"applied": True,
"rationale": "Multi-file refactor needs deep reasoning.",
}
def test_legacy_row_deserializes_with_defaults() -> None:
data = parse_item_data("routing_decision", dict(_LEGACY_ROW))
assert isinstance(data, RoutingDecisionData)
assert data.model == "databricks-claude-opus-4-8"
assert data.harness is None
assert data.scope == "turn"
assert data.decision_id is None
assert data.raw_model is None
assert data.attempted_override is None
assert data.router_source is None
@pytest.mark.parametrize(
("row", "expected_keys"),
[
# Every routing-identity field set: the dump carries each one and
# rebuilding from it reproduces the model exactly.
(
{
"model": "databricks-gpt-5-6-sol",
"applied": True,
"rationale": "Short prompt, cheapest arm.",
"agent": "claude_code",
"harness": "codex",
"scope": "native_subagent",
"decision_id": "dec_abc123",
"raw_model": "gpt-5-6-sol",
"attempted_override": "databricks-gpt-5-5",
"router_source": "databricks-aigw",
},
{
"harness": "codex",
"scope": "native_subagent",
"decision_id": "dec_abc123",
"raw_model": "gpt-5-6-sol",
"router_source": "databricks-aigw",
},
),
# An unapplied advisory decision: the unset optional stays null in the
# dump rather than being dropped from the wire.
(
{
"model": "databricks-claude-sonnet-5",
"applied": False,
"rationale": "Advise only.",
"harness": "claude-native",
"scope": "session",
"decision_id": "dec_1",
},
{
"harness": "claude-native",
"scope": "session",
"decision_id": "dec_1",
"raw_model": None,
"router_source": None,
},
),
],
)
def test_dump_carries_the_routing_identity_and_round_trips(
row: dict[str, object], expected_keys: dict[str, object]
) -> None:
original = RoutingDecisionData(**row) # type: ignore[arg-type]
dumped = original.model_dump()
for key, want in expected_keys.items():
assert dumped[key] == want, key
assert RoutingDecisionData(**dumped) == original
@pytest.mark.parametrize("scope", ["session", "turn", "child_session", "native_subagent"])
def test_every_scope_value_validates(scope: str) -> None:
data = RoutingDecisionData(
model="databricks-claude-sonnet-5",
applied=True,
rationale="ok",
scope=scope, # type: ignore[arg-type]
)
assert data.scope == scope
def test_unknown_scope_rejected() -> None:
with pytest.raises(ValidationError):
RoutingDecisionData(
model="databricks-claude-sonnet-5",
applied=True,
rationale="ok",
scope="galaxy", # type: ignore[arg-type]
)
# ── router_source ───────────────────────────────────────────────────────────
#
# Which router produced the decision. A plain ``str``, not a ``Literal``: a
# source added later must round-trip through stored rows and the wire rather
# than failing validation on the way back in.
@pytest.mark.parametrize("source", ["databricks-aigw", "oss-llm", "some-future-router"])
def test_any_router_source_round_trips(source: str) -> None:
original = RoutingDecisionData(
model="databricks-claude-sonnet-5",
applied=True,
rationale="ok",
router_source=source,
)
dumped = original.model_dump()
assert dumped["router_source"] == source
assert RoutingDecisionData(**dumped) == original
def test_an_omitted_router_source_parses_as_none() -> None:
data = parse_item_data("routing_decision", dict(_LEGACY_ROW))
assert isinstance(data, RoutingDecisionData)
assert data.router_source is None
# The field still rides the wire as an explicit null rather than vanishing.
assert data.model_dump()["router_source"] is None
+69
View File
@@ -509,6 +509,38 @@ async def test_handle_model_options_rejects_unsupported_harness() -> None:
)
async def test_handle_model_options_reports_the_endpoints_wider_catalog(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Generations no picker row names are still launchable, so they ship too."""
from omnigent import claude_native
monkeypatch.setattr(
claude_native,
"resolve_native_claude_config",
lambda *, spec: claude_native.ClaudeNativeUcodeConfig(
env={"ANTHROPIC_DEFAULT_OPUS_MODEL": "system.ai.claude-opus-5"},
model="system.ai.claude-opus-5",
routable_models=("system.ai.claude-opus-5", "system.ai.claude-opus-4-8"),
),
)
monkeypatch.setattr(
claude_native,
"claude_native_model_options",
lambda config: [{"id": "opus", "model": "system.ai.claude-opus-5"}],
)
host = _make_host_process()
result = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_models", harness="claude-native"),
)
assert result.routable_models == [
"system.ai.claude-opus-5",
"system.ai.claude-opus-4-8",
]
def _make_host_process() -> HostProcess:
"""Create a HostProcess with a test identity.
@@ -966,6 +998,7 @@ async def test_live_host_refreshes_harness_readiness_without_reconnect(
receive loop, so a slow probe can never stall the tunnel keepalive.
"""
readiness = iter(({"pi": True},))
monkeypatch.setattr("omnigent.host.connect.gateway_inference_map", lambda: {"codex": True})
monkeypatch.setattr(
"omnigent.host.connect.configured_harness_map",
lambda: next(readiness),
@@ -999,6 +1032,7 @@ async def test_live_host_full_refresh_detects_auth_completion(
) -> None:
"""The full-refresh fallback catches readiness changes beyond binary installs."""
readiness = iter(({"codex": True},))
monkeypatch.setattr("omnigent.host.connect.gateway_inference_map", lambda: {"codex": True})
monkeypatch.setattr(
"omnigent.host.connect.configured_harness_map",
lambda: next(readiness),
@@ -1034,6 +1068,7 @@ async def test_live_host_does_not_repeat_unchanged_readiness(
return {"codex": "needs-auth"}
monkeypatch.setattr("omnigent.host.connect.configured_harness_map", _unchanged_map)
monkeypatch.setattr("omnigent.host.connect.gateway_inference_map", lambda: {"codex": True})
monkeypatch.setattr(
"omnigent.host.connect.HARNESS_READINESS_FULL_REFRESH_INTERVAL_S",
0.01,
@@ -1056,6 +1091,40 @@ async def test_live_host_does_not_repeat_unchanged_readiness(
_cleanup_host(host)
async def test_live_host_repushes_when_only_gateway_inference_changes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A gateway-inference flip alone must reach the server, readiness unchanged."""
gateway = iter(({"codex": False}, {"codex": True}))
monkeypatch.setattr(
"omnigent.host.connect.configured_harness_map",
lambda: {"codex": True},
)
monkeypatch.setattr(
"omnigent.host.connect.gateway_inference_map",
lambda: next(gateway, {"codex": True}),
)
monkeypatch.setattr(
"omnigent.host.connect.HARNESS_READINESS_FULL_REFRESH_INTERVAL_S",
0.01,
)
host = _make_host_process()
ws = _RecordingWS()
task = asyncio.create_task(host._harness_readiness_loop(ws, {"codex": True}))
try:
await asyncio.wait_for(ws.first_send.wait(), timeout=2.0)
finally:
await _cancel(task)
assert len(ws.sent) == 1
refresh = decode_host_frame(ws.sent[0])
assert isinstance(refresh, HostHarnessReadinessFrame)
assert refresh.configured_harnesses == {"codex": True}
assert refresh.gateway_inference == {"codex": True}
_cleanup_host(host)
async def test_handle_launch_immediate_exit_reports_exit_code_and_log_tail(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
+104
View File
@@ -80,6 +80,23 @@ def test_model_options_frames_round_trip() -> None:
"displayName": "Sonnet 4.6",
}
]
# Absent from an older host's payload, and present when it reports the
# endpoint's wider catalog.
assert result.routable_models == []
with_routable = decode_host_frame(
encode_host_frame(
HostModelOptionsResultFrame(
request_id="req_models",
status="ok",
routable_models=["system.ai.claude-opus-5", "system.ai.claude-opus-4-8"],
)
)
)
assert isinstance(with_routable, HostModelOptionsResultFrame)
assert with_routable.routable_models == [
"system.ai.claude-opus-5",
"system.ai.claude-opus-4-8",
]
def test_encode_injects_traceparent_under_active_span() -> None:
@@ -252,6 +269,70 @@ def test_harness_readiness_frame_round_trip() -> None:
assert decoded.configured_harnesses == {"pi": True, "codex": "needs-auth"}
def test_hello_frame_gateway_inference_round_trip() -> None:
original = HostHelloFrame(
version="0.1.0",
frame_protocol_version=1,
name="corey-laptop",
configured_harnesses={"claude-native": True},
gateway_inference={"claude-native": True, "codex": False},
)
decoded = decode_host_frame(encode_host_frame(original))
assert isinstance(decoded, HostHelloFrame)
assert decoded.gateway_inference == {"claude-native": True, "codex": False}
def test_hello_frame_absent_gateway_inference_decodes_to_none() -> None:
encoded = json.dumps(
{
"kind": "host.hello",
"version": "0.1.0",
"frame_protocol_version": 1,
"name": "corey-laptop",
}
)
decoded = decode_host_frame(encoded)
assert isinstance(decoded, HostHelloFrame)
assert decoded.gateway_inference is None
def test_hello_frame_drops_non_bool_gateway_inference_values() -> None:
encoded = json.dumps(
{
"kind": "host.hello",
"version": "0.1.0",
"frame_protocol_version": 1,
"name": "corey-laptop",
"gateway_inference": {"codex": "maybe", "claude-native": True},
}
)
decoded = decode_host_frame(encoded)
assert isinstance(decoded, HostHelloFrame)
assert decoded.gateway_inference == {"claude-native": True}
def test_harness_readiness_frame_gateway_inference_round_trip() -> None:
original = HostHarnessReadinessFrame(
configured_harnesses={"codex": True},
gateway_inference={"codex": True, "native-codex": True},
)
decoded = decode_host_frame(encode_host_frame(original))
assert isinstance(decoded, HostHarnessReadinessFrame)
assert decoded.gateway_inference == {"codex": True, "native-codex": True}
def test_harness_readiness_frame_without_gateway_inference_is_none() -> None:
encoded = json.dumps(
{
"kind": "host.harness_readiness",
"configured_harnesses": {"codex": True},
}
)
decoded = decode_host_frame(encoded)
assert isinstance(decoded, HostHarnessReadinessFrame)
assert decoded.gateway_inference is None
def test_harness_readiness_frame_rejects_unknown_availability() -> None:
"""Unknown readiness states cannot partially replace the live map."""
encoded = json.dumps(
@@ -1189,9 +1270,32 @@ def test_install_harness_result_failure_round_trip() -> None:
assert isinstance(decoded, HostInstallHarnessResultFrame)
assert decoded.status == "failed"
assert decoded.configured_harnesses is None
assert decoded.gateway_inference is None
assert decoded.error == "npm not found"
def test_result_frames_round_trip_gateway_inference() -> None:
install = HostInstallHarnessResultFrame(
request_id="req_install_4",
status="ok",
configured_harnesses={"claude-native": True},
gateway_inference={"claude-native": False},
)
decoded_install = decode_host_frame(encode_host_frame(install))
assert isinstance(decoded_install, HostInstallHarnessResultFrame)
assert decoded_install.gateway_inference == {"claude-native": False}
secret = HostStoreSecretResultFrame(
request_id="req_cred_2",
status="ok",
configured_harnesses={"codex": True},
gateway_inference={"codex": True},
)
decoded_secret = decode_host_frame(encode_host_frame(secret))
assert isinstance(decoded_secret, HostStoreSecretResultFrame)
assert decoded_secret.gateway_inference == {"codex": True}
def test_store_secret_key_frame_round_trip() -> None:
"""A key store-secret request survives encode → decode with the secret.
+60
View File
@@ -8,6 +8,7 @@ import asyncio
import faulthandler
import gc
import inspect
import json
import logging
import os
import pathlib
@@ -151,6 +152,65 @@ async def _hang_diagnostic_task_dumper() -> asyncio.AsyncGenerator[None, None]:
handle.cancel()
def advertise_router(
router_dir: pathlib.Path,
*,
session_id: str | None = "conv_abc",
**extra: object,
) -> pathlib.Path:
"""Write a subagent-router advertisement into *router_dir*.
Shared by the claude and codex router-hook suites. The filename comes
from the hook script's own ``ADVERTISEMENT_FILE`` constant, so renaming
it fails these tests instead of quietly making every advertisement
invisible to the hook under test.
:param router_dir: Bridge/router directory the hook is pointed at.
:param session_id: Baked-in session id; ``None`` omits the key so the
hook has to fall back to its env/bridge-config sources.
:param extra: Extra advertisement keys to merge in.
:returns: *router_dir*, for use as the hook's ``--bridge-dir``.
"""
from omnigent.inner.hook_scripts import subagent_router
# A live ``pid`` by default: the hook rejects an advertisement without
# one, since the runner always writes it.
payload: dict[str, object] = {
"url": "http://127.0.0.1:1/",
"token": "t0k",
"pid": os.getpid(),
**extra,
}
if session_id is not None:
payload["session_id"] = session_id
(router_dir / subagent_router.ADVERTISEMENT_FILE).write_text(json.dumps(payload))
return router_dir
def advertise_relay_tools(bridge_dir: pathlib.Path, *tool_names: str) -> pathlib.Path:
"""Write a ``tool_relay.json`` advertising *tool_names* into *bridge_dir*.
The hook reads this to decide whether a deny reason may name
``sys_session_create``. The filename comes from the hook script's own
constant so a rename fails these tests instead of silently reading nothing.
:param bridge_dir: Bridge directory the hook is pointed at.
:param tool_names: Omnigent tool names to advertise; none writes an empty
list, which is how "the session holds no spawn tool" is expressed.
:returns: *bridge_dir*, for use as the hook's ``--bridge-dir``.
"""
from omnigent.inner.hook_scripts import subagent_router
payload = {
"url": "http://127.0.0.1:2/",
"token": "relay-t0k",
"pid": os.getpid(),
"tools": [{"name": name, "input_schema": {"type": "object"}} for name in tool_names],
}
(bridge_dir / subagent_router._TOOL_RELAY_FILE).write_text(json.dumps(payload))
return bridge_dir
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
if "model_name" not in metafunc.fixturenames:
return
+246 -5
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"
@@ -843,6 +846,7 @@ async def test_run_turn_applies_routed_model_before_message_under_one_lock(
command: str,
timeout_s: float = 30.0,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Record the ``/model`` switch keystroke and its auto_confirm flag."""
del bridge_dir_arg, timeout_s
@@ -861,6 +865,11 @@ async def test_run_turn_applies_routed_model_before_message_under_one_lock(
# No ucode profile at launch -> unknown baseline -> the routed model is
# treated as a change and switched.
monkeypatch.setattr(claude_native_executor, "read_launch_model", lambda _bridge: None)
monkeypatch.setattr(
claude_native_executor,
"read_model_env",
lambda _bridge: {"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-sonnet-5"},
)
monkeypatch.setattr(claude_native_executor, "inject_slash_command", fake_inject_slash_command)
monkeypatch.setattr(claude_native_executor, "inject_user_message", fake_inject_user_message)
@@ -878,12 +887,172 @@ async def test_run_turn_applies_routed_model_before_message_under_one_lock(
assert calls == [
# auto_confirm=True mirrors the manual picker path so the switch is
# accepted if the CLI ever pops a confirmation dialog.
("slash", "/model databricks-claude-sonnet-5", True),
("slash", "/model sonnet", True),
("message", "review this function"),
], f"Expected /model (auto_confirm) then message, in order; got {calls}."
assert events == [TurnComplete(response=None)]
@pytest.mark.asyncio
async def test_run_turn_uses_the_custom_model_slot_id_verbatim(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""A model pinned to the custom picker slot is applied exactly."""
monkeypatch.delenv(REQUEST_SESSION_ID_ENV_VAR, raising=False)
slash_calls: list[str] = []
def fake_inject_slash_command(
bridge_dir_arg: Path,
*,
command: str,
timeout_s: float = 30.0,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
del bridge_dir_arg, timeout_s, auto_confirm, confirm_hint
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,
confirm_hint: str | None = None,
) -> None:
del bridge_dir_arg, timeout_s, auto_confirm, confirm_hint
slash_calls.append(command)
def fake_inject_user_message(
bridge_dir_arg: Path, *, content: str, timeout_s: float = 30.0
) -> None:
del bridge_dir_arg, timeout_s
msg_calls.append(content)
monkeypatch.setattr(claude_native_executor, "read_launch_model", lambda _bridge: None)
# Only opus is pinned, so a sonnet id has no spelling this pane accepts:
# the bare "sonnet" alias would resolve to a vendor id the gateway rejects.
monkeypatch.setattr(
claude_native_executor,
"read_model_env",
lambda _bridge: {"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8"},
)
monkeypatch.setattr(claude_native_executor, "inject_slash_command", fake_inject_slash_command)
monkeypatch.setattr(claude_native_executor, "inject_user_message", fake_inject_user_message)
executor = ClaudeNativeExecutor(tmp_path / "bridge")
events = [
event
async for event in executor.run_turn(
messages=[{"role": "user", "content": "hello"}],
tools=[],
system_prompt="",
config=ExecutorConfig(model="databricks-claude-sonnet-5"),
)
]
assert slash_calls == []
assert msg_calls == ["hello"]
assert events == [TurnComplete(response=None)]
@pytest.mark.asyncio
async def test_run_turn_skips_switch_when_the_family_pin_drifted(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""A mismatched family pin must not be spoken as its alias.
The workspace serves two opus generations and ``opus`` is pinned to the
newer one, so ``/model opus`` would move the pane off the routed model
while the transcript claimed it ran.
"""
monkeypatch.delenv(REQUEST_SESSION_ID_ENV_VAR, raising=False)
slash_calls: list[str] = []
msg_calls: list[str] = []
monkeypatch.setattr(claude_native_executor, "read_launch_model", lambda _bridge: None)
monkeypatch.setattr(
claude_native_executor,
"read_model_env",
lambda _bridge: {"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5"},
)
monkeypatch.setattr(
claude_native_executor,
"inject_slash_command",
lambda bridge_dir_arg, *, command, timeout_s=30.0, auto_confirm=False, confirm_hint=None: (
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,
@@ -901,9 +1070,14 @@ async def test_run_turn_without_model_override_injects_message_only(
msg_calls: list[str] = []
def fake_inject_slash_command(
bridge_dir_arg: Path, *, command: str, timeout_s: float = 30.0, auto_confirm: bool = False
bridge_dir_arg: Path,
*,
command: str,
timeout_s: float = 30.0,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
del bridge_dir_arg, timeout_s, auto_confirm
del bridge_dir_arg, timeout_s, auto_confirm, confirm_hint
slash_calls.append(command)
def fake_inject_user_message(
@@ -950,9 +1124,14 @@ async def test_run_turn_skips_model_switch_when_already_on_that_model(
msg_calls: list[str] = []
def fake_inject_slash_command(
bridge_dir_arg: Path, *, command: str, timeout_s: float = 30.0, auto_confirm: bool = False
bridge_dir_arg: Path,
*,
command: str,
timeout_s: float = 30.0,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
del bridge_dir_arg, timeout_s, auto_confirm
del bridge_dir_arg, timeout_s, auto_confirm, confirm_hint
slash_calls.append(command)
def fake_inject_user_message(
@@ -983,3 +1162,65 @@ async def test_run_turn_skips_model_switch_when_already_on_that_model(
assert slash_calls == [], f"No /model expected when already on that model; got {slash_calls}."
assert msg_calls == ["hello"]
assert events == [TurnComplete(response=None)]
@pytest.mark.asyncio
async def test_a_routed_first_message_switches_the_model_exactly_once(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""
The replay of a routed first message must not re-issue ``/model``.
First-message routing blocks the prompt, types the switch itself, then
replays the prompt with the same ``model_override``. Seeding the baseline
from ``launch_model`` (written once at bridge prepare) made the replay
compare against the PRE-switch model and type a second, redundant
``/model`` visible in the transcript ahead of the very first turn.
"""
monkeypatch.delenv(REQUEST_SESSION_ID_ENV_VAR, raising=False)
bridge_dir = tmp_path / "bridge"
slash_calls: list[str] = []
msg_calls: list[str] = []
def fake_inject_slash_command(bridge_dir_arg: Path, *, command: str, **kwargs: object) -> None:
del bridge_dir_arg, kwargs
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)
# The launch model is stale — the turn router already moved the pane, and
# only the statusLine capture knows it.
monkeypatch.setattr(
claude_native_executor,
"read_launch_model",
lambda _bridge: "databricks-claude-sonnet-5",
)
monkeypatch.setattr(
claude_native_executor,
"read_claude_status_model",
lambda _bridge: "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(bridge_dir)
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 == [], (
f"The router already switched the pane; the replay must type nothing. Got {slash_calls}."
)
assert msg_calls == ["hello"]
assert events == [TurnComplete(response=None)]
+669
View File
@@ -0,0 +1,669 @@
from __future__ import annotations
import io
import json
import os
from pathlib import Path
from typing import Any
import pytest
from omnigent.claude_model_vocabulary import claude_model_alias
from omnigent.inner.hook_scripts import claude_router_hook, subagent_router
from tests.inner.conftest import advertise_relay_tools, advertise_router
def _payload(
*,
tool_name: str = "Agent",
subagent_type: str = "code-reviewer",
prompt: str = "review the diff",
model: str | None = None,
) -> dict[str, Any]:
tool_input: dict[str, Any] = {"subagent_type": subagent_type, "prompt": prompt}
if model is not None:
tool_input["model"] = model
return {
"hook_event_name": "PreToolUse",
"tool_name": tool_name,
"tool_input": tool_input,
"tool_use_id": "toolu_1",
}
def _run_hook_main(
monkeypatch: pytest.MonkeyPatch,
stdin: str,
argv: list[str],
) -> str:
"""Drive ``claude_router_hook.main`` over *stdin* and return its stdout."""
monkeypatch.setattr("sys.stdin", io.StringIO(stdin))
out = io.StringIO()
monkeypatch.setattr("sys.stdout", out)
assert claude_router_hook.main(argv) == 0
return out.getvalue()
def _no_router(monkeypatch: pytest.MonkeyPatch, why: str) -> None:
"""Fail the test if the hook reaches the router at all."""
def unreachable(*args: object, **kwargs: object) -> dict[str, Any] | None:
raise AssertionError(why)
monkeypatch.setattr(subagent_router, "request_decision", unreachable)
def _run_hook(
monkeypatch: pytest.MonkeyPatch,
router_dir: Path,
payload: dict[str, Any],
decision: dict[str, Any] | None,
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
"""Run the hook with a canned router *decision*; return output + requests."""
seen: list[dict[str, Any]] = []
def fake_request(
endpoint: subagent_router.RouterEndpoint,
session_id: str,
body: dict[str, Any],
*,
timeout: float = 0.0,
) -> dict[str, Any] | None:
seen.append({"endpoint": endpoint, "session_id": session_id, "body": body})
return decision
monkeypatch.setattr(subagent_router, "request_decision", fake_request)
raw = _run_hook_main(monkeypatch, json.dumps(payload), ["--bridge-dir", str(router_dir)])
return (json.loads(raw) if raw else None), seen
def test_rewrite_allows_with_routed_model(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
out, _requests = _run_hook(
monkeypatch,
router_dir,
_payload(),
{
"action": "rewrite",
"model": "databricks-claude-haiku-4-5",
"raw_model": "router-vocab-model",
"rationale": "cheapest arm",
"decision_id": "dec-1",
},
)
assert out == {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"subagent_type": "code-reviewer",
"prompt": "review the diff",
# Claude's Agent tool takes tier aliases, never catalog ids.
"model": "haiku",
},
"permissionDecisionReason": "cheapest arm (applied as 'haiku')",
}
}
@pytest.mark.parametrize(
("model", "expected"),
[
("databricks-claude-sonnet-5", "sonnet"),
("databricks-claude-sonnet-4-6", "sonnet"),
("databricks-claude-haiku-4-5", "haiku"),
("databricks-claude-opus-4-8", "opus"),
("databricks-claude-fable-5", "fable"),
("system.ai.claude-sonnet-5", "sonnet"),
("claude-opus-4-8[1m]", "opus"),
("sonnet", "sonnet"),
("databricks-gpt-5-5", None),
("mystery-model", None),
("", None),
],
)
def test_agent_tool_model_translation(model: str, expected: str | None) -> None:
assert claude_model_alias(model, {}) == expected
def test_agent_tool_model_prefers_workspace_alias_pinning() -> None:
# The workspace pins "sonnet" to a model whose own name says otherwise;
# the env mapping is authoritative over the name heuristic.
env = {"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-mystery-9"}
assert claude_model_alias("databricks-claude-mystery-9", env) == "sonnet"
def test_untranslatable_model_allows_spawn_unchanged(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An id with no Agent-tool alias must not be injected — the CLI 400s."""
router_dir = advertise_router(tmp_path)
for env_var in ("ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL"):
monkeypatch.delenv(env_var, raising=False)
out, _requests = _run_hook(
monkeypatch,
router_dir,
_payload(),
{"action": "rewrite", "model": "mystery-model", "rationale": "r", "decision_id": "d"},
)
assert out is None
def test_bridge_recorded_pinning_gates_the_translation(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The launch pinning recorded on the bridge decides what's spellable."""
router_dir = advertise_router(tmp_path)
(tmp_path / "bridge.json").write_text(
json.dumps(
{
"active_session_id": "conv_abc",
# Only opus is pinned to a gateway id, so a routed sonnet has
# no accepted spelling — "sonnet" would resolve to a vendor id
# the gateway rejects.
"model_env": {"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8"},
}
)
)
decision = {
"action": "rewrite",
"model": "databricks-claude-sonnet-5",
"rationale": "r",
"decision_id": "d",
}
out, _requests = _run_hook(monkeypatch, router_dir, _payload(), decision)
assert out is None
decision["model"] = "databricks-claude-opus-4-8"
out, _requests = _run_hook(monkeypatch, router_dir, _payload(), decision)
assert out is not None
assert out["hookSpecificOutput"]["updatedInput"]["model"] == "opus"
def test_codex_style_output_keeps_the_catalog_id() -> None:
"""Without a translator the servable id is injected verbatim (codex)."""
decision = {"action": "rewrite", "model": "databricks-gpt-5-5", "rationale": "r"}
output = subagent_router.decision_to_hook_output(decision, {"task_name": "t"})
assert output is not None
assert output["hookSpecificOutput"]["updatedInput"] == {
"task_name": "t",
"model": "databricks-gpt-5-5",
}
def _redirect_reason(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> str:
"""Run a cross-harness redirect through the hook and return its deny reason."""
out, _requests = _run_hook(
monkeypatch,
tmp_path,
_payload(),
{
"action": "redirect",
"model": "other-model",
"harness": "codex",
"rationale": "cross-harness pick",
"decision_id": "dec-2",
},
)
assert out is not None
hook_output = out["hookSpecificOutput"]
assert hook_output["hookEventName"] == "PreToolUse"
assert hook_output["permissionDecision"] == "deny"
reason = hook_output["permissionDecisionReason"]
assert isinstance(reason, str)
return reason
def test_redirect_denies_with_mcp_prefixed_session_create_instruction(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
advertise_router(tmp_path)
advertise_relay_tools(tmp_path, "sys_session_create", "sys_agent_list", "sys_read_inbox")
reason = _redirect_reason(tmp_path, monkeypatch)
# The instruction must name the tool the way Claude advertises it. Claude
# exposes Omnigent's MCP tools as ``mcp__omnigent__<tool>``, so the bare
# name it used to quote made the model report the tool as nonexistent and
# abandon the sub-task (live: session e26d94b2).
assert "mcp__omnigent__sys_session_create" in reason
assert "mcp__omnigent__sys_agent_list" in reason
# No bare occurrence outside the prefixed spelling.
assert "sys_session_create" not in reason.replace("mcp__omnigent__sys_session_create", "")
assert "sys_agent_list" not in reason.replace("mcp__omnigent__sys_agent_list", "")
assert "sys_session_send" not in reason
assert "other-model" in reason
assert "codex" in reason
# Claude Code defers MCP schemas behind tool search, so the tool is absent
# from the up-front list; the reason must say to search rather than assume.
assert "omnigent" in reason
assert "search" in reason.lower()
def test_redirect_without_the_spawn_tool_names_no_sys_session_tool(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A session whose relay has no spawn tool must not be told to call one.
Naming an unheld tool is the failure this whole change fixes; the fallback
has to name nothing and hand the work back to the current model.
"""
advertise_router(tmp_path)
advertise_relay_tools(tmp_path, "sys_read_inbox")
reason = _redirect_reason(tmp_path, monkeypatch)
assert "sys_session_" not in reason
assert "sys_agent_list" not in reason
assert "yourself" in reason
assert "other-model" in reason
def test_redirect_without_a_relay_file_keeps_the_actionable_instruction(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No ``tool_relay.json`` means availability is unknown, not unavailable.
Every harness without a relay (the claude-agent-sdk arm) must keep today's
instruction rather than degrade to the do-it-yourself fallback.
"""
advertise_router(tmp_path)
assert not (tmp_path / subagent_router._TOOL_RELAY_FILE).exists()
reason = _redirect_reason(tmp_path, monkeypatch)
assert "mcp__omnigent__sys_session_create" in reason
assert "yourself" not in reason
def test_deny_carries_router_rationale(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
out, _requests = _run_hook(
monkeypatch,
router_dir,
_payload(),
{"action": "deny", "model": None, "rationale": "router unreachable", "decision_id": "d"},
)
assert out == {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "router unreachable",
}
}
def test_allow_emits_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
out, _requests = _run_hook(
monkeypatch,
router_dir,
_payload(),
{"action": "allow", "model": None, "rationale": "", "decision_id": "d"},
)
assert out is None
def test_fork_typed_spawn_routes_like_any_other(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
router_dir = advertise_router(tmp_path)
_out, requests = _run_hook(
monkeypatch,
router_dir,
_payload(subagent_type="fork"),
{"action": "allow", "rationale": "", "decision_id": "d"},
)
body = requests[0]["body"]
assert body == {
"harness": "claude-native",
"task_name": "fork",
"prompt": "review the diff",
"parent_model": None,
"requested_model": None,
}
@pytest.mark.parametrize(
("asked", "forwarded"),
[
# Claude's Agent tool spells the ask as a family alias, so forwarding it
# verbatim would compare "opus" against a catalog id and report every
# honored ask as overridden.
("opus", "databricks-claude-opus-4-8"),
("OPUS", "databricks-claude-opus-4-8"),
# Already a catalog id, which compares as-is: passed through.
("databricks-claude-opus-4-8", "databricks-claude-opus-4-8"),
("system.ai.claude-sonnet-5", "system.ai.claude-sonnet-5"),
# An alias this session pins nothing to resolves to a vendor id we
# cannot name, so the body claims no ask at all.
("sonnet", None),
# Sentinels are not model asks.
("inherit", None),
("default", None),
],
)
def test_an_alias_ask_is_resolved_to_the_pinned_catalog_id(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
asked: str,
forwarded: str | None,
) -> None:
router_dir = advertise_router(tmp_path)
(tmp_path / "bridge.json").write_text(
json.dumps(
{
"active_session_id": "conv_abc",
"model_env": {"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-4-8"},
}
)
)
_out, requests = _run_hook(
monkeypatch,
router_dir,
_payload(model=asked),
{"action": "allow", "rationale": "", "decision_id": "d"},
)
assert requests[0]["body"]["requested_model"] == forwarded
def test_endpoint_down_allows_unchanged(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
out, _requests = _run_hook(monkeypatch, router_dir, _payload(), None)
assert out is None
def test_missing_advertisement_allows_unchanged(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv(subagent_router.ROUTER_DIR_ENV_VAR, raising=False)
monkeypatch.delenv(subagent_router.BRIDGE_DIR_ENV_VAR, raising=False)
_no_router(monkeypatch, "router must not be called without an advertisement")
stdout = _run_hook_main(monkeypatch, json.dumps(_payload()), ["--bridge-dir", str(tmp_path)])
assert stdout == ""
@pytest.mark.parametrize(
"url",
[
# Off-box exfiltration: the bridge dir is agent-writable, so an
# advertisement naming a remote host would leak the spawn prompt.
"http://evil.example.com:8080",
# A non-http scheme is not our loopback runner either.
"file:///tmp/x",
"https://127.0.0.1:9000",
# Not loopback, even though it is an IP literal.
"http://10.0.0.5:9000",
],
)
def test_non_loopback_advertisement_is_rejected(tmp_path: Path, url: str) -> None:
advertise_router(tmp_path, url=url)
assert subagent_router.read_router_endpoint(tmp_path) is None
def test_advertisement_from_a_dead_pid_is_rejected(tmp_path: Path) -> None:
"""A stale advertisement's port can be re-bound by another process."""
dead_pid = 2**22 - 1
advertise_router(tmp_path, pid=dead_pid)
assert subagent_router.read_router_endpoint(tmp_path) is None
def test_advertisement_from_a_live_pid_is_accepted(tmp_path: Path) -> None:
advertise_router(tmp_path, pid=os.getpid())
assert subagent_router.read_router_endpoint(tmp_path) is not None
@pytest.mark.parametrize("pid", [None, "1234", 0, -1, 1.5, True])
def test_advertisement_without_a_usable_pid_is_rejected(
tmp_path: Path, pid: object, capsys: pytest.CaptureFixture[str]
) -> None:
"""The runner always writes an int pid, so anything else is not ours."""
advertise_router(tmp_path, pid=pid)
assert subagent_router.read_router_endpoint(tmp_path) is None
assert "pid not alive" in capsys.readouterr().err
def test_rejected_advertisements_explain_themselves_on_stderr(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
advertise_router(tmp_path, url="http://10.0.0.5:9000")
assert subagent_router.read_router_endpoint(tmp_path) is None
err = capsys.readouterr().err
assert "not plain http on loopback" in err
assert subagent_router.ADVERTISEMENT_FILE in err
@pytest.mark.parametrize(
("url", "pid"),
[
("http://10.0.0.5:9000", None),
("http://127.0.0.1:9000/", 2**22 - 1),
],
)
def test_rejection_diagnostics_never_echo_the_advertisement(
tmp_path: Path, capsys: pytest.CaptureFixture[str], url: str, pid: object
) -> None:
"""The advertisement holds a bearer token, so nothing off it is logged."""
advertise_router(tmp_path, url=url, pid=pid, token="s3cr3t-bearer-value")
assert subagent_router.read_router_endpoint(tmp_path) is None
err = capsys.readouterr().err
assert err.strip()
assert "s3cr3t-bearer-value" not in err
assert url not in err
def test_other_tools_are_ignored(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
_no_router(monkeypatch, "non-spawn tools must not reach the router")
stdout = _run_hook_main(
monkeypatch,
json.dumps(_payload(tool_name="Bash")),
["--bridge-dir", str(router_dir)],
)
assert stdout == ""
def test_legacy_task_tool_name_is_routed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
router_dir = advertise_router(tmp_path)
out, _requests = _run_hook(
monkeypatch,
router_dir,
_payload(tool_name="Task"),
{
"action": "rewrite",
"model": "databricks-claude-sonnet-5",
"rationale": "",
"decision_id": "d",
},
)
assert out is not None
assert out["hookSpecificOutput"]["updatedInput"]["model"] == "sonnet"
def test_malformed_stdin_allows_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
assert _run_hook_main(monkeypatch, "not json", []) == ""
def test_session_id_falls_back_to_bridge_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
router_dir = advertise_router(tmp_path, session_id=None)
(tmp_path / "bridge.json").write_text(
json.dumps({"active_session_id": "conv_from_bridge", "launch_model": "parent-model"})
)
monkeypatch.delenv(subagent_router.SESSION_ID_ENV_VAR, raising=False)
monkeypatch.delenv(subagent_router.NATIVE_SESSION_ID_ENV_VAR, raising=False)
_out, requests = _run_hook(
monkeypatch,
router_dir,
_payload(),
{"action": "allow", "rationale": "", "decision_id": "d"},
)
request = requests[0]
assert request["session_id"] == "conv_from_bridge"
assert request["body"]["parent_model"] == "parent-model"
def test_malformed_advertisement_is_treated_as_absent(tmp_path: Path) -> None:
(tmp_path / subagent_router.ADVERTISEMENT_FILE).write_text("{not json")
assert subagent_router.read_router_endpoint(tmp_path) is None
(tmp_path / subagent_router.ADVERTISEMENT_FILE).write_text(json.dumps({"url": "u"}))
assert subagent_router.read_router_endpoint(tmp_path) is None
def test_redirect_without_target_fails_open() -> None:
decision = {"action": "redirect", "model": None, "harness": None, "rationale": "x"}
assert subagent_router.decision_to_hook_output(decision, {}) is None
class _FakeHookMatcher:
def __init__(self, *, matcher: str | None = None, hooks: list[Any], timeout: float) -> None:
self.matcher = matcher
self.hooks = hooks
self.timeout = timeout
class _FakeSDK:
HookMatcher = _FakeHookMatcher
class _FakeOptions:
hooks: dict[str, list[Any]] | None = None
def _install() -> _FakeOptions:
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
options = _FakeOptions()
ClaudeSDKExecutor()._install_subagent_router_hook(_FakeSDK(), options, "parent-model") # type: ignore[arg-type]
return options
def test_sdk_hook_registered_when_router_advertised(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
advertise_router(tmp_path)
monkeypatch.setenv(subagent_router.ROUTER_DIR_ENV_VAR, str(tmp_path))
options = _install()
assert options.hooks is not None
matcher = options.hooks["PreToolUse"][0]
assert matcher.matcher == subagent_router.AGENT_TOOL_MATCHER
# Strictly outside the router call's own budget: registered AT it, the SDK
# could cancel the hook at the same instant its request gave up, so the
# fail-open branch never ran and the harness saw a dead hook.
assert matcher.timeout == subagent_router.HOOK_TIMEOUT_S
assert matcher.timeout > subagent_router.REQUEST_TIMEOUT_S
def test_sdk_hook_not_registered_without_advertisement(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(subagent_router.ROUTER_DIR_ENV_VAR, str(tmp_path))
options = _install()
assert options.hooks is None
async def test_sdk_callback_maps_rewrite(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
advertise_router(tmp_path)
monkeypatch.setenv(subagent_router.ROUTER_DIR_ENV_VAR, str(tmp_path))
bodies: list[dict[str, Any]] = []
def fake_request(
endpoint: subagent_router.RouterEndpoint,
session_id: str,
body: dict[str, Any],
*,
timeout: float = 0.0,
) -> dict[str, Any]:
bodies.append(body)
return {"action": "rewrite", "model": "databricks-claude-sonnet-5", "rationale": "r"}
monkeypatch.setattr(subagent_router, "request_decision", fake_request)
options = _install()
assert options.hooks is not None
callback = options.hooks["PreToolUse"][0].hooks[0]
output = await callback(_payload(), "toolu_1", {"signal": None})
# The SDK callback shares the hook's translation: alias, not catalog id.
assert output["hookSpecificOutput"]["updatedInput"]["model"] == "sonnet"
assert bodies[0]["harness"] == "claude-sdk"
assert bodies[0]["parent_model"] == "parent-model"
async def test_sdk_callback_allows_unchanged_when_router_down(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
advertise_router(tmp_path)
monkeypatch.setenv(subagent_router.ROUTER_DIR_ENV_VAR, str(tmp_path))
monkeypatch.setattr(
subagent_router,
"request_decision",
lambda *args, **kwargs: None,
)
options = _install()
assert options.hooks is not None
callback = options.hooks["PreToolUse"][0].hooks[0]
assert await callback(_payload(), None, {"signal": None}) == {}
# ── Fail open, and fail fast ────────────────────────────────────────────────
def test_the_spawn_gate_asks_on_the_ladders_own_request_budget(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
The hook waits ``REQUEST_TIMEOUT_S`` for a verdict, and no longer.
A spawn gate holds the parent agent's tool call open until it answers, so
the budget IS the stall a wedged router costs. Asserted against the
constant, not elapsed time, so the ladder is what is pinned.
"""
router_dir = advertise_router(tmp_path)
seen: list[float] = []
def _timed_out(
endpoint: subagent_router.RouterEndpoint,
session_id: str,
body: dict[str, Any],
*,
timeout: float = 0.0,
) -> dict[str, Any] | None:
del endpoint, session_id, body
seen.append(timeout)
return None
monkeypatch.setattr(subagent_router, "request_decision", _timed_out)
raw = _run_hook_main(monkeypatch, json.dumps(_payload()), ["--bridge-dir", str(router_dir)])
# "No opinion": the spawn runs on exactly the model it asked for.
assert raw == ""
assert seen == [subagent_router.REQUEST_TIMEOUT_S]
assert subagent_router.REQUEST_TIMEOUT_S < 10.0
# The harness's own kill has to sit above it, or the fail-open branch above
# never runs and the harness sees a dead hook instead of "no opinion".
assert subagent_router.HOOK_TIMEOUT_S > subagent_router.REQUEST_TIMEOUT_S
assert subagent_router.HOOK_TIMEOUT_S <= 15
def test_an_unreachable_relay_is_no_opinion_not_a_dropped_spawn(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
Nothing is listening on the advertised port, so the real transport runs.
Exercises ``request_decision``'s own fail-open rather than a stubbed one:
an urllib failure of any kind has to read as "no opinion", because the
alternative is a spawn the agent asked for that never happens.
"""
router_dir = advertise_router(tmp_path)
endpoint = subagent_router.read_router_endpoint(router_dir)
assert endpoint is not None
assert (
subagent_router.request_decision(endpoint, "conv_abc", {"harness": "claude-sdk"}) is None
)
raw = _run_hook_main(monkeypatch, json.dumps(_payload()), ["--bridge-dir", str(router_dir)])
assert raw == ""
+553
View File
@@ -0,0 +1,553 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import shlex
import subprocess
import sys
from pathlib import Path
from typing import Any
import pytest
from omnigent.inner import codex_executor
from omnigent.inner.codex_executor import (
CODEX_EXTENDED_CATALOG_ENV_VAR,
CODEX_ROUTER_DIR_ENV_VAR,
CODEX_ROUTER_SESSION_ID_ENV_VAR,
_CodexAppServerSession,
_populate_codex_home_config,
codex_extended_catalog_requested,
codex_router_bridge_dir,
codex_router_hooks_settings,
codex_router_session_id,
merge_codex_user_hooks,
write_codex_router_hooks_file,
)
from omnigent.inner.hook_scripts.subagent_router import REQUEST_TIMEOUT_S
_USER_HOOKS = {
"hooks": {
"PreToolUse": [{"hooks": [{"type": "command", "command": "user-pre"}]}],
"Stop": [{"hooks": [{"type": "command", "command": "user-stop"}]}],
}
}
# Enough of a ``codex debug models`` payload for ``extended_model_catalog`` to
# clone the GLM arm from: it needs the clone-source slug to be present.
_MODEL_CATALOG: dict[str, Any] = { # type: ignore[explicit-any]
"models": [
{"slug": "gpt-5.6-luna", "visibility": "list", "supported_reasoning_levels": []},
]
}
def _write_user_home(tmp_path: Path, *, hooks: dict[str, object] | None = None) -> Path:
source = tmp_path / "user-codex"
source.mkdir()
(source / "auth.json").write_text("{}")
(source / "config.toml").write_text('model = "gpt-5.4-mini"\n')
if hooks is not None:
(source / "hooks.json").write_text(json.dumps(hooks))
return source
def test_router_hooks_settings_registers_the_pretooluse_gate(tmp_path: Path) -> None:
payload = codex_router_hooks_settings(
tmp_path / "bridge",
session_id="conv_abc",
python_executable="/usr/bin/python3",
)
hooks = payload["hooks"]
assert set(hooks) == {"PreToolUse"}
(pre_entry,) = hooks["PreToolUse"]
# Regex, never the flattened literal ``collaborationspawn_agent``.
assert pre_entry["matcher"] == r".*spawn_agent"
(pre_hook,) = pre_entry["hooks"]
assert pre_hook["type"] == "command"
# Codex's kill is the outermost bound: just above the hook's own budget.
assert pre_hook["timeout"] > REQUEST_TIMEOUT_S
assert pre_hook["timeout"] < 2 * REQUEST_TIMEOUT_S
assert "route-subagent" in pre_hook["command"]
assert "--session-id conv_abc" in pre_hook["command"]
assert "--harness codex" in pre_hook["command"]
assert f"--bridge-dir {tmp_path / 'bridge'}" in pre_hook["command"]
def test_router_hooks_settings_omits_session_flag_when_unknown(tmp_path: Path) -> None:
payload = codex_router_hooks_settings(tmp_path, python_executable="/usr/bin/python3")
assert "--session-id" not in payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
def test_merge_user_hooks_preserves_user_entries_after_omnigent(tmp_path: Path) -> None:
user_hooks = tmp_path / "hooks.json"
user_hooks.write_text(json.dumps(_USER_HOOKS))
payload = codex_router_hooks_settings(tmp_path, python_executable="/usr/bin/python3")
merged = merge_codex_user_hooks(payload, user_hooks)
pre = merged["hooks"]["PreToolUse"]
assert len(pre) == 2
assert pre[0]["matcher"] == r".*spawn_agent"
assert pre[1]["hooks"][0]["command"] == "user-pre"
assert merged["hooks"]["Stop"][0]["hooks"][0]["command"] == "user-stop"
# The original payload is not mutated.
assert len(payload["hooks"]["PreToolUse"]) == 1
def test_merge_user_hooks_tolerates_malformed_user_file(tmp_path: Path) -> None:
user_hooks = tmp_path / "hooks.json"
user_hooks.write_text("{not json")
payload = codex_router_hooks_settings(tmp_path, python_executable="/usr/bin/python3")
assert merge_codex_user_hooks(payload, user_hooks) == payload
def test_write_router_hooks_file_replaces_symlink_and_merges(tmp_path: Path) -> None:
source = _write_user_home(tmp_path, hooks=_USER_HOOKS)
codex_home = tmp_path / "private"
codex_home.mkdir()
_populate_codex_home_config(codex_home, source)
assert (codex_home / "hooks.json").is_symlink()
path = write_codex_router_hooks_file(
codex_home,
tmp_path / "bridge",
session_id="conv_abc",
python_executable="/usr/bin/python3",
)
assert not path.is_symlink()
payload = json.loads(path.read_text())
assert [entry.get("matcher") for entry in payload["hooks"]["PreToolUse"]] == [
r".*spawn_agent",
None,
]
assert payload["hooks"]["Stop"][0]["hooks"][0]["command"] == "user-stop"
# The user's real hooks.json is untouched.
assert json.loads((source / "hooks.json").read_text()) == _USER_HOOKS
def test_populate_skips_hooks_symlink_when_hooks_are_injected(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, inject_hooks=True)
assert not (codex_home / "hooks.json").exists()
assert (codex_home / "auth.json").is_symlink()
assert (codex_home / "config.toml").is_file()
def test_populate_symlinks_hooks_when_none_are_injected(tmp_path: Path) -> None:
source = _write_user_home(tmp_path, hooks=_USER_HOOKS)
codex_home = tmp_path / "private"
codex_home.mkdir()
_populate_codex_home_config(codex_home, source)
assert (codex_home / "hooks.json").is_symlink()
assert (codex_home / "hooks.json").resolve() == (source / "hooks.json").resolve()
def test_write_router_hooks_file_without_user_hooks(tmp_path: Path) -> None:
codex_home = tmp_path / "private"
codex_home.mkdir()
path = write_codex_router_hooks_file(
codex_home,
tmp_path / "bridge",
user_hooks_source=tmp_path / "missing" / "hooks.json",
python_executable="/usr/bin/python3",
)
payload = json.loads(path.read_text())
assert len(payload["hooks"]["PreToolUse"]) == 1
def test_the_launch_signals_survive_the_codex_env_filter(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The executor reads its session class out of the FILTERED env.
``_clean_codex_env`` is both the codex subprocess env and the executor's
own view of its launch, so a signal missing from its allowlist is dropped
and the feature it gates silently never engages the routing hooks and the
extended catalog were both unreachable on the wrapped ``codex`` harness.
"""
monkeypatch.setenv(CODEX_ROUTER_DIR_ENV_VAR, str(tmp_path))
monkeypatch.setenv(CODEX_ROUTER_SESSION_ID_ENV_VAR, "conv_abc")
monkeypatch.setenv(CODEX_EXTENDED_CATALOG_ENV_VAR, "1")
filtered = codex_executor._clean_codex_env()
assert codex_router_bridge_dir(filtered) == tmp_path
assert codex_router_session_id(filtered) == "conv_abc"
assert codex_extended_catalog_requested(filtered) is True
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
class _HomeSnapshot:
"""The private ``CODEX_HOME`` as codex would have read it at launch."""
def __init__(self, home: Path) -> None:
hooks = home / "hooks.json"
self.is_symlink = hooks.is_symlink()
self.payload: dict[str, Any] | None = None # type: ignore[explicit-any]
if hooks.is_file():
self.payload = json.loads(hooks.read_text())
self.catalog_written = (home / "model_catalog.json").is_file()
config = home / "config.toml"
self.config_names_catalog = config.is_file() and "model_catalog_json" in config.read_text(
encoding="utf-8"
)
class _FakeVersionProc:
"""Answers the routing gate's ``codex --version`` probe."""
def __init__(self, version: str) -> None:
self._stdout = f"codex-cli {version}\n".encode()
async def communicate(self) -> tuple[bytes, bytes]:
return self._stdout, b""
def _start_app_server(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
*,
env: dict[str, str],
probes: list[str] | None = None,
codex_version: str = "0.145.0",
) -> tuple[tuple[str, ...], _HomeSnapshot]:
source = _write_user_home(tmp_path, hooks=_USER_HOOKS)
workspace = tmp_path / "work"
workspace.mkdir()
captured: list[tuple[tuple[str, ...], _HomeSnapshot]] = []
async def fake_exec(*argv: str, **kwargs: Any) -> Any: # type: ignore[explicit-any]
if argv[1:2] == ("--version",):
return _FakeVersionProc(codex_version)
# The session deletes its private CODEX_HOME on the launch failure
# below, so snapshot the home while codex would have read it.
home = Path(kwargs["env"]["CODEX_HOME"])
captured.append((argv, _HomeSnapshot(home)))
raise RuntimeError("stop")
def fake_probe(codex_path: str, source_home: Path, *, timeout: float) -> dict[str, Any]: # type: ignore[explicit-any]
del source_home, timeout
if probes is not None:
probes.append(codex_path)
return _MODEL_CATALOG
monkeypatch.setattr(codex_executor, "populate_codex_skills_from_bundle", lambda *a, **k: None)
monkeypatch.setattr(codex_executor, "_codex_home_config_source_from_env", lambda: source)
monkeypatch.setattr(codex_executor, "_create_subprocess_exec", fake_exec)
# Never shell out to a real ``codex debug models`` from a unit test, and
# never let one session's cached catalog answer another's probe count.
monkeypatch.setattr(codex_executor, "_find_codex_cli", lambda: "/bin/codex")
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_CACHE", {})
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_FAILURES", {})
monkeypatch.setattr(codex_executor, "_probe_codex_model_catalog", fake_probe)
session = _CodexAppServerSession(
codex_path="/bin/echo",
cwd=str(workspace),
env=env,
tool_executor=None,
)
with contextlib.suppress(RuntimeError):
asyncio.run(session.start())
assert captured, "the app-server was never launched"
return captured[0]
def test_app_server_argv_carries_no_hook_trust_flag(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
bridge = tmp_path / "bridge"
bridge.mkdir()
argv, hooks = _start_app_server(
tmp_path,
monkeypatch,
env={
CODEX_ROUTER_DIR_ENV_VAR: str(bridge),
CODEX_ROUTER_SESSION_ID_ENV_VAR: "conv_abc",
},
)
assert argv[:2] == ("/bin/echo", "app-server")
assert "--dangerously-bypass-hook-trust" not in argv
assert hooks.payload is not None
payload = hooks.payload
assert len(payload["hooks"]["PreToolUse"]) == 2
assert "--session-id conv_abc" in payload["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
def test_app_server_keeps_symlinked_hooks_when_routing_off(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
argv, hooks = _start_app_server(tmp_path, monkeypatch, env={})
assert argv[:2] == ("/bin/echo", "app-server")
assert hooks.is_symlink
# ── The three codex session classes (SDK arm) ───────────────────────
#
# The wrapped app-server harness, not the native terminal: its spawns go
# through the session-create path, which routes off the stamped switch with no
# in-harness gate, so this arm still takes auto-harness before it is armed.
# (The native arm arms a pinned Smart Routing session too — see
# ``tests/test_codex_native_app_server.py``.)
#
# A plain codex session must look exactly like a pre-Smart-Routing one: no
# ``codex debug models`` probe replacing its model catalog, no generated
# ``hooks.json`` (so its ``spawn_agent`` never waits on a routing gate), and
# the user's own hooks.json still symlinked so a mid-session edit takes effect.
# A pinned Smart Routing session adds the catalog and nothing else — its routed
# turn can land on a gateway arm codex's bundled catalog has no entry for. Only
# an auto-harness session, whose spawns the router may move across families,
# gets the spawn-routing hook too.
def test_a_plain_codex_session_gets_no_probe_and_keeps_its_hooks_symlink(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
probes: list[str] = []
_argv, home = _start_app_server(tmp_path, monkeypatch, env={}, probes=probes)
assert probes == []
assert not home.catalog_written
assert not home.config_names_catalog
assert home.is_symlink
# The file codex reads is the user's own, byte for byte.
assert home.payload == _USER_HOOKS
def test_a_pinned_smart_routing_codex_session_gets_the_catalog_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
probes: list[str] = []
_argv, home = _start_app_server(
tmp_path,
monkeypatch,
env={CODEX_EXTENDED_CATALOG_ENV_VAR: "1"},
probes=probes,
)
assert probes == ["/bin/codex"]
assert home.catalog_written
assert home.config_names_catalog
# No hooks were injected, so the user's file is still the live one.
assert home.is_symlink
assert home.payload == _USER_HOOKS
def test_an_auto_harness_codex_session_gets_the_catalog_and_the_spawn_gate(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
bridge = tmp_path / "bridge"
bridge.mkdir()
probes: list[str] = []
_argv, home = _start_app_server(
tmp_path,
monkeypatch,
env={
CODEX_EXTENDED_CATALOG_ENV_VAR: "1",
CODEX_ROUTER_DIR_ENV_VAR: str(bridge),
CODEX_ROUTER_SESSION_ID_ENV_VAR: "conv_abc",
},
probes=probes,
)
assert probes == ["/bin/codex"]
assert home.catalog_written
assert home.config_names_catalog
assert not home.is_symlink
assert home.payload is not None
matchers = [entry.get("matcher") for entry in home.payload["hooks"]["PreToolUse"]]
assert matchers == [r".*spawn_agent", None]
# ── The routing hook's own CLI floor ────────────────────────────────
#
# The spawn gate matches a flattened ``spawn_agent`` tool name codex only
# spells that way from 0.145.0 on. Below that the hook can never fire, so
# registering it would leave the session paying for a gate that does nothing
# *and* replace the user's symlinked hooks.json. The floor lives here rather
# than in the launch check so an older codex still launches.
def test_an_old_codex_cli_registers_no_spawn_gate(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
bridge = tmp_path / "bridge"
bridge.mkdir()
with caplog.at_level("WARNING"):
_argv, home = _start_app_server(
tmp_path,
monkeypatch,
env={
CODEX_ROUTER_DIR_ENV_VAR: str(bridge),
CODEX_ROUTER_SESSION_ID_ENV_VAR: "conv_abc",
},
codex_version="0.139.0",
)
# Routing no-ops: the user's own hooks file is still the live one.
assert home.is_symlink
assert home.payload == _USER_HOOKS
assert "smart routing spawn gate disabled" in caplog.text
def test_a_new_enough_codex_cli_registers_the_spawn_gate(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
bridge = tmp_path / "bridge"
bridge.mkdir()
_argv, home = _start_app_server(
tmp_path,
monkeypatch,
env={
CODEX_ROUTER_DIR_ENV_VAR: str(bridge),
CODEX_ROUTER_SESSION_ID_ENV_VAR: "conv_abc",
},
codex_version="0.145.0",
)
assert not home.is_symlink
assert home.payload is not None
matchers = [entry.get("matcher") for entry in home.payload["hooks"]["PreToolUse"]]
assert r".*spawn_agent" in matchers
@pytest.mark.parametrize(
("version", "skipped"),
[
((0, 144, 9), True),
((0, 145, 0), False),
((0, 146, 0), False),
# An unparseable probe must not silently drop routing.
(None, False),
],
)
def test_the_routing_hook_floor_reads_the_probed_version(
version: tuple[int, int, int] | None, skipped: bool
) -> None:
reason = codex_executor.codex_routing_hook_skip_reason(version)
assert (reason is not None) is skipped
if reason is not None:
assert "smart routing spawn gate disabled" in reason
def test_an_old_codex_cli_keeps_the_sdk_harness_hooks_symlinked(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The wrapped (SDK) codex harness shares the floor with the native one."""
source = _write_user_home(tmp_path, hooks=_USER_HOOKS)
bridge = tmp_path / "bridge"
bridge.mkdir()
workspace = tmp_path / "work"
workspace.mkdir()
seen: list[_HomeSnapshot] = []
async def fake_exec(*argv: str, **kwargs: Any) -> Any: # type: ignore[explicit-any]
if argv[1:2] == ("--version",):
return _FakeVersionProc("0.139.0")
seen.append(_HomeSnapshot(Path(kwargs["env"]["CODEX_HOME"])))
raise RuntimeError("stop")
monkeypatch.setattr(codex_executor, "populate_codex_skills_from_bundle", lambda *a, **k: None)
monkeypatch.setattr(codex_executor, "_codex_home_config_source_from_env", lambda: source)
monkeypatch.setattr(codex_executor, "_create_subprocess_exec", fake_exec)
session = codex_executor._CodexAppServerSession(
codex_path="/bin/echo",
cwd=str(workspace),
env={
CODEX_ROUTER_DIR_ENV_VAR: str(bridge),
CODEX_ROUTER_SESSION_ID_ENV_VAR: "conv_abc",
},
tool_executor=None,
)
with contextlib.suppress(RuntimeError):
asyncio.run(session.start())
assert seen, "the app-server was never launched"
assert seen[0].is_symlink
assert seen[0].payload == _USER_HOOKS
def test_router_hook_commands_run_python_isolated(tmp_path: Path) -> None:
"""Every routing hook command passes ``-I`` before ``-m``.
Codex runs hooks with the session's *workspace* as cwd, and ``-m``
prepends cwd to ``sys.path``. A workspace holding a directory named
``omnigent`` any checkout of this project, the most likely workspace
of all then shadows the installed package and the hook dies on
``ModuleNotFoundError``. Codex discards the failure, so the routing gate
fails open in total silence. Observed live: a session whose cwd was a
second omnigent checkout ran with the hook *trusted* but not working.
"""
hooks = codex_router_hooks_settings(
tmp_path, session_id="conv_abc", python_executable="/venv/bin/python"
)["hooks"]
commands = [h["command"] for entries in hooks.values() for e in entries for h in e["hooks"]]
assert commands, "no routing hook commands generated"
for command in commands:
argv = shlex.split(command)
assert argv[1:3] == ["-I", "-m"], f"expected isolated python in {command!r}"
def test_router_hook_survives_a_shadowing_workspace(tmp_path: Path) -> None:
"""The routing hook runs when cwd holds a decoy ``omnigent`` package.
The end-to-end guard for the isolation flag: runs the real generated
``route-subagent`` command from a workspace that shadows the installed
package, exactly the live failure. Without ``-I`` the decoy package
imports and the hook dies on the decoy's ``AssertionError``; with it the
process starts cleanly, finds no router advertisement, and exits 0.
"""
workspace = tmp_path / "workspace"
decoy = workspace / "omnigent"
decoy.mkdir(parents=True)
(decoy / "__init__.py").write_text("raise AssertionError('decoy package imported')\n")
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
hooks = codex_router_hooks_settings(
bridge_dir, session_id="conv_abc", python_executable=sys.executable
)["hooks"]
command = hooks["PreToolUse"][0]["hooks"][0]["command"]
result = subprocess.run(
shlex.split(command),
cwd=str(workspace),
input="{}",
capture_output=True,
text=True,
timeout=120,
)
assert result.returncode == 0, result.stderr
assert "decoy package imported" not in result.stderr, result.stderr
+379
View File
@@ -0,0 +1,379 @@
"""The session-private codex model catalog that makes GLM spawnable.
Codex validates ``spawn_agent``'s ``model`` against its own model catalog
before any request leaves the CLI, and ``model_catalog_json`` REPLACES that
catalog rather than merging into it so the file omnigent writes has to be
codex's own catalog plus the gateway-only arms.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any
import pytest
from omnigent.codex_model_vocabulary import EXTENDED_CATALOG_MODELS
from omnigent.inner import codex_executor
from omnigent.inner.codex_executor import (
extended_model_catalog,
set_codex_model_catalog_path,
write_codex_model_catalog,
)
_GLM_SLUG = EXTENDED_CATALOG_MODELS["glm-5-2"]
def _catalog(**overrides: Any) -> dict[str, Any]: # type: ignore[explicit-any]
luna: dict[str, Any] = { # type: ignore[explicit-any]
"slug": "gpt-5.6-luna",
"display_name": "GPT-5.6-Luna",
"description": "Fast and affordable agentic coding model.",
"visibility": "list",
"context_window": 272000,
"default_reasoning_level": "medium",
"supported_reasoning_levels": [
{"effort": "low"},
{"effort": "medium"},
{"effort": "high"},
{"effort": "xhigh"},
],
"availability_nux": {"seen": 3},
"upgrade": {"to": "gpt-5.6-sol"},
"base_instructions": "You are Codex...",
**overrides,
}
return {"models": [{"slug": "gpt-5.6-sol", "visibility": "list"}, luna]}
def test_the_added_entry_carries_the_clone_sources_fields() -> None:
extended = extended_model_catalog(_catalog())
assert extended is not None
glm = next(m for m in extended["models"] if m["slug"] == _GLM_SLUG)
# Cloned, so codex has every field it needs without omnigent pinning a
# vendor prompt or a context window of its own.
assert glm["base_instructions"] == "You are Codex..."
assert glm["context_window"] == 272000
# Spawnable: the enum is the entries whose visibility lists them.
assert glm["visibility"] == "list"
# Upsell metadata described the cloned arm, not this one.
assert glm["availability_nux"] is None
assert glm["upgrade"] is None
def test_the_added_entry_declares_only_the_efforts_glm_accepts() -> None:
# Codex refuses a pairing outside the ladder ("Reasoning effort `xhigh` is
# not supported for model `system.ai.glm-5-2`"), and applies the default
# when a spawn names no effort — so both belong to the entry.
extended = extended_model_catalog(_catalog())
assert extended is not None
glm = next(m for m in extended["models"] if m["slug"] == _GLM_SLUG)
assert [lv["effort"] for lv in glm["supported_reasoning_levels"]] == [
"low",
"medium",
"high",
]
assert glm["default_reasoning_level"] == "medium"
def test_the_bundled_arms_survive() -> None:
# ``model_catalog_json`` replaces the bundled catalog, so dropping an arm
# here would make it unspawnable.
extended = extended_model_catalog(_catalog())
assert extended is not None
assert [m["slug"] for m in extended["models"]] == [
"gpt-5.6-sol",
"gpt-5.6-luna",
_GLM_SLUG,
]
@pytest.mark.parametrize(
"catalog",
[
# Nothing to clone from: leave codex on its own catalog rather than
# writing one that would narrow the spawn enum.
{"models": [{"slug": "gpt-5.6-sol"}]},
{"models": []},
{},
{"models": "not a list"},
],
)
def test_an_unusable_catalog_is_left_alone(
catalog: dict[str, Any], # type: ignore[explicit-any]
) -> None:
assert extended_model_catalog(catalog) is None
def test_an_arm_codex_already_carries_is_not_duplicated() -> None:
assert extended_model_catalog(_catalog(slug=_GLM_SLUG)) is None
def test_write_is_skipped_without_a_codex_binary(tmp_path: Path) -> None:
assert write_codex_model_catalog(tmp_path, codex_path=None, source_home=tmp_path) is None
assert list(tmp_path.iterdir()) == []
def test_the_cli_is_probed_once_per_host_process(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The probe blocks the event loop during session boot, and the catalog is a
# property of the installed CLI — so every session after the first reuses it.
calls: list[str] = []
def _probe(codex_path: str, source_home: Path, *, timeout: float) -> dict[str, Any]: # type: ignore[explicit-any]
del source_home, timeout
calls.append(codex_path)
return _catalog()
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_CACHE", {})
monkeypatch.setattr(codex_executor, "_probe_codex_model_catalog", _probe)
for _ in range(3):
assert codex_executor.read_codex_model_catalog("/bin/codex", tmp_path) is not None
assert calls == ["/bin/codex"]
def test_an_in_place_codex_upgrade_is_re_probed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The catalog is the INSTALLED binary's, and an upgrade replaces it in place.
``npm i -g @openai/codex`` keeps the same path, so a path-only cache key
served the old codex's models for the rest of the host process — including
the arms a newer codex added.
"""
calls: list[str] = []
def _probe(codex_path: str, source_home: Path, *, timeout: float) -> dict[str, Any]: # type: ignore[explicit-any]
del source_home, timeout
calls.append(codex_path)
return _catalog()
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_CACHE", {})
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_FAILURES", {})
monkeypatch.setattr(codex_executor, "_probe_codex_model_catalog", _probe)
binary = tmp_path / "codex"
binary.write_text("v1")
home = tmp_path / "home"
assert codex_executor.read_codex_model_catalog(str(binary), home) is not None
assert codex_executor.read_codex_model_catalog(str(binary), home) is not None
assert len(calls) == 1
# Upgraded in place: same path, different bytes.
binary.write_text("v2-and-then-some")
assert codex_executor.read_codex_model_catalog(str(binary), home) is not None
assert len(calls) == 2
def test_a_cli_that_cannot_answer_is_not_re_probed_within_the_ttl(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# A broken CLI must not cost the full timeout per session, so the failure is
# remembered — but only briefly (see the test below).
calls: list[str] = []
def _probe(codex_path: str, source_home: Path, *, timeout: float) -> None:
del source_home, timeout
calls.append(codex_path)
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_CACHE", {})
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_FAILURES", {})
monkeypatch.setattr(codex_executor, "_probe_codex_model_catalog", _probe)
for _ in range(3):
assert codex_executor.read_codex_model_catalog("/bin/codex", tmp_path) is None
assert calls == ["/bin/codex"]
def test_a_transient_probe_failure_is_retried_after_the_ttl(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
One 10 s timeout must not disable the catalog for the whole process.
The failure used to be cached permanently, so a single slow probe a loaded
host, a cold binary meant every LATER session on that host silently lost
the gateway-only arms from its ``spawn_agent`` catalog, with nothing in the
logs after the first warning to say why.
"""
results: list[dict[str, Any] | None] = [None, _catalog()]
calls: list[str] = []
def _probe(codex_path: str, source_home: Path, *, timeout: float) -> dict[str, Any] | None: # type: ignore[explicit-any]
del source_home, timeout
calls.append(codex_path)
return results[min(len(calls) - 1, len(results) - 1)]
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_CACHE", {})
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_FAILURES", {})
monkeypatch.setattr(codex_executor, "_probe_codex_model_catalog", _probe)
assert codex_executor.read_codex_model_catalog("/bin/codex", tmp_path) is None
# Inside the TTL: no re-probe.
assert codex_executor.read_codex_model_catalog("/bin/codex", tmp_path) is None
assert len(calls) == 1
# The TTL lapses.
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_FAILURE_TTL_S", 0.0)
codex_executor._MODEL_CATALOG_FAILURES[
codex_executor._model_catalog_cache_key("/bin/codex", tmp_path)
] = time.monotonic() - 1.0
assert codex_executor.read_codex_model_catalog("/bin/codex", tmp_path) is not None
assert len(calls) == 2
# And the success is remembered for good.
assert codex_executor.read_codex_model_catalog("/bin/codex", tmp_path) is not None
assert len(calls) == 2
def test_the_catalog_probe_runs_off_the_event_loop(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``codex debug models`` is a ~10 s subprocess, so it must not run inline.
Both async callers populate the private CODEX_HOME (which is what shells out
to the probe) through ``asyncio.to_thread``; run on the loop it stalled every
other session sharing it for the probe's whole timeout.
"""
import inspect
from omnigent import codex_native_app_server
for module in (codex_executor, codex_native_app_server):
source = inspect.getsource(module)
assert "await asyncio.to_thread(\n _populate_codex_home_config," in source, (
f"{module.__name__} must populate the codex home off the event loop"
)
assert "\n _populate_codex_home_config(" not in source
del tmp_path, monkeypatch
@pytest.mark.parametrize(
"stdout",
[
# Not JSON at all — a codex that printed a banner or an error.
"not json",
# JSON, but not an object.
"[]",
# Right shape, empty or unusable entries: an entry with no slug cannot
# be matched or cloned, and codex would accept only what this file
# lists — so a partially-readable payload would NARROW the spawn enum.
'{"models": []}',
'{"models": [{"slug": "gpt-5.6-luna"}, {"display_name": "no slug"}]}',
'{"models": [{"slug": ""}]}',
'{"models": [{"slug": "gpt-5.6-luna"}, "a string"]}',
],
)
def test_a_malformed_probe_result_keeps_the_bundled_catalog(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
stdout: str,
) -> None:
"""``model_catalog_json`` replaces codex's catalog, so a bad probe must not
become that file the session keeps codex's bundled one (fail open)."""
class _Completed:
returncode = 0
stderr = ""
def __init__(self, out: str) -> None:
self.stdout = out
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_CACHE", {})
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_FAILURES", {})
monkeypatch.setattr(
codex_executor.subprocess,
"run",
lambda *a, **k: _Completed(stdout), # type: ignore[arg-type]
)
assert codex_executor.read_codex_model_catalog("/bin/codex", tmp_path) is None
written = write_codex_model_catalog(tmp_path, codex_path="/bin/codex", source_home=tmp_path)
assert written is None
assert not (tmp_path / "model_catalog.json").exists()
def test_concurrent_populates_share_one_probe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Two sessions booting together must not each pay the 10 s probe.
Both callers reach the catalog from a worker thread (the populate runs
through ``asyncio.to_thread``), so without a lock they raced the cache and
shelled out twice.
"""
import threading
calls: list[str] = []
entered = threading.Event()
release = threading.Event()
def _probe(codex_path: str, source_home: Path, *, timeout: float) -> dict[str, Any]: # type: ignore[explicit-any]
del source_home, timeout
calls.append(codex_path)
entered.set()
# Hold the probe open so the second thread is guaranteed to arrive
# while the first is still inside it — the race window itself.
release.wait(timeout=10)
return _catalog()
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_CACHE", {})
monkeypatch.setattr(codex_executor, "_MODEL_CATALOG_FAILURES", {})
monkeypatch.setattr(codex_executor, "_probe_codex_model_catalog", _probe)
results: list[dict[str, Any] | None] = [] # type: ignore[explicit-any]
def _read() -> None:
results.append(codex_executor.read_codex_model_catalog("/bin/codex", tmp_path))
first = threading.Thread(target=_read)
first.start()
assert entered.wait(timeout=10)
second = threading.Thread(target=_read)
second.start()
release.set()
first.join(timeout=10)
second.join(timeout=10)
assert calls == ["/bin/codex"]
assert all(result is not None for result in results)
def test_the_config_key_lands_above_the_first_table(tmp_path: Path) -> None:
config = tmp_path / "config.toml"
config.write_text(
'model = "databricks-gpt-5-6-luna"\n\n[model_providers.Databricks]\nname = "x"\n'
)
catalog = tmp_path / "model_catalog.json"
catalog.write_text("{}")
assert set_codex_model_catalog_path(config, catalog) is True
lines = config.read_text().splitlines()
key_at = next(i for i, line in enumerate(lines) if line.startswith("model_catalog_json"))
table_at = next(i for i, line in enumerate(lines) if line.startswith("["))
# A top-level key inside a table would configure the provider, not codex.
assert key_at < table_at
assert json.loads(lines[key_at].split("=", 1)[1].strip()) == str(catalog)
def test_a_users_own_catalog_choice_wins(tmp_path: Path) -> None:
config = tmp_path / "config.toml"
config.write_text('model_catalog_json = "/mine.json"\n')
assert set_codex_model_catalog_path(config, tmp_path / "ours.json") is False
assert config.read_text() == 'model_catalog_json = "/mine.json"\n'
+56
View File
@@ -12,6 +12,7 @@ import pytest
from omnigent.codex_native_bridge import (
CodexNativeBridgeState,
read_bridge_state,
read_codex_config_model,
write_bridge_startup_error,
write_bridge_state,
)
@@ -838,6 +839,61 @@ def test_web_model_pick_applied_via_thread_settings_update(
]
def test_model_settings_update_mirrors_model_into_config_toml(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""
An applied model switch is mirrored into codex-home/config.toml.
``thread/settings/update`` changes the live thread but not
``config.toml`` the file the forwarder's model mirror and the
cost-gate hook treat as source of truth. Without the mirror write, the
next ``turn/started`` re-reads the stale launch model and posts an
``external_model_change`` back to Omnigent, silently reverting a routed
or web-picked model to the spawn default.
"""
_FakeCodexNativeClient.requests = []
_FakeCodexNativeClient.created = []
_FakeCodexNativeClient.next_turn = 1
monkeypatch.setattr(
"omnigent.codex_native_app_server.CodexAppServerClient",
_FakeCodexNativeClient,
)
_start_state(tmp_path)
home = tmp_path / "codex-home"
home.mkdir(parents=True, exist_ok=True)
(home / "config.toml").write_text('model = "databricks-gpt-5-5"\n')
executor = CodexNativeExecutor(bridge_dir=tmp_path)
_run_turn_with_config(executor, "hello", ExecutorConfig(model="gpt-5.6-luna"))
assert read_codex_config_model(tmp_path) == "gpt-5.6-luna"
def test_effort_only_settings_update_leaves_config_toml_model(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""An effort-only settings update must not rewrite the config model."""
_FakeCodexNativeClient.requests = []
_FakeCodexNativeClient.created = []
_FakeCodexNativeClient.next_turn = 1
monkeypatch.setattr(
"omnigent.codex_native_app_server.CodexAppServerClient",
_FakeCodexNativeClient,
)
_start_state(tmp_path)
home = tmp_path / "codex-home"
home.mkdir(parents=True, exist_ok=True)
(home / "config.toml").write_text('model = "databricks-gpt-5-5"\n')
executor = CodexNativeExecutor(bridge_dir=tmp_path)
_run_turn_with_config(executor, "hello", ExecutorConfig(extra={"reasoning_effort": "high"}))
assert read_codex_config_model(tmp_path) == "databricks-gpt-5-5"
def test_no_settings_update_when_overrides_unset(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
+528
View File
@@ -0,0 +1,528 @@
from __future__ import annotations
import io
import json
from pathlib import Path
from typing import Any
import pytest
from omnigent.inner.hook_scripts import codex_router_hook as hook
from omnigent.inner.hook_scripts import subagent_router
from tests.inner.conftest import advertise_relay_tools, advertise_router
# Delivered plaintext in hook payloads (measured live); the name survives
# from when it was assumed encrypted and now documents that either way the
# bytes are forwarded verbatim.
_ENCRYPTED_MESSAGE = "enc:AAAABBBBCCCC=="
def _payload(**tool_input: Any) -> dict[str, Any]: # type: ignore[explicit-any]
return {
"hook_event_name": "PreToolUse",
"tool_name": "collaborationspawn_agent",
"model": "gpt-5-6-sol",
"tool_input": {
"task_name": "refactor-tests",
"message": _ENCRYPTED_MESSAGE,
**tool_input,
},
}
def _route(
payload: dict[str, Any], # type: ignore[explicit-any]
*,
router_dir: Path,
session_id: str | None = None,
) -> dict[str, Any] | None: # type: ignore[explicit-any]
return subagent_router.route_pre_tool_use(
payload,
harness=hook.DEFAULT_HARNESS,
router_dir=router_dir,
session_id=session_id,
**hook.ROUTE_SEAMS,
)
def _build(
tool_input: dict[str, Any], # type: ignore[explicit-any]
*,
parent_model: str | None = None,
) -> dict[str, Any]: # type: ignore[explicit-any]
return subagent_router.build_route_request(
tool_input,
harness="codex-native",
parent_model=parent_model,
task_keys=hook.ROUTE_SEAMS["task_keys"],
include_prompt=hook.ROUTE_SEAMS["include_prompt"],
prompt_keys=hook.ROUTE_SEAMS["prompt_keys"],
)
class _Router:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = [] # type: ignore[explicit-any]
self.response: dict[str, Any] | None = None # type: ignore[explicit-any]
def __call__(
self,
endpoint: Any, # type: ignore[explicit-any]
session_id: str,
body: dict[str, Any], # type: ignore[explicit-any]
**kwargs: Any, # type: ignore[explicit-any]
) -> dict[str, Any] | None: # type: ignore[explicit-any]
self.calls.append({"endpoint": endpoint, "session_id": session_id, "body": body})
return self.response
@pytest.fixture
def router(monkeypatch: pytest.MonkeyPatch) -> _Router:
fake = _Router()
monkeypatch.setattr(subagent_router, "request_decision", fake)
return fake
def test_is_spawn_agent_tool_matches_flattened_name() -> None:
assert hook.is_spawn_agent_tool("collaborationspawn_agent")
assert hook.is_spawn_agent_tool("spawn_agent")
assert not hook.is_spawn_agent_tool("Bash")
assert not hook.is_spawn_agent_tool(None)
def test_build_route_request_sends_the_message_as_the_routing_prompt() -> None:
# The spawn message arrives plaintext, so it is the routing signal —
# without it every unnamed spawn scores the placeholder and lands the
# router's default arm, never a delegate arm like glm.
body = _build(_payload()["tool_input"], parent_model="gpt-5-6-sol")
assert body == {
"harness": "codex-native",
"task_name": "refactor-tests",
"prompt": _ENCRYPTED_MESSAGE,
"parent_model": "gpt-5-6-sol",
"requested_model": None,
}
def test_build_route_request_forwards_an_explicit_model_ask() -> None:
body = _build({"message": "fix the typo", "model": "system.ai.glm-5-2"})
assert body["requested_model"] == "system.ai.glm-5-2"
assert body["prompt"] == "fix the typo"
@pytest.mark.parametrize(
("tool_input", "expected_task_name"),
[
# Codex names the spawn ``agent_name`` on some paths, ``task_name`` on
# others; the explicit ``task_name`` wins when both are present.
({"agent_name": "doc-writer", "message": _ENCRYPTED_MESSAGE}, "doc-writer"),
({"task_name": "refactor-tests", "agent_name": "doc-writer"}, "refactor-tests"),
# The server supplies the placeholder task; the hook does not invent one.
({"message": _ENCRYPTED_MESSAGE}, ""),
],
)
def test_build_route_request_derives_task_name(
tool_input: dict[str, Any], # type: ignore[explicit-any]
expected_task_name: str,
) -> None:
body = _build(tool_input)
assert body["task_name"] == expected_task_name
# The message, when present, rides along as the routing prompt.
assert body["prompt"] == tool_input.get("message")
def test_rewrite_injects_the_spawn_slug_and_passes_message_verbatim(
tmp_path: Path,
router: _Router,
) -> None:
# ``spawn_agent`` validates ``model`` against codex's own catalog before
# the request leaves the CLI, so the routed catalog id has to be spelled
# in codex's slugs — injecting it verbatim fails the spawn.
advertise_router(tmp_path)
router.response = {
"action": "rewrite",
"model": "databricks-gpt-5-6-luna",
"rationale": "cheapest arm",
"decision_id": "d1",
}
out = _route(_payload(), router_dir=tmp_path)
assert out == {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"task_name": "refactor-tests",
"message": _ENCRYPTED_MESSAGE,
"model": "gpt-5.6-luna",
},
"permissionDecisionReason": "cheapest arm (applied as 'gpt-5.6-luna')",
},
"systemMessage": "Using Smart Routing. Routing to gpt-5.6-luna.",
}
assert router.calls[0]["session_id"] == "conv_abc"
assert router.calls[0]["body"]["prompt"] == _ENCRYPTED_MESSAGE
def test_glm_rewrite_spawns_under_the_gateways_own_spelling(
tmp_path: Path,
router: _Router,
) -> None:
# GLM has no codex slug of its own; omnigent adds it to the session's
# catalog under the exact id the gateway serves, so that id IS the slug.
advertise_router(tmp_path)
router.response = {"action": "rewrite", "model": "system.ai.glm-5-2", "rationale": "delegate"}
out = _route(_payload(), router_dir=tmp_path)
assert out is not None
assert out["hookSpecificOutput"]["updatedInput"]["model"] == "system.ai.glm-5-2"
def test_glm_rewrite_clamps_an_effort_glm_refuses(
tmp_path: Path,
router: _Router,
) -> None:
# Codex refuses the pairing client-side ("Reasoning effort `xhigh` is not
# supported for model `system.ai.glm-5-2`"), so an inherited session
# default would fail the spawn the router just approved.
advertise_router(tmp_path)
router.response = {"action": "rewrite", "model": "system.ai.glm-5-2", "rationale": "delegate"}
out = _route(_payload(reasoning_effort="xhigh"), router_dir=tmp_path)
assert out is not None
assert out["hookSpecificOutput"]["updatedInput"]["reasoning_effort"] == "medium"
@pytest.mark.parametrize("effort", ["low", "medium", "high"])
def test_an_effort_glm_accepts_is_left_alone(
tmp_path: Path,
router: _Router,
effort: str,
) -> None:
advertise_router(tmp_path)
router.response = {"action": "rewrite", "model": "system.ai.glm-5-2", "rationale": "delegate"}
out = _route(_payload(reasoning_effort=effort), router_dir=tmp_path)
assert out is not None
assert out["hookSpecificOutput"]["updatedInput"]["reasoning_effort"] == effort
def test_an_unset_effort_stays_unset(
tmp_path: Path,
router: _Router,
) -> None:
# Codex then applies the model's own catalog default, which is inside its
# ladder; inventing a value would override a user default needlessly.
advertise_router(tmp_path)
router.response = {"action": "rewrite", "model": "system.ai.glm-5-2", "rationale": "delegate"}
out = _route(_payload(), router_dir=tmp_path)
assert out is not None
assert "reasoning_effort" not in out["hookSpecificOutput"]["updatedInput"]
def test_a_pick_codex_cannot_spawn_allows_the_spawn_unchanged(
tmp_path: Path,
router: _Router,
) -> None:
# A Claude id has no spawn slug at all. Falling open leaves the spawn on
# the parent's model — a degraded spawn beats one the CLI kills.
advertise_router(tmp_path)
router.response = {
"action": "rewrite",
"model": "databricks-claude-sonnet-5",
"rationale": "r",
}
assert _route(_payload(), router_dir=tmp_path) is None
@pytest.mark.parametrize(
("response", "expected_notice"),
[
# A rewrite is otherwise invisible — codex reports no model change — so
# the routed model is announced in the TUI.
(
{"action": "rewrite", "model": "databricks-gpt-5-6-luna", "rationale": "cheap"},
# The slug the spawn actually runs on, not the catalog id.
"Using Smart Routing. Routing to gpt-5.6-luna.",
),
# A deny routed to nothing, so there is no model to announce.
({"action": "deny", "rationale": "over budget"}, None),
],
)
def test_routing_notice_announces_only_a_routed_model(
tmp_path: Path,
router: _Router,
response: dict[str, Any], # type: ignore[explicit-any]
expected_notice: str | None,
) -> None:
advertise_router(tmp_path)
router.response = response
out = _route(_payload(), router_dir=tmp_path)
assert out is not None
if expected_notice is None:
assert "systemMessage" not in out
else:
# Top level, alongside hookSpecificOutput — codex reads it there.
assert out["systemMessage"] == expected_notice
assert "systemMessage" not in out["hookSpecificOutput"]
@pytest.mark.parametrize(
("asked", "expected_notice"),
[
# The parent asked for one arm and the router picked another. Codex
# reports no model change of its own, so without naming the ask the
# parent reads its own choice back and never learns it was substituted.
("gpt-5.6-sol", "Using Smart Routing. Requested gpt-5.6-sol; routing to gpt-5.6-luna."),
# The router agreed with the ask — nothing was substituted.
("gpt-5.6-luna", "Using Smart Routing. Routing to gpt-5.6-luna."),
# No ask at all.
(None, "Using Smart Routing. Routing to gpt-5.6-luna."),
],
)
def test_the_notice_names_a_model_the_router_substituted(
tmp_path: Path,
router: _Router,
asked: str | None,
expected_notice: str,
) -> None:
advertise_router(tmp_path)
router.response = {"action": "rewrite", "model": "databricks-gpt-5-6-luna", "rationale": "r"}
tool_input = {} if asked is None else {"model": asked}
out = _route(_payload(**tool_input), router_dir=tmp_path)
assert out is not None
assert out["systemMessage"] == expected_notice
def test_finalize_spawn_input_passes_no_opinion_through() -> None:
assert hook.finalize_spawn_input(None) is None
def _redirect_reason(tmp_path: Path, router: _Router) -> str:
"""Run a cross-harness redirect through the hook and return its deny reason."""
advertise_router(tmp_path)
router.response = {
"action": "redirect",
"harness": "claude-native",
"model": "claude-opus-4-8",
}
out = _route(_payload(), router_dir=tmp_path)
assert out is not None
hook_output = out["hookSpecificOutput"]
assert hook_output["hookEventName"] == "PreToolUse"
assert hook_output["permissionDecision"] == "deny"
reason = hook_output["permissionDecisionReason"]
assert isinstance(reason, str)
return reason
def test_redirect_denies_with_bare_session_create_instruction(
tmp_path: Path,
router: _Router,
) -> None:
advertise_relay_tools(tmp_path, "sys_session_create", "sys_agent_list")
reason = _redirect_reason(tmp_path, router)
# Codex addresses an MCP tool by its BARE name plus a separate namespace
# field; the flattened ``omnigentsys_session_create`` spelling only shows up
# in codex's own logs and hook payloads and is not callable. The codex
# phrasing therefore quotes bare names and names the server as the
# namespace, never a prefixed spelling.
assert "sys_session_create" in reason
assert "sys_agent_list" in reason
assert "mcp__omnigent__" not in reason
assert "omnigentsys_session_create" not in reason
assert "omnigent" in reason
assert "sys_session_send" not in reason
assert "claude-opus-4-8" in reason
assert "claude-native" in reason
# Codex defers MCP schemas behind its own ``tool_search``, so the reason
# must send the model looking rather than let it conclude "no such tool".
assert "search" in reason.lower()
def test_redirect_without_the_spawn_tool_names_no_sys_session_tool(
tmp_path: Path,
router: _Router,
) -> None:
"""No advertised spawn tool → name none and hand the work back."""
advertise_relay_tools(tmp_path, "sys_read_inbox")
reason = _redirect_reason(tmp_path, router)
assert "sys_session_" not in reason
assert "sys_agent_list" not in reason
assert "yourself" in reason
assert "claude-opus-4-8" in reason
def test_redirect_without_a_relay_file_keeps_the_actionable_instruction(
tmp_path: Path,
router: _Router,
) -> None:
"""A missing relay file reads as "unknown", preserving today's instruction."""
assert not (tmp_path / subagent_router._TOOL_RELAY_FILE).exists()
reason = _redirect_reason(tmp_path, router)
assert "sys_session_create" in reason
assert "yourself" not in reason
def test_deny_uses_router_rationale(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
router.response = {"action": "deny", "rationale": "router unavailable"}
out = _route(_payload(), router_dir=tmp_path)
assert out is not None
assert out["hookSpecificOutput"]["permissionDecision"] == "deny"
assert out["hookSpecificOutput"]["permissionDecisionReason"] == "router unavailable"
def test_allow_emits_no_opinion(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
router.response = {"action": "allow", "rationale": "fork exempt"}
assert _route(_payload(), router_dir=tmp_path) is None
def test_router_unreachable_allows_unchanged(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
router.response = None
assert _route(_payload(), router_dir=tmp_path) is None
def test_missing_advertisement_allows_unchanged(
tmp_path: Path,
router: _Router,
) -> None:
assert _route(_payload(), router_dir=tmp_path) is None
assert router.calls == []
def test_malformed_advertisement_allows_unchanged(
tmp_path: Path,
router: _Router,
) -> None:
(tmp_path / subagent_router.ADVERTISEMENT_FILE).write_text("{not json")
assert _route(_payload(), router_dir=tmp_path) is None
assert router.calls == []
def test_unknown_session_allows_unchanged(
tmp_path: Path,
router: _Router,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv(subagent_router.SESSION_ID_ENV_VAR, raising=False)
monkeypatch.delenv(subagent_router.NATIVE_SESSION_ID_ENV_VAR, raising=False)
advertise_router(tmp_path, session_id=None)
assert _route(_payload(), router_dir=tmp_path) is None
assert router.calls == []
def test_baked_session_id_used_when_advertisement_has_none(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path, session_id=None)
router.response = {"action": "allow"}
_route(_payload(), router_dir=tmp_path, session_id="conv_baked")
assert router.calls[0]["session_id"] == "conv_baked"
def test_non_spawn_tool_is_ignored(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
payload = _payload()
payload["tool_name"] = "shell"
assert _route(payload, router_dir=tmp_path) is None
assert router.calls == []
def test_parent_model_falls_back_to_payload_model(
tmp_path: Path,
router: _Router,
) -> None:
advertise_router(tmp_path)
router.response = {"action": "allow"}
_route(_payload(), router_dir=tmp_path)
assert router.calls[0]["body"]["parent_model"] == "gpt-5-6-sol"
def test_route_subagent_without_a_bridge_dir_emits_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv(subagent_router.ROUTER_DIR_ENV_VAR, raising=False)
monkeypatch.delenv(subagent_router.BRIDGE_DIR_ENV_VAR, raising=False)
monkeypatch.setattr(hook.sys, "stdin", _Stdin(json.dumps(_payload())))
out = io.StringIO()
monkeypatch.setattr(hook.sys, "stdout", out)
assert hook.main(["route-subagent"]) == 0
assert out.getvalue() == ""
def test_route_subagent_tolerates_unknown_flags(
tmp_path: Path,
router: _Router,
monkeypatch: pytest.MonkeyPatch,
) -> None:
advertise_router(tmp_path)
router.response = {"action": "allow"}
monkeypatch.setattr(hook.sys, "stdin", _Stdin(json.dumps(_payload())))
out = io.StringIO()
monkeypatch.setattr(hook.sys, "stdout", out)
assert hook.main(["route-subagent", "--unknown-flag", "x", "--bridge-dir", str(tmp_path)]) == 0
assert router.calls[0]["session_id"] == "conv_abc"
def test_unknown_subcommand_is_a_no_op(capsys: pytest.CaptureFixture[str]) -> None:
assert hook.main(["nope"]) == 0
assert "unknown subcommand" in capsys.readouterr().err
class _Stdin:
def __init__(self, text: str) -> None:
self._text = text
def read(self) -> str:
return self._text
+24
View File
@@ -1190,6 +1190,30 @@ def test_harness_cli_installed_checks_minimum_for_other_versioned_specs(
assert hi.harness_cli_installed(key) is False
def test_the_codex_launch_floor_accepts_the_ci_pinned_cli(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""0.139.0 must read as installed, not ``version-too-low``.
A too-low codex makes ``harness_is_configured`` false, and the host then
refuses EVERY codex launch plain sessions included with a misleading
"run omni setup". Smart Routing's spawn hook wants 0.145.0, but that is
enforced where the hook is registered, so an older CLI loses only the
spawn gate.
"""
monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}")
def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]:
if len(argv) >= 2 and argv[1] == "--version":
return subprocess.CompletedProcess(
args=argv, returncode=0, stdout="codex-cli 0.139.0\n", stderr=""
)
raise AssertionError(f"unexpected subprocess: {argv!r}")
monkeypatch.setattr(hi.subprocess, "run", _run)
assert hi.harness_cli_installed(OPENAI_FAMILY) is True
def test_harness_cli_installed_true_when_version_in_range(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -12,6 +12,8 @@ passed. See designs/NATIVE_RUNNER_SERVER_LAUNCH.md.
from __future__ import annotations
from pathlib import Path
import pytest
from omnigent.claude_native import (
@@ -19,6 +21,13 @@ from omnigent.claude_native import (
build_native_claude_terminal_env,
)
from omnigent.runner.app import _build_claude_native_base_args, _claude_terminal_env_unset
from omnigent.runner.native.orchestration import (
_ROUTED_SPAWN_ALLOWED_TOOLS,
_claude_launch_metadata_from_envelope,
_load_legacy_claude_launch_metadata,
_routed_spawn_launch_args,
)
from omnigent.runner.subagent_routing import AUTO_HARNESS_LABEL_KEY
@pytest.mark.parametrize(
@@ -212,6 +221,44 @@ def test_native_launch_passes_synthesized_model_as_flag() -> None:
assert args == ("--model", "gateway-served-claude")
def test_routed_launch_model_reaches_the_terminal_env_as_the_custom_slot() -> None:
"""A routed exact id is launchable AND switchable back to mid-session.
Mirrors the runner's composition: the session override becomes
``--model`` and the same value is pinned into Claude Code's custom picker
slot, which is the only spelling ``/model`` accepts for an id no family
alias points at (``opus`` here resolves to the newer generation).
"""
from omnigent.claude_model_vocabulary import claude_model_command_arg
from omnigent.claude_native import claude_config_with_launch_model_pinned
config = ClaudeNativeUcodeConfig(
env={
"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5",
},
api_key_helper="printf %s sk-gateway",
model="databricks-claude-opus-5",
)
session_model_override = "databricks-claude-opus-4-8"
launched = claude_config_with_launch_model_pinned(config, session_model_override)
assert launched is not None
args = _build_claude_native_base_args(
reasoning_effort=None,
model_override=session_model_override,
terminal_launch_args=None,
)
terminal_env = build_native_claude_terminal_env(launched)
assert args == ("--model", "databricks-claude-opus-4-8")
assert terminal_env["ANTHROPIC_CUSTOM_MODEL_OPTION"] == "databricks-claude-opus-4-8"
assert (
claude_model_command_arg(session_model_override, terminal_env)
== "databricks-claude-opus-4-8"
)
def test_build_native_claude_terminal_env_rejects_raw_key_on_helper_path() -> None:
"""The env-build seam fails loud if a raw key rides the apiKeyHelper path.
@@ -279,3 +326,163 @@ def test_claude_terminal_env_databricks_gateway_helper_path() -> None:
)
assert args == ("--model", "databricks-claude-opus-4-8")
assert config.api_key_helper
@pytest.fixture
def bridge_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""
Yield a bridge dir the claude-native bridge accepts.
``augment_claude_args`` validates the bridge dir against the real
``$TMPDIR/omnigent-<uid>/claude-native`` root, so a raw ``tmp_path`` is
rejected. Point the bridge root and its trusted parent at the test's temp
dir the way ``tests/test_claude_native_bridge.py`` does.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Per-test temp directory.
:returns: Bridge dir under the patched bridge root.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path)
return tmp_path
def _augmented(bridge_dir: Path, *, auto_harness: bool) -> list[str]:
"""Run the runner's own claude-native argv composition for one session shape."""
from omnigent.claude_native_bridge import augment_claude_args
note, allowed = _routed_spawn_launch_args(auto_harness)
return augment_claude_args(
("--model", "databricks-claude-sonnet-5"),
bridge_dir=bridge_dir,
python_executable="/venv/bin/python",
append_system_prompt=note,
allowed_tools=allowed,
)
def test_auto_harness_launch_names_the_routed_spawn_tool_and_preapproves_it(
bridge_dir: Path,
) -> None:
"""An auto-harness Claude launch carries the note AND the tool allowlist.
Both halves of the live failure: the model reported
``mcp__omnigent__sys_session_create`` as nonexistent (no note, and the
schema is deferred behind tool search), and Claude Code's don't-ask mode
denied the Omnigent MCP call outright (no ``--allowedTools``).
"""
args = _augmented(bridge_dir, auto_harness=True)
note = args[args.index("--append-system-prompt") + 1]
assert "mcp__omnigent__sys_session_create" in note
assert "mcp__omnigent__sys_agent_list" in note
# Bare spellings would send the model looking for a tool Claude does not
# advertise, which is the bug.
assert "`sys_session_create`" not in note
allowed = args[args.index("--allowedTools") + 1].split(",")
assert "mcp__omnigent__sys_session_create" in allowed
assert "mcp__omnigent__sys_agent_list" in allowed
assert "mcp__omnigent__sys_session_send" in allowed
assert set(_ROUTED_SPAWN_ALLOWED_TOOLS) <= set(allowed)
def test_pinned_harness_launch_argv_is_unchanged(bridge_dir: Path) -> None:
"""A pinned session's argv must stay byte-identical to the pre-change one.
The routed-spawn note and the tool allowlist are additions for auto-harness
sessions only; leaking either into a pinned launch would change every
non-routed native session's command line.
"""
from omnigent.claude_native_bridge import augment_claude_args
baseline = augment_claude_args(
("--model", "databricks-claude-sonnet-5"),
bridge_dir=bridge_dir,
python_executable="/venv/bin/python",
)
assert _augmented(bridge_dir, auto_harness=False) == baseline
assert "--append-system-prompt" not in baseline
assert "--allowedTools" not in baseline
def test_routed_spawn_launch_args_gate_is_off_without_auto_harness() -> None:
assert _routed_spawn_launch_args(False) == (None, ())
note, allowed = _routed_spawn_launch_args(True)
assert note
assert allowed == _ROUTED_SPAWN_ALLOWED_TOOLS
@pytest.mark.parametrize(
("labels", "harness_override", "expected"),
[
({AUTO_HARNESS_LABEL_KEY: "1"}, None, True),
# The sentinel is replaced once first-message routing resolves a
# harness, so a session still carrying it is auto-harness too.
({}, "auto", True),
({}, "claude-native", False),
({AUTO_HARNESS_LABEL_KEY: "0"}, None, False),
({}, None, False),
],
ids=["label", "sentinel", "pinned", "label-off", "neither"],
)
def test_envelope_metadata_reads_the_auto_harness_flag(
labels: dict[str, str],
harness_override: str | None,
expected: bool,
) -> None:
from omnigent.runner.session_init_protocol import (
SESSION_INIT_PROTOCOL_VERSION,
RunnerSessionInitEnvelope,
)
envelope = RunnerSessionInitEnvelope(
protocol_version=SESSION_INIT_PROTOCOL_VERSION,
server_version="test",
session_id="conv_abc",
agent_id="agent",
snapshot={
"created_at": 0,
"updated_at": 0,
"labels": labels,
"harness_override": harness_override,
},
)
assert _claude_launch_metadata_from_envelope(envelope).auto_harness is expected
@pytest.mark.parametrize(
("labels", "harness_override", "expected"),
[
({AUTO_HARNESS_LABEL_KEY: "1"}, None, True),
({}, "auto", True),
({}, "claude-native", False),
({}, None, False),
],
ids=["label", "sentinel", "pinned", "neither"],
)
async def test_legacy_metadata_loader_reads_the_auto_harness_flag(
labels: dict[str, str],
harness_override: str | None,
expected: bool,
) -> None:
"""The removable legacy snapshot path must parse the flag too.
A server predating the init envelope still answers ``GET /v1/sessions``, and
an auto-harness session launched through it needs the same note.
"""
import httpx
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={"labels": labels, "harness_override": harness_override},
)
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler), base_url="http://runner"
) as client:
metadata = await _load_legacy_claude_launch_metadata(client, "conv_abc")
assert metadata.auto_harness is expected
@@ -2802,9 +2802,10 @@ async def test_events_effort_change_on_native_session_types_slash_command(
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Record the call and return without touching tmux."""
captured.append((bridge_dir, command, timeout_s))
captured.append((bridge_dir, command, timeout_s, confirm_hint))
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
@@ -2871,8 +2872,13 @@ async def test_events_effort_change_on_native_session_types_slash_command(
assert len(captured) == 1, (
f"Expected one inject_slash_command call from native effort_change, got {len(captured)}."
)
bridge_dir, command, timeout_s = captured[0]
bridge_dir, command, timeout_s, confirm_hint = captured[0]
assert bridge_dir == bridge_dir_for_conversation_id("c7e9584b9bb34910a0068521106c1abc")
# The effort dialog's own title, not "Switch model?". Watching for the wrong
# one would leave the pane wedged behind an unconfirmed modal; watching for
# "any dialog" would answer a foreign one (a permission prompt, a picker the
# person opened) that rendered while the poll was running.
assert confirm_hint == claude_native_bridge.EFFORT_DIALOG_HINT
# Body contract: ``/effort high`` is the literal Claude Code's TUI
# accepts. A regression in shape (``/efforthigh``, ``effort high``,
# missing leading slash) would either 404 on the slash router or
@@ -66,6 +66,7 @@ async def test_events_effort_change_on_native_session_skips_inject_for_unsupport
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Fail the test if the runner reaches inject for an unsupported level."""
del bridge_dir, command, timeout_s
@@ -141,6 +142,7 @@ async def test_events_effort_change_on_native_session_returns_503_when_bridge_no
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Simulate the bridge-not-ready path."""
del bridge_dir, command, timeout_s
@@ -216,6 +218,7 @@ async def test_events_effort_change_on_non_native_session_is_204_noop(
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Fail the test if a non-native session reaches the injector."""
del bridge_dir, command, timeout_s
@@ -301,6 +304,7 @@ async def test_events_compact_on_native_session_types_slash_command(
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Record the call (including auto_confirm) without touching tmux."""
captured.append((bridge_dir, command, timeout_s, auto_confirm))
@@ -408,6 +412,7 @@ async def test_events_compact_on_native_session_returns_503_when_bridge_not_read
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Simulate the bridge-not-ready path."""
del bridge_dir, command, timeout_s, auto_confirm
@@ -1648,6 +1653,7 @@ async def test_events_compact_on_non_native_session_is_204_noop(
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Fail the test if a non-native session reaches the injector."""
del bridge_dir, command, timeout_s, auto_confirm
@@ -1847,11 +1853,19 @@ async def test_events_model_change_on_native_session_types_slash_command(
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Record the call and return without touching tmux."""
captured.append((bridge_dir, command, timeout_s))
captured.append((bridge_dir, command, timeout_s, confirm_hint))
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
# The pane's own picker vocabulary: this id occupies the custom slot, so
# ``/model`` takes it exactly rather than stepping down to ``opus``.
monkeypatch.setattr(
claude_native_bridge,
"read_model_env",
lambda _bridge_dir: {"ANTHROPIC_CUSTOM_MODEL_OPTION": "claude-opus-4-7"},
)
native_spec = AgentSpec(
spec_version=1,
@@ -1909,16 +1923,83 @@ async def test_events_model_change_on_native_session_types_slash_command(
assert len(captured) == 1, (
f"Expected one inject_slash_command call from native model_change, got {len(captured)}."
)
_bridge_dir, command, timeout_s = captured[0]
_bridge_dir, command, timeout_s, confirm_hint = captured[0]
assert command == "/model claude-opus-4-7", (
f"Expected '/model claude-opus-4-7' literal, got {command!r}."
)
assert timeout_s == 1.0
# The model dialog's title is known, so it is polled for rather than
# confirmed blind after a fixed settle.
assert confirm_hint == claude_native_bridge.SWITCH_MODEL_DIALOG_HINT
assert queued_events == [], (
f"model_change must not publish session events; got {queued_events!r}."
)
@pytest.mark.asyncio
async def test_events_model_change_rejects_a_model_the_picker_cannot_spell(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A model outside the pane's ``/model`` vocabulary fails loud, typing nothing.
Typing a bare catalog id the picker has no row for leaves the pane on its
old model while the handler reports success, so the session's recorded
model diverges from the one it is running.
"""
from omnigent.spec.types import ExecutorSpec
captured: list[Any] = []
def _fake_inject(bridge_dir: Any, **kwargs: Any) -> None:
"""Record any injection so the assertion can prove none happened."""
captured.append((bridge_dir, kwargs))
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
# Every alias is pinned to something else, so the routed id maps to nothing
# ``/model`` accepts.
monkeypatch.setattr(
claude_native_bridge,
"read_model_env",
lambda _bridge_dir: {"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5"},
)
native_spec = AgentSpec(
spec_version=1,
name="t",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
)
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
del agent_id, session_id
return native_spec
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
create_resp = await client.post(
"/v1/sessions",
json={
"session_id": "57c7c1acc5eeec3978c5e62043da51a5",
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
},
)
assert create_resp.status_code == 201, create_resp.text
resp = await client.post(
"/v1/sessions/57c7c1acc5eeec3978c5e62043da51a5/events",
json={"type": "model_change", "model": "databricks-claude-opus-4-8"},
)
assert resp.status_code == 503, resp.text
assert resp.json()["error"] == "claude_native_model_unsupported"
assert captured == []
@pytest.mark.asyncio
async def test_events_model_change_on_kiro_session_types_slash_command(
monkeypatch: pytest.MonkeyPatch,
@@ -2017,6 +2098,7 @@ async def test_events_model_change_on_native_session_skips_inject_for_empty_or_n
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Fail the test if the runner reaches inject for an empty value."""
del bridge_dir, command, timeout_s
@@ -2088,6 +2170,7 @@ async def test_events_model_change_on_native_session_returns_503_when_bridge_not
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Simulate the bridge-not-ready path."""
del bridge_dir, command, timeout_s
@@ -2159,6 +2242,7 @@ async def test_events_model_change_on_non_native_session_is_204_noop(
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Fail the test if a non-native session reaches the injector."""
del bridge_dir, command, timeout_s
@@ -988,7 +988,7 @@ async def test_auto_create_claude_terminal_injects_ucode_gateway_config(
# ``omnigent.claude_native`` per call, so patch it at the source.
monkeypatch.setattr(
"omnigent.claude_native._ucode_config_for_profile",
lambda profile: ucode,
lambda profile, *, refresh_models=True: ucode,
)
captured: dict[str, Any] = {}
@@ -2707,3 +2707,213 @@ async def test_auto_create_claude_terminal_registers_permission_hook(
await asyncio.sleep(0)
assert isinstance(forwarder_kwargs.get("auth"), _RunnerDatabricksAuth)
# ── What a plain claude-native launch must NOT carry ─────────────────
#
# A session created without Smart Routing has to launch byte-for-byte like a
# pre-Smart-Routing one: no spawn-routing endpoint (so no loopback server, no
# bearer token on disk, and no ``Task`` PreToolUse hook paying a hook
# subprocess plus a round trip on every spawn), and no custom-picker-slot pin
# displacing the workspace's own picker row.
#
# Accepted consequence: the class is stamped at create, so flipping the gear's
# Subagent-routing toggle on for a plain session is inert until recreate.
async def _run_auto_create_claude_terminal_for_routing_class(
*,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
session_id: str,
routed: bool,
auto_harness: bool = False,
) -> Any:
"""Drive the claude-native launch and return the captured terminal spec.
:param routed: Stamp ``cost_control_mode_override="on"``, as the create
path does for a session launched on Smart Routing.
:param auto_harness: Stamp the auto-harness label and sentinel WITHOUT the
cost-control field, which is the shape a sub-agent child of a routed
parent is created with.
"""
from omnigent.claude_native import ClaudeNativeUcodeConfig
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
config_home = tmp_path / "config-home"
config_home.mkdir()
(config_home / "config.yaml").write_text(
"auth:\n type: databricks\n profile: test-profile\n"
)
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(config_home))
async def _no_op_forwarder(**kwargs: Any) -> None:
del kwargs
monkeypatch.setattr(
"omnigent.claude_native_forwarder.supervise_forwarder",
_no_op_forwarder,
)
# ``opus`` resolves to the newer generation, so the launch model
# (opus-4-7) has no spelling of its own — exactly the case the pin was
# added for, and the case that overwrites the workspace's picker row.
ucode = ClaudeNativeUcodeConfig(
env={
"ANTHROPIC_BASE_URL": "https://gw.example/anthropic",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5",
"ANTHROPIC_CUSTOM_MODEL_OPTION": "workspace-picker-row",
"ANTHROPIC_CUSTOM_MODEL_OPTION_NAME": "Workspace pick",
},
api_key_helper="printf %s sk-sentinel-do-not-use",
model="databricks-claude-opus-4-7",
)
monkeypatch.setattr(
"omnigent.claude_native._ucode_config_for_profile",
lambda profile, *, refresh_models=True: ucode,
)
captured: dict[str, Any] = {}
class _FakeResourceRegistry:
terminal_registry = None
async def launch_required_terminal(
self,
*,
session_id: str,
terminal_name: str,
session_key: str,
spec: Any,
resource_role: str | None = None,
parent_os_env: Any = None,
) -> SessionResourceView:
del terminal_name, session_key
captured["spec"] = spec
return SessionResourceView(
id="terminal_claude_main",
type="terminal",
session_id=session_id,
name="claude:main",
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
)
snapshot: dict[str, Any] = {"labels": {}}
if routed:
snapshot["cost_control_mode_override"] = "on"
if auto_harness:
from omnigent.runner.subagent_routing import AUTO_HARNESS_LABEL_KEY
snapshot["labels"][AUTO_HARNESS_LABEL_KEY] = "1"
snapshot["harness_override"] = "auto"
fake_client = httpx.AsyncClient(
base_url="http://test-server",
transport=httpx.MockTransport(lambda req: httpx.Response(200, json=snapshot)),
)
try:
await _auto_create_claude_terminal(
session_id,
_FakeResourceRegistry(),
lambda _sid, _evt: None,
server_client=fake_client,
)
finally:
from omnigent.runner import subagent_routing, turn_routing
subagent_routing.shutdown_session_router(session_id)
turn_routing.shutdown_session_turn_router(session_id)
await fake_client.aclose()
return captured["spec"]
def _claude_pretooluse_matchers(spec: Any) -> list[str | None]:
settings = _load_claude_invocation_settings(spec.args)
return [entry.get("matcher") for entry in settings["hooks"].get("PreToolUse", [])]
def _claude_hook_commands(spec: Any) -> list[str]:
settings = _load_claude_invocation_settings(spec.args)
return [
hook.get("command", "")
for entries in settings["hooks"].values()
for entry in entries
for hook in entry.get("hooks", [])
]
async def test_a_plain_claude_native_launch_carries_no_spawn_routing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
spec = await _run_auto_create_claude_terminal_for_routing_class(
tmp_path=tmp_path,
monkeypatch=monkeypatch,
session_id="4a1c9b1d1f0e4c5da0a1b2c3d4e5f601",
routed=False,
)
assert claude_native_bridge.CLAUDE_SUBAGENT_TOOL_MATCHER not in _claude_pretooluse_matchers(
spec
)
assert all("claude_router_hook" not in command for command in _claude_hook_commands(spec))
# The workspace's own picker row survives untouched.
assert spec.env["ANTHROPIC_CUSTOM_MODEL_OPTION"] == "workspace-picker-row"
async def test_a_routed_claude_native_launch_keeps_the_spawn_gate_and_the_pin(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
spec = await _run_auto_create_claude_terminal_for_routing_class(
tmp_path=tmp_path,
monkeypatch=monkeypatch,
session_id="4a1c9b1d1f0e4c5da0a1b2c3d4e5f602",
routed=True,
)
assert claude_native_bridge.CLAUDE_SUBAGENT_TOOL_MATCHER in _claude_pretooluse_matchers(spec)
assert any("claude_router_hook" in command for command in _claude_hook_commands(spec))
assert spec.env["ANTHROPIC_CUSTOM_MODEL_OPTION"] == "databricks-claude-opus-4-7"
# A pinned session's spawns stay on the claude family, so it gets neither
# the routed-spawn note nor the pre-approvals the cross-family hop needs.
assert "--append-system-prompt" not in spec.args
async def test_an_auto_harness_launch_without_a_cost_control_stamp_is_still_routed(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The sub-agent child of a routed parent: auto-harness label, no cost stamp.
Such a child is created with ``harness_override="auto"`` and the
auto-harness label but no ``cost_control_mode_override``. Deriving
``routing_enabled`` from the cost field alone launched it with the
routed-spawn note and the four pre-approved tools while starting no router
and pinning no arms an argv that tells Claude to route spawns through a
hook nothing answers.
"""
spec = await _run_auto_create_claude_terminal_for_routing_class(
tmp_path=tmp_path,
monkeypatch=monkeypatch,
session_id="4a1c9b1d1f0e4c5da0a1b2c3d4e5f603",
routed=False,
auto_harness=True,
)
# The whole routed apparatus, not just the spawn note.
assert claude_native_bridge.CLAUDE_SUBAGENT_TOOL_MATCHER in _claude_pretooluse_matchers(spec)
assert any("claude_router_hook" in command for command in _claude_hook_commands(spec))
assert spec.env["ANTHROPIC_CUSTOM_MODEL_OPTION"] == "databricks-claude-opus-4-7"
# And the auto-harness extras, which only make sense alongside the router.
assert "--append-system-prompt" in spec.args
allowed = spec.args[spec.args.index("--allowedTools") + 1]
assert "mcp__omnigent__sys_session_create" in allowed
def test_routed_spawn_launch_args_need_a_router() -> None:
"""The note and pre-approvals never ship without the router that serves them."""
from omnigent.runner.native.orchestration import _routed_spawn_launch_args
note, tools = _routed_spawn_launch_args(True)
assert note and tools
assert _routed_spawn_launch_args(True, router_started=False) == (None, ())
assert _routed_spawn_launch_args(False) == (None, ())
@@ -413,7 +413,7 @@ async def test_auto_create_codex_terminal_uses_persisted_resume_launch_config(
"""Minimal app-server object used by ``codex_terminal_env``."""
codex_path = "/opt/codex/bin/codex"
codex_cli_version: tuple[int, int, int] | None = None
codex_cli_version: tuple[int, int, int] | None = (0, 145, 0)
def __init__(self) -> None:
""":returns: None."""
@@ -612,6 +612,13 @@ async def test_auto_create_codex_terminal_uses_persisted_resume_launch_config(
)
]
assert published_events[0]["type"] == "session.resource.created"
assert len(forward_calls) == 1
# The router handles are threaded through so the forwarder's ``finally``
# tears down *its* endpoints, not a re-created session's live ones.
assert "subagent_router" in forward_calls[0]
del forward_calls[0]["subagent_router"]
assert "turn_router" in forward_calls[0]
del forward_calls[0]["turn_router"]
assert forward_calls == [
{
"session_id": session_id,
@@ -1414,6 +1421,15 @@ async def test_auto_create_codex_terminal_uses_worktree_workspace_not_bundle_dir
assert launched_sandbox is not None and launched_sandbox.type == "none"
assert launch_captured["parent_os_env"] is codex_os_env
# This fake app-server reports no codex version (an unparseable / failed
# probe). The argv flag requires a positively parsed version: on a
# pre-0.131 codex an unknown flag aborts argv parsing outright, which is
# strictly worse than the recoverable trust prompt. (The app-server's
# hooks-file gate keeps the opposite "unknown = supported" policy, since
# an unsupported hooks file is only ignored.)
assert app_server.codex_cli_version is None
assert "--dangerously-bypass-hook-trust" not in launch_captured["spec"].args
@pytest.mark.asyncio
async def test_auto_create_codex_terminal_starts_relay_at_session_creation(
@@ -839,6 +839,55 @@ async def test_messages_reach_harness_in_submission_order() -> None:
)
@pytest.mark.asyncio
async def test_forwarded_model_override_reaches_the_harness() -> None:
"""A routed model rides the forwarded message all the way to the harness.
Intelligent routing puts its pick in-band on the native-terminal message
(``model_override``); the harness forwards it into
``CreateResponseRequest.model_override`` and the executor adapter into
``ExecutorConfig.model``, which is the only way a native TUI learns to
type ``/model`` for this turn. ``_run_turn_bg`` builds the harness body
field by field, so a missing thread-through silently drops the switch
the routing card claims a model was applied while the pane never moves.
"""
hc = _ScriptedHarnessClient(
[
_sse({"type": "response.created", "response": {"id": "resp_1"}}),
_sse({"type": "response.completed", "response": {"id": "resp_1"}}),
]
)
pm = _FakeProcessManager(hc)
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
resp = await client.post(
"/v1/sessions/dd0f1b1a7e3f4a6c8f2b5c9d0e1f2a3b/events",
json={
"type": "message",
"role": "user",
"model": "test-agent",
"content": [{"type": "input_text", "text": "hi"}],
"harness": "claude-native",
"model_override": "databricks-claude-sonnet-5",
},
)
assert resp.status_code == 202
for _ in range(200):
if hc.posted_bodies:
break
await asyncio.sleep(0.01)
assert hc.posted_bodies, "harness never received a turn"
assert hc.posted_bodies[0].get("model_override") == "databricks-claude-sonnet-5", (
"the routed model was dropped between the runner's message intake and "
f"the harness body: {hc.posted_bodies[0].keys()}"
)
@pytest.mark.asyncio
async def test_buffered_continuation_skips_transient_idle() -> None:
"""End-of-turn `idle` is suppressed when a buffered message will start a new turn."""
+1 -1
View File
@@ -3366,7 +3366,7 @@ async def test_sys_session_send_model_rejected_for_unplumbed_harness(
pytest.param(
"codex-native",
"databricks-claude-sonnet-4-6",
"only runs GPT models",
"only runs codex-compatible models",
id="claude-on-codex",
),
pytest.param(
+443
View File
@@ -0,0 +1,443 @@
"""Launch-site behaviour for the per-session subagent-routing endpoint."""
from __future__ import annotations
import asyncio
import http.client
import stat
from pathlib import Path
from typing import Any
import pytest
from omnigent.inner.codex_executor import CODEX_EXTENDED_CATALOG_ENV_VAR
from omnigent.inner.hook_scripts.subagent_router import read_router_endpoint
from omnigent.runner import subagent_routing
from omnigent.runner.app import _build_spawn_env_from_spec, _ensure_session_subagent_router
from omnigent.runner.native.orchestration import _start_subagent_router_for_native_session
from omnigent.runner.subagent_routing import SessionRoutingClass
from omnigent.spec.types import AgentSpec, ExecutorSpec
# The three session classes the codex launch paths distinguish.
_PLAIN = SessionRoutingClass()
_PINNED = SessionRoutingClass(routing_enabled=True)
_AUTO = SessionRoutingClass(routing_enabled=True, auto_harness=True)
class _DeadClient:
"""Stands in for the runner→server client; never actually called."""
async def post(self, *args: Any, **kwargs: Any) -> Any: # type: ignore[explicit-any]
raise RuntimeError("server down")
@pytest.fixture(autouse=True)
def _cleanup_routers() -> Any: # type: ignore[explicit-any]
yield
for session_id in ("conv_native_launch", "conv_sdk_launch"):
subagent_routing.shutdown_session_router(session_id)
@pytest.mark.parametrize("harness", ["claude-native", "codex-native"])
@pytest.mark.parametrize("routing_class", [_PINNED, _AUTO])
async def test_a_routed_native_launch_installs_the_router(
tmp_path: Path, harness: str, routing_class: SessionRoutingClass
) -> None:
"""Either family routes spawns whether or not the harness is auto-picked.
On codex the advertisement is also what turns on the generated
``hooks.json`` spawn gate and the routed-spawn tool pre-approvals, so
withholding it from a pinned Smart Routing session left that session unable
to spawn at all not merely unrouted.
"""
advertised, router = _start_subagent_router_for_native_session(
"conv_native_launch",
bridge_dir=tmp_path,
harness=harness,
server_client=_DeadClient(), # type: ignore[arg-type]
routing_enabled=routing_class.routing_enabled,
auto_harness=routing_class.auto_harness,
)
assert advertised == tmp_path
assert router is not None
assert read_router_endpoint(tmp_path) is not None
@pytest.mark.parametrize("harness", ["claude-native", "codex-native"])
async def test_a_plain_native_launch_gets_no_router(tmp_path: Path, harness: str) -> None:
"""A plain session pays none of routing's cost.
No loopback server, no bearer token on disk, and (because the hooks are
pointed at the advertisement that is never written) no ``Task`` /
``spawn_agent`` PreToolUse hook stalling every spawn on a verdict the
server would never route anyway.
"""
assert _start_subagent_router_for_native_session(
"conv_native_launch",
bridge_dir=tmp_path,
harness=harness,
server_client=_DeadClient(), # type: ignore[arg-type]
routing_enabled=False,
auto_harness=False,
) == (None, None)
assert read_router_endpoint(tmp_path) is None
async def test_a_pinned_codex_sdk_session_gets_no_router(tmp_path: Path) -> None:
"""The SDK codex arm keeps the auto-harness requirement.
Its spawns go through the session-create path, which already routes off the
stamped switch, so an in-harness ``spawn_agent`` gate would only add a
round trip and with it the generated ``hooks.json`` that stops the user's
own hooks file from being symlinked through.
"""
assert _start_subagent_router_for_native_session(
"conv_native_launch",
bridge_dir=tmp_path,
harness="codex",
server_client=_DeadClient(), # type: ignore[arg-type]
routing_enabled=True,
auto_harness=False,
) == (None, None)
assert read_router_endpoint(tmp_path) is None
async def test_native_launch_skips_without_a_server_client(tmp_path: Path) -> None:
assert _start_subagent_router_for_native_session(
"conv_native_launch",
bridge_dir=tmp_path,
harness="claude-native",
server_client=None,
routing_enabled=True,
auto_harness=True,
) == (None, None)
async def test_stale_handle_shutdown_leaves_a_relaunched_router_alive(tmp_path: Path) -> None:
"""A forwarder's late ``finally`` must not kill the re-created router."""
session_id = "conv_native_launch"
_, first = _start_subagent_router_for_native_session(
session_id,
bridge_dir=tmp_path,
harness="claude-native",
server_client=_DeadClient(), # type: ignore[arg-type]
routing_enabled=True,
auto_harness=True,
)
assert first is not None
# Terminal re-create: the old router goes away and a new one binds.
subagent_routing.shutdown_session_router(session_id, first)
_, second = _start_subagent_router_for_native_session(
session_id,
bridge_dir=tmp_path,
harness="claude-native",
server_client=_DeadClient(), # type: ignore[arg-type]
routing_enabled=True,
auto_harness=True,
)
assert second is not None and second is not first
# The old forwarder's delayed teardown fires now.
subagent_routing.shutdown_session_router(session_id, first)
assert subagent_routing._session_routers.get(session_id) is second
assert not second._closed
advertised = read_router_endpoint(tmp_path)
assert advertised is not None
assert advertised.url == second.url
async def test_unscoped_shutdown_still_tears_down_the_live_router(tmp_path: Path) -> None:
session_id = "conv_native_launch"
_, router = _start_subagent_router_for_native_session(
session_id,
bridge_dir=tmp_path,
harness="claude-native",
server_client=_DeadClient(), # type: ignore[arg-type]
routing_enabled=True,
auto_harness=True,
)
assert router is not None
subagent_routing.shutdown_session_router(session_id)
assert router._closed
assert session_id not in subagent_routing._session_routers
@pytest.mark.parametrize("routing_class", [_PINNED, _AUTO])
async def test_a_routed_claude_sdk_launch_installs_the_router(
routing_class: SessionRoutingClass,
) -> None:
await _ensure_session_subagent_router(
"conv_sdk_launch",
"claude-sdk",
server_client=_DeadClient(), # type: ignore[arg-type]
routing_class=routing_class,
)
env = subagent_routing.session_router_env("conv_sdk_launch", "claude-sdk")
assert env["OMNIGENT_SUBAGENT_ROUTER_SESSION_ID"] == "conv_sdk_launch"
async def test_a_plain_claude_sdk_launch_gets_no_router() -> None:
"""No router means no env, so the executor registers no ``Task`` hook."""
await _ensure_session_subagent_router(
"conv_sdk_launch",
"claude-sdk",
server_client=_DeadClient(), # type: ignore[arg-type]
routing_class=_PLAIN,
)
assert "conv_sdk_launch" not in subagent_routing._session_routers
assert subagent_routing.session_router_env("conv_sdk_launch", "claude-sdk") == {}
@pytest.mark.parametrize("routing_class", [_PLAIN, _PINNED])
async def test_codex_sdk_launch_skips_the_router_unless_auto_harness(
routing_class: SessionRoutingClass,
) -> None:
await _ensure_session_subagent_router(
"conv_sdk_launch",
"codex",
server_client=_DeadClient(), # type: ignore[arg-type]
routing_class=routing_class,
)
assert "conv_sdk_launch" not in subagent_routing._session_routers
assert subagent_routing.session_router_env("conv_sdk_launch", "codex") == {}
async def test_codex_sdk_launch_installs_the_router_for_an_auto_harness_session() -> None:
await _ensure_session_subagent_router(
"conv_sdk_launch",
"codex",
server_client=_DeadClient(), # type: ignore[arg-type]
routing_class=_AUTO,
)
env = subagent_routing.session_router_env("conv_sdk_launch", "codex")
assert env["OMNIGENT_CODEX_SUBAGENT_ROUTER_SESSION_ID"] == "conv_sdk_launch"
async def test_the_routing_class_falls_back_to_what_session_init_stamped() -> None:
"""The env is rebuilt on every respawn, long after the init envelope."""
subagent_routing.remember_session_routing_class("conv_sdk_launch", _AUTO)
try:
await _ensure_session_subagent_router(
"conv_sdk_launch",
"codex",
server_client=_DeadClient(), # type: ignore[arg-type]
)
assert "conv_sdk_launch" in subagent_routing._session_routers
finally:
subagent_routing.forget_session_routing_class("conv_sdk_launch")
async def test_an_unknown_session_reads_as_plain() -> None:
assert subagent_routing.session_routing_class("conv_never_seen") == _PLAIN
await _ensure_session_subagent_router(
"conv_sdk_launch",
"codex",
server_client=_DeadClient(), # type: ignore[arg-type]
)
assert "conv_sdk_launch" not in subagent_routing._session_routers
@pytest.mark.parametrize(
("routing_class", "expected"),
[
# A plain codex session keeps codex's bundled catalog, so it never pays
# the ~10 s ``codex debug models`` probe at boot.
(_PLAIN, None),
# A pinned Smart Routing session's routed turn can land on a gateway arm
# that catalog has no entry for, which codex then refuses client-side.
(_PINNED, "1"),
(_AUTO, "1"),
],
)
def test_the_codex_spawn_env_carries_the_catalog_flag_per_session_class(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
routing_class: SessionRoutingClass,
expected: str | None,
) -> None:
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("OMNIGENT_DISABLE_KEYRING", "1")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
(tmp_path / "config.yaml").write_text(
"providers:\n"
" openai:\n"
" kind: key\n"
" default: true\n"
" openai:\n"
" base_url: https://api.openai.com/v1\n"
" api_key: $OPENAI_API_KEY\n"
" models:\n"
" default: gpt-5-4\n"
)
spec = AgentSpec(
spec_version=1,
name="x",
executor=ExecutorSpec(type="omnigent", config={"harness": "codex"}),
)
subagent_routing.remember_session_routing_class("conv_spawn_env", routing_class)
try:
env = _build_spawn_env_from_spec(spec, "codex", session_id="conv_spawn_env")
finally:
subagent_routing.forget_session_routing_class("conv_spawn_env")
assert env is not None
assert env.get(CODEX_EXTENDED_CATALOG_ENV_VAR) == expected
def test_the_claude_spawn_env_never_carries_the_codex_catalog_flag(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("OMNIGENT_DISABLE_KEYRING", "1")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
(tmp_path / "config.yaml").write_text(
"providers:\n"
" anthropic:\n"
" kind: key\n"
" default: true\n"
" anthropic:\n"
" base_url: https://api.anthropic.com\n"
" api_key: $ANTHROPIC_API_KEY\n"
" models:\n"
" default: test-default\n"
)
spec = AgentSpec(
spec_version=1,
name="x",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-sdk"}),
)
subagent_routing.remember_session_routing_class("conv_spawn_env", _AUTO)
try:
env = _build_spawn_env_from_spec(spec, "claude-sdk", session_id="conv_spawn_env")
finally:
subagent_routing.forget_session_routing_class("conv_spawn_env")
assert env is not None
assert CODEX_EXTENDED_CATALOG_ENV_VAR not in env
async def test_router_env_is_scoped_to_the_launching_harness(tmp_path: Path) -> None:
"""A codex spawn beneath a claude session must not see the codex vars.
They would carry the parent claude session's id, so the codex executor
would route and audit as the wrong session.
"""
claude_env = subagent_routing.router_env("conv_x", tmp_path, harness="claude-sdk")
codex_env = subagent_routing.router_env("conv_x", tmp_path, harness="codex")
assert set(claude_env) == {
"OMNIGENT_SUBAGENT_ROUTER_DIR",
"OMNIGENT_SUBAGENT_ROUTER_SESSION_ID",
}
assert set(codex_env) == {
"OMNIGENT_CODEX_SUBAGENT_ROUTER_DIR",
"OMNIGENT_CODEX_SUBAGENT_ROUTER_SESSION_ID",
}
# A harness with no routing hooks gets nothing at all.
assert subagent_routing.router_env("conv_x", tmp_path, harness="pi") == {}
async def test_sdk_launch_survives_an_unusable_router_root(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A poisoned bridge root must not fail session creation."""
from omnigent.runner import subagent_routing as routing_mod
def _boom(session_id: str) -> Path:
raise RuntimeError("unsafe bridge root")
monkeypatch.setattr(routing_mod, "router_dir_for_session", _boom)
await _ensure_session_subagent_router(
"conv_sdk_launch",
"claude-sdk",
server_client=_DeadClient(), # type: ignore[arg-type]
routing_class=_AUTO,
)
assert subagent_routing.session_router_env("conv_sdk_launch", "claude-sdk") == {}
@pytest.mark.parametrize("harness", ["pi", "copilot", "goose"])
async def test_sdk_launch_skips_harnesses_without_spawn_hooks(harness: str) -> None:
"""No hook reads the advertisement, so no endpoint is started at all."""
await _ensure_session_subagent_router(
"conv_sdk_launch",
harness,
server_client=_DeadClient(), # type: ignore[arg-type]
routing_class=_AUTO,
)
assert "conv_sdk_launch" not in subagent_routing._session_routers
async def test_sdk_launch_skips_native_harnesses() -> None:
await _ensure_session_subagent_router(
"conv_sdk_launch",
"claude-native",
server_client=_DeadClient(), # type: ignore[arg-type]
routing_class=_AUTO,
)
assert subagent_routing.session_router_env("conv_sdk_launch", "claude-native") == {}
# ── Endpoint hardening ──────────────────────────────────────────────
async def test_non_ascii_authorization_header_is_rejected_not_crashed(tmp_path: Path) -> None:
"""``compare_digest`` raises TypeError on a non-ASCII str operand."""
async def _resolver(session_id: str, req: Any) -> Any: # type: ignore[explicit-any]
raise AssertionError("resolver must not run for an unauthorized request")
router = subagent_routing.start_subagent_router(
bridge_dir=tmp_path,
session_id="conv_auth",
resolver=_resolver,
loop=asyncio.get_running_loop(),
)
try:
host, port = router.httpd.server_address[0], router.httpd.server_address[1]
def _post() -> int:
conn = http.client.HTTPConnection(str(host), int(port), timeout=10)
try:
conn.request(
"POST",
"/v1/sessions/conv_auth/route-subagent",
body=b"{}",
headers={"Authorization": "Bearer \u00fc\u00e9"},
)
return conn.getresponse().status
finally:
conn.close()
assert await asyncio.to_thread(_post) == 401
finally:
router.close()
def test_advertisement_is_written_owner_only_with_no_leftover_temp(tmp_path: Path) -> None:
path = subagent_routing.write_advertisement(
tmp_path, url="http://127.0.0.1:1", token="tok", session_id="conv_x"
)
assert stat.S_IMODE(path.stat().st_mode) == 0o600
# A unique temp name is used, so nothing may survive the rename.
assert [p.name for p in tmp_path.iterdir()] == [path.name]
subagent_routing.write_advertisement(tmp_path, url="http://127.0.0.1:2", token="tok2")
assert [p.name for p in tmp_path.iterdir()] == [path.name]
def test_prune_never_removes_the_shared_router_root(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
from omnigent import claude_native_bridge
root = tmp_path / "subagent-routers"
root.mkdir()
monkeypatch.setattr(claude_native_bridge, "subagent_router_bridge_root", lambda: root)
router = subagent_routing.SubagentRouter(
bridge_dir=root,
url="http://127.0.0.1:1",
token="tok",
httpd=None, # type: ignore[arg-type]
)
subagent_routing._prune_router_dirs(router)
assert root.is_dir()

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