Files
omnigent-ai--omnigent/dev/benchmarks/omnigent/journeys.py
T
Tomu Hirata 2aba5079d4 feat(bench): add CLI startup latency benchmark (#4793)
* feat(bench): add CLI startup latency benchmark

Measures wall-clock time from omnigent claude --server invocation to the
Claude terminal being ready (signalled by 'Claude terminal ready.' spinner
message, emitted just before tmux attach).

Unlike the HTTP/API benchmarks in run.py, this drives the real CLI binary
end-to-end against a remote server — auth, daemon tunnel, session create,
runner launch, terminal boot — via pexpect.

Usage:
  uv run --no-sync dev/benchmarks/omnigent/cli_startup.py
  uv run --no-sync dev/benchmarks/omnigent/cli_startup.py --also-isaac-omni --runs 10
  uv run --no-sync dev/benchmarks/omnigent/cli_startup.py --output startup.json
  uv run --no-sync dev/benchmarks/omnigent/cli_startup.py --max-p50-ms 12000

JSON output is compatible with the existing benchmark schema.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* ci(bench): add cli-startup job to benchmark workflow

Adds a new 'CLI startup latency' job that runs cli_startup.py against
the ai-devtools managed workspace (OMNIGENT_REMOTE_AUTH_TOKEN secret).

- Runs on nightly schedule (when secret is configured) and on
  workflow_dispatch with cli_startup_runs input (default 5, 0 = skip)
- Skips gracefully when OMNIGENT_REMOTE_AUTH_TOKEN secret is absent
- Uploads benchmark-results-cli-startup-{run_id}.json as an artifact
  for the Databricks trend dashboard (same schema as the HTTP benchmarks)
- Renders a job summary table via report_markdown.py

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* feat(bench): align cli_startup with existing journey schema

- Use RunResult/aggregate/print_results/check_thresholds/build_report
  from the existing framework instead of custom stats/output code
- Each run is now a RunResult with all latency samples (matching the
  HTTP/API journey shape), not one run-per-sample
- Journey names are cli_startup and isaac_omni (snake_case, no spaces)
- Output table uses the same renderer as run.py
- Add cli_startup_runs dispatch input and cli-startup job to benchmark.yml

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* refactor(bench): move cli_startup into journeys.py; use local bench server

The cli_startup journey now lives in journeys.py alongside the other
journeys, using env.base_url (the local bench server) instead of a
remote Databricks URL. This aligns it with the existing pattern:
needs_host=True boots the host daemon, and omnigent claude --server
<local-url> connects to it for the full startup sequence.

cli_startup.py becomes a thin shim that calls run.py --journeys cli_startup.

benchmark.yml cli-startup job now uses run.py directly — no
OMNIGENT_REMOTE_AUTH_TOKEN secret needed, just pexpect + claude CLI.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* ci(bench): fold claude CLI install into Install dependencies step

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* ci(bench): merge cli_startup into existing benchmark job (sqlite leg only)

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* remove cli_startup.py shim — use run.py --journeys cli_startup directly

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* ci(bench): run cli_startup on all matrix backends

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* ci(bench): install pexpect+claude before Run benchmark so cli_startup does not skip

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(bench): fix policy_evaluate setup (POST /v1/agents → /v1/sessions bundle); add needs_runner to cli_startup

- policy_evaluate setup was calling POST /v1/agents which is GET-only.
  Fix: use POST /v1/sessions multipart bundle upload (same as ensure_agent),
  with executor fields added to pass spec validation, and read session_id
  from the correct response key.

- cli_startup: add needs_runner=True so the test_runner_journeys_are_capped
  invariant passes (needs_host implies needs_runner in BenchEnvironment but
  not on the Journey dataclass itself).

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* ci(bench): add pexpect+claude install to benchmark-pr.yml

cli_startup is in ALL_JOURNEYS so it runs in the benchmark-pr regression
check too. Without pexpect and claude installed, every iteration fails
with RuntimeError.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(bench): replace test fixture function ref in policy_evaluate with self-contained one

tests.runtime.policies.conftest._always_allow is a test fixture that may
not be importable in the server subprocess's PYTHONPATH in CI, causing
HTTP 500 on every evaluate call. Replace with _bench_policy_allow defined
directly in journeys.py, which is always importable since dev/ is on
PYTHONPATH in the benchmark environment.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(bench): gate cli_startup on OMNIGENT_BENCH_SERVER; skip gracefully when not set

cli_startup conflicts with the bench environment's host daemon when run
against the local bench server — omnigent claude spawns its own daemon
which hits a 'host on another replica' error. Gate on OMNIGENT_BENCH_SERVER
env var instead: skip with a clear RuntimeError when unset, use the remote
server when set.

- Remove needs_runner/needs_host (no local server contact)
- Reduce max_iterations from 5 to 3 (each is ~10s)
- Set OMNIGENT_BENCH_SERVER in benchmark.yml and benchmark-pr.yml
- Relax test_runner_journeys_are_capped to allow non-runner journeys to cap

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* ci(bench): remove hardcoded OMNIGENT_BENCH_SERVER from workflows

cli_startup skips gracefully in CI (no OMNIGENT_BENCH_SERVER set).
Run it manually: OMNIGENT_BENCH_SERVER=<url> uv run --no-sync dev/benchmarks/omnigent/run.py --journeys cli_startup

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(bench): run cli_startup against local bench server; drop OMNIGENT_BENCH_SERVER

The daemon conflict was caused by needs_host=True booting a bench daemon
alongside the CLI's own daemon. With needs_host=False the bench environment
starts only the server; omnigent claude spawns its own daemon freely — no
conflict.

Result: 5.3s local vs 11s remote. CI runs it as part of the default suite
with no remote credentials needed.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(bench): use omnigent polly instead of omnigent claude for cli_startup

claude-native requires the external claude CLI binary which:
- Takes too long to boot on CI (90s timeout → job gets stuck)
- Requires npm install of @anthropic-ai/claude-code

polly (omnigent run with the bundled openai-agents harness) exercises the
same startup path (daemon, session create, runner launch, runner connect)
without any external binary dependency. Signal: 'Launching your agent'
with a 30s timeout instead of 90s.

Remove @anthropic-ai/claude-code install from both benchmark workflows.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(bench): move cli_startup to OPT_IN_JOURNEYS; exclude from default run

cli_startup against the local bench server hangs in CI — the polly runner
can't complete its startup within 30s, burning 19 min (39 attempts × 30s
including warmup) before failing.

Move it to OPT_IN_JOURNEYS: excluded from the default set, must be run
explicitly via --journeys cli_startup. resolve_journeys() looks in both
registries so it still works when named. Remove pexpect install from CI
workflows since it's no longer needed for the default benchmark run.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(bench): cli_startup back in ALL_JOURNEYS; add skip_warmup flag; 60s timeout

- Move cli_startup back to ALL_JOURNEYS (not needs_host; spawns its own daemon)
- Add Journey.skip_warmup: when True, run_latency skips the warmup phase
  regardless of --warmup. Avoids 10x60s = 10min of wasted warmup hangs.
- Increase timeout from 30s to 60s (CI runner is slower than local Mac)
- Restore pexpect install in both benchmark workflows

With skip_warmup=True and max_iterations=3: 3 runs x 3 = 9 iterations max,
no warmup hangs. Worst case: 9 x 60s = 9min if all timeout (shouldn't happen).

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* debug(bench): include RuntimeError message in failure breakdown for CI visibility

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(bench): stop stale daemons before each cli_startup iteration

A leftover host daemon from the previous iteration causes the next
omnigent polly to fail with 'runner tunnel rejection' or 'host is on
another replica'. Run omnigent stop before spawning polly to ensure
a clean slate each time.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(bench): move omnigent stop to prepare hook so it's outside the latency timer

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-15 12:16:06 +00:00

1120 lines
45 KiB
Python

"""User-journey definitions and the runners that time them.
A :class:`Journey` names a user-facing operation, an optional per-journey
``setup`` that returns a context object, and a ``measure`` coroutine — the
timed unit. :func:`run_latency` times ``measure`` sequentially; journeys marked
``concurrency_safe`` can also be driven by :func:`run_throughput` with many
operations in flight.
v1 journeys are pure HTTP/API (server + DB, no runner, no LLM):
- ``list_sessions`` — the session-list read behind the sidebar/home.
- ``create_session`` — session creation cost (POST then DELETE).
- ``get_session`` — single-session snapshot load.
- ``load_conversation_history`` — history read, seeded runner-free via
``external_conversation_item`` (see :meth:`BenchEnvironment.seed_items`).
- ``fork_session`` — fork a session (deep-copy its items), then DELETE.
- ``add_comment`` — create a review comment on a file (DB write).
- ``list_projects`` — the sidebar project list (dual-read union of first-class
projects + legacy ``omni_project`` label-projects).
- ``list_project_sessions`` — a project folder's session list, the
``?project=`` dual-read filter behind clicking a project in the sidebar.
``read_runner_file`` needs a runner but no LLM turn: it plants a file in the
runner environment (setup) and times the server → runner filesystem read proxy.
Full-turn journeys (``needs_runner=True``) drive a real turn through the runner
+ mock LLM. ``session_cold_start`` (``needs_host=True``) measures the real UI
new-conversation cold path: it spawns a host daemon once, then per iteration
creates a host-bound session (which fires ``host.launch_runner``), attaches the
SSE stream, sends the first message, and times to the first output-text delta —
so the span includes the on-demand runner launch + reverse-tunnel handshake the
UI's first message races, exactly as a real new chat pays it.
``session_cold_restart`` reuses one existing host-bound session. Before every
timed message it stops that session's runner outside the measured span; the
message then exercises the server's automatic relaunch path and times to the
first streamed response.
The framework (``Journey`` + the two runners) is harness-agnostic and reused
verbatim by phase-2 full-turn journeys.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Literal, cast
import httpx
from .environment import BenchEnvironment, ServerRequestSnapshot
from .measure import RunResult
# Per-journey context returned by ``setup`` and threaded to ``measure``. Its
# concrete type varies by journey (an agent id, a session id, or nothing), so
# it is opaque at the framework level; each measure op casts it as needed.
JourneyContext = object
JourneyKind = Literal["latency", "throughput"]
# Items requested per history-read page. Also the count self-seeded into a
# fallback session when the DB has no corpus (empty-DB smoke path).
_HISTORY_PAGE_LIMIT = 20
_HISTORY_SEED_ITEMS = _HISTORY_PAGE_LIMIT
@dataclass
class Journey:
"""One benchmarkable user journey.
:param name: Stable identifier used on the CLI and as the report key.
:param kind: ``"latency"`` (time each operation) or ``"throughput"``
(fixed request count under concurrency). A latency journey that is
``concurrency_safe`` can additionally be run as throughput.
:param measure: Coroutine performing exactly one timed operation, given
the environment and the setup context.
:param setup: Optional coroutine run once before timing; its return value
is passed to ``measure`` (and ``teardown``) as ``ctx``.
:param prepare: Optional coroutine run before every measured operation,
outside that operation's latency timer. Used when each sample needs a
repeatable precondition, such as an offline runner.
:param teardown: Optional coroutine run once after timing, given ``ctx``.
:param concurrency_safe: Whether many ``measure`` calls may run at once
against a shared setup (true for read-only / independent-write HTTP
journeys).
:param needs_runner: Whether this journey drives a full agent turn and so
requires ``BenchEnvironment(with_runner=True)`` (mock LLM + runner).
HTTP/DB journeys leave this ``False``.
:param needs_host: Whether this journey needs a real host daemon
(``BenchEnvironment(with_host=True)``) so a host-bound session-create
or restart can fire ``host.launch_runner``. Implies ``needs_runner``.
:param max_iterations: Upper bound on latency iterations for this journey,
clamping ``--iterations`` down (never up). Full-turn journeys cost ~1s+
per op, so 100+ iterations would blow the CI time budget; they cap at a
few samples per run and lean on ``--runs`` for repeats. ``None`` (HTTP
journeys) means no cap.
:param skip_warmup: When ``True``, the warmup phase is skipped regardless
of ``--warmup``. Useful for expensive journeys where even a single
warmup iteration would waste significant time.
:param description: Human-readable one-liner for ``--list``.
"""
name: str
kind: JourneyKind
measure: Callable[[BenchEnvironment, JourneyContext], Awaitable[None]]
setup: Callable[[BenchEnvironment], Awaitable[JourneyContext]] | None = None
prepare: Callable[[BenchEnvironment, JourneyContext], Awaitable[None]] | None = None
teardown: Callable[[BenchEnvironment, JourneyContext], Awaitable[None]] | None = None
concurrency_safe: bool = False
needs_runner: bool = False
needs_host: bool = False
max_iterations: int | None = None
skip_warmup: bool = False
description: str = ""
async def run_setup(self, env: BenchEnvironment) -> JourneyContext:
return await self.setup(env) if self.setup is not None else None
async def run_prepare(self, env: BenchEnvironment, ctx: JourneyContext) -> None:
if self.prepare is not None:
await self.prepare(env, ctx)
async def run_teardown(self, env: BenchEnvironment, ctx: JourneyContext) -> None:
if self.teardown is not None:
await self.teardown(env, ctx)
# ── failure classification (shared) ──────────────────────────
def _failure_reason(exc: Exception) -> str:
"""Classify an exception into a stable failure-breakdown label.
HTTP status errors key off their status code (``"HTTP 500"``) so the same
server error groups across ops; RuntimeErrors include the message so CI
failure breakdowns show the actual cause; anything else keys off class name.
"""
if isinstance(exc, httpx.HTTPStatusError):
return f"HTTP {exc.response.status_code}"
if isinstance(exc, RuntimeError):
return f"RuntimeError: {exc}"
return exc.__class__.__name__
def _setup_failed_result(exc: Exception) -> RunResult:
"""A run whose ``setup`` raised: zero successes, one recorded failure.
Returned in place of timing when a journey's per-run ``setup`` fails (e.g.
a 500 while resolving a target session), so the failure is recorded as a
data point and the suite moves on instead of the whole process aborting.
The ``setup:`` prefix distinguishes it from an operation-level failure, and
``n_success == 0`` keeps it out of the summary averages (see
:func:`measure.aggregate`).
"""
result = RunResult()
result.record_failure(f"setup: {_failure_reason(exc)}")
return result
# ── server request counting (shared by both runners) ─────────
# Route key for the harness's own counter-poll (see environment.py's debug
# endpoint). Filtered out of the per-journey route appendix — it's
# instrumentation overhead, not the journey's traffic.
_METRICS_ROUTE_KEY = "GET /debug/server-metrics"
async def _count_start(env: BenchEnvironment) -> ServerRequestSnapshot | None:
"""Snapshot the server request counters before a run's timed region.
Returns ``None`` (counting disabled for the run) if the counter is
unreachable, so a benchmark against a server without the debug router still
produces latency numbers — it just omits the network block.
"""
try:
return await env.server_request_snapshot()
except Exception: # noqa: BLE001 — counting is best-effort, never fatal
return None
async def _count_finish(
env: BenchEnvironment, start: ServerRequestSnapshot | None, result: RunResult
) -> None:
"""Record server HTTP requests handled during the run's timed region.
Diffs the counters against *start* and stores the total + per-route
breakdown on *result*. The closing poll itself hits the server and lands
inside the window, so subtract it (1) from the total to leave only the
journey's own requests plus any cross-process traffic (runner → server,
host → server). The poll targets the debug-metrics route, a bucket no
journey uses, so it never pollutes the per-route diff. A negative or
unavailable value leaves ``http_requests`` as ``None``.
"""
if start is None:
return
end = await _count_start(env)
if end is None:
return
result.http_requests = max(0, end.total - start.total - 1)
result.route_requests = {
route: delta
for route, count in end.routes.items()
# Exclude the harness's own counter-poll route so the appendix reflects
# only the journey's traffic (the total above already backs it out).
if route != _METRICS_ROUTE_KEY
if (delta := count - start.routes.get(route, 0)) > 0
}
async def _timed(
journey: Journey, env: BenchEnvironment, ctx: JourneyContext, result: RunResult
) -> None:
"""Run one ``measure`` op, recording its latency or a failure reason."""
start = time.perf_counter()
try:
await journey.measure(env, ctx)
except Exception as exc: # noqa: BLE001 — any failure is a recorded data point
result.record_failure(_failure_reason(exc))
else:
result.latencies_ms.append((time.perf_counter() - start) * 1000)
# ── runners ──────────────────────────────────────────────────
async def run_latency(
journey: Journey, env: BenchEnvironment, *, iterations: int, warmup: int
) -> RunResult:
"""Time *iterations* sequential operations after discarding *warmup*.
Warmup operations run through the same path but are excluded from the
result, so first-call import/JIT/connection costs don't skew the numbers.
A failing ``setup`` (e.g. a 500 resolving a target session) is recorded as
a failed run and returned, rather than propagating and aborting the suite.
"""
try:
ctx = await journey.run_setup(env)
except Exception as exc: # noqa: BLE001 — a setup failure is a recorded data point
return _setup_failed_result(exc)
try:
effective_warmup = 0 if journey.skip_warmup else warmup
for _ in range(effective_warmup):
with contextlib.suppress(Exception): # warmup errors are non-fatal
await journey.run_prepare(env, ctx)
await journey.measure(env, ctx)
result = RunResult()
count_start = await _count_start(env)
wall_start = time.perf_counter()
for _ in range(iterations):
try:
await journey.run_prepare(env, ctx)
except Exception as exc: # noqa: BLE001 — preparation failure is a data point
result.record_failure(_failure_reason(exc))
continue
await _timed(journey, env, ctx, result)
result.wall_time = time.perf_counter() - wall_start
await _count_finish(env, count_start, result)
return result
finally:
with contextlib.suppress(Exception): # teardown failure must not abort the suite
await journey.run_teardown(env, ctx)
async def run_throughput(
journey: Journey,
env: BenchEnvironment,
*,
requests: int,
concurrency: int,
warmup: int,
) -> RunResult:
"""Fire *requests* operations with at most *concurrency* in flight.
Wall time spans from the first dispatch to the last completion, so
``throughput`` reflects sustained req/s under load (MLflow's ``_run_once``
shape, with an :class:`asyncio.Semaphore` gate).
A failing ``setup`` is recorded as a failed run and returned, rather than
propagating and aborting the suite.
"""
try:
ctx = await journey.run_setup(env)
except Exception as exc: # noqa: BLE001 — a setup failure is a recorded data point
return _setup_failed_result(exc)
try:
sem = asyncio.Semaphore(concurrency)
async def _one(count_it: bool, result: RunResult) -> None:
async with sem:
if count_it:
try:
await journey.run_prepare(env, ctx)
except Exception as exc: # noqa: BLE001 — preparation failure is a data point
result.record_failure(_failure_reason(exc))
return
await _timed(journey, env, ctx, result)
else:
with contextlib.suppress(Exception): # warmup errors are non-fatal
await journey.run_prepare(env, ctx)
await journey.measure(env, ctx)
if warmup:
throwaway = RunResult()
await asyncio.gather(*[_one(False, throwaway) for _ in range(warmup)])
result = RunResult()
count_start = await _count_start(env)
wall_start = time.perf_counter()
await asyncio.gather(*[_one(True, result) for _ in range(requests)])
result.wall_time = time.perf_counter() - wall_start
await _count_finish(env, count_start, result)
return result
finally:
with contextlib.suppress(Exception): # teardown failure must not abort the suite
await journey.run_teardown(env, ctx)
# ── journey implementations ──────────────────────────────────
#
# Setups return the context each measure op needs. Ops must be independent so
# concurrency-safe journeys don't interfere across in-flight calls.
# A token present in the seeded corpus (titles + item text, see seed.py
# _FRAGMENTS) so search_sessions exercises the LIKE path with real matches.
_SEARCH_TOKEN = "runner"
async def _setup_agent_id(env: BenchEnvironment) -> str:
"""Register the benchmark agent and return its id."""
name = await env.ensure_agent()
return await env.agent_id(name)
async def _setup_target_session(env: BenchEnvironment) -> str:
"""Return a session id to read: an existing corpus session if any, else make one.
Real runs target a pre-seeded corpus (``seed.py``), so we read a
representative existing session. When the DB is empty (e.g. the smoke test
against a throwaway DB), fall back to creating one with a little history so
the journey still exercises the read path.
"""
assert env.client is not None
listing = await env.client.get("/v1/sessions", params={"limit": 1})
listing.raise_for_status()
data = listing.json().get("data", [])
if data:
return str(data[0]["id"])
# Empty DB: self-seed one session over HTTP (runner-free).
name = await env.ensure_agent()
agent_id = await env.agent_id(name)
session_id = await env.create_session(agent_id)
await env.seed_items(session_id, _HISTORY_SEED_ITEMS)
return session_id
async def _measure_list_sessions(env: BenchEnvironment, _ctx: JourneyContext) -> None:
assert env.client is not None
resp = await env.client.get("/v1/sessions", params={"limit": 20})
resp.raise_for_status()
async def _measure_search_sessions(env: BenchEnvironment, _ctx: JourneyContext) -> None:
assert env.client is not None
resp = await env.client.get(
"/v1/sessions", params={"limit": 20, "search_query": _SEARCH_TOKEN}
)
resp.raise_for_status()
async def _measure_create_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
assert env.client is not None
agent_id = cast(str, ctx) # _setup_agent_id
created = await env.client.post("/v1/sessions", json={"agent_id": agent_id})
created.raise_for_status()
# Delete inline so a long run doesn't accumulate unbounded sessions; the
# POST is the operation of interest and dominates the timed span.
session_id = created.json()["id"]
deleted = await env.client.delete(f"/v1/sessions/{session_id}")
deleted.raise_for_status()
async def _measure_get_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
assert env.client is not None
session_id = cast(str, ctx) # _setup_target_session
resp = await env.client.get(f"/v1/sessions/{session_id}")
resp.raise_for_status()
async def _measure_load_history(env: BenchEnvironment, ctx: JourneyContext) -> None:
assert env.client is not None
session_id = cast(str, ctx) # _setup_target_session
resp = await env.client.get(
f"/v1/sessions/{session_id}/items",
params={"order": "asc", "limit": _HISTORY_PAGE_LIMIT},
)
resp.raise_for_status()
# Project name for the folder-fetch journey. Self-seeded when the corpus has no
# project so the ``?project=`` filter has a real member to resolve and return.
_BENCH_PROJECT_NAME = "bench-project"
async def _setup_project_name(env: BenchEnvironment) -> str:
"""Return a project name to fetch: an existing corpus project, else seed one.
Mirrors ``_setup_target_session``: real runs read a representative project
from the seeded corpus (``seed.py`` files a configurable fraction of sessions
into first-class projects), so the folder fetch resolves a realistically
populated project. When none exists (empty-DB smoke path) we file one session
under a fresh project so the ``?project=`` filter still exercises the real
dual-read path instead of an empty match.
"""
assert env.client is not None
listing = await env.client.get("/v1/sessions/projects")
listing.raise_for_status()
projects = listing.json()
if projects:
return str(projects[0]["name"])
# Empty DB: create a first-class project and file one session into it.
name = await env.ensure_agent()
agent_id = await env.agent_id(name)
session_id = await env.create_session(agent_id)
created = await env.client.post("/v1/projects", json={"name": _BENCH_PROJECT_NAME})
created.raise_for_status()
filed = await env.client.patch(
f"/v1/sessions/{session_id}", json={"project_id": created.json()["id"]}
)
filed.raise_for_status()
return _BENCH_PROJECT_NAME
async def _measure_list_projects(env: BenchEnvironment, _ctx: JourneyContext) -> None:
assert env.client is not None
resp = await env.client.get("/v1/sessions/projects")
resp.raise_for_status()
async def _measure_list_project_sessions(env: BenchEnvironment, ctx: JourneyContext) -> None:
assert env.client is not None
project = cast(str, ctx) # _setup_project_name
resp = await env.client.get("/v1/sessions", params={"limit": 20, "project": project})
resp.raise_for_status()
@dataclass
class _ForkContext:
"""Fork-journey context: the session to fork + the forks to clean up.
``measure`` records each fork's id here instead of deleting it inline, so
the DELETE stays out of the timed span; ``teardown`` removes them after.
"""
source_id: str
fork_ids: list[str]
async def _setup_fork_session(env: BenchEnvironment) -> _ForkContext:
"""Resolve a session to fork; start an empty fork-id collector."""
source_id = await _setup_target_session(env)
return _ForkContext(source_id=source_id, fork_ids=[])
async def _measure_fork_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
assert env.client is not None
fork_ctx = cast(_ForkContext, ctx) # _setup_fork_session
forked = await env.client.post(f"/v1/sessions/{fork_ctx.source_id}/fork", json={})
forked.raise_for_status()
# Record the fork for teardown; deleting it here would fold the DELETE into
# the timed span. The fork POST (a deep-copy of the source's items) is the
# operation of interest.
fork_ctx.fork_ids.append(forked.json()["id"])
async def _teardown_fork_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Delete every fork created during the run (best effort, untimed)."""
assert env.client is not None
fork_ctx = cast(_ForkContext, ctx)
for fork_id in fork_ctx.fork_ids:
with contextlib.suppress(httpx.HTTPError):
await env.client.delete(f"/v1/sessions/{fork_id}")
# Anchor snapshot for the comment journey; the offsets below span it.
_COMMENT_ANCHOR = "benchmark"
async def _measure_add_comment(env: BenchEnvironment, ctx: JourneyContext) -> None:
assert env.client is not None
session_id = cast(str, ctx) # _setup_target_session
# Each POST creates an independent comment row. Unlike sessions, an
# accumulating comment skews no measured read path, so there's no cleanup.
# The file need not exist — the handler stores the path + offsets + body.
resp = await env.client.post(
f"/v1/sessions/{session_id}/comments",
json={
"path": "bench_target.py",
"body": "benchmark review comment",
"start_index": 0,
"end_index": len(_COMMENT_ANCHOR),
"anchor_content": _COMMENT_ANCHOR,
},
)
resp.raise_for_status()
# ── runner (full-turn) journeys ──────────────────────────────
#
# These drive a real agent turn through the runner + mock LLM (with_runner=True,
# openai-agents). The mock is zero-latency, so every number is omnigent dispatch
# / streaming / cancel overhead, not model latency. Short deterministic replies.
# A multi-word reply so the streaming path emits several output_text deltas.
_TURN_REPLY = "Hello there, this is a mock benchmark reply."
_TURN_PROMPT = "Say hello."
# Iteration cap for full-turn journeys. At ~1s+ per turn, matching the HTTP
# journeys' iteration count would overrun the CI time budget, so we take a few
# samples per run and lean on --runs for repeats. Sessions accumulate across a
# run (a cold start never deletes its session), so a small count also keeps that
# drift negligible.
_RUNNER_MAX_ITERATIONS = 5
# Iteration cap for the runner filesystem read. It's a proxied localhost read,
# not a full turn, so it's far cheaper than the drive-a-turn journeys — a higher
# cap gives a usable p50/p99 while staying well within the CI time budget.
_RUNNER_FS_MAX_ITERATIONS = 50
# File planted by the read-runner-file setup and fetched by its measure op.
# ~1 KB — a modest, representative source file, not a stress case.
_RUNNER_FILE_PATH = "bench_read_target.txt"
_RUNNER_FILE_CONTENT = "benchmark file content line\n" * 40
async def _setup_turn_agent(env: BenchEnvironment, *, stream: bool = False) -> str:
"""Register the agent + a reset-surviving reply; return the agent id.
The fallback survives per-call queue exhaustion, so every turn in the run
gets the same reply regardless of how many turns consume the queue. When
*stream* is set the reply emits per-word deltas (for the TTFT journey).
"""
name = await env.ensure_agent()
await env.set_mock_fallback(_TURN_REPLY, stream=stream)
return await env.agent_id(name)
async def _setup_cold_start_agent(env: BenchEnvironment) -> str:
"""Register a streaming-reply agent for the cold-start journey; return its id.
No session and no warm-up turn — the cold-start measure creates a fresh
host-bound session each iteration. The reply streams deltas so the measured
op can return on the first ``response.output_text.delta`` (the UI's
first-token signal).
"""
return await _setup_turn_agent(env, stream=True)
async def _setup_cold_restart_session(env: BenchEnvironment) -> str:
"""Create a host-backed session and complete its first turn.
This establishes the durable conversation and its runner binding before
the per-sample preparation stops the runner. Every measured message then
resumes this same existing session through the automatic relaunch path.
"""
agent_id = await _setup_turn_agent(env, stream=True)
session_id = await env.create_hosted_session(agent_id)
await env.drive_turn(session_id, _TURN_PROMPT)
return session_id
async def _prepare_cold_restart(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Stop the existing session's runner before a cold-restart sample."""
session_id = cast(str, ctx) # _setup_cold_restart_session
await env.stop_session_runner(session_id)
async def _teardown_cold_restart(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Stop the runner left online after the final first-token sample."""
session_id = cast(str, ctx) # _setup_cold_restart_session
with contextlib.suppress(Exception):
await env.stop_session_runner(session_id)
async def _setup_warm_session(env: BenchEnvironment) -> str:
"""Create+bind a session and drive one warm-up turn; return the session id.
The warm-up pays the cold-start cost (runner spawn + executor construction)
so the measured op times only steady-state per-turn overhead.
"""
agent_id = await _setup_turn_agent(env)
session_id = await env.create_bound_session(agent_id)
await env.drive_turn(session_id, _TURN_PROMPT)
return session_id
async def _setup_streaming_session(env: BenchEnvironment) -> str:
"""Warm session whose mock reply streams deltas — for the TTFT journey."""
agent_id = await _setup_turn_agent(env, stream=True)
session_id = await env.create_bound_session(agent_id)
await env.drive_turn(session_id, _TURN_PROMPT)
return session_id
async def _setup_interrupt_session(env: BenchEnvironment) -> str:
"""Create+bind a session for the interrupt journey; return the session id.
Configures a ``block=True`` mock response so each turn parks in ``running``
until the gate is released — giving the interrupt something to cancel
mid-flight, deterministically.
"""
name = await env.ensure_agent()
agent_id = await env.agent_id(name)
session_id = await env.create_bound_session(agent_id)
await env.configure_mock([{"text": _TURN_REPLY, "block": True}])
return session_id
async def _measure_session_cold_start(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Time the real UI cold path: create host-bound session → first token.
Faithfully imitates the Web UI's New Chat flow on a fresh session (see
``BenchEnvironment.cold_start_first_delta``): create a host-bound session
(which fires ``host.launch_runner`` at the host daemon and returns before
the runner connects), attach the SSE stream, wait for its ready heartbeat,
POST the first message, and return on the first response.
Because the message posts while the runner is still booting, the server's
connect-grace wait is on the timed path — so the measured span captures the
true new-conversation cost: host launch + runner boot + reverse-tunnel
connect + first-token pipeline.
Each iteration is its own fresh session with its own host-launched runner.
The server never stops an external-host runner on idle (only on an explicit
stop/delete, neither of which the UI first-message path does), so each
iteration's runner stays connected until the daemon is SIGTERM'd at env
teardown, which reaps them together. That is bounded — ``_RUNNER_MAX_ITERATIONS``
(+ warmups) runners at most, all cleaned up at the end — so we deliberately
skip per-iteration teardown: stopping the runner would add a
stop-round-trip to a journey whose whole point is to time the fresh-launch
cost, and would not reflect what a real first message does.
"""
agent_id = cast(str, ctx) # _setup_turn_agent (stream=True)
await env.cold_start_first_delta(agent_id, _TURN_PROMPT)
async def _measure_session_cold_restart(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Post to an existing session with a dead runner; await first token."""
session_id = cast(str, ctx) # _setup_cold_restart_session
await env.cold_restart_first_delta(session_id, _TURN_PROMPT)
async def _measure_warm_turn(env: BenchEnvironment, ctx: JourneyContext) -> None:
session_id = cast(str, ctx) # _setup_warm_session
await env.drive_turn(session_id, _TURN_PROMPT)
async def _measure_time_to_first_token(env: BenchEnvironment, ctx: JourneyContext) -> None:
session_id = cast(str, ctx) # _setup_warm_session
await env.time_to_first_delta(session_id, _TURN_PROMPT)
async def _measure_interrupt(env: BenchEnvironment, ctx: JourneyContext) -> None:
session_id = cast(str, ctx) # _setup_interrupt_session
await env.drive_and_interrupt(session_id)
async def _setup_runner_file_session(env: BenchEnvironment) -> str:
"""Bind a session to the runner and plant a file to read; return its id.
No turn is driven and no mock reply is configured — the measured op is a
filesystem read proxied to the runner, which never calls the LLM.
"""
name = await env.ensure_agent()
agent_id = await env.agent_id(name)
session_id = await env.create_bound_session(agent_id)
await env.write_runner_file(session_id, _RUNNER_FILE_PATH, _RUNNER_FILE_CONTENT)
return session_id
async def _measure_read_runner_file(env: BenchEnvironment, ctx: JourneyContext) -> None:
session_id = cast(str, ctx) # _setup_runner_file_session
await env.read_runner_file(session_id, _RUNNER_FILE_PATH)
# ── policy evaluate ──────────────────────────────────────────
def _bench_policy_allow(_event: dict) -> dict: # type: ignore[type-arg]
"""Benchmark policy function: always ALLOW. Self-contained in this module."""
return {"result": "allow"}
_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
# Build a bundle like BenchEnvironment._agent_bundle but with a policy
# declared so any_policies_apply is true and the full engine runs.
executor: dict[str, object] = {
"type": "omnigent",
"model": env.model,
"config": {"harness": env.harness},
}
config: dict[str, object] = {
"spec_version": 1,
"name": "bench-policy-agent",
"prompt": "benchmark",
"executor": executor,
"guardrails": {
"policies": {
"allow_all": {
"type": "function",
"on": ["tool_call"],
"function": "dev.benchmarks.omnigent.journeys._bench_policy_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()
# Register the agent + create a session in one call via the bundle upload
# path (``POST /v1/sessions`` multipart). ``/v1/agents`` is GET-only.
resp = await env.client.post(
"/v1/sessions",
data={"metadata": "{}"},
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
)
resp.raise_for_status()
body = resp.json()
# Bundle upload returns ``session_id`` (not ``id``).
session_id = body.get("session_id") or body["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()
# ── CLI startup (omnigent polly against the local bench server) ──────────────
# Signal that the REPL is ready — the last spinner message before the prompt.
# polly (omnigent run) emits this just before the agent REPL appears.
_CLI_STARTUP_READY_SIGNAL = "Launching your agent"
# Per-attempt timeout. With the bench host daemon pre-running (needs_host=True),
# polly reuses it; remaining work is session + runner connect ~5-20s on CI.
_CLI_STARTUP_TIMEOUT_S = 60
# ~5s per attempt; cap so a large --iterations stays in budget.
_CLI_STARTUP_MAX_ITERATIONS = 3
async def _prepare_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> None:
"""Stop stale daemons before each timed cli_startup iteration.
A leftover host daemon from the previous iteration causes the next
``omnigent polly`` to fail with "runner tunnel rejection (HTTP 401)"
or "host is on another replica". Runs outside the latency timer.
"""
del env
omnigent_bin = os.environ.get("OMNIGENT_BIN") or shutil.which("omnigent")
if omnigent_bin is None:
return
await asyncio.to_thread(
subprocess.run,
[omnigent_bin, "stop"],
capture_output=True,
timeout=15,
check=False,
)
async def _measure_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> None:
"""Time ``omnigent polly --server`` from invocation to REPL ready.
Spawns ``omnigent polly --server <local>`` via pexpect and times until
``"Launching your agent…"`` appears — the last spinner message before the
agent REPL. Using polly (the bundled openai-agents harness) avoids any
external binary dependency while exercising the same startup path as
``omnigent claude``: daemon start, session create, runner launch, and
runner connect.
Requires ``pexpect``. No external LLM binary needed.
:param env: Benchmark environment — ``env.base_url`` is the local server URL.
:param _ctx: Unused (no setup context).
:raises RuntimeError: On timeout or process exit before the ready signal.
"""
try:
import pexpect
except ImportError as exc:
raise RuntimeError(
"pexpect is required for cli_startup. Install with: pip install pexpect"
) from exc
omnigent_bin = os.environ.get("OMNIGENT_BIN") or shutil.which("omnigent")
if omnigent_bin is None:
raise RuntimeError("omnigent binary not found. Set OMNIGENT_BIN or add omnigent to PATH.")
child = pexpect.spawn(
omnigent_bin,
args=["polly", "--server", env.base_url],
timeout=_CLI_STARTUP_TIMEOUT_S,
encoding="utf-8",
codec_errors="ignore",
env=dict(os.environ),
)
try:
idx = child.expect([pexpect.TIMEOUT, pexpect.EOF, _CLI_STARTUP_READY_SIGNAL])
if idx == 0:
raise RuntimeError(
f"Timed out after {_CLI_STARTUP_TIMEOUT_S}s waiting for "
f"{_CLI_STARTUP_READY_SIGNAL!r}"
)
if idx == 1:
output = (child.before or "").strip()
raise RuntimeError(
f"Process exited before {_CLI_STARTUP_READY_SIGNAL!r}. "
f"Last output: {output[-200:]!r}"
)
child.sendline("/exit")
child.expect([pexpect.EOF, pexpect.TIMEOUT], timeout=10)
finally:
if child.isalive():
child.terminate(force=True)
# ── native hook spawn (no server involved) ───────────────────
# Claude Code blocks its TUI on command hooks, so one hook subprocess's whole
# lifetime is user-visible latency: the MessageDisplay hook runs once per
# streamed text chunk, and the same interpreter+import cost fronts every
# statusline refresh and per-tool-call policy hook. Spawn the per-chunk hook
# exactly as Claude Code does — isolated interpreter, module entrypoint, JSON
# payload on stdin — and time the full process lifetime. The import-graph side
# of this guarantee is pinned by tests/test_claude_native_message_display_hook.
_HOOK_SPAWN_PAYLOAD = json.dumps(
{
"hook_event_name": "MessageDisplay",
"message_id": "bench-message",
"index": 0,
"final": False,
"delta": "benchmark chunk",
}
).encode()
async def _setup_hook_spawn(env: BenchEnvironment) -> JourneyContext:
"""A throwaway bridge dir for the hook's appended deltas file."""
del env
return tempfile.mkdtemp(prefix="omnigent-bench-hook-")
async def _measure_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Spawn the MessageDisplay hook once, as Claude Code does, and wait."""
del env
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-I",
"-m",
"omnigent.claude_native_message_display_hook",
"--bridge-dir",
str(ctx),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate(_HOOK_SPAWN_PAYLOAD)
if proc.returncode != 0:
raise RuntimeError(
f"hook exited {proc.returncode}: {stderr.decode('utf-8', 'replace')[:200]}"
)
async def _teardown_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Remove the throwaway bridge dir."""
del env
shutil.rmtree(str(ctx), ignore_errors=True)
# ── registry ─────────────────────────────────────────────────
ALL_JOURNEYS: dict[str, Journey] = {
j.name: j
for j in (
Journey(
name="list_sessions",
kind="latency",
measure=_measure_list_sessions,
concurrency_safe=True,
description="GET /v1/sessions — session list read.",
),
Journey(
name="create_session",
kind="latency",
measure=_measure_create_session,
setup=_setup_agent_id,
concurrency_safe=True,
description="POST /v1/sessions then DELETE — session create.",
),
Journey(
name="get_session",
kind="latency",
measure=_measure_get_session,
setup=_setup_target_session,
concurrency_safe=True,
description="GET /v1/sessions/{id} — single-session snapshot.",
),
Journey(
name="load_conversation_history",
kind="latency",
measure=_measure_load_history,
setup=_setup_target_session,
concurrency_safe=True,
description="GET /v1/sessions/{id}/items — conversation history read.",
),
Journey(
name="search_sessions",
kind="latency",
measure=_measure_search_sessions,
concurrency_safe=True,
description="GET /v1/sessions?search_query= — unindexed LIKE over titles + items.",
),
Journey(
name="list_projects",
kind="latency",
measure=_measure_list_projects,
concurrency_safe=True,
description="GET /v1/sessions/projects — sidebar project list (dual-read union).",
),
Journey(
name="list_project_sessions",
kind="latency",
measure=_measure_list_project_sessions,
setup=_setup_project_name,
concurrency_safe=True,
description="GET /v1/sessions?project= — a project folder's sessions (dual-read).",
),
Journey(
name="fork_session",
kind="latency",
measure=_measure_fork_session,
setup=_setup_fork_session,
teardown=_teardown_fork_session,
concurrency_safe=True,
description="POST /v1/sessions/{id}/fork — session fork (deep-copy); DELETE untimed.",
),
Journey(
name="add_comment",
kind="latency",
measure=_measure_add_comment,
setup=_setup_target_session,
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",
kind="latency",
measure=_measure_session_cold_start,
setup=_setup_cold_start_agent,
needs_runner=True,
needs_host=True,
max_iterations=_RUNNER_MAX_ITERATIONS,
description="Create a host-bound session (fires host.launch_runner) then "
"time create → attach SSE → send → first token — the real UI cold path.",
),
Journey(
name="session_cold_restart",
kind="latency",
measure=_measure_session_cold_restart,
setup=_setup_cold_restart_session,
prepare=_prepare_cold_restart,
teardown=_teardown_cold_restart,
needs_runner=True,
needs_host=True,
max_iterations=_RUNNER_MAX_ITERATIONS,
description="Stop the runner for an existing host-bound session, then time "
"POST message → automatic runner relaunch → first token.",
),
Journey(
name="warm_turn",
kind="latency",
measure=_measure_warm_turn,
setup=_setup_warm_session,
needs_runner=True,
max_iterations=_RUNNER_MAX_ITERATIONS,
description="Drive a turn on an already-warm session (steady-state overhead).",
),
Journey(
name="time_to_first_token",
kind="latency",
measure=_measure_time_to_first_token,
setup=_setup_streaming_session,
needs_runner=True,
max_iterations=_RUNNER_MAX_ITERATIONS,
description="Post a turn; time to the first streamed output_text delta.",
),
Journey(
name="interrupt",
kind="latency",
measure=_measure_interrupt,
setup=_setup_interrupt_session,
needs_runner=True,
max_iterations=_RUNNER_MAX_ITERATIONS,
description="Interrupt a running (gated) turn; time to cancellation.",
),
Journey(
name="read_runner_file",
kind="latency",
measure=_measure_read_runner_file,
setup=_setup_runner_file_session,
needs_runner=True,
max_iterations=_RUNNER_FS_MAX_ITERATIONS,
description="GET .../environments/default/filesystem/{path} — runner file read proxy.",
),
Journey(
name="native_hook_spawn",
kind="latency",
measure=_measure_hook_spawn,
setup=_setup_hook_spawn,
teardown=_teardown_hook_spawn,
description="Spawn the per-chunk MessageDisplay hook exactly as Claude Code does.",
),
Journey(
name="cli_startup",
kind="latency",
measure=_measure_cli_startup,
prepare=_prepare_cli_startup,
max_iterations=_CLI_STARTUP_MAX_ITERATIONS,
skip_warmup=True,
description=(
"Spawn `omnigent polly --server` and time invocation → REPL ready "
"(daemon + session + runner connect). No LLM call needed. "
"Requires pexpect."
),
),
)
}
# Registry alias — kept for callers that enumerate opt-in journeys explicitly.
OPT_IN_JOURNEYS: dict[str, Journey] = {}
def resolve_journeys(names: list[str] | None) -> list[Journey]:
"""Resolve requested journey *names* (or all when ``None``/empty).
:raises KeyError: If a requested name isn't registered.
"""
if not names:
return list(ALL_JOURNEYS.values())
resolved = []
for name in names:
if name not in ALL_JOURNEYS:
raise KeyError(f"unknown journey {name!r}; known: {', '.join(ALL_JOURNEYS)}")
resolved.append(ALL_JOURNEYS[name])
return resolved