Files
Bryan Qiu 5860ae08f0 Smart Routing follow-ups: let a healthy route finish before the hook gives up (#4181)
* fix: let a healthy route finish before the routing hook gives up

The first-message ladder was sized from the routing call alone, but the
server prepares the candidate catalog before it calls the router — about
three seconds on a first message. A healthy route therefore cost ~4.8s
against a 7s relay budget that started earlier, so the runner abandoned
verdicts that did arrive: the attempt was wasted, the prompt was replayed
a second time, and the transcript showed it twice.

Each hop now covers preparation plus the call, with the hook budget at the
15s ceiling and the harness kill still under Claude Code's own 30s
UserPromptSubmit default. A wedged router costs 15s instead of the 45s it
cost before this ladder existed. The magnitude test gains a floor as well
as a ceiling, so a future tightening cannot re-open the gap.

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

* fix(web): say claude and codex on spawn chips, without the native suffix

A spawn chip's harness id is how the spawn runs, not something the chip
needs to spell out; the native suffix reads as noise there. SDK-brain
sub-agents (a bundle agent's codex / claude-sdk children) carry no suffix
and render unchanged, as do the session's own session/turn chips.

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

* test: align the spawn-gate budget assertion with the widened ladder

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

* fix(routing): keep a pinned session's spawns in its own family at the source

A pinned Smart Routing session was offered every agent by
``sys_agent_list``, so a codex session could stand up a claude-native
child and only then have routing decline it. Refuse the spawn before it
happens instead:

- ``sys_agent_list`` drops built-ins outside the caller's family when the
  caller routes its spawns and is not auto-harness.
- ``POST /v1/sessions`` refuses an out-of-family child of such a parent,
  naming the rule.

Auto-harness parents still cross families (the router owns theirs), and a
plain session sees and spawns exactly what it did before. The routing
decline stays as the fail-safe for a pane that exists anyway.

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

* fix(routing): decline a route-turn whose parent routes another family

``route_turn_hook`` routed a pane's first typed prompt in the pane's own
family with no look at its parent, so a child pane on another family's CLI
could be pinned to a model its parent's family serves and the pane cannot
speak. The policy now declines (fail-open, nothing pinned, no chip) when
the pane's parent is a pinned Smart Routing session of another family.

The create gate refuses such a pane outright, so this only catches a row
that predates it — hence non-terminal, and the parent's switch stays
togglable.

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

* fix(routing): a failed auto-harness route must not claim the route-once label

The auto-harness path stamped the routing-decision label on its own
"unavailable" card, and that label is the route-once gate — so a router
that happened to be down when the session started made every later
in-harness prompt decline as "already routed". Leave the label unclaimed
on failure, the way the turn, native-pane and child-spawn paths already
do; the declined card still says what happened.

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

* fix(routing): stop routing a Smart Routing create's prompt twice

A native Smart Routing create routes the landing screen's prompt and pins
what it picked; the harness then submits that same prompt, and the
first-prompt hook scored it again — a second judge call tens of seconds
later, for the verdict the pane was already running on, and a needless
block-and-replay of the turn.

The create now fingerprints the prompt it routed (a hash: the label is
metadata, and the user's prompt does not belong there). When the hook sees
that prompt again it claims the create's decision instead of making a new
one — one router call, one chip. A prompt the user edited before sending
does not match and still routes on its own, as does the first prompt of a
session whose create-time route failed.

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

* perf(routing): take catalog preparation off the turn path

A first routed message spent ~3.2s preparing routing candidates before the
routes:select POST went out, and nothing in the logs named where it went. Two
runner-derived catalogs were being resolved while the user's prompt was held:
the claude-native picker vocabulary, whose stale entry the turn path awaits for
up to _ROUTING_CATALOG_WAIT_S (3.0s) while the fetch retries a booting runner,
and the runner model catalog, a round trip per turn for every pane that has no
picker vocabulary of its own.

Warm both when the runner binds instead. _on_runner_connect now calls
prefetch_session_routing_catalogs once the session-init handshake has created
the terminal, so the catalogs land before the first prompt rather than under
it. The runner catalog also gains a per-session cache behind _fetch_runner_catalog
(single-flight, 5-minute backstop TTL) whose entries drop through the seam that
already invalidates runner-derived snapshot overlays — a rebind or relaunch can
change which models a pane accepts, so it must not keep routing off the previous
runner's list. A cold cache still takes the inline fetch, so nothing depends on
the prefetch having run.

route_turn now logs its two phases separately (prep vs router) and the stale
catalog refresh logs what it waited, so the timeout ladder can be revisited
against measurements instead of a guess. The ladder constants are unchanged
here.

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

* fix(codex): check a routed slug is reachable before switching the pane

The routing verdict comes from a server-side gateway map that can go stale, so
the routed model is not necessarily one this pane's gateway serves. The hook
switched onto it regardless: codex accepted the id, the next turn failed, and
nothing anywhere said why — the failure mode the #4074 review flagged.

The pane's live model/list is the only authority on what it can be moved onto,
and the hook already reads it to translate the routed id into codex's spelling.
Make that read the reachability check too: codex_model_slug becomes
codex_reachable_model_slug and answers None when no row names the model, and
_apply_thread_model returns a decline reason instead of a bare bool. An
unreachable pick leaves the pane on its own model, writes no marker, blocks
nothing, and records "routed model not in this pane's catalog" to the routing
trace and stderr — the same fail-open shape the claude side uses when a routed
model has no spelling its picker accepts.

A model/list that cannot be read is now distinguished from an empty catalog and
also declines: an unreadable catalog is not evidence of reachability, and
declining costs a turn of routing where switching blind costs the turn itself.

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

* fix(auth): one workspace identity, and a refresh that can fall back

Two credential faults that made a healthy workspace look unreachable.

**One identity.** A pane and the server could authenticate as different
~/.databrickscfg profiles for the same host. The server's router client uses
the config's `kind: databricks` provider profile; the claude-native pane
installed ucode's recorded token command, which selects the workspace however
ucode was set up — usually by host. Two profiles on one host are two
identities, so re-authing one left the other's token expired and the two halves
disagreed about whether the workspace was up. The named profile is now the
authority on both sides: the pane's apiKeyHelper is regenerated against it
(only for the recognizable `databricks auth token` shape — an enterprise
deployment's own token command has a selector we have no business guessing at),
and a `routing:` block that names no profile falls back to the provider block's
rather than to the ambient SDK chain. Host selection stays the fallback for
when nothing names a profile.

**A refresh that can fall back.** The generated helper forced a refresh on
every call. The reason is real — `--force-refresh` renews a still-valid token
and keeps a long gateway session off a mid-session 401 — but it fails outright
once the refresh token has gone stale, which turned a perfectly usable cached
access token into a hard auth failure (twice in one day). The forced attempt is
now speculative: its output is captured, its stderr dropped, and an empty
result falls back to plain `auth token`, which serves the cached token and
renews it near expiry. The fallback keeps its stderr so a genuine auth failure
is still visible.

Both harnesses generated this command separately, so the shape now has one
definition (databricks_bearer_token_command) and the claude and codex helpers
delegate to it.

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

* test: align both hook-budget assertions with the widened ladder

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

* test: keep the catalog-cache reset import-free; cover the spawn chip in e2e_ui

The autouse cache-reset fixture imported omnigent.server.smart_routing in
every teardown, which detonated inside the spec suite's import-blocker
test and taxed lanes that never load the server. A sys.modules lookup
clears the cache only where it exists. The new Playwright case pins the
shortened spawn-chip harness label the UI judge flagged.

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

* fix: leave a visible declined chip when the turn hook's routing call fails

The create and dispatch paths already card a failed route; the in-harness
first-message hook failed open silently, so a router 401 looked like the
session simply ignoring Smart Routing. The hook now persists the same
unavailable card with the cause, without claiming the route-once label —
the next prompt can still route. Benign allows (already routed, routing
off, the family guard) are not failures and stay chipless.

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

* feat(cli): drop create-time Smart Routing; keep first-message routing

The CLI can only route a prompt it never shows: `--smart-routing -p` picked a
model (and, on `run`, a harness) before the TUI existed, so the user typed at a
session whose pick they could neither see nor change. The web UI is the surface
that can do that. So the CLI keeps the one routing shape a terminal can honour
— arm the session, let the harness's own hook route the first message typed —
and rejects the rest.

`omni claude|codex --smart-routing` stay, bare only. `-p` alongside them is now
a usage error pointing at the TUI or the web UI, and `run --smart-routing`
(with it the CLI's auto-harness route) is rejected outright; its flag stays
hidden purely to say where routing moved, and comes out in 0.11.

That leaves nothing behind the create-time path: the routed create no longer
sends a message or the `auto` sentinel, reads back no verdict, and the
launch-side plumbing that applied one is gone. `create_smart_routing_session`
becomes `arm_smart_routing_session` and `RoutingDecision` becomes
`ArmedSession` (session id + fail-open notice), because neither decides
anything any more. The preflight gate, the `--resume` rejection and every
server-side create path are untouched.

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

* fix(web): drop "-native" from every routing chip, not just spawn chips

A session-scope chip read "codex-native", which leaks how the pane runs
into a label that only needs to name the brain. The shortening was scoped
to sub-agent decisions; it belongs on every chip, so harnessDisplayLabel
no longer takes a scope and always trims the trailing suffix. SDK ids
(codex / claude-sdk / auto) carry no suffix and render unchanged.

The e2e session-chip assertion now also pins the negative: a bare
"claude" substring-matches "claude-native", so only not_to_contain_text
catches a regression. Same for the card unit test, which anchors on the
full label.

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

* fix(web): render an auto-harness create chip below its prompt

A session created with Smart Routing as both the model AND the harness
records the pick as a `session` chip at create time, and its first turn
routes again and records a `turn` chip — so two chips sit above the
session's first user message. `deferredRoutingChips` only paired a chip
whose immediate next content block was that message, so the first of the
two was left in place and rendered ABOVE the prompt, reading as a
preamble instead of the verdict on it. It only looked right when the two
verdicts matched and the create chip was dropped by the collapse.

Look forward past the sibling chips waiting on the same message (and
past superseded ones, which render nothing) and defer them all below the
message, in transcript order. A sub-agent chip still stops the scan: it
renders standalone where it occurred, and stepping over it would reorder
the two. The cache's pending-pair guard learns the same rule so the pair
stays stable frame by frame.

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

* perf(runner): skip the sys_agent_list routing lookup on plain sessions

Family confinement made every sys_agent_list pay a serial
GET /v1/sessions/{id} with a 30s budget before discovering the session
was not routed at all. Plain sessions — the overwhelming majority —
carried seconds of fan-out latency for a feature they never use, and a
wedged server stalled the listing for the full 30s.

Read the runner-local routing class first: a session with no routing
armed, or an auto-harness one, answers without a server hop. Only a
locally pinned routed session spends the lookup, now on a 5s budget that
fails open to the unfiltered listing, and its answer is cached for the
session (routing state is fixed at create). The create-path gate still
refuses out-of-family creates, so a fail-open listing stays safe.

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

* fix(auth): fall back to ucode's recorded token command

Pinning the pane's apiKeyHelper to the config-named Databricks profile
fixed one outage and opened its mirror image: when the named profile
holds no usable credential — a config naming DEFAULT while the user
authenticated under another profile on the same host — the helper now
prints nothing and every turn 401s, where before the rewrite ucode's own
recorded command served a working token.

The named profile stays the preferred identity; the recorded command
becomes the helper's last resort, after the forced refresh and the cached
token have both come up empty. An injected DATABRICKS_BEARER still
short-circuits everything.

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

* perf(routing): only warm catalogs for routed, live sessions

A runner reconnect walks every session bound to that runner, and the
catalog prefetch fired for all of them — archived rows included — with no
Smart Routing gate. One host's tunnel flap with ~25 plain codex panes
launched 50 fire-and-forget tasks whose provider listings run on worker
threads, so the session re-init running alongside them timed out and the
panes came back stranded, all to warm a cache only Smart Routing reads.

Gate the prefetch on the canonical routing reader
(routing_class_from_snapshot), skip archived sessions, cap concurrent
warm-ups with a small semaphore, and have each task retrieve its own
exception: a tunnel dropped mid-prefetch raised RuntimeError that nothing
ever retrieved, which surfaced only as asyncio unretrieved-exception
noise.

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

* feat(routing): route a pinned native create before its pane launches

Picking Claude Code or Codex with Smart Routing as the model created the
session with no prompt to route on, so routing fell through to the
in-pane first-message hook: the prompt was blocked, routed, switched with
`/model` and replayed. The user watched their own message disappear for
seconds, and the composer's model pill stayed stale because the pin
landed mid-turn instead of before the snapshot bound.

The web create now sends `smart_routing_message` for a pinned
claude-native / codex-native pane too, whenever routing owns the model.
The server already routes the MODEL only on that path and pins
`model_override` before the terminal launches; the client still delivers
the real first message after navigation, exactly as the auto path does.
Bundle agents are untouched — their harness isn't decided until the first
message event, so there is nothing to route at create.

With the model pinned and the routing-decision label stamped before the
pane exists, the `UserPromptSubmit` turn-routing hook has no answer left
but "already routed" — paid for with a held prompt and a round trip per
prompt. The session's routing class now carries a `turn_routing` flag
that drops to false once the row has a routing decision, and the native
launch skips the loopback router; the absent advertisement is what leaves
the hook out of the generated settings. A create whose routing failed
stamps nothing and keeps its hook, so the first message is still its
retry, and spawn routing plus the extended catalog are untouched.

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

* fix(web): keep a create-time routing chip below the prompt it decides

A pinned Smart Routing create routes at create time, so the session-scope
decision is persisted before the pane launches while the landing composer's
prompt is only posted after navigation. The prompt is on screen the whole
time, but as an optimistic `pendingUserMessages` entry merged in AFTER the
bubble walk — never a `user_message` block — so `pairableMessageAfter` cannot
see it and the chip renders above the message until the server persists it,
then visibly moves below.

Splice the pending prompt above a run of session-scope chips that opens the
committed timeline, matching the position `buildBubbles` gives the chip once
the message is persisted. The chip renders once, below the prompt, and stays
put across the pending → committed swap. Chips anywhere else (paired with
their message, or a standalone sub-agent spawn) keep their place, and a chip
with no message — including a declined create route — still renders.

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

* chore: trigger CI on the rebased tip

The rebase onto main and the chip-ordering fix never ran the test lanes;
only CodeQL and DCO reported.

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

---------

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-06 17:53:04 -07:00

549 lines
22 KiB
Python

"""Codex Code hook entrypoint for native Omnigent policy enforcement.
Registered as the ``PreToolUse`` / ``PostToolUse`` command hook in the
per-session private ``CODEX_HOME`` (see
:mod:`omnigent.codex_native_app_server`). Codex spawns this module as
a short subprocess before/after each built-in tool call, piping the hook
payload on stdin and reading a verdict on stdout. The conversion to/from
the Omnigent policy schema is shared with the Claude-native hook via
:mod:`omnigent.native_policy_hook`.
"""
from __future__ import annotations
import argparse
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,
read_codex_config_model,
read_policy_hook_config,
)
from omnigent.native_policy_hook import (
evaluation_response_to_hook_output,
fail_closed_hook_output,
hook_payload_to_evaluation_request,
policy_hook_reauth,
post_evaluate_with_retry,
read_relay_policy_config,
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
# client must wait as long as the permission long-poll. Held at one
# day; the server caps the real wait via the deciding policy's
# ``ask_timeout``. Kept in lockstep with the Claude-native hook's
# ``_EVALUATE_POLICY_TIMEOUT_S``.
_EVALUATE_POLICY_TIMEOUT_S = 86400.0
def main(argv: list[str] | None = None) -> int:
"""
Dispatch a Codex hook subcommand.
:param argv: Optional argv override excluding program name.
``None`` reads :data:`sys.argv`.
:returns: Process exit code. Always ``0`` — blocking verdicts are
expressed via the JSON written to stdout, never via exit code,
so a hook failure never wedges Codex.
"""
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,
)
return 0
def _main_evaluate_policy(argv: list[str]) -> int:
"""
Evaluate a Codex ``PreToolUse`` / ``PostToolUse`` /
``UserPromptSubmit`` hook against Omnigent policies.
Reads the hook JSON payload from stdin, converts it into the
proto-compatible ``EvaluationRequest`` schema via
:func:`omnigent.native_policy_hook.hook_payload_to_evaluation_request`,
POSTs to ``/v1/sessions/{id}/policies/evaluate``, and converts the
``EvaluationResponse`` back into Codex's hook output format
(``hookSpecificOutput.permissionDecision`` for PreToolUse;
``additionalContext`` warning for PostToolUse; top-level
``decision: "block"`` for UserPromptSubmit — the request-phase gate
for native sessions, which drops the prompt before the model runs).
Failure handling is phase-aware (mirroring the runner-side default
from PR #163), shared with the Claude-native hook. Once the session is
known to be governed (an active session id and a configured
``ap_server_url``) and the round-trip to ``/policies/evaluate`` cannot
yield a usable verdict — server unreachable, non-2xx, or an empty /
malformed body — a ``PreToolUse`` (``PHASE_TOOL_CALL``) call fails
CLOSED with a ``deny`` (this hook is the sole enforcement point for
native tools), while ``UserPromptSubmit`` and ``PostToolUse`` fail
OPEN. Conditions that mean the session simply is not governed — no
bridge state, no ``ap_server_url``, an unparseable payload, or an
``mcp__omnigent__*`` tool already gated on the relay path — still
return exit 0 with no output ("no opinion") so non-Omnigent tool calls
are never blocked. The complementary fail-loud guard — asserting the
hook is actually registered and trusted — lives at session startup in
:mod:`omnigent.codex_native_app_server`, not here, because a
silently-skipped hook cannot report its own absence.
:param argv: CLI argv after the ``evaluate-policy`` subcommand,
e.g. ``["--bridge-dir", "/tmp/x"]``.
:returns: Process exit code. Always ``0``.
"""
args = _parse_evaluate_policy_args(argv)
raw = sys.stdin.read()
try:
payload = json.loads(raw or "{}")
except json.JSONDecodeError as exc:
print(f"omnigent codex evaluate-policy hook: malformed JSON: {exc}", file=sys.stderr)
return 0
if not isinstance(payload, dict):
print("omnigent codex evaluate-policy hook: expected JSON object", file=sys.stderr)
return 0
bridge_dir = Path(args.bridge_dir)
state = read_bridge_state(bridge_dir)
if state is None:
return 0
session_id = state.session_id
hook_event = payload.get("hook_event_name", "")
eval_request = hook_payload_to_evaluation_request(hook_event, payload)
if eval_request is None:
# Unrecognized hook event or an mcp__omnigent__* tool (relay-enforced).
return 0
# Stamp the live model from this session's config.toml (what an in-TUI
# ``/model`` writes) onto the request so the cost-budget gate evaluates
# against the user's CURRENT selection.
context = eval_request["event"]["context"]
context["harness"] = "codex-native"
model = read_codex_config_model(bridge_dir)
if model:
context["model"] = model
def _fail_closed(detail: str | None = None) -> int:
out = fail_closed_hook_output(hook_event, detail)
if out is not None:
sys.stdout.write(json.dumps(out))
return 0
# Prefer the relay (non-expiring local token); fall back to direct server
# call when the relay isn't up yet (first-call race) or not configured.
relay = read_relay_policy_config(bridge_dir)
if relay:
relay_url, relay_token, _sid = relay
url = relay_policy_evaluate_url(relay_url)
headers: dict[str, str] = {
"Content-Type": "application/json",
"Authorization": f"Bearer {relay_token}",
}
reauth = None
else:
config = read_policy_hook_config(bridge_dir)
if config is None:
return 0
ap_server_url = config.get("ap_server_url")
if not isinstance(ap_server_url, str) or not ap_server_url:
return 0
raw_headers = config.get("ap_auth_headers")
headers = {}
if isinstance(raw_headers, dict):
headers = {str(k): str(v) for k, v in raw_headers.items()}
session_component = urllib.parse.quote(session_id, safe="")
url = f"{ap_server_url.rstrip('/')}/v1/sessions/{session_component}/policies/evaluate"
reauth = policy_hook_reauth(ap_server_url, headers)
resp, api_error = post_evaluate_with_retry(
url,
headers,
eval_request,
_EVALUATE_POLICY_TIMEOUT_S,
"codex evaluate-policy hook",
reauth=reauth,
)
if resp is None:
return _fail_closed(api_error or (reauth.failure_reason if reauth else None))
if not resp.content:
print("omnigent codex evaluate-policy hook: empty Omnigent response", file=sys.stderr)
return _fail_closed()
try:
eval_response = resp.json()
except json.JSONDecodeError:
print(
"omnigent codex evaluate-policy hook: malformed Omnigent response",
file=sys.stderr,
)
return _fail_closed()
hook_output = evaluation_response_to_hook_output(hook_event, eval_response)
if hook_output is not None:
sys.stdout.write(json.dumps(hook_output))
return 0
def _parse_evaluate_policy_args(argv: list[str]) -> argparse.Namespace:
"""
Parse ``evaluate-policy`` hook arguments.
:param argv: CLI argv excluding program name and subcommand, e.g.
``["--bridge-dir", "/tmp/x"]``.
:returns: Parsed namespace with a ``bridge_dir`` attribute.
"""
parser = argparse.ArgumentParser(prog="python -m omnigent.codex_native_hook evaluate-policy")
parser.add_argument("--bridge-dir", required=True)
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: check the pick against this pane's live
``model/list``, 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, a pick this pane's gateway does not
serve, 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
declined = _apply_thread_model(bridge_dir, model)
if declined is not None:
# 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", declined)
print(
f"omnigent codex route-turn hook: {declined}; "
"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) -> str | None:
"""
Switch the live Codex thread onto *model*, if this pane can serve it.
``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 id is resolved against this pane's live ``model/list`` first
(see :mod:`omnigent.codex_model_vocabulary`), which is both the spelling
translation and the reachability check. The routing verdict comes from a
server-side gateway map that can go stale, and switching a pane onto a
model its gateway cannot serve fails silently at the next turn — so a
routed id no row names declines the switch instead, and the pane keeps
running on its own model.
:param bridge_dir: Native Codex bridge directory.
:param model: Routed model id, e.g. ``"databricks-gpt-5-6-luna"``.
:returns: ``None`` when Codex accepted the switch, else a short reason
the switch was declined, for the caller's trace and stderr note.
"""
import asyncio
from omnigent.codex_model_vocabulary import codex_reachable_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 "no bridge state to switch through"
# The spelling codex accepted, mirrored into config.toml below so the
# file and the live thread never disagree about the model.
applied: str | None = None
declined: str | None = None
async def _switch() -> None:
nonlocal applied, declined
client = client_for_transport(state.socket_path, client_name="omnigent-route-turn-hook")
await client.connect()
try:
rows = await _list_codex_models(client)
if rows is None:
declined = "could not read this pane's model catalog"
return
slug = codex_reachable_model_slug(model, rows)
if slug is None:
declined = f"routed model not in this pane's catalog ({model})"
return
await client.request(
"thread/settings/update",
{"threadId": state.thread_id, "model": slug},
)
applied = slug
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"
return f"thread/settings/update failed: {exc}"
if declined is not None:
return declined
if applied is None:
return f"could not switch to {model}"
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 None
async def _list_codex_models(client: CodexAppServerClient) -> list[dict[str, object]] | None:
"""
Read this session's codex model catalog over an open app-server client.
Hidden rows are included: they are still switchable, and a routed model
listed only there is reachable all the same.
:param client: Connected app-server client.
:returns: Raw ``model/list`` rows, or ``None`` when the call failed —
which is not the same as an empty catalog, and the caller declines
the switch rather than reading "no rows" as "not reachable".
"""
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 "do not switch"
print(
f"omnigent codex route-turn hook: model/list failed: {exc}",
file=sys.stderr,
)
return None
return rows
if __name__ == "__main__":
raise SystemExit(main())