Files
omnigent-ai--omnigent/omnigent/stores/conversation_store/sqlalchemy_store.py
T
Bryan Qiu b268130340 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>
2026-08-05 15:34:54 -07:00

3958 lines
178 KiB
Python

"""SQLAlchemy-backed conversation store."""
from __future__ import annotations
import json
import logging
from typing import Any, Protocol, cast
from sqlalchemy import (
ColumnElement,
Select,
and_,
asc,
delete,
desc,
func,
literal_column,
or_,
select,
text,
update,
)
from sqlalchemy.orm import QueryableAttribute, Session, aliased, load_only
from sqlalchemy.sql.selectable import Subquery
from omnigent._wrapper_labels import UI_MODE_LABEL_KEY, WRAPPER_LABEL_KEY
from omnigent.db.converters import sql_agent_to_entity
from omnigent.db.db_models import (
LABEL_VALUE_MAX_LEN,
SqlAgent,
SqlComment,
SqlConversation,
SqlConversationItem,
SqlConversationLabel,
SqlConversationMetadata,
SqlPolicy,
SqlProject,
SqlSessionPermission,
SqlUserDailyCost,
current_workspace_id,
uuid_to_bytes,
)
from omnigent.db.enum_codecs import (
decode_item_status,
decode_item_type,
decode_session_live_status,
encode_agent_kind,
encode_conversation_kind,
encode_item_status,
encode_item_type,
encode_session_live_status,
)
from omnigent.db.query_context import query_name_scope
from omnigent.db.utils import (
_supports_fts5,
build_search_snippet,
delete_fts_by_conversation_ids,
ensure_fts_table,
extract_search_text,
generate_conversation_id,
generate_item_id,
get_or_create_conversation_engine,
get_or_create_engine,
insert_fts_bulk,
make_named_managed_session_maker,
now_epoch,
strip_nul_bytes,
)
from omnigent.entities import (
Conversation,
ConversationItem,
NewConversationItem,
PagedList,
parse_item_data,
)
from omnigent.session_import.models import (
IMPORT_EXTERNAL_SESSION_ID_LABEL_KEY,
IMPORT_SOURCE_LABEL_KEY,
)
from omnigent.stores.conversation_store import (
_FORK_ONLY_DROPPED_LABEL_KEYS,
_INSTANCE_SCOPED_LABEL_KEYS,
FORK_CARRY_HISTORY_LABEL_KEY,
FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY,
FORK_SOURCE_LABEL_KEY,
PINNED_LABEL_KEY,
PROJECT_LABEL_KEY,
SWITCH_PREVIOUS_BUILTIN_LABEL_KEY,
ConversationAlreadyExistsError,
ConversationNotFoundError,
ConversationStore,
CreatedSession,
SessionConnectivity,
pinned_label_key,
)
_logger = logging.getLogger(__name__)
class _RowCountResult(Protocol):
rowcount: int
# Per-session config overrides packed into the ``conversations.session_overrides``
# JSON blob. Order is fixed so the encoded object is stable across writes.
_SESSION_OVERRIDE_KEYS = (
"reasoning_effort",
"model_override",
"cost_control_mode_override",
"subagent_routing_override",
"harness_override",
)
def _encode_session_overrides(overrides: dict[str, str | None]) -> str | None:
"""Pack the set per-session overrides into a compact JSON blob.
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 :data:`_SESSION_OVERRIDE_KEYS` are
considered; any other keys in *overrides* are ignored.
:param overrides: Mapping of override key to value (missing / ``None``
values mean "unset").
:returns: Compact JSON object string, or ``None`` when no override is set.
"""
data = {
key: overrides[key] for key in _SESSION_OVERRIDE_KEYS if overrides.get(key) is not None
}
return json.dumps(data, separators=(",", ":")) if data else 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 :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.
:param raw: The stored JSON blob, or ``None``.
:returns: Dict keyed by every override name, value ``None`` when unset.
"""
data: dict[str, Any] = json.loads(raw) if raw else {}
return {key: data.get(key) for key in _SESSION_OVERRIDE_KEYS}
def _to_conversation(
row: SqlConversation,
meta: SqlConversationMetadata | None = None,
labels: dict[str, str] | None = None,
) -> Conversation:
"""
Convert a :class:`SqlConversation` ORM row (plus optional metadata) to a
:class:`Conversation` entity.
The agent binding (``agent_id``) and per-session overrides live on the
conversation row itself — the latter packed in the ``session_overrides``
JSON blob, unpacked here via :func:`_decode_session_overrides`.
:param row: The SQLAlchemy ORM row to convert.
:param meta: Optional metadata row from
``omnigent_conversation_metadata``. When ``None``, all
Omnigent-operational fields default (``kind="default"``,
everything else ``None`` / ``False``).
:param labels: Pre-fetched guardrails labels for this
conversation. ``None`` means "no label fetch was
performed" (callers that don't need labels pass
``None`` rather than forcing a second query); this
maps to an empty dict on the entity. Populated
callers pass the JOINed ``{key: value}`` map.
:returns: A :class:`Conversation` dataclass instance.
"""
session_state: dict[str, Any] = {}
if meta and meta.session_state:
session_state = json.loads(meta.session_state)
session_usage: dict[str, Any] = {}
if meta and meta.session_usage:
session_usage = json.loads(meta.session_usage)
overrides = _decode_session_overrides(row.session_overrides)
return Conversation(
id=row.id,
created_at=row.created_at,
updated_at=row.updated_at,
title=row.title or None, # empty string → None at entity layer
# kind is derived from parent-nullness, not the stored metadata column:
# a conversation is a sub-agent iff it has a parent. This is the single
# source of truth (every writer couples them) and stays correct even for
# an orphaned row whose metadata write crashed (``meta is None``).
kind="sub_agent" if row.parent_conversation_id is not None else "default",
parent_conversation_id=row.parent_conversation_id,
root_conversation_id=row.root_conversation_id,
agent_id=row.agent_id,
runner_id=meta.runner_id if meta else None,
host_id=meta.host_id if meta else None,
labels=labels if labels is not None else {},
session_state=session_state,
session_usage=session_usage,
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,
# NULL → None; a stored JSON array (e.g. ``"[]"`` or
# ``'["--foo"]'``) decodes back to a list. ``"[]"`` is a
# non-empty, truthy string, so an explicitly-empty arg list
# round-trips as ``[]`` and stays distinct from NULL/None.
terminal_launch_args=(
json.loads(meta.terminal_launch_args)
if meta and meta.terminal_launch_args is not None
else None
),
workspace=meta.workspace if meta else None,
git_branch=meta.git_branch if meta else None,
archived=row.archived,
live_status=(
decode_session_live_status(meta.live_status)
if meta and meta.live_status is not None
else None
),
pending_elicitation_count=meta.pending_elicitation_count if meta else None,
project_id=meta.project_id if meta else None,
)
def _new_session_conversation_row(
conversation_id: str,
now: int,
title: str | None,
parent_conversation_id: str | None = None,
root_conversation_id: str | None = None,
agent_id: str | None = None,
session_overrides: str | None = None,
) -> SqlConversation:
"""
Build the AP conversation row for atomic session creation.
The agent binding (``agent_id``) and the per-session override blob
(``session_overrides``) live on this row; Omnigent operational fields
(runner_id, host_id, workspace, terminal_launch_args, kind, etc.)
in :func:`_new_session_metadata_row`.
:param conversation_id: New conversation id, e.g.
``"conv_abc123"``.
:param now: Unix epoch seconds used for created/updated fields.
:param title: Optional session title.
:param parent_conversation_id: Optional parent conversation id,
e.g. ``"conv_parent1"``. ``None`` creates a top-level row.
:param root_conversation_id: Root of the spawn tree. Required
when ``parent_conversation_id`` is set; ``None`` for
top-level rows where the root mirrors the primary key.
:param agent_id: Optional agent binding. ``None`` leaves it NULL.
:param session_overrides: Optional pre-encoded per-session override
JSON blob (see :func:`_encode_session_overrides`). ``None`` leaves
it NULL.
:returns: Unsaved :class:`SqlConversation` row.
"""
# Sub-agent children must have a unique title per parent.
# Fall back to the conversation id to guarantee uniqueness.
if parent_conversation_id and not title:
title = f"untitled:{conversation_id}"
return SqlConversation(
id=conversation_id,
created_at=now,
updated_at=now,
title=title or "", # None → '' for top-level conversations
parent_conversation_id=parent_conversation_id,
# Top-level row: ``root_conversation_id`` mirrors the
# primary key so tree-scoped lookups treat it as its own
# root. Child rows inherit their parent's root.
root_conversation_id=root_conversation_id or conversation_id,
agent_id=agent_id,
session_overrides=session_overrides,
)
def _new_session_metadata_row(
conversation_id: str,
parent_conversation_id: str | None = None,
runner_id: str | None = None,
workspace: str | None = None,
terminal_launch_args: list[str] | None = None,
) -> SqlConversationMetadata:
"""
Build the Omnigent metadata row paired with a new session conversation.
:param conversation_id: New conversation id, e.g. ``"conv_abc123"``.
:param parent_conversation_id: When set, the row is created as a
sub-agent child (``kind="sub_agent"``); ``None`` → ``"default"``.
:param runner_id: Optional runner binding inherited from the
parent session. ``None`` leaves the column NULL.
:param workspace: Optional starting cwd. ``None`` leaves it NULL.
:param terminal_launch_args: Optional pass-through CLI args for a
native terminal wrapper. ``None`` leaves it NULL; a list
(including ``[]``) is JSON-encoded.
:returns: Unsaved :class:`SqlConversationMetadata` row.
"""
return SqlConversationMetadata(
id=conversation_id,
kind=encode_conversation_kind("sub_agent" if parent_conversation_id else "default"),
runner_id=runner_id,
workspace=workspace,
terminal_launch_args=(
json.dumps(terminal_launch_args) if terminal_launch_args is not None else None
),
)
def _new_session_agent_row(
*,
agent_id: str,
agent_name: str,
agent_bundle_location: str,
agent_description: str | None,
now: int,
) -> SqlAgent:
"""
Build the session-scoped agent row for atomic creation.
:param agent_id: New agent id, e.g. ``"ag_abc123"``.
:param agent_name: Agent name loaded from the uploaded spec.
:param agent_bundle_location: Artifact-store key for the bundle.
:param agent_description: Optional description from the spec.
:param now: Unix epoch seconds used for the created field.
:returns: Unsaved :class:`SqlAgent` row.
"""
return SqlAgent(
id=agent_id,
created_at=now,
name=agent_name,
bundle_location=agent_bundle_location,
version=1,
kind=encode_agent_kind("session"),
description=agent_description,
)
def _created_session_from_rows(
conversation_row: SqlConversation,
meta_row: SqlConversationMetadata | None,
agent_row: SqlAgent,
labels: dict[str, str] | None,
) -> CreatedSession:
"""
Convert committed session creation rows to store entities.
:param conversation_row: Inserted conversation row (carries the agent
binding + per-session override blob).
:param meta_row: Inserted metadata row, or ``None`` when not yet
persisted (entity defaults apply).
:param agent_row: Inserted session-scoped agent row.
:param labels: Labels written during creation, or ``None``.
:returns: :class:`CreatedSession` with entity objects.
"""
return CreatedSession(
conversation=_to_conversation(
conversation_row,
meta_row,
labels if labels is not None else {},
),
agent=sql_agent_to_entity(agent_row, session_id=conversation_row.id),
)
def _upsert_labels(
session: Session,
conversation_id: str,
updates: dict[str, str],
updated_at: int,
) -> None:
"""
Atomically UPSERT multiple labels on one conversation.
Dialect-aware: SQLite and PostgreSQL both support
``INSERT ... ON CONFLICT ... DO UPDATE``, so we use
their dedicated INSERT builders. Other dialects fall
back to a SELECT-then-INSERT/UPDATE path, which is
race-safe inside one transaction under SERIALIZABLE or
(for SQLite) its default single-writer semantics.
:param session: Active SQLAlchemy session (the atomic
unit of work).
:param conversation_id: Owning conversation ID.
:param updates: Non-empty dict of label key → value.
:param updated_at: Timestamp to write on every row
touched by this call.
"""
dialect = session.bind.dialect.name if session.bind is not None else ""
# Defense-in-depth: clamp every value to the column width so no label
# writer can overflow ``String(256)`` and raise ``DataError`` on
# PostgreSQL. Callers (session error labels, client-supplied ``body.labels``
# on session create/patch, policy-author writes) all funnel through here,
# so this is the single point that guarantees the column constraint. The
# slice is character-based, matching Postgres ``VARCHAR(n)`` semantics.
rows = [
{
"conversation_id": conversation_id,
"key": key,
"value": value[:LABEL_VALUE_MAX_LEN],
"updated_at": updated_at,
}
for key, value in updates.items()
]
if dialect in ("sqlite", "postgresql"):
_dialect_upsert_labels(session, dialect, rows)
return
# Generic dialect fallback — SELECT-then-INSERT/UPDATE in
# one transaction. Safe for the v1 "one active workflow
# per conversation" invariant (POLICIES.md §10); the
# SQLite / Postgres dialect-specific paths above give
# true atomic UPSERT for the supported production dbs.
for row in rows:
existing = session.get(
SqlConversationLabel,
(current_workspace_id(), row["conversation_id"], row["key"]),
)
if existing is None:
session.add(SqlConversationLabel(**row))
else:
# mypy sees existing.{value,updated_at} as the
# Mapped[...] descriptor types; at runtime these
# are plain attributes that accept the target
# Python type directly. SQLAlchemy's ORM handles
# the coercion.
existing.value = row["value"] # type: ignore[assignment]
existing.updated_at = row["updated_at"] # type: ignore[assignment]
def _dialect_upsert_labels(
session: Session,
dialect: str,
rows: list[dict[str, Any]],
) -> None:
"""
Dialect-specific UPSERT path for SQLite / PostgreSQL.
Extracted from ``_upsert_labels`` so the two branches
(which use different ``insert`` builders producing
incompatible type variances at the mypy level) each live
in their own narrow scope. The outer function selects the
branch; this one executes it.
:param session: Active SQLAlchemy session.
:param dialect: ``"sqlite"`` or ``"postgresql"`` (the
outer function gates all other dialects onto the
generic fallback path).
:param rows: Pre-built row dicts to upsert.
"""
# Typed as Any to sidestep the mypy variance issue between
# the two dialect-specific ``Insert`` classes; the runtime
# shape of both classes is identical for our use.
stmt: Any
if dialect == "sqlite":
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(SqlConversationLabel).values(rows)
else:
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(SqlConversationLabel).values(rows)
stmt = stmt.on_conflict_do_update(
index_elements=["workspace_id", "conversation_id", "key"],
set_={
"value": stmt.excluded.value,
"updated_at": stmt.excluded.updated_at,
},
)
session.execute(stmt)
def _fetch_labels(
session: Session,
conversation_id: str,
) -> dict[str, str]:
"""
Load all guardrails labels for a conversation.
Returns an empty dict when no labels have been written
yet — a conversation that was created before its spec
declared guardrails, or before any policy wrote a label.
:param session: The active SQLAlchemy session.
:param conversation_id: Unique conversation identifier,
e.g. ``"conv_abc123"``.
:returns: Mapping from label key to value (string-typed).
Empty dict when no rows match.
"""
with query_name_scope("omnigent.conversation_store.select_conversation_labels"):
rows = (
session.execute(
select(SqlConversationLabel.key, SqlConversationLabel.value).where(
SqlConversationLabel.workspace_id == current_workspace_id(),
SqlConversationLabel.conversation_id == conversation_id,
)
)
.tuples()
.all()
)
return dict(rows)
def _fetch_labels_bulk(
session: Session,
conversation_ids: list[str],
) -> dict[str, dict[str, str]]:
"""
Load labels for many conversations in a single query.
Used by ``list_conversations`` to avoid an N+1 fan-out.
Empty input returns an empty map without touching the
database.
:param session: The active SQLAlchemy session.
:param conversation_ids: Conversation IDs to fetch labels
for, e.g. ``["conv_a", "conv_b"]``. Duplicates are
tolerated but yield the same map entries.
:returns: Mapping ``{conversation_id: {key: value}}``.
Conversations with no label rows are absent from the
outer map (callers should default to ``{}``).
"""
if not conversation_ids:
return {}
rows = session.execute(
select(
SqlConversationLabel.conversation_id,
SqlConversationLabel.key,
SqlConversationLabel.value,
).where(
SqlConversationLabel.workspace_id == current_workspace_id(),
SqlConversationLabel.conversation_id.in_(conversation_ids),
)
).all()
out: dict[str, dict[str, str]] = {}
for conv_id, key, value in rows:
out.setdefault(conv_id, {})[key] = value
return out
def _fetch_search_snippets(
session: Session,
conversation_ids: list[str],
query: str,
) -> dict[str, str]:
"""
Build a per-conversation preview excerpt of matching chat content.
For each conversation whose body matched ``query`` (case-insensitive
substring on ``search_text``), returns a short snippet centered on the
match so the search UI can show *where* the session matched. The
earliest matching item per conversation wins.
Bulk (no N+1) *and* bounded to one row per conversation: a grouped
subquery finds the min matching ``position`` per conversation, then the
outer query materializes only those rows. Without the ``MIN(position)``
join, the plain ``LIKE`` would stream every matching item's full
``search_text`` body — potentially thousands per long conversation —
just to keep the first.
:param session: The active SQLAlchemy session.
:param conversation_ids: Conversation IDs to build snippets for,
e.g. ``["conv_a", "conv_b"]``.
:param query: The user's search string.
:returns: Mapping ``{conversation_id: snippet}``. Conversations whose
only match was the title (no item body match) are absent — the
caller leaves their ``search_snippet`` as ``None``.
"""
if not conversation_ids or not query:
return {}
pattern = f"%{query.lower()}%"
workspace_id = current_workspace_id()
# workspace_id leads the (workspace_id, conversation_id, position) index.
# Both the aggregate and the join-back below must include it or Postgres
# can't use that index and falls back to a full table scan of every item.
match_pred = and_(
SqlConversationItem.workspace_id == workspace_id,
SqlConversationItem.conversation_id.in_(conversation_ids),
func.lower(SqlConversationItem.search_text).like(pattern),
)
# Earliest matching position per conversation — a small (conv_id, position)
# aggregate, no bodies materialized.
earliest = (
select(
SqlConversationItem.conversation_id.label("cid"),
func.min(SqlConversationItem.position).label("pos"),
)
.where(match_pred)
.group_by(SqlConversationItem.conversation_id)
.subquery()
)
# Join back to pull exactly one search_text body per conversation. The
# workspace_id predicate keeps this on the composite index.
rows = session.execute(
select(
SqlConversationItem.conversation_id,
SqlConversationItem.search_text,
).join(
earliest,
and_(
SqlConversationItem.workspace_id == workspace_id,
SqlConversationItem.conversation_id == earliest.c.cid,
SqlConversationItem.position == earliest.c.pos,
),
)
).all()
out: dict[str, str] = {}
for conv_id, search_text in rows:
if not search_text:
continue
snippet = build_search_snippet(search_text, query)
if snippet is not None:
out[conv_id] = snippet
return out
def _to_item(row: SqlConversationItem, data_json: str) -> ConversationItem:
"""
Convert a :class:`SqlConversationItem` ORM row to a
:class:`ConversationItem` entity.
Parses *data_json* into the appropriate typed data model.
:param row: The SQLAlchemy ORM row to convert.
:param data_json: The row's already-decoded ``data`` JSON. Callers decode a
page of rows up front via
:meth:`SqlAlchemyConversationStore._decode_item_data_batch` (identity by
default), so this builds the entity from plaintext and never reads
``row.data`` directly — letting a subclass decode a whole page in one
pass (e.g. a single batched decrypt) rather than once per row.
:returns: A :class:`ConversationItem` Pydantic model.
"""
item_type = decode_item_type(row.type)
return ConversationItem(
id=row.id,
type=item_type,
status=decode_item_status(row.status),
response_id=row.response_id,
created_at=row.created_at,
data=parse_item_data(item_type, json.loads(data_json)),
created_by=row.created_by,
)
def _ranked_latest_message_items(conversation_ids: list[str]) -> Subquery:
"""
Build a ranked latest-message subquery for multiple conversations.
Selects only the columns :func:`_to_item` needs (plus ``conversation_id``
and ``position`` for grouping/ordering) and a per-conversation ``row_num``
so the caller can filter to the top-N rows without a join back to the base
table. Avoiding the join is critical: the primary key is
``(workspace_id, conversation_id, id)``, so a join on ``id`` alone forces a
full table scan. The heavy ``search_text`` column is deliberately omitted —
the message-preview caller never reads it, and it roughly doubles the bytes
pulled per row on a chatty conversation.
:param conversation_ids: Conversation ids to fetch messages for,
e.g. ``["conv_child1", "conv_child2"]``.
:returns: SQLAlchemy subquery with the projected item columns plus
per-conversation ``row_num``, newest message first.
"""
return (
select(
SqlConversationItem.conversation_id,
SqlConversationItem.id,
SqlConversationItem.response_id,
SqlConversationItem.created_at,
SqlConversationItem.status,
SqlConversationItem.position,
SqlConversationItem.type,
SqlConversationItem.data,
SqlConversationItem.created_by,
func.row_number()
.over(
partition_by=SqlConversationItem.conversation_id,
order_by=desc(SqlConversationItem.position),
)
.label("row_num"),
)
.where(
SqlConversationItem.workspace_id == current_workspace_id(),
SqlConversationItem.conversation_id.in_(conversation_ids),
SqlConversationItem.type == encode_item_type("message"),
)
.subquery()
)
class SqlAlchemyConversationStore(ConversationStore):
"""
SQLAlchemy-backed implementation of :class:`ConversationStore`.
Persists conversations and their items in a relational database
via SQLAlchemy ORM. Also manages a full-text search (FTS) table
for item content.
"""
def __init__(
self, storage_location: str, conversation_storage_location: str | None = None
) -> None:
"""
Initialize the SQLAlchemy conversation store.
Creates or reuses a SQLAlchemy engine and session factory,
and ensures the FTS virtual table exists.
:param storage_location: SQLAlchemy database URI for the Omnigent DB,
e.g. ``"sqlite:///omnigent.db"`` or
``"postgresql://<user>:<password>@host/db"``.
:param conversation_storage_location: SQLAlchemy database URI for the Agent
Platform DB (conversations, items, labels). Defaults to
``storage_location`` when ``None`` (single-DB mode).
"""
super().__init__(storage_location, conversation_storage_location)
# Omnigent DB: agents, hosts, policies, files, user_daily_costs,
# session_permissions, comments, omnigent_conversation_metadata.
self._engine = get_or_create_engine(storage_location)
self._session = make_named_managed_session_maker(
self._engine,
query_name_prefix="omnigent.conversation_store",
)
# Immediate session: used for read-modify-write operations that must be
# atomic. On SQLite, ``BEGIN IMMEDIATE`` acquires the write lock before
# the first read, preventing ``SQLITE_BUSY_SNAPSHOT`` under concurrent
# writers. On other dialects ``immediate=True`` is a no-op — those paths
# use ``SELECT … FOR UPDATE`` via ``_supports_for_update`` instead.
self._session_immediate = make_named_managed_session_maker(
self._engine,
query_name_prefix="omnigent.conversation_store",
immediate=True,
)
# Agent Platform DB: conversations, conversation_items, conversation_labels.
# Defaults to the Omnigent DB when not separately configured. Always creates
# a separate session factory so AP and Omnigent writes run in independent
# transactions, even when both point at the same underlying engine.
conv_uri = conversation_storage_location or storage_location
self._conv_engine = (
self._engine
if conv_uri == storage_location
else get_or_create_conversation_engine(conv_uri)
)
self._conv_session = make_named_managed_session_maker(
self._conv_engine,
query_name_prefix="omnigent.conversation_store",
)
self._conv_session_immediate = make_named_managed_session_maker(
self._conv_engine,
query_name_prefix="omnigent.conversation_store",
immediate=True,
)
# Dialect-appropriate row-locking flags. Each flag is derived from its
# own engine so a mixed-dialect split-DB (e.g. Postgres AP + SQLite
# Omnigent) gets the correct lock strategy for each table group.
self._supports_for_update = self._conv_engine.dialect.name != "sqlite"
self._meta_supports_for_update = self._engine.dialect.name != "sqlite"
# SQLite rowid is monotonically increasing absent deletions; it serves
# as an insertion-ordered tiebreaker for timestamp ties. Note: without
# the AUTOINCREMENT keyword, SQLite may reuse a rowid if the max-rowid
# row is deleted — acceptable here since deletions won't cause
# same-timestamp collisions in practice. Other dialects fall back to
# the string id column (non-deterministic for ties; proper fix: add a
# BIGSERIAL seq col).
self._tiebreaker_col: ColumnElement[Any] = (
literal_column("conversations.rowid")
if self._conv_engine.dialect.name == "sqlite"
else cast(ColumnElement[Any], SqlConversation.id)
)
ensure_fts_table(self._conv_engine)
def _get_meta(
self, _unused_session: Session, conversation_id: str
) -> SqlConversationMetadata | None:
"""
Fetch the metadata row for a conversation from the Omnigent DB.
"""
with self._session("select_conversation_metadata_by_id") as meta_sess:
return meta_sess.get(
SqlConversationMetadata, (current_workspace_id(), conversation_id)
)
def _lock_conversation(self, session: Session, conversation_id: str) -> None:
"""
Acquire a row-level lock on the conversation to serialize
position writes.
On PostgreSQL, issues ``SELECT ... FOR UPDATE`` on the
conversation row.
On SQLite, issues a no-op ``UPDATE`` on the conversation
row to escalate the transaction to ``RESERVED``. SQLite
starts transactions as ``DEFERRED`` (read-only) by
default — concurrent ``append()`` calls would otherwise
both read the same ``next_position`` counter (or, for a
pre-counter conversation, the same ``max(position)``) without
holding any write lock, both allocate the same position, and
both try to INSERT it → UNIQUE
constraint failure on
``ix_conversation_items_conversation_id_position``.
Reproduced 2026-04-30 in the user's 20-shell scenario:
the agent loop's incremental tool-call persist raced the
steering inbox's auto-injection of idle-notification user
messages, both grabbed positions 34 + 35, the loser
crashed with ``IntegrityError``. Issuing an UPDATE here
escalates this transaction to ``RESERVED`` immediately,
so a second concurrent transaction blocks on
``busy_timeout`` (20s, set in :func:`make_managed_session_maker`)
rather than racing the read, and re-reads the up-to-date
``next_position`` counter (or, for a pre-counter conversation,
``max(position)``) once the holder commits.
:param session: The active SQLAlchemy session.
:param conversation_id: The conversation to lock,
e.g. ``"conv_abc123"``.
"""
if self._supports_for_update:
stmt = (
select(SqlConversation.id)
.where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.id == conversation_id,
)
.with_for_update()
)
session.execute(stmt)
else:
# SQLite: any UPDATE escalates the transaction to
# RESERVED. Setting ``updated_at`` to itself is the
# cheapest no-op write that achieves this — SQLite
# actually executes it (no statement-level
# short-circuit on equal values), which is what we
# want here.
session.execute(
text("UPDATE conversations SET updated_at = updated_at WHERE id = :id"),
# Raw SQL bypasses the Uuid16 decorator; bind the 16-byte form
# so the WHERE matches the binary id column.
{"id": uuid_to_bytes(conversation_id)},
)
def create_conversation(
self,
kind: str = "default",
title: str | None = None,
parent_conversation_id: str | None = None,
agent_id: str | None = None,
runner_id: str | None = None,
sub_agent_name: str | None = None,
host_id: str | None = None,
workspace: str | None = None,
git_branch: str | None = None,
terminal_launch_args: list[str] | None = None,
conversation_id: str | None = None,
) -> Conversation:
"""
Create a new conversation in the database.
:param kind: Conversation type. ``"default"`` for
user-initiated, ``"sub_agent"`` for sub-agent
execution conversations.
:param title: Optional title. Phase 4 named sub-agents
store ``"<type>:<name>"`` so the partial unique
index enforces ``(parent_conversation_id, title)``
uniqueness within a parent.
:param parent_conversation_id: Phase 4 — id of the
owning parent conversation. ``None`` for top-level.
:param agent_id: Agent to bind at creation time, e.g.
``"ag_abc123"``. ``None`` only for legacy rows or
callers that cannot bind a conversation.
:param runner_id: Optional runner binding to persist at
creation time, e.g. ``"runner_abc123"``. Child
sub-agent conversations inherit the parent's binding
through this field so runner dispatch remains explicit
in store state.
:param sub_agent_name: For sub-agent sessions, the
sub-agent type name within the parent's spec tree,
e.g. ``"summarizer"``. ``None`` for top-level.
:param host_id: Host that should launch the runner for
this session, e.g. ``"host_a1b2c3d4..."``. ``None``
for CLI-initiated sessions.
:param workspace: Absolute path on disk where the runner
should start, e.g. ``"/Users/corey/universe/src/foo"``.
Required when ``host_id`` is set (DB check constraint
``ck_conversations_workspace_required_for_host``);
optional for CLI-launched sessions that record their
starting cwd for display. The caller passes the
already-canonicalized realpath from
``host.stat`` — this method does no expansion. When a git
worktree was created, this is the worktree directory path.
:param git_branch: Git branch checked out in the session's
worktree, e.g. ``"feature/login"``. Set only when the
session was created with a server-created worktree;
``None`` otherwise. See designs/SESSION_GIT_WORKTREE.md.
:param terminal_launch_args: Optional pass-through CLI args
for a native terminal wrapper (claude / codex), e.g.
``["--dangerously-skip-permissions"]``. ``None`` leaves
the column NULL; a list (including ``[]``) is JSON-encoded
so the runner applies it when it auto-launches the
terminal.
:param conversation_id: Optional caller-supplied identifier.
``None`` generates a new random id.
:returns: The newly created :class:`Conversation`.
:raises NameAlreadyExistsError: If
``parent_conversation_id`` is set and a sibling row
with the same ``title`` already exists.
:raises IntegrityError: If ``host_id`` is set without
``workspace`` (the check constraint catches it).
:raises ConversationAlreadyExistsError: If a caller-supplied
``conversation_id`` is already in use.
"""
from sqlalchemy.exc import IntegrityError
from omnigent.stores.conversation_store import (
ConversationNotFoundError,
NameAlreadyExistsError,
)
now = now_epoch()
new_id = conversation_id if conversation_id is not None else generate_conversation_id()
try:
# Get parent's root from AP, then write AP row and Omnigent meta separately.
root_id = new_id
if parent_conversation_id is not None:
with self._conv_session("select_parent_conversation") as ap_sess:
parent_row = ap_sess.get(
SqlConversation,
(current_workspace_id(), parent_conversation_id),
)
if parent_row is None:
raise ConversationNotFoundError(
f"parent conversation {parent_conversation_id!r} does not exist"
)
root_id = parent_row.root_conversation_id
if parent_conversation_id is not None and not title:
title = f"untitled:{new_id}"
with self._conv_session("insert_conversation") as ap_sess:
# Application-level (parent, title) uniqueness — there is no DB
# unique constraint. Only children are scoped; top-level sessions
# (NULL parent) may reuse titles freely. The SELECT seeks this
# parent's children via idx_conversations_parent and filters
# title as a residual. Best-effort: a concurrent same-name create
# can still race past this check, yielding a duplicate child
# rather than an error (the common repeat-send path is served by
# the runner's find-or-create pre-check, so this fires only on a
# genuine collision).
if parent_conversation_id is not None:
with query_name_scope(
"omnigent.conversation_store.select_duplicate_child_title"
):
duplicate = ap_sess.execute(
select(SqlConversation.id)
.where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.parent_conversation_id == parent_conversation_id,
SqlConversation.title == (title or ""),
)
.limit(1)
).first()
if duplicate is not None:
raise NameAlreadyExistsError(
f"sub-agent name already exists under parent "
f"{parent_conversation_id!r}: title={title!r}"
)
row = SqlConversation(
id=new_id,
created_at=now,
updated_at=now,
title=title or "",
parent_conversation_id=parent_conversation_id,
root_conversation_id=root_id,
agent_id=agent_id,
)
ap_sess.add(row)
meta = SqlConversationMetadata(
id=new_id,
kind=encode_conversation_kind(kind),
runner_id=runner_id,
host_id=host_id,
sub_agent_name=sub_agent_name,
workspace=workspace,
git_branch=git_branch,
terminal_launch_args=(
json.dumps(terminal_launch_args) if terminal_launch_args is not None else None
),
)
with self._session("insert_conversation_metadata") as meta_sess:
meta_sess.add(meta)
return _to_conversation(row, meta)
except IntegrityError as exc:
# Translate a caller-supplied-id PK collision into a clean exception
# type. Per-parent title uniqueness is enforced by the SELECT above,
# not a DB constraint, so only the id PK violation is handled here;
# other integrity violations (FK, check constraints) re-raise.
#
# Detection prefers the PK constraint name (Postgres/MySQL surface it
# directly), and falls back on SQLite's failed-column signature:
# Postgres → "pk_conversations" (repo naming convention; the stock
# "conversations_pkey" is kept as a defensive fallback)
# MySQL → duplicate entry ... for key '...PRIMARY'
# SQLite → "conversations.id" (dotted) in the failed-UNIQUE clause.
msg = str(exc).lower()
is_id_unique_violation = conversation_id is not None and (
"pk_conversations" in msg
or "conversations_pkey" in msg
or (
"duplicate entry" in msg
and ("for key 'primary'" in msg or "for key 'conversations.primary'" in msg)
)
or ("unique" in msg and "conversations.id" in msg)
)
if is_id_unique_violation:
raise ConversationAlreadyExistsError(
f"conversation id {conversation_id!r} already exists"
) from exc
raise
def get_conversation(self, conversation_id: str) -> Conversation | None:
"""
Fetch a conversation by its unique ID.
Issues two queries inside one session: the conversation row
(which carries the agent binding + per-session override blob) and
a label fetch on ``conversation_labels``.
:param conversation_id: Unique conversation identifier,
e.g. ``"conv_abc123"``.
:returns: The :class:`Conversation` if found, otherwise
``None``.
"""
with self._conv_session("select_conversation_by_id") as session:
row = session.get(SqlConversation, (current_workspace_id(), conversation_id))
if row is None:
return None
meta = self._get_meta(session, conversation_id)
return _to_conversation(row, meta, _fetch_labels(session, conversation_id))
def find_imported_conversation(
self,
source: str,
external_session_id: str,
) -> Conversation | None:
"""Find the original conversation carrying an import provenance pair."""
source_label = aliased(SqlConversationLabel)
external_label = aliased(SqlConversationLabel)
with self._conv_session("select_imported_conversation") as session:
conversation_id = session.execute(
select(SqlConversation.id)
.join(
source_label,
(source_label.workspace_id == SqlConversation.workspace_id)
& (source_label.conversation_id == SqlConversation.id),
)
.join(
external_label,
(external_label.workspace_id == SqlConversation.workspace_id)
& (external_label.conversation_id == SqlConversation.id),
)
.where(
SqlConversation.workspace_id == current_workspace_id(),
source_label.key == IMPORT_SOURCE_LABEL_KEY,
source_label.value == source,
external_label.key == IMPORT_EXTERNAL_SESSION_ID_LABEL_KEY,
external_label.value == external_session_id,
)
.order_by(SqlConversation.created_at, SqlConversation.id)
.limit(1)
).scalar_one_or_none()
return self.get_conversation(conversation_id) if conversation_id is not None else None
def get_runner_ids(self, conversation_ids: list[str]) -> dict[str, str | None]:
"""
Single ``SELECT id, runner_id WHERE id IN (...)`` — bulk
variant of :meth:`get_conversation` for the runner-dot path.
Missing ids are omitted; ids without a bound runner map to
``None``.
"""
if not conversation_ids:
return {}
unique_ids = list(set(conversation_ids))
with self._session("select_runner_ids") as session:
rows = session.execute(
select(SqlConversationMetadata.id, SqlConversationMetadata.runner_id).where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id.in_(unique_ids),
)
).all()
return {row.id: row.runner_id for row in rows}
def get_session_connectivity(
self, conversation_ids: list[str]
) -> dict[str, SessionConnectivity]:
"""
Return connectivity fields for a batch of sessions in one query.
Two bulk ``SELECT`` s — one over ``conversations`` for the
runner/host binding, one over ``conversation_labels`` for the
fork-source connectivity marker — instead of the per-id
``get_conversation`` + labels fan-out the sidebar online-dot used
to drive. See the abstract method for the contract.
:param conversation_ids: Session/conversation IDs to look up,
e.g. ``["conv_abc123", "conv_def456"]``.
:returns: Mapping ``conversation_id -> SessionConnectivity``;
ids without a conversation row are omitted.
"""
if not conversation_ids:
return {}
unique_ids = list(set(conversation_ids))
# runner_id and host_id are in the Omnigent DB (metadata).
with self._session("get_session_connectivity") as session:
meta_rows = session.execute(
select(
SqlConversationMetadata.id,
SqlConversationMetadata.runner_id,
SqlConversationMetadata.host_id,
SqlConversationMetadata.runner_last_seen,
).where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id.in_(unique_ids),
)
).all()
# Fork-source label is in the AP DB.
with self._conv_session("get_session_connectivity") as ap_sess:
# One pass over the fork-source connectivity marker, which
# signals on presence (its value is the source id).
label_rows = ap_sess.execute(
select(
SqlConversationLabel.conversation_id,
SqlConversationLabel.key,
SqlConversationLabel.value,
).where(
SqlConversationLabel.workspace_id == current_workspace_id(),
SqlConversationLabel.conversation_id.in_(unique_ids),
SqlConversationLabel.key.in_([FORK_SOURCE_LABEL_KEY]),
)
).all()
needs_workspace_ids = {
row.conversation_id for row in label_rows if row.key == FORK_SOURCE_LABEL_KEY
}
return {
row.id: SessionConnectivity(
runner_id=row.runner_id,
host_id=row.host_id,
needs_workspace=row.id in needs_workspace_ids,
runner_last_seen=row.runner_last_seen,
)
for row in meta_rows
}
def get_conversations(self, conversation_ids: list[str]) -> dict[str, Conversation]:
"""
Bulk variant of :meth:`get_conversation` — one ``SELECT ... WHERE
id IN (...)`` for the rows plus one batched label query, so the
watch-set rescan costs a constant number of round-trips instead
of one per id. Missing ids are omitted from the result.
:param conversation_ids: Conversation ids to fetch,
e.g. ``["conv_abc123", "conv_def456"]``. Duplicates are
tolerated; empty input returns ``{}`` without a query.
:returns: Mapping ``{conversation_id: Conversation}`` for the
ids that resolved to a row.
"""
if not conversation_ids:
return {}
unique_ids = list(set(conversation_ids))
with self._conv_session("get_conversations") as session:
rows = list(
session.execute(
select(SqlConversation).where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.id.in_(unique_ids),
)
)
.scalars()
.all()
)
# Batch the labels in the same session so the bulk fetch sees a
# consistent snapshot and avoids the per-row label fan-out that
# get_conversation incurs. Build the entities inside the session
# too — _to_conversation reads ORM columns, which would raise
# DetachedInstanceError once the session closes.
labels_by_conv = _fetch_labels_bulk(session, [row.id for row in rows])
meta_rows: list[SqlConversationMetadata] = []
if rows:
row_ids = [r.id for r in rows]
with self._session("get_conversations") as meta_sess:
meta_rows = list(
meta_sess.execute(
select(SqlConversationMetadata).where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id.in_(row_ids),
)
)
.scalars()
.all()
)
meta_by_id = {m.id: m for m in meta_rows}
return {
row.id: _to_conversation(
row,
meta_by_id.get(row.id),
labels_by_conv.get(row.id, {}),
)
for row in rows
}
def list_child_conversation_ids_by_parent(
self,
parent_conversation_ids: list[str],
) -> dict[str, list[str]]:
"""
Return direct sub-agent child ids grouped by parent conversation.
A conversation has a parent iff it is a sub-agent (``kind`` is fully
determined by parent nullness), so filtering on
``parent_conversation_id IN (...)`` alone already yields exactly the
sub-agent children — no metadata ``kind`` lookup needed. This resolves
as one batched query on the AP ``idx_conversations_parent`` index,
giving sidebar session-list status roll-up one identity query instead
of one full child listing per visible parent row.
:param parent_conversation_ids: Parent conversation ids to
inspect, e.g. ``["conv_parent1", "conv_parent2"]``.
Duplicates are tolerated.
:returns: Mapping from every unique input parent id to direct
child ids. Parents with no direct sub-agent children, or ids
that do not exist, map to an empty list.
"""
unique_ids = list(dict.fromkeys(parent_conversation_ids))
result: dict[str, list[str]] = {parent_id: [] for parent_id in unique_ids}
if not unique_ids:
return result
with self._conv_session("list_child_conversation_ids_by_parent") as ap_sess:
rows = ap_sess.execute(
select(SqlConversation.parent_conversation_id, SqlConversation.id)
.where(SqlConversation.workspace_id == current_workspace_id())
.where(SqlConversation.parent_conversation_id.in_(unique_ids))
.order_by(
SqlConversation.parent_conversation_id,
desc(SqlConversation.created_at),
desc(self._tiebreaker_col),
)
).all()
for parent_id, child_id in rows:
if parent_id is not None:
result[parent_id].append(child_id)
return result
def set_labels(
self,
conversation_id: str,
updates: dict[str, str],
updated_at: int | None = None,
) -> None:
"""
Upsert guardrails labels on a conversation.
Single-transaction batched UPSERT — either every key
lands or none do (POLICIES.md §6.3). The dialect-aware
path dispatches to ``INSERT ... ON CONFLICT`` on
SQLite / PostgreSQL; other dialects fall back to
SELECT-then-INSERT/UPDATE inside the same transaction.
Empty updates is a no-op.
:param conversation_id: The conversation to update,
e.g. ``"conv_abc123"``.
:param updates: Mapping from label key to new value.
Example: ``{"integrity": "0"}``. Empty dict
returns immediately without opening a transaction.
:param updated_at: Caller-supplied timestamp
(``None`` → current wall-clock). See the abstract
method docstring for why callers may want to
pass their own.
"""
if not updates:
return
stamp = updated_at if updated_at is not None else now_epoch()
with self._conv_session("set_labels") as session:
_upsert_labels(session, conversation_id, updates, stamp)
def set_session_state(
self,
conversation_id: str,
state: dict[str, Any],
) -> None:
"""
Persist the full session-state snapshot for a conversation.
Serializes *state* as JSON and writes it to the
``session_state`` column on the ``conversations`` table.
:param conversation_id: The conversation to update,
e.g. ``"conv_abc123"``.
:param state: The complete session-state dict to persist.
"""
import json
with self._session("set_session_state") as session:
session.execute(
update(SqlConversationMetadata)
.where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id == conversation_id,
)
.values(session_state=json.dumps(state))
)
def set_session_usage(
self,
conversation_id: str,
usage: dict[str, Any],
) -> None:
"""
Persist the cumulative LLM token usage for a conversation.
Serializes *usage* as JSON and writes it to the
``session_usage`` column on the ``conversations`` table.
:param conversation_id: The conversation to update,
e.g. ``"conv_abc123"``.
:param usage: The complete usage dict to persist, e.g.
``{"input_tokens": 1500, "output_tokens": 350,
"total_tokens": 1850}``. May carry a nested ``"by_model"``
sub-dict (per-model token/cost buckets), hence ``Any``.
"""
import json
with self._session("set_session_usage") as session:
session.execute(
update(SqlConversationMetadata)
.where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id == conversation_id,
)
.values(session_usage=json.dumps(usage))
)
def set_conversation_project(
self,
conversation_id: str,
project_id: str | None,
) -> bool:
"""
File a conversation into a first-class project (or unfile it).
Sets ``omnigent_conversation_metadata.project_id``. ``None`` unfiles the
session. The first-class counterpart to moving a session between
``omni_project`` labels.
:param conversation_id: The conversation to update, e.g. ``"conv_abc"``.
:param project_id: The project id to file under, or ``None`` to unfile.
:returns: ``True`` if a metadata row was updated; ``False`` if the
conversation has no metadata row.
"""
with self._session("set_conversation_project") as session:
result = cast(
_RowCountResult,
session.execute(
update(SqlConversationMetadata)
.where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id == conversation_id,
)
.values(project_id=project_id)
),
)
return result.rowcount > 0
def increment_session_usage(
self,
conversation_id: str,
delta: dict[str, Any],
) -> dict[str, Any]:
"""
Atomically increment the session usage for one conversation.
Runs the read-modify-write in a single database transaction, serialising
concurrent writers via two complementary mechanisms:
- **PostgreSQL / MySQL / MariaDB**: ``SELECT … FOR UPDATE`` acquires an
exclusive row lock for the duration of the transaction; a concurrent
second writer blocks until this one commits.
- **SQLite**: the session is opened with ``BEGIN IMMEDIATE``
(``self._session_immediate``), which acquires SQLite's write lock
*before* the first read. A plain ``SELECT``-then-``UPDATE`` in a
deferred transaction would expose concurrent writers to
``SQLITE_BUSY_SNAPSHOT`` because each writer takes a read snapshot
first; ``BEGIN IMMEDIATE`` prevents that by serialising at lock
acquisition time.
:param conversation_id: The conversation to update.
:param delta: Usage increments (see
:meth:`ConversationStore.increment_session_usage`).
:returns: The updated ``session_usage`` dict.
"""
import json
from omnigent.stores.conversation_store import apply_session_usage_delta
with self._session_immediate("increment_session_usage") as session:
q = select(SqlConversationMetadata).where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id == conversation_id,
)
if self._meta_supports_for_update:
q = q.with_for_update()
meta = session.scalars(q).first()
current: dict[str, Any] = (
dict(json.loads(meta.session_usage)) if meta and meta.session_usage else {}
)
apply_session_usage_delta(current, delta)
session.execute(
update(SqlConversationMetadata)
.where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id == conversation_id,
)
.values(session_usage=json.dumps(current))
)
return current
def add_daily_cost(self, user_id: str, day_utc: str, delta_usd: float) -> None:
"""
Atomically add *delta_usd* to a user's spend for one UTC day.
Dialect-aware: SQLite and PostgreSQL both support
``INSERT ... ON CONFLICT ... DO UPDATE``, used here for a true
atomic increment (``cost_usd = cost_usd + :delta``) so
concurrent turns never lose updates. Other dialects fall back
to a SELECT-then-INSERT/UPDATE inside the same transaction.
``delta_usd <= 0`` is a no-op (never creates a row).
:param user_id: The user the cost is attributed to (session
creator), e.g. ``"alice@example.com"``.
:param day_utc: UTC day as ``"YYYY-MM-DD"``, e.g.
``"2026-06-05"``.
:param delta_usd: USD amount to add; ``<= 0`` is a no-op.
"""
if delta_usd <= 0:
return
now = now_epoch()
with self._session("add_daily_cost") as session:
dialect = session.bind.dialect.name if session.bind is not None else ""
if dialect in ("sqlite", "postgresql"):
self._upsert_daily_cost_dialect(session, dialect, user_id, day_utc, delta_usd, now)
return
# Generic dialect fallback — SELECT-then-INSERT/UPDATE in one
# transaction (race-safe under SERIALIZABLE / SQLite's
# single-writer semantics).
existing = session.get(SqlUserDailyCost, (current_workspace_id(), user_id, day_utc))
if existing is None:
session.add(
SqlUserDailyCost(
user_id=user_id,
day_utc=day_utc,
cost_usd=delta_usd,
updated_at=now,
)
)
else:
existing.cost_usd = existing.cost_usd + delta_usd
existing.updated_at = now
def _upsert_daily_cost_dialect(
self,
session: Session,
dialect: str,
user_id: str,
day_utc: str,
delta_usd: float,
now: int,
) -> None:
"""
Atomic ``INSERT ... ON CONFLICT DO UPDATE`` increment for
SQLite / PostgreSQL.
Extracted from :meth:`add_daily_cost` so each method stays
small; the outer method selects the dialect branch and this
one executes the dedicated INSERT builder. The conflict target
is the ``(user_id, day_utc)`` primary key; on conflict the
existing ``cost_usd`` is incremented by the new row's value.
:param session: Active SQLAlchemy session.
:param dialect: ``"sqlite"`` or ``"postgresql"`` (the caller
gates all other dialects onto the generic fallback).
:param user_id: The user the cost is attributed to, e.g.
``"alice@example.com"``.
:param day_utc: UTC day as ``"YYYY-MM-DD"``, e.g.
``"2026-06-05"``.
:param delta_usd: USD amount to add (already validated ``> 0``).
:param now: Unix epoch seconds to stamp on ``updated_at``.
"""
# Typed as Any to sidestep the mypy variance between the two
# dialect-specific ``Insert`` classes; their runtime shape is
# identical for this UPSERT.
stmt: Any
if dialect == "sqlite":
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(SqlUserDailyCost)
else:
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(SqlUserDailyCost)
stmt = stmt.values(user_id=user_id, day_utc=day_utc, cost_usd=delta_usd, updated_at=now)
stmt = stmt.on_conflict_do_update(
index_elements=["workspace_id", "user_id", "day_utc"],
set_={
"cost_usd": SqlUserDailyCost.cost_usd + stmt.excluded.cost_usd,
"updated_at": stmt.excluded.updated_at,
},
)
session.execute(stmt)
def get_daily_cost(self, user_id: str, day_utc: str) -> float:
"""
Return a user's accumulated LLM spend for one UTC day.
:param user_id: The user to read, e.g. ``"alice@example.com"``.
:param day_utc: UTC day as ``"YYYY-MM-DD"``, e.g.
``"2026-06-05"``.
:returns: The accumulated ``cost_usd``, or ``0.0`` when no row
exists for ``(user_id, day_utc)``.
"""
with self._session("get_daily_cost") as session:
row = session.get(SqlUserDailyCost, (current_workspace_id(), user_id, day_utc))
return float(row.cost_usd) if row is not None else 0.0
def sum_daily_cost(self, user_id: str, since_day_utc: str) -> float:
"""
Sum a user's LLM spend over all UTC days ``>= since_day_utc``.
See :meth:`ConversationStore.sum_daily_cost`. Day strings compare
lexicographically (zero-padded ``"YYYY-MM-DD"``), so the range is
a plain ``>=`` on the string column; ``SUM`` returns ``NULL`` for
an empty range, coalesced to ``0.0``.
"""
with self._session("sum_daily_cost") as session:
total = session.execute(
select(func.coalesce(func.sum(SqlUserDailyCost.cost_usd), 0.0))
.where(SqlUserDailyCost.workspace_id == current_workspace_id())
.where(SqlUserDailyCost.user_id == user_id)
.where(SqlUserDailyCost.day_utc >= since_day_utc)
).scalar_one()
return float(total or 0.0)
def get_daily_cost_state(self, user_id: str, day_utc: str) -> dict[str, float]:
"""
Return a user's daily cost rollup state for one UTC day.
Reads both fields the per-user daily cost-budget policy needs in
a single point lookup: the accumulated spend and the highest
soft checkpoint already approved that day.
:param user_id: The user to read, e.g. ``"alice@example.com"``.
:param day_utc: UTC day as ``"YYYY-MM-DD"``, e.g.
``"2026-06-05"``.
:returns: ``{"cost_usd": <float>, "ask_approved_usd": <float>}``;
both ``0.0`` when no row exists for ``(user_id, day_utc)``.
"""
with self._session("get_daily_cost_state") as session:
row = session.get(SqlUserDailyCost, (current_workspace_id(), user_id, day_utc))
if row is None:
return {"cost_usd": 0.0, "ask_approved_usd": 0.0}
return {
"cost_usd": float(row.cost_usd),
"ask_approved_usd": float(row.ask_approved_usd or 0.0),
}
def set_daily_ask_approved(self, user_id: str, day_utc: str, ask_approved_usd: float) -> None:
"""
Record the highest approved soft checkpoint for a user+day.
UPSERT that sets ``ask_approved_usd`` **without touching
``cost_usd``** (inserts a ``cost_usd = 0`` row when none exists
yet, otherwise updates only the approval field). Called when a
per-user daily cost-budget ASK is approved, so the same
checkpoint won't re-prompt for that user again that day — even
from a different session.
:param user_id: The user the approval is for, e.g.
``"alice@example.com"``.
:param day_utc: UTC day as ``"YYYY-MM-DD"``, e.g.
``"2026-06-05"``.
:param ask_approved_usd: The crossed checkpoint value (USD) the
user approved continuing past, e.g. ``0.05``.
"""
now = now_epoch()
with self._session("set_daily_ask_approved") as session:
dialect = session.bind.dialect.name if session.bind is not None else ""
if dialect in ("sqlite", "postgresql"):
# Typed as Any to sidestep the mypy variance between the
# two dialect-specific ``Insert`` classes.
stmt: Any
if dialect == "sqlite":
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(SqlUserDailyCost)
else:
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(SqlUserDailyCost)
stmt = stmt.values(
user_id=user_id,
day_utc=day_utc,
cost_usd=0.0,
ask_approved_usd=ask_approved_usd,
updated_at=now,
)
# On conflict touch only the approval (+ stamp) — never
# the accumulated cost.
stmt = stmt.on_conflict_do_update(
index_elements=["workspace_id", "user_id", "day_utc"],
set_={
"ask_approved_usd": stmt.excluded.ask_approved_usd,
"updated_at": stmt.excluded.updated_at,
},
)
session.execute(stmt)
return
# Generic dialect fallback — SELECT-then-INSERT/UPDATE.
existing = session.get(SqlUserDailyCost, (current_workspace_id(), user_id, day_utc))
if existing is None:
session.add(
SqlUserDailyCost(
user_id=user_id,
day_utc=day_utc,
cost_usd=0.0,
ask_approved_usd=ask_approved_usd,
updated_at=now,
)
)
else:
existing.ask_approved_usd = ask_approved_usd
existing.updated_at = now
def get_session_owner(self, conversation_id: str) -> str | None:
"""
Return the user id that owns a session (its creator).
Reads ``session_permissions`` and returns the
highest-``level`` grantee: the creator's ``LEVEL_OWNER``
(4) grant outranks any read (1) / edit (2) / manage (3)
grant, so ``ORDER BY level DESC LIMIT 1`` yields the owner
without hardcoding the owner-level integer. The
``"__public__"`` public-access sentinel is excluded, so a
session that only carries a public grant (and no real
owner) returns ``None`` rather than the sentinel.
:param conversation_id: The session to look up, e.g.
``"conv_abc123"``.
:returns: The owner's user id, e.g. ``"alice@example.com"``,
or ``None`` when the session has no real (non-public)
permission grants.
"""
from omnigent.server.auth import RESERVED_USER_PUBLIC
with self._session("select_session_owner") as session:
return session.execute(
select(SqlSessionPermission.user_id)
.where(SqlSessionPermission.workspace_id == current_workspace_id())
.where(SqlSessionPermission.conversation_id == conversation_id)
.where(SqlSessionPermission.user_id != RESERVED_USER_PUBLIC)
.order_by(SqlSessionPermission.level.desc())
.limit(1)
).scalar_one_or_none()
def search(
self,
query: str,
conversation_id: str | None = None,
limit: int = 20,
) -> list[ConversationItem]:
"""
Full-text search over conversation items.
Uses the FTS virtual table to match items by
``search_text``, ranked by relevance.
:param query: The FTS search query string,
e.g. ``"deployment error"``.
:param conversation_id: Optional conversation to scope
the search to, e.g. ``"conv_abc123"``.
:param limit: Maximum number of results to return.
:returns: A list of matching :class:`ConversationItem`
objects in relevance order.
"""
with self._conv_session("search_conversations") as session:
# Dialect-specific search: the SQLite family (SQLite + D1) has
# FTS5 virtual tables (MATCH + rank); PostgreSQL doesn't. ILIKE on
# the JSON data column is a functional fallback there. Proper
# tsvector indexing is a future optimization (tracked in GAPS.md).
use_fts = _supports_fts5(self._conv_engine.dialect.name)
if use_fts:
if conversation_id is not None:
stmt = text(
"SELECT item_id FROM conversation_items_fts "
"WHERE conversation_id = :cid "
"AND search_text MATCH :query "
"ORDER BY rank LIMIT :limit"
)
else:
stmt = text(
"SELECT item_id FROM conversation_items_fts "
"WHERE search_text MATCH :query "
"ORDER BY rank LIMIT :limit"
)
else:
# Non-SQLite fallback: LIKE/ILIKE on the data column.
# PostgreSQL: cast MEDIUMBLOB/JSONB to text and use ILIKE.
# MySQL: CONVERT(data USING utf8mb4) + LIKE (case-insensitive
# by default with utf8mb4_unicode_ci collation).
like_pattern = f"%{query}%"
is_mysql = self._conv_engine.dialect.name == "mysql"
if is_mysql:
data_expr = "CONVERT(ci.data USING utf8mb4)"
like_op = "LIKE"
else:
data_expr = "ci.data::text"
like_op = "ILIKE"
if conversation_id is not None:
stmt = text(
f"SELECT ci.id FROM conversation_items ci "
f"WHERE ci.workspace_id = :ws "
f"AND ci.conversation_id = :cid "
f"AND {data_expr} {like_op} :query "
f"ORDER BY ci.created_at DESC LIMIT :limit"
)
else:
stmt = text(
f"SELECT ci.id FROM conversation_items ci "
f"WHERE ci.workspace_id = :ws "
f"AND {data_expr} {like_op} :query "
f"ORDER BY ci.created_at DESC LIMIT :limit"
)
query = like_pattern
params: dict[str, str | int | bytes] = {
"query": query,
"limit": limit,
"ws": current_workspace_id(),
}
if conversation_id is not None:
# Raw SQL bypasses Uuid16: the FTS mirror stores hex text, but
# conversation_items.conversation_id is 16 raw bytes — bind the
# form each branch actually compares against.
params["cid"] = conversation_id if use_fts else uuid_to_bytes(conversation_id)
item_ids = [
item_id.hex() if isinstance(item_id, (bytes, memoryview)) else item_id
for item_id in (row[0] for row in session.execute(stmt, params).fetchall())
]
if not item_ids:
return []
rows = (
session.execute(
select(SqlConversationItem).where(
SqlConversationItem.workspace_id == current_workspace_id(),
SqlConversationItem.id.in_(item_ids),
)
)
.scalars()
.all()
)
# Preserve FTS rank order
order = {iid: i for i, iid in enumerate(item_ids)}
ordered = sorted(rows, key=lambda r: order[r.id])
decoded = self._decode_item_data_batch([r.data for r in ordered])
return [_to_item(r, d) for r, d in zip(ordered, decoded, strict=True)]
def list_items(
self,
conversation_id: str,
limit: int = 100,
after: str | None = None,
before: str | None = None,
order: str = "asc",
type: str | None = None,
) -> PagedList[ConversationItem]:
"""
List items in a conversation with cursor-based pagination.
:param conversation_id: Unique conversation identifier,
e.g. ``"conv_abc123"``.
:param limit: Maximum number of items to return.
:param after: Cursor item ID; return items appearing
after this item in sort order,
e.g. ``"msg_xyz789"``.
:param before: Cursor item ID; return items appearing
before this item in sort order.
:param order: Sort direction on position,
``"asc"`` or ``"desc"``.
:param type: Optional item type filter. When provided, only items
with this type are returned, e.g. ``"compaction"``. ``None``
means return all types.
:returns: A :class:`PagedList` of
:class:`ConversationItem` objects.
"""
with self._conv_session("list_items") as session:
is_asc = order == "asc"
sort_fn = asc if is_asc else desc
# Load only the columns _to_item reads. search_text is a wide Text
# column (roughly mirrors the message body) that this read path never
# touches; on Postgres it is TOAST-ed, so omitting it skips a detoast
# and roughly halves the bytes pulled per row on a chatty conversation.
stmt = (
select(SqlConversationItem)
.options(
load_only(
SqlConversationItem.id,
SqlConversationItem.type,
SqlConversationItem.status,
SqlConversationItem.response_id,
SqlConversationItem.created_at,
SqlConversationItem.data,
SqlConversationItem.created_by,
)
)
.where(
SqlConversationItem.workspace_id == current_workspace_id(),
SqlConversationItem.conversation_id == conversation_id,
)
)
if type is not None:
stmt = stmt.where(SqlConversationItem.type == encode_item_type(type))
if after:
# Scope the cursor lookup to conversation_id so it lands on the
# (workspace_id, conversation_id, id) primary key as a point
# lookup. Without it, (workspace_id, id) leads no index and the
# subquery degrades to a workspace-wide scan every paginated page.
sub = (
select(SqlConversationItem.position)
.where(
SqlConversationItem.workspace_id == current_workspace_id(),
SqlConversationItem.conversation_id == conversation_id,
SqlConversationItem.id == after,
)
.scalar_subquery()
)
# "after" = further in sort direction
stmt = stmt.where(
SqlConversationItem.position > sub
if is_asc
else SqlConversationItem.position < sub
)
if before:
sub = (
select(SqlConversationItem.position)
.where(
SqlConversationItem.workspace_id == current_workspace_id(),
SqlConversationItem.conversation_id == conversation_id,
SqlConversationItem.id == before,
)
.scalar_subquery()
)
# "before" = opposite of sort direction
stmt = stmt.where(
SqlConversationItem.position < sub
if is_asc
else SqlConversationItem.position > sub
)
stmt = stmt.order_by(sort_fn(SqlConversationItem.position)).limit(limit + 1)
rows = list(session.execute(stmt).scalars().all())
has_more = len(rows) > limit
if has_more:
rows = rows[:limit]
decoded = self._decode_item_data_batch([r.data for r in rows])
items = [_to_item(r, d) for r, d in zip(rows, decoded, strict=True)]
return PagedList(
data=items,
first_id=items[0].id if items else None,
last_id=items[-1].id if items else None,
has_more=has_more,
)
def list_latest_message_items_for_conversations(
self,
conversation_ids: list[str],
per_conversation_limit: int = 10,
) -> dict[str, list[ConversationItem]]:
"""
Return newest message items for multiple conversations.
Uses ``row_number() over (partition by conversation_id order by
position desc)`` so the database returns at most
``per_conversation_limit`` message rows per conversation. This keeps
child-session summary rendering to one query instead of an N+1
``list_items`` fan-out.
:param conversation_ids: Conversation ids to fetch messages for,
e.g. ``["conv_child1", "conv_child2"]``.
:param per_conversation_limit: Maximum number of message items per
conversation, e.g. ``10``.
:returns: Mapping from every unique input id to its newest message
items in descending position order.
"""
unique_ids = list(dict.fromkeys(conversation_ids))
result: dict[str, list[ConversationItem]] = {cid: [] for cid in unique_ids}
if not unique_ids or per_conversation_limit <= 0:
return result
with self._conv_session("list_latest_message_items_for_conversations") as session:
ranked = _ranked_latest_message_items(unique_ids)
rows = session.execute(
select(ranked)
.where(ranked.c.row_num <= per_conversation_limit)
.order_by(ranked.c.conversation_id, ranked.c.position.desc())
).all()
decoded = self._decode_item_data_batch([row.data for row in rows])
for row, data_json in zip(rows, decoded, strict=True):
result[row.conversation_id].append(_to_item(row, data_json)) # type: ignore[arg-type]
return result
def _encode_item_data(self, data_json: str) -> str:
"""
Transform an item's serialized ``data`` JSON on its way into the
``conversation_items.data`` column. Inverse of
:meth:`_decode_item_data_batch`.
The default is identity — the column stays plaintext ``Text`` and the
JSON is returned unchanged. A subclass may override to compress or
encrypt the payload, provided it applies the matching inverse in
:meth:`_decode_item_data_batch` (and maps the column to a binary type if
the transform yields non-text bytes).
"""
return data_json
def _decode_item_data_batch(self, stored: list[str]) -> list[str]:
"""
Inverse of :meth:`_encode_item_data` for a whole page of rows, applied
when reading. Returns one decoded ``data`` JSON per input, in order.
The default returns the values unchanged (the column is plaintext). A
subclass that encoded the column on write reverses it here; overriding
the *batch* — rather than a per-row hook — lets it decode the page in a
single pass (e.g. one bulk decrypt call) instead of once per row.
"""
return stored
def _item_search_text(self, item: NewConversationItem) -> str | None:
"""
Plain-text extraction of *item* persisted in ``search_text`` and indexed
for full-text search by :meth:`append`.
The default extracts the searchable text as before. A subclass whose
schema omits ``search_text`` (e.g. because ``data`` is stored opaquely
and cannot be searched in SQL) returns ``None`` to skip persisting the
column and its FTS row entirely.
"""
return strip_nul_bytes(extract_search_text(item))
def append(
self,
conversation_id: str,
items: list[NewConversationItem],
) -> list[ConversationItem]:
"""
Append items to a conversation.
Assigns a globally unique ID, timestamp, and incrementing
position to each item. Also inserts FTS records for
searchability.
:param conversation_id: Unique conversation identifier,
e.g. ``"conv_abc123"``.
:param items: List of :class:`NewConversationItem` objects
to persist.
:returns: The persisted :class:`ConversationItem` list
with store-assigned IDs and timestamps.
"""
now = now_epoch()
persisted: list[ConversationItem] = []
with self._conv_session("append_conversation_items") as session:
# Lock the conversation row to serialize position writes.
# On PostgreSQL this is a row-level FOR UPDATE lock; on
# SQLite the database-level lock already serializes.
self._lock_conversation(session, conversation_id)
# Bump updated_at on the conversation.
conv_row = session.get(SqlConversation, (current_workspace_id(), conversation_id))
if conv_row is not None:
conv_row.updated_at = now
# Allocate item positions from the conversation's maintained
# next_position counter instead of running a MAX(position) aggregate
# on every append. Reading + advancing the counter under
# _lock_conversation keeps allocation O(1), drops a query per write,
# and stays collision-free. The aggregate is an index lookup on this
# schema (ix_conversation_items_conversation_id_position), but a
# maintained counter avoids the per-append round-trip regardless and
# scales to backends where that same allocation is a full scan.
#
# Backwards compatibility: conversations created before this counter
# existed have next_position = NULL; fall back to a one-time
# MAX(position) scan (coalesce to -1 so the first item gets 0), then
# persist the counter below so every later append is scan-free.
if conv_row is not None and conv_row.next_position is not None:
next_pos = conv_row.next_position
else:
next_pos = (
session.execute(
select(func.coalesce(func.max(SqlConversationItem.position), -1)).where(
SqlConversationItem.workspace_id == current_workspace_id(),
SqlConversationItem.conversation_id == conversation_id,
)
).scalar_one()
+ 1
)
fts_rows: list[tuple[str, str, str]] = []
for item in items:
position = next_pos
next_pos += 1
data_dict = item.data.model_dump(exclude_none=True)
# Strip NUL bytes before they reach a Postgres text
# column, which rejects them outright. Tool output can
# embed NUL (e.g. reading a binary file); without this
# the whole INSERT aborts and the item never persists.
data = self._encode_item_data(strip_nul_bytes(json.dumps(data_dict)))
search = self._item_search_text(item)
item_id = generate_item_id(item.type)
row = SqlConversationItem(
id=item_id,
conversation_id=conversation_id,
response_id=item.response_id,
created_at=now,
status=encode_item_status("completed"), # items are final on append
position=position,
type=encode_item_type(item.type),
data=data,
created_by=item.created_by,
)
# A backend may omit search_text (see _item_search_text); leaving
# the attribute unset drops it from the INSERT so a schema without
# the column still works, and skips its FTS row.
if search is not None:
row.search_text = search
fts_rows.append((item_id, conversation_id, search))
session.add(row)
persisted.append(
ConversationItem(
id=row.id,
# The row stores int codes; the entity carries the
# string names. item.type is the source string and
# the status was just written as "completed".
type=item.type,
status="completed",
response_id=row.response_id,
created_at=row.created_at,
data=item.data,
created_by=item.created_by,
)
)
insert_fts_bulk(session, fts_rows)
# Persist the advanced counter so the next append reads it instead
# of scanning; this also lazily backfills a pre-counter conversation.
if conv_row is not None:
conv_row.next_position = next_pos
return persisted
def list_projects(
self,
accessible_by: str | None = None,
owned_by: str | None = None,
) -> list[str]:
"""
Return all distinct project names, ordered alphabetically.
Projects are implicit: they exist as long as at least one
*non-archived* ``conversation_labels`` row with ``key="omni_project"``
references them. Archived sessions keep their project label (so
unarchiving restores a session to its original project), but a project
whose every member is archived drops out of this list — that is what
makes "Delete project" (which archives all members) remove the folder
while leaving the sessions recoverable. The label key is namespaced
(``omni_*``) to keep this internal storage key distinct from the
user-facing "project" term and from any future reserved keys; it is
never surfaced as a label in the UI.
:param accessible_by: When set, restrict to sessions that
``accessible_by`` has a permission row for (mirrors the
``list_conversations`` ACL filter).
:param owned_by: When set, restrict to projects that contain at
least one session ``owned_by`` owns (an ``owner``-level grant).
Filing into a project is owner-only, so the sidebar renders
folders only on "My sessions"; scoping by ownership keeps a
project shared *with* the user (but owned by someone else) from
surfacing as one of their own folders.
:returns: List of project names ordered ascending.
"""
from omnigent.server.auth import LEVEL_OWNER
# ACL (accessible_by/owned_by) resolves against session_permissions on
# the Omnigent DB, so it still needs a pre-fetch; archived now lives on
# the AP conversations table and is filtered inline below.
permission_ids: list[str] | None = None
if accessible_by is not None or owned_by is not None:
with self._session("list_projects") as meta_sess:
accessible_set: set[str] | None = None
owned_set: set[str] | None = None
if accessible_by is not None:
accessible_set = set(
meta_sess.execute(
select(SqlSessionPermission.conversation_id).where(
SqlSessionPermission.workspace_id == current_workspace_id(),
SqlSessionPermission.user_id == accessible_by,
)
).scalars()
)
if owned_by is not None:
owned_set = set(
meta_sess.execute(
select(SqlSessionPermission.conversation_id).where(
SqlSessionPermission.workspace_id == current_workspace_id(),
SqlSessionPermission.user_id == owned_by,
SqlSessionPermission.level >= LEVEL_OWNER,
)
).scalars()
)
if accessible_set is not None and owned_set is not None:
permission_ids = list(accessible_set & owned_set)
else:
permission_ids = list(
accessible_set if accessible_set is not None else owned_set or set()
)
with self._conv_session("list_projects") as ap_sess:
# Non-archived conversations, resolved on the AP table.
non_archived_ids = select(SqlConversation.id).where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.archived.is_(False),
)
stmt = (
select(SqlConversationLabel.value)
.where(
SqlConversationLabel.workspace_id == current_workspace_id(),
SqlConversationLabel.key == PROJECT_LABEL_KEY,
SqlConversationLabel.conversation_id.in_(non_archived_ids),
)
.distinct()
.order_by(SqlConversationLabel.value)
)
if permission_ids is not None:
stmt = stmt.where(SqlConversationLabel.conversation_id.in_(permission_ids))
return [row[0] for row in ap_sess.execute(stmt).all()]
def delete_label(
self,
conversation_id: str,
key: str,
) -> None:
"""
Delete a single label key from a conversation.
No-op if the label does not exist.
:param conversation_id: The conversation to update.
:param key: The label key to remove, e.g. ``"omni_project"``.
"""
with self._conv_session("delete_label") as session:
session.execute(
delete(SqlConversationLabel).where(
SqlConversationLabel.workspace_id == current_workspace_id(),
SqlConversationLabel.conversation_id == conversation_id,
SqlConversationLabel.key == key,
)
)
def list_conversations(
self,
limit: int = 20,
after: str | None = None,
before: str | None = None,
kind: str | None = "default",
parent_conversation_id: str | None = None,
root_conversation_id: str | None = None,
agent_id: str | None = None,
agent_name: str | None = None,
has_agent_id: bool | None = None,
order: str = "desc",
sort_by: str = "created_at",
search_query: str | None = None,
accessible_by: str | None = None,
owned_by: str | None = None,
include_archived: bool = False,
project: str | None = None,
pinned: bool = False,
pinned_owner: str | None = None,
title: str | None = None,
) -> PagedList[Conversation]:
"""
List conversations with cursor-based pagination.
:param limit: Maximum number of conversations to return.
:param after: Cursor conversation ID; return
conversations appearing after this one in sort
order, e.g. ``"conv_abc123"``.
:param before: Cursor conversation ID; return
conversations appearing before this one in sort
order.
:param kind: Filter to conversations of this kind.
:param parent_conversation_id: Phase 4 — when set, only
return conversations whose parent matches. ``None``
disables the filter.
:param agent_id: When set, only return conversations
that have at least one task whose ``agent_id``
matches. Implemented as an EXISTS subquery on
``tasks`` so the resulting rows stay distinct (no
JOIN duplication). ``None`` disables the filter.
:param agent_name: When set, only return conversations
whose bound ``conversations.agent_id`` points at an
agent row with this name. Unlike ``agent_id``, this
intentionally matches session-scoped agents that share
a user-authored name. ``None`` disables the filter.
:param has_agent_id: When ``True``, only return
conversations whose ``agent_id`` column is not
``None``. Powers ``GET /v1/sessions`` — sessions
always have an agent binding. ``None`` disables.
:param order: Sort direction, ``"desc"`` or ``"asc"``.
:param sort_by: Column to sort on, ``"created_at"``
or ``"updated_at"``.
:param search_query: Case-insensitive substring filter on
the session title OR conversation item content.
``None`` or empty string disables the filter;
otherwise matches conversations where
``LOWER(title) LIKE %query%`` or any
``conversation_items.search_text`` contains the
query. Implemented with the SQL ``LIKE`` operator
(no FTS) so it works against both SQLite and
Postgres without extra extensions.
:param include_archived: When ``False`` (default), exclude
rows where ``archived`` is true. When ``True``, include
archived rows alongside non-archived ones.
:param project: Filter by project NAME, dual-reading both storage
paths. A non-empty string returns sessions that EITHER have a
first-class membership (``metadata.project_id`` → ``owned_by``'s
project of this name) OR carry the legacy ``omni_project`` label
with this value. ``""`` returns sessions with NEITHER (unfiled).
``None`` disables the filter. The name→id resolution is scoped to
``owned_by`` (projects are owner-private), so pass ``owned_by``
alongside a specific name for the first-class half to resolve.
:param pinned: When ``True``, restrict to sessions ``pinned_owner`` has
pinned (their per-user ``omnigent.pinned.<user>`` label — the
sidebar's Pinned section). ``False`` (default) disables the filter.
Lets the client enumerate its pinned sessions independent of the
loaded pagination window.
:param pinned_owner: The user whose pins ``pinned=True`` filters to.
``None`` → the single-user ``local`` sentinel.
:param owned_by: When set, restrict to sessions the user owns
(an ``owner``-level grant) — stricter than ``accessible_by``,
which also matches sessions merely shared with them. Powers
the per-project folder fetch. ``None`` disables the filter.
:returns: A :class:`PagedList` of :class:`Conversation`
objects.
"""
from omnigent.server.auth import LEVEL_OWNER
sort_col = self._resolve_sort_column(sort_by)
is_desc = order == "desc"
sort_fn = desc if is_desc else asc
# ``kind`` is fully determined by ``parent_conversation_id`` nullness — a
# child always has a parent, a top-level session never does — so the kind
# filter is expressed directly on the AP ``conversations`` table below
# instead of prefetching the metadata ``kind`` column across the pool.
kind_requires_parent: bool | None = None
if kind == "sub_agent":
kind_requires_parent = True
elif kind == "default":
kind_requires_parent = False
# kind and archived both live on the AP ``conversations`` table now
# (kind derived from parent-nullness, archived a real column), so they
# are filtered directly on the AP query below. The only filters that
# still require an Omnigent-side prefetch are the permission scopes.
needs_meta_filter = (accessible_by is not None) or (owned_by is not None)
qualifying_ids: list[str] | None = None
if needs_meta_filter:
# Pre-fetch permission-qualifying IDs from the Omnigent DB
# (session_permissions), then filter the AP query. accessible_by and
# owned_by are intersected (both applied) to match the prior
# behaviour. (ACL pushdown to a single AP query is a follow-up.)
with self._session("list_conversations") as meta_sess:
accessible_set: set[str] | None = None
owned_set: set[str] | None = None
if accessible_by is not None:
accessible_set = set(
meta_sess.execute(
select(SqlSessionPermission.conversation_id).where(
SqlSessionPermission.workspace_id == current_workspace_id(),
SqlSessionPermission.user_id == accessible_by,
)
).scalars()
)
if owned_by is not None:
owned_set = set(
meta_sess.execute(
select(SqlSessionPermission.conversation_id).where(
SqlSessionPermission.workspace_id == current_workspace_id(),
SqlSessionPermission.user_id == owned_by,
SqlSessionPermission.level >= LEVEL_OWNER,
)
).scalars()
)
if accessible_set is not None and owned_set is not None:
qualifying_ids = list(accessible_set & owned_set)
else:
qualifying_ids = list(
accessible_set if accessible_set is not None else owned_set or set()
)
with self._conv_session("list_conversations") as session:
stmt = select(SqlConversation).where(
SqlConversation.workspace_id == current_workspace_id()
)
if qualifying_ids is not None:
stmt = stmt.where(SqlConversation.id.in_(qualifying_ids))
# Kind filter as parent-nullness (see above): sub_agent ⇔ parent set.
if kind_requires_parent is True:
stmt = stmt.where(SqlConversation.parent_conversation_id.is_not(None))
elif kind_requires_parent is False:
stmt = stmt.where(SqlConversation.parent_conversation_id.is_(None))
# archived lives on the AP conversations table, so exclude it inline
# (no metadata prefetch, no post-fetch filtering).
if not include_archived:
stmt = stmt.where(SqlConversation.archived.is_(False))
if parent_conversation_id is not None:
stmt = stmt.where(
SqlConversation.parent_conversation_id == parent_conversation_id,
)
if root_conversation_id is not None:
stmt = stmt.where(
SqlConversation.root_conversation_id == root_conversation_id,
)
if has_agent_id is True:
stmt = stmt.where(SqlConversation.agent_id.is_not(None))
if agent_name is not None:
# Agents live in the Omnigent DB — resolve to IDs first, then
# filter on the conversations.agent_id column directly.
with self._session("list_conversations") as agent_sess:
agent_ids_for_name = list(
agent_sess.execute(
select(SqlAgent.id).where(
SqlAgent.workspace_id == current_workspace_id(),
SqlAgent.name == agent_name,
)
)
.scalars()
.all()
)
stmt = stmt.where(SqlConversation.agent_id.in_(agent_ids_for_name))
if agent_id is not None:
# Conversations without an agent binding (legacy rows) correctly
# return no results: their agent_id column is NULL.
stmt = stmt.where(SqlConversation.agent_id == agent_id)
if title is not None:
stmt = stmt.where(SqlConversation.title == title)
if search_query:
pattern = f"%{search_query.lower()}%"
title_match = func.lower(SqlConversation.title).like(pattern)
content_match = SqlConversation.id.in_(
select(SqlConversationItem.conversation_id)
.where(
SqlConversationItem.workspace_id == current_workspace_id(),
func.lower(SqlConversationItem.search_text).like(pattern),
)
.distinct()
)
stmt = stmt.where(or_(title_match, content_match))
if project is not None:
# Dual-read by project NAME: a session is "in <name>" if it has
# EITHER the first-class membership (metadata.project_id → the
# owner's project of that name) OR the legacy ``omni_project``
# label. The label is colocated on the AP DB (inline subquery);
# projects + metadata are on the Omnigent DB, so member ids are
# resolved there first, then combined with the label subquery.
label_filed = select(SqlConversationLabel.conversation_id).where(
SqlConversationLabel.workspace_id == current_workspace_id(),
SqlConversationLabel.key == PROJECT_LABEL_KEY,
)
if project == "":
# Unfiled: no first-class membership AND no label.
first_class_stmt = select(SqlConversationMetadata.id).where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.project_id.is_not(None),
)
if self._conv_engine is self._engine:
# Single-DB: metadata is colocated with conversations, so
# push the exclusion down as a NOT IN subquery — no need to
# pull every filed id into Python.
first_class_filed: Any = first_class_stmt
else:
# Split-DB: metadata lives elsewhere, so prefetch the ids.
# Bound to qualifying_ids (when permission-scoped) so the
# NOT IN list stays capped to the caller's own sessions.
if qualifying_ids is not None:
first_class_stmt = first_class_stmt.where(
SqlConversationMetadata.id.in_(qualifying_ids)
)
with self._session("list_conversations") as meta_sess:
first_class_filed = list(meta_sess.execute(first_class_stmt).scalars())
stmt = stmt.where(
SqlConversation.id.not_in(first_class_filed),
SqlConversation.id.not_in(label_filed),
)
else:
# Resolve the owner's project of this name → its member ids
# (one join). No such project yields an empty match, so the
# filter collapses to the label match alone (v1 behaviour).
member_stmt = (
select(SqlConversationMetadata.id)
.join(
SqlProject,
SqlConversationMetadata.project_id == SqlProject.id,
)
.where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlProject.workspace_id == current_workspace_id(),
SqlProject.user_id == owned_by,
SqlProject.name == project,
)
)
if self._conv_engine is self._engine:
# Single-DB: metadata + projects are colocated with
# conversations, so use the SELECT as an IN subquery — no
# need to pull member ids into Python.
member_match: Any = member_stmt
else:
# Split-DB: resolve member ids first, bounded to
# qualifying_ids (when permission-scoped) so the IN list
# stays capped to the caller's own sessions.
if qualifying_ids is not None:
member_stmt = member_stmt.where(
SqlConversationMetadata.id.in_(qualifying_ids)
)
with self._session("list_conversations") as meta_sess:
member_match = list(meta_sess.execute(member_stmt).scalars())
stmt = stmt.where(
or_(
SqlConversation.id.in_(member_match),
SqlConversation.id.in_(
label_filed.where(SqlConversationLabel.value == project)
),
)
)
if pinned:
# Restrict to sessions the caller has pinned. Pins are per-user,
# so match the caller's own key (``omnigent.pinned.<user>``), not
# a shared key — otherwise one user's pin would surface for every
# user with access. The row exists only while pinned (unpin
# deletes it), so key presence alone is the filter; the value is
# the pin timestamp, not a flag. Colocated on the AP DB, so an
# inline IN-subquery is enough (no cross-DB prefetch).
stmt = stmt.where(
SqlConversation.id.in_(
select(SqlConversationLabel.conversation_id).where(
SqlConversationLabel.workspace_id == current_workspace_id(),
SqlConversationLabel.key == pinned_label_key(pinned_owner),
)
)
)
if after:
stmt = self._apply_cursor(
stmt,
after,
sort_col,
is_desc,
tiebreaker_col=self._tiebreaker_col,
forward=True,
)
if before:
stmt = self._apply_cursor(
stmt,
before,
sort_col,
is_desc,
tiebreaker_col=self._tiebreaker_col,
forward=False,
)
stmt = stmt.order_by(
sort_fn(sort_col),
sort_fn(self._tiebreaker_col), # insertion-order tiebreaker for timestamp ties
).limit(limit + 1)
rows = list(session.execute(stmt).scalars().all())
has_more = len(rows) > limit
if has_more:
rows = rows[:limit]
row_ids = [r.id for r in rows]
# Fetch labels for all returned conversations in a single IN-clause
# query so the list-path is O(1) queries regardless of page size.
# The agent binding + overrides ride on each conversation row.
labels_by_conv = _fetch_labels_bulk(session, row_ids)
# On a content search, fetch a preview excerpt of the matching
# chat text so the UI can show *where* each session matched (the
# match is often invisible in the title). Title-only matches keep
# search_snippet=None — the title already shows the hit. Items
# are AP-side, so this must run inside the conv session.
snippets = (
_fetch_search_snippets(session, row_ids, search_query) if search_query else {}
)
# Build AP-only entities; metadata fetched separately below.
ap_entities = [(r, labels_by_conv.get(r.id, {})) for r in rows]
# Fetch metadata from Omnigent DB and merge.
meta_by_id: dict[str, SqlConversationMetadata] = {}
if row_ids:
with self._session("list_conversations") as meta_sess:
meta_rows = (
meta_sess.execute(
select(SqlConversationMetadata).where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id.in_(row_ids),
)
)
.scalars()
.all()
)
# Access .id inside the session to avoid DetachedInstanceError.
meta_by_id = {m.id: m for m in meta_rows}
convs = [
_to_conversation(r, meta_by_id.get(r.id), labels) for r, labels in ap_entities
]
else:
convs = []
for conv in convs:
conv.search_snippet = snippets.get(conv.id)
return PagedList(
data=convs,
first_id=convs[0].id if convs else None,
last_id=convs[-1].id if convs else None,
has_more=has_more,
)
@staticmethod
def _resolve_sort_column(sort_by: str) -> QueryableAttribute[int]:
"""
Map a ``sort_by`` string to the corresponding
:class:`SqlConversation` column.
:param sort_by: ``"created_at"`` or ``"updated_at"``.
:returns: The mapped column attribute.
:raises ValueError: If ``sort_by`` is not a valid column
name.
"""
allowed = {
"created_at": SqlConversation.created_at,
"updated_at": SqlConversation.updated_at,
}
col = allowed.get(sort_by)
if col is None:
raise ValueError(f"invalid sort_by: {sort_by!r}")
return col
@staticmethod
def _apply_cursor(
stmt: Select[tuple[SqlConversation]],
cursor_id: str,
sort_col: QueryableAttribute[int],
is_desc: bool,
tiebreaker_col: ColumnElement[Any],
forward: bool,
) -> Select[tuple[SqlConversation]]:
"""
Add a cursor-based WHERE clause to the query.
Add a ``(sort_col, tiebreaker_col)`` composite WHERE clause so
that cursor pagination is consistent with the ORDER BY key.
:param stmt: The current SELECT statement to augment.
:param cursor_id: The conversation ID acting as the page cursor,
e.g. ``"conv_abc123"``.
:param sort_col: Primary sort column (``created_at`` or ``updated_at``).
:param is_desc: ``True`` for descending, ``False`` for ascending.
:param tiebreaker_col: Secondary sort column; must match the
secondary ORDER BY column. See ``_tiebreaker_col`` in
``__init__`` for the SQLite/non-SQLite choice.
:param forward: ``True`` for ``after`` cursors, ``False`` for
``before`` cursors.
:returns: The statement with the cursor WHERE clause applied.
"""
sub = (
select(sort_col)
.where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.id == cursor_id,
)
.scalar_subquery()
)
# When tiebreaker_col is SqlConversation.id (non-SQLite), its value for
# the cursor row is cursor_id itself — no extra subquery needed.
# For SQLite rowid (a literal_column), we must query the DB.
if isinstance(tiebreaker_col, QueryableAttribute):
tiebreaker_val: Any = cursor_id
else:
tiebreaker_val = (
select(tiebreaker_col)
.where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.id == cursor_id,
)
.scalar_subquery()
)
# "after" (forward=True) = further in sort direction;
# "before" (forward=False) = opposite of sort direction.
if forward:
ts_cmp = sort_col < sub if is_desc else sort_col > sub
id_cmp = (
tiebreaker_col < tiebreaker_val if is_desc else tiebreaker_col > tiebreaker_val
)
else:
ts_cmp = sort_col > sub if is_desc else sort_col < sub
id_cmp = (
tiebreaker_col > tiebreaker_val if is_desc else tiebreaker_col < tiebreaker_val
)
return stmt.where(or_(ts_cmp, and_(sort_col == sub, id_cmp)))
def update_conversation(
self,
conversation_id: str,
title: str | None = None,
reasoning_effort: str | None = None,
_unset_reasoning_effort: bool = False,
model_override: str | None = None,
_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,
archived: bool | None = None,
) -> Conversation | None:
"""
Update mutable fields on a conversation.
:param conversation_id: Unique conversation identifier,
e.g. ``"conv_abc123"``.
:param title: New title, or ``None`` to leave unchanged.
:param reasoning_effort: Per-session reasoning effort,
e.g. ``"high"``. ``None`` leaves unchanged.
:param _unset_reasoning_effort: When ``True``, clear
``reasoning_effort`` to ``None``.
:param model_override: Per-session LLM model override,
e.g. ``"claude-opus-4-7"``. ``None`` leaves unchanged.
:param _unset_model_override: When ``True``, clear
``model_override`` to ``None``.
:param cost_control_mode_override: Per-session cost-control
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
``harness_override`` to ``None`` (used to replace the
``"auto"`` sentinel after first-message routing resolves).
:param terminal_launch_args: Per-session native-terminal
pass-through args, e.g.
``["--dangerously-skip-permissions"]``. ``None`` leaves
unchanged; a list (including ``[]``) replaces the stored
value wholesale (resume is last-write-wins, never an
append). JSON-encoded into the column.
:param archived: New archived state. ``True`` archives,
``False`` unarchives, ``None`` leaves unchanged.
:returns: The updated :class:`Conversation`, or ``None``
if the conversation does not exist.
"""
now = now_epoch()
# Two transactions: AP (the conversation row, which carries the agent
# binding + per-session override blob) and Omnigent (metadata).
with self._conv_session("update_conversation") as ap_sess:
row = ap_sess.get(SqlConversation, (current_workspace_id(), conversation_id))
if not row:
return None
ap_changed = False
if title is not None:
row.title = title or ""
ap_changed = True
# Read-modify-write the override blob so partial updates preserve the
# keys they don't touch. Only re-encode when something actually changed.
overrides = _decode_session_overrides(row.session_overrides)
overrides_changed = False
if _unset_reasoning_effort:
overrides["reasoning_effort"] = None
overrides_changed = True
elif reasoning_effort is not None:
overrides["reasoning_effort"] = reasoning_effort
overrides_changed = True
if _unset_model_override:
overrides["model_override"] = None
overrides_changed = True
elif model_override is not None:
overrides["model_override"] = model_override
overrides_changed = True
if _unset_cost_control_mode_override:
overrides["cost_control_mode_override"] = None
overrides_changed = True
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
elif harness_override is not None:
overrides["harness_override"] = harness_override
overrides_changed = True
if overrides_changed:
row.session_overrides = _encode_session_overrides(overrides)
ap_changed = True
if archived is not None:
# archived lives on the AP conversations row; a visible state change.
row.archived = archived
ap_changed = True
if ap_changed:
row.updated_at = now
labels = _fetch_labels(ap_sess, conversation_id)
if terminal_launch_args is not None:
with self._session("update_conversation") as meta_sess:
meta = meta_sess.get(
SqlConversationMetadata, (current_workspace_id(), conversation_id)
)
if meta is None:
# Orphaned conversation (a crash between the AP and
# metadata transactions during creation left no metadata
# row). Recreate it rather than silently dropping the
# update; kind derives from the parent pointer, same as
# at creation.
_logger.warning(
"conversation %s has no metadata row; recreating it",
conversation_id,
)
meta = _new_session_metadata_row(
conversation_id,
parent_conversation_id=row.parent_conversation_id,
)
meta_sess.add(meta)
meta.terminal_launch_args = json.dumps(terminal_launch_args)
else:
meta = self._get_meta(ap_sess, conversation_id)
return _to_conversation(row, meta, labels)
def rename_conversation_if_title_matches(
self,
conversation_id: str,
expected_title: str,
title: str,
) -> Conversation | None:
"""Rename a conversation with an atomic title compare-and-swap."""
with self._conv_session("rename_conversation_if_title_matches") as session:
result = cast(
_RowCountResult,
session.execute(
update(SqlConversation)
.where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.id == conversation_id,
SqlConversation.title == expected_title,
)
.values(
title=title,
updated_at=now_epoch(),
)
),
)
if result.rowcount != 1:
return None
# Bulk UPDATE leaves no in-session ORM row to reuse; re-read.
return self.get_conversation(conversation_id)
def set_runner_id(self, conversation_id: str, runner_id: str) -> bool:
"""
Pin a conversation to a runner via atomic
``UPDATE ... WHERE runner_id IS NULL``.
See :meth:`ConversationStore.set_runner_id` for the
contract. Implementation: a single ``UPDATE`` statement
whose ``WHERE`` clause matches both the conversation id
and ``runner_id IS NULL``. Concurrent first-dispatches
racing to pin the same conversation are serialized by
the database — exactly one wins, the other's UPDATE
affects zero rows and returns ``False``. The caller can
then re-read the row to discover the winning runner.
:param conversation_id: Conversation to pin.
:param runner_id: Runner UUID to pin to.
:returns: ``True`` if this call won the race and
transitioned the row from NULL → ``runner_id``;
``False`` if the row was already pinned or doesn't
exist.
"""
from sqlalchemy import update
with self._session("set_runner_id") as session:
stmt = (
update(SqlConversationMetadata)
.where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id == conversation_id,
)
.where(SqlConversationMetadata.runner_id.is_(None))
.values(runner_id=runner_id)
)
result = cast(_RowCountResult, session.execute(stmt))
return result.rowcount == 1
def touch_runner_liveness(self, runner_ids: list[str], now: int) -> None:
"""
Stamp ``runner_last_seen`` for sessions bound to live runners.
One bulk ``UPDATE`` on ``omnigent_conversation_metadata``, so
``conversations.updated_at`` (sidebar ordering) is untouched by
construction. See the abstract method.
:param runner_ids: Runner ids with a live tunnel. Empty = no-op.
:param now: Epoch seconds to stamp.
"""
if not runner_ids:
return
from sqlalchemy import update
with self._session("touch_runner_liveness") as session:
session.execute(
update(SqlConversationMetadata)
.where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.runner_id.in_(runner_ids),
)
.values(runner_last_seen=now)
)
def clear_runner_liveness(self, runner_id: str) -> None:
"""
Clear ``runner_last_seen`` for sessions bound to a runner.
Lives on ``omnigent_conversation_metadata``, so ``conversations.updated_at``
(sidebar ordering) is untouched by construction. See the abstract method.
:param runner_id: The disconnected runner's id.
"""
from sqlalchemy import update
with self._session("clear_runner_liveness") as session:
session.execute(
update(SqlConversationMetadata)
.where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.runner_id == runner_id,
)
.values(runner_last_seen=None)
)
def set_session_live_status(self, conversation_id: str, status: str) -> None:
"""
Persist the relay-observed turn status for one session.
Lives on ``omnigent_conversation_metadata``, so ``conversations.updated_at``
(sidebar ordering) is untouched by construction. See the abstract method.
:param conversation_id: Session/conversation identifier.
:param status: One of ``enum_codecs.SESSION_LIVE_STATUS``.
"""
from sqlalchemy import update
with self._session("set_session_live_status") as session:
session.execute(
update(SqlConversationMetadata)
.where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id == conversation_id,
)
.values(live_status=encode_session_live_status(status))
)
def set_pending_elicitation_count(self, conversation_id: str, count: int) -> None:
"""
Persist the outstanding elicitation count for one session.
Lives on ``omnigent_conversation_metadata``, so ``conversations.updated_at``
(sidebar ordering) is untouched by construction. See the abstract method.
:param conversation_id: Session/conversation identifier.
:param count: Outstanding elicitations, ``>= 0``.
"""
from sqlalchemy import update
with self._session("set_pending_elicitation_count") as session:
session.execute(
update(SqlConversationMetadata)
.where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id == conversation_id,
)
.values(pending_elicitation_count=count)
)
def replace_runner_id(self, conversation_id: str, runner_id: str) -> Conversation:
"""
Atomically overwrite ``conversations.runner_id``.
Public ``PATCH /v1/sessions/{id}`` callers validate
session-scoped agent ownership in the route before calling
this method. Internal sub-agent code may also use this to
rebind child conversations to their parent's current runner.
:param conversation_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param runner_id: New runner id, e.g. ``"runner_abc123"``.
:returns: The updated :class:`Conversation`.
:raises ConversationNotFoundError: If no conversation row
exists for ``conversation_id``.
"""
with self._session("replace_runner_id") as session:
meta = session.get(SqlConversationMetadata, (current_workspace_id(), conversation_id))
if meta is None:
raise ConversationNotFoundError(
f"conversation {conversation_id!r} does not exist",
)
meta.runner_id = runner_id
with self._conv_session("replace_runner_id") as ap_sess:
ap_row = ap_sess.get(SqlConversation, (current_workspace_id(), conversation_id))
if ap_row is None:
raise ConversationNotFoundError(
f"conversation {conversation_id!r} does not exist",
)
ap_row.updated_at = now_epoch()
labels = _fetch_labels(ap_sess, conversation_id)
return _to_conversation(ap_row, meta, labels)
def clear_runner_id(self, conversation_id: str) -> Conversation:
"""
Null out ``conversations.runner_id``. Atomic last-write-wins.
:param conversation_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:returns: The updated :class:`Conversation`.
:raises ConversationNotFoundError: If no conversation row
exists for ``conversation_id``.
"""
with self._session("clear_runner_id") as session:
meta = session.get(SqlConversationMetadata, (current_workspace_id(), conversation_id))
if meta is None:
raise ConversationNotFoundError(
f"conversation {conversation_id!r} does not exist",
)
meta.runner_id = None
with self._conv_session("clear_runner_id") as ap_sess:
ap_row = ap_sess.get(SqlConversation, (current_workspace_id(), conversation_id))
if ap_row is None:
raise ConversationNotFoundError(
f"conversation {conversation_id!r} does not exist",
)
ap_row.updated_at = now_epoch()
labels = _fetch_labels(ap_sess, conversation_id)
return _to_conversation(ap_row, meta, labels)
def clear_host_binding(self, conversation_id: str) -> Conversation:
"""
NULL ``host_id``/``workspace``/``git_branch``/``runner_id`` together.
Single-transaction full unbind — see
:meth:`ConversationStore.clear_host_binding`. ``host_id`` and
``workspace`` are cleared together so the row never violates
``ck_conversations_workspace_required_for_host`` mid-update.
:param conversation_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:returns: The updated :class:`Conversation`.
:raises ConversationNotFoundError: If no conversation row
exists for ``conversation_id``.
"""
with self._session("clear_host_binding") as session:
meta = session.get(SqlConversationMetadata, (current_workspace_id(), conversation_id))
if meta is None:
raise ConversationNotFoundError(
f"conversation {conversation_id!r} does not exist",
)
meta.host_id = None
meta.workspace = None
meta.git_branch = None
meta.runner_id = None
with self._conv_session("clear_host_binding") as ap_sess:
ap_row = ap_sess.get(SqlConversation, (current_workspace_id(), conversation_id))
if ap_row is None:
raise ConversationNotFoundError(
f"conversation {conversation_id!r} does not exist",
)
ap_row.updated_at = now_epoch()
labels = _fetch_labels(ap_sess, conversation_id)
return _to_conversation(ap_row, meta, labels)
def list_conversations_by_runner_id(
self,
runner_id: str,
) -> list[Conversation]:
"""
Return all conversations bound to the given ``runner_id``.
:param runner_id: Runner identifier, e.g.
``"runner_token_a1b2c3d4..."``.
:returns: List of :class:`Conversation` entities.
"""
with self._session("list_conversations_by_runner_id") as session:
meta_rows = (
session.execute(
select(SqlConversationMetadata).where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.runner_id == runner_id,
)
)
.scalars()
.all()
)
if not meta_rows:
return []
conv_ids = [m.id for m in meta_rows]
meta_by_id = {m.id: m for m in meta_rows}
with self._conv_session("list_conversations_by_runner_id") as ap_sess:
ap_rows = (
ap_sess.execute(
select(SqlConversation).where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.id.in_(conv_ids),
)
)
.scalars()
.all()
)
# Hydrate labels (one batched query, no N+1): the runner
# session-init envelope is built from ``conversation.labels``, and
# the reconnect path (``_on_runner_connect``) sources its
# conversations here. Without this the envelope ships empty labels,
# so fork directives (carry-history / source transcript) never reach
# the runner and a forked native session launches without history.
labels_by_conv = _fetch_labels_bulk(ap_sess, conv_ids)
return [
_to_conversation(r, meta_by_id.get(r.id), labels_by_conv.get(r.id, {}))
for r in ap_rows
]
def set_host_id(
self,
conversation_id: str,
host_id: str,
workspace: str | None = None,
git_branch: str | None = None,
) -> Conversation:
"""
Set the host that launched (or should launch) the runner.
Last-write-wins — mirrors :meth:`replace_runner_id`.
``workspace`` is updated together with ``host_id`` when
provided so the row never violates
``ck_conversations_workspace_required_for_host`` mid-update.
Callers that already populated ``workspace`` at session
create can pass ``None`` to leave it untouched.
:param conversation_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param host_id: Host identifier, e.g.
``"host_a1b2c3d4..."``.
:param workspace: Optional canonical absolute workspace
path to set alongside ``host_id``, e.g.
``"/Users/corey/projects/myapp"``. ``None`` (default)
leaves the existing workspace value untouched —
useful when the workspace was set at session create.
:param git_branch: Optional git branch checked out in a
server-created worktree, e.g. ``"feature/login"``. Set
together with ``host_id``/``workspace`` when binding an
existing session to a freshly created worktree (the fork
resume path). ``None`` (default) leaves it untouched.
:returns: The updated :class:`Conversation`.
:raises ConversationNotFoundError: If no conversation row
exists for ``conversation_id``.
:raises IntegrityError: If the resulting row violates
``ck_conversations_workspace_required_for_host`` (i.e.
``host_id`` is being set on a row with no ``workspace``
and the caller did not supply one).
"""
with self._session("set_host_id") as session:
meta = session.get(SqlConversationMetadata, (current_workspace_id(), conversation_id))
if meta is None:
raise ConversationNotFoundError(
f"conversation {conversation_id!r} does not exist",
)
meta.host_id = host_id
if workspace is not None:
meta.workspace = workspace
if git_branch is not None:
meta.git_branch = git_branch
with self._conv_session("set_host_id") as ap_sess:
ap_row = ap_sess.get(SqlConversation, (current_workspace_id(), conversation_id))
if ap_row is None:
raise ConversationNotFoundError(
f"conversation {conversation_id!r} does not exist",
)
ap_row.updated_at = now_epoch()
labels = _fetch_labels(ap_sess, conversation_id)
return _to_conversation(ap_row, meta, labels)
def set_external_session_id(
self,
conversation_id: str,
value: str,
) -> Conversation:
"""
Persist the runtime-native session id this conversation wraps.
Idempotent on same-value writes; raises ``ValueError`` on
attempted overwrite of an existing different value. See
:meth:`ConversationStore.set_external_session_id` for the
full contract.
:param conversation_id: Conversation to update, e.g.
``"conv_abc123"``.
:param value: Runtime-native session id, e.g.
``"a1b2c3d4-..."``.
:returns: The updated :class:`Conversation`.
:raises ConversationNotFoundError: If no conversation row
exists for ``conversation_id``.
:raises ValueError: If the row already has a different
``external_session_id``.
"""
with self._session("set_external_session_id") as session:
meta = session.get(SqlConversationMetadata, (current_workspace_id(), conversation_id))
if meta is None:
raise ConversationNotFoundError(
f"conversation {conversation_id!r} does not exist",
)
existing = meta.external_session_id
if existing is not None and existing != value:
raise ValueError(
f"conversation {conversation_id!r} already has "
f"external_session_id={existing!r}; refusing to "
f"overwrite with {value!r}",
)
changed = existing != value
if changed:
meta.external_session_id = value
with self._conv_session("set_external_session_id") as ap_sess:
ap_row = ap_sess.get(SqlConversation, (current_workspace_id(), conversation_id))
if ap_row is None:
raise ConversationNotFoundError(
f"conversation {conversation_id!r} does not exist",
)
if changed:
ap_row.updated_at = now_epoch()
labels = _fetch_labels(ap_sess, conversation_id)
return _to_conversation(ap_row, meta, labels)
def create_session_with_agent(
self,
*,
agent_id: str,
agent_name: str,
agent_bundle_location: str,
agent_description: str | None,
title: str | None = None,
labels: dict[str, str] | None = None,
reasoning_effort: str | None = None,
workspace: str | None = None,
terminal_launch_args: list[str] | None = None,
parent_conversation_id: str | None = None,
runner_id: str | None = None,
) -> CreatedSession:
"""
Atomically insert a conversation row and session-scoped agent.
The two rows share one managed SQLAlchemy session, so the
context manager commits them together on success and rolls
both back on any exception. The insert order creates the
conversation with ``agent_id=NULL``, creates the agent with
``session_id`` pointing at that conversation, then backfills
``conversations.agent_id``.
:param agent_id: Pre-generated agent id, e.g.
``"ag_abc123"``.
:param agent_name: Human-readable agent name from the
uploaded spec, e.g. ``"code-assistant"``.
:param agent_bundle_location: Artifact-store key for the
uploaded bundle, e.g. ``"ag_abc123/a1b2c3d4"``.
:param agent_description: Optional spec description.
``None`` when the spec omits it.
:param title: Optional session title, e.g.
``"debugging auth flow"``.
:param labels: Optional initial guardrails labels,
e.g. ``{"env": "test"}``. ``None`` writes no labels.
:param reasoning_effort: Optional per-session
reasoning-effort hint, e.g. ``"high"``. ``None``
means use the agent default.
:param workspace: Optional starting cwd to record on the
session for display, e.g.
``"/Users/corey/projects/myapp"``. CLI-launched
sessions populate this with ``os.getcwd()``;
multipart bundle uploads from the Web UI may pass
``None``. ``None`` is allowed because this path
doesn't set ``host_id`` (so the
``ck_conversations_workspace_required_for_host``
constraint isn't active).
:param terminal_launch_args: Optional pass-through CLI args
for a native terminal wrapper (claude / codex), e.g.
``["--dangerously-skip-permissions"]``. ``None`` leaves
the column NULL.
:param parent_conversation_id: Optional parent conversation
id, e.g. ``"conv_parent1"``. When set, the new session
is a sub-agent child of that conversation
(``kind="sub_agent"``) and inherits its spawn-tree root.
``None`` creates a top-level session.
:param runner_id: Optional runner binding to persist at
creation time, e.g. ``"runner_abc123"``. Child sessions
inherit the parent's binding through this field so
runner dispatch remains explicit in store state.
:returns: A :class:`CreatedSession` with both entities.
:raises ConversationNotFoundError: If
``parent_conversation_id`` is set but no such
conversation exists.
"""
return self._create_session_with_agent_with_id(
generate_conversation_id(),
agent_id=agent_id,
agent_name=agent_name,
agent_bundle_location=agent_bundle_location,
agent_description=agent_description,
title=title,
labels=labels,
reasoning_effort=reasoning_effort,
workspace=workspace,
terminal_launch_args=terminal_launch_args,
parent_conversation_id=parent_conversation_id,
runner_id=runner_id,
)
def _create_session_with_agent_with_id(
self,
conversation_id: str,
*,
agent_id: str,
agent_name: str,
agent_bundle_location: str,
agent_description: str | None,
title: str | None = None,
labels: dict[str, str] | None = None,
reasoning_effort: str | None = None,
workspace: str | None = None,
terminal_launch_args: list[str] | None = None,
parent_conversation_id: str | None = None,
runner_id: str | None = None,
) -> CreatedSession:
"""Body of :meth:`create_session_with_agent` under a caller-supplied
``conversation_id``. The public method generates a fresh id; this seam
lets a subclass inject one (MAS's WHS-homed store injects the WHS node id)."""
from omnigent.stores.conversation_store import ConversationNotFoundError
now = now_epoch()
# Conversation + labels go to AP; agent + metadata go to Omnigent.
# Get parent root_id from AP first.
root_conversation_id: str | None = None
if parent_conversation_id is not None:
with self._conv_session("create_session_with_agent") as ap_sess:
parent_row = ap_sess.get(
SqlConversation, (current_workspace_id(), parent_conversation_id)
)
if parent_row is None:
raise ConversationNotFoundError(
f"parent conversation {parent_conversation_id!r} does not exist"
)
root_conversation_id = parent_row.root_conversation_id
conversation_row = _new_session_conversation_row(
conversation_id,
now,
title,
parent_conversation_id=parent_conversation_id,
root_conversation_id=root_conversation_id,
agent_id=agent_id,
session_overrides=_encode_session_overrides({"reasoning_effort": reasoning_effort}),
)
with self._conv_session("create_session_with_agent") as ap_sess:
ap_sess.add(conversation_row)
if labels:
_upsert_labels(ap_sess, conversation_id, labels, now)
agent_row = _new_session_agent_row(
agent_id=agent_id,
agent_name=agent_name,
agent_bundle_location=agent_bundle_location,
agent_description=agent_description,
now=now,
)
meta_row = _new_session_metadata_row(
conversation_id,
parent_conversation_id=parent_conversation_id,
runner_id=runner_id,
workspace=workspace,
terminal_launch_args=terminal_launch_args,
)
with self._session("create_session_with_agent") as session:
session.add(agent_row)
session.add(meta_row)
session.flush()
return _created_session_from_rows(conversation_row, meta_row, agent_row, labels)
def fork_conversation(
self,
source_conversation_id: str,
*,
title: str | None = None,
agent_id: str | None = None,
cloned_agent_name: str | None = None,
cloned_agent_bundle_location: str | None = None,
cloned_agent_description: str | None = None,
copy_model_settings: bool = True,
copy_terminal_launch_args: bool = True,
carry_history_into_native: bool = False,
resume_source_native_session: bool = True,
presentation_labels: dict[str, str] | None = None,
up_to_response_id: str | None = None,
project_id: str | None = None,
) -> Conversation:
"""
Deep-copy a conversation and its items into a new conversation.
Reads the source conversation and all its items in one
transaction, creates a new top-level ``SqlConversation``
(``kind="default"``, ``parent_conversation_id=None``)
with the source's ``reasoning_effort``,
``terminal_launch_args``, and (unless overridden)
``agent_id``, copies each item with a fresh ID and position,
and inserts FTS records for each copied item. Identity-bound
columns (``external_session_id``, ``workspace``,
``git_branch``) are deliberately NOT copied — a fork is a
fresh session that re-binds those on its own launch. Source
labels are copied EXCEPT instance-scoped ones
(:data:`_INSTANCE_SCOPED_LABEL_KEYS` — native bridge ids,
context metrics), which belong to the source's running instance
and would mis-route or mis-display on the clone.
When the source had a ``workspace``, the fork is additionally
stamped with ``FORK_SOURCE_LABEL_KEY`` (value = source id) so the
unbound clone reports offline until it rebinds a directory (see
:class:`SessionConnectivity`).
:param source_conversation_id: ID of the conversation to
fork, e.g. ``"conv_abc123"``.
:param title: Title for the new conversation. When
``None``, defaults to ``"Fork of <source_title>"``
(or ``"Fork of <source_id>"`` when the source has no
title).
:param agent_id: Agent ID to bind the fork to. When ``None``,
the fork inherits the source's ``agent_id``. With
``cloned_agent_bundle_location`` set, a fresh agent row is
created with this id; otherwise it must name an existing
agent, whose ``session_id`` is repointed at the fork.
:param cloned_agent_name: Name for the cloned agent row.
Required when ``cloned_agent_bundle_location`` is set.
:param cloned_agent_bundle_location: When set, clone this
bundle into a new session-scoped agent row (id
``agent_id``) created atomically in this transaction, so a
fork failure rolls it back instead of orphaning a
``session_id IS NULL`` built-in. ``None`` keeps the legacy
bind-existing behavior.
:param cloned_agent_description: Optional description for the
cloned agent row. Ignored unless
``cloned_agent_bundle_location`` is set.
:param copy_model_settings: When ``True`` (default), copy the
source's ``model_override`` and ``reasoning_effort``. When
``False``, both are left ``None`` so the fork falls back to
the bound agent's defaults — used when the fork switches to
an agent in a different provider family, where the source's
model id is meaningless (a model is provider-bound).
:param carry_history_into_native: When ``True``, stamp
:data:`FORK_CARRY_HISTORY_LABEL_KEY` on the fork so a native
target harness rebuilds its transcript instead of starting
fresh. Set by the route only for native targets whose harness can
replay fork history.
:param resume_source_native_session: When ``True`` (default), a
full fork of a source with a native session stamps
:data:`FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY` so the runner
clones the source's local native transcript. ``False`` on a
cross-family agent switch: the source's native transcript is
the wrong format for the target harness, so the directive is
skipped and the runner builds the native transcript from the
copied Omnigent items instead.
:param presentation_labels: When not ``None``, drop the source's
``omnigent.ui`` / ``omnigent.wrapper`` labels from the clone
and apply these instead, so the clone's Web UI mode matches the
switched-to TARGET harness (native → ``{ui: terminal, wrapper:
...}``; SDK → ``{}``). ``None`` keeps the copied labels (same-
agent fork).
:param up_to_response_id: When set, copy only the items up to and
including the last item of this response (by position), e.g.
``"resp_abc123"`` — a "fork from this response" truncation.
A truncated fork skips the
:data:`FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY` directive so a
native target rebuilds its transcript from the truncated
items (the carry-history fork-rebuild path) instead of
resuming the source's full native transcript; when the
response is the source's last one the copy is equivalent to a
full fork, so the directive is kept. ``None`` (default)
copies the full history.
:param project_id: First-class project to file the fork into
(``metadata.project_id``), or ``None`` (default) to leave it
unfiled. The caller resolves whether the fork keeps the
source's project — projects are owner-private, so the route
passes the source's id only when the forker owns it.
:returns: The newly created :class:`Conversation`.
:raises LookupError: If no conversation with
*source_conversation_id* exists.
:raises ValueError: If *up_to_response_id* is set but no item in
the source conversation has that ``response_id``.
"""
return self._fork_conversation_with_id(
generate_conversation_id(),
source_conversation_id,
title=title,
agent_id=agent_id,
cloned_agent_name=cloned_agent_name,
cloned_agent_bundle_location=cloned_agent_bundle_location,
cloned_agent_description=cloned_agent_description,
copy_model_settings=copy_model_settings,
copy_terminal_launch_args=copy_terminal_launch_args,
carry_history_into_native=carry_history_into_native,
resume_source_native_session=resume_source_native_session,
presentation_labels=presentation_labels,
up_to_response_id=up_to_response_id,
project_id=project_id,
)
def _fork_conversation_with_id(
self,
conversation_id: str,
source_conversation_id: str,
*,
title: str | None = None,
agent_id: str | None = None,
cloned_agent_name: str | None = None,
cloned_agent_bundle_location: str | None = None,
cloned_agent_description: str | None = None,
copy_model_settings: bool = True,
copy_terminal_launch_args: bool = True,
carry_history_into_native: bool = False,
resume_source_native_session: bool = True,
presentation_labels: dict[str, str] | None = None,
up_to_response_id: str | None = None,
project_id: str | None = None,
) -> Conversation:
"""Body of :meth:`fork_conversation` under a caller-supplied
``conversation_id``. The public method generates a fresh id; this seam
lets a subclass inject one (MAS's WHS-homed store injects the WHS node id
so a forked session keeps a single identity across storage backends)."""
now = now_epoch()
new_conv_id = conversation_id
# Fetch source metadata (workspace, external_session_id, terminal_launch_args)
# from the Omnigent DB before opening the AP session.
with self._session("fork_conversation") as meta_sess:
source_meta_ref: SqlConversationMetadata | None = meta_sess.get(
SqlConversationMetadata, (current_workspace_id(), source_conversation_id)
)
with self._conv_session("fork_conversation") as session:
source = session.get(SqlConversation, (current_workspace_id(), source_conversation_id))
if source is None:
raise LookupError(f"conversation not found: {source_conversation_id!r}")
source_overrides = _decode_session_overrides(source.session_overrides)
fork_title = (
title
if title is not None
else (
f"Fork of {source.title}"
if source.title
else f"Fork of {source_conversation_id[:16]}…"
)
)
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.
# The routing switches (cost_control_mode_override,
# subagent_routing_override) are intentionally never carried onto a fork.
fork_overrides = _encode_session_overrides(
{
"reasoning_effort": (
source_overrides["reasoning_effort"] if copy_model_settings else None
),
"model_override": (
source_overrides["model_override"] if copy_model_settings else None
),
"harness_override": (
source_overrides["harness_override"] if copy_model_settings else None
),
}
)
new_conv = SqlConversation(
id=new_conv_id,
created_at=now,
updated_at=now,
title=fork_title or "", # None → empty string at DB layer
# A fork is a fresh top-level conversation, so its
# root mirrors its own id (matches the
# ``_new_session_conversation_row`` invariant).
root_conversation_id=new_conv_id,
# An explicit agent_id (clone or existing) beats inheriting the
# source's binding.
agent_id=(agent_id if agent_id is not None else source.agent_id),
session_overrides=fork_overrides,
)
session.add(new_conv)
# Resolve the truncation cutoff: the position of the LAST item
# of the selected response, so the fork never ends mid-turn.
# When the selected response is also the conversation's last
# one, the "truncation" copies everything — treat it as a full
# fork (``truncated`` stays False) so the native fork-resume
# directive below is preserved and the runner can still clone
# the source's native transcript verbatim.
truncated = False
cutoff_position: int | None = None
if up_to_response_id is not None:
cutoff_position = session.execute(
select(func.max(SqlConversationItem.position)).where(
SqlConversationItem.workspace_id == current_workspace_id(),
SqlConversationItem.conversation_id == source_conversation_id,
SqlConversationItem.response_id == up_to_response_id,
)
).scalar_one()
if cutoff_position is None:
raise ValueError(
f"response not found in conversation "
f"{source_conversation_id!r}: {up_to_response_id!r}"
)
last_position = session.execute(
select(func.max(SqlConversationItem.position)).where(
SqlConversationItem.workspace_id == current_workspace_id(),
SqlConversationItem.conversation_id == source_conversation_id,
)
).scalar_one()
truncated = cutoff_position < last_position
# Copy items ordered by position so the fork preserves
# the original chronological order.
items_query = (
select(SqlConversationItem)
.where(
SqlConversationItem.workspace_id == current_workspace_id(),
SqlConversationItem.conversation_id == source_conversation_id,
)
.order_by(SqlConversationItem.position.asc())
)
if cutoff_position is not None:
items_query = items_query.where(SqlConversationItem.position <= cutoff_position)
source_items = session.execute(items_query).scalars().all()
fts_rows: list[tuple[str, str, str]] = []
for pos, src_item in enumerate(source_items):
# src_item.type/status are int codes copied verbatim to the new
# row; only generate_item_id needs the decoded string type.
new_item_id = generate_item_id(decode_item_type(src_item.type))
new_item = SqlConversationItem(
id=new_item_id,
conversation_id=new_conv.id,
response_id=src_item.response_id,
created_at=now,
status=src_item.status,
position=pos,
type=src_item.type,
data=src_item.data,
search_text=src_item.search_text,
created_by=src_item.created_by,
)
session.add(new_item)
fts_rows.append((new_item_id, new_conv.id, src_item.search_text or ""))
insert_fts_bulk(session, fts_rows)
# The clone copied len(source_items) items at dense positions
# 0..N-1, so its position allocator starts at N. Seed it from the
# snapshot (not the source row's counter) so the fork is correct
# even when the source predates the counter.
new_conv.next_position = len(source_items)
# Cloned agent: the row itself is written to the Omnigent DB after
# the AP session commits (see the block below the with-statement);
# the fork's binding already lives on new_conv.agent_id.
if creating_clone:
assert (
agent_id is not None
and cloned_agent_name is not None
and cloned_agent_bundle_location is not None
)
# Copy labels from the source conversation, minus the
# instance-scoped ones (native bridge ids, context metrics)
# — those belong to the source's running instance and would
# mis-route or mis-display on the clone
# (see _INSTANCE_SCOPED_LABEL_KEYS). When the source had a
# working directory, also stamp the fork-source label: the
# clone is unbound (workspace/host not copied) and must rebind
# a directory before it can run, so the online-dot reports it
# offline and the UI opens the directory picker on the first
# message instead of dropping it. Forks of chat-only sources
# (no workspace) get no such label and resume in-process like
# a brand-new chat session.
# Per-user pin keys (``omnigent.pinned.<user>``) are dynamic-suffix,
# so they're never in the exact-match drop sets — drop them by prefix
# instead. A fork is a NEW conversation; inheriting the source's pins
# would show the clone as pinned for the forker AND carry every other
# user's pin key along as dead data.
fork_labels = {
key: value
for key, value in _fetch_labels(session, source_conversation_id).items()
if key not in (_INSTANCE_SCOPED_LABEL_KEYS | _FORK_ONLY_DROPPED_LABEL_KEYS)
and not key.startswith(f"{PINNED_LABEL_KEY}.")
}
source_workspace = source_meta_ref.workspace if source_meta_ref else None
source_ext_session = source_meta_ref.external_session_id if source_meta_ref else None
# ``terminal_launch_args`` are CLI-specific launch flags. A fork
# that switches CLI family (e.g. claude-code → pi) must NOT inherit
# them: the source's flags are meaningless or rejected by the new
# CLI — Claude Code's ``--permission-mode auto`` makes ``pi`` exit 1
# at launch (unknown option), which surfaces as
# ``required_terminal_exited``. Drop them on a switching fork.
source_terminal_args = (
source_meta_ref.terminal_launch_args
if source_meta_ref and copy_terminal_launch_args
else None
)
if source_workspace is not None:
fork_labels[FORK_SOURCE_LABEL_KEY] = source_conversation_id
# Carry the source's native session id as a one-shot fork
# directive so a native harness can resume + branch the source's
# local transcript into the clone (see
# FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY). external_session_id
# itself stays NULL — the clone isn't that session yet. A
# TRUNCATED fork must not resume the source's full transcript,
# and a CROSS-FAMILY fork can't (wrong transcript format —
# ``resume_source_native_session=False``); in both cases the
# directive is skipped so the runner's carry-history
# fork-rebuild path synthesizes the native transcript from the
# copied items instead.
if source_ext_session and not truncated and resume_source_native_session:
fork_labels[FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY] = source_ext_session
# When the fork binds a native target, mark it so the runner
# rebuilds the native transcript (clone the source's native
# transcript when same-family, else build from the copied
# Omnigent items) rather than launching fresh (see
# FORK_CARRY_HISTORY_LABEL_KEY).
if carry_history_into_native:
fork_labels[FORK_CARRY_HISTORY_LABEL_KEY] = "1"
# On an agent switch, the harness-presentation labels
# (omnigent.ui / omnigent.wrapper) must reflect the TARGET
# harness, not the source's: copying the source's would leave an
# SDK clone of a claude-native session wrongly in terminal-first
# mode (a stale interactive terminal + the source's transcript).
# Drop the source's and apply the route-computed target labels.
if presentation_labels is not None:
for _pkey in (UI_MODE_LABEL_KEY, WRAPPER_LABEL_KEY):
fork_labels.pop(_pkey, None)
fork_labels.update(presentation_labels)
if fork_labels:
_upsert_labels(session, new_conv.id, fork_labels, now)
# Build the fork's metadata row (default kind, no runner/host/workspace).
fork_meta = SqlConversationMetadata(
id=new_conv_id,
kind=encode_conversation_kind("default"),
# Copy terminal args from source so the fork launches with same native args.
terminal_launch_args=source_terminal_args,
# First-class project membership, resolved by the caller
# (None = unfiled).
project_id=project_id,
)
# Write fork metadata (and cloned agent if any) to the Omnigent DB.
with self._session("fork_conversation") as meta_sess:
meta_sess.add(fork_meta)
if creating_clone and agent_id is not None:
assert cloned_agent_name is not None and cloned_agent_bundle_location is not None
meta_sess.add(
_new_session_agent_row(
agent_id=agent_id,
agent_name=cloned_agent_name,
agent_bundle_location=cloned_agent_bundle_location,
agent_description=cloned_agent_description,
now=now,
)
)
return _to_conversation(new_conv, fork_meta, fork_labels)
def switch_conversation_agent(
self,
conversation_id: str,
*,
new_agent_id: str,
new_agent_name: str,
new_agent_bundle_location: str,
new_agent_description: str | None,
copy_model_settings: bool,
carry_history_into_native: bool,
presentation_labels: dict[str, str],
previous_builtin_id: str | None,
) -> Conversation:
"""
Rebind a session in place to a different (cloned) agent.
See :meth:`ConversationStore.switch_conversation_agent` for the
full contract. Mutates the same conversation row in one
transaction: deletes the current session-scoped agent, creates
the new one, repoints ``agent_id``, resets model settings on a
cross-family switch, clears ``external_session_id``, and
replaces the harness-presentation / carry-history labels.
:param conversation_id: Session to switch, e.g. ``"conv_abc123"``.
:param new_agent_id: Pre-generated id for the new agent row.
:param new_agent_name: Name for the new agent row.
:param new_agent_bundle_location: Artifact-store key to clone.
:param new_agent_description: Optional spec description.
:param copy_model_settings: Keep model settings when ``True``,
else reset to ``None`` (cross-family switch).
:param carry_history_into_native: Stamp / clear
:data:`FORK_CARRY_HISTORY_LABEL_KEY`.
:param presentation_labels: Target-harness ui/wrapper labels.
:param previous_builtin_id: Built-in switched away from, or
``None``.
:returns: The updated :class:`Conversation`.
:raises LookupError: If *conversation_id* does not exist.
"""
now = now_epoch()
drop_keys = (
set(_INSTANCE_SCOPED_LABEL_KEYS)
| {FORK_SOURCE_LABEL_KEY, FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY}
| {UI_MODE_LABEL_KEY, WRAPPER_LABEL_KEY}
# Always drop the previous-builtin pointer, then re-stamp below
# only when this switch supplies one — otherwise a stale pointer
# from an earlier switch survives and offers the wrong "switch
# back" target (the label is overwritten on each switch).
| {SWITCH_PREVIOUS_BUILTIN_LABEL_KEY}
)
if not carry_history_into_native:
drop_keys.add(FORK_CARRY_HISTORY_LABEL_KEY)
upserts: dict[str, str] = dict(presentation_labels)
if carry_history_into_native:
upserts[FORK_CARRY_HISTORY_LABEL_KEY] = "1"
if previous_builtin_id is not None:
upserts[SWITCH_PREVIOUS_BUILTIN_LABEL_KEY] = previous_builtin_id
# AP holds the conversation (agent binding + overrides) + labels;
# Omnigent holds agent+metadata. Read old_agent_id before overwriting it.
with self._conv_session("switch_conversation_agent") as ap_sess:
row = ap_sess.get(SqlConversation, (current_workspace_id(), conversation_id))
if row is None:
raise LookupError(f"conversation not found: {conversation_id!r}")
old_agent_id = row.agent_id
row.agent_id = new_agent_id
overrides = _decode_session_overrides(row.session_overrides)
if not copy_model_settings:
overrides["model_override"] = None
overrides["reasoning_effort"] = None
# The brain-harness override never survives a rebind.
overrides["harness_override"] = None
row.session_overrides = _encode_session_overrides(overrides)
row.updated_at = now
existing = _fetch_labels(ap_sess, conversation_id)
present_drop = [key for key in drop_keys if key in existing]
if present_drop:
ap_sess.execute(
delete(SqlConversationLabel).where(
SqlConversationLabel.workspace_id == current_workspace_id(),
SqlConversationLabel.conversation_id == conversation_id,
SqlConversationLabel.key.in_(present_drop),
)
)
if upserts:
_upsert_labels(ap_sess, conversation_id, upserts, now)
# Update agent + metadata on the Omnigent side.
with self._session("switch_conversation_agent") as session:
if old_agent_id is not None:
old_agent = session.get(SqlAgent, (current_workspace_id(), old_agent_id))
if old_agent is not None and old_agent.kind == encode_agent_kind("session"):
session.delete(old_agent)
session.flush()
session.add(
_new_session_agent_row(
agent_id=new_agent_id,
agent_name=new_agent_name,
agent_bundle_location=new_agent_bundle_location,
agent_description=new_agent_description,
now=now,
)
)
meta = session.get(SqlConversationMetadata, (current_workspace_id(), conversation_id))
if meta is not None:
meta.external_session_id = None
# Launch flags are CLI-specific: a switch to a different CLI
# (e.g. claude-code → pi) leaves the prior CLI's flags stale —
# Claude Code's ``--permission-mode`` makes pi exit 1 at launch.
# Clear them so the new CLI launches with its own defaults.
meta.terminal_launch_args = None
conv = self.get_conversation(conversation_id)
if conv is None:
raise LookupError(f"conversation not found: {conversation_id!r}")
return conv
async def delete_conversation(self, conversation_id: str) -> bool:
"""
Delete a conversation and all of its descendants, cleaning up
every related row explicitly (no DB-level CASCADE).
Collects the full subtree of conversation IDs (the target plus
all direct/indirect children), then deletes their items, labels,
comments, policies, and session-permission rows before deleting
the conversation rows themselves (children before parent).
:param conversation_id: Unique conversation identifier,
e.g. ``"conv_abc123"``.
:returns: ``True`` if the conversation existed,
``False`` otherwise.
"""
# AP rows are deleted first so the conversation is immediately unreachable;
# Omnigent-side rows (metadata/comments/policies/permissions) are cleaned up
# second. A failure of the second transaction leaves orphaned Omnigent rows
# for a conversation that no longer exists — an acceptable best-effort tradeoff.
with self._conv_session("delete_conversation") as ap_sess:
row = ap_sess.get(SqlConversation, (current_workspace_id(), conversation_id))
if not row:
return False
cte = (
select(SqlConversation.id)
.where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.id == conversation_id,
)
.cte(name="subtree", recursive=True)
)
cte = cte.union_all(
select(SqlConversation.id).where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.parent_conversation_id == cte.c.id,
)
)
subtree_ids = [r[0] for r in ap_sess.execute(select(cte.c.id)).fetchall()]
# Collect the subtree's agent bindings before their rows go, so
# the Omnigent transaction below can delete the session-scoped
# agent rows that backed these conversations. Only include agents
# with NO surviving reference outside the deleted subtree: a
# session-scoped agent may be referenced by multiple conversations
# (e.g. when POST /v1/sessions reuses an existing agent_id), and
# should only be removed when ALL its referrers are deleted.
candidate_agent_ids = set(
ap_sess.execute(
select(SqlConversation.agent_id).where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.id.in_(subtree_ids),
SqlConversation.agent_id.is_not(None),
)
)
.scalars()
.all()
)
# Keep only agents that have no remaining reference outside the
# subtree being deleted.
surviving_refs = set(
ap_sess.execute(
select(SqlConversation.agent_id).where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.agent_id.in_(candidate_agent_ids),
SqlConversation.id.not_in(subtree_ids),
)
)
.scalars()
.all()
)
bound_agent_ids = candidate_agent_ids - surviving_refs
delete_fts_by_conversation_ids(ap_sess, list(subtree_ids))
ap_sess.execute(
delete(SqlConversationItem).where(
SqlConversationItem.workspace_id == current_workspace_id(),
SqlConversationItem.conversation_id.in_(subtree_ids),
)
)
ap_sess.execute(
delete(SqlConversationLabel).where(
SqlConversationLabel.workspace_id == current_workspace_id(),
SqlConversationLabel.conversation_id.in_(subtree_ids),
)
)
ap_sess.execute(
delete(SqlConversation).where(
SqlConversation.workspace_id == current_workspace_id(),
SqlConversation.id.in_(subtree_ids),
SqlConversation.id != conversation_id,
)
)
ap_sess.delete(row)
with self._session("delete_conversation") as session:
session.execute(
delete(SqlComment).where(
SqlComment.workspace_id == current_workspace_id(),
SqlComment.conversation_id.in_(subtree_ids),
)
)
session.execute(
delete(SqlPolicy).where(
SqlPolicy.workspace_id == current_workspace_id(),
SqlPolicy.session_id.in_(subtree_ids),
)
)
session.execute(
delete(SqlSessionPermission).where(
SqlSessionPermission.workspace_id == current_workspace_id(),
SqlSessionPermission.conversation_id.in_(subtree_ids),
)
)
session.execute(
delete(SqlConversationMetadata).where(
SqlConversationMetadata.workspace_id == current_workspace_id(),
SqlConversationMetadata.id.in_(subtree_ids),
)
)
if bound_agent_ids:
# Session-scoped agents are 1:1 with their conversation
# (forks always clone a fresh agent), so every binding
# collected from the deleted subtree is dead. Template
# agents are shared and survive via the kind guard.
session.execute(
delete(SqlAgent).where(
SqlAgent.workspace_id == current_workspace_id(),
SqlAgent.id.in_(bound_agent_ids),
SqlAgent.kind == encode_agent_kind("session"),
)
)
return True