Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions dev/benchmarks/omnigent/journeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,86 @@ async def _measure_read_runner_file(env: BenchEnvironment, ctx: JourneyContext)
await env.read_runner_file(session_id, _RUNNER_FILE_PATH)


# ── policy evaluate ──────────────────────────────────────────

_POLICY_EVALUATE_PAYLOAD = {
"event": {
"type": "PHASE_TOOL_CALL",
"data": {"name": "Bash", "arguments": {"command": "ls"}},
}
}


async def _setup_policy_evaluate_session(env: BenchEnvironment) -> str:
"""Create an agent-bound session with a declared policy and warm the caches.

The agent must declare at least one policy so ``any_policies_apply`` is
true and the full engine build (single tree scan + preloaded conversation)
runs on every evaluate call. A zero-policy spec short-circuits before the
build, which would measure the wrong path.

Two warm calls are made before returning so the agent-spec and
session-policy caches are populated; the measured iteration then reflects
steady-state overhead, not cold-cache cost.
"""
assert env.client is not None
import io
import tarfile

import yaml

config: dict[str, object] = {
"spec_version": 1,
"name": "bench-policy-agent",
"guardrails": {
"policies": {
"allow_all": {
"type": "function",
"on": ["tool_call"],
"function": "tests.runtime.policies.conftest._always_allow",
}
}
},
}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
payload = yaml.safe_dump(config).encode()
info = tarfile.TarInfo("config.yaml")
info.size = len(payload)
tar.addfile(info, io.BytesIO(payload))
bundle = buf.getvalue()

resp = await env.client.post(
"/v1/agents",
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
)
resp.raise_for_status()
agent_id = resp.json()["id"]

session_resp = await env.client.post("/v1/sessions", json={"agent_id": agent_id})
session_resp.raise_for_status()
session_id = session_resp.json()["id"]

# Warm the spec + policy caches — the measured iteration is steady-state.
for _ in range(2):
await env.client.post(
f"/v1/sessions/{session_id}/policies/evaluate",
json=_POLICY_EVALUATE_PAYLOAD,
)

return session_id


async def _measure_policy_evaluate(env: BenchEnvironment, ctx: JourneyContext) -> None:
session_id = cast(str, ctx) # _setup_policy_evaluate_session
assert env.client is not None
resp = await env.client.post(
f"/v1/sessions/{session_id}/policies/evaluate",
json=_POLICY_EVALUATE_PAYLOAD,
)
resp.raise_for_status()


# ── registry ─────────────────────────────────────────────────

ALL_JOURNEYS: dict[str, Journey] = {
Expand Down Expand Up @@ -754,6 +834,15 @@ async def _measure_read_runner_file(env: BenchEnvironment, ctx: JourneyContext)
concurrency_safe=True,
description="POST /v1/sessions/{id}/comments — create a review comment.",
),
Journey(
name="policy_evaluate",
kind="latency",
measure=_measure_policy_evaluate,
setup=_setup_policy_evaluate_session,
concurrency_safe=True,
description="POST /v1/sessions/{id}/policies/evaluate — PreToolUse hook "
"(single tree scan, preloaded conversation row, caches warm).",
),
# Runner (full-turn) journeys — with_runner=True, openai-agents, mock LLM.
Journey(
name="session_cold_start",
Expand Down
491 changes: 403 additions & 88 deletions omnigent/runtime/policies/builder.py

Large diffs are not rendered by default.

58 changes: 38 additions & 20 deletions omnigent/server/routes/_sessions/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6667,22 +6667,33 @@ def _build_policy_engine_from_spec_impl(
spec: AgentSpec,
session_id: str,
conversation_store: ConversationStore,
conversation: Conversation | None = None,
) -> PolicyEngine:
"""Build an engine for *spec*, reusing a conversation row when held.

Every caller of this wrapper already loaded the conversation to
resolve *spec*; passing it lets the builder skip its own read. Only
the row's immutable identity is reused — the builder re-derives
labels, session_state and model from a fresh read (see
:func:`build_policy_engine`).
"""
caps = get_caps()
host_connection = (
caps.policy_llm_connection_factory() if caps.policy_llm_connection_factory else None
)
return cast(
PolicyEngine,
build_policy_engine(
spec=spec,
conversation_id=session_id,
conversation_store=conversation_store,
default_policies=caps.default_policies,
policy_store=get_policy_store(),
server_llm=caps.llm,
host_connection=host_connection,
),
return build_policy_engine(
spec=spec,
conversation_id=session_id,
conversation_store=conversation_store,
conversation=conversation,
# The spec was resolved from this row's agent binding; the builder
# confirms it against its own fresh read and fails closed if a
# switch-agent landed in between.
expected_agent_id=conversation.agent_id if conversation is not None else None,
default_policies=caps.default_policies,
policy_store=get_policy_store(),
server_llm=caps.llm,
host_connection=host_connection,
)


Expand Down Expand Up @@ -6734,22 +6745,27 @@ async def _apply_pending_policy_ask_writes(
return
# Non-MCP relay path: pop and apply writes here since no retry
# will arrive.
_pending_policy_ask_writes.pop(elicitation_id, None)
# Resolve the agent spec + build the engine off the event loop: the
# lookup, cold-cache bundle fetch, and engine construction are all
# blocking DB/IO.
spec = await asyncio.to_thread(_load_agent_spec_for_session, conv, agent_store)
if spec is None:
_pending_policy_ask_writes.pop(elicitation_id, None)
return
engine = await asyncio.to_thread(
_build_policy_engine_from_spec, spec, session_id, conversation_store
_build_policy_engine_from_spec, spec, session_id, conversation_store, conv
)
# Pop only after the engine build succeeds: a raise here (e.g. a
# concurrent agent rebind) would otherwise lose the approved writes
# with no retry possible.
_pending_policy_ask_writes.pop(elicitation_id, None)
# The label/state writes hit the DB synchronously too — keep them
# off the loop.
if pending.set_labels:
await asyncio.to_thread(engine.apply_label_writes, pending.set_labels)
if pending.state_updates:
await asyncio.to_thread(engine.apply_state_updates, pending.state_updates)
with contextlib.suppress(ConversationNotFoundError):
await asyncio.to_thread(engine.apply_state_updates, pending.state_updates)


def _build_actor(user_id: str | None) -> dict[str, str] | None:
Expand Down Expand Up @@ -6856,11 +6872,13 @@ def _build_evaluation_context(
harness=hook_harness,
)
# REQUEST / RESPONSE — content is the user/assistant text. The wire ``data``
# is a dict for the native command hooks (``{"text"|"content": ...}``), but
# may be a bare string — opencode's policy plugin sends the prompt text
# directly for ``PHASE_REQUEST``. Accept both, and NEVER raise here: a crash
# 500s the evaluate endpoint, which silently fails the request/result gate
# OPEN (the exact symptom that let cost-over-budget terminal prompts through).
# is a dict for every current first-party producer (``{"text"|"content":
# ...}``, including OpenCode's plugin, which sends ``{"text": ...}``), but
# a bare string is still accepted for ``PHASE_REQUEST`` for compatibility
# with older or third-party callers that send the prompt text directly.
# Accept both, and NEVER raise here: a crash 500s the evaluate endpoint,
# which silently fails the request/result gate OPEN (the exact symptom
# that let cost-over-budget terminal prompts through).
if isinstance(data, str):
text = data
elif isinstance(data, dict):
Expand Down Expand Up @@ -7145,7 +7163,7 @@ async def _evaluate_output_policy(
return None

engine = await asyncio.to_thread(
_build_policy_engine_from_spec, spec, session_id, conversation_store
_build_policy_engine_from_spec, spec, session_id, conversation_store, conv
)
ctx = EvaluationContext(
phase=Phase.RESPONSE,
Expand Down
42 changes: 32 additions & 10 deletions omnigent/server/routes/_sessions/orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
resolve_ask_timeout,
)
from omnigent.runtime.policies.builder import (
_sum_subtree_usage,
ancestor_ids_from_tree,
load_session_tree,
load_session_usage,
)
from omnigent.runtime.policies.engine import PolicyEngine
Expand Down Expand Up @@ -191,7 +194,6 @@
# primitives) live in _sessions.helpers.
from omnigent.server.routes._sessions.helpers import (
SessionLiveness,
_ancestor_session_ids,
_await_settled_managed_launch,
_build_new_item,
_build_policy_engine_from_spec,
Expand Down Expand Up @@ -868,6 +870,7 @@ def _build_session_list_item(
def _publish_subtree_cost_to_ancestors(
conv_store: ConversationStore,
session_id: str,
conv: Conversation | None = None,
) -> None:
"""
Re-publish each ancestor's subtree-summed cost after a child usage update.
Expand All @@ -880,18 +883,35 @@ def _publish_subtree_cost_to_ancestors(
display side.) For each ancestor of *session_id*, recompute its subtree
priced cost and publish a ``session.usage`` event carrying it.

One tree load serves the whole walk. Every ancestor of this session is in
the same tree, so both the chain and each ancestor's sum are derived from
one set of freshly-read rows — previously each ancestor paged the tree
again, and the chain came from a conversation row that may have been read
before a concurrent delete/recreate moved this session elsewhere.

Sync (does store reads + SSE fan-out); call via
:func:`asyncio.to_thread`, mirroring the elicitation ancestor-publish
helpers. ``session_stream.publish`` is safe to call from a worker thread.

:param conv_store: Store used to discover ancestors and sum each
ancestor's subtree usage.
:param conv_store: Store used to load the tree.
:param session_id: The child session whose usage just changed, e.g.
``"conv_child123"``.
:param conv: The child's already-loaded conversation row, when the caller
holds one. Only its ``root_conversation_id`` is used, and only as a
hint: :func:`load_session_tree` verifies the tree it names actually
contains this session and resolves the root itself when it does not.
The parameter belongs to this function, not to whichever caller first
needed it, so that no caller can be removed and leave a signature
behind that its remaining callers already depend on.
:returns: None.
"""
for ancestor_id in _ancestor_session_ids(conv_store, session_id):
ancestor_usage = load_session_usage(ancestor_id, conv_store)
tree = load_session_tree(
session_id,
conv_store,
conv.root_conversation_id if conv is not None else None,
)
for ancestor_id in ancestor_ids_from_tree(tree, session_id):
ancestor_usage = _sum_subtree_usage(tree, ancestor_id)
subtree_cost = _priced_cost_for_display(ancestor_usage)
usage_by_model = _usage_by_model_for_display(ancestor_usage)
if subtree_cost is None and usage_by_model is None:
Expand Down Expand Up @@ -1915,7 +1935,8 @@ async def _hold_native_ask_gate_impl(
if result.set_labels:
engine.apply_label_writes(result.set_labels)
if result.state_updates:
engine.apply_state_updates(result.state_updates)
with contextlib.suppress(ConversationNotFoundError):
engine.apply_state_updates(result.state_updates)
return approved


Expand Down Expand Up @@ -6073,7 +6094,7 @@ async def _evaluate_tool_call_policy(
if spec is None:
return None
engine = await asyncio.to_thread(
_build_policy_engine_from_spec, spec, session_id, conversation_store
_build_policy_engine_from_spec, spec, session_id, conversation_store, conv
)

try:
Expand Down Expand Up @@ -6230,7 +6251,7 @@ async def _evaluate_input_policy(
request_content = {"user_content": user_text, "attachments": attachments}

engine = await asyncio.to_thread(
_build_policy_engine_from_spec, spec, session_id, conversation_store
_build_policy_engine_from_spec, spec, session_id, conversation_store, conv
)
ctx = EvaluationContext(
phase=Phase.REQUEST,
Expand Down Expand Up @@ -8120,7 +8141,7 @@ async def _handle_mcp_tools_call(
# only) and TOOL_RESULT (both paths). Engine construction reads
# session-policy specs and labels from the DB, so keep it off-loop too.
engine = await asyncio.to_thread(
_build_policy_engine_from_spec, spec, session_id, conversation_store
_build_policy_engine_from_spec, spec, session_id, conversation_store, conv
)

if is_retry:
Expand Down Expand Up @@ -8200,7 +8221,8 @@ async def _handle_mcp_tools_call(
if _pending.set_labels:
await asyncio.to_thread(engine.apply_label_writes, _pending.set_labels)
if _pending.state_updates:
await asyncio.to_thread(engine.apply_state_updates, _pending.state_updates)
with contextlib.suppress(ConversationNotFoundError):
await asyncio.to_thread(engine.apply_state_updates, _pending.state_updates)
else:
# ALLOW — policy no longer requires approval (e.g. label
# state changed between the original ASK and this retry).
Expand Down
30 changes: 26 additions & 4 deletions omnigent/server/routes/sessions/routes_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from fastapi.responses import Response

from omnigent.codex_native_elicitation import codex_elicitation_id
from omnigent.entities import Conversation
from omnigent.errors import ElicitationDeclinedError, ErrorCode, OmnigentError
from omnigent.runner.routing import RunnerRouter
from omnigent.runtime import (
Expand Down Expand Up @@ -700,7 +701,14 @@ async def evaluate_policy(
code=ErrorCode.INVALID_INPUT,
)

conv = conversation_store.get_conversation(session_id)
# Reuse the row the ACL check already fetched — same point in the
# request, so no less fresh than reading it again here, one query
# fewer on the blocking PreToolUse path. Absent for admin callers
# (who bypass the conversation lookup) and when permissions are
# disabled, which fall back to their own read.
conv = access.conversation
if conv is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
f"Session {session_id!r} not found.",
Expand Down Expand Up @@ -760,7 +768,7 @@ async def evaluate_policy(
_caps.policy_llm_connection_factory() if _caps.policy_llm_connection_factory else None
)

def _build_engine() -> PolicyEngine:
def _build_engine(preloaded_conv: Conversation | None = None) -> PolicyEngine:
"""
Build a policy engine for this session from the loaded spec.

Expand All @@ -769,27 +777,41 @@ def _build_engine() -> PolicyEngine:
does not re-query it during ``evaluate``, so a fresh build is the
only way to observe a concurrent sibling's just-recorded approval.

:param preloaded_conv: The conversation row this handler already
loaded, passed on the FIRST build only to skip the builder's
re-read. Rebuilds that must observe concurrent writes (the
ASK-gate re-evaluation) pass ``None`` for a fresh read.
:returns: A :class:`PolicyEngine` seeded with the latest
persisted state for ``session_id``.
"""
return build_policy_engine(
spec=loaded.spec,
conversation_id=session_id,
conversation_store=conversation_store,
conversation=preloaded_conv,
# ``agent`` below was resolved from conv.agent_id; the builder
# re-reads the row and fails closed if it was rebound since.
expected_agent_id=agent.id,
default_policies=_caps.default_policies,
policy_store=get_policy_store(),
server_llm=_caps.llm,
host_connection=_host_conn,
)

engine = _build_engine()
engine = _build_engine(conv)
# Use the turn-initiating human's identity (persisted at forward time)
# so per-user policies gate on the correct actor even when the HTTP
# caller is the runner's service-account credential. Falls back to
# user_id for direct API callers and native-terminal sessions (whose
# turns go via _dispatch_session_event_to_runner, which does not write
# this label).
turn_actor = conv.labels.get(_TURN_ACTOR_LABEL)
# Read the actor from the engine's label snapshot, not from the row
# fetched at the top of this handler: the engine's labels come from a
# read taken after the agent/spec load, so a turn-actor label written
# in that window still gates on the right principal. (``agent_id``
# cannot be treated the same way — it selects the spec the engine is
# built from, so it is necessarily read first.)
turn_actor = engine.labels.get(_TURN_ACTOR_LABEL)
ctx = _build_evaluation_context(
phase, data, event, actor=_build_actor(turn_actor or user_id)
)
Expand Down
Loading
Loading