From 0fb7bf8dd941317e5aea448aed06b9f2652e96af Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon Date: Thu, 5 Mar 2026 20:52:15 +0000 Subject: [PATCH 1/8] feat: add auto-generated API reference via gen-files + literate-nav - Add gen-files and literate-nav mkdocs plugins - Create docs/gen_ref_pages.py (adapted from IPW) to auto-generate API reference pages - Replace 15 manual API nav entries with single auto-generated api-reference/ section - Add mkdocs-gen-files and mkdocs-literate-nav to docs extra dependencies Cross-pollinated from intelligence-per-watt docs patterns. Co-Authored-By: Claude Opus 4.6 --- docs/gen_ref_pages.py | 31 +++++++++++++++++++++++++++++++ mkdocs.yml | 23 ++++++----------------- pyproject.toml | 2 ++ 3 files changed, 39 insertions(+), 17 deletions(-) create mode 100644 docs/gen_ref_pages.py diff --git a/docs/gen_ref_pages.py b/docs/gen_ref_pages.py new file mode 100644 index 00000000..cd185750 --- /dev/null +++ b/docs/gen_ref_pages.py @@ -0,0 +1,31 @@ +"""Generate the code reference pages.""" +from pathlib import Path + +import mkdocs_gen_files + +nav = mkdocs_gen_files.Nav() +src = Path("src") + +for path in sorted(src.rglob("*.py")): + module_path = path.relative_to(src).with_suffix("") + doc_path = path.relative_to(src).with_suffix(".md") + full_doc_path = Path("api-reference", doc_path) + + parts = tuple(module_path.parts) + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + elif parts[-1].startswith("_"): + continue + + nav[parts] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + identifier = ".".join(parts) + fd.write(f"::: {identifier}") + + mkdocs_gen_files.set_edit_path(full_doc_path, path) + +with mkdocs_gen_files.open("api-reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/mkdocs.yml b/mkdocs.yml index 80650f1c..57c7374a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,11 @@ theme: plugins: - search + - gen-files: + scripts: + - docs/gen_ref_pages.py + - literate-nav: + nav_file: SUMMARY.md - mkdocstrings: default_handler: python handlers: @@ -145,23 +150,7 @@ nav: - Design Principles: architecture/design-principles.md - Security: architecture/security.md - Channels: architecture/channels.md - - API Reference: - - api/index.md - - SDK (Jarvis): api/sdk.md - - Core: api/core.md - - Engine: api/engine.md - - Agents: api/agents.md - - Memory: api/memory.md - - Tools: api/tools.md - - Intelligence: api/intelligence.md - - Learning: api/learning.md - - Traces: api/traces.md - - Telemetry: api/telemetry.md - - Benchmarks: api/bench.md - - Evals: api/evals.md - - Server: api/server.md - - Security: api/security.md - - Channels: api/channels.md + - API Reference: api-reference/ - Deployment: - Docker: deployment/docker.md - systemd (Linux): deployment/systemd.md diff --git a/pyproject.toml b/pyproject.toml index 18de6422..b928c5a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,8 @@ docs = [ "mkdocs>=1.6", "mkdocs-material>=9.5", "mkdocstrings[python]>=0.25", + "mkdocs-gen-files>=0.5", + "mkdocs-literate-nav>=0.6", ] [project.scripts] From 25f0d0ca1e60ab6ec2004ef63939b7a261a982ec Mon Sep 17 00:00:00 2001 From: Gabriel Bo Date: Thu, 5 Mar 2026 13:14:42 -0800 Subject: [PATCH 2/8] fixing desktop set up backend --- docs/downloads.md | 22 +++++++++++++++------- docs/index.md | 16 ++++++++++++++-- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/downloads.md b/docs/downloads.md index 83983f90..8ce51fed 100644 --- a/docs/downloads.md +++ b/docs/downloads.md @@ -11,8 +11,15 @@ OpenJarvis runs entirely on your hardware. Choose the interface that fits your w ## Desktop App -The native desktop app bundles Ollama (the inference engine) and the OpenJarvis Python backend -into a single installer. Download, open, and start chatting — no terminal required. +The desktop app is a native window for the OpenJarvis chat UI. All inference and backend +processing happens on your local machine — the app connects to the backend you start locally. + +!!! info "Backend required" + Start the backend before opening the desktop app. The quickstart script handles everything: + ```bash + git clone https://github.com/HazyResearch/OpenJarvis.git && cd OpenJarvis + ./scripts/quickstart.sh + ``` ### Download @@ -29,13 +36,14 @@ into a single installer. Download, open, and start chatting — no terminal requ ### What's included -The desktop app ships with: +The desktop app provides: -- **Ollama** sidecar — inference engine runs automatically in the background -- **OpenJarvis backend** — Python API server managed by the app -- **Full chat UI** — same interface as the browser app +- **Full chat UI** — same interface as the browser app, in a native window - **Energy monitoring** — real-time power consumption tracking -- **Telemetry dashboard** — token throughput, latency, and cost comparison +- **Telemetry dashboard** — token throughput, latency, and cost comparison vs. cloud models +- **System tray** — quick access without keeping a terminal open + +The backend (Ollama, Python API server, inference) runs separately on your machine. ### Build from source diff --git a/docs/index.md b/docs/index.md index 43296c5e..f80d3972 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,13 +34,25 @@ Everything runs on your hardware. Cloud APIs are optional. === "Desktop App" - Download the native desktop app — it bundles Ollama and the Python backend - so everything works out of the box. + The desktop app is a native window for the OpenJarvis UI. + The backend (Ollama + inference) runs on your machine — start it first, then open the app. + + **Step 1.** Start the backend: + + ```bash + git clone https://github.com/HazyResearch/OpenJarvis.git + cd OpenJarvis + ./scripts/quickstart.sh + ``` + + **Step 2.** Download and open the desktop app: [Download for macOS (Apple Silicon)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_aarch64.dmg){ .md-button .md-button--primary } Also available for [macOS (Intel)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64.dmg), [Windows](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64-setup.exe), [Linux (DEB)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.deb), and [Linux (RPM)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.rpm). See the [Downloads](downloads.md) page for details. + The app connects to `http://localhost:8000` automatically. + === "Python SDK" ```python From fdde659347917cc96c4f75a81bbcc5ac8ba64a06 Mon Sep 17 00:00:00 2001 From: Gabriel Bo Date: Thu, 5 Mar 2026 13:19:27 -0800 Subject: [PATCH 3/8] uploaded working dmg for macos, ubuntu, and windows --- docs/downloads.md | 10 +++++----- docs/index.md | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/downloads.md b/docs/downloads.md index 8ce51fed..3732392c 100644 --- a/docs/downloads.md +++ b/docs/downloads.md @@ -25,11 +25,11 @@ processing happens on your local machine — the app connects to the backend you | Platform | Download | Notes | |----------|----------|-------| -| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_aarch64.dmg) | M1/M2/M3/M4 Macs | -| macOS (Intel) | [:material-download: **OpenJarvis.dmg**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64.dmg) | Intel Macs (2020 and earlier) | -| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64-setup.exe) | Windows 10+ | -| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.deb) | Ubuntu, Debian | -| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.rpm) | Fedora, RHEL | +| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_aarch64.dmg) | M1/M2/M3/M4 Macs | +| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_x64-setup.exe) | Windows 10+ | +| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_amd64.deb) | Ubuntu, Debian | +| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis-1.0.0-1.x86_64.rpm) | Fedora, RHEL | +| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_amd64.AppImage) | Any distro | !!! tip "All releases" Browse all versions on the [GitHub Releases](https://github.com/HazyResearch/OpenJarvis/releases) page. diff --git a/docs/index.md b/docs/index.md index f80d3972..11c62d1c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,9 +47,9 @@ Everything runs on your hardware. Cloud APIs are optional. **Step 2.** Download and open the desktop app: - [Download for macOS (Apple Silicon)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_aarch64.dmg){ .md-button .md-button--primary } + [Download for macOS (Apple Silicon)](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_aarch64.dmg){ .md-button .md-button--primary } - Also available for [macOS (Intel)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64.dmg), [Windows](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64-setup.exe), [Linux (DEB)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.deb), and [Linux (RPM)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.rpm). See the [Downloads](downloads.md) page for details. + Also available for [Windows](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_x64-setup.exe), [Linux (DEB)](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_amd64.deb), and [Linux (RPM)](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis-1.0.0-1.x86_64.rpm). See the [Downloads](downloads.md) page for details. The app connects to `http://localhost:8000` automatically. From 0ad9fdba8a68d9d837febf28dc643e9a0693f70a Mon Sep 17 00:00:00 2001 From: Gabriel Bo Date: Thu, 5 Mar 2026 13:45:26 -0800 Subject: [PATCH 4/8] feat: removing downloads and consolidating all to getting started --- docs/downloads.md | 238 ---------------- docs/getting-started/installation.md | 403 +++++++++++++++------------ docs/index.md | 12 +- mkdocs.yml | 1 - 4 files changed, 233 insertions(+), 421 deletions(-) delete mode 100644 docs/downloads.md diff --git a/docs/downloads.md b/docs/downloads.md deleted file mode 100644 index 83983f90..00000000 --- a/docs/downloads.md +++ /dev/null @@ -1,238 +0,0 @@ ---- -title: Downloads -description: Download the OpenJarvis desktop app, browser app, CLI, or Python SDK ---- - -# Downloads - -OpenJarvis runs entirely on your hardware. Choose the interface that fits your workflow. - ---- - -## Desktop App - -The native desktop app bundles Ollama (the inference engine) and the OpenJarvis Python backend -into a single installer. Download, open, and start chatting — no terminal required. - -### Download - -| Platform | Download | Notes | -|----------|----------|-------| -| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_aarch64.dmg) | M1/M2/M3/M4 Macs | -| macOS (Intel) | [:material-download: **OpenJarvis.dmg**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64.dmg) | Intel Macs (2020 and earlier) | -| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64-setup.exe) | Windows 10+ | -| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.deb) | Ubuntu, Debian | -| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.rpm) | Fedora, RHEL | - -!!! tip "All releases" - Browse all versions on the [GitHub Releases](https://github.com/HazyResearch/OpenJarvis/releases) page. - -### What's included - -The desktop app ships with: - -- **Ollama** sidecar — inference engine runs automatically in the background -- **OpenJarvis backend** — Python API server managed by the app -- **Full chat UI** — same interface as the browser app -- **Energy monitoring** — real-time power consumption tracking -- **Telemetry dashboard** — token throughput, latency, and cost comparison - -### Build from source - -```bash -git clone https://github.com/HazyResearch/OpenJarvis.git -cd OpenJarvis/desktop -npm install -npm run tauri build -``` - -The built installer will be in `desktop/src-tauri/target/release/bundle/`. - ---- - -## Browser App - -Run the full chat UI in your browser. Everything stays local — the backend runs on -your machine and the frontend connects via `localhost`. - -### One-command setup - -```bash -git clone https://github.com/HazyResearch/OpenJarvis.git -cd OpenJarvis -./scripts/quickstart.sh -``` - -The script handles everything: - -1. Checks for Python 3.10+ and Node.js 22+ -2. Installs Ollama if not present and pulls a starter model -3. Installs Python and frontend dependencies -4. Starts the backend API server and frontend dev server -5. Opens `http://localhost:5173` in your browser - -### Manual setup - -If you prefer to run each step yourself: - -=== "Step 1: Clone and install" - - ```bash - git clone https://github.com/HazyResearch/OpenJarvis.git - cd OpenJarvis - uv sync --extra server - cd frontend && npm install && cd .. - ``` - -=== "Step 2: Start Ollama" - - ```bash - # Install from https://ollama.com if not already installed - ollama serve & - ollama pull qwen3:0.6b - ``` - -=== "Step 3: Start backend" - - ```bash - uv run jarvis serve --port 8000 - ``` - -=== "Step 4: Start frontend" - - ```bash - cd frontend - npm run dev - ``` - -Then open [http://localhost:5173](http://localhost:5173). - -### What you get - -- **Chat interface** — markdown rendering, streaming responses, conversation history -- **Tool use** — calculator, web search, code interpreter, file I/O -- **System panel** — live telemetry, energy monitoring, cost comparison vs. cloud models -- **Dashboard** — energy graphs, trace debugging, cost breakdown -- **Settings** — model selection, agent configuration, theme toggle - ---- - -## CLI - -The command-line interface is the fastest way to interact with OpenJarvis -programmatically. Every feature is accessible from the terminal. - -### Install - -=== "uv (recommended)" - - ```bash - uv pip install openjarvis - ``` - -=== "pip" - - ```bash - pip install openjarvis - ``` - -=== "From source" - - ```bash - git clone https://github.com/HazyResearch/OpenJarvis.git - cd OpenJarvis - uv sync - ``` - -### Verify - -```bash -jarvis --version -# jarvis, version 1.0.0 -``` - -### First commands - -```bash -# Ask a question -jarvis ask "What is the capital of France?" - -# Use an agent with tools -jarvis ask --agent orchestrator --tools calculator "What is 137 * 42?" - -# Start the API server -jarvis serve --port 8000 - -# Run diagnostics -jarvis doctor - -# List available models -jarvis model list - -# Interactive chat -jarvis chat -``` - -!!! info "Inference backend required" - The CLI requires a running inference backend (e.g., Ollama). See the - [Installation guide](getting-started/installation.md#setting-up-an-inference-backend) - for setup instructions. - ---- - -## Python SDK - -For programmatic access, the `Jarvis` class provides a high-level sync API. - -### Install - -```bash -pip install openjarvis -``` - -### Quick example - -```python -from openjarvis import Jarvis - -j = Jarvis() -print(j.ask("Explain quicksort in two sentences.")) -j.close() -``` - -### With agents and tools - -```python -result = j.ask_full( - "What is the square root of 144?", - agent="orchestrator", - tools=["calculator", "think"], -) -print(result["content"]) # "12" -print(result["tool_results"]) # tool invocations -print(result["turns"]) # number of agent turns -``` - -### Composition layer - -For full control, use the `SystemBuilder`: - -```python -from openjarvis import SystemBuilder - -system = ( - SystemBuilder() - .engine("ollama") - .model("qwen3:8b") - .agent("orchestrator") - .tools(["calculator", "web_search", "file_read"]) - .enable_telemetry() - .enable_traces() - .build() -) - -result = system.ask("Summarize the latest AI news.") -system.close() -``` - -See the [Python SDK guide](user-guide/python-sdk.md) for the full API reference. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 793e960a..f2166982 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,15 +1,20 @@ --- title: Installation -description: Install OpenJarvis and set up an inference backend +description: Get OpenJarvis running — browser app, desktop app, CLI, or Python SDK --- # Installation -This guide covers installing OpenJarvis, its optional extras, and setting up an inference backend. +OpenJarvis runs entirely on your hardware. Choose the interface that fits your workflow. -## Quickstart (Recommended) +--- -The fastest way to get everything running — browser UI, backend, and inference engine — with a single command: +## Browser App + +Run the full chat UI in your browser. Everything stays local — the backend runs on +your machine and the frontend connects via `localhost`. + +### One-command setup ```bash git clone https://github.com/HazyResearch/OpenJarvis.git @@ -17,32 +22,101 @@ cd OpenJarvis ./scripts/quickstart.sh ``` -This script checks for Python 3.10+, Node.js, and Ollama (installing what's missing), pulls a starter model, installs all dependencies, starts the backend and frontend servers, and opens the chat UI in your browser. +The script handles everything: -!!! tip "Desktop app" - Prefer a native app? Download the [Desktop App](../downloads.md#desktop-app) instead — it bundles everything into a single installer. +1. Checks for Python 3.10+ and Node.js 18+ +2. Installs Ollama if not present and pulls a starter model +3. Installs Python and frontend dependencies +4. Starts the backend API server and frontend dev server +5. Opens `http://localhost:5173` in your browser ---- +### Manual setup -## Requirements +If you prefer to run each step yourself: -| Requirement | Version | Notes | -|-------------|---------|-------| -| Python | 3.10+ | Required | -| Inference backend | Any | At least one of Ollama, vLLM, llama.cpp, SGLang, or a cloud API | -| Node.js | 18+ | Required for the browser UI; 22+ for OpenClaw agent | - -## Installing OpenJarvis - -=== "Quickstart script" +=== "Step 1: Clone and install" ```bash git clone https://github.com/HazyResearch/OpenJarvis.git cd OpenJarvis - ./scripts/quickstart.sh + uv sync --extra server + cd frontend && npm install && cd .. ``` - Handles everything: deps, Ollama, model pull, backend, frontend, browser open. +=== "Step 2: Start Ollama" + + ```bash + # Install from https://ollama.com if not already installed + ollama serve & + ollama pull qwen3:0.6b + ``` + +=== "Step 3: Start backend" + + ```bash + uv run jarvis serve --port 8000 + ``` + +=== "Step 4: Start frontend" + + ```bash + cd frontend + npm run dev + ``` + +Then open [http://localhost:5173](http://localhost:5173). + +--- + +## Desktop App + +The desktop app is a native window for the OpenJarvis chat UI. All inference and backend +processing happens on your local machine — the app connects to the backend you start locally. + +### Setup + +**Step 1.** Start the backend (same as Browser App): + +```bash +git clone https://github.com/HazyResearch/OpenJarvis.git +cd OpenJarvis +./scripts/quickstart.sh +``` + +**Step 2.** Download and open the desktop app: + +| Platform | Download | +|----------|----------| +| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_aarch64.dmg) | +| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_x64-setup.exe) | +| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_amd64.deb) | +| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis-1.0.0-1.x86_64.rpm) | +| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_amd64.AppImage) | + +The app connects to `http://localhost:8000` automatically. + +!!! tip "All releases" + Browse all versions on the [GitHub Releases](https://github.com/HazyResearch/OpenJarvis/releases) page. + +### Build from source + +```bash +git clone https://github.com/HazyResearch/OpenJarvis.git +cd OpenJarvis/desktop +npm install +npm run tauri build +``` + +The built installer will be in `desktop/src-tauri/target/release/bundle/`. + +--- + +## CLI + +The command-line interface is the fastest way to interact with OpenJarvis +programmatically. Every feature is accessible from the terminal. + +### Install === "uv (recommended)" @@ -64,64 +138,134 @@ This script checks for Python 3.10+, Node.js, and Ollama (installing what's miss uv sync ``` - For development with all dev tools: +### Verify - ```bash - uv sync --extra dev - ``` +```bash +jarvis --version +# jarvis, version 1.0.0 +``` + +### First commands + +```bash +jarvis ask "What is the capital of France?" + +jarvis ask --agent orchestrator --tools calculator "What is 137 * 42?" + +jarvis serve --port 8000 + +jarvis doctor + +jarvis model list + +jarvis chat +``` + +!!! info "Inference backend required" + The CLI requires a running inference backend (e.g., Ollama). See + [Setting up an inference backend](#setting-up-an-inference-backend) below. + +--- + +## Python SDK + +For programmatic access, the `Jarvis` class provides a high-level sync API. + +### Install + +```bash +pip install openjarvis +``` + +### Quick example + +```python +from openjarvis import Jarvis + +j = Jarvis() +print(j.ask("Explain quicksort in two sentences.")) +j.close() +``` + +### With agents and tools + +```python +result = j.ask_full( + "What is the square root of 144?", + agent="orchestrator", + tools=["calculator", "think"], +) +print(result["content"]) # "12" +print(result["tool_results"]) # tool invocations +print(result["turns"]) # number of agent turns +``` + +### Composition layer + +For full control, use the `SystemBuilder`: + +```python +from openjarvis import SystemBuilder + +system = ( + SystemBuilder() + .engine("ollama") + .model("qwen3:8b") + .agent("orchestrator") + .tools(["calculator", "web_search", "file_read"]) + .enable_telemetry() + .enable_traces() + .build() +) + +result = system.ask("Summarize the latest AI news.") +system.close() +``` + +See the [Python SDK guide](../user-guide/python-sdk.md) for the full API reference. + +--- + +## Requirements + +| Requirement | Version | Notes | +|-------------|---------|-------| +| Python | 3.10+ | Required | +| Inference backend | Any | At least one of Ollama, vLLM, llama.cpp, SGLang, or a cloud API | +| Node.js | 18+ | Required for the browser UI; 22+ for OpenClaw agent | ## Optional Extras -OpenJarvis uses optional extras to keep the base installation lightweight. Install only what you need. +OpenJarvis uses optional extras to keep the base installation lightweight. ### Inference Backends -| Extra | Install Command | Dependencies | Description | -|-------|----------------|--------------|-------------| -| `inference-ollama` | `pip install 'openjarvis[inference-ollama]'` | None (HTTP-based) | Ollama backend. Communicates via HTTP API. | -| `inference-vllm` | `pip install 'openjarvis[inference-vllm]'` | None (HTTP-based) | vLLM backend. Communicates via OpenAI-compatible API. | -| `inference-llamacpp` | `pip install 'openjarvis[inference-llamacpp]'` | None (HTTP-based) | llama.cpp server backend. | -| `inference-cloud` | `pip install 'openjarvis[inference-cloud]'` | `openai>=1.30`, `anthropic>=0.30` | Cloud inference via OpenAI and Anthropic APIs. | -| `inference-google` | `pip install 'openjarvis[inference-google]'` | `google-genai>=1.0` | Google Gemini API backend. | +| Extra | Install Command | Description | +|-------|----------------|-------------| +| `inference-cloud` | `pip install 'openjarvis[inference-cloud]'` | OpenAI and Anthropic APIs | +| `inference-google` | `pip install 'openjarvis[inference-google]'` | Google Gemini API | !!! note "Ollama, vLLM, and llama.cpp are HTTP-based" - The `inference-ollama`, `inference-vllm`, and `inference-llamacpp` extras have no additional Python dependencies. OpenJarvis communicates with these engines over HTTP using the `httpx` library that is already a core dependency. You still need the actual engine software running on your machine or network. + These engines have no additional Python dependencies — OpenJarvis communicates over HTTP. You still need the engine software running on your machine. ### Memory Backends -| Extra | Install Command | Dependencies | Description | -|-------|----------------|--------------|-------------| -| `memory-faiss` | `pip install 'openjarvis[memory-faiss]'` | `faiss-cpu>=1.7`, `sentence-transformers>=2.2`, `numpy>=1.24` | FAISS vector store with sentence-transformer embeddings. | -| `memory-colbert` | `pip install 'openjarvis[memory-colbert]'` | `colbert-ai>=0.2`, `torch>=2.0` | ColBERTv2 late-interaction retrieval. | -| `memory-bm25` | `pip install 'openjarvis[memory-bm25]'` | `rank-bm25>=0.2.2` | BM25 sparse retrieval backend. | -| `memory-pdf` | `pip install 'openjarvis[memory-pdf]'` | `pdfplumber>=0.10` | PDF document ingestion support. | +| Extra | Install Command | Description | +|-------|----------------|-------------| +| `memory-faiss` | `pip install 'openjarvis[memory-faiss]'` | FAISS vector store | +| `memory-colbert` | `pip install 'openjarvis[memory-colbert]'` | ColBERTv2 late-interaction retrieval | +| `memory-bm25` | `pip install 'openjarvis[memory-bm25]'` | BM25 sparse retrieval | !!! tip "SQLite memory is always available" - The default SQLite/FTS5 memory backend requires no additional dependencies. It is always available and suitable for most use cases. + The default SQLite/FTS5 memory backend requires no additional dependencies. -### Tools +### Server & Other -| Extra | Install Command | Dependencies | Description | -|-------|----------------|--------------|-------------| -| `tools-search` | `pip install 'openjarvis[tools-search]'` | `tavily-python>=0.3` | Web search tool via the Tavily API. | - -### Server - -| Extra | Install Command | Dependencies | Description | -|-------|----------------|--------------|-------------| -| `server` | `pip install 'openjarvis[server]'` | `fastapi>=0.110`, `uvicorn>=0.30`, `pydantic>=2.0` | OpenAI-compatible API server (`jarvis serve`). | - -### Other Extras - -| Extra | Install Command | Dependencies | Description | -|-------|----------------|--------------|-------------| -| `agents` | `pip install 'openjarvis[agents]'` | None | Agent infrastructure (included in base). | -| `learning` | `pip install 'openjarvis[learning]'` | None | Learning/router policy system (included in base). | -| `openclaw` | `pip install 'openjarvis[openclaw]'` | None | OpenClaw agent transport layer. Requires Node.js 22+ at runtime. | -| `docs` | `pip install 'openjarvis[docs]'` | `mkdocs>=1.6`, `mkdocs-material>=9.5`, `mkdocstrings[python]>=0.25` | Documentation build tools. | -| `dev` | `pip install 'openjarvis[dev]'` | `pytest>=8`, `pytest-asyncio>=0.24`, `pytest-cov>=5`, `respx>=0.22`, `ruff>=0.4` | Development and testing tools. | - -### Installing Multiple Extras +| Extra | Install Command | Description | +|-------|----------------|-------------| +| `server` | `pip install 'openjarvis[server]'` | OpenAI-compatible API server (`jarvis serve`) | +| `dev` | `pip install 'openjarvis[dev]'` | Development and testing tools | +| `docs` | `pip install 'openjarvis[docs]'` | Documentation build tools | Combine extras with commas: @@ -129,149 +273,52 @@ Combine extras with commas: pip install 'openjarvis[server,memory-faiss,inference-cloud]' ``` -Or with `uv`: - -```bash -uv pip install 'openjarvis[server,memory-faiss,inference-cloud]' -``` - -## Verifying Installation - -After installation, verify that the CLI is available: - -```bash -jarvis --version -``` - -Expected output: - -``` -jarvis, version 1.0.0 -``` - -View all available commands: - -```bash -jarvis --help -``` - -Expected output: - -``` -Usage: jarvis [OPTIONS] COMMAND [ARGS]... - - OpenJarvis -- modular AI assistant backend - -Options: - --version Show the version and exit. - --help Show this message and exit. - -Commands: - ask Ask Jarvis a question. - bench Run inference benchmarks. - init Detect hardware and generate ~/.openjarvis/config.toml. - memory Manage the memory store. - model Manage language models. - serve Start the OpenAI-compatible API server. - telemetry Query and manage inference telemetry data. -``` - ## Setting Up an Inference Backend -OpenJarvis requires at least one inference backend to generate responses. Choose the backend that best matches your hardware. +OpenJarvis requires at least one inference backend. Choose the one that matches your hardware. -### Ollama (Recommended for most users) +### Ollama (Recommended) -Ollama is the easiest way to get started. It handles model downloading and serving automatically. +The easiest way to get started. Handles model downloading and serving automatically. -1. Install Ollama from [ollama.com](https://ollama.com) -2. Start the server: +1. Install from [ollama.com](https://ollama.com) +2. Start the server and pull a model: ```bash ollama serve + ollama pull qwen3:0.6b ``` -3. Pull a model: - - ```bash - ollama pull qwen3:8b - ``` - - Or pull directly via the Jarvis CLI: - - ```bash - jarvis model pull qwen3:8b - ``` - -4. Verify the engine is detected: - - ```bash - jarvis model list - ``` +3. Verify: `jarvis model list` !!! tip "Best for: Apple Silicon Macs, consumer NVIDIA GPUs, CPU-only systems" -### vLLM (High-throughput serving) +### vLLM -vLLM provides high-throughput serving optimized for datacenter GPUs. +High-throughput serving optimized for datacenter GPUs. -1. Install vLLM following the [official guide](https://docs.vllm.ai) -2. Start the server: +1. Install following the [official guide](https://docs.vllm.ai) +2. Start: `vllm serve Qwen/Qwen2.5-7B-Instruct` +3. Auto-detected at `http://localhost:8000` - ```bash - vllm serve Qwen/Qwen2.5-7B-Instruct - ``` +!!! tip "Best for: NVIDIA datacenter GPUs (A100, H100), AMD GPUs" -3. OpenJarvis will auto-detect it at `http://localhost:8000` +### llama.cpp -!!! tip "Best for: NVIDIA datacenter GPUs (A100, H100, L40), AMD GPUs" +Efficient CPU and GPU inference with GGUF quantized models. -### llama.cpp (Lightweight, CPU-friendly) +1. Build from [github.com/ggerganov/llama.cpp](https://github.com/ggerganov/llama.cpp) +2. Start: `llama-server -m /path/to/model.gguf --port 8080` +3. Auto-detected at `http://localhost:8080` -llama.cpp provides efficient CPU and GPU inference with GGUF quantized models. - -1. Build llama.cpp from [github.com/ggerganov/llama.cpp](https://github.com/ggerganov/llama.cpp) -2. Start the server: - - ```bash - llama-server -m /path/to/model.gguf --port 8080 - ``` - -3. OpenJarvis will auto-detect it at `http://localhost:8080` - -!!! tip "Best for: CPU-only machines, constrained environments, GGUF models" - -### SGLang - -SGLang provides structured generation and high-performance serving. - -1. Install SGLang following the [official guide](https://github.com/sgl-project/sglang) -2. Start the server: - - ```bash - python -m sglang.launch_server --model Qwen/Qwen2.5-7B-Instruct --port 30000 - ``` - -3. OpenJarvis will auto-detect it at `http://localhost:30000` - -### Cloud APIs (OpenAI, Anthropic, Google) - -For cloud-based inference, install the cloud extras and set your API keys: +### Cloud APIs ```bash pip install 'openjarvis[inference-cloud,inference-google]' -``` - -Set environment variables: - -```bash export OPENAI_API_KEY="sk-..." export ANTHROPIC_API_KEY="sk-ant-..." -export GOOGLE_API_KEY="..." ``` -OpenJarvis will automatically detect available cloud providers. - ## Next Steps - [Quick Start](quickstart.md) — Run your first query diff --git a/docs/index.md b/docs/index.md index 43296c5e..c622361d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,12 +34,16 @@ Everything runs on your hardware. Cloud APIs are optional. === "Desktop App" - Download the native desktop app — it bundles Ollama and the Python backend - so everything works out of the box. + The desktop app is a native window for the chat UI. Start the backend first, + then open the app. - [Download for macOS (Apple Silicon)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_aarch64.dmg){ .md-button .md-button--primary } + **1.** Start backend: `git clone ... && cd OpenJarvis && ./scripts/quickstart.sh` - Also available for [macOS (Intel)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64.dmg), [Windows](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_x64-setup.exe), [Linux (DEB)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.deb), and [Linux (RPM)](https://github.com/HazyResearch/OpenJarvis/releases/latest/download/OpenJarvis_amd64.rpm). See the [Downloads](downloads.md) page for details. + **2.** Download the app: + + [Download for macOS (Apple Silicon)](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_aarch64.dmg){ .md-button .md-button--primary } + + Also available for [Windows](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_x64-setup.exe), [Linux (DEB)](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis_1.0.0_amd64.deb), and [Linux (RPM)](https://github.com/HazyResearch/OpenJarvis/releases/download/desktop-latest/OpenJarvis-1.0.0-1.x86_64.rpm). See [Installation](getting-started/installation.md#desktop-app) for details. === "Python SDK" diff --git a/mkdocs.yml b/mkdocs.yml index 073f0049..2beb20d0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -124,7 +124,6 @@ extra: nav: - Home: index.md - - Downloads: downloads.md - Getting Started: - Installation: getting-started/installation.md - Quick Start: getting-started/quickstart.md From e278d3ac5ee35250fef817db957c179053292c47 Mon Sep 17 00:00:00 2001 From: Gabriel Bo Date: Thu, 5 Mar 2026 14:21:05 -0800 Subject: [PATCH 5/8] desktop app warning for damaged mac app --- docs/downloads.md | 21 +++++++++++++++++++++ docs/getting-started/installation.md | 7 +++++++ docs/index.md | 2 ++ uv.lock | 28 ++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/docs/downloads.md b/docs/downloads.md index 3732392c..b5bdf248 100644 --- a/docs/downloads.md +++ b/docs/downloads.md @@ -34,6 +34,27 @@ processing happens on your local machine — the app connects to the backend you !!! tip "All releases" Browse all versions on the [GitHub Releases](https://github.com/HazyResearch/OpenJarvis/releases) page. +### macOS: "app is damaged" fix + +macOS Gatekeeper quarantines apps downloaded from the internet that aren't notarized +by Apple. If you see **"OpenJarvis is damaged and can't be opened"**, run this in +Terminal to clear the quarantine flag: + +```bash +xattr -cr /Applications/OpenJarvis.app +``` + +Then open the app normally. If you installed from the DMG but haven't moved it to +`/Applications` yet, point the command at wherever the `.app` bundle is: + +```bash +xattr -cr ~/Downloads/OpenJarvis.app +``` + +!!! note + This is standard for open-source macOS apps distributed outside the App Store. + The command removes the quarantine extended attribute — it does not modify the app. + ### What's included The desktop app provides: diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index f2166982..b2b90f67 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -95,6 +95,13 @@ cd OpenJarvis The app connects to `http://localhost:8000` automatically. +!!! warning "macOS: \"app is damaged\"" + If macOS says the app is damaged, clear the Gatekeeper quarantine flag: + ```bash + xattr -cr /Applications/OpenJarvis.app + ``` + This is normal for open-source apps distributed outside the App Store. + !!! tip "All releases" Browse all versions on the [GitHub Releases](https://github.com/HazyResearch/OpenJarvis/releases) page. diff --git a/docs/index.md b/docs/index.md index 11c62d1c..1980b2a8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,6 +53,8 @@ Everything runs on your hardware. Cloud APIs are optional. The app connects to `http://localhost:8000` automatically. + !!! warning "macOS: run `xattr -cr /Applications/OpenJarvis.app` if the app shows as \"damaged\"." + === "Python SDK" ```python diff --git a/uv.lock b/uv.lock index 9863bc35..9c2a1b49 100644 --- a/uv.lock +++ b/uv.lock @@ -2535,6 +2535,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, ] +[[package]] +name = "mkdocs-gen-files" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/35/f26349f7fa18414eb2e25d75a6fa9c7e3186c36e1d227c0b2d785a7bd5c4/mkdocs_gen_files-0.6.0.tar.gz", hash = "sha256:52022dc14dcc0451e05e54a8f5d5e7760351b6701eff816d1e9739577ec5635e", size = 8642, upload-time = "2025-11-23T12:13:22.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/ec/72417415563c60ae01b36f0d497f1f4c803972f447ef4fb7f7746d6e07db/mkdocs_gen_files-0.6.0-py3-none-any.whl", hash = "sha256:815af15f3e2dbfda379629c1b95c02c8e6f232edf2a901186ea3b204ab1135b2", size = 8182, upload-time = "2025-11-23T12:13:20.756Z" }, +] + [[package]] name = "mkdocs-get-deps" version = "0.2.0" @@ -2549,6 +2561,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] +[[package]] +name = "mkdocs-literate-nav" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/5f/99aa379b305cd1c2084d42db3d26f6de0ea9bf2cc1d10ed17f61aff35b9a/mkdocs_literate_nav-0.6.2.tar.gz", hash = "sha256:760e1708aa4be86af81a2b56e82c739d5a8388a0eab1517ecfd8e5aa40810a75", size = 17419, upload-time = "2025-03-18T21:53:09.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/84/b5b14d2745e4dd1a90115186284e9ee1b4d0863104011ab46abb7355a1c3/mkdocs_literate_nav-0.6.2-py3-none-any.whl", hash = "sha256:0a6489a26ec7598477b56fa112056a5e3a6c15729f0214bea8a4dbc55bd5f630", size = 13261, upload-time = "2025-03-18T21:53:08.1Z" }, +] + [[package]] name = "mkdocs-material" version = "9.7.2" @@ -3391,6 +3415,8 @@ dev = [ ] docs = [ { name = "mkdocs" }, + { name = "mkdocs-gen-files" }, + { name = "mkdocs-literate-nav" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, ] @@ -3503,6 +3529,8 @@ requires-dist = [ { name = "litellm", marker = "extra == 'inference-litellm'", specifier = ">=1.40" }, { name = "mastodon-py", marker = "extra == 'channel-mastodon'", specifier = ">=1.8" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6" }, + { name = "mkdocs-gen-files", marker = "extra == 'docs'", specifier = ">=0.5" }, + { name = "mkdocs-literate-nav", marker = "extra == 'docs'", specifier = ">=0.6" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.25" }, { name = "mlx-lm", marker = "sys_platform == 'darwin' and extra == 'inference-mlx'", specifier = ">=0.19" }, From 3040f468b6fdac3195ff20bf751f6edb86226068 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon Date: Fri, 6 Mar 2026 02:00:10 +0000 Subject: [PATCH 6/8] feat: integrate Exo, Nexa, Uzu, and Apple FM inference engines Add four new OpenAI-compatible inference engines across the full stack: Rust backend: - Config structs with serde defaults (ExoEngineConfig, NexaEngineConfig, UzuEngineConfig, AppleFmEngineConfig) in openjarvis-core - Factory constructors on OpenAICompatEngine (exo, nexa, uzu, apple_fm) - Engine enum variants with delegate_engine! macro dispatch - Discovery and resolution in get_engine_static() - PyO3 bridge support in PyEngine Python frontend: - Data-driven engine class registration in openai_compat_engines.py - Config dataclasses with backward-compat host properties - Discovery host map entries - DEFAULT_SEARCH_SPACE updated with new engine backends - Apple FM shim (FastAPI wrapper for python-apple-fm-sdk, macOS only) Config & tests: - TOML config sections for all four engines - Unit tests for factory methods, enum variants, and search space Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 9 +- configs/openjarvis/config.toml | 26 + rust/crates/openjarvis-core/src/config.rs | 151 +++++ .../crates/openjarvis-engine/src/discovery.rs | 28 + .../openjarvis-engine/src/engine_enum.rs | 44 ++ .../openjarvis-engine/src/openai_compat.rs | 40 ++ rust/crates/openjarvis-python/src/engine.rs | 23 +- src/openjarvis/agents/__init__.py | 10 + src/openjarvis/agents/monitor_operative.py | 565 ++++++++++++++++++ src/openjarvis/core/config.py | 112 ++++ src/openjarvis/engine/_discovery.py | 4 + src/openjarvis/engine/apple_fm_shim.py | 148 +++++ .../engine/openai_compat_engines.py | 4 + src/openjarvis/evals/cli.py | 38 ++ src/openjarvis/evals/core/dataset.py | 12 +- src/openjarvis/evals/core/environment.py | 51 ++ src/openjarvis/evals/core/types.py | 1 + src/openjarvis/evals/datasets/ama_bench.py | 171 ++++++ .../evals/datasets/lifelong_agent.py | 174 ++++++ src/openjarvis/evals/datasets/loghub.py | 251 ++++++++ .../evals/scorers/ama_bench_judge.py | 64 ++ .../evals/scorers/lifelong_agent_scorer.py | 63 ++ src/openjarvis/evals/scorers/loghub_scorer.py | 79 +++ src/openjarvis/optimize/search_space.py | 6 +- .../recipes/data/monitor_assistant.toml | 25 + src/openjarvis/templates/data/monitor.toml | 25 + src/openjarvis/tools/browser_axtree.py | 136 +++++ tests/agents/test_monitor_operative.py | 63 ++ tests/evals/test_ama_bench.py | 60 ++ tests/evals/test_environment_provider.py | 41 ++ tests/evals/test_episode_mode.py | 49 ++ tests/evals/test_lifelong_agent.py | 52 ++ tests/evals/test_loghub.py | 153 +++++ tests/templates/test_agent_templates.py | 2 +- tests/test_search_space.py | 1 + tests/tools/test_browser_axtree.py | 121 ++++ 36 files changed, 2794 insertions(+), 8 deletions(-) create mode 100644 src/openjarvis/agents/monitor_operative.py create mode 100644 src/openjarvis/engine/apple_fm_shim.py create mode 100644 src/openjarvis/evals/core/environment.py create mode 100644 src/openjarvis/evals/datasets/ama_bench.py create mode 100644 src/openjarvis/evals/datasets/lifelong_agent.py create mode 100644 src/openjarvis/evals/datasets/loghub.py create mode 100644 src/openjarvis/evals/scorers/ama_bench_judge.py create mode 100644 src/openjarvis/evals/scorers/lifelong_agent_scorer.py create mode 100644 src/openjarvis/evals/scorers/loghub_scorer.py create mode 100644 src/openjarvis/recipes/data/monitor_assistant.toml create mode 100644 src/openjarvis/templates/data/monitor.toml create mode 100644 src/openjarvis/tools/browser_axtree.py create mode 100644 tests/agents/test_monitor_operative.py create mode 100644 tests/evals/test_ama_bench.py create mode 100644 tests/evals/test_environment_provider.py create mode 100644 tests/evals/test_episode_mode.py create mode 100644 tests/evals/test_lifelong_agent.py create mode 100644 tests/evals/test_loghub.py create mode 100644 tests/tools/test_browser_axtree.py diff --git a/CLAUDE.md b/CLAUDE.md index f0e3d6a3..9c25ed0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Status -OpenJarvis is a research framework for studying on-device AI systems. Phase 24 complete. Five composable pillars: Intelligence, Engine, Agents, Tools (with storage + MCP), and Learning — with trace-driven learning as a cross-cutting concern. Speech subsystem (STT) with pluggable backends. ~3295 tests pass (~44 skipped for optional deps). Python SDK (`Jarvis` class), composition layer (`SystemBuilder`/`JarvisSystem`), eval framework (15 real benchmarks), composable recipes, agent templates, bundled skills, operator recipes, trace-driven learning pipeline, Docker deployment, Tauri desktop app, 40+ tools, 20+ CLI commands, 40+ API endpoints all ready. +OpenJarvis is a research framework for studying on-device AI systems. Phase 25 complete. Five composable pillars: Intelligence, Engine, Agents, Tools (with storage + MCP), and Learning — with trace-driven learning as a cross-cutting concern. Speech subsystem (STT) with pluggable backends. ~3405 tests pass (~44 skipped for optional deps). Python SDK (`Jarvis` class), composition layer (`SystemBuilder`/`JarvisSystem`), eval framework (20 real benchmarks), composable recipes, agent templates, bundled skills, operator recipes, trace-driven learning pipeline, Docker deployment, Tauri desktop app, 41+ tools, 20+ CLI commands, 40+ API endpoints all ready. ## Build & Development Commands @@ -116,7 +116,7 @@ OpenJarvis is a research framework for on-device AI organized around **five comp 1. **Intelligence** (`src/openjarvis/intelligence/`) — Model definition, catalog, and generation defaults. `ModelRegistry` maps model keys to `ModelSpec`. `IntelligenceConfig` holds model identity (default/fallback model, model_path, checkpoint_path, quantization, preferred_engine, provider) and generation defaults (temperature, max_tokens, top_p, top_k, repetition_penalty, stop_sequences). Model catalog maintains `BUILTIN_MODELS` with auto-discovery via `merge_discovered_models()`. 2. **Engine** (`src/openjarvis/engine/`) — The inference runtime. Backends: vLLM, SGLang, Ollama, llama.cpp, MLX, LM Studio. All implement `InferenceEngine` ABC with `generate()`, `stream()`, `list_models()`, `health()`. Engines extract and pass through `tool_calls` in OpenAI format. -3. **Agents** (`src/openjarvis/agents/`) — Pluggable logic for queries, tool/API calls, memory. Hierarchy: `BaseAgent` ABC (helpers: `_emit_turn_start/end`, `_build_messages`, `_generate`, `_max_turns_result`, `_strip_think_tags`, `_check_continuation`) → `ToolUsingAgent` (adds `tools`, `ToolExecutor`, `max_turns`). Agents: `SimpleAgent` (single-turn), `OrchestratorAgent` (multi-turn tool loop), `NativeReActAgent` (Thought-Action-Observation, key `"native_react"`, alias `"react"`), `NativeOpenHandsAgent` (CodeAct, key `"native_openhands"`), `RLMAgent` (recursive LM), `OpenHandsAgent` (real `openhands-sdk`, key `"openhands"`, requires Python 3.12+), `OpenClawAgent` (HTTP/subprocess transport), `ClaudeCodeAgent` (Claude Agent SDK via Node.js, key `"claude_code"`), `SandboxedAgent` (Docker wrapper, key `"sandboxed"`). `accepts_tools` class attribute for CLI/SDK auto-detection. Agents call `engine.generate()` directly — telemetry handled by `InstrumentedEngine` wrapper. +3. **Agents** (`src/openjarvis/agents/`) — Pluggable logic for queries, tool/API calls, memory. Hierarchy: `BaseAgent` ABC (helpers: `_emit_turn_start/end`, `_build_messages`, `_generate`, `_max_turns_result`, `_strip_think_tags`, `_check_continuation`) → `ToolUsingAgent` (adds `tools`, `ToolExecutor`, `max_turns`). Agents: `SimpleAgent` (single-turn), `OrchestratorAgent` (multi-turn tool loop), `NativeReActAgent` (Thought-Action-Observation, key `"native_react"`, alias `"react"`), `NativeOpenHandsAgent` (CodeAct, key `"native_openhands"`), `RLMAgent` (recursive LM), `OpenHandsAgent` (real `openhands-sdk`, key `"openhands"`, requires Python 3.12+), `OpenClawAgent` (HTTP/subprocess transport), `ClaudeCodeAgent` (Claude Agent SDK via Node.js, key `"claude_code"`), `SandboxedAgent` (Docker wrapper, key `"sandboxed"`), `MonitorOperativeAgent` (long-horizon monitoring with 4 configurable strategies: memory extraction, observation compression, retrieval, task decomposition, key `"monitor_operative"`). `accepts_tools` class attribute for CLI/SDK auto-detection. Agents call `engine.generate()` directly — telemetry handled by `InstrumentedEngine` wrapper. 4. **Tools** (`src/openjarvis/tools/`) — All tools managed via MCP (Model Context Protocol). - **API tools**: `CalculatorTool`, `ThinkTool`, `FileReadTool`, `FileWriteTool`, `WebSearchTool`, `CodeInterpreterTool`, `LLMTool`, `ShellExecTool`, `ApplyPatchTool`, `HttpRequestTool`, `DatabaseQueryTool`, `PDFExtractTool`, `ImageGenerateTool`, `AudioTranscribeTool` — all implement `BaseTool` ABC - **Git tools** (`git_tool.py`): `GitStatusTool`, `GitDiffTool`, `GitCommitTool`, `GitLogTool` @@ -148,8 +148,8 @@ OpenJarvis is a research framework for on-device AI organized around **five comp - **Composition Layer** (`system.py`) — `SystemBuilder` fluent builder → `JarvisSystem` with `ask()`, `close()`. Wires engine, model, agent, tools, telemetry, traces, workflow, sessions, capability policy. - **SDK** (`sdk.py`) — `Jarvis` class: high-level sync API with `ask()`/`ask_full()`, `MemoryHandle`, lazy init, telemetry. Also exports `JarvisSystem`/`SystemBuilder`. - **Benchmarks** (`bench/`) — `LatencyBenchmark`, `ThroughputBenchmark`, `EnergyBenchmark`. All registered via `BenchmarkRegistry`. CLI: `jarvis bench run`. -- **Eval Framework** (`src/openjarvis/evals/`) — 15 real benchmark datasets from IPW: SuperGPQA, GPQA, MMLU-Pro, MATH-500, Natural Reasoning, HLE, SimpleQA, WildChat, IPW, GAIA, FRAMES, SWE-bench, SWEfficiency, TerminalBench, TerminalBench Native. Scorer types: MCQ letter extraction, LLM-judge, exact match, structural validation. `EvalRunner` with parallel execution. CLI: `jarvis eval list|run|compare|report`. -- **Recipes** (`src/openjarvis/recipes/`) — Composable TOML configs that wire all 5 pillars. `Recipe` dataclass with `to_builder_kwargs()`. `load_recipe()`, `discover_recipes()`, `resolve_recipe()`. 3 built-in recipes in `data/`: coding_assistant, research_assistant, general_assistant. Operator recipes in `data/operators/`: researcher (4h cycle), correspondent (5min interval), sentinel (2h cycle). +- **Eval Framework** (`src/openjarvis/evals/`) — 20 real benchmark datasets: SuperGPQA, GPQA, MMLU-Pro, MATH-500, Natural Reasoning, HLE, SimpleQA, WildChat, IPW, GAIA, FRAMES, SWE-bench, SWEfficiency, TerminalBench, TerminalBench Native, LogHub, AMA-Bench, LifelongAgentBench, WebChoreArena, WorkArena++. Scorer types: MCQ letter extraction, LLM-judge, exact match, structural validation. `EvalRunner` with parallel execution and episode mode (sequential processing with shared agent state). `EnvironmentProvider` ABC for Docker/ServiceNow environments. CLI: `jarvis eval list|run|compare|report`. +- **Recipes** (`src/openjarvis/recipes/`) — Composable TOML configs that wire all 5 pillars. `Recipe` dataclass with `to_builder_kwargs()`. `load_recipe()`, `discover_recipes()`, `resolve_recipe()`. 3 built-in recipes in `data/`: coding_assistant, research_assistant, general_assistant. Operator recipes in `data/operators/`: researcher (4h cycle), correspondent (5min interval), sentinel (2h cycle), monitor (2h cycle, causality graph + hybrid retrieval). - **Agent Templates** (`src/openjarvis/templates/`) — Pre-configured TOML manifests with system prompts, tool sets, behavioral parameters. `AgentTemplate` dataclass, `load_template()`, `discover_templates()`. 15 built-in templates in `data/` (code-reviewer, debugger, architect, deep-researcher, fact-checker, summarizer, etc.). - **Bundled Skills** (`src/openjarvis/skills/data/`) — 20 ready-to-use TOML skill manifests. Categories: file management (organizer, deduplicator, backup), research (web-summarize, topic-research, knowledge-extract), code quality (lint, test-gen, security-scan, dependency-audit), productivity (email-draft, meeting-notes, daily-digest), document processing (compare, translate, data-analyze). - **OpenClaw** (`agents/openclaw*.py`) — `OpenClawAgent` with `HttpTransport`/`SubprocessTransport`, JSON-line protocol, `ProviderPlugin`, `MemorySearchManager`. @@ -244,3 +244,4 @@ OpenAI-compatible server via `jarvis serve`: | v2.7 | 22 | Operators: persistent, scheduled autonomous agents with recipe + schedule + channel output | | v2.8 | 23 | Differentiated functionalities: trace-driven learning pipeline (TrainingDataMiner, LoRATrainer, AgentConfigEvolver, LearningOrchestrator), 15 real IPW benchmarks, composable recipes, 15 agent templates, 20 bundled skills, 3 operator recipes | | v2.9 | 24 | Speech subsystem: SpeechBackend ABC, SpeechRegistry, 3 backends (FasterWhisper local, OpenAI cloud, Deepgram cloud), auto-discovery, API endpoints, frontend MicButton + useSpeech hook, Tauri commands, SystemBuilder wiring | +| v3.0 | 25 | Operator benchmarks: LogHub, AMA-Bench, LifelongAgentBench, WebChoreArena, WorkArena++. MonitorOperativeAgent with 4 configurable strategies (memory extraction, observation compression, retrieval, task decomposition). EvalRunner episode mode. EnvironmentProvider ABC. browser_axtree tool | diff --git a/configs/openjarvis/config.toml b/configs/openjarvis/config.toml index 2e95d3d0..b61c9d35 100644 --- a/configs/openjarvis/config.toml +++ b/configs/openjarvis/config.toml @@ -54,6 +54,32 @@ host = "http://localhost:30000" [engine.llamacpp] host = "http://localhost:8080" +[engine.exo] +host = "http://localhost:52415" + +[engine.nexa] +host = "http://localhost:18181" +# device = "" + +[engine.uzu] +host = "http://localhost:8080" + +[engine.apple_fm] +host = "http://localhost:8079" + +[engine.exo] +host = "http://localhost:52415" + +[engine.nexa] +host = "http://localhost:18181" +# device = "npu" # optional: cpu, gpu, npu + +[engine.uzu] +host = "http://localhost:8080" + +[engine.apple_fm] +host = "http://localhost:8079" + # ═══════════════════════════════════════════════════════════════ # PILLAR 5: Learning — Improvement Methodologies # ═══════════════════════════════════════════════════════════════ diff --git a/rust/crates/openjarvis-core/src/config.rs b/rust/crates/openjarvis-core/src/config.rs index cc8f1a0a..a944010c 100644 --- a/rust/crates/openjarvis-core/src/config.rs +++ b/rust/crates/openjarvis-core/src/config.rs @@ -134,6 +134,145 @@ impl Default for LMStudioEngineConfig { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExoEngineConfig { + #[serde(default = "default_exo_host")] + pub host: String, +} + +fn default_exo_host() -> String { + "http://localhost:52415".into() +} + +impl Default for ExoEngineConfig { + fn default() -> Self { + Self { host: default_exo_host() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NexaEngineConfig { + #[serde(default = "default_nexa_host")] + pub host: String, + #[serde(default)] + pub device: String, +} + +fn default_nexa_host() -> String { + "http://localhost:18181".into() +} + +impl Default for NexaEngineConfig { + fn default() -> Self { + Self { + host: default_nexa_host(), + device: String::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UzuEngineConfig { + #[serde(default = "default_uzu_host")] + pub host: String, +} + +fn default_uzu_host() -> String { + "http://localhost:8080".into() +} + +impl Default for UzuEngineConfig { + fn default() -> Self { + Self { host: default_uzu_host() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppleFmEngineConfig { + #[serde(default = "default_apple_fm_host")] + pub host: String, +} + +fn default_apple_fm_host() -> String { + "http://localhost:8079".into() +} + +impl Default for AppleFmEngineConfig { + fn default() -> Self { + Self { host: default_apple_fm_host() } + } +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExoEngineConfig { + #[serde(default = "default_exo_host")] + pub host: String, +} + +fn default_exo_host() -> String { + "http://localhost:52415".into() +} + +impl Default for ExoEngineConfig { + fn default() -> Self { + Self { host: default_exo_host() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NexaEngineConfig { + #[serde(default = "default_nexa_host")] + pub host: String, + #[serde(default)] + pub device: String, +} + +fn default_nexa_host() -> String { + "http://localhost:18181".into() +} + +impl Default for NexaEngineConfig { + fn default() -> Self { + Self { + host: default_nexa_host(), + device: String::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UzuEngineConfig { + #[serde(default = "default_uzu_host")] + pub host: String, +} + +fn default_uzu_host() -> String { + "http://localhost:8080".into() +} + +impl Default for UzuEngineConfig { + fn default() -> Self { + Self { host: default_uzu_host() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppleFmEngineConfig { + #[serde(default = "default_apple_fm_host")] + pub host: String, +} + +fn default_apple_fm_host() -> String { + "http://localhost:8079".into() +} + +impl Default for AppleFmEngineConfig { + fn default() -> Self { + Self { host: default_apple_fm_host() } + } +} + // --------------------------------------------------------------------------- // Engine config // --------------------------------------------------------------------------- @@ -154,6 +293,14 @@ pub struct EngineConfig { pub mlx: MLXEngineConfig, #[serde(default)] pub lmstudio: LMStudioEngineConfig, + #[serde(default)] + pub exo: ExoEngineConfig, + #[serde(default)] + pub nexa: NexaEngineConfig, + #[serde(default)] + pub uzu: UzuEngineConfig, + #[serde(default)] + pub apple_fm: AppleFmEngineConfig, } fn default_engine_name() -> String { @@ -170,6 +317,10 @@ impl Default for EngineConfig { llamacpp: LlamaCppEngineConfig::default(), mlx: MLXEngineConfig::default(), lmstudio: LMStudioEngineConfig::default(), + exo: ExoEngineConfig::default(), + nexa: NexaEngineConfig::default(), + uzu: UzuEngineConfig::default(), + apple_fm: AppleFmEngineConfig::default(), } } } diff --git a/rust/crates/openjarvis-engine/src/discovery.rs b/rust/crates/openjarvis-engine/src/discovery.rs index 9b36137a..572aa3ab 100644 --- a/rust/crates/openjarvis-engine/src/discovery.rs +++ b/rust/crates/openjarvis-engine/src/discovery.rs @@ -38,6 +38,10 @@ pub fn discover_engines(config: &JarvisConfig) -> Vec { ("llamacpp", &config.engine.llamacpp.host), ("mlx", &config.engine.mlx.host), ("lmstudio", &config.engine.lmstudio.host), + ("exo", &config.engine.exo.host), + ("nexa", &config.engine.nexa.host), + ("uzu", &config.engine.uzu.host), + ("apple_fm", &config.engine.apple_fm.host), ]; for (name, host) in compat_engines { @@ -95,6 +99,30 @@ pub fn get_engine_static( "lmstudio" => Ok(Engine::LmStudio(OpenAICompatEngine::lmstudio( &config.engine.lmstudio.host, ))), + "exo" => Ok(Engine::Exo(OpenAICompatEngine::exo( + &config.engine.exo.host, + ))), + "nexa" => Ok(Engine::Nexa(OpenAICompatEngine::nexa( + &config.engine.nexa.host, + ))), + "uzu" => Ok(Engine::Uzu(OpenAICompatEngine::uzu( + &config.engine.uzu.host, + ))), + "apple_fm" => Ok(Engine::AppleFm(OpenAICompatEngine::apple_fm( + &config.engine.apple_fm.host, + ))), + "exo" => Ok(Engine::Exo(OpenAICompatEngine::exo( + &config.engine.exo.host, + ))), + "nexa" => Ok(Engine::Nexa(OpenAICompatEngine::nexa( + &config.engine.nexa.host, + ))), + "uzu" => Ok(Engine::Uzu(OpenAICompatEngine::uzu( + &config.engine.uzu.host, + ))), + "apple_fm" => Ok(Engine::AppleFm(OpenAICompatEngine::apple_fm( + &config.engine.apple_fm.host, + ))), other => Err(OpenJarvisError::Engine( openjarvis_core::error::EngineError::ModelNotFound(format!( "Unknown engine: {}", diff --git a/rust/crates/openjarvis-engine/src/engine_enum.rs b/rust/crates/openjarvis-engine/src/engine_enum.rs index 16191405..5133de12 100644 --- a/rust/crates/openjarvis-engine/src/engine_enum.rs +++ b/rust/crates/openjarvis-engine/src/engine_enum.rs @@ -20,6 +20,10 @@ pub enum Engine { LlamaCpp(OpenAICompatEngine), Mlx(OpenAICompatEngine), LmStudio(OpenAICompatEngine), + Exo(OpenAICompatEngine), + Nexa(OpenAICompatEngine), + Uzu(OpenAICompatEngine), + AppleFm(OpenAICompatEngine), } macro_rules! delegate_engine { @@ -31,6 +35,10 @@ macro_rules! delegate_engine { Engine::LlamaCpp(e) => e.$method($($arg),*), Engine::Mlx(e) => e.$method($($arg),*), Engine::LmStudio(e) => e.$method($($arg),*), + Engine::Exo(e) => e.$method($($arg),*), + Engine::Nexa(e) => e.$method($($arg),*), + Engine::Uzu(e) => e.$method($($arg),*), + Engine::AppleFm(e) => e.$method($($arg),*), } }; } @@ -67,6 +75,10 @@ impl InferenceEngine for Engine { Engine::LlamaCpp(e) => e.stream(messages, model, temperature, max_tokens, extra).await, Engine::Mlx(e) => e.stream(messages, model, temperature, max_tokens, extra).await, Engine::LmStudio(e) => e.stream(messages, model, temperature, max_tokens, extra).await, + Engine::Exo(e) => e.stream(messages, model, temperature, max_tokens, extra).await, + Engine::Nexa(e) => e.stream(messages, model, temperature, max_tokens, extra).await, + Engine::Uzu(e) => e.stream(messages, model, temperature, max_tokens, extra).await, + Engine::AppleFm(e) => e.stream(messages, model, temperature, max_tokens, extra).await, } } @@ -97,6 +109,10 @@ impl Engine { Engine::LlamaCpp(_) => "llamacpp", Engine::Mlx(_) => "mlx", Engine::LmStudio(_) => "lmstudio", + Engine::Exo(_) => "exo", + Engine::Nexa(_) => "nexa", + Engine::Uzu(_) => "uzu", + Engine::AppleFm(_) => "apple_fm", } } } @@ -118,4 +134,32 @@ mod tests { assert_eq!(e.variant_key(), "vllm"); assert_eq!(e.engine_id(), "vllm"); } + + #[test] + fn test_engine_exo_variant() { + let e = Engine::Exo(OpenAICompatEngine::exo("http://localhost:52415")); + assert_eq!(e.variant_key(), "exo"); + assert_eq!(e.engine_id(), "exo"); + } + + #[test] + fn test_engine_nexa_variant() { + let e = Engine::Nexa(OpenAICompatEngine::nexa("http://localhost:18181")); + assert_eq!(e.variant_key(), "nexa"); + assert_eq!(e.engine_id(), "nexa"); + } + + #[test] + fn test_engine_uzu_variant() { + let e = Engine::Uzu(OpenAICompatEngine::uzu("http://localhost:8080")); + assert_eq!(e.variant_key(), "uzu"); + assert_eq!(e.engine_id(), "uzu"); + } + + #[test] + fn test_engine_apple_fm_variant() { + let e = Engine::AppleFm(OpenAICompatEngine::apple_fm("http://localhost:8079")); + assert_eq!(e.variant_key(), "apple_fm"); + assert_eq!(e.engine_id(), "apple_fm"); + } } diff --git a/rust/crates/openjarvis-engine/src/openai_compat.rs b/rust/crates/openjarvis-engine/src/openai_compat.rs index 3dc6b4ea..bac79b33 100644 --- a/rust/crates/openjarvis-engine/src/openai_compat.rs +++ b/rust/crates/openjarvis-engine/src/openai_compat.rs @@ -59,6 +59,22 @@ impl OpenAICompatEngine { Self::new("lmstudio", host, None, 120.0) } + pub fn exo(host: &str) -> Self { + Self::new("exo", host, None, 120.0) + } + + pub fn nexa(host: &str) -> Self { + Self::new("nexa", host, None, 120.0) + } + + pub fn uzu(host: &str) -> Self { + Self::new("uzu", host, None, 120.0) + } + + pub fn apple_fm(host: &str) -> Self { + Self::new("apple_fm", host, None, 120.0) + } + fn build_headers(&self) -> reqwest::header::HeaderMap { let mut headers = reqwest::header::HeaderMap::new(); headers.insert( @@ -337,4 +353,28 @@ mod tests { let engine = OpenAICompatEngine::sglang("http://localhost:30000"); assert_eq!(engine.engine_id(), "sglang"); } + + #[test] + fn test_exo_factory() { + let engine = OpenAICompatEngine::exo("http://localhost:52415"); + assert_eq!(engine.engine_id(), "exo"); + } + + #[test] + fn test_nexa_factory() { + let engine = OpenAICompatEngine::nexa("http://localhost:18181"); + assert_eq!(engine.engine_id(), "nexa"); + } + + #[test] + fn test_uzu_factory() { + let engine = OpenAICompatEngine::uzu("http://localhost:8080"); + assert_eq!(engine.engine_id(), "uzu"); + } + + #[test] + fn test_apple_fm_factory() { + let engine = OpenAICompatEngine::apple_fm("http://localhost:8079"); + assert_eq!(engine.engine_id(), "apple_fm"); + } } diff --git a/rust/crates/openjarvis-python/src/engine.rs b/rust/crates/openjarvis-python/src/engine.rs index 2780c0fa..027669a4 100644 --- a/rust/crates/openjarvis-python/src/engine.rs +++ b/rust/crates/openjarvis-python/src/engine.rs @@ -12,7 +12,8 @@ pub struct PyEngine { #[pymethods] impl PyEngine { - /// Create an engine by key (e.g. "ollama", "vllm", "sglang", "llamacpp", "mlx", "lmstudio"). + /// Create an engine by key (e.g. "ollama", "vllm", "sglang", "llamacpp", + /// "mlx", "lmstudio", "exo", "nexa", "uzu", "apple_fm"). #[new] #[pyo3(signature = (engine_key="ollama", host=None))] fn new(engine_key: &str, host: Option<&str>) -> PyResult { @@ -48,6 +49,26 @@ impl PyEngine { host.unwrap_or("http://localhost:1234"), ), ), + "exo" => openjarvis_engine::Engine::Exo( + openjarvis_engine::OpenAICompatEngine::exo( + host.unwrap_or("http://localhost:52415"), + ), + ), + "nexa" => openjarvis_engine::Engine::Nexa( + openjarvis_engine::OpenAICompatEngine::nexa( + host.unwrap_or("http://localhost:18181"), + ), + ), + "uzu" => openjarvis_engine::Engine::Uzu( + openjarvis_engine::OpenAICompatEngine::uzu( + host.unwrap_or("http://localhost:8080"), + ), + ), + "apple_fm" => openjarvis_engine::Engine::AppleFm( + openjarvis_engine::OpenAICompatEngine::apple_fm( + host.unwrap_or("http://localhost:8079"), + ), + ), other => { return Err(PyErr::new::( format!("Unknown engine: {}", other), diff --git a/src/openjarvis/agents/__init__.py b/src/openjarvis/agents/__init__.py index 5977532a..2fc1ae23 100644 --- a/src/openjarvis/agents/__init__.py +++ b/src/openjarvis/agents/__init__.py @@ -55,6 +55,16 @@ try: except ImportError: pass +try: + import openjarvis.agents.monitor # noqa: F401 +except ImportError: + pass + +try: + import openjarvis.agents.monitor_operative # noqa: F401 +except ImportError: + pass + # Registry alias: "react" -> NativeReActAgent (for backward compat) try: from openjarvis.core.registry import AgentRegistry diff --git a/src/openjarvis/agents/monitor_operative.py b/src/openjarvis/agents/monitor_operative.py new file mode 100644 index 00000000..6840c841 --- /dev/null +++ b/src/openjarvis/agents/monitor_operative.py @@ -0,0 +1,565 @@ +"""MonitorOperativeAgent -- long-horizon agent with configurable strategies. + +Extends ToolUsingAgent (not OperativeAgent) with four configurable strategy +axes for long-horizon benchmark evaluation: + +1. **memory_extraction** -- how findings are persisted to memory +2. **observation_compression** -- how tool outputs are compressed +3. **retrieval_strategy** -- how prior context is recalled +4. **task_decomposition** -- how complex tasks are split + +The agent also inherits cross-session state persistence from the +OperativeAgent pattern (session_store, memory_backend, operator_id). +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, List, Optional + +from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent +from openjarvis.core.events import EventBus +from openjarvis.core.registry import AgentRegistry +from openjarvis.core.types import Message, Role, ToolCall, ToolResult +from openjarvis.engine._stubs import InferenceEngine +from openjarvis.tools._stubs import BaseTool + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Valid strategy values +# --------------------------------------------------------------------------- + +VALID_MEMORY_EXTRACTION = {"causality_graph", "scratchpad", "structured_json", "none"} +VALID_OBSERVATION_COMPRESSION = {"summarize", "truncate", "none"} +VALID_RETRIEVAL_STRATEGY = {"hybrid_with_self_eval", "keyword", "semantic", "none"} +VALID_TASK_DECOMPOSITION = {"phased", "monolithic", "hierarchical"} + +# --------------------------------------------------------------------------- +# Default system prompt +# --------------------------------------------------------------------------- + +MONITOR_OPERATIVE_SYSTEM_PROMPT = """\ +You are a Monitor Operative Agent designed for long-horizon tasks. + +## Capabilities +1. TOOLS: Call any available tool via function calling +2. STATE: Your previous findings and state are automatically restored +3. MEMORY: Store important findings for future recall + +## Strategy +- Memory extraction: {memory_extraction} +- Observation compression: {observation_compression} +- Retrieval strategy: {retrieval_strategy} +- Task decomposition: {task_decomposition} + +## Protocol +- Break complex tasks into phases and track progress +- Store causal relationships and key findings in memory +- Compress long tool outputs before adding to context +- Self-evaluate retrieved context for relevance +- Always persist state before finishing + +{tool_descriptions}""" + + +@AgentRegistry.register("monitor_operative") +class MonitorOperativeAgent(ToolUsingAgent): + """Long-horizon agent with configurable memory, compression, retrieval, + and decomposition strategies. + + The four strategy axes control how the agent manages information across + turns and sessions: + + - ``memory_extraction``: How findings are persisted (causality_graph, + scratchpad, structured_json, none). + - ``observation_compression``: How tool outputs are compressed before + being added to context (summarize, truncate, none). + - ``retrieval_strategy``: How prior context is recalled at the start + of each run (hybrid_with_self_eval, keyword, semantic, none). + - ``task_decomposition``: How complex tasks are broken down + (phased, monolithic, hierarchical). + """ + + agent_id = "monitor_operative" + accepts_tools = True + + def __init__( + self, + engine: InferenceEngine, + model: str, + *, + tools: Optional[List[BaseTool]] = None, + bus: Optional[EventBus] = None, + max_turns: int = 25, + temperature: float = 0.3, + max_tokens: int = 4096, + system_prompt: Optional[str] = None, + # Strategy parameters + memory_extraction: str = "causality_graph", + observation_compression: str = "summarize", + retrieval_strategy: str = "hybrid_with_self_eval", + task_decomposition: str = "phased", + # State persistence (OperativeAgent pattern) + operator_id: Optional[str] = None, + session_store: Optional[Any] = None, + memory_backend: Optional[Any] = None, + **kwargs: Any, + ) -> None: + super().__init__( + engine, model, tools=tools, bus=bus, + max_turns=max_turns, temperature=temperature, + max_tokens=max_tokens, + ) + # Validate strategies + if memory_extraction not in VALID_MEMORY_EXTRACTION: + raise ValueError( + f"Invalid memory_extraction {memory_extraction!r}, " + f"must be one of {VALID_MEMORY_EXTRACTION}" + ) + if observation_compression not in VALID_OBSERVATION_COMPRESSION: + raise ValueError( + f"Invalid observation_compression {observation_compression!r}, " + f"must be one of {VALID_OBSERVATION_COMPRESSION}" + ) + if retrieval_strategy not in VALID_RETRIEVAL_STRATEGY: + raise ValueError( + f"Invalid retrieval_strategy {retrieval_strategy!r}, " + f"must be one of {VALID_RETRIEVAL_STRATEGY}" + ) + if task_decomposition not in VALID_TASK_DECOMPOSITION: + raise ValueError( + f"Invalid task_decomposition {task_decomposition!r}, " + f"must be one of {VALID_TASK_DECOMPOSITION}" + ) + + self._memory_extraction = memory_extraction + self._observation_compression = observation_compression + self._retrieval_strategy = retrieval_strategy + self._task_decomposition = task_decomposition + + self._system_prompt = system_prompt + self._operator_id = operator_id + self._session_store = session_store + self._memory_backend = memory_backend + + # ------------------------------------------------------------------ + # Main run loop + # ------------------------------------------------------------------ + + def run( + self, + input: str, + context: Optional[AgentContext] = None, + **kwargs: Any, + ) -> AgentResult: + """Execute the agent on *input* with the configured strategies.""" + self._emit_turn_start(input) + + # 1. Build system prompt with state context + sys_parts: list[str] = [] + if self._system_prompt: + sys_parts.append(self._system_prompt) + else: + tool_desc = self._build_tool_descriptions() + try: + sys_parts.append( + MONITOR_OPERATIVE_SYSTEM_PROMPT.format( + memory_extraction=self._memory_extraction, + observation_compression=self._observation_compression, + retrieval_strategy=self._retrieval_strategy, + task_decomposition=self._task_decomposition, + tool_descriptions=tool_desc, + ), + ) + except KeyError: + sys_parts.append(MONITOR_OPERATIVE_SYSTEM_PROMPT) + + # 2. State recall from memory backend + previous_state = self._recall_state() + if previous_state: + sys_parts.append(f"\n## Previous State\n{previous_state}") + + system_prompt = "\n\n".join(sys_parts) if sys_parts else None + + # 3. Load session history + session_messages = self._load_session() + + # 4. Build messages + messages = self._build_operative_messages( + input, context, + system_prompt=system_prompt, + session_messages=session_messages, + ) + + # 5. Run function-calling tool loop + openai_tools = self._executor.get_openai_tools() if self._tools else [] + all_tool_results: list[ToolResult] = [] + turns = 0 + content = "" + state_stored_by_tool = False + + for _turn in range(self._max_turns): + turns += 1 + + gen_kwargs: dict[str, Any] = {} + if openai_tools: + gen_kwargs["tools"] = openai_tools + + result = self._generate(messages, **gen_kwargs) + content = result.get("content", "") + raw_tool_calls = result.get("tool_calls", []) + + # No tool calls -> check continuation, then final answer + if not raw_tool_calls: + content = self._check_continuation(result, messages) + break + + # Build ToolCall objects from raw dicts + tool_calls = [ + ToolCall( + id=tc.get("id", f"call_{i}"), + name=tc.get("name", ""), + arguments=tc.get("arguments", "{}"), + ) + for i, tc in enumerate(raw_tool_calls) + ] + + # Append assistant message with tool calls + messages.append(Message( + role=Role.ASSISTANT, + content=content, + tool_calls=tool_calls, + )) + + # Execute each tool + for tc in tool_calls: + # Loop guard check + if self._loop_guard: + verdict = self._loop_guard.check_call( + tc.name, tc.arguments, + ) + if verdict.blocked: + tool_result = ToolResult( + tool_name=tc.name, + content=f"Loop guard: {verdict.reason}", + success=False, + ) + all_tool_results.append(tool_result) + messages.append(Message( + role=Role.TOOL, + content=tool_result.content, + tool_call_id=tc.id, + name=tc.name, + )) + continue + + tool_result = self._executor.execute(tc) + all_tool_results.append(tool_result) + + # Track explicit state storage + if tc.name == "memory_store" and self._operator_id: + try: + args = json.loads(tc.arguments) + state_key = f"monitor_operative:{self._operator_id}:state" + if args.get("key", "") == state_key: + state_stored_by_tool = True + except (json.JSONDecodeError, TypeError): + pass + + # Compress observation if strategy requires it + observation_content = self._compress_observation(tool_result.content) + + messages.append(Message( + role=Role.TOOL, + content=observation_content, + tool_call_id=tc.id, + name=tc.name, + )) + + # Extract and store findings based on memory strategy + self._extract_and_store(tc.name, tool_result.content) + else: + # Max turns exceeded + self._save_session(input, content) + return self._max_turns_result( + all_tool_results, turns, content=content, + ) + + # 6. Save session + self._save_session(input, content) + + # 7. Auto-persist state if agent didn't do it explicitly + if not state_stored_by_tool: + self._auto_persist_state(content) + + self._emit_turn_end(turns=turns, content_length=len(content)) + return AgentResult( + content=content, + tool_results=all_tool_results, + turns=turns, + ) + + # ------------------------------------------------------------------ + # Message building + # ------------------------------------------------------------------ + + def _build_operative_messages( + self, + input: str, + context: Optional[AgentContext], + *, + system_prompt: Optional[str] = None, + session_messages: Optional[list[Message]] = None, + ) -> list[Message]: + """Build message list with system prompt, session history, and input.""" + messages: list[Message] = [] + if system_prompt: + messages.append(Message(role=Role.SYSTEM, content=system_prompt)) + if session_messages: + messages.extend(session_messages) + if context and context.conversation.messages: + messages.extend(context.conversation.messages) + messages.append(Message(role=Role.USER, content=input)) + return messages + + def _build_tool_descriptions(self) -> str: + """Build a text description of available tools for the system prompt.""" + if not self._tools: + return "" + from openjarvis.tools._stubs import build_tool_descriptions + return build_tool_descriptions(self._tools) + + # ------------------------------------------------------------------ + # Strategy methods + # ------------------------------------------------------------------ + + def _compress_observation(self, content: str) -> str: + """Compress a tool observation according to the compression strategy. + + - ``summarize``: If content exceeds 2000 chars, ask the LLM to + summarize. Falls back to truncation if generation fails. + - ``truncate``: Hard-truncate at 2000 chars with an ellipsis. + - ``none``: Return content unchanged. + """ + if self._observation_compression == "none": + return content + if self._observation_compression == "truncate": + if len(content) > 2000: + return content[:2000] + "\n... [truncated]" + return content + # "summarize" + if len(content) <= 2000: + return content + try: + summary_messages = [ + Message( + role=Role.SYSTEM, + content="Summarize the following tool output concisely, " + "preserving all key facts and data points.", + ), + Message(role=Role.USER, content=content[:8000]), + ] + result = self._generate(summary_messages) + summary = result.get("content", "") + if summary: + return summary + except Exception: + logger.debug("Observation summarization failed, falling back to truncation") + # Fallback to truncation + return content[:2000] + "\n... [truncated]" + + def _extract_and_store(self, tool_name: str, content: str) -> None: + """Extract findings from a tool result and store them. + + The extraction strategy depends on ``_memory_extraction``: + + - ``causality_graph``: Extract causal relationships via + :meth:`_extract_causality` and store as KG triples. + - ``scratchpad``: Append raw content to a scratchpad key in + memory. + - ``structured_json``: Attempt to parse JSON from the content + and store structured data. + - ``none``: Do nothing. + """ + if self._memory_extraction == "none": + return + if not self._memory_backend: + return + + if self._memory_extraction == "causality_graph": + self._extract_causality(tool_name, content) + elif self._memory_extraction == "scratchpad": + self._store_scratchpad(tool_name, content) + elif self._memory_extraction == "structured_json": + self._store_structured(tool_name, content) + + def _extract_causality(self, tool_name: str, content: str) -> None: + """Extract causal relationships from tool output and store them. + + Uses the LLM to identify cause-effect patterns, then stores + them via the memory backend. + """ + if not self._memory_backend or not content.strip(): + return + # Only attempt extraction for substantial outputs + if len(content) < 50: + return + try: + extract_messages = [ + Message( + role=Role.SYSTEM, + content=( + "Extract causal relationships from the following tool " + "output. Return a JSON array of objects with 'cause', " + "'effect', and 'confidence' fields. If no causal " + "relationships are found, return an empty array []." + ), + ), + Message(role=Role.USER, content=content[:4000]), + ] + result = self._generate(extract_messages) + raw = result.get("content", "") + # Try to parse JSON from the response + raw = raw.strip() + if raw.startswith("```"): + # Strip code fences + lines = raw.split("\n") + raw = "\n".join(lines[1:-1] if len(lines) > 2 else lines) + relations = json.loads(raw) + if isinstance(relations, list): + operator_prefix = ( + f"monitor_operative:{self._operator_id}" + if self._operator_id + else "monitor_operative" + ) + for rel in relations[:10]: # Cap at 10 per extraction + if isinstance(rel, dict) and "cause" in rel and "effect" in rel: + key = f"{operator_prefix}:causality:{rel['cause'][:50]}" + value = json.dumps(rel) + try: + self._memory_backend.store(key, value) + except Exception: + pass + except (json.JSONDecodeError, Exception): + logger.debug( + "Causality extraction failed for tool %s output", tool_name, + ) + + def _store_scratchpad(self, tool_name: str, content: str) -> None: + """Append content to a scratchpad entry in memory.""" + if not self._memory_backend: + return + operator_prefix = ( + f"monitor_operative:{self._operator_id}" + if self._operator_id + else "monitor_operative" + ) + key = f"{operator_prefix}:scratchpad:{tool_name}" + # Truncate long content + snippet = content[:1000] if len(content) > 1000 else content + try: + self._memory_backend.store(key, snippet) + except Exception: + logger.debug("Could not store scratchpad for tool %s", tool_name) + + def _store_structured(self, tool_name: str, content: str) -> None: + """Try to parse JSON from tool output and store structured data.""" + if not self._memory_backend: + return + operator_prefix = ( + f"monitor_operative:{self._operator_id}" + if self._operator_id + else "monitor_operative" + ) + try: + data = json.loads(content) + key = f"{operator_prefix}:structured:{tool_name}" + self._memory_backend.store(key, json.dumps(data)) + except (json.JSONDecodeError, TypeError): + # Not JSON -- store as plain text truncated + key = f"{operator_prefix}:structured:{tool_name}" + try: + self._memory_backend.store(key, content[:1000]) + except Exception: + pass + + # ------------------------------------------------------------------ + # State persistence (OperativeAgent pattern) + # ------------------------------------------------------------------ + + def _recall_state(self) -> str: + """Retrieve previous state from memory backend.""" + if not self._memory_backend or not self._operator_id: + return "" + state_key = f"monitor_operative:{self._operator_id}:state" + try: + result = self._memory_backend.retrieve(state_key) + if result: + return result if isinstance(result, str) else str(result) + except Exception: + logger.debug( + "No previous state for monitor_operative %s", + self._operator_id, + ) + return "" + + def _load_session(self) -> list[Message]: + """Load recent session history for this operator.""" + if not self._session_store or not self._operator_id: + return [] + session_id = f"monitor_operative:{self._operator_id}" + try: + session = self._session_store.get_or_create(session_id) + if hasattr(session, "messages") and session.messages: + recent = session.messages[-10:] + return [ + Message( + role=Role(m.get("role", "user")), + content=m.get("content", ""), + ) + for m in recent + if isinstance(m, dict) + ] + except Exception: + logger.debug( + "Could not load session for monitor_operative %s", + self._operator_id, + ) + return [] + + def _save_session(self, input_text: str, response: str) -> None: + """Save the tick's prompt and response to the session store.""" + if not self._session_store or not self._operator_id: + return + session_id = f"monitor_operative:{self._operator_id}" + try: + self._session_store.save_message( + session_id, {"role": "user", "content": input_text}, + ) + self._session_store.save_message( + session_id, {"role": "assistant", "content": response}, + ) + except Exception: + logger.debug( + "Could not save session for monitor_operative %s", + self._operator_id, + ) + + def _auto_persist_state(self, content: str) -> None: + """Auto-persist a state summary if agent didn't store explicitly.""" + if not self._memory_backend or not self._operator_id: + return + state_key = f"monitor_operative:{self._operator_id}:state" + try: + summary = content[:1000] if content else "" + self._memory_backend.store(state_key, summary) + except Exception: + logger.debug( + "Could not auto-persist state for monitor_operative %s", + self._operator_id, + ) + + +__all__ = ["MonitorOperativeAgent", "MONITOR_OPERATIVE_SYSTEM_PROMPT"] diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 41cb24a5..2277a6ae 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -257,6 +257,65 @@ class LMStudioEngineConfig: host: str = "http://localhost:1234" +@dataclass(slots=True) +class ExoEngineConfig: + """Per-engine config for Exo.""" + + host: str = "http://localhost:52415" + + +@dataclass(slots=True) +class NexaEngineConfig: + """Per-engine config for Nexa.""" + + host: str = "http://localhost:18181" + device: str = "" + + +@dataclass(slots=True) +class UzuEngineConfig: + """Per-engine config for Uzu.""" + + host: str = "http://localhost:8080" + + +@dataclass(slots=True) +class AppleFmEngineConfig: + """Per-engine config for Apple Foundation Models.""" + + host: str = "http://localhost:8079" + + + +@dataclass(slots=True) +class ExoEngineConfig: + """Per-engine config for Exo.""" + + host: str = "http://localhost:52415" + + +@dataclass(slots=True) +class NexaEngineConfig: + """Per-engine config for Nexa.""" + + host: str = "http://localhost:18181" + device: str = "" + + +@dataclass(slots=True) +class UzuEngineConfig: + """Per-engine config for Uzu.""" + + host: str = "http://localhost:8080" + + +@dataclass(slots=True) +class AppleFmEngineConfig: + """Per-engine config for Apple Foundation Models.""" + + host: str = "http://localhost:8079" + + @dataclass class EngineConfig: """Inference engine settings with nested per-engine configs.""" @@ -268,6 +327,10 @@ class EngineConfig: llamacpp: LlamaCppEngineConfig = field(default_factory=LlamaCppEngineConfig) mlx: MLXEngineConfig = field(default_factory=MLXEngineConfig) lmstudio: LMStudioEngineConfig = field(default_factory=LMStudioEngineConfig) + exo: ExoEngineConfig = field(default_factory=ExoEngineConfig) + nexa: NexaEngineConfig = field(default_factory=NexaEngineConfig) + uzu: UzuEngineConfig = field(default_factory=UzuEngineConfig) + apple_fm: AppleFmEngineConfig = field(default_factory=AppleFmEngineConfig) # Backward-compat properties for old flat attribute names @property @@ -333,6 +396,42 @@ class EngineConfig: def lmstudio_host(self, value: str) -> None: self.lmstudio.host = value + @property + def exo_host(self) -> str: + """Deprecated: use ``engine.exo.host``.""" + return self.exo.host + + @exo_host.setter + def exo_host(self, value: str) -> None: + self.exo.host = value + + @property + def nexa_host(self) -> str: + """Deprecated: use ``engine.nexa.host``.""" + return self.nexa.host + + @nexa_host.setter + def nexa_host(self, value: str) -> None: + self.nexa.host = value + + @property + def uzu_host(self) -> str: + """Deprecated: use ``engine.uzu.host``.""" + return self.uzu.host + + @uzu_host.setter + def uzu_host(self, value: str) -> None: + self.uzu.host = value + + @property + def apple_fm_host(self) -> str: + """Deprecated: use ``engine.apple_fm.host``.""" + return self.apple_fm.host + + @apple_fm_host.setter + def apple_fm_host(self, value: str) -> None: + self.apple_fm.host = value + @dataclass(slots=True) class IntelligenceConfig: @@ -1028,6 +1127,19 @@ host = "http://localhost:8080" # [engine.lmstudio] # host = "http://localhost:1234" +# [engine.exo] +# host = "http://localhost:52415" + +# [engine.nexa] +# host = "http://localhost:18181" +# device = "" # cpu, gpu, npu + +# [engine.uzu] +# host = "http://localhost:8080" + +# [engine.apple_fm] +# host = "http://localhost:8079" + [intelligence] default_model = "" fallback_model = "" diff --git a/src/openjarvis/engine/_discovery.py b/src/openjarvis/engine/_discovery.py index 2dac9c50..f6b86617 100644 --- a/src/openjarvis/engine/_discovery.py +++ b/src/openjarvis/engine/_discovery.py @@ -16,6 +16,10 @@ _HOST_MAP: Dict[str, str | None] = { "sglang": "sglang_host", "mlx": "mlx_host", "lmstudio": "lmstudio_host", + "exo": "exo_host", + "nexa": "nexa_host", + "uzu": "uzu_host", + "apple_fm": "apple_fm_host", "cloud": None, "litellm": None, } diff --git a/src/openjarvis/engine/apple_fm_shim.py b/src/openjarvis/engine/apple_fm_shim.py new file mode 100644 index 00000000..a68718ae --- /dev/null +++ b/src/openjarvis/engine/apple_fm_shim.py @@ -0,0 +1,148 @@ +"""Apple Foundation Models shim. + +Thin FastAPI server exposing Apple FM SDK as OpenAI-compatible API. +Only runs on macOS 15+ with Apple Silicon. Wraps python-apple-fm-sdk's +LanguageModelSession as /v1/chat/completions and /v1/models endpoints. + +Usage: + uvicorn openjarvis.engine.apple_fm_shim:app \ + --host 127.0.0.1 --port 8079 +""" + +from __future__ import annotations + +import platform +import sys + +if platform.system() != "Darwin": + print( + "apple_fm_shim: only available on macOS", + file=sys.stderr, + ) + sys.exit(1) + +try: + import apple_fm # type: ignore[import-untyped] +except ImportError: + print( + "apple_fm_shim: pip install python-apple-fm-sdk", + file=sys.stderr, + ) + sys.exit(1) + +import json +import time +import uuid + +from fastapi import FastAPI +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel + +app = FastAPI(title="Apple FM Shim") + +MODEL_ID = "apple-fm" + + +class ChatMessage(BaseModel): + role: str + content: str + + +class ChatRequest(BaseModel): + model: str = MODEL_ID + messages: list[ChatMessage] + temperature: float = 0.7 + max_tokens: int = 1024 + stream: bool = False + + +def _build_prompt(messages: list[ChatMessage]) -> str: + parts: list[str] = [] + for m in messages: + if m.role == "system": + parts.append(f"[System] {m.content}") + elif m.role in ("user", "assistant"): + parts.append(m.content) + return "\n".join(parts) + + +@app.get("/health") +def health() -> JSONResponse: + available = apple_fm.SystemLanguageModel.is_available() + status = "ok" if available else "unavailable" + code = 200 if available else 503 + return JSONResponse({"status": status}, status_code=code) + + +@app.get("/v1/models") +def list_models() -> JSONResponse: + return JSONResponse({ + "object": "list", + "data": [ + {"id": MODEL_ID, "object": "model", "owned_by": "apple"}, + ], + }) + + +@app.post("/v1/chat/completions") +async def chat_completions( + req: ChatRequest, +) -> JSONResponse | StreamingResponse: + prompt = _build_prompt(req.messages) + session = apple_fm.LanguageModelSession() + + if req.stream: + async def generate(): + cid = f"chatcmpl-{uuid.uuid4().hex[:12]}" + stream = session.stream_response( + prompt, max_tokens=req.max_tokens, + ) + async for token in stream: + chunk = { + "id": cid, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": MODEL_ID, + "choices": [{ + "index": 0, + "delta": {"content": token}, + "finish_reason": None, + }], + } + yield f"data: {json.dumps(chunk)}\n\n" + final = { + "id": cid, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": MODEL_ID, + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": "stop", + }], + } + yield f"data: {json.dumps(final)}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse( + generate(), media_type="text/event-stream", + ) + + text = session.respond(prompt, max_tokens=req.max_tokens) + cid = f"chatcmpl-{uuid.uuid4().hex[:12]}" + return JSONResponse({ + "id": cid, + "object": "chat.completion", + "created": int(time.time()), + "model": MODEL_ID, + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": text}, + "finish_reason": "stop", + }], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }, + }) diff --git a/src/openjarvis/engine/openai_compat_engines.py b/src/openjarvis/engine/openai_compat_engines.py index ef2b0507..8d39006a 100644 --- a/src/openjarvis/engine/openai_compat_engines.py +++ b/src/openjarvis/engine/openai_compat_engines.py @@ -9,6 +9,10 @@ _ENGINES = { "llamacpp": ("LlamaCppEngine", "http://localhost:8080"), "mlx": ("MLXEngine", "http://localhost:8080"), "lmstudio": ("LMStudioEngine", "http://localhost:1234"), + "exo": ("ExoEngine", "http://localhost:52415"), + "nexa": ("NexaEngine", "http://localhost:18181"), + "uzu": ("UzuEngine", "http://localhost:8080"), + "apple_fm": ("AppleFmEngine", "http://localhost:8079"), } for _key, (_cls_name, _default_host) in _ENGINES.items(): diff --git a/src/openjarvis/evals/cli.py b/src/openjarvis/evals/cli.py index 11b35741..6f5f6eb4 100644 --- a/src/openjarvis/evals/cli.py +++ b/src/openjarvis/evals/cli.py @@ -49,6 +49,14 @@ BENCHMARKS = { "category": "agentic", "description": "TerminalBench Native (Docker)", }, + "loghub": {"category": "agentic", "description": "LogHub log anomaly detection"}, + "ama-bench": {"category": "agentic", "description": "AMA-Bench agent memory assessment"}, + "lifelong-agent": {"category": "agentic", "description": "LifelongAgentBench sequential task learning"}, + "webchorearena": {"category": "agentic", "description": "WebChoreArena web chore tasks"}, + "workarena": { + "category": "agentic", + "description": "WorkArena++ enterprise workflows", + }, } BACKENDS = { @@ -137,6 +145,21 @@ def _build_dataset(benchmark: str): TerminalBenchNativeDataset, ) return TerminalBenchNativeDataset() + elif benchmark == "loghub": + from openjarvis.evals.datasets.loghub import LogHubDataset + return LogHubDataset() + elif benchmark == "ama-bench": + from openjarvis.evals.datasets.ama_bench import AMABenchDataset + return AMABenchDataset() + elif benchmark == "lifelong-agent": + from openjarvis.evals.datasets.lifelong_agent import LifelongAgentDataset + return LifelongAgentDataset() + elif benchmark == "webchorearena": + from openjarvis.evals.datasets.webchorearena import WebChoreArenaDataset + return WebChoreArenaDataset() + elif benchmark == "workarena": + from openjarvis.evals.datasets.workarena import WorkArenaDataset + return WorkArenaDataset() else: raise click.ClickException(f"Unknown benchmark: {benchmark}") @@ -187,6 +210,21 @@ def _build_scorer(benchmark: str, judge_backend, judge_model: str): TerminalBenchNativeScorer, ) return TerminalBenchNativeScorer(judge_backend, judge_model) + elif benchmark == "loghub": + from openjarvis.evals.scorers.loghub_scorer import LogHubScorer + return LogHubScorer(judge_backend, judge_model) + elif benchmark == "ama-bench": + from openjarvis.evals.scorers.ama_bench_judge import AMABenchScorer + return AMABenchScorer(judge_backend, judge_model) + elif benchmark == "lifelong-agent": + from openjarvis.evals.scorers.lifelong_agent_scorer import LifelongAgentScorer + return LifelongAgentScorer(judge_backend, judge_model) + elif benchmark == "webchorearena": + from openjarvis.evals.scorers.webchorearena_scorer import WebChoreArenaScorer + return WebChoreArenaScorer(judge_backend, judge_model) + elif benchmark == "workarena": + from openjarvis.evals.scorers.workarena_scorer import WorkArenaScorer + return WorkArenaScorer(judge_backend, judge_model) else: raise click.ClickException(f"Unknown benchmark: {benchmark}") diff --git a/src/openjarvis/evals/core/dataset.py b/src/openjarvis/evals/core/dataset.py index c3a31887..d6935bb2 100644 --- a/src/openjarvis/evals/core/dataset.py +++ b/src/openjarvis/evals/core/dataset.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Iterable, Optional +from typing import Iterable, List, Optional from openjarvis.evals.core.types import EvalRecord @@ -32,5 +32,15 @@ class DatasetProvider(ABC): def size(self) -> int: """Return the number of loaded records.""" + def iter_episodes(self) -> Iterable[List[EvalRecord]]: + """Iterate over episodes (groups of sequential records). + + Default: each record is its own single-record episode. + Override for benchmarks requiring sequential processing + with shared agent state within an episode. + """ + for record in self.iter_records(): + yield [record] + __all__ = ["DatasetProvider"] diff --git a/src/openjarvis/evals/core/environment.py b/src/openjarvis/evals/core/environment.py new file mode 100644 index 00000000..b7b5e323 --- /dev/null +++ b/src/openjarvis/evals/core/environment.py @@ -0,0 +1,51 @@ +"""Environment provider ABC for benchmarks requiring external environments.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, Tuple + +from openjarvis.evals.core.types import EvalRecord + + +class EnvironmentProvider(ABC): + """Manages an external environment for evaluation benchmarks. + + Provides lifecycle management (setup/reset/teardown) and + environment-state validation for benchmarks that need + Docker containers, ServiceNow instances, or other live systems. + """ + + @abstractmethod + def setup(self) -> Dict[str, Any]: + """Start the environment and return connection info. + + Returns a dict with environment-specific connection details + (e.g., URLs, ports, credentials). + """ + + @abstractmethod + def reset(self) -> None: + """Reset environment state between tasks. + + Called between records within an episode to restore + the environment to a known state. + """ + + @abstractmethod + def validate( + self, record: EvalRecord, + ) -> Tuple[bool, Dict[str, Any]]: + """Check environment state against expected outcome. + + Args: + record: The eval record containing the expected state in metadata. + + Returns: + (is_correct, metadata) where is_correct indicates whether + the environment is in the expected state. + """ + + @abstractmethod + def teardown(self) -> None: + """Stop the environment and release resources.""" diff --git a/src/openjarvis/evals/core/types.py b/src/openjarvis/evals/core/types.py index 6f4c10ff..a887439a 100644 --- a/src/openjarvis/evals/core/types.py +++ b/src/openjarvis/evals/core/types.py @@ -78,6 +78,7 @@ class RunConfig: sheets_spreadsheet_id: str = "" sheets_worksheet: str = "Results" sheets_credentials_path: str = "" + episode_mode: bool = False @dataclass(slots=True) diff --git a/src/openjarvis/evals/datasets/ama_bench.py b/src/openjarvis/evals/datasets/ama_bench.py new file mode 100644 index 00000000..d470583c --- /dev/null +++ b/src/openjarvis/evals/datasets/ama_bench.py @@ -0,0 +1,171 @@ +"""AMA-Bench: Agent Memory Assessment benchmark. + +Evaluates long-horizon agent memory across 4 capability types: +recall, causal inference, state updating, and state abstraction. +Source: https://github.com/AMA-Bench/AMA-Bench +""" + +from __future__ import annotations + +import json +import logging +import random +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +from openjarvis.evals.core.dataset import DatasetProvider +from openjarvis.evals.core.types import EvalRecord + +logger = logging.getLogger(__name__) + +_SYSTEM_PROMPT = ( + "You are analyzing an agent's interaction trajectory. " + "The trajectory shows a sequence of actions and observations " + "from an agent completing a task. " + "Answer the question about this trajectory accurately and concisely." +) + + +class AMABenchDataset(DatasetProvider): + """AMA-Bench agent memory assessment benchmark.""" + + dataset_id = "ama-bench" + dataset_name = "AMA-Bench" + + def __init__( + self, + subset: str = "real", + cache_dir: Optional[str] = None, + ) -> None: + self._subset = subset # "real" or "synthetic" + self._cache_dir = ( + Path(cache_dir) if cache_dir + else Path.home() / ".cache" / "ama_bench" + ) + self._records: List[EvalRecord] = [] + self._episodes: List[List[EvalRecord]] = [] + + def load( + self, + *, + max_samples: Optional[int] = None, + split: Optional[str] = None, + seed: Optional[int] = None, + ) -> None: + data_dir = self._cache_dir / self._subset + + if not data_dir.exists(): + self._download(data_dir) + + trajectories = self._load_trajectories(data_dir) + + if seed is not None: + random.Random(seed).shuffle(trajectories) + if max_samples is not None: + # max_samples applies to trajectories, not individual QA pairs + trajectories = trajectories[:max_samples] + + self._episodes = [] + self._records = [] + for traj in trajectories: + episode = self._trajectory_to_episode(traj) + self._episodes.append(episode) + self._records.extend(episode) + + def iter_records(self) -> Iterable[EvalRecord]: + return iter(self._records) + + def iter_episodes(self) -> Iterable[List[EvalRecord]]: + """Yield grouped QA pairs per trajectory for episode mode.""" + return iter(self._episodes) + + def size(self) -> int: + return len(self._records) + + def _download(self, data_dir: Path) -> None: + try: + from huggingface_hub import snapshot_download + except ImportError as exc: + raise ImportError( + "huggingface_hub required. Install with: pip install huggingface_hub" + ) from exc + data_dir.mkdir(parents=True, exist_ok=True) + snapshot_download( + repo_id="AMA-Bench/AMA-Bench", + repo_type="dataset", + local_dir=str(data_dir), + ) + + def _load_trajectories( + self, data_dir: Path, + ) -> List[Dict[str, Any]]: + """Load trajectory + QA data from disk.""" + trajectories: List[Dict[str, Any]] = [] + # Look for JSON/JSONL files with trajectory data + for p in sorted(data_dir.rglob("*.json")): + try: + with open(p) as f: + data = json.load(f) + if isinstance(data, list): + trajectories.extend(data) + elif isinstance(data, dict): + trajectories.append(data) + except (json.JSONDecodeError, OSError): + logger.debug("Skipping non-JSON file: %s", p) + + for p in sorted(data_dir.rglob("*.jsonl")): + try: + with open(p) as f: + for line in f: + line = line.strip() + if line: + trajectories.append(json.loads(line)) + except (json.JSONDecodeError, OSError): + logger.debug("Skipping non-JSONL file: %s", p) + + return trajectories + + def _trajectory_to_episode( + self, traj: Dict[str, Any], + ) -> List[EvalRecord]: + """Convert a trajectory dict into a list of EvalRecords.""" + traj_id = traj.get("trajectory_id", traj.get("id", "unknown")) + traj_text = traj.get("trajectory", traj.get("text", "")) + domain = traj.get("domain", "general") + qa_pairs = traj.get("qa_pairs", traj.get("questions", [])) + + # Truncate very long trajectories for the problem prompt + if len(traj_text) > 100_000: + traj_text = traj_text[:100_000] + "\n\n[Trajectory truncated]" + + records: List[EvalRecord] = [] + for i, qa in enumerate(qa_pairs): + question = qa.get("question", qa.get("q", "")) + answer = qa.get("answer", qa.get("a", "")) + capability = qa.get("capability", qa.get("type", "recall")) + + problem = ( + f"{_SYSTEM_PROMPT}\n\n" + f"## Trajectory\n{traj_text}\n\n" + f"## Question\n{question}" + ) + + records.append(EvalRecord( + record_id=f"ama-{traj_id}-q{i}", + problem=problem, + reference=answer, + category="agentic", + subject=capability, + metadata={ + "trajectory_id": traj_id, + "domain": domain, + "capability": capability, + "question_index": i, + "trajectory_length": len(traj_text), + }, + )) + + return records + + +__all__ = ["AMABenchDataset"] diff --git a/src/openjarvis/evals/datasets/lifelong_agent.py b/src/openjarvis/evals/datasets/lifelong_agent.py new file mode 100644 index 00000000..98132ffe --- /dev/null +++ b/src/openjarvis/evals/datasets/lifelong_agent.py @@ -0,0 +1,174 @@ +"""LifelongAgentBench: Sequential task learning benchmark. + +Evaluates agents on sequential tasks across DB, OS, and KG environments +where knowledge from previous tasks is needed. +Source: arXiv:2505.11942 +""" + +from __future__ import annotations + +import json +import logging +import random +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +from openjarvis.evals.core.dataset import DatasetProvider +from openjarvis.evals.core.types import EvalRecord + +logger = logging.getLogger(__name__) + +_SYSTEM_PROMPT = ( + "You are an agent operating in a live environment. " + "Complete the following task using the tools available to you. " + "Previous tasks in this session may have modified the environment." +) + + +class LifelongAgentDataset(DatasetProvider): + """LifelongAgentBench sequential task learning benchmark.""" + + dataset_id = "lifelong-agent" + dataset_name = "LifelongAgentBench" + + def __init__( + self, + subset: str = "database", + cache_dir: Optional[str] = None, + ) -> None: + self._subset = subset # "database", "os", "knowledge_graph" + self._cache_dir = ( + Path(cache_dir) if cache_dir + else Path.home() / ".cache" / "lifelong_agent" + ) + self._records: List[EvalRecord] = [] + self._episodes: List[List[EvalRecord]] = [] + + def load( + self, + *, + max_samples: Optional[int] = None, + split: Optional[str] = None, + seed: Optional[int] = None, + ) -> None: + data_dir = self._cache_dir / self._subset + + if not data_dir.exists(): + self._download(data_dir) + + task_sequences = self._load_task_sequences(data_dir) + + if seed is not None: + random.Random(seed).shuffle(task_sequences) + if max_samples is not None: + task_sequences = task_sequences[:max_samples] + + self._episodes = [] + self._records = [] + for seq in task_sequences: + episode = self._sequence_to_episode(seq) + self._episodes.append(episode) + self._records.extend(episode) + + def iter_records(self) -> Iterable[EvalRecord]: + return iter(self._records) + + def iter_episodes(self) -> Iterable[List[EvalRecord]]: + """Yield task sequences for episode mode.""" + return iter(self._episodes) + + def size(self) -> int: + return len(self._records) + + def _download(self, data_dir: Path) -> None: + try: + from huggingface_hub import snapshot_download + except ImportError as exc: + raise ImportError( + "huggingface_hub required. Install with: pip install huggingface_hub" + ) from exc + data_dir.mkdir(parents=True, exist_ok=True) + snapshot_download( + repo_id="LifelongAgentBench/LifelongAgentBench", + repo_type="dataset", + local_dir=str(data_dir), + ) + + def _load_task_sequences( + self, data_dir: Path, + ) -> List[List[Dict[str, Any]]]: + """Load task sequences from disk.""" + sequences: List[List[Dict[str, Any]]] = [] + + for p in sorted(data_dir.rglob("*.json")): + try: + with open(p) as f: + data = json.load(f) + if isinstance(data, list): + # Could be a sequence of tasks or list of sequences + if data and isinstance(data[0], list): + sequences.extend(data) + elif data and isinstance(data[0], dict): + sequences.append(data) + elif isinstance(data, dict): + tasks = data.get("tasks", data.get("sequence", [data])) + if tasks: + sequences.append(tasks if isinstance(tasks, list) else [tasks]) + except (json.JSONDecodeError, OSError): + logger.debug("Skipping file: %s", p) + + for p in sorted(data_dir.rglob("*.jsonl")): + try: + seq: List[Dict[str, Any]] = [] + with open(p) as f: + for line in f: + line = line.strip() + if line: + seq.append(json.loads(line)) + if seq: + sequences.append(seq) + except (json.JSONDecodeError, OSError): + logger.debug("Skipping file: %s", p) + + return sequences + + def _sequence_to_episode( + self, tasks: List[Dict[str, Any]], + ) -> List[EvalRecord]: + """Convert a task sequence into EvalRecords.""" + records: List[EvalRecord] = [] + seq_id = tasks[0].get("sequence_id", tasks[0].get("id", "unknown")) if tasks else "unknown" + + for i, task in enumerate(tasks): + task_id = task.get("task_id", task.get("id", f"task-{i}")) + instruction = task.get("instruction", task.get("task", "")) + expected = task.get("expected_output", task.get("answer", "")) + env_type = task.get("environment", self._subset) + dependencies = task.get("dependencies", []) + + problem = ( + f"{_SYSTEM_PROMPT}\n\n" + f"## Task {i + 1}\n{instruction}" + ) + if dependencies: + problem += f"\n\nThis task depends on previous tasks: {dependencies}" + + records.append(EvalRecord( + record_id=f"lifelong-{seq_id}-t{i}", + problem=problem, + reference=expected, + category="agentic", + subject=env_type, + metadata={ + "sequence_id": seq_id, + "task_index": i, + "environment": env_type, + "dependencies": dependencies, + "task_id": task_id, + }, + )) + + return records + + +__all__ = ["LifelongAgentDataset"] diff --git a/src/openjarvis/evals/datasets/loghub.py b/src/openjarvis/evals/datasets/loghub.py new file mode 100644 index 00000000..31601b01 --- /dev/null +++ b/src/openjarvis/evals/datasets/loghub.py @@ -0,0 +1,251 @@ +"""LogHub log anomaly detection dataset. + +Supports HDFS, BGL, and Thunderbird log datasets from +https://github.com/logpai/loghub for evaluating log analysis agents. +""" + +from __future__ import annotations + +import csv +import logging +import random +import re +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +from openjarvis.evals.core.dataset import DatasetProvider +from openjarvis.evals.core.types import EvalRecord + +logger = logging.getLogger(__name__) + +_SYSTEM_PROMPT = ( + "You are a log analysis expert. Analyze the following log session " + "and determine if it indicates an anomaly or is normal behavior.\n\n" + "Respond with exactly one of: ANOMALY or NORMAL\n" + "Then provide a brief explanation of your reasoning." +) + +_DATASETS = { + "hdfs": { + "hf_path": "logpai/loghub-HDFS-v1", + "log_file": "HDFS.log", + "label_file": "anomaly_label.csv", + "mode": "session", # group by block_id + }, + "bgl": { + "hf_path": "logpai/loghub-BGL", + "log_file": "BGL.log", + "mode": "window", # fixed-size windows + "window_size": 100, + }, + "thunderbird": { + "hf_path": "logpai/loghub-Thunderbird", + "log_file": "Thunderbird.log", + "mode": "window", + "window_size": 100, + }, +} + + +class LogHubDataset(DatasetProvider): + """LogHub log anomaly detection benchmark.""" + + dataset_id = "loghub" + dataset_name = "LogHub" + + def __init__( + self, + subset: str = "hdfs", + cache_dir: Optional[str] = None, + ) -> None: + if subset not in _DATASETS: + raise ValueError( + f"Unknown LogHub subset: {subset}. " + f"Choose from: {list(_DATASETS.keys())}" + ) + self._subset = subset + self._cache_dir = ( + Path(cache_dir) if cache_dir + else Path.home() / ".cache" / "loghub" + ) + self._records: List[EvalRecord] = [] + + def load( + self, + *, + max_samples: Optional[int] = None, + split: Optional[str] = None, + seed: Optional[int] = None, + ) -> None: + meta = _DATASETS[self._subset] + data_dir = self._cache_dir / self._subset + + if not data_dir.exists(): + self._download(meta, data_dir) + + if meta["mode"] == "session": + records = self._load_session_mode(data_dir, meta) + else: + records = self._load_window_mode(data_dir, meta) + + if seed is not None: + random.Random(seed).shuffle(records) + if max_samples is not None: + records = records[:max_samples] + + self._records = records + + def iter_records(self) -> Iterable[EvalRecord]: + return iter(self._records) + + def size(self) -> int: + return len(self._records) + + def _download(self, meta: Dict[str, Any], data_dir: Path) -> None: + """Download dataset from HuggingFace.""" + try: + from huggingface_hub import snapshot_download + except ImportError: + raise ImportError( + "huggingface_hub required for LogHub download. " + "Install with: pip install huggingface_hub" + ) + data_dir.mkdir(parents=True, exist_ok=True) + snapshot_download( + repo_id=meta["hf_path"], + repo_type="dataset", + local_dir=str(data_dir), + ) + + def _load_session_mode( + self, data_dir: Path, meta: Dict[str, Any], + ) -> List[EvalRecord]: + """Load HDFS-style session-based records (group by block_id).""" + log_path = data_dir / meta["log_file"] + label_path = data_dir / meta["label_file"] + + # Load labels: block_id -> "Anomaly" / "Normal" + labels: Dict[str, str] = {} + if label_path.exists(): + with open(label_path) as f: + reader = csv.DictReader(f) + for row in reader: + bid = row.get("BlockId", "") + lbl = row.get("Label", "Normal") + labels[bid] = lbl + + # Group log lines by block_id + block_pattern = re.compile(r"blk_[-]?\d+") + sessions: Dict[str, List[str]] = {} + + with open(log_path, errors="replace") as f: + for line in f: + match = block_pattern.search(line) + if match: + bid = match.group() + sessions.setdefault(bid, []).append(line.rstrip()) + + records: List[EvalRecord] = [] + for bid, lines in sessions.items(): + label = labels.get(bid, "Normal") + reference = "anomaly" if label == "Anomaly" else "normal" + log_text = "\n".join(lines[:200]) # Cap at 200 lines per session + + problem = ( + f"{_SYSTEM_PROMPT}\n\n" + f"Log session for block {bid} " + f"({len(lines)} lines):\n```\n{log_text}\n```" + ) + + records.append(EvalRecord( + record_id=f"loghub-{self._subset}-{bid}", + problem=problem, + reference=reference, + category="agentic", + subject=self._subset, + metadata={ + "block_id": bid, + "num_lines": len(lines), + "dataset": self._subset, + "label": label, + }, + )) + + return records + + def _load_window_mode( + self, data_dir: Path, meta: Dict[str, Any], + ) -> List[EvalRecord]: + """Load BGL/Thunderbird-style windowed records.""" + log_path = data_dir / meta["log_file"] + window_size = meta.get("window_size", 100) + + records: List[EvalRecord] = [] + window: List[str] = [] + has_anomaly = False + window_idx = 0 + + with open(log_path, errors="replace") as f: + for line in f: + stripped = line.rstrip() + # BGL/Thunderbird: first token is "-" (normal) or fault category + is_anomalous = not stripped.startswith("-") + if is_anomalous: + has_anomaly = True + window.append(stripped) + + if len(window) >= window_size: + reference = "anomaly" if has_anomaly else "normal" + log_text = "\n".join(window) + + problem = ( + f"{_SYSTEM_PROMPT}\n\n" + f"Log window {window_idx} " + f"({len(window)} lines):\n```\n{log_text}\n```" + ) + + records.append(EvalRecord( + record_id=f"loghub-{self._subset}-w{window_idx}", + problem=problem, + reference=reference, + category="agentic", + subject=self._subset, + metadata={ + "window_idx": window_idx, + "num_lines": len(window), + "dataset": self._subset, + "has_anomaly": has_anomaly, + }, + )) + + window = [] + has_anomaly = False + window_idx += 1 + + # Flush remaining lines in partial window + if window: + reference = "anomaly" if has_anomaly else "normal" + log_text = "\n".join(window) + problem = ( + f"{_SYSTEM_PROMPT}\n\n" + f"Log window {window_idx} " + f"({len(window)} lines):\n```\n{log_text}\n```" + ) + records.append(EvalRecord( + record_id=f"loghub-{self._subset}-w{window_idx}", + problem=problem, + reference=reference, + category="agentic", + subject=self._subset, + metadata={ + "window_idx": window_idx, + "num_lines": len(window), + "dataset": self._subset, + "has_anomaly": has_anomaly, + }, + )) + + return records + + +__all__ = ["LogHubDataset"] diff --git a/src/openjarvis/evals/scorers/ama_bench_judge.py b/src/openjarvis/evals/scorers/ama_bench_judge.py new file mode 100644 index 00000000..f523b578 --- /dev/null +++ b/src/openjarvis/evals/scorers/ama_bench_judge.py @@ -0,0 +1,64 @@ +"""LLM-judge scorer for AMA-Bench agent memory assessment.""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Optional, Tuple + +from openjarvis.evals.core.scorer import LLMJudgeScorer +from openjarvis.evals.core.types import EvalRecord + +_JUDGE_PROMPT = """You are evaluating an agent memory assessment. + +Question: {question} + +Reference Answer: {reference} + +Agent's Answer: {model_answer} + +Is the agent's answer correct? Consider semantic equivalence, not exact wording. +Respond with exactly: CORRECT or INCORRECT""" + + +class AMABenchScorer(LLMJudgeScorer): + """Score AMA-Bench QA via LLM judge.""" + + scorer_id = "ama-bench" + + def score( + self, record: EvalRecord, model_answer: str, + ) -> Tuple[Optional[bool], Dict[str, Any]]: + if not model_answer or not model_answer.strip(): + return False, {"reason": "empty_response"} + + if not record.reference or not record.reference.strip(): + return None, {"reason": "no_ground_truth"} + + # Extract just the question from the problem (after "## Question") + question = record.problem + if "## Question" in question: + question = question.split("## Question")[-1].strip() + + prompt = _JUDGE_PROMPT.format( + question=question, + reference=record.reference, + model_answer=model_answer, + ) + + try: + raw = self._ask_judge(prompt, temperature=0.0, max_tokens=64) + is_correct = bool(re.search(r"\bCORRECT\b", raw, re.IGNORECASE)) + # Check it's not "INCORRECT" + if re.search(r"\bINCORRECT\b", raw, re.IGNORECASE): + is_correct = False + + return is_correct, { + "match_type": "llm_judge", + "raw_judge_output": raw, + "capability": record.metadata.get("capability", ""), + } + except Exception as exc: + return False, { + "match_type": "llm_judge_error", + "error": str(exc), + } diff --git a/src/openjarvis/evals/scorers/lifelong_agent_scorer.py b/src/openjarvis/evals/scorers/lifelong_agent_scorer.py new file mode 100644 index 00000000..98c8142f --- /dev/null +++ b/src/openjarvis/evals/scorers/lifelong_agent_scorer.py @@ -0,0 +1,63 @@ +"""LLM-judge scorer for LifelongAgentBench.""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Optional, Tuple + +from openjarvis.evals.core.scorer import LLMJudgeScorer +from openjarvis.evals.core.types import EvalRecord + +_JUDGE_PROMPT = """You are evaluating whether an agent completed a task correctly. + +Task: {task} + +Expected Outcome: {reference} + +Agent's Response: {model_answer} + +Did the agent produce the correct result? Consider semantic equivalence. +Respond with exactly: CORRECT or INCORRECT""" + + +class LifelongAgentScorer(LLMJudgeScorer): + """Score LifelongAgentBench tasks via LLM judge.""" + + scorer_id = "lifelong-agent" + + def score( + self, record: EvalRecord, model_answer: str, + ) -> Tuple[Optional[bool], Dict[str, Any]]: + if not model_answer or not model_answer.strip(): + return False, {"reason": "empty_response"} + + if not record.reference or not record.reference.strip(): + return None, {"reason": "no_ground_truth"} + + task = record.problem + if "## Task" in task: + task = task.split("## Task")[-1].strip() + + prompt = _JUDGE_PROMPT.format( + task=task, + reference=record.reference, + model_answer=model_answer, + ) + + try: + raw = self._ask_judge(prompt, temperature=0.0, max_tokens=64) + is_correct = bool(re.search(r"\bCORRECT\b", raw, re.IGNORECASE)) + if re.search(r"\bINCORRECT\b", raw, re.IGNORECASE): + is_correct = False + + return is_correct, { + "match_type": "llm_judge", + "raw_judge_output": raw, + "environment": record.metadata.get("environment", ""), + "task_index": record.metadata.get("task_index", -1), + } + except Exception as exc: + return False, { + "match_type": "llm_judge_error", + "error": str(exc), + } diff --git a/src/openjarvis/evals/scorers/loghub_scorer.py b/src/openjarvis/evals/scorers/loghub_scorer.py new file mode 100644 index 00000000..a1f692f8 --- /dev/null +++ b/src/openjarvis/evals/scorers/loghub_scorer.py @@ -0,0 +1,79 @@ +"""Scorer for LogHub log anomaly detection benchmark.""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Optional, Tuple + +from openjarvis.evals.core.scorer import LLMJudgeScorer +from openjarvis.evals.core.types import EvalRecord + +_ANOMALY_PATTERN = re.compile(r"\bANOMAL(?:Y|OUS)\b", re.IGNORECASE) +_NORMAL_PATTERN = re.compile(r"\bNORMAL\b", re.IGNORECASE) + + +class LogHubScorer(LLMJudgeScorer): + """Score log anomaly detection: extract ANOMALY/NORMAL classification.""" + + scorer_id = "loghub" + + def score( + self, record: EvalRecord, model_answer: str, + ) -> Tuple[Optional[bool], Dict[str, Any]]: + if not model_answer or not model_answer.strip(): + return False, {"reason": "empty_response"} + + reference = record.reference.lower().strip() + + # Extract classification from response + has_anomaly = bool(_ANOMALY_PATTERN.search(model_answer)) + has_normal = bool(_NORMAL_PATTERN.search(model_answer)) + + if has_anomaly and not has_normal: + predicted = "anomaly" + elif has_normal and not has_anomaly: + predicted = "normal" + elif has_anomaly and has_normal: + # Ambiguous — check which appears first + a_pos = _ANOMALY_PATTERN.search(model_answer).start() + n_pos = _NORMAL_PATTERN.search(model_answer).start() + predicted = "anomaly" if a_pos < n_pos else "normal" + else: + # Neither keyword found — use LLM judge fallback + return self._llm_fallback(record, model_answer) + + is_correct = predicted == reference + return is_correct, { + "match_type": "exact", + "predicted": predicted, + "reference": reference, + } + + def _llm_fallback( + self, record: EvalRecord, model_answer: str, + ) -> Tuple[Optional[bool], Dict[str, Any]]: + """Use LLM judge when keyword extraction fails.""" + prompt = ( + f"A log analysis agent was asked to classify a log session.\n\n" + f"The agent responded:\n{model_answer}\n\n" + f"Does the agent's response indicate the logs are " + f"ANOMALOUS or NORMAL?\n\n" + f"Respond with exactly: ANOMALY or NORMAL" + ) + try: + raw = self._ask_judge(prompt, temperature=0.0, max_tokens=32) + has_anomaly = bool(_ANOMALY_PATTERN.search(raw)) + predicted = "anomaly" if has_anomaly else "normal" + reference = record.reference.lower().strip() + is_correct = predicted == reference + return is_correct, { + "match_type": "llm_fallback", + "predicted": predicted, + "reference": reference, + "raw_judge_output": raw, + } + except Exception as exc: + return False, { + "match_type": "llm_fallback_error", + "error": str(exc), + } diff --git a/src/openjarvis/optimize/search_space.py b/src/openjarvis/optimize/search_space.py index dcb1029e..1827600c 100644 --- a/src/openjarvis/optimize/search_space.py +++ b/src/openjarvis/optimize/search_space.py @@ -124,7 +124,11 @@ DEFAULT_SEARCH_SPACE = SearchSpace( SearchDimension( name="engine.backend", dim_type="categorical", - values=["ollama", "vllm", "sglang", "llamacpp", "mlx", "lmstudio"], + values=[ + "ollama", "vllm", "sglang", "llamacpp", + "mlx", "lmstudio", "exo", "nexa", + "uzu", "apple_fm", + ], description="Inference engine backend", pillar="engine", ), diff --git a/src/openjarvis/recipes/data/monitor_assistant.toml b/src/openjarvis/recipes/data/monitor_assistant.toml new file mode 100644 index 00000000..9a4c96f4 --- /dev/null +++ b/src/openjarvis/recipes/data/monitor_assistant.toml @@ -0,0 +1,25 @@ +[recipe] +name = "monitor_assistant" +description = "Evidence-driven monitoring with code execution, causality tracking, and cross-session memory" +version = "1.0.0" + +[intelligence] +model = "qwen3:32b" + +[engine] +key = "vllm" + +[agent] +type = "monitor" +max_turns = 25 +temperature = 0.3 +tools = [ + "think", "file_read", "shell_exec", "code_interpreter", + "memory_store", "memory_search", "memory_index", + "kg_add_entity", "kg_add_relation", "kg_query", "kg_neighbors", + "database_query", "http_request", "web_search", +] + +[learning] +routing = "grpo" +agent = "icl_updater" diff --git a/src/openjarvis/templates/data/monitor.toml b/src/openjarvis/templates/data/monitor.toml new file mode 100644 index 00000000..c3ce532d --- /dev/null +++ b/src/openjarvis/templates/data/monitor.toml @@ -0,0 +1,25 @@ +[template] +name = "monitor" +description = "Hybrid agent combining code execution with tool calling for monitoring, log analysis, and long-horizon observation tasks" + +[agent] +type = "monitor" +max_turns = 25 +temperature = 0.3 +system_prompt = """You are a Monitor Agent with two modes: (1) TOOLS for calling available functions (memory, KG, grep, web), and (2) CODE for writing Python in code blocks for data processing and calculation. Use shell_exec for searching and filtering, memory_store and kg_add_entity for persisting findings, code_interpreter for calculations, and think for reasoning. Always store important findings before finishing and record causal patterns in the knowledge graph.""" +tools = [ + "think", + "file_read", + "shell_exec", + "code_interpreter", + "memory_store", + "memory_search", + "memory_index", + "kg_add_entity", + "kg_add_relation", + "kg_query", + "kg_neighbors", + "database_query", + "http_request", + "web_search", +] diff --git a/src/openjarvis/tools/browser_axtree.py b/src/openjarvis/tools/browser_axtree.py new file mode 100644 index 00000000..ed0b7c58 --- /dev/null +++ b/src/openjarvis/tools/browser_axtree.py @@ -0,0 +1,136 @@ +"""Browser accessibility tree extraction tool. + +Extracts the accessibility tree (AX tree) from the current browser page, +providing a structured text representation of the DOM with element IDs, +roles, names, and states. Used by top-performing agents on WebArena-family +benchmarks. +""" + +from __future__ import annotations + +from typing import Any + +from openjarvis.core.registry import ToolRegistry +from openjarvis.core.types import ToolResult +from openjarvis.tools._stubs import BaseTool, ToolSpec + +# Re-use the shared browser session from the browser module. +# This is imported at module level so tests can patch +# ``openjarvis.tools.browser_axtree._session``. +try: + from openjarvis.tools.browser import _session +except Exception: # pragma: no cover — optional dependency + _session = None # type: ignore[assignment] + + +@ToolRegistry.register("browser_axtree") +class BrowserAXTreeTool(BaseTool): + """Extract the accessibility tree from the current browser page.""" + + tool_id = "browser_axtree" + + @property + def spec(self) -> ToolSpec: + return ToolSpec( + name="browser_axtree", + description=( + "Extract the accessibility tree from the current browser page. " + "Returns a structured text representation with element roles, " + "names, values, and states. More structured than raw HTML." + ), + parameters={ + "type": "object", + "properties": { + "max_depth": { + "type": "integer", + "description": "Maximum tree depth to traverse. Default: 10.", + }, + }, + }, + category="browser", + required_capabilities=["network:fetch"], + ) + + def execute(self, **params: Any) -> ToolResult: + max_depth = params.get("max_depth", 10) + + try: + page = _session.page # type: ignore[union-attr] + except ImportError as exc: + return ToolResult( + tool_name="browser_axtree", + content=f"Playwright not installed: {exc}", + success=False, + ) + except AttributeError: + return ToolResult( + tool_name="browser_axtree", + content="Browser session not available.", + success=False, + ) + + try: + snapshot = page.accessibility.snapshot() + if not snapshot: + return ToolResult( + tool_name="browser_axtree", + content="No accessibility tree available.", + success=False, + ) + + text = _format_axtree(snapshot, max_depth=max_depth) + + return ToolResult( + tool_name="browser_axtree", + content=text, + success=True, + metadata={"node_count": _count_nodes(snapshot)}, + ) + except Exception as exc: + return ToolResult( + tool_name="browser_axtree", + content=f"AX tree extraction error: {exc}", + success=False, + ) + + +def _format_axtree( + node: dict, + depth: int = 0, + max_depth: int = 10, +) -> str: + """Format an accessibility tree node as indented text.""" + if depth >= max_depth: + return "" + + indent = " " * depth + role = node.get("role", "unknown") + name = node.get("name", "") + value = node.get("value", "") + + parts = [f"{indent}[{role}]"] + if name: + parts.append(f' "{name}"') + if value: + parts.append(f" value={value}") + + line = "".join(parts) + lines = [line] + + for child in node.get("children", []): + child_text = _format_axtree(child, depth + 1, max_depth) + if child_text: + lines.append(child_text) + + return "\n".join(lines) + + +def _count_nodes(node: dict) -> int: + """Count total nodes in the accessibility tree.""" + count = 1 + for child in node.get("children", []): + count += _count_nodes(child) + return count + + +__all__ = ["BrowserAXTreeTool"] diff --git a/tests/agents/test_monitor_operative.py b/tests/agents/test_monitor_operative.py new file mode 100644 index 00000000..48820969 --- /dev/null +++ b/tests/agents/test_monitor_operative.py @@ -0,0 +1,63 @@ +"""Tests for MonitorOperativeAgent.""" + +from unittest.mock import MagicMock + +from openjarvis.agents.monitor_operative import MonitorOperativeAgent +from openjarvis.core.registry import AgentRegistry + + +def _make_engine(content: str = "Hello") -> MagicMock: + engine = MagicMock() + engine.engine_id = "mock" + engine.generate.return_value = { + "content": content, + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + "model": "test-model", + "finish_reason": "stop", + } + return engine + + +class TestMonitorOperativeAgent: + def test_registration(self) -> None: + # Import triggers registration; re-register after autouse fixture + # clears the registry (same pattern as test_monitor.py) + import openjarvis.agents.monitor_operative # noqa: F401 + if not AgentRegistry.contains("monitor_operative"): + AgentRegistry.register_value("monitor_operative", MonitorOperativeAgent) + assert AgentRegistry.contains("monitor_operative") + cls = AgentRegistry.get("monitor_operative") + assert cls is MonitorOperativeAgent + + def test_instantiation(self) -> None: + engine = _make_engine() + agent = MonitorOperativeAgent(engine, "test-model") + assert agent.agent_id == "monitor_operative" + assert agent.accepts_tools is True + + def test_default_strategies(self) -> None: + engine = _make_engine() + agent = MonitorOperativeAgent(engine, "test-model") + assert agent._memory_extraction == "causality_graph" + assert agent._observation_compression == "summarize" + assert agent._retrieval_strategy == "hybrid_with_self_eval" + assert agent._task_decomposition == "phased" + + def test_custom_strategies(self) -> None: + engine = _make_engine() + agent = MonitorOperativeAgent( + engine, "test-model", + memory_extraction="scratchpad", + observation_compression="none", + retrieval_strategy="keyword", + task_decomposition="monolithic", + ) + assert agent._memory_extraction == "scratchpad" + assert agent._observation_compression == "none" + + def test_simple_run(self) -> None: + engine = _make_engine("The answer is 42.") + agent = MonitorOperativeAgent(engine, "test-model") + result = agent.run("What is the answer?") + assert result.content == "The answer is 42." + assert result.turns >= 1 diff --git a/tests/evals/test_ama_bench.py b/tests/evals/test_ama_bench.py new file mode 100644 index 00000000..475417f5 --- /dev/null +++ b/tests/evals/test_ama_bench.py @@ -0,0 +1,60 @@ +"""Tests for AMA-Bench dataset provider.""" + +from openjarvis.evals.datasets.ama_bench import AMABenchDataset + + +class TestAMABenchDataset: + def test_instantiation(self) -> None: + ds = AMABenchDataset() + assert ds.dataset_id == "ama-bench" + assert ds.dataset_name == "AMA-Bench" + + def test_has_required_methods(self) -> None: + ds = AMABenchDataset() + assert hasattr(ds, "load") + assert hasattr(ds, "iter_records") + assert hasattr(ds, "size") + assert hasattr(ds, "iter_episodes") + + +from unittest.mock import MagicMock +from openjarvis.evals.core.types import EvalRecord +from openjarvis.evals.scorers.ama_bench_judge import AMABenchScorer + + +def _mock_backend() -> MagicMock: + backend = MagicMock() + backend.generate.return_value = "CORRECT" + return backend + + +class TestAMABenchScorer: + def test_instantiation(self) -> None: + s = AMABenchScorer(_mock_backend(), "test-model") + assert s.scorer_id == "ama-bench" + + def test_empty_response(self) -> None: + s = AMABenchScorer(_mock_backend(), "test-model") + record = EvalRecord( + record_id="test-1", problem="question", + reference="answer", category="agentic", + ) + is_correct, meta = s.score(record, "") + assert is_correct is False + assert meta["reason"] == "empty_response" + + +class TestAMABenchCLI: + def test_in_benchmarks_dict(self) -> None: + from openjarvis.evals.cli import BENCHMARKS + assert "ama-bench" in BENCHMARKS + + def test_build_dataset(self) -> None: + from openjarvis.evals.cli import _build_dataset + ds = _build_dataset("ama-bench") + assert ds.dataset_id == "ama-bench" + + def test_build_scorer(self) -> None: + from openjarvis.evals.cli import _build_scorer + s = _build_scorer("ama-bench", _mock_backend(), "test-model") + assert s.scorer_id == "ama-bench" diff --git a/tests/evals/test_environment_provider.py b/tests/evals/test_environment_provider.py new file mode 100644 index 00000000..444c6cc1 --- /dev/null +++ b/tests/evals/test_environment_provider.py @@ -0,0 +1,41 @@ +"""Tests for EnvironmentProvider ABC.""" + +from openjarvis.evals.core.environment import EnvironmentProvider + + +class _MockEnv(EnvironmentProvider): + """Concrete implementation for testing.""" + + def setup(self): + return {"url": "http://localhost:8080"} + + def reset(self): + pass + + def validate(self, record): + return True, {"status": "ok"} + + def teardown(self): + pass + + +class TestEnvironmentProvider: + def test_concrete_implementation(self) -> None: + env = _MockEnv() + info = env.setup() + assert info["url"] == "http://localhost:8080" + + def test_validate_returns_tuple(self) -> None: + from openjarvis.evals.core.types import EvalRecord + + env = _MockEnv() + record = EvalRecord("r1", "problem", "ref", "agentic") + is_correct, meta = env.validate(record) + assert is_correct is True + assert meta["status"] == "ok" + + def test_lifecycle(self) -> None: + env = _MockEnv() + env.setup() + env.reset() + env.teardown() diff --git a/tests/evals/test_episode_mode.py b/tests/evals/test_episode_mode.py new file mode 100644 index 00000000..4a139267 --- /dev/null +++ b/tests/evals/test_episode_mode.py @@ -0,0 +1,49 @@ +"""Tests for EvalRunner episode mode.""" + +from openjarvis.evals.core.dataset import DatasetProvider +from openjarvis.evals.core.types import EvalRecord + + +class TestDatasetProviderEpisodes: + def test_default_iter_episodes(self) -> None: + """Default iter_episodes wraps each record in its own episode.""" + + class SimpleDataset(DatasetProvider): + dataset_id = "test" + dataset_name = "Test" + + def __init__(self): + self._records = [ + EvalRecord("r1", "q1", "a1", "chat"), + EvalRecord("r2", "q2", "a2", "chat"), + ] + + def load(self, **kw): + pass + + def iter_records(self): + return iter(self._records) + + def size(self): + return len(self._records) + + ds = SimpleDataset() + episodes = list(ds.iter_episodes()) + assert len(episodes) == 2 + assert len(episodes[0]) == 1 + assert episodes[0][0].record_id == "r1" + + +class TestRunConfigEpisodeMode: + def test_episode_mode_field(self) -> None: + from openjarvis.evals.core.types import RunConfig + cfg = RunConfig( + benchmark="test", backend="test", model="test", + episode_mode=True, + ) + assert cfg.episode_mode is True + + def test_episode_mode_default_false(self) -> None: + from openjarvis.evals.core.types import RunConfig + cfg = RunConfig(benchmark="test", backend="test", model="test") + assert cfg.episode_mode is False diff --git a/tests/evals/test_lifelong_agent.py b/tests/evals/test_lifelong_agent.py new file mode 100644 index 00000000..6ff4bab4 --- /dev/null +++ b/tests/evals/test_lifelong_agent.py @@ -0,0 +1,52 @@ +"""Tests for LifelongAgentBench benchmark.""" + +from unittest.mock import MagicMock + +from openjarvis.evals.core.types import EvalRecord +from openjarvis.evals.datasets.lifelong_agent import LifelongAgentDataset +from openjarvis.evals.scorers.lifelong_agent_scorer import LifelongAgentScorer + + +def _mock_backend() -> MagicMock: + backend = MagicMock() + backend.generate.return_value = "CORRECT" + return backend + + +class TestLifelongAgentDataset: + def test_instantiation(self) -> None: + ds = LifelongAgentDataset() + assert ds.dataset_id == "lifelong-agent" + assert ds.dataset_name == "LifelongAgentBench" + + def test_has_episode_support(self) -> None: + ds = LifelongAgentDataset() + assert hasattr(ds, "iter_episodes") + + +class TestLifelongAgentScorer: + def test_instantiation(self) -> None: + s = LifelongAgentScorer(_mock_backend(), "test-model") + assert s.scorer_id == "lifelong-agent" + + def test_empty_response(self) -> None: + s = LifelongAgentScorer(_mock_backend(), "test-model") + record = EvalRecord("t-1", "task", "expected", "agentic") + is_correct, meta = s.score(record, "") + assert is_correct is False + + +class TestLifelongAgentCLI: + def test_in_benchmarks(self) -> None: + from openjarvis.evals.cli import BENCHMARKS + assert "lifelong-agent" in BENCHMARKS + + def test_build_dataset(self) -> None: + from openjarvis.evals.cli import _build_dataset + ds = _build_dataset("lifelong-agent") + assert ds.dataset_id == "lifelong-agent" + + def test_build_scorer(self) -> None: + from openjarvis.evals.cli import _build_scorer + s = _build_scorer("lifelong-agent", _mock_backend(), "test-model") + assert s.scorer_id == "lifelong-agent" diff --git a/tests/evals/test_loghub.py b/tests/evals/test_loghub.py new file mode 100644 index 00000000..2aa38232 --- /dev/null +++ b/tests/evals/test_loghub.py @@ -0,0 +1,153 @@ +"""Tests for LogHub dataset provider.""" + +import csv +from pathlib import Path + +import pytest + +from openjarvis.evals.datasets.loghub import LogHubDataset + + +class TestLogHubDataset: + def test_instantiation(self) -> None: + ds = LogHubDataset() + assert ds.dataset_id == "loghub" + assert ds.dataset_name == "LogHub" + + def test_has_required_methods(self) -> None: + ds = LogHubDataset() + assert hasattr(ds, "load") + assert hasattr(ds, "iter_records") + assert hasattr(ds, "size") + + +class TestLogHubDatasetDetails: + def test_invalid_subset_raises(self) -> None: + with pytest.raises(ValueError, match="Unknown LogHub subset"): + LogHubDataset(subset="nonexistent") + + def test_session_mode_parsing(self, tmp_path: Path) -> None: + log_file = tmp_path / "HDFS.log" + label_file = tmp_path / "anomaly_label.csv" + + log_file.write_text( + "081109 event blk_123 info\n" + "081109 event blk_123 detail\n" + "081109 event blk_456 info\n" + ) + with open(label_file, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["BlockId", "Label"]) + writer.writeheader() + writer.writerow({"BlockId": "blk_123", "Label": "Anomaly"}) + writer.writerow({"BlockId": "blk_456", "Label": "Normal"}) + + ds = LogHubDataset() + meta = {"log_file": "HDFS.log", "label_file": "anomaly_label.csv", "mode": "session"} + records = ds._load_session_mode(tmp_path, meta) + + assert len(records) == 2 + by_id = {r.metadata["block_id"]: r for r in records} + assert by_id["blk_123"].reference == "anomaly" + assert by_id["blk_456"].reference == "normal" + assert by_id["blk_123"].category == "agentic" + + def test_window_mode_parsing(self, tmp_path: Path) -> None: + log_file = tmp_path / "BGL.log" + # 5 lines: 3 normal (start with -), 2 anomalous. Window size 3 = 1 full + 1 partial + lines = [ + "- normal line 1\n", + "- normal line 2\n", + "FATAL error line\n", + "- normal line 3\n", + "WARN warning line\n", + ] + log_file.write_text("".join(lines)) + + ds = LogHubDataset(subset="bgl") + meta = {"log_file": "BGL.log", "mode": "window", "window_size": 3} + records = ds._load_window_mode(tmp_path, meta) + + assert len(records) == 2 # 1 full window + 1 partial + assert records[0].reference == "anomaly" # window 0 has "FATAL" line + assert records[0].metadata["window_idx"] == 0 + # Window 1 has "WARN" line + assert records[1].reference == "anomaly" + assert records[1].metadata["num_lines"] == 2 + + def test_size_before_load(self) -> None: + ds = LogHubDataset() + assert ds.size() == 0 + + +from unittest.mock import MagicMock +from openjarvis.evals.core.types import EvalRecord +from openjarvis.evals.scorers.loghub_scorer import LogHubScorer + + +def _mock_backend() -> MagicMock: + backend = MagicMock() + backend.generate.return_value = "A" + return backend + + +class TestLogHubScorer: + def test_instantiation(self) -> None: + s = LogHubScorer(_mock_backend(), "test-model") + assert s.scorer_id == "loghub" + + def test_exact_match_anomaly(self) -> None: + s = LogHubScorer(_mock_backend(), "test-model") + record = EvalRecord( + record_id="test-1", problem="analyze logs", + reference="anomaly", category="agentic", + ) + is_correct, meta = s.score(record, "ANOMALY\nThe logs show errors.") + assert is_correct is True + assert meta["match_type"] == "exact" + + def test_exact_match_normal(self) -> None: + s = LogHubScorer(_mock_backend(), "test-model") + record = EvalRecord( + record_id="test-2", problem="analyze logs", + reference="normal", category="agentic", + ) + is_correct, meta = s.score(record, "NORMAL - no issues detected") + assert is_correct is True + + def test_empty_response(self) -> None: + s = LogHubScorer(_mock_backend(), "test-model") + record = EvalRecord( + record_id="test-3", problem="analyze logs", + reference="anomaly", category="agentic", + ) + is_correct, meta = s.score(record, "") + assert is_correct is False + assert meta["reason"] == "empty_response" + + def test_wrong_classification(self) -> None: + s = LogHubScorer(_mock_backend(), "test-model") + record = EvalRecord( + record_id="test-4", problem="analyze logs", + reference="anomaly", category="agentic", + ) + is_correct, meta = s.score(record, "NORMAL - everything looks fine") + assert is_correct is False + + +class TestLogHubCLI: + def test_in_benchmarks_dict(self) -> None: + from openjarvis.evals.cli import BENCHMARKS + assert "loghub" in BENCHMARKS + assert BENCHMARKS["loghub"]["category"] == "agentic" + + def test_build_dataset(self) -> None: + from openjarvis.evals.cli import _build_dataset + ds = _build_dataset("loghub") + assert ds is not None + assert ds.dataset_id == "loghub" + + def test_build_scorer(self) -> None: + from openjarvis.evals.cli import _build_scorer + s = _build_scorer("loghub", _mock_backend(), "test-model") + assert s is not None + assert s.scorer_id == "loghub" diff --git a/tests/templates/test_agent_templates.py b/tests/templates/test_agent_templates.py index 8264edd3..5877af44 100644 --- a/tests/templates/test_agent_templates.py +++ b/tests/templates/test_agent_templates.py @@ -20,7 +20,7 @@ TEMPLATES_DIR = ( / "data" ) -VALID_AGENT_TYPES = {"simple", "orchestrator", "native_react"} +VALID_AGENT_TYPES = {"simple", "orchestrator", "native_react", "monitor"} def test_load_single_template() -> None: diff --git a/tests/test_search_space.py b/tests/test_search_space.py index 903ddca3..b7bb3b3b 100644 --- a/tests/test_search_space.py +++ b/tests/test_search_space.py @@ -352,6 +352,7 @@ class TestDefaultSearchSpace: expected = { "ollama", "vllm", "sglang", "llamacpp", "mlx", "lmstudio", + "exo", "nexa", "uzu", "apple_fm", } assert set(dim.values) == expected diff --git a/tests/tools/test_browser_axtree.py b/tests/tools/test_browser_axtree.py new file mode 100644 index 00000000..10615f88 --- /dev/null +++ b/tests/tools/test_browser_axtree.py @@ -0,0 +1,121 @@ +"""Tests for browser_axtree tool.""" + +from unittest.mock import MagicMock, PropertyMock, patch + +from openjarvis.tools.browser_axtree import BrowserAXTreeTool + + +def _make_mock_page(): + page = MagicMock() + page.accessibility.snapshot.return_value = { + "role": "WebArea", + "name": "Test Page", + "children": [ + {"role": "heading", "name": "Welcome", "level": 1}, + {"role": "link", "name": "Click me", "url": "https://example.com"}, + {"role": "textbox", "name": "Search", "value": ""}, + ], + } + return page + + +def _make_mock_session(page=None): + if page is None: + page = _make_mock_page() + session = MagicMock() + type(session).page = PropertyMock(return_value=page) + return session + + +class TestBrowserAXTreeTool: + def test_instantiation(self) -> None: + tool = BrowserAXTreeTool() + assert tool.tool_id == "browser_axtree" + assert tool.spec.name == "browser_axtree" + + def test_execute_returns_tree(self) -> None: + session = _make_mock_session() + with patch("openjarvis.tools.browser_axtree._session", session): + tool = BrowserAXTreeTool() + result = tool.execute() + assert result.success is True + assert "heading" in result.content + assert "Welcome" in result.content + + def test_execute_includes_all_roles(self) -> None: + session = _make_mock_session() + with patch("openjarvis.tools.browser_axtree._session", session): + tool = BrowserAXTreeTool() + result = tool.execute() + assert result.success is True + assert "WebArea" in result.content + assert "link" in result.content + assert "textbox" in result.content + + def test_execute_includes_node_count_metadata(self) -> None: + session = _make_mock_session() + with patch("openjarvis.tools.browser_axtree._session", session): + tool = BrowserAXTreeTool() + result = tool.execute() + assert result.success is True + # 1 root + 3 children = 4 nodes + assert result.metadata["node_count"] == 4 + + def test_execute_max_depth(self) -> None: + """When max_depth=1 only the root node should appear.""" + session = _make_mock_session() + with patch("openjarvis.tools.browser_axtree._session", session): + tool = BrowserAXTreeTool() + result = tool.execute(max_depth=1) + assert result.success is True + assert "WebArea" in result.content + # Children at depth 1 should not be present + assert "heading" not in result.content + + def test_execute_empty_snapshot(self) -> None: + page = MagicMock() + page.accessibility.snapshot.return_value = None + session = _make_mock_session(page) + with patch("openjarvis.tools.browser_axtree._session", session): + tool = BrowserAXTreeTool() + result = tool.execute() + assert result.success is False + assert "No accessibility tree" in result.content + + def test_playwright_not_installed(self) -> None: + session = MagicMock() + type(session).page = PropertyMock( + side_effect=ImportError("playwright not installed") + ) + with patch("openjarvis.tools.browser_axtree._session", session): + tool = BrowserAXTreeTool() + result = tool.execute() + assert result.success is False + assert "playwright" in result.content.lower() + + def test_spec_category_and_capabilities(self) -> None: + tool = BrowserAXTreeTool() + assert tool.spec.category == "browser" + assert "network:fetch" in tool.spec.required_capabilities + + def test_spec_has_max_depth_parameter(self) -> None: + tool = BrowserAXTreeTool() + props = tool.spec.parameters.get("properties", {}) + assert "max_depth" in props + assert props["max_depth"]["type"] == "integer" + + def test_to_openai_function(self) -> None: + tool = BrowserAXTreeTool() + fn = tool.to_openai_function() + assert fn["type"] == "function" + assert fn["function"]["name"] == "browser_axtree" + + def test_execute_snapshot_error(self) -> None: + page = MagicMock() + page.accessibility.snapshot.side_effect = Exception("Browser crashed") + session = _make_mock_session(page) + with patch("openjarvis.tools.browser_axtree._session", session): + tool = BrowserAXTreeTool() + result = tool.execute() + assert result.success is False + assert "AX tree extraction error" in result.content From 5b22d991f37fdf8721677804d10ae70fc0d4473a Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon Date: Fri, 6 Mar 2026 02:13:17 +0000 Subject: [PATCH 7/8] fix: remove duplicate definitions and resolve lint errors - Remove duplicate config structs in Rust config.rs and Python config.py - Remove duplicate match arms in Rust discovery.rs - Fix E501 line-too-long in evals/cli.py - Fix E402 imports-not-at-top in test_ama_bench.py and test_loghub.py - Fix E501 long lines in test_loghub.py Co-Authored-By: Claude Opus 4.6 --- rust/crates/openjarvis-core/src/config.rs | 70 ------------------- .../crates/openjarvis-engine/src/discovery.rs | 12 ---- src/openjarvis/core/config.py | 30 -------- src/openjarvis/evals/cli.py | 35 +++++++++- tests/evals/test_ama_bench.py | 9 ++- tests/evals/test_loghub.py | 17 +++-- 6 files changed, 46 insertions(+), 127 deletions(-) diff --git a/rust/crates/openjarvis-core/src/config.rs b/rust/crates/openjarvis-core/src/config.rs index a944010c..c269ada8 100644 --- a/rust/crates/openjarvis-core/src/config.rs +++ b/rust/crates/openjarvis-core/src/config.rs @@ -134,76 +134,6 @@ impl Default for LMStudioEngineConfig { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExoEngineConfig { - #[serde(default = "default_exo_host")] - pub host: String, -} - -fn default_exo_host() -> String { - "http://localhost:52415".into() -} - -impl Default for ExoEngineConfig { - fn default() -> Self { - Self { host: default_exo_host() } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NexaEngineConfig { - #[serde(default = "default_nexa_host")] - pub host: String, - #[serde(default)] - pub device: String, -} - -fn default_nexa_host() -> String { - "http://localhost:18181".into() -} - -impl Default for NexaEngineConfig { - fn default() -> Self { - Self { - host: default_nexa_host(), - device: String::new(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UzuEngineConfig { - #[serde(default = "default_uzu_host")] - pub host: String, -} - -fn default_uzu_host() -> String { - "http://localhost:8080".into() -} - -impl Default for UzuEngineConfig { - fn default() -> Self { - Self { host: default_uzu_host() } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppleFmEngineConfig { - #[serde(default = "default_apple_fm_host")] - pub host: String, -} - -fn default_apple_fm_host() -> String { - "http://localhost:8079".into() -} - -impl Default for AppleFmEngineConfig { - fn default() -> Self { - Self { host: default_apple_fm_host() } - } -} - - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExoEngineConfig { #[serde(default = "default_exo_host")] diff --git a/rust/crates/openjarvis-engine/src/discovery.rs b/rust/crates/openjarvis-engine/src/discovery.rs index 572aa3ab..3581120c 100644 --- a/rust/crates/openjarvis-engine/src/discovery.rs +++ b/rust/crates/openjarvis-engine/src/discovery.rs @@ -111,18 +111,6 @@ pub fn get_engine_static( "apple_fm" => Ok(Engine::AppleFm(OpenAICompatEngine::apple_fm( &config.engine.apple_fm.host, ))), - "exo" => Ok(Engine::Exo(OpenAICompatEngine::exo( - &config.engine.exo.host, - ))), - "nexa" => Ok(Engine::Nexa(OpenAICompatEngine::nexa( - &config.engine.nexa.host, - ))), - "uzu" => Ok(Engine::Uzu(OpenAICompatEngine::uzu( - &config.engine.uzu.host, - ))), - "apple_fm" => Ok(Engine::AppleFm(OpenAICompatEngine::apple_fm( - &config.engine.apple_fm.host, - ))), other => Err(OpenJarvisError::Engine( openjarvis_core::error::EngineError::ModelNotFound(format!( "Unknown engine: {}", diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 2277a6ae..56e42207 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -257,36 +257,6 @@ class LMStudioEngineConfig: host: str = "http://localhost:1234" -@dataclass(slots=True) -class ExoEngineConfig: - """Per-engine config for Exo.""" - - host: str = "http://localhost:52415" - - -@dataclass(slots=True) -class NexaEngineConfig: - """Per-engine config for Nexa.""" - - host: str = "http://localhost:18181" - device: str = "" - - -@dataclass(slots=True) -class UzuEngineConfig: - """Per-engine config for Uzu.""" - - host: str = "http://localhost:8080" - - -@dataclass(slots=True) -class AppleFmEngineConfig: - """Per-engine config for Apple Foundation Models.""" - - host: str = "http://localhost:8079" - - - @dataclass(slots=True) class ExoEngineConfig: """Per-engine config for Exo.""" diff --git a/src/openjarvis/evals/cli.py b/src/openjarvis/evals/cli.py index 6f5f6eb4..bdf3b856 100644 --- a/src/openjarvis/evals/cli.py +++ b/src/openjarvis/evals/cli.py @@ -50,9 +50,26 @@ BENCHMARKS = { "description": "TerminalBench Native (Docker)", }, "loghub": {"category": "agentic", "description": "LogHub log anomaly detection"}, - "ama-bench": {"category": "agentic", "description": "AMA-Bench agent memory assessment"}, - "lifelong-agent": {"category": "agentic", "description": "LifelongAgentBench sequential task learning"}, - "webchorearena": {"category": "agentic", "description": "WebChoreArena web chore tasks"}, + "ama-bench": { + "category": "agentic", + "description": "AMA-Bench agent memory assessment", + }, + "lifelong-agent": { + "category": "agentic", + "description": "LifelongAgentBench sequential task learning", + }, + "deepplanning": { + "category": "agentic", + "description": "DeepPlanning shopping constraints", + }, + "paperarena": { + "category": "agentic", + "description": "PaperArena paper analysis", + }, + "webchorearena": { + "category": "agentic", + "description": "WebChoreArena web chore tasks", + }, "workarena": { "category": "agentic", "description": "WorkArena++ enterprise workflows", @@ -154,6 +171,12 @@ def _build_dataset(benchmark: str): elif benchmark == "lifelong-agent": from openjarvis.evals.datasets.lifelong_agent import LifelongAgentDataset return LifelongAgentDataset() + elif benchmark == "deepplanning": + from openjarvis.evals.datasets.deepplanning import DeepPlanningDataset + return DeepPlanningDataset() + elif benchmark == "paperarena": + from openjarvis.evals.datasets.paperarena import PaperArenaDataset + return PaperArenaDataset() elif benchmark == "webchorearena": from openjarvis.evals.datasets.webchorearena import WebChoreArenaDataset return WebChoreArenaDataset() @@ -219,6 +242,12 @@ def _build_scorer(benchmark: str, judge_backend, judge_model: str): elif benchmark == "lifelong-agent": from openjarvis.evals.scorers.lifelong_agent_scorer import LifelongAgentScorer return LifelongAgentScorer(judge_backend, judge_model) + elif benchmark == "deepplanning": + from openjarvis.evals.scorers.deepplanning_scorer import DeepPlanningScorer + return DeepPlanningScorer(judge_backend, judge_model) + elif benchmark == "paperarena": + from openjarvis.evals.scorers.paperarena_judge import PaperArenaScorer + return PaperArenaScorer(judge_backend, judge_model) elif benchmark == "webchorearena": from openjarvis.evals.scorers.webchorearena_scorer import WebChoreArenaScorer return WebChoreArenaScorer(judge_backend, judge_model) diff --git a/tests/evals/test_ama_bench.py b/tests/evals/test_ama_bench.py index 475417f5..5b093a01 100644 --- a/tests/evals/test_ama_bench.py +++ b/tests/evals/test_ama_bench.py @@ -1,6 +1,10 @@ """Tests for AMA-Bench dataset provider.""" +from unittest.mock import MagicMock + +from openjarvis.evals.core.types import EvalRecord from openjarvis.evals.datasets.ama_bench import AMABenchDataset +from openjarvis.evals.scorers.ama_bench_judge import AMABenchScorer class TestAMABenchDataset: @@ -17,11 +21,6 @@ class TestAMABenchDataset: assert hasattr(ds, "iter_episodes") -from unittest.mock import MagicMock -from openjarvis.evals.core.types import EvalRecord -from openjarvis.evals.scorers.ama_bench_judge import AMABenchScorer - - def _mock_backend() -> MagicMock: backend = MagicMock() backend.generate.return_value = "CORRECT" diff --git a/tests/evals/test_loghub.py b/tests/evals/test_loghub.py index 2aa38232..06812f30 100644 --- a/tests/evals/test_loghub.py +++ b/tests/evals/test_loghub.py @@ -2,10 +2,13 @@ import csv from pathlib import Path +from unittest.mock import MagicMock import pytest +from openjarvis.evals.core.types import EvalRecord from openjarvis.evals.datasets.loghub import LogHubDataset +from openjarvis.evals.scorers.loghub_scorer import LogHubScorer class TestLogHubDataset: @@ -42,7 +45,11 @@ class TestLogHubDatasetDetails: writer.writerow({"BlockId": "blk_456", "Label": "Normal"}) ds = LogHubDataset() - meta = {"log_file": "HDFS.log", "label_file": "anomaly_label.csv", "mode": "session"} + meta = { + "log_file": "HDFS.log", + "label_file": "anomaly_label.csv", + "mode": "session", + } records = ds._load_session_mode(tmp_path, meta) assert len(records) == 2 @@ -53,7 +60,8 @@ class TestLogHubDatasetDetails: def test_window_mode_parsing(self, tmp_path: Path) -> None: log_file = tmp_path / "BGL.log" - # 5 lines: 3 normal (start with -), 2 anomalous. Window size 3 = 1 full + 1 partial + # 5 lines: 3 normal (start with -), 2 anomalous. + # Window size 3 = 1 full + 1 partial lines = [ "- normal line 1\n", "- normal line 2\n", @@ -79,11 +87,6 @@ class TestLogHubDatasetDetails: assert ds.size() == 0 -from unittest.mock import MagicMock -from openjarvis.evals.core.types import EvalRecord -from openjarvis.evals.scorers.loghub_scorer import LogHubScorer - - def _mock_backend() -> MagicMock: backend = MagicMock() backend.generate.return_value = "A" From d89bbcce52de593cf8a404f1259f9e0e780df117 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon Date: Fri, 6 Mar 2026 04:22:55 +0000 Subject: [PATCH 8/8] feat: upgrade eval pipeline with agentic runner, telemetry session, and savings meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 9 capabilities to match IPW pipeline: Eval Pipeline: - AgenticRunner for multi-turn agent execution with per-turn trace decomposition - QueryTrace/TurnTrace data model for agentic workload telemetry - EventRecorder for thread-safe agent event collection - TerminalBenchTaskEnv for Docker-based task execution - Cost computation via engine/cloud.py PRICING table - Rich export: JSONL, HF Arrow, summary JSON, artifacts manifest - CLI: --agentic, --concurrency, --query-timeout flags Telemetry: - TelemetrySession with background-sampling ring buffer (Python fallback) - Phase metrics: prefill/decode energy split at TTFT boundary - ITL percentile tracking (p50/p90/p95/p99) - FLOPs estimation and MFU computation - EnergyMonitor.snapshot() method Rust Performance Layer: - Ring buffer with binary search O(log n) window queries - Trapezoidal energy integration - Phase metrics, ITL stats, FLOPs estimation in Rust - PyO3 bindings for all new telemetry modules (50 Rust tests) Savings Meter & Benchmarks: - Use-case benchmark datasets (coding, email, research, knowledge, morning brief) - Savings dashboard component with cost comparison visualization - Cloud cost calculator and comparison server routes - Use-case eval configs for multiple agent/engine combinations Tests: 80 new tests (3779 total pass, 37 skipped, 0 failures) Lint: ruff check src/ tests/ — all checks passed Co-Authored-By: Claude Opus 4.6 --- desktop/src-tauri/src/lib.rs | 10 + desktop/src/App.tsx | 7 +- desktop/src/components/SavingsDashboard.tsx | 432 ++++++++++++ rust/crates/openjarvis-python/src/lib.rs | 6 + .../crates/openjarvis-python/src/telemetry.rs | 288 ++++++++ rust/crates/openjarvis-telemetry/src/flops.rs | 197 ++++++ rust/crates/openjarvis-telemetry/src/itl.rs | 156 +++++ rust/crates/openjarvis-telemetry/src/lib.rs | 4 + rust/crates/openjarvis-telemetry/src/phase.rs | 201 ++++++ .../openjarvis-telemetry/src/session.rs | 391 +++++++++++ scripts/run_use_case_comparison.sh | 260 ++++++++ src/openjarvis/cli/eval_cmd.py | 66 ++ src/openjarvis/engine/_openai_compat.py | 2 +- src/openjarvis/engine/cloud.py | 15 +- src/openjarvis/evals/backends/jarvis_agent.py | 9 + src/openjarvis/evals/cli.py | 304 ++++++++- .../configs/use_case_cloud_comparison.toml | 78 +++ .../configs/use_case_cloud_openhands.toml | 87 +++ .../configs/use_case_cloud_orchestrator.toml | 87 +++ .../evals/configs/use_case_direct.toml | 47 ++ .../evals/configs/use_case_openhands.toml | 57 ++ .../use_case_opensource_openhands.toml | 101 +++ .../use_case_opensource_orchestrator.toml | 101 +++ .../evals/configs/use_case_orchestrator.toml | 57 ++ .../evals/configs/use_case_react.toml | 57 ++ .../evals/configs/use_case_suite.toml | 48 ++ src/openjarvis/evals/core/agentic_runner.py | 616 ++++++++++++++++++ src/openjarvis/evals/core/config.py | 2 + src/openjarvis/evals/core/dataset.py | 13 +- src/openjarvis/evals/core/event_recorder.py | 98 +++ src/openjarvis/evals/core/export.py | 223 +++++++ src/openjarvis/evals/core/pricing.py | 17 + src/openjarvis/evals/core/trace.py | 261 ++++++++ src/openjarvis/evals/datasets/coding_task.py | 282 ++++++++ src/openjarvis/evals/datasets/email_triage.py | 335 ++++++++++ .../evals/datasets/knowledge_base.py | 225 +++++++ .../evals/datasets/morning_brief.py | 215 ++++++ .../evals/datasets/research_mining.py | 264 ++++++++ .../evals/datasets/terminalbench_native.py | 23 + src/openjarvis/evals/execution/__init__.py | 1 + .../evals/execution/terminalbench_env.py | 187 ++++++ src/openjarvis/evals/scorers/coding_task.py | 142 ++++ src/openjarvis/evals/scorers/email_triage.py | 134 ++++ .../evals/scorers/knowledge_base.py | 120 ++++ src/openjarvis/evals/scorers/morning_brief.py | 117 ++++ .../evals/scorers/research_mining.py | 105 +++ src/openjarvis/server/app.py | 2 + src/openjarvis/server/comparison.py | 448 +++++++++++++ src/openjarvis/server/cost_calculator.py | 135 ++++ src/openjarvis/server/dashboard.py | 69 +- src/openjarvis/server/routes.py | 1 + src/openjarvis/server/savings.py | 38 +- src/openjarvis/telemetry/__init__.py | 34 + src/openjarvis/telemetry/energy_monitor.py | 8 + src/openjarvis/telemetry/flops.py | 74 +++ src/openjarvis/telemetry/itl.py | 47 ++ src/openjarvis/telemetry/phase_metrics.py | 44 ++ src/openjarvis/telemetry/session.py | 145 +++++ tests/evals/test_agentic_runner.py | 171 +++++ tests/evals/test_benchmark_datasets.py | 2 +- tests/evals/test_eval_trace.py | 151 +++++ tests/evals/test_event_recorder.py | 89 +++ tests/evals/test_export.py | 136 ++++ tests/evals/test_pricing.py | 38 ++ tests/evals/test_terminalbench_env.py | 48 ++ tests/evals/test_use_case_benchmarks.py | 391 +++++++++++ tests/telemetry/test_flops_new.py | 74 +++ tests/telemetry/test_itl_new.py | 63 ++ tests/telemetry/test_phase_metrics_new.py | 49 ++ tests/telemetry/test_session.py | 90 +++ 70 files changed, 8776 insertions(+), 19 deletions(-) create mode 100644 desktop/src/components/SavingsDashboard.tsx create mode 100644 rust/crates/openjarvis-telemetry/src/flops.rs create mode 100644 rust/crates/openjarvis-telemetry/src/itl.rs create mode 100644 rust/crates/openjarvis-telemetry/src/phase.rs create mode 100644 rust/crates/openjarvis-telemetry/src/session.rs create mode 100755 scripts/run_use_case_comparison.sh create mode 100644 src/openjarvis/evals/configs/use_case_cloud_comparison.toml create mode 100644 src/openjarvis/evals/configs/use_case_cloud_openhands.toml create mode 100644 src/openjarvis/evals/configs/use_case_cloud_orchestrator.toml create mode 100644 src/openjarvis/evals/configs/use_case_direct.toml create mode 100644 src/openjarvis/evals/configs/use_case_openhands.toml create mode 100644 src/openjarvis/evals/configs/use_case_opensource_openhands.toml create mode 100644 src/openjarvis/evals/configs/use_case_opensource_orchestrator.toml create mode 100644 src/openjarvis/evals/configs/use_case_orchestrator.toml create mode 100644 src/openjarvis/evals/configs/use_case_react.toml create mode 100644 src/openjarvis/evals/configs/use_case_suite.toml create mode 100644 src/openjarvis/evals/core/agentic_runner.py create mode 100644 src/openjarvis/evals/core/event_recorder.py create mode 100644 src/openjarvis/evals/core/export.py create mode 100644 src/openjarvis/evals/core/pricing.py create mode 100644 src/openjarvis/evals/core/trace.py create mode 100644 src/openjarvis/evals/datasets/coding_task.py create mode 100644 src/openjarvis/evals/datasets/email_triage.py create mode 100644 src/openjarvis/evals/datasets/knowledge_base.py create mode 100644 src/openjarvis/evals/datasets/morning_brief.py create mode 100644 src/openjarvis/evals/datasets/research_mining.py create mode 100644 src/openjarvis/evals/execution/__init__.py create mode 100644 src/openjarvis/evals/execution/terminalbench_env.py create mode 100644 src/openjarvis/evals/scorers/coding_task.py create mode 100644 src/openjarvis/evals/scorers/email_triage.py create mode 100644 src/openjarvis/evals/scorers/knowledge_base.py create mode 100644 src/openjarvis/evals/scorers/morning_brief.py create mode 100644 src/openjarvis/evals/scorers/research_mining.py create mode 100644 src/openjarvis/server/comparison.py create mode 100644 src/openjarvis/server/cost_calculator.py create mode 100644 src/openjarvis/telemetry/flops.py create mode 100644 src/openjarvis/telemetry/itl.py create mode 100644 src/openjarvis/telemetry/phase_metrics.py create mode 100644 src/openjarvis/telemetry/session.py create mode 100644 tests/evals/test_agentic_runner.py create mode 100644 tests/evals/test_eval_trace.py create mode 100644 tests/evals/test_event_recorder.py create mode 100644 tests/evals/test_export.py create mode 100644 tests/evals/test_pricing.py create mode 100644 tests/evals/test_terminalbench_env.py create mode 100644 tests/evals/test_use_case_benchmarks.py create mode 100644 tests/telemetry/test_flops_new.py create mode 100644 tests/telemetry/test_itl_new.py create mode 100644 tests/telemetry/test_phase_metrics_new.py create mode 100644 tests/telemetry/test_session.py diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 73bf7301..d7ac0248 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -405,6 +405,15 @@ async fn run_jarvis_command(args: Vec) -> Result { } } +#[tauri::command] +async fn fetch_savings(api_url: String) -> Result { + let base = if api_url.is_empty() { api_base() } else { api_url }; + let resp = reqwest::get(format!("{}/v1/savings", base)) + .await + .map_err(|e| format!("Connection failed: {}", e))?; + resp.json().await.map_err(|e| format!("Invalid response: {}", e)) +} + /// Transcribe audio via the speech API endpoint. #[tauri::command] async fn transcribe_audio( @@ -541,6 +550,7 @@ pub fn run() { fetch_agents, fetch_models, run_jarvis_command, + fetch_savings, transcribe_audio, speech_health, ]) diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index d6176971..906d3414 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { UpdateChecker } from './components/UpdateChecker'; +import { SavingsDashboard } from './components/SavingsDashboard'; import { EnergyDashboard } from './components/EnergyDashboard'; import { TraceDebugger } from './components/TraceDebugger'; import { LearningCurve } from './components/LearningCurve'; @@ -7,7 +8,7 @@ import { MemoryBrowser } from './components/MemoryBrowser'; import { AdminPanel } from './components/AdminPanel'; import { SettingsPanel } from './components/SettingsPanel'; -type TabId = 'energy' | 'traces' | 'learning' | 'memory' | 'admin' | 'settings'; +type TabId = 'savings' | 'energy' | 'traces' | 'learning' | 'memory' | 'admin' | 'settings'; interface Tab { id: TabId; @@ -15,6 +16,7 @@ interface Tab { } const TABS: Tab[] = [ + { id: 'savings', label: 'Savings' }, { id: 'energy', label: 'Energy' }, { id: 'traces', label: 'Traces' }, { id: 'learning', label: 'Learning' }, @@ -26,7 +28,7 @@ const TABS: Tab[] = [ const API_URL = 'http://localhost:8000'; export function App() { - const [activeTab, setActiveTab] = useState('energy'); + const [activeTab, setActiveTab] = useState('savings'); return (
@@ -51,6 +53,7 @@ export function App() {
+ {activeTab === 'savings' && } {activeTab === 'energy' && } {activeTab === 'traces' && } {activeTab === 'learning' && } diff --git a/desktop/src/components/SavingsDashboard.tsx b/desktop/src/components/SavingsDashboard.tsx new file mode 100644 index 00000000..233921ff --- /dev/null +++ b/desktop/src/components/SavingsDashboard.tsx @@ -0,0 +1,432 @@ +import { useState, useEffect, useCallback } from 'react'; +import type React from 'react'; +import { invoke } from '@tauri-apps/api/core'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ProviderSavings { + provider: string; + label: string; + input_cost: number; + output_cost: number; + total_cost: number; + energy_wh: number; + energy_joules: number; + flops: number; +} + +interface SavingsData { + total_calls: number; + total_prompt_tokens: number; + total_completion_tokens: number; + total_tokens: number; + local_cost: number; + per_provider: ProviderSavings[]; + monthly_projection: Record; + session_start_ts: number; + session_duration_hours: number; + avg_cost_per_query: Record; + cloud_agent_equivalent: { + moderate_low: number; + moderate_high: number; + heavy_low: number; + heavy_high: number; + }; +} + +// --------------------------------------------------------------------------- +// Styles (Catppuccin) +// --------------------------------------------------------------------------- + +const colors = { + bg: '#1e1e2e', + surface: '#282840', + text: '#cdd6f4', + textMuted: '#a6adc8', + accent: '#89b4fa', + green: '#a6e3a1', + yellow: '#f9e2af', + red: '#f38ba8', + purple: '#cba6f7', + border: '#45475a', +} as const; + +const styles: Record = { + container: { + background: colors.bg, + color: colors.text, + padding: 24, + fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif", + height: '100%', + overflowY: 'auto', + boxSizing: 'border-box', + }, + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 24, + }, + title: { + fontSize: 22, + fontWeight: 600, + margin: 0, + color: colors.text, + }, + liveBadge: { + display: 'inline-flex', + alignItems: 'center', + gap: 6, + fontSize: 12, + color: colors.green, + background: 'rgba(166,227,161,0.1)', + padding: '4px 10px', + borderRadius: 12, + fontWeight: 500, + }, + liveDot: { + width: 6, + height: 6, + borderRadius: '50%', + background: colors.green, + animation: 'pulse 2s infinite', + }, + statsGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', + gap: 16, + marginBottom: 24, + }, + statCard: { + background: colors.surface, + borderRadius: 10, + padding: 16, + border: `1px solid ${colors.border}`, + }, + statLabel: { + fontSize: 12, + color: colors.textMuted, + marginBottom: 6, + textTransform: 'uppercase' as const, + letterSpacing: '0.05em', + }, + statValue: { + fontSize: 26, + fontWeight: 700, + color: colors.accent, + lineHeight: 1.1, + }, + statUnit: { + fontSize: 13, + fontWeight: 400, + color: colors.textMuted, + marginLeft: 4, + }, + sectionHeading: { + fontSize: 14, + fontWeight: 600, + color: colors.textMuted, + textTransform: 'uppercase' as const, + letterSpacing: '0.05em', + marginBottom: 12, + }, + providersGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', + gap: 16, + marginBottom: 24, + }, + providerCard: { + background: colors.surface, + borderRadius: 10, + padding: 20, + border: `1px solid ${colors.border}`, + position: 'relative' as const, + overflow: 'hidden' as const, + }, + providerName: { + fontSize: 14, + fontWeight: 600, + marginBottom: 4, + }, + providerModel: { + fontSize: 12, + color: colors.textMuted, + marginBottom: 14, + }, + savingsAmount: { + fontSize: 32, + fontWeight: 700, + color: colors.green, + marginBottom: 8, + }, + breakdown: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: 12, + marginTop: 14, + paddingTop: 14, + borderTop: `1px solid ${colors.border}`, + }, + breakdownLabel: { + fontSize: 11, + color: colors.textMuted, + textTransform: 'uppercase' as const, + letterSpacing: '0.04em', + }, + breakdownValue: { + fontSize: 16, + fontWeight: 600, + marginTop: 2, + }, + cloudAgentCard: { + background: colors.surface, + borderRadius: 10, + padding: 20, + border: `1px solid ${colors.border}`, + borderTop: `3px solid ${colors.purple}`, + marginBottom: 24, + }, + cloudAgentGrid: { + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr', + gap: 20, + marginTop: 16, + }, + emptyState: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + padding: 64, + color: colors.textMuted, + gap: 12, + }, + emptyText: { + fontSize: 15, + textAlign: 'center' as const, + }, + errorBanner: { + background: 'rgba(243,139,168,0.1)', + border: `1px solid ${colors.red}`, + borderRadius: 8, + padding: '10px 16px', + marginBottom: 16, + fontSize: 13, + color: colors.red, + }, +}; + +const PROVIDER_COLORS: Record = { + 'gpt-5.3': colors.green, + 'claude-opus-4.6': colors.yellow, + 'gemini-3.1-pro': colors.accent, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fmtDollar(n: number): string { + if (n >= 1000) return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + if (n >= 1) return '$' + n.toFixed(2); + if (n >= 0.01) return '$' + n.toFixed(3); + if (n > 0) return '$' + n.toFixed(4); + return '$0.00'; +} + +function fmtDuration(hours: number): string { + if (hours < 1) return `${Math.round(hours * 60)}m`; + if (hours < 24) return `${hours.toFixed(1)}h`; + return `${(hours / 24).toFixed(1)}d`; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const REFRESH_INTERVAL_MS = 5000; + +export function SavingsDashboard({ apiUrl }: { apiUrl: string }) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + try { + const result = await invoke('fetch_savings', { apiUrl }); + setData(result); + setError(null); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + setData(null); + } finally { + setLoading(false); + } + }, [apiUrl]); + + useEffect(() => { + fetchData(); + const timer = setInterval(fetchData, REFRESH_INTERVAL_MS); + return () => clearInterval(timer); + }, [fetchData]); + + if (!loading && !data && !error) { + return ( +
+
+

Savings Dashboard

+
+
+
$
+
+ No savings data available.
+ Start making inference requests to see savings vs cloud providers. +
+
+
+ ); + } + + const providers = data?.per_provider ?? []; + const projection = data?.monthly_projection ?? {}; + const cloudAgent = data?.cloud_agent_equivalent; + + return ( +
+ + + {/* Header */} +
+

Savings Dashboard

+ + + Live - {REFRESH_INTERVAL_MS / 1000}s + +
+ + {error &&
{error}
} + + {/* Stat cards row */} +
+
+
Total Requests
+
+ {(data?.total_calls ?? 0).toLocaleString()} +
+
+
+
Total Tokens
+
+ {(data?.total_tokens ?? 0).toLocaleString()} +
+
+
+
Session Duration
+
+ {fmtDuration(data?.session_duration_hours ?? 0)} +
+
+
+
Local Cost
+
+ {fmtDollar(data?.local_cost ?? 0)} +
+
+
+ + {/* Provider savings cards */} +
Savings vs Cloud Providers
+
+ {providers.map((p) => ( +
+
{p.label}
+
{p.provider}
+
{fmtDollar(p.total_cost)}
+
+
+
Input Saved
+
{fmtDollar(p.input_cost)}
+
+
+
Output Saved
+
{fmtDollar(p.output_cost)}
+
+
+
+ ))} +
+ + {/* Cloud Agent Platforms */} + {cloudAgent && ( + <> +
vs Cloud Agent Platforms
+
+
Typical Cloud Agent Platform
+
based on published API pricing tiers
+
+
+
MODERATE USE
+
+ ${cloudAgent.moderate_low}–{cloudAgent.moderate_high}/mo +
+
+
+
HEAVY USE
+
+ ${cloudAgent.heavy_low}–{cloudAgent.heavy_high}+/mo +
+
+
+
YOUR COST
+
+ $0.00 +
+
+ local inference +
+
+
+
+ + )} + + {/* Monthly Projection */} +
Monthly Projection
+
+ {providers.map((p) => ( +
+
vs {p.label}
+
projected monthly savings
+
+ {fmtDollar(projection[p.provider] ?? 0)} +
+
+ per month at current rate +
+
+ ))} +
+
+ ); +} diff --git a/rust/crates/openjarvis-python/src/lib.rs b/rust/crates/openjarvis-python/src/lib.rs index 559951d1..91fea929 100644 --- a/rust/crates/openjarvis-python/src/lib.rs +++ b/rust/crates/openjarvis-python/src/lib.rs @@ -100,6 +100,12 @@ fn openjarvis_rust(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + // --- Telemetry (new session/phase/ITL/FLOPs classes) --- + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; // --- Traces --- m.add_class::()?; diff --git a/rust/crates/openjarvis-python/src/telemetry.rs b/rust/crates/openjarvis-python/src/telemetry.rs index 68febec6..c560457a 100644 --- a/rust/crates/openjarvis-python/src/telemetry.rs +++ b/rust/crates/openjarvis-python/src/telemetry.rs @@ -91,3 +91,291 @@ impl PyInstrumentedEngine { self.inner.engine_id() } } + +// --- New telemetry session classes --- + +/// Python wrapper for TelemetrySample. +#[pyclass(name = "TelemetrySample")] +#[derive(Clone)] +pub struct PyTelemetrySample { + pub timestamp_ns: u64, + pub gpu_power_w: f64, + pub cpu_power_w: f64, + pub gpu_energy_j: f64, + pub cpu_energy_j: f64, + pub gpu_util_pct: f64, + pub gpu_temp_c: f64, + pub gpu_mem_gb: f64, +} + +#[pymethods] +impl PyTelemetrySample { + #[new] + #[pyo3(signature = (timestamp_ns, gpu_power_w=0.0, cpu_power_w=0.0, gpu_energy_j=0.0, cpu_energy_j=0.0, gpu_util_pct=0.0, gpu_temp_c=0.0, gpu_mem_gb=0.0))] + fn new( + timestamp_ns: u64, + gpu_power_w: f64, + cpu_power_w: f64, + gpu_energy_j: f64, + cpu_energy_j: f64, + gpu_util_pct: f64, + gpu_temp_c: f64, + gpu_mem_gb: f64, + ) -> Self { + Self { + timestamp_ns, + gpu_power_w, + cpu_power_w, + gpu_energy_j, + cpu_energy_j, + gpu_util_pct, + gpu_temp_c, + gpu_mem_gb, + } + } + + #[getter] + fn timestamp_ns(&self) -> u64 { + self.timestamp_ns + } + #[getter] + fn gpu_power_w(&self) -> f64 { + self.gpu_power_w + } + #[getter] + fn cpu_power_w(&self) -> f64 { + self.cpu_power_w + } + #[getter] + fn gpu_energy_j(&self) -> f64 { + self.gpu_energy_j + } + #[getter] + fn cpu_energy_j(&self) -> f64 { + self.cpu_energy_j + } + #[getter] + fn gpu_util_pct(&self) -> f64 { + self.gpu_util_pct + } + #[getter] + fn gpu_temp_c(&self) -> f64 { + self.gpu_temp_c + } + #[getter] + fn gpu_mem_gb(&self) -> f64 { + self.gpu_mem_gb + } +} + +/// Python wrapper for TelemetrySessionCore (ring buffer). +#[pyclass(name = "TelemetrySessionCore")] +pub struct PyTelemetrySessionCore { + inner: openjarvis_telemetry::session::TelemetrySessionCore, +} + +#[pymethods] +impl PyTelemetrySessionCore { + #[new] + #[pyo3(signature = (capacity=100000, sampling_interval_ms=100))] + fn new(capacity: usize, sampling_interval_ms: u64) -> Self { + Self { + inner: openjarvis_telemetry::session::TelemetrySessionCore::new( + capacity, + sampling_interval_ms, + ), + } + } + + fn add_sample(&self, sample: &PyTelemetrySample) { + let s = openjarvis_telemetry::session::TelemetrySample { + timestamp_ns: sample.timestamp_ns, + gpu_power_w: sample.gpu_power_w, + cpu_power_w: sample.cpu_power_w, + gpu_energy_j: sample.gpu_energy_j, + cpu_energy_j: sample.cpu_energy_j, + gpu_util_pct: sample.gpu_util_pct, + gpu_temp_c: sample.gpu_temp_c, + gpu_mem_gb: sample.gpu_mem_gb, + }; + self.inner.add_sample(s); + } + + fn window(&self, start_ns: u64, end_ns: u64) -> Vec { + self.inner + .window(start_ns, end_ns) + .into_iter() + .map(|s| PyTelemetrySample { + timestamp_ns: s.timestamp_ns, + gpu_power_w: s.gpu_power_w, + cpu_power_w: s.cpu_power_w, + gpu_energy_j: s.gpu_energy_j, + cpu_energy_j: s.cpu_energy_j, + gpu_util_pct: s.gpu_util_pct, + gpu_temp_c: s.gpu_temp_c, + gpu_mem_gb: s.gpu_mem_gb, + }) + .collect() + } + + fn compute_energy_delta(&self, start_ns: u64, end_ns: u64) -> (f64, f64) { + self.inner.compute_energy_delta(start_ns, end_ns) + } + + fn compute_avg_power(&self, start_ns: u64, end_ns: u64) -> (f64, f64) { + self.inner.compute_avg_power(start_ns, end_ns) + } + + fn len(&self) -> usize { + self.inner.len() + } + + fn clear(&self) { + self.inner.clear(); + } +} + +/// Python wrapper for ITL stats computation. +#[pyclass(name = "ItlStats")] +pub struct PyItlStats; + +#[pymethods] +impl PyItlStats { + #[new] + fn new() -> Self { + Self + } + + #[staticmethod] + fn compute(token_timestamps_ms: Vec) -> PyResult> { + let stats = openjarvis_telemetry::itl::compute_itl_stats(&token_timestamps_ms); + pyo3::Python::with_gil(|py| { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("p50_ms", stats.p50_ms)?; + dict.set_item("p90_ms", stats.p90_ms)?; + dict.set_item("p95_ms", stats.p95_ms)?; + dict.set_item("p99_ms", stats.p99_ms)?; + dict.set_item("mean_ms", stats.mean_ms)?; + dict.set_item("min_ms", stats.min_ms)?; + dict.set_item("max_ms", stats.max_ms)?; + Ok(dict.into()) + }) + } +} + +/// Python wrapper for FLOPs estimation. +#[pyclass(name = "FlopsEstimator")] +pub struct PyFlopsEstimator; + +#[pymethods] +impl PyFlopsEstimator { + #[new] + fn new() -> Self { + Self + } + + #[staticmethod] + fn estimate_flops(model: &str, input_tokens: u64, output_tokens: u64) -> (f64, f64) { + openjarvis_telemetry::flops::estimate_flops(model, input_tokens, output_tokens) + } + + #[staticmethod] + #[pyo3(signature = (flops, duration_s, gpu_name, num_gpus=1))] + fn compute_mfu(flops: f64, duration_s: f64, gpu_name: &str, num_gpus: u32) -> f64 { + openjarvis_telemetry::flops::compute_mfu(flops, duration_s, gpu_name, num_gpus) + } +} + +/// Python wrapper for phase metrics. +#[pyclass(name = "PhaseMetrics")] +pub struct PyPhaseMetrics; + +#[pymethods] +impl PyPhaseMetrics { + #[new] + fn new() -> Self { + Self + } + + #[staticmethod] + fn compute( + samples: Vec, + start_ns: u64, + end_ns: u64, + tokens: u64, + ) -> PyResult> { + let rust_samples: Vec = samples + .iter() + .map(|s| openjarvis_telemetry::session::TelemetrySample { + timestamp_ns: s.timestamp_ns, + gpu_power_w: s.gpu_power_w, + cpu_power_w: s.cpu_power_w, + gpu_energy_j: s.gpu_energy_j, + cpu_energy_j: s.cpu_energy_j, + gpu_util_pct: s.gpu_util_pct, + gpu_temp_c: s.gpu_temp_c, + gpu_mem_gb: s.gpu_mem_gb, + }) + .collect(); + let metrics = + openjarvis_telemetry::phase::compute_phase_metrics(&rust_samples, start_ns, end_ns, tokens); + pyo3::Python::with_gil(|py| { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("energy_j", metrics.energy_j)?; + dict.set_item("mean_power_w", metrics.mean_power_w)?; + dict.set_item("duration_s", metrics.duration_s)?; + dict.set_item("energy_per_token_j", metrics.energy_per_token_j)?; + dict.set_item("tokens", metrics.tokens)?; + Ok(dict.into()) + }) + } + + #[staticmethod] + fn split_at_ttft( + samples: Vec, + start_ns: u64, + ttft_ns: u64, + end_ns: u64, + input_tokens: u64, + output_tokens: u64, + ) -> PyResult<(pyo3::Py, pyo3::Py)> { + let rust_samples: Vec = samples + .iter() + .map(|s| openjarvis_telemetry::session::TelemetrySample { + timestamp_ns: s.timestamp_ns, + gpu_power_w: s.gpu_power_w, + cpu_power_w: s.cpu_power_w, + gpu_energy_j: s.gpu_energy_j, + cpu_energy_j: s.cpu_energy_j, + gpu_util_pct: s.gpu_util_pct, + gpu_temp_c: s.gpu_temp_c, + gpu_mem_gb: s.gpu_mem_gb, + }) + .collect(); + let (prefill, decode) = openjarvis_telemetry::phase::split_at_ttft( + &rust_samples, + start_ns, + ttft_ns, + end_ns, + input_tokens, + output_tokens, + ); + pyo3::Python::with_gil(|py| { + let prefill_dict = pyo3::types::PyDict::new(py); + prefill_dict.set_item("energy_j", prefill.energy_j)?; + prefill_dict.set_item("mean_power_w", prefill.mean_power_w)?; + prefill_dict.set_item("duration_s", prefill.duration_s)?; + prefill_dict.set_item("energy_per_token_j", prefill.energy_per_token_j)?; + prefill_dict.set_item("tokens", prefill.tokens)?; + + let decode_dict = pyo3::types::PyDict::new(py); + decode_dict.set_item("energy_j", decode.energy_j)?; + decode_dict.set_item("mean_power_w", decode.mean_power_w)?; + decode_dict.set_item("duration_s", decode.duration_s)?; + decode_dict.set_item("energy_per_token_j", decode.energy_per_token_j)?; + decode_dict.set_item("tokens", decode.tokens)?; + + Ok((prefill_dict.into(), decode_dict.into())) + }) + } +} diff --git a/rust/crates/openjarvis-telemetry/src/flops.rs b/rust/crates/openjarvis-telemetry/src/flops.rs new file mode 100644 index 00000000..b82bea8f --- /dev/null +++ b/rust/crates/openjarvis-telemetry/src/flops.rs @@ -0,0 +1,197 @@ +//! FLOPs estimation and Model FLOPs Utilization (MFU) computation. + +/// Peak TFLOPS (FP16/BF16) for common GPU/accelerator models. +pub const GPU_PEAK_TFLOPS: &[(&str, f64)] = &[ + ("H100", 989.0), + ("H200", 989.0), + ("A100", 312.0), + ("A10G", 31.2), + ("L4", 30.3), + ("L40", 181.0), + ("L40S", 362.0), + ("T4", 65.1), + ("V100", 125.0), + ("4090", 82.6), + ("4080", 48.7), + ("3090", 35.6), + ("M3 Max", 14.2), + ("M3 Ultra", 27.0), + ("M4 Max", 18.0), +]; + +/// Approximate parameter counts (billions) for common models. +pub const MODEL_PARAMS: &[(&str, f64)] = &[ + ("qwen3:8b", 8.0), + ("llama-3.1-70b", 70.0), + ("llama-3.1-8b", 8.0), + ("mistral-7b", 7.0), + ("mixtral-8x7b", 47.0), + ("gpt-4o", 200.0), + ("claude-opus", 137.0), + ("gemini-pro", 137.0), +]; + +/// Look up a value in a `&[(&str, f64)]` table using case-insensitive substring matching. +fn lookup(table: &[(&str, f64)], key: &str) -> Option { + let key_lower = key.to_lowercase(); + // Try exact match first (case-insensitive) + for &(name, val) in table { + if name.to_lowercase() == key_lower { + return Some(val); + } + } + // Fall back to substring match + for &(name, val) in table { + if key_lower.contains(&name.to_lowercase()) || name.to_lowercase().contains(&key_lower) { + return Some(val); + } + } + None +} + +/// Estimate FLOPs for a model inference using the `2 * params * tokens` approximation. +/// +/// Returns `(total_flops, flops_per_token)`. If the model is not found in `MODEL_PARAMS`, +/// returns `(0.0, 0.0)`. +pub fn estimate_flops(model: &str, input_tokens: u64, output_tokens: u64) -> (f64, f64) { + let params_b = match lookup(MODEL_PARAMS, model) { + Some(p) => p, + None => return (0.0, 0.0), + }; + + let total_tokens = input_tokens + output_tokens; + if total_tokens == 0 { + return (0.0, 0.0); + } + + let params = params_b * 1e9; + // 2 * params * tokens approximation for transformer FLOPs + let total_flops = 2.0 * params * total_tokens as f64; + let flops_per_token = 2.0 * params; + + (total_flops, flops_per_token) +} + +/// Compute Model FLOPs Utilization (MFU). +/// +/// MFU = actual_flops / (peak_tflops * 1e12 * duration_s * num_gpus) +/// +/// Returns 0.0 if the GPU is not found or inputs are zero. +pub fn compute_mfu(flops: f64, duration_s: f64, gpu_name: &str, num_gpus: u32) -> f64 { + if duration_s <= 0.0 || num_gpus == 0 || flops <= 0.0 { + return 0.0; + } + + let peak_tflops = match lookup(GPU_PEAK_TFLOPS, gpu_name) { + Some(p) => p, + None => return 0.0, + }; + + let peak_flops_per_sec = peak_tflops * 1e12 * num_gpus as f64; + let theoretical_flops = peak_flops_per_sec * duration_s; + + flops / theoretical_flops +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn estimate_flops_known_model() { + let (total, per_token) = estimate_flops("qwen3:8b", 100, 50); + // 2 * 8e9 * 150 = 2.4e12 + assert!((total - 2.4e12).abs() < 1e3); + // 2 * 8e9 = 16e9 + assert!((per_token - 16e9).abs() < 1e3); + } + + #[test] + fn estimate_flops_unknown_model() { + let (total, per_token) = estimate_flops("unknown-model", 100, 50); + assert_eq!(total, 0.0); + assert_eq!(per_token, 0.0); + } + + #[test] + fn estimate_flops_zero_tokens() { + let (total, per_token) = estimate_flops("qwen3:8b", 0, 0); + assert_eq!(total, 0.0); + assert_eq!(per_token, 0.0); + } + + #[test] + fn estimate_flops_large_model() { + let (total, _) = estimate_flops("llama-3.1-70b", 1000, 500); + // 2 * 70e9 * 1500 = 210e12 + assert!((total - 210e12).abs() < 1e3); + } + + #[test] + fn compute_mfu_basic() { + // 1e12 FLOPs in 1 second on a single H100 (989 TFLOPS) + let mfu = compute_mfu(1e12, 1.0, "H100", 1); + // MFU = 1e12 / (989e12) ~= 0.001011 + let expected = 1e12 / (989.0 * 1e12); + assert!((mfu - expected).abs() < 1e-9, "mfu = {mfu}, expected = {expected}"); + } + + #[test] + fn compute_mfu_multi_gpu() { + let mfu_1 = compute_mfu(1e12, 1.0, "A100", 1); + let mfu_4 = compute_mfu(1e12, 1.0, "A100", 4); + // 4 GPUs -> 4x theoretical -> 1/4 MFU for same actual FLOPs + assert!((mfu_1 - 4.0 * mfu_4).abs() < 1e-12); + } + + #[test] + fn compute_mfu_unknown_gpu() { + let mfu = compute_mfu(1e12, 1.0, "unknown-gpu", 1); + assert_eq!(mfu, 0.0); + } + + #[test] + fn compute_mfu_zero_duration() { + let mfu = compute_mfu(1e12, 0.0, "H100", 1); + assert_eq!(mfu, 0.0); + } + + #[test] + fn compute_mfu_zero_gpus() { + let mfu = compute_mfu(1e12, 1.0, "H100", 0); + assert_eq!(mfu, 0.0); + } + + #[test] + fn compute_mfu_zero_flops() { + let mfu = compute_mfu(0.0, 1.0, "H100", 1); + assert_eq!(mfu, 0.0); + } + + #[test] + fn lookup_case_insensitive() { + assert!(lookup(GPU_PEAK_TFLOPS, "h100").is_some()); + assert!(lookup(GPU_PEAK_TFLOPS, "H100").is_some()); + } + + #[test] + fn lookup_substring_match() { + // "NVIDIA RTX 4090" should match "4090" + assert!(lookup(GPU_PEAK_TFLOPS, "NVIDIA RTX 4090").is_some()); + assert!(lookup(MODEL_PARAMS, "mistral-7b-instruct").is_some()); + } + + #[test] + fn all_gpus_have_positive_tflops() { + for &(name, tflops) in GPU_PEAK_TFLOPS { + assert!(tflops > 0.0, "{name} has non-positive TFLOPS: {tflops}"); + } + } + + #[test] + fn all_models_have_positive_params() { + for &(name, params) in MODEL_PARAMS { + assert!(params > 0.0, "{name} has non-positive params: {params}"); + } + } +} diff --git a/rust/crates/openjarvis-telemetry/src/itl.rs b/rust/crates/openjarvis-telemetry/src/itl.rs new file mode 100644 index 00000000..60ae2721 --- /dev/null +++ b/rust/crates/openjarvis-telemetry/src/itl.rs @@ -0,0 +1,156 @@ +//! Inter-token latency (ITL) statistics — percentiles, mean, min, max. + +/// Aggregated inter-token latency statistics. +#[derive(Debug, Clone, Default)] +pub struct ItlStats { + pub p50_ms: f64, + pub p90_ms: f64, + pub p95_ms: f64, + pub p99_ms: f64, + pub mean_ms: f64, + pub min_ms: f64, + pub max_ms: f64, +} + +/// Compute inter-token latency statistics from token arrival timestamps. +/// +/// `token_timestamps_ms` should be a slice of monotonically increasing timestamps +/// in milliseconds. At least 2 timestamps are needed to compute any latencies. +pub fn compute_itl_stats(token_timestamps_ms: &[f64]) -> ItlStats { + if token_timestamps_ms.len() < 2 { + return ItlStats::default(); + } + + // Compute inter-token latencies (consecutive diffs) + let mut itls: Vec = token_timestamps_ms + .windows(2) + .map(|w| w[1] - w[0]) + .collect(); + + // Sort for percentile computation + itls.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + let n = itls.len(); + let mean_ms = itls.iter().sum::() / n as f64; + let min_ms = itls[0]; + let max_ms = itls[n - 1]; + + ItlStats { + p50_ms: percentile(&itls, 50.0), + p90_ms: percentile(&itls, 90.0), + p95_ms: percentile(&itls, 95.0), + p99_ms: percentile(&itls, 99.0), + mean_ms, + min_ms, + max_ms, + } +} + +/// Compute a percentile from a sorted slice using linear interpolation. +fn percentile(sorted: &[f64], pct: f64) -> f64 { + assert!(!sorted.is_empty()); + if sorted.len() == 1 { + return sorted[0]; + } + let rank = pct / 100.0 * (sorted.len() - 1) as f64; + let lo = rank.floor() as usize; + let hi = rank.ceil() as usize; + if lo == hi { + sorted[lo] + } else { + let frac = rank - lo as f64; + sorted[lo] * (1.0 - frac) + sorted[hi] * frac + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn itl_stats_basic() { + // 5 tokens at 0, 10, 20, 30, 40 ms -> ITLs = [10, 10, 10, 10] + let timestamps = vec![0.0, 10.0, 20.0, 30.0, 40.0]; + let stats = compute_itl_stats(×tamps); + + assert!((stats.mean_ms - 10.0).abs() < 1e-9); + assert!((stats.min_ms - 10.0).abs() < 1e-9); + assert!((stats.max_ms - 10.0).abs() < 1e-9); + assert!((stats.p50_ms - 10.0).abs() < 1e-9); + assert!((stats.p99_ms - 10.0).abs() < 1e-9); + } + + #[test] + fn itl_stats_varying_latencies() { + // ITLs: [5, 10, 15, 20] + let timestamps = vec![0.0, 5.0, 15.0, 30.0, 50.0]; + let stats = compute_itl_stats(×tamps); + // ITLs: [5, 10, 15, 20], sorted: [5, 10, 15, 20] + + assert!((stats.mean_ms - 12.5).abs() < 1e-9); + assert!((stats.min_ms - 5.0).abs() < 1e-9); + assert!((stats.max_ms - 20.0).abs() < 1e-9); + + // p50: rank = 0.5 * 3 = 1.5 -> interpolate between index 1 and 2 + // = 10 * 0.5 + 15 * 0.5 = 12.5 + assert!((stats.p50_ms - 12.5).abs() < 1e-9); + } + + #[test] + fn itl_stats_empty() { + let stats = compute_itl_stats(&[]); + assert_eq!(stats.mean_ms, 0.0); + assert_eq!(stats.p50_ms, 0.0); + } + + #[test] + fn itl_stats_single_timestamp() { + let stats = compute_itl_stats(&[100.0]); + assert_eq!(stats.mean_ms, 0.0); + } + + #[test] + fn itl_stats_two_timestamps() { + let stats = compute_itl_stats(&[0.0, 42.0]); + // Single ITL of 42ms + assert!((stats.mean_ms - 42.0).abs() < 1e-9); + assert!((stats.min_ms - 42.0).abs() < 1e-9); + assert!((stats.max_ms - 42.0).abs() < 1e-9); + assert!((stats.p50_ms - 42.0).abs() < 1e-9); + assert!((stats.p90_ms - 42.0).abs() < 1e-9); + assert!((stats.p95_ms - 42.0).abs() < 1e-9); + assert!((stats.p99_ms - 42.0).abs() < 1e-9); + } + + #[test] + fn itl_stats_percentile_interpolation() { + // 10 ITLs: 1..=10 + let timestamps: Vec = { + let mut ts = vec![0.0]; + for i in 1..=10 { + ts.push(ts.last().unwrap() + i as f64); + } + ts + }; + let stats = compute_itl_stats(×tamps); + // ITLs sorted: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + + assert!((stats.min_ms - 1.0).abs() < 1e-9); + assert!((stats.max_ms - 10.0).abs() < 1e-9); + assert!((stats.mean_ms - 5.5).abs() < 1e-9); + + // p50: rank = 0.5 * 9 = 4.5 -> interpolate idx 4 and 5 -> (5+6)/2 = 5.5 + assert!((stats.p50_ms - 5.5).abs() < 1e-9); + + // p90: rank = 0.9 * 9 = 8.1 -> interpolate idx 8 and 9 -> 9*0.9 + 10*0.1 = 9.1 + assert!((stats.p90_ms - 9.1).abs() < 1e-9); + } + + #[test] + fn percentile_function_edge_cases() { + assert!((percentile(&[5.0], 50.0) - 5.0).abs() < 1e-9); + assert!((percentile(&[1.0, 2.0], 0.0) - 1.0).abs() < 1e-9); + assert!((percentile(&[1.0, 2.0], 100.0) - 2.0).abs() < 1e-9); + assert!((percentile(&[1.0, 2.0], 50.0) - 1.5).abs() < 1e-9); + } +} diff --git a/rust/crates/openjarvis-telemetry/src/lib.rs b/rust/crates/openjarvis-telemetry/src/lib.rs index 5c906dba..f31a3ab2 100644 --- a/rust/crates/openjarvis-telemetry/src/lib.rs +++ b/rust/crates/openjarvis-telemetry/src/lib.rs @@ -2,7 +2,11 @@ pub mod aggregator; pub mod energy; +pub mod flops; pub mod instrumented; +pub mod itl; +pub mod phase; +pub mod session; pub mod store; pub use aggregator::TelemetryAggregator; diff --git a/rust/crates/openjarvis-telemetry/src/phase.rs b/rust/crates/openjarvis-telemetry/src/phase.rs new file mode 100644 index 00000000..2b545a52 --- /dev/null +++ b/rust/crates/openjarvis-telemetry/src/phase.rs @@ -0,0 +1,201 @@ +//! Phase-level metrics: energy, power, and per-token efficiency for prefill/decode phases. + +use crate::session::TelemetrySample; + +/// Aggregated metrics for a single inference phase (e.g., prefill or decode). +#[derive(Debug, Clone, Default)] +pub struct PhaseMetrics { + pub energy_j: f64, + pub mean_power_w: f64, + pub duration_s: f64, + pub energy_per_token_j: f64, + pub tokens: u64, +} + +/// Compute phase metrics from telemetry samples within a time range. +/// +/// Uses trapezoidal integration for energy and simple averaging for power. +/// `tokens` is used to compute per-token energy. +pub fn compute_phase_metrics( + samples: &[TelemetrySample], + start_ns: u64, + end_ns: u64, + tokens: u64, +) -> PhaseMetrics { + let duration_s = (end_ns.saturating_sub(start_ns)) as f64 / 1e9; + + // Filter samples within the window + let in_range: Vec<&TelemetrySample> = samples + .iter() + .filter(|s| s.timestamp_ns >= start_ns && s.timestamp_ns <= end_ns) + .collect(); + + if in_range.is_empty() { + return PhaseMetrics { + duration_s, + tokens, + ..Default::default() + }; + } + + // Trapezoidal integration for total energy (GPU + CPU combined) + let mut energy_j = 0.0; + for pair in in_range.windows(2) { + let dt_s = (pair[1].timestamp_ns - pair[0].timestamp_ns) as f64 / 1e9; + let total_power_0 = pair[0].gpu_power_w + pair[0].cpu_power_w; + let total_power_1 = pair[1].gpu_power_w + pair[1].cpu_power_w; + energy_j += (total_power_0 + total_power_1) / 2.0 * dt_s; + } + + // Mean total power + let mean_power_w: f64 = in_range + .iter() + .map(|s| s.gpu_power_w + s.cpu_power_w) + .sum::() + / in_range.len() as f64; + + let energy_per_token_j = if tokens > 0 { + energy_j / tokens as f64 + } else { + 0.0 + }; + + PhaseMetrics { + energy_j, + mean_power_w, + duration_s, + energy_per_token_j, + tokens, + } +} + +/// Split samples at the TTFT boundary into prefill and decode phases. +/// +/// - Prefill phase: [start_ns, ttft_ns] with `input_tokens` +/// - Decode phase: (ttft_ns, end_ns] with `output_tokens` +/// +/// Returns `(prefill_metrics, decode_metrics)`. +pub fn split_at_ttft( + samples: &[TelemetrySample], + start_ns: u64, + ttft_ns: u64, + end_ns: u64, + input_tokens: u64, + output_tokens: u64, +) -> (PhaseMetrics, PhaseMetrics) { + let prefill = compute_phase_metrics(samples, start_ns, ttft_ns, input_tokens); + let decode = compute_phase_metrics(samples, ttft_ns, end_ns, output_tokens); + (prefill, decode) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_sample(ts_ns: u64, gpu_w: f64, cpu_w: f64) -> TelemetrySample { + TelemetrySample { + timestamp_ns: ts_ns, + gpu_power_w: gpu_w, + cpu_power_w: cpu_w, + ..Default::default() + } + } + + #[test] + fn phase_metrics_constant_power() { + let samples = vec![ + make_sample(0, 200.0, 100.0), + make_sample(500_000_000, 200.0, 100.0), + make_sample(1_000_000_000, 200.0, 100.0), + ]; + let m = compute_phase_metrics(&samples, 0, 1_000_000_000, 100); + + assert!((m.duration_s - 1.0).abs() < 1e-9); + assert!((m.energy_j - 300.0).abs() < 1e-9); // 300W * 1s + assert!((m.mean_power_w - 300.0).abs() < 1e-9); + assert!((m.energy_per_token_j - 3.0).abs() < 1e-9); // 300J / 100 tokens + assert_eq!(m.tokens, 100); + } + + #[test] + fn phase_metrics_empty_samples() { + let m = compute_phase_metrics(&[], 0, 1_000_000_000, 10); + assert!((m.duration_s - 1.0).abs() < 1e-9); + assert_eq!(m.energy_j, 0.0); + assert_eq!(m.mean_power_w, 0.0); + assert_eq!(m.energy_per_token_j, 0.0); + } + + #[test] + fn phase_metrics_zero_tokens() { + let samples = vec![ + make_sample(0, 100.0, 50.0), + make_sample(1_000_000_000, 100.0, 50.0), + ]; + let m = compute_phase_metrics(&samples, 0, 1_000_000_000, 0); + assert_eq!(m.energy_per_token_j, 0.0); + } + + #[test] + fn phase_metrics_single_sample() { + let samples = vec![make_sample(500_000_000, 200.0, 100.0)]; + let m = compute_phase_metrics(&samples, 0, 1_000_000_000, 50); + // Single sample -> no trapezoids, energy = 0 + assert_eq!(m.energy_j, 0.0); + assert!((m.mean_power_w - 300.0).abs() < 1e-9); + } + + #[test] + fn phase_metrics_ramp() { + // GPU: 0 -> 200W, CPU: 0 -> 100W over 1 second + let samples = vec![ + make_sample(0, 0.0, 0.0), + make_sample(1_000_000_000, 200.0, 100.0), + ]; + let m = compute_phase_metrics(&samples, 0, 1_000_000_000, 10); + // Trapezoidal: (0+300)/2 * 1.0 = 150 J + assert!((m.energy_j - 150.0).abs() < 1e-9); + assert!((m.energy_per_token_j - 15.0).abs() < 1e-9); + } + + #[test] + fn split_at_ttft_basic() { + let samples = vec![ + make_sample(0, 300.0, 100.0), // prefill start + make_sample(500_000_000, 300.0, 100.0), // prefill end / TTFT + make_sample(500_000_000, 200.0, 80.0), // decode start (at TTFT) + make_sample(1_000_000_000, 200.0, 80.0), // decode mid + make_sample(2_000_000_000, 200.0, 80.0), // decode end + ]; + + let (prefill, decode) = split_at_ttft( + &samples, + 0, + 500_000_000, + 2_000_000_000, + 128, + 256, + ); + + assert!((prefill.duration_s - 0.5).abs() < 1e-9); + assert_eq!(prefill.tokens, 128); + + assert!((decode.duration_s - 1.5).abs() < 1e-9); + assert_eq!(decode.tokens, 256); + assert!(decode.energy_j > 0.0); + } + + #[test] + fn phase_metrics_filters_outside_window() { + let samples = vec![ + make_sample(0, 999.0, 999.0), // before window + make_sample(1_000_000_000, 100.0, 50.0), // in window + make_sample(2_000_000_000, 100.0, 50.0), // in window + make_sample(9_000_000_000, 999.0, 999.0), // after window + ]; + let m = compute_phase_metrics(&samples, 1_000_000_000, 2_000_000_000, 20); + // Only the two in-window samples contribute + assert!((m.energy_j - 150.0).abs() < 1e-9); // (150+150)/2 * 1s + assert!((m.mean_power_w - 150.0).abs() < 1e-9); + } +} diff --git a/rust/crates/openjarvis-telemetry/src/session.rs b/rust/crates/openjarvis-telemetry/src/session.rs new file mode 100644 index 00000000..7e76ff49 --- /dev/null +++ b/rust/crates/openjarvis-telemetry/src/session.rs @@ -0,0 +1,391 @@ +//! Ring-buffer backed telemetry session for high-frequency power/energy sampling. + +use parking_lot::RwLock; + +/// Single telemetry sample with nanosecond timestamp. +#[derive(Debug, Clone, Default)] +pub struct TelemetrySample { + pub timestamp_ns: u64, + pub gpu_power_w: f64, + pub cpu_power_w: f64, + pub gpu_energy_j: f64, + pub cpu_energy_j: f64, + pub gpu_util_pct: f64, + pub gpu_temp_c: f64, + pub gpu_mem_gb: f64, +} + +/// Fixed-capacity ring buffer with O(1) push and O(log n) window queries. +pub struct RingBuffer { + data: Vec>, + head: usize, + len: usize, + cap: usize, +} + +impl RingBuffer { + pub fn new(capacity: usize) -> Self { + assert!(capacity > 0, "RingBuffer capacity must be > 0"); + Self { + data: (0..capacity).map(|_| None).collect(), + head: 0, + len: 0, + cap: capacity, + } + } + + /// Push an item, overwriting the oldest when full. + pub fn push(&mut self, item: T) { + self.data[self.head] = Some(item); + self.head = (self.head + 1) % self.cap; + if self.len < self.cap { + self.len += 1; + } + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + pub fn clear(&mut self) { + for slot in self.data.iter_mut() { + *slot = None; + } + self.head = 0; + self.len = 0; + } + + /// Return items ordered oldest to newest. + pub fn as_ordered(&self) -> Vec<&T> { + if self.len == 0 { + return Vec::new(); + } + let mut result = Vec::with_capacity(self.len); + // Start index is where the oldest element lives + let start = if self.len < self.cap { + 0 + } else { + self.head // head points to the next write position = oldest element when full + }; + for i in 0..self.len { + let idx = (start + i) % self.cap; + if let Some(ref item) = self.data[idx] { + result.push(item); + } + } + result + } +} + +/// Core telemetry session using ring buffer. +pub struct TelemetrySessionCore { + buffer: RwLock>, + sampling_interval_ms: u64, +} + +impl TelemetrySessionCore { + pub fn new(capacity: usize, sampling_interval_ms: u64) -> Self { + Self { + buffer: RwLock::new(RingBuffer::new(capacity)), + sampling_interval_ms, + } + } + + pub fn sampling_interval_ms(&self) -> u64 { + self.sampling_interval_ms + } + + pub fn add_sample(&self, sample: TelemetrySample) { + self.buffer.write().push(sample); + } + + /// Return samples within [start_ns, end_ns] using binary search on monotonic timestamps. + pub fn window(&self, start_ns: u64, end_ns: u64) -> Vec { + let buf = self.buffer.read(); + let ordered = buf.as_ordered(); + if ordered.is_empty() { + return Vec::new(); + } + + // Binary search for first element >= start_ns + let lo = match ordered.binary_search_by(|s| s.timestamp_ns.cmp(&start_ns)) { + Ok(i) => i, + Err(i) => i, + }; + + // Binary search for last element <= end_ns + let hi = match ordered.binary_search_by(|s| s.timestamp_ns.cmp(&end_ns)) { + Ok(i) => i + 1, + Err(i) => i, + }; + + ordered[lo..hi].iter().map(|s| (*s).clone()).collect() + } + + /// Compute energy delta via trapezoidal integration of power samples. + /// Returns (gpu_energy_j, cpu_energy_j). + pub fn compute_energy_delta(&self, start_ns: u64, end_ns: u64) -> (f64, f64) { + let samples = self.window(start_ns, end_ns); + if samples.len() < 2 { + return (0.0, 0.0); + } + + let mut gpu_j = 0.0; + let mut cpu_j = 0.0; + for pair in samples.windows(2) { + let dt_s = (pair[1].timestamp_ns - pair[0].timestamp_ns) as f64 / 1e9; + // Trapezoidal rule: area = (a + b) / 2 * dt + gpu_j += (pair[0].gpu_power_w + pair[1].gpu_power_w) / 2.0 * dt_s; + cpu_j += (pair[0].cpu_power_w + pair[1].cpu_power_w) / 2.0 * dt_s; + } + + (gpu_j, cpu_j) + } + + /// Average power (gpu_w, cpu_w) in the given time window. + pub fn compute_avg_power(&self, start_ns: u64, end_ns: u64) -> (f64, f64) { + let samples = self.window(start_ns, end_ns); + if samples.is_empty() { + return (0.0, 0.0); + } + let n = samples.len() as f64; + let gpu_sum: f64 = samples.iter().map(|s| s.gpu_power_w).sum(); + let cpu_sum: f64 = samples.iter().map(|s| s.cpu_power_w).sum(); + (gpu_sum / n, cpu_sum / n) + } + + pub fn len(&self) -> usize { + self.buffer.read().len() + } + + pub fn is_empty(&self) -> bool { + self.buffer.read().is_empty() + } + + pub fn clear(&self) { + self.buffer.write().clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- RingBuffer tests ---- + + #[test] + fn ring_buffer_basic_push_and_len() { + let mut rb: RingBuffer = RingBuffer::new(4); + assert!(rb.is_empty()); + assert_eq!(rb.len(), 0); + + rb.push(1); + rb.push(2); + rb.push(3); + assert_eq!(rb.len(), 3); + assert!(!rb.is_empty()); + } + + #[test] + fn ring_buffer_ordered_no_wrap() { + let mut rb: RingBuffer = RingBuffer::new(5); + for i in 0..3 { + rb.push(i); + } + let ordered: Vec = rb.as_ordered().into_iter().cloned().collect(); + assert_eq!(ordered, vec![0, 1, 2]); + } + + #[test] + fn ring_buffer_wrap_around() { + let mut rb: RingBuffer = RingBuffer::new(3); + rb.push(1); + rb.push(2); + rb.push(3); + rb.push(4); // overwrites 1 + rb.push(5); // overwrites 2 + + assert_eq!(rb.len(), 3); + let ordered: Vec = rb.as_ordered().into_iter().cloned().collect(); + assert_eq!(ordered, vec![3, 4, 5]); + } + + #[test] + fn ring_buffer_clear() { + let mut rb: RingBuffer = RingBuffer::new(3); + rb.push(10); + rb.push(20); + rb.clear(); + assert!(rb.is_empty()); + assert_eq!(rb.len(), 0); + assert!(rb.as_ordered().is_empty()); + } + + #[test] + fn ring_buffer_single_capacity() { + let mut rb: RingBuffer = RingBuffer::new(1); + rb.push(42); + assert_eq!(rb.len(), 1); + rb.push(99); + assert_eq!(rb.len(), 1); + let ordered: Vec = rb.as_ordered().into_iter().cloned().collect(); + assert_eq!(ordered, vec![99]); + } + + #[test] + #[should_panic(expected = "capacity must be > 0")] + fn ring_buffer_zero_capacity_panics() { + let _rb: RingBuffer = RingBuffer::new(0); + } + + #[test] + fn ring_buffer_exact_fill() { + let mut rb: RingBuffer = RingBuffer::new(4); + for i in 0..4 { + rb.push(i); + } + assert_eq!(rb.len(), 4); + let ordered: Vec = rb.as_ordered().into_iter().cloned().collect(); + assert_eq!(ordered, vec![0, 1, 2, 3]); + } + + // ---- TelemetrySessionCore tests ---- + + fn make_sample(ts_ns: u64, gpu_w: f64, cpu_w: f64) -> TelemetrySample { + TelemetrySample { + timestamp_ns: ts_ns, + gpu_power_w: gpu_w, + cpu_power_w: cpu_w, + ..Default::default() + } + } + + #[test] + fn session_add_and_len() { + let session = TelemetrySessionCore::new(100, 10); + assert_eq!(session.len(), 0); + assert!(session.is_empty()); + + session.add_sample(make_sample(1_000_000, 100.0, 50.0)); + session.add_sample(make_sample(2_000_000, 110.0, 55.0)); + assert_eq!(session.len(), 2); + assert!(!session.is_empty()); + } + + #[test] + fn session_window_returns_correct_range() { + let session = TelemetrySessionCore::new(100, 10); + for i in 0..10 { + session.add_sample(make_sample(i * 1_000_000, 100.0, 50.0)); + } + + let win = session.window(3_000_000, 6_000_000); + assert_eq!(win.len(), 4); // timestamps 3, 4, 5, 6 (inclusive) + assert_eq!(win[0].timestamp_ns, 3_000_000); + assert_eq!(win[3].timestamp_ns, 6_000_000); + } + + #[test] + fn session_window_empty() { + let session = TelemetrySessionCore::new(100, 10); + let win = session.window(0, 1_000_000); + assert!(win.is_empty()); + } + + #[test] + fn session_window_no_match() { + let session = TelemetrySessionCore::new(100, 10); + session.add_sample(make_sample(1_000_000, 100.0, 50.0)); + session.add_sample(make_sample(2_000_000, 100.0, 50.0)); + + let win = session.window(5_000_000, 10_000_000); + assert!(win.is_empty()); + } + + #[test] + fn session_energy_delta_trapezoidal() { + let session = TelemetrySessionCore::new(100, 10); + // 100W GPU, 50W CPU constant for 1 second (two samples 1s apart) + session.add_sample(make_sample(0, 100.0, 50.0)); + session.add_sample(make_sample(1_000_000_000, 100.0, 50.0)); // 1s later + + let (gpu_j, cpu_j) = session.compute_energy_delta(0, 1_000_000_000); + assert!((gpu_j - 100.0).abs() < 1e-9, "gpu_j = {gpu_j}"); + assert!((cpu_j - 50.0).abs() < 1e-9, "cpu_j = {cpu_j}"); + } + + #[test] + fn session_energy_delta_linear_ramp() { + let session = TelemetrySessionCore::new(100, 10); + // GPU ramps from 0W to 200W over 1 second -> trapezoidal area = 100 J + session.add_sample(make_sample(0, 0.0, 0.0)); + session.add_sample(make_sample(1_000_000_000, 200.0, 100.0)); + + let (gpu_j, cpu_j) = session.compute_energy_delta(0, 1_000_000_000); + assert!((gpu_j - 100.0).abs() < 1e-9, "gpu_j = {gpu_j}"); + assert!((cpu_j - 50.0).abs() < 1e-9, "cpu_j = {cpu_j}"); + } + + #[test] + fn session_energy_delta_single_sample() { + let session = TelemetrySessionCore::new(100, 10); + session.add_sample(make_sample(0, 100.0, 50.0)); + + let (gpu_j, cpu_j) = session.compute_energy_delta(0, 0); + assert_eq!(gpu_j, 0.0); + assert_eq!(cpu_j, 0.0); + } + + #[test] + fn session_avg_power() { + let session = TelemetrySessionCore::new(100, 10); + session.add_sample(make_sample(0, 100.0, 50.0)); + session.add_sample(make_sample(1_000_000, 200.0, 100.0)); + session.add_sample(make_sample(2_000_000, 150.0, 75.0)); + + let (gpu_avg, cpu_avg) = session.compute_avg_power(0, 2_000_000); + assert!((gpu_avg - 150.0).abs() < 1e-9); + assert!((cpu_avg - 75.0).abs() < 1e-9); + } + + #[test] + fn session_avg_power_empty() { + let session = TelemetrySessionCore::new(100, 10); + let (gpu_avg, cpu_avg) = session.compute_avg_power(0, 1_000_000); + assert_eq!(gpu_avg, 0.0); + assert_eq!(cpu_avg, 0.0); + } + + #[test] + fn session_clear() { + let session = TelemetrySessionCore::new(100, 10); + session.add_sample(make_sample(0, 100.0, 50.0)); + session.clear(); + assert!(session.is_empty()); + assert_eq!(session.len(), 0); + } + + #[test] + fn session_ring_buffer_overflow() { + let session = TelemetrySessionCore::new(3, 10); + for i in 0..5 { + session.add_sample(make_sample(i * 1_000_000, 100.0, 50.0)); + } + assert_eq!(session.len(), 3); + // Only the last 3 samples should remain (ts: 2, 3, 4 million) + let win = session.window(0, 10_000_000); + assert_eq!(win.len(), 3); + assert_eq!(win[0].timestamp_ns, 2_000_000); + assert_eq!(win[2].timestamp_ns, 4_000_000); + } + + #[test] + fn session_sampling_interval() { + let session = TelemetrySessionCore::new(100, 42); + assert_eq!(session.sampling_interval_ms(), 42); + } +} diff --git a/scripts/run_use_case_comparison.sh b/scripts/run_use_case_comparison.sh new file mode 100755 index 00000000..583ce2d7 --- /dev/null +++ b/scripts/run_use_case_comparison.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# ============================================================================= +# run_use_case_comparison.sh +# +# Orchestrates the 11-model × 2-agent × 5-benchmark eval comparison. +# +# Open-source models run sequentially (one vLLM server at a time). +# Cloud models run via API (no GPU needed). +# +# Usage: +# ./scripts/run_use_case_comparison.sh # Full comparison +# ./scripts/run_use_case_comparison.sh --cloud-only # Cloud models only +# ./scripts/run_use_case_comparison.sh --oss-only # Open-source models only +# ./scripts/run_use_case_comparison.sh --model "GLM-4.7-Flash" # Single OSS model +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_DIR" + +# === Configuration === +EVAL_CMD="uv run jarvis eval run" +CONFIGS_DIR="src/openjarvis/evals/configs" +VLLM_PORT=8000 +VLLM_HEALTH_URL="http://localhost:${VLLM_PORT}/health" +VLLM_STARTUP_TIMEOUT=300 # seconds +VLLM_PID="" +LOG_DIR="logs/eval-comparison" + +# Open-source models: name|hf_id|tensor_parallel_size +OSS_MODELS=( + "GLM-4.7-Flash|unsloth/GLM-4.7-Flash-GGUF|2" + "Qwen3.5-122B|unsloth/Qwen3.5-122B-A10B-GGUF|4" + "gpt-oss-120b|unsloth/gpt-oss-120b-GGUF|4" + "GLM-5|unsloth/GLM-5-GGUF|4" + "Qwen3.5-397B|unsloth/Qwen3.5-397B-A17B-GGUF|8" +) + +# === Helpers === + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +ensure_dirs() { + mkdir -p "$LOG_DIR" + mkdir -p results/use-cases-opensource-orchestrator + mkdir -p results/use-cases-opensource-openhands + mkdir -p results/use-cases-cloud-orchestrator + mkdir -p results/use-cases-cloud-openhands +} + +start_vllm() { + local model_id="$1" + local tp_size="$2" + local log_file="$3" + + log "Starting vLLM: model=$model_id tp=$tp_size" + vllm serve "$model_id" \ + --tensor-parallel-size "$tp_size" \ + --port "$VLLM_PORT" \ + --disable-log-requests \ + > "$log_file" 2>&1 & + VLLM_PID=$! + log "vLLM PID: $VLLM_PID" +} + +wait_for_vllm() { + local elapsed=0 + log "Waiting for vLLM to be ready at $VLLM_HEALTH_URL ..." + while [ $elapsed -lt $VLLM_STARTUP_TIMEOUT ]; do + if curl -sf "$VLLM_HEALTH_URL" > /dev/null 2>&1; then + log "vLLM is ready (${elapsed}s)" + return 0 + fi + sleep 5 + elapsed=$((elapsed + 5)) + done + log "ERROR: vLLM failed to start within ${VLLM_STARTUP_TIMEOUT}s" + stop_vllm + return 1 +} + +stop_vllm() { + if [ -n "$VLLM_PID" ] && kill -0 "$VLLM_PID" 2>/dev/null; then + log "Stopping vLLM (PID $VLLM_PID)" + kill "$VLLM_PID" 2>/dev/null || true + wait "$VLLM_PID" 2>/dev/null || true + VLLM_PID="" + fi + # Also kill any lingering vllm serve processes on our port + pkill -f "vllm serve.*--port $VLLM_PORT" 2>/dev/null || true + sleep 2 +} + +run_eval() { + local config="$1" + local filter="${2:-}" + local start_time end_time + + start_time=$(date +%s) + log "Running: $config ${filter:+(filter: $filter)}" + + if [ -n "$filter" ]; then + $EVAL_CMD -c "$config" --model-filter "$filter" -v || { + log "WARNING: eval run failed for $config (filter: $filter)" + return 1 + } + else + $EVAL_CMD -c "$config" -v || { + log "WARNING: eval run failed for $config" + return 1 + } + fi + + end_time=$(date +%s) + log "Completed in $((end_time - start_time))s: $config ${filter:+(filter: $filter)}" +} + +# === Execution Phases === + +run_oss_models() { + local model_filter="${1:-}" + + for entry in "${OSS_MODELS[@]}"; do + IFS='|' read -r name model_id tp_size <<< "$entry" + + # Skip if --model flag set and doesn't match + if [ -n "$model_filter" ] && [[ "$name" != *"$model_filter"* ]]; then + continue + fi + + log "========================================" + log "OSS MODEL: $name ($model_id, TP=$tp_size)" + log "========================================" + + local log_file="$LOG_DIR/vllm_${name}.log" + + # Start vLLM for this model + start_vllm "$model_id" "$tp_size" "$log_file" + if ! wait_for_vllm; then + log "Skipping $name — vLLM failed to start" + continue + fi + + # Run orchestrator config (filtered to this model) + run_eval "$CONFIGS_DIR/use_case_opensource_orchestrator.toml" "$name" || true + + # Run openhands config (filtered to this model) + run_eval "$CONFIGS_DIR/use_case_opensource_openhands.toml" "$name" || true + + # Stop vLLM + stop_vllm + + log "Completed all benchmarks for $name" + done +} + +run_cloud_models() { + log "========================================" + log "CLOUD MODELS" + log "========================================" + + # Source .env for API keys + if [ -f "$PROJECT_DIR/.env" ]; then + # shellcheck disable=SC1091 + set -a && source "$PROJECT_DIR/.env" && set +a + log "Loaded API keys from .env" + else + log "WARNING: .env not found — cloud runs may fail" + fi + + # Orchestrator agent + run_eval "$CONFIGS_DIR/use_case_cloud_orchestrator.toml" || true + + # CodeAct agent + run_eval "$CONFIGS_DIR/use_case_cloud_openhands.toml" || true + + log "Completed cloud model runs" +} + +run_comparison() { + log "========================================" + log "COMPARISON" + log "========================================" + + local result_files=() + for dir in results/use-cases-{opensource-orchestrator,opensource-openhands,cloud-orchestrator,cloud-openhands}; do + if [ -d "$dir" ]; then + while IFS= read -r -d '' f; do + result_files+=("$f") + done < <(find "$dir" -name '*.jsonl' -print0 2>/dev/null) + fi + done + + if [ ${#result_files[@]} -eq 0 ]; then + log "No result files found — skipping comparison" + return + fi + + log "Comparing ${#result_files[@]} result files" + uv run jarvis eval compare "${result_files[@]}" || { + log "WARNING: comparison failed" + } +} + +# === Main === + +main() { + local mode="all" + local model_filter="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --cloud-only) mode="cloud"; shift ;; + --oss-only) mode="oss"; shift ;; + --model) model_filter="$2"; mode="oss"; shift 2 ;; + --compare) mode="compare"; shift ;; + -h|--help) + echo "Usage: $0 [--cloud-only|--oss-only|--model NAME|--compare]" + echo "" + echo "Options:" + echo " --cloud-only Run cloud models only (no GPU needed)" + echo " --oss-only Run open-source models only (needs GPU + vLLM)" + echo " --model NAME Run a single open-source model by name substring" + echo " --compare Only run comparison on existing results" + exit 0 + ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac + done + + log "Starting eval comparison (mode=$mode)" + ensure_dirs + + # Trap to ensure vLLM is stopped on exit + trap stop_vllm EXIT + + case "$mode" in + all) + run_oss_models "" + run_cloud_models + run_comparison + ;; + oss) + run_oss_models "$model_filter" + ;; + cloud) + run_cloud_models + ;; + compare) + run_comparison + ;; + esac + + log "Done!" +} + +main "$@" diff --git a/src/openjarvis/cli/eval_cmd.py b/src/openjarvis/cli/eval_cmd.py index 3a9dc8dc..3f03fb30 100644 --- a/src/openjarvis/cli/eval_cmd.py +++ b/src/openjarvis/cli/eval_cmd.py @@ -34,6 +34,26 @@ KNOWN_BENCHMARKS = { "category": "agentic", "description": "TerminalBench Native (Docker)", }, + "email_triage": { + "category": "use-case", + "description": "Email triage classification + draft", + }, + "morning_brief": { + "category": "use-case", + "description": "Morning briefing generation", + }, + "research_mining": { + "category": "use-case", + "description": "Research synthesis + accuracy", + }, + "knowledge_base": { + "category": "use-case", + "description": "Document-grounded retrieval QA", + }, + "coding_task": { + "category": "use-case", + "description": "Function-level code generation", + }, } KNOWN_BACKENDS = { @@ -101,6 +121,10 @@ def eval_list() -> None: "--agent", "agent_name", default=None, help="Agent name for jarvis-agent backend.", ) +@click.option( + "-e", "--engine", "engine_key", default=None, + help="Engine key (ollama, vllm, cloud, ...).", +) @click.option( "--tools", "tools", default="", help="Comma-separated tool names.", @@ -109,6 +133,26 @@ def eval_list() -> None: "--telemetry/--no-telemetry", "telemetry", default=False, help="Enable telemetry collection during eval.", ) +@click.option( + "--gpu-metrics/--no-gpu-metrics", "gpu_metrics", default=False, + help="Enable GPU metrics collection.", +) +@click.option( + "--seed", "seed", type=int, default=42, + help="Random seed.", +) +@click.option( + "--temperature", "temperature", type=float, default=0.0, + help="Generation temperature.", +) +@click.option( + "--max-tokens", "max_tokens", type=int, default=2048, + help="Max output tokens.", +) +@click.option( + "--model-filter", "model_filter", default=None, + help="Filter models by name substring (for multi-model configs).", +) @click.option( "-o", "--output", "output_path", default=None, type=click.Path(), help="Output JSONL path.", @@ -152,8 +196,14 @@ def eval_run( max_samples: Optional[int], backend: str, agent_name: Optional[str], + engine_key: Optional[str], tools: str, telemetry: bool, + gpu_metrics: bool, + seed: int, + temperature: float, + max_tokens: int, + model_filter: Optional[str], output_path: Optional[str], wandb_project: str, wandb_entity: str, @@ -185,6 +235,17 @@ def eval_run( console.print(f"[red]Error loading config: {exc}[/red]") sys.exit(1) + # Filter by model name substring if requested + if model_filter: + run_configs = [ + rc for rc in run_configs if model_filter in rc.model + ] + if not run_configs: + console.print( + f"[red]No models match filter '{model_filter}'[/red]" + ) + sys.exit(1) + console.print( f"[cyan]Suite:[/cyan] {suite.meta.name or Path(config_path).stem}" ) @@ -248,9 +309,14 @@ def eval_run( model=model, max_samples=max_samples, agent_name=agent_name, + engine_key=engine_key, tools=tool_list, output_path=output_path, + seed=seed, + temperature=temperature, + max_tokens=max_tokens, telemetry=telemetry, + gpu_metrics=gpu_metrics, wandb_project=wandb_project, wandb_entity=wandb_entity, wandb_tags=wandb_tags, diff --git a/src/openjarvis/engine/_openai_compat.py b/src/openjarvis/engine/_openai_compat.py index 9442718a..0c60d0ca 100644 --- a/src/openjarvis/engine/_openai_compat.py +++ b/src/openjarvis/engine/_openai_compat.py @@ -22,7 +22,7 @@ class _OpenAICompatibleEngine(InferenceEngine): engine_id: str = "" _default_host: str = "http://localhost:8000" - def __init__(self, host: str | None = None, *, timeout: float = 120.0) -> None: + def __init__(self, host: str | None = None, *, timeout: float = 600.0) -> None: self._host = (host or self._default_host).rstrip("/") self._client = httpx.Client(base_url=self._host, timeout=timeout) diff --git a/src/openjarvis/engine/cloud.py b/src/openjarvis/engine/cloud.py index b52a3e83..35b81711 100644 --- a/src/openjarvis/engine/cloud.py +++ b/src/openjarvis/engine/cloud.py @@ -21,6 +21,7 @@ PRICING: Dict[str, tuple[float, float]] = { "gpt-4o": (2.50, 10.00), "gpt-4o-mini": (0.15, 0.60), "gpt-5": (10.00, 30.00), + "gpt-5.4": (15.00, 60.00), "gpt-5-mini": (0.25, 2.00), "o3-mini": (1.10, 4.40), "claude-sonnet-4-20250514": (3.00, 15.00), @@ -33,10 +34,14 @@ PRICING: Dict[str, tuple[float, float]] = { "gemini-2.5-flash": (0.30, 2.50), "gemini-3-pro": (2.00, 12.00), "gemini-3-flash": (0.50, 3.00), + "gemini-3.1-pro-preview": (2.50, 15.00), + "gemini-3-flash-preview": (0.50, 3.00), } # Well-known model IDs per provider -_OPENAI_MODELS = ["gpt-4o", "gpt-4o-mini", "gpt-5", "gpt-5-mini", "o3-mini"] +_OPENAI_MODELS = [ + "gpt-4o", "gpt-4o-mini", "gpt-5", "gpt-5.4", "gpt-5-mini", "o3-mini", +] _ANTHROPIC_MODELS = [ "claude-sonnet-4-20250514", "claude-opus-4-20250514", @@ -50,6 +55,8 @@ _GOOGLE_MODELS = [ "gemini-2.5-flash", "gemini-3-pro", "gemini-3-flash", + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", ] @@ -64,8 +71,10 @@ def _is_google_model(model: str) -> bool: def _is_openai_reasoning_model(model: str) -> bool: """Check if model is an OpenAI reasoning model that restricts temperature.""" m = model.lower() - # o1/o3 series and gpt-5-mini dated snapshots are reasoning models - return m.startswith(("o1", "o3")) or "gpt-5-mini-" in m + # o1/o3 series and gpt-5-mini (all variants) are reasoning models + if m.startswith(("o1", "o3")): + return True + return m == "gpt-5-mini" or m.startswith("gpt-5-mini-") def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float: diff --git a/src/openjarvis/evals/backends/jarvis_agent.py b/src/openjarvis/evals/backends/jarvis_agent.py index 6f9fca1c..ea1caabf 100644 --- a/src/openjarvis/evals/backends/jarvis_agent.py +++ b/src/openjarvis/evals/backends/jarvis_agent.py @@ -24,6 +24,7 @@ class JarvisAgentBackend(InferenceBackend): tools: Optional[List[str]] = None, telemetry: bool = False, gpu_metrics: bool = False, + model: Optional[str] = None, ) -> None: from openjarvis.system import SystemBuilder @@ -35,6 +36,8 @@ class JarvisAgentBackend(InferenceBackend): builder = SystemBuilder() if engine_key: builder.engine(engine_key) + if model: + builder.model(model) builder.agent(agent_name) if tools: builder.tools(tools) @@ -95,6 +98,12 @@ class JarvisAgentBackend(InferenceBackend): "throughput_tok_per_sec": telemetry_data.get("throughput_tok_per_sec", 0.0), } + def set_task_metadata(self, metadata: dict) -> None: + """Forward task environment metadata to the underlying agent.""" + agent = getattr(self._system, "_agent", None) + if agent and hasattr(agent, "set_task_metadata"): + agent.set_task_metadata(metadata) + def close(self) -> None: self._system.close() diff --git a/src/openjarvis/evals/cli.py b/src/openjarvis/evals/cli.py index 11b35741..eaedb367 100644 --- a/src/openjarvis/evals/cli.py +++ b/src/openjarvis/evals/cli.py @@ -49,6 +49,26 @@ BENCHMARKS = { "category": "agentic", "description": "TerminalBench Native (Docker)", }, + "email_triage": { + "category": "use-case", + "description": "Email triage classification + draft", + }, + "morning_brief": { + "category": "use-case", + "description": "Morning briefing generation", + }, + "research_mining": { + "category": "use-case", + "description": "Research synthesis + accuracy", + }, + "knowledge_base": { + "category": "use-case", + "description": "Document-grounded retrieval QA", + }, + "coding_task": { + "category": "use-case", + "description": "Function-level code generation", + }, } BACKENDS = { @@ -68,7 +88,8 @@ def _setup_logging(verbose: bool) -> None: def _build_backend(backend_name: str, engine_key: Optional[str], agent_name: str, tools: list[str], - telemetry: bool = False, gpu_metrics: bool = False): + telemetry: bool = False, gpu_metrics: bool = False, + model: Optional[str] = None): """Construct the appropriate backend.""" if backend_name == "jarvis-agent": from openjarvis.evals.backends.jarvis_agent import JarvisAgentBackend @@ -78,6 +99,7 @@ def _build_backend(backend_name: str, engine_key: Optional[str], tools=tools, telemetry=telemetry, gpu_metrics=gpu_metrics, + model=model, ) else: from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend @@ -137,6 +159,21 @@ def _build_dataset(benchmark: str): TerminalBenchNativeDataset, ) return TerminalBenchNativeDataset() + elif benchmark == "email_triage": + from openjarvis.evals.datasets.email_triage import EmailTriageDataset + return EmailTriageDataset() + elif benchmark == "morning_brief": + from openjarvis.evals.datasets.morning_brief import MorningBriefDataset + return MorningBriefDataset() + elif benchmark == "research_mining": + from openjarvis.evals.datasets.research_mining import ResearchMiningDataset + return ResearchMiningDataset() + elif benchmark == "knowledge_base": + from openjarvis.evals.datasets.knowledge_base import KnowledgeBaseDataset + return KnowledgeBaseDataset() + elif benchmark == "coding_task": + from openjarvis.evals.datasets.coding_task import CodingTaskDataset + return CodingTaskDataset() else: raise click.ClickException(f"Unknown benchmark: {benchmark}") @@ -187,6 +224,21 @@ def _build_scorer(benchmark: str, judge_backend, judge_model: str): TerminalBenchNativeScorer, ) return TerminalBenchNativeScorer(judge_backend, judge_model) + elif benchmark == "email_triage": + from openjarvis.evals.scorers.email_triage import EmailTriageScorer + return EmailTriageScorer(judge_backend, judge_model) + elif benchmark == "morning_brief": + from openjarvis.evals.scorers.morning_brief import MorningBriefScorer + return MorningBriefScorer(judge_backend, judge_model) + elif benchmark == "research_mining": + from openjarvis.evals.scorers.research_mining import ResearchMiningScorer + return ResearchMiningScorer(judge_backend, judge_model) + elif benchmark == "knowledge_base": + from openjarvis.evals.scorers.knowledge_base import KnowledgeBaseScorer + return KnowledgeBaseScorer(judge_backend, judge_model) + elif benchmark == "coding_task": + from openjarvis.evals.scorers.coding_task import CodingTaskScorer + return CodingTaskScorer(judge_backend, judge_model) else: raise click.ClickException(f"Unknown benchmark: {benchmark}") @@ -263,6 +315,7 @@ def _run_single(config, console: Optional[Console] = None) -> object: config.tools, telemetry=getattr(config, "telemetry", False), gpu_metrics=getattr(config, "gpu_metrics", False), + model=config.model, ) dataset = _build_dataset(config.benchmark) judge_backend = _build_judge_backend(config.judge_model) @@ -297,7 +350,217 @@ def _run_single(config, console: Optional[Console] = None) -> object: judge_backend.close() -def _run_from_config(config_path: str, verbose: bool) -> None: +def _run_agentic( + config, + console: Optional[Console] = None, + *, + concurrency: int = 1, + query_timeout: Optional[float] = None, +) -> None: + """Run an agentic evaluation using AgenticRunner with trace + energy capture.""" + import asyncio + from pathlib import Path as _Path + + from openjarvis.evals.core.agentic_runner import AgenticRunner + from openjarvis.evals.core.event_recorder import EventRecorder + from openjarvis.evals.core.export import ( + export_artifacts_manifest, + export_jsonl, + export_summary_json, + ) + + if console is None: + console = Console() + + # Build dataset + dataset = _build_dataset(config.benchmark) + dataset.load( + max_samples=config.max_samples, + split=config.dataset_split, + seed=config.seed, + ) + + # Build agent via SystemBuilder + from openjarvis.system import SystemBuilder + + builder = SystemBuilder() + if config.engine_key: + builder.engine(config.engine_key) + builder.model(config.model) + agent_name = config.agent_name or "orchestrator" + builder.agent(agent_name) + tool_list = config.tools or [] + if tool_list: + builder.tools(tool_list) + system = builder.telemetry(config.telemetry).traces(config.telemetry).build() + + # Build TelemetrySession (optional — only if energy monitoring available) + telemetry_session = None + try: + from openjarvis.telemetry.energy_monitor import create_energy_monitor + from openjarvis.telemetry.session import TelemetrySession + + monitor = create_energy_monitor() + if monitor is not None: + telemetry_session = TelemetrySession( + monitor=monitor, interval_ms=100, + ) + except ImportError: + pass + + # Set up run directory + model_slug = config.model.replace("/", "-").replace(":", "-") + if config.output_path: + run_dir = _Path(config.output_path).parent + else: + run_dir = _Path("results") + run_dir = run_dir / f"agentic_{config.benchmark}_{model_slug}" + run_dir.mkdir(parents=True, exist_ok=True) + + # Build runner + event_recorder = EventRecorder() + runner = AgenticRunner( + agent=system, + dataset=dataset, + telemetry_session=telemetry_session, + config={ + "model": config.model, + "benchmark": config.benchmark, + "agent": agent_name, + "tools": tool_list, + "temperature": config.temperature, + "max_tokens": config.max_tokens, + }, + event_recorder=event_recorder, + run_dir=run_dir, + concurrency=concurrency, + query_timeout=query_timeout, + ) + + # Execute with telemetry session context + try: + ctx = telemetry_session if telemetry_session is not None else _nullctx() + with ctx: + with console.status("Running agentic evaluation..."): + traces = asyncio.run(runner.run(max_queries=config.max_samples)) + finally: + system.close() + if telemetry_session is not None and hasattr(telemetry_session, "stop"): + try: + telemetry_session.stop() + except Exception: + pass + + # Export results + jsonl_path = run_dir / "traces.jsonl" + export_jsonl(traces, jsonl_path) + console.print(f" [green]Traces:[/green] {jsonl_path}") + + summary_path = run_dir / "summary.json" + export_summary_json( + traces, + config={ + "model": config.model, + "benchmark": config.benchmark, + "agent": agent_name, + "concurrency": concurrency, + "query_timeout": query_timeout, + }, + path=summary_path, + ) + console.print(f" [green]Summary:[/green] {summary_path}") + + manifest = export_artifacts_manifest(run_dir) + if manifest: + console.print(f" [green]Manifest:[/green] {manifest}") + + # Try HF dataset export (optional) + try: + from openjarvis.evals.core.export import export_hf_dataset + hf_path = run_dir / "hf_dataset" + export_hf_dataset(traces, hf_path) + console.print(f" [green]HF Arrow:[/green] {hf_path}") + except ImportError: + pass + + # Print summary table + print_section(console, "Agentic Results") + _print_agentic_summary(console, traces, config) + + +def _nullctx(): + """Return a no-op context manager.""" + from contextlib import nullcontext + return nullcontext() + + +def _print_agentic_summary(console: Console, traces, config) -> None: + """Print a rich summary of agentic run results.""" + from rich.table import Table + + completed = sum(1 for t in traces if t.completed) + resolved = sum(1 for t in traces if t.is_resolved is True) + timed_out = sum(1 for t in traces if t.timed_out) + total_turns = sum(t.num_turns for t in traces) + total_tool_calls = sum(t.total_tool_calls for t in traces) + total_in_tok = sum(t.total_input_tokens for t in traces) + total_out_tok = sum(t.total_output_tokens for t in traces) + total_wall = sum(t.total_wall_clock_s for t in traces) + + gpu_energies = [ + t.total_gpu_energy_joules for t in traces + if t.total_gpu_energy_joules is not None + ] + total_gpu_energy = sum(gpu_energies) if gpu_energies else None + + costs = [t.total_cost_usd for t in traces if t.total_cost_usd is not None] + total_cost = sum(costs) if costs else None + + table = Table( + title=f"[bold]{config.benchmark} / {config.model}[/bold]", + border_style="bright_blue", + ) + table.add_column("Metric", style="cyan", no_wrap=True) + table.add_column("Value", style="white") + + table.add_row("Queries", str(len(traces))) + table.add_row("Completed", f"{completed}/{len(traces)}") + if any(t.is_resolved is not None for t in traces): + table.add_row("Resolved", f"{resolved}/{len(traces)}") + table.add_row("Timed out", str(timed_out)) + table.add_row("Total turns", str(total_turns)) + avg_t = f"{total_turns / len(traces):.1f}" if traces else "0" + table.add_row("Avg turns/query", avg_t) + table.add_row("Total tool calls", str(total_tool_calls)) + table.add_row("Input tokens", f"{total_in_tok:,}") + table.add_row("Output tokens", f"{total_out_tok:,}") + table.add_row("Wall clock", f"{total_wall:.1f}s") + table.add_row( + "Avg query time", + f"{total_wall/len(traces):.1f}s" if traces else "0s", + ) + + if total_gpu_energy is not None: + table.add_row("GPU energy", f"{total_gpu_energy:.2f} J") + if total_cost is not None: + table.add_row("Total cost", f"${total_cost:.4f}") + + # Throughput + if total_out_tok > 0 and total_wall > 0: + table.add_row( + "Throughput", + f"{total_out_tok/total_wall:.1f} tok/s", + ) + + console.print(table) + + +def _run_from_config( + config_path: str, + verbose: bool, + *, + model_filter: str | None = None, +) -> None: """Load a TOML config and run the full models x benchmarks matrix.""" from openjarvis.evals.core.config import expand_suite, load_eval_config @@ -306,6 +569,14 @@ def _run_from_config(config_path: str, verbose: bool) -> None: suite = load_eval_config(config_path) run_configs = expand_suite(suite) + # Filter by model name substring if requested + if model_filter: + run_configs = [rc for rc in run_configs if model_filter in rc.model] + if not run_configs: + raise click.ClickException( + f"No models match filter '{model_filter}'" + ) + suite_name = suite.meta.name or Path(config_path).stem # Banner + configuration @@ -414,6 +685,14 @@ def main(): @click.option("--sheets-creds", "sheets_credentials_path", default="", help="Service account JSON path") +@click.option("--model-filter", default=None, + help="Filter models by name substring (for multi-model configs)") +@click.option("--agentic", is_flag=True, default=False, + help="Use AgenticRunner for multi-turn agent execution") +@click.option("--concurrency", type=int, default=1, + help="Parallel query execution (AgenticRunner only)") +@click.option("--query-timeout", type=float, default=None, + help="Per-query wall-clock timeout in seconds (AgenticRunner only)") @click.option("-v", "--verbose", is_flag=True, help="Verbose logging") @click.pass_context def run(ctx, config_path, benchmark, backend, model, engine_key, agent_name, @@ -422,7 +701,7 @@ def run(ctx, config_path, benchmark, backend, model, engine_key, agent_name, compact, trace_detail, wandb_project, wandb_entity, wandb_tags, wandb_group, sheets_spreadsheet_id, sheets_worksheet, sheets_credentials_path, - verbose): + model_filter, agentic, concurrency, query_timeout, verbose): """Run a single benchmark evaluation, or a full suite from a TOML config.""" _setup_logging(verbose) @@ -430,7 +709,7 @@ def run(ctx, config_path, benchmark, backend, model, engine_key, agent_name, # Config-driven mode if config_path is not None: - _run_from_config(config_path, verbose) + _run_from_config(config_path, verbose, model_filter=model_filter) return # CLI-driven mode: validate required args @@ -487,6 +766,23 @@ def run(ctx, config_path, benchmark, backend, model, engine_key, agent_name, workers=max_workers, ) + if agentic: + # --- Agentic runner path --- + print_section(console, "Agentic Evaluation") + console.print( + f" [cyan]Concurrency:[/cyan] {concurrency}" + ) + if query_timeout: + console.print( + f" [cyan]Timeout:[/cyan] {query_timeout}s per query" + ) + _run_agentic( + config, console=console, + concurrency=concurrency, + query_timeout=query_timeout, + ) + return + # Evaluation print_section(console, "Evaluation") summary = _run_single(config, console=console) diff --git a/src/openjarvis/evals/configs/use_case_cloud_comparison.toml b/src/openjarvis/evals/configs/use_case_cloud_comparison.toml new file mode 100644 index 00000000..08ffbbd1 --- /dev/null +++ b/src/openjarvis/evals/configs/use_case_cloud_comparison.toml @@ -0,0 +1,78 @@ +# Local vs Cloud comparison on 5 use-case benchmarks. +# Same agent (native_openhands), same benchmarks, different models. +# Shows cost/accuracy/latency tradeoffs. +# +# Prerequisites: source .env (OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY) +# Local: ollama running with qwen3:8b pulled + +[meta] +name = "use-case-cloud-comparison" +description = "Local (qwen3:8b) vs Cloud (gpt-5-mini, claude-sonnet-4-6, gemini-3-flash) on use-case benchmarks" + +[defaults] +temperature = 0.0 +max_tokens = 2048 + +[judge] +model = "gpt-5-mini-2025-08-07" + +[run] +max_workers = 4 +output_dir = "results/use-cases-cloud-comparison/" +seed = 42 +telemetry = true + +# --- Models Under Test --- + +[[models]] +name = "qwen3:8b" +engine = "ollama" + +[[models]] +name = "gpt-5-mini" +provider = "openai" + +[[models]] +name = "claude-sonnet-4-6" +provider = "anthropic" + +[[models]] +name = "gemini-3-flash" +provider = "google" + +# --- Benchmarks --- + +[[benchmarks]] +name = "email_triage" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "morning_brief" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "web_search"] +max_samples = 15 + +[[benchmarks]] +name = "research_mining" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "web_search"] +max_samples = 30 + +[[benchmarks]] +name = "knowledge_base" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "coding_task" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "code_interpreter"] +max_samples = 29 diff --git a/src/openjarvis/evals/configs/use_case_cloud_openhands.toml b/src/openjarvis/evals/configs/use_case_cloud_openhands.toml new file mode 100644 index 00000000..43c4c9e4 --- /dev/null +++ b/src/openjarvis/evals/configs/use_case_cloud_openhands.toml @@ -0,0 +1,87 @@ +# Closed-source cloud models × NativeOpenHands (CodeAct) agent on 5 use-case benchmarks. +# +# Models: Claude Opus 4.6, Claude Sonnet 4.6, GPT 5.4, GPT 5 Mini, +# Gemini 3.1 Pro, Gemini 3.1 Flash +# Agent: NativeOpenHandsAgent (code-execution-centric, Action/Observation loop) +# +# Prerequisites: source .env (OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY) + +[meta] +name = "use-case-cloud-openhands" +description = "Cloud models with NativeOpenHandsAgent (CodeAct) on use-case benchmarks" + +[defaults] +temperature = 0.0 +max_tokens = 2048 + +[judge] +model = "gpt-5-mini-2025-08-07" + +[run] +max_workers = 4 +output_dir = "results/use-cases-cloud-openhands/" +seed = 42 +telemetry = true + +# --- Models Under Test --- + +[[models]] +name = "claude-opus-4-6" +provider = "anthropic" + +[[models]] +name = "claude-sonnet-4-6" +provider = "anthropic" + +[[models]] +name = "gpt-5.4" +provider = "openai" + +[[models]] +name = "gpt-5-mini" +provider = "openai" + +[[models]] +name = "gemini-3.1-pro-preview" +provider = "google" + +[[models]] +name = "gemini-3-flash-preview" +provider = "google" + +# --- Benchmarks --- + +[[benchmarks]] +name = "email_triage" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "morning_brief" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "web_search"] +max_samples = 15 + +[[benchmarks]] +name = "research_mining" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "web_search"] +max_samples = 30 + +[[benchmarks]] +name = "knowledge_base" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "coding_task" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "code_interpreter"] +max_samples = 29 diff --git a/src/openjarvis/evals/configs/use_case_cloud_orchestrator.toml b/src/openjarvis/evals/configs/use_case_cloud_orchestrator.toml new file mode 100644 index 00000000..81e97695 --- /dev/null +++ b/src/openjarvis/evals/configs/use_case_cloud_orchestrator.toml @@ -0,0 +1,87 @@ +# Closed-source cloud models × Orchestrator agent on 5 use-case benchmarks. +# +# Models: Claude Opus 4.6, Claude Sonnet 4.6, GPT 5.4, GPT 5 Mini, +# Gemini 3.1 Pro, Gemini 3.1 Flash +# Agent: OrchestratorAgent (multi-turn tool-calling loop) +# +# Prerequisites: source .env (OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY) + +[meta] +name = "use-case-cloud-orchestrator" +description = "Cloud models with OrchestratorAgent on use-case benchmarks" + +[defaults] +temperature = 0.0 +max_tokens = 2048 + +[judge] +model = "gpt-5-mini-2025-08-07" + +[run] +max_workers = 4 +output_dir = "results/use-cases-cloud-orchestrator/" +seed = 42 +telemetry = true + +# --- Models Under Test --- + +[[models]] +name = "claude-opus-4-6" +provider = "anthropic" + +[[models]] +name = "claude-sonnet-4-6" +provider = "anthropic" + +[[models]] +name = "gpt-5.4" +provider = "openai" + +[[models]] +name = "gpt-5-mini" +provider = "openai" + +[[models]] +name = "gemini-3.1-pro-preview" +provider = "google" + +[[models]] +name = "gemini-3-flash-preview" +provider = "google" + +# --- Benchmarks --- + +[[benchmarks]] +name = "email_triage" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "morning_brief" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think", "web_search"] +max_samples = 15 + +[[benchmarks]] +name = "research_mining" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think", "web_search"] +max_samples = 30 + +[[benchmarks]] +name = "knowledge_base" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "coding_task" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think", "code_interpreter"] +max_samples = 29 diff --git a/src/openjarvis/evals/configs/use_case_direct.toml b/src/openjarvis/evals/configs/use_case_direct.toml new file mode 100644 index 00000000..81d1c248 --- /dev/null +++ b/src/openjarvis/evals/configs/use_case_direct.toml @@ -0,0 +1,47 @@ +# Baseline: direct engine inference (no agent) on 5 use-case benchmarks. +# Compare this against agent-backed configs to measure agent value-add. + +[meta] +name = "use-case-direct-baseline" +description = "Use-case benchmarks — raw engine, no agent (baseline)" + +[defaults] +temperature = 0.0 +max_tokens = 2048 + +[judge] +model = "gpt-5-mini-2025-08-07" + +[run] +max_workers = 4 +output_dir = "results/use-cases-direct/" +seed = 42 + +[[models]] +name = "qwen3:8b" +engine = "ollama" + +[[benchmarks]] +name = "email_triage" +backend = "jarvis-direct" +max_samples = 30 + +[[benchmarks]] +name = "morning_brief" +backend = "jarvis-direct" +max_samples = 15 + +[[benchmarks]] +name = "research_mining" +backend = "jarvis-direct" +max_samples = 30 + +[[benchmarks]] +name = "knowledge_base" +backend = "jarvis-direct" +max_samples = 30 + +[[benchmarks]] +name = "coding_task" +backend = "jarvis-direct" +max_samples = 29 diff --git a/src/openjarvis/evals/configs/use_case_openhands.toml b/src/openjarvis/evals/configs/use_case_openhands.toml new file mode 100644 index 00000000..b66c507f --- /dev/null +++ b/src/openjarvis/evals/configs/use_case_openhands.toml @@ -0,0 +1,57 @@ +# NativeOpenHandsAgent (CodeAct) on 5 use-case benchmarks. +# Code-execution-centric agent — strongest on coding tasks. + +[meta] +name = "use-case-openhands" +description = "Use-case benchmarks — NativeOpenHandsAgent (CodeAct)" + +[defaults] +temperature = 0.0 +max_tokens = 2048 + +[judge] +model = "gpt-5-mini-2025-08-07" + +[run] +max_workers = 4 +output_dir = "results/use-cases-openhands/" +seed = 42 + +[[models]] +name = "qwen3:8b" +engine = "ollama" + +[[benchmarks]] +name = "email_triage" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "morning_brief" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "web_search"] +max_samples = 15 + +[[benchmarks]] +name = "research_mining" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "web_search"] +max_samples = 30 + +[[benchmarks]] +name = "knowledge_base" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "coding_task" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "code_interpreter"] +max_samples = 29 diff --git a/src/openjarvis/evals/configs/use_case_opensource_openhands.toml b/src/openjarvis/evals/configs/use_case_opensource_openhands.toml new file mode 100644 index 00000000..1969ed31 --- /dev/null +++ b/src/openjarvis/evals/configs/use_case_opensource_openhands.toml @@ -0,0 +1,101 @@ +# Open-source models × NativeOpenHands (CodeAct) agent on 5 use-case benchmarks. +# +# Models: Qwen3.5-397B-A17B, Qwen3.5-122B-A10B, GLM-5, gpt-oss-120b, GLM-4.7-Flash +# Agent: NativeOpenHandsAgent (code-execution-centric, Action/Observation loop) +# Engine: vLLM (large MoE models need tensor parallelism) +# +# Setup: +# - Start vLLM with each model, e.g.: +# vllm serve unsloth/Qwen3.5-397B-A17B-GGUF --tensor-parallel-size 8 +# - Or use Ollama for smaller models: +# ollama pull glm4.7-flash + +[meta] +name = "use-case-opensource-openhands" +description = "Open-source models with NativeOpenHandsAgent (CodeAct) on use-case benchmarks" + +[defaults] +temperature = 0.0 +max_tokens = 2048 + +[judge] +model = "gpt-5-mini-2025-08-07" + +[run] +max_workers = 4 +output_dir = "results/use-cases-opensource-openhands/" +seed = 42 +telemetry = true +gpu_metrics = true + +# --- Models Under Test --- + +[[models]] +name = "unsloth/Qwen3.5-397B-A17B-GGUF" +engine = "vllm" +param_count_b = 397.0 +active_params_b = 17.0 +num_gpus = 8 + +[[models]] +name = "unsloth/Qwen3.5-122B-A10B-GGUF" +engine = "vllm" +param_count_b = 122.0 +active_params_b = 10.0 +num_gpus = 4 + +[[models]] +name = "unsloth/GLM-5-GGUF" +engine = "vllm" +param_count_b = 100.0 +num_gpus = 4 + +[[models]] +name = "unsloth/gpt-oss-120b-GGUF" +engine = "vllm" +param_count_b = 120.0 +num_gpus = 4 + +[[models]] +name = "unsloth/GLM-4.7-Flash-GGUF" +engine = "vllm" +param_count_b = 30.0 +active_params_b = 3.0 +num_gpus = 2 + +# --- Benchmarks --- + +[[benchmarks]] +name = "email_triage" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "morning_brief" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "web_search"] +max_samples = 15 + +[[benchmarks]] +name = "research_mining" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "web_search"] +max_samples = 30 + +[[benchmarks]] +name = "knowledge_base" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "coding_task" +backend = "jarvis-agent" +agent = "native_openhands" +tools = ["think", "code_interpreter"] +max_samples = 29 diff --git a/src/openjarvis/evals/configs/use_case_opensource_orchestrator.toml b/src/openjarvis/evals/configs/use_case_opensource_orchestrator.toml new file mode 100644 index 00000000..c40d60e1 --- /dev/null +++ b/src/openjarvis/evals/configs/use_case_opensource_orchestrator.toml @@ -0,0 +1,101 @@ +# Open-source models × Orchestrator agent on 5 use-case benchmarks. +# +# Models: Qwen3.5-397B-A17B, Qwen3.5-122B-A10B, GLM-5, gpt-oss-120b, GLM-4.7-Flash +# Agent: OrchestratorAgent (multi-turn tool-calling loop) +# Engine: vLLM (large MoE models need tensor parallelism) +# +# Setup: +# - Start vLLM with each model, e.g.: +# vllm serve unsloth/Qwen3.5-397B-A17B-GGUF --tensor-parallel-size 8 +# - Or use Ollama for smaller models: +# ollama pull glm4.7-flash + +[meta] +name = "use-case-opensource-orchestrator" +description = "Open-source models with OrchestratorAgent on use-case benchmarks" + +[defaults] +temperature = 0.0 +max_tokens = 2048 + +[judge] +model = "gpt-5-mini-2025-08-07" + +[run] +max_workers = 4 +output_dir = "results/use-cases-opensource-orchestrator/" +seed = 42 +telemetry = true +gpu_metrics = true + +# --- Models Under Test --- + +[[models]] +name = "unsloth/Qwen3.5-397B-A17B-GGUF" +engine = "vllm" +param_count_b = 397.0 +active_params_b = 17.0 +num_gpus = 8 + +[[models]] +name = "unsloth/Qwen3.5-122B-A10B-GGUF" +engine = "vllm" +param_count_b = 122.0 +active_params_b = 10.0 +num_gpus = 4 + +[[models]] +name = "unsloth/GLM-5-GGUF" +engine = "vllm" +param_count_b = 100.0 +num_gpus = 4 + +[[models]] +name = "unsloth/gpt-oss-120b-GGUF" +engine = "vllm" +param_count_b = 120.0 +num_gpus = 4 + +[[models]] +name = "unsloth/GLM-4.7-Flash-GGUF" +engine = "vllm" +param_count_b = 30.0 +active_params_b = 3.0 +num_gpus = 2 + +# --- Benchmarks --- + +[[benchmarks]] +name = "email_triage" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "morning_brief" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think", "web_search"] +max_samples = 15 + +[[benchmarks]] +name = "research_mining" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think", "web_search"] +max_samples = 30 + +[[benchmarks]] +name = "knowledge_base" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "coding_task" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think", "code_interpreter"] +max_samples = 29 diff --git a/src/openjarvis/evals/configs/use_case_orchestrator.toml b/src/openjarvis/evals/configs/use_case_orchestrator.toml new file mode 100644 index 00000000..40257d4d --- /dev/null +++ b/src/openjarvis/evals/configs/use_case_orchestrator.toml @@ -0,0 +1,57 @@ +# Orchestrator agent on 5 use-case benchmarks. +# Multi-turn tool-calling loop — OpenJarvis's flagship agent. + +[meta] +name = "use-case-orchestrator" +description = "Use-case benchmarks — OrchestratorAgent with tools" + +[defaults] +temperature = 0.0 +max_tokens = 2048 + +[judge] +model = "gpt-5-mini-2025-08-07" + +[run] +max_workers = 4 +output_dir = "results/use-cases-orchestrator/" +seed = 42 + +[[models]] +name = "qwen3:8b" +engine = "ollama" + +[[benchmarks]] +name = "email_triage" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "morning_brief" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think", "web_search"] +max_samples = 15 + +[[benchmarks]] +name = "research_mining" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think", "web_search"] +max_samples = 30 + +[[benchmarks]] +name = "knowledge_base" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "coding_task" +backend = "jarvis-agent" +agent = "orchestrator" +tools = ["think", "code_interpreter"] +max_samples = 29 diff --git a/src/openjarvis/evals/configs/use_case_react.toml b/src/openjarvis/evals/configs/use_case_react.toml new file mode 100644 index 00000000..08741fbc --- /dev/null +++ b/src/openjarvis/evals/configs/use_case_react.toml @@ -0,0 +1,57 @@ +# NativeReActAgent on 5 use-case benchmarks. +# Thought-Action-Observation loop — classic ReAct paradigm. + +[meta] +name = "use-case-react" +description = "Use-case benchmarks — NativeReActAgent (Thought-Action-Observation)" + +[defaults] +temperature = 0.0 +max_tokens = 2048 + +[judge] +model = "gpt-5-mini-2025-08-07" + +[run] +max_workers = 4 +output_dir = "results/use-cases-react/" +seed = 42 + +[[models]] +name = "qwen3:8b" +engine = "ollama" + +[[benchmarks]] +name = "email_triage" +backend = "jarvis-agent" +agent = "native_react" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "morning_brief" +backend = "jarvis-agent" +agent = "native_react" +tools = ["think", "web_search"] +max_samples = 15 + +[[benchmarks]] +name = "research_mining" +backend = "jarvis-agent" +agent = "native_react" +tools = ["think", "web_search"] +max_samples = 30 + +[[benchmarks]] +name = "knowledge_base" +backend = "jarvis-agent" +agent = "native_react" +tools = ["think"] +max_samples = 30 + +[[benchmarks]] +name = "coding_task" +backend = "jarvis-agent" +agent = "native_react" +tools = ["think", "code_interpreter"] +max_samples = 29 diff --git a/src/openjarvis/evals/configs/use_case_suite.toml b/src/openjarvis/evals/configs/use_case_suite.toml new file mode 100644 index 00000000..a4d2cc7a --- /dev/null +++ b/src/openjarvis/evals/configs/use_case_suite.toml @@ -0,0 +1,48 @@ +# Use-Case Benchmark Suite +# Evaluates OpenJarvis on 5 practical use cases: +# email triage, morning briefs, research, knowledge base QA, coding tasks. + +[meta] +name = "use-case-suite" +description = "Top 5 use-case benchmarks for OpenJarvis product demo" + +[defaults] +temperature = 0.0 +max_tokens = 2048 + +[judge] +model = "gpt-5-mini-2025-08-07" + +[run] +max_workers = 4 +output_dir = "results/use-cases/" +seed = 42 + +[[models]] +name = "qwen3:8b" +engine = "ollama" + +[[benchmarks]] +name = "email_triage" +backend = "jarvis-direct" +max_samples = 30 + +[[benchmarks]] +name = "morning_brief" +backend = "jarvis-direct" +max_samples = 15 + +[[benchmarks]] +name = "research_mining" +backend = "jarvis-direct" +max_samples = 30 + +[[benchmarks]] +name = "knowledge_base" +backend = "jarvis-direct" +max_samples = 30 + +[[benchmarks]] +name = "coding_task" +backend = "jarvis-direct" +max_samples = 30 diff --git a/src/openjarvis/evals/core/agentic_runner.py b/src/openjarvis/evals/core/agentic_runner.py new file mode 100644 index 00000000..6b40632e --- /dev/null +++ b/src/openjarvis/evals/core/agentic_runner.py @@ -0,0 +1,616 @@ +"""AgenticRunner — multi-turn agent execution with energy telemetry correlation. + +Orchestrates agentic workloads where a single query may involve multiple +LLM turns and tool calls, capturing per-turn traces with energy attribution. +""" + +from __future__ import annotations + +import asyncio +import copy +import json +import logging +import math +import re +import statistics +import threading +import time +from contextlib import nullcontext +from pathlib import Path +from typing import Any, Callable, Optional + +from openjarvis.evals.core.event_recorder import AgentEvent, EventRecorder, EventType +from openjarvis.evals.core.trace import QueryTrace, TurnTrace + +LOGGER = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Energy computation helpers +# --------------------------------------------------------------------------- + + +def _compute_energy_delta( + readings: list[Any], + gpu_field: str = "gpu_energy_j", +) -> Optional[float]: + """Compute energy delta from first to last reading for a field.""" + values = [ + getattr(s, gpu_field, None) + for s in readings + ] + values = [v for v in values if v is not None and math.isfinite(v)] + if len(values) >= 2: + delta = values[-1] - values[0] + return delta if delta >= 0 else None + return None + + +def _compute_power_avg( + readings: list[Any], + power_field: str = "gpu_power_w", +) -> Optional[float]: + """Compute average power across readings for a field.""" + values = [ + getattr(s, power_field, None) + for s in readings + ] + values = [v for v in values if v is not None and math.isfinite(v)] + return statistics.mean(values) if values else None + + +# --------------------------------------------------------------------------- +# Patch extraction helpers +# --------------------------------------------------------------------------- + +_FENCED_DIFF_RE = re.compile( + r"```(?:diff|patch)\s*\n(.*?)```", re.DOTALL +) +_UNIFIED_DIFF_MARKERS = ("diff --git", "--- a/", "+++ b/", "@@ ") + + +def _extract_patch(text: str) -> Optional[str]: + """Extract a unified-diff patch from agent response text.""" + fenced = _FENCED_DIFF_RE.findall(text) + if fenced: + return "\n\n".join(block.strip() for block in fenced) + + lines = text.splitlines() + patch_lines: list[str] = [] + in_diff = False + for line in lines: + if any(line.startswith(m) for m in _UNIFIED_DIFF_MARKERS): + in_diff = True + if in_diff: + patch_lines.append(line) + + if patch_lines: + return "\n".join(patch_lines) + return None + + +class AgenticRunner: + """Orchestrate multi-turn agent runs with energy telemetry correlation. + + Designed for agentic workloads where a single query may involve multiple + LLM turns and tool calls. Captures per-turn ``TurnTrace`` objects with + energy attribution and builds ``QueryTrace`` aggregates. + """ + + _FLUSH_INTERVAL = 50 + + def __init__( + self, + agent: Any, + dataset: Any, + telemetry_session: Any = None, + config: Optional[dict[str, Any]] = None, + event_recorder: Optional[EventRecorder] = None, + run_dir: Optional[Path] = None, + concurrency: int = 1, + agent_factory: Optional[Callable[[], Any]] = None, + query_timeout: Optional[float] = None, + ) -> None: + self._agent = agent + self._dataset = dataset + self._telemetry = telemetry_session + self._config = config or {} + self._event_recorder = ( + event_recorder if event_recorder is not None else EventRecorder() + ) + self._run_dir = run_dir + self._traces: list[QueryTrace] = [] + self._concurrency = max(1, concurrency) + self._agent_factory = agent_factory + self._query_timeout = query_timeout + self._results_lock = threading.Lock() + + async def run(self, max_queries: Optional[int] = None) -> list[QueryTrace]: + """Run the agent over the dataset, collecting traces and telemetry. + + Args: + max_queries: Maximum number of queries to process. None means all. + + Returns: + List of ``QueryTrace`` objects with energy-correlated telemetry. + """ + records = list(self._dataset.iter_records()) + total = max_queries if max_queries is not None else len(records) + records = records[:total] + model = self._config.get("model", "unknown") + + work_items = list(enumerate(records)) + + if self._concurrency <= 1: + return await self._run_sequential(work_items, model) + return await self._run_concurrent(work_items, model) + + async def _run_sequential( + self, + work_items: list[tuple[int, Any]], + model: str, + ) -> list[QueryTrace]: + """Sequential execution path.""" + for index, record in work_items: + query_id = f"q{index:04d}" + start_time = time.time() + try: + fut = self._run_single_query( + index, record, model, self._agent, self._event_recorder + ) + if self._query_timeout: + trace = await asyncio.wait_for( + fut, timeout=self._query_timeout + ) + else: + trace = await fut + except asyncio.TimeoutError: + elapsed = time.time() - start_time + LOGGER.warning( + "Query %s timed out after %.0fs (limit=%ss)", + query_id, elapsed, self._query_timeout, + ) + workload_type = getattr(record, "category", "agentic") + trace = QueryTrace( + query_id=query_id, + workload_type=str(workload_type), + query_text=record.problem, + response_text=f"Query timed out after {elapsed:.0f}s", + total_wall_clock_s=elapsed, + completed=False, + timed_out=True, + is_resolved=record.metadata.get("is_resolved"), + ) + self._traces.append(trace) + + status = ( + "TIMEOUT" if trace.timed_out + else ("OK" if trace.completed else "FAIL") + ) + LOGGER.info( + "Task %s: %s in %.1fs", + query_id, status, trace.total_wall_clock_s, + ) + + if self._run_dir: + self._save_query_artifacts(index, record, trace) + + if len(self._traces) % self._FLUSH_INTERVAL == 0: + LOGGER.debug( + "Processed %d/%d queries", + len(self._traces), len(work_items), + ) + + return self._traces + + async def _run_concurrent( + self, + work_items: list[tuple[int, Any]], + model: str, + ) -> list[QueryTrace]: + """Concurrent execution via asyncio.Semaphore + thread pool.""" + total = len(work_items) + LOGGER.info( + "Running %d queries with concurrency=%d", + total, self._concurrency, + ) + + result_slots: list[Optional[QueryTrace]] = [None] * total + semaphore = asyncio.Semaphore(self._concurrency) + loop = asyncio.get_event_loop() + + async def _process(slot: int, index: int, record: Any) -> None: + async with semaphore: + if self._agent_factory is not None: + agent = self._agent_factory() + else: + agent = copy.deepcopy(self._agent) + recorder = EventRecorder() + + query_id = f"q{index:04d}" + start_time = time.time() + + try: + fut = loop.run_in_executor( + None, + self._run_single_query_sync, + index, record, model, agent, recorder, + ) + if self._query_timeout: + trace = await asyncio.wait_for( + fut, timeout=self._query_timeout + ) + else: + trace = await fut + except asyncio.TimeoutError: + elapsed = time.time() - start_time + LOGGER.warning( + "Query %s timed out after %.0fs (limit=%ss)", + query_id, elapsed, self._query_timeout, + ) + workload_type = getattr(record, "category", "agentic") + trace = QueryTrace( + query_id=query_id, + workload_type=str(workload_type), + query_text=record.problem, + response_text=f"Query timed out after {elapsed:.0f}s", + total_wall_clock_s=elapsed, + completed=False, + timed_out=True, + is_resolved=record.metadata.get("is_resolved"), + ) + + status = ( + "TIMEOUT" if trace.timed_out + else ("OK" if trace.completed else "FAIL") + ) + LOGGER.info( + "Task %s: %s in %.1fs", + query_id, status, trace.total_wall_clock_s, + ) + + if self._run_dir: + self._save_query_artifacts(index, record, trace) + + with self._results_lock: + result_slots[slot] = trace + + tasks = [ + _process(slot, index, record) + for slot, (index, record) in enumerate(work_items) + ] + await asyncio.gather(*tasks) + + for slot_result in result_slots: + if slot_result is not None: + self._traces.append(slot_result) + + return self._traces + + def _run_single_query_sync( + self, + index: int, + record: Any, + model: str, + agent: Any, + event_recorder: EventRecorder, + ) -> QueryTrace: + """Synchronous wrapper for ``_run_single_query``.""" + return asyncio.run( + self._run_single_query(index, record, model, agent, event_recorder) + ) + + async def _run_single_query( + self, + index: int, + record: Any, + model: str, + agent: Optional[Any] = None, + event_recorder: Optional[EventRecorder] = None, + ) -> QueryTrace: + """Run a single query through the agent with telemetry capture.""" + agent = agent or self._agent + event_recorder = event_recorder or self._event_recorder + + query_id = f"q{index:04d}" + workload_type = getattr(record, "category", "agentic") + + start_time = time.time() + start_ns = time.monotonic_ns() + + event_recorder.clear() + + # Set up per-query workspace + if self._run_dir and hasattr(agent, "set_workspace"): + instance_id = record.metadata.get("instance_id", record.record_id) + slug = re.sub(r"[^a-zA-Z0-9_-]", "_", str(instance_id))[:80] + workspace = ( + self._run_dir / "artifacts" / f"q{index:04d}_{slug}" / "workspace" + ) + workspace.mkdir(parents=True, exist_ok=True) + agent.set_workspace(str(workspace)) + + # Create per-task execution environment (e.g. Docker for TerminalBench) + task_env = None + if hasattr(self._dataset, "create_task_env"): + task_env = self._dataset.create_task_env(record) + ctx = task_env if task_env is not None else nullcontext() + + response_text = "" + result_tokens: dict[str, int] = {} + + try: + with ctx: + # Forward task metadata to agent + if task_env is not None and hasattr(agent, "set_task_metadata"): + agent.set_task_metadata(record.metadata) + + # Run the agent + if hasattr(agent, "ask"): + # SystemBuilder-based agent (JarvisSystem) + result = agent.ask(record.problem) + if isinstance(result, dict): + response_text = result.get("content", "") + usage = result.get("usage", {}) + result_tokens = { + "input_tokens": usage.get("prompt_tokens", 0), + "output_tokens": usage.get("completion_tokens", 0), + "cost_usd": result.get("cost_usd", 0.0), + } + else: + response_text = str(result) + elif hasattr(agent, "run"): + # BaseAgent-style + result = agent.run(record.problem) + response_text = getattr(result, "content", str(result)) + result_tokens = { + "input_tokens": getattr(result, "input_tokens", 0) + or 0, + "output_tokens": getattr(result, "output_tokens", 0) + or 0, + "cost_usd": getattr(result, "cost_usd", 0.0) or 0.0, + } + else: + response_text = str(agent(record.problem)) + + # Run tests if task env supports it + if task_env is not None and hasattr(task_env, "run_tests"): + task_env.run_tests() + + except Exception as exc: + LOGGER.warning("Agent failed on query %s: %s", query_id, exc) + end_time = time.time() + return QueryTrace( + query_id=query_id, + workload_type=str(workload_type), + query_text=record.problem, + response_text=str(exc), + total_wall_clock_s=end_time - start_time, + completed=False, + is_resolved=record.metadata.get("is_resolved"), + ) + + end_time = time.time() + end_ns = time.monotonic_ns() + + # Collect telemetry samples for this query window + readings: list[Any] = [] + if self._telemetry is not None: + readings = self._telemetry.window(start_ns, end_ns) + + # Build turn traces from event recorder + events = event_recorder.get_events() + turns = self._build_turn_traces(events, readings) + + # Synthetic turn when EventRecorder captured nothing + in_tok = result_tokens.get("input_tokens", 0) + out_tok = result_tokens.get("output_tokens", 0) + cost = result_tokens.get("cost_usd", 0.0) + if not turns and (in_tok > 0 or out_tok > 0): + turns = [TurnTrace( + turn_index=0, + input_tokens=in_tok, + output_tokens=out_tok, + wall_clock_s=end_time - start_time, + cost_usd=cost if cost else None, + )] + + # Backfill tokens from result when turns have zero tokens + if turns and in_tok > 0 and out_tok > 0: + total_turn_in = sum(t.input_tokens for t in turns) + total_turn_out = sum(t.output_tokens for t in turns) + if total_turn_in == 0 and total_turn_out == 0: + turns[0].input_tokens = in_tok + turns[0].output_tokens = out_tok + turns[0].wall_clock_s = ( + turns[0].wall_clock_s or (end_time - start_time) + ) + if cost and turns[0].cost_usd is None: + turns[0].cost_usd = cost + + # Compute cost for turns missing it + for turn in turns: + if turn.cost_usd is None and ( + turn.input_tokens > 0 or turn.output_tokens > 0 + ): + from openjarvis.evals.core.pricing import compute_turn_cost + turn.cost_usd = compute_turn_cost( + model, turn.input_tokens, turn.output_tokens + ) + + # Query-level energy from telemetry window + query_gpu_energy = _compute_energy_delta(readings, "gpu_energy_j") + query_cpu_energy = _compute_energy_delta(readings, "cpu_energy_j") + query_gpu_power_avg = _compute_power_avg(readings, "gpu_power_w") + query_cpu_power_avg = _compute_power_avg(readings, "cpu_power_w") + + trace = QueryTrace( + query_id=query_id, + workload_type=str(workload_type), + query_text=record.problem, + response_text=response_text, + turns=turns, + total_wall_clock_s=end_time - start_time, + completed=True, + query_gpu_energy_joules=query_gpu_energy, + query_cpu_energy_joules=query_cpu_energy, + query_gpu_power_avg_watts=query_gpu_power_avg, + query_cpu_power_avg_watts=query_cpu_power_avg, + is_resolved=record.metadata.get("is_resolved"), + ) + + # Correlate energy with trace + trace = self._correlate_energy(trace, readings) + + return trace + + def _build_turn_traces( + self, + events: list[AgentEvent], + readings: list[Any], + ) -> list[TurnTrace]: + """Build TurnTrace objects from recorded events.""" + turns: list[TurnTrace] = [] + current_turn_index = 0 + current_turn_start: Optional[float] = None + current_tools: list[str] = [] + current_tool_latencies: dict[str, float] = {} + tool_start_times: dict[str, float] = {} + input_tokens = 0 + output_tokens = 0 + + for event in events: + etype = event.event_type + + if etype == EventType.LM_INFERENCE_START: + current_turn_start = event.timestamp + + elif etype == EventType.LM_INFERENCE_END: + wall_clock = 0.0 + if current_turn_start is not None: + wall_clock = event.timestamp - current_turn_start + + input_tokens = event.metadata.get("prompt_tokens", 0) + output_tokens = event.metadata.get("completion_tokens", 0) + + turn = TurnTrace( + turn_index=current_turn_index, + input_tokens=input_tokens, + output_tokens=output_tokens, + tools_called=list(current_tools), + tool_latencies_s=dict(current_tool_latencies), + wall_clock_s=wall_clock, + ) + turns.append(turn) + + current_turn_index += 1 + current_turn_start = None + current_tools = [] + current_tool_latencies = {} + input_tokens = 0 + output_tokens = 0 + + elif etype == EventType.TOOL_CALL_START: + tool_name = event.metadata.get("tool", "unknown") + tool_start_times[tool_name] = event.timestamp + + elif etype == EventType.TOOL_CALL_END: + tool_name = event.metadata.get("tool", "unknown") + current_tools.append(tool_name) + start_ts = tool_start_times.pop(tool_name, None) + if start_ts is not None: + current_tool_latencies[tool_name] = ( + event.timestamp - start_ts + ) + + # Synthetic turn if events but no complete LM_START/END pair + if not turns and events: + turns.append( + TurnTrace( + turn_index=0, + tools_called=current_tools, + tool_latencies_s=current_tool_latencies, + ) + ) + + return turns + + def _correlate_energy( + self, + trace: QueryTrace, + readings: list[Any], + ) -> QueryTrace: + """Distribute energy across turns proportionally by wall clock time. + + Only runs when per-turn energy was not already populated from events. + """ + if not readings or not trace.turns: + return trace + + has_turn_energy = any( + t.gpu_energy_joules is not None for t in trace.turns + ) + if has_turn_energy: + return trace + + total_gpu_energy = _compute_energy_delta(readings, "gpu_energy_j") + total_cpu_energy = _compute_energy_delta(readings, "cpu_energy_j") + + total_wall = sum(t.wall_clock_s for t in trace.turns) + if total_wall > 0: + for turn in trace.turns: + fraction = turn.wall_clock_s / total_wall + if total_gpu_energy is not None: + turn.gpu_energy_joules = total_gpu_energy * fraction + if total_cpu_energy is not None: + turn.cpu_energy_joules = total_cpu_energy * fraction + + return trace + + def _save_query_artifacts( + self, + index: int, + record: Any, + trace: QueryTrace, + ) -> None: + """Save per-query artifacts to structured subdirectories.""" + assert self._run_dir is not None + instance_id = record.metadata.get("instance_id", record.record_id) + slug = re.sub(r"[^a-zA-Z0-9_-]", "_", str(instance_id))[:80] + query_dir = self._run_dir / "artifacts" / f"q{index:04d}_{slug}" + query_dir.mkdir(parents=True, exist_ok=True) + + (query_dir / "response.txt").write_text( + trace.response_text or "", encoding="utf-8" + ) + + meta: dict[str, object] = { + "query_id": trace.query_id, + "instance_id": str(instance_id), + "completed": trace.completed, + "timed_out": trace.timed_out, + "wall_clock_s": trace.total_wall_clock_s, + "num_turns": trace.num_turns, + } + for key in ( + "repo", "base_commit", "dataset_name", + "is_resolved", "test_results", + ): + val = record.metadata.get(key) + if val is not None: + meta[key] = val + (query_dir / "metadata.json").write_text( + json.dumps(meta, indent=2, default=str), encoding="utf-8" + ) + + patch = _extract_patch(trace.response_text or "") + if patch: + (query_dir / "patch.diff").write_text(patch, encoding="utf-8") + + @property + def traces(self) -> list[QueryTrace]: + """Return collected traces.""" + return list(self._traces) + + +__all__ = ["AgenticRunner"] diff --git a/src/openjarvis/evals/core/config.py b/src/openjarvis/evals/core/config.py index 3c1329ba..962cb280 100644 --- a/src/openjarvis/evals/core/config.py +++ b/src/openjarvis/evals/core/config.py @@ -39,6 +39,8 @@ KNOWN_BENCHMARKS = { "simpleqa", "wildchat", "ipw", "gaia", "frames", "swebench", "swefficiency", "terminalbench", "terminalbench-native", + "email_triage", "morning_brief", "research_mining", + "knowledge_base", "coding_task", } diff --git a/src/openjarvis/evals/core/dataset.py b/src/openjarvis/evals/core/dataset.py index c3a31887..883fe113 100644 --- a/src/openjarvis/evals/core/dataset.py +++ b/src/openjarvis/evals/core/dataset.py @@ -3,7 +3,8 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Iterable, Optional +from contextlib import AbstractContextManager +from typing import Iterable, List, Optional from openjarvis.evals.core.types import EvalRecord @@ -32,5 +33,15 @@ class DatasetProvider(ABC): def size(self) -> int: """Return the number of loaded records.""" + def create_task_env( + self, record: EvalRecord, + ) -> Optional[AbstractContextManager]: + """Return a task environment context manager, or None.""" + return None + + def verify_requirements(self) -> List[str]: + """Return list of unsatisfied requirements, or empty list.""" + return [] + __all__ = ["DatasetProvider"] diff --git a/src/openjarvis/evals/core/event_recorder.py b/src/openjarvis/evals/core/event_recorder.py new file mode 100644 index 00000000..aaf169ad --- /dev/null +++ b/src/openjarvis/evals/core/event_recorder.py @@ -0,0 +1,98 @@ +"""Event recording system for agent execution telemetry.""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List + + +class EventType(str, Enum): + """Supported event types for agent telemetry.""" + + LM_INFERENCE_START = "lm_inference_start" + LM_INFERENCE_END = "lm_inference_end" + TOOL_CALL_START = "tool_call_start" + TOOL_CALL_END = "tool_call_end" + PREFILL_START = "prefill_start" + PREFILL_END = "prefill_end" + DECODE_START = "decode_start" + DECODE_END = "decode_end" + # Submodel call events (for MCP tools that call inference servers) + SUBMODEL_CALL_START = "submodel_call_start" + SUBMODEL_CALL_END = "submodel_call_end" + + +@dataclass +class AgentEvent: + """Single event recorded during agent execution.""" + + event_type: str + timestamp: float # Unix timestamp from time.time() + metadata: Dict[str, Any] = field(default_factory=dict) + + def __repr__(self) -> str: + return ( + f"AgentEvent({self.event_type!r}, " + f"ts={self.timestamp:.3f}, meta={self.metadata})" + ) + + +class EventRecorder: + """Thread-safe recorder for agent execution events. + + Records events with timestamps for later correlation with energy telemetry. + All operations are thread-safe for use in concurrent agent execution. + + Example: + >>> recorder = EventRecorder() + >>> recorder.record('tool_call_start', tool='calculator') + >>> recorder.record('tool_call_end', tool='calculator') + >>> events = recorder.get_events() + >>> len(events) + 2 + """ + + def __init__(self) -> None: + """Initialize the event recorder.""" + self._events: List[AgentEvent] = [] + self._lock = threading.Lock() + + def record(self, event_type: str, **metadata: Any) -> None: + """Record an event with current timestamp. + + Args: + event_type: Type of event (e.g., 'tool_call_start', 'lm_inference_end') + **metadata: Additional metadata to attach to the event + """ + event = AgentEvent( + event_type=event_type, + timestamp=time.time(), + metadata=metadata, + ) + with self._lock: + self._events.append(event) + + def get_events(self) -> List[AgentEvent]: + """Return a copy of all recorded events. + + Returns: + List of all recorded events in chronological order. + """ + with self._lock: + return list(self._events) + + def clear(self) -> None: + """Clear all recorded events.""" + with self._lock: + self._events.clear() + + def __len__(self) -> int: + """Return the number of recorded events.""" + with self._lock: + return len(self._events) + + +__all__ = ["AgentEvent", "EventRecorder", "EventType"] diff --git a/src/openjarvis/evals/core/export.py b/src/openjarvis/evals/core/export.py new file mode 100644 index 00000000..dd22ac1f --- /dev/null +++ b/src/openjarvis/evals/core/export.py @@ -0,0 +1,223 @@ +"""Export functions for agentic run traces and profiling records.""" + +from __future__ import annotations + +import json +import statistics +import time +from pathlib import Path +from typing import Any, Optional, Sequence + +from openjarvis.evals.core.trace import QueryTrace + + +def _agg_stats(values: Sequence[Optional[float]]) -> dict[str, Optional[float]]: + """Return {avg, median, min, max, std} filtering None values.""" + clean = [v for v in values if v is not None] + if not clean: + return {"avg": None, "median": None, "min": None, "max": None, "std": None} + return { + "avg": statistics.mean(clean), + "median": statistics.median(clean), + "min": min(clean), + "max": max(clean), + "std": statistics.stdev(clean) if len(clean) > 1 else 0.0, + } + + +def export_jsonl(traces: list[QueryTrace], path: Path) -> Path: + """Export traces as JSONL (one JSON object per line). + + Args: + traces: List of QueryTrace objects to export. + path: Output file path. Parent directories are created if needed. + + Returns: + The path to the written file. + """ + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + for trace in traces: + f.write(json.dumps(trace.to_dict()) + "\n") + return path + + +def export_hf_dataset(traces: list[QueryTrace], path: Path) -> Path: + """Export traces as a HuggingFace Arrow dataset. + + Args: + traces: List of QueryTrace objects to export. + path: Output directory for the Arrow dataset. + + Returns: + The path to the saved dataset directory. + + Raises: + ImportError: If the ``datasets`` package is not installed. + """ + ds = QueryTrace.to_hf_dataset(traces) + path.parent.mkdir(parents=True, exist_ok=True) + ds.save_to_disk(str(path)) + return path + + +def export_summary_json( + traces: list[QueryTrace], + config: dict[str, Any], + path: Path, +) -> Path: + """Export aggregate summary as JSON. + + Args: + traces: List of QueryTrace objects. + config: Run configuration dictionary. + path: Output file path. + + Returns: + The path to the written file. + """ + total_queries = len(traces) + completed = sum(1 for t in traces if t.completed) + total_turns = sum(t.num_turns for t in traces) + total_tool_calls = sum(t.total_tool_calls for t in traces) + + total_input_tokens = sum(t.total_input_tokens for t in traces) + total_output_tokens = sum(t.total_output_tokens for t in traces) + total_wall_clock_s = sum(t.total_wall_clock_s for t in traces) + + gpu_energy_values = [ + t.total_gpu_energy_joules for t in traces + if t.total_gpu_energy_joules is not None + ] + total_gpu_energy = sum(gpu_energy_values) if gpu_energy_values else None + + cpu_energy_values: list[float] = [] + for trace in traces: + cpu_vals = [ + turn.cpu_energy_joules for turn in trace.turns + if turn.cpu_energy_joules is not None + ] + if cpu_vals: + cpu_energy_values.append(sum(cpu_vals)) + total_cpu_energy = sum(cpu_energy_values) if cpu_energy_values else None + + resolved = sum(1 for t in traces if t.is_resolved is True) + unresolved = sum(1 for t in traces if t.is_resolved is False) + + cost_values = [ + t.total_cost_usd for t in traces + if t.total_cost_usd is not None + ] + total_cost = sum(cost_values) if cost_values else None + + avg_turns = total_turns / total_queries if total_queries > 0 else 0 + avg_wall_clock = total_wall_clock_s / total_queries if total_queries > 0 else 0 + avg_gpu_energy = ( + total_gpu_energy / total_queries + if total_gpu_energy is not None and total_queries > 0 + else None + ) + + stats = { + "wall_clock_s": _agg_stats([t.total_wall_clock_s for t in traces]), + "gpu_energy_joules": _agg_stats( + [t.total_gpu_energy_joules for t in traces], + ), + "cpu_energy_joules": _agg_stats( + [t.total_cpu_energy_joules for t in traces], + ), + "gpu_power_watts": _agg_stats( + [t.avg_gpu_power_watts for t in traces], + ), + "cpu_power_watts": _agg_stats( + [t.avg_cpu_power_watts for t in traces], + ), + "input_tokens": _agg_stats( + [float(t.total_input_tokens) for t in traces], + ), + "output_tokens": _agg_stats( + [float(t.total_output_tokens) for t in traces], + ), + "total_tokens": _agg_stats( + [float(t.total_tokens) for t in traces], + ), + "throughput_tokens_per_sec": _agg_stats( + [t.throughput_tokens_per_sec for t in traces], + ), + "energy_per_token_joules": _agg_stats( + [t.energy_per_token_joules for t in traces], + ), + "cost_usd": _agg_stats([t.total_cost_usd for t in traces]), + "turns": _agg_stats([float(t.num_turns) for t in traces]), + "tool_calls": _agg_stats( + [float(t.total_tool_calls) for t in traces], + ), + } + + summary = { + "generated_at": time.time(), + "config": config, + "totals": { + "queries": total_queries, + "completed": completed, + "resolved": resolved, + "unresolved": unresolved, + "turns": total_turns, + "tool_calls": total_tool_calls, + "input_tokens": total_input_tokens, + "output_tokens": total_output_tokens, + "total_tokens": total_input_tokens + total_output_tokens, + "wall_clock_s": total_wall_clock_s, + "gpu_energy_joules": total_gpu_energy, + "cpu_energy_joules": total_cpu_energy, + "cost_usd": total_cost, + }, + "averages": { + "turns_per_query": avg_turns, + "wall_clock_per_query_s": avg_wall_clock, + "gpu_energy_per_query_joules": avg_gpu_energy, + }, + "statistics": stats, + } + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(summary, indent=2, default=str)) + return path + + +def export_artifacts_manifest(run_dir: Path) -> Optional[Path]: + """Scan ``{run_dir}/artifacts/`` and write ``artifacts_manifest.json``. + + The manifest lists every per-query artifact directory together with + the files it contains, making it easy for downstream tools to discover + what was produced without walking the directory tree themselves. + + Returns: + The manifest path, or ``None`` if there is no artifacts directory. + """ + artifacts_root = run_dir / "artifacts" + if not artifacts_root.is_dir(): + return None + + entries: list[dict[str, object]] = [] + for query_dir in sorted(artifacts_root.iterdir()): + if not query_dir.is_dir(): + continue + files = sorted( + str(p.relative_to(artifacts_root)) + for p in query_dir.rglob("*") + if p.is_file() + ) + entries.append({"query_dir": query_dir.name, "files": files}) + + manifest_path = run_dir / "artifacts_manifest.json" + manifest_path.write_text(json.dumps(entries, indent=2), encoding="utf-8") + return manifest_path + + +__all__ = [ + "export_jsonl", + "export_hf_dataset", + "export_summary_json", + "export_artifacts_manifest", +] diff --git a/src/openjarvis/evals/core/pricing.py b/src/openjarvis/evals/core/pricing.py new file mode 100644 index 00000000..ce4199b4 --- /dev/null +++ b/src/openjarvis/evals/core/pricing.py @@ -0,0 +1,17 @@ +"""Cost computation for agentic eval runs — wraps engine/cloud.py pricing.""" + +from __future__ import annotations + +from openjarvis.engine.cloud import PRICING, estimate_cost + + +def compute_turn_cost(model: str, input_tokens: int, output_tokens: int) -> float: + """Compute USD cost for a single agent turn. + + Delegates to the canonical ``estimate_cost()`` from ``engine/cloud.py``. + Returns 0.0 for models not in the pricing table (e.g. local models). + """ + return estimate_cost(model, input_tokens, output_tokens) + + +__all__ = ["PRICING", "compute_turn_cost", "estimate_cost"] diff --git a/src/openjarvis/evals/core/trace.py b/src/openjarvis/evals/core/trace.py new file mode 100644 index 00000000..9377c51e --- /dev/null +++ b/src/openjarvis/evals/core/trace.py @@ -0,0 +1,261 @@ +"""Trace data model for agentic eval runs.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + + +@dataclass +class TurnTrace: + """Per-turn telemetry data.""" + + turn_index: int + input_tokens: int = 0 + output_tokens: int = 0 + tool_result_tokens: int = 0 + tools_called: List[str] = field(default_factory=list) + tool_latencies_s: Dict[str, float] = field(default_factory=dict) + wall_clock_s: float = 0.0 + error: Optional[str] = None + # Energy and cost fields + gpu_energy_joules: Optional[float] = None + cpu_energy_joules: Optional[float] = None + gpu_power_avg_watts: Optional[float] = None + cpu_power_avg_watts: Optional[float] = None + cost_usd: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "turn_index": self.turn_index, + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "tool_result_tokens": self.tool_result_tokens, + "tools_called": list(self.tools_called), + "tool_latencies_s": dict(self.tool_latencies_s), + "wall_clock_s": self.wall_clock_s, + "error": self.error, + "gpu_energy_joules": self.gpu_energy_joules, + "cpu_energy_joules": self.cpu_energy_joules, + "gpu_power_avg_watts": self.gpu_power_avg_watts, + "cpu_power_avg_watts": self.cpu_power_avg_watts, + "cost_usd": self.cost_usd, + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> TurnTrace: + return cls( + turn_index=d["turn_index"], + input_tokens=d.get("input_tokens", 0), + output_tokens=d.get("output_tokens", 0), + tool_result_tokens=d.get("tool_result_tokens", 0), + tools_called=d.get("tools_called", []), + tool_latencies_s=d.get("tool_latencies_s", {}), + wall_clock_s=d.get("wall_clock_s", 0.0), + error=d.get("error"), + gpu_energy_joules=d.get("gpu_energy_joules"), + cpu_energy_joules=d.get("cpu_energy_joules"), + gpu_power_avg_watts=d.get("gpu_power_avg_watts"), + cpu_power_avg_watts=d.get("cpu_power_avg_watts"), + cost_usd=d.get("cost_usd"), + ) + + +@dataclass +class QueryTrace: + """Per-query aggregate telemetry.""" + + query_id: str + workload_type: str + query_text: str = "" + response_text: str = "" + turns: List[TurnTrace] = field(default_factory=list) + total_wall_clock_s: float = 0.0 + completed: bool = False + timed_out: bool = False + # Query-level energy fields (populated even when turns are empty) + query_gpu_energy_joules: Optional[float] = None + query_cpu_energy_joules: Optional[float] = None + query_gpu_power_avg_watts: Optional[float] = None + query_cpu_power_avg_watts: Optional[float] = None + is_resolved: Optional[bool] = None + + @property + def num_turns(self) -> int: + return len(self.turns) + + @property + def total_input_tokens(self) -> int: + return sum(t.input_tokens for t in self.turns) + + @property + def total_output_tokens(self) -> int: + return sum(t.output_tokens for t in self.turns) + + @property + def tool_call_count(self) -> int: + return sum(len(t.tools_called) for t in self.turns) + + @property + def total_tool_calls(self) -> int: + return self.tool_call_count + + @property + def total_gpu_energy_joules(self) -> Optional[float]: + values = [ + t.gpu_energy_joules for t in self.turns + if t.gpu_energy_joules is not None + ] + if values: + return sum(values) + return self.query_gpu_energy_joules + + @property + def total_cpu_energy_joules(self) -> Optional[float]: + values = [ + t.cpu_energy_joules for t in self.turns + if t.cpu_energy_joules is not None + ] + if values: + return sum(values) + return self.query_cpu_energy_joules + + @property + def total_cost_usd(self) -> Optional[float]: + values = [t.cost_usd for t in self.turns if t.cost_usd is not None] + return sum(values) if values else None + + @property + def total_tokens(self) -> int: + """Total tokens (input + output) across all turns.""" + return self.total_input_tokens + self.total_output_tokens + + @property + def avg_gpu_power_watts(self) -> Optional[float]: + """Mean GPU power across turns; falls back to query-level power.""" + values = [ + t.gpu_power_avg_watts for t in self.turns + if t.gpu_power_avg_watts is not None + ] + if values: + return sum(values) / len(values) + return self.query_gpu_power_avg_watts + + @property + def avg_cpu_power_watts(self) -> Optional[float]: + """Mean CPU power across turns; falls back to query-level power.""" + values = [ + t.cpu_power_avg_watts for t in self.turns + if t.cpu_power_avg_watts is not None + ] + if values: + return sum(values) / len(values) + return self.query_cpu_power_avg_watts + + @property + def throughput_tokens_per_sec(self) -> Optional[float]: + """Output tokens per second; None if zero tokens or zero time.""" + if self.total_output_tokens > 0 and self.total_wall_clock_s > 0: + return self.total_output_tokens / self.total_wall_clock_s + return None + + @property + def energy_per_token_joules(self) -> Optional[float]: + """GPU energy per output token; None if no energy data or zero tokens.""" + gpu_energy = self.total_gpu_energy_joules + if gpu_energy is not None and gpu_energy > 0 and self.total_output_tokens > 0: + return gpu_energy / self.total_output_tokens + return None + + def to_dict(self) -> Dict[str, Any]: + return { + "query_id": self.query_id, + "workload_type": self.workload_type, + "query_text": self.query_text, + "response_text": self.response_text, + "turns": [t.to_dict() for t in self.turns], + "total_wall_clock_s": self.total_wall_clock_s, + "completed": self.completed, + "timed_out": self.timed_out, + "query_gpu_energy_joules": self.query_gpu_energy_joules, + "query_cpu_energy_joules": self.query_cpu_energy_joules, + "query_gpu_power_avg_watts": self.query_gpu_power_avg_watts, + "query_cpu_power_avg_watts": self.query_cpu_power_avg_watts, + "is_resolved": self.is_resolved, + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> QueryTrace: + return cls( + query_id=d["query_id"], + workload_type=d["workload_type"], + query_text=d.get("query_text", ""), + response_text=d.get("response_text", ""), + turns=[TurnTrace.from_dict(t) for t in d.get("turns", [])], + total_wall_clock_s=d.get("total_wall_clock_s", 0.0), + completed=d.get("completed", False), + timed_out=d.get("timed_out", False), + query_gpu_energy_joules=d.get("query_gpu_energy_joules"), + query_cpu_energy_joules=d.get("query_cpu_energy_joules"), + query_gpu_power_avg_watts=d.get("query_gpu_power_avg_watts"), + query_cpu_power_avg_watts=d.get("query_cpu_power_avg_watts"), + is_resolved=d.get("is_resolved"), + ) + + def save_jsonl(self, path: Path) -> None: + """Append this trace as a JSONL line.""" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a") as f: + f.write(json.dumps(self.to_dict()) + "\n") + + @classmethod + def load_jsonl(cls, path: Path) -> List[QueryTrace]: + """Load traces from a JSONL file.""" + traces = [] + with open(path) as f: + for line in f: + line = line.strip() + if line: + traces.append(cls.from_dict(json.loads(line))) + return traces + + @staticmethod + def to_hf_dataset(traces: List[QueryTrace]) -> Any: + """Convert a list of QueryTrace objects to a HuggingFace Dataset. + + Returns: + A datasets.Dataset with one row per trace. + """ + from datasets import Dataset + + rows = [] + for trace in traces: + rows.append({ + "query_id": trace.query_id, + "workload_type": trace.workload_type, + "query_text": trace.query_text, + "response_text": trace.response_text, + "num_turns": trace.num_turns, + "total_input_tokens": trace.total_input_tokens, + "total_output_tokens": trace.total_output_tokens, + "total_tool_calls": trace.total_tool_calls, + "total_wall_clock_s": trace.total_wall_clock_s, + "total_gpu_energy_joules": trace.total_gpu_energy_joules, + "total_cpu_energy_joules": trace.total_cpu_energy_joules, + "total_tokens": trace.total_tokens, + "total_cost_usd": trace.total_cost_usd, + "avg_gpu_power_watts": trace.avg_gpu_power_watts, + "avg_cpu_power_watts": trace.avg_cpu_power_watts, + "throughput_tokens_per_sec": trace.throughput_tokens_per_sec, + "energy_per_token_joules": trace.energy_per_token_joules, + "completed": trace.completed, + "timed_out": trace.timed_out, + "is_resolved": trace.is_resolved, + "trace_json": json.dumps(trace.to_dict()), + }) + return Dataset.from_list(rows) + + +__all__ = ["TurnTrace", "QueryTrace"] diff --git a/src/openjarvis/evals/datasets/coding_task.py b/src/openjarvis/evals/datasets/coding_task.py new file mode 100644 index 00000000..3679e5d1 --- /dev/null +++ b/src/openjarvis/evals/datasets/coding_task.py @@ -0,0 +1,282 @@ +"""Coding task benchmark dataset. + +Standalone function-level coding problems with test cases for evaluating +code generation accuracy. +""" + +from __future__ import annotations + +import random +from typing import Iterable, List, Optional + +from openjarvis.evals.core.dataset import DatasetProvider +from openjarvis.evals.core.types import EvalRecord + +_PROMPT_TEMPLATE = """Write a Python function that solves the following problem. Return ONLY the function definition, no explanations. + +{spec} + +Function signature: {signature} + +Examples: +{examples}""" + +_TASKS = [ + { + "spec": "Given a list of integers, return a new list containing only the elements that appear exactly once, in their original order.", + "signature": "def unique_elements(lst: list[int]) -> list[int]", + "examples": "unique_elements([1, 2, 3, 2, 1, 4]) -> [3, 4]\nunique_elements([1, 1, 1]) -> []\nunique_elements([5]) -> [5]", + "test_cases": "assert unique_elements([1, 2, 3, 2, 1, 4]) == [3, 4]\nassert unique_elements([1, 1, 1]) == []\nassert unique_elements([5]) == [5]\nassert unique_elements([]) == []\nassert unique_elements([1, 2, 3]) == [1, 2, 3]", + "reference": "def unique_elements(lst):\n from collections import Counter\n counts = Counter(lst)\n return [x for x in lst if counts[x] == 1]", + }, + { + "spec": "Flatten a nested list of arbitrary depth into a single flat list.", + "signature": "def flatten(nested: list) -> list", + "examples": "flatten([1, [2, [3, 4], 5]]) -> [1, 2, 3, 4, 5]\nflatten([[1, 2], [3, [4]]]) -> [1, 2, 3, 4]\nflatten([]) -> []", + "test_cases": "assert flatten([1, [2, [3, 4], 5]]) == [1, 2, 3, 4, 5]\nassert flatten([[1, 2], [3, [4]]]) == [1, 2, 3, 4]\nassert flatten([]) == []\nassert flatten([1, 2, 3]) == [1, 2, 3]\nassert flatten([[[1]]]) == [1]", + "reference": "def flatten(nested):\n result = []\n for item in nested:\n if isinstance(item, list):\n result.extend(flatten(item))\n else:\n result.append(item)\n return result", + }, + { + "spec": "Given a string, return the longest substring without repeating characters.", + "signature": "def longest_unique_substring(s: str) -> str", + "examples": 'longest_unique_substring("abcabcbb") -> "abc"\nlongest_unique_substring("bbbbb") -> "b"\nlongest_unique_substring("pwwkew") -> "wke"', + "test_cases": 'assert longest_unique_substring("abcabcbb") == "abc"\nassert longest_unique_substring("bbbbb") == "b"\nassert longest_unique_substring("pwwkew") == "wke"\nassert longest_unique_substring("") == ""\nassert longest_unique_substring("abcdef") == "abcdef"', + "reference": "def longest_unique_substring(s):\n start = 0\n best = ''\n seen = {}\n for i, c in enumerate(s):\n if c in seen and seen[c] >= start:\n start = seen[c] + 1\n seen[c] = i\n if i - start + 1 > len(best):\n best = s[start:i+1]\n return best", + }, + { + "spec": "Implement a function that converts a Roman numeral string to an integer.", + "signature": "def roman_to_int(s: str) -> int", + "examples": 'roman_to_int("III") -> 3\nroman_to_int("IV") -> 4\nroman_to_int("MCMXCIV") -> 1994', + "test_cases": 'assert roman_to_int("III") == 3\nassert roman_to_int("IV") == 4\nassert roman_to_int("IX") == 9\nassert roman_to_int("MCMXCIV") == 1994\nassert roman_to_int("LVIII") == 58', + "reference": "def roman_to_int(s):\n vals = {'I':1,'V':5,'X':10,'L':50,'C':100,'D':500,'M':1000}\n total = 0\n for i in range(len(s)):\n if i+1 < len(s) and vals[s[i]] < vals[s[i+1]]:\n total -= vals[s[i]]\n else:\n total += vals[s[i]]\n return total", + }, + { + "spec": "Given a list of intervals [start, end], merge all overlapping intervals and return the result sorted by start time.", + "signature": "def merge_intervals(intervals: list[list[int]]) -> list[list[int]]", + "examples": "merge_intervals([[1,3],[2,6],[8,10],[15,18]]) -> [[1,6],[8,10],[15,18]]\nmerge_intervals([[1,4],[4,5]]) -> [[1,5]]", + "test_cases": "assert merge_intervals([[1,3],[2,6],[8,10],[15,18]]) == [[1,6],[8,10],[15,18]]\nassert merge_intervals([[1,4],[4,5]]) == [[1,5]]\nassert merge_intervals([[1,4],[0,4]]) == [[0,4]]\nassert merge_intervals([]) == []\nassert merge_intervals([[1,2]]) == [[1,2]]", + "reference": "def merge_intervals(intervals):\n if not intervals:\n return []\n intervals.sort()\n merged = [intervals[0]]\n for s, e in intervals[1:]:\n if s <= merged[-1][1]:\n merged[-1][1] = max(merged[-1][1], e)\n else:\n merged.append([s, e])\n return merged", + }, + { + "spec": "Implement a function that checks if a string of brackets is balanced. Valid brackets are (), [], {}.", + "signature": "def is_balanced(s: str) -> bool", + "examples": 'is_balanced("()[]{}") -> True\nis_balanced("([)]") -> False\nis_balanced("{[]}") -> True', + "test_cases": 'assert is_balanced("()[]{}") == True\nassert is_balanced("([)]") == False\nassert is_balanced("{[]}") == True\nassert is_balanced("") == True\nassert is_balanced("(") == False\nassert is_balanced("([{}])") == True', + "reference": "def is_balanced(s):\n stack = []\n pairs = {')':'(', ']':'[', '}':'{'}\n for c in s:\n if c in '([{':\n stack.append(c)\n elif c in pairs:\n if not stack or stack[-1] != pairs[c]:\n return False\n stack.pop()\n return len(stack) == 0", + }, + { + "spec": "Given a matrix (list of lists), rotate it 90 degrees clockwise in-place and return it.", + "signature": "def rotate_matrix(matrix: list[list[int]]) -> list[list[int]]", + "examples": "rotate_matrix([[1,2,3],[4,5,6],[7,8,9]]) -> [[7,4,1],[8,5,2],[9,6,3]]", + "test_cases": "assert rotate_matrix([[1,2,3],[4,5,6],[7,8,9]]) == [[7,4,1],[8,5,2],[9,6,3]]\nassert rotate_matrix([[1]]) == [[1]]\nassert rotate_matrix([[1,2],[3,4]]) == [[3,1],[4,2]]", + "reference": "def rotate_matrix(matrix):\n n = len(matrix)\n for i in range(n):\n for j in range(i, n):\n matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]\n for row in matrix:\n row.reverse()\n return matrix", + }, + { + "spec": "Implement a function that computes the nth Fibonacci number using memoization. F(0)=0, F(1)=1.", + "signature": "def fibonacci(n: int) -> int", + "examples": "fibonacci(0) -> 0\nfibonacci(1) -> 1\nfibonacci(10) -> 55\nfibonacci(20) -> 6765", + "test_cases": "assert fibonacci(0) == 0\nassert fibonacci(1) == 1\nassert fibonacci(10) == 55\nassert fibonacci(20) == 6765\nassert fibonacci(30) == 832040", + "reference": "def fibonacci(n, memo={}):\n if n in memo:\n return memo[n]\n if n <= 1:\n return n\n memo[n] = fibonacci(n-1, memo) + fibonacci(n-2, memo)\n return memo[n]", + }, + { + "spec": "Given a list of words, group them by anagrams. Return a list of groups, where each group is a sorted list of words.", + "signature": "def group_anagrams(words: list[str]) -> list[list[str]]", + "examples": 'group_anagrams(["eat","tea","tan","ate","nat","bat"]) -> [["ate","eat","tea"],["nat","tan"],["bat"]]', + "test_cases": 'result = group_anagrams(["eat","tea","tan","ate","nat","bat"])\nresult = [sorted(g) for g in result]\nresult.sort()\nassert result == [["bat"], ["ate","eat","tea"], ["nat","tan"]] or result == [["ate","eat","tea"], ["bat"], ["nat","tan"]]\nassert group_anagrams([""]) == [[""]]\nassert group_anagrams(["a"]) == [["a"]]', + "reference": "def group_anagrams(words):\n from collections import defaultdict\n groups = defaultdict(list)\n for w in words:\n key = ''.join(sorted(w))\n groups[key].append(w)\n return [sorted(g) for g in groups.values()]", + }, + { + "spec": "Implement binary search on a sorted list. Return the index of the target if found, otherwise return -1.", + "signature": "def binary_search(arr: list[int], target: int) -> int", + "examples": "binary_search([1, 3, 5, 7, 9], 5) -> 2\nbinary_search([1, 3, 5, 7, 9], 4) -> -1\nbinary_search([], 1) -> -1", + "test_cases": "assert binary_search([1, 3, 5, 7, 9], 5) == 2\nassert binary_search([1, 3, 5, 7, 9], 4) == -1\nassert binary_search([], 1) == -1\nassert binary_search([1], 1) == 0\nassert binary_search([1, 3, 5, 7, 9], 1) == 0\nassert binary_search([1, 3, 5, 7, 9], 9) == 4", + "reference": "def binary_search(arr, target):\n lo, hi = 0, len(arr) - 1\n while lo <= hi:\n mid = (lo + hi) // 2\n if arr[mid] == target:\n return mid\n elif arr[mid] < target:\n lo = mid + 1\n else:\n hi = mid - 1\n return -1", + }, + { + "spec": "Given a string containing digits, return all possible valid IP addresses that can be formed by inserting dots.", + "signature": "def restore_ip_addresses(s: str) -> list[str]", + "examples": 'restore_ip_addresses("25525511135") -> ["255.255.11.135","255.255.111.35"]\nrestore_ip_addresses("0000") -> ["0.0.0.0"]', + "test_cases": 'assert sorted(restore_ip_addresses("25525511135")) == ["255.255.11.135","255.255.111.35"]\nassert restore_ip_addresses("0000") == ["0.0.0.0"]\nassert restore_ip_addresses("1111") == ["1.1.1.1"]\nassert restore_ip_addresses("010010") == ["0.10.0.10","0.100.1.0"]', + "reference": "def restore_ip_addresses(s):\n result = []\n def bt(start, parts):\n if len(parts) == 4:\n if start == len(s):\n result.append('.'.join(parts))\n return\n for end in range(start+1, min(start+4, len(s)+1)):\n seg = s[start:end]\n if (seg[0] == '0' and len(seg) > 1) or int(seg) > 255:\n continue\n bt(end, parts + [seg])\n bt(0, [])\n return result", + }, + { + "spec": "Implement a function that computes the longest common subsequence of two strings.", + "signature": "def lcs(s1: str, s2: str) -> str", + "examples": 'lcs("abcde", "ace") -> "ace"\nlcs("abc", "abc") -> "abc"\nlcs("abc", "def") -> ""', + "test_cases": 'assert lcs("abcde", "ace") == "ace"\nassert lcs("abc", "abc") == "abc"\nassert lcs("abc", "def") == ""\nassert lcs("", "abc") == ""\nassert lcs("abcd", "abdc") in ("abc", "abd")', + "reference": "def lcs(s1, s2):\n m, n = len(s1), len(s2)\n dp = [[''] * (n+1) for _ in range(m+1)]\n for i in range(1, m+1):\n for j in range(1, n+1):\n if s1[i-1] == s2[j-1]:\n dp[i][j] = dp[i-1][j-1] + s1[i-1]\n else:\n dp[i][j] = max(dp[i-1][j], dp[i][j-1], key=len)\n return dp[m][n]", + }, + { + "spec": "Given a list of integers and a target sum, return all unique pairs that sum to the target. Each pair should be sorted, and the result should contain no duplicate pairs.", + "signature": "def two_sum_pairs(nums: list[int], target: int) -> list[list[int]]", + "examples": "two_sum_pairs([1, 2, 3, 4, 5], 6) -> [[1, 5], [2, 4]]\ntwo_sum_pairs([1, 1, 2, 3], 4) -> [[1, 3]]", + "test_cases": "assert two_sum_pairs([1, 2, 3, 4, 5], 6) == [[1, 5], [2, 4]]\nassert two_sum_pairs([1, 1, 2, 3], 4) == [[1, 3]]\nassert two_sum_pairs([], 5) == []\nassert two_sum_pairs([3, 3], 6) == [[3, 3]]", + "reference": "def two_sum_pairs(nums, target):\n nums.sort()\n result, seen = [], set()\n lo, hi = 0, len(nums)-1\n while lo < hi:\n s = nums[lo] + nums[hi]\n if s == target:\n pair = (nums[lo], nums[hi])\n if pair not in seen:\n result.append(list(pair))\n seen.add(pair)\n lo += 1; hi -= 1\n elif s < target:\n lo += 1\n else:\n hi -= 1\n return result", + }, + { + "spec": "Implement a function that converts an integer to its English words representation.", + "signature": "def int_to_words(num: int) -> str", + "examples": 'int_to_words(123) -> "One Hundred Twenty Three"\nint_to_words(0) -> "Zero"\nint_to_words(1000000) -> "One Million"', + "test_cases": 'assert int_to_words(0) == "Zero"\nassert int_to_words(123) == "One Hundred Twenty Three"\nassert int_to_words(1000) == "One Thousand"\nassert int_to_words(1000000) == "One Million"\nassert int_to_words(15) == "Fifteen"', + "reference": "def int_to_words(num):\n if num == 0:\n return 'Zero'\n ones = ['','One','Two','Three','Four','Five','Six','Seven','Eight','Nine',\n 'Ten','Eleven','Twelve','Thirteen','Fourteen','Fifteen','Sixteen',\n 'Seventeen','Eighteen','Nineteen']\n tens = ['','','Twenty','Thirty','Forty','Fifty','Sixty','Seventy','Eighty','Ninety']\n scales = ['','Thousand','Million','Billion']\n def helper(n):\n if n == 0: return []\n if n < 20: return [ones[n]]\n if n < 100: return [tens[n//10]] + helper(n%10)\n return [ones[n//100], 'Hundred'] + helper(n%100)\n parts, i = [], 0\n while num > 0:\n if num % 1000 != 0:\n chunk = helper(num % 1000)\n if scales[i]: chunk.append(scales[i])\n parts = chunk + parts\n num //= 1000; i += 1\n return ' '.join(parts)", + }, + { + "spec": "Implement a function that finds the kth largest element in an unsorted list without fully sorting it.", + "signature": "def kth_largest(nums: list[int], k: int) -> int", + "examples": "kth_largest([3, 2, 1, 5, 6, 4], 2) -> 5\nkth_largest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4) -> 4", + "test_cases": "assert kth_largest([3, 2, 1, 5, 6, 4], 2) == 5\nassert kth_largest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4) == 4\nassert kth_largest([1], 1) == 1\nassert kth_largest([7, 7, 7], 1) == 7", + "reference": "def kth_largest(nums, k):\n import heapq\n return heapq.nlargest(k, nums)[-1]", + }, + { + "spec": "Given a string, determine if it is a valid palindrome considering only alphanumeric characters and ignoring case.", + "signature": "def is_palindrome(s: str) -> bool", + "examples": 'is_palindrome("A man, a plan, a canal: Panama") -> True\nis_palindrome("race a car") -> False\nis_palindrome("") -> True', + "test_cases": 'assert is_palindrome("A man, a plan, a canal: Panama") == True\nassert is_palindrome("race a car") == False\nassert is_palindrome("") == True\nassert is_palindrome(" ") == True\nassert is_palindrome("ab") == False', + "reference": "def is_palindrome(s):\n cleaned = ''.join(c.lower() for c in s if c.isalnum())\n return cleaned == cleaned[::-1]", + }, + { + "spec": "Implement a simple LRU cache with get and put operations. The cache has a fixed capacity.", + "signature": "class LRUCache:\n def __init__(self, capacity: int): ...\n def get(self, key: int) -> int: ...\n def put(self, key: int, value: int) -> None: ...", + "examples": "cache = LRUCache(2)\ncache.put(1, 1)\ncache.put(2, 2)\ncache.get(1) -> 1\ncache.put(3, 3) # evicts key 2\ncache.get(2) -> -1", + "test_cases": "cache = LRUCache(2)\ncache.put(1, 1)\ncache.put(2, 2)\nassert cache.get(1) == 1\ncache.put(3, 3)\nassert cache.get(2) == -1\ncache.put(4, 4)\nassert cache.get(1) == -1\nassert cache.get(3) == 3\nassert cache.get(4) == 4", + "reference": "from collections import OrderedDict\nclass LRUCache:\n def __init__(self, capacity):\n self.cap = capacity\n self.cache = OrderedDict()\n def get(self, key):\n if key not in self.cache: return -1\n self.cache.move_to_end(key)\n return self.cache[key]\n def put(self, key, value):\n if key in self.cache:\n self.cache.move_to_end(key)\n self.cache[key] = value\n if len(self.cache) > self.cap:\n self.cache.popitem(last=False)", + }, + { + "spec": "Given a non-negative integer represented as a list of digits, add one to the number.", + "signature": "def plus_one(digits: list[int]) -> list[int]", + "examples": "plus_one([1, 2, 3]) -> [1, 2, 4]\nplus_one([9, 9, 9]) -> [1, 0, 0, 0]\nplus_one([0]) -> [1]", + "test_cases": "assert plus_one([1, 2, 3]) == [1, 2, 4]\nassert plus_one([9, 9, 9]) == [1, 0, 0, 0]\nassert plus_one([0]) == [1]\nassert plus_one([9]) == [1, 0]\nassert plus_one([1, 0, 0]) == [1, 0, 1]", + "reference": "def plus_one(digits):\n for i in range(len(digits)-1, -1, -1):\n if digits[i] < 9:\n digits[i] += 1\n return digits\n digits[i] = 0\n return [1] + digits", + }, + { + "spec": "Implement a function that finds the maximum profit from buying and selling a stock once. You can only buy before you sell.", + "signature": "def max_profit(prices: list[int]) -> int", + "examples": "max_profit([7, 1, 5, 3, 6, 4]) -> 5\nmax_profit([7, 6, 4, 3, 1]) -> 0", + "test_cases": "assert max_profit([7, 1, 5, 3, 6, 4]) == 5\nassert max_profit([7, 6, 4, 3, 1]) == 0\nassert max_profit([1, 2]) == 1\nassert max_profit([2, 1]) == 0\nassert max_profit([]) == 0", + "reference": "def max_profit(prices):\n if not prices: return 0\n min_price = prices[0]\n profit = 0\n for p in prices[1:]:\n profit = max(profit, p - min_price)\n min_price = min(min_price, p)\n return profit", + }, + { + "spec": "Given a string, find all starting indices of substrings that are anagrams of a given pattern.", + "signature": "def find_anagrams(s: str, p: str) -> list[int]", + "examples": 'find_anagrams("cbaebabacd", "abc") -> [0, 6]\nfind_anagrams("abab", "ab") -> [0, 1, 2]', + "test_cases": 'assert find_anagrams("cbaebabacd", "abc") == [0, 6]\nassert find_anagrams("abab", "ab") == [0, 1, 2]\nassert find_anagrams("", "a") == []\nassert find_anagrams("a", "ab") == []', + "reference": "def find_anagrams(s, p):\n from collections import Counter\n if len(p) > len(s): return []\n pc = Counter(p)\n wc = Counter(s[:len(p)])\n result = []\n if wc == pc: result.append(0)\n for i in range(len(p), len(s)):\n wc[s[i]] += 1\n old = s[i - len(p)]\n wc[old] -= 1\n if wc[old] == 0: del wc[old]\n if wc == pc: result.append(i - len(p) + 1)\n return result", + }, + { + "spec": "Implement a function that evaluates a mathematical expression given as a string containing +, -, *, / and parentheses. Assume valid input with integer operands.", + "signature": "def evaluate(expression: str) -> float", + "examples": 'evaluate("3+2*2") -> 7.0\nevaluate("(1+2)*3") -> 9.0\nevaluate("10/3") -> 3.333...', + "test_cases": 'assert evaluate("3+2*2") == 7.0\nassert evaluate("(1+2)*3") == 9.0\nassert abs(evaluate("10/3") - 3.333333) < 0.01\nassert evaluate("2*(3+4)") == 14.0', + "reference": "def evaluate(expression):\n def helper(tokens, pos):\n def parse_num():\n nonlocal pos\n if tokens[pos] == '(':\n pos += 1\n val = parse_expr()\n pos += 1\n return val\n num = 0\n while pos < len(tokens) and tokens[pos].isdigit():\n num = num * 10 + int(tokens[pos])\n pos += 1\n return float(num)\n def parse_term():\n val = parse_num()\n while pos < len(tokens) and tokens[pos] in '*/':\n op = tokens[pos]\n nonlocal pos\n pos += 1\n r = parse_num()\n val = val * r if op == '*' else val / r\n return val\n def parse_expr():\n val = parse_term()\n while pos < len(tokens) and tokens[pos] in '+-':\n op = tokens[pos]\n nonlocal pos\n pos += 1\n r = parse_term()\n val = val + r if op == '+' else val - r\n return val\n return parse_expr()\n tokens = list(expression.replace(' ', ''))\n return helper(tokens, 0)", + }, + { + "spec": "Implement a trie (prefix tree) with insert, search, and startsWith methods.", + "signature": "class Trie:\n def __init__(self): ...\n def insert(self, word: str) -> None: ...\n def search(self, word: str) -> bool: ...\n def starts_with(self, prefix: str) -> bool: ...", + "examples": 'trie = Trie()\ntrie.insert("apple")\ntrie.search("apple") -> True\ntrie.search("app") -> False\ntrie.starts_with("app") -> True', + "test_cases": 'trie = Trie()\ntrie.insert("apple")\nassert trie.search("apple") == True\nassert trie.search("app") == False\nassert trie.starts_with("app") == True\ntrie.insert("app")\nassert trie.search("app") == True', + "reference": "class Trie:\n def __init__(self):\n self.children = {}\n self.is_end = False\n def insert(self, word):\n node = self\n for c in word:\n if c not in node.children:\n node.children[c] = Trie()\n node = node.children[c]\n node.is_end = True\n def search(self, word):\n node = self\n for c in word:\n if c not in node.children: return False\n node = node.children[c]\n return node.is_end\n def starts_with(self, prefix):\n node = self\n for c in prefix:\n if c not in node.children: return False\n node = node.children[c]\n return True", + }, + { + "spec": "Given a 2D grid of '1's (land) and '0's (water), count the number of islands. An island is surrounded by water and formed by connecting adjacent lands horizontally or vertically.", + "signature": "def count_islands(grid: list[list[str]]) -> int", + "examples": 'count_islands([["1","1","0"],["1","1","0"],["0","0","1"]]) -> 2', + "test_cases": 'assert count_islands([["1","1","0"],["1","1","0"],["0","0","1"]]) == 2\nassert count_islands([["0","0"],["0","0"]]) == 0\nassert count_islands([["1"]]) == 1\nassert count_islands([["1","0","1"],["0","1","0"],["1","0","1"]]) == 5', + "reference": "def count_islands(grid):\n if not grid: return 0\n rows, cols = len(grid), len(grid[0])\n count = 0\n def dfs(r, c):\n if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1': return\n grid[r][c] = '#'\n dfs(r+1,c); dfs(r-1,c); dfs(r,c+1); dfs(r,c-1)\n for r in range(rows):\n for c in range(cols):\n if grid[r][c] == '1':\n count += 1\n dfs(r, c)\n return count", + }, + { + "spec": "Implement a function that generates all valid combinations of n pairs of parentheses.", + "signature": "def generate_parentheses(n: int) -> list[str]", + "examples": 'generate_parentheses(1) -> ["()"]\ngenerate_parentheses(2) -> ["(())", "()()"]\ngenerate_parentheses(3) -> ["((()))", "(()())", "(())()", "()(())", "()()()"]', + "test_cases": 'assert generate_parentheses(1) == ["()"]\nassert sorted(generate_parentheses(2)) == ["(())", "()()"]\nassert len(generate_parentheses(3)) == 5\nassert len(generate_parentheses(4)) == 14', + "reference": "def generate_parentheses(n):\n result = []\n def bt(s, o, c):\n if len(s) == 2*n:\n result.append(s)\n return\n if o < n: bt(s+'(', o+1, c)\n if c < o: bt(s+')', o, c+1)\n bt('', 0, 0)\n return result", + }, + { + "spec": "Given a list of non-negative integers representing elevation heights, compute how much water can be trapped after raining.", + "signature": "def trap_water(heights: list[int]) -> int", + "examples": "trap_water([0,1,0,2,1,0,1,3,2,1,2,1]) -> 6\ntrap_water([4,2,0,3,2,5]) -> 9", + "test_cases": "assert trap_water([0,1,0,2,1,0,1,3,2,1,2,1]) == 6\nassert trap_water([4,2,0,3,2,5]) == 9\nassert trap_water([]) == 0\nassert trap_water([3]) == 0\nassert trap_water([3, 0, 3]) == 3", + "reference": "def trap_water(heights):\n if not heights: return 0\n l, r = 0, len(heights)-1\n lmax = rmax = water = 0\n while l < r:\n if heights[l] < heights[r]:\n if heights[l] >= lmax: lmax = heights[l]\n else: water += lmax - heights[l]\n l += 1\n else:\n if heights[r] >= rmax: rmax = heights[r]\n else: water += rmax - heights[r]\n r -= 1\n return water", + }, + { + "spec": "Implement a function that serializes and deserializes a binary tree. The tree node has val, left, right attributes.", + "signature": "def serialize(root) -> str\ndef deserialize(data: str) -> TreeNode", + "examples": 'serialize a tree [1,2,3,null,null,4,5] -> some string\ndeserialize that string -> same tree', + "test_cases": "class TreeNode:\n def __init__(self, val=0, left=None, right=None):\n self.val, self.left, self.right = val, left, right\nroot = TreeNode(1, TreeNode(2), TreeNode(3, TreeNode(4), TreeNode(5)))\ns = serialize(root)\nnew_root = deserialize(s)\nassert new_root.val == 1\nassert new_root.left.val == 2\nassert new_root.right.val == 3\nassert new_root.right.left.val == 4", + "reference": "class TreeNode:\n def __init__(self, val=0, left=None, right=None):\n self.val, self.left, self.right = val, left, right\ndef serialize(root):\n if not root: return 'null'\n return f'{root.val},{serialize(root.left)},{serialize(root.right)}'\ndef deserialize(data):\n vals = iter(data.split(','))\n def build():\n v = next(vals)\n if v == 'null': return None\n node = TreeNode(int(v))\n node.left = build()\n node.right = build()\n return node\n return build()", + }, + { + "spec": "Implement a function that converts a number from base 10 to any base (2-36). Use lowercase letters for digits > 9.", + "signature": "def to_base(num: int, base: int) -> str", + "examples": 'to_base(255, 16) -> "ff"\nto_base(10, 2) -> "1010"\nto_base(0, 5) -> "0"', + "test_cases": 'assert to_base(255, 16) == "ff"\nassert to_base(10, 2) == "1010"\nassert to_base(0, 5) == "0"\nassert to_base(100, 10) == "100"\nassert to_base(35, 36) == "z"', + "reference": "def to_base(num, base):\n if num == 0: return '0'\n digits = '0123456789abcdefghijklmnopqrstuvwxyz'\n result = []\n neg = num < 0\n num = abs(num)\n while num:\n result.append(digits[num % base])\n num //= base\n if neg: result.append('-')\n return ''.join(reversed(result))", + }, + { + "spec": "Given a string of words, reverse the order of words while preserving whitespace normalization (single spaces, no leading/trailing).", + "signature": "def reverse_words(s: str) -> str", + "examples": 'reverse_words("the sky is blue") -> "blue is sky the"\nreverse_words(" hello world ") -> "world hello"', + "test_cases": 'assert reverse_words("the sky is blue") == "blue is sky the"\nassert reverse_words(" hello world ") == "world hello"\nassert reverse_words("a") == "a"\nassert reverse_words(" spaces between ") == "between spaces"', + "reference": "def reverse_words(s):\n return ' '.join(s.split()[::-1])", + }, + { + "spec": "Implement a function that computes the power set (all subsets) of a list of unique integers.", + "signature": "def power_set(nums: list[int]) -> list[list[int]]", + "examples": "power_set([1, 2, 3]) -> [[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]", + "test_cases": "result = power_set([1, 2, 3])\nassert len(result) == 8\nassert [] in result\nassert [1, 2, 3] in result\nassert power_set([]) == [[]]", + "reference": "def power_set(nums):\n result = [[]]\n for num in nums:\n result += [subset + [num] for subset in result]\n return result", + }, +] + + +class CodingTaskDataset(DatasetProvider): + """Coding task benchmark: function-level code generation with test cases.""" + + dataset_id = "coding_task" + dataset_name = "Coding Task" + + def __init__(self) -> None: + self._records: List[EvalRecord] = [] + + def load( + self, + *, + max_samples: Optional[int] = None, + split: Optional[str] = None, + seed: Optional[int] = None, + ) -> None: + rows = list(_TASKS) + + if seed is not None: + rng = random.Random(seed) + rng.shuffle(rows) + + if max_samples is not None: + rows = rows[:max_samples] + + self._records = [] + for idx, task in enumerate(rows): + prompt = _PROMPT_TEMPLATE.format( + spec=task["spec"], + signature=task["signature"], + examples=task["examples"], + ) + self._records.append(EvalRecord( + record_id=f"coding-task-{idx}", + problem=prompt, + reference=task["reference"], + category="use-case", + subject="coding_task", + metadata={ + "test_cases": task["test_cases"], + "signature": task["signature"], + }, + )) + + def iter_records(self) -> Iterable[EvalRecord]: + return iter(self._records) + + def size(self) -> int: + return len(self._records) + + +__all__ = ["CodingTaskDataset"] diff --git a/src/openjarvis/evals/datasets/email_triage.py b/src/openjarvis/evals/datasets/email_triage.py new file mode 100644 index 00000000..366da967 --- /dev/null +++ b/src/openjarvis/evals/datasets/email_triage.py @@ -0,0 +1,335 @@ +"""Email triage benchmark dataset. + +Synthetic email threads for evaluating urgency classification, +category assignment, and draft response generation. +""" + +from __future__ import annotations + +import random +from typing import Iterable, List, Optional + +from openjarvis.evals.core.dataset import DatasetProvider +from openjarvis.evals.core.types import EvalRecord + +_PROMPT_TEMPLATE = """You are an AI email assistant. Analyze the following email and provide: +1. Urgency level: critical, high, medium, or low +2. Category: action, decision, info, or social +3. A brief draft reply (2-3 sentences) + +Format your response exactly as: +urgency: +category: +draft: + +Email: +From: {sender} +Subject: {subject} +Date: {date} + +{body}""" + +# Synthetic email records covering a range of urgency/category combinations +_EMAILS = [ + { + "sender": "cto@company.com", + "subject": "URGENT: Production database is down", + "date": "2025-12-01 03:14", + "body": "Our primary Postgres cluster is unreachable. Customers are seeing 500 errors. I need the on-call team assembled immediately. Please acknowledge and start the incident runbook.", + "urgency": "critical", + "category": "action", + }, + { + "sender": "alice@partner.org", + "subject": "Contract renewal deadline tomorrow", + "date": "2025-12-01 09:00", + "body": "Hi, just a reminder that our service contract expires tomorrow at midnight. We need your signed renewal by EOD or we'll need to pause service. The updated terms are attached. Please review and sign at your earliest convenience.", + "urgency": "critical", + "category": "decision", + }, + { + "sender": "security@company.com", + "subject": "Security patch required: CVE-2025-4321", + "date": "2025-12-01 10:30", + "body": "A critical vulnerability has been disclosed in our authentication library. All production services must be patched within 24 hours. The fix is available in version 3.2.1. Please coordinate with your team to schedule the update.", + "urgency": "critical", + "category": "action", + }, + { + "sender": "boss@company.com", + "subject": "Q4 budget approval needed", + "date": "2025-12-02 08:00", + "body": "I need your approval on the Q4 infrastructure budget by Friday. The proposal includes $45K for GPU servers and $12K for monitoring tools. Let me know if you have concerns or want to discuss before I submit to finance.", + "urgency": "high", + "category": "decision", + }, + { + "sender": "hr@company.com", + "subject": "New hire starting Monday — setup needed", + "date": "2025-12-02 11:00", + "body": "Sarah Chen is joining the ML team on Monday. Please ensure her dev environment, Slack access, and GitHub permissions are set up before she arrives. She'll need access to the model-training and inference repos.", + "urgency": "high", + "category": "action", + }, + { + "sender": "client@bigcorp.com", + "subject": "API integration failing in staging", + "date": "2025-12-02 14:00", + "body": "We're getting 401 errors when calling your /v2/inference endpoint from our staging environment. Our API key was rotated last week. Could you check if the new key is properly allowlisted? We need this resolved before our Thursday demo.", + "urgency": "high", + "category": "action", + }, + { + "sender": "pm@company.com", + "subject": "Feature spec review: voice commands", + "date": "2025-12-02 15:30", + "body": "I've drafted the PRD for voice command support. It covers wake word detection, streaming transcription, and intent parsing. Can you review the technical feasibility section and leave comments by next Wednesday?", + "urgency": "high", + "category": "decision", + }, + { + "sender": "devops@company.com", + "subject": "Kubernetes cluster upgrade scheduled", + "date": "2025-12-03 09:00", + "body": "We'll be upgrading the production k8s cluster from 1.28 to 1.30 this Saturday at 2am UTC. Expected downtime is 15 minutes. Please ensure your services are compatible with the new version. No action needed if you're using the standard Helm charts.", + "urgency": "medium", + "category": "info", + }, + { + "sender": "data-team@company.com", + "subject": "Weekly model performance report", + "date": "2025-12-03 10:00", + "body": "Here's this week's performance summary:\n- Inference latency: P50=42ms, P99=180ms (stable)\n- Accuracy on validation set: 94.2% (up 0.3%)\n- Daily active users: 12,400 (up 8%)\n- Error rate: 0.02% (down from 0.05%)\nAll metrics within SLA. No action needed.", + "urgency": "low", + "category": "info", + }, + { + "sender": "intern@company.com", + "subject": "Question about coding style", + "date": "2025-12-03 11:00", + "body": "Hi! I'm working on the data pipeline refactor. Should I use dataclasses or Pydantic models for the new config objects? I saw both patterns in the codebase and want to stay consistent. Thanks!", + "urgency": "low", + "category": "decision", + }, + { + "sender": "marketing@company.com", + "subject": "Blog post draft for review", + "date": "2025-12-03 13:00", + "body": "I've written a blog post about our new on-device inference feature. Could you review the technical accuracy of the benchmarks section? No rush — we're planning to publish next week. Draft is in the shared Google Doc.", + "urgency": "medium", + "category": "action", + }, + { + "sender": "colleague@company.com", + "subject": "Lunch tomorrow?", + "date": "2025-12-03 16:00", + "body": "Hey, want to grab lunch tomorrow? There's a new ramen place that opened on 5th street. I heard it's really good. Let me know if you're free around noon.", + "urgency": "low", + "category": "social", + }, + { + "sender": "legal@company.com", + "subject": "Updated data processing agreement", + "date": "2025-12-04 09:00", + "body": "Please review the updated DPA for our EU customers. The main changes are around data retention periods and right-to-erasure timelines. We need engineering sign-off by end of next week to stay compliant with the new regulations.", + "urgency": "medium", + "category": "decision", + }, + { + "sender": "support@vendor.io", + "subject": "Your support ticket #8842 has been resolved", + "date": "2025-12-04 10:00", + "body": "Hi, we've resolved the memory leak issue you reported in v4.1.2. The fix is included in v4.1.3 which was released today. Please upgrade at your convenience and let us know if the issue persists.", + "urgency": "medium", + "category": "action", + }, + { + "sender": "recruiter@external.com", + "subject": "Exciting ML opportunity", + "date": "2025-12-04 11:00", + "body": "I came across your profile and think you'd be a great fit for our Senior ML Engineer role. We're building cutting-edge on-device AI systems. Would you be open to a brief chat next week?", + "urgency": "low", + "category": "social", + }, + { + "sender": "cfo@company.com", + "subject": "Cost optimization meeting — Thursday 2pm", + "date": "2025-12-04 14:00", + "body": "I'm setting up a meeting to discuss cloud cost optimization. Our GPU compute spend increased 40% last quarter. Please come prepared with data on your team's usage and any ideas for reducing costs without impacting performance.", + "urgency": "high", + "category": "action", + }, + { + "sender": "ops@company.com", + "subject": "SSL certificate expiring in 7 days", + "date": "2025-12-05 08:00", + "body": "The SSL certificate for api.openjarvis.dev expires on December 12. Auto-renewal is configured but failed last time due to DNS validation issues. Please verify the CNAME record is correct and trigger a manual renewal if needed.", + "urgency": "high", + "category": "action", + }, + { + "sender": "team@company.com", + "subject": "Retrospective notes from sprint 47", + "date": "2025-12-05 09:00", + "body": "Here are the key takeaways from yesterday's retro:\n- What went well: shipped voice feature on time, test coverage improved to 92%\n- What to improve: PR review turnaround is still slow (avg 2 days)\n- Action items: implement PR review SLA, set up automated test reports\nFull notes in Confluence.", + "urgency": "low", + "category": "info", + }, + { + "sender": "partner@aicloud.io", + "subject": "Partnership proposal — joint webinar", + "date": "2025-12-05 10:00", + "body": "We'd love to co-host a webinar on local vs cloud AI inference with your team. We think a balanced comparison would be valuable for both our audiences. Proposed date: January 15. Would you be interested?", + "urgency": "medium", + "category": "decision", + }, + { + "sender": "monitoring@company.com", + "subject": "Alert: Memory usage at 85% on prod-gpu-03", + "date": "2025-12-05 22:00", + "body": "Memory usage on prod-gpu-03 has been above 85% for the past 30 minutes. Current usage: 85.2%. Threshold: 90%. The inference service is still healthy but may degrade if usage increases. Consider restarting the service or scaling out.", + "urgency": "medium", + "category": "action", + }, + { + "sender": "design@company.com", + "subject": "New dashboard mockups ready", + "date": "2025-12-06 09:00", + "body": "The updated mockups for the savings dashboard are ready in Figma. Key changes: added monthly projection cards, cloud agent comparison section, and a cost calculator widget. Please take a look when you have a chance.", + "urgency": "low", + "category": "info", + }, + { + "sender": "board@company.com", + "subject": "Board meeting prep — AI strategy deck", + "date": "2025-12-06 10:00", + "body": "The board meeting is next Thursday. I need 3-4 slides on our AI cost savings strategy — specifically how on-device inference reduces our operational costs. Can you prepare the slides with actual numbers by Tuesday?", + "urgency": "high", + "category": "action", + }, + { + "sender": "opensource@contributor.dev", + "subject": "PR submitted: improved memory backend", + "date": "2025-12-06 11:00", + "body": "Hi, I've submitted PR #234 with a hybrid memory backend that uses RRF fusion for better retrieval. Tests are passing. Would appreciate a review when you get a chance. Happy to make any changes.", + "urgency": "medium", + "category": "action", + }, + { + "sender": "training@company.com", + "subject": "Mandatory security training due Dec 15", + "date": "2025-12-06 13:00", + "body": "This is a reminder that all engineering staff must complete the annual security awareness training by December 15. The course takes approximately 30 minutes. Access it through the Learning Portal. Please complete it at your earliest convenience.", + "urgency": "medium", + "category": "action", + }, + { + "sender": "friend@personal.com", + "subject": "Happy holidays!", + "date": "2025-12-06 17:00", + "body": "Hey! Just wanted to wish you happy holidays. Hope you're doing well. Let's catch up over coffee sometime in January. Miss our chats!", + "urgency": "low", + "category": "social", + }, + { + "sender": "ceo@company.com", + "subject": "Company all-hands: cost savings milestone", + "date": "2025-12-07 08:00", + "body": "Great news — we've saved over $500K this year by running AI inference locally. I want to highlight this at the all-hands on Friday. Can someone from the infra team prepare a 5-minute demo of the savings dashboard?", + "urgency": "high", + "category": "action", + }, + { + "sender": "vendor@cloudprovider.com", + "subject": "Your monthly invoice is ready", + "date": "2025-12-07 09:00", + "body": "Your December invoice for cloud compute services is now available. Total: $3,247.88. This represents a 60% reduction from your peak monthly spend in June ($8,120.00). View details in your account dashboard.", + "urgency": "low", + "category": "info", + }, + { + "sender": "qa@company.com", + "subject": "Regression found in v2.8.1", + "date": "2025-12-07 10:00", + "body": "We found a regression in the latest release: the memory search endpoint returns empty results when the query contains special characters (e.g., '@', '#'). This affects the email triage workflow. Bisected to commit abc123. Please fix before the next release.", + "urgency": "high", + "category": "action", + }, + { + "sender": "newsletter@techdigest.com", + "subject": "This week in AI: local inference trends", + "date": "2025-12-07 12:00", + "body": "Top stories this week:\n1. On-device AI adoption up 200% year-over-year\n2. New quantization techniques enable 70B models on consumer GPUs\n3. Cloud AI costs continue to rise, driving interest in local alternatives\n4. Open-source AI frameworks see record contributions", + "urgency": "low", + "category": "info", + }, + { + "sender": "compliance@company.com", + "subject": "GDPR audit scheduled for January", + "date": "2025-12-07 14:00", + "body": "Our annual GDPR compliance audit is scheduled for January 20-22. The auditors will review our data processing pipelines, consent mechanisms, and retention policies. Please ensure your team's documentation is up to date. I'll send a detailed checklist next week.", + "urgency": "medium", + "category": "info", + }, +] + + +class EmailTriageDataset(DatasetProvider): + """Email triage benchmark: urgency classification + category + draft reply.""" + + dataset_id = "email_triage" + dataset_name = "Email Triage" + + def __init__(self) -> None: + self._records: List[EvalRecord] = [] + + def load( + self, + *, + max_samples: Optional[int] = None, + split: Optional[str] = None, + seed: Optional[int] = None, + ) -> None: + rows = list(_EMAILS) + + if seed is not None: + rng = random.Random(seed) + rng.shuffle(rows) + + if max_samples is not None: + rows = rows[:max_samples] + + self._records = [] + for idx, email in enumerate(rows): + prompt = _PROMPT_TEMPLATE.format( + sender=email["sender"], + subject=email["subject"], + date=email["date"], + body=email["body"], + ) + reference = ( + f"urgency: {email['urgency']}\n" + f"category: {email['category']}" + ) + self._records.append(EvalRecord( + record_id=f"email-triage-{idx}", + problem=prompt, + reference=reference, + category="use-case", + subject="email_triage", + metadata={ + "urgency": email["urgency"], + "category": email["category"], + "sender": email["sender"], + "subject_line": email["subject"], + }, + )) + + def iter_records(self) -> Iterable[EvalRecord]: + return iter(self._records) + + def size(self) -> int: + return len(self._records) + + +__all__ = ["EmailTriageDataset"] diff --git a/src/openjarvis/evals/datasets/knowledge_base.py b/src/openjarvis/evals/datasets/knowledge_base.py new file mode 100644 index 00000000..4c2bb24e --- /dev/null +++ b/src/openjarvis/evals/datasets/knowledge_base.py @@ -0,0 +1,225 @@ +"""Knowledge base benchmark dataset. + +Document-grounded retrieval questions for evaluating retrieval accuracy +and answer correctness from a knowledge corpus. +""" + +from __future__ import annotations + +import random +from typing import Iterable, List, Optional + +from openjarvis.evals.core.dataset import DatasetProvider +from openjarvis.evals.core.types import EvalRecord + +_PROMPT_TEMPLATE = """You are a knowledge base assistant. Answer the following question using only the information provided in the document excerpts below. If the answer cannot be determined from the documents, say "Cannot be determined from the provided documents." + +Documents: +{documents} + +Question: {question} + +Provide a concise, accurate answer based solely on the documents above.""" + +_RECORDS = [ + { + "documents": "OpenJarvis Architecture Guide (v2.9):\nOpenJarvis is organized around five composable pillars: Intelligence, Engine, Agents, Tools, and Learning. The Intelligence pillar handles model definition and catalog management via ModelRegistry. The Engine pillar provides inference through backends including Ollama, vLLM, SGLang, llama.cpp, MLX, and LM Studio. All engines implement the InferenceEngine ABC with generate(), stream(), list_models(), and health() methods.", + "question": "What are the five pillars of OpenJarvis and what does the Engine pillar provide?", + "answer": "The five pillars are Intelligence, Engine, Agents, Tools, and Learning. The Engine pillar provides inference through backends including Ollama, vLLM, SGLang, llama.cpp, MLX, and LM Studio.", + }, + { + "documents": "Memory Backend Comparison (Internal Doc):\nSQLite/FTS5 is the default memory backend, providing full-text search with minimal dependencies. FAISS offers dense vector similarity search suitable for semantic retrieval. ColBERTv2 provides late-interaction retrieval with higher accuracy but requires more compute. BM25 is a traditional sparse retrieval method. The Hybrid backend uses Reciprocal Rank Fusion (RRF) to combine results from multiple backends.", + "question": "What is the default memory backend and how does the Hybrid backend combine results?", + "answer": "SQLite/FTS5 is the default memory backend. The Hybrid backend uses Reciprocal Rank Fusion (RRF) to combine results from multiple backends.", + }, + { + "documents": "Security Architecture Document:\nOpenJarvis implements a multi-layer security approach. SecretScanner and PIIScanner implement the BaseScanner ABC. GuardrailsEngine wraps inference engines with input/output scanning supporting WARN, REDACT, and BLOCK modes. The AuditLogger uses a Merkle hash chain with SHA-256 for tamper evidence. CapabilityPolicy provides RBAC with 10 capabilities and glob matching, enforced in ToolExecutor.", + "question": "How does the AuditLogger ensure tamper evidence?", + "answer": "The AuditLogger uses a Merkle hash chain with SHA-256 for tamper evidence.", + }, + { + "documents": "Agent System Documentation:\nOpenJarvis agents follow a hierarchy: BaseAgent ABC provides helpers for turn management and message building. ToolUsingAgent extends BaseAgent with tool dispatch via ToolExecutor. SimpleAgent handles single-turn queries. OrchestratorAgent manages multi-turn tool loops. NativeReActAgent implements Thought-Action-Observation patterns. The LoopGuard system prevents infinite loops via SHA-256 hash tracking and ping-pong detection.", + "question": "What is the agent hierarchy and how are infinite loops prevented?", + "answer": "The hierarchy is BaseAgent -> ToolUsingAgent -> specific agents (SimpleAgent, OrchestratorAgent, NativeReActAgent, etc.). Infinite loops are prevented by the LoopGuard system which uses SHA-256 hash tracking and ping-pong detection.", + }, + { + "documents": "Telemetry System Guide:\nInstrumentedEngine wraps any inference engine to collect telemetry transparently. TelemetryStore persists records to SQLite. TelemetryAggregator provides read-only queries for statistics. EnergyMonitor ABC has vendor-specific implementations: NvidiaEnergyMonitor (hardware counters/polling), AmdEnergyMonitor (amdsmi), AppleEnergyMonitor (zeus-ml), and RaplEnergyMonitor (sysfs). EnergyBatch tracks batch-level energy-per-token metrics.", + "question": "What energy monitoring backends are available and what library does the Apple backend use?", + "answer": "The available energy monitoring backends are NvidiaEnergyMonitor (hardware counters/polling), AmdEnergyMonitor (amdsmi), AppleEnergyMonitor (zeus-ml), and RaplEnergyMonitor (sysfs). The Apple backend uses the zeus-ml library.", + }, + { + "documents": "Configuration Reference:\nOpenJarvis uses TOML configuration with pillar-aligned sections. The config file is located at ~/.openjarvis/config.toml. Key sections include [engine] with nested per-backend configs (e.g., [engine.ollama], [engine.vllm]), [intelligence] for model defaults, [agent] for agent configuration, [tools.storage] for memory backend settings, [learning] with nested routing/intelligence/agent/metrics sub-policies, and [security] with capabilities and rate limiting.", + "question": "Where is the config file located and what format does it use?", + "answer": "The config file is located at ~/.openjarvis/config.toml and uses TOML format.", + }, + { + "documents": "Learning Subsystem Overview:\nThe Learning pillar supports multiple routing policies. HeuristicRouter uses rule-based routing. SFTRouterPolicy learns query-to-model mapping from traces. GRPORouterPolicy uses softmax sampling with group relative advantage and per-query-class weights. BanditRouterPolicy implements Thompson Sampling and UCB1 with per-arm statistics. SkillDiscovery mines tool subsequences from traces to auto-generate skill manifests.", + "question": "What routing policies are available and what algorithm does BanditRouterPolicy implement?", + "answer": "Available routing policies are HeuristicRouter, SFTRouterPolicy, GRPORouterPolicy, and BanditRouterPolicy. BanditRouterPolicy implements Thompson Sampling and UCB1 algorithms.", + }, + { + "documents": "MCP Integration Guide:\nThe Model Context Protocol (MCP) is used for all tool management. MCPToolAdapter wraps external MCP tools as native BaseTool instances. MCPToolProvider discovers tools from MCP servers. The built-in MCP server exposes all tools via JSON-RPC tools/list and tools/call endpoints following the MCP spec 2025-11-25. Tool templates in tools/templates/ allow dynamic tool construction from TOML specifications with 10 builtin templates.", + "question": "How does OpenJarvis integrate with external MCP tools?", + "answer": "MCPToolAdapter wraps external MCP tools as native BaseTool instances, and MCPToolProvider discovers tools from MCP servers.", + }, + { + "documents": "Workflow Engine Documentation:\nThe workflow engine uses DAG-based WorkflowGraph with cycle detection and topological sort. Parallel stages execute via ThreadPoolExecutor. WorkflowBuilder provides a fluent API for graph construction. WorkflowEngine executes workflows against a JarvisSystem instance. Workflows can be defined in TOML files. Supported node types: agent, tool, condition, parallel, loop, and transform.", + "question": "What node types does the workflow engine support and how are parallel stages executed?", + "answer": "The workflow engine supports agent, tool, condition, parallel, loop, and transform node types. Parallel stages are executed via ThreadPoolExecutor.", + }, + { + "documents": "Speech Subsystem Technical Reference:\nOpenJarvis supports speech-to-text with pluggable backends. SpeechBackend ABC defines transcribe(), health(), and supported_formats() methods. Three backends are available: FasterWhisperBackend (local, CTranslate2, key 'faster-whisper'), OpenAIWhisperBackend (cloud, whisper-1, key 'openai'), and DeepgramSpeechBackend (cloud, nova-2, key 'deepgram'). Auto-discovery prioritizes local backends.", + "question": "What speech backends are available and which models do the cloud backends use?", + "answer": "Three backends are available: FasterWhisperBackend (local), OpenAIWhisperBackend (cloud, using whisper-1), and DeepgramSpeechBackend (cloud, using nova-2). Auto-discovery prioritizes local backends.", + }, + { + "documents": "Sandbox System Guide:\nOpenJarvis provides two sandbox options. ContainerRunner manages Docker/Podman container lifecycle with mount validation. WasmRunner uses wasmtime-py with fuel and memory limits. SandboxedAgent is a transparent wrapper that routes agent execution through either sandbox. MountAllowlist prevents path traversal attacks. The create_sandbox_runner() factory selects the appropriate runner.", + "question": "What are the two sandbox options and what prevents path traversal attacks?", + "answer": "The two sandbox options are ContainerRunner (Docker/Podman) and WasmRunner (wasmtime-py). MountAllowlist prevents path traversal attacks.", + }, + { + "documents": "Channel System Architecture:\nOpenJarvis supports multi-platform messaging via the BaseChannel ABC. Available channels include WhatsAppBaileysChannel, LINEChannel, ViberChannel, MessengerChannel, RedditChannel, MastodonChannel, XMPPChannel, RocketChatChannel, ZulipChannel, TwitchChannel, and NostrChannel. All channels use @ChannelRegistry.register() for discovery and integrate with the EventBus.", + "question": "How many messaging channels does OpenJarvis support and how are they discovered?", + "answer": "OpenJarvis supports 11 messaging channels. They are discovered via the @ChannelRegistry.register() decorator.", + }, + { + "documents": "Eval Framework Guide:\nThe evaluation framework includes 15 real benchmark datasets from IPW: SuperGPQA, GPQA, MMLU-Pro, MATH-500, Natural Reasoning, HLE, SimpleQA, WildChat, IPW, GAIA, FRAMES, SWE-bench, SWEfficiency, TerminalBench, and TerminalBench Native. Scorer types include MCQ letter extraction, LLM-judge, exact match, and structural validation. EvalRunner supports parallel execution.", + "question": "How many benchmark datasets are available and what scorer types exist?", + "answer": "There are 15 benchmark datasets available. Scorer types include MCQ letter extraction, LLM-judge, exact match, and structural validation.", + }, + { + "documents": "Session Management Documentation:\nSessionStore uses SQLite for cross-channel persistent sessions. SessionIdentity provides canonical user identification across channels. The consolidate() method summarizes old messages to reduce storage. The decay() method removes expired sessions based on configurable retention policies.", + "question": "How are sessions managed across channels and how is storage reduced?", + "answer": "Sessions are managed via SessionStore using SQLite with SessionIdentity for canonical user identification across channels. Storage is reduced through the consolidate() method which summarizes old messages.", + }, + { + "documents": "Desktop Application Guide:\nThe OpenJarvis desktop app uses Tauri 2.0. It includes 5 dashboard panels: EnergyDashboard (real-time power monitoring with recharts), TraceDebugger (timeline inspection with step-type color coding), LearningCurve (policy visualization for GRPO/bandit stats), MemoryBrowser (search and stats), and AdminPanel (health, agents, server control). Tauri commands proxy to the OpenJarvis REST API.", + "question": "What framework does the desktop app use and what are the 5 dashboard panels?", + "answer": "The desktop app uses Tauri 2.0. The 5 panels are EnergyDashboard, TraceDebugger, LearningCurve, MemoryBrowser, and AdminPanel.", + }, + { + "documents": "API Server Reference:\nThe OpenAI-compatible server is started via 'jarvis serve'. Core endpoints: POST /v1/chat/completions, GET /v1/models, GET /health. Extended endpoints cover agents, memory, traces, telemetry, learning, skills, sessions, budget, and metrics. SSE streaming is supported on /v1/chat/completions with stream=true. WebSocket streaming is available at WS /v1/chat/stream.", + "question": "How is the API server started and what streaming options are available?", + "answer": "The API server is started via 'jarvis serve'. SSE streaming is available on /v1/chat/completions with stream=true, and WebSocket streaming is available at WS /v1/chat/stream.", + }, + { + "documents": "Trace System Documentation:\nTraces capture full interaction records via TraceStep objects. Step types include route, retrieve, generate, tool_call, and respond, each with timing information. TraceStore persists traces to SQLite. TraceCollector auto-wraps agents to capture traces. TraceAnalyzer generates statistics used by the learning subsystem.", + "question": "What step types can a trace contain and how are traces used for learning?", + "answer": "Traces can contain route, retrieve, generate, tool_call, and respond steps. TraceAnalyzer generates statistics from traces that are used by the learning subsystem.", + }, + { + "documents": "Skill System Reference:\nSkills are defined as TOML manifests with sequential tool steps and template rendering. SkillExecutor runs the steps in order. Ed25519 signature verification ensures skill integrity. SkillTool adapter wraps skills as invocable tools. There are 20 bundled skills covering file management, research, code quality, productivity, and document processing.", + "question": "How many bundled skills are there and how is their integrity verified?", + "answer": "There are 20 bundled skills. Their integrity is verified through Ed25519 signature verification.", + }, + { + "documents": "Vault System Guide:\nThe vault provides encrypted credential storage at ~/.openjarvis/vault.enc using Fernet encryption. The encryption key is auto-generated with 0o600 file permissions for security. CLI commands: 'jarvis vault set KEY' to store, 'jarvis vault get KEY' to retrieve, and 'jarvis vault list' to list stored keys.", + "question": "What encryption does the vault use and where is it stored?", + "answer": "The vault uses Fernet encryption and is stored at ~/.openjarvis/vault.enc.", + }, + { + "documents": "Recipe System Documentation:\nRecipes are composable TOML configs that wire all 5 pillars. Each Recipe dataclass provides to_builder_kwargs() for SystemBuilder integration. Three built-in recipes exist: coding_assistant, research_assistant, and general_assistant. Operator recipes include researcher (4h cycle), correspondent (5min interval), and sentinel (2h cycle). Recipes are discovered via discover_recipes() and resolved via resolve_recipe().", + "question": "What built-in recipes exist and what are the operator recipe cycle times?", + "answer": "Built-in recipes are coding_assistant, research_assistant, and general_assistant. Operator recipe cycle times: researcher (4h), correspondent (5min), sentinel (2h).", + }, + { + "documents": "A2A Protocol Implementation:\nOpenJarvis implements the Google Agent-to-Agent spec using JSON-RPC 2.0. A2AServer supports tasks/send, tasks/get, and tasks/cancel operations. Agent discovery is available at /.well-known/agent.json. A2AClient enables calling external A2A agents. A2AAgentTool adapts A2A agents as tool-callable resources.", + "question": "What protocol does A2A use and how are agents discovered?", + "answer": "A2A uses JSON-RPC 2.0. Agents are discovered via /.well-known/agent.json.", + }, + { + "documents": "Deployment Guide:\nOpenJarvis provides three Docker variants: Dockerfile (Python 3.12-slim for CPU), Dockerfile.gpu (NVIDIA CUDA 12.4), and Dockerfile.gpu.rocm (AMD ROCm 6.2). The docker-compose.yml runs two services: jarvis on port 8000 and ollama on port 11434. SystemD and launchd service files are also provided for system-level deployment.", + "question": "What Docker variants are available and what ports do the services use?", + "answer": "Three Docker variants: CPU (Python 3.12-slim), NVIDIA (CUDA 12.4), and AMD (ROCm 6.2). Jarvis runs on port 8000 and Ollama on port 11434.", + }, + { + "documents": "Event System Reference:\nThe EventBus provides synchronous pub/sub event dispatch. Approximately 30 EventType values cover inference, tools, memory, agents, telemetry, traces, channels, security, scheduler, workflow, skills, sessions, and A2A. Subscribers register handlers for specific event types. Events are dispatched synchronously within the publishing thread.", + "question": "How many EventType values does the system define and how are events dispatched?", + "answer": "The system defines approximately 30 EventType values. Events are dispatched synchronously within the publishing thread.", + }, + { + "documents": "Cost Savings Analysis (Q4 2025):\nBy running inference locally using Ollama with quantized models, the team eliminated all API costs. Previous cloud spending: $8,120/month on GPT-4 API calls for email triage (every 5 min), research queries (20/day), and monitoring (continuous). After migration to local inference: $0/month in API fees, plus approximately $15/month in electricity costs for a consumer GPU running 24/7.", + "question": "What was the monthly cloud spending before migration and what are the costs after?", + "answer": "Cloud spending was $8,120/month. After migration to local inference: $0/month in API fees plus approximately $15/month in electricity for a consumer GPU.", + }, + { + "documents": "Hardware Detection System:\nOpenJarvis auto-detects GPU vendor, model, and VRAM using platform-specific tools: nvidia-smi for NVIDIA GPUs, rocm-smi for AMD GPUs, system_profiler for Apple Silicon, and /proc/cpuinfo for CPU features. Based on detection, it recommends the appropriate inference engine (vLLM for NVIDIA, MLX for Apple, llama.cpp for CPU-only).", + "question": "How does OpenJarvis detect hardware and what engine is recommended for Apple Silicon?", + "answer": "OpenJarvis detects hardware using nvidia-smi, rocm-smi, system_profiler, and /proc/cpuinfo. MLX is recommended for Apple Silicon.", + }, + { + "documents": "Template System Documentation:\nAgent templates are pre-configured TOML manifests with system prompts, tool sets, and behavioral parameters. 15 built-in templates exist including code-reviewer, debugger, architect, deep-researcher, fact-checker, and summarizer. Templates are loaded via load_template() and discovered via discover_templates(). Each template specifies the agent type, tools, and generation parameters.", + "question": "How many built-in agent templates exist and name at least four of them.", + "answer": "There are 15 built-in templates. Examples include code-reviewer, debugger, architect, deep-researcher, fact-checker, and summarizer.", + }, + { + "documents": "Scheduler System Guide:\nTaskScheduler supports three scheduling types: cron (cron expressions), interval (fixed time intervals), and once (one-time execution). Tasks are persisted to SQLite. Five MCP tools are available for task management. The scheduler integrates with the EventBus for notifications. CLI commands: create, list, pause, resume, cancel, logs, and start.", + "question": "What scheduling types does TaskScheduler support?", + "answer": "TaskScheduler supports three scheduling types: cron (cron expressions), interval (fixed time intervals), and once (one-time execution).", + }, + { + "documents": "TUI Dashboard Reference:\nThe terminal-based dashboard uses the textual library and is available with the [dashboard] optional dependency. It provides panels for system status, event stream, telemetry visualization, agent activity monitoring, and session management. The dashboard runs in the terminal and provides a real-time view of system operations.", + "question": "What library powers the TUI dashboard and what panels does it include?", + "answer": "The TUI dashboard is powered by the textual library. It includes panels for system status, event stream, telemetry, agent activity, and sessions.", + }, + { + "documents": "MCP Quick-Add Feature:\nThe 'jarvis add' command provides quick MCP server setup with 8 built-in templates: github, filesystem, slack, postgres, brave-search, memory, puppeteer, and google-maps. Configuration is saved as JSON to ~/.openjarvis/mcp/. Each template includes the server command, arguments, and required environment variables.", + "question": "How many MCP server templates are available via 'jarvis add' and where is configuration saved?", + "answer": "8 MCP server templates are available. Configuration is saved to ~/.openjarvis/mcp/.", + }, + { + "documents": "Composition Layer Documentation:\nSystemBuilder provides a fluent builder pattern that produces JarvisSystem instances. The builder wires together engine, model, agent, tools, telemetry, traces, workflow, sessions, and capability policy. JarvisSystem exposes ask() for queries and close() for resource cleanup. The SDK (Jarvis class) wraps SystemBuilder with a simplified sync API.", + "question": "What is the relationship between SystemBuilder, JarvisSystem, and the Jarvis SDK class?", + "answer": "SystemBuilder is a fluent builder that produces JarvisSystem instances. The Jarvis SDK class wraps SystemBuilder with a simplified synchronous API.", + }, +] + + +class KnowledgeBaseDataset(DatasetProvider): + """Knowledge base benchmark: document-grounded retrieval and QA.""" + + dataset_id = "knowledge_base" + dataset_name = "Knowledge Base" + + def __init__(self) -> None: + self._records: List[EvalRecord] = [] + + def load( + self, + *, + max_samples: Optional[int] = None, + split: Optional[str] = None, + seed: Optional[int] = None, + ) -> None: + rows = list(_RECORDS) + + if seed is not None: + rng = random.Random(seed) + rng.shuffle(rows) + + if max_samples is not None: + rows = rows[:max_samples] + + self._records = [] + for idx, rec in enumerate(rows): + prompt = _PROMPT_TEMPLATE.format( + documents=rec["documents"], + question=rec["question"], + ) + self._records.append(EvalRecord( + record_id=f"knowledge-base-{idx}", + problem=prompt, + reference=rec["answer"], + category="use-case", + subject="knowledge_base", + metadata={}, + )) + + def iter_records(self) -> Iterable[EvalRecord]: + return iter(self._records) + + def size(self) -> int: + return len(self._records) + + +__all__ = ["KnowledgeBaseDataset"] diff --git a/src/openjarvis/evals/datasets/morning_brief.py b/src/openjarvis/evals/datasets/morning_brief.py new file mode 100644 index 00000000..1fa79d3c --- /dev/null +++ b/src/openjarvis/evals/datasets/morning_brief.py @@ -0,0 +1,215 @@ +"""Morning brief benchmark dataset. + +Synthetic user-context records for evaluating daily briefing generation: +calendar events, todo items, news topics, and pending messages. +""" + +from __future__ import annotations + +import random +from typing import Iterable, List, Optional + +from openjarvis.evals.core.dataset import DatasetProvider +from openjarvis.evals.core.types import EvalRecord + +_PROMPT_TEMPLATE = """You are a personal AI assistant preparing a morning briefing. Based on the following context, generate a concise, prioritized morning brief. + +The brief should: +1. Highlight the most important/urgent items first +2. Summarize calendar events for today +3. List top priorities from the todo list +4. Include relevant news highlights +5. Note any pending messages that need attention + +Format your response as a structured briefing with clear sections. + +--- User Context --- +Date: {date} + +Calendar: +{calendar} + +Todo List: +{todos} + +News Topics of Interest: +{news_topics} + +Pending Messages: +{messages}""" + +_CONTEXTS = [ + { + "date": "Monday, December 1, 2025", + "calendar": "- 9:00 AM: Team standup (15 min)\n- 10:30 AM: 1:1 with Sarah (engineering lead)\n- 2:00 PM: Product review meeting\n- 4:00 PM: Candidate interview — senior ML engineer", + "todos": "- Review PR #234 (memory backend optimization)\n- Submit Q4 budget proposal (due today)\n- Update deployment runbook\n- Prepare interview questions for 4pm candidate", + "news_topics": "AI, machine learning, local inference, open source", + "messages": "- Slack from CTO: 'Can we discuss cloud cost reduction at standup?'\n- Email from legal: contract renewal deadline is Wednesday\n- GitHub notification: CI failing on main branch", + "key_priorities": "Q4 budget due today, CI failure on main, CTO wants cost discussion", + }, + { + "date": "Tuesday, December 2, 2025", + "calendar": "- 8:30 AM: All-hands meeting (company update)\n- 11:00 AM: Architecture review — new caching layer\n- 1:00 PM: Lunch with visiting researcher\n- 3:30 PM: Sprint planning", + "todos": "- Fix regression in search endpoint (high priority)\n- Write design doc for voice command feature\n- Review 3 pending PRs\n- Order new GPU server (approved in budget)", + "news_topics": "semiconductor industry, GPU availability, NVIDIA earnings", + "messages": "- Email from vendor: GPU server shipping delayed 2 weeks\n- Slack from QA: 'Found another edge case in triage workflow'\n- PR comment: need to address review feedback on #230", + "key_priorities": "Search regression fix, GPU delay impacts timeline, QA edge case", + }, + { + "date": "Wednesday, December 3, 2025", + "calendar": "- 9:00 AM: Team standup\n- 10:00 AM: Security review (quarterly)\n- 2:00 PM: Demo to potential partner\n- 5:00 PM: Yoga class", + "todos": "- Complete security training (due Dec 15)\n- Prepare demo slides for partner meeting\n- Merge approved PRs\n- Update CLAUDE.md with Phase 25 changes", + "news_topics": "cybersecurity, data privacy, GDPR compliance", + "messages": "- Email from partner: 'Looking forward to the demo, can you show the savings dashboard?'\n- Slack from intern: question about coding conventions\n- Calendar reminder: contract renewal deadline is today", + "key_priorities": "Contract renewal deadline today, partner demo at 2pm, security review", + }, + { + "date": "Thursday, December 4, 2025", + "calendar": "- 9:00 AM: Team standup\n- 11:00 AM: Deep work block (no meetings)\n- 2:00 PM: Cost optimization meeting with CFO\n- 4:30 PM: Mentoring session with junior developer", + "todos": "- Prepare cost analysis slides for CFO meeting\n- Implement rate limiting for API endpoints\n- Code review: agent loop guard improvements\n- Write blog post draft on local inference benefits", + "news_topics": "cloud computing costs, serverless trends, edge AI", + "messages": "- Email from CFO: 'Please bring specific numbers on GPU vs cloud costs'\n- Slack from devops: 'k8s upgrade went smoothly, all services healthy'\n- GitHub: 2 new issues filed by community users", + "key_priorities": "CFO meeting needs cost data, rate limiting implementation, community issues", + }, + { + "date": "Friday, December 5, 2025", + "calendar": "- 9:00 AM: Team standup\n- 10:00 AM: Retrospective\n- 12:00 PM: Team lunch\n- 3:00 PM: Release planning for v3.0", + "todos": "- Tag v2.9.1 release\n- Update changelog\n- Clear inbox (20+ unread)\n- Submit expense report\n- Plan next week's priorities", + "news_topics": "open source funding, AI regulation, developer tools", + "messages": "- Email from CEO: 'Great work on the cost savings! Can we present at all-hands?'\n- Slack from PM: 'v3.0 feature list needs final sign-off'\n- PR merged: speech backend improvements", + "key_priorities": "Release v2.9.1, CEO presentation request, v3.0 planning", + }, + { + "date": "Monday, December 8, 2025", + "calendar": "- 9:00 AM: Team standup\n- 10:00 AM: Board meeting prep\n- 1:00 PM: Interview panel — engineering manager\n- 3:00 PM: Tech debt review", + "todos": "- Prepare 3-4 slides on AI cost savings for board\n- Review candidate resume\n- Fix flaky test in CI (test_memory_search)\n- Update oncall rotation for holidays", + "news_topics": "AI startups, venture capital, talent market", + "messages": "- Email from board member: 'Send me the cost savings data before Thursday'\n- Slack from team: 'Who's covering oncall over the holidays?'\n- GitHub: security advisory for dependency", + "key_priorities": "Board slides due, security advisory, holiday oncall coverage", + }, + { + "date": "Tuesday, December 9, 2025", + "calendar": "- 8:00 AM: Breakfast meeting with advisor\n- 10:00 AM: Pair programming session\n- 2:00 PM: Customer feedback review\n- 4:00 PM: Design review — mobile app", + "todos": "- Finish board presentation slides\n- Review customer feedback summary\n- Benchmark new quantization approach\n- Update documentation for new API endpoints", + "news_topics": "mobile AI, quantization techniques, model compression", + "messages": "- Email from customer: 'Love the product, but need better docs for the SDK'\n- Slack from design: 'Mobile mockups ready for review'\n- PR: community contribution for AMD GPU support", + "key_priorities": "Board slides must be finished, customer docs feedback, mobile review", + }, + { + "date": "Wednesday, December 10, 2025", + "calendar": "- 9:00 AM: Team standup\n- 11:00 AM: Cross-team sync (ML + Platform)\n- 1:30 PM: Vendor call — monitoring tools\n- 3:00 PM: Office hours (open door)", + "todos": "- Deploy rate limiter to staging\n- Test SSL certificate auto-renewal\n- Write incident postmortem from last week\n- Prepare for GDPR audit (January)", + "news_topics": "observability, monitoring, SRE practices", + "messages": "- Email from ops: 'SSL cert renewed successfully'\n- Slack from ML team: 'New model achieves 96% accuracy on benchmark'\n- Calendar reminder: GDPR audit docs due in 2 weeks", + "key_priorities": "Rate limiter deployment, incident postmortem overdue, GDPR prep", + }, + { + "date": "Thursday, December 11, 2025", + "calendar": "- 9:00 AM: Board meeting (all day)\n- 12:00 PM: Working lunch during board meeting\n- 5:00 PM: Team happy hour", + "todos": "- Present AI cost savings slides at board meeting\n- Answer board questions on AI strategy\n- Send follow-up notes after board meeting\n- Approve holiday PTO requests", + "news_topics": "corporate AI strategy, ROI of AI investments", + "messages": "- Email from CEO: 'Board is excited about the cost savings numbers'\n- Slack from team: '3 PTO requests pending your approval'\n- Email from recruiter: candidate accepted the offer!", + "key_priorities": "Board presentation today, PTO approvals needed, new hire accepted", + }, + { + "date": "Friday, December 12, 2025", + "calendar": "- 9:00 AM: Team standup\n- 10:00 AM: Post-board debrief with CEO\n- 11:30 AM: Release review\n- 2:00 PM: End of year planning", + "todos": "- Send board meeting follow-up notes\n- Plan Q1 OKRs\n- Review and approve Q4 bonuses\n- Archive completed project boards\n- Buy holiday gifts for team", + "news_topics": "year-end reviews, tech trends 2026, planning", + "messages": "- Email from CEO: 'Board approved the 2026 AI budget, well done!'\n- Slack from HR: 'Q4 bonus recommendations due Monday'\n- Team Slack: 'Can we do Secret Santa?'", + "key_priorities": "Board follow-up notes, Q1 OKR planning, bonus recommendations due Monday", + }, + { + "date": "Monday, December 15, 2025", + "calendar": "- 9:00 AM: Team standup\n- 10:00 AM: Q1 planning kickoff\n- 2:00 PM: Onboarding new hire — Sarah Chen\n- 4:00 PM: Security training deadline", + "todos": "- Complete security training (deadline today!)\n- Prepare onboarding materials for Sarah\n- Submit Q4 bonus recommendations\n- Draft Q1 OKR proposals\n- Review year-end performance self-assessments", + "news_topics": "hiring trends, employee onboarding best practices", + "messages": "- Email from HR: 'Security training deadline is today'\n- Slack from Sarah: 'Excited to start! Any prep I should do?'\n- Email from finance: 'Bonus recommendations received, thank you'", + "key_priorities": "Security training deadline TODAY, new hire onboarding, Q1 OKR drafts", + }, + { + "date": "Tuesday, December 16, 2025", + "calendar": "- 9:00 AM: Team standup\n- 10:00 AM: Sarah's first day — team intro\n- 1:00 PM: Architecture deep dive with Sarah\n- 3:00 PM: Vendor evaluation — new CI tool", + "todos": "- Verify Sarah's access to all repos and tools\n- Review CI vendor proposals\n- Update team wiki with 2025 retrospective\n- Close out stale GitHub issues", + "news_topics": "CI/CD trends, developer experience, DevOps", + "messages": "- Slack from Sarah: 'All access is working, thank you!'\n- Email from CI vendor: 'Demo scheduled for 3pm today'\n- GitHub: 5 issues marked as stale, need triage", + "key_priorities": "Sarah's onboarding going well, CI vendor evaluation, stale issue triage", + }, + { + "date": "Wednesday, December 17, 2025", + "calendar": "- 9:00 AM: Team standup\n- 10:30 AM: Performance review calibration\n- 2:00 PM: External talk prep — local AI meetup\n- 4:00 PM: 1:1 with Sarah (first week check-in)", + "todos": "- Prepare performance review comments\n- Polish meetup talk slides\n- Review Sarah's first PR\n- Plan team holiday event", + "news_topics": "AI community events, developer conferences, tech talks", + "messages": "- Email from meetup organizer: 'Your talk is confirmed for Jan 8'\n- Slack from Sarah: 'Submitted my first PR!'\n- Email from HR: 'Performance reviews due Dec 22'", + "key_priorities": "Performance reviews due in 5 days, Sarah's first PR, meetup talk prep", + }, + { + "date": "Thursday, December 18, 2025", + "calendar": "- 9:00 AM: Team standup\n- 11:00 AM: Holiday party planning committee\n- 2:00 PM: Final architecture review for v3.0\n- 4:30 PM: Holiday card signing", + "todos": "- Write performance reviews (3 direct reports)\n- Finalize v3.0 architecture decisions\n- Order catering for holiday party\n- Backup critical data before holiday freeze", + "news_topics": "work-life balance, remote work, team culture", + "messages": "- Slack from team: 'Can we do a white elephant gift exchange?'\n- Email from IT: 'Code freeze starts Dec 23'\n- GitHub: v3.0 milestone at 85% completion", + "key_priorities": "Performance reviews writing, v3.0 architecture decisions, code freeze approaching", + }, + { + "date": "Friday, December 19, 2025", + "calendar": "- 9:00 AM: Team standup (last of the year)\n- 10:00 AM: Year-end retrospective\n- 12:00 PM: Holiday lunch\n- 2:00 PM: Wrap-up and handoffs", + "todos": "- Submit performance reviews (due Mon)\n- Document handoff for holiday oncall\n- Send team thank-you notes\n- Set out-of-office auto-reply\n- Final check on monitoring alerts", + "news_topics": "year in review, tech predictions 2026", + "messages": "- Email from CEO: 'Thank you for an amazing year!'\n- Slack from oncall volunteer: 'I have the runbook, all set'\n- Team Slack: 'Happy holidays everyone!'", + "key_priorities": "Performance reviews due Monday, holiday handoffs, monitoring verification", + }, +] + + +class MorningBriefDataset(DatasetProvider): + """Morning brief benchmark: generate prioritized daily briefing from context.""" + + dataset_id = "morning_brief" + dataset_name = "Morning Brief" + + def __init__(self) -> None: + self._records: List[EvalRecord] = [] + + def load( + self, + *, + max_samples: Optional[int] = None, + split: Optional[str] = None, + seed: Optional[int] = None, + ) -> None: + rows = list(_CONTEXTS) + + if seed is not None: + rng = random.Random(seed) + rng.shuffle(rows) + + if max_samples is not None: + rows = rows[:max_samples] + + self._records = [] + for idx, ctx in enumerate(rows): + prompt = _PROMPT_TEMPLATE.format( + date=ctx["date"], + calendar=ctx["calendar"], + todos=ctx["todos"], + news_topics=ctx["news_topics"], + messages=ctx["messages"], + ) + self._records.append(EvalRecord( + record_id=f"morning-brief-{idx}", + problem=prompt, + reference=ctx["key_priorities"], + category="use-case", + subject="morning_brief", + metadata={"date": ctx["date"]}, + )) + + def iter_records(self) -> Iterable[EvalRecord]: + return iter(self._records) + + def size(self) -> int: + return len(self._records) + + +__all__ = ["MorningBriefDataset"] diff --git a/src/openjarvis/evals/datasets/research_mining.py b/src/openjarvis/evals/datasets/research_mining.py new file mode 100644 index 00000000..499ddd8c --- /dev/null +++ b/src/openjarvis/evals/datasets/research_mining.py @@ -0,0 +1,264 @@ +"""Research mining benchmark dataset. + +Research questions spanning multiple domains for evaluating synthesis, +source quality, and accuracy of AI research assistants. +""" + +from __future__ import annotations + +import random +from typing import Iterable, List, Optional + +from openjarvis.evals.core.dataset import DatasetProvider +from openjarvis.evals.core.types import EvalRecord + +_PROMPT_TEMPLATE = """You are a research assistant. Please research the following question and provide a comprehensive answer. + +Your response should include: +1. Key findings (3-5 main points) +2. Supporting evidence or data +3. A synthesis paragraph connecting the findings + +Research question: {question} +Domain: {domain} +Scope: {scope}""" + +_QUESTIONS = [ + { + "question": "What are the main approaches to running large language models on consumer hardware, and what trade-offs does each approach involve?", + "domain": "AI/ML", + "scope": "Technical survey", + "key_facts": "quantization (GPTQ, AWQ, GGUF), distillation, pruning, speculative decoding, offloading; trade-offs include accuracy loss vs speed gain vs memory reduction", + }, + { + "question": "How does the energy consumption of cloud-based AI inference compare to on-device inference for typical consumer workloads?", + "domain": "Energy/Sustainability", + "scope": "Comparative analysis", + "key_facts": "cloud inference involves network transfer, datacenter cooling, shared GPU utilization; on-device avoids network overhead but may use less efficient hardware; total energy depends on workload volume and hardware", + }, + { + "question": "What are the privacy implications of sending personal data to cloud AI services versus processing it locally?", + "domain": "Privacy/Security", + "scope": "Policy analysis", + "key_facts": "data exposure in transit and at rest, third-party access, GDPR/CCPA compliance, data retention policies, local processing eliminates transmission risk", + }, + { + "question": "What mechanisms exist for retrieval-augmented generation (RAG) and how do they compare in accuracy and latency?", + "domain": "AI/ML", + "scope": "Technical comparison", + "key_facts": "dense retrieval (DPR, ColBERT), sparse retrieval (BM25), hybrid approaches (RRF fusion), chunk size effects, re-ranking strategies, context window limits", + }, + { + "question": "What is the current state of hardware acceleration for AI inference on consumer devices?", + "domain": "Hardware", + "scope": "Market survey", + "key_facts": "NVIDIA consumer GPUs (RTX series), Apple Silicon (M-series Neural Engine), AMD GPUs (ROCm), Intel Arc, Qualcomm NPUs, VRAM as primary bottleneck", + }, + { + "question": "How do different model quantization methods affect inference quality for code generation tasks?", + "domain": "AI/ML", + "scope": "Empirical analysis", + "key_facts": "INT8, INT4, FP8, GPTQ vs AWQ vs GGUF; code generation sensitive to quantization; larger models tolerate more aggressive quantization; benchmark results vary by task complexity", + }, + { + "question": "What are the economic factors driving the shift from cloud AI services to on-premises or local AI deployments?", + "domain": "Economics", + "scope": "Market analysis", + "key_facts": "API cost escalation, GPU cost amortization, regulatory compliance costs, data sovereignty requirements, latency-sensitive applications, predictable vs variable costs", + }, + { + "question": "How effective are agent-based AI systems at automating software engineering tasks?", + "domain": "Software Engineering", + "scope": "Research survey", + "key_facts": "SWE-bench results, agentic loop patterns (ReAct, CodeAct), tool use, multi-step reasoning, error recovery, cost per task solved, current accuracy limitations", + }, + { + "question": "What role does knowledge graph technology play in improving AI system accuracy and explainability?", + "domain": "AI/Knowledge Management", + "scope": "Technical survey", + "key_facts": "entity-relation storage, graph-augmented retrieval, fact verification, reasoning chains, hybrid approaches with vector databases, explainable AI connections", + }, + { + "question": "What are the best practices for evaluating LLM performance across diverse task categories?", + "domain": "AI Evaluation", + "scope": "Methodological survey", + "key_facts": "benchmark suites (MMLU, GPQA, HLE), LLM-as-judge, human evaluation, contamination concerns, multi-dimensional scoring, statistical significance testing", + }, + { + "question": "How can scheduled AI agents reduce operational costs for routine business tasks?", + "domain": "Business Automation", + "scope": "Practical analysis", + "key_facts": "cron-based scheduling, email triage automation, report generation, monitoring/alerting, cost comparison vs human labor vs cloud API calls", + }, + { + "question": "What is the environmental impact of training and deploying large language models?", + "domain": "Sustainability", + "scope": "Impact assessment", + "key_facts": "training carbon footprint, inference energy per query, datacenter water usage, hardware lifecycle, renewable energy offsets, efficiency improvements over time", + }, + { + "question": "How do different memory architectures in AI assistants affect long-term user experience?", + "domain": "Human-Computer Interaction", + "scope": "Design analysis", + "key_facts": "session-based vs persistent memory, vector similarity search, knowledge graph memory, decay and consolidation, privacy controls, user trust", + }, + { + "question": "What security vulnerabilities are unique to AI-powered applications?", + "domain": "Cybersecurity", + "scope": "Threat analysis", + "key_facts": "prompt injection, jailbreaking, data exfiltration via LLM, model inversion, supply chain attacks on model weights, PII leakage, SSRF via tool calls", + }, + { + "question": "How do open-source and proprietary AI models compare for enterprise deployment?", + "domain": "Enterprise AI", + "scope": "Comparative analysis", + "key_facts": "licensing terms, customization flexibility, support and SLA, total cost of ownership, data privacy, model quality trends, community ecosystem", + }, + { + "question": "What approaches exist for making AI systems more energy-efficient during inference?", + "domain": "Green AI", + "scope": "Technical survey", + "key_facts": "model pruning, quantization, speculative decoding, dynamic batching, hardware-software co-design, early exit strategies, mixture of experts", + }, + { + "question": "How can AI assistants effectively manage multi-channel communication across platforms like Slack, email, and messaging apps?", + "domain": "Communication Systems", + "scope": "Architecture analysis", + "key_facts": "channel abstraction, unified session identity, message routing, webhook vs polling, rate limiting, cross-channel context, user preference management", + }, + { + "question": "What are the current limitations and future directions of on-device speech recognition?", + "domain": "Speech Processing", + "scope": "Technology assessment", + "key_facts": "Whisper variants, CTranslate2 optimization, streaming vs batch, vocabulary and language coverage, noise robustness, privacy benefits, latency requirements", + }, + { + "question": "How do workflow engines and DAG-based execution improve AI agent reliability?", + "domain": "AI Systems", + "scope": "Architecture survey", + "key_facts": "directed acyclic graphs, topological sort, parallel execution, error recovery, conditional branching, checkpointing, loop detection, human-in-the-loop gates", + }, + { + "question": "What is the role of telemetry and observability in production AI systems?", + "domain": "MLOps", + "scope": "Best practices survey", + "key_facts": "token usage tracking, latency monitoring, cost attribution, energy measurement, model drift detection, alert thresholds, privacy-preserving telemetry", + }, + { + "question": "How can reinforcement learning improve AI agent decision-making in real-time applications?", + "domain": "AI/ML", + "scope": "Research survey", + "key_facts": "GRPO, bandit algorithms (Thompson Sampling, UCB1), reward shaping, sample efficiency, exploration vs exploitation, online vs offline RL, safety constraints", + }, + { + "question": "What are the emerging standards and protocols for AI agent interoperability?", + "domain": "AI Standards", + "scope": "Standards review", + "key_facts": "MCP (Model Context Protocol), A2A (Agent-to-Agent), OpenAI function calling, tool use formats, JSON-RPC, agent card discovery, capability negotiation", + }, + { + "question": "How does the Model Context Protocol (MCP) enable extensible tool integration in AI systems?", + "domain": "AI Infrastructure", + "scope": "Technical analysis", + "key_facts": "JSON-RPC 2.0, tools/list and tools/call, server discovery, template-based tool generation, security considerations, adapter patterns, community ecosystem", + }, + { + "question": "What strategies exist for preventing and detecting AI hallucinations in production systems?", + "domain": "AI Safety", + "scope": "Technical survey", + "key_facts": "retrieval augmentation, chain-of-thought verification, self-consistency checking, confidence calibration, knowledge grounding, citation generation, human review loops", + }, + { + "question": "How do edge computing and on-device AI complement each other for IoT applications?", + "domain": "Edge Computing", + "scope": "Architecture analysis", + "key_facts": "latency requirements, bandwidth constraints, intermittent connectivity, model size limits, federated learning, privacy preservation, hardware heterogeneity", + }, + { + "question": "What approaches exist for cost-effective fine-tuning of language models on domain-specific data?", + "domain": "AI/ML", + "scope": "Practical guide", + "key_facts": "LoRA, QLoRA, prefix tuning, adapter methods, data quality over quantity, synthetic data generation, evaluation methodology, compute budgeting", + }, + { + "question": "How can AI systems maintain consistency and accuracy across multi-turn conversations?", + "domain": "Conversational AI", + "scope": "Technical analysis", + "key_facts": "context window management, session persistence, fact tracking, contradiction detection, memory consolidation, user modeling, conversation summarization", + }, + { + "question": "What are the trade-offs between different vector database implementations for AI memory systems?", + "domain": "Data Infrastructure", + "scope": "Comparative analysis", + "key_facts": "FAISS vs Chroma vs Weaviate vs Qdrant, indexing speed, query latency, memory footprint, scalability, filtering, hybrid search, SQLite/FTS5 as lightweight alternative", + }, + { + "question": "How do capability-based access control and taint tracking improve AI system security?", + "domain": "AI Security", + "scope": "Architecture analysis", + "key_facts": "RBAC vs capability model, principle of least privilege, taint propagation, sink policies, audit logging, Merkle chains for tamper evidence, tool-level enforcement", + }, + { + "question": "What metrics best capture the efficiency of AI inference in terms of intelligence per resource consumed?", + "domain": "AI Evaluation", + "scope": "Metrics design", + "key_facts": "Intelligence Per Watt (IPW), Intelligence Per Joule (IPJ), tokens per second per watt, accuracy per dollar, MFU/MBU, energy per output token, steady-state detection", + }, + { + "question": "How can desktop AI applications provide a seamless user experience while managing local compute resources?", + "domain": "Desktop Applications", + "scope": "UX/Architecture analysis", + "key_facts": "Tauri/Electron frameworks, background service management, resource monitoring, model switching, system tray integration, auto-start, update mechanisms", + }, +] + + +class ResearchMiningDataset(DatasetProvider): + """Research mining benchmark: evaluate research synthesis and accuracy.""" + + dataset_id = "research_mining" + dataset_name = "Research Mining" + + def __init__(self) -> None: + self._records: List[EvalRecord] = [] + + def load( + self, + *, + max_samples: Optional[int] = None, + split: Optional[str] = None, + seed: Optional[int] = None, + ) -> None: + rows = list(_QUESTIONS) + + if seed is not None: + rng = random.Random(seed) + rng.shuffle(rows) + + if max_samples is not None: + rows = rows[:max_samples] + + self._records = [] + for idx, q in enumerate(rows): + prompt = _PROMPT_TEMPLATE.format( + question=q["question"], + domain=q["domain"], + scope=q["scope"], + ) + self._records.append(EvalRecord( + record_id=f"research-mining-{idx}", + problem=prompt, + reference=q["key_facts"], + category="use-case", + subject="research_mining", + metadata={"domain": q["domain"], "scope": q["scope"]}, + )) + + def iter_records(self) -> Iterable[EvalRecord]: + return iter(self._records) + + def size(self) -> int: + return len(self._records) + + +__all__ = ["ResearchMiningDataset"] diff --git a/src/openjarvis/evals/datasets/terminalbench_native.py b/src/openjarvis/evals/datasets/terminalbench_native.py index 97fc709e..eca582b7 100644 --- a/src/openjarvis/evals/datasets/terminalbench_native.py +++ b/src/openjarvis/evals/datasets/terminalbench_native.py @@ -112,6 +112,8 @@ class TerminalBenchNativeDataset(DatasetProvider): "tags": getattr(task, "tags", None), "difficulty": getattr(task, "difficulty", None), "timeout": getattr(task, "timeout", None), + "task": task, + "task_paths": task_paths, } return EvalRecord( @@ -124,4 +126,25 @@ class TerminalBenchNativeDataset(DatasetProvider): ) + def create_task_env(self, record): + """Return a TerminalBenchTaskEnv for the given record.""" + try: + from openjarvis.evals.execution.terminalbench_env import ( + TerminalBenchTaskEnv, + ) + return TerminalBenchTaskEnv(record.metadata) + except ImportError: + return None + + def verify_requirements(self): + """Check that terminal-bench and docker are available.""" + issues = [] + if not _HAS_TERMINALBENCH: + issues.append("terminal-bench package not installed") + import shutil + if not shutil.which("docker"): + issues.append("docker not found in PATH") + return issues + + __all__ = ["TerminalBenchNativeDataset"] diff --git a/src/openjarvis/evals/execution/__init__.py b/src/openjarvis/evals/execution/__init__.py new file mode 100644 index 00000000..1ae9fafa --- /dev/null +++ b/src/openjarvis/evals/execution/__init__.py @@ -0,0 +1 @@ +"""Execution environments for agentic eval workloads.""" diff --git a/src/openjarvis/evals/execution/terminalbench_env.py b/src/openjarvis/evals/execution/terminalbench_env.py new file mode 100644 index 00000000..07f9dd98 --- /dev/null +++ b/src/openjarvis/evals/execution/terminalbench_env.py @@ -0,0 +1,187 @@ +"""TerminalBench task environment — per-task Docker lifecycle + test execution.""" + +from __future__ import annotations + +import logging +import tempfile +from pathlib import Path +from types import TracebackType +from typing import Any, MutableMapping, Optional, Type + +LOGGER = logging.getLogger(__name__) + + +class TerminalBenchTaskEnv: + """Per-task Docker environment for TerminalBench. + + Context manager that spins up a Docker container, creates a tmux session, + and runs test scripts after the agent finishes. + """ + + def __init__(self, metadata: MutableMapping[str, Any]) -> None: + self._metadata = metadata + self._terminal: Any = None + self._terminal_cm: Any = None + self._logs_tmpdir: Any = None + + # ------------------------------------------------------------------ + # Context manager + # ------------------------------------------------------------------ + + def __enter__(self) -> TerminalBenchTaskEnv: + from terminal_bench.terminal.terminal import spin_up_terminal + + task = self._metadata.get("task") + task_paths = self._metadata.get("task_paths") + task_id = self._metadata.get("task_id", "unknown") + + if task is None or task_paths is None: + raise ValueError( + "Task metadata missing 'task' or 'task_paths'. " + "Use the 'terminalbench-native' dataset." + ) + + docker_image_prefix = f"tb__{task_id}".replace(".", "-") + client_image_name = f"{docker_image_prefix}__client" + client_container_name = f"oj-{task_id}".replace(".", "-") + + self._logs_tmpdir = tempfile.TemporaryDirectory(prefix="oj_tb_logs_") + logs_path = Path(self._logs_tmpdir.name) + + self._terminal_cm = spin_up_terminal( + client_container_name=client_container_name, + client_image_name=client_image_name, + docker_compose_path=task_paths.docker_compose_path, + docker_image_name_prefix=docker_image_prefix, + sessions_logs_path=logs_path, + disable_recording=task.disable_asciinema, + ) + self._terminal = self._terminal_cm.__enter__() + + session = self._terminal.create_session( + "agent", is_active_stream=False, as_configured_user=True + ) + + self._metadata["terminal"] = self._terminal + self._metadata["session"] = session + self._metadata["container"] = client_container_name + + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + self._metadata.pop("terminal", None) + self._metadata.pop("session", None) + self._metadata.pop("container", None) + + if self._terminal_cm is not None: + self._terminal_cm.__exit__(exc_type, exc_val, exc_tb) + self._terminal_cm = None + self._terminal = None + + if self._logs_tmpdir is not None: + self._logs_tmpdir.cleanup() + self._logs_tmpdir = None + + # ------------------------------------------------------------------ + # Test execution + # ------------------------------------------------------------------ + + def run_tests(self) -> tuple[bool, dict[str, Any]]: + """Copy test scripts into container, execute, parse results.""" + from terminal_bench.parsers.base_parser import UnitTestStatus + from terminal_bench.parsers.parser_factory import ParserFactory + from terminal_bench.terminal.docker_compose_manager import ( + DockerComposeManager, + ) + + task = self._metadata["task"] + task_paths = self._metadata["task_paths"] + terminal = self._terminal + results: dict[str, Any] = {} + + if terminal is None: + results["error"] = "terminal_not_running" + self._metadata["is_resolved"] = False + self._metadata["test_results"] = results + return False, results + + try: + paths_to_copy = [task_paths.run_tests_path] + if task_paths.test_dir.exists(): + paths_to_copy.append(task_paths.test_dir) + + terminal.copy_to_container( + paths=paths_to_copy, + container_dir=str(DockerComposeManager.CONTAINER_TEST_DIR), + ) + + if not task.run_tests_in_same_shell: + test_session = terminal.create_session( + "tests", is_active_stream=False, as_configured_user=False + ) + else: + test_session = terminal.create_session( + "agent-tests", + is_active_stream=False, + as_configured_user=True, + ) + + test_timeout = task.max_test_timeout_sec + test_script_path = ( + DockerComposeManager.CONTAINER_TEST_DIR + / task_paths.run_tests_path.name + ) + + try: + test_session.send_keys( + ["bash ", str(test_script_path), "Enter"], + block=True, + max_timeout_sec=test_timeout, + ) + except TimeoutError: + LOGGER.warning( + "Test command timed out after %.0fs", test_timeout + ) + results["error"] = "test_timeout" + self._metadata["is_resolved"] = False + self._metadata["test_results"] = results + return False, results + + post_test_pane = test_session.capture_pane(capture_entire=True) + results["test_output"] = post_test_pane[:10000] + + parser = ParserFactory.get_parser(task.parser_name) + try: + parser_results = parser.parse(post_test_pane) + results["parser_results"] = { + name: status.value + for name, status in parser_results.items() + } + is_resolved = all( + status == UnitTestStatus.PASSED + for status in parser_results.values() + ) + except Exception as exc: + LOGGER.warning("Parser failed: %s", exc) + results["parse_error"] = str(exc) + is_resolved = False + + results["is_resolved"] = is_resolved + self._metadata["is_resolved"] = is_resolved + self._metadata["test_results"] = results + return is_resolved, results + + except Exception as exc: + LOGGER.exception("Test execution failed") + results["error"] = str(exc) + self._metadata["is_resolved"] = False + self._metadata["test_results"] = results + return False, results + + +__all__ = ["TerminalBenchTaskEnv"] diff --git a/src/openjarvis/evals/scorers/coding_task.py b/src/openjarvis/evals/scorers/coding_task.py new file mode 100644 index 00000000..7329096d --- /dev/null +++ b/src/openjarvis/evals/scorers/coding_task.py @@ -0,0 +1,142 @@ +"""Coding task scorer — test pass rate + structural validation. + +Extracts the function/class from model output and runs test cases +to determine correctness. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Dict, Optional, Tuple + +from openjarvis.evals.core.scorer import Scorer +from openjarvis.evals.core.types import EvalRecord + +LOGGER = logging.getLogger(__name__) + + +def _extract_code(answer: str) -> str: + """Extract Python code from model answer, handling markdown fences.""" + # Try markdown code fence first + fence_match = re.search( + r"```(?:python)?\s*\n(.*?)```", + answer, + re.DOTALL, + ) + if fence_match: + return fence_match.group(1).strip() + + # Look for function/class definitions + lines = answer.strip().split("\n") + code_lines = [] + in_code = False + for line in lines: + stripped = line.lstrip() + if stripped.startswith(("def ", "class ", "from ", "import ")): + in_code = True + if in_code: + code_lines.append(line) + + if code_lines: + return "\n".join(code_lines) + + # Last resort: return the whole answer + return answer.strip() + + +def _run_tests(code: str, test_cases: str) -> Tuple[int, int, str]: + """Execute code + tests in a restricted namespace. Returns (passed, total, error).""" + namespace: Dict[str, Any] = {} + + try: + exec(code, namespace) # noqa: S102 + except Exception as exc: + return 0, 0, f"Code execution error: {exc}" + + # Parse individual test assertions + test_lines = [ + line.strip() + for line in test_cases.strip().split("\n") + if line.strip() + ] + + passed = 0 + total = 0 + + # Some tests span multiple lines (setup + assert), so we run + # the whole block together but count assertions + try: + exec(test_cases, namespace) # noqa: S102 + # Count assertions in the test code + total = sum(1 for line in test_lines if "assert " in line) + passed = total + return passed, total, "" + except AssertionError as exc: + # Count how many assertions were in the code + total = sum(1 for line in test_lines if "assert " in line) + # Run line by line to count individual passes + passed = 0 + for line in test_lines: + if "assert " not in line: + try: + exec(line, namespace) # noqa: S102 + except Exception: + pass + continue + try: + exec(line, namespace) # noqa: S102 + passed += 1 + except (AssertionError, Exception): + pass + return passed, total, str(exc) + except Exception as exc: + total = sum(1 for line in test_lines if "assert " in line) + return 0, max(total, 1), f"Test execution error: {exc}" + + +class CodingTaskScorer(Scorer): + """Score coding tasks by running test cases against model output.""" + + scorer_id = "coding_task" + + def __init__(self, judge_backend=None, judge_model: str = "") -> None: + # Accept same constructor args as LLMJudgeScorer for compatibility + # but don't need the judge for this scorer + pass + + def score( + self, record: EvalRecord, model_answer: str, + ) -> Tuple[Optional[bool], Dict[str, Any]]: + if not model_answer or not model_answer.strip(): + return False, {"reason": "empty_response"} + + test_cases = record.metadata.get("test_cases", "") + if not test_cases: + return None, {"reason": "no_test_cases"} + + code = _extract_code(model_answer) + if not code: + return False, {"reason": "no_code_extracted"} + + passed, total, error = _run_tests(code, test_cases) + + if total == 0: + return None, {"reason": "no_assertions_found"} + + pass_rate = passed / total + is_correct = pass_rate == 1.0 + + meta: Dict[str, Any] = { + "match_type": "test_execution", + "tests_passed": passed, + "tests_total": total, + "pass_rate": pass_rate, + } + if error: + meta["error"] = error + + return is_correct, meta + + +__all__ = ["CodingTaskScorer"] diff --git a/src/openjarvis/evals/scorers/email_triage.py b/src/openjarvis/evals/scorers/email_triage.py new file mode 100644 index 00000000..0023d74a --- /dev/null +++ b/src/openjarvis/evals/scorers/email_triage.py @@ -0,0 +1,134 @@ +"""Email triage scorer — classification accuracy + draft quality. + +Scores urgency and category via exact match, with LLM fallback +for draft reply quality assessment. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Dict, Optional, Tuple + +from openjarvis.evals.core.scorer import LLMJudgeScorer +from openjarvis.evals.core.types import EvalRecord + +LOGGER = logging.getLogger(__name__) + +_DRAFT_JUDGE_PROMPT = """You are evaluating an AI-generated email triage response. + +The AI was asked to classify an email and draft a reply. + +Reference classification: +{reference} + +AI response: +{response} + +Evaluate: +1. Did the AI correctly identify the urgency level? (critical/high/medium/low) +2. Did the AI correctly identify the category? (action/decision/info/social) +3. Is the draft reply appropriate, professional, and helpful? + +Your response MUST use exactly this format: +urgency_correct: +category_correct: +draft_quality: +reasoning: +overall_correct: """ + + +def _extract_field(text: str, field: str) -> str: + """Extract a field value from structured model output.""" + match = re.search( + rf"^{field}\s*:\s*(.+)", + text, + re.MULTILINE | re.IGNORECASE, + ) + return match.group(1).strip().lower() if match else "" + + +class EmailTriageScorer(LLMJudgeScorer): + """Score email triage: classification accuracy + draft quality.""" + + scorer_id = "email_triage" + + def score( + self, record: EvalRecord, model_answer: str, + ) -> Tuple[Optional[bool], Dict[str, Any]]: + if not model_answer or not model_answer.strip(): + return False, {"reason": "empty_response"} + + ref_urgency = record.metadata.get("urgency", "") + ref_category = record.metadata.get("category", "") + + # Try exact field extraction first + pred_urgency = _extract_field(model_answer, "urgency") + pred_category = _extract_field(model_answer, "category") + + urgency_match = pred_urgency == ref_urgency + category_match = pred_category == ref_category + + # If both match exactly, score as correct without LLM call + if urgency_match and category_match: + return True, { + "match_type": "exact", + "urgency_correct": True, + "category_correct": True, + "pred_urgency": pred_urgency, + "pred_category": pred_category, + } + + # Fall back to LLM judge for ambiguous cases + try: + prompt = _DRAFT_JUDGE_PROMPT.format( + reference=record.reference, + response=model_answer, + ) + raw = self._ask_judge(prompt, temperature=0.0, max_tokens=512) + + overall_match = re.search( + r"^overall_correct:\s*(yes|no)", + raw, + re.MULTILINE | re.IGNORECASE, + ) + is_correct = ( + overall_match.group(1).lower() == "yes" + if overall_match + else (urgency_match and category_match) + ) + + urg_match = re.search( + r"^urgency_correct:\s*(yes|no)", + raw, + re.MULTILINE | re.IGNORECASE, + ) + cat_match = re.search( + r"^category_correct:\s*(yes|no)", + raw, + re.MULTILINE | re.IGNORECASE, + ) + + return is_correct, { + "match_type": "llm_judge", + "urgency_correct": ( + urg_match.group(1).lower() == "yes" if urg_match else urgency_match + ), + "category_correct": ( + cat_match.group(1).lower() == "yes" if cat_match else category_match + ), + "pred_urgency": pred_urgency, + "pred_category": pred_category, + "raw_judge_output": raw, + } + except Exception as exc: + LOGGER.error("Email triage LLM judge failed: %s", exc) + return urgency_match and category_match, { + "match_type": "exact_fallback", + "urgency_correct": urgency_match, + "category_correct": category_match, + "error": str(exc), + } + + +__all__ = ["EmailTriageScorer"] diff --git a/src/openjarvis/evals/scorers/knowledge_base.py b/src/openjarvis/evals/scorers/knowledge_base.py new file mode 100644 index 00000000..10e0a3e6 --- /dev/null +++ b/src/openjarvis/evals/scorers/knowledge_base.py @@ -0,0 +1,120 @@ +"""Knowledge base scorer — answer correctness via normalized match + LLM fallback. + +Evaluates document-grounded QA by checking if the model answer +matches the reference answer using exact match with normalization, +falling back to LLM judge for semantic comparison. +""" + +from __future__ import annotations + +import logging +import re +import string +from typing import Any, Dict, Optional, Tuple + +from openjarvis.evals.core.scorer import LLMJudgeScorer +from openjarvis.evals.core.types import EvalRecord + +LOGGER = logging.getLogger(__name__) + + +def _normalize(text: str) -> str: + """Lowercase, remove punctuation, collapse whitespace.""" + text = text.lower() + text = text.translate(str.maketrans("", "", string.punctuation)) + return " ".join(text.split()) + + +def _contains_key_phrases(answer: str, reference: str) -> bool: + """Check if answer contains the key phrases from the reference.""" + ref_norm = _normalize(reference) + ans_norm = _normalize(answer) + + # Split reference into key phrases (sentences or comma-separated items) + phrases = [p.strip() for p in re.split(r"[.,;]", ref_norm) if p.strip()] + if not phrases: + return False + + matched = sum(1 for p in phrases if p in ans_norm) + return matched / len(phrases) >= 0.5 + + +_LLM_JUDGE_PROMPT = """You are evaluating a knowledge base QA response. + +The AI was given document excerpts and asked a question. It should answer based solely on the provided documents. + +Reference answer: {reference} +AI answer: {answer} + +Evaluate whether the AI's answer is correct and consistent with the reference. Minor wording differences are acceptable as long as the meaning is preserved. + +Your response MUST use exactly this format: +factually_correct: +completeness: +reasoning: +overall_correct: """ + + +class KnowledgeBaseScorer(LLMJudgeScorer): + """Score knowledge base QA: answer correctness.""" + + scorer_id = "knowledge_base" + + def score( + self, record: EvalRecord, model_answer: str, + ) -> Tuple[Optional[bool], Dict[str, Any]]: + if not model_answer or not model_answer.strip(): + return False, {"reason": "empty_response"} + + reference = record.reference + if not reference or not reference.strip(): + return None, {"reason": "no_ground_truth"} + + # Try phrase containment check first + if _contains_key_phrases(model_answer, reference): + return True, {"match_type": "phrase_match"} + + # Fall back to LLM judge + try: + prompt = _LLM_JUDGE_PROMPT.format( + reference=reference, + answer=model_answer, + ) + raw = self._ask_judge(prompt, temperature=0.0, max_tokens=512) + + overall_match = re.search( + r"^overall_correct:\s*(yes|no)", + raw, + re.MULTILINE | re.IGNORECASE, + ) + is_correct = ( + overall_match.group(1).lower() == "yes" + if overall_match + else False + ) + + completeness_match = re.search( + r"^completeness:\s*(complete|partial|missing)", + raw, + re.MULTILINE | re.IGNORECASE, + ) + completeness = ( + completeness_match.group(1).lower() + if completeness_match + else "unknown" + ) + + return is_correct, { + "match_type": "llm_judge", + "completeness": completeness, + "raw_judge_output": raw, + } + except Exception as exc: + LOGGER.error("Knowledge base LLM judge failed: %s", exc) + return False, { + "match_type": "llm_judge_error", + "error": str(exc), + } + + +__all__ = ["KnowledgeBaseScorer"] diff --git a/src/openjarvis/evals/scorers/morning_brief.py b/src/openjarvis/evals/scorers/morning_brief.py new file mode 100644 index 00000000..b5bf8a38 --- /dev/null +++ b/src/openjarvis/evals/scorers/morning_brief.py @@ -0,0 +1,117 @@ +"""Morning brief scorer — LLM judge for briefing quality. + +Evaluates completeness, prioritization, conciseness, and actionability +of generated morning briefings. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Dict, Optional, Tuple + +from openjarvis.evals.core.scorer import LLMJudgeScorer +from openjarvis.evals.core.types import EvalRecord + +LOGGER = logging.getLogger(__name__) + +_JUDGE_PROMPT = """You are evaluating an AI-generated morning briefing. + +The AI was given calendar events, todos, news topics, and pending messages, and asked to produce a prioritized morning brief. + +Key priorities that should be highlighted: +{key_priorities} + +Original user context (summary): +{problem_excerpt} + +AI-generated briefing: +{response} + +Evaluate the briefing on these criteria: +1. Completeness: Does it cover calendar, todos, messages, and news? +2. Prioritization: Are the most urgent/important items highlighted first? +3. Conciseness: Is it well-organized and scannable, not too verbose? +4. Actionability: Does it clearly indicate what needs attention today? +5. Key priorities: Does it mention the key priorities listed above? + +Your response MUST use exactly this format: +completeness: <1-5> +prioritization: <1-5> +conciseness: <1-5> +actionability: <1-5> +key_priorities_covered: +reasoning: +overall_correct: """ + + +class MorningBriefScorer(LLMJudgeScorer): + """Score morning briefings on quality dimensions.""" + + scorer_id = "morning_brief" + + def score( + self, record: EvalRecord, model_answer: str, + ) -> Tuple[Optional[bool], Dict[str, Any]]: + if not model_answer or not model_answer.strip(): + return False, {"reason": "empty_response"} + + # Truncate problem for the judge prompt to save tokens + problem_excerpt = record.problem[:500] + "..." if len(record.problem) > 500 else record.problem + + try: + prompt = _JUDGE_PROMPT.format( + key_priorities=record.reference, + problem_excerpt=problem_excerpt, + response=model_answer, + ) + raw = self._ask_judge(prompt, temperature=0.0, max_tokens=512) + + # Extract scores + scores = {} + for dim in ("completeness", "prioritization", "conciseness", "actionability"): + match = re.search( + rf"^{dim}:\s*(\d)", + raw, + re.MULTILINE | re.IGNORECASE, + ) + scores[dim] = int(match.group(1)) if match else 3 + + priorities_match = re.search( + r"^key_priorities_covered:\s*(yes|partial|no)", + raw, + re.MULTILINE | re.IGNORECASE, + ) + priorities_covered = ( + priorities_match.group(1).lower() if priorities_match else "partial" + ) + + overall_match = re.search( + r"^overall_correct:\s*(yes|no)", + raw, + re.MULTILINE | re.IGNORECASE, + ) + + # Consider correct if average score >= 3.5 and priorities at least partial + avg_score = sum(scores.values()) / len(scores) + if overall_match: + is_correct = overall_match.group(1).lower() == "yes" + else: + is_correct = avg_score >= 3.5 and priorities_covered != "no" + + return is_correct, { + "match_type": "llm_judge", + "scores": scores, + "avg_score": avg_score, + "key_priorities_covered": priorities_covered, + "raw_judge_output": raw, + } + except Exception as exc: + LOGGER.error("Morning brief LLM judge failed: %s", exc) + return None, { + "match_type": "llm_judge_error", + "error": str(exc), + } + + +__all__ = ["MorningBriefScorer"] diff --git a/src/openjarvis/evals/scorers/research_mining.py b/src/openjarvis/evals/scorers/research_mining.py new file mode 100644 index 00000000..2682df38 --- /dev/null +++ b/src/openjarvis/evals/scorers/research_mining.py @@ -0,0 +1,105 @@ +"""Research mining scorer — LLM judge for research synthesis quality. + +Evaluates accuracy, depth, source quality, and synthesis of +AI-generated research responses. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Dict, Optional, Tuple + +from openjarvis.evals.core.scorer import LLMJudgeScorer +from openjarvis.evals.core.types import EvalRecord + +LOGGER = logging.getLogger(__name__) + +_JUDGE_PROMPT = """You are evaluating an AI-generated research response. + +The AI was asked to research a topic and provide key findings, supporting evidence, and a synthesis. + +Expected key facts/topics that should be covered: +{key_facts} + +Research question: +{question} + +AI response: +{response} + +Evaluate the response on these criteria: +1. Accuracy: Are the stated facts correct and not hallucinated? +2. Coverage: Does it address the key facts/topics listed above? +3. Depth: Does it go beyond surface-level observations? +4. Structure: Is it well-organized with clear findings and synthesis? +5. Relevance: Does it stay focused on the research question? + +Your response MUST use exactly this format: +accuracy: <1-5> +coverage: <1-5> +depth: <1-5> +structure: <1-5> +relevance: <1-5> +reasoning: +overall_correct: """ + + +class ResearchMiningScorer(LLMJudgeScorer): + """Score research mining responses on quality dimensions.""" + + scorer_id = "research_mining" + + def score( + self, record: EvalRecord, model_answer: str, + ) -> Tuple[Optional[bool], Dict[str, Any]]: + if not model_answer or not model_answer.strip(): + return False, {"reason": "empty_response"} + + # Extract original question from the prompt + question = record.problem.split("Research question: ")[-1].split("\n")[0].strip() + + try: + prompt = _JUDGE_PROMPT.format( + key_facts=record.reference, + question=question, + response=model_answer, + ) + raw = self._ask_judge(prompt, temperature=0.0, max_tokens=512) + + scores = {} + for dim in ("accuracy", "coverage", "depth", "structure", "relevance"): + match = re.search( + rf"^{dim}:\s*(\d)", + raw, + re.MULTILINE | re.IGNORECASE, + ) + scores[dim] = int(match.group(1)) if match else 3 + + overall_match = re.search( + r"^overall_correct:\s*(yes|no)", + raw, + re.MULTILINE | re.IGNORECASE, + ) + + avg_score = sum(scores.values()) / len(scores) + if overall_match: + is_correct = overall_match.group(1).lower() == "yes" + else: + is_correct = avg_score >= 3.5 + + return is_correct, { + "match_type": "llm_judge", + "scores": scores, + "avg_score": avg_score, + "raw_judge_output": raw, + } + except Exception as exc: + LOGGER.error("Research mining LLM judge failed: %s", exc) + return None, { + "match_type": "llm_judge_error", + "error": str(exc), + } + + +__all__ = ["ResearchMiningScorer"] diff --git a/src/openjarvis/server/app.py b/src/openjarvis/server/app.py index 806f06f6..ca0f5e10 100644 --- a/src/openjarvis/server/app.py +++ b/src/openjarvis/server/app.py @@ -10,6 +10,7 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from openjarvis.server.api_routes import include_all_routes +from openjarvis.server.comparison import comparison_router from openjarvis.server.dashboard import dashboard_router from openjarvis.server.routes import router @@ -122,6 +123,7 @@ def create_app( app.include_router(router) app.include_router(dashboard_router) + app.include_router(comparison_router) include_all_routes(app) # Add security headers middleware diff --git a/src/openjarvis/server/comparison.py b/src/openjarvis/server/comparison.py new file mode 100644 index 00000000..89f58436 --- /dev/null +++ b/src/openjarvis/server/comparison.py @@ -0,0 +1,448 @@ +"""Comparison page -- static cost comparison of local vs cloud inference.""" + +from __future__ import annotations + +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +comparison_router = APIRouter() + +COMPARISON_HTML = """\ + + + + + +OpenJarvis — Cost Comparison + + + + +
+

OpenJarvis Cost Comparison

+ +
+ +
+ + +
+

Run AI Locally — $0 API Costs

+
$0
+
estimated annual savings vs cloud APIs
+
+ OpenJarvis runs models on your own hardware. Every inference call that would + cost money on a cloud API is completely free when running locally. +
+
+ + +
Choose a Use Case
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
OpenJarvis (Local)GPT-5.3Claude Opus 4.6Gemini 3.1 Pro
Monthly Cost$0.00------
Annual Cost$0.00------
+
+ + +
+

Interactive Cost Calculator

+
+
+ +
50
+ +
+
+ +
1000
+ +
+
+
+
+
OpenJarvis
+
$0.00/mo
+
+
+
GPT-5.3
+
--
+
+
+
Claude Opus 4.6
+
--
+
+
+
Gemini 3.1 Pro
+
--
+
+
+
+ + +
+

Start Saving Today

+
Install OpenJarvis and run AI locally + with zero API costs.
+
+ pip install openjarvis + +
+
+ +
+ + + + +""" + + +@comparison_router.get("/comparison", response_class=HTMLResponse) +async def comparison(): + """Serve the cost comparison page.""" + return HTMLResponse(content=COMPARISON_HTML) + + +__all__ = ["comparison_router"] diff --git a/src/openjarvis/server/cost_calculator.py b/src/openjarvis/server/cost_calculator.py new file mode 100644 index 00000000..a79459cc --- /dev/null +++ b/src/openjarvis/server/cost_calculator.py @@ -0,0 +1,135 @@ +"""Cost calculator -- estimate monthly cloud API costs for common use cases.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List + +from openjarvis.server.savings import CLOUD_PRICING + + +@dataclass(slots=True) +class CostEstimate: + """Estimated cost for a provider given a usage scenario.""" + provider: str + label: str + monthly_cost: float + annual_cost: float + input_cost: float + output_cost: float + total_calls_per_month: int + + +@dataclass(slots=True) +class Scenario: + """A prebuilt usage scenario.""" + name: str + label: str + description: str + calls_per_month: int + avg_input_tokens: int + avg_output_tokens: int + + +SCENARIOS: Dict[str, Scenario] = { + "daily_briefing": Scenario( + name="daily_briefing", + label="Daily Briefing", + description="Morning brief every 5 minutes, 24/7", + calls_per_month=8_640, + avg_input_tokens=500, + avg_output_tokens=200, + ), + "email_triage": Scenario( + name="email_triage", + label="Email Triage", + description="Email classification and drafting every 5 minutes", + calls_per_month=8_640, + avg_input_tokens=800, + avg_output_tokens=300, + ), + "research_assistant": Scenario( + name="research_assistant", + label="Research Assistant", + description="Deep research queries, ~20 per day", + calls_per_month=600, + avg_input_tokens=2_000, + avg_output_tokens=1_500, + ), + "overnight_coder": Scenario( + name="overnight_coder", + label="Overnight Coder", + description="Automated coding tasks, ~100 per night", + calls_per_month=3_000, + avg_input_tokens=3_000, + avg_output_tokens=2_000, + ), + "always_on": Scenario( + name="always_on", + label="Always-On (All Above)", + description="All use cases combined", + calls_per_month=20_880, + avg_input_tokens=1_200, + avg_output_tokens=700, + ), +} + + +def estimate_monthly_cost( + calls_per_month: int, + avg_input_tokens: int, + avg_output_tokens: int, + provider_key: str, +) -> CostEstimate: + """Estimate monthly cost for a provider given usage parameters.""" + pricing = CLOUD_PRICING.get(provider_key) + if pricing is None: + raise ValueError(f"Unknown provider: {provider_key}") + + total_input = calls_per_month * avg_input_tokens + total_output = calls_per_month * avg_output_tokens + + input_cost = (total_input / 1_000_000) * pricing["input_per_1m"] + output_cost = (total_output / 1_000_000) * pricing["output_per_1m"] + monthly = input_cost + output_cost + + return CostEstimate( + provider=provider_key, + label=str(pricing["label"]), + monthly_cost=monthly, + annual_cost=monthly * 12, + input_cost=input_cost, + output_cost=output_cost, + total_calls_per_month=calls_per_month, + ) + + +def estimate_scenario(scenario_name: str) -> List[CostEstimate]: + """Estimate costs for all providers for a named scenario.""" + scenario = SCENARIOS.get(scenario_name) + if scenario is None: + raise ValueError(f"Unknown scenario: {scenario_name}") + return [ + estimate_monthly_cost( + scenario.calls_per_month, + scenario.avg_input_tokens, + scenario.avg_output_tokens, + provider_key, + ) + for provider_key in CLOUD_PRICING + ] + + +def estimate_all_scenarios() -> Dict[str, List[CostEstimate]]: + """Estimate costs for all scenarios and all providers.""" + return {name: estimate_scenario(name) for name in SCENARIOS} + + +__all__ = [ + "SCENARIOS", + "CostEstimate", + "Scenario", + "estimate_all_scenarios", + "estimate_monthly_cost", + "estimate_scenario", +] diff --git a/src/openjarvis/server/dashboard.py b/src/openjarvis/server/dashboard.py index 16398f42..3430dadb 100644 --- a/src/openjarvis/server/dashboard.py +++ b/src/openjarvis/server/dashboard.py @@ -184,7 +184,7 @@ DASHBOARD_HTML = """\
OpenAI
-
GPT 5.2 — $1.75 / $14.00 per 1M tokens
+
GPT-5.3 — $2.00 / $10.00 per 1M tokens
$0.00
@@ -229,16 +229,64 @@ DASHBOARD_HTML = """\
+ +
Monthly Projection
+
+
+
vs OpenAI
+
projected monthly savings
+
$0.00
+
per month at current rate
+
+
+
vs Anthropic
+
projected monthly savings
+
$0.00
+
per month at current rate
+
+
+
vs Google
+
projected monthly savings
+
$0.00
+
per month at current rate
+
+
+ + +
vs Cloud Agent Platforms
+
+
+
Typical Cloud Agent Platform
+
based on published API pricing tiers
+
+
+
MODERATE USE
+
$15–60/mo
+
+
+
HEAVY USE
+
$100–400+/mo
+
+
+
YOUR COST
+
$0.00
+
local inference
+
+
+
+
+
Energy & Compute Avoided
-
Energy Saved (vs GPT 5.2)
+
Energy Saved (vs GPT-5.3)
0 J
0 kWh of cloud datacenter energy avoided
-
FLOPs Avoided (vs GPT 5.2)
+
FLOPs Avoided (vs GPT-5.3)
0 FLOP
cloud compute operations not needed
@@ -306,8 +354,8 @@ async function refresh() { providerMap[p.provider] = p; }); - // OpenAI / GPT 5.2 - const oa = providerMap['gpt-5.2'] || {}; + // OpenAI / GPT-5.3 + const oa = providerMap['gpt-5.3'] || {}; document.getElementById('save-openai') .textContent = fmtDollar(oa.total_cost || 0); document.getElementById('save-openai-in') @@ -333,7 +381,16 @@ async function refresh() { document.getElementById('save-google-out') .textContent = fmtDollar(go.output_cost || 0); - // Energy / FLOPs (use GPT 5.2 as reference) + // Monthly projections + const proj = d.monthly_projection || {}; + document.getElementById('proj-openai') + .textContent = fmtDollar(proj['gpt-5.3'] || 0); + document.getElementById('proj-anthropic') + .textContent = fmtDollar(proj['claude-opus-4.6'] || 0); + document.getElementById('proj-google') + .textContent = fmtDollar(proj['gemini-3.1-pro'] || 0); + + // Energy / FLOPs (use GPT-5.3 as reference) const ej = oa.energy_joules || 0; const eWh = oa.energy_wh || 0; const fl = oa.flops || 0; diff --git a/src/openjarvis/server/routes.py b/src/openjarvis/server/routes.py index a26f2397..9f847919 100644 --- a/src/openjarvis/server/routes.py +++ b/src/openjarvis/server/routes.py @@ -243,6 +243,7 @@ async def savings(request: Request): prompt_tokens=sum(m.prompt_tokens for m in summary.per_model), completion_tokens=sum(m.completion_tokens for m in summary.per_model), total_calls=summary.total_calls, + session_start=session_start if session_start else 0.0, ) return savings_to_dict(result) finally: diff --git a/src/openjarvis/server/savings.py b/src/openjarvis/server/savings.py index af6b95cb..020c23e5 100644 --- a/src/openjarvis/server/savings.py +++ b/src/openjarvis/server/savings.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from dataclasses import asdict, dataclass, field from typing import Any, Dict, List @@ -61,20 +62,33 @@ class SavingsSummary: total_tokens: int = 0 local_cost: float = 0.0 # always 0 for local inference per_provider: List[ProviderSavings] = field(default_factory=list) + monthly_projection: Dict[str, float] = field(default_factory=dict) + session_start_ts: float = 0.0 + session_duration_hours: float = 0.0 + avg_cost_per_query: Dict[str, float] = field(default_factory=dict) + cloud_agent_equivalent: Dict[str, int] = field(default_factory=dict) def compute_savings( prompt_tokens: int, completion_tokens: int, total_calls: int = 0, + session_start: float = 0.0, ) -> SavingsSummary: """Compute savings vs cloud providers given token counts.""" total_tokens = prompt_tokens + completion_tokens providers: List[ProviderSavings] = [] + now = time.time() + session_duration_hours = (now - session_start) / 3600 if session_start > 0 else 0.0 + + monthly_projection: Dict[str, float] = {} + avg_cost_per_query: Dict[str, float] = {} + for key, pricing in CLOUD_PRICING.items(): input_cost = (prompt_tokens / 1_000_000) * pricing["input_per_1m"] output_cost = (completion_tokens / 1_000_000) * pricing["output_per_1m"] + total_cost = input_cost + output_cost energy_wh = (total_tokens / 1000) * pricing["energy_wh_per_1k_tokens"] flops = total_tokens * pricing["flops_per_token"] @@ -83,12 +97,24 @@ def compute_savings( label=pricing["label"], input_cost=input_cost, output_cost=output_cost, - total_cost=input_cost + output_cost, + total_cost=total_cost, energy_wh=energy_wh, energy_joules=energy_wh * 3600, # 1 Wh = 3600 J flops=flops, )) + # Monthly projection: extrapolate current spend to 720 hours/month + if session_duration_hours > 0: + monthly_projection[key] = (total_cost / session_duration_hours) * 720 + else: + monthly_projection[key] = 0.0 + + # Average cost per query + if total_calls > 0: + avg_cost_per_query[key] = total_cost / total_calls + else: + avg_cost_per_query[key] = 0.0 + return SavingsSummary( total_calls=total_calls, total_prompt_tokens=prompt_tokens, @@ -96,6 +122,16 @@ def compute_savings( total_tokens=total_tokens, local_cost=0.0, per_provider=providers, + monthly_projection=monthly_projection, + session_start_ts=session_start, + session_duration_hours=session_duration_hours, + avg_cost_per_query=avg_cost_per_query, + cloud_agent_equivalent={ + "moderate_low": 15, + "moderate_high": 60, + "heavy_low": 100, + "heavy_high": 400, + }, ) diff --git a/src/openjarvis/telemetry/__init__.py b/src/openjarvis/telemetry/__init__.py index 8c748cd1..f2c68646 100644 --- a/src/openjarvis/telemetry/__init__.py +++ b/src/openjarvis/telemetry/__init__.py @@ -48,6 +48,31 @@ from openjarvis.telemetry.steady_state import ( SteadyStateResult, ) +try: + from openjarvis.telemetry.session import TelemetrySample, TelemetrySession +except ImportError: + pass + +try: + from openjarvis.telemetry.phase_metrics import compute_phase_metrics, split_at_ttft +except ImportError: + pass + +try: + from openjarvis.telemetry.itl import compute_itl_stats +except ImportError: + pass + +try: + from openjarvis.telemetry.flops import ( + GPU_PEAK_TFLOPS_BF16, + MODEL_PARAMS_B, + compute_mfu, + estimate_flops, + ) +except ImportError: + pass + __all__ = [ "AggregatedStats", "BatchMetrics", @@ -69,6 +94,15 @@ __all__ = [ "SteadyStateConfig", "SteadyStateDetector", "SteadyStateResult", + "TelemetrySession", + "TelemetrySample", + "compute_phase_metrics", + "split_at_ttft", + "compute_itl_stats", + "estimate_flops", + "compute_mfu", + "GPU_PEAK_TFLOPS_BF16", + "MODEL_PARAMS_B", "compute_efficiency", "create_energy_monitor", "instrumented_generate", diff --git a/src/openjarvis/telemetry/energy_monitor.py b/src/openjarvis/telemetry/energy_monitor.py index 33ba0d8d..f2b97342 100644 --- a/src/openjarvis/telemetry/energy_monitor.py +++ b/src/openjarvis/telemetry/energy_monitor.py @@ -84,6 +84,14 @@ class EnergyMonitor(ABC): """ yield EnergySample() # pragma: no cover + def snapshot(self) -> EnergySample: + """Return an instantaneous energy reading without start/stop bracket. + + Subclasses should override to provide actual readings. Default returns + an empty sample. + """ + return EnergySample() + @abstractmethod def close(self) -> None: """Release any resources (handles, threads, etc.).""" diff --git a/src/openjarvis/telemetry/flops.py b/src/openjarvis/telemetry/flops.py new file mode 100644 index 00000000..2fd4ce39 --- /dev/null +++ b/src/openjarvis/telemetry/flops.py @@ -0,0 +1,74 @@ +"""FLOPs estimation and Model FLOPs Utilization (MFU) computation.""" + +from __future__ import annotations + +GPU_PEAK_TFLOPS_BF16: dict[str, float] = { + "H100": 989.0, + "H200": 989.0, + "A100": 312.0, + "A10G": 31.2, + "L4": 30.3, + "L40": 181.0, + "L40S": 362.0, + "T4": 65.1, + "V100": 125.0, + "4090": 82.6, + "4080": 48.7, + "3090": 35.6, + "M3 Max": 14.2, + "M3 Ultra": 27.0, + "M4 Max": 18.0, +} + +MODEL_PARAMS_B: dict[str, float] = { + "qwen3:8b": 8.0, + "qwen3:0.6b": 0.6, + "qwen3:4b": 4.0, + "llama-3.1-70b": 70.0, + "llama-3.1-8b": 8.0, + "mistral-7b": 7.0, + "mixtral-8x7b": 47.0, +} + + +def estimate_flops( + model: str, input_tokens: int, output_tokens: int +) -> tuple[float, float]: + """Estimate FLOPs for an inference pass. + + Uses the 2 * P * T approximation where P = params, T = total tokens. + Returns (total_flops, flops_per_token). + """ + params_b = MODEL_PARAMS_B.get(model, 0.0) + if params_b == 0.0: + # Try prefix matching + for key, val in MODEL_PARAMS_B.items(): + if model.startswith(key.split(":")[0]): + params_b = val + break + + total_tokens = input_tokens + output_tokens + params = params_b * 1e9 + total_flops = 2.0 * params * total_tokens + flops_per_token = 2.0 * params if total_tokens > 0 else 0.0 + return (total_flops, flops_per_token) + + +def compute_mfu( + flops: float, duration_s: float, gpu_name: str, num_gpus: int = 1 +) -> float: + """Compute Model FLOPs Utilization. + + MFU = actual_tflops / (peak_tflops * num_gpus) + """ + peak = GPU_PEAK_TFLOPS_BF16.get(gpu_name, 0.0) + if peak == 0.0: + # Try substring matching + for key, val in GPU_PEAK_TFLOPS_BF16.items(): + if key.lower() in gpu_name.lower(): + peak = val + break + if peak <= 0 or duration_s <= 0: + return 0.0 + actual_tflops = flops / (duration_s * 1e12) + return (actual_tflops / (peak * num_gpus)) * 100.0 diff --git a/src/openjarvis/telemetry/itl.py b/src/openjarvis/telemetry/itl.py new file mode 100644 index 00000000..c35fbf84 --- /dev/null +++ b/src/openjarvis/telemetry/itl.py @@ -0,0 +1,47 @@ +"""Inter-token latency percentile computation.""" + +from __future__ import annotations + +import statistics + + +def compute_itl_stats(token_timestamps: list[float]) -> dict: + """Compute ITL statistics from token arrival timestamps (in ms). + + Returns dict with p50_ms, p90_ms, p95_ms, p99_ms, mean_ms, min_ms, max_ms. + """ + if len(token_timestamps) < 2: + return { + "p50_ms": 0, + "p90_ms": 0, + "p95_ms": 0, + "p99_ms": 0, + "mean_ms": 0, + "min_ms": 0, + "max_ms": 0, + } + + # Compute inter-token latencies + itls = [ + token_timestamps[i] - token_timestamps[i - 1] + for i in range(1, len(token_timestamps)) + ] + itls.sort() + + def percentile(data: list[float], p: float) -> float: + k = (len(data) - 1) * p + f = int(k) + c = f + 1 + if c >= len(data): + return data[-1] + return data[f] + (k - f) * (data[c] - data[f]) + + return { + "p50_ms": percentile(itls, 0.50), + "p90_ms": percentile(itls, 0.90), + "p95_ms": percentile(itls, 0.95), + "p99_ms": percentile(itls, 0.99), + "mean_ms": statistics.mean(itls), + "min_ms": min(itls), + "max_ms": max(itls), + } diff --git a/src/openjarvis/telemetry/phase_metrics.py b/src/openjarvis/telemetry/phase_metrics.py new file mode 100644 index 00000000..7f53d499 --- /dev/null +++ b/src/openjarvis/telemetry/phase_metrics.py @@ -0,0 +1,44 @@ +"""Phase metrics computation -- prefill/decode energy separation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from openjarvis.telemetry.session import TelemetrySession + + +def compute_phase_metrics( + session: TelemetrySession, + start_ns: int, + end_ns: int, + tokens: int, +) -> dict: + """Compute energy/power metrics for a phase window.""" + gpu_j, cpu_j = session.energy_delta(start_ns, end_ns) + gpu_w, cpu_w = session.avg_power(start_ns, end_ns) + duration_s = (end_ns - start_ns) / 1e9 + energy_per_token = gpu_j / tokens if tokens > 0 else 0.0 + return { + "energy_j": gpu_j, + "cpu_energy_j": cpu_j, + "mean_power_w": gpu_w, + "cpu_mean_power_w": cpu_w, + "duration_s": duration_s, + "energy_per_token_j": energy_per_token, + "tokens": tokens, + } + + +def split_at_ttft( + session: TelemetrySession, + start_ns: int, + ttft_ns: int, + end_ns: int, + input_tokens: int, + output_tokens: int, +) -> tuple[dict, dict]: + """Split energy at TTFT boundary into prefill and decode phases.""" + prefill = compute_phase_metrics(session, start_ns, ttft_ns, input_tokens) + decode = compute_phase_metrics(session, ttft_ns, end_ns, output_tokens) + return (prefill, decode) diff --git a/src/openjarvis/telemetry/session.py b/src/openjarvis/telemetry/session.py new file mode 100644 index 00000000..d75931f9 --- /dev/null +++ b/src/openjarvis/telemetry/session.py @@ -0,0 +1,145 @@ +"""Background-sampling telemetry session. + +Uses Rust ring buffer when available, with Python fallback. +""" + +from __future__ import annotations + +import threading +import time +from collections import deque +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Deque, List, Optional + +if TYPE_CHECKING: + from openjarvis.telemetry.energy_monitor import EnergyMonitor + + +@dataclass +class TelemetrySample: + """Single telemetry sample.""" + + timestamp_ns: int + gpu_power_w: float = 0.0 + cpu_power_w: float = 0.0 + gpu_energy_j: float = 0.0 + cpu_energy_j: float = 0.0 + gpu_util_pct: float = 0.0 + gpu_temp_c: float = 0.0 + gpu_mem_gb: float = 0.0 + + +class _PythonRingBuffer: + """Pure-Python fallback ring buffer.""" + + def __init__(self, capacity: int = 100_000): + self._data: Deque[TelemetrySample] = deque(maxlen=capacity) + + def push(self, sample: TelemetrySample) -> None: + self._data.append(sample) + + def window(self, start_ns: int, end_ns: int) -> List[TelemetrySample]: + return [s for s in self._data if start_ns <= s.timestamp_ns <= end_ns] + + def compute_energy_delta(self, start_ns: int, end_ns: int) -> tuple[float, float]: + samples = self.window(start_ns, end_ns) + if len(samples) < 2: + return (0.0, 0.0) + # Trapezoidal integration over power samples + gpu_j = 0.0 + cpu_j = 0.0 + for i in range(1, len(samples)): + dt = (samples[i].timestamp_ns - samples[i - 1].timestamp_ns) / 1e9 + gpu_j += 0.5 * (samples[i - 1].gpu_power_w + samples[i].gpu_power_w) * dt + cpu_j += 0.5 * (samples[i - 1].cpu_power_w + samples[i].cpu_power_w) * dt + return (gpu_j, cpu_j) + + def compute_avg_power(self, start_ns: int, end_ns: int) -> tuple[float, float]: + samples = self.window(start_ns, end_ns) + if not samples: + return (0.0, 0.0) + gpu_avg = sum(s.gpu_power_w for s in samples) / len(samples) + cpu_avg = sum(s.cpu_power_w for s in samples) / len(samples) + return (gpu_avg, cpu_avg) + + def clear(self) -> None: + self._data.clear() + + def __len__(self) -> int: + return len(self._data) + + +class TelemetrySession: + """Background-sampling telemetry session. + + Spawns a daemon thread that calls monitor.snapshot() at the configured + interval. Stores samples in a ring buffer (Rust-backed if available, + else pure-Python fallback). + """ + + def __init__( + self, + monitor: Optional[EnergyMonitor] = None, + interval_ms: int = 100, + buffer_size: int = 100_000, + ) -> None: + self._monitor = monitor + self._interval_ms = interval_ms + self._buffer = _PythonRingBuffer(buffer_size) + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + def _sample_loop(self) -> None: + """Daemon thread loop: poll monitor and push samples.""" + interval_s = self._interval_ms / 1000.0 + while not self._stop_event.is_set(): + try: + if self._monitor is not None: + es = self._monitor.snapshot() + sample = TelemetrySample( + timestamp_ns=time.time_ns(), + gpu_power_w=es.mean_power_watts, + cpu_power_w=0.0, + gpu_energy_j=es.gpu_energy_joules, + cpu_energy_j=es.cpu_energy_joules, + gpu_util_pct=es.mean_utilization_pct, + gpu_temp_c=es.mean_temperature_c, + gpu_mem_gb=es.mean_memory_used_gb, + ) + self._buffer.push(sample) + except Exception: + pass + self._stop_event.wait(interval_s) + + def start(self) -> None: + """Start background sampling thread.""" + if self._monitor is None: + return + if self._thread is not None and self._thread.is_alive(): + return + self._stop_event.clear() + self._thread = threading.Thread(target=self._sample_loop, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop sampling thread.""" + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + + def window(self, start_ns: int, end_ns: int) -> List[TelemetrySample]: + return self._buffer.window(start_ns, end_ns) + + def energy_delta(self, start_ns: int, end_ns: int) -> tuple[float, float]: + return self._buffer.compute_energy_delta(start_ns, end_ns) + + def avg_power(self, start_ns: int, end_ns: int) -> tuple[float, float]: + return self._buffer.compute_avg_power(start_ns, end_ns) + + def __enter__(self) -> TelemetrySession: + self.start() + return self + + def __exit__(self, *args: Any) -> None: + self.stop() diff --git a/tests/evals/test_agentic_runner.py b/tests/evals/test_agentic_runner.py new file mode 100644 index 00000000..188b2387 --- /dev/null +++ b/tests/evals/test_agentic_runner.py @@ -0,0 +1,171 @@ +"""Tests for AgenticRunner with mock agent and dataset.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import Any, Dict, List + +import pytest + +from openjarvis.evals.core.agentic_runner import AgenticRunner, _extract_patch + +# --------------------------------------------------------------------------- +# Mock objects +# --------------------------------------------------------------------------- + + +@dataclass +class MockRecord: + record_id: str + problem: str + expected: str = "" + category: str = "test" + metadata: Dict[str, Any] = field(default_factory=dict) + + +class MockDataset: + def __init__(self, records: List[MockRecord]): + self._records = records + + def iter_records(self): + return iter(self._records) + + +class MockAgent: + """Agent that echoes the query.""" + + def ask(self, query: str) -> dict: + return { + "content": f"Response to: {query}", + "usage": {"prompt_tokens": 50, "completion_tokens": 25}, + "cost_usd": 0.001, + } + + +class MockFailingAgent: + """Agent that always raises.""" + + def ask(self, query: str) -> dict: + raise RuntimeError("Agent error") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestAgenticRunner: + def _run_async(self, coro): + return asyncio.get_event_loop().run_until_complete(coro) + + @pytest.fixture(autouse=True) + def _setup_loop(self): + try: + asyncio.get_event_loop() + except RuntimeError: + asyncio.set_event_loop(asyncio.new_event_loop()) + + def test_basic_run(self): + records = [ + MockRecord(record_id="r1", problem="What is 2+2?"), + MockRecord(record_id="r2", problem="What is 3+3?"), + ] + dataset = MockDataset(records) + agent = MockAgent() + runner = AgenticRunner(agent=agent, dataset=dataset) + + traces = self._run_async(runner.run()) + assert len(traces) == 2 + assert all(t.completed for t in traces) + assert traces[0].query_id == "q0000" + assert traces[1].query_id == "q0001" + assert "Response to: What is 2+2?" in traces[0].response_text + + def test_max_queries(self): + records = [MockRecord(record_id=f"r{i}", problem=f"Q{i}") for i in range(10)] + dataset = MockDataset(records) + runner = AgenticRunner(agent=MockAgent(), dataset=dataset) + + traces = self._run_async(runner.run(max_queries=3)) + assert len(traces) == 3 + + def test_agent_failure(self): + records = [MockRecord(record_id="r1", problem="test")] + dataset = MockDataset(records) + runner = AgenticRunner(agent=MockFailingAgent(), dataset=dataset) + + traces = self._run_async(runner.run()) + assert len(traces) == 1 + assert not traces[0].completed + assert "Agent error" in traces[0].response_text + + def test_synthetic_turn_created(self): + records = [MockRecord(record_id="r1", problem="test")] + dataset = MockDataset(records) + runner = AgenticRunner(agent=MockAgent(), dataset=dataset) + + traces = self._run_async(runner.run()) + assert traces[0].num_turns == 1 + assert traces[0].turns[0].input_tokens == 50 + assert traces[0].turns[0].output_tokens == 25 + + def test_traces_property(self): + records = [MockRecord(record_id="r1", problem="test")] + dataset = MockDataset(records) + runner = AgenticRunner(agent=MockAgent(), dataset=dataset) + + self._run_async(runner.run()) + assert len(runner.traces) == 1 + + def test_artifacts_saved(self, tmp_path): + records = [MockRecord(record_id="r1", problem="test")] + dataset = MockDataset(records) + runner = AgenticRunner( + agent=MockAgent(), dataset=dataset, run_dir=tmp_path + ) + + self._run_async(runner.run()) + arts = tmp_path / "artifacts" + assert arts.exists() + subdirs = list(arts.iterdir()) + assert len(subdirs) == 1 + assert (subdirs[0] / "response.txt").exists() + assert (subdirs[0] / "metadata.json").exists() + + def test_query_timeout_configured(self): + """Verify timeout is stored and runner accepts the parameter.""" + records = [MockRecord(record_id="r1", problem="test")] + dataset = MockDataset(records) + runner = AgenticRunner( + agent=MockAgent(), dataset=dataset, query_timeout=30.0 + ) + assert runner._query_timeout == 30.0 + + +class TestExtractPatch: + def test_fenced_diff(self): + text = ( + "Here's the fix:\n```diff\n" + "--- a/foo.py\n+++ b/foo.py\n" + "@@ -1 +1 @@\n-old\n+new\n```\n" + ) + patch = _extract_patch(text) + assert patch is not None + assert "--- a/foo.py" in patch + + def test_unfenced_diff(self): + text = ( + "Some explanation\n" + "diff --git a/x.py b/x.py\n" + "--- a/x.py\n+++ b/x.py\n" + "@@ -1 +1 @@\n-old\n+new\n" + ) + patch = _extract_patch(text) + assert patch is not None + assert "diff --git" in patch + + def test_no_patch(self): + text = "This is just a regular response with no code changes." + patch = _extract_patch(text) + assert patch is None diff --git a/tests/evals/test_benchmark_datasets.py b/tests/evals/test_benchmark_datasets.py index a381a78c..a59db6f9 100644 --- a/tests/evals/test_benchmark_datasets.py +++ b/tests/evals/test_benchmark_datasets.py @@ -266,7 +266,7 @@ class TestConfigBenchmarks: def test_benchmarks_count(self) -> None: from openjarvis.evals.core.config import KNOWN_BENCHMARKS - assert len(KNOWN_BENCHMARKS) == 15 + assert len(KNOWN_BENCHMARKS) == 20 # --------------------------------------------------------------------------- diff --git a/tests/evals/test_eval_trace.py b/tests/evals/test_eval_trace.py new file mode 100644 index 00000000..8d3a73e3 --- /dev/null +++ b/tests/evals/test_eval_trace.py @@ -0,0 +1,151 @@ +"""Tests for QueryTrace and TurnTrace data model.""" + +from __future__ import annotations + +import pytest + +from openjarvis.evals.core.trace import QueryTrace, TurnTrace + + +class TestTurnTrace: + def test_defaults(self): + t = TurnTrace(turn_index=0) + assert t.input_tokens == 0 + assert t.output_tokens == 0 + assert t.tools_called == [] + assert t.gpu_energy_joules is None + assert t.cost_usd is None + + def test_to_dict_roundtrip(self): + t = TurnTrace( + turn_index=1, + input_tokens=100, + output_tokens=50, + tools_called=["calculator"], + tool_latencies_s={"calculator": 0.5}, + wall_clock_s=1.2, + gpu_energy_joules=10.0, + cost_usd=0.001, + ) + d = t.to_dict() + t2 = TurnTrace.from_dict(d) + assert t2.turn_index == 1 + assert t2.input_tokens == 100 + assert t2.output_tokens == 50 + assert t2.tools_called == ["calculator"] + assert t2.tool_latencies_s == {"calculator": 0.5} + assert t2.wall_clock_s == pytest.approx(1.2) + assert t2.gpu_energy_joules == pytest.approx(10.0) + assert t2.cost_usd == pytest.approx(0.001) + + def test_from_dict_missing_keys(self): + d = {"turn_index": 0} + t = TurnTrace.from_dict(d) + assert t.input_tokens == 0 + assert t.tools_called == [] + assert t.error is None + + +class TestQueryTrace: + def _make_trace(self, **kwargs): + defaults = { + "query_id": "q0001", + "workload_type": "coding", + "query_text": "Hello", + "response_text": "World", + "turns": [ + TurnTrace( + turn_index=0, + input_tokens=100, + output_tokens=50, + wall_clock_s=1.0, + gpu_energy_joules=5.0, + cost_usd=0.01, + ), + TurnTrace( + turn_index=1, + input_tokens=150, + output_tokens=75, + wall_clock_s=1.5, + gpu_energy_joules=7.5, + cost_usd=0.015, + ), + ], + "total_wall_clock_s": 2.5, + "completed": True, + } + defaults.update(kwargs) + return QueryTrace(**defaults) + + def test_num_turns(self): + t = self._make_trace() + assert t.num_turns == 2 + + def test_total_tokens(self): + t = self._make_trace() + assert t.total_input_tokens == 250 + assert t.total_output_tokens == 125 + assert t.total_tokens == 375 + + def test_total_gpu_energy(self): + t = self._make_trace() + assert t.total_gpu_energy_joules == pytest.approx(12.5) + + def test_total_gpu_energy_fallback(self): + t = self._make_trace( + turns=[TurnTrace(turn_index=0)], + query_gpu_energy_joules=20.0, + ) + assert t.total_gpu_energy_joules == pytest.approx(20.0) + + def test_total_cost(self): + t = self._make_trace() + assert t.total_cost_usd == pytest.approx(0.025) + + def test_total_cost_none(self): + t = self._make_trace(turns=[TurnTrace(turn_index=0)]) + assert t.total_cost_usd is None + + def test_throughput(self): + t = self._make_trace() + assert t.throughput_tokens_per_sec == pytest.approx(125 / 2.5) + + def test_energy_per_token(self): + t = self._make_trace() + assert t.energy_per_token_joules == pytest.approx(12.5 / 125) + + def test_avg_gpu_power(self): + t = self._make_trace() + # No per-turn power set, so falls back to query-level + assert t.avg_gpu_power_watts is None + + def test_to_dict_roundtrip(self): + t = self._make_trace(is_resolved=True) + d = t.to_dict() + t2 = QueryTrace.from_dict(d) + assert t2.query_id == "q0001" + assert t2.workload_type == "coding" + assert t2.num_turns == 2 + assert t2.completed is True + assert t2.is_resolved is True + assert t2.total_input_tokens == 250 + + def test_save_load_jsonl(self, tmp_path): + t1 = self._make_trace(query_id="q0001") + t2 = self._make_trace(query_id="q0002") + path = tmp_path / "traces.jsonl" + t1.save_jsonl(path) + t2.save_jsonl(path) + loaded = QueryTrace.load_jsonl(path) + assert len(loaded) == 2 + assert loaded[0].query_id == "q0001" + assert loaded[1].query_id == "q0002" + + def test_tool_call_count(self): + t = self._make_trace( + turns=[ + TurnTrace(turn_index=0, tools_called=["calc", "search"]), + TurnTrace(turn_index=1, tools_called=["read"]), + ] + ) + assert t.total_tool_calls == 3 diff --git a/tests/evals/test_event_recorder.py b/tests/evals/test_event_recorder.py new file mode 100644 index 00000000..fab83277 --- /dev/null +++ b/tests/evals/test_event_recorder.py @@ -0,0 +1,89 @@ +"""Tests for EventRecorder thread safety and functionality.""" + +from __future__ import annotations + +import threading + +from openjarvis.evals.core.event_recorder import AgentEvent, EventRecorder, EventType + + +class TestEventType: + def test_enum_values(self): + assert EventType.LM_INFERENCE_START == "lm_inference_start" + assert EventType.TOOL_CALL_END == "tool_call_end" + + def test_all_types_exist(self): + assert len(EventType) == 10 + + +class TestAgentEvent: + def test_creation(self): + e = AgentEvent(event_type="test", timestamp=123.456, metadata={"key": "val"}) + assert e.event_type == "test" + assert e.timestamp == 123.456 + assert e.metadata == {"key": "val"} + + def test_repr(self): + e = AgentEvent(event_type="test", timestamp=1.0) + r = repr(e) + assert "test" in r + assert "1.000" in r + + +class TestEventRecorder: + def test_record_and_get(self): + rec = EventRecorder() + rec.record("tool_call_start", tool="calc") + rec.record("tool_call_end", tool="calc") + events = rec.get_events() + assert len(events) == 2 + assert events[0].event_type == "tool_call_start" + assert events[0].metadata["tool"] == "calc" + assert events[1].event_type == "tool_call_end" + + def test_len(self): + rec = EventRecorder() + assert len(rec) == 0 + rec.record("test") + assert len(rec) == 1 + + def test_clear(self): + rec = EventRecorder() + rec.record("test") + rec.clear() + assert len(rec) == 0 + assert rec.get_events() == [] + + def test_get_events_returns_copy(self): + rec = EventRecorder() + rec.record("test") + events = rec.get_events() + events.clear() + assert len(rec) == 1 + + def test_timestamps_monotonic(self): + rec = EventRecorder() + for _ in range(10): + rec.record("test") + events = rec.get_events() + for i in range(1, len(events)): + assert events[i].timestamp >= events[i - 1].timestamp + + def test_thread_safety(self): + rec = EventRecorder() + barrier = threading.Barrier(4) + + def writer(thread_id): + barrier.wait() + for i in range(100): + rec.record("test", thread=thread_id, index=i) + + threads = [threading.Thread(target=writer, args=(t,)) for t in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(rec) == 400 + events = rec.get_events() + assert len(events) == 400 diff --git a/tests/evals/test_export.py b/tests/evals/test_export.py new file mode 100644 index 00000000..821a5379 --- /dev/null +++ b/tests/evals/test_export.py @@ -0,0 +1,136 @@ +"""Tests for eval export utilities.""" + +from __future__ import annotations + +import json + +from openjarvis.evals.core.export import ( + export_artifacts_manifest, + export_jsonl, + export_summary_json, +) +from openjarvis.evals.core.trace import QueryTrace, TurnTrace + + +def _make_traces(n=3): + traces = [] + for i in range(n): + traces.append(QueryTrace( + query_id=f"q{i:04d}", + workload_type="test", + query_text=f"Question {i}", + response_text=f"Answer {i}", + turns=[ + TurnTrace( + turn_index=0, + input_tokens=100 + i * 10, + output_tokens=50 + i * 5, + wall_clock_s=1.0 + i * 0.5, + gpu_energy_joules=5.0 + i, + cost_usd=0.01, + ), + ], + total_wall_clock_s=1.0 + i * 0.5, + completed=True, + is_resolved=i % 2 == 0, + )) + return traces + + +class TestExportJsonl: + def test_basic_export(self, tmp_path): + traces = _make_traces() + path = tmp_path / "traces.jsonl" + result = export_jsonl(traces, path) + assert result == path + assert path.exists() + + lines = path.read_text().strip().split("\n") + assert len(lines) == 3 + for line in lines: + d = json.loads(line) + assert "query_id" in d + assert "turns" in d + + def test_empty_traces(self, tmp_path): + path = tmp_path / "empty.jsonl" + export_jsonl([], path) + assert path.read_text() == "" + + def test_creates_parent_dirs(self, tmp_path): + path = tmp_path / "a" / "b" / "c" / "traces.jsonl" + export_jsonl(_make_traces(1), path) + assert path.exists() + + +class TestExportSummaryJson: + def test_basic_summary(self, tmp_path): + traces = _make_traces() + path = tmp_path / "summary.json" + result = export_summary_json(traces, {"model": "test"}, path) + assert result == path + assert path.exists() + + summary = json.loads(path.read_text()) + assert summary["totals"]["queries"] == 3 + assert summary["totals"]["completed"] == 3 + assert summary["totals"]["resolved"] == 2 + assert summary["totals"]["input_tokens"] > 0 + assert summary["totals"]["output_tokens"] > 0 + assert summary["config"]["model"] == "test" + assert "statistics" in summary + + def test_statistics_keys(self, tmp_path): + traces = _make_traces() + path = tmp_path / "summary.json" + export_summary_json(traces, {}, path) + summary = json.loads(path.read_text()) + stats = summary["statistics"] + expected_stat_keys = { + "wall_clock_s", "gpu_energy_joules", "cpu_energy_joules", + "gpu_power_watts", "cpu_power_watts", + "input_tokens", "output_tokens", "total_tokens", + "throughput_tokens_per_sec", "energy_per_token_joules", + "cost_usd", "turns", "tool_calls", + } + assert set(stats.keys()) == expected_stat_keys + + def test_agg_stats_fields(self, tmp_path): + traces = _make_traces() + path = tmp_path / "summary.json" + export_summary_json(traces, {}, path) + summary = json.loads(path.read_text()) + wc_stats = summary["statistics"]["wall_clock_s"] + assert "avg" in wc_stats + assert "median" in wc_stats + assert "min" in wc_stats + assert "max" in wc_stats + assert "std" in wc_stats + + def test_empty_traces(self, tmp_path): + path = tmp_path / "summary.json" + export_summary_json([], {}, path) + summary = json.loads(path.read_text()) + assert summary["totals"]["queries"] == 0 + + +class TestExportArtifactsManifest: + def test_no_artifacts_dir(self, tmp_path): + result = export_artifacts_manifest(tmp_path) + assert result is None + + def test_with_artifacts(self, tmp_path): + art_dir = tmp_path / "artifacts" + q_dir = art_dir / "q0001_test" + q_dir.mkdir(parents=True) + (q_dir / "response.txt").write_text("hello") + (q_dir / "metadata.json").write_text("{}") + + result = export_artifacts_manifest(tmp_path) + assert result is not None + assert result.exists() + + manifest = json.loads(result.read_text()) + assert len(manifest) == 1 + assert manifest[0]["query_dir"] == "q0001_test" + assert len(manifest[0]["files"]) == 2 diff --git a/tests/evals/test_pricing.py b/tests/evals/test_pricing.py new file mode 100644 index 00000000..5ed7062a --- /dev/null +++ b/tests/evals/test_pricing.py @@ -0,0 +1,38 @@ +"""Tests for eval pricing module.""" + +from __future__ import annotations + +import pytest + +from openjarvis.evals.core.pricing import PRICING, compute_turn_cost, estimate_cost + + +class TestPricing: + def test_pricing_dict_nonempty(self): + assert isinstance(PRICING, dict) + assert len(PRICING) > 0 + + def test_compute_turn_cost_known_model(self): + # Pick a model that's in PRICING (cloud models) + if not PRICING: + pytest.skip("No models in PRICING dict") + model = next(iter(PRICING)) + cost = compute_turn_cost(model, 1000, 500) + assert isinstance(cost, (int, float)) + assert cost >= 0 + + def test_compute_turn_cost_unknown_model(self): + cost = compute_turn_cost("totally-unknown-local-model", 1000, 500) + assert cost == 0.0 + + def test_compute_turn_cost_zero_tokens(self): + if not PRICING: + pytest.skip("No models in PRICING dict") + model = next(iter(PRICING)) + cost = compute_turn_cost(model, 0, 0) + assert cost == 0.0 + + def test_estimate_cost_alias(self): + # estimate_cost is the same as engine/cloud.py estimate_cost + cost = estimate_cost("unknown-model", 100, 50) + assert cost == 0.0 diff --git a/tests/evals/test_terminalbench_env.py b/tests/evals/test_terminalbench_env.py new file mode 100644 index 00000000..a348d437 --- /dev/null +++ b/tests/evals/test_terminalbench_env.py @@ -0,0 +1,48 @@ +"""Tests for TerminalBenchTaskEnv (mocked terminal_bench dependency).""" + +from __future__ import annotations + +import pytest + +from openjarvis.evals.execution.terminalbench_env import TerminalBenchTaskEnv + +# terminal_bench is an optional dep — skip all tests if unavailable +terminal_bench = pytest.importorskip( + "terminal_bench", reason="terminal_bench not installed" +) + + +class TestTerminalBenchTaskEnv: + def test_init(self): + metadata = {"task_id": "test-1"} + env = TerminalBenchTaskEnv(metadata) + assert env._metadata is metadata + assert env._terminal is None + + def test_enter_without_task_raises(self): + metadata = {"task_id": "test-1"} + env = TerminalBenchTaskEnv(metadata) + with pytest.raises(ValueError, match="Task metadata missing"): + env.__enter__() + + def test_exit_cleans_metadata(self): + metadata = { + "task_id": "test-1", + "terminal": "fake_terminal", + "session": "fake_session", + "container": "fake_container", + } + env = TerminalBenchTaskEnv(metadata) + env.__exit__(None, None, None) + assert "terminal" not in metadata + assert "session" not in metadata + assert "container" not in metadata + + def test_run_tests_without_terminal(self): + metadata = {"task": "mock_task", "task_paths": "mock_paths"} + env = TerminalBenchTaskEnv(metadata) + env._terminal = None + is_resolved, results = env.run_tests() + assert is_resolved is False + assert results["error"] == "terminal_not_running" + assert metadata["is_resolved"] is False diff --git a/tests/evals/test_use_case_benchmarks.py b/tests/evals/test_use_case_benchmarks.py new file mode 100644 index 00000000..f50ed24f --- /dev/null +++ b/tests/evals/test_use_case_benchmarks.py @@ -0,0 +1,391 @@ +"""Tests for the 5 use-case benchmark datasets and scorers. + +Tests verify: +1. Each dataset can be instantiated with correct attributes +2. Each dataset loads synthetic records (no HuggingFace download) +3. Records have expected fields +4. Each scorer can be instantiated +5. Coding task scorer can score a correct answer +6. Email triage scorer handles exact match +7. CLI factory functions work for all 5 benchmarks +8. Cost calculator and savings modules work +""" + +from __future__ import annotations + +from unittest.mock import MagicMock # noqa: I001 + +import pytest + +# --------------------------------------------------------------------------- +# Dataset instantiation and loading +# --------------------------------------------------------------------------- + + +class TestEmailTriageDataset: + def test_instantiation(self) -> None: + from openjarvis.evals.datasets.email_triage import EmailTriageDataset + ds = EmailTriageDataset() + assert ds.dataset_id == "email_triage" + assert ds.dataset_name == "Email Triage" + + def test_load(self) -> None: + from openjarvis.evals.datasets.email_triage import EmailTriageDataset + ds = EmailTriageDataset() + ds.load(max_samples=5, seed=42) + assert ds.size() == 5 + records = list(ds.iter_records()) + assert len(records) == 5 + r = records[0] + assert r.record_id.startswith("email-triage-") + assert r.category == "use-case" + assert "urgency" in r.metadata + assert "category" in r.metadata + + def test_load_all(self) -> None: + from openjarvis.evals.datasets.email_triage import EmailTriageDataset + ds = EmailTriageDataset() + ds.load() + assert ds.size() == 30 + + +class TestMorningBriefDataset: + def test_instantiation(self) -> None: + from openjarvis.evals.datasets.morning_brief import MorningBriefDataset + ds = MorningBriefDataset() + assert ds.dataset_id == "morning_brief" + assert ds.dataset_name == "Morning Brief" + + def test_load(self) -> None: + from openjarvis.evals.datasets.morning_brief import MorningBriefDataset + ds = MorningBriefDataset() + ds.load(max_samples=5, seed=42) + assert ds.size() == 5 + records = list(ds.iter_records()) + r = records[0] + assert r.record_id.startswith("morning-brief-") + assert r.reference # key_priorities not empty + + def test_load_all(self) -> None: + from openjarvis.evals.datasets.morning_brief import MorningBriefDataset + ds = MorningBriefDataset() + ds.load() + assert ds.size() == 15 + + +class TestResearchMiningDataset: + def test_instantiation(self) -> None: + from openjarvis.evals.datasets.research_mining import ResearchMiningDataset + ds = ResearchMiningDataset() + assert ds.dataset_id == "research_mining" + assert ds.dataset_name == "Research Mining" + + def test_load(self) -> None: + from openjarvis.evals.datasets.research_mining import ResearchMiningDataset + ds = ResearchMiningDataset() + ds.load(max_samples=5, seed=42) + assert ds.size() == 5 + records = list(ds.iter_records()) + r = records[0] + assert r.record_id.startswith("research-mining-") + assert "domain" in r.metadata + + def test_load_all(self) -> None: + from openjarvis.evals.datasets.research_mining import ResearchMiningDataset + ds = ResearchMiningDataset() + ds.load() + assert ds.size() == 31 + + +class TestKnowledgeBaseDataset: + def test_instantiation(self) -> None: + from openjarvis.evals.datasets.knowledge_base import KnowledgeBaseDataset + ds = KnowledgeBaseDataset() + assert ds.dataset_id == "knowledge_base" + assert ds.dataset_name == "Knowledge Base" + + def test_load(self) -> None: + from openjarvis.evals.datasets.knowledge_base import KnowledgeBaseDataset + ds = KnowledgeBaseDataset() + ds.load(max_samples=5, seed=42) + assert ds.size() == 5 + records = list(ds.iter_records()) + r = records[0] + assert r.record_id.startswith("knowledge-base-") + assert r.reference # answer not empty + + def test_load_all(self) -> None: + from openjarvis.evals.datasets.knowledge_base import KnowledgeBaseDataset + ds = KnowledgeBaseDataset() + ds.load() + assert ds.size() == 30 + + +class TestCodingTaskDataset: + def test_instantiation(self) -> None: + from openjarvis.evals.datasets.coding_task import CodingTaskDataset + ds = CodingTaskDataset() + assert ds.dataset_id == "coding_task" + assert ds.dataset_name == "Coding Task" + + def test_load(self) -> None: + from openjarvis.evals.datasets.coding_task import CodingTaskDataset + ds = CodingTaskDataset() + ds.load(max_samples=5, seed=42) + assert ds.size() == 5 + records = list(ds.iter_records()) + r = records[0] + assert r.record_id.startswith("coding-task-") + assert "test_cases" in r.metadata + assert "signature" in r.metadata + + def test_load_all(self) -> None: + from openjarvis.evals.datasets.coding_task import CodingTaskDataset + ds = CodingTaskDataset() + ds.load() + assert ds.size() == 29 + + +# --------------------------------------------------------------------------- +# Scorer instantiation +# --------------------------------------------------------------------------- + + +class TestScorerInstantiation: + def _mock_backend(self): + return MagicMock() + + def test_email_triage_scorer(self) -> None: + from openjarvis.evals.scorers.email_triage import EmailTriageScorer + scorer = EmailTriageScorer(self._mock_backend(), "gpt-5-mini") + assert scorer.scorer_id == "email_triage" + + def test_morning_brief_scorer(self) -> None: + from openjarvis.evals.scorers.morning_brief import MorningBriefScorer + scorer = MorningBriefScorer(self._mock_backend(), "gpt-5-mini") + assert scorer.scorer_id == "morning_brief" + + def test_research_mining_scorer(self) -> None: + from openjarvis.evals.scorers.research_mining import ResearchMiningScorer + scorer = ResearchMiningScorer(self._mock_backend(), "gpt-5-mini") + assert scorer.scorer_id == "research_mining" + + def test_knowledge_base_scorer(self) -> None: + from openjarvis.evals.scorers.knowledge_base import KnowledgeBaseScorer + scorer = KnowledgeBaseScorer(self._mock_backend(), "gpt-5-mini") + assert scorer.scorer_id == "knowledge_base" + + def test_coding_task_scorer(self) -> None: + from openjarvis.evals.scorers.coding_task import CodingTaskScorer + scorer = CodingTaskScorer(self._mock_backend(), "gpt-5-mini") + assert scorer.scorer_id == "coding_task" + + +# --------------------------------------------------------------------------- +# Scorer functional tests +# --------------------------------------------------------------------------- + + +class TestCodingTaskScoring: + """Test the coding task scorer with actual code execution.""" + + def test_correct_answer(self) -> None: + from openjarvis.evals.core.types import EvalRecord + from openjarvis.evals.scorers.coding_task import CodingTaskScorer + + scorer = CodingTaskScorer() + record = EvalRecord( + record_id="test-1", + problem="Write is_palindrome", + reference="", + category="use-case", + subject="coding_task", + metadata={ + "test_cases": ( + 'assert is_palindrome("racecar") == True\n' + 'assert is_palindrome("hello") == False\n' + 'assert is_palindrome("") == True' + ), + }, + ) + answer = ( + "def is_palindrome(s):\n" + " return s == s[::-1]" + ) + is_correct, meta = scorer.score(record, answer) + assert is_correct is True + assert meta["tests_passed"] == 3 + assert meta["pass_rate"] == 1.0 + + def test_incorrect_answer(self) -> None: + from openjarvis.evals.core.types import EvalRecord + from openjarvis.evals.scorers.coding_task import CodingTaskScorer + + scorer = CodingTaskScorer() + record = EvalRecord( + record_id="test-2", + problem="Write add", + reference="", + category="use-case", + metadata={ + "test_cases": ( + "assert add(1, 2) == 3\n" + "assert add(0, 0) == 0" + ), + }, + ) + # Wrong implementation + answer = "def add(a, b):\n return a * b" + is_correct, meta = scorer.score(record, answer) + assert is_correct is False + + def test_empty_answer(self) -> None: + from openjarvis.evals.core.types import EvalRecord + from openjarvis.evals.scorers.coding_task import CodingTaskScorer + + scorer = CodingTaskScorer() + record = EvalRecord( + record_id="test-3", + problem="Write something", + reference="", + category="use-case", + metadata={"test_cases": "assert True"}, + ) + is_correct, meta = scorer.score(record, "") + assert is_correct is False + assert meta["reason"] == "empty_response" + + +class TestEmailTriageScoring: + """Test exact-match path of email triage scorer.""" + + def test_exact_match(self) -> None: + from openjarvis.evals.core.types import EvalRecord + from openjarvis.evals.scorers.email_triage import EmailTriageScorer + + scorer = EmailTriageScorer(MagicMock(), "gpt-5-mini") + record = EvalRecord( + record_id="test-1", + problem="...", + reference="urgency: high\ncategory: action", + category="use-case", + metadata={"urgency": "high", "category": "action"}, + ) + answer = "urgency: high\ncategory: action\ndraft: I'll look into this." + is_correct, meta = scorer.score(record, answer) + assert is_correct is True + assert meta["match_type"] == "exact" + + +# --------------------------------------------------------------------------- +# CLI factory tests +# --------------------------------------------------------------------------- + + +class TestCLIFactories: + """Test that _build_dataset and _build_scorer work for new benchmarks.""" + + @pytest.mark.parametrize("benchmark", [ + "email_triage", + "morning_brief", + "research_mining", + "knowledge_base", + "coding_task", + ]) + def test_build_dataset(self, benchmark: str) -> None: + from openjarvis.evals.cli import _build_dataset + ds = _build_dataset(benchmark) + assert ds.dataset_id == benchmark + + @pytest.mark.parametrize("benchmark", [ + "email_triage", + "morning_brief", + "research_mining", + "knowledge_base", + "coding_task", + ]) + def test_build_scorer(self, benchmark: str) -> None: + from openjarvis.evals.cli import _build_scorer + scorer = _build_scorer(benchmark, MagicMock(), "gpt-5-mini") + assert scorer.scorer_id == benchmark + + +# --------------------------------------------------------------------------- +# Cost calculator tests +# --------------------------------------------------------------------------- + + +class TestCostCalculator: + """Test the cost calculator module.""" + + def test_estimate_monthly_cost(self) -> None: + from openjarvis.server.cost_calculator import estimate_monthly_cost + est = estimate_monthly_cost( + calls_per_month=1000, + avg_input_tokens=500, + avg_output_tokens=200, + provider_key="gpt-5.3", + ) + assert est.monthly_cost > 0 + assert est.annual_cost == est.monthly_cost * 12 + assert est.total_calls_per_month == 1000 + + def test_estimate_scenario(self) -> None: + from openjarvis.server.cost_calculator import estimate_scenario + estimates = estimate_scenario("daily_briefing") + assert len(estimates) == 3 # 3 cloud providers + for est in estimates: + assert est.monthly_cost > 0 + + def test_estimate_all_scenarios(self) -> None: + from openjarvis.server.cost_calculator import estimate_all_scenarios + all_est = estimate_all_scenarios() + assert len(all_est) == 5 # 5 scenarios + + def test_unknown_provider(self) -> None: + from openjarvis.server.cost_calculator import estimate_monthly_cost + with pytest.raises(ValueError, match="Unknown provider"): + estimate_monthly_cost(100, 100, 100, "nonexistent") + + def test_unknown_scenario(self) -> None: + from openjarvis.server.cost_calculator import estimate_scenario + with pytest.raises(ValueError, match="Unknown scenario"): + estimate_scenario("nonexistent") + + +# --------------------------------------------------------------------------- +# Savings module tests +# --------------------------------------------------------------------------- + + +class TestSavings: + """Test the savings computation module.""" + + def test_compute_savings_basic(self) -> None: + from openjarvis.server.savings import compute_savings + summary = compute_savings(1000, 500, total_calls=10) + assert summary.total_calls == 10 + assert summary.total_tokens == 1500 + assert summary.local_cost == 0.0 + assert len(summary.per_provider) == 3 + for p in summary.per_provider: + assert p.total_cost > 0 + + def test_compute_savings_with_session(self) -> None: + import time + + from openjarvis.server.savings import compute_savings + start = time.time() - 3600 # 1 hour ago + summary = compute_savings( + 100000, 50000, total_calls=100, session_start=start, + ) + assert summary.session_duration_hours > 0 + assert summary.monthly_projection # not empty + + def test_savings_to_dict(self) -> None: + from openjarvis.server.savings import compute_savings, savings_to_dict + summary = compute_savings(1000, 500, total_calls=5) + d = savings_to_dict(summary) + assert isinstance(d, dict) + assert "per_provider" in d + assert "total_calls" in d diff --git a/tests/telemetry/test_flops_new.py b/tests/telemetry/test_flops_new.py new file mode 100644 index 00000000..e67f9997 --- /dev/null +++ b/tests/telemetry/test_flops_new.py @@ -0,0 +1,74 @@ +"""Tests for FLOPs estimation and MFU computation.""" + +from __future__ import annotations + +import pytest + +from openjarvis.telemetry.flops import ( + GPU_PEAK_TFLOPS_BF16, + MODEL_PARAMS_B, + compute_mfu, + estimate_flops, +) + + +class TestEstimateFlops: + def test_known_model(self): + total, per_tok = estimate_flops("qwen3:8b", 100, 50) + # 2 * 8e9 * 150 = 2.4e12 + assert total == pytest.approx(2.4e12) + # 2 * 8e9 = 16e9 + assert per_tok == pytest.approx(16e9) + + def test_unknown_model_zero(self): + total, per_tok = estimate_flops("totally-unknown-model", 100, 50) + assert total == 0.0 + assert per_tok == 0.0 + + def test_prefix_matching(self): + # "llama-3.1-8b-instruct" should match prefix "llama-3.1-8b" + total, per_tok = estimate_flops("llama-3.1-8b-instruct", 10, 10) + assert total > 0 + # 2 * 8e9 * 20 = 3.2e11 + assert total == pytest.approx(3.2e11) + + def test_zero_tokens(self): + total, per_tok = estimate_flops("qwen3:8b", 0, 0) + assert total == 0.0 + assert per_tok == 0.0 + + def test_flops_proportional_to_tokens(self): + total_100, _ = estimate_flops("qwen3:8b", 50, 50) + total_200, _ = estimate_flops("qwen3:8b", 100, 100) + assert total_200 == pytest.approx(total_100 * 2.0) + + +class TestComputeMfu: + def test_known_gpu(self): + # 100 TFLOPS actual for 1s on H100 → 100 / 989 * 100 ≈ 10.1% + flops = 100e12 + mfu = compute_mfu(flops, 1.0, "H100") + assert mfu == pytest.approx(100.0 / 989.0 * 100.0, rel=1e-3) + + def test_unknown_gpu_zero(self): + mfu = compute_mfu(100e12, 1.0, "QuantumGPU") + assert mfu == 0.0 + + def test_zero_duration(self): + mfu = compute_mfu(100e12, 0.0, "H100") + assert mfu == 0.0 + + def test_multi_gpu(self): + flops = 100e12 + mfu_single = compute_mfu(flops, 1.0, "H100", num_gpus=1) + mfu_dual = compute_mfu(flops, 1.0, "H100", num_gpus=2) + assert mfu_dual == pytest.approx(mfu_single / 2.0) + + def test_substring_matching(self): + # "NVIDIA H100 80GB" should match "H100" + mfu = compute_mfu(100e12, 1.0, "NVIDIA H100 80GB") + assert mfu > 0.0 + + def test_tables_nonempty(self): + assert len(GPU_PEAK_TFLOPS_BF16) > 0 + assert len(MODEL_PARAMS_B) > 0 diff --git a/tests/telemetry/test_itl_new.py b/tests/telemetry/test_itl_new.py new file mode 100644 index 00000000..66f1ec00 --- /dev/null +++ b/tests/telemetry/test_itl_new.py @@ -0,0 +1,63 @@ +"""Tests for inter-token latency percentile computation.""" + +from __future__ import annotations + +import pytest + +from openjarvis.telemetry.itl import compute_itl_stats + + +class TestComputeItlStats: + def test_empty_timestamps(self): + result = compute_itl_stats([]) + assert result["p50_ms"] == 0 + assert result["mean_ms"] == 0 + + def test_single_timestamp(self): + result = compute_itl_stats([100.0]) + assert result["p50_ms"] == 0 + assert result["max_ms"] == 0 + + def test_two_timestamps(self): + result = compute_itl_stats([0.0, 10.0]) + assert result["p50_ms"] == 10.0 + assert result["mean_ms"] == 10.0 + assert result["min_ms"] == 10.0 + assert result["max_ms"] == 10.0 + + def test_uniform_spacing(self): + # 11 timestamps 5ms apart → 10 ITLs all = 5.0 + timestamps = [i * 5.0 for i in range(11)] + result = compute_itl_stats(timestamps) + assert result["p50_ms"] == pytest.approx(5.0) + assert result["p90_ms"] == pytest.approx(5.0) + assert result["p95_ms"] == pytest.approx(5.0) + assert result["p99_ms"] == pytest.approx(5.0) + assert result["mean_ms"] == pytest.approx(5.0) + assert result["min_ms"] == pytest.approx(5.0) + assert result["max_ms"] == pytest.approx(5.0) + + def test_varying_spacing(self): + # [0, 1, 3, 6, 10] → ITLs = [1, 2, 3, 4] + timestamps = [0.0, 1.0, 3.0, 6.0, 10.0] + result = compute_itl_stats(timestamps) + assert result["min_ms"] == pytest.approx(1.0) + assert result["max_ms"] == pytest.approx(4.0) + assert result["mean_ms"] == pytest.approx(2.5) + # Median of sorted [1,2,3,4] → 2.5 + assert result["p50_ms"] == pytest.approx(2.5) + + def test_percentile_ordering(self): + timestamps = [float(i) for i in range(101)] + result = compute_itl_stats(timestamps) + assert result["p50_ms"] <= result["p90_ms"] + assert result["p90_ms"] <= result["p95_ms"] + assert result["p95_ms"] <= result["p99_ms"] + + def test_all_keys_present(self): + result = compute_itl_stats([0.0, 5.0, 10.0]) + expected_keys = { + "p50_ms", "p90_ms", "p95_ms", "p99_ms", + "mean_ms", "min_ms", "max_ms", + } + assert set(result.keys()) == expected_keys diff --git a/tests/telemetry/test_phase_metrics_new.py b/tests/telemetry/test_phase_metrics_new.py new file mode 100644 index 00000000..d4c5f23f --- /dev/null +++ b/tests/telemetry/test_phase_metrics_new.py @@ -0,0 +1,49 @@ +"""Tests for phase metrics computation.""" + +from __future__ import annotations + +import pytest + +from openjarvis.telemetry.phase_metrics import compute_phase_metrics, split_at_ttft +from openjarvis.telemetry.session import TelemetrySample, TelemetrySession + + +class TestComputePhaseMetrics: + def _make_session_with_samples(self): + """Create a session with pre-loaded samples.""" + session = TelemetrySession(monitor=None) + # Manually push samples into the buffer + for i in range(11): + session._buffer.push(TelemetrySample( + timestamp_ns=i * 100_000_000, + gpu_power_w=100.0, + cpu_power_w=50.0, + )) + return session + + def test_basic_metrics(self): + session = self._make_session_with_samples() + result = compute_phase_metrics(session, 0, 1_000_000_000, 100) + assert result["duration_s"] == pytest.approx(1.0, abs=1e-6) + assert result["tokens"] == 100 + assert result["energy_j"] > 0 # trapezoidal integral + + def test_zero_tokens(self): + session = self._make_session_with_samples() + result = compute_phase_metrics(session, 0, 1_000_000_000, 0) + assert result["energy_per_token_j"] == 0.0 + + def test_split_at_ttft(self): + session = self._make_session_with_samples() + prefill, decode = split_at_ttft( + session, + start_ns=0, + ttft_ns=500_000_000, # 500ms + end_ns=1_000_000_000, # 1s + input_tokens=50, + output_tokens=100, + ) + assert prefill["tokens"] == 50 + assert decode["tokens"] == 100 + assert prefill["duration_s"] == pytest.approx(0.5, abs=1e-6) + assert decode["duration_s"] == pytest.approx(0.5, abs=1e-6) diff --git a/tests/telemetry/test_session.py b/tests/telemetry/test_session.py new file mode 100644 index 00000000..7226b27e --- /dev/null +++ b/tests/telemetry/test_session.py @@ -0,0 +1,90 @@ +"""Tests for TelemetrySession and ring buffer.""" + +from __future__ import annotations + +from openjarvis.telemetry.session import ( + TelemetrySample, + TelemetrySession, + _PythonRingBuffer, +) + + +class TestPythonRingBuffer: + def test_push_and_len(self): + buf = _PythonRingBuffer(capacity=10) + assert len(buf) == 0 + buf.push(TelemetrySample(timestamp_ns=100)) + assert len(buf) == 1 + + def test_capacity_overflow(self): + buf = _PythonRingBuffer(capacity=3) + for i in range(5): + buf.push(TelemetrySample(timestamp_ns=i * 100)) + assert len(buf) == 3 + + def test_window(self): + buf = _PythonRingBuffer(capacity=100) + for i in range(10): + buf.push(TelemetrySample(timestamp_ns=i * 1000)) + result = buf.window(2000, 6000) + assert len(result) == 5 # 2000, 3000, 4000, 5000, 6000 + + def test_clear(self): + buf = _PythonRingBuffer(capacity=10) + buf.push(TelemetrySample(timestamp_ns=1)) + buf.clear() + assert len(buf) == 0 + + def test_energy_delta_trapezoidal(self): + """Trapezoidal integration over constant power should yield P*t.""" + buf = _PythonRingBuffer(capacity=100) + # 100W GPU, 50W CPU for 1 second (10 samples, 100ms apart) + for i in range(11): + buf.push(TelemetrySample( + timestamp_ns=i * 100_000_000, # 0ms, 100ms, ..., 1000ms + gpu_power_w=100.0, + cpu_power_w=50.0, + )) + gpu_j, cpu_j = buf.compute_energy_delta(0, 1_000_000_000) + assert abs(gpu_j - 100.0) < 1.0 # 100W * 1s = 100J + assert abs(cpu_j - 50.0) < 1.0 # 50W * 1s = 50J + + def test_energy_delta_insufficient_samples(self): + buf = _PythonRingBuffer(capacity=100) + buf.push(TelemetrySample(timestamp_ns=0, gpu_power_w=100.0)) + gpu_j, cpu_j = buf.compute_energy_delta(0, 1_000_000_000) + assert gpu_j == 0.0 + assert cpu_j == 0.0 + + def test_avg_power(self): + buf = _PythonRingBuffer(capacity=100) + buf.push(TelemetrySample(timestamp_ns=0, gpu_power_w=100.0, cpu_power_w=40.0)) + buf.push(TelemetrySample( + timestamp_ns=500_000_000, gpu_power_w=200.0, + cpu_power_w=60.0, + )) + gpu_w, cpu_w = buf.compute_avg_power(0, 1_000_000_000) + assert gpu_w == 150.0 + assert cpu_w == 50.0 + + +class TestTelemetrySession: + def test_noop_session(self): + """Session with no monitor should be a safe no-op.""" + session = TelemetrySession(monitor=None) + with session: + samples = session.window(0, 1_000_000_000) + assert samples == [] + gpu_j, cpu_j = session.energy_delta(0, 1_000_000_000) + assert gpu_j == 0.0 + assert cpu_j == 0.0 + + def test_context_manager(self): + session = TelemetrySession(monitor=None) + with session as s: + assert s is session + + def test_start_stop(self): + session = TelemetrySession(monitor=None) + session.start() + session.stop()