Skip to content

perf(policies): load the conversation and spawn tree once per engine build - #4320

Merged
TomeHirata merged 1 commit into
mainfrom
perf/policy-engine-load-once
Aug 7, 2026
Merged

perf(policies): load the conversation and spawn tree once per engine build#4320
TomeHirata merged 1 commit into
mainfrom
perf/policy-engine-load-once

Conversation

@TomeHirata

@TomeHirata TomeHirata commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Related issue

Closes #3003

Summary

Policy evaluation sits on the PreToolUse critical path — the hook blocks on the verdict before a tool call can proceed. `build_policy_engine` fetched the conversation about four times per call (root resolution, labels, session state, model override) and walked the spawn tree twice, because the session-wide gating seed and the per-node subtree seed each independently called `load_session_usage`, which does its own conversation read plus a full paged tree scan.

ELI5: instead of asking "who's the root, what are the labels, what's the state, what's the model, and what has everyone spent" as five separate trips to the database, the engine now makes one trip, reads everything off that one snapshot, and derives every other answer from it in memory.

Before: build_policy_engine()                 After: build_policy_engine()
  read conversation (root)                      read conversation
  read labels                                   walk spawn tree ONCE
  read session state                            -> labels, state, model,
  read model override                              usage seeds all derived
  walk spawn tree (session usage)                   from this one tree
  walk spawn tree AGAIN (subtree usage)
  = ~4 conversation reads + 2 tree walks         = 1 conversation read + 1 tree walk

SQL statement counts (measured, now pinned by automated oracles):

Shape Before After (no preload) After (preload)
Builder only (3-node tree) ~30 6 3
Authenticated evaluate route 11

The builder used to re-fetch the conversation ~4× and walk the tree twice; the route number (11) covers ACL resolution (3), handler conversation load (3), session-policy lookup, agent row, and spawn-tree scan (3). A caller that already holds the conversation row can pass it as a hint — the builder verifies identity against the fresh tree and skips its own read, dropping the builder cost from 6 → 3 statements.

One conversation read and one tree scan now feed everything. Both usage seeds derive from that same list through a pure aggregation, so they stay semantically distinct: cost gating remains tree-wide (a sub-agent gates against the whole session's spend), while the subtree total remains the per-node display figure.

Two real correctness fixes ship in the same change, because they touch the same tree load:

  1. A caller-supplied row is a hint, not a fact — and was being trusted as one. Loading the tree now verifies the claim: if the conversation isn't in the tree the row named, the root is re-resolved. Mutable state (labels, session state, model override, agent binding) is always re-derived from the verified tree.
  2. Archived conversations were excluded from the tree entirely, and archiving is a listing concern, not an accounting one. An archived root — or an archived mid-tree node — seeded the enforcement total as $0 and allowed a tool call over budget.

Also included: `apply_state_updates` suppresses `ConversationNotFoundError` consistently on all approval paths (native ask gate, MCP retry, relay `_apply_pending_policy_ask_writes`). The `_apply_pending_policy_ask_writes` pop is deferred until after a successful engine build to avoid silently dropping approved writes when a concurrent agent rebind raises.

A `policy_evaluate` journey is added to the benchmark harness (`dev/benchmarks/omnigent`) so future regressions in evaluate-route latency are detectable without re-measuring manually.

Test Plan

uv run --no-sync pytest tests/runtime/policies/test_builder.py tests/runtime/policies/test_session_cost_ask_routing.py tests/server/integration/test_sessions_policy_evaluate.py tests/server/routes/test_sessions_snapshot.py -q
uv run --no-sync ruff check omnigent/runtime/policies omnigent/server/routes/_sessions omnigent/server/routes/sessions/routes_hooks.py
uv run --no-sync ruff format --check omnigent/runtime/policies omnigent/server/routes/_sessions omnigent/server/routes/sessions/routes_hooks.py

Demo

N/A — backend/policy-engine change, no UI surface.

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

Every oracle is paired with the mutation that kills it. The SQL statement counts are pinned by `test_build_issues_one_read_and_one_tree_scan` (builder, both preload/no-preload shapes) and `test_authenticated_evaluate_route_sql_budget` (route level). Removing the `conversation=` preload arg changes the builder count from 3 → 6, which the budget oracle detects even though both variants return the same verdict.

Changelog

Fixed a bug where an archived session (or an archived sub-agent) could be gated as if it had spent nothing, letting a tool call proceed over its actual budget.

Copilot AI lite review requested due to automatic review settings August 7, 2026 04:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added P1-high Priority: major feature broken, no workaround size/XL Pull request size: XL labels Aug 7, 2026
Comment thread omnigent/stores/conversation_store/__init__.py Fixed
Comment thread omnigent/stores/conversation_store/__init__.py Fixed
Comment thread omnigent/stores/conversation_store/sqlalchemy_store.py Fixed
Comment thread tests/stores/test_conversation_store.py Fixed
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Benchmark results (SQLite, PR #4320)

Commit: dd04dcbaa9aa47111f2acc1b8ef95d82fc18223a

Benchmark comparison

Regression threshold: 100% on avg P50 or avg P95.

Journey Status Base P50 ms Cand P50 ms Δ P50 Base P95 ms Cand P95 ms Δ P95 Req/op
list_sessions ✅ ok 36.1 33.0 -8.5% 137.9 125.5 -9.0% 1.0
create_session ✅ ok 290.3 269.1 -7.3% 301.7 274.3 -9.1% 2.0
get_session ✅ ok 16.7 16.4 -1.9% 17.4 17.3 -0.6% 1.0
load_conversation_history ✅ ok 6.0 5.6 -6.3% 6.8 6.0 -12.4% 1.0
search_sessions ✅ ok 591.9 570.5 -3.6% 689.9 674.2 -2.3% 1.0
list_projects ✅ ok 28.3 26.4 -6.9% 126.2 123.1 -2.4% 1.0
list_project_sessions ✅ ok 50.1 45.5 -9.2% 157.8 141.9 -10.1% 1.0
fork_session ✅ ok 19.2 17.1 -11.0% 22.5 33.3 +48.3% 1.0
add_comment ✅ ok 5.3 4.9 -7.6% 5.7 5.3 -6.5% 1.0
policy_evaluate ⚠️ skipped
session_cold_start ✅ ok 3278.6 2975.5 -9.2% 3323.4 3047.0 -8.3% 13.0
session_cold_restart ✅ ok 3441.9 3093.9 -10.1% 3480.7 3162.8 -9.1% 12.0
warm_turn ✅ ok 134.9 112.0 -17.0% 142.6 124.6 -12.6% 3.0
time_to_first_token ✅ ok 371.4 352.5 -5.1% 388.0 404.6 +4.3% 4.8
interrupt ✅ ok 121.9 101.1 -17.1% 129.4 118.3 -8.6% 5.0
read_runner_file ✅ ok 11.6 10.6 -9.1% 14.6 14.3 -2.3% 1.0

PASS — no regressions detected.

@omnigent-ci

This comment was marked as off-topic.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread omnigent/stores/conversation_store/sqlalchemy_store.py Fixed
Copilot AI review requested due to automatic review settings August 7, 2026 05:11
@TomeHirata
TomeHirata force-pushed the perf/policy-engine-load-once branch from da2b60b to e6aeb44 Compare August 7, 2026 05:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@TomeHirata

Copy link
Copy Markdown
Contributor Author

/review

@omnigent-ci

omnigent-ci Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Polly AI Review

Blocking issues

1. The PR references ConversationStore.seed_labels_if_absent, which does not exist — the label-seeding path was never converted, and two new tests will fail.

The module docstring and the build_policy_engine docstring both now claim label seeding goes through :meth:ConversationStore.seed_labels_if_absent with insert-if-absent (DO NOTHING) semantics. But:

  • No such method exists anywhere in the repo (grep -rn seed_labels_if_absent omnigent/ → nothing), and the PR touches 8 files, none of which is a conversation-store file.
  • The actual seeding body in _seed_and_load_labels is unchanged in its store call — it still does conversation_store.set_labels(conversation_id, to_seed), and set_labels_dialect_upsert_labels performs INSERT ... ON CONFLICT DO UPDATE (a clobber), not DO NOTHING.

Two new tests in tests/runtime/policies/test_builder.py codify the missing method as a hard contract and will fail against this diff:

  • test_initial_label_seed_uses_the_atomic_store_operation asserts "seed_labels_if_absent" in calls and "set_labels" not in calls. The code calls set_labels, so this fails outright.
  • test_initial_label_seed_does_not_clobber_a_concurrent_write constructs a race where a competing write of {"integrity": "7"} lands after the snapshot read, then asserts the final value is "7". With set_labels's DO UPDATE semantics the seed of "0" overwrites the concurrent "7", so this also fails.

Either a store commit (adding seed_labels_if_absent with ON CONFLICT DO NOTHING semantics) plus the builder call-site change was dropped from the PR, or the docstrings/tests were written ahead of the implementation. As it stands the branch does not pass its own test suite, and the "seed can't clobber a concurrent write" correctness claim in the description is not actually delivered. This must be resolved (add the store method + switch the call site, or revert the docstrings/tests) before merge.

Security vulnerabilities

None found. The change strengthens the authorization path rather than weakening it: the builder now fails closed on a disappeared row (CONFLICT), on a mid-build agent rebind (expected_agent_id mismatch), on a mismatched preload (INVALID_INPUT), and on a paged-tree race — all previously silent windows. Reusing access.conversation in evaluate_policy is safe: it is the same row fetched during the ACL check at the same request instant, with a correct fallback read for admins/permissions-disabled where access.conversation is None, and every mutable field is still re-derived from a fresh read inside the builder. Reading turn_actor from engine.labels (a post-agent-load snapshot) rather than the earlier row is a correctness improvement, not a boundary weakening.

Non-blocking notes

  • Archived rows now inflate the displayed total, not just the gate. include_archived=True is applied to both tree loaders, and load_session_usage is the display path (session badge / usage report), so an archived descendant's spend now appears in user-facing totals. The PR calls this out as intentional ("the badge should agree with the gate"), and it's the right call — but it is a user-visible behavior change worth flagging to whoever watches the usage badge.
  • Empty-seed equivalence on a missing root. initial_usage is now gated on conv is not None and root_conv is not None, where the old _policy_usage_seed returned {} only on conv is None (and produced an empty sum when the root tree came back empty). The end seed is equivalent, but the guard is stricter — worth a quick confirmation that a legitimately-rootless-but-present conversation can't hit the new else {} branch unexpectedly.
  • _policy_usage_seed / _subtree_usage_seed / _resolve_session_model remain in the source but the diff routes around them; if they're now dead on this branch, remove them to avoid two implementations of the same seed drifting.

Summary

The refactor itself is well-reasoned: collapsing ~4 conversation reads + 2 tree walks into one verified tree load is a real win on the blocking PreToolUse path, and the accompanying correctness fixes (verified tree root, fail-closed on delete/rebind/paging races, archived spend in the accounting tree, deferred _apply_pending_policy_ask_writes pop) are genuine improvements backed by thorough tests. However, the branch ships docstrings and two tests that depend on a ConversationStore.seed_labels_if_absent method that was never added, while the seeding code still calls set_labels — so the suite does not pass and one advertised race fix is not actually implemented. That gap is blocking; the rest of the change is in good shape once it's closed.


Automated review by Polly · workflow run

Copilot AI review requested due to automatic review settings August 7, 2026 05:39
@TomeHirata
TomeHirata force-pushed the perf/policy-engine-load-once branch from e6aeb44 to 469da68 Compare August 7, 2026 05:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…build

Policy evaluation sits on the PreToolUse critical path — the hook blocks on
the verdict — and spent most of its time re-reading the same rows.
build_policy_engine fetched the conversation about four times (root
resolution, labels, session state, model override) and walked the spawn tree
twice, because the session-wide gating seed and the per-node subtree seed
each called load_session_usage, which does its own conversation read plus a
full paged tree scan.

One conversation read and one tree scan now feed everything. Both usage seeds
derive from that list through a pure aggregation, so they stay semantically
distinct: cost gating remains tree-wide, so a sub-agent gates against the
whole session's spend, while the subtree total remains the per-node display
figure. A caller that already holds the row can pass it and skip the read.

A row the caller supplies is a HINT, not a fact. It names a tree, and loading
that tree verifies the claim: if the conversation is not in it, the root is
resolved again. Everything downstream — the rows, the root id, the policies
attached to that root, the accounting sums — comes from the tree that
verification produced. Deriving the root from the caller's row while taking
rows from a corrected tree mixes two epochs, and a conversation deleted and
recreated under a different root then seeded the old tree's spend.

Mutable state is likewise re-derived rather than trusted: labels, session
state, model override and agent binding all come from the verified tree,
whoever read the row first, because a caller's preload and this function's own
read are equally stale by the time a decision is made. A row absent from the
tree is confirmed with one re-read and then fails closed. A tree that needed
more than one page cannot vouch for its own rows — page one was read before
page two — so identity is confirmed once in that case, which single-page trees
never pay for.

Also here, because it is the same tree: the ancestor cost re-publish used to
do a conversation read plus a full tree scan PER ancestor, and derived the
chain from a row read earlier in the request. It now walks the verified tree,
so the whole fan-out costs one load and cannot publish to a chain that has
since changed. A chain that cannot be walked to the root yields nothing
rather than a prefix, since the caller publishes to every id returned.

The tree also stopped excluding archived conversations. Archiving is a listing
concern; the tree is an accounting structure. Excluding them let an archived
root — or an archived mid-tree node, which orphaned its descendants from the
walk — seed the enforcement total as $0 and allow a tool call over budget.
Archived spend consequently appears in displayed totals too, which is the
intended reading: the badge should agree with the gate.

Measured on both dialects: 30 queries per build to 6, or 3 when the caller
supplies the row. The whole authenticated route, by (tree size, whether the
caller supplies the row): 11 on a one-page tree when supplied, 14 when not;
17 on a 101-node tree when supplied, 20 when not. The tree load pages, so
cost is not independent of tree size, and the extra 3 on a paged tree over
the one-page count are the paging confirmation above, a full conversation
read — consistent at both tree sizes and both supplied/not-supplied. Counted
as SQL statements rather than store calls, because a store-call count cannot
see a helper that issues three statements per call. The route-level oracle
below covers only the one-page shape; the 101-node figures are measured, not
pinned by a test yet.

Every oracle here is paired with the mutation that kills it, including the two
that pin this round's fixes: deriving the root from the pre-refresh row fails
the recreated-child test, and skipping the paged-tree confirmation fails the
switch-during-paging test.

Signed-off-by: Andrew Reid <andrew@reid.ee>
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
@TomeHirata
TomeHirata force-pushed the perf/policy-engine-load-once branch from 469da68 to dd04dcb Compare August 7, 2026 05:41
Copilot AI review requested due to automatic review settings August 7, 2026 05:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@TomeHirata

Copy link
Copy Markdown
Contributor Author

/review

1 similar comment
@TomeHirata

Copy link
Copy Markdown
Contributor Author

/review

@omnigent-ci

omnigent-ci Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Polly AI Review

Blocking issues

None. I traced the changed builder logic, the evaluate entrypoints, and the store layer, and found no correctness bug, broken contract, or data-loss risk that rises to blocking.

Things I specifically verified as safe:

  • list_conversations fully hydrates the fields the builder now derives from tree rows. _to_conversation populates labels (via _fetch_labels_bulk), session_state/session_usage (from the metadata merge), and model_override/agent_id (from the row + session_overrides) on the list path exactly as get_conversation does. So re-deriving labels/state/model/usage from the freshly-scanned tree row instead of a separate read is behavior-preserving. Good.
  • New CONFLICT/disappeared/no longer resolves raises fail closed on the enforcement path. On the native PreToolUse hook, a builder raise becomes a non-2xx from /policies/evaluate; post_evaluate_with_retry returns None for <500, and fail_closed_hook_output maps PreToolUsedeny / UserPromptSubmitblock. The web _evaluate_input_policy path wraps evaluation in except Exception → deny. This is the correct direction.
  • initial_usage == {} when the root row is missing matches the prior _policy_usage_seed semantics (old code also returned {} when the root was absent), so no new over-budget window is introduced there. The genuine over-budget hole being closed here is the archived-node exclusion, which is the right fix.
  • The _publish_subtree_cost_to_ancestors rewrite (one tree load + ancestor_ids_from_tree) is a strict improvement over the per-ancestor load_session_usage re-paging, and its two callers correctly pass conv=None (self-resolving the root).

Security vulnerabilities

None introduced. The preload-identity contract is the security-relevant surface, and it is handled defensively: a mismatched preload raises, mutable fields (labels/state/model/agent_id) are always re-derived from a fresh read rather than trusted from the caller row, and the expected_agent_id check fires after the refresh (correctly rejecting a switch-agent-in-window, an unbound fresh row, and a deleted row). The paged-tree confirmation read closes the "tree assembled across a change" window. No secret exposure, no auth-boundary weakening. No lockfile or extras changes in this diff.

Non-blocking notes

  • _load_tree_conversations appears to become dead code. Its only caller was the old load_session_usage, which the PR rewrites to go through load_session_tree_load_tree_pages. The diff even adds include_archived=True to _load_tree_conversations (now unreachable) while introducing the near-identical _load_tree_pages. Worth deleting _load_tree_conversations (and folding the two into one) to avoid a second, subtly-different tree walk lingering in the module.
  • Two divergent ancestor-walk semantics now coexist. ancestor_ids_from_tree returns [] on a cycle or broken chain (correct — the callers publish to each id), but the still-used _ancestor_session_ids (elicitation publish paths) keeps the old "return the walked prefix" behavior. Not wrong here, but the inconsistency is a latent trap; consider aligning _ancestor_session_ids to the same discard-on-untrustworthy-chain rule.
  • User-visible accounting change. Including archived descendants in load_session_usage also changes the displayed session badge / session.usage total, not just the gate. The PR calls this out and tests it (test_archived_descendant_spend_counts_toward_the_displayed_total), and the "badge must not disagree with the gate" rationale is sound — just flagging that a previously-archived sub-agent's spend will now reappear in a user's total.
  • The pinned SQL budgets (= 11, 6/3) are brittle oracles. They're the intended regression guard, but any unrelated store refactor (e.g. an extra metadata read) will trip them; the failure message dumps the statements, which mitigates this. Acceptable given the stated goal of pinning the description against the code.

Summary

A careful, well-tested performance change that collapses ~4 conversation reads + 2 tree walks into one read + one tree scan, and ships two legitimate correctness fixes (verify-the-caller-row-is-a-hint, and count archived spend so archiving can't reset a budget gate) that genuinely belong with the same tree-load rework. The freshness/fail-closed contract is reasoned through thoroughly and backed by strong parametrized tests across preload/no-preload × switch/delete provenance. No blocking or security issues; the only cleanup worth doing before merge is removing the now-dead _load_tree_conversations duplicate. Purely backend/accounting — no visual demonstration required.


Automated review by Polly · workflow run

@TomeHirata
TomeHirata merged commit 8b1644b into main Aug 7, 2026
67 checks passed
@TomeHirata
TomeHirata deleted the perf/policy-engine-load-once branch August 7, 2026 06:57
@github-actions github-actions Bot added the no-doc-update Merged PR does not need a docs update label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🏷️ Doc impact: no-doc-update

Internal refactor and concurrency-hardening of the policy-engine builder (single tree scan, fresh-read/fail-closed semantics, archived-spend accounting) plus tests and benchmarks — no user-facing surface, integration, or built-in policy was added, removed, or reconfigured.

Auto-classified on merge. Set the label manually before merging to override. · run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-doc-update Merged PR does not need a docs update P1-high Priority: major feature broken, no workaround size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Performance] Policy engine rebuilt per evaluation: 32 queries + two full-tree scans for ~18ms of policy logic (~1s per tool call on claude-native)

3 participants