5860ae08f0
* 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>
549 lines
22 KiB
Python
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())
|