chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:12:00 +08:00
commit 3de48288cb
2986 changed files with 1131193 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Performance benchmarks (runnable via ``uv run``, not shipped)."""
+250
View File
@@ -0,0 +1,250 @@
# Omnigent performance benchmark
Baseline, repeatable latency/throughput numbers for key Omnigent user
journeys, so we can track them over time and catch regressions. Modeled on
MLflow's `dev/benchmarks/gateway/` workflow.
The harness boots a real `omnigent server`, drives the selected journeys under
load, prints latency/throughput tables, and writes a versioned JSON report.
Two families: **HTTP/API journeys** (server + DB, no runner/LLM — fast and
low-noise) and **full-turn journeys** (a real agent turn through the runner +
a zero-latency mock LLM). See *Journeys* below.
By default the server boots a fresh, empty SQLite DB, which gives best-case
numbers that don't move with load. For meaningful results, point it at a
**pre-seeded corpus** (`seed.py`) and, ideally, at **Postgres** — production
runs on Databricks Lakebase (Postgres), whose per-query round-trip + pooling
cost SQLite doesn't have. See *Seeding* and *Backends* below.
## Run it
```bash
# All journeys, sequential latency (100 iterations × 3 runs each).
uv run --no-sync dev/benchmarks/omnigent/run.py
# A subset, writing a report for CI artifact upload.
uv run --no-sync dev/benchmarks/omnigent/run.py \
--journeys list_sessions,load_conversation_history \
--iterations 200 --runs 3 --output bench.json
# Throughput mode: >1 concurrency drives concurrency-safe journeys as load.
uv run --no-sync dev/benchmarks/omnigent/run.py \
--requests 500 --concurrency 25 --runs 3
# CI gating: exit 1 if a threshold is breached.
uv run --no-sync dev/benchmarks/omnigent/run.py --max-p50-ms 25 --max-p99-ms 100
```
`--no-sync` runs against the already-installed venv. (A bare `uv run` may try to
rebuild the project, which fails in a git worktree without a Node web-UI build;
`OMNIGENT_SKIP_WEB_UI=true uv sync` prepares the venv once, then use
`--no-sync`.)
Key flags (`--help` for all): `--journeys A,B`, `--database-uri URI` (seeded
corpus / Postgres; default: throwaway empty SQLite), `--iterations N` (per
latency run), `--requests N` / `--concurrency N` (throughput), `--runs N`,
`--warmup N`, `--output FILE`, `--min-rps` / `--max-p50-ms` / `--max-p99-ms`
(CI thresholds).
## Journeys
### HTTP/API (server + DB, runner-free)
| Journey | Operation timed | Stressed by |
| --- | --- | --- |
| `list_sessions` | `GET /v1/sessions` — session-list read | session count |
| `create_session` | `POST /v1/sessions` then `DELETE` — session create | write path |
| `get_session` | `GET /v1/sessions/{id}` — single-session snapshot | (O(1)) |
| `load_conversation_history` | `GET /v1/sessions/{id}/items` — history read | items/session |
| `search_sessions` | `GET /v1/sessions?search_query=` — unindexed `LIKE` | total item count |
| `fork_session` | `POST /v1/sessions/{id}/fork` — fork (deep-copy items); forks deleted in teardown, untimed | items/session |
| `add_comment` | `POST /v1/sessions/{id}/comments` — create a review comment | write path |
Read journeys target a **pre-seeded** session when the DB has a corpus; against
an empty DB they self-seed a small fallback session over HTTP (the
`external_conversation_item` event — appends items without starting a task), so
they still work with no runner or LLM.
### Full-turn (runner + mock LLM)
These drive a real agent turn end-to-end — `POST …/events` → server → **runner**
→ in-process executor → mock LLM → stream back → `idle`. Selecting any of them
boots `BenchEnvironment(with_runner=True)` automatically.
Each turn costs ~1 s+ (vs. the millisecond HTTP journeys), so these journeys
cap their latency iterations (`Journey.max_iterations`, currently 5) — a large
`--iterations` tuned for the HTTP journeys is clamped down for them so the run
stays within the CI time budget, with `--runs` providing the repeats. The cap
only lowers the count, never raises it. A cold start never deletes its session,
so sessions accumulate across a run; keeping the count small also keeps that
drift negligible (~2 ms/turn).
| Journey | Operation timed |
| --- | --- |
| `session_cold_start` | Create+bind a fresh session and drive its first turn to `idle` (runner spawn + executor construction + turn) |
| `warm_turn` | Drive a turn on an already-warm session — steady-state dispatch overhead |
| `time_to_first_token` | Post a turn; time to the first streamed `output_text` delta |
| `interrupt` | Interrupt a running (gated) turn; time to cancellation |
| `read_runner_file` | `GET .../environments/default/filesystem/{path}` — server → runner filesystem read proxy |
`read_runner_file` needs a runner but does **not** drive a turn or call the LLM:
its setup plants a file via `PUT`, and the timed op is the proxied read (a
localhost round-trip). Being far cheaper than a turn, it uses a higher iteration
cap (50) than the full-turn journeys.
**Only measure what we control.** Full-turn journeys always use the
**`openai-agents`** SDK harness, which runs **in-process** (a call into the
`agents` library + an HTTP call to the mock LLM) — no vendor binary, no external
process. Native harnesses (e.g. `claude-native`) launch the real vendor CLI
into a tmux pane, whose startup we don't control, so they're deliberately
excluded. The mock LLM is zero-latency, so every number is omnigent
dispatch/streaming/cancel overhead, not model latency.
Add a journey by registering a `Journey` in `journeys.py` (set `needs_runner`
for full-turn journeys).
## Seeding a realistic corpus
`seed.py` writes a sizeable, deterministic corpus directly through the store
API (no HTTP, no runner) into the same DB the server then boots against:
```bash
# Seed 5000 sessions × 50 items into a SQLite file, then benchmark against it.
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri sqlite:////abs/path/bench.db --sessions 5000 --items-per-session 50
uv run --no-sync dev/benchmarks/omnigent/run.py \
--database-uri sqlite:////abs/path/bench.db --output bench.json
```
Seeding is **idempotent**: a matching corpus (same sessions/items/schema) is
detected and reused, so re-running is a fast no-op — pass `--reseed` to force,
or a differing config to be warned. SQLite absolute paths need four slashes
(`sqlite:////abs/...`). The reuse marker records the DB's Alembic head read at
seed time, so a corpus from an older schema is automatically reseeded — no
manual revision bookkeeping. `test_seed_creates_listable_corpus` (which seeds
through the store, running migrations to the current head) is the safety net
that a schema change hasn't broken seeding.
## Backends
`--database-uri` selects the DB; the report's `backend` field (`sqlite` /
`postgres` / `mysql`) is derived from the URI scheme so results group by
backend.
- **SQLite** (default) — in-process; fast, but not prod-representative.
- **Postgres** — `postgresql+psycopg://user@host:5432/db` (the fully-qualified
`+psycopg` form; the server CLI does not normalize a bare `postgresql://`).
Requires `psycopg[binary]` (the `databricks` extra). Matches prod's
round-trip/pooling profile. Stand up a local one with
`docker run -e POSTGRES_PASSWORD=… -p 5432:5432 postgres:16`.
- **MySQL** — `mysql+mysqldb://user@host:3306/db`. Requires the `mysqlclient`
driver (`pip install mysqlclient`, which needs the `libmysqlclient-dev`
system library) — it is not in any extra. A supported backend, though prod
runs on Postgres. Stand up a local one with
`docker run -e MYSQL_ROOT_PASSWORD=… -e MYSQL_DATABASE=benchdb -p 3306:3306 mysql:8.0`.
## Output → Databricks → dashboard
The harness writes JSON only. Storage and charting live in Databricks:
```
run.py --output bench.json → GitHub Actions artifact → Databricks notebook (ETL) → Delta table → AI/BI dashboard
(this repo) (CI, follow-up) (workspace, yours)
```
The repo's contract is the **JSON schema** below. A workspace notebook (owned
outside this repo, modeled on MLflow's gateway ETL) pulls the CI artifacts via
the GitHub API, flattens each run's `summary` + `runs` + metadata, and
`saveAsTable`s into a Delta table the dashboard reads. `sample_output.json` is a
committed, faithful example so the notebook can be written against a real
document without running the harness.
### JSON schema (`schema.py`, `SCHEMA_VERSION`)
```jsonc
{
"schema_version": 1,
"generated_at": "<ISO-8601 UTC>",
"git_sha": "<HEAD sha>",
"git_branch": "<branch>",
"host": {"platform": "...", "python": "...", "cpu_count": 12},
"harness": "http-only",
"config": {"iterations": 100, "requests": 500, "concurrency": 1,
"runs": 3, "warmup": 10, "with_runner": false,
"backend": "sqlite"},
"journeys": {
"<journey name>": {
"kind": "latency" | "throughput",
"backend": "sqlite" | "postgres" | "mysql",
"runs": [ // one per --runs
{"n_success": N, "n_failures": N, "failures": {"HTTP 500": 1},
"wall_time_s": , "mean_ms": , "p50_ms": , "p95_ms": ,
"p99_ms": , "max_ms": , "rps": }
],
"summary": {"avg_mean_ms": , "avg_p50_ms": , "avg_p95_ms": ,
"avg_p99_ms": , "avg_rps": } // averaged across runs
}
}
}
```
The per-journey `summary` + `runs` shape mirrors MLflow's gateway benchmark, so
the same ETL flatten works — keyed by `journey` and `backend`. Bump
`SCHEMA_VERSION` on any breaking shape change so the notebook can branch on it.
## Layout
| File | Role |
| --- | --- |
| `run.py` | CLI orchestrator + entrypoint |
| `seed.py` | deterministic corpus seeder (store API) |
| `journeys.py` | `Journey` dataclass, latency/throughput runners, registry |
| `environment.py` | server (± runner + mock LLM) lifecycle; `--database-uri` |
| `measure.py` | `RunResult`, percentile, aggregation, thresholds, tables |
| `schema.py` | `SCHEMA_VERSION`, `build_report`, git/host metadata |
| `sample_output.json` | committed example of the JSON contract |
The smoke test is `tests/benchmarks/test_benchmark_smoke.py` (boots the server
with tiny counts + a seeded-corpus unit test; runs on the normal CI lane, no
creds).
## CI
`.github/workflows/benchmark.yml` runs nightly (and on dispatch) as a backend
matrix — `sqlite`, `postgres` (a `postgres:16` service container), and `mysql`
(a `mysql:8.0` service container; the `mysqlclient` driver is installed on that
leg only). Each leg seeds a corpus (SQLite reuses a cache keyed on the schema
head + `seed.py` + corpus config, so a migration busts the cache and forces a
reseed; Postgres and MySQL are fresh per run), runs the benchmark, and uploads
`benchmark-results-<backend>-<run_id>.json`. The workspace notebook pulls those
artifacts.
Schema changes need no manual step: the seed always targets the current
migrated schema (migrations run when the store is constructed), the reuse
marker records the head read at seed time (so old corpora auto-reseed), and
`test_seed_creates_listable_corpus` fails if a migration genuinely breaks
seeding.
## Follow-ups
- **Subagent spawn.** A planned full-turn journey (`needs_runner=True`): the
parent agent emits a `sys_session_send` tool call, the runner dispatches a
child session, and the parent auto-wakes with the collected result. It's
fully mockable with the zero-latency mock LLM (no real model) — script the
parent's queue to emit the tool call and the child's queue to return a short
reply, then poll for the child's marker. It needs the parent bundle to declare
a sub-agent under `tools:` (extend `_agent_bundle`); the pattern is in
`tests/e2e/test_coder_subagent.py`.
- **Excluded journeys** (agent-behaviour-dependent, deliberately not measured):
multi-turn and tool-calling turns (dominated by the agent's own choices) and
large-history turns (the O(N) `history_to_input_items` conversion is real app
work but only fires on a cold runner cache, so isolating it entangles with
cold-start cost).
- **CI matrix.** Runner journeys are backend-agnostic (they exercise runner
dispatch, not big DB reads), so the nightly workflow can run them on the
SQLite leg only rather than both — wire a runner `--journeys` set into
`benchmark.yml` when desired.
- **Simulated provider latency.** The mock LLM returns at ~zero latency, which
is what isolates omnigent overhead. A fixed per-response delay knob would let
turns model end-user wall-clock instead; it's a small change behind the
`configure_mock` / `set_mock_fallback` seam if that's ever wanted.
+7
View File
@@ -0,0 +1,7 @@
"""Omnigent user-journey performance benchmark.
Stands up a real server + runner against a zero-latency mock LLM, drives
key user journeys under load, and emits a versioned JSON report of latency
percentiles and throughput. See ``README.md`` for the workflow and how the
workspace ETL notebook consumes the JSON.
"""
+694
View File
@@ -0,0 +1,694 @@
"""Benchmark environment lifecycle.
:class:`BenchEnvironment` is an async context manager that stands up a real
Omnigent ``server`` with no Databricks credentials. Two modes:
- ``with_runner=False`` (default): server + SQLite DB only. Enough for the
HTTP/API journeys, which never drive an agent turn.
- ``with_runner=True``: additionally spawns a zero-latency mock LLM and a
sibling ``runner``, routes the server-side prompt-policy classifier at the
mock (via ``--config``), and sets an ALLOW fallback — everything the
full-turn journeys need.
A full env is a strict superset of the HTTP-only env, so both modes share one
class; the runner mode is gated behind the flag rather than forked into a
separate type. It mirrors the proven ``live_server`` e2e recipe
(``tests/e2e/conftest.py``) and reuses the credential-free spawn core: the
compat helpers (so subprocesses import this worktree) and
``token_bound_runner_id``.
"""
from __future__ import annotations
import asyncio
import contextlib
import io
import os
import signal
import socket
import subprocess
import sys
import tarfile
import time
import uuid
from pathlib import Path
from typing import IO
import httpx
import yaml
from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN, token_bound_runner_id
from tests._helpers.compat import (
apply_runner_env,
apply_server_env,
compat_runner_cwd,
compat_server_cwd,
runner_executable,
server_executable,
)
_REPO_ROOT = Path(__file__).resolve().parents[3]
_MOCK_SERVER = _REPO_ROOT / "tests" / "server" / "integration" / "mock_llm_server.py"
_HEALTH_TIMEOUT_S = 90.0
_MOCK_TIMEOUT_S = 15.0
_POLL_INTERVAL_S = 0.2
_TURN_TIMEOUT_S = 180.0
# Terminal SSE events — if one arrives before any delta, the turn produced no
# streamed text (a failure for the TTFT journey).
_STREAM_TERMINAL_EVENTS = frozenset(
{"response.completed", "response.failed", "response.cancelled"}
)
# The server persists an interrupted turn as a synthetic user message whose
# text contains this marker (see tests/e2e/test_cancel_history.py).
_CANCELLATION_MARKER = "interrupted"
# Default full-turn agent (with_runner=True). The mock ignores the model for
# routing (its "default" queue serves any request), but the key is baked into
# the spec so the harness has a concrete model to send.
_DEFAULT_MODEL = "mock-bench-brain"
_DEFAULT_HARNESS = "openai-agents"
# Server-side prompt-policy classifier queue key. In runner mode we set an
# ALLOW fallback here so a classifier call (if the agent trips one) never
# blocks or returns non-verdict text.
_POLICY_LLM_KEY = "_policy_llm_"
_POLICY_ALLOW = '{"action": "allow", "reason": ""}'
def _find_free_port() -> int:
"""Bind an ephemeral port and return it (races are tolerated by retries)."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
class BenchEnvironment:
"""Async context manager owning the benchmark's server (± runner + mock).
:param with_runner: When ``False`` (default), boot the server only — the
v1 HTTP-journey path. When ``True``, also spawn the mock LLM and a
runner and wire the policy classifier at the mock — the phase-2
full-turn path.
:param database_uri: SQLAlchemy URI the server boots against. ``None``
(default) uses a fresh throwaway SQLite file in the temp dir — the
empty-DB path. Pass a pre-seeded URI (e.g. a seeded SQLite file, or a
``postgresql+psycopg://…`` instance) to benchmark against a realistic
corpus. Postgres must be the fully-qualified ``+psycopg`` form — the
server CLI does not normalize it.
:param harness: Harness for full-turn agents when ``with_runner`` (default
``openai-agents``, a base dependency needing no vendor CLI binary).
:param model: Model string baked into registered agent specs.
"""
def __init__(
self,
*,
with_runner: bool = False,
database_uri: str | None = None,
harness: str = _DEFAULT_HARNESS,
model: str = _DEFAULT_MODEL,
) -> None:
self.with_runner = with_runner
self.database_uri = database_uri
self.harness = harness
self.model = model
self.base_url = ""
self.mock_url = ""
self.runner_id = ""
self.client: httpx.AsyncClient | None = None
self._tmp = Path("/tmp") / f"omni-bench-{uuid.uuid4().hex[:8]}"
self._mock_proc: subprocess.Popen[bytes] | None = None
self._server_proc: subprocess.Popen[bytes] | None = None
self._runner_proc: subprocess.Popen[bytes] | None = None
self._log_handles: list[IO[bytes]] = []
self._agent_cache: dict[str, str] = {}
# ── lifecycle ────────────────────────────────────────────
async def __aenter__(self) -> BenchEnvironment:
await asyncio.to_thread(self._start)
self.client = httpx.AsyncClient(
base_url=self.base_url,
timeout=300.0,
headers={"Origin": OMNIGENT_INTERNAL_WS_ORIGIN},
)
if self.with_runner:
# ALLOW fallback so a server-side classifier call resolves against
# the mock (never api.openai.com) and returns a valid verdict.
await self._mock_post(
"/mock/set_fallback", {"key": _POLICY_LLM_KEY, "text": _POLICY_ALLOW}
)
return self
async def __aexit__(self, *exc: object) -> None:
if self.client is not None:
await self.client.aclose()
await asyncio.to_thread(self._stop)
def _start(self) -> None:
"""Spawn the server (± mock + runner) and block until ready."""
self._tmp.mkdir(mode=0o700, parents=True, exist_ok=True)
artifact_dir = self._tmp / "artifacts"
artifact_dir.mkdir(exist_ok=True)
if self.with_runner:
mock_port = _find_free_port()
self.mock_url = f"http://127.0.0.1:{mock_port}"
self._mock_proc = self._spawn_mock(mock_port)
self._wait_mock_ready()
port = _find_free_port()
self.base_url = f"http://localhost:{port}"
binding_token = uuid.uuid4().hex
base_env = {**os.environ}
if self.with_runner:
self.runner_id = token_bound_runner_id(binding_token)
base_env["OPENAI_API_KEY"] = "mock-key"
# The OpenAI SDK appends /responses, so include /v1 in the base.
base_env["OPENAI_BASE_URL"] = f"{self.mock_url}/v1"
# Prepend the worktree so subprocesses import this branch's source.
apply_server_env(base_env, _REPO_ROOT)
self._server_proc = self._spawn_server(port, base_env, binding_token, artifact_dir)
if self.with_runner:
self._runner_proc = self._spawn_runner(base_env, binding_token)
self._wait_ready()
def _stop(self) -> None:
"""Terminate runner, server, and mock; remove the temp dir."""
for proc in (self._runner_proc, self._server_proc, self._mock_proc):
if proc is not None and proc.poll() is None:
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
for handle in self._log_handles:
handle.close()
import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
# ── spawns ───────────────────────────────────────────────
def _log(self, name: str) -> IO[bytes]:
handle = (self._tmp / name).open("wb")
self._log_handles.append(handle)
return handle
def _spawn_mock(self, port: int) -> subprocess.Popen[bytes]:
return subprocess.Popen(
[sys.executable, str(_MOCK_SERVER), str(port)],
env={**os.environ, "PYTHONPATH": str(_REPO_ROOT)},
stdout=self._log("mock.log"),
stderr=subprocess.STDOUT,
)
def _spawn_server(
self,
port: int,
base_env: dict[str, str],
binding_token: str,
artifact_dir: Path,
) -> subprocess.Popen[bytes]:
# Pre-seeded URI when given (realistic corpus), else a throwaway SQLite
# file in the temp dir (the empty-DB path). SQLite absolute paths need
# four slashes; the temp path is absolute.
db_uri = self.database_uri or f"sqlite:///{self._tmp / 'bench.db'}"
args = [
server_executable(),
"-m",
"omnigent.cli",
"server",
"--port",
str(port),
"--database-uri",
db_uri,
"--artifact-location",
str(artifact_dir),
]
env = {**base_env}
if self.with_runner:
# Route the server-side policy-classifier LLM at the mock, mirroring
# live_server. Without this the classifier's client defaults to
# api.openai.com and errors. Server-only mode needs no llm config —
# the classifier only builds under OMNIGENT_SMART_ROUTING=1.
server_cfg = self._tmp / "server.yaml"
server_cfg.write_text(
yaml.safe_dump(
{
"llm": {
"model": _POLICY_LLM_KEY,
"connection": {
"base_url": f"{self.mock_url}/v1",
"api_key": "mock-key",
},
}
}
)
)
args.extend(["--config", str(server_cfg)])
env["OMNIGENT_RUNNER_TUNNEL_TOKEN"] = binding_token
return subprocess.Popen(
args,
env=env,
cwd=compat_server_cwd(),
stdout=self._log("server.log"),
stderr=subprocess.STDOUT,
)
def _spawn_runner(
self, base_env: dict[str, str], binding_token: str
) -> subprocess.Popen[bytes]:
# Point the runner's filesystem workspace at the temp dir so file
# writes (e.g. read_runner_file's setup) land there and are cleaned up
# on teardown, rather than in the launch cwd (its default).
workspace = self._tmp / "workspace"
workspace.mkdir(exist_ok=True)
runner_env = apply_runner_env(
{
**base_env,
"OMNIGENT_RUNNER_ID": self.runner_id,
"OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN": binding_token,
"OMNIGENT_RUNNER_PARENT_PID": str(os.getpid()),
"RUNNER_SERVER_URL": self.base_url,
"OMNIGENT_RUNNER_WORKSPACE": str(workspace),
}
)
return subprocess.Popen(
[runner_executable(), "-m", "omnigent.runner._entry"],
env=runner_env,
cwd=compat_runner_cwd(),
stdout=self._log("runner.log"),
stderr=subprocess.STDOUT,
)
# ── readiness ────────────────────────────────────────────
def _wait_mock_ready(self) -> None:
deadline = time.monotonic() + _MOCK_TIMEOUT_S
while time.monotonic() < deadline:
try:
if httpx.get(f"{self.mock_url}/stats", timeout=1).status_code == 200:
return
except httpx.HTTPError:
pass
time.sleep(0.1)
raise RuntimeError(f"mock LLM not ready within {_MOCK_TIMEOUT_S}s; logs in {self._tmp}")
def _wait_ready(self) -> None:
"""Wait for ``/health`` (and, in runner mode, the runner online)."""
deadline = time.monotonic() + _HEALTH_TIMEOUT_S
while time.monotonic() < deadline:
try:
health = httpx.get(f"{self.base_url}/health", timeout=2)
if health.status_code == 200 and self._runner_ready():
return
except httpx.HTTPError:
pass
time.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"server not ready within {_HEALTH_TIMEOUT_S}s; logs in {self._tmp}")
def _runner_ready(self) -> bool:
"""Whether the runner reports online (always ``True`` server-only)."""
if not self.with_runner:
return True
status = httpx.get(f"{self.base_url}/v1/runners/{self.runner_id}/status", timeout=2)
return status.status_code == 200 and status.json().get("online") is True
# ── mock control (runner mode only) ──────────────────────
async def _mock_post(self, path: str, body: dict[str, object]) -> None:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.post(f"{self.mock_url}{path}", json=body)
resp.raise_for_status()
async def configure_mock(
self,
responses: list[dict[str, object]],
*,
key: str = "default",
match: str | None = None,
) -> None:
"""Load a keyed response queue on the mock (see e2e ``configure_mock_llm``)."""
payload: dict[str, object] = {"key": key, "responses": responses}
if match is not None:
payload["match"] = match
await self._mock_post("/mock/configure", payload)
async def set_mock_fallback(
self, text: str, *, key: str = "default", stream: bool = False
) -> None:
"""Set a reset-surviving fallback response for a mock queue *key*.
:param stream: When ``True`` the fallback emits per-word
``output_text.delta`` events before completing — needed for the
time-to-first-token journey to observe streamed deltas.
"""
await self._mock_post("/mock/set_fallback", {"key": key, "text": text, "stream": stream})
# ── agent + session primitives ───────────────────────────
def _agent_bundle(self, name: str) -> bytes:
"""Build a ``spec_version: 1`` agent bundle.
In runner mode the executor is wired at the mock LLM (auth +
connection). Server-only, no LLM is ever called, so the bundle just
needs to be a valid spec the server can register and bind sessions to.
"""
executor: dict[str, object] = {
"type": "omnigent",
"model": self.model,
"config": {"harness": self.harness},
}
config: dict[str, object] = {
"spec_version": 1,
"name": name,
"prompt": "You are a helpful assistant used for performance benchmarking.",
"executor": executor,
}
if self.with_runner:
executor["auth"] = {
"type": "api_key",
"api_key": "mock-key",
"base_url": f"{self.mock_url}/v1",
}
executor["connection"] = {"base_url": f"{self.mock_url}/v1", "api_key": "mock-key"}
# A filesystem env so the runner can serve the resource endpoints
# (read_runner_file). Without os_env the runner has no primary
# environment to materialize and the filesystem proxy 404s.
# sandbox.type=none avoids needing a bwrap binary on the host.
config["os_env"] = {
"type": "caller_process",
"cwd": ".",
"sandbox": {"type": "none"},
}
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))
return buf.getvalue()
async def ensure_agent(self, name: str = "bench-agent") -> str:
"""Register the benchmark agent once, returning its name (idempotent)."""
assert self.client is not None
if name in self._agent_cache:
return name
resp = await self.client.post(
"/v1/sessions",
data={"metadata": "{}"},
files={"bundle": ("agent.tar.gz", self._agent_bundle(name), "application/gzip")},
)
if resp.status_code not in (200, 201, 409):
raise RuntimeError(f"agent register failed: {resp.status_code} {resp.text[:400]}")
self._agent_cache[name] = name
return name
async def agent_id(self, agent_name: str) -> str:
"""Resolve a registered agent's id by name."""
assert self.client is not None
listing = await self.client.get(
"/v1/sessions", params={"agent_name": agent_name, "limit": 1}
)
listing.raise_for_status()
return str(listing.json()["data"][0]["agent_id"])
async def create_session(self, agent_id: str) -> str:
"""Create an (unbound) session for *agent_id*, returning its id."""
assert self.client is not None
created = await self.client.post("/v1/sessions", json={"agent_id": agent_id})
created.raise_for_status()
return str(created.json()["id"])
async def seed_items(self, session_id: str, count: int) -> None:
"""Append *count* history items over HTTP, with no runner or LLM.
Uses the ``external_conversation_item`` event, which the server
appends "without starting or steering a task" — the runner-free path
for giving ``load_conversation_history`` something to read back.
Items are user messages: assistant messages require an ``agent`` field
the server only has after a real turn, and the read path this seeds is
role-agnostic — item count and size, not role, drive its cost.
"""
assert self.client is not None
for i in range(count):
body = {
"type": "external_conversation_item",
"data": {
"item_type": "message",
"item_data": {
"role": "user",
"content": [{"type": "input_text", "text": f"benchmark seed item {i}"}],
},
},
}
resp = await self.client.post(f"/v1/sessions/{session_id}/events", json=body)
resp.raise_for_status()
# ── runner-mode session driving (phase 2) ────────────────
async def create_bound_session(self, agent_id: str) -> str:
"""Create a session for *agent_id* and bind it to the runner."""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("create_bound_session requires with_runner=True")
session_id = await self.create_session(agent_id)
bound = await self.client.patch(
f"/v1/sessions/{session_id}", json={"runner_id": self.runner_id}
)
bound.raise_for_status()
return session_id
async def write_runner_file(self, session_id: str, relative_path: str, content: str) -> None:
"""Write a file into the runner's default environment over HTTP.
The server proxies the ``PUT`` to the bound runner, which writes to its
sandboxed filesystem — so this needs a runner. Used to plant a file the
read journey can then fetch back.
:raises RuntimeError: If not in runner mode.
"""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("write_runner_file requires with_runner=True")
resp = await self.client.put(
f"/v1/sessions/{session_id}/resources/environments/default/filesystem/{relative_path}",
json={"content": content, "encoding": "utf-8"},
)
resp.raise_for_status()
async def read_runner_file(self, session_id: str, relative_path: str) -> None:
"""Read a file from the runner's default environment over HTTP.
Times the server → runner filesystem proxy (a localhost round-trip); no
LLM is involved. Requires a runner — the server returns 502 without one.
:raises RuntimeError: If not in runner mode.
"""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("read_runner_file requires with_runner=True")
resp = await self.client.get(
f"/v1/sessions/{session_id}/resources/environments/default/filesystem/{relative_path}",
)
resp.raise_for_status()
async def drive_turn(
self, session_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
) -> None:
"""Post a user message and poll the session to a terminal state.
:raises RuntimeError: If not in runner mode, the turn fails, or it does
not settle within *timeout* seconds.
"""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("drive_turn requires with_runner=True")
body = {
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
}
posted = await self.client.post(f"/v1/sessions/{session_id}/events", json=body)
posted.raise_for_status()
deadline = time.monotonic() + timeout
seen_running = False
while time.monotonic() < deadline:
snap = await self.client.get(f"/v1/sessions/{session_id}")
snap.raise_for_status()
status = snap.json().get("status")
if status in ("running", "waiting"):
seen_running = True
elif status == "failed":
raise RuntimeError(f"turn failed: {snap.json().get('last_task_error')}")
elif status == "idle" and seen_running:
return
await asyncio.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"turn did not settle within {timeout}s (session {session_id})")
async def _wait_idle(self, session_id: str, *, timeout: float = _TURN_TIMEOUT_S) -> None:
"""Poll until the session is ``idle`` (a prior turn has settled)."""
assert self.client is not None
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
snap = await self.client.get(f"/v1/sessions/{session_id}")
snap.raise_for_status()
if snap.json().get("status") == "idle":
return
await asyncio.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"session did not reach idle within {timeout}s ({session_id})")
async def time_to_first_delta(
self, session_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
) -> None:
"""Post a turn and return once the first output-text delta streams back.
The session SSE stream (``GET …/stream``) is separate from the message
POST, so we subscribe first (as a concurrent task), post the turn, then
return when the first ``response.output_text.delta`` event arrives. This
times omnigent's streaming-pipeline overhead to first token — with the
zero-latency mock there is no model latency in the number.
:raises RuntimeError: If not in runner mode, or no delta / a terminal
event arrives within *timeout*.
"""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("time_to_first_delta requires with_runner=True")
connected = asyncio.Event()
first_delta = asyncio.Event()
outcome: dict[str, str] = {}
async def _read_stream() -> None:
try:
async with self.client.stream( # type: ignore[union-attr]
"GET", f"/v1/sessions/{session_id}/stream", timeout=timeout
) as resp:
# Any first line means the SSE connection is live (the server
# emits a heartbeat on connect). Signalling here lets us post
# the turn only once subscribed — without a blind sleep that
# would otherwise inflate the measured time-to-first-delta.
connected.set()
async for line in resp.aiter_lines():
if not line.startswith("event:"):
continue
etype = line[len("event:") :].strip()
if etype == "response.output_text.delta":
first_delta.set()
return
if etype in _STREAM_TERMINAL_EVENTS:
outcome["terminal"] = etype
first_delta.set()
return
except httpx.HTTPError as exc:
outcome["error"] = repr(exc)
connected.set()
first_delta.set()
# Ensure any prior turn has settled so the fresh subscription's first
# terminal event can't be the previous turn completing (which would
# otherwise race ahead of this turn's delta).
await self._wait_idle(session_id, timeout=timeout)
reader = asyncio.create_task(_read_stream())
try:
# Wait until the stream is actually connected (not a fixed sleep) so
# the measured window is post → first delta, not subscription setup.
await asyncio.wait_for(connected.wait(), timeout=timeout)
posted = await self.client.post(
f"/v1/sessions/{session_id}/events",
json={
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
},
)
posted.raise_for_status()
try:
await asyncio.wait_for(first_delta.wait(), timeout=timeout)
except TimeoutError as exc:
raise RuntimeError(
f"no output_text.delta within {timeout}s (session {session_id})"
) from exc
if "error" in outcome:
raise RuntimeError(f"stream error: {outcome['error']}")
if "terminal" in outcome:
raise RuntimeError(
f"turn reached {outcome['terminal']} before any delta (session {session_id})"
)
finally:
reader.cancel()
async def drive_and_interrupt(
self, session_id: str, *, timeout: float = _TURN_TIMEOUT_S
) -> None:
"""Drive a gated turn, interrupt it mid-flight, return when cancelled.
The caller configures a ``block=True`` mock response first (see
:meth:`configure_mock`), so the turn parks in ``running`` on the
executor's LLM call. We post an ``interrupt`` once running, wait for the
server's cancellation marker, then release the gate so the runner
unwinds cleanly. Times the server → runner → executor cancel path.
:raises RuntimeError: If not in runner mode, or the interrupt is not
honored within *timeout*.
"""
assert self.client is not None
if not self.with_runner:
raise RuntimeError("drive_and_interrupt requires with_runner=True")
body = {
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": "Interrupt me."}]},
}
posted = await self.client.post(f"/v1/sessions/{session_id}/events", json=body)
posted.raise_for_status()
deadline = time.monotonic() + timeout
interrupted = False
try:
while time.monotonic() < deadline:
snap = (await self.client.get(f"/v1/sessions/{session_id}")).json()
status = snap.get("status")
items = snap.get("items", [])
if status in ("running", "waiting") and not interrupted:
await self.client.post(
f"/v1/sessions/{session_id}/events", json={"type": "interrupt"}
)
interrupted = True
if _has_cancellation_marker(items):
return
if status == "idle" and interrupted:
if _has_cancellation_marker(items):
return
raise RuntimeError("turn settled without a cancellation marker")
await asyncio.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"interrupt not honored within {timeout}s (session {session_id})")
finally:
# Always release the gate so the blocked runner turn unwinds and
# teardown doesn't hang, even if the interrupt path errored above.
with contextlib.suppress(httpx.HTTPError):
await self._mock_post("/gate/release", {})
def _has_cancellation_marker(items: list[dict[str, object]]) -> bool:
"""Whether items include the synthetic 'interrupted' user message."""
for raw in items:
data = raw.get("data", raw)
if not isinstance(data, dict):
continue
if raw.get("type") == "message" and data.get("role") == "user":
content = data.get("content") or []
if isinstance(content, list) and any(
isinstance(b, dict) and _CANCELLATION_MARKER in str(b.get("text", ""))
for b in content
):
return True
return False
+559
View File
@@ -0,0 +1,559 @@
"""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).
``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.
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 time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Literal, cast
import httpx
from .environment import BenchEnvironment
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 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 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 description: Human-readable one-liner for ``--list``.
"""
name: str
kind: JourneyKind
measure: Callable[[BenchEnvironment, JourneyContext], Awaitable[None]]
setup: Callable[[BenchEnvironment], Awaitable[JourneyContext]] | None = None
teardown: Callable[[BenchEnvironment, JourneyContext], Awaitable[None]] | None = None
concurrency_safe: bool = False
needs_runner: bool = False
max_iterations: int | None = None
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_teardown(self, env: BenchEnvironment, ctx: JourneyContext) -> None:
if self.teardown is not None:
await self.teardown(env, ctx)
# ── timed operation (shared by both runners) ─────────────────
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 httpx.HTTPStatusError as exc:
result.record_failure(f"HTTP {exc.response.status_code}")
except Exception as exc: # noqa: BLE001 — any failure is a recorded data point
result.record_failure(exc.__class__.__name__)
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.
"""
ctx = await journey.run_setup(env)
try:
for _ in range(warmup):
with contextlib.suppress(Exception): # warmup errors are non-fatal
await journey.measure(env, ctx)
result = RunResult()
wall_start = time.perf_counter()
for _ in range(iterations):
await _timed(journey, env, ctx, result)
result.wall_time = time.perf_counter() - wall_start
return result
finally:
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).
"""
ctx = await journey.run_setup(env)
try:
sem = asyncio.Semaphore(concurrency)
async def _one(count_it: bool, result: RunResult) -> None:
async with sem:
if count_it:
await _timed(journey, env, ctx, result)
else:
with contextlib.suppress(Exception): # warmup errors are non-fatal
await journey.measure(env, ctx)
if warmup:
throwaway = RunResult()
await asyncio.gather(*[_one(False, throwaway) for _ in range(warmup)])
result = RunResult()
wall_start = time.perf_counter()
await asyncio.gather(*[_one(True, result) for _ in range(requests)])
result.wall_time = time.perf_counter() - wall_start
return result
finally:
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()
@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_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:
agent_id = cast(str, ctx) # _setup_turn_agent
session_id = await env.create_bound_session(agent_id)
await env.drive_turn(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)
# ── 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="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.",
),
# 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_turn_agent,
needs_runner=True,
max_iterations=_RUNNER_MAX_ITERATIONS,
description="Create+bind a fresh session and drive its first turn to idle.",
),
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.",
),
)
}
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
+222
View File
@@ -0,0 +1,222 @@
"""Latency/throughput measurement primitives.
Pure and I/O-free: a :class:`RunResult` accumulates per-operation latencies
and failures for one timed run, :func:`aggregate` folds several runs into the
``runs`` + ``summary`` shape the workspace ETL flattens, and
:func:`check_thresholds` gates a run in CI. Adapted from MLflow's
``dev/benchmarks/gateway/benchmark.py``.
"""
from __future__ import annotations
import math
import statistics
from dataclasses import dataclass, field
from rich.console import Console
from rich.table import Table
console = Console()
@dataclass
class RunResult:
"""Latencies and failures collected during one timed run.
:param latencies_ms: Per-operation wall-clock latency in milliseconds,
one entry per successful operation.
:param failures: Failure reason (e.g. ``"HTTP 500"`` / an exception
class name) mapped to how many times it occurred.
:param wall_time: Total elapsed seconds for the run, used for throughput.
"""
latencies_ms: list[float] = field(default_factory=list)
failures: dict[str, int] = field(default_factory=dict)
wall_time: float = 0.0
@property
def n_success(self) -> int:
"""Number of operations that completed without error."""
return len(self.latencies_ms)
@property
def n_failures(self) -> int:
"""Total failed operations across all reasons."""
return sum(self.failures.values())
@property
def throughput(self) -> float:
"""Successful operations per second over the run's wall time."""
return self.n_success / self.wall_time if self.wall_time > 0 else 0.0
def record_failure(self, reason: str) -> None:
"""Increment the count for one failure *reason*."""
self.failures[reason] = self.failures.get(reason, 0) + 1
def percentile(self, p: float) -> float:
"""Return the *p*-th percentile latency in ms (ceil-index method).
:param p: Percentile in ``[0, 100]``, e.g. ``99`` for p99.
:returns: The latency at that percentile, or ``0.0`` when no
successful operation was recorded.
"""
if not self.latencies_ms:
return 0.0
ordered = sorted(self.latencies_ms)
idx = max(0, math.ceil(p / 100 * len(ordered)) - 1)
return ordered[idx]
def mean_ms(self) -> float:
"""Mean latency in ms, or ``0.0`` when no operation succeeded."""
return statistics.mean(self.latencies_ms) if self.latencies_ms else 0.0
def max_ms(self) -> float:
"""Maximum latency in ms, or ``0.0`` when no operation succeeded."""
return max(self.latencies_ms) if self.latencies_ms else 0.0
def _run_to_dict(result: RunResult) -> dict[str, object]:
"""Flatten one :class:`RunResult` into a JSON-serializable per-run row."""
return {
"n_success": result.n_success,
"n_failures": result.n_failures,
"failures": dict(result.failures),
"wall_time_s": result.wall_time,
"mean_ms": result.mean_ms(),
"p50_ms": result.percentile(50),
"p95_ms": result.percentile(95),
"p99_ms": result.percentile(99),
"max_ms": result.max_ms(),
"rps": result.throughput,
}
def aggregate(results: list[RunResult]) -> dict[str, object]:
"""Fold per-run results into ``{"runs": [...], "summary": {...}}``.
The ``summary`` averages each metric across runs. Its keys mirror
MLflow's gateway benchmark (``avg_mean_ms`` / ``avg_p50_ms`` /
``avg_p99_ms`` / ``avg_rps``) plus ``avg_p95_ms``, so the workspace ETL
that flattens ``summary`` works unchanged.
:param results: One :class:`RunResult` per timed run (warmup excluded).
:returns: A dict with a per-run ``runs`` list and an averaged
``summary`` (empty ``summary`` when *results* is empty).
"""
runs = [_run_to_dict(r) for r in results]
if not results:
return {"runs": runs, "summary": {}}
summary = {
"avg_mean_ms": statistics.mean(r.mean_ms() for r in results),
"avg_p50_ms": statistics.mean(r.percentile(50) for r in results),
"avg_p95_ms": statistics.mean(r.percentile(95) for r in results),
"avg_p99_ms": statistics.mean(r.percentile(99) for r in results),
"avg_rps": statistics.mean(r.throughput for r in results),
}
return {"runs": runs, "summary": summary}
def check_thresholds(
results: list[RunResult],
*,
min_rps: float | None = None,
max_p50_ms: float | None = None,
max_p99_ms: float | None = None,
) -> bool:
"""Check averaged results against optional CI thresholds.
:param results: Timed runs for one journey.
:param min_rps: Fail if average throughput is below this (req/s).
:param max_p50_ms: Fail if average p50 latency exceeds this (ms).
:param max_p99_ms: Fail if average p99 latency exceeds this (ms).
:returns: ``True`` when every supplied threshold passes (vacuously
true when none are supplied or *results* is empty).
"""
if not results:
return True
avg_rps = statistics.mean(r.throughput for r in results)
avg_p50 = statistics.mean(r.percentile(50) for r in results)
avg_p99 = statistics.mean(r.percentile(99) for r in results)
passed = True
if min_rps is not None and avg_rps < min_rps:
console.print(
f" [red]THRESHOLD FAILED:[/red] avg throughput {avg_rps:.0f} req/s"
f" < minimum {min_rps:.0f} req/s"
)
passed = False
if max_p50_ms is not None and avg_p50 > max_p50_ms:
console.print(
f" [red]THRESHOLD FAILED:[/red] avg P50 {avg_p50:.1f} ms"
f" > maximum {max_p50_ms:.1f} ms"
)
passed = False
if max_p99_ms is not None and avg_p99 > max_p99_ms:
console.print(
f" [red]THRESHOLD FAILED:[/red] avg P99 {avg_p99:.1f} ms"
f" > maximum {max_p99_ms:.1f} ms"
)
passed = False
return passed
def print_results(journey_name: str, results: list[RunResult]) -> None:
"""Render per-run and averaged metrics for one journey as a rich table.
:param journey_name: Journey label used as the table title.
:param results: Timed runs to display.
"""
table = Table(
title=journey_name,
show_header=True,
header_style="bold cyan",
box=None,
padding=(0, 2),
title_justify="left",
)
table.add_column("Run", style="dim", width=5)
table.add_column("Mean ms", justify="right")
table.add_column("P50 ms", justify="right")
table.add_column("P95 ms", justify="right")
table.add_column("P99 ms", justify="right")
table.add_column("Max ms", justify="right")
table.add_column("Req/s", justify="right")
table.add_column("Failures", justify="right")
for i, r in enumerate(results):
fail_str = f"[red]{r.n_failures}[/red]" if r.n_failures else "0"
table.add_row(
str(i + 1),
f"{r.mean_ms():.1f}",
f"{r.percentile(50):.1f}",
f"{r.percentile(95):.1f}",
f"{r.percentile(99):.1f}",
f"{r.max_ms():.1f}",
f"{r.throughput:.0f}",
fail_str,
)
if len(results) > 1:
table.add_section()
table.add_row(
"[bold]avg[/bold]",
f"[bold]{statistics.mean(r.mean_ms() for r in results):.1f}[/bold]",
f"[bold]{statistics.mean(r.percentile(50) for r in results):.1f}[/bold]",
f"[bold]{statistics.mean(r.percentile(95) for r in results):.1f}[/bold]",
f"[bold]{statistics.mean(r.percentile(99) for r in results):.1f}[/bold]",
f"[bold]{statistics.mean(r.max_ms() for r in results):.1f}[/bold]",
f"[bold]{statistics.mean(r.throughput for r in results):.0f}[/bold]",
"",
)
console.print()
console.print(table)
combined: dict[str, int] = {}
for r in results:
for reason, count in r.failures.items():
combined[reason] = combined.get(reason, 0) + count
if combined:
console.print(" [red]Failure breakdown:[/red]")
for reason, count in sorted(combined.items(), key=lambda kv: -kv[1]):
console.print(f" {reason}: {count}")
+268
View File
@@ -0,0 +1,268 @@
"""Omnigent user-journey benchmark runner.
Boots a real ``omnigent server`` against a SQLite DB (no runner, no LLM),
drives the selected HTTP journeys under load, prints per-journey latency /
throughput tables, and writes a versioned JSON report. Exits non-zero if any
supplied threshold is breached.
Runs in the project venv — it imports ``omnigent`` and ``tests._helpers`` and
spawns the real server, so it is NOT a standalone PEP 723 script. Invoke with
``--no-sync`` so ``uv`` uses the existing environment instead of rebuilding the
project (which triggers a web-UI build that fails in a worktree)::
uv run --no-sync dev/benchmarks/omnigent/run.py
uv run --no-sync dev/benchmarks/omnigent/run.py --journeys list_sessions,get_session
uv run --no-sync dev/benchmarks/omnigent/run.py --requests 500 --concurrency 25 --runs 3
uv run --no-sync dev/benchmarks/omnigent/run.py --output bench.json --max-p50-ms 25
The JSON is the contract consumed by the workspace Databricks ETL notebook —
see ``README.md``.
"""
from __future__ import annotations
import argparse
import asyncio
import datetime
import json
import sys
from pathlib import Path
# Allow ``uv run <path>`` (no package context) to import the sibling modules.
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from dev.benchmarks.omnigent.environment import BenchEnvironment
from dev.benchmarks.omnigent.journeys import (
ALL_JOURNEYS,
Journey,
resolve_journeys,
run_latency,
run_throughput,
)
from dev.benchmarks.omnigent.measure import (
RunResult,
aggregate,
check_thresholds,
console,
print_results,
)
from dev.benchmarks.omnigent.schema import build_report
# Harness label stamped in the report: HTTP/DB journeys drive no agent turn;
# runner journeys drive turns through the in-process openai-agents SDK harness.
_HTTP_HARNESS = "http-only"
_RUNNER_HARNESS = "openai-agents"
def _backend_of(database_uri: str | None) -> str:
"""Classify the DB URI into a coarse backend label for the report.
``None`` is the harness's throwaway SQLite temp file. Otherwise key off the
URI scheme so the report (and the workspace dashboard) can group by backend.
"""
if database_uri is None or database_uri.startswith("sqlite"):
return "sqlite"
if database_uri.startswith("postgres"):
return "postgres"
if database_uri.startswith("mysql"):
return "mysql"
return "other"
def _effective_iterations(journey: Journey, requested: int) -> int:
"""Clamp *requested* iterations down to the journey's ``max_iterations``.
Full-turn journeys cost ~1s+ per op and cap themselves so a large
``--iterations`` (tuned for the millisecond HTTP journeys) doesn't overrun
the CI time budget. The cap only ever lowers the count, never raises it.
"""
if journey.max_iterations is not None:
return min(requested, journey.max_iterations)
return requested
async def _run_journey(
journey: Journey, env: BenchEnvironment, args: argparse.Namespace
) -> tuple[str, list[RunResult]]:
"""Run one journey's timed runs, returning its report kind + per-run results.
A journey runs as throughput when ``--concurrency > 1`` and it is
concurrency-safe; otherwise as sequential latency.
"""
as_throughput = args.concurrency > 1 and journey.concurrency_safe
iterations = _effective_iterations(journey, args.iterations)
results: list[RunResult] = []
for _ in range(args.runs):
if as_throughput:
results.append(
await run_throughput(
journey,
env,
requests=args.requests,
concurrency=args.concurrency,
warmup=args.warmup,
)
)
else:
results.append(
await run_latency(journey, env, iterations=iterations, warmup=args.warmup)
)
return ("throughput" if as_throughput else "latency"), results
async def run_benchmark(args: argparse.Namespace) -> tuple[dict[str, object], bool]:
"""Run all selected journeys and build the report.
:returns: ``(report, passed)`` where *passed* is ``False`` if any journey
breached a supplied threshold.
"""
journeys = resolve_journeys(args.journeys)
journey_results: dict[str, dict[str, object]] = {}
passed = True
backend = _backend_of(args.database_uri)
# Any full-turn journey needs the runner + mock LLM. A full env is a
# superset — HTTP journeys still run against it — so a mixed selection just
# boots with_runner=True. The harness label reflects what drove the turns.
with_runner = any(j.needs_runner for j in journeys)
harness = _RUNNER_HARNESS if with_runner else _HTTP_HARNESS
async with BenchEnvironment(with_runner=with_runner, database_uri=args.database_uri) as env:
for journey in journeys:
console.print(f"\n[bold]Benchmarking[/bold] {journey.name} [dim]({backend})[/dim]")
kind, results = await _run_journey(journey, env, args)
print_results(journey.name, results)
block = aggregate(results)
block["kind"] = kind
block["backend"] = backend
journey_results[journey.name] = block
if not check_thresholds(
results,
min_rps=args.min_rps,
max_p50_ms=args.max_p50_ms,
max_p99_ms=args.max_p99_ms,
):
passed = False
config = {
"iterations": args.iterations,
"requests": args.requests,
"concurrency": args.concurrency,
"runs": args.runs,
"warmup": args.warmup,
"with_runner": with_runner,
"backend": backend,
}
generated_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
report = build_report(
journey_results,
generated_at=generated_at,
config=config,
harness=harness,
)
return report, passed
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="omnigent-benchmark",
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--journeys",
type=lambda s: [p.strip() for p in s.split(",") if p.strip()],
default=None,
metavar="A,B,C",
help=f"Comma-separated journeys to run. Default: all ({', '.join(ALL_JOURNEYS)}).",
)
parser.add_argument(
"--database-uri",
default=None,
metavar="URI",
help="DB the server boots against — a pre-seeded SQLite file, a "
"postgresql+psycopg://… instance, or a mysql+mysqldb://… instance "
"(see seed.py). Default: a fresh throwaway SQLite DB (empty — "
"best-case numbers). The report's `backend` field is derived from this.",
)
parser.add_argument(
"--iterations",
type=int,
default=100,
metavar="N",
help="Sequential operations per latency run (default: 100).",
)
parser.add_argument(
"--requests",
type=int,
default=500,
metavar="N",
help="Total operations per throughput run — used when --concurrency>1 (default: 500).",
)
parser.add_argument(
"--concurrency",
type=int,
default=1,
metavar="N",
help="Max in-flight operations. >1 runs concurrency-safe journeys as "
"throughput (default: 1 = sequential latency).",
)
parser.add_argument(
"--runs",
type=int,
default=3,
metavar="N",
help="Timed runs per journey; results are per-run and averaged (default: 3).",
)
parser.add_argument(
"--warmup",
type=int,
default=10,
metavar="N",
help="Warmup operations discarded before each run (default: 10).",
)
parser.add_argument(
"--output",
type=Path,
default=None,
metavar="FILE",
help="Write the JSON report to FILE (for CI artifact upload).",
)
parser.add_argument(
"--min-rps",
type=float,
default=None,
metavar="N",
help="Exit 1 if any journey's avg throughput falls below N req/s.",
)
parser.add_argument(
"--max-p50-ms",
type=float,
default=None,
metavar="N",
help="Exit 1 if any journey's avg P50 latency exceeds N ms.",
)
parser.add_argument(
"--max-p99-ms",
type=float,
default=None,
metavar="N",
help="Exit 1 if any journey's avg P99 latency exceeds N ms.",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv if argv is not None else sys.argv[1:])
report, passed = asyncio.run(run_benchmark(args))
if args.output is not None:
args.output.write_text(json.dumps(report, indent=2))
console.print(f"\n Results written to [cyan]{args.output}[/cyan]")
if not passed:
console.print("\n[red]One or more thresholds failed.[/red]")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+268
View File
@@ -0,0 +1,268 @@
{
"schema_version": 1,
"generated_at": "2026-07-08T18:30:00+00:00",
"git_sha": "0000000000000000000000000000000000000000",
"git_branch": "main",
"host": {
"platform": "macOS-15.5-arm64-arm-64bit",
"python": "3.12.8",
"cpu_count": 12
},
"harness": "http-only",
"config": {
"iterations": 100,
"requests": 500,
"concurrency": 1,
"runs": 3,
"warmup": 10,
"with_runner": false,
"backend": "sqlite"
},
"journeys": {
"list_sessions": {
"runs": [
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.6587757079978473,
"mean_ms": 6.586994149838574,
"p50_ms": 6.261250004172325,
"p95_ms": 7.65325000975281,
"p99_ms": 7.9127089702524245,
"max_ms": 38.27937500318512,
"rps": 151.79673261468648
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.6238234580378048,
"mean_ms": 6.237602901528589,
"p50_ms": 6.126708001829684,
"p95_ms": 7.152916979975998,
"p99_ms": 7.425624993629754,
"max_ms": 7.667875033803284,
"rps": 160.30176280087855
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.5675292499945499,
"mean_ms": 5.674769564066082,
"p50_ms": 5.528832960408181,
"p95_ms": 6.237249996047467,
"p99_ms": 7.393250009045005,
"max_ms": 11.83562504593283,
"rps": 176.20237194992208
}
],
"summary": {
"avg_mean_ms": 6.166455538477749,
"avg_p50_ms": 5.972263655470063,
"avg_p95_ms": 7.014472328592092,
"avg_p99_ms": 7.577194657642394,
"avg_rps": 162.7669557884957
},
"kind": "latency",
"backend": "sqlite"
},
"create_session": {
"runs": [
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 2.4451411250047386,
"mean_ms": 24.450668342760764,
"p50_ms": 24.028874991927296,
"p95_ms": 27.25041698431596,
"p99_ms": 29.374166973866522,
"max_ms": 29.56758299842477,
"rps": 40.89743490769115
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 2.498600291030016,
"mean_ms": 24.985182073432952,
"p50_ms": 24.459875014144927,
"p95_ms": 28.391665953677148,
"p99_ms": 29.0600000298582,
"max_ms": 34.39550002804026,
"rps": 40.02240788932922
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 2.455423459003214,
"mean_ms": 24.553418274153955,
"p50_ms": 24.073333013802767,
"p95_ms": 27.43383398046717,
"p99_ms": 29.375000041909516,
"max_ms": 29.430416005197912,
"rps": 40.72617276394162
}
],
"summary": {
"avg_mean_ms": 24.663089563449223,
"avg_p50_ms": 24.187361006624997,
"avg_p95_ms": 27.691972306153428,
"avg_p99_ms": 29.269722348544747,
"avg_rps": 40.548671853654
},
"kind": "latency",
"backend": "sqlite"
},
"get_session": {
"runs": [
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.5546917500323616,
"mean_ms": 5.5460037814918905,
"p50_ms": 5.360124981962144,
"p95_ms": 6.925499998033047,
"p99_ms": 7.144333969336003,
"max_ms": 7.331291970331222,
"rps": 180.28030882767922
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.49645508296089247,
"mean_ms": 4.963922067545354,
"p50_ms": 4.782959003932774,
"p95_ms": 5.978292028885335,
"p99_ms": 6.7617910099215806,
"max_ms": 6.881375040393323,
"rps": 201.42809174919327
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.460644083970692,
"mean_ms": 4.605880451854318,
"p50_ms": 4.526541975792497,
"p95_ms": 5.18629199359566,
"p99_ms": 5.445250018965453,
"max_ms": 5.790999974124134,
"rps": 217.08734244020465
}
],
"summary": {
"avg_mean_ms": 5.0386021002971875,
"avg_p50_ms": 4.889875320562472,
"avg_p95_ms": 6.030028006838013,
"avg_p99_ms": 6.450458332741012,
"avg_rps": 199.59858100569238
},
"kind": "latency",
"backend": "sqlite"
},
"load_conversation_history": {
"runs": [
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.20811437495285645,
"mean_ms": 2.080691678565927,
"p50_ms": 2.037000027485192,
"p95_ms": 2.5742079596966505,
"p99_ms": 2.768124977592379,
"max_ms": 2.784749958664179,
"rps": 480.50501087516284
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.19513549999101087,
"mean_ms": 1.9509158097207546,
"p50_ms": 1.9018329912796617,
"p95_ms": 2.284207963384688,
"p99_ms": 2.4481670116074383,
"max_ms": 2.5021659675985575,
"rps": 512.4644157757384
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 0.19278304203180596,
"mean_ms": 1.9274150469573215,
"p50_ms": 1.8819589749909937,
"p95_ms": 2.2150420118123293,
"p99_ms": 2.2878749878145754,
"max_ms": 2.316958038136363,
"rps": 518.7178236532945
}
],
"summary": {
"avg_mean_ms": 1.9863408450813342,
"avg_p50_ms": 1.9402639979186158,
"avg_p95_ms": 2.3578193116312227,
"avg_p99_ms": 2.5013889923381307,
"avg_rps": 503.8957501013986
},
"kind": "latency",
"backend": "sqlite"
},
"search_sessions": {
"runs": [
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 8.150427000015043,
"mean_ms": 81.50338126753923,
"p50_ms": 80.11816703947261,
"p95_ms": 90.9090840141289,
"p99_ms": 94.67683301772922,
"max_ms": 96.44366696011275,
"rps": 12.26929582950874
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 8.152122708968818,
"mean_ms": 81.5203430026304,
"p50_ms": 79.57212498877198,
"p95_ms": 95.20591603359208,
"p99_ms": 98.80137501750141,
"max_ms": 99.93629204109311,
"rps": 12.266743714490682
},
{
"n_success": 100,
"n_failures": 0,
"failures": {},
"wall_time_s": 8.053999124967959,
"mean_ms": 80.53909589187242,
"p50_ms": 79.52124997973442,
"p95_ms": 91.39445802429691,
"p99_ms": 93.2748339837417,
"max_ms": 94.79766699951142,
"rps": 12.416192061654566
}
],
"summary": {
"avg_mean_ms": 81.18760672068068,
"avg_p50_ms": 79.73718066932634,
"avg_p95_ms": 92.50315269067262,
"avg_p99_ms": 95.58434733965744,
"avg_rps": 12.317410535217997
},
"kind": "latency",
"backend": "sqlite"
}
}
}
+89
View File
@@ -0,0 +1,89 @@
"""Benchmark report schema + metadata capture.
:func:`build_report` assembles the single JSON document the harness writes.
Its per-journey ``summary`` + ``runs`` shape mirrors MLflow's gateway
benchmark so the workspace ETL notebook flattens it unchanged — keyed by
journey (and ``harness``) instead of ``backend``. Bump :data:`SCHEMA_VERSION`
whenever the document's shape changes so the ETL can branch on it.
"""
from __future__ import annotations
import platform
import subprocess
# Incremented on any breaking change to the report document shape below.
SCHEMA_VERSION = 1
def _git(*args: str) -> str:
"""Run ``git *args`` at the repo root, returning stripped stdout or ``""``.
Never raises: a missing git, detached checkout, or non-zero exit all
surface as an empty string so a benchmark run outside a clean checkout
still produces a valid report.
"""
try:
out = subprocess.run(
["git", *args],
capture_output=True,
text=True,
timeout=10,
check=False,
)
except (OSError, subprocess.SubprocessError):
return ""
return out.stdout.strip() if out.returncode == 0 else ""
def git_sha() -> str:
"""Return the current commit SHA, or ``""`` when unavailable."""
return _git("rev-parse", "HEAD")
def git_branch() -> str:
"""Return the current branch name, or ``""`` when detached/unavailable."""
return _git("rev-parse", "--abbrev-ref", "HEAD")
def host_info() -> dict[str, object]:
"""Capture coarse host facts for cross-machine result comparison."""
import os
return {
"platform": platform.platform(),
"python": platform.python_version(),
"cpu_count": os.cpu_count(),
}
def build_report(
journey_results: dict[str, dict[str, object]],
*,
generated_at: str,
config: dict[str, object],
harness: str,
) -> dict[str, object]:
"""Assemble the full benchmark report document.
:param journey_results: Per-journey ``{"kind", "runs", "summary"}``
blocks (each ``runs``/``summary`` produced by
:func:`measure.aggregate`), keyed by journey name.
:param generated_at: ISO-8601 timestamp stamped by the caller (kept out
of this pure function so it stays deterministic under test).
:param config: The run's knobs (iterations, requests, concurrency, runs,
mock_llm) for provenance.
:param harness: Harness driving full-turn journeys, e.g.
``"openai-agents"``.
:returns: The JSON-serializable report document.
"""
return {
"schema_version": SCHEMA_VERSION,
"generated_at": generated_at,
"git_sha": git_sha(),
"git_branch": git_branch(),
"host": host_info(),
"harness": harness,
"config": config,
"journeys": journey_results,
}
+228
View File
@@ -0,0 +1,228 @@
"""Deterministic corpus seeder for the performance benchmark.
The v1 harness booted an empty DB, so the read journeys measured a best-case
near-empty table. This seeds a sizeable, realistic corpus directly through the
store API (no HTTP, no runner) so ``list_sessions`` / ``get_session`` /
``load_conversation_history`` read a production-shaped volume.
Writes to the same DB URI the server later boots against; startup migrations
are an idempotent no-op on an at-head DB. The seed is deterministic (fixed RNG,
fixed counts) so the same config always yields the same corpus — which is what
makes "seed once, reuse" sound. The reuse marker records the Alembic head read
at seed time, so a corpus from an older schema is auto-reseeded (no manual
revision bookkeeping).
Listable-corpus recipe, per session (the permission grant is the gotcha — the
loopback server resolves every request to user ``"local"`` and
``list_sessions`` filters by it):
1. ``create_session_with_agent`` — conversation + session-scoped agent row.
2. ``permission_store.grant("local", sid, LEVEL_OWNER)`` — makes it listable.
3. one batched ``append(sid, items)`` — user-role message items.
Run standalone::
uv run --no-sync dev/benchmarks/omnigent/seed.py \
--database-uri sqlite:///tmp/bench.db --sessions 5000 --items-per-session 50
"""
from __future__ import annotations
import argparse
import random
import sys
from pathlib import Path
# Allow ``uv run <path>`` (no package context) to import omnigent + siblings.
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from omnigent.db.utils import _get_head_db_revision, generate_agent_id
from omnigent.entities import MessageData, NewConversationItem
from omnigent.server.auth import LEVEL_OWNER, RESERVED_USER_LOCAL
from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore
from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore
# Label key stamped on the first seeded session recording the corpus config, so
# a later run can detect an existing (and matching) seed and skip re-seeding.
_SEED_META_LABEL = "omni_bench_seed"
# Fixed identifiers so the corpus is byte-stable across runs at a given config.
_AGENT_NAME = "bench-agent"
_DEFAULT_SESSIONS = 5000
_DEFAULT_ITEMS = 50
_DEFAULT_RNG_SEED = 1234
# A pool of realistic-ish message fragments; the RNG assembles item text from
# these so search_text has lexical variety without external data.
_FRAGMENTS = (
"investigate the failing migration",
"the runner keeps disconnecting under load",
"add pagination to the sessions endpoint",
"why does the policy classifier time out",
"refactor the conversation store append path",
"benchmark the list endpoints against postgres",
"the web UI drops the last streamed token",
"trace the tunnel handshake for this runner id",
"summarize the changes in this pull request",
"reproduce the elicitation race on reconnect",
)
def _meta_value(sessions: int, items_per_session: int, rng_seed: int, head: str) -> str:
"""Serialize the corpus config into the seed-marker label value.
Includes the Alembic *head* read at seed time, so a corpus seeded under an
older schema auto-mismatches the current head and is reseeded — no
hand-maintained revision constant.
"""
return f"sessions={sessions};items={items_per_session};rng={rng_seed};rev={head}"
def _existing_seed_meta(conv: SqlAlchemyConversationStore) -> str | None:
"""Return the seed-marker label value if a bench corpus already exists.
Looks up the most recent ``bench-agent`` session and reads its
``omni_bench_seed`` label. ``None`` means no (recognizable) seed present.
"""
listing = conv.list_conversations(limit=1, agent_name=_AGENT_NAME)
if not listing.data:
return None
marked = conv.get_conversation(listing.data[0].id)
return marked.labels.get(_SEED_META_LABEL) if marked is not None else None
def _make_items(rng: random.Random, count: int) -> list[NewConversationItem]:
"""Build *count* deterministic user-role message items.
User-role only: assistant messages require an ``agent`` field the store
only assigns after a real turn, and the seeded read path is role-agnostic.
"""
items: list[NewConversationItem] = []
for i in range(count):
text = f"{rng.choice(_FRAGMENTS)} (item {i})"
items.append(
NewConversationItem(
type="message",
response_id=f"resp_seed_{i}",
data=MessageData(role="user", content=[{"type": "input_text", "text": text}]),
)
)
return items
def seed(
db_uri: str,
*,
sessions: int = _DEFAULT_SESSIONS,
items_per_session: int = _DEFAULT_ITEMS,
rng_seed: int = _DEFAULT_RNG_SEED,
reseed: bool = False,
) -> int:
"""Seed *sessions* sessions × *items_per_session* items into *db_uri*.
Idempotent: if a matching seed already exists (same config + schema
revision) it is left untouched unless *reseed* is set. Constructing the
store runs migrations to head on first init, so *db_uri* need not
pre-exist.
:param db_uri: SQLAlchemy URI the server will also boot against, e.g.
``"sqlite:///abs/bench.db"`` or ``"postgresql+psycopg://…"``.
:param sessions: Number of listable sessions to create.
:param items_per_session: Conversation items appended to each session.
:param rng_seed: Seed for the deterministic text RNG.
:param reseed: Seed even when a matching corpus is already present.
:returns: The number of sessions created (0 when a matching seed is reused).
"""
conv = SqlAlchemyConversationStore(db_uri)
perms = SqlAlchemyPermissionStore(db_uri)
# Read the current schema head at runtime (no DB contacted) and fold it into
# the reuse marker, so a corpus from an older schema is auto-reseeded.
head = _get_head_db_revision("sqlite:///:memory:")
want = _meta_value(sessions, items_per_session, rng_seed, head)
if not reseed:
existing = _existing_seed_meta(conv)
if existing == want:
print(f"seed: matching corpus already present ({want}); skipping")
return 0
if existing is not None:
print(f"seed: existing corpus differs ({existing!r} != {want!r}); pass --reseed")
return 0
perms.ensure_user(RESERVED_USER_LOCAL)
rng = random.Random(rng_seed)
last_sid = ""
for s in range(sessions):
created = conv.create_session_with_agent(
agent_id=generate_agent_id(),
agent_name=_AGENT_NAME,
agent_bundle_location="bench/seed", # never validated on the read path
agent_description=None,
title=f"bench session {s}: {rng.choice(_FRAGMENTS)}",
)
sid = created.conversation.id
last_sid = sid
perms.grant(RESERVED_USER_LOCAL, sid, LEVEL_OWNER)
if items_per_session:
conv.append(sid, _make_items(rng, items_per_session))
if sessions >= 100 and s % (sessions // 10) == 0 and s:
print(f"seed: {s}/{sessions} sessions")
# Stamp the corpus config on the LAST (newest) session — that's the one
# ``_existing_seed_meta``'s default desc listing returns, so the reuse
# check finds it regardless of corpus size.
if last_sid:
conv.set_labels(last_sid, {_SEED_META_LABEL: want})
print(f"seed: created {sessions} sessions × {items_per_session} items ({want})")
return sessions
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="omnigent-benchmark-seed",
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--database-uri",
metavar="URI",
help="DB to seed. Required unless --print-head.",
)
parser.add_argument("--sessions", type=int, default=_DEFAULT_SESSIONS, metavar="N")
parser.add_argument("--items-per-session", type=int, default=_DEFAULT_ITEMS, metavar="N")
parser.add_argument("--rng-seed", type=int, default=_DEFAULT_RNG_SEED, metavar="N")
parser.add_argument(
"--reseed",
action="store_true",
help="Seed even if a matching corpus is already present.",
)
parser.add_argument(
"--print-head",
action="store_true",
help="Print the repo's Alembic head revision and exit (drift-check helper).",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv if argv is not None else sys.argv[1:])
if args.print_head:
print(_get_head_db_revision("sqlite:///:memory:"))
return 0
if not args.database_uri:
print("seed: --database-uri is required (unless --print-head)", file=sys.stderr)
return 2
seed(
args.database_uri,
sessions=args.sessions,
items_per_session=args.items_per_session,
rng_seed=args.rng_seed,
reseed=args.reseed,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())