mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dbe5461bb | ||
|
|
a65592fecb | ||
|
|
0513fbdb84 | ||
|
|
d4eb6308b1 | ||
|
|
2853a0001d | ||
|
|
3c99481975 | ||
|
|
4bf39af9bd | ||
|
|
0a3e812751 | ||
|
|
eb46febad5 | ||
|
|
81482b45d4 | ||
|
|
3e2f4bcdb4 | ||
|
|
a35b21195f | ||
|
|
f9d1bc8c27 | ||
|
|
dfa908c358 | ||
|
|
8ef1ab1928 | ||
|
|
28e75cb513 | ||
|
|
4b9948250b | ||
|
|
79e23719d4 | ||
|
|
7ba334b5f0 | ||
|
|
48a2627c9a | ||
|
|
8625f4f95f | ||
|
|
cf08f164c0 | ||
|
|
b21463aab6 | ||
|
|
0cac61d3bb | ||
|
|
50993dfa4d | ||
|
|
527f84f960 | ||
|
|
8eaeb3a754 | ||
|
|
90b7d0cb9b |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "107,695",
|
||||
"message": "128,077",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 107695,
|
||||
"last_updated": "2026-06-10T07:32:26Z",
|
||||
"total_clones": 128077,
|
||||
"last_updated": "2026-06-22T08:05:29Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -75,6 +75,19 @@
|
||||
"2026-06-05": 2127,
|
||||
"2026-06-06": 2204,
|
||||
"2026-06-07": 1174,
|
||||
"2026-06-08": 2369
|
||||
"2026-06-08": 2369,
|
||||
"2026-06-09": 1361,
|
||||
"2026-06-10": 1310,
|
||||
"2026-06-11": 2564,
|
||||
"2026-06-12": 1313,
|
||||
"2026-06-13": 2804,
|
||||
"2026-06-14": 1543,
|
||||
"2026-06-15": 1379,
|
||||
"2026-06-16": 1317,
|
||||
"2026-06-17": 1170,
|
||||
"2026-06-18": 1408,
|
||||
"2026-06-19": 1350,
|
||||
"2026-06-20": 1437,
|
||||
"2026-06-21": 1426
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,20 @@ jobs:
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Base version is the next patch above whatever is in pyproject.toml.
|
||||
# Base version is the next patch above the latest plain release tag
|
||||
# (vX.Y.Z) reachable from HEAD. pyproject.toml no longer carries a
|
||||
# static version (#526 switched it to hatch-vcs), so the release tag
|
||||
# is the source of truth. `.devN`/`.rcN`/`desktop-*` tags are excluded
|
||||
# so they can't be mistaken for the release base.
|
||||
# Any future manual `X.Y.Z` release will outrank every `X.Y.Z.devN`
|
||||
# autotag — PEP 440 sorts dev releases strictly below the final.
|
||||
BASE=$(grep -E '^version = "' pyproject.toml | head -1 | sed -E 's/^version = "([^"]+)"/\1/')
|
||||
if [[ -z "$BASE" ]]; then
|
||||
echo "::error::Could not parse version from pyproject.toml"
|
||||
LATEST_RELEASE=$(git tag --list 'v[0-9]*' --merged HEAD \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
if [[ -z "$LATEST_RELEASE" ]]; then
|
||||
echo "::error::No release tag (vX.Y.Z) reachable from HEAD"
|
||||
exit 1
|
||||
fi
|
||||
BASE="${LATEST_RELEASE#v}"
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
|
||||
|
||||
@@ -11,22 +11,33 @@ concurrency:
|
||||
group: claude-issues-${{ github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least-privilege: only what the issue-fixer job actually needs.
|
||||
# id-token (OIDC) is intentionally omitted — claude-code-action@v1 is passed
|
||||
# github_token directly, so OIDC is unused here.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
fix:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 15
|
||||
# Security gate: this job reaches secrets.ANTHROPIC_API_KEY and holds a
|
||||
# write-scoped GITHUB_TOKEN. `issues` / `issue_comment` are public,
|
||||
# attacker-controllable events that run in the base-repo context with full
|
||||
# secret access, so the human-triggered paths are restricted to actors with
|
||||
# write-level association (OWNER / MEMBER / COLLABORATOR). This blocks
|
||||
# external / first-time contributors from draining the API budget or
|
||||
# creating branches/PRs, while leaving maintainer use unaffected.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issues' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association) &&
|
||||
(contains(github.event.issue.labels.*.name, 'bug') ||
|
||||
contains(github.event.issue.labels.*.name, 'autofix'))) ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
|
||||
!github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
github.actor != 'claude[bot]')
|
||||
|
||||
@@ -11,23 +11,33 @@ concurrency:
|
||||
group: claude-review-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least-privilege: PR review only needs to post comments on the PR.
|
||||
# id-token (OIDC) is omitted — claude-code-action@v1 is passed github_token
|
||||
# directly, so OIDC is unused here.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Security gate: this job reaches secrets.ANTHROPIC_API_KEY. Both
|
||||
# issue_comment and pull_request_review_comment are public,
|
||||
# attacker-controllable events that run in the base-repo context with full
|
||||
# secret access, so the @claude paths are restricted to actors with
|
||||
# write-level association (OWNER / MEMBER / COLLABORATOR). External /
|
||||
# first-time contributors cannot trigger the key; maintainers are unaffected.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
github.actor != 'claude[bot]') ||
|
||||
(github.event_name == 'pull_request_review_comment' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
github.actor != 'claude[bot]')
|
||||
steps:
|
||||
|
||||
@@ -114,6 +114,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Full history + tags so the workflow_dispatch fallback in
|
||||
# "Determine release info" can derive the dev version from the
|
||||
# latest release tag (#526).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
@@ -183,7 +188,16 @@ jobs:
|
||||
# workflow_dispatch fallback (manual UI dispatch without --ref).
|
||||
# Derive a PEP 440 dev version aligned with autotag.yml so we
|
||||
# don't burn the X.Y.Z release-version namespace.
|
||||
BASE=$(grep -E '^version = "' pyproject.toml | head -1 | sed -E 's/^version = "([^"]+)"/\1/')
|
||||
# pyproject.toml no longer carries a static version (#526), so the
|
||||
# base comes from the latest plain release tag (vX.Y.Z), matching
|
||||
# autotag.yml. .dev/.rc/desktop-* tags are excluded.
|
||||
LATEST_RELEASE=$(git tag --list 'v[0-9]*' --merged HEAD \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
if [[ -z "$LATEST_RELEASE" ]]; then
|
||||
echo "::error::No release tag (vX.Y.Z) reachable from HEAD"
|
||||
exit 1
|
||||
fi
|
||||
BASE="${LATEST_RELEASE#v}"
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
|
||||
|
||||
@@ -12,6 +12,11 @@ on:
|
||||
description: 'Tag to publish (e.g. v1.0.2.dev500). Overrides github.ref.'
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run: build + validate, then publish to TestPyPI instead of PyPI (no production upload).'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -67,27 +72,41 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Set version from tag
|
||||
- name: Resolve build version from tag
|
||||
env:
|
||||
REF: ${{ steps.ref.outputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Strip leading "v" if present (e.g. v1.0.2.dev500 -> 1.0.2.dev500)
|
||||
# Strip leading "v" (e.g. v1.0.3.dev825 -> 1.0.3.dev825).
|
||||
VERSION="${REF#v}"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "::error::Could not resolve version from ref '$REF'"
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||
echo "::error::ref '$REF' is not a version tag (expected vX.Y.Z[.devN]); pass -f tag=vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
|
||||
# Sanity check the substitution actually took
|
||||
grep -q "^version = \"${VERSION}\"" pyproject.toml || {
|
||||
echo "::error::sed failed to update pyproject.toml version"
|
||||
exit 1
|
||||
}
|
||||
echo "Building version $VERSION"
|
||||
# pyproject.toml is now dynamic = ["version"] via hatch-vcs (#526), so
|
||||
# there is no static line to sed. setuptools_scm cannot bump custom
|
||||
# `.devN` tags, so we pin the exact build version explicitly — the
|
||||
# published version always equals the pushed tag.
|
||||
echo "SETUPTOOLS_SCM_PRETEND_VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "Building version ${VERSION}"
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
- name: Publish to TestPyPI (dry run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${UV_PUBLISH_TOKEN:-}" ]]; then
|
||||
echo "::warning::TEST_PYPI_API_TOKEN is not set — skipping the TestPyPI upload."
|
||||
echo "Build + twine check passed, which validated version derivation and packaging end to end."
|
||||
echo "To exercise a real upload, add a TEST_PYPI_API_TOKEN secret (or a TestPyPI trusted publisher)."
|
||||
exit 0
|
||||
fi
|
||||
uv publish --publish-url https://test.pypi.org/legacy/
|
||||
|
||||
- name: Publish to PyPI
|
||||
if: ${{ !inputs.dry_run }}
|
||||
run: uv publish
|
||||
|
||||
@@ -8,6 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
**Vision input for `jarvis ask`** — attach images to a query with
|
||||
`-i`/`--image` (repeatable) or capture the current screen with
|
||||
`-S`/`--screen`, for vision-capable models such as `gemma3:4b`. Images flow
|
||||
through `Message.images` into Ollama's `/api/chat` `images` field; text-only
|
||||
requests are unaffected. A privacy guard warns before any image is sent to a
|
||||
non-local engine, and the security guardrail now preserves images when it
|
||||
sanitizes a flagged prompt. Screen capture uses the built-in Windows .NET
|
||||
stack with `mss`/`Pillow` fallbacks on other platforms. Adds the
|
||||
`JARVIS_NUM_CTX` environment variable to tune the Ollama context window
|
||||
(default `16384`).
|
||||
|
||||
## [1.0.2] - 2026-05-24
|
||||
|
||||
A patch release that fixes a packaging bug which broke the v1.0.1
|
||||
|
||||
@@ -20,7 +20,7 @@ What it does:
|
||||
4. Installs `uv` (https://astral.sh/uv) if absent.
|
||||
5. Clones the OpenJarvis repository to `%LOCALAPPDATA%\OpenJarvis`
|
||||
(override with `$env:OPENJARVIS_HOME`).
|
||||
6. Runs `uv sync --extra server` so the FastAPI server entry point is
|
||||
6. Runs `uv sync --extra desktop` so the FastAPI server and speech backend are
|
||||
importable.
|
||||
7. Optionally prompts to register a scheduled task that auto-starts the
|
||||
server at logon.
|
||||
@@ -105,7 +105,7 @@ To pull the latest:
|
||||
```powershell
|
||||
cd "$env:LOCALAPPDATA\OpenJarvis\src"
|
||||
git pull --ff-only
|
||||
uv sync --extra server
|
||||
uv sync --extra desktop
|
||||
```
|
||||
|
||||
Or re-run the installer with `-Force`:
|
||||
|
||||
+35
-30
@@ -5,19 +5,19 @@
|
||||
.DESCRIPTION
|
||||
Phase-1 of the native-Windows-support RFC (#298). Mirrors the
|
||||
behavior of scripts/install/install.sh (the curl-pipe-bash installer
|
||||
for Linux/WSL2/macOS) but for native Windows PowerShell — no WSL,
|
||||
for Linux/WSL2/macOS) but for native Windows PowerShell - no WSL,
|
||||
no Docker, no MSYS2.
|
||||
|
||||
Steps:
|
||||
1. Refuse non-Windows / Windows < 10.
|
||||
2. Check Python 3.10 — 3.13 on PATH (3.14 has no numpy wheels yet,
|
||||
2. Check Python 3.10 - 3.13 on PATH (3.14 has no numpy wheels yet,
|
||||
see #432).
|
||||
3. Check git on PATH.
|
||||
4. Install uv (https://astral.sh/uv) if absent.
|
||||
5. Clone the OpenJarvis repository to $env:LOCALAPPDATA\OpenJarvis
|
||||
(override with $env:OPENJARVIS_HOME).
|
||||
6. Run `uv sync --extra server` so the FastAPI server entry point
|
||||
is importable.
|
||||
6. Run `uv sync --extra desktop` so the FastAPI server and speech
|
||||
backend are importable.
|
||||
7. Optionally register the scheduled-task service (see
|
||||
deploy/windows/jarvis-service.ps1).
|
||||
|
||||
@@ -65,7 +65,7 @@ if (-not $Service -and $env:OPENJARVIS_SERVICE) { $Service = $true
|
||||
if (-not $Force -and $env:OPENJARVIS_FORCE) { $Force = $true }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output helpers — coloured but plain enough for Constrained Language Mode.
|
||||
# Output helpers - coloured but plain enough for Constrained Language Mode.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Write-Info ($msg) { Write-Host "[info] $msg" -ForegroundColor Cyan }
|
||||
@@ -77,13 +77,13 @@ function Write-Fail ($msg) {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers — winget bootstrap + PATH refresh
|
||||
# Shared helpers - winget bootstrap + PATH refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Pull the latest Machine + User PATH from the registry into the current
|
||||
# PowerShell session. Tools installed by `winget install` (Python, git,
|
||||
# Ollama, etc.) update the User PATH, but the running process inherits
|
||||
# the parent shell's environment — so without this refresh the just-
|
||||
# the parent shell's environment - so without this refresh the just-
|
||||
# installed tool stays invisible to subsequent `Get-Command` calls.
|
||||
#
|
||||
# CRITICAL: registry PATH entries can be REG_EXPAND_SZ (with literal
|
||||
@@ -157,7 +157,7 @@ function Get-PythonCommand {
|
||||
Write-Info "Checking Python (3.10 - 3.13)..."
|
||||
$pythonExe = Get-PythonCommand
|
||||
if (-not $pythonExe) {
|
||||
Write-Info "Python not on PATH — attempting auto-install via winget..."
|
||||
Write-Info "Python not on PATH - attempting auto-install via winget..."
|
||||
$pythonExe = Install-WithWinget -WingetId 'Python.Python.3.13' -CommandName 'python'
|
||||
if (-not $pythonExe) {
|
||||
Write-Fail @"
|
||||
@@ -196,7 +196,7 @@ Write-Ok "Python $pyMajor.$pyMinor ($pythonExe)"
|
||||
Write-Info "Checking git..."
|
||||
$gitExe = (Get-Command git -ErrorAction SilentlyContinue).Source
|
||||
if (-not $gitExe) {
|
||||
Write-Info "git not on PATH — attempting auto-install via winget..."
|
||||
Write-Info "git not on PATH - attempting auto-install via winget..."
|
||||
$gitExe = Install-WithWinget -WingetId 'Git.Git' -CommandName 'git'
|
||||
if (-not $gitExe) {
|
||||
Write-Fail @"
|
||||
@@ -227,7 +227,7 @@ if (-not $uvExe) {
|
||||
}
|
||||
# The astral installer puts uv at %USERPROFILE%\.local\bin\uv.exe and
|
||||
# adds that dir to the User PATH. The current process's PATH isn't
|
||||
# refreshed automatically — prepend the install dir so the rest of
|
||||
# refreshed automatically - prepend the install dir so the rest of
|
||||
# this script picks it up.
|
||||
$uvDir = Join-Path $env:USERPROFILE '.local\bin'
|
||||
if (Test-Path (Join-Path $uvDir 'uv.exe')) {
|
||||
@@ -279,13 +279,13 @@ if (Test-Path (Join-Path $srcDir '.git')) {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. uv sync --extra server
|
||||
# 6. uv sync --extra desktop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Running 'uv sync --extra server' in $srcDir (this can take a few minutes)..."
|
||||
Write-Info "Running 'uv sync --extra desktop' in $srcDir (this can take a few minutes)..."
|
||||
Push-Location $srcDir
|
||||
try {
|
||||
& $uvExe sync --extra server
|
||||
& $uvExe sync --extra desktop
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Fail "uv sync failed with exit code $LASTEXITCODE. Check the output above."
|
||||
}
|
||||
@@ -295,13 +295,13 @@ try {
|
||||
Write-Ok "Dependencies installed"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Ollama — install + start + wait for daemon
|
||||
# 7. Ollama - install + start + wait for daemon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Checking Ollama..."
|
||||
$ollamaExe = (Get-Command ollama -ErrorAction SilentlyContinue).Source
|
||||
if (-not $ollamaExe) {
|
||||
Write-Info " Ollama not on PATH — downloading the official installer (~150 MB)..."
|
||||
Write-Info " Ollama not on PATH - downloading the official installer (~150 MB)..."
|
||||
$ollamaSetup = Join-Path $env:TEMP 'OllamaSetup.exe'
|
||||
# SilentlyContinue is load-bearing in PS 5.1: the default progress
|
||||
# bar renderer slows Invoke-WebRequest down 30x on large downloads
|
||||
@@ -340,13 +340,18 @@ Write-Ok "Ollama ($ollamaExe)"
|
||||
Write-Info "Waiting for Ollama daemon..."
|
||||
$ollamaReady = $false
|
||||
for ($i = 0; $i -lt 60; $i++) {
|
||||
& $ollamaExe list 2>&1 | Out-Null
|
||||
# 'ollama list' writes to stderr until the daemon is reachable; under
|
||||
# $ErrorActionPreference='Stop' the 2>&1 merge surfaces that as a
|
||||
# terminating NativeCommandError that would abort the whole install on
|
||||
# the very first probe. Swallow it and rely on $LASTEXITCODE so the
|
||||
# Start-Process serve fallback below actually runs (issue #522).
|
||||
try { & $ollamaExe list 2>&1 | Out-Null } catch { }
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$ollamaReady = $true
|
||||
break
|
||||
}
|
||||
if ($i -eq 5) {
|
||||
# Daemon clearly isn't auto-running — start it ourselves. Ollama
|
||||
# Daemon clearly isn't auto-running - start it ourselves. Ollama
|
||||
# for Windows uses the tray app `ollama app.exe`; falling back to
|
||||
# `ollama serve` works headless.
|
||||
Start-Process -FilePath $ollamaExe -ArgumentList 'serve' -WindowStyle Hidden -ErrorAction SilentlyContinue
|
||||
@@ -354,11 +359,11 @@ for ($i = 0; $i -lt 60; $i++) {
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
if (-not $ollamaReady) {
|
||||
Write-Warn2 "Ollama daemon didn't become ready in 60s. Continuing — bg-orchestrator will retry later."
|
||||
Write-Warn2 "Ollama daemon didn't become ready in 60s. Continuing - bg-orchestrator will retry later."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Pull a starter model (qwen3.5:2b — ~1.5 GB)
|
||||
# 8. Pull a starter model (qwen3.5:2b - ~1.5 GB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$modelPullOk = $false
|
||||
@@ -372,11 +377,11 @@ if ($ollamaReady) {
|
||||
Write-Warn2 "ollama pull failed; the bg-orchestrator will retry once Ollama is reachable."
|
||||
}
|
||||
} else {
|
||||
Write-Warn2 "Skipping model pull — daemon wasn't ready."
|
||||
Write-Warn2 "Skipping model pull - daemon wasn't ready."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. jarvis.cmd shim — so bare `jarvis` works in any new PowerShell
|
||||
# 9. jarvis.cmd shim - so bare `jarvis` works in any new PowerShell
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$binDir = Join-Path $installRoot 'bin'
|
||||
@@ -387,7 +392,7 @@ if (-not (Test-Path $binDir)) {
|
||||
}
|
||||
|
||||
# %~dp0 in a .cmd file resolves to the directory containing the script,
|
||||
# so the shim is self-locating — moving %LOCALAPPDATA%\OpenJarvis won't
|
||||
# so the shim is self-locating - moving %LOCALAPPDATA%\OpenJarvis won't
|
||||
# break it as long as the user moves the whole tree. `uv` is resolved
|
||||
# from PATH at runtime (astral installer adds it to User PATH); avoids
|
||||
# pinning to the install-time uv.exe path which can shift on uv updates.
|
||||
@@ -400,7 +405,7 @@ uv run --project "%SRC%" jarvis %*
|
||||
Set-Content -Path $shimPath -Value $shimContent -Encoding ASCII
|
||||
|
||||
# Add %LOCALAPPDATA%\OpenJarvis\bin to User PATH if it isn't already
|
||||
# there. The current process won't see it until restart — handled in the
|
||||
# there. The current process won't see it until restart - handled in the
|
||||
# final banner.
|
||||
#
|
||||
# Compare against the EXPANDED form: a previous install may have written
|
||||
@@ -430,7 +435,7 @@ Write-Ok "jarvis shim installed at $shimPath"
|
||||
$serviceScript = Join-Path $srcDir 'deploy\windows\jarvis-service.ps1'
|
||||
$shouldInstallService = $false
|
||||
|
||||
# Pre-check admin if the user wants the service — Register-ScheduledTask
|
||||
# Pre-check admin if the user wants the service - Register-ScheduledTask
|
||||
# requires elevation. We do this before the prompt so we don't ask "do
|
||||
# you want the service?" only to fail with Access Denied after they say
|
||||
# yes.
|
||||
@@ -439,7 +444,7 @@ $isAdmin = ([Security.Principal.WindowsPrincipal] `
|
||||
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
|
||||
if ($Service -and -not $isAdmin) {
|
||||
Write-Fail "-Service was requested, but this PowerShell is not elevated. Register-ScheduledTask needs admin rights — re-run from an elevated PowerShell, or drop -Service."
|
||||
Write-Fail "-Service was requested, but this PowerShell is not elevated. Register-ScheduledTask needs admin rights - re-run from an elevated PowerShell, or drop -Service."
|
||||
}
|
||||
if ($Service) {
|
||||
$shouldInstallService = $true
|
||||
@@ -448,7 +453,7 @@ if ($Service) {
|
||||
} elseif (-not $isAdmin) {
|
||||
# Default to skip-with-explanation when we can't elevate, rather
|
||||
# than prompting and then failing at Register-ScheduledTask.
|
||||
Write-Warn2 "Skipping scheduled-task setup — this PowerShell is not elevated."
|
||||
Write-Warn2 "Skipping scheduled-task setup - this PowerShell is not elevated."
|
||||
Write-Warn2 " Register-ScheduledTask requires admin. To install the service later:"
|
||||
Write-Warn2 " Right-click PowerShell -> Run as administrator, then run:"
|
||||
Write-Warn2 " powershell -ExecutionPolicy Bypass -File `"$serviceScript`" install"
|
||||
@@ -464,7 +469,7 @@ if ($Service) {
|
||||
$reply = Read-Host "Register OpenJarvis as a Windows scheduled task (auto-start at logon, loopback only)? [y/N]"
|
||||
$shouldInstallService = ($reply -match '^[yY]')
|
||||
} else {
|
||||
Write-Warn2 "Non-interactive install — skipping scheduled-task setup."
|
||||
Write-Warn2 "Non-interactive install - skipping scheduled-task setup."
|
||||
Write-Warn2 "To register the service later, run (from an elevated PowerShell):"
|
||||
Write-Warn2 " powershell -ExecutionPolicy Bypass -File `"$serviceScript`" install"
|
||||
}
|
||||
@@ -487,9 +492,9 @@ if ($shouldInstallService) {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ┌──────────────────────────────────┐" -ForegroundColor Green
|
||||
Write-Host " │ OpenJarvis install complete │" -ForegroundColor Green
|
||||
Write-Host " └──────────────────────────────────┘" -ForegroundColor Green
|
||||
Write-Host " +----------------------------------+" -ForegroundColor Green
|
||||
Write-Host " | OpenJarvis install complete |" -ForegroundColor Green
|
||||
Write-Host " +----------------------------------+" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host " Repo: $srcDir"
|
||||
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ If you prefer to run each step yourself:
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync --extra server
|
||||
uv sync --extra desktop
|
||||
cd frontend && npm install && cd ..
|
||||
```
|
||||
|
||||
|
||||
@@ -17,6 +17,46 @@ The configuration file lives at:
|
||||
|
||||
OpenJarvis creates the `~/.openjarvis/` directory and populates it with a default config when you run `jarvis init`.
|
||||
|
||||
## Relocating the OpenJarvis directory
|
||||
|
||||
OpenJarvis keeps **all** of its state — config, databases, caches, logs,
|
||||
credentials, skills, recipes, connectors — under a **single root** so it never
|
||||
clutters your home directory beyond one folder. By default that root is
|
||||
`~/.openjarvis`, but you can move it.
|
||||
|
||||
The root is resolved in priority order:
|
||||
|
||||
1. **`$OPENJARVIS_HOME`** — explicit override. Honored by both the installer
|
||||
and the Python runtime.
|
||||
2. **`$XDG_DATA_HOME/openjarvis`** — used when `$XDG_DATA_HOME` is set (a single
|
||||
`openjarvis` directory nested under it, per the XDG Base Directory spec).
|
||||
3. **`~/.openjarvis`** — the default. With no environment variables set, the
|
||||
resolved path is exactly this, so existing installs are untouched.
|
||||
|
||||
```bash
|
||||
# Relocate the whole install + runtime tree at install time:
|
||||
OPENJARVIS_HOME=~/apps/openjarvis curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
|
||||
# Or for a single run / your shell profile:
|
||||
export OPENJARVIS_HOME=~/apps/openjarvis
|
||||
```
|
||||
|
||||
Confirm where your data lives with:
|
||||
|
||||
```bash
|
||||
jarvis config path
|
||||
```
|
||||
|
||||
!!! note "Migration"
|
||||
Because the default is unchanged, **no data migration is required** for
|
||||
existing installs. If you set `OPENJARVIS_HOME` (or `XDG_DATA_HOME`) on a
|
||||
machine that already has data in `~/.openjarvis`, OpenJarvis will look in
|
||||
the new location and not see your old data — move it yourself if you want
|
||||
to keep it: `mv ~/.openjarvis "$OPENJARVIS_HOME"`.
|
||||
|
||||
`$OPENJARVIS_CONFIG` still points at an explicit `config.toml` file
|
||||
independently of the root, if you need to override just the config file path.
|
||||
|
||||
## Generating Configuration
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
@@ -41,7 +41,7 @@ If you prefer to run each step yourself:
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync --extra server
|
||||
uv sync --extra desktop
|
||||
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
|
||||
cd frontend && npm install && cd ..
|
||||
```
|
||||
@@ -278,6 +278,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight.
|
||||
|
||||
| Extra | Install Command | Description |
|
||||
|-------|----------------|-------------|
|
||||
| `desktop` | `uv sync --extra desktop` | Desktop/API server plus local speech input |
|
||||
| `server` | `uv sync --extra server` | OpenAI-compatible API server (`jarvis serve`) |
|
||||
| `dev` | `uv sync --extra dev` | Development and testing tools |
|
||||
| `docs` | `uv sync --extra docs` | Documentation build tools |
|
||||
@@ -285,7 +286,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight.
|
||||
Combine extras:
|
||||
|
||||
```bash
|
||||
uv sync --extra server --extra memory-faiss --extra inference-cloud
|
||||
uv sync --extra desktop --extra memory-faiss --extra inference-cloud
|
||||
```
|
||||
|
||||
## Setting Up an Inference Backend
|
||||
|
||||
@@ -8,7 +8,7 @@ avoid a Linux VM; WSL2 remains the smoother experience for most users.
|
||||
## What you get
|
||||
|
||||
- A PowerShell installer that probes prerequisites, installs `uv`,
|
||||
clones the repo, and runs `uv sync --extra server`.
|
||||
clones the repo, and runs `uv sync --extra desktop`.
|
||||
- An optional Windows scheduled-task service equivalent to the systemd
|
||||
unit and launchd plist.
|
||||
- Loopback default — the service binds `127.0.0.1` so no API key is
|
||||
@@ -38,7 +38,7 @@ The installer will:
|
||||
4. Install `uv` if absent (via the official `astral.sh/uv` PowerShell
|
||||
installer).
|
||||
5. Clone the repo to `%LOCALAPPDATA%\OpenJarvis\src`.
|
||||
6. Run `uv sync --extra server`.
|
||||
6. Run `uv sync --extra desktop`.
|
||||
7. Prompt to register the scheduled-task service (skip with
|
||||
`-SkipService`).
|
||||
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@ OpenJarvis is built around five composable layers. Each has a clean interface an
|
||||
|
||||
---
|
||||
|
||||
CLI, Python SDK, and guides for [Morning Digest](user-guide/morning-digest.md), [Deep Research](user-guide/deep-research.md), [Code Assistant](user-guide/code-assistant.md), [Scheduled Monitor](user-guide/scheduled-monitor.md), [Simple Chat](user-guide/chat-simple.md), agents, memory, tools, and telemetry.
|
||||
CLI, Python SDK, and guides for [Morning Digest](user-guide/morning-digest.md), [Deep Research](user-guide/deep-research.md), [Code Assistant](user-guide/code-assistant.md), [Scheduled Monitor](user-guide/scheduled-monitor.md), [Simple Chat](user-guide/chat-simple.md), [Evaluations](user-guide/evaluations.md), agents, memory, tools, and telemetry.
|
||||
|
||||
- **[Architecture](architecture/overview.md)**
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ jarvis ask "What is the capital of France?"
|
||||
| `--no-context` | flag | off | Disable memory context injection |
|
||||
| `-a`, `--agent AGENT` | string | none | Agent to use (`simple`, `orchestrator`) |
|
||||
| `--tools TOOLS` | string | none | Comma-separated tool names to enable |
|
||||
| `-i`, `--image PATH` | path | none | Image file for a vision model (e.g. `gemma3:4b`); repeatable |
|
||||
| `-S`, `--screen` | flag | off | Capture the current screen and send it to the vision model |
|
||||
|
||||
### Direct Mode vs Agent Mode
|
||||
|
||||
@@ -105,6 +107,39 @@ jarvis ask --no-context "Tell me about Python"
|
||||
jarvis ask --max-tokens 2048 "Write a detailed essay about AI"
|
||||
```
|
||||
|
||||
### Vision Input
|
||||
|
||||
Vision-capable models (such as `gemma3:4b`) can read images alongside your
|
||||
text prompt. Attach one or more image files with `-i`/`--image`, or capture
|
||||
the current screen with `-S`/`--screen`:
|
||||
|
||||
```bash
|
||||
# Ask about a local image
|
||||
jarvis ask -i screenshot.png "What is shown in this image?"
|
||||
|
||||
# Send multiple images (the flag is repeatable)
|
||||
jarvis ask -i chart-a.png -i chart-b.png "Compare these two charts"
|
||||
|
||||
# Capture the current screen and ask about it
|
||||
jarvis ask --screen "Summarize what's on my screen"
|
||||
```
|
||||
|
||||
Vision runs in **direct mode** only. If you also pass `--agent`, the image is
|
||||
ignored and a note is printed — re-run with `--agent ""` to force direct mode.
|
||||
|
||||
The Ollama context window can be tuned for large images or long prompts with
|
||||
the `JARVIS_NUM_CTX` environment variable (default `16384`):
|
||||
|
||||
```bash
|
||||
JARVIS_NUM_CTX=8192 jarvis ask --screen "What's on my screen?"
|
||||
```
|
||||
|
||||
!!! note "Keep vision on-device"
|
||||
Images are sensitive. OpenJarvis prints a privacy warning before sending
|
||||
an image to a non-local engine, so a screenshot never leaves your machine
|
||||
unnoticed. Use a local engine (e.g. `ollama` with `gemma3:4b`) to keep
|
||||
vision fully local.
|
||||
|
||||
### JSON Output Format
|
||||
|
||||
When using `--json` in **direct mode**, the output includes:
|
||||
|
||||
+191
-55
@@ -1,14 +1,14 @@
|
||||
# Evaluations
|
||||
|
||||
The OpenJarvis evaluation framework (`openjarvis-evals`) measures model **correctness and accuracy** on academic datasets. It is a separate package from the main OpenJarvis library and is designed specifically for research workflows where you need reproducible, dataset-driven quality assessments.
|
||||
The OpenJarvis evaluation framework (`openjarvis.evals`) measures model **correctness and accuracy** on academic datasets. It ships inside the main `openjarvis` package (at `src/openjarvis/evals/`) and is designed specifically for research workflows where you need reproducible, dataset-driven quality assessments.
|
||||
|
||||
!!! info "Evals vs. Benchmarks"
|
||||
OpenJarvis has two distinct measurement systems that complement each other:
|
||||
|
||||
| System | Package | Measures | Entry Point |
|
||||
|--------|---------|----------|-------------|
|
||||
| **Evaluations** | `openjarvis-evals` | Correctness on academic datasets (accuracy, pass rate) | `openjarvis-eval` |
|
||||
| **Benchmarks** | `openjarvis` | Engine performance (latency, throughput) | `jarvis bench` |
|
||||
| System | Module | Measures | Entry Point |
|
||||
|--------|--------|----------|-------------|
|
||||
| **Evaluations** | `openjarvis.evals` | Correctness on academic datasets (accuracy, pass rate) | `jarvis eval` |
|
||||
| **Benchmarks** | `openjarvis.bench` | Engine performance (latency, throughput) | `jarvis bench` |
|
||||
|
||||
Use evaluations to answer "does this model get the right answer?" and benchmarks to answer "how fast does this model respond?". See the [Benchmarks guide](benchmarks.md) for the performance measurement system.
|
||||
|
||||
@@ -18,22 +18,38 @@ The OpenJarvis evaluation framework (`openjarvis-evals`) measures model **correc
|
||||
|
||||
## Installation
|
||||
|
||||
The evaluation framework is a standalone package in the `evals/` directory. Install it alongside OpenJarvis:
|
||||
The evaluation framework is part of the main `openjarvis` package — no separate install or extra is required. The standard dev setup is enough:
|
||||
|
||||
```bash
|
||||
uv sync --extra eval
|
||||
uv sync --extra dev
|
||||
```
|
||||
|
||||
This installs the `openjarvis-eval` CLI entry point and all required dependencies (`datasets`, `huggingface-hub`, `tqdm`, `rich`).
|
||||
The framework's core dependencies (`click`, `datasets`, `rich`) are base dependencies of `openjarvis`. Two optional extras enable experiment tracking integrations:
|
||||
|
||||
```bash
|
||||
uv sync --extra dev --extra eval-wandb # Weights & Biases run tracking
|
||||
uv sync --extra dev --extra eval-sheets # Google Sheets results export
|
||||
```
|
||||
|
||||
!!! note "Python version requirement"
|
||||
Python 3.10 requires the `tomli` package for TOML config parsing. The `evals/pyproject.toml` includes this as a conditional dependency, so it is installed automatically.
|
||||
Python 3.10 requires the `tomli` package for TOML config parsing. `openjarvis` declares it as a conditional dependency, so it is installed automatically.
|
||||
|
||||
## Entry Points
|
||||
|
||||
Two equivalent entry points expose the framework:
|
||||
|
||||
| Command | Surface |
|
||||
|---------|---------|
|
||||
| `jarvis eval {list,run,compare,report}` | Canonical CLI. `run` covers the common options; `compare` and `report` post-process result files. |
|
||||
| `python -m openjarvis.evals {list,run,run-all,summarize,reparse-judge}` | Full research surface, including judge configuration, the agentic runner, and episode mode. |
|
||||
|
||||
The `openjarvis-eval` console script is an alias for `python -m openjarvis.evals` — same commands, same options. This guide uses `jarvis eval` wherever its option set suffices and the module form for research-only options.
|
||||
|
||||
---
|
||||
|
||||
## Datasets
|
||||
|
||||
The framework ships with **30+ datasets** covering academic reasoning, agentic tasks, retrieval, conversation quality, and practical use-case benchmarks. Datasets are grouped by category below.
|
||||
The framework ships with **40 registered benchmarks** covering academic reasoning, agentic tasks, coding, retrieval, conversation quality, and practical use-case benchmarks. Datasets are grouped by category below; `uv run python -m openjarvis.evals list` prints the authoritative registry.
|
||||
|
||||
### Use-Case Benchmarks
|
||||
|
||||
@@ -64,6 +80,7 @@ These benchmarks measure reasoning and knowledge on established academic dataset
|
||||
| **MATH-500** | `math500` | reasoning | Competition-level math problems |
|
||||
| **NaturalReasoning** | `natural-reasoning` | reasoning | Natural language reasoning |
|
||||
| **HLE** | `hle` | reasoning | Humanity's Last Exam hard challenges |
|
||||
| **LiveResearchBench** | `liveresearchbench` | reasoning | Recent research comprehension (Salesforce) |
|
||||
| **SimpleQA** | `simpleqa` | chat | Short-form factual question answering |
|
||||
| **IPW** | `ipw` | chat | Intelligence Per Watt mixed benchmark |
|
||||
|
||||
@@ -79,6 +96,11 @@ These benchmarks test multi-step agent capabilities including tool use, code gen
|
||||
| **TerminalBench** | `terminalbench` | agentic | Terminal-based task completion |
|
||||
| **TerminalBench Native** | `terminalbench-native` | agentic | TerminalBench with native Docker execution |
|
||||
| **TerminalBench V2.1** | `terminalbench-v2.1` | agentic | TB v2.1 Harbor-style Docker tasks |
|
||||
| **PinchBench** | `pinchbench` | agentic | Real-world agent tasks |
|
||||
| **TauBench** | `taubench` | agentic | Multi-turn customer service |
|
||||
| **DeepResearchBench** | `liveresearch` | agentic | Deep research report generation |
|
||||
| **DeepResearchBench (alias)** | `deepresearch` | agentic | Same benchmark as `liveresearch` |
|
||||
| **ToolCall-15** | `toolcall15` | agentic | Tool calling benchmark |
|
||||
| **LifelongAgent** | `lifelong-agent` | agentic | Sequential task learning across sessions |
|
||||
| **PaperArena** | `paperarena` | agentic | Scientific paper analysis |
|
||||
| **DeepPlanning** | `deepplanning` | agentic | Shopping constraint planning |
|
||||
@@ -87,6 +109,14 @@ These benchmarks test multi-step agent capabilities including tool use, code gen
|
||||
| **WebChoreArena** | `webchorearena` | agentic | Web chore tasks |
|
||||
| **WorkArena** | `workarena` | agentic | WorkArena++ enterprise workflows |
|
||||
|
||||
Both `liveresearch` and `deepresearch` are registered keys for the DeepResearchBench report-generation benchmark.
|
||||
|
||||
### Coding Benchmarks
|
||||
|
||||
| Dataset | Key | Category | Description |
|
||||
|---------|-----|----------|-------------|
|
||||
| **LiveCodeBench** | `livecodebench` | coding | Competitive programming |
|
||||
|
||||
### Retrieval Benchmarks
|
||||
|
||||
| Dataset | Key | Category | Description |
|
||||
@@ -123,7 +153,7 @@ The framework includes two pre-built configs for evaluating models on the five c
|
||||
### Cloud models
|
||||
|
||||
```bash
|
||||
uv run python -m openjarvis.evals --config src/openjarvis/evals/configs/use_case_v2_cloud.toml
|
||||
uv run jarvis eval run --config src/openjarvis/evals/configs/use_case_v2_cloud.toml
|
||||
```
|
||||
|
||||
This config evaluates **6 cloud models** (Claude Opus 4.6, Claude Haiku 4.5, Gemini 3.1 Pro, Gemini 3.1 Flash Lite, GPT-5.4, GPT-5 Mini) against all 5 use-case benchmarks with 30 samples each, producing a 6x5 = 30-run matrix. Results are written to `results/use-cases-v2-cloud/`.
|
||||
@@ -131,7 +161,7 @@ This config evaluates **6 cloud models** (Claude Opus 4.6, Claude Haiku 4.5, Gem
|
||||
### Local models
|
||||
|
||||
```bash
|
||||
uv run python -m openjarvis.evals --config src/openjarvis/evals/configs/use_case_v2_local.toml
|
||||
uv run jarvis eval run --config src/openjarvis/evals/configs/use_case_v2_local.toml
|
||||
```
|
||||
|
||||
This config evaluates **5 local models** via Ollama (Qwen3.5 122B-A10B, GPT-OSS 120B, GLM4, Qwen3.5 35B-A3B, GLM-4.7-Flash) against the same 5 benchmarks, producing a 5x5 = 25-run matrix. Uses 2 workers (suitable for single-GPU setups). Results are written to `results/use-cases-v2-local/`.
|
||||
@@ -143,15 +173,22 @@ This config evaluates **5 local models** via Ollama (Qwen3.5 122B-A10B, GPT-OSS
|
||||
|
||||
## Inference Backends
|
||||
|
||||
Every evaluation run routes model calls through one of two backends:
|
||||
Every evaluation run routes model calls through one of four backends:
|
||||
|
||||
| Backend | Key | Description |
|
||||
|---------|-----|-------------|
|
||||
| **jarvis-direct** | `jarvis-direct` | Engine-level inference via `SystemBuilder`. Works for local (Ollama, vLLM, llama.cpp) and cloud models. |
|
||||
| **jarvis-agent** | `jarvis-agent` | Agent-level inference with tool calling. Uses `JarvisSystem.ask()` with the specified agent and tools. |
|
||||
| **hermes** | `hermes` | Real Hermes Agent (Nous Research) via subprocess. Requires `--base-url` and `--api-key`. |
|
||||
| **openclaw** | `openclaw` | Real OpenClaw via Node subprocess. Requires `--base-url` and `--api-key`. |
|
||||
|
||||
Use `jarvis-direct` for most evaluations. Use `jarvis-agent` when the benchmark requires tool use — for example, GAIA tasks that reference files that must be read with `file_read`, or arithmetic tasks that benefit from `calculator`.
|
||||
|
||||
The `hermes` and `openclaw` backends shell out to external agent frameworks and need an OpenAI-compatible endpoint for their model calls: pass `--base-url`/`--api-key`, set the `JARVIS_BACKEND_BASE_URL`/`JARVIS_BACKEND_API_KEY` environment variables, or add a `[backend.external]` section to your config (see [Config Reference](#backendexternal)).
|
||||
|
||||
!!! note "TerminalBench Native"
|
||||
`jarvis eval run --backend` additionally accepts `terminalbench-native`, a Docker-based execution backend used by the TerminalBench Native benchmark.
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage
|
||||
@@ -159,73 +196,106 @@ Use `jarvis-direct` for most evaluations. Use `jarvis-agent` when the benchmark
|
||||
### List available benchmarks and backends
|
||||
|
||||
```bash
|
||||
openjarvis-eval list
|
||||
uv run python -m openjarvis.evals list
|
||||
```
|
||||
|
||||
Output:
|
||||
Abridged output (40 benchmarks, 4 backends):
|
||||
|
||||
```
|
||||
Benchmarks:
|
||||
supergpqa [reasoning ] SuperGPQA multiple-choice
|
||||
gaia [agentic ] GAIA agentic benchmark
|
||||
frames [rag ] FRAMES multi-hop RAG
|
||||
wildchat [chat ] WildChat conversation quality
|
||||
|
||||
Backends:
|
||||
jarvis-direct Engine-level inference (local or cloud)
|
||||
jarvis-agent Agent-level inference with tool calling
|
||||
Available Benchmarks
|
||||
┌──────────────────────┬───────────┬───────────────────────────────────┐
|
||||
│ Name │ Category │ Description │
|
||||
├──────────────────────┼───────────┼───────────────────────────────────┤
|
||||
│ supergpqa │ reasoning │ SuperGPQA multiple-choice │
|
||||
│ gpqa │ reasoning │ GPQA graduate-level MCQ │
|
||||
│ ... │ ... │ ... │
|
||||
│ livecodebench │ coding │ LiveCodeBench competitive progr. │
|
||||
│ toolcall15 │ agentic │ ToolCall-15 tool calling benchmark│
|
||||
└──────────────────────┴───────────┴───────────────────────────────────┘
|
||||
Available Backends
|
||||
┌───────────────┬──────────────────────────────────────────────────┐
|
||||
│ jarvis-direct │ Engine-level inference (local or cloud) │
|
||||
│ jarvis-agent │ Agent-level inference with tool calling │
|
||||
│ hermes │ Real Hermes Agent (Nous Research) via subprocess │
|
||||
│ openclaw │ Real OpenClaw via Node subprocess │
|
||||
└───────────────┴──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`jarvis eval list` prints a similar table but currently shows a curated subset of the registry; the module form above is the authoritative listing.
|
||||
|
||||
### Run a single benchmark
|
||||
|
||||
```bash
|
||||
# Evaluate qwen3:8b on SuperGPQA (engine-level, 10 samples default)
|
||||
openjarvis-eval run -b supergpqa -m qwen3:8b
|
||||
# Evaluate qwen3:8b on SuperGPQA (engine-level, 10 samples)
|
||||
uv run jarvis eval run -b supergpqa -m qwen3:8b -n 10
|
||||
|
||||
# Evaluate GPT-4o on GAIA using the agent backend with tools
|
||||
openjarvis-eval run -b gaia -m gpt-4o --backend jarvis-agent \
|
||||
# Evaluate GPT-5 Mini on GAIA using the agent backend with tools
|
||||
uv run jarvis eval run -b gaia -m gpt-5-mini --backend jarvis-agent \
|
||||
--agent orchestrator --tools calculator,file_read -n 50
|
||||
|
||||
# Run FRAMES with vLLM engine, write output to a file
|
||||
openjarvis-eval run -b frames -m llama3:70b -e vllm \
|
||||
# Run FRAMES with the vLLM engine, write output to a file
|
||||
uv run jarvis eval run -b frames -m llama3:70b -e vllm \
|
||||
-o results/frames_llama70b.jsonl
|
||||
|
||||
# Run WildChat with a higher temperature for chat quality
|
||||
openjarvis-eval run -b wildchat -m qwen3:8b --temperature 0.7 -n 100
|
||||
uv run jarvis eval run -b wildchat -m qwen3:8b --temperature 0.7 -n 100
|
||||
```
|
||||
|
||||
#### Full option reference
|
||||
#### `jarvis eval run` option reference
|
||||
|
||||
| Option | Short | Type | Default | Description |
|
||||
|--------|-------|------|---------|-------------|
|
||||
| `--config` | `-c` | path | — | TOML config file; when provided, `-b` and `-m` are not required |
|
||||
| `--benchmark` | `-b` | choice | required* | `supergpqa`, `gaia`, `frames`, or `wildchat` |
|
||||
| `--backend` | | choice | `jarvis-direct` | `jarvis-direct` or `jarvis-agent` |
|
||||
| `--model` | `-m` | str | required* | Model identifier (e.g., `qwen3:8b`, `gpt-4o`) |
|
||||
| `--engine` | `-e` | str | auto | Engine key (`ollama`, `vllm`, `cloud`, ...) |
|
||||
| `--agent` | | str | `orchestrator` | Agent name for `jarvis-agent` backend |
|
||||
| `--tools` | | str | `""` | Comma-separated tool names (e.g., `calculator,file_read`) |
|
||||
| `--benchmark` | `-b` | str | required* | Any registered benchmark key (see `... list`) |
|
||||
| `--model` | `-m` | str | required* | Model identifier (e.g., `qwen3:8b`, `gpt-5-mini`) |
|
||||
| `--max-samples` | `-n` | int | all | Limit the number of samples evaluated |
|
||||
| `--max-workers` | `-w` | int | `4` | Parallel evaluation workers |
|
||||
| `--judge-model` | | str | `gpt-4o` | LLM used for judge-based scoring |
|
||||
| `--output` | `-o` | path | auto-generated | Output JSONL file path |
|
||||
| `--backend` | | choice | `jarvis-direct` | `jarvis-direct`, `jarvis-agent`, `hermes`, `openclaw`, or `terminalbench-native` |
|
||||
| `--base-url` | | str | — | OpenAI-compatible endpoint URL (env: `JARVIS_BACKEND_BASE_URL`) |
|
||||
| `--api-key` | | str | — | API key for the endpoint (env: `JARVIS_BACKEND_API_KEY`) |
|
||||
| `--agent` | | str | — | Agent name for `jarvis-agent` backend (e.g., `orchestrator`) |
|
||||
| `--engine` | `-e` | str | auto | Engine key (`ollama`, `vllm`, `cloud`, ...) |
|
||||
| `--tools` | | str | `""` | Comma-separated tool names (e.g., `calculator,file_read`) |
|
||||
| `--telemetry/--no-telemetry` | | flag | off | Enable telemetry collection during eval |
|
||||
| `--gpu-metrics/--no-gpu-metrics` | | flag | off | Enable GPU metric polling |
|
||||
| `--seed` | | int | `42` | Random seed for dataset shuffling |
|
||||
| `--split` | | str | dataset default | Override the dataset split |
|
||||
| `--temperature` | | float | `0.0` | Generation temperature |
|
||||
| `--max-tokens` | | int | `2048` | Maximum output tokens |
|
||||
| `--model-filter` | | str | — | Filter models by name substring (multi-model configs) |
|
||||
| `--output` | `-o` | path | auto-generated | Output JSONL file path |
|
||||
| `--wandb-project` / `--wandb-entity` / `--wandb-tags` / `--wandb-group` | | str | `""` | Weights & Biases tracking (requires `eval-wandb` extra) |
|
||||
| `--sheets-id` / `--sheets-worksheet` / `--sheets-creds` | | str | `""` | Google Sheets export (requires `eval-sheets` extra) |
|
||||
| `--verbose` | `-v` | flag | off | Enable debug logging |
|
||||
|
||||
*Required when `--config` is not provided.
|
||||
|
||||
#### Research-only options (`python -m openjarvis.evals run`)
|
||||
|
||||
The module CLI accepts everything above plus research-grade options that `jarvis eval run` does not expose:
|
||||
|
||||
| Option | Short | Type | Default | Description |
|
||||
|--------|-------|------|---------|-------------|
|
||||
| `--max-workers` | `-w` | int | `4` | Parallel evaluation workers |
|
||||
| `--judge-model` | | str | `gpt-5-mini-2025-08-07` | LLM used for judge-based scoring (see `--help` for the current default) |
|
||||
| `--judge-engine` | | str | `cloud` | Engine key for the LLM judge; use `vllm` to judge locally |
|
||||
| `--split` | | str | dataset default | Override the dataset split |
|
||||
| `--compact` | | flag | off | Dense single-table output |
|
||||
| `--trace-detail` | | flag | off | Full per-step trace listing |
|
||||
| `--agentic` | | flag | off | Use `AgenticRunner` for multi-turn agent execution |
|
||||
| `--episode-mode` | | flag | off | Sequential episode processing with lifelong learning (required for `lifelong-agent` and similar benchmarks) |
|
||||
| `--concurrency` | | int | `1` | Parallel query execution (AgenticRunner only) |
|
||||
| `--query-timeout` | | float | — | Per-query wall-clock timeout in seconds (AgenticRunner only) |
|
||||
|
||||
Note: the module CLI's `--backend` choice covers `jarvis-direct`, `jarvis-agent`, `hermes`, and `openclaw`; `terminalbench-native` as a backend is available via `jarvis eval run` and TOML configs.
|
||||
|
||||
### Run all benchmarks at once
|
||||
|
||||
The `run-all` command evaluates a single model against all four benchmarks sequentially and writes results to an output directory:
|
||||
The `run-all` command (module CLI only) evaluates a single model against **every registered benchmark** sequentially and writes results to an output directory:
|
||||
|
||||
```bash
|
||||
openjarvis-eval run-all -m qwen3:8b
|
||||
uv run python -m openjarvis.evals run-all -m qwen3:8b
|
||||
|
||||
# With options
|
||||
openjarvis-eval run-all -m gpt-4o -n 100 --output-dir results/gpt4o/
|
||||
uv run python -m openjarvis.evals run-all -m gpt-5-mini -n 100 --output-dir results/gpt5mini/
|
||||
```
|
||||
|
||||
Output files are written as `{output_dir}/{benchmark}_{model-slug}.jsonl`. The model slug replaces `/` and `:` with `-`, so `qwen3:8b` becomes `qwen3-8b`.
|
||||
@@ -235,7 +305,7 @@ Output files are written as `{output_dir}/{benchmark}_{model-slug}.jsonl`. The m
|
||||
After a run, inspect a JSONL results file:
|
||||
|
||||
```bash
|
||||
openjarvis-eval summarize results/supergpqa_qwen3-8b.jsonl
|
||||
uv run python -m openjarvis.evals summarize results/supergpqa_qwen3-8b.jsonl
|
||||
```
|
||||
|
||||
Output:
|
||||
@@ -251,6 +321,55 @@ Accuracy: 0.7222
|
||||
Errors: 2
|
||||
```
|
||||
|
||||
The module CLI also provides `reparse-judge`, which re-parses stored judge output in a results file and recovers records whose judge verdicts initially failed to parse — useful after improving the judge-output parser without re-running inference.
|
||||
|
||||
### Compare and report
|
||||
|
||||
`jarvis eval` adds two post-processing commands for result files:
|
||||
|
||||
```bash
|
||||
# Side-by-side metric comparison across runs
|
||||
uv run jarvis eval compare results/supergpqa_qwen3-8b.jsonl results/supergpqa_gpt-5-mini.jsonl
|
||||
|
||||
# Detailed report (accuracy, latency, cost, per-subject breakdown) for one run
|
||||
uv run jarvis eval report results/supergpqa_qwen3-8b.jsonl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Evaluating an Already-Running Endpoint
|
||||
|
||||
If you already have an OpenAI-compatible server running — `jarvis serve`, vLLM, SGLang, llama.cpp's server, or a hosted endpoint — point an eval directly at it with `--base-url` and `--api-key`:
|
||||
|
||||
```bash
|
||||
# A vLLM server is already serving Qwen/Qwen3-8B on a GPU node:
|
||||
# vllm serve Qwen/Qwen3-8B --port 8000
|
||||
uv run jarvis eval run -b supergpqa -m Qwen/Qwen3-8B \
|
||||
--base-url http://gpu-node:8000/v1 \
|
||||
--api-key local-key \
|
||||
-n 50
|
||||
```
|
||||
|
||||
The `-m` value must match a model id the server reports at `GET /v1/models`. Both flags fall back to the `JARVIS_BACKEND_BASE_URL` and `JARVIS_BACKEND_API_KEY` environment variables, so CI jobs can set them once:
|
||||
|
||||
```bash
|
||||
export JARVIS_BACKEND_BASE_URL=http://gpu-node:8000/v1
|
||||
export JARVIS_BACKEND_API_KEY=local-key
|
||||
uv run jarvis eval run -b gaia -m Qwen/Qwen3-8B --backend jarvis-agent -n 25
|
||||
```
|
||||
|
||||
For the external `hermes` and `openclaw` backends these values are **required** (the foreign frameworks need an endpoint to send model calls to).
|
||||
|
||||
!!! tip "Engine-level alternative for vLLM"
|
||||
The vLLM engine also honors the `VLLM_HOST` environment variable (default `http://localhost:8000`):
|
||||
|
||||
```bash
|
||||
VLLM_HOST=http://gpu-node:8000 uv run python -m openjarvis.evals run \
|
||||
-b supergpqa -m Qwen/Qwen3-8B -e vllm -n 50
|
||||
```
|
||||
|
||||
`VLLM_HOST` is process-global — if the candidate and the judge both use the `vllm` engine, they share the same endpoint. Prefer `--base-url` when you need them separate.
|
||||
|
||||
---
|
||||
|
||||
## TOML Config System
|
||||
@@ -260,7 +379,7 @@ For research workflows that compare multiple models across multiple benchmarks,
|
||||
### Running from a config
|
||||
|
||||
```bash
|
||||
openjarvis-eval run --config src/openjarvis/evals/configs/full-suite.toml
|
||||
uv run jarvis eval run --config src/openjarvis/evals/configs/full-suite.toml
|
||||
```
|
||||
|
||||
When `--config` is provided, the `-b`/`--benchmark` and `-m`/`--model` options are not required. All settings come from the config file. The CLI expands the matrix, prints a progress table, and writes results to the configured `output_dir`.
|
||||
@@ -269,7 +388,7 @@ When `--config` is provided, the `-b`/`--benchmark` and `-m`/`--model` options a
|
||||
|
||||
A config file has six sections: `[meta]`, `[defaults]`, `[judge]`, `[run]`, `[[models]]`, and `[[benchmarks]]`. Only `[[models]]` and `[[benchmarks]]` are required — all other sections are optional and fall back to built-in defaults.
|
||||
|
||||
```toml title="evals/configs/full-suite.toml"
|
||||
```toml title="src/openjarvis/evals/configs/full-suite.toml"
|
||||
# Suite-level metadata (optional)
|
||||
[meta]
|
||||
name = "full-suite-v1"
|
||||
@@ -353,7 +472,7 @@ For example, `temperature` is resolved as: use `[defaults].temperature` (0.0), t
|
||||
|
||||
A config requires only one `[[models]]` and one `[[benchmarks]]` entry:
|
||||
|
||||
```toml title="evals/configs/minimal.toml"
|
||||
```toml title="src/openjarvis/evals/configs/minimal.toml"
|
||||
[[models]]
|
||||
name = "qwen3:8b"
|
||||
|
||||
@@ -365,7 +484,7 @@ This runs SuperGPQA against qwen3:8b with all default settings. Use this as a st
|
||||
|
||||
### Single-run config with full options
|
||||
|
||||
```toml title="evals/configs/single-run.toml"
|
||||
```toml title="src/openjarvis/evals/configs/single-run.toml"
|
||||
[meta]
|
||||
name = "single-run-example"
|
||||
description = "Evaluate SuperGPQA with a single model and full configuration"
|
||||
@@ -425,7 +544,8 @@ Configuration for the LLM used as a judge in GAIA, FRAMES, and WildChat scoring.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `model` | str | `"gpt-4o"` | Judge model identifier |
|
||||
| `model` | str | `"gpt-5-mini-2025-08-07"` | Judge model identifier |
|
||||
| `engine` | str | `None` | Engine key for the judge (e.g., `"vllm"` to judge locally; defaults to cloud) |
|
||||
| `provider` | str | `None` | Provider override (e.g., `"openai"`) |
|
||||
| `temperature` | float | `0.0` | Judge sampling temperature |
|
||||
| `max_tokens` | int | `1024` | Maximum judge output tokens |
|
||||
@@ -444,6 +564,20 @@ Execution settings that apply to the entire suite.
|
||||
| `seed` | int | `42` | Random seed for dataset shuffling |
|
||||
| `telemetry` | bool | `false` | Enable GPU telemetry capture (energy, power, utilization, throughput) |
|
||||
| `gpu_metrics` | bool | `false` | Enable GPU metric polling via `pynvml` (requires `pynvml` or `nvidia-ml-py`) |
|
||||
| `warmup_samples` | int | `0` | Untimed warmup samples before measurement |
|
||||
| `energy_vendor` | str | `""` | GPU energy vendor override |
|
||||
| `max_turns` | int | `None` | Maximum agent turns per query |
|
||||
| `wandb_project` / `wandb_entity` / `wandb_tags` / `wandb_group` | str | `""` | Weights & Biases tracking |
|
||||
| `sheets_spreadsheet_id` / `sheets_worksheet` / `sheets_credentials_path` | str | `""` / `"Results"` / `""` | Google Sheets export |
|
||||
|
||||
### `[backend.external]`
|
||||
|
||||
Endpoint settings for the `hermes` and `openclaw` backends. Environment variables override TOML values.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `base_url` | str | `None` | OpenAI-compatible endpoint URL (env: `JARVIS_BACKEND_BASE_URL`) |
|
||||
| `api_key` | str | `None` | API key for the endpoint (env: `JARVIS_BACKEND_API_KEY`) |
|
||||
|
||||
### `[[models]]`
|
||||
|
||||
@@ -451,7 +585,7 @@ One block per model. The `name` field is required.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `name` | str | required | Model identifier (e.g., `"qwen3:8b"`, `"gpt-4o"`) |
|
||||
| `name` | str | required | Model identifier (e.g., `"qwen3:8b"`, `"gpt-5-mini"`) |
|
||||
| `engine` | str | `None` | Engine key to use (`"ollama"`, `"vllm"`, `"cloud"`, ...) |
|
||||
| `provider` | str | `None` | Provider override for cloud models (e.g., `"openai"`) |
|
||||
| `temperature` | float | `None` | Override `[defaults].temperature` for this model |
|
||||
@@ -468,10 +602,12 @@ One block per benchmark. The `name` field is required.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `name` | str | required | Benchmark key: `supergpqa`, `gaia`, `frames`, or `wildchat` |
|
||||
| `backend` | str | `"jarvis-direct"` | Inference backend: `jarvis-direct` or `jarvis-agent` |
|
||||
| `name` | str | required | Any registered benchmark key (see `uv run python -m openjarvis.evals list`) |
|
||||
| `backend` | str | `"jarvis-direct"` | `jarvis-direct`, `jarvis-agent`, `hermes`, `openclaw`, or `terminalbench-native` |
|
||||
| `max_samples` | int | `None` | Limit number of samples; `None` evaluates the full dataset |
|
||||
| `split` | str | `None` | Override the default dataset split |
|
||||
| `subset` | str | `None` | Dataset subset/variant (benchmark-specific) |
|
||||
| `record_ids` | list[str] | `None` | Evaluate only these record ids |
|
||||
| `agent` | str | `None` | Agent name for `jarvis-agent` backend (e.g., `"orchestrator"`) |
|
||||
| `tools` | list[str] | `[]` | Tool names for `jarvis-agent` backend |
|
||||
| `judge_model` | str | `None` | Override `[judge].model` for this benchmark only |
|
||||
@@ -647,7 +783,7 @@ The `EvalRunner` processes samples concurrently using a `ThreadPoolExecutor`. Re
|
||||
|
||||
```bash
|
||||
# Use more workers for faster evaluation (if the engine supports concurrent requests)
|
||||
openjarvis-eval run -b supergpqa -m qwen3:8b -w 8 -n 500
|
||||
uv run python -m openjarvis.evals run -b supergpqa -m qwen3:8b -w 8 -n 500
|
||||
```
|
||||
|
||||
!!! warning "Worker count and engine load"
|
||||
|
||||
+105
-17
@@ -682,7 +682,7 @@ fn format_uv_sync_failure(
|
||||
format!(
|
||||
"`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\
|
||||
Try opening a terminal in that directory and running \
|
||||
`uv sync --extra server` manually for the full output.",
|
||||
`uv sync --extra desktop` manually for the full output.",
|
||||
root.display(),
|
||||
code,
|
||||
uv_sync_stderr_tail(stderr, 800),
|
||||
@@ -1143,7 +1143,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
sync_cmd
|
||||
.args([
|
||||
"sync",
|
||||
"--extra", "server",
|
||||
"--extra", "desktop",
|
||||
"--extra", "inference-cloud",
|
||||
"--extra", "inference-google",
|
||||
])
|
||||
@@ -1540,19 +1540,89 @@ async fn fetch_models(api_url: String) -> Result<serde_json::Value, String> {
|
||||
|
||||
#[tauri::command]
|
||||
async fn run_jarvis_command(args: Vec<String>) -> Result<String, String> {
|
||||
let mut cmd_args = vec!["run".to_string(), "jarvis".to_string()];
|
||||
cmd_args.extend(args);
|
||||
let uv_bin = resolve_bin("uv");
|
||||
let output = tokio::process::Command::new(&uv_bin)
|
||||
.args(&cmd_args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
let mut cmd_args = vec!["run".to_string(), "jarvis".to_string()];
|
||||
cmd_args.extend(args.iter().cloned());
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&uv_bin);
|
||||
cmd.args(&cmd_args);
|
||||
// Run from the project root so `uv run jarvis` resolves the OpenJarvis
|
||||
// project regardless of the app's launch cwd. In a packaged install the
|
||||
// cwd isn't the checkout, so without this `jarvis` isn't found and the
|
||||
// backend never starts — the UI then shows "Failed to get response"
|
||||
// (see #531).
|
||||
if let Some(ref root) = find_project_root() {
|
||||
cmd.current_dir(root);
|
||||
}
|
||||
|
||||
let is_serve = args.first().map(|a| a.as_str() == "serve").unwrap_or(false);
|
||||
|
||||
if !is_serve {
|
||||
// Short-lived command (e.g. `stop`, `status`): wait for it and return
|
||||
// its captured output.
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
|
||||
return if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
};
|
||||
}
|
||||
|
||||
// `jarvis serve` is a long-running server that never exits. The old code
|
||||
// used `.output()`, which waits for the process to exit and so hung this
|
||||
// command forever — the "Start" button never resolved (#531). Spawn it
|
||||
// detached instead, drain stderr (a full 4 KB Windows pipe can otherwise
|
||||
// stall the child mid-startup, #309), and poll /health for readiness.
|
||||
cmd.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch jarvis serve: {}", e))?;
|
||||
|
||||
let tail: StderrTail = Arc::new(Mutex::new(Vec::new()));
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
spawn_jarvis_stderr_drainer(stderr, tail.clone());
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
|
||||
let url = format!("http://127.0.0.1:{}/health", JARVIS_PORT);
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(120);
|
||||
|
||||
loop {
|
||||
// Surface an early crash (bad venv, missing Rust ext, etc.) right away
|
||||
// instead of waiting out the full readiness timeout.
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
let stderr = String::from_utf8_lossy(tail.lock().await.as_slice()).into_owned();
|
||||
return Err(format!(
|
||||
"jarvis serve exited (code {:?}) before becoming healthy:\n{}",
|
||||
status.code(),
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
if let Ok(resp) = client.get(&url).send().await {
|
||||
if resp.status().is_success() {
|
||||
// Leave the server running (the Child is detached on drop —
|
||||
// kill_on_drop defaults to false); `stop` tears it down.
|
||||
return Ok(format!(
|
||||
"jarvis serve is ready on http://127.0.0.1:{}",
|
||||
JARVIS_PORT
|
||||
));
|
||||
}
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"jarvis serve did not become healthy on port {} within 120s.",
|
||||
JARVIS_PORT
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1594,11 +1664,29 @@ async fn transcribe_audio(
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
if !status.is_success() {
|
||||
let detail = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("detail")
|
||||
.and_then(|detail| detail.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.filter(|detail| !detail.is_empty())
|
||||
.unwrap_or(body);
|
||||
return Err(format!(
|
||||
"Transcription failed ({}): {}",
|
||||
status.as_u16(),
|
||||
detail
|
||||
));
|
||||
}
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
/// Submit savings to Supabase leaderboard.
|
||||
@@ -2486,7 +2574,7 @@ mod tests {
|
||||
assert!(msg.contains("exit 2"));
|
||||
assert!(msg.contains("/home/u/.openjarvis/src"));
|
||||
assert!(msg.contains("failed to resolve numpy==2.1.3"));
|
||||
assert!(msg.contains("uv sync --extra server")); // actionable next step
|
||||
assert!(msg.contains("uv sync --extra desktop")); // actionable next step
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -97,7 +97,13 @@ export function InputArea() {
|
||||
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
|
||||
const corpusSync = useResearchCorpusSync(deepResearch);
|
||||
|
||||
const { state: speechState, available: speechAvailable, startRecording, stopRecording } = useSpeech();
|
||||
const {
|
||||
state: speechState,
|
||||
error: speechError,
|
||||
available: speechAvailable,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
} = useSpeech();
|
||||
|
||||
// Abort in-flight stream when the user switches models mid-generation.
|
||||
// This prevents errors from trying to continue a stream with a stale model.
|
||||
@@ -122,6 +128,12 @@ export function InputArea() {
|
||||
: streamState.isStreaming ? 'streaming'
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (speechError) {
|
||||
toast.error(speechError, { duration: 8000 });
|
||||
}
|
||||
}, [speechError]);
|
||||
|
||||
const handleMicClick = useCallback(async () => {
|
||||
if (speechState === 'recording') {
|
||||
try {
|
||||
|
||||
+13
-3
@@ -317,8 +317,9 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
|
||||
audioData: Array.from(new Uint8Array(buffer)),
|
||||
filename,
|
||||
});
|
||||
} catch {
|
||||
// Fall through to fetch
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(msg || 'Transcription failed');
|
||||
}
|
||||
}
|
||||
const formData = new FormData();
|
||||
@@ -327,7 +328,16 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Transcription failed: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = typeof body.detail === 'string' ? body.detail : "";
|
||||
} catch {
|
||||
// Keep the status-only message below when the body is not JSON.
|
||||
}
|
||||
throw new Error(detail || `Transcription failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getBase } from './api';
|
||||
import type { ConnectorInfo, SyncStatus, ConnectRequest } from '../types/connectors';
|
||||
import type { ConnectorInfo, SyncStatus, ConnectRequest, ConnectResponse } from '../types/connectors';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connectors API
|
||||
@@ -18,16 +18,47 @@ export async function getConnector(id: string): Promise<ConnectorInfo> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function connectSource(id: string, req: ConnectRequest): Promise<ConnectorInfo> {
|
||||
export async function connectSource(id: string, req: ConnectRequest): Promise<ConnectResponse> {
|
||||
const res = await fetch(`${getBase()}/v1/connectors/${encodeURIComponent(id)}/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to connect ${id}: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
// Surface the backend's actionable detail (e.g. malformed Client ID /
|
||||
// Secret) instead of a bare status code so the UI can render it.
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `Failed to connect ${id}: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Open the server-side OAuth consent flow in a popup and resolve once the
|
||||
* connector reports connected (or reject on timeout). Reused for any OAuth
|
||||
* connector whose /connect returned `oauth_required` (issue #512). */
|
||||
export function startServerOAuth(id: string, oauthStartPath?: string): Promise<void> {
|
||||
const path = oauthStartPath || `/v1/connectors/${encodeURIComponent(id)}/oauth/start`;
|
||||
window.open(`${getBase()}${path}`, '_blank', 'width=600,height=700');
|
||||
return new Promise((resolve, reject) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const info = await getConnector(id);
|
||||
if (info.connected) {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
} catch {
|
||||
// ignore transient polling errors
|
||||
}
|
||||
}, 2000);
|
||||
const timer = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
reject(new Error('Authorization timed out — please try again.'));
|
||||
}, 180000);
|
||||
});
|
||||
}
|
||||
|
||||
export async function disconnectSource(id: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/connectors/${encodeURIComponent(id)}/disconnect`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ResearchEvent, SSEEvent } from '../types';
|
||||
import { getBase } from './api';
|
||||
import { getBase, authHeaders } from './api';
|
||||
|
||||
export interface ChatRequest {
|
||||
model: string;
|
||||
@@ -16,7 +16,7 @@ export async function* streamChat(
|
||||
const base = getBase();
|
||||
const response = await fetch(`${base}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
@@ -67,7 +67,7 @@ export async function* streamResearch(
|
||||
const base = getBase().replace(/\/v1\/?$/, '');
|
||||
const response = await fetch(`${base}/api/research`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ query }),
|
||||
signal,
|
||||
});
|
||||
@@ -106,3 +106,4 @@ export async function* streamResearch(
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { SOURCE_CATALOG } from '../types/connectors';
|
||||
import type { ConnectRequest } from '../types/connectors';
|
||||
import { listConnectors, connectSource, disconnectSource, getSyncStatus, triggerSync } from '../lib/connectors-api';
|
||||
import { listConnectors, connectSource, disconnectSource, getSyncStatus, triggerSync, startServerOAuth } from '../lib/connectors-api';
|
||||
import type { SyncStatus } from '../types/connectors';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -673,7 +673,19 @@ function DataSourcesSection() {
|
||||
setConnectStage('Connecting...');
|
||||
setConnectError('');
|
||||
try {
|
||||
await connectSource(id, req);
|
||||
const resp = await connectSource(id, req);
|
||||
|
||||
// OAuth connectors (Google Drive/Calendar/Contacts/Gmail/Tasks): pasting
|
||||
// a Client ID / Secret only registers the app credentials. The backend
|
||||
// returns `oauth_required` with the path to the in-process consent flow,
|
||||
// which is the only path that actually mints an access token. Open it now
|
||||
// and wait for the callback to flip the connector to connected. Without
|
||||
// this the connector would stay "pending" forever — the exact #512 bug.
|
||||
if (resp.status === 'oauth_required') {
|
||||
setConnectStage('Opening Google sign-in...');
|
||||
await startServerOAuth(id, resp.oauth_start);
|
||||
}
|
||||
|
||||
setConnectStage('Connected! Starting sync...');
|
||||
|
||||
// Wait for connector to show as connected
|
||||
|
||||
@@ -417,7 +417,7 @@ function SelfHostedView() {
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Launch the API server to get the full UI in your browser:
|
||||
</p>
|
||||
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra server\njarvis serve --port 8000"} />
|
||||
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra desktop\njarvis serve --port 8000"} />
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
The chat, dashboard, energy profiling, and cost comparison all run
|
||||
locally on your machine.
|
||||
|
||||
@@ -55,6 +55,19 @@ export interface ConnectRequest {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/** Response from POST /v1/connectors/{id}/connect.
|
||||
* For OAuth connectors, pasting a Client ID / Secret pair only registers the
|
||||
* app credentials; the backend returns `status: "oauth_required"` plus an
|
||||
* `oauth_start` path the UI must open to run the browser consent flow that
|
||||
* actually mints an access token (see issue #512). */
|
||||
export interface ConnectResponse {
|
||||
connector_id: string;
|
||||
connected: boolean;
|
||||
status: "connected" | "pending" | "oauth_required" | "disconnected";
|
||||
oauth_start?: string;
|
||||
sync_status?: string | null;
|
||||
}
|
||||
|
||||
export type WizardStep = "pick" | "connect" | "ingest" | "ready";
|
||||
|
||||
// Backward-compatible alias
|
||||
@@ -257,12 +270,12 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
urlLabel: 'Enable Drive API',
|
||||
},
|
||||
{
|
||||
label: 'Create OAuth credentials: go to Credentials (link below) → click "+ Create Credentials" → choose "OAuth client ID" → Application type: "Desktop app" → click "Create"',
|
||||
label: 'Create OAuth credentials: go to Credentials (link below) → click "+ Create Credentials" → choose "OAuth client ID" → Application type: "Web application". Under "Authorized redirect URIs" add this server\'s callback (e.g. http://localhost:1313/v1/connectors/gdrive/oauth/callback — match the host/port your OpenJarvis server is bound to) → click "Create".',
|
||||
url: 'https://console.cloud.google.com/apis/credentials',
|
||||
urlLabel: 'Open Credentials',
|
||||
},
|
||||
{
|
||||
label: 'A dialog will show your Client ID and Client Secret. Copy both and paste them below. (If you miss it, click the download icon next to your OAuth client to see them again)',
|
||||
label: 'A dialog will show your Client ID and Client Secret. Copy both and paste them below, then click Connect — a Google sign-in window opens to finish authorization. (If you miss the dialog, click the download icon next to your OAuth client to see them again.)',
|
||||
},
|
||||
],
|
||||
inputFields: [
|
||||
|
||||
@@ -193,6 +193,8 @@ nav:
|
||||
- External MCP Servers: user-guide/mcp-external-servers.md
|
||||
- Scheduler: user-guide/scheduler.md
|
||||
- Telemetry: user-guide/telemetry.md
|
||||
- Evaluations: user-guide/evaluations.md
|
||||
- Benchmarks: user-guide/benchmarks.md
|
||||
- Security: user-guide/security.md
|
||||
- LLM-guided spec search: user-guide/llm-guided-spec-search.md
|
||||
- Leaderboard: leaderboard.md
|
||||
|
||||
+34
-2
@@ -1,10 +1,10 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
requires = ["hatchling", "hatch-vcs"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "OpenJarvis"
|
||||
version = "1.0.2"
|
||||
dynamic = ["version"]
|
||||
description = "OpenJarvis — modular AI assistant backend with composable intelligence primitives"
|
||||
readme = "README.md"
|
||||
# Upper bound: numpy 2.2.x (pinned transitively via datasets/pandas) ships no
|
||||
@@ -84,6 +84,13 @@ server = [
|
||||
"pydantic>=2.0",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
desktop = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn>=0.30",
|
||||
"pydantic>=2.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"faster-whisper>=1.0",
|
||||
]
|
||||
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
|
||||
gpu-metrics = ["pynvml>=12.0"]
|
||||
energy-amd = ["amdsmi>=6.1"]
|
||||
@@ -152,6 +159,31 @@ Issues = "https://github.com/open-jarvis/OpenJarvis/issues"
|
||||
|
||||
[project.scripts]
|
||||
jarvis = "openjarvis.cli:main"
|
||||
openjarvis-eval = "openjarvis.evals.cli:main"
|
||||
|
||||
# Version is derived from git tags by hatch-vcs (see #526). For source/editable
|
||||
# checkouts this yields the true `git describe` version (e.g. 1.0.3.dev109+g<sha>)
|
||||
# rather than a stale static string. CI release builds override this with
|
||||
# SETUPTOOLS_SCM_PRETEND_VERSION so the published version equals the pushed tag.
|
||||
#
|
||||
# setuptools_scm cannot bump custom `.devN` tags (only `.dev0`), so the autotag
|
||||
# `vX.Y.Z.devN` tags are deliberately EXCLUDED from version derivation here; the
|
||||
# base is taken from the latest plain release tag (vX.Y.Z) and the dev distance
|
||||
# is computed from commit count since that release.
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.version.raw-options]
|
||||
tag_regex = '^v(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$'
|
||||
git_describe_command = [
|
||||
"git", "describe", "--dirty", "--tags", "--long",
|
||||
"--match", "v[0-9]*", "--exclude", "*dev*", "--exclude", "*rc*", "--exclude", "desktop-*",
|
||||
]
|
||||
# Builds without a git checkout (e.g. the `COPY src/ src/` Docker stages, which
|
||||
# never include .git) can't run `git describe`. Without a fallback that would
|
||||
# hard-fail the build. Mirror the runtime sentinel in src/openjarvis/__init__.py.
|
||||
# Such builds can inject the real version via SETUPTOOLS_SCM_PRETEND_VERSION.
|
||||
fallback_version = "0.0.0+unknown"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openjarvis"]
|
||||
|
||||
@@ -210,6 +210,14 @@ if ! command -v python3 >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
# ---- env ----
|
||||
# OpenJarvis keeps ALL of its state (install tree + runtime data, configs,
|
||||
# databases, caches, logs) under a single root so it never clutters $HOME
|
||||
# beyond one directory. Relocate it by exporting OPENJARVIS_HOME before
|
||||
# running the installer, e.g.:
|
||||
# OPENJARVIS_HOME=~/apps/openjarvis curl ... | bash
|
||||
# The Python runtime honors the same override (and, when OPENJARVIS_HOME is
|
||||
# unset, $XDG_DATA_HOME/openjarvis if XDG_DATA_HOME is set). With nothing set
|
||||
# the root is ~/.openjarvis, so existing installs are untouched.
|
||||
OPENJARVIS_HOME="${OPENJARVIS_HOME:-$HOME/.openjarvis}"
|
||||
OPENJARVIS_REPO_URL="${OPENJARVIS_REPO_URL:-https://github.com/open-jarvis/OpenJarvis.git}"
|
||||
SRC_DIR="$OPENJARVIS_HOME/src"
|
||||
|
||||
@@ -148,7 +148,7 @@ fi
|
||||
|
||||
# ── 7. Install Python dependencies ──────────────────────────────────
|
||||
info "Installing Python dependencies..."
|
||||
uv sync --extra server --quiet 2>/dev/null || uv sync --extra server
|
||||
uv sync --extra desktop --quiet 2>/dev/null || uv sync --extra desktop
|
||||
ok "Python dependencies installed"
|
||||
|
||||
# ── 7b. Build Rust extension ──────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
@@ -103,7 +104,7 @@ class ClaudeCodeAgent(BaseAgent):
|
||||
"Install it from https://nodejs.org/ or via your package manager."
|
||||
)
|
||||
|
||||
dest = Path.home() / ".openjarvis" / "claude_code_runner"
|
||||
dest = get_config_dir() / "claude_code_runner"
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy runner files if missing or outdated
|
||||
|
||||
@@ -9,6 +9,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
|
||||
@dataclass
|
||||
class DigestArtifact:
|
||||
@@ -30,7 +32,7 @@ class DigestStore:
|
||||
|
||||
def __init__(self, db_path: str = "") -> None:
|
||||
if not db_path:
|
||||
db_path = str(Path.home() / ".openjarvis" / "digest.db")
|
||||
db_path = str(get_config_dir() / "digest.db")
|
||||
self._db_path = db_path
|
||||
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
|
||||
@@ -73,12 +73,19 @@ WEB_SEARCH_COST_PER_CALL = 0.01
|
||||
# $0.01/call number — kept as a separate constant so it can drift.
|
||||
OPENAI_WEB_SEARCH_COST_PER_CALL = 0.01
|
||||
|
||||
# Gemini Google-Search grounding: billed at $35 per 1000 grounded
|
||||
# *requests* (2025-12 public list price for the Grounding-with-Google-Search
|
||||
# tool, charged once per request that uses the tool regardless of how many
|
||||
# internal queries it issues). We charge per grounded request, not per
|
||||
# `web_search_queries` entry.
|
||||
GEMINI_SEARCH_COST_PER_CALL = 0.035
|
||||
# Gemini 3 Google-Search grounding: billed at $14 per 1000 search queries.
|
||||
# `_call_gemini_agent` reports the model's `web_search_queries`, so this is
|
||||
# charged per query, not per outer generate_content request.
|
||||
GEMINI_SEARCH_COST_PER_CALL = 0.014
|
||||
|
||||
# Tavily Search, advanced depth: 2 API credits per search request at $0.008
|
||||
# per credit on the public pay-as-you-go plan. WebSearchTool captures actual
|
||||
# credits when Tavily returns usage metadata; this is the fallback estimate.
|
||||
TAVILY_SEARCH_COST_PER_CREDIT = 0.008
|
||||
TAVILY_ADVANCED_SEARCH_CREDITS = 2
|
||||
TAVILY_SEARCH_COST_PER_CALL = (
|
||||
TAVILY_SEARCH_COST_PER_CREDIT * TAVILY_ADVANCED_SEARCH_CREDITS
|
||||
)
|
||||
|
||||
ANTHROPIC_WEB_SEARCH_TOOL = {
|
||||
"type": "web_search_20250305",
|
||||
@@ -101,6 +108,40 @@ def build_web_search_tool(max_uses: int = 8) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def tavily_search_context(
|
||||
query: str,
|
||||
*,
|
||||
max_results: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run OpenJarvis WebSearchTool and return accounting-friendly metadata."""
|
||||
from openjarvis.tools.web_search import WebSearchTool
|
||||
|
||||
tool = WebSearchTool(max_results=max_results)
|
||||
res = tool.execute(query=query, max_results=max_results)
|
||||
meta = dict(res.metadata or {})
|
||||
engine = str(meta.get("engine") or "unknown")
|
||||
credits = 0
|
||||
cost_usd = 0.0
|
||||
if engine == "tavily":
|
||||
try:
|
||||
credits = int(meta.get("credits") or TAVILY_ADVANCED_SEARCH_CREDITS)
|
||||
except (TypeError, ValueError):
|
||||
credits = TAVILY_ADVANCED_SEARCH_CREDITS
|
||||
cost_usd = credits * TAVILY_SEARCH_COST_PER_CREDIT
|
||||
text = res.content or ""
|
||||
if not res.success and not text:
|
||||
text = "(no search results)"
|
||||
return {
|
||||
"text": text,
|
||||
"success": bool(res.success),
|
||||
"engine": engine,
|
||||
"credits": credits,
|
||||
"cost_usd": cost_usd,
|
||||
"n_searches": 1 if (query or "").strip() else 0,
|
||||
"error": None if res.success else text,
|
||||
}
|
||||
|
||||
|
||||
def web_search_cfg(method_cfg: Optional[Dict[str, Any]]) -> Tuple[bool, int]:
|
||||
"""Parse ``method_cfg.web_search = { enabled, max_uses }``.
|
||||
|
||||
@@ -1322,6 +1363,9 @@ __all__ = [
|
||||
"LocalCloudAgent",
|
||||
"NO_TEMP_PREFIXES",
|
||||
"OPENAI_WEB_SEARCH_COST_PER_CALL",
|
||||
"TAVILY_ADVANCED_SEARCH_CREDITS",
|
||||
"TAVILY_SEARCH_COST_PER_CALL",
|
||||
"TAVILY_SEARCH_COST_PER_CREDIT",
|
||||
"WEB_SEARCH_COST_PER_CALL",
|
||||
"_bump_cloud_calls",
|
||||
"_bump_local_calls",
|
||||
@@ -1329,5 +1373,6 @@ __all__ = [
|
||||
"estimate_cost",
|
||||
"is_gpt5_family",
|
||||
"supports_temperature",
|
||||
"tavily_search_context",
|
||||
"web_search_cfg",
|
||||
]
|
||||
|
||||
@@ -15,13 +15,16 @@ PRICES: dict[str, tuple[float, float]] = {
|
||||
"claude-sonnet-4-6": (3.00, 15.0),
|
||||
"claude-haiku-4-5": (1.00, 5.00),
|
||||
"claude-haiku-4-5-20251001": (1.00, 5.00),
|
||||
"gpt-5.5": (5.00, 30.0),
|
||||
"gpt-5": (1.25, 10.0),
|
||||
"gpt-5-mini": (0.25, 2.00),
|
||||
"gpt-5-mini-2025-08-07": (0.25, 2.00),
|
||||
"gpt-4o": (0.15, 0.60),
|
||||
# Gemini Developer API prices (USD per 1M tokens), 2025-12 list price.
|
||||
# 2.5 Pro uses tiered pricing (>200K context = $2.50/$15); we charge the
|
||||
# low-context tier since GAIA / SWE-bench prompts stay well under 200K.
|
||||
# Gemini Developer API prices (USD per 1M tokens). Pro models use tiered
|
||||
# pricing above 200K prompt tokens; GAIA prompts stay under that tier, so
|
||||
# charge the low-context standard rate.
|
||||
"gemini-3.1-pro-preview": (2.00, 12.0),
|
||||
"gemini-3.1-pro-preview-customtools": (2.00, 12.0),
|
||||
"gemini-2.5-pro": (1.25, 10.0),
|
||||
"gemini-2.5-flash": (0.30, 2.50),
|
||||
"gemini-2.5-flash-lite": (0.10, 0.40),
|
||||
@@ -61,7 +64,11 @@ def is_reasoning_model(model: str) -> bool:
|
||||
before emitting visible answer text. At max_tokens=4096 these silently
|
||||
truncate with empty answers on GAIA (26/100 GPT-5, 18/100 Gemini Pro)."""
|
||||
m = (model or "").lower()
|
||||
return is_gpt5_family(model) or "gemini-2.5-pro" in m
|
||||
return (
|
||||
is_gpt5_family(model)
|
||||
or "gemini-2.5-pro" in m
|
||||
or "gemini-3.1-pro" in m
|
||||
)
|
||||
|
||||
|
||||
def default_max_output_tokens(model: str) -> int:
|
||||
|
||||
@@ -34,6 +34,7 @@ from openjarvis.agents.hybrid._base import (
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
web_search_cfg,
|
||||
)
|
||||
from openjarvis.agents.hybrid.mini_swe_agent import (
|
||||
@@ -135,7 +136,12 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
advisor_temperature = float(cfg.get("advisor_temperature", 0.2))
|
||||
|
||||
ws_enabled, ws_max_uses = web_search_cfg(cfg)
|
||||
if ws_enabled and self._cloud_endpoint not in _SEARCH_CAPABLE_ENDPOINTS:
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
if (
|
||||
ws_enabled
|
||||
and search_backend != "tavily"
|
||||
and self._cloud_endpoint not in _SEARCH_CAPABLE_ENDPOINTS
|
||||
):
|
||||
raise ValueError(
|
||||
f"web_search.enabled=true but cloud_endpoint={self._cloud_endpoint!r}; "
|
||||
"server-side web_search is wired for anthropic / openai / gemini "
|
||||
@@ -146,19 +152,23 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
use_ws = ws_enabled
|
||||
gaia_max_turns = int(cfg.get("gaia_max_turns", 8))
|
||||
n_searches_total = 0
|
||||
search_cost_total = 0.0
|
||||
|
||||
# 1. Initial executor pass — advisor (Qwen) doesn't get tools;
|
||||
# only the cloud executor passes do. With web_search on, dispatch
|
||||
# to the search-capable agent loop for the configured provider.
|
||||
if use_ws:
|
||||
initial_resp, e1_in, e1_out, n_s1, e1_turns = self._executor_search(
|
||||
(initial_resp, e1_in, e1_out, n_s1, e1_turns,
|
||||
e1_search_cost) = self._executor_search(
|
||||
user=f"Question:\n{question}",
|
||||
system=EXECUTOR_INITIAL_SYS,
|
||||
max_tokens=executor_max_tokens,
|
||||
ws_max_uses=ws_max_uses,
|
||||
max_turns=gaia_max_turns,
|
||||
query=question,
|
||||
)
|
||||
n_searches_total += n_s1
|
||||
search_cost_total += e1_search_cost
|
||||
else:
|
||||
initial_resp, e1_in, e1_out = self._call_cloud(
|
||||
user=f"Question:\n{question}",
|
||||
@@ -196,14 +206,17 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
f"answer-format rules."
|
||||
)
|
||||
if use_ws:
|
||||
final_answer, e2_in, e2_out, n_s2, e2_turns = self._executor_search(
|
||||
(final_answer, e2_in, e2_out, n_s2, e2_turns,
|
||||
e2_search_cost) = self._executor_search(
|
||||
user=final_user,
|
||||
system=EXECUTOR_FINAL_SYS,
|
||||
max_tokens=executor_max_tokens,
|
||||
ws_max_uses=ws_max_uses,
|
||||
max_turns=gaia_max_turns,
|
||||
query=question,
|
||||
)
|
||||
n_searches_total += n_s2
|
||||
search_cost_total += e2_search_cost
|
||||
else:
|
||||
final_answer, e2_in, e2_out = self._call_cloud(
|
||||
user=final_user,
|
||||
@@ -216,7 +229,10 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
tokens_local = adv_in + adv_out
|
||||
tokens_cloud = e1_in + e1_out + e2_in + e2_out
|
||||
cost = self.cost_usd(self._cloud_model, e1_in + e2_in, e1_out + e2_out)
|
||||
cost += n_searches_total * _search_cost_per_call(self._cloud_endpoint)
|
||||
if search_backend == "tavily":
|
||||
cost += search_cost_total
|
||||
else:
|
||||
cost += n_searches_total * _search_cost_per_call(self._cloud_endpoint)
|
||||
|
||||
meta: Dict[str, Any] = {
|
||||
"tokens_local": tokens_local,
|
||||
@@ -233,7 +249,9 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
"initial_response": initial_resp,
|
||||
"advisor_feedback": advisor_text,
|
||||
"web_search_enabled": use_ws,
|
||||
"search_backend": search_backend,
|
||||
"n_web_searches": n_searches_total,
|
||||
"search_cost_usd": search_cost_total,
|
||||
"note": "inference-only advisor (untrained); lower bound on the technique.",
|
||||
},
|
||||
}
|
||||
@@ -251,16 +269,40 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
max_tokens: int,
|
||||
ws_max_uses: int,
|
||||
max_turns: int,
|
||||
) -> Tuple[str, int, int, int, int]:
|
||||
query: Optional[str] = None,
|
||||
) -> Tuple[str, int, int, int, int, float]:
|
||||
"""Run a search-capable executor pass for the configured cloud.
|
||||
|
||||
Dispatches by ``self._cloud_endpoint`` to the matching ``_base``
|
||||
agent loop. Returns the shared 5-tuple ``(text, p_tok, c_tok,
|
||||
n_searches, turns)``. The endpoint is assumed already validated
|
||||
against ``_SEARCH_CAPABLE_ENDPOINTS`` by the caller.
|
||||
agent loop, or through Tavily when ``method_cfg.search_backend`` is
|
||||
``"tavily"``. Returns ``(text, p_tok, c_tok, n_searches, turns,
|
||||
search_cost_usd)``.
|
||||
"""
|
||||
if str(self._cfg.get("search_backend", "provider")).lower() == "tavily":
|
||||
res = tavily_search_context(
|
||||
query or user,
|
||||
max_results=int(self._cfg.get("tavily_max_results", 5)),
|
||||
)
|
||||
grounded_user = (
|
||||
f"Web search results:\n{res['text']}\n\n"
|
||||
f"Using the search results above, answer this request:\n{user}"
|
||||
)
|
||||
text, p, c = self._call_cloud(
|
||||
user=grounded_user,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.0,
|
||||
)
|
||||
return (
|
||||
text,
|
||||
p,
|
||||
c,
|
||||
int(res["n_searches"]),
|
||||
1,
|
||||
float(res["cost_usd"]),
|
||||
)
|
||||
if self._cloud_endpoint == "anthropic":
|
||||
return self._call_anthropic_agent(
|
||||
text, p, c, n_searches, turns = self._call_anthropic_agent(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=system,
|
||||
@@ -269,8 +311,9 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
tools=[build_web_search_tool(ws_max_uses)],
|
||||
max_turns=max_turns,
|
||||
)
|
||||
return text, p, c, n_searches, turns, 0.0
|
||||
if self._cloud_endpoint == "openai":
|
||||
return self._call_openai_agent(
|
||||
text, p, c, n_searches, turns = self._call_openai_agent(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=system,
|
||||
@@ -278,8 +321,9 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
temperature=0.0,
|
||||
max_turns=max_turns,
|
||||
)
|
||||
return text, p, c, n_searches, turns, 0.0
|
||||
if self._cloud_endpoint == "gemini":
|
||||
return self._call_gemini_agent(
|
||||
text, p, c, n_searches, turns = self._call_gemini_agent(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=system,
|
||||
@@ -287,6 +331,7 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
temperature=0.0,
|
||||
max_turns=max_turns,
|
||||
)
|
||||
return text, p, c, n_searches, turns, 0.0
|
||||
# Genuinely unsupported (openrouter / vllm / unknown). The caller
|
||||
# guard should have caught this; raise defensively.
|
||||
raise ValueError(
|
||||
|
||||
@@ -46,6 +46,7 @@ from openjarvis.agents.hybrid._base import (
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
web_search_cfg,
|
||||
)
|
||||
from openjarvis.agents.hybrid._prices import (
|
||||
@@ -456,8 +457,14 @@ def _format_worker_pool(workers: List[Dict[str, Any]]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _search_capable_indices(workers: List[Dict[str, Any]]) -> List[int]:
|
||||
def _search_capable_indices(
|
||||
workers: List[Dict[str, Any]],
|
||||
*,
|
||||
search_backend: str = "provider",
|
||||
) -> List[int]:
|
||||
"""Indices of workers whose endpoint can run server-side web search."""
|
||||
if search_backend == "tavily":
|
||||
return [w["id"] for w in workers]
|
||||
return [
|
||||
w["id"] for w in workers
|
||||
if (w.get("endpoint") or "openai").lower()
|
||||
@@ -470,6 +477,7 @@ def _build_conductor_prompt(
|
||||
workers: List[Dict[str, Any]],
|
||||
*,
|
||||
web_search_enabled: bool = False,
|
||||
search_backend: str = "provider",
|
||||
) -> str:
|
||||
"""Build the planner prompt.
|
||||
|
||||
@@ -485,12 +493,16 @@ def _build_conductor_prompt(
|
||||
)
|
||||
if not web_search_enabled:
|
||||
return base
|
||||
capable = _search_capable_indices(workers)
|
||||
capable = _search_capable_indices(workers, search_backend=search_backend)
|
||||
if capable:
|
||||
cap_str = ", ".join(str(i) for i in capable)
|
||||
if search_backend == "tavily":
|
||||
capability = "External Tavily search results will be prepended to worker prompts"
|
||||
else:
|
||||
capability = "Only these model indices can perform live web search"
|
||||
constraint = (
|
||||
"\n\nWEB SEARCH CONSTRAINT:\n"
|
||||
f"Only these model indices can perform live web search: [{cap_str}]. "
|
||||
f"{capability}: [{cap_str}]. "
|
||||
"Any step that needs to look up facts, current events, or other "
|
||||
"information not reliably known from memory MUST be routed to one "
|
||||
"of those indices. Steps routed to any other model can only use "
|
||||
@@ -550,8 +562,8 @@ def _call_worker(
|
||||
*,
|
||||
web_search_tool: Optional[Dict[str, Any]] = None,
|
||||
web_search_max_uses: int = 8,
|
||||
) -> Tuple[str, int, int, bool, int]:
|
||||
"""Returns (text, p_tok, c_tok, is_local, n_web_searches).
|
||||
) -> Tuple[str, int, int, bool, int, float]:
|
||||
"""Returns (text, p_tok, c_tok, is_local, n_web_searches, extra_cost).
|
||||
|
||||
``web_search_tool``: a truthy marker that web_search is enabled for
|
||||
this run. When set AND the worker endpoint is search-capable
|
||||
@@ -565,6 +577,22 @@ def _call_worker(
|
||||
max_tok = int(cfg.get("worker_max_tokens", 4096))
|
||||
temp = float(cfg.get("worker_temperature", 0.2))
|
||||
use_ws = web_search_tool is not None
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
extra_cost = 0.0
|
||||
if use_ws and search_backend == "tavily":
|
||||
res = tavily_search_context(
|
||||
prompt,
|
||||
max_results=int(cfg.get("tavily_max_results", 5)),
|
||||
)
|
||||
prompt = (
|
||||
f"Web search results:\n{res['text']}\n\n"
|
||||
f"Using the search results above, answer this request:\n{prompt}"
|
||||
)
|
||||
extra_cost = float(res["cost_usd"])
|
||||
use_ws = False
|
||||
tavily_searches = int(res["n_searches"])
|
||||
else:
|
||||
tavily_searches = 0
|
||||
|
||||
if ep == "vllm":
|
||||
text, p, c = LocalCloudAgent._call_vllm(
|
||||
@@ -575,7 +603,7 @@ def _call_worker(
|
||||
temperature=temp,
|
||||
enable_thinking=False,
|
||||
)
|
||||
return text, p, c, True, 0
|
||||
return text, p, c, True, tavily_searches, extra_cost
|
||||
if ep == "openai":
|
||||
if use_ws:
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_openai_agent(
|
||||
@@ -584,14 +612,14 @@ def _call_worker(
|
||||
max_tokens=max_tok,
|
||||
temperature=(1.0 if is_gpt5_family(worker["model"]) else temp),
|
||||
)
|
||||
return text, p, c, False, n_searches
|
||||
return text, p, c, False, n_searches, 0.0
|
||||
text, p, c = LocalCloudAgent._call_openai(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max_tok,
|
||||
temperature=(1.0 if is_gpt5_family(worker["model"]) else temp),
|
||||
)
|
||||
return text, p, c, False, 0
|
||||
return text, p, c, False, tavily_searches, extra_cost
|
||||
if ep == "openrouter":
|
||||
# OpenRouter is OpenAI-compatible; the helper handles the
|
||||
# base_url + OPENROUTER_API_KEY plumbing. No server-side web
|
||||
@@ -607,7 +635,7 @@ def _call_worker(
|
||||
temperature=temp,
|
||||
extra_body=extra_body if isinstance(extra_body, dict) else None,
|
||||
)
|
||||
return text, p, c, False, 0
|
||||
return text, p, c, False, tavily_searches, extra_cost
|
||||
if ep == "anthropic":
|
||||
eff_temp = temp if supports_temperature(worker["model"]) else 0.0
|
||||
anthropic_kwargs: Dict[str, Any] = dict(
|
||||
@@ -620,7 +648,7 @@ def _call_worker(
|
||||
text, p, c, n_searches = LocalCloudAgent._call_anthropic(
|
||||
worker["model"], **anthropic_kwargs
|
||||
)
|
||||
return text, p, c, False, n_searches
|
||||
return text, p, c, False, n_searches or tavily_searches, extra_cost
|
||||
if ep == "gemini":
|
||||
# Gemini Developer API via google-genai. With web_search on, route
|
||||
# through the Google-Search-grounded agent loop; otherwise plain
|
||||
@@ -632,14 +660,14 @@ def _call_worker(
|
||||
max_tokens=max_tok,
|
||||
temperature=temp,
|
||||
)
|
||||
return text, p, c, False, n_searches
|
||||
return text, p, c, False, n_searches, 0.0
|
||||
text, p, c = LocalCloudAgent._call_gemini(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max_tok,
|
||||
temperature=temp,
|
||||
)
|
||||
return text, p, c, False, 0
|
||||
return text, p, c, False, tavily_searches, extra_cost
|
||||
raise ValueError(f"unsupported worker endpoint: {ep!r}")
|
||||
|
||||
|
||||
@@ -672,7 +700,7 @@ def _swe_worker_step(
|
||||
# backbones today (the loop's tool-call format is Anthropic- or
|
||||
# OpenAI-via-vllm-shaped only). Fall back to one-shot for those —
|
||||
# SWE-bench-wise they were already weak; this preserves behavior.
|
||||
text, p, c, is_local, n_searches = _call_worker(worker, prompt, cfg)
|
||||
text, p, c, is_local, n_searches, _extra = _call_worker(worker, prompt, cfg)
|
||||
return text, p, c, is_local, n_searches, 0
|
||||
out = run_swe_agent_loop(
|
||||
task,
|
||||
@@ -753,13 +781,17 @@ class ConductorAgent(LocalCloudAgent):
|
||||
and bool(task_meta_early.get("base_commit"))
|
||||
)
|
||||
ws_enabled, ws_max_uses = web_search_cfg(cfg)
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
planner_ws = ws_enabled and not swe_mode_early
|
||||
|
||||
# 1. Plan — when web_search is on (GAIA), the prompt names which
|
||||
# worker indices can actually search, so the planner routes
|
||||
# research steps to a search-capable worker.
|
||||
user = _build_conductor_prompt(
|
||||
question, workers, web_search_enabled=planner_ws,
|
||||
question,
|
||||
workers,
|
||||
web_search_enabled=planner_ws,
|
||||
search_backend=search_backend,
|
||||
)
|
||||
plan_text, p_in, p_out = self._call_cloud(
|
||||
user=user,
|
||||
@@ -833,7 +865,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
# memory. Fail loud instead of degrading silently.
|
||||
# ``ws_enabled`` / ``ws_max_uses`` computed up front for the planner
|
||||
# constraint — reuse them here.
|
||||
if ws_enabled and not swe_mode:
|
||||
if ws_enabled and search_backend != "tavily" and not swe_mode:
|
||||
search_workers = [
|
||||
w for w in workers
|
||||
if (w.get("endpoint") or "openai").lower()
|
||||
@@ -894,7 +926,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
# may legitimately not need search; see Task-3 planner
|
||||
# constraint that tries to prevent this upfront).
|
||||
if (
|
||||
ws_enabled and not swe_mode
|
||||
ws_enabled and search_backend != "tavily" and not swe_mode
|
||||
and worker_ep not in _SEARCH_CAPABLE_WORKER_ENDPOINTS
|
||||
):
|
||||
self.record_trace_event({
|
||||
@@ -911,6 +943,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
),
|
||||
})
|
||||
|
||||
extra_cost = 0.0
|
||||
if swe_mode:
|
||||
text, w_in, w_out, is_local, n_searches, bash_turns = (
|
||||
_swe_worker_step(
|
||||
@@ -919,7 +952,9 @@ class ConductorAgent(LocalCloudAgent):
|
||||
)
|
||||
tool_calls += bash_turns
|
||||
else:
|
||||
text, w_in, w_out, is_local, n_searches = _call_worker(
|
||||
(
|
||||
text, w_in, w_out, is_local, n_searches, extra_cost
|
||||
) = _call_worker(
|
||||
worker, prompt, cfg,
|
||||
web_search_tool=ws_tool,
|
||||
web_search_max_uses=ws_max_uses,
|
||||
@@ -930,7 +965,10 @@ class ConductorAgent(LocalCloudAgent):
|
||||
else:
|
||||
tokens_cloud += w_in + w_out
|
||||
cost += self.cost_usd(worker["model"], w_in, w_out)
|
||||
cost += n_searches * _worker_search_cost_per_call(worker_ep)
|
||||
if search_backend != "tavily":
|
||||
cost += n_searches * _worker_search_cost_per_call(worker_ep)
|
||||
if search_backend == "tavily":
|
||||
cost += extra_cost
|
||||
n_web_searches_total += n_searches
|
||||
tool_calls += n_searches
|
||||
steps.append({
|
||||
@@ -981,6 +1019,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
"plan": plan,
|
||||
"fallback_used": fallback_used,
|
||||
"web_search_enabled": ws_enabled,
|
||||
"search_backend": search_backend,
|
||||
"n_web_searches": n_web_searches_total,
|
||||
"parse_attempts": parse_attempts,
|
||||
"workers": [
|
||||
|
||||
@@ -48,12 +48,13 @@ from openjarvis.agents.hybrid._base import (
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
web_search_cfg,
|
||||
)
|
||||
from openjarvis.agents.hybrid._openai_retry import (
|
||||
patch_openai_globally as _patch_openai_globally,
|
||||
)
|
||||
from openjarvis.agents.hybrid._prices import NO_TEMP_PREFIXES
|
||||
from openjarvis.agents.hybrid._prices import NO_TEMP_PREFIXES, default_max_output_tokens
|
||||
from openjarvis.agents.hybrid.mini_swe_agent import run_swe_agent_loop
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
@@ -362,6 +363,8 @@ def _prefetch_context(
|
||||
cloud_endpoint: str,
|
||||
cloud_model: str,
|
||||
max_uses: int = 8,
|
||||
search_backend: str = "provider",
|
||||
tavily_max_results: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Use Anthropic web_search to fetch real source material the worker can read.
|
||||
|
||||
@@ -375,6 +378,22 @@ def _prefetch_context(
|
||||
out: Dict[str, Any] = {
|
||||
"text": "", "tokens": 0, "cost_usd": 0.0, "n_searches": 0,
|
||||
}
|
||||
if search_backend == "tavily":
|
||||
try:
|
||||
res = tavily_search_context(question, max_results=tavily_max_results)
|
||||
out.update(
|
||||
text=res["text"],
|
||||
cost_usd=float(res["cost_usd"]),
|
||||
n_searches=int(res["n_searches"]),
|
||||
tokens=0,
|
||||
engine=res.get("engine"),
|
||||
credits=res.get("credits"),
|
||||
)
|
||||
if res.get("error"):
|
||||
out["error"] = res["error"]
|
||||
except Exception as e:
|
||||
out["error"] = f"{type(e).__name__}: {e}"
|
||||
return out
|
||||
if cloud_endpoint != "anthropic" or not (question or "").strip():
|
||||
return out
|
||||
try:
|
||||
@@ -498,18 +517,22 @@ class MinionsAgent(LocalCloudAgent):
|
||||
max_tokens=cfg.get("worker_max_tokens", 4096),
|
||||
local=True,
|
||||
)
|
||||
cloud_max_tokens = int(
|
||||
cfg.get("cloud_max_tokens")
|
||||
or default_max_output_tokens(self._cloud_model)
|
||||
)
|
||||
if self._cloud_endpoint == "openai":
|
||||
cloud_client = OpenAIClient(
|
||||
model_name=self._cloud_model,
|
||||
temperature=0.0,
|
||||
max_tokens=4096,
|
||||
max_tokens=cloud_max_tokens,
|
||||
)
|
||||
elif self._cloud_endpoint == "anthropic":
|
||||
# Temperature stripping is handled by the global patch above for Opus 4.7+.
|
||||
cloud_client = AnthropicClient(
|
||||
model_name=self._cloud_model,
|
||||
temperature=0.0,
|
||||
max_tokens=4096,
|
||||
max_tokens=cloud_max_tokens,
|
||||
)
|
||||
elif self._cloud_endpoint == "gemini":
|
||||
# The vendored Minion library already special-cases GeminiClient
|
||||
@@ -520,7 +543,7 @@ class MinionsAgent(LocalCloudAgent):
|
||||
cloud_client = GeminiClient(
|
||||
model_name=self._cloud_model,
|
||||
temperature=0.0,
|
||||
max_tokens=4096,
|
||||
max_tokens=cloud_max_tokens,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unsupported cloud endpoint: {self._cloud_endpoint!r}")
|
||||
@@ -560,6 +583,8 @@ class MinionsAgent(LocalCloudAgent):
|
||||
self._cloud_endpoint,
|
||||
self._cloud_model,
|
||||
max_uses=ws_max_uses,
|
||||
search_backend=str(cfg.get("search_backend", "provider")).lower(),
|
||||
tavily_max_results=int(cfg.get("tavily_max_results", 5)),
|
||||
)
|
||||
|
||||
if prefetch.get("text"):
|
||||
|
||||
@@ -38,13 +38,14 @@ except ModuleNotFoundError:
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult
|
||||
from openjarvis.agents.hybrid._energy import EnergyCollector
|
||||
from openjarvis.agents.hybrid._prompts import format_prompt as _format_prompt
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
PACKAGE_DIR = Path(__file__).parent
|
||||
DEFAULT_REGISTRY_DIR = PACKAGE_DIR / "registry"
|
||||
DEFAULT_EXPERIMENTS_DIR = Path(
|
||||
os.environ.get(
|
||||
"OPENJARVIS_HYBRID_EXPERIMENTS_DIR",
|
||||
Path.home() / ".openjarvis" / "experiments" / "hybrid",
|
||||
get_config_dir() / "experiments" / "hybrid",
|
||||
)
|
||||
)
|
||||
DEFAULT_SUBSETS_DIR = DEFAULT_EXPERIMENTS_DIR / "subsets"
|
||||
@@ -122,6 +123,7 @@ def load_registry(registry_dir: Optional[Path] = None) -> Dict[str, Dict[str, An
|
||||
|
||||
# ---------- Bench dispatch ----------
|
||||
|
||||
|
||||
def _load_gaia_tasks(n: Optional[int]) -> List[Dict[str, Any]]:
|
||||
"""GAIA validation. Each task is a dict with `task_id` + `question`."""
|
||||
from openjarvis.evals.datasets.gaia import GAIADataset
|
||||
@@ -137,12 +139,14 @@ def _load_gaia_tasks(n: Optional[int]) -> List[Dict[str, Any]]:
|
||||
# id round-trip.
|
||||
md = rec.metadata or {}
|
||||
task_id = md.get("task_id") or rec.record_id
|
||||
out.append({
|
||||
"task_id": task_id,
|
||||
"question": md.get("question", rec.problem),
|
||||
"reference": rec.reference,
|
||||
"metadata": dict(md),
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"question": md.get("question", rec.problem),
|
||||
"reference": rec.reference,
|
||||
"metadata": dict(md),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -155,19 +159,21 @@ def _load_swebench_tasks(n: Optional[int]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for rec in ds.iter_records():
|
||||
md = rec.metadata or {}
|
||||
out.append({
|
||||
"task_id": md.get("instance_id", rec.record_id),
|
||||
"repo": md.get("repo", ""),
|
||||
"base_commit": md.get("base_commit", ""),
|
||||
"problem_statement": md.get("problem_statement", rec.problem),
|
||||
"hints_text": md.get("hints_text", ""),
|
||||
"test_patch": md.get("test_patch", ""),
|
||||
"FAIL_TO_PASS": md.get("FAIL_TO_PASS", []),
|
||||
"PASS_TO_PASS": md.get("PASS_TO_PASS", []),
|
||||
"version": md.get("version"),
|
||||
"reference": rec.reference,
|
||||
"metadata": dict(md),
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"task_id": md.get("instance_id", rec.record_id),
|
||||
"repo": md.get("repo", ""),
|
||||
"base_commit": md.get("base_commit", ""),
|
||||
"problem_statement": md.get("problem_statement", rec.problem),
|
||||
"hints_text": md.get("hints_text", ""),
|
||||
"test_patch": md.get("test_patch", ""),
|
||||
"FAIL_TO_PASS": md.get("FAIL_TO_PASS", []),
|
||||
"PASS_TO_PASS": md.get("PASS_TO_PASS", []),
|
||||
"version": md.get("version"),
|
||||
"reference": rec.reference,
|
||||
"metadata": dict(md),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -206,7 +212,9 @@ def _load_subset_file(subset_path: str) -> Dict[str, Any]:
|
||||
f"subset {p.name} has no 'task_ids' field; got keys {list(data.keys())}"
|
||||
)
|
||||
return data
|
||||
raise ValueError(f"subset {p.name} must be a list or dict; got {type(data).__name__}")
|
||||
raise ValueError(
|
||||
f"subset {p.name} must be a list or dict; got {type(data).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def _apply_subset(
|
||||
@@ -332,7 +340,11 @@ def _score_swebench(
|
||||
|
||||
patch = extract_patch(answer)
|
||||
if patch is None:
|
||||
return {"success": False, "score": 0.0, "details": {"reason": "no_patch_extracted"}}
|
||||
return {
|
||||
"success": False,
|
||||
"score": 0.0,
|
||||
"details": {"reason": "no_patch_extracted"},
|
||||
}
|
||||
|
||||
record = EvalRecord(
|
||||
record_id=task["task_id"],
|
||||
@@ -368,6 +380,7 @@ def score(
|
||||
|
||||
# ---------- Cell run ----------
|
||||
|
||||
|
||||
def _cell_dir(cell_name: str, root: Path) -> Path:
|
||||
d = root / cell_name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
@@ -440,8 +453,10 @@ def _error_row(task: Dict[str, Any], t0: float, error: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"task_id": task["task_id"],
|
||||
"answer": "",
|
||||
"tokens_local": 0, "tokens_cloud": 0,
|
||||
"cost_usd": 0.0, "latency_s": time.time() - t0,
|
||||
"tokens_local": 0,
|
||||
"tokens_cloud": 0,
|
||||
"cost_usd": 0.0,
|
||||
"latency_s": time.time() - t0,
|
||||
"web_search_uses": 0,
|
||||
"tool_calls": 0,
|
||||
"n_cloud_calls": 0,
|
||||
@@ -456,11 +471,13 @@ def _run_one_inner(
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the agent on one task. Returns a hybrid-shape row."""
|
||||
prompt = _format_prompt(task)
|
||||
ctx = AgentContext(metadata={
|
||||
"task": task,
|
||||
"task_id": task["task_id"],
|
||||
"log_dir": log_dir,
|
||||
})
|
||||
ctx = AgentContext(
|
||||
metadata={
|
||||
"task": task,
|
||||
"task_id": task["task_id"],
|
||||
"log_dir": log_dir,
|
||||
}
|
||||
)
|
||||
t0 = time.time()
|
||||
try:
|
||||
result: AgentResult = agent.run(prompt, ctx)
|
||||
@@ -534,11 +551,12 @@ def _run_one(
|
||||
if worker.is_alive():
|
||||
print(
|
||||
f"[timeout] task={task['task_id']} exceeded "
|
||||
f"{task_timeout_s/60:.1f}m — abandoning worker, recording error row",
|
||||
f"{task_timeout_s / 60:.1f}m — abandoning worker, recording error row",
|
||||
flush=True,
|
||||
)
|
||||
return _error_row(
|
||||
task, t0,
|
||||
task,
|
||||
t0,
|
||||
f"TaskTimeout: task exceeded the {task_timeout_s:.0f}s hybrid "
|
||||
"per-task wall-clock cap (likely a hung network or Modal-harness "
|
||||
"call); worker thread abandoned, task left for resume.",
|
||||
@@ -558,7 +576,7 @@ def _heartbeat(done: int, total: int, row: Dict[str, Any], t_start: float) -> No
|
||||
print(
|
||||
f"[{done}/{total}] {ok} task={row['task_id']} score={sc_str} "
|
||||
f"local={row['tokens_local']} cloud={row['tokens_cloud']} "
|
||||
f"${row['cost_usd']:.3f} {row['latency_s']:.1f}s eta={eta/60:.1f}m",
|
||||
f"${row['cost_usd']:.3f} {row['latency_s']:.1f}s eta={eta / 60:.1f}m",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
@@ -644,9 +662,9 @@ def _write_summary(
|
||||
summary_path.write_text(json.dumps(summary, indent=2))
|
||||
print(
|
||||
f"[summary] {cell_name}: n={n_done}/{cell['n']} err={n_err} "
|
||||
f"acc={acc:.3f} cost=${total_cost:.2f} time={wall/60:.1f}m "
|
||||
f"energy={energy_j/1000:.1f}kJ "
|
||||
f"(session +{elapsed/60:.1f}m +{energy_j_session/1000:.1f}kJ, "
|
||||
f"acc={acc:.3f} cost=${total_cost:.2f} time={wall / 60:.1f}m "
|
||||
f"energy={energy_j / 1000:.1f}kJ "
|
||||
f"(session +{elapsed / 60:.1f}m +{energy_j_session / 1000:.1f}kJ, "
|
||||
f"processed={n_processed})",
|
||||
flush=True,
|
||||
)
|
||||
@@ -664,8 +682,11 @@ def run_cell(
|
||||
out_dir = _cell_dir(cell_name, out_root)
|
||||
with _cell_lock(out_dir, cell_name):
|
||||
_run_cell_locked(
|
||||
cell_name, cell, out_dir,
|
||||
do_score=do_score, resume=resume,
|
||||
cell_name,
|
||||
cell,
|
||||
out_dir,
|
||||
do_score=do_score,
|
||||
resume=resume,
|
||||
)
|
||||
|
||||
|
||||
@@ -727,7 +748,7 @@ def _run_cell_locked(
|
||||
mcfg = cell.get("method_cfg") or {}
|
||||
task_timeout_s = float(mcfg.get("task_timeout_s", DEFAULT_TASK_TIMEOUT_S))
|
||||
if task_timeout_s > 0:
|
||||
print(f"[task-timeout] {task_timeout_s/60:.1f}m per task", flush=True)
|
||||
print(f"[task-timeout] {task_timeout_s / 60:.1f}m per task", flush=True)
|
||||
|
||||
agent = _build_agent(cell)
|
||||
|
||||
@@ -739,18 +760,25 @@ def _run_cell_locked(
|
||||
|
||||
def _process(task: Dict[str, Any]) -> None:
|
||||
row = _run_one(
|
||||
agent, cell["bench"], task, log_dir,
|
||||
agent,
|
||||
cell["bench"],
|
||||
task,
|
||||
log_dir,
|
||||
task_timeout_s=task_timeout_s,
|
||||
)
|
||||
scored: Optional[Dict[str, Any]] = None
|
||||
if do_score and row.get("error") is None:
|
||||
try:
|
||||
scored = score(
|
||||
cell["bench"], task, row["answer"], cell_name=cell_name,
|
||||
cell["bench"],
|
||||
task,
|
||||
row["answer"],
|
||||
cell_name=cell_name,
|
||||
)
|
||||
except Exception as e:
|
||||
scored = {
|
||||
"success": False, "score": 0.0,
|
||||
"success": False,
|
||||
"score": 0.0,
|
||||
"details": {"score_error": str(e)},
|
||||
}
|
||||
full_row = {**row, "score": scored}
|
||||
@@ -789,9 +817,7 @@ def _run_cell_locked(
|
||||
while not watchdog_stop.wait(60.0):
|
||||
try:
|
||||
cur = (
|
||||
results_path.stat().st_mtime
|
||||
if results_path.exists()
|
||||
else last_seen
|
||||
results_path.stat().st_mtime if results_path.exists() else last_seen
|
||||
)
|
||||
except Exception:
|
||||
cur = last_seen
|
||||
@@ -830,7 +856,11 @@ def _run_cell_locked(
|
||||
watchdog_stop.set()
|
||||
|
||||
_write_summary(
|
||||
out_dir, cell_name, cell, tasks, t_start,
|
||||
out_dir,
|
||||
cell_name,
|
||||
cell,
|
||||
tasks,
|
||||
t_start,
|
||||
n_processed=len(pending),
|
||||
energy_j_session=energy.energy_j_total,
|
||||
)
|
||||
@@ -838,6 +868,7 @@ def _run_cell_locked(
|
||||
|
||||
# ---------- CLI ----------
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="python -m openjarvis.agents.hybrid.runner",
|
||||
@@ -855,13 +886,18 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
help="Override experiments output root.",
|
||||
)
|
||||
p.add_argument("--no-score", action="store_true", help="Skip scoring.")
|
||||
p.add_argument("--no-resume", action="store_true", help="Don't resume from results.jsonl.")
|
||||
p.add_argument(
|
||||
"--no-resume", action="store_true", help="Don't resume from results.jsonl."
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
reg_dir = Path(args.registry_dir) if args.registry_dir else None
|
||||
cells = load_registry(reg_dir)
|
||||
if not cells:
|
||||
print(f"[error] no cells found in {reg_dir or DEFAULT_REGISTRY_DIR}", file=sys.stderr)
|
||||
print(
|
||||
f"[error] no cells found in {reg_dir or DEFAULT_REGISTRY_DIR}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
if args.cell not in cells:
|
||||
print(
|
||||
@@ -871,7 +907,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
return 2
|
||||
root = Path(args.root) if args.root else None
|
||||
run_cell(
|
||||
args.cell, cells[args.cell],
|
||||
args.cell,
|
||||
cells[args.cell],
|
||||
do_score=not args.no_score,
|
||||
resume=not args.no_resume,
|
||||
root=root,
|
||||
|
||||
@@ -149,6 +149,17 @@ def _build_router_schema(agent_ids: List[str]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _openai_response_format(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "skillorchestra_route",
|
||||
"schema": schema["format"]["schema"],
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _parse_router_json(text: str) -> Dict[str, Any]:
|
||||
s = (text or "").strip()
|
||||
try:
|
||||
@@ -196,6 +207,70 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
|
||||
agent_id = "skillorchestra"
|
||||
|
||||
def _route_call(
|
||||
self,
|
||||
*,
|
||||
question: str,
|
||||
router_sys: str,
|
||||
router_schema: Dict[str, Any],
|
||||
router_max: int,
|
||||
) -> Tuple[str, int, int]:
|
||||
user = f"Question:\n{question}"
|
||||
if self._cloud_endpoint == "anthropic":
|
||||
kwargs: Dict[str, Any] = {
|
||||
"user": user,
|
||||
"system": router_sys,
|
||||
"max_tokens": router_max,
|
||||
"output_config": router_schema,
|
||||
}
|
||||
if supports_temperature(self._cloud_model):
|
||||
kwargs["temperature"] = 0.0
|
||||
text, r_in, r_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
**kwargs,
|
||||
)
|
||||
return text, r_in, r_out
|
||||
if self._cloud_endpoint == "openai":
|
||||
return self._call_openai(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
temperature=0.0,
|
||||
response_format=_openai_response_format(router_schema),
|
||||
)
|
||||
if self._cloud_endpoint == "gemini":
|
||||
return self._call_gemini(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
temperature=0.0,
|
||||
)
|
||||
raise ValueError(
|
||||
f"SkillOrchestra router unsupported cloud_endpoint={self._cloud_endpoint!r}"
|
||||
)
|
||||
|
||||
def _executor_call(
|
||||
self,
|
||||
*,
|
||||
question: str,
|
||||
max_tokens: int,
|
||||
) -> Tuple[str, int, int]:
|
||||
if self._cloud_endpoint == "anthropic":
|
||||
text, w_in, w_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=question,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.0,
|
||||
)
|
||||
return text, w_in, w_out
|
||||
return self._call_cloud(
|
||||
user=question,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
def _is_soft_failure(self, exc: BaseException) -> Optional[str]:
|
||||
# Empty/unbalanced router JSON — treat as soft failure to match the
|
||||
# hybrid adapter's behavior (matches `err=1` rows in the n=30 cell).
|
||||
@@ -220,33 +295,13 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
router_sys = _build_router_sys(competence, cost)
|
||||
router_schema = _build_router_schema(agent_ids)
|
||||
|
||||
# 1. Route — Anthropic only (output_config schema is Anthropic-specific
|
||||
# in the hybrid adapter). If you need OpenAI routing, swap the prompt
|
||||
# to JSON-mode and bypass output_config.
|
||||
if self._cloud_endpoint != "anthropic":
|
||||
raise ValueError(
|
||||
"SkillOrchestra router requires cloud_endpoint='anthropic'; "
|
||||
f"got {self._cloud_endpoint!r}"
|
||||
)
|
||||
router_max = int(cfg.get("router_max_tokens", 1024))
|
||||
# Strip temperature for Opus 4.7+; Anthropic's output_config does the schema.
|
||||
if supports_temperature(self._cloud_model):
|
||||
router_text, r_in, r_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=f"Question:\n{question}",
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
temperature=0.0,
|
||||
output_config=router_schema,
|
||||
)
|
||||
else:
|
||||
router_text, r_in, r_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=f"Question:\n{question}",
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
output_config=router_schema,
|
||||
)
|
||||
router_text, r_in, r_out = self._route_call(
|
||||
question=question,
|
||||
router_sys=router_sys,
|
||||
router_schema=router_schema,
|
||||
router_max=router_max,
|
||||
)
|
||||
|
||||
decision = _parse_router_json(router_text)
|
||||
skill_weights: Dict[str, float] = decision.get("skill_weights") or {}
|
||||
@@ -329,11 +384,9 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
tokens_cloud += out["tokens_in"] + out["tokens_out"]
|
||||
run_cost += out["cost_usd"]
|
||||
else:
|
||||
ans, w_in, w_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=question,
|
||||
ans, w_in, w_out = self._executor_call(
|
||||
question=question,
|
||||
max_tokens=int(cfg.get("cloud_max_tokens", 4096)),
|
||||
temperature=0.0,
|
||||
)
|
||||
tokens_cloud += w_in + w_out
|
||||
run_cost += self.cost_usd(self._cloud_model, w_in, w_out)
|
||||
|
||||
@@ -31,7 +31,14 @@ from .stage_router import (
|
||||
get_routing_strategy,
|
||||
parse_skill_analysis,
|
||||
)
|
||||
from .tools import anthropic_tools, openai_tools, run_answer, run_code, run_search
|
||||
from .tools import (
|
||||
anthropic_tools,
|
||||
gemini_tools,
|
||||
openai_tools,
|
||||
run_answer,
|
||||
run_code,
|
||||
run_search,
|
||||
)
|
||||
|
||||
# tool name -> routing stage (stage_router uses "reasoning" for code).
|
||||
_TOOL_STAGE = {
|
||||
@@ -115,10 +122,49 @@ def _orchestrate_step(
|
||||
u = resp.usage
|
||||
p = getattr(u, "prompt_tokens", 0) if u else 0
|
||||
c = getattr(u, "completion_tokens", 0) if u else 0
|
||||
elif endpoint == "gemini":
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(
|
||||
http_options=types.HttpOptions(timeout=600_000)
|
||||
)
|
||||
cfg = types.GenerateContentConfig(
|
||||
temperature=1.0,
|
||||
max_output_tokens=max_tokens,
|
||||
tools=[types.Tool(function_declarations=gemini_tools())],
|
||||
)
|
||||
resp = client.models.generate_content(
|
||||
model=model,
|
||||
contents=user,
|
||||
config=cfg,
|
||||
)
|
||||
text = (resp.text or "") if hasattr(resp, "text") else ""
|
||||
tool_calls = []
|
||||
try:
|
||||
parts = resp.candidates[0].content.parts or []
|
||||
except Exception: # noqa: BLE001
|
||||
parts = []
|
||||
for part in parts:
|
||||
fc = getattr(part, "function_call", None)
|
||||
if fc is None:
|
||||
continue
|
||||
name = getattr(fc, "name", None)
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
args = getattr(fc, "args", None) or {}
|
||||
try:
|
||||
args = dict(args)
|
||||
except Exception: # noqa: BLE001
|
||||
args = {}
|
||||
tool_calls.append({"name": name, "input": args})
|
||||
um = getattr(resp, "usage_metadata", None)
|
||||
p = int(getattr(um, "prompt_token_count", 0) or 0) if um else 0
|
||||
c = int(getattr(um, "candidates_token_count", 0) or 0) if um else 0
|
||||
else:
|
||||
raise ValueError(
|
||||
f"orchestrator endpoint {endpoint!r} unsupported — route the "
|
||||
"orchestrator through anthropic/openai (set method_cfg."
|
||||
"orchestrator through anthropic/openai/gemini (set method_cfg."
|
||||
"orchestrator_endpoint)."
|
||||
)
|
||||
|
||||
@@ -192,6 +238,8 @@ def run_orchestrator(
|
||||
code_timeout = int(cfg.get("code_timeout_s", 60))
|
||||
answer_max_tokens = int(cfg.get("answer_max_tokens", 40000))
|
||||
ws_max_uses = int(cfg.get("web_search_max_uses", 5))
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
tavily_max_results = int(cfg.get("tavily_max_results", 5))
|
||||
|
||||
# The orchestrator model: a fixed model per run (the original's
|
||||
# MODEL_NAME). Defaults to the cell's cloud model when that endpoint
|
||||
@@ -203,7 +251,7 @@ def run_orchestrator(
|
||||
orch_model = (cfg.get("orchestrator_model")
|
||||
or cfg.get("router_model")
|
||||
or agent._cloud_model)
|
||||
if orch_endpoint not in ("anthropic", "openai"):
|
||||
if orch_endpoint not in ("anthropic", "openai", "gemini"):
|
||||
orch_endpoint, orch_model = "anthropic", "claude-opus-4-7"
|
||||
orch_max_tokens = int(cfg.get("orchestrator_max_tokens", 4096))
|
||||
|
||||
@@ -306,6 +354,8 @@ def run_orchestrator(
|
||||
res = run_search(
|
||||
agent, spec, context_str=context_str, problem=problem,
|
||||
retriever_url=retriever_url, web_search_max_uses=ws_max_uses,
|
||||
search_backend=search_backend,
|
||||
tavily_max_results=tavily_max_results,
|
||||
)
|
||||
docs = res["search_results_data"]
|
||||
joined = "\n---\n".join(d for d in docs if d)[:char_cap]
|
||||
|
||||
@@ -27,6 +27,7 @@ from .._base import (
|
||||
OPENAI_WEB_SEARCH_COST_PER_CALL,
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
)
|
||||
from .pool import ModelSpec, call_alias
|
||||
|
||||
@@ -110,6 +111,26 @@ def openai_tools() -> List[Dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def gemini_tools() -> List[Dict[str, Any]]:
|
||||
"""The 3 orchestrator tools in Gemini function-declaration shape."""
|
||||
out = []
|
||||
for name, desc in (
|
||||
("search", _SEARCH_DESC),
|
||||
("enhance_reasoning", _CODE_DESC),
|
||||
("answer", _ANSWER_DESC),
|
||||
):
|
||||
out.append({
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# enhance_reasoning / code — eval_frames.py:659-812
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -256,6 +277,8 @@ def run_search(
|
||||
retriever_url: Optional[str] = None,
|
||||
topk: int = 150,
|
||||
web_search_max_uses: int = 5,
|
||||
search_backend: str = "provider",
|
||||
tavily_max_results: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Write a search query with ``spec``, then retrieve documents.
|
||||
|
||||
@@ -283,7 +306,12 @@ def run_search(
|
||||
contents: List[str] = []
|
||||
search_uses = 0
|
||||
|
||||
if retriever_url:
|
||||
if search_backend == "tavily":
|
||||
res = tavily_search_context(query, max_results=tavily_max_results)
|
||||
contents.append(res["text"])
|
||||
search_uses = int(res["n_searches"])
|
||||
cost += float(res["cost_usd"])
|
||||
elif retriever_url:
|
||||
# Faithful path — the original FAISS retriever service.
|
||||
import requests
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ Two modes, gated by ``method_cfg.orchestrator_mode``:
|
||||
(``answer-1``, ``reasoner-2``, ``search-3``, …) is mapped to a real
|
||||
backend through ``EXPERT_MODEL_MAPPING`` — by default the frontier
|
||||
Anthropic worker for `*-1` slots, gpt-5-mini for `*-2`, local Qwen
|
||||
for `*-3`. Search routes to the Anthropic server-side web_search.
|
||||
for `*-3`. Search routes to the configured provider's server-side
|
||||
web-search helper when available.
|
||||
|
||||
We do NOT reproduce the upstream Tavily / FAISS-wiki retriever, the
|
||||
code-interpreter sandbox, or the multi-vLLM mix (Llama-3.3-70B,
|
||||
@@ -43,8 +44,8 @@ Prompted-mode pipeline:
|
||||
prompt; fallback to strongest worker on parse failure.
|
||||
|
||||
Workers come from ``cfg["workers"]`` or a sensible default pool (local
|
||||
Qwen if vLLM up, plus a web-search tool via Anthropic, Opus 4.7,
|
||||
gpt-5-mini).
|
||||
Qwen if vLLM up, plus provider-native web search, the configured frontier
|
||||
cloud model, and gpt-5-mini).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -59,8 +60,11 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from openjarvis.agents._stubs import AgentContext
|
||||
from openjarvis.agents.hybrid._base import (
|
||||
ANTHROPIC_WEB_SEARCH_TOOL,
|
||||
GEMINI_SEARCH_COST_PER_CALL,
|
||||
OPENAI_WEB_SEARCH_COST_PER_CALL,
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
tavily_search_context,
|
||||
)
|
||||
from openjarvis.agents.hybrid._prices import (
|
||||
PRICES,
|
||||
@@ -197,10 +201,23 @@ def _expert_for(slot: str, local_model: Optional[str],
|
||||
cost tier for mid OpenAI calls)
|
||||
- `*-3` (local tier) -> local vLLM (`local_model`)
|
||||
- `answer-math-*` -> same tiers as the numeric suffix
|
||||
- `search-*` -> always the Anthropic web_search tool (the
|
||||
upstream uses Tavily; we have web_search)
|
||||
- `search-*` -> provider-native web search when the cloud
|
||||
endpoint supports it; otherwise Anthropic
|
||||
"""
|
||||
if slot.startswith("search"):
|
||||
ep = (cloud_endpoint or "anthropic").lower()
|
||||
if ep == "openai":
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "openai-web-search",
|
||||
"model": cloud_model,
|
||||
}
|
||||
if ep == "gemini":
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "gemini-web-search",
|
||||
"model": cloud_model,
|
||||
}
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "anthropic-web-search",
|
||||
@@ -340,21 +357,18 @@ def _paper_expert_for(
|
||||
|
||||
# ---- Tavily + Modal helpers -------------------------------------------------
|
||||
|
||||
def _call_tavily_search(query: str, max_results: int = 5) -> Tuple[str, int, int]:
|
||||
"""One-shot Tavily search. Returns (text, p_tok=0, c_tok=0).
|
||||
def _call_tavily_search(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
) -> Tuple[str, int, int, float, int]:
|
||||
"""One-shot Tavily search. Returns (text, p_tok=0, c_tok=0, cost, uses).
|
||||
|
||||
Token counts are reported as zero (no LLM was billed); the OpenJarvis
|
||||
accounting layer separately tallies tool-call counts. Falls back to
|
||||
DuckDuckGo if Tavily is unreachable (see ``WebSearchTool``).
|
||||
"""
|
||||
from openjarvis.tools.web_search import WebSearchTool
|
||||
|
||||
tool = WebSearchTool(max_results=max_results)
|
||||
res = tool.execute(query=query, max_results=max_results)
|
||||
text = res.content or ""
|
||||
if not res.success and not text:
|
||||
text = "(no results)"
|
||||
return text, 0, 0
|
||||
res = tavily_search_context(query, max_results=max_results)
|
||||
return res["text"], 0, 0, float(res["cost_usd"]), int(res["n_searches"])
|
||||
|
||||
|
||||
_MODAL_APP_NAME = "openjarvis-toolorchestra-sandbox"
|
||||
@@ -674,13 +688,25 @@ def _default_pool(
|
||||
"concise extraction, formatting, arithmetic on given data."
|
||||
),
|
||||
})
|
||||
if ep == "openai":
|
||||
search_type = "openai-web-search"
|
||||
search_model = cloud_model
|
||||
search_desc = "OpenAI hosted web search on the configured frontier model."
|
||||
elif ep == "gemini":
|
||||
search_type = "gemini-web-search"
|
||||
search_model = cloud_model
|
||||
search_desc = "Gemini Google Search grounding on the configured frontier model."
|
||||
else:
|
||||
search_type = "anthropic-web-search"
|
||||
search_model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
search_desc = "Anthropic server-side web_search."
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "web-search",
|
||||
"type": "anthropic-web-search",
|
||||
"model": "claude-haiku-4-5",
|
||||
"type": search_type,
|
||||
"model": search_model,
|
||||
"description": (
|
||||
"Anthropic server-side web_search. Use for facts that need a lookup "
|
||||
f"{search_desc} Use for facts that need a lookup "
|
||||
"(recent events, rare names/dates, niche sources). Returns a digest."
|
||||
),
|
||||
})
|
||||
@@ -717,8 +743,13 @@ def _default_pool(
|
||||
# `modal-python` — One-shot Python exec in a fresh Modal Sandbox (the
|
||||
# paper's "Python sandbox" inside `enhance_reasoning`).
|
||||
_TOOLORCH_VALID_TYPES = (
|
||||
"vllm", "openai", "anthropic", "anthropic-web-search", "gemini",
|
||||
"tavily-search", "openrouter", "modal-python",
|
||||
"vllm", "openai", "anthropic", "anthropic-web-search",
|
||||
"openai-web-search", "gemini", "gemini-web-search", "tavily-search",
|
||||
"openrouter", "modal-python",
|
||||
)
|
||||
_TOOLORCH_SEARCH_TYPES = (
|
||||
"anthropic-web-search", "openai-web-search", "gemini-web-search",
|
||||
"tavily-search",
|
||||
)
|
||||
|
||||
# Default model used when an `anthropic-web-search` entry omits `model`.
|
||||
@@ -739,10 +770,12 @@ def _resolve_worker_pool(
|
||||
the override is absent.
|
||||
|
||||
Each user-supplied entry must be a dict with keys ``id``, ``name``,
|
||||
``type``, and (for non-search types) ``model``. ``type`` must be one
|
||||
of ``vllm`` / ``openai`` / ``anthropic`` / ``anthropic-web-search``.
|
||||
``anthropic-web-search`` entries may omit ``model`` — it defaults to
|
||||
``claude-haiku-4-5``.
|
||||
``type``, and (for non-search types) ``model``. Search worker types are
|
||||
``anthropic-web-search``, ``openai-web-search``, ``gemini-web-search``,
|
||||
and ``tavily-search``. ``anthropic-web-search`` entries may omit
|
||||
``model`` — it defaults to ``claude-haiku-4-5``. OpenAI and Gemini
|
||||
search workers default to the configured cloud model. Tavily does not
|
||||
require a model.
|
||||
|
||||
Substitution: ``model = "$local"`` (or ``"<local>"``) resolves to
|
||||
``local_model``; ``model = "$cloud"`` / ``"<cloud>"`` to ``cloud_model``.
|
||||
@@ -804,14 +837,24 @@ def _resolve_worker_pool(
|
||||
elif isinstance(model, str) and model in ("$cloud", "<cloud>"):
|
||||
model = cloud_model
|
||||
entry["model"] = model
|
||||
if wtype == "anthropic-web-search":
|
||||
if wtype in _TOOLORCH_SEARCH_TYPES:
|
||||
if model in (None, ""):
|
||||
model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
if wtype == "anthropic-web-search":
|
||||
model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
elif wtype in ("openai-web-search", "gemini-web-search"):
|
||||
model = cloud_model
|
||||
else:
|
||||
model = wtype
|
||||
entry["model"] = model
|
||||
elif not isinstance(model, str):
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: 'model' must be a string when set"
|
||||
)
|
||||
if wtype in ("openai-web-search", "gemini-web-search") and model not in PRICES:
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: model {model!r} "
|
||||
f"is not in PRICES (known: {sorted(PRICES)})"
|
||||
)
|
||||
# Search workers don't satisfy the "needs a solver" requirement.
|
||||
else:
|
||||
if not isinstance(model, str) or not model:
|
||||
@@ -843,7 +886,7 @@ def _resolve_worker_pool(
|
||||
if not has_non_search:
|
||||
raise ValueError(
|
||||
"Invalid worker_pool entry [-]: worker_pool must contain at least "
|
||||
"one non-search worker (vllm / openai / anthropic)"
|
||||
"one non-search worker (vllm / openai / anthropic / gemini)"
|
||||
)
|
||||
return resolved
|
||||
|
||||
@@ -910,13 +953,31 @@ def _call_worker(
|
||||
)
|
||||
extra = n_searches * WEB_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "openai-web-search":
|
||||
eff_temp = 1.0 if is_gpt5_family(worker["model"]) else temp
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_openai_agent(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max(max_tok, 16384) if is_gpt5_family(worker["model"]) else max_tok,
|
||||
temperature=eff_temp,
|
||||
)
|
||||
extra = n_searches * OPENAI_WEB_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "gemini-web-search":
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_gemini_agent(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max_tok,
|
||||
temperature=temp,
|
||||
)
|
||||
extra = n_searches * GEMINI_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "tavily-search":
|
||||
# Tavily costs are flat per call; charge `WEB_SEARCH_COST_PER_CALL`
|
||||
# for parity with the Anthropic web-search worker. One call = one
|
||||
# "n_search" for accounting.
|
||||
max_results = int(cfg.get("tavily_max_results", 5))
|
||||
text, p, c = _call_tavily_search(str(prompt), max_results=max_results)
|
||||
return text, p, c, False, WEB_SEARCH_COST_PER_CALL, 1
|
||||
text, p, c, extra, n_searches = _call_tavily_search(
|
||||
str(prompt), max_results=max_results,
|
||||
)
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "openrouter":
|
||||
text, p, c = LocalCloudAgent._call_openrouter(
|
||||
worker["model"],
|
||||
@@ -951,7 +1012,7 @@ def _swe_call_worker(
|
||||
caller can surface ``tool_calls`` per row. Fallbacks to one-shot
|
||||
workers return 0 bash turns (no agent loop ran)."""
|
||||
wtype = worker.get("type", "openai")
|
||||
if wtype == "anthropic-web-search":
|
||||
if wtype in _TOOLORCH_SEARCH_TYPES:
|
||||
# Search workers stay one-shot.
|
||||
text, p, c, is_local, extra, n_searches = _call_worker(worker, prompt, cfg)
|
||||
return text, p, c, is_local, extra, n_searches, 0
|
||||
@@ -1197,7 +1258,8 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# Search workers are excluded — they answer fact-lookup
|
||||
# questions, not synthesis.
|
||||
non_search = [
|
||||
w for w in workers if w.get("type") != "anthropic-web-search"
|
||||
w for w in workers
|
||||
if w.get("type") not in _TOOLORCH_SEARCH_TYPES
|
||||
] or workers
|
||||
worker = max(
|
||||
non_search,
|
||||
|
||||
@@ -11,10 +11,11 @@ import logging
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CREATE_AGENTS = """\
|
||||
@@ -537,7 +538,7 @@ class AgentManager:
|
||||
pass
|
||||
|
||||
# User templates
|
||||
user_dir = Path("~/.openjarvis/templates").expanduser()
|
||||
user_dir = get_config_dir() / "templates"
|
||||
if user_dir.is_dir():
|
||||
for f in user_dir.glob("*.toml"):
|
||||
try:
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
|
||||
from openjarvis.agents.digest_store import DigestArtifact, DigestStore
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
|
||||
@@ -21,7 +22,7 @@ def _load_persona(persona_name: str) -> str:
|
||||
"""Load a persona prompt file by name."""
|
||||
search_paths = [
|
||||
Path("configs/openjarvis/prompts/personas") / f"{persona_name}.md",
|
||||
Path.home() / ".openjarvis" / "prompts" / "personas" / f"{persona_name}.md",
|
||||
get_config_dir() / "prompts" / "personas" / f"{persona_name}.md",
|
||||
]
|
||||
for p in search_paths:
|
||||
if p.exists():
|
||||
@@ -202,7 +203,7 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
tts_text = re.sub(r"\*{1,2}([^*]+)\*{1,2}", r"\1", tts_text)
|
||||
tts_text = tts_text.strip()
|
||||
|
||||
output_dir = str(Path.home() / ".openjarvis" / "digests")
|
||||
output_dir = str(get_config_dir() / "digests")
|
||||
tts_call = ToolCall(
|
||||
id="digest-tts-1",
|
||||
name="text_to_speech",
|
||||
|
||||
@@ -43,6 +43,7 @@ from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
from openjarvis.tools.approval_store import (
|
||||
@@ -342,8 +343,8 @@ class ProactiveAgent(ToolUsingAgent):
|
||||
return self._approval_store
|
||||
|
||||
def _build_system_prompt(self) -> str:
|
||||
user_md = _load_md_file(Path.home() / ".openjarvis" / "USER.md")
|
||||
memory_md = _load_md_file(Path.home() / ".openjarvis" / "MEMORY.md")
|
||||
user_md = _load_md_file(get_config_dir() / "USER.md")
|
||||
memory_md = _load_md_file(get_config_dir() / "MEMORY.md")
|
||||
now = datetime.now()
|
||||
context_block = ""
|
||||
if user_md or memory_md:
|
||||
|
||||
@@ -15,16 +15,17 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _openjarvis_home() -> Path:
|
||||
"""Resolve $OPENJARVIS_HOME, defaulting to ~/.openjarvis."""
|
||||
return Path(os.environ.get("OPENJARVIS_HOME", "~/.openjarvis")).expanduser()
|
||||
"""Resolve the OpenJarvis root, honoring OPENJARVIS_HOME / XDG_DATA_HOME."""
|
||||
return get_config_dir()
|
||||
|
||||
|
||||
def load_system_prompt_override(agent_name: str) -> str | None:
|
||||
|
||||
@@ -18,11 +18,13 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_DB_PATH = str(Path.home() / "Library" / "Messages" / "chat.db")
|
||||
_POLL_INTERVAL = 5
|
||||
_PID_FILE = str(Path.home() / ".openjarvis" / "imessage-agent.pid")
|
||||
_PID_FILE = str(get_config_dir() / "imessage-agent.pid")
|
||||
|
||||
|
||||
def poll_new_messages(
|
||||
|
||||
@@ -14,9 +14,11 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PID_FILE = str(Path.home() / ".openjarvis" / "slack-daemon.pid")
|
||||
_PID_FILE = str(get_config_dir() / "slack-daemon.pid")
|
||||
|
||||
|
||||
def _to_slack_fmt(text: str) -> str:
|
||||
|
||||
@@ -22,6 +22,7 @@ from openjarvis.channels._stubs import (
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,7 +39,7 @@ if not _BRIDGE_SRC.exists():
|
||||
)
|
||||
|
||||
# Default runtime directory (npm install + auth state).
|
||||
_DEFAULT_RUNTIME_DIR = Path.home() / ".openjarvis" / "whatsapp_baileys_bridge"
|
||||
_DEFAULT_RUNTIME_DIR = get_config_dir() / "whatsapp_baileys_bridge"
|
||||
|
||||
|
||||
@ChannelRegistry.register("whatsapp_baileys")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Screen capture for vision input (``jarvis ask --screen``).
|
||||
|
||||
Captures the primary monitor to a temporary PNG so it can be handed to a
|
||||
vision-capable model. On Windows this uses the built-in .NET
|
||||
``System.Drawing`` stack (no third-party dependency). Other platforms fall
|
||||
back to ``mss`` or ``Pillow`` if installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# PowerShell: capture the PRIMARY monitor (more legible for a vision model
|
||||
# than a downscaled multi-monitor grab). {path} is filled in with forward
|
||||
# slashes, which .NET accepts on Windows and which avoids backslash escaping.
|
||||
_PS_CAPTURE = """
|
||||
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
|
||||
$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||
$bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.CopyFromScreen($b.X, $b.Y, 0, 0, $bmp.Size)
|
||||
$bmp.Save("{path}", [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$g.Dispose(); $bmp.Dispose()
|
||||
"""
|
||||
|
||||
|
||||
def capture_screen_to_temp() -> str:
|
||||
"""Capture the screen to a temp PNG and return its absolute path.
|
||||
|
||||
Raises ``RuntimeError`` with actionable guidance if capture fails or the
|
||||
platform has no available backend.
|
||||
"""
|
||||
fd, path = tempfile.mkstemp(prefix="jarvis_screen_", suffix=".png")
|
||||
os.close(fd)
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
script = _PS_CAPTURE.replace("{path}", path.replace("\\", "/"))
|
||||
proc = subprocess.run(
|
||||
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if (
|
||||
proc.returncode != 0
|
||||
or not os.path.exists(path)
|
||||
or not os.path.getsize(path)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"screen capture failed: "
|
||||
+ (proc.stderr.strip() or "empty image written")
|
||||
)
|
||||
return path
|
||||
|
||||
# Non-Windows: optional backends.
|
||||
try:
|
||||
import mss # type: ignore
|
||||
|
||||
with mss.mss() as sct:
|
||||
sct.shot(mon=-1, output=path)
|
||||
return path
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from PIL import ImageGrab # type: ignore
|
||||
|
||||
ImageGrab.grab().save(path)
|
||||
return path
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(
|
||||
"screen capture on this platform needs 'mss' or 'Pillow' "
|
||||
"(try: pip install mss)"
|
||||
) from exc
|
||||
|
||||
|
||||
__all__ = ["capture_screen_to_temp"]
|
||||
@@ -9,9 +9,11 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_PATH = Path("~/.openjarvis/version-check.json").expanduser()
|
||||
_CACHE_PATH = get_config_dir() / "version-check.json"
|
||||
_CACHE_TTL = 86400 # 24 hours
|
||||
_PYPI_API = "https://pypi.org/pypi/openjarvis/json"
|
||||
|
||||
@@ -21,7 +23,7 @@ def _config_path() -> Path:
|
||||
override = os.environ.get("OPENJARVIS_CONFIG")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return Path("~/.openjarvis/config.toml").expanduser()
|
||||
return get_config_dir() / "config.toml"
|
||||
|
||||
|
||||
# Commands that surface the "new version available" nudge. We deliberately
|
||||
|
||||
@@ -12,15 +12,12 @@ from rich.table import Table
|
||||
|
||||
def _get_manager():
|
||||
"""Get or create the AgentManager singleton."""
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.agents.manager import AgentManager
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
config = load_config()
|
||||
db_path = config.agent_manager.db_path or str(
|
||||
Path("~/.openjarvis/agents.db").expanduser()
|
||||
)
|
||||
db_path = config.agent_manager.db_path or str(get_config_dir() / "agents.db")
|
||||
return AgentManager(db_path=db_path)
|
||||
|
||||
|
||||
@@ -273,6 +270,7 @@ def search(agent_id: str, query: str, limit: int) -> None:
|
||||
console = Console(stderr=True)
|
||||
try:
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.traces.store import TraceStore
|
||||
|
||||
config = load_config()
|
||||
@@ -281,7 +279,7 @@ def search(agent_id: str, query: str, limit: int) -> None:
|
||||
if not agent:
|
||||
console.print(f"[red]Agent not found: {agent_id}[/red]")
|
||||
return
|
||||
store = TraceStore(config.traces.db_path or "~/.openjarvis/traces.db")
|
||||
store = TraceStore(config.traces.db_path or str(get_config_dir() / "traces.db"))
|
||||
results = store.search(query, agent=agent["name"], limit=limit)
|
||||
if not results:
|
||||
console.print("[dim]No results.[/dim]")
|
||||
@@ -545,8 +543,7 @@ def run_agent(agent_id):
|
||||
updated = manager.get_agent(agent_id)
|
||||
runs = updated.get("total_runs", 0)
|
||||
console.print(
|
||||
f"[green]✓[/green] Tick complete. "
|
||||
f"Status: {updated['status']}, runs: {runs}"
|
||||
f"[green]✓[/green] Tick complete. Status: {updated['status']}, runs: {runs}"
|
||||
)
|
||||
|
||||
# Print the agent's actual output. summary_memory holds the latest tick's
|
||||
@@ -662,6 +659,7 @@ def trace(agent_id, run_number, limit):
|
||||
import datetime
|
||||
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.traces.store import TraceStore
|
||||
|
||||
manager = _get_manager()
|
||||
@@ -671,7 +669,7 @@ def trace(agent_id, run_number, limit):
|
||||
raise SystemExit(1)
|
||||
|
||||
config = load_config()
|
||||
store = TraceStore(config.traces.db_path or "~/.openjarvis/traces.db")
|
||||
store = TraceStore(config.traces.db_path or str(get_config_dir() / "traces.db"))
|
||||
traces = store.list_traces(agent=agent_id, limit=limit)
|
||||
|
||||
if not traces:
|
||||
@@ -841,8 +839,8 @@ def ask(agent_id, message, auto_approve):
|
||||
if auto_approve:
|
||||
executor._confirm_callback = lambda _prompt: True
|
||||
else:
|
||||
executor._confirm_callback = (
|
||||
lambda prompt: click.confirm(f"\n{prompt}", default=False)
|
||||
executor._confirm_callback = lambda prompt: click.confirm(
|
||||
f"\n{prompt}", default=False
|
||||
)
|
||||
# Run the tick with a live trace rather than blocking in silence — the
|
||||
# message we just queued is consumed as this tick's input, so the user
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json as json_mod
|
||||
import logging
|
||||
import sys
|
||||
@@ -619,6 +620,21 @@ def _print_profile(
|
||||
"(default: ~/.openjarvis/knowledge.db)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"-i",
|
||||
"--image",
|
||||
"image_paths",
|
||||
multiple=True,
|
||||
type=click.Path(exists=True, dir_okay=False),
|
||||
help="Image file for a vision model (e.g. gemma3). Repeatable.",
|
||||
)
|
||||
@click.option(
|
||||
"-S",
|
||||
"--screen",
|
||||
"capture_screen",
|
||||
is_flag=True,
|
||||
help="Capture the current screen and send it to the vision model.",
|
||||
)
|
||||
@click.option(
|
||||
"--persona",
|
||||
"persona_name",
|
||||
@@ -645,6 +661,8 @@ def ask(
|
||||
research_mode: bool,
|
||||
knowledge_db: str | None,
|
||||
persona_name: str | None,
|
||||
image_paths: tuple[str, ...] = (),
|
||||
capture_screen: bool = False,
|
||||
) -> None:
|
||||
"""Ask Jarvis a question."""
|
||||
quiet = (ctx.obj or {}).get("quiet", False) or output_json
|
||||
@@ -652,6 +670,27 @@ def ask(
|
||||
console = Console(stderr=True)
|
||||
query_text = " ".join(query)
|
||||
|
||||
# Vision: collect base64 images from --image files and/or --screen.
|
||||
image_b64: list[str] = []
|
||||
for _img_path in image_paths:
|
||||
try:
|
||||
with open(_img_path, "rb") as _fh:
|
||||
image_b64.append(base64.b64encode(_fh.read()).decode("ascii"))
|
||||
except OSError as exc:
|
||||
console.print(f"[red]Could not read image {_img_path}: {exc}[/red]")
|
||||
sys.exit(1)
|
||||
if capture_screen:
|
||||
try:
|
||||
from openjarvis.cli._screen import capture_screen_to_temp
|
||||
|
||||
_shot = capture_screen_to_temp()
|
||||
with open(_shot, "rb") as _fh:
|
||||
image_b64.append(base64.b64encode(_fh.read()).decode("ascii"))
|
||||
logger.debug("Captured screen to %s", _shot)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(f"[red]Screen capture failed:[/red] {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
wall_start = time.monotonic() if enable_profile else None
|
||||
|
||||
# Load config
|
||||
@@ -671,11 +710,26 @@ def ask(
|
||||
# Without this fallback, `[agent].default_system_prompt` and the
|
||||
# SOUL.md / MEMORY.md / USER.md persona system are silently bypassed for
|
||||
# the most common command (`jarvis ask "..."`).
|
||||
agent_explicitly_set = agent_name is not None
|
||||
if agent_name is None:
|
||||
configured_default = (config.agent.default_agent or "").strip()
|
||||
if configured_default:
|
||||
agent_name = configured_default
|
||||
|
||||
# Vision flows only through direct-to-engine mode. If an image/screenshot
|
||||
# was supplied without an explicit --agent, route to direct mode so the
|
||||
# picture reaches the model; if an agent was explicitly requested, say
|
||||
# plainly that the image is being skipped rather than dropping it silently.
|
||||
if image_b64:
|
||||
if not agent_explicitly_set:
|
||||
agent_name = ""
|
||||
else:
|
||||
console.print(
|
||||
"[yellow]Note:[/yellow] --image/--screen only works in direct "
|
||||
"mode; the image is ignored with --agent set. Re-run with "
|
||||
'`--agent ""` to use vision.'
|
||||
)
|
||||
|
||||
# Track whether the user explicitly set --max-tokens
|
||||
user_set_max_tokens = max_tokens is not None
|
||||
|
||||
@@ -714,7 +768,13 @@ def ask(
|
||||
register_builtin_models()
|
||||
|
||||
effective_engine_key = engine_key or config.intelligence.preferred_engine or None
|
||||
resolved = get_engine(config, effective_engine_key)
|
||||
# Pass the model we intend to run so engine selection can skip an engine
|
||||
# that can't actually serve it (e.g. the cloud fallback when the local
|
||||
# engine is down but only a non-OpenAI key is set — see #532). This is the
|
||||
# -m flag or the configured default; when neither is set we leave it None
|
||||
# and a model is chosen per-engine below.
|
||||
selection_model = model_name or config.intelligence.default_model or None
|
||||
resolved = get_engine(config, effective_engine_key, model=selection_model)
|
||||
if resolved is None:
|
||||
console.print(
|
||||
"[red bold]No inference engine available.[/red bold]\n\n"
|
||||
@@ -865,6 +925,27 @@ def ask(
|
||||
return
|
||||
|
||||
# Direct-to-engine mode (no agent)
|
||||
# Privacy guard: a screenshot/image is sensitive, and OpenJarvis is
|
||||
# local-first. If the active engine isn't local, warn before the image
|
||||
# leaves the machine rather than silently uploading it to a third party.
|
||||
_LOCAL_ENGINES = {
|
||||
"ollama",
|
||||
"llamacpp",
|
||||
"vllm",
|
||||
"sglang",
|
||||
"exo",
|
||||
"nexa",
|
||||
"uzu",
|
||||
"apple_fm",
|
||||
"gemma_cpp",
|
||||
}
|
||||
if image_b64 and engine_name not in _LOCAL_ENGINES:
|
||||
console.print(
|
||||
f"[yellow]Privacy warning:[/yellow] sending {len(image_b64)} "
|
||||
f"image(s) to a non-local engine ('{engine_name}'). The image will "
|
||||
"leave this machine. Use a local engine (e.g. ollama) to keep "
|
||||
"vision on-device."
|
||||
)
|
||||
messages = [Message(role=Role.USER, content=query_text)]
|
||||
|
||||
# Memory-augmented context injection
|
||||
@@ -891,6 +972,15 @@ def ask(
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to inject memory context: %s", exc)
|
||||
|
||||
# Vision: attach images to the final user message *after* any context
|
||||
# injection (which may rebuild the list). messages_to_dicts() forwards
|
||||
# the "images" field to Ollama's /api/chat.
|
||||
if image_b64:
|
||||
for _m in reversed(messages):
|
||||
if _m.role == Role.USER:
|
||||
_m.images = image_b64
|
||||
break
|
||||
|
||||
# Generate (InstrumentedEngine handles telemetry + energy recording)
|
||||
try:
|
||||
with console.status("[bold green]Generating...[/bold green]"):
|
||||
|
||||
@@ -332,7 +332,7 @@ def compose_bench(
|
||||
for i, rc in enumerate(run_configs, 1):
|
||||
console.print(f"\n[bold]Run {i}/{len(run_configs)}:[/bold] {rc.benchmark}")
|
||||
try:
|
||||
summary = _run_single(rc, console=console)
|
||||
summary = _run_single(rc, console=console, suite_mode=True)
|
||||
results_table.add_row(
|
||||
rc.benchmark,
|
||||
f"{summary.accuracy:.4f}",
|
||||
|
||||
@@ -276,6 +276,36 @@ def hardware() -> None:
|
||||
config.add_command(show_group, "show")
|
||||
|
||||
|
||||
@config.command("path")
|
||||
def show_path() -> None:
|
||||
"""Print the resolved OpenJarvis directories (home, config, cache).
|
||||
|
||||
All OpenJarvis state lives under a single root, resolved in priority
|
||||
order: ``$OPENJARVIS_HOME`` > ``$XDG_DATA_HOME/openjarvis`` >
|
||||
``~/.openjarvis``. Use this to confirm where your data is stored after
|
||||
setting an override.
|
||||
"""
|
||||
from openjarvis.core.paths import get_cache_dir, get_config_dir, get_config_path
|
||||
|
||||
console = Console(stderr=True)
|
||||
home = get_config_dir()
|
||||
override = (
|
||||
"OPENJARVIS_HOME"
|
||||
if os.environ.get("OPENJARVIS_HOME")
|
||||
else "XDG_DATA_HOME"
|
||||
if os.environ.get("XDG_DATA_HOME")
|
||||
else "default (~/.openjarvis)"
|
||||
)
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Directory")
|
||||
table.add_column("Path", style="cyan")
|
||||
table.add_row("Home (root)", str(home))
|
||||
table.add_row("Config file", str(get_config_path()))
|
||||
table.add_row("Cache", str(get_cache_dir()))
|
||||
console.print(table)
|
||||
console.print(f"[dim]Resolved via: {override}[/dim]")
|
||||
|
||||
|
||||
def _probe_engine_host(url: str, console: Console) -> None:
|
||||
"""Probe an engine host URL and print reachability status."""
|
||||
try:
|
||||
|
||||
@@ -223,6 +223,47 @@ def _check_optional_deps() -> List[CheckResult]:
|
||||
return results
|
||||
|
||||
|
||||
def _check_speech_backend() -> CheckResult:
|
||||
"""Check whether the configured speech backend can load."""
|
||||
try:
|
||||
from openjarvis.speech._discovery import get_speech_backend
|
||||
|
||||
config = _get_config()
|
||||
backend = get_speech_backend(config)
|
||||
if backend is None:
|
||||
return CheckResult(
|
||||
"Speech backend",
|
||||
"warn",
|
||||
"Not configured",
|
||||
details="Install desktop dependencies with `uv sync --extra desktop`.",
|
||||
)
|
||||
|
||||
if backend.health():
|
||||
return CheckResult(
|
||||
"Speech backend",
|
||||
"ok",
|
||||
f"{backend.backend_id} ready",
|
||||
)
|
||||
|
||||
details = None
|
||||
last_error = getattr(backend, "last_error", None)
|
||||
if callable(last_error):
|
||||
details = last_error()
|
||||
return CheckResult(
|
||||
"Speech backend",
|
||||
"warn",
|
||||
f"{backend.backend_id} unavailable",
|
||||
details=details
|
||||
or "Install desktop dependencies with `uv sync --extra desktop`.",
|
||||
)
|
||||
except Exception as exc:
|
||||
return CheckResult(
|
||||
"Speech backend",
|
||||
"warn",
|
||||
f"Could not check: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def _check_security_profile() -> CheckResult:
|
||||
"""Check if a security profile is configured."""
|
||||
try:
|
||||
@@ -306,6 +347,7 @@ def _run_all_checks() -> List[CheckResult]:
|
||||
checks.extend(_check_models())
|
||||
checks.append(_check_default_model())
|
||||
checks.extend(_check_optional_deps())
|
||||
checks.append(_check_speech_backend())
|
||||
checks.append(_check_nodejs())
|
||||
checks.append(_check_security_profile())
|
||||
return checks
|
||||
@@ -354,7 +396,9 @@ def doctor(as_json: bool) -> None:
|
||||
|
||||
# Background tasks section
|
||||
from openjarvis.cli._bg_state import get_status
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
scripts_dir = get_config_dir() / ".scripts"
|
||||
console.print("[bold]Background tasks[/bold]")
|
||||
bg = get_status()
|
||||
bg_failed = False
|
||||
@@ -364,8 +408,8 @@ def doctor(as_json: bool) -> None:
|
||||
elif bg.rust_extension == "failed":
|
||||
console.print(f" [red]✗[/red] Rust extension: failed — {bg.rust_error[:80]}")
|
||||
console.print(
|
||||
" retry: ~/.openjarvis/.scripts/install-rust.sh && "
|
||||
"~/.openjarvis/.scripts/build-extension.sh"
|
||||
f" retry: {scripts_dir}/install-rust.sh && "
|
||||
f"{scripts_dir}/build-extension.sh"
|
||||
)
|
||||
bg_failed = True
|
||||
else:
|
||||
@@ -380,7 +424,7 @@ def doctor(as_json: bool) -> None:
|
||||
console.print(f" [green]✓[/green] {model_id}: ready")
|
||||
elif state == "failed":
|
||||
console.print(f" [red]✗[/red] {model_id}: failed")
|
||||
console.print(f" retry: ~/.openjarvis/.scripts/pull-model.sh {model_id}")
|
||||
console.print(f" retry: {scripts_dir}/pull-model.sh {model_id}")
|
||||
bg_failed = True
|
||||
else:
|
||||
console.print(f" [yellow]…[/yellow] {model_id}: downloading")
|
||||
|
||||
@@ -61,6 +61,12 @@ KNOWN_BENCHMARKS = {
|
||||
KNOWN_BACKENDS = {
|
||||
"jarvis-direct": "Engine-level inference (local or cloud)",
|
||||
"jarvis-agent": "Agent-level inference with tool calling",
|
||||
"hermes": "Real Hermes Agent (Nous Research) via subprocess",
|
||||
"openclaw": "Real OpenClaw via Node subprocess",
|
||||
"terminalbench-native": (
|
||||
"TerminalBench V2.1 via terminal-bench Harness "
|
||||
"(selected with -b terminalbench-native)"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +152,9 @@ def eval_list() -> None:
|
||||
"base_url",
|
||||
default=None,
|
||||
help=(
|
||||
"OpenAI-compat endpoint URL for hermes/openclaw backends "
|
||||
"OpenAI-compatible endpoint for the model under eval. Required for "
|
||||
"hermes/openclaw; for jarvis-direct/jarvis-agent/terminalbench-native "
|
||||
"it bypasses engine discovery and targets this URL directly "
|
||||
"(env: JARVIS_BACKEND_BASE_URL)."
|
||||
),
|
||||
)
|
||||
@@ -154,7 +162,11 @@ def eval_list() -> None:
|
||||
"--api-key",
|
||||
"api_key",
|
||||
default=None,
|
||||
help=("API key for the hermes/openclaw endpoint (env: JARVIS_BACKEND_API_KEY)."),
|
||||
help=(
|
||||
"API key for the --base-url endpoint, sent as a Bearer token. "
|
||||
"Required for hermes/openclaw; optional for first-party backends "
|
||||
"(env: JARVIS_BACKEND_API_KEY)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--agent",
|
||||
@@ -347,7 +359,7 @@ def eval_run(
|
||||
f"{rc.benchmark} / {rc.model}"
|
||||
)
|
||||
try:
|
||||
summary = _run_single(rc, console=console)
|
||||
summary = _run_single(rc, console=console, suite_mode=True)
|
||||
console.print(
|
||||
f" [green]{summary.accuracy:.4f}[/green] "
|
||||
f"({summary.correct}/{summary.scored_samples})"
|
||||
@@ -399,8 +411,10 @@ def eval_run(
|
||||
sheets_spreadsheet_id=sheets_spreadsheet_id,
|
||||
sheets_worksheet=sheets_worksheet,
|
||||
sheets_credentials_path=sheets_credentials_path,
|
||||
# Spec §6.2 — for hermes/openclaw external backends. Falls back to env vars
|
||||
# so users can also set JARVIS_BACKEND_BASE_URL/JARVIS_BACKEND_API_KEY.
|
||||
# OpenAI-compatible endpoint for the model under eval. Required for
|
||||
# hermes/openclaw (Spec §6.2); honored by first-party backends too on
|
||||
# this CLI path. Falls back to env vars so users can also set
|
||||
# JARVIS_BACKEND_BASE_URL/JARVIS_BACKEND_API_KEY.
|
||||
base_url=base_url or os.environ.get("JARVIS_BACKEND_BASE_URL"),
|
||||
api_key=api_key or os.environ.get("JARVIS_BACKEND_API_KEY"),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.security.credential_stripper import CredentialStripper
|
||||
|
||||
_stripper = CredentialStripper()
|
||||
@@ -68,7 +69,7 @@ def setup_logging(
|
||||
if log_file is None:
|
||||
from openjarvis.security.file_utils import secure_mkdir
|
||||
|
||||
log_dir = Path.home() / ".openjarvis"
|
||||
log_dir = get_config_dir()
|
||||
secure_mkdir(log_dir)
|
||||
log_file = log_dir / "cli.log"
|
||||
file_handler = RotatingFileHandler(
|
||||
|
||||
@@ -11,6 +11,8 @@ from typing import Callable, List
|
||||
|
||||
import click
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
# Engine ports that should only be listening on localhost.
|
||||
_ENGINE_PORTS = {11434, 8080, 8000, 30000, 1234, 52415, 18181}
|
||||
|
||||
@@ -132,7 +134,7 @@ class PrivacyScanner:
|
||||
def check_icloud_sync(self) -> ScanResult:
|
||||
"""Check whether ~/.openjarvis is inside iCloud Drive sync scope."""
|
||||
try:
|
||||
config_path = Path("~/.openjarvis").expanduser().resolve()
|
||||
config_path = get_config_dir().resolve()
|
||||
icloud_path = Path("~/Library/Mobile Documents/").expanduser().resolve()
|
||||
if str(config_path).startswith(str(icloud_path)):
|
||||
return ScanResult(
|
||||
|
||||
@@ -11,6 +11,7 @@ from rich.console import Console
|
||||
from openjarvis.cli._banner import print_banner
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.engine import (
|
||||
discover_engines,
|
||||
discover_models,
|
||||
@@ -146,7 +147,13 @@ def serve(
|
||||
except Exception as exc:
|
||||
logger.debug("Telemetry store init failed: %s", exc)
|
||||
|
||||
resolved = get_engine(config, engine_key)
|
||||
# Select with the model we'll actually serve so an engine that can't
|
||||
# serve it (e.g. the cloud fallback without the matching provider key) is
|
||||
# skipped rather than chosen and failing per-request later (see #532).
|
||||
selection_model = (
|
||||
model_name or config.server.model or config.intelligence.default_model or None
|
||||
)
|
||||
resolved = get_engine(config, engine_key, model=selection_model)
|
||||
if resolved is None:
|
||||
console.print(
|
||||
"[red bold]No inference engine available.[/red bold]\n\n"
|
||||
@@ -490,13 +497,9 @@ def serve(
|
||||
agent_manager = None
|
||||
if config.agent_manager.enabled:
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.agents.manager import AgentManager
|
||||
|
||||
am_db = config.agent_manager.db_path or str(
|
||||
Path("~/.openjarvis/agents.db").expanduser()
|
||||
)
|
||||
am_db = config.agent_manager.db_path or str(get_config_dir() / "agents.db")
|
||||
# The server owns the scheduler and is the authoritative tick
|
||||
# runner — on boot it holds no locks, so it (and only it) sweeps
|
||||
# any zombie running→idle left by a previous crash.
|
||||
@@ -601,9 +604,7 @@ def serve(
|
||||
try:
|
||||
import tomllib
|
||||
|
||||
_cfg_path = str(
|
||||
__import__("pathlib").Path.home() / ".openjarvis" / "config.toml"
|
||||
)
|
||||
_cfg_path = str(get_config_dir() / "config.toml")
|
||||
with open(_cfg_path, "rb") as _f:
|
||||
_raw = tomllib.load(_f)
|
||||
api_key = _raw.get("server", {}).get("auth", {}).get("api_key", "")
|
||||
|
||||
@@ -11,6 +11,7 @@ from rich.table import Table
|
||||
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.skills.manager import SkillManager
|
||||
|
||||
|
||||
@@ -28,12 +29,12 @@ def _get_trace_store():
|
||||
|
||||
def _get_discovered_dir() -> Path:
|
||||
"""Return the directory where discovered skill manifests are written."""
|
||||
return Path("~/.openjarvis/skills/discovered/").expanduser()
|
||||
return get_config_dir() / "skills" / "discovered"
|
||||
|
||||
|
||||
def _get_overlay_dir() -> Path:
|
||||
"""Return the directory where optimization overlays are stored."""
|
||||
return Path("~/.openjarvis/learning/skills/").expanduser()
|
||||
return get_config_dir() / "learning" / "skills"
|
||||
|
||||
|
||||
def _get_skill_paths() -> List[Path]:
|
||||
@@ -41,7 +42,7 @@ def _get_skill_paths() -> List[Path]:
|
||||
workspace = Path("./skills")
|
||||
if workspace.exists():
|
||||
paths.append(workspace)
|
||||
user_dir = Path("~/.openjarvis/skills/").expanduser()
|
||||
user_dir = get_config_dir() / "skills"
|
||||
paths.append(user_dir)
|
||||
return paths
|
||||
|
||||
@@ -174,8 +175,10 @@ def _get_resolver(source: str, url: str = ""):
|
||||
from openjarvis.skills.sources.github import GitHubResolver
|
||||
|
||||
cache = _Path(
|
||||
"~/.openjarvis/skill-cache/github/" + url.rstrip("/").rsplit("/", 1)[-1]
|
||||
).expanduser()
|
||||
str(get_config_dir() / "skill-cache" / "github")
|
||||
+ "/"
|
||||
+ url.rstrip("/").rsplit("/", 1)[-1]
|
||||
)
|
||||
return GitHubResolver(cache_root=cache, repo_url=url)
|
||||
raise click.BadParameter(f"Unknown source: {source!r}")
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
|
||||
delete_tokens,
|
||||
load_tokens,
|
||||
resolve_google_credentials,
|
||||
run_oauth_flow,
|
||||
save_tokens,
|
||||
)
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
@@ -290,12 +289,18 @@ class GCalendarConnector(BaseConnector):
|
||||
"""Handle the OAuth callback.
|
||||
|
||||
If *code* looks like a ``client_id:client_secret`` pair (containing
|
||||
``.apps.googleusercontent.com``), store the credentials and trigger
|
||||
the full browser-based OAuth flow. Otherwise treat it as a raw
|
||||
token / auth code.
|
||||
``.apps.googleusercontent.com``), persist the client credentials only.
|
||||
The browser consent + code→token exchange is owned by the in-process
|
||||
server flow (``/v1/connectors/{id}/oauth/start`` → ``/oauth/callback``),
|
||||
which writes the real ``access_token`` to every Google credential file.
|
||||
|
||||
The previous daemon-thread browser flow (its own ``localhost:8789``
|
||||
callback server) failed silently in the bundled desktop context and is
|
||||
intentionally removed here (issue #512).
|
||||
|
||||
Any other *code* is treated as a raw token / auth code.
|
||||
"""
|
||||
code = code.strip()
|
||||
# If user pastes client_id:client_secret, store and run OAuth flow
|
||||
if ":" in code and ".apps.googleusercontent.com" in code:
|
||||
client_id, client_secret = code.split(":", 1)
|
||||
save_tokens(
|
||||
@@ -305,20 +310,6 @@ class GCalendarConnector(BaseConnector):
|
||||
"client_secret": client_secret.strip(),
|
||||
},
|
||||
)
|
||||
import threading
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
run_oauth_flow(
|
||||
client_id=client_id.strip(),
|
||||
client_secret=client_secret.strip(),
|
||||
scopes=GOOGLE_ALL_SCOPES,
|
||||
credentials_path=self._credentials_path,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
else:
|
||||
# Raw token or auth code
|
||||
save_tokens(self._credentials_path, {"token": code})
|
||||
|
||||
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
|
||||
delete_tokens,
|
||||
load_tokens,
|
||||
resolve_google_credentials,
|
||||
run_oauth_flow,
|
||||
save_tokens,
|
||||
)
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
@@ -195,12 +194,18 @@ class GContactsConnector(BaseConnector):
|
||||
"""Handle the OAuth callback.
|
||||
|
||||
If *code* looks like a ``client_id:client_secret`` pair (containing
|
||||
``.apps.googleusercontent.com``), store the credentials and trigger
|
||||
the full browser-based OAuth flow. Otherwise treat it as a raw
|
||||
token / auth code.
|
||||
``.apps.googleusercontent.com``), persist the client credentials only.
|
||||
The browser consent + code→token exchange is owned by the in-process
|
||||
server flow (``/v1/connectors/{id}/oauth/start`` → ``/oauth/callback``),
|
||||
which writes the real ``access_token`` to every Google credential file.
|
||||
|
||||
The previous daemon-thread browser flow (its own ``localhost:8789``
|
||||
callback server) failed silently in the bundled desktop context and is
|
||||
intentionally removed here (issue #512).
|
||||
|
||||
Any other *code* is treated as a raw token / auth code.
|
||||
"""
|
||||
code = code.strip()
|
||||
# If user pastes client_id:client_secret, store and run OAuth flow
|
||||
if ":" in code and ".apps.googleusercontent.com" in code:
|
||||
client_id, client_secret = code.split(":", 1)
|
||||
save_tokens(
|
||||
@@ -210,20 +215,6 @@ class GContactsConnector(BaseConnector):
|
||||
"client_secret": client_secret.strip(),
|
||||
},
|
||||
)
|
||||
import threading
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
run_oauth_flow(
|
||||
client_id=client_id.strip(),
|
||||
client_secret=client_secret.strip(),
|
||||
scopes=GOOGLE_ALL_SCOPES,
|
||||
credentials_path=self._credentials_path,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
else:
|
||||
# Raw token or auth code
|
||||
save_tokens(self._credentials_path, {"token": code})
|
||||
|
||||
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
|
||||
delete_tokens,
|
||||
load_tokens,
|
||||
resolve_google_credentials,
|
||||
run_oauth_flow,
|
||||
save_tokens,
|
||||
)
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
@@ -178,15 +177,25 @@ class GDriveConnector(BaseConnector):
|
||||
"""Handle the OAuth callback.
|
||||
|
||||
If *code* looks like a ``client_id:client_secret`` pair (containing
|
||||
``.apps.googleusercontent.com``), store the credentials and trigger
|
||||
the full browser-based OAuth flow. Otherwise treat it as a raw
|
||||
token / auth code.
|
||||
``.apps.googleusercontent.com``), persist the client credentials only.
|
||||
The actual browser consent + code→token exchange is owned by the
|
||||
in-process server flow (``/v1/connectors/{id}/oauth/start`` →
|
||||
``/oauth/callback``), which writes the real ``access_token`` to every
|
||||
Google credential file.
|
||||
|
||||
Previously this spawned a daemon thread that popped a browser and ran
|
||||
its own ``localhost:8789`` callback server; that thread failed silently
|
||||
in the bundled desktop context, so the connector never gained an access
|
||||
token and never appeared in Data Sources (issue #512). The background
|
||||
flow is intentionally removed here.
|
||||
|
||||
Any other *code* is treated as a raw token / auth code.
|
||||
"""
|
||||
code = code.strip()
|
||||
# If user pastes client_id:client_secret, store and run OAuth flow
|
||||
# A pasted client_id:client_secret pair is the app registration, not a
|
||||
# completed credential — persist it and let the server flow finish auth.
|
||||
if ":" in code and ".apps.googleusercontent.com" in code:
|
||||
client_id, client_secret = code.split(":", 1)
|
||||
# Save credentials immediately
|
||||
save_tokens(
|
||||
self._credentials_path,
|
||||
{
|
||||
@@ -194,21 +203,6 @@ class GDriveConnector(BaseConnector):
|
||||
"client_secret": client_secret.strip(),
|
||||
},
|
||||
)
|
||||
# Run OAuth flow in background thread to avoid blocking
|
||||
import threading
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
run_oauth_flow(
|
||||
client_id=client_id.strip(),
|
||||
client_secret=client_secret.strip(),
|
||||
scopes=GOOGLE_ALL_SCOPES,
|
||||
credentials_path=self._credentials_path,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
else:
|
||||
# Raw token or auth code
|
||||
save_tokens(self._credentials_path, {"token": code})
|
||||
|
||||
@@ -16,6 +16,14 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.core.paths import (
|
||||
ConfigurationError,
|
||||
get_cache_dir,
|
||||
get_config_dir,
|
||||
get_config_path,
|
||||
get_data_dir,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Only used by type-checkers (mypy/pyright) for the ``JarvisConfig.mining``
|
||||
# field annotation. The runtime import is deferred inside
|
||||
@@ -33,15 +41,24 @@ except ModuleNotFoundError:
|
||||
# Hardware dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_CONFIG_DIR = Path.home() / ".openjarvis"
|
||||
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
|
||||
# Legacy names, kept for the ~45 modules that import them. They are resolved
|
||||
# once at import via the env-aware resolver in ``openjarvis.core.paths`` (the
|
||||
# install-script model: ``OPENJARVIS_HOME`` / ``XDG_DATA_HOME`` are set before
|
||||
# the process starts). They are real module attributes — not computed lazily —
|
||||
# so existing tests can ``monkeypatch.setattr`` them and so dataclass-instance
|
||||
# defaults stay consistent. Code that must react to a mid-process env change
|
||||
# (or wants the override regardless of import order) should call
|
||||
# ``get_config_dir()`` / ``get_config_path()`` directly; the dataclass field
|
||||
# defaults below already do this via ``default_factory``.
|
||||
DEFAULT_CONFIG_DIR = get_config_dir()
|
||||
DEFAULT_CONFIG_PATH = get_config_path()
|
||||
|
||||
|
||||
def _ensure_config_dir() -> Path:
|
||||
"""Ensure the config directory exists with restrictive permissions."""
|
||||
from openjarvis.security.file_utils import secure_mkdir
|
||||
|
||||
return secure_mkdir(DEFAULT_CONFIG_DIR)
|
||||
return secure_mkdir(get_config_dir())
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -742,7 +759,9 @@ class SkillsLearningConfig:
|
||||
optimizer: str = "dspy" # "dspy" or "gepa"
|
||||
min_traces_per_skill: int = 20
|
||||
optimization_interval_seconds: int = 86400
|
||||
overlay_dir: str = "~/.openjarvis/learning/skills/"
|
||||
overlay_dir: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "learning" / "skills")
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -894,7 +913,7 @@ class StorageConfig:
|
||||
"""Storage (memory) backend settings."""
|
||||
|
||||
default_backend: str = "sqlite"
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "memory.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "memory.db"))
|
||||
context_top_k: int = 5
|
||||
context_min_score: float = 0.0
|
||||
context_max_tokens: int = 2048
|
||||
@@ -946,8 +965,10 @@ class AgentConfig:
|
||||
system_prompt_path: str = "" # path to system prompt file (.txt, .md)
|
||||
context_from_memory: bool = True # inject relevant memory context into prompts
|
||||
default_system_prompt: str = (
|
||||
"You are a helpful AI assistant running locally on the user's own "
|
||||
"hardware through OpenJarvis. You are not a cloud service. Respond "
|
||||
"You are OpenJarvis, a helpful AI assistant running locally on the "
|
||||
"user's own hardware. You are not a cloud service, and you are not "
|
||||
"Claude, ChatGPT, Gemini, or any other branded assistant. If asked "
|
||||
"who or what you are, identify yourself as OpenJarvis. Respond "
|
||||
"helpfully, concisely, and accurately."
|
||||
)
|
||||
|
||||
@@ -996,7 +1017,7 @@ class TelemetryConfig:
|
||||
"""Telemetry persistence settings."""
|
||||
|
||||
enabled: bool = True
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "telemetry.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "telemetry.db"))
|
||||
gpu_metrics: bool = False
|
||||
gpu_poll_interval_ms: int = 50
|
||||
energy_vendor: str = "" # auto-detect or force "nvidia"/"amd"/"apple"/"cpu_rapl"
|
||||
@@ -1021,7 +1042,7 @@ class AnalyticsConfig:
|
||||
enabled: bool = True
|
||||
host: str = "https://34.231.106.201.sslip.io"
|
||||
key: str = "phc_ysKu72QaxzYNmDpHFcesD2ZZAe68zkdWJEKoYYkc5e3n"
|
||||
anon_id_path: str = str(DEFAULT_CONFIG_DIR / "anon_id")
|
||||
anon_id_path: str = field(default_factory=lambda: str(get_config_dir() / "anon_id"))
|
||||
flush_interval_seconds: int = 30
|
||||
flush_at_size: int = 100
|
||||
|
||||
@@ -1031,7 +1052,7 @@ class TracesConfig:
|
||||
"""Trace system settings."""
|
||||
|
||||
enabled: bool = True
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "traces.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "traces.db"))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -1233,7 +1254,9 @@ class SecurityConfig:
|
||||
mode: str = "redact" # "redact" | "warn" | "block"
|
||||
secret_scanner: bool = True
|
||||
pii_scanner: bool = True
|
||||
audit_log_path: str = str(DEFAULT_CONFIG_DIR / "audit.db")
|
||||
audit_log_path: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "audit.db")
|
||||
)
|
||||
enforce_tool_confirmation: bool = True
|
||||
merkle_audit: bool = True
|
||||
signing_key_path: str = ""
|
||||
@@ -1244,7 +1267,9 @@ class SecurityConfig:
|
||||
local_engine_bypass: bool = False
|
||||
local_tool_bypass: bool = False
|
||||
profile: str = ""
|
||||
vault_key_path: str = str(DEFAULT_CONFIG_DIR / ".vault_key")
|
||||
vault_key_path: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / ".vault_key")
|
||||
)
|
||||
capabilities: CapabilitiesConfig = field(default_factory=CapabilitiesConfig)
|
||||
|
||||
|
||||
@@ -1365,7 +1390,7 @@ class SessionConfig:
|
||||
enabled: bool = False
|
||||
max_age_hours: float = 24.0
|
||||
consolidation_threshold: int = 100
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "sessions.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "sessions.db"))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -1383,7 +1408,9 @@ class OperatorsConfig:
|
||||
"""Operator lifecycle settings."""
|
||||
|
||||
enabled: bool = False
|
||||
manifests_dir: str = "~/.openjarvis/operators"
|
||||
manifests_dir: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "operators")
|
||||
)
|
||||
auto_activate: str = "" # Comma-separated operator IDs
|
||||
|
||||
|
||||
@@ -1409,7 +1436,7 @@ class OptimizeConfig:
|
||||
benchmark: str = ""
|
||||
max_samples: int = 50
|
||||
judge_model: str = "gpt-5-mini-2025-08-07"
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "optimize.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "optimize.db"))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -1417,18 +1444,20 @@ class AgentManagerConfig:
|
||||
"""Persistent agent manager settings."""
|
||||
|
||||
enabled: bool = True
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "agents.db")
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "agents.db"))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MemoryFilesConfig:
|
||||
"""Persistent memory-file paths and nudge settings."""
|
||||
|
||||
soul_path: str = "~/.openjarvis/SOUL.md"
|
||||
memory_path: str = "~/.openjarvis/MEMORY.md"
|
||||
user_path: str = "~/.openjarvis/USER.md"
|
||||
soul_path: str = field(default_factory=lambda: str(get_config_dir() / "SOUL.md"))
|
||||
memory_path: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "MEMORY.md")
|
||||
)
|
||||
user_path: str = field(default_factory=lambda: str(get_config_dir() / "USER.md"))
|
||||
nudge_interval: int = 10
|
||||
persona_name: str = "" # named persona dir under ~/.openjarvis/personas/<name>/
|
||||
persona_name: str = "" # named persona dir under <config-dir>/personas/<name>/
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -1467,13 +1496,15 @@ class SkillsConfig:
|
||||
"""Configuration for agent-authored procedural skills."""
|
||||
|
||||
enabled: bool = True
|
||||
skills_dir: str = "~/.openjarvis/skills/"
|
||||
skills_dir: str = field(default_factory=lambda: str(get_config_dir() / "skills"))
|
||||
active: str = "*"
|
||||
auto_discover: bool = True
|
||||
auto_sync: bool = False
|
||||
nudge_interval: int = 15
|
||||
index_repo: str = "https://github.com/openjarvis/skill-index.git"
|
||||
index_dir: str = "~/.openjarvis/skill-index/"
|
||||
index_dir: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "skill-index")
|
||||
)
|
||||
max_depth: int = 5
|
||||
sandbox_dangerous: bool = True
|
||||
sources: List[SkillSourceConfig] = field(default_factory=list)
|
||||
@@ -1779,7 +1810,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
|
||||
elif os.environ.get("OPENJARVIS_CONFIG"):
|
||||
config_path = Path(os.environ["OPENJARVIS_CONFIG"]).expanduser().resolve()
|
||||
else:
|
||||
config_path = DEFAULT_CONFIG_PATH
|
||||
config_path = get_config_path()
|
||||
if config_path.exists():
|
||||
with open(config_path, "rb") as fh:
|
||||
data = tomllib.load(fh)
|
||||
@@ -2117,9 +2148,14 @@ __all__ = [
|
||||
"BrowserConfig",
|
||||
"CapabilitiesConfig",
|
||||
"ChannelConfig",
|
||||
"ConfigurationError",
|
||||
"DEFAULT_CONFIG_DIR",
|
||||
"DEFAULT_CONFIG_PATH",
|
||||
"DiscordChannelConfig",
|
||||
"get_cache_dir",
|
||||
"get_config_dir",
|
||||
"get_config_path",
|
||||
"get_data_dir",
|
||||
"EmailChannelConfig",
|
||||
"EngineConfig",
|
||||
"FeishuChannelConfig",
|
||||
|
||||
@@ -10,13 +10,20 @@ import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
_DEFAULT_PATH = Path.home() / ".openjarvis" / "credentials.toml"
|
||||
|
||||
|
||||
def _default_path() -> Path:
|
||||
"""Resolve the credentials file under the OpenJarvis root (env-aware)."""
|
||||
return get_config_dir() / "credentials.toml"
|
||||
|
||||
|
||||
TOOL_CREDENTIALS: dict[str, list[str]] = {
|
||||
"web_search": ["TAVILY_API_KEY"],
|
||||
@@ -53,7 +60,7 @@ TOOL_CREDENTIALS: dict[str, list[str]] = {
|
||||
|
||||
def load_credentials(path: Path | None = None) -> dict[str, dict[str, str]]:
|
||||
"""Load credentials from TOML file."""
|
||||
p = Path(path) if path else _DEFAULT_PATH
|
||||
p = Path(path) if path else _default_path()
|
||||
if not p.exists():
|
||||
return {}
|
||||
with open(p, "rb") as f:
|
||||
@@ -75,7 +82,7 @@ def save_credential(
|
||||
if not stripped:
|
||||
raise ValueError("Credential value must not be empty")
|
||||
|
||||
p = Path(path) if path else _DEFAULT_PATH
|
||||
p = Path(path) if path else _default_path()
|
||||
with _LOCK:
|
||||
creds = load_credentials(path=p)
|
||||
if tool_name not in creds:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Central, env-aware resolution of OpenJarvis' home directory.
|
||||
|
||||
OpenJarvis keeps all of its runtime state (config, databases, caches, logs,
|
||||
credentials, skills, recipes, …) under a single root so it never clutters the
|
||||
user's home directory beyond one directory. That root is resolved here, with
|
||||
the following precedence (highest first):
|
||||
|
||||
1. ``$OPENJARVIS_HOME`` — explicit override (also honored by the shell
|
||||
installer, see ``scripts/install/install.sh``).
|
||||
2. ``$XDG_DATA_HOME/openjarvis`` — when ``$XDG_DATA_HOME`` is set, follow the
|
||||
XDG Base Directory spec by nesting a single ``openjarvis`` directory under
|
||||
it. We deliberately use ONE directory rather than splitting across XDG
|
||||
config/data/cache so the install tree stays self-contained and relocatable.
|
||||
3. ``~/.openjarvis`` — the historical default. With no env vars set, the
|
||||
resolved path is exactly this, so existing installs are untouched.
|
||||
|
||||
``config.py`` re-exports :func:`get_config_dir` results through the legacy
|
||||
``DEFAULT_CONFIG_DIR``/``DEFAULT_CONFIG_PATH`` names (computed dynamically) so
|
||||
the ~45 modules that import those names keep working while honoring the
|
||||
override. Modules that previously hardcoded ``Path.home() / ".openjarvis"``
|
||||
should call :func:`get_config_dir` (or :func:`get_data_dir` /
|
||||
:func:`get_cache_dir`) instead.
|
||||
|
||||
Defense in depth: the resolved root must never live inside the OpenJarvis
|
||||
source tree (a misconfigured ``$OPENJARVIS_HOME`` pointing at the repo would
|
||||
otherwise scatter runtime artifacts into the working tree). This mirrors the
|
||||
guard in ``learning/spec_search/storage/paths.py`` and fails loudly per
|
||||
REVIEW.md's no-silent-failure discipline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_DEFAULT_DIR_NAME = ".openjarvis"
|
||||
_XDG_SUBDIR_NAME = "openjarvis"
|
||||
|
||||
|
||||
class ConfigurationError(RuntimeError):
|
||||
"""Raised when the resolved home directory would violate isolation guarantees."""
|
||||
|
||||
|
||||
def _find_source_root() -> Path | None:
|
||||
"""Walk upward from this module to find the OpenJarvis source root.
|
||||
|
||||
Returns the directory containing the OpenJarvis ``pyproject.toml`` (the one
|
||||
whose ``name = "openjarvis"``), or ``None`` when running from an installed
|
||||
wheel rather than a source checkout.
|
||||
"""
|
||||
here = Path(__file__).resolve()
|
||||
for candidate in (here, *here.parents):
|
||||
py = candidate / "pyproject.toml"
|
||||
if py.exists():
|
||||
try:
|
||||
content = py.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
if 'name = "openjarvis"' in content.lower():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _reject_source_tree(path: Path) -> Path:
|
||||
"""Raise if ``path`` resolves inside the OpenJarvis source tree."""
|
||||
source_root = _find_source_root()
|
||||
if source_root is not None:
|
||||
try:
|
||||
path.relative_to(source_root)
|
||||
except ValueError:
|
||||
pass # Good — not inside the source tree.
|
||||
else:
|
||||
raise ConfigurationError(
|
||||
f"OpenJarvis home ({path}) is inside the source tree "
|
||||
f"({source_root}). OpenJarvis refuses to write runtime state "
|
||||
"inside its own repo. Set OPENJARVIS_HOME (or XDG_DATA_HOME) "
|
||||
"to a directory outside the repo (default: ~/.openjarvis)."
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def get_config_dir() -> Path:
|
||||
"""Resolve OpenJarvis' single root directory, honoring env overrides.
|
||||
|
||||
Precedence: ``$OPENJARVIS_HOME`` > ``$XDG_DATA_HOME/openjarvis`` >
|
||||
``~/.openjarvis``. The result is always absolute and is rejected if it
|
||||
falls inside the OpenJarvis source tree.
|
||||
"""
|
||||
env_home = os.environ.get("OPENJARVIS_HOME")
|
||||
if env_home:
|
||||
resolved = Path(env_home).expanduser().resolve()
|
||||
return _reject_source_tree(resolved)
|
||||
|
||||
xdg_data = os.environ.get("XDG_DATA_HOME")
|
||||
if xdg_data:
|
||||
resolved = (Path(xdg_data).expanduser() / _XDG_SUBDIR_NAME).resolve()
|
||||
return _reject_source_tree(resolved)
|
||||
|
||||
return (Path.home() / _DEFAULT_DIR_NAME).resolve()
|
||||
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""Resolve the path to ``config.toml`` under the OpenJarvis root."""
|
||||
return get_config_dir() / "config.toml"
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
"""Resolve the directory for persistent data (databases, blobs, …).
|
||||
|
||||
Consolidated under the single root; identical to :func:`get_config_dir`.
|
||||
Provided as a distinct name so call sites read intentionally.
|
||||
"""
|
||||
return get_config_dir()
|
||||
|
||||
|
||||
def get_cache_dir() -> Path:
|
||||
"""Resolve the directory for regenerable caches (eval datasets, etc.).
|
||||
|
||||
Lives at ``<root>/cache`` so caches stay inside the single OpenJarvis
|
||||
directory instead of scattering across ``~/.cache``.
|
||||
"""
|
||||
return get_config_dir() / "cache"
|
||||
@@ -68,6 +68,10 @@ class Message:
|
||||
tool_calls: Optional[List[ToolCall]] = None
|
||||
tool_call_id: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
# Base64-encoded image data for vision-capable models (e.g. gemma3,
|
||||
# qwen2.5-vl). Forwarded to Ollama's /api/chat "images" field; None or
|
||||
# empty for text-only messages (the common case).
|
||||
images: Optional[List[str]] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -34,6 +34,10 @@ def messages_to_dicts(messages: Sequence[Message]) -> List[Dict[str, Any]]:
|
||||
]
|
||||
if m.tool_call_id:
|
||||
d["tool_call_id"] = m.tool_call_id
|
||||
# Vision: forward base64 images to the engine. Ollama's /api/chat
|
||||
# accepts an "images" array on a message; text messages skip this.
|
||||
if getattr(m, "images", None):
|
||||
d["images"] = list(m.images)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
@@ -156,12 +156,26 @@ def discover_models(
|
||||
|
||||
|
||||
def get_engine(
|
||||
config: JarvisConfig, engine_key: str | None = None
|
||||
config: JarvisConfig,
|
||||
engine_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> Tuple[str, InferenceEngine] | None:
|
||||
"""Get a specific engine by key, or the default with fallback.
|
||||
|
||||
When *model* is given, an engine is selected only if it can actually
|
||||
serve that model (``engine.can_serve(model)``). This stops the cloud
|
||||
fallback from being chosen — when the local engine is down — for a model
|
||||
whose provider client is missing, which otherwise surfaces as a confusing
|
||||
"OpenAI client not available" instead of a helpful "start your local
|
||||
engine" message (see #532). When *model* is ``None`` selection stays
|
||||
model-agnostic (unchanged behaviour).
|
||||
|
||||
Returns ``(key, engine_instance)`` or ``None`` if no engine is available.
|
||||
"""
|
||||
|
||||
def _usable(engine: InferenceEngine) -> bool:
|
||||
return engine.health() and (model is None or engine.can_serve(model))
|
||||
|
||||
# Build an ordered list of keys to try, then fall back to full discovery.
|
||||
keys_to_try: list[str] = []
|
||||
if engine_key:
|
||||
@@ -176,14 +190,16 @@ def get_engine(
|
||||
continue
|
||||
try:
|
||||
engine = _make_engine(key, config)
|
||||
if engine.health():
|
||||
if _usable(engine):
|
||||
return (key, engine)
|
||||
except Exception as exc:
|
||||
logger.debug("Engine %r health check failed: %s", key, exc)
|
||||
|
||||
# Fallback to any healthy engine
|
||||
healthy = discover_engines(config)
|
||||
return healthy[0] if healthy else None
|
||||
# Fallback to the first healthy engine that can serve the model.
|
||||
for key, engine in discover_engines(config):
|
||||
if model is None or engine.can_serve(model):
|
||||
return (key, engine)
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["discover_engines", "discover_models", "get_engine"]
|
||||
|
||||
@@ -28,12 +28,31 @@ class _OpenAICompatibleEngine(InferenceEngine):
|
||||
_default_host: str = "http://localhost:8000"
|
||||
_api_prefix: str = "/v1"
|
||||
|
||||
def __init__(self, host: str | None = None, *, timeout: float = 600.0) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
host: str | None = None,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
timeout: float = 600.0,
|
||||
) -> None:
|
||||
import os
|
||||
|
||||
env_key = f"{self.engine_id.upper()}_HOST"
|
||||
self._host = (host or os.environ.get(env_key) or self._default_host).rstrip("/")
|
||||
self._client = httpx.Client(base_url=self._host, timeout=timeout)
|
||||
# Sanitize the engine id for env-var lookup ("openai-compat" ->
|
||||
# "OPENAI_COMPAT_..."); shells cannot set hyphenated variable names.
|
||||
env_prefix = self.engine_id.upper().replace("-", "_")
|
||||
self._host = (
|
||||
host or os.environ.get(f"{env_prefix}_HOST") or self._default_host
|
||||
).rstrip("/")
|
||||
# Bearer auth for endpoints started with e.g. ``vllm serve --api-key``.
|
||||
# Setting it on the client covers generate/stream/stream_full/
|
||||
# list_models/health alike; ``None`` keeps requests header-free.
|
||||
self._api_key = api_key or os.environ.get(f"{env_prefix}_API_KEY") or None
|
||||
headers = (
|
||||
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
|
||||
)
|
||||
self._client = httpx.Client(
|
||||
base_url=self._host, timeout=timeout, headers=headers
|
||||
)
|
||||
|
||||
# -- InferenceEngine interface ------------------------------------------
|
||||
|
||||
|
||||
@@ -119,6 +119,17 @@ class InferenceEngine(ABC):
|
||||
def health(self) -> bool:
|
||||
"""Return ``True`` when the engine is reachable and healthy."""
|
||||
|
||||
def can_serve(self, model: str) -> bool:
|
||||
"""Return ``True`` if this engine can serve *model*.
|
||||
|
||||
Defaults to ``True``: local engines accept any model id (whether a
|
||||
specific model is *installed* is a separate concern from engine
|
||||
selection). Engines that multiplex provider-specific clients (e.g.
|
||||
the cloud engine) override this so selection can skip an engine whose
|
||||
client for the model's provider isn't configured (see #532).
|
||||
"""
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release resources (HTTP clients, connections, threads, etc.)."""
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Cloud inference engine — OpenAI, Anthropic, Google, and MiniMax API backends."""
|
||||
"""Cloud inference engine.
|
||||
|
||||
OpenAI, Anthropic, Google, MiniMax, and DeepSeek API backends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -48,6 +51,8 @@ PRICING: Dict[str, tuple[float, float]] = {
|
||||
"MiniMax-M2.7-highspeed": (0.60, 2.40),
|
||||
"MiniMax-M2.5": (0.30, 1.20),
|
||||
"MiniMax-M2.5-highspeed": (0.60, 2.40),
|
||||
"deepseek-v4-flash": (0.27, 1.10),
|
||||
"deepseek-v4-pro": (0.55, 2.19),
|
||||
}
|
||||
|
||||
# Well-known model IDs per provider
|
||||
@@ -83,6 +88,10 @@ _MINIMAX_MODELS = [
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
]
|
||||
_DEEPSEEK_MODELS = [
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
]
|
||||
|
||||
# OpenRouter models — prefixed with "openrouter/" so they can be identified
|
||||
_OPENROUTER_POPULAR = [
|
||||
@@ -111,6 +120,10 @@ def _is_minimax_model(model: str) -> bool:
|
||||
return model.lower().startswith("minimax")
|
||||
|
||||
|
||||
def _is_deepseek_model(model: str) -> bool:
|
||||
return model.lower().startswith("deepseek")
|
||||
|
||||
|
||||
def _is_openrouter_model(model: str) -> bool:
|
||||
return model.startswith("openrouter/")
|
||||
|
||||
@@ -127,6 +140,35 @@ def _is_google_model(model: str) -> bool:
|
||||
return "gemini" in model.lower() and not _is_openrouter_model(model)
|
||||
|
||||
|
||||
# Positive prefix predicate for genuine OpenAI models. Kept in sync with
|
||||
# ``server/cloud_router.py:_OPENAI_PREFIXES`` so local-vs-cloud classification
|
||||
# agrees across the codebase. Used by ``_client_for_model``/``can_serve`` so the
|
||||
# cloud engine never claims it can serve an unrecognized (e.g. local Ollama)
|
||||
# model name just because an OpenAI key happens to be present (see #335).
|
||||
_OPENAI_PREFIXES = ("gpt-", "chatgpt-", "o1", "o3", "o4")
|
||||
|
||||
|
||||
def _is_openai_model(model: str) -> bool:
|
||||
"""True only for genuine OpenAI models (gpt-*, chatgpt-*, o1/o3/o4 series).
|
||||
|
||||
Defined positively so that an unrecognized model name (a local Ollama model
|
||||
like ``qwen3.5:0.8b``, or a typo) is NOT treated as an OpenAI model. This is
|
||||
the routing surface ``can_serve`` relies on; ``generate``/``stream`` keep
|
||||
their OpenAI fall-through so an explicitly-requested unknown cloud model
|
||||
still errors loudly at call time.
|
||||
|
||||
Caveat: a user may repoint the OpenAI client at an OpenAI-compatible server
|
||||
(vLLM/LM Studio) via ``OPENAI_BASE_URL`` and legitimately serve non-gpt
|
||||
names. That path is undocumented/untested in this engine; if it is added,
|
||||
this predicate (or ``_client_for_model``) should treat a configured custom
|
||||
base_url as "serves anything".
|
||||
"""
|
||||
m = model.lower()
|
||||
if m in (name.lower() for name in _OPENAI_MODELS):
|
||||
return True
|
||||
return m.startswith(_OPENAI_PREFIXES)
|
||||
|
||||
|
||||
def _is_openai_reasoning_model(model: str) -> bool:
|
||||
"""Check if model is an OpenAI reasoning model that restricts temperature."""
|
||||
m = model.lower()
|
||||
@@ -269,7 +311,7 @@ def _convert_tools_to_google(
|
||||
|
||||
@EngineRegistry.register("cloud")
|
||||
class CloudEngine(InferenceEngine):
|
||||
"""Cloud inference via OpenAI, Anthropic, Google, and MiniMax SDKs."""
|
||||
"""Cloud inference via OpenAI, Anthropic, Google, MiniMax, and DeepSeek SDKs."""
|
||||
|
||||
engine_id = "cloud"
|
||||
is_cloud = True
|
||||
@@ -280,6 +322,7 @@ class CloudEngine(InferenceEngine):
|
||||
self._google_client: Any = None
|
||||
self._openrouter_client: Any = None
|
||||
self._minimax_client: Any = None
|
||||
self._deepseek_client: Any = None
|
||||
self._codex_client: Any = None
|
||||
# Gemini thought_signatures: tool_call_id -> signature bytes
|
||||
self._thought_sigs: Dict[str, bytes] = {}
|
||||
@@ -332,6 +375,17 @@ class CloudEngine(InferenceEngine):
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
deepseek_key = os.environ.get("DEEPSEEK_API_KEY")
|
||||
if deepseek_key:
|
||||
try:
|
||||
import openai
|
||||
|
||||
self._deepseek_client = openai.OpenAI(
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
api_key=deepseek_key,
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
# Codex — uses the OpenAI Responses API.
|
||||
# Supports both standard API keys (api.openai.com) and ChatGPT
|
||||
# OAuth tokens (chatgpt.com) via OPENAI_CODEX_BASE_URL override.
|
||||
@@ -985,6 +1039,56 @@ class CloudEngine(InferenceEngine):
|
||||
]
|
||||
return result
|
||||
|
||||
def _generate_deepseek(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
if self._deepseek_client is None:
|
||||
raise EngineConnectionError(
|
||||
"DeepSeek client not available — set DEEPSEEK_API_KEY"
|
||||
)
|
||||
kwargs.pop("response_format", None)
|
||||
create_kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages_to_dicts(messages),
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
t0 = time.monotonic()
|
||||
resp = self._deepseek_client.chat.completions.create(**create_kwargs)
|
||||
elapsed = time.monotonic() - t0
|
||||
choice = resp.choices[0]
|
||||
usage = resp.usage
|
||||
prompt_tokens = usage.prompt_tokens if usage else 0
|
||||
completion_tokens = usage.completion_tokens if usage else 0
|
||||
result: Dict[str, Any] = {
|
||||
"content": choice.message.content or "",
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": (usage.total_tokens if usage else 0),
|
||||
},
|
||||
"model": resp.model,
|
||||
"finish_reason": choice.finish_reason or "stop",
|
||||
"cost_usd": estimate_cost(model, prompt_tokens, completion_tokens),
|
||||
"ttft": elapsed,
|
||||
}
|
||||
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
|
||||
result["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
}
|
||||
for tc in choice.message.tool_calls
|
||||
]
|
||||
return result
|
||||
|
||||
def generate(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
@@ -1006,6 +1110,8 @@ class CloudEngine(InferenceEngine):
|
||||
return self._generate_openrouter(messages, **kw)
|
||||
if _is_minimax_model(model):
|
||||
return self._generate_minimax(messages, **kw)
|
||||
if _is_deepseek_model(model):
|
||||
return self._generate_deepseek(messages, **kw)
|
||||
if _is_anthropic_model(model):
|
||||
return self._generate_anthropic(messages, **kw)
|
||||
if _is_google_model(model):
|
||||
@@ -1036,6 +1142,9 @@ class CloudEngine(InferenceEngine):
|
||||
elif _is_minimax_model(model):
|
||||
async for token in self._stream_minimax(messages, **kw):
|
||||
yield token
|
||||
elif _is_deepseek_model(model):
|
||||
async for token in self._stream_deepseek(messages, **kw):
|
||||
yield token
|
||||
elif _is_anthropic_model(model):
|
||||
async for token in self._stream_anthropic(messages, **kw):
|
||||
yield token
|
||||
@@ -1254,6 +1363,30 @@ class CloudEngine(InferenceEngine):
|
||||
if delta and delta.content:
|
||||
yield delta.content
|
||||
|
||||
async def _stream_deepseek(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[str]:
|
||||
if self._deepseek_client is None:
|
||||
raise EngineConnectionError("DeepSeek client not available")
|
||||
create_kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages_to_dicts(messages),
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
}
|
||||
resp = self._deepseek_client.chat.completions.create(**create_kwargs)
|
||||
for chunk in resp:
|
||||
delta = chunk.choices[0].delta if chunk.choices else None
|
||||
if delta and delta.content:
|
||||
yield delta.content
|
||||
|
||||
# -- stream_full: rich streaming with tool_calls support ----------------
|
||||
|
||||
async def _stream_full_openai(
|
||||
@@ -1307,6 +1440,18 @@ class CloudEngine(InferenceEngine):
|
||||
"stream": True,
|
||||
**kwargs,
|
||||
}
|
||||
elif _is_deepseek_model(model):
|
||||
client = self._deepseek_client
|
||||
if client is None:
|
||||
raise EngineConnectionError("DeepSeek client not available")
|
||||
create_kwargs = {
|
||||
"model": model,
|
||||
"messages": messages_to_dicts(messages),
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
**kwargs,
|
||||
}
|
||||
else:
|
||||
client = self._openai_client
|
||||
if client is None:
|
||||
@@ -1473,10 +1618,54 @@ class CloudEngine(InferenceEngine):
|
||||
models.extend(_OPENROUTER_POPULAR)
|
||||
if self._minimax_client is not None:
|
||||
models.extend(_MINIMAX_MODELS)
|
||||
if self._deepseek_client is not None:
|
||||
models.extend(_DEEPSEEK_MODELS)
|
||||
if self._codex_client is not None:
|
||||
models.extend(_CODEX_MODELS)
|
||||
return models
|
||||
|
||||
def _client_for_model(self, model: str) -> Any:
|
||||
"""Return the provider client ``generate``/``stream`` will dispatch to
|
||||
for *model*, or ``None`` for a model this engine cannot route.
|
||||
|
||||
Mirrors the routing in ``generate``/``stream``, but is intentionally
|
||||
*stricter* on the OpenAI fall-through: only genuine OpenAI models map to
|
||||
the OpenAI client. Unrecognized names (e.g. a local Ollama model like
|
||||
``qwen3.5:0.8b``) return ``None`` so ``can_serve`` declines them and the
|
||||
cloud engine is not mis-selected as a fallback when the local engine is
|
||||
transiently down and any (even dummy) ``OPENAI_API_KEY`` is set (#335).
|
||||
``generate``/``stream`` keep their OpenAI fall-through, so an
|
||||
explicitly-requested unknown cloud model still fails loudly at call time.
|
||||
"""
|
||||
if _is_codex_model(model):
|
||||
return self._codex_client
|
||||
if _is_openrouter_model(model):
|
||||
return self._openrouter_client
|
||||
if _is_minimax_model(model):
|
||||
return self._minimax_client
|
||||
if _is_deepseek_model(model):
|
||||
return self._deepseek_client
|
||||
if _is_anthropic_model(model):
|
||||
return self._anthropic_client
|
||||
if _is_google_model(model):
|
||||
return self._google_client
|
||||
if _is_openai_model(model):
|
||||
return self._openai_client
|
||||
return None
|
||||
|
||||
def can_serve(self, model: str) -> bool:
|
||||
"""Return ``True`` only if the provider client for *model* exists.
|
||||
|
||||
``health()`` is ``True`` whenever *any* provider client is configured,
|
||||
but a request for, say, a ``gpt-*`` model still needs the OpenAI
|
||||
client specifically. Without this check the cloud engine gets picked
|
||||
as a fallback (when the local engine is down) for a model it can't
|
||||
serve, then dies at call time with "<provider> client not available"
|
||||
instead of the user getting a helpful "start your local engine"
|
||||
message (see #532).
|
||||
"""
|
||||
return self._client_for_model(model) is not None
|
||||
|
||||
def health(self) -> bool:
|
||||
return (
|
||||
self._openai_client is not None
|
||||
@@ -1484,6 +1673,7 @@ class CloudEngine(InferenceEngine):
|
||||
or self._google_client is not None
|
||||
or self._openrouter_client is not None
|
||||
or self._minimax_client is not None
|
||||
or self._deepseek_client is not None
|
||||
or self._codex_client is not None
|
||||
)
|
||||
|
||||
|
||||
@@ -22,6 +22,62 @@ from openjarvis.engine._stubs import StreamChunk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Qwen3 treats ``/think`` and ``/no_think`` as soft-switch control tokens that
|
||||
# toggle reasoning mode. Small models (e.g. qwen3:14b) fed a multi-line prompt
|
||||
# sometimes emit one of these as the sole tool argument, e.g.
|
||||
# ``{"command": "/no_think"}`` instead of the real command. Ollama parses that
|
||||
# into a fully-formed tool_call via the model's chat template, so we have to
|
||||
# drop it on our side before the agent executes garbage.
|
||||
_QWEN_CONTROL_TOKENS = frozenset({"/think", "/no_think"})
|
||||
|
||||
|
||||
def _is_control_token_only_args(raw_args: Any) -> bool:
|
||||
"""Return True if tool-call arguments contain nothing but a Qwen3 token.
|
||||
|
||||
``raw_args`` may be a dict (Ollama's native shape) or a JSON / bare string.
|
||||
A call is considered degenerate only when it carries at least one control
|
||||
token and no other usable content, so legitimate calls such as
|
||||
``{"command": "date"}`` or ``{"command": "echo /no_think"}`` are kept.
|
||||
"""
|
||||
parsed: Any = raw_args
|
||||
if isinstance(raw_args, str):
|
||||
try:
|
||||
parsed = json.loads(raw_args)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = raw_args
|
||||
|
||||
if isinstance(parsed, str):
|
||||
return parsed.strip().lower() in _QWEN_CONTROL_TOKENS
|
||||
|
||||
if not isinstance(parsed, dict) or not parsed:
|
||||
return False
|
||||
|
||||
saw_token = False
|
||||
for value in parsed.values():
|
||||
if not isinstance(value, str):
|
||||
return False # a non-string value is real content
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.lower() in _QWEN_CONTROL_TOKENS:
|
||||
saw_token = True
|
||||
else:
|
||||
return False # real string content
|
||||
return saw_token
|
||||
|
||||
|
||||
def _default_num_ctx() -> int:
|
||||
"""Default context window (tokens). Override with ``JARVIS_NUM_CTX``.
|
||||
|
||||
Raised above Ollama's 4k default so an image (which costs many tokens)
|
||||
plus a real conversation fit. 16k is comfortable for small models on a
|
||||
typical consumer GPU.
|
||||
"""
|
||||
try:
|
||||
return int(os.environ.get("JARVIS_NUM_CTX", "16384"))
|
||||
except ValueError:
|
||||
return 16384
|
||||
|
||||
|
||||
@EngineRegistry.register("ollama")
|
||||
class OllamaEngine(InferenceEngine):
|
||||
@@ -73,7 +129,7 @@ class OllamaEngine(InferenceEngine):
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
"num_ctx": kwargs.get("num_ctx", 8192),
|
||||
"num_ctx": kwargs.get("num_ctx", _default_num_ctx()),
|
||||
},
|
||||
}
|
||||
# Disable extended thinking by default (Qwen3.5 etc.).
|
||||
@@ -155,14 +211,19 @@ class OllamaEngine(InferenceEngine):
|
||||
if raw_tool_calls:
|
||||
tool_calls = []
|
||||
for i, tc in enumerate(raw_tool_calls):
|
||||
raw_args = tc.get("function", {}).get(
|
||||
"arguments",
|
||||
"{}",
|
||||
)
|
||||
fn = tc.get("function", {})
|
||||
raw_args = fn.get("arguments", "{}")
|
||||
if _is_control_token_only_args(raw_args):
|
||||
logger.warning(
|
||||
"Dropping Qwen3 control-token tool call %s(%r)",
|
||||
fn.get("name", ""),
|
||||
raw_args,
|
||||
)
|
||||
continue
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": tc.get("id", f"call_{i}"),
|
||||
"name": tc.get("function", {}).get("name", ""),
|
||||
"name": fn.get("name", ""),
|
||||
"arguments": (
|
||||
json.dumps(raw_args)
|
||||
if isinstance(raw_args, dict)
|
||||
@@ -170,7 +231,8 @@ class OllamaEngine(InferenceEngine):
|
||||
),
|
||||
}
|
||||
)
|
||||
result["tool_calls"] = tool_calls
|
||||
if tool_calls:
|
||||
result["tool_calls"] = tool_calls
|
||||
return result
|
||||
|
||||
async def stream(
|
||||
@@ -189,7 +251,7 @@ class OllamaEngine(InferenceEngine):
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
"num_ctx": kwargs.get("num_ctx", 8192),
|
||||
"num_ctx": kwargs.get("num_ctx", _default_num_ctx()),
|
||||
},
|
||||
}
|
||||
# Mirror generate()'s default: disable extended thinking unless the
|
||||
@@ -268,7 +330,7 @@ class OllamaEngine(InferenceEngine):
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
"num_ctx": kwargs.get("num_ctx", 8192),
|
||||
"num_ctx": kwargs.get("num_ctx", _default_num_ctx()),
|
||||
},
|
||||
}
|
||||
if "think" not in kwargs:
|
||||
@@ -327,14 +389,22 @@ class OllamaEngine(InferenceEngine):
|
||||
# OpenAI-delta fragment shape that agent_manager_routes
|
||||
# expects in _merge_tool_call_fragments.
|
||||
fragments: List[Dict[str, Any]] = []
|
||||
for i, tc in enumerate(raw_tool_calls):
|
||||
for tc in raw_tool_calls:
|
||||
fn = tc.get("function", {}) or {}
|
||||
raw_args = fn.get("arguments", "{}")
|
||||
if _is_control_token_only_args(raw_args):
|
||||
logger.warning(
|
||||
"Dropping Qwen3 control-token tool call %s(%r)",
|
||||
fn.get("name", ""),
|
||||
raw_args,
|
||||
)
|
||||
continue
|
||||
args_str = (
|
||||
json.dumps(raw_args)
|
||||
if isinstance(raw_args, dict)
|
||||
else str(raw_args)
|
||||
)
|
||||
i = len(fragments)
|
||||
fragments.append(
|
||||
{
|
||||
"index": i,
|
||||
@@ -346,8 +416,9 @@ class OllamaEngine(InferenceEngine):
|
||||
},
|
||||
}
|
||||
)
|
||||
yield StreamChunk(tool_calls=fragments)
|
||||
finish_reason = "tool_calls"
|
||||
if fragments:
|
||||
yield StreamChunk(tool_calls=fragments)
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
if chunk.get("done", False):
|
||||
reported_prompt = chunk.get("prompt_eval_count", 0)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Data-driven registration of OpenAI-compatible inference engines."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openjarvis.core.registry import EngineRegistry
|
||||
from openjarvis.engine._openai_compat import _OpenAICompatibleEngine
|
||||
|
||||
@@ -25,4 +27,35 @@ for _key, (_cls_name, _default_host, _api_prefix) in _ENGINES.items():
|
||||
EngineRegistry.register(_key)(_cls)
|
||||
globals()[_cls_name] = _cls
|
||||
|
||||
__all__ = [name for name, _, _ in _ENGINES.values()]
|
||||
|
||||
def normalize_openai_base_url(url: str) -> str:
|
||||
"""Strip a single trailing ``/v1`` segment from a user-supplied base URL.
|
||||
|
||||
Users habitually pass ``http://host:8000/v1`` (the full OpenAI-compatible
|
||||
prefix); the engine's ``_api_prefix`` re-appends ``/v1`` to every request
|
||||
path, so a trailing copy would double up as ``/v1/v1``. Only a literal
|
||||
trailing ``/v1`` is stripped — proxy/gateway path prefixes are preserved.
|
||||
"""
|
||||
base = url.rstrip("/")
|
||||
if base.endswith("/v1"):
|
||||
base = base[: -len("/v1")]
|
||||
return base
|
||||
|
||||
|
||||
class OpenAICompatEngine(_OpenAICompatibleEngine):
|
||||
"""Generic engine for an explicitly-provided OpenAI-compatible endpoint.
|
||||
|
||||
Deliberately NOT registered in ``EngineRegistry``: it is only ever
|
||||
constructed with an explicit host (e.g. ``jarvis eval --base-url``), so
|
||||
registering it would just add a useless localhost discovery probe and
|
||||
interact with the per-test registry wipe.
|
||||
"""
|
||||
|
||||
engine_id = "openai-compat"
|
||||
_api_prefix = "/v1"
|
||||
|
||||
|
||||
__all__ = [name for name, _, _ in _ENGINES.values()] + [
|
||||
"OpenAICompatEngine",
|
||||
"normalize_openai_base_url",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Shared helper for targeting an explicit OpenAI-compatible endpoint.
|
||||
|
||||
Used by the first-party eval backends (jarvis-direct, jarvis-agent) when
|
||||
``--base-url`` is given: the eval must use exactly that endpoint, with no
|
||||
silent fallback to whatever other engine discovery happens to find.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_endpoint_engine(
|
||||
base_url: str,
|
||||
api_key: Optional[str] = None,
|
||||
engine_key: Optional[str] = None,
|
||||
):
|
||||
"""Construct an :class:`OpenAICompatEngine` pinned to ``base_url``.
|
||||
|
||||
Pre-flight health-checks the endpoint and raises a loud, actionable
|
||||
error when it is unreachable — engine discovery is never consulted.
|
||||
"""
|
||||
from openjarvis.engine.openai_compat_engines import (
|
||||
OpenAICompatEngine,
|
||||
normalize_openai_base_url,
|
||||
)
|
||||
|
||||
if engine_key:
|
||||
logger.warning(
|
||||
"Both an engine key (%r) and base_url (%r) were given; "
|
||||
"base_url wins — targeting the endpoint directly.",
|
||||
engine_key,
|
||||
base_url,
|
||||
)
|
||||
host = normalize_openai_base_url(base_url)
|
||||
engine = OpenAICompatEngine(host=host, api_key=api_key)
|
||||
if not engine.health():
|
||||
engine.close()
|
||||
raise RuntimeError(
|
||||
f"--base-url endpoint not reachable: {base_url} "
|
||||
f"(GET {host}/v1/models failed). Is an OpenAI-compatible server "
|
||||
"(e.g. `vllm serve`) running at that address? If it requires "
|
||||
"authentication (HTTP 401), pass --api-key or set "
|
||||
"JARVIS_BACKEND_API_KEY."
|
||||
)
|
||||
return engine
|
||||
|
||||
|
||||
__all__ = ["build_endpoint_engine"]
|
||||
@@ -31,6 +31,8 @@ class JarvisAgentBackend(InferenceBackend):
|
||||
max_turns: Optional[int] = None,
|
||||
skills_enabled: bool = True,
|
||||
overlay_dir: Optional[Path] = None,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> None:
|
||||
from openjarvis.system import SystemBuilder
|
||||
|
||||
@@ -40,7 +42,17 @@ class JarvisAgentBackend(InferenceBackend):
|
||||
self._gpu_metrics = gpu_metrics
|
||||
|
||||
builder = SystemBuilder()
|
||||
if engine_key:
|
||||
if base_url:
|
||||
# Explicit endpoint targeting (--base-url): pin the eval to
|
||||
# exactly this OpenAI-compatible endpoint. Fails fast if it is
|
||||
# unreachable; never falls back to a discovered engine.
|
||||
from openjarvis.evals.backends._endpoint_util import (
|
||||
build_endpoint_engine,
|
||||
)
|
||||
|
||||
engine = build_endpoint_engine(base_url, api_key, engine_key)
|
||||
builder.engine_instance(engine, key=engine_key or "openai-compat")
|
||||
elif engine_key:
|
||||
builder.engine(engine_key)
|
||||
if model:
|
||||
builder.model(model)
|
||||
|
||||
@@ -24,6 +24,8 @@ class JarvisDirectBackend(InferenceBackend):
|
||||
engine_key: Optional[str] = None,
|
||||
telemetry: bool = False,
|
||||
gpu_metrics: bool = False,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> None:
|
||||
from openjarvis.system import SystemBuilder
|
||||
|
||||
@@ -31,7 +33,17 @@ class JarvisDirectBackend(InferenceBackend):
|
||||
self._gpu_metrics = gpu_metrics
|
||||
|
||||
builder = SystemBuilder()
|
||||
if engine_key:
|
||||
if base_url:
|
||||
# Explicit endpoint targeting (--base-url): pin the eval to
|
||||
# exactly this OpenAI-compatible endpoint. Fails fast if it is
|
||||
# unreachable; never falls back to a discovered engine.
|
||||
from openjarvis.evals.backends._endpoint_util import (
|
||||
build_endpoint_engine,
|
||||
)
|
||||
|
||||
engine = build_endpoint_engine(base_url, api_key, engine_key)
|
||||
builder.engine_instance(engine, key=engine_key or "openai-compat")
|
||||
elif engine_key:
|
||||
builder.engine(engine_key)
|
||||
# Propagate gpu_metrics to the runtime config so SystemBuilder
|
||||
# creates an EnergyMonitor / GpuMonitor for the InstrumentedEngine.
|
||||
|
||||
@@ -5,11 +5,13 @@ Uses Harness for Docker-based execution and scoring.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from openjarvis.evals.core.backend import InferenceBackend
|
||||
from openjarvis.evals.core.types import RunSummary
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,6 +22,97 @@ try:
|
||||
except ImportError:
|
||||
_HAS_TB = False
|
||||
|
||||
# terminal-bench FailureMode values that are definitionally infrastructure
|
||||
# failures (the harness broke before/while driving the agent), never a
|
||||
# judgment on the model's answer. NOTE: clean trials leave failure_mode
|
||||
# "unset" in terminal-bench 0.2.18 — both on success AND on genuine
|
||||
# unresolved misses — so failure_mode alone can NOT be used to detect
|
||||
# harness errors (it would misflag every real model miss).
|
||||
_INFRA_FAILURE_MODES = frozenset({"agent_installation_failed", "unknown_agent_error"})
|
||||
|
||||
# Harness kwargs that older terminal-bench versions may not support.
|
||||
_TIMEOUT_KWARGS = ("global_agent_timeout_sec", "global_timeout_multiplier")
|
||||
|
||||
|
||||
def summarize_benchmark_results(
|
||||
results: Any,
|
||||
*,
|
||||
model: str,
|
||||
benchmark: str = "terminalbench-native",
|
||||
) -> Tuple[RunSummary, List[Dict[str, str]]]:
|
||||
"""Convert terminal-bench ``BenchmarkResults`` into a ``RunSummary``.
|
||||
|
||||
Trials are classified into three buckets:
|
||||
|
||||
- resolved: ``is_resolved`` is True -> counted correct.
|
||||
- model miss: unresolved, but the model was actually contacted ->
|
||||
counted in the accuracy denominator.
|
||||
- harness/infra failure: excluded from the accuracy denominator and
|
||||
reported in ``RunSummary.errors`` plus the returned failure list.
|
||||
|
||||
Zero-model-contact signal choice: terminal-bench 0.2.18 leaves
|
||||
``failure_mode`` UNSET both on clean success and on genuine unresolved
|
||||
misses, so failure_mode cannot distinguish "the model tried and failed"
|
||||
from "the agent never called the model". Token usage can: this backend
|
||||
always runs terminus-2, which reports real LiteLLM usage, so an
|
||||
unresolved trial with zero/missing input+output tokens means no model
|
||||
request ever completed — an infrastructure failure (in-container setup
|
||||
hang/death, tmux failure), not a model miss. CAVEAT: terminal-bench
|
||||
"installed agents" (openhands, claude-code, ...) hardcode 0 tokens even
|
||||
on success; if this backend ever honors ``agent_name`` for installed
|
||||
agents, this heuristic must be gated on the agent type.
|
||||
"""
|
||||
trials = list(getattr(results, "results", None) or [])
|
||||
|
||||
harness_failures: List[Dict[str, str]] = []
|
||||
scored = 0
|
||||
correct = 0
|
||||
|
||||
for tr in trials:
|
||||
task_id = getattr(tr, "task_id", None) or getattr(tr, "trial_name", "unknown")
|
||||
is_resolved = getattr(tr, "is_resolved", None) is True
|
||||
fm = getattr(tr, "failure_mode", None)
|
||||
fm_value = str(getattr(fm, "value", fm) or "unset").lower()
|
||||
tokens = (getattr(tr, "total_input_tokens", None) or 0) + (
|
||||
getattr(tr, "total_output_tokens", None) or 0
|
||||
)
|
||||
|
||||
zero_model_contact = not is_resolved and tokens == 0
|
||||
infra_failure_mode = fm_value in _INFRA_FAILURE_MODES
|
||||
|
||||
if zero_model_contact or infra_failure_mode:
|
||||
harness_failures.append(
|
||||
{
|
||||
"task_id": str(task_id),
|
||||
"failure_mode": fm_value,
|
||||
"reason": (
|
||||
"zero_model_requests" if zero_model_contact else fm_value
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
scored += 1
|
||||
if is_resolved:
|
||||
correct += 1
|
||||
|
||||
return (
|
||||
RunSummary(
|
||||
benchmark=benchmark,
|
||||
category="agentic",
|
||||
backend="terminalbench-native",
|
||||
model=model,
|
||||
total_samples=len(trials),
|
||||
scored_samples=scored,
|
||||
correct=correct,
|
||||
accuracy=correct / scored if scored else 0.0,
|
||||
errors=len(harness_failures),
|
||||
mean_latency_seconds=0.0,
|
||||
total_cost_usd=0.0,
|
||||
),
|
||||
harness_failures,
|
||||
)
|
||||
|
||||
|
||||
class TerminalBenchNativeBackend(InferenceBackend):
|
||||
"""Runs terminal-bench tasks natively via Harness with Docker execution.
|
||||
@@ -44,7 +137,22 @@ class TerminalBenchNativeBackend(InferenceBackend):
|
||||
system_prompt: str = "",
|
||||
max_tokens: int = 16384,
|
||||
n_concurrent: int = 4,
|
||||
global_agent_timeout_sec: Optional[float] = 1800.0,
|
||||
global_timeout_multiplier: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Args of note:
|
||||
|
||||
global_agent_timeout_sec: Hard wall-clock bound for each trial's
|
||||
agent phase. terminal-bench runs installed-agent SETUP inside
|
||||
this same budget with an infinite tmux timeout, so this bounds
|
||||
SETUP+RUN together (a setup-only timeout needs an upstream
|
||||
terminal-bench change). When set, it REPLACES each task's own
|
||||
``max_agent_timeout_sec``. Set ``None`` or ``0`` to fall back
|
||||
to per-task budgets.
|
||||
global_timeout_multiplier: Scales per-task budgets when
|
||||
``global_agent_timeout_sec`` is not set. ``None`` keeps
|
||||
terminal-bench's default (1.0).
|
||||
"""
|
||||
if not _HAS_TB:
|
||||
raise ImportError("terminal-bench is required: pip install terminal-bench")
|
||||
|
||||
@@ -59,6 +167,8 @@ class TerminalBenchNativeBackend(InferenceBackend):
|
||||
self._system_prompt = system_prompt
|
||||
self._max_tokens = max_tokens
|
||||
self._n_concurrent = n_concurrent
|
||||
self._global_agent_timeout_sec = global_agent_timeout_sec
|
||||
self._global_timeout_multiplier = global_timeout_multiplier
|
||||
self._results: Optional[BenchmarkResults] = None
|
||||
|
||||
def run_harness(self, run_id: str) -> BenchmarkResults:
|
||||
@@ -91,10 +201,53 @@ class TerminalBenchNativeBackend(InferenceBackend):
|
||||
if self._max_samples is not None:
|
||||
harness_kwargs["n_tasks"] = self._max_samples
|
||||
|
||||
# Bound each trial's agent phase. Without this, an in-container
|
||||
# installed-agent SETUP hang runs with an infinite tmux timeout,
|
||||
# bounded only by whatever budget the task happens to declare.
|
||||
if self._global_agent_timeout_sec:
|
||||
harness_kwargs["global_agent_timeout_sec"] = float(
|
||||
self._global_agent_timeout_sec
|
||||
)
|
||||
if self._global_timeout_multiplier is not None:
|
||||
harness_kwargs["global_timeout_multiplier"] = float(
|
||||
self._global_timeout_multiplier
|
||||
)
|
||||
|
||||
self._check_timeout_kwargs_supported(harness_kwargs)
|
||||
|
||||
harness = Harness(**harness_kwargs)
|
||||
self._results = harness.run()
|
||||
return self._results
|
||||
|
||||
@staticmethod
|
||||
def _check_timeout_kwargs_supported(harness_kwargs: Dict[str, Any]) -> None:
|
||||
"""Fail loudly if this terminal-bench build lacks the timeout kwargs.
|
||||
|
||||
terminal-bench is an undeclared, unpinned dependency, so installs may
|
||||
predate the global timeout kwargs (added by 0.2.x). Passing an
|
||||
unknown kwarg raises an opaque TypeError; dropping it silently would
|
||||
re-create the unbounded-setup hang. Detect and explain instead.
|
||||
"""
|
||||
try:
|
||||
params = inspect.signature(Harness.__init__).parameters
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
||||
return
|
||||
unsupported = [
|
||||
key
|
||||
for key in _TIMEOUT_KWARGS
|
||||
if key in harness_kwargs and key not in params
|
||||
]
|
||||
if unsupported:
|
||||
raise RuntimeError(
|
||||
"The installed terminal-bench does not support "
|
||||
f"{', '.join(unsupported)} (requires terminal-bench >= "
|
||||
"0.2.18). Upgrade terminal-bench, or disable the bound by "
|
||||
"setting global_agent_timeout_sec = 0 in the eval config "
|
||||
"[run] section."
|
||||
)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -121,4 +274,4 @@ class TerminalBenchNativeBackend(InferenceBackend):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["TerminalBenchNativeBackend"]
|
||||
__all__ = ["TerminalBenchNativeBackend", "summarize_benchmark_results"]
|
||||
|
||||
+155
-55
@@ -183,14 +183,28 @@ def _build_backend(
|
||||
max_turns: Optional[int] = None,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
first_party_endpoint: bool = True,
|
||||
):
|
||||
"""Construct the appropriate backend.
|
||||
|
||||
For "hermes" and "openclaw" backends, ``base_url`` and ``api_key`` are
|
||||
REQUIRED — these foreign frameworks need an OpenAI-compatible endpoint
|
||||
to send model calls to. Pass them via the eval config's
|
||||
``[backend.external]`` section or env vars.
|
||||
``base_url``/``api_key`` point at the OpenAI-compatible endpoint serving
|
||||
the model under eval:
|
||||
|
||||
- For "hermes" and "openclaw" they are REQUIRED — these foreign
|
||||
frameworks always call out to an external endpoint.
|
||||
- "jarvis-direct" and "jarvis-agent" honor them when
|
||||
``first_party_endpoint`` is True (the CLI ``--base-url`` path): the
|
||||
eval targets exactly that endpoint — no engine-discovery fallback —
|
||||
and fails fast if it is unreachable. Suite mode passes
|
||||
``first_party_endpoint=False`` so the suite TOML's
|
||||
``[backend.external]`` section stays scoped to hermes/openclaw
|
||||
(extending it to first-party backends is explicitly deferred).
|
||||
"""
|
||||
if not first_party_endpoint:
|
||||
fp_base_url = fp_api_key = None
|
||||
else:
|
||||
fp_base_url, fp_api_key = base_url, api_key
|
||||
|
||||
if backend_name == "jarvis-agent":
|
||||
from openjarvis.evals.backends.jarvis_agent import JarvisAgentBackend
|
||||
|
||||
@@ -202,6 +216,8 @@ def _build_backend(
|
||||
gpu_metrics=gpu_metrics,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
base_url=fp_base_url,
|
||||
api_key=fp_api_key,
|
||||
)
|
||||
elif backend_name == "jarvis-direct":
|
||||
from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend
|
||||
@@ -210,6 +226,8 @@ def _build_backend(
|
||||
engine_key=engine_key,
|
||||
telemetry=telemetry,
|
||||
gpu_metrics=gpu_metrics,
|
||||
base_url=fp_base_url,
|
||||
api_key=fp_api_key,
|
||||
)
|
||||
elif backend_name == "hermes":
|
||||
from openjarvis.evals.backends.external import HermesBackend
|
||||
@@ -656,25 +674,53 @@ def _build_trackers(config) -> list:
|
||||
return trackers
|
||||
|
||||
|
||||
def _run_terminalbench_native(config, console: Console) -> object:
|
||||
"""Run TerminalBench V2.1 natively via terminal-bench Harness."""
|
||||
def _run_terminalbench_native(
|
||||
config,
|
||||
console: Console,
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> object:
|
||||
"""Run TerminalBench V2.1 natively via terminal-bench Harness.
|
||||
|
||||
``base_url`` (from ``--base-url`` / JARVIS_BACKEND_BASE_URL) targets an
|
||||
already-running OpenAI-compatible endpoint; when unset, the legacy local
|
||||
vLLM default (http://localhost:8000/v1) is used.
|
||||
"""
|
||||
from openjarvis.engine.openai_compat_engines import normalize_openai_base_url
|
||||
from openjarvis.evals.backends.terminalbench_native import (
|
||||
TerminalBenchNativeBackend,
|
||||
summarize_benchmark_results,
|
||||
)
|
||||
from openjarvis.evals.core.types import RunSummary
|
||||
|
||||
model = config.model
|
||||
# LiteLLM expects "openai/<model>" for OpenAI-compatible servers
|
||||
litellm_model = f"openai/{model}"
|
||||
output_dir = getattr(config, "output_path", None) or "results/terminalbench-native/"
|
||||
|
||||
# Harness budgets: only forward explicit config values so the backend
|
||||
# defaults (global_agent_timeout_sec=1800) apply otherwise.
|
||||
timeout_kwargs = {}
|
||||
if getattr(config, "global_agent_timeout_sec", None) is not None:
|
||||
timeout_kwargs["global_agent_timeout_sec"] = config.global_agent_timeout_sec
|
||||
if getattr(config, "global_timeout_multiplier", None) is not None:
|
||||
timeout_kwargs["global_timeout_multiplier"] = config.global_timeout_multiplier
|
||||
|
||||
# Normalize to exactly one trailing "/v1" — LiteLLM's api_base wants the
|
||||
# full OpenAI-compatible prefix, and users pass both forms of the URL.
|
||||
if base_url:
|
||||
api_base = normalize_openai_base_url(base_url) + "/v1"
|
||||
else:
|
||||
api_base = "http://localhost:8000/v1"
|
||||
|
||||
backend = TerminalBenchNativeBackend(
|
||||
model=litellm_model,
|
||||
api_base="http://localhost:8000/v1",
|
||||
api_base=api_base,
|
||||
temperature=config.temperature,
|
||||
max_samples=config.max_samples,
|
||||
output_dir=output_dir,
|
||||
n_concurrent=config.max_workers or 4,
|
||||
**timeout_kwargs,
|
||||
)
|
||||
|
||||
import re
|
||||
@@ -683,46 +729,83 @@ def _run_terminalbench_native(config, console: Console) -> object:
|
||||
model_slug = re.sub(r"[^a-z0-9_-]", "-", model.lower().replace("/", "-"))
|
||||
run_id = f"tb21-{model_slug}"
|
||||
console.print(f" Running TerminalBench V2.1 natively: {model}")
|
||||
console.print(f" API base: {api_base}")
|
||||
console.print(f" Harness run_id: {run_id}")
|
||||
|
||||
results = backend.run_harness(run_id)
|
||||
if api_key:
|
||||
# terminus-2 routes model calls through LiteLLM with the "openai/"
|
||||
# prefix, which reads OPENAI_API_KEY from the environment. The
|
||||
# harness runs in-process, so set the var for the duration of the
|
||||
# run and restore the previous value afterwards.
|
||||
prev_key = os.environ.get("OPENAI_API_KEY")
|
||||
os.environ["OPENAI_API_KEY"] = api_key
|
||||
try:
|
||||
results = backend.run_harness(run_id)
|
||||
finally:
|
||||
if prev_key is None:
|
||||
os.environ.pop("OPENAI_API_KEY", None)
|
||||
else:
|
||||
os.environ["OPENAI_API_KEY"] = prev_key
|
||||
else:
|
||||
results = backend.run_harness(run_id)
|
||||
|
||||
# Convert BenchmarkResults to RunSummary
|
||||
total = len(results.trial_results) if hasattr(results, "trial_results") else 0
|
||||
correct = 0
|
||||
if hasattr(results, "trial_results"):
|
||||
for tr in results.trial_results:
|
||||
if getattr(tr, "is_resolved", False):
|
||||
correct += 1
|
||||
|
||||
accuracy = correct / total if total > 0 else 0.0
|
||||
return RunSummary(
|
||||
benchmark="terminalbench-native",
|
||||
category="agentic",
|
||||
backend="terminalbench-native",
|
||||
model=model,
|
||||
total_samples=total,
|
||||
scored_samples=total,
|
||||
correct=correct,
|
||||
accuracy=accuracy,
|
||||
errors=0,
|
||||
mean_latency_seconds=0.0,
|
||||
total_cost_usd=0.0,
|
||||
)
|
||||
# Convert BenchmarkResults to RunSummary, classifying harness/infra
|
||||
# failures (e.g. zero-model-contact setup hangs) out of the resolve-rate.
|
||||
summary, harness_failures = summarize_benchmark_results(results, model=model)
|
||||
if harness_failures:
|
||||
console.print(
|
||||
f" [red bold]{len(harness_failures)} harness/infra failure(s) "
|
||||
"excluded from resolve-rate:[/red bold]"
|
||||
)
|
||||
for failure in harness_failures:
|
||||
console.print(
|
||||
f" [red]- {failure['task_id']}: {failure['reason']} "
|
||||
f"(failure_mode={failure['failure_mode']})[/red]"
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def _run_single(config, console: Optional[Console] = None) -> object:
|
||||
"""Run a single eval from a RunConfig and return the summary."""
|
||||
def _run_single(
|
||||
config,
|
||||
console: Optional[Console] = None,
|
||||
*,
|
||||
suite_mode: bool = False,
|
||||
) -> object:
|
||||
"""Run a single eval from a RunConfig and return the summary.
|
||||
|
||||
``suite_mode=True`` (TOML-suite drivers) scopes ``config.base_url`` /
|
||||
``config.api_key`` — stamped from the suite's ``[backend.external]``
|
||||
section onto every RunConfig — to the hermes/openclaw backends only;
|
||||
extending suite-level endpoint targeting to first-party backends is
|
||||
explicitly deferred. The CLI single-run path (``suite_mode=False``)
|
||||
honors ``--base-url``/``--api-key`` for every backend.
|
||||
"""
|
||||
from openjarvis.evals.core.runner import EvalRunner
|
||||
|
||||
if console is None:
|
||||
console = Console()
|
||||
|
||||
_metadata = getattr(config, "metadata", None) or {}
|
||||
base_url = (
|
||||
getattr(config, "base_url", None)
|
||||
or _metadata.get("base_url")
|
||||
or os.environ.get("JARVIS_BACKEND_BASE_URL")
|
||||
)
|
||||
api_key = (
|
||||
getattr(config, "api_key", None)
|
||||
or _metadata.get("api_key")
|
||||
or os.environ.get("JARVIS_BACKEND_API_KEY")
|
||||
)
|
||||
|
||||
# TerminalBench V2.1 native: use terminal-bench Harness directly
|
||||
if config.benchmark == "terminalbench-native":
|
||||
return _run_terminalbench_native(config, console)
|
||||
return _run_terminalbench_native(
|
||||
config,
|
||||
console,
|
||||
base_url=None if suite_mode else base_url,
|
||||
api_key=None if suite_mode else api_key,
|
||||
)
|
||||
|
||||
_metadata = getattr(config, "metadata", None) or {}
|
||||
eval_backend = _build_backend(
|
||||
config.backend,
|
||||
config.engine_key,
|
||||
@@ -732,16 +815,9 @@ def _run_single(config, console: Optional[Console] = None) -> object:
|
||||
gpu_metrics=getattr(config, "gpu_metrics", False),
|
||||
model=config.model,
|
||||
max_turns=getattr(config, "max_turns", None),
|
||||
base_url=(
|
||||
getattr(config, "base_url", None)
|
||||
or _metadata.get("base_url")
|
||||
or os.environ.get("JARVIS_BACKEND_BASE_URL")
|
||||
),
|
||||
api_key=(
|
||||
getattr(config, "api_key", None)
|
||||
or _metadata.get("api_key")
|
||||
or os.environ.get("JARVIS_BACKEND_API_KEY")
|
||||
),
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
first_party_endpoint=not suite_mode,
|
||||
)
|
||||
dataset = _build_dataset(config.benchmark)
|
||||
# Inject engine config for benchmarks that run their own simulation
|
||||
@@ -964,7 +1040,9 @@ def _print_agentic_summary(console: Console, traces, config) -> None:
|
||||
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)
|
||||
harness_errors = [t for t in traces if t.error_kind == "harness_error"]
|
||||
model_traces = [t for t in traces if t.error_kind != "harness_error"]
|
||||
resolved = sum(1 for t in model_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)
|
||||
@@ -992,8 +1070,11 @@ def _print_agentic_summary(console: Console, traces, config) -> None:
|
||||
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)}")
|
||||
# Harness errors are excluded from the resolve-rate denominator:
|
||||
# they are infra failures, not model misses.
|
||||
table.add_row("Resolved", f"{resolved}/{len(model_traces)}")
|
||||
table.add_row("Timed out", str(timed_out))
|
||||
table.add_row("Harness errors", str(len(harness_errors)))
|
||||
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)
|
||||
@@ -1020,6 +1101,19 @@ def _print_agentic_summary(console: Console, traces, config) -> None:
|
||||
|
||||
console.print(table)
|
||||
|
||||
if harness_errors:
|
||||
console.print(
|
||||
f"[red bold]{len(harness_errors)} harness/infra failure(s) "
|
||||
"excluded from resolve-rate:[/red bold]"
|
||||
)
|
||||
for t in harness_errors[:5]:
|
||||
console.print(f"[red] {t.query_id}: {(t.error or '')[:300]}[/red]")
|
||||
if len(harness_errors) > 5:
|
||||
console.print(
|
||||
f"[red] ... and {len(harness_errors) - 5} more "
|
||||
"(see traces.jsonl)[/red]"
|
||||
)
|
||||
|
||||
|
||||
def _run_from_config(
|
||||
config_path: str,
|
||||
@@ -1070,7 +1164,7 @@ def _run_from_config(
|
||||
f"Run {i}/{len(run_configs)}: {rc.benchmark} / {rc.model}",
|
||||
)
|
||||
try:
|
||||
summary = _run_single(rc, console=console)
|
||||
summary = _run_single(rc, console=console, suite_mode=True)
|
||||
summaries.append(summary)
|
||||
console.print(
|
||||
f" [green]{summary.accuracy:.4f}[/green] "
|
||||
@@ -1115,12 +1209,21 @@ def main():
|
||||
@click.option(
|
||||
"--base-url",
|
||||
default=None,
|
||||
help="OpenAI-compat endpoint for hermes/openclaw",
|
||||
help=(
|
||||
"OpenAI-compatible endpoint for the model under eval. Required for "
|
||||
"hermes/openclaw; for jarvis-direct/jarvis-agent/terminalbench-native "
|
||||
"it bypasses engine discovery and targets this URL directly "
|
||||
"(env: JARVIS_BACKEND_BASE_URL)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--api-key",
|
||||
default=None,
|
||||
help="API key for hermes/openclaw endpoint",
|
||||
help=(
|
||||
"API key for the --base-url endpoint, sent as a Bearer token. "
|
||||
"Required for hermes/openclaw; optional for first-party backends "
|
||||
"(env: JARVIS_BACKEND_API_KEY)."
|
||||
),
|
||||
)
|
||||
@click.option("-m", "--model", default=None, help="Model identifier")
|
||||
@click.option(
|
||||
@@ -1526,8 +1629,7 @@ def summarize(jsonl_path):
|
||||
default=None,
|
||||
type=click.Path(),
|
||||
help=(
|
||||
"Output JSONL path. Defaults to <jsonl>.reparsed when "
|
||||
"--in-place is not set."
|
||||
"Output JSONL path. Defaults to <jsonl>.reparsed when --in-place is not set."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
@@ -1642,9 +1744,7 @@ def reparse_judge(jsonl_path, out_path, in_place, summary_out):
|
||||
_json.dump(summary, f, indent=2)
|
||||
|
||||
old_cont = [float(s) for s in old_scores if s is not None]
|
||||
old_acc = (
|
||||
sum(1 for s in old_cont if s >= 0.5) / len(old_cont) if old_cont else 0.0
|
||||
)
|
||||
old_acc = sum(1 for s in old_cont if s >= 0.5) / len(old_cont) if old_cont else 0.0
|
||||
old_mean = sum(old_cont) / len(old_cont) if old_cont else 0.0
|
||||
new_mean = sum(cont) / len(cont) if cont else 0.0
|
||||
mean_shift = new_mean - old_mean
|
||||
|
||||
@@ -20,6 +20,7 @@ from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from openjarvis.evals.core.environment import TaskEnvironmentError
|
||||
from openjarvis.evals.core.event_recorder import AgentEvent, EventRecorder, EventType
|
||||
from openjarvis.evals.core.trace import QueryTrace, TurnTrace
|
||||
|
||||
@@ -176,9 +177,12 @@ class AgenticRunner:
|
||||
)
|
||||
self._traces.append(trace)
|
||||
|
||||
status = (
|
||||
"TIMEOUT" if trace.timed_out else ("OK" if trace.completed else "FAIL")
|
||||
)
|
||||
if trace.timed_out:
|
||||
status = "TIMEOUT"
|
||||
elif trace.error_kind == "harness_error":
|
||||
status = "HARNESS_ERROR"
|
||||
else:
|
||||
status = "OK" if trace.completed else "FAIL"
|
||||
LOGGER.info(
|
||||
"Task %s: %s in %.1fs",
|
||||
query_id,
|
||||
@@ -260,11 +264,12 @@ class AgenticRunner:
|
||||
is_resolved=record.metadata.get("is_resolved"),
|
||||
)
|
||||
|
||||
status = (
|
||||
"TIMEOUT"
|
||||
if trace.timed_out
|
||||
else ("OK" if trace.completed else "FAIL")
|
||||
)
|
||||
if trace.timed_out:
|
||||
status = "TIMEOUT"
|
||||
elif trace.error_kind == "harness_error":
|
||||
status = "HARNESS_ERROR"
|
||||
else:
|
||||
status = "OK" if trace.completed else "FAIL"
|
||||
LOGGER.info(
|
||||
"Task %s: %s in %.1fs",
|
||||
query_id,
|
||||
@@ -444,7 +449,23 @@ class AgenticRunner:
|
||||
_run_body()
|
||||
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Agent failed on query %s: %s", query_id, exc)
|
||||
# Distinguish infrastructure breakage (task env failed to start,
|
||||
# Docker/tmux death) from agent failures: harness errors must be
|
||||
# recorded distinctly so scoring excludes them from resolve-rate
|
||||
# instead of silently counting a model miss. Either way the run
|
||||
# continues with the next record (fail THIS task, not the run).
|
||||
is_harness_error = isinstance(exc, TaskEnvironmentError) or bool(
|
||||
record.metadata.get("harness_error")
|
||||
)
|
||||
if is_harness_error:
|
||||
LOGGER.error(
|
||||
"Harness/environment failure on query %s (record %s): %s",
|
||||
query_id,
|
||||
getattr(record, "record_id", "?"),
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
LOGGER.warning("Agent failed on query %s: %s", query_id, exc)
|
||||
end_time = time.time()
|
||||
# Unsubscribe EventBus relays
|
||||
if agent_bus is not None:
|
||||
@@ -461,6 +482,8 @@ class AgenticRunner:
|
||||
total_wall_clock_s=end_time - start_time,
|
||||
completed=False,
|
||||
is_resolved=record.metadata.get("is_resolved"),
|
||||
error=str(exc),
|
||||
error_kind="harness_error" if is_harness_error else "agent_error",
|
||||
)
|
||||
|
||||
# Unsubscribe EventBus relays
|
||||
@@ -534,6 +557,48 @@ class AgenticRunner:
|
||||
model, turn.input_tokens, turn.output_tokens
|
||||
)
|
||||
|
||||
# --- Zero-model-contact sanity check ----------------------------
|
||||
# Failed in-container setups (e.g. an installed agent's SETUP phase
|
||||
# hanging or dying) historically produced traces that looked like
|
||||
# model results: completed=True, a synthetic 1-event turn,
|
||||
# is_resolved=False from run_tests, and zero tokens — silently
|
||||
# dragging resolve-rate down as a fake model miss. Signal choice:
|
||||
# token usage plus LM inference events is the reliable discriminator
|
||||
# here — a genuine model miss has token usage (and/or LM events),
|
||||
# while "the agent never called the model" has neither. We do NOT
|
||||
# key off completion status or is_resolved, which are identical in
|
||||
# both cases. run_agent_loop envs drive the model directly
|
||||
# (bypassing usage reporting), so their turn_wall_clocks count as
|
||||
# model contact.
|
||||
had_lm_events = any(
|
||||
e.event_type in (EventType.LM_INFERENCE_START, EventType.LM_INFERENCE_END)
|
||||
for e in events
|
||||
)
|
||||
had_loop_turns = bool(
|
||||
task_env is not None and getattr(task_env, "turn_wall_clocks", None)
|
||||
)
|
||||
turn_tokens = sum(t.input_tokens + t.output_tokens for t in turns)
|
||||
error: Optional[str] = None
|
||||
error_kind: Optional[str] = None
|
||||
if (
|
||||
not had_lm_events
|
||||
and not had_loop_turns
|
||||
and in_tok + out_tok == 0
|
||||
and turn_tokens == 0
|
||||
):
|
||||
error = (
|
||||
"zero_model_requests: the agent produced no LM inference "
|
||||
"events and reported zero token usage — the model was never "
|
||||
"contacted. This is a harness/infrastructure failure (e.g. "
|
||||
"in-container agent setup hang/death), not a model miss, and "
|
||||
"is excluded from resolve-rate. If your agent genuinely "
|
||||
"contacted the model, make it report token usage or emit "
|
||||
"LM_INFERENCE events. Response tail: "
|
||||
f"{(response_text or '')[-500:]!r}"
|
||||
)
|
||||
error_kind = "harness_error"
|
||||
LOGGER.error("Query %s: %s", query_id, error)
|
||||
|
||||
# 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")
|
||||
@@ -563,6 +628,8 @@ class AgenticRunner:
|
||||
is_resolved=record.metadata.get("is_resolved"),
|
||||
query_mbu_avg_pct=query_mbu_avg,
|
||||
query_mbu_max_pct=query_mbu_max,
|
||||
error=error,
|
||||
error_kind=error_kind,
|
||||
)
|
||||
|
||||
# Correlate energy with trace
|
||||
|
||||
@@ -143,6 +143,16 @@ def load_eval_config(path: str | Path) -> EvalSuiteConfig:
|
||||
sheets_worksheet=run_raw.get("sheets_worksheet", "Results"),
|
||||
sheets_credentials_path=run_raw.get("sheets_credentials_path", ""),
|
||||
max_turns=(int(run_raw["max_turns"]) if "max_turns" in run_raw else None),
|
||||
global_agent_timeout_sec=(
|
||||
float(run_raw["global_agent_timeout_sec"])
|
||||
if "global_agent_timeout_sec" in run_raw
|
||||
else None
|
||||
),
|
||||
global_timeout_multiplier=(
|
||||
float(run_raw["global_timeout_multiplier"])
|
||||
if "global_timeout_multiplier" in run_raw
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
# Parse [[models]]
|
||||
@@ -212,6 +222,16 @@ def load_eval_config(path: str | Path) -> EvalSuiteConfig:
|
||||
max_tokens=int(b["max_tokens"]) if "max_tokens" in b else None,
|
||||
subset=b.get("subset"),
|
||||
record_ids=record_ids,
|
||||
global_agent_timeout_sec=(
|
||||
float(b["global_agent_timeout_sec"])
|
||||
if "global_agent_timeout_sec" in b
|
||||
else None
|
||||
),
|
||||
global_timeout_multiplier=(
|
||||
float(b["global_timeout_multiplier"])
|
||||
if "global_timeout_multiplier" in b
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -275,6 +295,14 @@ def expand_suite(suite: EvalSuiteConfig) -> List[RunConfig]:
|
||||
if bench.judge_model is not None:
|
||||
judge_model = bench.judge_model
|
||||
|
||||
# terminal-bench harness budgets: benchmark > [run]
|
||||
global_agent_timeout_sec = suite.run.global_agent_timeout_sec
|
||||
if bench.global_agent_timeout_sec is not None:
|
||||
global_agent_timeout_sec = bench.global_agent_timeout_sec
|
||||
global_timeout_multiplier = suite.run.global_timeout_multiplier
|
||||
if bench.global_timeout_multiplier is not None:
|
||||
global_timeout_multiplier = bench.global_timeout_multiplier
|
||||
|
||||
# Auto-generate output path
|
||||
model_slug = model.name.replace("/", "-").replace(":", "-")
|
||||
output_path = f"{output_dir}/{bench.name}_{model_slug}.jsonl"
|
||||
@@ -328,6 +356,8 @@ def expand_suite(suite: EvalSuiteConfig) -> List[RunConfig]:
|
||||
base_url=suite.backend_external_base_url,
|
||||
api_key=suite.backend_external_api_key,
|
||||
record_ids=bench.record_ids,
|
||||
global_agent_timeout_sec=global_agent_timeout_sec,
|
||||
global_timeout_multiplier=global_timeout_multiplier,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,18 @@ from typing import Any, Dict, Tuple
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
|
||||
class TaskEnvironmentError(RuntimeError):
|
||||
"""A task execution environment failed to start or operate.
|
||||
|
||||
Raised when infrastructure backing a task (Docker container, docker
|
||||
compose project, tmux session, recording binaries, ...) breaks. This is
|
||||
a harness/environment failure, **not** a model failure: runners record
|
||||
it distinctly (``QueryTrace.error_kind == "harness_error"``) so scoring
|
||||
can exclude the sample from resolve-rate instead of silently counting
|
||||
it as a model miss.
|
||||
"""
|
||||
|
||||
|
||||
class EnvironmentProvider(ABC):
|
||||
"""Manages an external environment for evaluation benchmarks.
|
||||
|
||||
@@ -50,3 +62,6 @@ class EnvironmentProvider(ABC):
|
||||
@abstractmethod
|
||||
def teardown(self) -> None:
|
||||
"""Stop the environment and release resources."""
|
||||
|
||||
|
||||
__all__ = ["EnvironmentProvider", "TaskEnvironmentError"]
|
||||
|
||||
@@ -26,13 +26,22 @@ def _agg_stats(values: Sequence[Optional[float]]) -> dict[str, Optional[float]]:
|
||||
}
|
||||
|
||||
|
||||
def _model_attributable(traces: list[QueryTrace]) -> list[QueryTrace]:
|
||||
"""Traces whose outcome is attributable to the model.
|
||||
|
||||
Harness errors (infra/setup failures, zero-model-contact runs) are
|
||||
excluded so they never count as model misses in resolve-rate.
|
||||
"""
|
||||
return [t for t in traces if t.error_kind != "harness_error"]
|
||||
|
||||
|
||||
def _compute_efficiency(
|
||||
traces: list[QueryTrace],
|
||||
total_gpu_energy: Optional[float],
|
||||
total_cpu_energy: Optional[float],
|
||||
) -> dict[str, Optional[float]]:
|
||||
"""Compute efficiency metrics from traces and aggregate energy."""
|
||||
scored = [t for t in traces if t.is_resolved is not None]
|
||||
scored = [t for t in _model_attributable(traces) if t.is_resolved is not None]
|
||||
resolved = sum(1 for t in scored if t.is_resolved is True)
|
||||
accuracy = resolved / len(scored) if scored else None
|
||||
gpu_powers = [
|
||||
@@ -247,8 +256,12 @@ def export_summary_json(
|
||||
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)
|
||||
# Harness errors (infra failures, zero-model-contact runs) are excluded
|
||||
# from the resolve-rate denominator: they are not model misses.
|
||||
harness_error_traces = [t for t in traces if t.error_kind == "harness_error"]
|
||||
model_traces = _model_attributable(traces)
|
||||
resolved = sum(1 for t in model_traces if t.is_resolved is True)
|
||||
unresolved = sum(1 for t in model_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
|
||||
@@ -345,6 +358,7 @@ def export_summary_json(
|
||||
"completed": completed,
|
||||
"resolved": resolved,
|
||||
"unresolved": unresolved,
|
||||
"harness_errors": len(harness_error_traces),
|
||||
"accuracy": accuracy,
|
||||
"turns": total_turns,
|
||||
"tool_calls": total_tool_calls,
|
||||
@@ -365,6 +379,12 @@ def export_summary_json(
|
||||
"efficiency": efficiency,
|
||||
}
|
||||
|
||||
if harness_error_traces:
|
||||
summary["harness_error_details"] = [
|
||||
{"query_id": t.query_id, "error": (t.error or "")[:500]}
|
||||
for t in harness_error_traces
|
||||
]
|
||||
|
||||
if action_totals:
|
||||
summary["action_energy_summary"] = action_totals
|
||||
|
||||
@@ -399,7 +419,7 @@ def export_summary_json(
|
||||
|
||||
accuracy_vals: list[float] = [
|
||||
1.0 if t.is_resolved is True else 0.0
|
||||
for t in traces
|
||||
for t in _model_attributable(traces)
|
||||
if t.is_resolved is not None
|
||||
]
|
||||
latency_vals = [t.total_wall_clock_s for t in traces if t.total_wall_clock_s > 0]
|
||||
|
||||
@@ -91,6 +91,13 @@ class QueryTrace:
|
||||
is_resolved: Optional[bool] = None
|
||||
query_mbu_avg_pct: Optional[float] = None
|
||||
query_mbu_max_pct: Optional[float] = None
|
||||
# Error taxonomy. ``error_kind`` distinguishes infrastructure failures
|
||||
# ("harness_error": task env / Docker / tmux broke, or the agent never
|
||||
# contacted the model) from agent failures ("agent_error"). Harness
|
||||
# errors are excluded from resolve-rate by export/summary code so they
|
||||
# are never silently counted as model misses.
|
||||
error: Optional[str] = None
|
||||
error_kind: Optional[str] = None
|
||||
|
||||
@property
|
||||
def num_turns(self) -> int:
|
||||
@@ -196,6 +203,8 @@ class QueryTrace:
|
||||
"is_resolved": self.is_resolved,
|
||||
"query_mbu_avg_pct": self.query_mbu_avg_pct,
|
||||
"query_mbu_max_pct": self.query_mbu_max_pct,
|
||||
"error": self.error,
|
||||
"error_kind": self.error_kind,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -216,6 +225,8 @@ class QueryTrace:
|
||||
is_resolved=d.get("is_resolved"),
|
||||
query_mbu_avg_pct=d.get("query_mbu_avg_pct"),
|
||||
query_mbu_max_pct=d.get("query_mbu_max_pct"),
|
||||
error=d.get("error"),
|
||||
error_kind=d.get("error_kind"),
|
||||
)
|
||||
|
||||
def save_jsonl(self, path: Path) -> None:
|
||||
|
||||
@@ -102,6 +102,15 @@ class RunConfig:
|
||||
# specific records (e.g. recovering silent-fake records without
|
||||
# re-running the entire benchmark).
|
||||
record_ids: Optional[List[str]] = None
|
||||
# terminal-bench harness budgets (terminalbench-native backend).
|
||||
# global_agent_timeout_sec bounds each trial's agent phase — SETUP+RUN
|
||||
# together, since terminal-bench runs installed-agent setup inside the
|
||||
# agent budget with an infinite tmux timeout. When set it REPLACES the
|
||||
# per-task max_agent_timeout_sec; 0 disables the bound (per-task budgets
|
||||
# apply); None uses the backend default (1800 s).
|
||||
global_agent_timeout_sec: Optional[float] = None
|
||||
# Scales per-task budgets when global_agent_timeout_sec is not set.
|
||||
global_timeout_multiplier: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -232,6 +241,9 @@ class ExecutionConfig:
|
||||
# to JarvisConfig.agent.max_turns (default 10). Bump to 30-50 for
|
||||
# thinking/reasoning models on agentic benchmarks (GAIA, LiveResearch).
|
||||
max_turns: Optional[int] = None
|
||||
# terminal-bench harness budgets (see RunConfig for semantics).
|
||||
global_agent_timeout_sec: Optional[float] = None
|
||||
global_timeout_multiplier: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -265,6 +277,10 @@ class BenchmarkConfig:
|
||||
max_tokens: Optional[int] = None
|
||||
subset: Optional[str] = None
|
||||
record_ids: Optional[List[str]] = None
|
||||
# Per-benchmark override of the terminal-bench harness budgets
|
||||
# (see RunConfig for semantics).
|
||||
global_agent_timeout_sec: Optional[float] = None
|
||||
global_timeout_multiplier: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -11,11 +11,12 @@ import shutil
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, MutableMapping, Optional, Sequence
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
_DEFAULT_CACHE_DIR = Path.home() / ".cache" / "gaia_benchmark"
|
||||
_DEFAULT_CACHE_DIR = get_cache_dir() / "gaia_benchmark"
|
||||
|
||||
_DEFAULT_INPUT_PROMPT = """Please answer the question below. You should:
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
@@ -24,7 +25,7 @@ from openjarvis.evals.core.types import EvalRecord
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
LIVERESEARCH_REPO = "https://github.com/Ayanami0730/deep_research_bench.git"
|
||||
CACHE_DIR = Path.home() / ".cache" / "liveresearch_bench"
|
||||
CACHE_DIR = get_cache_dir() / "liveresearch_bench"
|
||||
|
||||
|
||||
def _load_jsonl(path: Path) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -13,6 +13,7 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
@@ -64,9 +65,7 @@ class LogHubDataset(DatasetProvider):
|
||||
f"Choose from: {list(_DATASETS.keys())}"
|
||||
)
|
||||
self._subset = subset
|
||||
self._cache_dir = (
|
||||
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "loghub"
|
||||
)
|
||||
self._cache_dir = Path(cache_dir) if cache_dir else get_cache_dir() / "loghub"
|
||||
self._records: List[EvalRecord] = []
|
||||
|
||||
def load(
|
||||
|
||||
@@ -15,6 +15,7 @@ import random
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
@@ -41,7 +42,7 @@ class PaperArenaDataset(DatasetProvider):
|
||||
cache_dir: Optional[str] = None,
|
||||
) -> None:
|
||||
self._cache_dir = (
|
||||
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "paperarena"
|
||||
Path(cache_dir) if cache_dir else get_cache_dir() / "paperarena"
|
||||
)
|
||||
self._records: List[EvalRecord] = []
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
@@ -25,7 +26,7 @@ from openjarvis.evals.core.types import EvalRecord
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PINCHBENCH_REPO = "https://github.com/pinchbench/skill.git"
|
||||
CACHE_DIR = Path.home() / ".cache" / "pinchbench"
|
||||
CACHE_DIR = get_cache_dir() / "pinchbench"
|
||||
|
||||
|
||||
def _parse_task_markdown(content: str, filename: str = "") -> Dict[str, Any]:
|
||||
|
||||
@@ -12,9 +12,9 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
@@ -22,7 +22,7 @@ from openjarvis.evals.core.types import EvalRecord
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TAU2_REPO = "https://github.com/sierra-research/tau2-bench.git"
|
||||
CACHE_DIR = Path.home() / ".cache" / "tau2-bench"
|
||||
CACHE_DIR = get_cache_dir() / "tau2-bench"
|
||||
|
||||
DOMAINS = ("airline", "retail", "telecom")
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
@@ -56,7 +57,7 @@ class WebChoreArenaDataset(DatasetProvider):
|
||||
) -> None:
|
||||
self._subset = subset # "all", "small", or a site name
|
||||
self._cache_dir = (
|
||||
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "webchorearena"
|
||||
Path(cache_dir) if cache_dir else get_cache_dir() / "webchorearena"
|
||||
)
|
||||
self._headless = headless
|
||||
self._records: List[EvalRecord] = []
|
||||
|
||||
@@ -8,6 +8,8 @@ from pathlib import Path
|
||||
from types import TracebackType
|
||||
from typing import Any, MutableMapping, Optional, Type
|
||||
|
||||
from openjarvis.evals.core.environment import TaskEnvironmentError
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -29,8 +31,6 @@ class TerminalBenchTaskEnv:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
@@ -41,6 +41,8 @@ class TerminalBenchTaskEnv:
|
||||
"Use the 'terminalbench-native' dataset."
|
||||
)
|
||||
|
||||
from terminal_bench.terminal.terminal import spin_up_terminal
|
||||
|
||||
docker_image_prefix = f"tb__{task_id}".replace(".", "-")
|
||||
client_image_name = f"{docker_image_prefix}__client"
|
||||
client_container_name = f"oj-{task_id}".replace(".", "-")
|
||||
@@ -48,19 +50,59 @@ class TerminalBenchTaskEnv:
|
||||
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__()
|
||||
# Everything below is exception-safe: a failure mid-startup (docker
|
||||
# compose, tmux, asciinema) tears the spun-up terminal back down
|
||||
# immediately instead of leaking the docker compose project until GC
|
||||
# (or forever, when the env object is retained), and re-raises as a
|
||||
# loud TaskEnvironmentError naming the task image so the runner can
|
||||
# record a harness error for THIS task and continue with the rest.
|
||||
try:
|
||||
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
|
||||
)
|
||||
# Preflight BEFORE the agent loop: terminal-bench drives the
|
||||
# agent through tmux (and records via asciinema unless the task
|
||||
# disables it). A missing binary otherwise surfaces mid-run as
|
||||
# an opaque RuntimeError or a fake TimeoutError.
|
||||
self._preflight_container_binaries(
|
||||
task, task_id, client_image_name, client_container_name
|
||||
)
|
||||
|
||||
session = self._terminal.create_session(
|
||||
"agent", is_active_stream=False, as_configured_user=True
|
||||
)
|
||||
except BaseException as exc:
|
||||
self._teardown(type(exc), exc, exc.__traceback__)
|
||||
if not isinstance(exc, Exception):
|
||||
# KeyboardInterrupt / SystemExit: clean up but never mask.
|
||||
raise
|
||||
if isinstance(exc, TaskEnvironmentError):
|
||||
self._metadata["harness_error"] = str(exc)
|
||||
raise
|
||||
message = (
|
||||
f"Task '{task_id}': failed to start the task environment "
|
||||
f"(image '{client_image_name}', container "
|
||||
f"'{client_container_name}'): {exc}. This is a harness/"
|
||||
"environment failure, not a model failure. Check that the "
|
||||
"Docker daemon is healthy and that tmux is installed in the "
|
||||
"task image."
|
||||
)
|
||||
# docker compose stderr is only logged at DEBUG by
|
||||
# terminal-bench; surface it here so the failure is actionable.
|
||||
stderr = getattr(exc, "stderr", None)
|
||||
if stderr:
|
||||
if isinstance(stderr, bytes):
|
||||
stderr = stderr.decode("utf-8", errors="replace")
|
||||
message += f"\ndocker compose stderr (tail):\n{stderr[-2000:]}"
|
||||
self._metadata["harness_error"] = message
|
||||
raise TaskEnvironmentError(message) from exc
|
||||
|
||||
self._metadata["terminal"] = self._terminal
|
||||
self._metadata["session"] = session
|
||||
@@ -68,24 +110,99 @@ class TerminalBenchTaskEnv:
|
||||
|
||||
return self
|
||||
|
||||
def _preflight_container_binaries(
|
||||
self,
|
||||
task: Any,
|
||||
task_id: str,
|
||||
client_image_name: str,
|
||||
client_container_name: str,
|
||||
) -> None:
|
||||
"""Verify tmux (and asciinema if recording) exist in the container.
|
||||
|
||||
Raises:
|
||||
TaskEnvironmentError: naming the task image and the missing
|
||||
binary, with the remedy, before any agent work starts.
|
||||
"""
|
||||
container = getattr(self._terminal, "container", None)
|
||||
if container is None:
|
||||
# Terminal implementation without a container handle (e.g. a
|
||||
# future terminal-bench version); fall through to terminal-bench's
|
||||
# own checks rather than guessing.
|
||||
return
|
||||
|
||||
checks: list[tuple[str, list[str], str]] = [
|
||||
(
|
||||
"tmux",
|
||||
["tmux", "-V"],
|
||||
f"install tmux in the task image '{client_image_name}'",
|
||||
),
|
||||
]
|
||||
if not getattr(task, "disable_asciinema", False):
|
||||
checks.append(
|
||||
(
|
||||
"asciinema",
|
||||
["asciinema", "--version"],
|
||||
f"install asciinema in the task image '{client_image_name}' "
|
||||
"or set disable_asciinema in task.yaml",
|
||||
)
|
||||
)
|
||||
|
||||
for binary, cmd, remedy in checks:
|
||||
result = container.exec_run(cmd)
|
||||
exit_code = getattr(result, "exit_code", 0)
|
||||
if exit_code == 0:
|
||||
continue
|
||||
output = getattr(result, "output", b"")
|
||||
if isinstance(output, bytes):
|
||||
output = output.decode("utf-8", errors="replace")
|
||||
raise TaskEnvironmentError(
|
||||
f"Task '{task_id}': required binary '{binary}' is not usable "
|
||||
f"in task image '{client_image_name}' (container "
|
||||
f"'{client_container_name}'): exec exit code {exit_code}, "
|
||||
f"output {str(output).strip()!r}. Remedy: {remedy}. This is "
|
||||
"a harness/environment failure, not a model failure."
|
||||
)
|
||||
|
||||
def _teardown(
|
||||
self,
|
||||
exc_type: Optional[Type[BaseException]] = None,
|
||||
exc_val: Optional[BaseException] = None,
|
||||
exc_tb: Optional[TracebackType] = None,
|
||||
) -> None:
|
||||
"""Idempotent cleanup shared by ``__exit__`` and failed ``__enter__``.
|
||||
|
||||
Secondary cleanup errors are logged, never raised, so they cannot
|
||||
mask the original failure.
|
||||
"""
|
||||
self._metadata.pop("terminal", None)
|
||||
self._metadata.pop("session", None)
|
||||
self._metadata.pop("container", None)
|
||||
|
||||
terminal_cm, self._terminal_cm, self._terminal = self._terminal_cm, None, None
|
||||
if terminal_cm is not None:
|
||||
try:
|
||||
terminal_cm.__exit__(exc_type, exc_val, exc_tb)
|
||||
except Exception:
|
||||
LOGGER.exception(
|
||||
"Secondary error while tearing down the terminal for "
|
||||
"task %s (original error, if any, is re-raised)",
|
||||
self._metadata.get("task_id", "unknown"),
|
||||
)
|
||||
|
||||
logs_tmpdir, self._logs_tmpdir = self._logs_tmpdir, None
|
||||
if logs_tmpdir is not None:
|
||||
try:
|
||||
logs_tmpdir.cleanup()
|
||||
except Exception:
|
||||
LOGGER.exception("Failed to clean up session logs tmpdir")
|
||||
|
||||
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
|
||||
self._teardown(exc_type, exc_val, exc_tb)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Test execution
|
||||
@@ -93,12 +210,6 @@ class TerminalBenchTaskEnv:
|
||||
|
||||
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
|
||||
@@ -110,6 +221,12 @@ class TerminalBenchTaskEnv:
|
||||
self._metadata["test_results"] = results
|
||||
return False, 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,
|
||||
)
|
||||
|
||||
try:
|
||||
paths_to_copy = [task_paths.run_tests_path]
|
||||
if task_paths.test_dir.exists():
|
||||
|
||||
@@ -49,6 +49,7 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.scorer import Scorer
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
@@ -93,9 +94,7 @@ def _run_subprocess_hard_timeout(
|
||||
)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout_s)
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, proc.returncode, stdout, stderr
|
||||
)
|
||||
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Kill the whole group, not just the direct child — Modal harness
|
||||
# subprocesses fork workers that would otherwise keep pipes open.
|
||||
@@ -115,9 +114,7 @@ def _run_subprocess_hard_timeout(
|
||||
stdout, stderr = proc.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
stdout, stderr = "", ""
|
||||
raise subprocess.TimeoutExpired(
|
||||
cmd, timeout_s, output=stdout, stderr=stderr
|
||||
)
|
||||
raise subprocess.TimeoutExpired(cmd, timeout_s, output=stdout, stderr=stderr)
|
||||
|
||||
|
||||
# ---------- Patch tracking ----------
|
||||
@@ -208,11 +205,11 @@ def _patch_modal_sandbox_source() -> None:
|
||||
# Upstream changed the line — bail rather than apply blindly.
|
||||
return
|
||||
replacement = (
|
||||
' # ' + _CGROUP_SOURCE_SENTINEL + '\n'
|
||||
' try:\n'
|
||||
" # " + _CGROUP_SOURCE_SENTINEL + "\n"
|
||||
" try:\n"
|
||||
' self.write_file("/sys/fs/cgroup/cpu/cpu.shares", "2048")\n'
|
||||
' except FileNotFoundError:\n'
|
||||
' pass # cgroup v2 Modal sandbox — path missing is fine\n'
|
||||
" except FileNotFoundError:\n"
|
||||
" pass # cgroup v2 Modal sandbox — path missing is fine\n"
|
||||
)
|
||||
new_src = src.replace(needle + "\n", replacement, 1)
|
||||
try:
|
||||
@@ -324,22 +321,23 @@ def extract_patch(text: str) -> Optional[str]:
|
||||
|
||||
# ---------- Harness invocation ----------
|
||||
|
||||
|
||||
def _harness_cache_dir() -> Path:
|
||||
"""Where the swebench subprocess writes its report JSON + logs/ tree.
|
||||
|
||||
Defaults to ``$OPENJARVIS_HOME/.swebench-cache`` if set, otherwise to a
|
||||
process-shared tempdir. Pin both so we don't pollute the project root.
|
||||
Consolidated under the env-aware OpenJarvis cache root
|
||||
(``<openjarvis-home>/cache/swebench``) so it never pollutes the project
|
||||
root or scatters across ``$HOME``. Honors ``OPENJARVIS_HOME`` /
|
||||
``XDG_DATA_HOME`` via :func:`openjarvis.core.paths.get_cache_dir`.
|
||||
"""
|
||||
home = os.environ.get("OPENJARVIS_HOME")
|
||||
if home:
|
||||
cache = Path(home) / ".swebench-cache"
|
||||
else:
|
||||
cache = Path(tempfile.gettempdir()) / "openjarvis-swebench-cache"
|
||||
cache = get_cache_dir() / "swebench"
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
return cache
|
||||
|
||||
|
||||
def _find_report(cache: Path, instance_id: str, run_id: str) -> Optional[Dict[str, Any]]:
|
||||
def _find_report(
|
||||
cache: Path, instance_id: str, run_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Find the harness's report JSON for one instance.
|
||||
|
||||
swebench writes ``<model_name_or_path>.<run_id>.json`` inside the
|
||||
@@ -427,26 +425,40 @@ def _run_harness(
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
preds_path = tmp_path / "predictions.jsonl"
|
||||
preds_path.write_text(json.dumps({
|
||||
"instance_id": instance_id,
|
||||
"model_name_or_path": "openjarvis-harness",
|
||||
"model_patch": patch,
|
||||
}) + "\n")
|
||||
preds_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"instance_id": instance_id,
|
||||
"model_name_or_path": "openjarvis-harness",
|
||||
"model_patch": patch,
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "swebench.harness.run_evaluation",
|
||||
"--predictions_path", str(preds_path),
|
||||
"--max_workers", "1",
|
||||
"--run_id", run_id,
|
||||
"--dataset_name", "SWE-bench/SWE-bench_Verified",
|
||||
"--instance_ids", instance_id,
|
||||
sys.executable,
|
||||
"-m",
|
||||
"swebench.harness.run_evaluation",
|
||||
"--predictions_path",
|
||||
str(preds_path),
|
||||
"--max_workers",
|
||||
"1",
|
||||
"--run_id",
|
||||
run_id,
|
||||
"--dataset_name",
|
||||
"SWE-bench/SWE-bench_Verified",
|
||||
"--instance_ids",
|
||||
instance_id,
|
||||
]
|
||||
if backend == "modal":
|
||||
cmd += ["--modal", "true"]
|
||||
|
||||
try:
|
||||
proc = _run_subprocess_hard_timeout(
|
||||
cmd, timeout_s=timeout_s, cwd=str(cache),
|
||||
cmd,
|
||||
timeout_s=timeout_s,
|
||||
cwd=str(cache),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
# The harness subprocess (and its Modal grandchildren) exceeded
|
||||
@@ -497,6 +509,7 @@ def _run_harness(
|
||||
|
||||
# ---------- Scorer ----------
|
||||
|
||||
|
||||
class SWEBenchHarnessScorer(Scorer):
|
||||
"""SWE-bench Verified scorer that runs the official harness.
|
||||
|
||||
@@ -516,7 +529,7 @@ class SWEBenchHarnessScorer(Scorer):
|
||||
timeout_s: int = 1800,
|
||||
cell_name: Optional[str] = None,
|
||||
judge_backend: object = None, # noqa: ARG002 — CLI factory compat
|
||||
judge_model: str = "", # noqa: ARG002 — CLI factory compat
|
||||
judge_model: str = "", # noqa: ARG002 — CLI factory compat
|
||||
) -> None:
|
||||
self._timeout_s = int(timeout_s)
|
||||
# ``cell_name`` namespaces the ``run_id`` so concurrent cells scoring
|
||||
@@ -538,16 +551,15 @@ class SWEBenchHarnessScorer(Scorer):
|
||||
if patch is None:
|
||||
return False, {"reason": "no_patch_extracted"}
|
||||
|
||||
instance_id = (
|
||||
record.metadata.get("instance_id")
|
||||
or record.record_id
|
||||
or ""
|
||||
)
|
||||
instance_id = record.metadata.get("instance_id") or record.record_id or ""
|
||||
if not instance_id:
|
||||
return False, {"reason": "missing_instance_id"}
|
||||
|
||||
result = _run_harness(
|
||||
instance_id, patch, self._timeout_s, cell_name=self._cell_name,
|
||||
instance_id,
|
||||
patch,
|
||||
self._timeout_s,
|
||||
cell_name=self._cell_name,
|
||||
)
|
||||
details = dict(result.get("details", {}))
|
||||
details["patch"] = patch
|
||||
|
||||
@@ -16,6 +16,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONDITIONS = (
|
||||
@@ -52,14 +54,12 @@ class SkillBenchmarkConfig:
|
||||
seeds: List[int] = field(default_factory=lambda: [42, 43, 44])
|
||||
max_samples: Optional[int] = None
|
||||
output_dir: Path = field(default_factory=lambda: Path("docs/superpowers/results/"))
|
||||
skills_dir: Path = field(
|
||||
default_factory=lambda: Path("~/.openjarvis/skills/").expanduser()
|
||||
)
|
||||
skills_dir: Path = field(default_factory=lambda: get_config_dir() / "skills")
|
||||
overlay_dir_dspy: Path = field(
|
||||
default_factory=lambda: Path("~/.openjarvis/learning/skills-dspy/").expanduser()
|
||||
default_factory=lambda: get_config_dir() / "learning" / "skills-dspy"
|
||||
)
|
||||
overlay_dir_gepa: Path = field(
|
||||
default_factory=lambda: Path("~/.openjarvis/learning/skills-gepa/").expanduser()
|
||||
default_factory=lambda: get_config_dir() / "learning" / "skills-gepa"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,27 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
|
||||
def _mock_builder() -> MagicMock:
|
||||
"""A SystemBuilder mock whose fluent methods chain like the real one."""
|
||||
builder = MagicMock()
|
||||
for method in (
|
||||
"engine",
|
||||
"engine_instance",
|
||||
"model",
|
||||
"agent",
|
||||
"tools",
|
||||
"telemetry",
|
||||
"traces",
|
||||
):
|
||||
getattr(builder, method).return_value = builder
|
||||
builder.build.return_value = MagicMock()
|
||||
return builder
|
||||
|
||||
|
||||
class TestJarvisDirectBackend:
|
||||
@patch("openjarvis.system.SystemBuilder")
|
||||
@@ -137,3 +158,130 @@ class TestJarvisAgentBackend:
|
||||
assert result["content"] == "The answer is 4."
|
||||
assert result["turns"] == 2
|
||||
assert len(result["tool_results"]) == 1
|
||||
|
||||
|
||||
class TestJarvisDirectBackendBaseUrl:
|
||||
"""--base-url targeting for the jarvis-direct backend."""
|
||||
|
||||
@patch("openjarvis.system.SystemBuilder")
|
||||
def test_base_url_injects_pinned_openai_compat_engine(self, mock_builder_cls):
|
||||
from openjarvis.engine.openai_compat_engines import OpenAICompatEngine
|
||||
from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend
|
||||
|
||||
mock_builder = _mock_builder()
|
||||
mock_builder_cls.return_value = mock_builder
|
||||
|
||||
with respx.mock:
|
||||
respx.get("http://127.0.0.1:18999/v1/models").mock(
|
||||
return_value=httpx.Response(200, json={"data": []})
|
||||
)
|
||||
JarvisDirectBackend(base_url="http://127.0.0.1:18999/v1", api_key="sk-x")
|
||||
|
||||
mock_builder.engine_instance.assert_called_once()
|
||||
injected = mock_builder.engine_instance.call_args[0][0]
|
||||
assert isinstance(injected, OpenAICompatEngine)
|
||||
# Trailing /v1 is normalized away so request paths don't double up.
|
||||
assert injected._host == "http://127.0.0.1:18999"
|
||||
assert injected._api_key == "sk-x"
|
||||
# The discovery path must not be engaged at all.
|
||||
mock_builder.engine.assert_not_called()
|
||||
|
||||
@patch("openjarvis.system.SystemBuilder")
|
||||
def test_unreachable_base_url_fails_fast_naming_url(self, mock_builder_cls):
|
||||
from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend
|
||||
|
||||
mock_builder = _mock_builder()
|
||||
mock_builder_cls.return_value = mock_builder
|
||||
|
||||
with respx.mock:
|
||||
respx.get("http://127.0.0.1:18998/v1/models").mock(
|
||||
side_effect=httpx.ConnectError("connection refused")
|
||||
)
|
||||
with pytest.raises(RuntimeError, match=r"http://127\.0\.0\.1:18998"):
|
||||
JarvisDirectBackend(base_url="http://127.0.0.1:18998")
|
||||
|
||||
# No silent engine substitution: the system is never built.
|
||||
mock_builder.engine_instance.assert_not_called()
|
||||
mock_builder.build.assert_not_called()
|
||||
|
||||
@patch("openjarvis.system.SystemBuilder")
|
||||
def test_no_base_url_keeps_engine_key_path(self, mock_builder_cls):
|
||||
from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend
|
||||
|
||||
mock_builder = _mock_builder()
|
||||
mock_builder_cls.return_value = mock_builder
|
||||
|
||||
JarvisDirectBackend(engine_key="vllm")
|
||||
mock_builder.engine.assert_called_with("vllm")
|
||||
mock_builder.engine_instance.assert_not_called()
|
||||
|
||||
@patch("openjarvis.system.SystemBuilder")
|
||||
def test_base_url_wins_over_engine_key(self, mock_builder_cls):
|
||||
from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend
|
||||
|
||||
mock_builder = _mock_builder()
|
||||
mock_builder_cls.return_value = mock_builder
|
||||
|
||||
with respx.mock:
|
||||
respx.get("http://127.0.0.1:18999/v1/models").mock(
|
||||
return_value=httpx.Response(200, json={"data": []})
|
||||
)
|
||||
JarvisDirectBackend(engine_key="vllm", base_url="http://127.0.0.1:18999")
|
||||
|
||||
mock_builder.engine.assert_not_called()
|
||||
mock_builder.engine_instance.assert_called_once()
|
||||
# The engine key is kept as the label for the injected engine.
|
||||
assert mock_builder.engine_instance.call_args.kwargs["key"] == "vllm"
|
||||
|
||||
|
||||
class TestJarvisAgentBackendBaseUrl:
|
||||
"""--base-url targeting for the jarvis-agent backend."""
|
||||
|
||||
@patch("openjarvis.system.SystemBuilder")
|
||||
def test_base_url_injects_pinned_openai_compat_engine(self, mock_builder_cls):
|
||||
from openjarvis.engine.openai_compat_engines import OpenAICompatEngine
|
||||
from openjarvis.evals.backends.jarvis_agent import JarvisAgentBackend
|
||||
|
||||
mock_builder = _mock_builder()
|
||||
mock_builder_cls.return_value = mock_builder
|
||||
|
||||
with respx.mock:
|
||||
respx.get("http://127.0.0.1:18999/v1/models").mock(
|
||||
return_value=httpx.Response(200, json={"data": []})
|
||||
)
|
||||
JarvisAgentBackend(base_url="http://127.0.0.1:18999/v1", api_key="sk-x")
|
||||
|
||||
mock_builder.engine_instance.assert_called_once()
|
||||
injected = mock_builder.engine_instance.call_args[0][0]
|
||||
assert isinstance(injected, OpenAICompatEngine)
|
||||
assert injected._host == "http://127.0.0.1:18999"
|
||||
assert injected._api_key == "sk-x"
|
||||
mock_builder.engine.assert_not_called()
|
||||
|
||||
@patch("openjarvis.system.SystemBuilder")
|
||||
def test_unreachable_base_url_fails_fast_naming_url(self, mock_builder_cls):
|
||||
from openjarvis.evals.backends.jarvis_agent import JarvisAgentBackend
|
||||
|
||||
mock_builder = _mock_builder()
|
||||
mock_builder_cls.return_value = mock_builder
|
||||
|
||||
with respx.mock:
|
||||
respx.get("http://127.0.0.1:18998/v1/models").mock(
|
||||
side_effect=httpx.ConnectError("connection refused")
|
||||
)
|
||||
with pytest.raises(RuntimeError, match=r"http://127\.0\.0\.1:18998"):
|
||||
JarvisAgentBackend(base_url="http://127.0.0.1:18998")
|
||||
|
||||
mock_builder.engine_instance.assert_not_called()
|
||||
mock_builder.build.assert_not_called()
|
||||
|
||||
@patch("openjarvis.system.SystemBuilder")
|
||||
def test_no_base_url_keeps_engine_key_path(self, mock_builder_cls):
|
||||
from openjarvis.evals.backends.jarvis_agent import JarvisAgentBackend
|
||||
|
||||
mock_builder = _mock_builder()
|
||||
mock_builder_cls.return_value = mock_builder
|
||||
|
||||
JarvisAgentBackend(engine_key="vllm")
|
||||
mock_builder.engine.assert_called_with("vllm")
|
||||
mock_builder.engine_instance.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""--base-url/--api-key forwarding through the eval CLI plumbing.
|
||||
|
||||
Covers the fix for the eval-CLI endpoint gap: the flags used to be silently
|
||||
dropped for jarvis-direct/jarvis-agent and ignored by terminalbench-native
|
||||
(which hardcoded api_base="http://localhost:8000/v1").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from openjarvis.evals.cli import _build_backend, _run_terminalbench_native
|
||||
from openjarvis.evals.core.types import RunConfig
|
||||
|
||||
|
||||
def _quiet_console() -> Console:
|
||||
return Console(file=io.StringIO())
|
||||
|
||||
|
||||
def _tb_config(**overrides) -> RunConfig:
|
||||
defaults = dict(
|
||||
benchmark="terminalbench-native",
|
||||
backend="jarvis-direct",
|
||||
model="my-model",
|
||||
max_samples=1,
|
||||
max_workers=1,
|
||||
temperature=0.2,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return RunConfig(**defaults)
|
||||
|
||||
|
||||
class TestBuildBackendForwardsEndpoint:
|
||||
@patch("openjarvis.evals.backends.jarvis_direct.JarvisDirectBackend")
|
||||
def test_jarvis_direct_receives_base_url_and_api_key(self, mock_cls):
|
||||
_build_backend(
|
||||
"jarvis-direct",
|
||||
"vllm",
|
||||
"orchestrator",
|
||||
[],
|
||||
base_url="http://node7:8123/v1",
|
||||
api_key="sk-k",
|
||||
)
|
||||
kwargs = mock_cls.call_args.kwargs
|
||||
assert kwargs["base_url"] == "http://node7:8123/v1"
|
||||
assert kwargs["api_key"] == "sk-k"
|
||||
|
||||
@patch("openjarvis.evals.backends.jarvis_agent.JarvisAgentBackend")
|
||||
def test_jarvis_agent_receives_base_url_and_api_key(self, mock_cls):
|
||||
_build_backend(
|
||||
"jarvis-agent",
|
||||
"vllm",
|
||||
"orchestrator",
|
||||
["calculator"],
|
||||
base_url="http://node7:8123/v1",
|
||||
api_key="sk-k",
|
||||
)
|
||||
kwargs = mock_cls.call_args.kwargs
|
||||
assert kwargs["base_url"] == "http://node7:8123/v1"
|
||||
assert kwargs["api_key"] == "sk-k"
|
||||
|
||||
@patch("openjarvis.evals.backends.jarvis_direct.JarvisDirectBackend")
|
||||
def test_suite_mode_scopes_endpoint_to_external_backends(self, mock_cls):
|
||||
"""[backend.external] suite semantics stay hermes/openclaw-only:
|
||||
first_party_endpoint=False must not forward to first-party."""
|
||||
_build_backend(
|
||||
"jarvis-direct",
|
||||
"vllm",
|
||||
"orchestrator",
|
||||
[],
|
||||
base_url="http://node7:8123/v1",
|
||||
api_key="sk-k",
|
||||
first_party_endpoint=False,
|
||||
)
|
||||
kwargs = mock_cls.call_args.kwargs
|
||||
assert kwargs["base_url"] is None
|
||||
assert kwargs["api_key"] is None
|
||||
|
||||
def test_hermes_still_requires_base_url_and_api_key(self):
|
||||
with pytest.raises(click.UsageError, match="hermes"):
|
||||
_build_backend("hermes", None, "orchestrator", [])
|
||||
|
||||
def test_openclaw_still_requires_base_url_and_api_key(self):
|
||||
with pytest.raises(click.UsageError, match="openclaw"):
|
||||
_build_backend("openclaw", None, "orchestrator", [])
|
||||
|
||||
|
||||
class TestTerminalBenchNativeApiBase:
|
||||
@patch("openjarvis.evals.backends.terminalbench_native.TerminalBenchNativeBackend")
|
||||
def test_base_url_passed_through_as_api_base(self, mock_cls):
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.run_harness.return_value = SimpleNamespace(trial_results=[])
|
||||
mock_cls.return_value = mock_backend
|
||||
|
||||
_run_terminalbench_native(
|
||||
_tb_config(),
|
||||
_quiet_console(),
|
||||
base_url="http://node7:8123/v1",
|
||||
)
|
||||
assert mock_cls.call_args.kwargs["api_base"] == "http://node7:8123/v1"
|
||||
|
||||
@patch("openjarvis.evals.backends.terminalbench_native.TerminalBenchNativeBackend")
|
||||
def test_base_url_without_v1_gets_single_v1_suffix(self, mock_cls):
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.run_harness.return_value = SimpleNamespace(trial_results=[])
|
||||
mock_cls.return_value = mock_backend
|
||||
|
||||
_run_terminalbench_native(
|
||||
_tb_config(),
|
||||
_quiet_console(),
|
||||
base_url="http://node7:8123",
|
||||
)
|
||||
assert mock_cls.call_args.kwargs["api_base"] == "http://node7:8123/v1"
|
||||
|
||||
@patch("openjarvis.evals.backends.terminalbench_native.TerminalBenchNativeBackend")
|
||||
def test_default_api_base_unchanged_without_base_url(self, mock_cls):
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.run_harness.return_value = SimpleNamespace(trial_results=[])
|
||||
mock_cls.return_value = mock_backend
|
||||
|
||||
_run_terminalbench_native(_tb_config(), _quiet_console())
|
||||
assert mock_cls.call_args.kwargs["api_base"] == "http://localhost:8000/v1"
|
||||
|
||||
@patch("openjarvis.evals.backends.terminalbench_native.TerminalBenchNativeBackend")
|
||||
def test_api_key_exported_as_openai_api_key_during_run(self, mock_cls, monkeypatch):
|
||||
"""terminus-2 reads OPENAI_API_KEY via LiteLLM; the var must be set
|
||||
during harness.run() and restored afterwards."""
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
seen: dict = {}
|
||||
|
||||
def fake_run_harness(run_id):
|
||||
seen["openai_api_key"] = os.environ.get("OPENAI_API_KEY")
|
||||
return SimpleNamespace(trial_results=[])
|
||||
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.run_harness.side_effect = fake_run_harness
|
||||
mock_cls.return_value = mock_backend
|
||||
|
||||
_run_terminalbench_native(
|
||||
_tb_config(),
|
||||
_quiet_console(),
|
||||
base_url="http://node7:8123/v1",
|
||||
api_key="sk-tb",
|
||||
)
|
||||
assert seen["openai_api_key"] == "sk-tb"
|
||||
assert "OPENAI_API_KEY" not in os.environ # restored
|
||||
|
||||
@patch("openjarvis.evals.backends.terminalbench_native.TerminalBenchNativeBackend")
|
||||
def test_preexisting_openai_api_key_restored(self, mock_cls, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-original")
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.run_harness.return_value = SimpleNamespace(trial_results=[])
|
||||
mock_cls.return_value = mock_backend
|
||||
|
||||
_run_terminalbench_native(
|
||||
_tb_config(),
|
||||
_quiet_console(),
|
||||
base_url="http://node7:8123/v1",
|
||||
api_key="sk-tb",
|
||||
)
|
||||
assert os.environ["OPENAI_API_KEY"] == "sk-original"
|
||||
|
||||
|
||||
class TestRunSingleSuiteModeGating:
|
||||
@patch("openjarvis.evals.cli._run_terminalbench_native")
|
||||
def test_suite_mode_drops_endpoint_for_terminalbench(self, mock_tb):
|
||||
from openjarvis.evals.cli import _run_single
|
||||
|
||||
mock_tb.return_value = SimpleNamespace(accuracy=0.0)
|
||||
config = _tb_config(base_url="http://node7:8123/v1", api_key="sk-k")
|
||||
_run_single(config, console=_quiet_console(), suite_mode=True)
|
||||
assert mock_tb.call_args.kwargs["base_url"] is None
|
||||
assert mock_tb.call_args.kwargs["api_key"] is None
|
||||
|
||||
@patch("openjarvis.evals.cli._run_terminalbench_native")
|
||||
def test_cli_mode_forwards_endpoint_for_terminalbench(self, mock_tb):
|
||||
from openjarvis.evals.cli import _run_single
|
||||
|
||||
mock_tb.return_value = SimpleNamespace(accuracy=0.0)
|
||||
config = _tb_config(base_url="http://node7:8123/v1", api_key="sk-k")
|
||||
_run_single(config, console=_quiet_console())
|
||||
assert mock_tb.call_args.kwargs["base_url"] == "http://node7:8123/v1"
|
||||
assert mock_tb.call_args.kwargs["api_key"] == "sk-k"
|
||||
@@ -30,6 +30,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from openjarvis.core.config import ACEOptimizerConfig
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import LearningRegistry
|
||||
from openjarvis.learning._stubs import AgentLearningPolicy
|
||||
|
||||
@@ -48,7 +49,7 @@ except ImportError:
|
||||
|
||||
|
||||
def _default_save_dir(task_name: str) -> Path:
|
||||
return Path.home() / ".openjarvis" / "learning" / "ace" / task_name
|
||||
return get_config_dir() / "learning" / "ace" / task_name
|
||||
|
||||
|
||||
class _TraceDataProcessor:
|
||||
@@ -87,9 +88,7 @@ class _TraceDataProcessor:
|
||||
if not predictions:
|
||||
return 0.0
|
||||
n_correct = sum(
|
||||
1
|
||||
for p, g in zip(predictions, ground_truths)
|
||||
if cls.answer_is_correct(p, g)
|
||||
1 for p, g in zip(predictions, ground_truths) if cls.answer_is_correct(p, g)
|
||||
)
|
||||
return n_correct / len(predictions)
|
||||
|
||||
@@ -149,8 +148,7 @@ class ACEAgentOptimizer:
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": (
|
||||
f"only {len(traces)} traces, "
|
||||
f"min_traces={self.config.min_traces}"
|
||||
f"only {len(traces)} traces, min_traces={self.config.min_traces}"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -158,8 +156,7 @@ class ACEAgentOptimizer:
|
||||
return {
|
||||
"status": "error",
|
||||
"reason": (
|
||||
"ace not installed (pip install "
|
||||
"'openjarvis[learning-ace]')"
|
||||
"ace not installed (pip install 'openjarvis[learning-ace]')"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.types import Trace, TraceStep
|
||||
from openjarvis.skills.manager import SkillManager
|
||||
from openjarvis.skills.overlay import SkillOverlay, write_overlay
|
||||
@@ -99,7 +100,7 @@ class SkillOptimizer:
|
||||
pass
|
||||
if overlay_dir is None:
|
||||
overlay_dir = Path(
|
||||
"~/.openjarvis/learning/skills/"
|
||||
str(get_config_dir() / "learning" / "skills")
|
||||
).expanduser()
|
||||
overlay_dir = Path(overlay_dir).expanduser()
|
||||
|
||||
|
||||
@@ -11,14 +11,21 @@ writing artifacts into the working tree.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.paths import ConfigurationError, get_config_dir
|
||||
from openjarvis.security.file_utils import secure_mkdir
|
||||
|
||||
|
||||
class ConfigurationError(RuntimeError):
|
||||
"""Raised when path configuration would violate isolation guarantees."""
|
||||
# ``ConfigurationError`` is re-exported from ``openjarvis.core.paths`` (it used
|
||||
# to be defined here). Spec search now resolves the home dir through the unified
|
||||
# core resolver, which raises the same exception type on a source-tree path, so
|
||||
# we alias rather than redefine to keep ``except ConfigurationError`` callers and
|
||||
# existing tests working.
|
||||
__all__ = [
|
||||
"ConfigurationError",
|
||||
"ensure_spec_search_dirs",
|
||||
"resolve_spec_search_root",
|
||||
]
|
||||
|
||||
|
||||
def _find_source_root() -> Path | None:
|
||||
@@ -42,11 +49,12 @@ def _find_source_root() -> Path | None:
|
||||
|
||||
|
||||
def _resolve_openjarvis_home() -> Path:
|
||||
"""Resolve the OPENJARVIS_HOME directory (env var or default)."""
|
||||
env = os.environ.get("OPENJARVIS_HOME")
|
||||
if env:
|
||||
return Path(env).expanduser().resolve()
|
||||
return (Path.home() / ".openjarvis").resolve()
|
||||
"""Resolve the OpenJarvis home directory via the unified core resolver.
|
||||
|
||||
Delegates to ``get_config_dir`` so spec-search honors the same env-aware
|
||||
resolution (OPENJARVIS_HOME and XDG) as the rest of the framework.
|
||||
"""
|
||||
return get_config_dir()
|
||||
|
||||
|
||||
def resolve_spec_search_root() -> Path:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user