mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cadb3e2ae6 | ||
|
|
7dc904c1b2 | ||
|
|
d9725fbb6a | ||
|
|
23f04264f9 | ||
|
|
8b59eb87e0 | ||
|
|
2e68e227b7 | ||
|
|
fc98614437 | ||
|
|
b1c5aba6fd | ||
|
|
6240c59ca3 | ||
|
|
9f3c7fd086 | ||
|
|
657c8dd26b | ||
|
|
4ef296e9d0 | ||
|
|
d5d06ca0e5 | ||
|
|
213ee4ff7e | ||
|
|
215ab76e5f | ||
|
|
0812ee0701 | ||
|
|
0b140110d8 | ||
|
|
2623f9e0f4 | ||
|
|
d454c41500 | ||
|
|
e3fb816d12 | ||
|
|
3486f27357 | ||
|
|
928776a71c | ||
|
|
2c2a4b6ae4 | ||
|
|
26bc7efb09 | ||
|
|
f3954e087a | ||
|
|
d865b4bed4 | ||
|
|
c686517cc7 | ||
|
|
904133cb25 | ||
|
|
299dee1f40 | ||
|
|
44ff286005 | ||
|
|
be51eb8684 | ||
|
|
420908401c | ||
|
|
b70be55681 | ||
|
|
a0187e40e6 | ||
|
|
d32f20f9b3 | ||
|
|
0b552cbcb5 | ||
|
|
19fd3c8d2b | ||
|
|
1fa80d8ecd | ||
|
|
b3f90691bf | ||
|
|
4ebf0839e7 | ||
|
|
b1e93d4ed0 | ||
|
|
eb2b612c7c | ||
|
|
560ec860df | ||
|
|
e7c46c1985 | ||
|
|
00d1e39b6d | ||
|
|
843375d6ef | ||
|
|
8d33cb58fa | ||
|
|
e4c4bcbae3 | ||
|
|
993c24c8b9 | ||
|
|
5bc8d3a2f6 | ||
|
|
433d10db5e | ||
|
|
9b7b3681f6 | ||
|
|
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 | ||
|
|
d7053c35d5 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "107,695",
|
||||
"message": "159,322",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 107695,
|
||||
"last_updated": "2026-06-10T07:32:26Z",
|
||||
"total_clones": 159322,
|
||||
"last_updated": "2026-07-16T08:08:14Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -75,6 +75,43 @@
|
||||
"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,
|
||||
"2026-06-22": 1350,
|
||||
"2026-06-23": 1468,
|
||||
"2026-06-24": 1635,
|
||||
"2026-06-25": 1640,
|
||||
"2026-06-26": 1338,
|
||||
"2026-06-27": 1338,
|
||||
"2026-06-28": 1028,
|
||||
"2026-06-29": 765,
|
||||
"2026-06-30": 951,
|
||||
"2026-07-01": 1134,
|
||||
"2026-07-02": 593,
|
||||
"2026-07-03": 537,
|
||||
"2026-07-04": 411,
|
||||
"2026-07-05": 485,
|
||||
"2026-07-06": 555,
|
||||
"2026-07-07": 905,
|
||||
"2026-07-08": 1171,
|
||||
"2026-07-09": 1857,
|
||||
"2026-07-10": 1181,
|
||||
"2026-07-11": 2185,
|
||||
"2026-07-12": 1917,
|
||||
"2026-07-13": 2102,
|
||||
"2026-07-14": 2337,
|
||||
"2026-07-15": 2362
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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].*$//')
|
||||
|
||||
@@ -23,6 +23,8 @@ jobs:
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra framework-comparison --extra server
|
||||
@@ -30,6 +32,9 @@ jobs:
|
||||
- name: Ruff check
|
||||
run: uv run ruff check src/ tests/
|
||||
|
||||
- name: Ruff format check
|
||||
run: uv run ruff format --check src/ tests/
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -55,6 +60,8 @@ jobs:
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra framework-comparison --extra server
|
||||
@@ -63,8 +70,13 @@ jobs:
|
||||
run: uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
|
||||
|
||||
- name: Run tests
|
||||
# COVERAGE_CORE=sysmon uses CPython 3.12's sys.monitoring backend,
|
||||
# which is dramatically cheaper than the default C trace function.
|
||||
# -n auto fans the suite out across all runner cores via pytest-xdist.
|
||||
env:
|
||||
COVERAGE_CORE: sysmon
|
||||
run: |
|
||||
uv run pytest tests/ -v --tb=short -m "not live and not cloud and not hub" \
|
||||
uv run pytest tests/ -n auto -q --tb=short -m "not live and not cloud and not hub" \
|
||||
--cov=openjarvis \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=xml \
|
||||
@@ -107,6 +119,8 @@ jobs:
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra server
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -40,7 +40,8 @@ jobs:
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libxdo-dev
|
||||
libxdo-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -114,6 +115,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'
|
||||
@@ -125,7 +131,8 @@ jobs:
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libxdo-dev
|
||||
libxdo-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -183,7 +190,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].*$//')
|
||||
@@ -238,6 +254,10 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
TAURI_CONFIG: '{"version":"${{ steps.release-info.outputs.tauri_version }}","bundle":{"externalBin":["binaries/ollama"]}}'
|
||||
# tauri-action runs beforeBuildCommand (npm run build:tauri -> vite
|
||||
# build), which requires this at build time (#587). Strict for
|
||||
# releases: a missing/empty secret fails the build by design.
|
||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
with:
|
||||
projectPath: frontend
|
||||
tauriScript: npx tauri
|
||||
|
||||
@@ -41,6 +41,26 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra docs
|
||||
|
||||
# Inject the public Supabase anon key so the savings leaderboard works on
|
||||
# the published docs site. Missing/empty (e.g. fork PRs) leaves the
|
||||
# leaderboard gracefully disabled. The key is read from env (not inlined)
|
||||
# and JSON-encoded into a JS string literal to avoid any injection.
|
||||
- name: Inject leaderboard Supabase anon key
|
||||
env:
|
||||
OPENJARVIS_LEADERBOARD_ANON: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, pathlib
|
||||
|
||||
key = os.environ.get("OPENJARVIS_LEADERBOARD_ANON", "")
|
||||
pathlib.Path("docs/javascripts/leaderboard-config.js").write_text(
|
||||
"// Generated at docs-build time from the VITE_SUPABASE_ANON_KEY secret.\n"
|
||||
"window.OPENJARVIS_SUPABASE_ANON_KEY = " + json.dumps(key) + ";\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print("leaderboard anon key:", "set" if key else "empty (leaderboard disabled)")
|
||||
PY
|
||||
|
||||
- name: Build documentation
|
||||
run: uv run mkdocs build
|
||||
|
||||
|
||||
@@ -35,3 +35,8 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npx tsc --noEmit
|
||||
- run: npm run build
|
||||
env:
|
||||
# Optional: when the secret is unset the build still succeeds and the
|
||||
# leaderboard is disabled (see src/lib/supabase.ts). No placeholder,
|
||||
# so a keyless CI build doesn't bake in a bogus anon key.
|
||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
|
||||
@@ -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
|
||||
@@ -50,6 +55,8 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend and bundle into package
|
||||
env:
|
||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd frontend
|
||||
@@ -67,27 +74,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
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.PHONY: setup build test lint format
|
||||
|
||||
# Mirrors .github/workflows/ci.yml so `make test` matches CI locally.
|
||||
|
||||
setup:
|
||||
uv sync --extra dev --extra framework-comparison --extra server
|
||||
|
||||
build:
|
||||
uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
|
||||
|
||||
test: build
|
||||
uv run pytest tests/ -n auto -q --tb=short -m "not live and not cloud and not hub"
|
||||
|
||||
lint:
|
||||
uv run ruff check src/ tests/
|
||||
uv run ruff format --check src/ tests/
|
||||
|
||||
format:
|
||||
uv run ruff format src/ tests/
|
||||
@@ -4,7 +4,7 @@
|
||||
<p><i>Personal AI, On Personal Devices.</i></p>
|
||||
|
||||
<p>
|
||||
<a href="https://scalingintelligence.stanford.edu/blogs/openjarvis/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
|
||||
<a href="https://openjarvis.stanford.edu/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
|
||||
<a href="https://open-jarvis.github.io/OpenJarvis/"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
|
||||
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python">
|
||||
<img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License">
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
> **[Documentation](https://open-jarvis.github.io/OpenJarvis/)**
|
||||
>
|
||||
> **[Project Site](https://scalingintelligence.stanford.edu/blogs/openjarvis/)**
|
||||
> **[Project Site](https://openjarvis.stanford.edu/)**
|
||||
>
|
||||
> **[Paper](https://arxiv.org/abs/2605.17172)**
|
||||
>
|
||||
|
||||
@@ -106,6 +106,11 @@ enabled = true # Record traces for analysis
|
||||
db_path = "~/.openjarvis/traces.db"
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
# Bind to loopback by default so the API is not exposed to the local network.
|
||||
# To serve other devices on your LAN, set host = "0.0.0.0" AND set an API key
|
||||
# (OPENJARVIS_API_KEY / `jarvis auth generate-key`) — startup refuses a
|
||||
# non-loopback bind without a key. The "server" security profile also flips
|
||||
# this to 0.0.0.0 intentionally.
|
||||
host = "127.0.0.1"
|
||||
port = 8000
|
||||
agent = "native_openhands"
|
||||
|
||||
@@ -1,34 +1,83 @@
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers — reproducible builds and
|
||||
# safe rollbacks (#563).
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). `uv export --frozen`
|
||||
# reads uv.lock as-is (no re-resolution) and emits a fully pinned, hash-verified
|
||||
# requirements set; `--no-deps` then installs exactly that set. This is a
|
||||
# separate layer from the source copy so dependency installs stay cached when
|
||||
# only application code changes.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
# Copy the source and the non-src force-include paths (see pyproject
|
||||
# [tool.hatch.build.targets.wheel.force-include]) before building the project.
|
||||
COPY src/ src/
|
||||
COPY rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
# Copy built frontend into the server static directory
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
# Install the project itself without re-resolving dependencies.
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM python:3.12-slim-bookworm
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
# Run as an unprivileged user — the server needs no root privileges, so dropping
|
||||
# them limits the blast radius of a compromise (#565). The app writes only to
|
||||
# $HOME (config/cache/state), which is owned by this user.
|
||||
RUN groupadd --system --gid 10001 openjarvis && \
|
||||
useradd --system --uid 10001 --gid openjarvis \
|
||||
--create-home --home-dir /home/openjarvis openjarvis
|
||||
ENV HOME=/home/openjarvis
|
||||
USER openjarvis
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
|
||||
@@ -1,32 +1,69 @@
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers — reproducible builds and
|
||||
# safe rollbacks (#563).
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package (NVIDIA CUDA 12.4)
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 AS builder
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3 AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
|
||||
# for the rationale behind the frozen export + --no-deps install.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY src/ src/
|
||||
COPY rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip && \
|
||||
@@ -36,6 +73,14 @@ COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
# Run as an unprivileged user (#565). NVIDIA device nodes (/dev/nvidia*) are
|
||||
# world-accessible, so GPU workloads do not require root.
|
||||
RUN groupadd --system --gid 10001 openjarvis && \
|
||||
useradd --system --uid 10001 --gid openjarvis \
|
||||
--create-home --home-dir /home/openjarvis openjarvis
|
||||
ENV HOME=/home/openjarvis
|
||||
USER openjarvis
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
|
||||
@@ -1,32 +1,69 @@
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers — reproducible builds and
|
||||
# safe rollbacks (#563).
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package (AMD ROCm 7.2)
|
||||
FROM rocm/dev-ubuntu-22.04:7.2 AS builder
|
||||
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644 AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
|
||||
# for the rationale behind the frozen export + --no-deps install.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY src/ src/
|
||||
COPY rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM rocm/dev-ubuntu-22.04:7.2
|
||||
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip && \
|
||||
@@ -36,6 +73,18 @@ COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
# Run as an unprivileged user (#565). ROCm GPU access is gated by the `video` and
|
||||
# `render` groups (see group_add in docker-compose.gpu.rocm.yml), so the user is
|
||||
# added to both; root is not required.
|
||||
RUN groupadd --system --gid 10001 openjarvis && \
|
||||
useradd --system --uid 10001 --gid openjarvis \
|
||||
--create-home --home-dir /home/openjarvis openjarvis && \
|
||||
(getent group video >/dev/null || groupadd --system video) && \
|
||||
(getent group render >/dev/null || groupadd --system render) && \
|
||||
usermod -aG video,render openjarvis
|
||||
ENV HOME=/home/openjarvis
|
||||
USER openjarvis
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
|
||||
@@ -1,15 +1,69 @@
|
||||
FROM python:3.12-slim
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers (#563).
|
||||
|
||||
# Install Node.js 22
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
# Node.js is sourced from the official, digest-pinned image rather than piping a
|
||||
# remote setup script into bash (`curl ... | bash -`), which performed no
|
||||
# checksum or signature verification of the downloaded installer (#566). The
|
||||
# image digest is the integrity check, and the copy is architecture-agnostic.
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS node
|
||||
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies from the committed lockfile (#567): `uv export --frozen`
|
||||
# reads uv.lock as-is and emits a pinned, hash-verified set installed with
|
||||
# --no-deps (no re-resolution). Copied first so this layer caches independently
|
||||
# of application source.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY . .
|
||||
RUN pip install --no-cache-dir ".[server]"
|
||||
|
||||
# Install the project itself without re-resolving dependencies.
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust/target
|
||||
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
|
||||
|
||||
# libstdc++6 + ca-certificates are the only runtime requirements of the Node
|
||||
# binary copied below (the python slim image already provides libc/libgcc).
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# Transplant the Node.js runtime from the official image. Both images are Debian
|
||||
# bookworm, so the glibc/libstdc++ ABI matches.
|
||||
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
LABEL openjarvis-sandbox=true
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ services:
|
||||
capabilities: [gpu]
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
# Pinned to a fixed version + digest for reproducible deployments (#563);
|
||||
# must match the tag in docker-compose.yml.
|
||||
image: ollama/ollama:0.30.10@sha256:bfc9c6d53cc6989aa5131a6fde6b162b2802d4d337657f3253b5f69579bddeee
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
@@ -18,7 +18,9 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
# Pinned to a fixed version + digest for reproducible deployments and
|
||||
# predictable rollbacks (#563). Bump deliberately, not implicitly via :latest.
|
||||
image: ollama/ollama:0.30.10@sha256:bfc9c6d53cc6989aa5131a6fde6b162b2802d4d337657f3253b5f69579bddeee
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
|
||||
@@ -14,7 +14,27 @@ Environment=HOME=/opt/openjarvis
|
||||
# OPENJARVIS_API_KEY=<key> (generate one: `jarvis auth generate-key`)
|
||||
# It is not prefixed with "-", so the unit fails to start if the file is
|
||||
# missing — preventing an accidentally unauthenticated public server.
|
||||
# Keep secrets here (mode 0600, owned by root) rather than inline Environment=
|
||||
# lines, which leak into `systemctl show` and the journal.
|
||||
EnvironmentFile=/etc/openjarvis/env
|
||||
|
||||
# --- Sandboxing / hardening (#564) ---
|
||||
# Conservative set: tightens the unit without blocking the server's normal I/O
|
||||
# or local GPU inference. ProtectSystem=strict makes the whole filesystem
|
||||
# read-only except ReadWritePaths, so $HOME (config/cache/state under
|
||||
# /opt/openjarvis) stays writable.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/openjarvis
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -20,8 +20,8 @@ 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
|
||||
importable.
|
||||
6. Runs `uv sync --extra desktop --group desktop-native` so the FastAPI server,
|
||||
speech backend, and native extension 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 --group desktop-native
|
||||
```
|
||||
|
||||
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 --group desktop-native` so the FastAPI
|
||||
server, speech backend, and native extension 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 --group desktop-native
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Running 'uv sync --extra server' in $srcDir (this can take a few minutes)..."
|
||||
Write-Info "Running 'uv sync --extra desktop --group desktop-native' in $srcDir (this can take a few minutes)..."
|
||||
Push-Location $srcDir
|
||||
try {
|
||||
& $uvExe sync --extra server
|
||||
& $uvExe sync --extra desktop --group desktop-native
|
||||
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 --group desktop-native`.
|
||||
- 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 --group desktop-native`.
|
||||
7. Prompt to register the scheduled-task service (skip with
|
||||
`-SkipService`).
|
||||
|
||||
|
||||
+2
-2
@@ -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)**
|
||||
|
||||
@@ -215,7 +215,7 @@ OpenJarvis is built around five composable layers. Each has a clean interface an
|
||||
|
||||
OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.ai/), a research initiative studying the efficiency of on-device AI systems. Developed at [Hazy Research](https://hazyresearch.stanford.edu/) and the [Scaling Intelligence Lab](https://scalingintelligence.stanford.edu/) at [Stanford SAIL](https://ai.stanford.edu/).
|
||||
|
||||
Read the [blog post](https://scalingintelligence.stanford.edu/blogs/openjarvis/) for the full research motivation, architecture details, and experimental results.
|
||||
Read the [blog post](https://openjarvis.stanford.edu/) for the full research motivation, architecture details, and experimental results.
|
||||
|
||||
## Citation
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Public Supabase config for the savings leaderboard.
|
||||
//
|
||||
// This file is loaded *before* leaderboard.js and supplies the anon key it
|
||||
// reads from `window.OPENJARVIS_SUPABASE_ANON_KEY`. The key is injected at
|
||||
// docs-build time from the VITE_SUPABASE_ANON_KEY repo secret (see
|
||||
// .github/workflows/docs.yml). It is intentionally empty here so that local
|
||||
// `mkdocs build` and fork pull requests — which have no secret — render the
|
||||
// graceful "Leaderboard not configured yet" message instead of failing.
|
||||
//
|
||||
// The anon key is public by design: Supabase Row-Level Security protects the
|
||||
// data, so shipping it in the public docs bundle is expected.
|
||||
window.OPENJARVIS_SUPABASE_ANON_KEY = "";
|
||||
@@ -1,9 +1,9 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var SUPABASE_URL = "https://mtbtgpwzrbostweaanpr.supabase.co";
|
||||
var SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c";
|
||||
var SUPABASE_URL =
|
||||
window.OPENJARVIS_SUPABASE_URL || "https://mtbtgpwzrbostweaanpr.supabase.co";
|
||||
var SUPABASE_ANON_KEY = window.OPENJARVIS_SUPABASE_ANON_KEY || "";
|
||||
|
||||
var PAGE_SIZE = 50;
|
||||
var allRows = [];
|
||||
|
||||
+1
-1
@@ -55,5 +55,5 @@ See how the OpenJarvis community saves money, energy, and compute by running AI
|
||||
<div id="leaderboard-pagination" class="lb-pagination"></div>
|
||||
|
||||
<p style="font-size:12px;opacity:0.6;margin-top:12px">
|
||||
*Dollar savings estimated vs. Claude Opus 4.6 API pricing ($5/1M input, $25/1M output tokens). Assumes local open-source models produce roughly the same number of tokens per request as cloud models.
|
||||
*Dollar savings estimated vs. Claude Fable 5 API pricing ($10/1M input, $50/1M output tokens). Assumes local open-source models produce roughly the same number of tokens per request as cloud models.
|
||||
</p>
|
||||
|
||||
@@ -19,6 +19,71 @@ Agents are the agentic logic layer of OpenJarvis. They determine how a query is
|
||||
|
||||
---
|
||||
|
||||
## Persistent Persona: SOUL.md, MEMORY.md, USER.md
|
||||
|
||||
Every agent's system prompt is assembled at conversation start by the `SystemPromptBuilder`, which injects up to three optional Markdown files -- the **persistent persona**. They are plain text you own and edit, loaded at the start of each conversation. There is no vector database or embedding cache behind them.
|
||||
|
||||
| File | What it holds | Example line |
|
||||
|------|---------------|--------------|
|
||||
| `SOUL.md` | How the agent should behave -- tone, length, what to push back on | `Be concise. Challenge weak assumptions.` |
|
||||
| `MEMORY.md` | Facts about you, your projects, your preferences | `I deploy to Postgres, never MySQL.` |
|
||||
| `USER.md` | Who you are -- role, team, context | `Backend engineer at Acme, on the payments team.` |
|
||||
|
||||
This persona is distinct from the retrieval [memory backend](memory.md): the persona is always-on Markdown context loaded into the prompt, while the memory backend is searchable long-term storage the agent queries on demand.
|
||||
|
||||
### Where they live
|
||||
|
||||
By default the files are read from the config directory:
|
||||
|
||||
```
|
||||
~/.openjarvis/SOUL.md
|
||||
~/.openjarvis/MEMORY.md
|
||||
~/.openjarvis/USER.md
|
||||
```
|
||||
|
||||
(The config directory honors `$OPENJARVIS_HOME` / `$XDG_DATA_HOME` when set.) The paths are configurable under `[memory_files]`:
|
||||
|
||||
```toml
|
||||
[memory_files]
|
||||
soul_path = "~/.openjarvis/SOUL.md"
|
||||
memory_path = "~/.openjarvis/MEMORY.md"
|
||||
user_path = "~/.openjarvis/USER.md"
|
||||
persona_name = "" # optional named persona -- see below
|
||||
```
|
||||
|
||||
### How they're loaded
|
||||
|
||||
At the start of each conversation, `SystemPromptBuilder` reads each file as UTF-8 and adds its contents as a section of the system prompt, after the agent template and before the skill catalog:
|
||||
|
||||
- **All three are optional.** A missing or empty file is skipped, so any subset works and an install with no persona files behaves exactly as before.
|
||||
- **Edits apply to the next conversation.** The files are read once when a conversation's prompt is built, so there is no restart or re-indexing -- edit or delete a line and it takes effect the next time you start a conversation.
|
||||
- **Each section is length-capped.** Files are truncated to a per-section character budget so a large `MEMORY.md` cannot crowd out the rest of the prompt.
|
||||
|
||||
### Named personas
|
||||
|
||||
A single install can answer as different personas without changing global config. A named persona lives in its own directory:
|
||||
|
||||
```
|
||||
~/.openjarvis/personas/<name>/SOUL.md
|
||||
~/.openjarvis/personas/<name>/MEMORY.md
|
||||
~/.openjarvis/personas/<name>/USER.md
|
||||
```
|
||||
|
||||
Select one per invocation, or opt out entirely:
|
||||
|
||||
```bash
|
||||
jarvis ask --persona work "summarize my open PRs"
|
||||
jarvis ask --persona none "what is 2 + 2?" # inject no persona
|
||||
```
|
||||
|
||||
Set `persona_name` under `[memory_files]` to make a named persona the default. `persona_name = "none"` (equivalently `--persona none`) disables persona injection for that run.
|
||||
|
||||
### Editing them
|
||||
|
||||
`SOUL.md`, `MEMORY.md`, and `USER.md` are plain Markdown -- open them in any editor. `MEMORY.md` and `USER.md` can also be updated by the agent itself through the `memory_manage` and `user_profile_manage` tools when those are enabled, so the agent can record a new fact mid-conversation. These tools always target the default `MEMORY.md` and `USER.md` (under `~/.openjarvis/`), never a named persona's copies -- edit those by hand.
|
||||
|
||||
---
|
||||
|
||||
## BaseAgent ABC
|
||||
|
||||
All agents extend the abstract `BaseAgent` class.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+67
-52
@@ -11,7 +11,7 @@
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
@@ -42,7 +42,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -3720,9 +3720,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3730,9 +3730,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
|
||||
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
@@ -3746,23 +3746,23 @@
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.10.1",
|
||||
"@tauri-apps/cli-darwin-x64": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
|
||||
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3777,9 +3777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
|
||||
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3794,9 +3794,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
|
||||
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3811,13 +3811,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3828,13 +3831,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3845,13 +3851,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3862,13 +3871,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3879,13 +3891,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3896,9 +3911,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3913,9 +3928,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -3930,9 +3945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
@@ -49,7 +49,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
Generated
+1108
-1007
File diff suppressed because it is too large
Load Diff
@@ -24,9 +24,22 @@ serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Cloud API keys are stored in the OS credential store via `keyring`. keyring v3
|
||||
# enables NO backend by default — without an explicit per-platform feature it
|
||||
# silently falls back to a non-persistent in-memory mock, so keys would not
|
||||
# survive an app restart. Each desktop target opts into its native store.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2"
|
||||
dispatch = "0.2"
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Blocking Secret Service backend (no internal async runtime, so it is safe to
|
||||
# call from the tokio-driven Tauri commands). Needs libdbus-1-dev at build time.
|
||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
|
||||
+678
-113
@@ -8,8 +8,10 @@ use tokio::sync::Mutex;
|
||||
|
||||
const OLLAMA_PORT: u16 = 11434;
|
||||
const JARVIS_PORT: u16 = 8000;
|
||||
const DESKTOP_UV_SYNC_COMMAND: &str =
|
||||
"uv sync --extra desktop --extra inference-cloud --extra inference-google --group desktop-native";
|
||||
|
||||
/// Small, fast model pulled at startup so the app opens quickly.
|
||||
/// Small, fast model used when startup needs a default Ollama tag.
|
||||
const STARTUP_MODEL: &str = "qwen3.5:4b";
|
||||
|
||||
/// Tiny fallback model if even the startup model can't be pulled.
|
||||
@@ -104,7 +106,7 @@ fn default_local_model(ram_gb: f64) -> &'static str {
|
||||
struct BootPlan {
|
||||
/// Whether to start and wait for the bundled Ollama.
|
||||
launch_ollama: bool,
|
||||
/// The single Ollama model to pull (None for custom endpoints).
|
||||
/// The preferred Ollama model (None for custom endpoints).
|
||||
model_to_pull: Option<String>,
|
||||
/// Optional `(engine_key, bare_host)` override for a custom endpoint,
|
||||
/// e.g. `("lmstudio", "http://localhost:1234")`. Written into
|
||||
@@ -608,6 +610,69 @@ async fn wait_for_jarvis_health(
|
||||
}
|
||||
|
||||
async fn ollama_has_model(model: &str) -> bool {
|
||||
let models = ollama_model_names().await;
|
||||
matching_installed_model(&models, model).is_some()
|
||||
}
|
||||
|
||||
fn parse_ollama_model_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body.get("models")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|models| {
|
||||
models
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
m.get("name")
|
||||
.or_else(|| m.get("model"))
|
||||
.and_then(|n| n.as_str())
|
||||
})
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.map(|name| name.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn model_names_match(installed: &str, requested: &str) -> bool {
|
||||
installed == requested
|
||||
|| installed.strip_suffix(":latest") == Some(requested)
|
||||
|| requested.strip_suffix(":latest") == Some(installed)
|
||||
}
|
||||
|
||||
fn matching_installed_model(models: &[String], requested: &str) -> Option<String> {
|
||||
models
|
||||
.iter()
|
||||
.find(|model| model_names_match(model, requested))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn model_name_looks_embedding_only(model: &str) -> bool {
|
||||
let name = model.to_ascii_lowercase();
|
||||
["embed", "embedding", "rerank", "minilm", "bge-", "bge_", "e5-", "e5_"]
|
||||
.iter()
|
||||
.any(|marker| name.contains(marker))
|
||||
}
|
||||
|
||||
fn preferred_installed_model(models: &[String]) -> Option<String> {
|
||||
models
|
||||
.iter()
|
||||
.find(|model| !model.trim().is_empty() && !model_name_looks_embedding_only(model))
|
||||
.or_else(|| models.iter().find(|model| !model.trim().is_empty()))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn startup_installed_model(requested_model: &str, installed_models: &[String]) -> Option<String> {
|
||||
matching_installed_model(installed_models, requested_model)
|
||||
.or_else(|| preferred_installed_model(installed_models))
|
||||
}
|
||||
|
||||
fn should_persist_resolved_model(cfg: &InferenceConfig) -> bool {
|
||||
cfg.model
|
||||
.as_deref()
|
||||
.map(|model| model.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn ollama_model_names() -> Vec<String> {
|
||||
let url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
@@ -615,21 +680,10 @@ async fn ollama_has_model(model: &str) -> bool {
|
||||
.unwrap();
|
||||
if let Ok(resp) = client.get(&url).send().await {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(models) = body.get("models").and_then(|m| m.as_array()) {
|
||||
return models.iter().any(|m| {
|
||||
m.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|n| {
|
||||
n == model
|
||||
|| n.strip_suffix(":latest") == Some(model)
|
||||
|| model.strip_suffix(":latest") == Some(n)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
return parse_ollama_model_names(&body);
|
||||
}
|
||||
}
|
||||
false
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn pull_model(model: &str) -> Result<(), String> {
|
||||
@@ -679,13 +733,21 @@ fn format_uv_sync_failure(
|
||||
let code = exit_code
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let tail = uv_sync_stderr_tail(stderr, 800);
|
||||
let rust_hint = if looks_like_rust_extension_build_error(stderr) {
|
||||
format!("\n\n{}", rust_toolchain_install_hint())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
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.",
|
||||
`{}` manually for the full output.{}",
|
||||
root.display(),
|
||||
code,
|
||||
uv_sync_stderr_tail(stderr, 800),
|
||||
tail,
|
||||
DESKTOP_UV_SYNC_COMMAND,
|
||||
rust_hint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -734,6 +796,122 @@ fn format_uv_sync_spawn_error(root: &std::path::Path, uv_bin: &str, err: &str) -
|
||||
)
|
||||
}
|
||||
|
||||
fn rust_toolchain_install_hint() -> &'static str {
|
||||
"The desktop app needs the Rust toolchain to build `openjarvis_rust`. \
|
||||
Install Rust from https://rustup.rs. On Windows, also install Visual Studio \
|
||||
Build Tools with the C++ workload, then relaunch."
|
||||
}
|
||||
|
||||
fn looks_like_rust_extension_build_error(stderr: &str) -> bool {
|
||||
let lower = stderr.to_ascii_lowercase();
|
||||
[
|
||||
"openjarvis-rust",
|
||||
"openjarvis_rust",
|
||||
"maturin",
|
||||
"cargo",
|
||||
"rustc",
|
||||
"link.exe",
|
||||
"visual studio",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lower.contains(marker))
|
||||
}
|
||||
|
||||
fn format_missing_rust_toolchain() -> String {
|
||||
format!(
|
||||
"Could not find Rust's `cargo` command. {}\n\n\
|
||||
If Rust is already installed, close and relaunch the desktop app so \
|
||||
PATH includes `~/.cargo/bin`.",
|
||||
rust_toolchain_install_hint(),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_extension_import_failure(root: &std::path::Path, stderr: &str) -> String {
|
||||
let tail = uv_sync_stderr_tail(stderr, 4000);
|
||||
format!(
|
||||
"`openjarvis_rust` is still not importable after building. Last output:\n\n{}\n\n\
|
||||
Run these manually for the full build log:\n\n\
|
||||
cd {}\n\
|
||||
{}\n\
|
||||
uv run python -c \"import openjarvis_rust\"",
|
||||
if tail.is_empty() {
|
||||
"(no stderr output)"
|
||||
} else {
|
||||
&tail
|
||||
},
|
||||
root.display(),
|
||||
DESKTOP_UV_SYNC_COMMAND,
|
||||
)
|
||||
}
|
||||
|
||||
fn add_cargo_bin_to_path(cmd: &mut tokio::process::Command) {
|
||||
let mut paths: Vec<std::path::PathBuf> = std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).collect())
|
||||
.unwrap_or_default();
|
||||
paths.insert(
|
||||
0,
|
||||
std::path::PathBuf::from(home_dir())
|
||||
.join(".cargo")
|
||||
.join("bin"),
|
||||
);
|
||||
if let Ok(joined) = std::env::join_paths(paths) {
|
||||
cmd.env("PATH", joined);
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_openjarvis_rust_extension(
|
||||
root: &std::path::Path,
|
||||
uv_bin: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut cmd = tokio::process::Command::new(uv_bin);
|
||||
cmd.args(["run", "python", "-c", "import openjarvis_rust"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.current_dir(root);
|
||||
prepare_subprocess_for_appimage(&mut cmd);
|
||||
add_cargo_bin_to_path(&mut cmd);
|
||||
|
||||
match cmd.output().await {
|
||||
Ok(out) if out.status.success() => Ok(()),
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
Err(format_extension_import_failure(root, &stderr))
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"Could not verify `openjarvis_rust`: {}. Verify uv is installed at `{}`.",
|
||||
e, uv_bin
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn port_owner_hint() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
format!("netstat -ano | findstr :{}", JARVIS_PORT)
|
||||
} else {
|
||||
format!("lsof -i :{}", JARVIS_PORT)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_port_unavailable(port: u16, reason: &str) -> String {
|
||||
format!(
|
||||
"Port {} is not available: {}. Stop the process using that port or \
|
||||
change the OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
|
||||
port,
|
||||
reason,
|
||||
port_owner_hint(),
|
||||
)
|
||||
}
|
||||
|
||||
fn check_jarvis_port_available() -> Result<(), String> {
|
||||
match std::net::TcpListener::bind(("127.0.0.1", JARVIS_PORT)) {
|
||||
Ok(listener) => {
|
||||
drop(listener);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(format_port_unavailable(JARVIS_PORT, &err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend boot sequence (runs in background after app launch)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -751,7 +929,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
.into();
|
||||
}
|
||||
|
||||
// For the Ollama path, the model pull may fall back to FALLBACK_MODEL; we
|
||||
// For the Ollama path, model resolution may fall back to FALLBACK_MODEL; we
|
||||
// record what is actually available here so the serve command below uses
|
||||
// it instead of the originally-planned tag. None on the custom path.
|
||||
let mut serve_model_override: Option<String> = None;
|
||||
@@ -798,8 +976,8 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
s.detail = "Inference engine ready.".into();
|
||||
}
|
||||
|
||||
// Phase 2: Pull the single default model (see default_local_model /
|
||||
// boot_plan). We deliberately do NOT pull any others.
|
||||
// Phase 2: Resolve one model to serve. Prefer an installed model on
|
||||
// first run so startup does not depend on a download succeeding.
|
||||
let model = plan
|
||||
.model_to_pull
|
||||
.clone()
|
||||
@@ -810,41 +988,63 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
s.detail = format!("Checking for {}...", model);
|
||||
}
|
||||
|
||||
if !ollama_has_model(&model).await {
|
||||
let installed_models = ollama_model_names().await;
|
||||
let resolved_model = if let Some(installed) = startup_installed_model(&model, &installed_models) {
|
||||
installed
|
||||
} else {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}... (this may take a minute)", model);
|
||||
}
|
||||
if let Err(e) = pull_model(&model).await {
|
||||
// If the chosen model fails, try the tiny fallback
|
||||
eprintln!("Warning: failed to pull {}: {}", model, e);
|
||||
if !ollama_has_model(FALLBACK_MODEL).await {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
|
||||
}
|
||||
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!("Failed to download model: {}", e2));
|
||||
return;
|
||||
match pull_model(&model).await {
|
||||
Ok(()) => model.clone(),
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to pull {}: {}", model, e);
|
||||
|
||||
// If a local model appeared while pulling, use it instead of
|
||||
// making startup depend on another network pull.
|
||||
if let Some(installed) = preferred_installed_model(&ollama_model_names().await) {
|
||||
installed
|
||||
} else if ollama_has_model(FALLBACK_MODEL).await {
|
||||
FALLBACK_MODEL.to_string()
|
||||
} else {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
|
||||
}
|
||||
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
|
||||
if let Some(installed) =
|
||||
preferred_installed_model(&ollama_model_names().await)
|
||||
{
|
||||
installed
|
||||
} else {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!("Failed to download model: {}", e2));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
FALLBACK_MODEL.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if resolved_model != model {
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Using installed model {}.", resolved_model);
|
||||
}
|
||||
|
||||
// The pull may have fallen back to FALLBACK_MODEL; serve and persist
|
||||
// whatever is actually available now, not the originally-planned tag.
|
||||
let resolved_model = if ollama_has_model(&model).await {
|
||||
model
|
||||
} else {
|
||||
FALLBACK_MODEL.to_string()
|
||||
};
|
||||
serve_model_override = Some(resolved_model.clone());
|
||||
|
||||
// Persist the resolved model so Settings shows it and future boots reuse it.
|
||||
let mut persisted = cfg.clone();
|
||||
persisted.model = Some(resolved_model);
|
||||
let _ = write_inference_config(&persisted);
|
||||
// Persist only first-run/default resolution. If the user explicitly
|
||||
// configured a model, do not overwrite that choice with a temporary
|
||||
// fallback selected just to keep startup nonfatal.
|
||||
if should_persist_resolved_model(&cfg) {
|
||||
let mut persisted = cfg.clone();
|
||||
persisted.model = Some(resolved_model);
|
||||
let _ = write_inference_config(&persisted);
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
@@ -1097,11 +1297,6 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
// Something else (a different web server, a stale process,
|
||||
// a 4xx-returning instance) is on our port. Don't kill it —
|
||||
// give the user actionable info instead.
|
||||
let lsof_hint = if cfg!(target_os = "windows") {
|
||||
format!("netstat -ano | findstr :{}", JARVIS_PORT)
|
||||
} else {
|
||||
format!("lsof -i :{}", JARVIS_PORT)
|
||||
};
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!(
|
||||
"Port {} is already in use by another service (it answered \
|
||||
@@ -1109,7 +1304,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
|
||||
JARVIS_PORT,
|
||||
resp.status(),
|
||||
lsof_hint,
|
||||
port_owner_hint(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
@@ -1119,8 +1314,21 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = check_jarvis_port_available() {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let root = project_root.as_ref().unwrap();
|
||||
|
||||
let cargo_bin = resolve_bin("cargo");
|
||||
if !std::path::Path::new(&cargo_bin).exists() && cargo_bin == "cargo" {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format_missing_rust_toolchain());
|
||||
return;
|
||||
}
|
||||
|
||||
// Install dependencies automatically (handles fresh clones).
|
||||
//
|
||||
// Previously we ran `uv sync` with both stdout AND stderr piped to
|
||||
@@ -1143,15 +1351,19 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
sync_cmd
|
||||
.args([
|
||||
"sync",
|
||||
"--extra", "server",
|
||||
"--extra", "desktop",
|
||||
"--extra", "inference-cloud",
|
||||
"--extra", "inference-google",
|
||||
// openjarvis_rust lives in a uv dependency group (not the published
|
||||
// `desktop` extra) so pip installs from PyPI don't require it (#584).
|
||||
"--group", "desktop-native",
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.current_dir(root);
|
||||
// Avoid LD_LIBRARY_PATH leak when running inside an AppImage (#455).
|
||||
prepare_subprocess_for_appimage(&mut sync_cmd);
|
||||
add_cargo_bin_to_path(&mut sync_cmd);
|
||||
let sync_output = sync_cmd.output().await;
|
||||
match sync_output {
|
||||
Ok(out) if !out.status.success() => {
|
||||
@@ -1168,6 +1380,16 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
Ok(_) => {} // success — fall through
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = "Verifying Rust extension (openjarvis_rust)...".into();
|
||||
}
|
||||
if let Err(err) = verify_openjarvis_rust_extension(root, &uv_bin).await {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(err);
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Starting API server from {}...", root.display());
|
||||
@@ -1204,7 +1426,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
// additions aren't accidentally stripped.
|
||||
prepare_subprocess_for_appimage(&mut cmd);
|
||||
|
||||
// Inject cloud API keys from ~/.openjarvis/cloud-keys.env
|
||||
// Inject cloud API keys from secure desktop storage.
|
||||
for (key, value) in read_cloud_keys() {
|
||||
cmd.env(&key, &value);
|
||||
}
|
||||
@@ -1540,19 +1762,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 +1886,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.
|
||||
@@ -1632,17 +1942,111 @@ async fn submit_savings(
|
||||
// Cloud API key management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Path to the cloud keys file (~/.openjarvis/cloud-keys.env).
|
||||
fn cloud_keys_path() -> std::path::PathBuf {
|
||||
const SECURE_KEY_SERVICE: &str = "OpenJarvis Cloud Keys";
|
||||
const MANAGED_CLOUD_KEY_NAMES: &[&str] = &[
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
];
|
||||
|
||||
/// Legacy path used by older desktop builds. New saves never write here.
|
||||
fn legacy_cloud_keys_path() -> std::path::PathBuf {
|
||||
let home = home_dir();
|
||||
std::path::PathBuf::from(home)
|
||||
.join(".openjarvis")
|
||||
.join("cloud-keys.env")
|
||||
}
|
||||
|
||||
/// Read cloud keys from disk and return as key=value pairs.
|
||||
fn read_cloud_keys() -> Vec<(String, String)> {
|
||||
let path = cloud_keys_path();
|
||||
fn validate_cloud_key_name(key_name: &str) -> Result<(), String> {
|
||||
let valid = !key_name.is_empty()
|
||||
&& key_name.len() <= 128
|
||||
&& key_name.ends_with("_API_KEY")
|
||||
&& key_name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_');
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Invalid API key name: {}", key_name))
|
||||
}
|
||||
}
|
||||
|
||||
fn engine_api_key_name(engine: &str) -> String {
|
||||
let normalized: String = engine
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_uppercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = normalized.trim_matches('_');
|
||||
let engine_name = if trimmed.is_empty() {
|
||||
CUSTOM_FALLBACK_ENGINE.to_ascii_uppercase()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
format!("{}_API_KEY", engine_name)
|
||||
}
|
||||
|
||||
fn managed_cloud_key_names() -> Vec<String> {
|
||||
let mut names: Vec<String> = MANAGED_CLOUD_KEY_NAMES
|
||||
.iter()
|
||||
.map(|name| (*name).to_string())
|
||||
.collect();
|
||||
|
||||
let cfg = read_inference_config();
|
||||
if matches!(&cfg.kind, SourceKind::Custom) {
|
||||
let engine = cfg.engine.unwrap_or_else(|| CUSTOM_FALLBACK_ENGINE.to_string());
|
||||
let key_name = engine_api_key_name(&engine);
|
||||
if validate_cloud_key_name(&key_name).is_ok() {
|
||||
names.push(key_name);
|
||||
}
|
||||
}
|
||||
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
fn secure_store_get(key_name: &str) -> Result<Option<String>, String> {
|
||||
validate_cloud_key_name(key_name)?;
|
||||
let entry = keyring::Entry::new(SECURE_KEY_SERVICE, key_name)
|
||||
.map_err(|err| format!("Failed to open secure key storage for {}: {}", key_name, err))?;
|
||||
match entry.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(format!("Failed to read {} from secure key storage: {}", key_name, err)),
|
||||
}
|
||||
}
|
||||
|
||||
fn secure_store_set(key_name: &str, key_value: &str) -> Result<(), String> {
|
||||
validate_cloud_key_name(key_name)?;
|
||||
let entry = keyring::Entry::new(SECURE_KEY_SERVICE, key_name)
|
||||
.map_err(|err| format!("Failed to open secure key storage for {}: {}", key_name, err))?;
|
||||
if key_value.is_empty() {
|
||||
return match entry.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(format!(
|
||||
"Failed to remove {} from secure key storage: {}",
|
||||
key_name, err
|
||||
)),
|
||||
};
|
||||
}
|
||||
entry
|
||||
.set_password(key_value)
|
||||
.map_err(|err| format!("Failed to save {} in secure key storage: {}", key_name, err))
|
||||
}
|
||||
|
||||
fn read_legacy_cloud_keys() -> Vec<(String, String)> {
|
||||
let path = legacy_cloud_keys_path();
|
||||
let mut keys = Vec::new();
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
for line in contents.lines() {
|
||||
@@ -1658,47 +2062,68 @@ fn read_cloud_keys() -> Vec<(String, String)> {
|
||||
keys
|
||||
}
|
||||
|
||||
/// Save a single cloud API key to the keys file.
|
||||
#[tauri::command]
|
||||
async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), String> {
|
||||
let path = cloud_keys_path();
|
||||
// Ensure directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
fn migrate_legacy_cloud_keys() {
|
||||
let path = legacy_cloud_keys_path();
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read existing keys, update/add the one being saved
|
||||
let mut keys: Vec<(String, String)> = read_cloud_keys()
|
||||
let legacy_keys = read_legacy_cloud_keys();
|
||||
if legacy_keys.is_empty() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut migrated_all = true;
|
||||
for (key, value) in legacy_keys {
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if secure_store_set(&key, &value).is_err() {
|
||||
migrated_all = false;
|
||||
}
|
||||
}
|
||||
|
||||
if migrated_all {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read cloud keys from secure desktop storage and return key=value pairs.
|
||||
fn read_cloud_keys() -> Vec<(String, String)> {
|
||||
migrate_legacy_cloud_keys();
|
||||
managed_cloud_key_names()
|
||||
.into_iter()
|
||||
.filter(|(k, _)| k != &key_name)
|
||||
.collect();
|
||||
if !key_value.is_empty() {
|
||||
keys.push((key_name, key_value));
|
||||
}
|
||||
.filter_map(|key| match secure_store_get(&key) {
|
||||
Ok(Some(value)) if !value.is_empty() => Some((key, value)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Write back
|
||||
let content: String = keys
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
std::fs::write(&path, content + "\n").map_err(|e| format!("Failed to save key: {}", e))?;
|
||||
|
||||
// Set permissions to owner-only (chmod 600)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
// Tell the running server to hot-reload its cloud engine so the user
|
||||
// doesn't need to restart the app after entering an API key.
|
||||
async fn reload_cloud_keys(keys: Vec<(String, String)>) {
|
||||
let reload_url = format!("http://127.0.0.1:{}/v1/cloud/reload", JARVIS_PORT);
|
||||
let key_map: serde_json::Map<String, serde_json::Value> = keys
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, serde_json::Value::String(value)))
|
||||
.collect();
|
||||
let _ = reqwest::Client::new()
|
||||
.post(&reload_url)
|
||||
.json(&serde_json::json!({ "keys": key_map }))
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Save a single cloud API key to secure desktop storage.
|
||||
#[tauri::command]
|
||||
async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), String> {
|
||||
let key_value = key_value.trim().to_string();
|
||||
secure_store_set(&key_name, &key_value)?;
|
||||
|
||||
// Tell the running server to hot-reload its cloud engine so the user
|
||||
// doesn't need to restart the app after entering an API key.
|
||||
reload_cloud_keys(vec![(key_name, key_value)]).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1706,10 +2131,13 @@ async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), Strin
|
||||
/// Get which cloud providers have keys configured (without exposing values).
|
||||
#[tauri::command]
|
||||
async fn get_cloud_key_status() -> Result<serde_json::Value, String> {
|
||||
let keys = read_cloud_keys();
|
||||
let status: Vec<serde_json::Value> = keys
|
||||
.iter()
|
||||
.map(|(k, v)| serde_json::json!({ "key": k, "set": !v.is_empty() }))
|
||||
migrate_legacy_cloud_keys();
|
||||
let status: Vec<serde_json::Value> = managed_cloud_key_names()
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let set = matches!(secure_store_get(&key), Ok(Some(value)) if !value.is_empty());
|
||||
serde_json::json!({ "key": key, "set": set })
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!(status))
|
||||
}
|
||||
@@ -1721,8 +2149,8 @@ async fn get_inference_source() -> Result<InferenceConfig, String> {
|
||||
}
|
||||
|
||||
/// Persist the chosen inference source. `host` is normalized to a bare base
|
||||
/// URL. For custom endpoints, an optional API key is stored in cloud-keys.env
|
||||
/// under `<ENGINE>_API_KEY`. Applies on next app launch.
|
||||
/// URL. For custom endpoints, an optional API key is stored in secure desktop
|
||||
/// storage under `<ENGINE>_API_KEY`. Applies on next app launch.
|
||||
#[tauri::command]
|
||||
async fn set_inference_source(
|
||||
kind: String,
|
||||
@@ -1754,7 +2182,7 @@ async fn set_inference_source(
|
||||
.engine
|
||||
.clone()
|
||||
.unwrap_or_else(|| CUSTOM_FALLBACK_ENGINE.to_string());
|
||||
let key_name = format!("{}_API_KEY", engine.to_ascii_uppercase());
|
||||
let key_name = engine_api_key_name(&engine);
|
||||
// Save the key before persisting the config: if the key can't be
|
||||
// written, surface it and DON'T record a custom source whose
|
||||
// credential is missing (which would fail confusingly at runtime).
|
||||
@@ -2440,9 +2868,12 @@ pub fn run() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
boot_plan, default_local_model, format_uv_sync_failure, format_uv_sync_spawn_error,
|
||||
normalize_host, parse_inference_config, upsert_engine_host, uv_sync_stderr_tail,
|
||||
InferenceConfig, SourceKind,
|
||||
boot_plan, default_local_model, format_extension_import_failure,
|
||||
format_missing_rust_toolchain, format_port_unavailable, format_uv_sync_failure,
|
||||
format_uv_sync_spawn_error, matching_installed_model, model_names_match, normalize_host,
|
||||
parse_inference_config, parse_ollama_model_names, preferred_installed_model,
|
||||
should_persist_resolved_model, startup_installed_model, upsert_engine_host,
|
||||
uv_sync_stderr_tail, InferenceConfig, SourceKind, DESKTOP_UV_SYNC_COMMAND,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -2486,7 +2917,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(DESKTOP_UV_SYNC_COMMAND)); // actionable next step
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2509,6 +2940,49 @@ mod tests {
|
||||
assert!(msg.contains("No such file or directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_rust_toolchain_message_names_cargo_and_installer() {
|
||||
let msg = format_missing_rust_toolchain();
|
||||
assert!(msg.contains("cargo"));
|
||||
assert!(msg.contains("https://rustup.rs"));
|
||||
assert!(msg.contains("openjarvis_rust"));
|
||||
assert!(msg.contains("Visual Studio Build Tools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_sync_rust_failure_mentions_toolchain() {
|
||||
let msg = format_uv_sync_failure(
|
||||
Path::new("C:\\Users\\me\\OpenJarvis"),
|
||||
Some(1),
|
||||
"maturin failed: linker `link.exe` not found while building openjarvis-rust",
|
||||
);
|
||||
assert!(msg.contains("exit 1"));
|
||||
assert!(msg.contains("link.exe"));
|
||||
assert!(msg.contains("https://rustup.rs"));
|
||||
assert!(msg.contains("Visual Studio Build Tools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_import_failure_names_verification_command() {
|
||||
let msg = format_extension_import_failure(
|
||||
Path::new("C:\\Users\\me\\OpenJarvis"),
|
||||
"ModuleNotFoundError: No module named 'openjarvis_rust'",
|
||||
);
|
||||
assert!(msg.contains("openjarvis_rust"));
|
||||
assert!(msg.contains(DESKTOP_UV_SYNC_COMMAND));
|
||||
assert!(msg.contains("uv run python -c \"import openjarvis_rust\""));
|
||||
assert!(msg.contains("ModuleNotFoundError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_unavailable_message_names_port_and_owner_hint() {
|
||||
let msg = format_port_unavailable(8000, "address already in use");
|
||||
assert!(msg.contains("Port 8000 is not available"));
|
||||
assert!(msg.contains("address already in use"));
|
||||
assert!(msg.contains("To identify it"));
|
||||
assert!(msg.contains("8000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_local_model_picks_second_largest_that_fits() {
|
||||
// QWEN35_MODELS min_ram ladder: 4,6,8,12,24,32,96 GB
|
||||
@@ -2524,6 +2998,97 @@ mod tests {
|
||||
assert_eq!(default_local_model(1.0), super::FALLBACK_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ollama_model_names_reads_nonempty_names() {
|
||||
let body = serde_json::json!({
|
||||
"models": [
|
||||
{"name": "llama3.2:latest"},
|
||||
{"name": ""},
|
||||
{"name": "qwen3.5:4b"},
|
||||
{"model": "mistral:latest"}
|
||||
]
|
||||
});
|
||||
assert_eq!(
|
||||
parse_ollama_model_names(&body),
|
||||
vec![
|
||||
"llama3.2:latest".to_string(),
|
||||
"qwen3.5:4b".to_string(),
|
||||
"mistral:latest".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_names_match_treats_latest_as_optional() {
|
||||
assert!(model_names_match("llama3.2:latest", "llama3.2"));
|
||||
assert!(model_names_match("llama3.2", "llama3.2:latest"));
|
||||
assert!(model_names_match("qwen3.5:4b", "qwen3.5:4b"));
|
||||
assert!(!model_names_match("llama3.2:latest", "qwen3.5:4b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_model_helpers_pick_matching_or_first_model() {
|
||||
let models = vec!["llama3.2:latest".to_string(), "qwen3.5:4b".to_string()];
|
||||
assert_eq!(
|
||||
matching_installed_model(&models, "llama3.2"),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
preferred_installed_model(&models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_installed_model_skips_embedding_names_when_chat_model_exists() {
|
||||
let models = vec![
|
||||
"nomic-embed-text:latest".to_string(),
|
||||
"llama3.2:latest".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
preferred_installed_model(&models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_installed_model_uses_existing_model_for_defaults() {
|
||||
let models = vec!["llama3.2:latest".to_string()];
|
||||
assert_eq!(
|
||||
startup_installed_model("qwen3.5:4b", &models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_installed_model_uses_existing_model_when_configured_model_missing() {
|
||||
let models = vec!["llama3.2:latest".to_string()];
|
||||
assert_eq!(
|
||||
startup_installed_model("qwen3.5:4b", &models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_model_is_only_persisted_when_no_model_was_configured() {
|
||||
let default_cfg = InferenceConfig { kind: SourceKind::Ollama, ..Default::default() };
|
||||
assert!(should_persist_resolved_model(&default_cfg));
|
||||
|
||||
let empty_cfg = InferenceConfig {
|
||||
kind: SourceKind::Ollama,
|
||||
model: Some(" ".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(should_persist_resolved_model(&empty_cfg));
|
||||
|
||||
let user_cfg = InferenceConfig {
|
||||
kind: SourceKind::Ollama,
|
||||
model: Some("qwen3.5:9b".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!should_persist_resolved_model(&user_cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_defaults_to_ollama_when_file_missing_or_garbage() {
|
||||
assert!(matches!(parse_inference_config("").kind, SourceKind::Ollama));
|
||||
|
||||
@@ -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 {
|
||||
@@ -231,7 +243,11 @@ export function InputArea() {
|
||||
|
||||
try {
|
||||
if (deepResearch) {
|
||||
for await (const ev of streamResearch(content, controller.signal)) {
|
||||
for await (const ev of streamResearch(
|
||||
content,
|
||||
selectedModel,
|
||||
controller.signal,
|
||||
)) {
|
||||
if (ev.type === 'search_call') {
|
||||
const trace: ResearchSearchTrace = {
|
||||
id: generateId(),
|
||||
|
||||
@@ -29,8 +29,8 @@ interface TelemetryStats {
|
||||
}
|
||||
|
||||
const CLOUD_PRICING = [
|
||||
{ name: 'GPT-5.3', input: 2.00, output: 10.00, primary: true },
|
||||
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00, primary: false },
|
||||
{ name: 'GPT-5.6 Sol', input: 5.00, output: 30.00, primary: true },
|
||||
{ name: 'Claude Fable 5', input: 10.00, output: 50.00, primary: false },
|
||||
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00, primary: false },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Search, Cpu, X, Download, Loader2, Trash2, Check, Cloud, Key, Eye, EyeOff } from 'lucide-react';
|
||||
import { useAppStore } from '../lib/store';
|
||||
import { pullModel, deleteModel, fetchModels, preloadModel, isTauri } from '../lib/api';
|
||||
import {
|
||||
pullModel,
|
||||
deleteModel,
|
||||
fetchModels,
|
||||
preloadModel,
|
||||
isTauri,
|
||||
getCloudKeyStatus,
|
||||
saveCloudKey,
|
||||
} from '../lib/api';
|
||||
|
||||
/** Popular models that users can download from the catalogue. */
|
||||
const CATALOGUE_MODELS = [
|
||||
@@ -23,7 +31,6 @@ const CATALOGUE_MODELS = [
|
||||
interface CloudProvider {
|
||||
name: string;
|
||||
envKey: string;
|
||||
storageKey: string;
|
||||
models: Array<{ id: string; desc: string }>;
|
||||
}
|
||||
|
||||
@@ -31,7 +38,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
storageKey: 'openjarvis-openai-key',
|
||||
models: [
|
||||
{ id: 'gpt-4o', desc: 'GPT-4o — fast, multimodal' },
|
||||
{ id: 'gpt-4o-mini', desc: 'GPT-4o Mini — cheap, fast' },
|
||||
@@ -41,7 +47,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'Anthropic',
|
||||
envKey: 'ANTHROPIC_API_KEY',
|
||||
storageKey: 'openjarvis-anthropic-key',
|
||||
models: [
|
||||
{ id: 'claude-sonnet-4-6', desc: 'Claude Sonnet 4.6 — balanced' },
|
||||
{ id: 'claude-opus-4-6', desc: 'Claude Opus 4.6 — most capable' },
|
||||
@@ -51,7 +56,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'Google',
|
||||
envKey: 'GEMINI_API_KEY',
|
||||
storageKey: 'openjarvis-gemini-key',
|
||||
models: [
|
||||
{ id: 'gemini-2.5-pro', desc: 'Gemini 2.5 Pro — flagship' },
|
||||
{ id: 'gemini-2.5-flash', desc: 'Gemini 2.5 Flash — fast' },
|
||||
@@ -61,7 +65,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'OpenRouter',
|
||||
envKey: 'OPENROUTER_API_KEY',
|
||||
storageKey: 'openjarvis-openrouter-key',
|
||||
models: [
|
||||
{ id: 'openrouter/auto', desc: 'Auto — best model for the task' },
|
||||
{ id: 'openrouter/anthropic/claude-sonnet-4', desc: 'Claude Sonnet 4 via OpenRouter' },
|
||||
@@ -70,16 +73,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function getStoredKey(storageKey: string): string {
|
||||
try { return localStorage.getItem(storageKey) || ''; } catch { return ''; }
|
||||
}
|
||||
function setStoredKey(storageKey: string, value: string): void {
|
||||
try {
|
||||
if (value) localStorage.setItem(storageKey, value);
|
||||
else localStorage.removeItem(storageKey);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
type Tab = 'installed' | 'catalogue' | 'cloud';
|
||||
|
||||
export function CommandPalette() {
|
||||
@@ -92,11 +85,10 @@ export function CommandPalette() {
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>(() => {
|
||||
const keys: Record<string, string> = {};
|
||||
for (const p of CLOUD_PROVIDERS) keys[p.storageKey] = getStoredKey(p.storageKey);
|
||||
return keys;
|
||||
});
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
const [cloudKeyStatus, setCloudKeyStatus] = useState<Record<string, boolean>>({});
|
||||
const [cloudKeyError, setCloudKeyError] = useState<string | null>(null);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const models = useAppStore((s) => s.models);
|
||||
@@ -106,6 +98,20 @@ export function CommandPalette() {
|
||||
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
|
||||
|
||||
const installedIds = new Set(models.map((m) => m.id));
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refreshCloudKeyStatus = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setCloudKeyStatus({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCloudKeyStatus(await getCloudKeyStatus());
|
||||
setCloudKeyError(null);
|
||||
} catch (e: any) {
|
||||
setCloudKeyError(e?.message || 'Failed to read cloud key status');
|
||||
}
|
||||
}, [desktopKeyStorage]);
|
||||
|
||||
const filtered = tab === 'installed'
|
||||
? (query
|
||||
@@ -122,6 +128,10 @@ export function CommandPalette() {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCloudKeyStatus();
|
||||
}, [refreshCloudKeyStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIdx(0);
|
||||
}, [query, tab]);
|
||||
@@ -210,24 +220,29 @@ export function CommandPalette() {
|
||||
};
|
||||
|
||||
const handleSaveKey = async (provider: CloudProvider, value: string) => {
|
||||
setStoredKey(provider.storageKey, value);
|
||||
setApiKeys((prev) => ({ ...prev, [provider.storageKey]: value }));
|
||||
const keyValue = value.trim();
|
||||
setSavingKey(provider.envKey);
|
||||
setCloudKeyError(null);
|
||||
|
||||
// Also save to Tauri backend so the server process picks up the key
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('save_cloud_key', { keyName: provider.envKey, keyValue: value });
|
||||
} catch {}
|
||||
try {
|
||||
await saveCloudKey(provider.envKey, keyValue);
|
||||
setApiKeys((prev) => ({ ...prev, [provider.envKey]: '' }));
|
||||
await refreshCloudKeyStatus();
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(), level: 'info', category: 'model',
|
||||
message: `${provider.name} API key ${keyValue ? 'saved' : 'removed'}. Refreshing model list...`,
|
||||
});
|
||||
await refreshModels();
|
||||
} catch (e: any) {
|
||||
setCloudKeyError(e?.message || `Failed to save ${provider.name} API key`);
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(), level: 'info', category: 'model',
|
||||
message: `${provider.name} API key ${value ? 'saved' : 'removed'}. Refreshing model list…`,
|
||||
});
|
||||
|
||||
// Refresh the model list so cloud models appear immediately.
|
||||
await refreshModels();
|
||||
const handleKeyBlur = (provider: CloudProvider) => {
|
||||
const draft = apiKeys[provider.envKey] || '';
|
||||
if (draft.trim()) void handleSaveKey(provider, draft);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -323,6 +338,11 @@ export function CommandPalette() {
|
||||
<Check size={12} /> Downloaded {pullSuccess} successfully
|
||||
</div>
|
||||
)}
|
||||
{tab === 'cloud' && cloudKeyError && (
|
||||
<div className="px-4 py-2 text-xs" style={{ color: 'var(--color-error)', background: 'rgba(220,38,38,0.05)' }}>
|
||||
{cloudKeyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[400px] overflow-y-auto py-2">
|
||||
@@ -431,13 +451,17 @@ export function CommandPalette() {
|
||||
/* ── Cloud Models tab ── */
|
||||
<div className="px-4 py-2">
|
||||
<div className="text-[11px] mb-3" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
Add your API keys to use cloud models. Keys are stored locally on your device only.
|
||||
{desktopKeyStorage
|
||||
? 'Add your API keys to use cloud models. Keys are stored in secure desktop storage.'
|
||||
: 'Configure cloud provider keys in the server environment to use cloud models.'}
|
||||
</div>
|
||||
|
||||
{CLOUD_PROVIDERS.map((provider) => {
|
||||
const key = apiKeys[provider.storageKey] || '';
|
||||
const hasKey = !!key;
|
||||
const isVisible = showKeys[provider.storageKey];
|
||||
const key = apiKeys[provider.envKey] || '';
|
||||
const hasSavedKey = !!cloudKeyStatus[provider.envKey];
|
||||
const hasKey = hasSavedKey || !!key.trim();
|
||||
const isVisible = showKeys[provider.envKey];
|
||||
const isSaving = savingKey === provider.envKey;
|
||||
|
||||
return (
|
||||
<div key={provider.name} className="mb-4">
|
||||
@@ -458,26 +482,28 @@ export function CommandPalette() {
|
||||
<input
|
||||
type={isVisible ? 'text' : 'password'}
|
||||
value={key}
|
||||
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider.storageKey]: e.target.value }))}
|
||||
onBlur={() => handleSaveKey(provider, apiKeys[provider.storageKey] || '')}
|
||||
placeholder={`${provider.envKey}`}
|
||||
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider.envKey]: e.target.value }))}
|
||||
onBlur={() => handleKeyBlur(provider)}
|
||||
placeholder={hasSavedKey ? 'Saved in secure storage' : provider.envKey}
|
||||
disabled={!desktopKeyStorage || isSaving}
|
||||
className="flex-1 text-xs px-2 py-1.5 bg-transparent outline-none font-mono"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKeys((prev) => ({ ...prev, [provider.storageKey]: !prev[provider.storageKey] }))}
|
||||
onClick={() => setShowKeys((prev) => ({ ...prev, [provider.envKey]: !prev[provider.envKey] }))}
|
||||
className="px-2 cursor-pointer" style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
{isVisible ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
{hasKey && (
|
||||
{hasSavedKey && (
|
||||
<button
|
||||
onClick={() => handleSaveKey(provider, '')}
|
||||
disabled={isSaving}
|
||||
className="px-2 py-1 rounded-lg text-[10px] cursor-pointer"
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)', opacity: isSaving ? 0.5 : 1 }}
|
||||
>
|
||||
Remove
|
||||
{isSaving ? 'Saving' : 'Remove'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { DollarSign, TrendingDown, Cloud, HardDrive } from 'lucide-react';
|
||||
import { useAppStore } from '../../lib/store';
|
||||
|
||||
const CLOUD_PRICING = [
|
||||
{ name: 'GPT-5.3', input: 2.00, output: 10.00 },
|
||||
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00 },
|
||||
{ name: 'GPT-5.6 Sol', input: 5.00, output: 30.00 },
|
||||
{ name: 'Claude Fable 5', input: 10.00, output: 50.00 },
|
||||
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00 },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { LEADERBOARD_ENABLED, SUPABASE_ANON_KEY, SUPABASE_URL } from '../../lib/supabase';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -221,8 +222,8 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
};
|
||||
|
||||
const PROVIDER_COLORS: Record<string, string> = {
|
||||
'gpt-5.3': colors.green,
|
||||
'claude-opus-4.6': colors.yellow,
|
||||
'gpt-5.6-sol': colors.green,
|
||||
'claude-fable-5': colors.yellow,
|
||||
'gemini-3.1-pro': colors.accent,
|
||||
};
|
||||
|
||||
@@ -279,9 +280,6 @@ function getOrCreateAnonId(): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
const SUPABASE_URL = 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5000;
|
||||
|
||||
export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
@@ -318,15 +316,16 @@ export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchData]);
|
||||
|
||||
// Share savings to Supabase when opted in and data changes
|
||||
// Share savings to Supabase when opted in and data changes. Skipped entirely
|
||||
// when no anon key was built in (leaderboard disabled).
|
||||
useEffect(() => {
|
||||
if (!optInEnabled || !displayName || !data) return;
|
||||
if (!LEADERBOARD_ENABLED || !optInEnabled || !displayName || !data) return;
|
||||
const dollarSavings = data.per_provider.reduce((s, p) => s + p.total_cost, 0);
|
||||
const energySaved = data.per_provider.reduce((s, p) => s + (p.energy_wh || 0), 0);
|
||||
const flopsSaved = data.per_provider.reduce((s, p) => s + (p.flops || 0), 0);
|
||||
invoke('submit_savings', {
|
||||
supabaseUrl: SUPABASE_URL,
|
||||
supabaseKey: SUPABASE_KEY,
|
||||
supabaseKey: SUPABASE_ANON_KEY,
|
||||
payload: {
|
||||
anon_id: anonId,
|
||||
display_name: displayName,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Regression for #266: the frontend must send the local API key as a Bearer
|
||||
// token on /v1 + /api requests, or `jarvis serve` with a key configured 401s
|
||||
@@ -26,11 +26,14 @@ class MemoryStorage {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
+40
-7
@@ -1,12 +1,10 @@
|
||||
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
|
||||
import { SUPABASE_ANON_KEY, SUPABASE_URL } from './supabase';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supabase config — safe to embed (RLS protects writes)
|
||||
// Supabase config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
@@ -15,6 +13,31 @@ declare global {
|
||||
|
||||
export const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI_INTERNALS__;
|
||||
|
||||
export type CloudKeyStatus = Record<string, boolean>;
|
||||
|
||||
export async function getCloudKeyStatus(): Promise<CloudKeyStatus> {
|
||||
if (!isTauri()) return {};
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const rows = await invoke<Array<{ key: string; set: boolean }>>('get_cloud_key_status');
|
||||
return Object.fromEntries(rows.map((row) => [row.key, row.set]));
|
||||
} catch (e: any) {
|
||||
throw new Error(e?.message ?? e ?? 'Failed to read cloud key status');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCloudKey(keyName: string, keyValue: string): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Cloud API keys can be saved in the desktop app only.');
|
||||
}
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('save_cloud_key', { keyName, keyValue });
|
||||
} catch (e: any) {
|
||||
throw new Error(e?.message ?? e ?? 'Failed to save cloud key');
|
||||
}
|
||||
}
|
||||
|
||||
// Cached API base URL fetched from the Tauri backend at startup.
|
||||
// This avoids hardcoding the port — the Rust backend is the single
|
||||
// source of truth for JARVIS_PORT.
|
||||
@@ -317,8 +340,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 +351,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,
|
||||
});
|
||||
@@ -60,6 +60,7 @@ export async function* streamChat(
|
||||
|
||||
export async function* streamResearch(
|
||||
query: string,
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<ResearchEvent> {
|
||||
// /api/research is mounted at the server root — strip any trailing /v1
|
||||
@@ -67,8 +68,8 @@ export async function* streamResearch(
|
||||
const base = getBase().replace(/\/v1\/?$/, '');
|
||||
const response = await fetch(`${base}/api/research`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ query, ...(model ? { model } : {}) }),
|
||||
signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const SUPABASE_URL =
|
||||
import.meta.env.VITE_SUPABASE_URL || 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
|
||||
// The Supabase anon key is optional at build time. When it is unset the public
|
||||
// savings leaderboard is disabled rather than failing the build — this keeps
|
||||
// the `openjarvis` package and desktop app buildable without coupling
|
||||
// publishability to a leaderboard credential. Set VITE_SUPABASE_ANON_KEY at
|
||||
// build time (from a repo secret) to enable the leaderboard.
|
||||
export const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY ?? '';
|
||||
|
||||
export const LEADERBOARD_ENABLED = SUPABASE_ANON_KEY.length > 0;
|
||||
@@ -3740,8 +3740,8 @@ export function AgentsPage() {
|
||||
const paramsB = paramMatch ? parseFloat(paramMatch[1]) : 9;
|
||||
const flops = 2 * paramsB * 1e9 * (inTok + outTok);
|
||||
const providers = [
|
||||
{ label: 'GPT-5.3', inPer1M: 2.0, outPer1M: 10.0 },
|
||||
{ label: 'Claude Opus 4.6', inPer1M: 5.0, outPer1M: 25.0 },
|
||||
{ label: 'GPT-5.6 Sol', inPer1M: 5.0, outPer1M: 30.0 },
|
||||
{ label: 'Claude Fable 5', inPer1M: 10.0, outPer1M: 50.0 },
|
||||
{ label: 'Gemini 3.1 Pro', inPer1M: 2.0, outPer1M: 12.0 },
|
||||
];
|
||||
const energyWh = (inTok + outTok) / 1000 * 0.4;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,9 +19,21 @@ import {
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore, type ThemeMode } from '../lib/store';
|
||||
import { checkHealth, fetchSpeechHealth, getMemoryStats, getInferenceSource, setInferenceSource, type InferenceSource } from '../lib/api';
|
||||
import {
|
||||
checkHealth,
|
||||
fetchSpeechHealth,
|
||||
getMemoryStats,
|
||||
getInferenceSource,
|
||||
setInferenceSource,
|
||||
getCloudKeyStatus,
|
||||
saveCloudKey,
|
||||
isTauri,
|
||||
type InferenceSource,
|
||||
} from '../lib/api';
|
||||
import { isAutoUpdateDisabled, setAutoUpdateDisabled } from '../components/Desktop/UpdateChecker';
|
||||
|
||||
const CLOUD_KEY_STATUS_CHANGED = 'openjarvis-cloud-key-status-changed';
|
||||
|
||||
function OllamaModelList() {
|
||||
const [models, setModels] = useState<Array<{ name: string; size: number }>>([]);
|
||||
useEffect(() => {
|
||||
@@ -44,32 +56,111 @@ function OllamaModelList() {
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyInput({ storageKey, placeholder }: { storageKey: string; placeholder: string }) {
|
||||
const [value, setValue] = useState(() => {
|
||||
try { return localStorage.getItem(storageKey) || ''; } catch { return ''; }
|
||||
});
|
||||
function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: string }) {
|
||||
const [value, setValue] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const save = (v: string) => {
|
||||
setValue(v);
|
||||
try { if (v) localStorage.setItem(storageKey, v); else localStorage.removeItem(storageKey); } catch {}
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setHasKey(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getCloudKeyStatus();
|
||||
setHasKey(!!status[keyName]);
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
}
|
||||
}, [desktopKeyStorage, keyName]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
window.addEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
return () => window.removeEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
const save = async (v: string) => {
|
||||
const next = v.trim();
|
||||
if (!next) return;
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, next);
|
||||
setValue('');
|
||||
setHasKey(true);
|
||||
setSaved(true);
|
||||
window.dispatchEvent(new Event(CLOUD_KEY_STATUS_CHANGED));
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save API key');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, '');
|
||||
setValue('');
|
||||
setHasKey(false);
|
||||
setSaved(true);
|
||||
window.dispatchEvent(new Event(CLOUD_KEY_STATUS_CHANGED));
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to remove API key');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="password" value={value} onChange={e => save(e.target.value)} placeholder={placeholder}
|
||||
<input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={() => { if (value.trim()) void save(value); }}
|
||||
placeholder={hasKey ? 'Saved in secure storage' : placeholder}
|
||||
disabled={!desktopKeyStorage}
|
||||
className="w-48 px-2 py-1 rounded text-xs"
|
||||
style={{ background: 'var(--color-bg)', border: '1px solid var(--color-border)', color: 'var(--color-text)' }} />
|
||||
{hasKey && (
|
||||
<button
|
||||
onClick={() => void remove()}
|
||||
className="px-2 py-1 rounded text-[10px] cursor-pointer"
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
{saved && <span className="text-[10px]" style={{ color: 'var(--color-success)' }}>Saved</span>}
|
||||
{error && <span className="text-[10px]" style={{ color: 'var(--color-error)' }}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudProviderStatus({ label, storageKey }: { label: string; storageKey: string }) {
|
||||
function CloudProviderStatus({ label, keyName }: { label: string; keyName: string }) {
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setHasKey(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getCloudKeyStatus();
|
||||
setHasKey(!!status[keyName]);
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
}
|
||||
}, [desktopKeyStorage, keyName]);
|
||||
|
||||
useEffect(() => {
|
||||
try { setHasKey(!!localStorage.getItem(storageKey)); } catch { setHasKey(false); }
|
||||
}, [storageKey]);
|
||||
void refresh();
|
||||
window.addEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
return () => window.removeEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
<span style={{
|
||||
@@ -424,10 +515,10 @@ export function SettingsPage() {
|
||||
</div>
|
||||
<SettingRow label="Cloud providers" description="Green dot means API key is configured">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<CloudProviderStatus label="OpenAI" storageKey="openjarvis-openai-key" />
|
||||
<CloudProviderStatus label="Anthropic" storageKey="openjarvis-anthropic-key" />
|
||||
<CloudProviderStatus label="Google" storageKey="openjarvis-gemini-key" />
|
||||
<CloudProviderStatus label="OpenRouter" storageKey="openjarvis-openrouter-key" />
|
||||
<CloudProviderStatus label="OpenAI" keyName="OPENAI_API_KEY" />
|
||||
<CloudProviderStatus label="Anthropic" keyName="ANTHROPIC_API_KEY" />
|
||||
<CloudProviderStatus label="Google" keyName="GEMINI_API_KEY" />
|
||||
<CloudProviderStatus label="OpenRouter" keyName="OPENROUTER_API_KEY" />
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
@@ -435,23 +526,23 @@ export function SettingsPage() {
|
||||
{/* API Keys */}
|
||||
<Section title="API Keys">
|
||||
<SettingRow label="OpenAI" description="GPT-4, GPT-3.5, etc.">
|
||||
<ApiKeyInput storageKey="openjarvis-openai-key" placeholder="sk-..." />
|
||||
<ApiKeyInput keyName="OPENAI_API_KEY" placeholder="sk-..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="Anthropic" description="Claude models">
|
||||
<ApiKeyInput storageKey="openjarvis-anthropic-key" placeholder="sk-ant-..." />
|
||||
<ApiKeyInput keyName="ANTHROPIC_API_KEY" placeholder="sk-ant-..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="Google" description="Gemini models">
|
||||
<ApiKeyInput storageKey="openjarvis-gemini-key" placeholder="AI..." />
|
||||
<ApiKeyInput keyName="GEMINI_API_KEY" placeholder="AI..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="OpenRouter" description="Multi-provider routing">
|
||||
<ApiKeyInput storageKey="openjarvis-openrouter-key" placeholder="sk-or-..." />
|
||||
<ApiKeyInput keyName="OPENROUTER_API_KEY" placeholder="sk-or-..." />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Tools */}
|
||||
<Section title="Tools">
|
||||
<SettingRow label="Web Search" description="SerpAPI or Tavily key for web search tool">
|
||||
<ApiKeyInput storageKey="openjarvis-search-key" placeholder="API key..." />
|
||||
<SettingRow label="Web Search" description="Tavily key for web search tool">
|
||||
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
@@ -714,7 +805,7 @@ export function SettingsPage() {
|
||||
</p>
|
||||
<div className="flex gap-3 mt-3 text-xs">
|
||||
<a
|
||||
href="https://scalingintelligence.stanford.edu/blogs/openjarvis/"
|
||||
href="https://openjarvis.stanford.edu/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Vendored
+3
-1
@@ -1,7 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_SUPABASE_URL?: string;
|
||||
readonly VITE_SUPABASE_ANON_KEY?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -4,6 +4,9 @@ import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
// VITE_SUPABASE_ANON_KEY is intentionally NOT required here: a missing key
|
||||
// disables the savings leaderboard at runtime (see src/lib/supabase.ts) rather
|
||||
// than failing the build, so the package/app stays publishable without it.
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -127,6 +127,7 @@ markdown_extensions:
|
||||
- pymdownx.tilde
|
||||
|
||||
extra_javascript:
|
||||
- javascripts/leaderboard-config.js
|
||||
- javascripts/leaderboard.js
|
||||
- https://cdn.jsdelivr.net/npm/@docsearch/js@3
|
||||
- javascripts/docsearch-init.js
|
||||
@@ -193,6 +194,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
|
||||
|
||||
+47
-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
|
||||
@@ -48,6 +48,7 @@ dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-cov>=5",
|
||||
"pytest-xdist>=3",
|
||||
"respx>=0.22",
|
||||
"ruff>=0.4",
|
||||
"pre-commit>=3.0",
|
||||
@@ -84,6 +85,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 +160,34 @@ 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.uv.sources]
|
||||
openjarvis-rust = { path = "rust/crates/openjarvis-python" }
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openjarvis"]
|
||||
@@ -202,3 +238,12 @@ select = ["E", "F", "I", "W"]
|
||||
dev = [
|
||||
"maturin>=1.12.6",
|
||||
]
|
||||
# openjarvis_rust is the native PyO3 extension, built from the local Rust
|
||||
# workspace. It lives in a uv dependency group (PEP 735) — not the published
|
||||
# `desktop` extra — so `uv sync --group desktop-native` builds it from source
|
||||
# for the desktop app, while `pip install openjarvis[desktop]` from PyPI does
|
||||
# NOT try to resolve openjarvis-rust from PyPI, where it isn't published
|
||||
# (dependency groups are excluded from wheel metadata). See #584 / #615.
|
||||
desktop-native = [
|
||||
"openjarvis-rust",
|
||||
]
|
||||
|
||||
@@ -144,16 +144,21 @@ impl MemoryBackend for SQLiteMemory {
|
||||
) -> Result<Vec<RetrievalResult>, OpenJarvisError> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
// Split on any non-alphanumeric character (not just whitespace) so
|
||||
// internal punctuation — apostrophes in particular ("user's") — never
|
||||
// reaches the FTS5 MATCH string. FTS5's query grammar treats an
|
||||
// unescaped `'` as a string delimiter, so passing a raw token like
|
||||
// `user's` through silently fails to parse and yields zero rows with
|
||||
// no visible error. Splitting fully avoids needing to escape anything.
|
||||
let words: Vec<String> = query
|
||||
.split_whitespace()
|
||||
.map(|w| w.trim_matches(|c: char| "?.,!;:'\"()[]{}/ ".contains(c)).to_string())
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.map(|w| w.to_string())
|
||||
.filter(|w| !w.is_empty())
|
||||
.collect();
|
||||
let fts_query = if words.len() == 1 {
|
||||
words[0].clone()
|
||||
} else {
|
||||
words.join(" OR ")
|
||||
};
|
||||
if words.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let fts_query = words.join(" OR ");
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
@@ -320,6 +325,27 @@ mod tests {
|
||||
assert_eq!(mixed.len(), 2, "mixed-case query should find both documents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_apostrophe_in_query() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
mem.store("The user's name is Trev.", "identity", None).unwrap();
|
||||
|
||||
// A query containing an internal apostrophe must not break FTS5's
|
||||
// MATCH syntax (an unescaped `'` is a string delimiter in FTS5's
|
||||
// query grammar), which previously caused this to silently return
|
||||
// zero results instead of matching or erroring.
|
||||
let multi_word = mem.retrieve("what is the user's name", 5).unwrap();
|
||||
assert!(
|
||||
!multi_word.is_empty(),
|
||||
"query with an internal apostrophe should not silently return zero results"
|
||||
);
|
||||
|
||||
// Bare single-word possessive: exercises the (former) single-word
|
||||
// bypass path that skipped the OR-join entirely.
|
||||
let bare = mem.retrieve("user's", 5).unwrap();
|
||||
assert!(!bare.is_empty(), "single-word possessive query should still match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_scores_are_positive() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openjarvis.engine._base import looks_like_context_length_error
|
||||
|
||||
|
||||
class AgentTickError(Exception):
|
||||
"""Base class for agent tick errors."""
|
||||
@@ -64,6 +66,14 @@ def classify_error(exc: Exception) -> AgentTickError:
|
||||
|
||||
msg = str(exc).lower()
|
||||
|
||||
# A context-window overflow is deterministic: retrying the identical
|
||||
# over-length request can never succeed, so fail fast instead of burning
|
||||
# the retry budget on it.
|
||||
if getattr(exc, "is_context_length_error", False) or (
|
||||
looks_like_context_length_error(msg)
|
||||
):
|
||||
return FatalError(str(exc))
|
||||
|
||||
# Check fatal patterns first (more specific)
|
||||
if isinstance(exc, PermissionError):
|
||||
return FatalError(str(exc))
|
||||
@@ -90,6 +100,11 @@ def retry_delay(attempt: int) -> int:
|
||||
def suggest_action(error: AgentTickError) -> str:
|
||||
"""Return a human-readable suggested action for the given error."""
|
||||
msg = str(error).lower()
|
||||
if looks_like_context_length_error(msg):
|
||||
return (
|
||||
"Conversation too long for the model's context window \u2014 "
|
||||
"start a new chat or shorten the conversation"
|
||||
)
|
||||
if any(p in msg for p in ("rate limit", "rate_limit", "429", "too many requests")):
|
||||
return "Rate limited \u2014 agent will auto-retry on next tick"
|
||||
if any(p in msg for p in ("timeout", "timed out", "connection", "unavailable")):
|
||||
|
||||
@@ -102,9 +102,7 @@ class AgentExecutor:
|
||||
)
|
||||
return agent.run(input_text)
|
||||
|
||||
def execute_tick(
|
||||
self, agent_id: str, *, lock_already_held: bool = False
|
||||
) -> None:
|
||||
def execute_tick(self, agent_id: str, *, lock_already_held: bool = False) -> None:
|
||||
"""Run one tick for the given agent.
|
||||
|
||||
1. Acquire concurrency guard (start_tick)
|
||||
@@ -126,9 +124,7 @@ class AgentExecutor:
|
||||
self._manager.start_tick(agent_id)
|
||||
self._set_activity(agent_id, "Preparing tick...")
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Agent %s already running, skipping tick", agent_id
|
||||
)
|
||||
logger.warning("Agent %s already running, skipping tick", agent_id)
|
||||
return
|
||||
|
||||
agent = self._manager.get_agent(agent_id)
|
||||
@@ -412,18 +408,14 @@ class AgentExecutor:
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
state_kwargs["prompt_builder"] = SystemPromptBuilder(
|
||||
agent_template=getattr(
|
||||
cfg.agent, "default_system_prompt", ""
|
||||
)
|
||||
agent_template=getattr(cfg.agent, "default_system_prompt", "")
|
||||
or "",
|
||||
memory_files_config=cfg.memory_files,
|
||||
system_prompt_config=cfg.system_prompt,
|
||||
)
|
||||
|
||||
try:
|
||||
agent_instance = agent_cls(
|
||||
engine, model, **agent_kwargs, **state_kwargs
|
||||
)
|
||||
agent_instance = agent_cls(engine, model, **agent_kwargs, **state_kwargs)
|
||||
except TypeError:
|
||||
try:
|
||||
agent_instance = agent_cls(engine, model, **agent_kwargs)
|
||||
@@ -474,9 +466,7 @@ class AgentExecutor:
|
||||
tick_note = f"Previous tick: {first_sentence}"
|
||||
|
||||
if instruction:
|
||||
input_text = (
|
||||
f"Current date: {today}\n\nStanding instruction: {instruction}"
|
||||
)
|
||||
input_text = f"Current date: {today}\n\nStanding instruction: {instruction}"
|
||||
if tick_note:
|
||||
input_text += f"\n\n{tick_note}"
|
||||
else:
|
||||
|
||||
@@ -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 }``.
|
||||
|
||||
@@ -262,7 +303,9 @@ def _openrouter_limiter() -> _OpenRouterLimiter:
|
||||
if _OPENROUTER_LIMITER is None:
|
||||
with _OPENROUTER_LIMITER_LOCK:
|
||||
if _OPENROUTER_LIMITER is None:
|
||||
max_concurrent = int(os.environ.get("OJ_OPENROUTER_MAX_CONCURRENT", "20") or 20)
|
||||
max_concurrent = int(
|
||||
os.environ.get("OJ_OPENROUTER_MAX_CONCURRENT", "20") or 20
|
||||
)
|
||||
rpm = int(os.environ.get("OJ_OPENROUTER_RPM", "60") or 60)
|
||||
_OPENROUTER_LIMITER = _OpenRouterLimiter(max_concurrent, rpm)
|
||||
return _OPENROUTER_LIMITER
|
||||
@@ -278,8 +321,14 @@ def _serialize_block(block: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
out: Dict[str, Any] = {"type": getattr(block, "type", type(block).__name__)}
|
||||
for attr in (
|
||||
"id", "name", "input", "text", "thinking", "signature",
|
||||
"tool_use_id", "content",
|
||||
"id",
|
||||
"name",
|
||||
"input",
|
||||
"text",
|
||||
"thinking",
|
||||
"signature",
|
||||
"tool_use_id",
|
||||
"content",
|
||||
):
|
||||
if hasattr(block, attr):
|
||||
val = getattr(block, attr)
|
||||
@@ -300,14 +349,16 @@ def _serialize_openai_tool_calls(tool_calls: Any) -> List[Dict[str, Any]]:
|
||||
return out
|
||||
for tc in tool_calls:
|
||||
fn = getattr(tc, "function", None)
|
||||
out.append({
|
||||
"id": getattr(tc, "id", None),
|
||||
"type": getattr(tc, "type", "function"),
|
||||
"function": {
|
||||
"name": getattr(fn, "name", None) if fn else None,
|
||||
"arguments": getattr(fn, "arguments", None) if fn else None,
|
||||
},
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"id": getattr(tc, "id", None),
|
||||
"type": getattr(tc, "type", "function"),
|
||||
"function": {
|
||||
"name": getattr(fn, "name", None) if fn else None,
|
||||
"arguments": getattr(fn, "arguments", None) if fn else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -432,32 +483,46 @@ class LocalCloudAgent(BaseAgent):
|
||||
srv = getattr(msg.usage, "server_tool_use", None)
|
||||
n_searches = getattr(srv, "web_search_requests", 0) if srv else 0
|
||||
content_blocks = [_serialize_block(b) for b in msg.content]
|
||||
tool_use_blocks = [b for b in content_blocks if b.get("type") in (
|
||||
"tool_use", "server_tool_use",
|
||||
)]
|
||||
tool_result_blocks = [b for b in content_blocks if b.get("type") in (
|
||||
"web_search_tool_result", "tool_result",
|
||||
)]
|
||||
_record_event({
|
||||
"kind": "anthropic",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"content_blocks": content_blocks,
|
||||
"tool_calls": tool_use_blocks,
|
||||
"tool_results": tool_result_blocks,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"output_config": output_config,
|
||||
"stop_reason": getattr(msg, "stop_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
tool_use_blocks = [
|
||||
b
|
||||
for b in content_blocks
|
||||
if b.get("type")
|
||||
in (
|
||||
"tool_use",
|
||||
"server_tool_use",
|
||||
)
|
||||
]
|
||||
tool_result_blocks = [
|
||||
b
|
||||
for b in content_blocks
|
||||
if b.get("type")
|
||||
in (
|
||||
"web_search_tool_result",
|
||||
"tool_result",
|
||||
)
|
||||
]
|
||||
_record_event(
|
||||
{
|
||||
"kind": "anthropic",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"content_blocks": content_blocks,
|
||||
"tool_calls": tool_use_blocks,
|
||||
"tool_results": tool_result_blocks,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"output_config": output_config,
|
||||
"stop_reason": getattr(msg, "stop_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, msg.usage.input_tokens, msg.usage.output_tokens, n_searches
|
||||
|
||||
@staticmethod
|
||||
@@ -509,24 +574,26 @@ class LocalCloudAgent(BaseAgent):
|
||||
u = resp.usage
|
||||
p = getattr(u, "prompt_tokens", 0) if u else 0
|
||||
c = getattr(u, "completion_tokens", 0) if u else 0
|
||||
_record_event({
|
||||
"kind": "openai",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"response_format": response_format,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "openai",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"response_format": response_format,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c
|
||||
|
||||
@staticmethod
|
||||
@@ -573,12 +640,10 @@ class LocalCloudAgent(BaseAgent):
|
||||
from openai import OpenAI
|
||||
|
||||
if model.startswith("openrouter/"):
|
||||
model = model[len("openrouter/"):]
|
||||
model = model[len("openrouter/") :]
|
||||
api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
"OPENROUTER_API_KEY is not set; cannot call OpenRouter."
|
||||
)
|
||||
raise RuntimeError("OPENROUTER_API_KEY is not set; cannot call OpenRouter.")
|
||||
client = OpenAI(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=api_key,
|
||||
@@ -617,21 +682,23 @@ class LocalCloudAgent(BaseAgent):
|
||||
u = resp.usage
|
||||
p = getattr(u, "prompt_tokens", 0) if u else 0
|
||||
c = getattr(u, "completion_tokens", 0) if u else 0
|
||||
_record_event({
|
||||
"kind": "openrouter",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "openrouter",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c
|
||||
|
||||
@staticmethod
|
||||
@@ -660,7 +727,9 @@ class LocalCloudAgent(BaseAgent):
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(http_options=types.HttpOptions(timeout=int(timeout * 1000)))
|
||||
client = genai.Client(
|
||||
http_options=types.HttpOptions(timeout=int(timeout * 1000))
|
||||
)
|
||||
cfg = types.GenerateContentConfig(
|
||||
temperature=temperature,
|
||||
max_output_tokens=max_tokens,
|
||||
@@ -687,21 +756,23 @@ class LocalCloudAgent(BaseAgent):
|
||||
finish_reason = str(resp.candidates[0].finish_reason)
|
||||
except Exception:
|
||||
pass
|
||||
_record_event({
|
||||
"kind": "gemini",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"finish_reason": finish_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "gemini",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"finish_reason": finish_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c
|
||||
|
||||
@staticmethod
|
||||
@@ -756,27 +827,29 @@ class LocalCloudAgent(BaseAgent):
|
||||
u = resp.usage
|
||||
p = getattr(u, "prompt_tokens", 0) if u else 0
|
||||
c = getattr(u, "completion_tokens", 0) if u else 0
|
||||
_record_event({
|
||||
"kind": "vllm",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"endpoint": endpoint,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"enable_thinking": enable_thinking,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "vllm",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"endpoint": endpoint,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"enable_thinking": enable_thinking,
|
||||
"tools_declared": tools,
|
||||
"tool_choice": tool_choice,
|
||||
"finish_reason": getattr(choice, "finish_reason", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c
|
||||
|
||||
@staticmethod
|
||||
@@ -843,33 +916,37 @@ class LocalCloudAgent(BaseAgent):
|
||||
n_searches = getattr(srv, "web_search_requests", 0) if srv else 0
|
||||
content_blocks = [_serialize_block(b) for b in msg.content]
|
||||
tool_use_blocks = [
|
||||
b for b in content_blocks
|
||||
b
|
||||
for b in content_blocks
|
||||
if b.get("type") in ("tool_use", "server_tool_use")
|
||||
]
|
||||
tool_result_blocks = [
|
||||
b for b in content_blocks
|
||||
b
|
||||
for b in content_blocks
|
||||
if b.get("type") in ("web_search_tool_result", "tool_result")
|
||||
]
|
||||
stop_reason = getattr(msg, "stop_reason", None)
|
||||
_record_event({
|
||||
"kind": "anthropic",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system if turn == 0 else None,
|
||||
"user": user if turn == 0 else None,
|
||||
"turn": turn,
|
||||
"response": text,
|
||||
"content_blocks": content_blocks,
|
||||
"tool_calls": tool_use_blocks,
|
||||
"tool_results": tool_result_blocks,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": tools,
|
||||
"stop_reason": stop_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "anthropic",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system if turn == 0 else None,
|
||||
"user": user if turn == 0 else None,
|
||||
"turn": turn,
|
||||
"response": text,
|
||||
"content_blocks": content_blocks,
|
||||
"tool_calls": tool_use_blocks,
|
||||
"tool_results": tool_result_blocks,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": tools,
|
||||
"stop_reason": stop_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
p_total += msg.usage.input_tokens
|
||||
c_total += msg.usage.output_tokens
|
||||
n_searches_total += n_searches
|
||||
@@ -879,9 +956,7 @@ class LocalCloudAgent(BaseAgent):
|
||||
# here — break and let the caller (or future loop variant)
|
||||
# handle it. Only ``server_tool_use`` blocks (web_search)
|
||||
# are auto-continued by Anthropic itself.
|
||||
client_tool_use = any(
|
||||
b.get("type") == "tool_use" for b in content_blocks
|
||||
)
|
||||
client_tool_use = any(b.get("type") == "tool_use" for b in content_blocks)
|
||||
if client_tool_use:
|
||||
break
|
||||
if stop_reason == "end_turn" or stop_reason is None:
|
||||
@@ -890,10 +965,12 @@ class LocalCloudAgent(BaseAgent):
|
||||
# (server side) — Anthropic returned mid-thought. Append the
|
||||
# assistant turn and ask it to continue.
|
||||
messages.append({"role": "assistant", "content": msg.content})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": "Continue.",
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Continue.",
|
||||
}
|
||||
)
|
||||
return last_text, p_total, c_total, n_searches_total, turns
|
||||
|
||||
@staticmethod
|
||||
@@ -967,8 +1044,12 @@ class LocalCloudAgent(BaseAgent):
|
||||
continue
|
||||
raise
|
||||
if resp is None:
|
||||
raise last_exc if last_exc is not None else RuntimeError(
|
||||
"openai responses.create failed for all web_search tool names"
|
||||
raise (
|
||||
last_exc
|
||||
if last_exc is not None
|
||||
else RuntimeError(
|
||||
"openai responses.create failed for all web_search tool names"
|
||||
)
|
||||
)
|
||||
_bump_cloud_calls()
|
||||
latency = time.time() - t0
|
||||
@@ -992,30 +1073,35 @@ class LocalCloudAgent(BaseAgent):
|
||||
text = "".join(chunks)
|
||||
|
||||
n_searches = sum(
|
||||
1 for item in output_items
|
||||
if getattr(item, "type", None) in (
|
||||
"web_search_call", "web_search_tool_call",
|
||||
1
|
||||
for item in output_items
|
||||
if getattr(item, "type", None)
|
||||
in (
|
||||
"web_search_call",
|
||||
"web_search_tool_call",
|
||||
)
|
||||
)
|
||||
u = getattr(resp, "usage", None)
|
||||
p = int(getattr(u, "input_tokens", 0) or 0) if u else 0
|
||||
c = int(getattr(u, "output_tokens", 0) or 0) if u else 0
|
||||
_record_event({
|
||||
"kind": "openai_agent",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"output_items": _jsonable(output_items),
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": [{"type": used_tool_name}],
|
||||
"stop_reason": getattr(resp, "status", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "openai_agent",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"output_items": _jsonable(output_items),
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"n_web_searches": n_searches,
|
||||
"tools_declared": [{"type": used_tool_name}],
|
||||
"stop_reason": getattr(resp, "status", None),
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c, n_searches, 1
|
||||
|
||||
@staticmethod
|
||||
@@ -1088,23 +1174,25 @@ class LocalCloudAgent(BaseAgent):
|
||||
n_searches = len(web_search_queries)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
_record_event({
|
||||
"kind": "gemini_agent",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"n_web_searches": n_searches,
|
||||
"web_search_queries": web_search_queries,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"finish_reason": finish_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "gemini_agent",
|
||||
"role": trace_role,
|
||||
"model": model,
|
||||
"system": system,
|
||||
"user": user,
|
||||
"response": text,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"n_web_searches": n_searches,
|
||||
"web_search_queries": web_search_queries,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"finish_reason": finish_reason,
|
||||
"latency_s": latency,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return text, p, c, n_searches, 1
|
||||
|
||||
def _call_cloud(
|
||||
@@ -1228,8 +1316,13 @@ class LocalCloudAgent(BaseAgent):
|
||||
# Persist the trace before the trace state is closed (and even on
|
||||
# hard failure, so we get a record of what we did before it broke).
|
||||
self._write_trace_log(
|
||||
context, input, answer, meta if "meta" in locals() else {},
|
||||
events, soft_reason, exc_obj,
|
||||
context,
|
||||
input,
|
||||
answer,
|
||||
meta if "meta" in locals() else {},
|
||||
events,
|
||||
soft_reason,
|
||||
exc_obj,
|
||||
)
|
||||
_close_trace()
|
||||
_close_call_counts()
|
||||
@@ -1285,9 +1378,7 @@ class LocalCloudAgent(BaseAgent):
|
||||
"metadata": meta,
|
||||
"events": events,
|
||||
"soft_error": soft_reason,
|
||||
"error": (
|
||||
f"{type(exc).__name__}: {exc}" if exc is not None else None
|
||||
),
|
||||
"error": (f"{type(exc).__name__}: {exc}" if exc is not None else None),
|
||||
}
|
||||
(out_dir / f"{task_id}.json").write_text(
|
||||
json.dumps(blob, indent=2, default=str)
|
||||
@@ -1322,6 +1413,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 +1423,6 @@ __all__ = [
|
||||
"estimate_cost",
|
||||
"is_gpt5_family",
|
||||
"supports_temperature",
|
||||
"tavily_search_context",
|
||||
"web_search_cfg",
|
||||
]
|
||||
|
||||
@@ -96,6 +96,7 @@ class EnergyCollector:
|
||||
return self
|
||||
try:
|
||||
import pynvml # type: ignore[import-not-found]
|
||||
|
||||
pynvml.nvmlInit()
|
||||
total = pynvml.nvmlDeviceGetCount()
|
||||
self.gpu_indices = _resolve_gpu_indices(total)
|
||||
|
||||
@@ -174,12 +174,15 @@ def _is_retryable(exc: BaseException) -> bool:
|
||||
import openai
|
||||
except ImportError:
|
||||
return False
|
||||
if isinstance(exc, (
|
||||
openai.RateLimitError,
|
||||
openai.APITimeoutError,
|
||||
openai.APIConnectionError,
|
||||
openai.InternalServerError,
|
||||
)):
|
||||
if isinstance(
|
||||
exc,
|
||||
(
|
||||
openai.RateLimitError,
|
||||
openai.APITimeoutError,
|
||||
openai.APIConnectionError,
|
||||
openai.InternalServerError,
|
||||
),
|
||||
):
|
||||
return True
|
||||
if isinstance(exc, openai.APIStatusError):
|
||||
status = getattr(exc, "status_code", None)
|
||||
@@ -196,7 +199,7 @@ def _sleep_for(attempt: int, exc: BaseException) -> float:
|
||||
# Respect a server-provided hint, but clamp to our cap so a
|
||||
# pathological header can't stall the run for hours.
|
||||
return min(_RETRY_CAP, hinted) + random.uniform(0, 0.5)
|
||||
base = min(_RETRY_CAP, _RETRY_BASE * (2 ** attempt))
|
||||
base = min(_RETRY_CAP, _RETRY_BASE * (2**attempt))
|
||||
# Full jitter — better tail behavior than equal jitter when many
|
||||
# workers wake at the same moment.
|
||||
return random.uniform(0.0, base)
|
||||
@@ -237,16 +240,19 @@ def _wrap_create(orig: Callable[..., Any]) -> Callable[..., Any]:
|
||||
import openai
|
||||
except ImportError:
|
||||
raise
|
||||
if not isinstance(exc, (
|
||||
openai.APIConnectionError,
|
||||
openai.APITimeoutError,
|
||||
openai.InternalServerError,
|
||||
)):
|
||||
if not isinstance(
|
||||
exc,
|
||||
(
|
||||
openai.APIConnectionError,
|
||||
openai.APITimeoutError,
|
||||
openai.InternalServerError,
|
||||
),
|
||||
):
|
||||
raise
|
||||
local_last_exc = exc
|
||||
if attempt >= 2:
|
||||
break
|
||||
time.sleep(2 ** attempt)
|
||||
time.sleep(2**attempt)
|
||||
assert local_last_exc is not None
|
||||
raise local_last_exc
|
||||
|
||||
@@ -266,6 +272,7 @@ def _wrap_create(orig: Callable[..., Any]) -> Callable[..., Any]:
|
||||
# stay parseable in the runner log.
|
||||
try:
|
||||
import sys
|
||||
|
||||
print(
|
||||
f"[openai-retry] attempt {attempt + 1}/{_MAX_RETRIES} "
|
||||
f"{type(exc).__name__}: {str(exc)[:120]} — "
|
||||
@@ -333,9 +340,7 @@ def patch_openai_globally() -> None:
|
||||
from openai.resources.chat import completions as _comp_mod_async
|
||||
|
||||
cls = getattr(_comp_mod_async, "AsyncCompletions", None)
|
||||
if cls is not None and not getattr(
|
||||
cls.create, "_hybrid_patched", False
|
||||
):
|
||||
if cls is not None and not getattr(cls.create, "_hybrid_patched", False):
|
||||
# Async wrapper is structurally different — only patch
|
||||
# the bumped defaults via __init__; full retry loop on
|
||||
# async would need an async wrapper. Leave that for the
|
||||
|
||||
@@ -11,24 +11,27 @@ from __future__ import annotations
|
||||
|
||||
# USD per million tokens, (input, output). Local models = 0.
|
||||
PRICES: dict[str, tuple[float, float]] = {
|
||||
"claude-opus-4-7": (5.00, 25.0),
|
||||
"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": (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-2.5-pro": (1.25, 10.0),
|
||||
"gemini-2.5-flash": (0.30, 2.50),
|
||||
"gemini-2.5-flash-lite": (0.10, 0.40),
|
||||
"claude-opus-4-7": (5.00, 25.0),
|
||||
"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). 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),
|
||||
# OpenRouter slugs (used by toolorchestra paper-match pool).
|
||||
# Prices are OpenRouter list (USD/1M tokens), 2026-05 snapshot.
|
||||
"qwen/qwen-2.5-coder-32b-instruct": (0.08, 0.18),
|
||||
"qwen/qwen3-32b": (0.10, 0.30),
|
||||
"qwen/qwen-2.5-coder-32b-instruct": (0.08, 0.18),
|
||||
"qwen/qwen3-32b": (0.10, 0.30),
|
||||
"meta-llama/llama-3.3-70b-instruct": (0.13, 0.39),
|
||||
}
|
||||
|
||||
@@ -61,7 +64,7 @@ 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 (
|
||||
@@ -79,9 +80,7 @@ def _resolve_local_model(endpoint: str, registry_model: str) -> str:
|
||||
a model id (e.g. ``Qwen3.5-9B``) that's different from what's loaded.
|
||||
"""
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
endpoint.rstrip("/") + "/models", timeout=5
|
||||
) as r:
|
||||
with urllib.request.urlopen(endpoint.rstrip("/") + "/models", timeout=5) as r:
|
||||
data = json.loads(r.read())
|
||||
served = [m["id"] for m in data.get("data", [])]
|
||||
except Exception:
|
||||
@@ -135,7 +134,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 +150,24 @@ 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(
|
||||
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,
|
||||
(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}",
|
||||
@@ -176,7 +185,8 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
)
|
||||
local_model = _resolve_local_model(self._local_endpoint, self._local_model)
|
||||
advisor_prompt = ADVISOR_TEMPLATE.format(
|
||||
question=question, initial_response=initial_resp,
|
||||
question=question,
|
||||
initial_response=initial_resp,
|
||||
)
|
||||
advisor_text, adv_in, adv_out = self._call_vllm(
|
||||
local_model,
|
||||
@@ -196,14 +206,18 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
f"answer-format rules."
|
||||
)
|
||||
if use_ws:
|
||||
final_answer, e2_in, e2_out, n_s2, e2_turns = 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,
|
||||
(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 +230,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 +250,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 +270,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 +312,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 +322,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 +332,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(
|
||||
@@ -379,8 +425,10 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
|
||||
tokens_local = adv_in + adv_out
|
||||
tokens_cloud = (
|
||||
initial_out["tokens_in"] + initial_out["tokens_out"]
|
||||
+ final_out["tokens_in"] + final_out["tokens_out"]
|
||||
initial_out["tokens_in"]
|
||||
+ initial_out["tokens_out"]
|
||||
+ final_out["tokens_in"]
|
||||
+ final_out["tokens_out"]
|
||||
)
|
||||
cost = initial_out["cost_usd"] + final_out["cost_usd"]
|
||||
meta: Dict[str, Any] = {
|
||||
|
||||
@@ -73,6 +73,7 @@ ARCHON_SWE_RANKER_SYS = (
|
||||
|
||||
# ---------- Stubs for Archon's eager-imported heavy deps we don't need ----------
|
||||
|
||||
|
||||
def _stub_archon_imports() -> None:
|
||||
"""``utils.py`` imports groq/google/litellm/dotenv at module load. Stub
|
||||
the ones we don't use so the import chain doesn't fail when those
|
||||
@@ -97,6 +98,7 @@ def _add_archon_to_path() -> None:
|
||||
|
||||
# ---------- Anthropic patch for Opus 4.7 ----------
|
||||
|
||||
|
||||
def _patch_anthropic_for_opus() -> None:
|
||||
from anthropic.resources.messages import messages as _msgs_mod
|
||||
|
||||
@@ -129,8 +131,10 @@ def _tally() -> Dict[str, int]:
|
||||
counts = getattr(_TALLY_LOCAL, "counts", None)
|
||||
if counts is None:
|
||||
counts = {
|
||||
"cloud_prompt": 0, "cloud_completion": 0,
|
||||
"local_prompt": 0, "local_completion": 0,
|
||||
"cloud_prompt": 0,
|
||||
"cloud_completion": 0,
|
||||
"local_prompt": 0,
|
||||
"local_completion": 0,
|
||||
"n_web_searches": 0,
|
||||
}
|
||||
_TALLY_LOCAL.counts = counts
|
||||
@@ -141,8 +145,10 @@ def _tally() -> Dict[str, int]:
|
||||
|
||||
def _reset_tally() -> None:
|
||||
_TALLY_LOCAL.counts = {
|
||||
"cloud_prompt": 0, "cloud_completion": 0,
|
||||
"local_prompt": 0, "local_completion": 0,
|
||||
"cloud_prompt": 0,
|
||||
"cloud_completion": 0,
|
||||
"local_prompt": 0,
|
||||
"local_completion": 0,
|
||||
"n_web_searches": 0,
|
||||
}
|
||||
|
||||
@@ -169,6 +175,7 @@ def _make_local_generator(local_endpoint: str, local_model: str):
|
||||
|
||||
def local_gen(model, messages, max_tokens=2048, temperature=0.7, **_kw): # type: ignore[no-untyped-def]
|
||||
import time as _time
|
||||
|
||||
t0 = _time.time()
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
@@ -179,31 +186,35 @@ def _make_local_generator(local_endpoint: str, local_model: str):
|
||||
)
|
||||
_bump_local_calls()
|
||||
except Exception as e:
|
||||
_record_event({
|
||||
"kind": "archon_local_gen_error",
|
||||
"model": local_model,
|
||||
"messages": messages,
|
||||
"error": f"{type(e).__name__}: {e}",
|
||||
"ts": _time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "archon_local_gen_error",
|
||||
"model": local_model,
|
||||
"messages": messages,
|
||||
"error": f"{type(e).__name__}: {e}",
|
||||
"ts": _time.time(),
|
||||
}
|
||||
)
|
||||
return f"[local-vllm error: {e!r}]"
|
||||
u = resp.usage
|
||||
if u:
|
||||
_tally()["local_prompt"] += getattr(u, "prompt_tokens", 0) or 0
|
||||
_tally()["local_completion"] += getattr(u, "completion_tokens", 0) or 0
|
||||
text = (resp.choices[0].message.content or "").strip()
|
||||
_record_event({
|
||||
"kind": "archon_local_gen",
|
||||
"model": local_model,
|
||||
"messages": messages,
|
||||
"response": text,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "archon_local_gen",
|
||||
"model": local_model,
|
||||
"messages": messages,
|
||||
"response": text,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
}
|
||||
)
|
||||
return text
|
||||
|
||||
return local_gen
|
||||
@@ -218,10 +229,13 @@ def _wrap_archon_cloud_generators() -> None:
|
||||
|
||||
def gen_openai(model, messages, max_tokens=2048, temperature=0.7, **_kw): # type: ignore[no-untyped-def]
|
||||
import time as _time
|
||||
|
||||
client = _OAI()
|
||||
kwargs: Dict[str, Any] = dict(
|
||||
model=model, messages=messages,
|
||||
max_tokens=max_tokens, temperature=temperature,
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
# GPT-5/o1/o3 reject non-default temperature and use max_completion_tokens.
|
||||
if model.startswith(("gpt-5", "o1", "o3")):
|
||||
@@ -236,20 +250,23 @@ def _wrap_archon_cloud_generators() -> None:
|
||||
_tally()["cloud_prompt"] += getattr(u, "prompt_tokens", 0) or 0
|
||||
_tally()["cloud_completion"] += getattr(u, "completion_tokens", 0) or 0
|
||||
text = (resp.choices[0].message.content or "").strip()
|
||||
_record_event({
|
||||
"kind": "archon_cloud_openai",
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"response": text,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "archon_cloud_openai",
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"response": text,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
}
|
||||
)
|
||||
return text
|
||||
|
||||
def gen_anthropic(model, messages, max_tokens=2048, temperature=0.7, **_kw): # type: ignore[no-untyped-def]
|
||||
import time as _time
|
||||
|
||||
client = _anth.Anthropic(timeout=600.0)
|
||||
system = ""
|
||||
msgs = []
|
||||
@@ -259,7 +276,10 @@ def _wrap_archon_cloud_generators() -> None:
|
||||
else:
|
||||
msgs.append(m)
|
||||
kwargs: Dict[str, Any] = dict(
|
||||
model=model, system=system, messages=msgs, max_tokens=max_tokens,
|
||||
model=model,
|
||||
system=system,
|
||||
messages=msgs,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
if not model.startswith(NO_TEMP_PREFIXES):
|
||||
kwargs["temperature"] = temperature
|
||||
@@ -277,24 +297,27 @@ def _wrap_archon_cloud_generators() -> None:
|
||||
srv = getattr(u, "server_tool_use", None) if u else None
|
||||
n_searches = getattr(srv, "web_search_requests", 0) if srv else 0
|
||||
_tally()["n_web_searches"] += int(n_searches)
|
||||
_record_event({
|
||||
"kind": "archon_cloud_anthropic",
|
||||
"model": model,
|
||||
"system": system,
|
||||
"messages": msgs,
|
||||
"response": text.strip(),
|
||||
"tokens_in": getattr(u, "input_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "output_tokens", 0) if u else 0,
|
||||
"n_web_searches": int(n_searches),
|
||||
"tools_declared": kwargs.get("tools"),
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": "archon_cloud_anthropic",
|
||||
"model": model,
|
||||
"system": system,
|
||||
"messages": msgs,
|
||||
"response": text.strip(),
|
||||
"tokens_in": getattr(u, "input_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "output_tokens", 0) if u else 0,
|
||||
"n_web_searches": int(n_searches),
|
||||
"tools_declared": kwargs.get("tools"),
|
||||
"latency_s": _time.time() - t0,
|
||||
"ts": _time.time(),
|
||||
}
|
||||
)
|
||||
return text.strip()
|
||||
|
||||
from archon.completions.components.Generator import (
|
||||
GENERATE_MAP as _GMAP, # type: ignore[import-not-found]
|
||||
)
|
||||
|
||||
_GMAP["OpenAI_API"] = gen_openai
|
||||
_GMAP["Anthropic_API"] = gen_anthropic
|
||||
|
||||
@@ -330,7 +353,9 @@ def _patch_archon_prompts() -> None:
|
||||
orig = _p.make_fuser_prompt
|
||||
|
||||
def patched(conv, references, critiques=None, length_control=False): # type: ignore[no-untyped-def]
|
||||
base = orig(conv, references, critiques=critiques, length_control=length_control)
|
||||
base = orig(
|
||||
conv, references, critiques=critiques, length_control=length_control
|
||||
)
|
||||
return base + _FUSER_FORMAT_REMINDER
|
||||
|
||||
patched._hybrid_format_patched = True # type: ignore[attr-defined]
|
||||
@@ -339,6 +364,7 @@ def _patch_archon_prompts() -> None:
|
||||
from archon.completions.components import (
|
||||
Fuser as _F, # type: ignore[import-not-found]
|
||||
)
|
||||
|
||||
_F.make_fuser_prompt = patched
|
||||
|
||||
|
||||
@@ -354,6 +380,7 @@ def _apply_patches_once() -> None:
|
||||
_patch_anthropic_for_opus()
|
||||
# Trigger Archon imports so GENERATE_MAP exists.
|
||||
import archon.completions.components.Generator # type: ignore[import-not-found] # noqa: F401
|
||||
|
||||
_wrap_archon_cloud_generators()
|
||||
_patch_archon_prompts()
|
||||
_PATCHES_APPLIED = True
|
||||
@@ -361,49 +388,62 @@ def _apply_patches_once() -> None:
|
||||
|
||||
# ---------- Architecture presets ----------
|
||||
|
||||
|
||||
def _presets():
|
||||
return {
|
||||
"ensemble_rank_fuse": lambda K, local_model, ranker_model, fuser_model, max_tokens, temperature: [
|
||||
[{
|
||||
"type": "generator",
|
||||
"model": local_model,
|
||||
"model_type": "vllm_local",
|
||||
"top_k": 1,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": K,
|
||||
}],
|
||||
[{
|
||||
"type": "ranker",
|
||||
"model": ranker_model,
|
||||
"model_type": "Anthropic_API" if ranker_model.startswith("claude") else "OpenAI_API",
|
||||
"top_k": min(K, 5),
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
}],
|
||||
[{
|
||||
"type": "fuser",
|
||||
"model": fuser_model,
|
||||
"model_type": "Anthropic_API" if fuser_model.startswith("claude") else "OpenAI_API",
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": 1,
|
||||
}],
|
||||
[
|
||||
{
|
||||
"type": "generator",
|
||||
"model": local_model,
|
||||
"model_type": "vllm_local",
|
||||
"top_k": 1,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": K,
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "ranker",
|
||||
"model": ranker_model,
|
||||
"model_type": "Anthropic_API"
|
||||
if ranker_model.startswith("claude")
|
||||
else "OpenAI_API",
|
||||
"top_k": min(K, 5),
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"type": "fuser",
|
||||
"model": fuser_model,
|
||||
"model_type": "Anthropic_API"
|
||||
if fuser_model.startswith("claude")
|
||||
else "OpenAI_API",
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": 1,
|
||||
}
|
||||
],
|
||||
],
|
||||
# ``single_local`` honors the cfg ``max_tokens`` (passed positionally
|
||||
# like ``ensemble_rank_fuse``). Previously it hard-coded 2048, which
|
||||
# cut Qwen off mid-reasoning before it could emit the GAIA
|
||||
# ``FINAL ANSWER:`` line — the scorer then had nothing to extract.
|
||||
"single_local": lambda K, local_model, ranker_model, fuser_model, max_tokens, temperature: [
|
||||
[{
|
||||
"type": "generator",
|
||||
"model": local_model,
|
||||
"model_type": "vllm_local",
|
||||
"top_k": 1,
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": 1,
|
||||
}],
|
||||
[
|
||||
{
|
||||
"type": "generator",
|
||||
"model": local_model,
|
||||
"model_type": "vllm_local",
|
||||
"top_k": 1,
|
||||
"temperature": 0.0,
|
||||
"max_tokens": max_tokens,
|
||||
"samples": 1,
|
||||
}
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
@@ -464,7 +504,12 @@ class ArchonAgent(LocalCloudAgent):
|
||||
)
|
||||
|
||||
layers = presets[arch](
|
||||
K, self._local_model, ranker_model, fuser_model, max_tokens, temperature,
|
||||
K,
|
||||
self._local_model,
|
||||
ranker_model,
|
||||
fuser_model,
|
||||
max_tokens,
|
||||
temperature,
|
||||
)
|
||||
archon_cfg = {"name": f"hybrid-archon-{arch}", "layers": layers}
|
||||
|
||||
@@ -480,10 +525,12 @@ class ArchonAgent(LocalCloudAgent):
|
||||
archon = Archon(archon_cfg)
|
||||
|
||||
try:
|
||||
answer = archon.generate([
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": input},
|
||||
])
|
||||
answer = archon.generate(
|
||||
[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": input},
|
||||
]
|
||||
)
|
||||
except Exception:
|
||||
# Re-raise so the base ``run()`` / runner's ``_run_one_inner``
|
||||
# records this in the row's ``error`` field instead of stashing
|
||||
@@ -518,10 +565,10 @@ class ArchonAgent(LocalCloudAgent):
|
||||
"tool_calls": int(n_searches),
|
||||
"traces": {
|
||||
"architecture": arch,
|
||||
"n_samples": K,
|
||||
"n_samples": K,
|
||||
"ranker_model": ranker_model,
|
||||
"fuser_model": fuser_model,
|
||||
"local_model": self._local_model,
|
||||
"fuser_model": fuser_model,
|
||||
"local_model": self._local_model,
|
||||
"tokens_breakdown": dict(_tally()),
|
||||
"web_search_enabled": ws_enabled,
|
||||
"n_web_searches": n_searches,
|
||||
@@ -568,26 +615,30 @@ class ArchonAgent(LocalCloudAgent):
|
||||
turn_max_tokens=turn_max_tokens,
|
||||
trace_prefix=f"archon_gen{k}",
|
||||
)
|
||||
candidates.append({
|
||||
"idx": k,
|
||||
"summary": out["final_summary"],
|
||||
"patch": out["patch"],
|
||||
"framed": out["answer"],
|
||||
"tokens_in": out["tokens_in"],
|
||||
"tokens_out": out["tokens_out"],
|
||||
"turns": out["turns"],
|
||||
})
|
||||
candidates.append(
|
||||
{
|
||||
"idx": k,
|
||||
"summary": out["final_summary"],
|
||||
"patch": out["patch"],
|
||||
"framed": out["answer"],
|
||||
"tokens_in": out["tokens_in"],
|
||||
"tokens_out": out["tokens_out"],
|
||||
"turns": out["turns"],
|
||||
}
|
||||
)
|
||||
total_tokens_local += out["tokens_in"] + out["tokens_out"]
|
||||
self.record_trace_event({
|
||||
"kind": "archon_swe_candidate",
|
||||
"idx": k,
|
||||
"patch_chars": len(out["patch"]),
|
||||
"summary": out["final_summary"],
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "archon_swe_candidate",
|
||||
"idx": k,
|
||||
"patch_chars": len(out["patch"]),
|
||||
"summary": out["final_summary"],
|
||||
}
|
||||
)
|
||||
|
||||
# Ranker: cloud picks the best candidate.
|
||||
ranker_user = (
|
||||
f"Issue:\n{task.get('problem_statement','')}\n\n"
|
||||
f"Issue:\n{task.get('problem_statement', '')}\n\n"
|
||||
f"K = {K} candidate patches:\n\n"
|
||||
+ "\n\n".join(
|
||||
f"=== Candidate {c['idx']} ===\nSummary: {c['summary']}\n"
|
||||
@@ -615,13 +666,15 @@ class ArchonAgent(LocalCloudAgent):
|
||||
chosen_idx = 0
|
||||
chosen = candidates[chosen_idx]
|
||||
|
||||
self.record_trace_event({
|
||||
"kind": "archon_swe_rank",
|
||||
"chosen_idx": chosen_idx,
|
||||
"ranker_raw": ranker_text,
|
||||
"tokens_in": r_in,
|
||||
"tokens_out": r_out,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "archon_swe_rank",
|
||||
"chosen_idx": chosen_idx,
|
||||
"ranker_raw": ranker_text,
|
||||
"tokens_in": r_in,
|
||||
"tokens_out": r_out,
|
||||
}
|
||||
)
|
||||
|
||||
meta = {
|
||||
"tokens_local": total_tokens_local,
|
||||
@@ -635,8 +688,12 @@ class ArchonAgent(LocalCloudAgent):
|
||||
"swe_mode": True,
|
||||
"K": K,
|
||||
"candidates": [
|
||||
{"idx": c["idx"], "summary": c["summary"],
|
||||
"patch_chars": len(c["patch"]), "turns": c["turns"]}
|
||||
{
|
||||
"idx": c["idx"],
|
||||
"summary": c["summary"],
|
||||
"patch_chars": len(c["patch"]),
|
||||
"turns": c["turns"],
|
||||
}
|
||||
for c in candidates
|
||||
],
|
||||
"chosen_idx": chosen_idx,
|
||||
|
||||
@@ -82,7 +82,11 @@ class BaselineCloudAgent(LocalCloudAgent):
|
||||
max_turns=int(cfg.get("swe_max_turns", 30)),
|
||||
bash_timeout=int(cfg.get("swe_bash_timeout_s", 120)),
|
||||
output_cap=int(cfg.get("swe_output_cap", 10_000)),
|
||||
turn_max_tokens=int(cfg.get("cloud_max_tokens", default_max_output_tokens(self._cloud_model))),
|
||||
turn_max_tokens=int(
|
||||
cfg.get(
|
||||
"cloud_max_tokens", default_max_output_tokens(self._cloud_model)
|
||||
)
|
||||
),
|
||||
trace_prefix="baseline_cloud",
|
||||
)
|
||||
meta = {
|
||||
@@ -112,7 +116,11 @@ class BaselineCloudAgent(LocalCloudAgent):
|
||||
text, p_tok, c_tok, n_searches, turns = self._call_anthropic_agent(
|
||||
self._cloud_model,
|
||||
user=input,
|
||||
max_tokens=int(cfg.get("cloud_max_tokens", default_max_output_tokens(self._cloud_model))),
|
||||
max_tokens=int(
|
||||
cfg.get(
|
||||
"cloud_max_tokens", default_max_output_tokens(self._cloud_model)
|
||||
)
|
||||
),
|
||||
temperature=0.0,
|
||||
tools=[build_web_search_tool(ws_max_uses)],
|
||||
max_turns=gaia_max_turns,
|
||||
@@ -145,17 +153,23 @@ class BaselineCloudAgent(LocalCloudAgent):
|
||||
# wired here. Skip cleanly rather than fake one. Cells that
|
||||
# want web_search must run on Anthropic until those backends
|
||||
# are wired.
|
||||
self.record_trace_event({
|
||||
"kind": "web_search_skipped",
|
||||
"reason": "non_anthropic_endpoint",
|
||||
"endpoint": self._cloud_endpoint,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "web_search_skipped",
|
||||
"reason": "non_anthropic_endpoint",
|
||||
"endpoint": self._cloud_endpoint,
|
||||
}
|
||||
)
|
||||
|
||||
# One-shot direct cloud call. GAIA only — SWE goes through the
|
||||
# mini-SWE-agent loop above (now supports anthropic/openai/gemini).
|
||||
text, p_tok, c_tok = self._call_cloud(
|
||||
user=input,
|
||||
max_tokens=int(cfg.get("cloud_max_tokens", default_max_output_tokens(self._cloud_model))),
|
||||
max_tokens=int(
|
||||
cfg.get(
|
||||
"cloud_max_tokens", default_max_output_tokens(self._cloud_model)
|
||||
)
|
||||
),
|
||||
temperature=0.0,
|
||||
)
|
||||
meta = {
|
||||
|
||||
@@ -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 (
|
||||
@@ -88,19 +89,20 @@ CONDUCTOR_STRICTER = (
|
||||
"Your previous response was not valid JSON or was missing required fields. "
|
||||
"Reply with ONLY a single JSON object — no prose, no code fences, no commentary "
|
||||
"— containing exactly the three keys model_id (list[int]), subtasks (list[str]), "
|
||||
"and access_list (list[list[int] or \"all\"]) of equal length, at most 5 entries, "
|
||||
'and access_list (list[list[int] or "all"]) of equal length, at most 5 entries, '
|
||||
"and access_list[0] must be [] (an empty list)."
|
||||
)
|
||||
|
||||
|
||||
# ---------- Plan parsing ----------
|
||||
|
||||
|
||||
def _strip_fences(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
first_nl = s.find("\n")
|
||||
if first_nl != -1:
|
||||
s = s[first_nl + 1:]
|
||||
s = s[first_nl + 1 :]
|
||||
if s.endswith("```"):
|
||||
s = s[:-3]
|
||||
s = s.strip()
|
||||
@@ -118,9 +120,7 @@ def _try_literal(s: str):
|
||||
"""Fallback for the paper's literal Python-list output style."""
|
||||
out = {}
|
||||
for key in ("model_id", "subtasks", "access_list"):
|
||||
m = re.search(
|
||||
rf"{key}\s*=\s*(\[[^\]]*\](?:\s*\+\s*\[[^\]]*\])*)", s, re.DOTALL
|
||||
)
|
||||
m = re.search(rf"{key}\s*=\s*(\[[^\]]*\](?:\s*\+\s*\[[^\]]*\])*)", s, re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
@@ -153,7 +153,7 @@ def _validate_plan(plan: Any, n_workers: int) -> Optional[str]:
|
||||
if a == "all":
|
||||
continue
|
||||
if not isinstance(a, list):
|
||||
return f"access_list[{i}] must be list or \"all\""
|
||||
return f'access_list[{i}] must be list or "all"'
|
||||
for j in a:
|
||||
if not isinstance(j, int) or not (0 <= j < i):
|
||||
return f"access_list[{i}] has bad ref {j!r}"
|
||||
@@ -173,17 +173,18 @@ def _parse_plan(text: str, n_workers: int):
|
||||
|
||||
# ---------- Worker pool ----------
|
||||
|
||||
|
||||
def _vllm_alive(base_url: str) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
base_url.rstrip("/") + "/models", timeout=3
|
||||
) as r:
|
||||
with urllib.request.urlopen(base_url.rstrip("/") + "/models", timeout=3) as r:
|
||||
return r.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _default_pool(local_model: Optional[str], local_endpoint: Optional[str]) -> List[Dict[str, Any]]:
|
||||
def _default_pool(
|
||||
local_model: Optional[str], local_endpoint: Optional[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Default worker pool — faithful to the Sakana Conductor paper (arXiv 2512.04388).
|
||||
|
||||
The paper composes a heterogeneous 7-worker pool spanning three frontier
|
||||
@@ -205,98 +206,112 @@ def _default_pool(local_model: Optional[str], local_endpoint: Optional[str]) ->
|
||||
del local_model, local_endpoint # paper default carries no local worker
|
||||
pool: List[Dict[str, Any]] = []
|
||||
if not os.environ.get("OJ_CONDUCTOR_DISABLE_GEMINI"):
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "gemini-pro",
|
||||
"endpoint": "gemini",
|
||||
"model": "gemini-2.5-pro",
|
||||
"description": (
|
||||
"Google Gemini 2.5 Pro. Frontier multimodal reasoner with a "
|
||||
"very large context window. Strong at long-document synthesis, "
|
||||
"multi-hop factual reasoning, and tasks that benefit from "
|
||||
"wide retrieval. Slower and pricier than mid-tier workers."
|
||||
),
|
||||
})
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "gemini-pro",
|
||||
"endpoint": "gemini",
|
||||
"model": "gemini-2.5-pro",
|
||||
"description": (
|
||||
"Google Gemini 2.5 Pro. Frontier multimodal reasoner with a "
|
||||
"very large context window. Strong at long-document synthesis, "
|
||||
"multi-hop factual reasoning, and tasks that benefit from "
|
||||
"wide retrieval. Slower and pricier than mid-tier workers."
|
||||
),
|
||||
}
|
||||
)
|
||||
if not os.environ.get("OJ_CONDUCTOR_DISABLE_ANTHROPIC"):
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "claude-sonnet-4",
|
||||
"endpoint": "anthropic",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"description": (
|
||||
"Anthropic Claude Sonnet 4. Strong general-purpose reasoner "
|
||||
"with careful instruction following and reliable formatting. "
|
||||
"Good default for code, structured writing, and decisive "
|
||||
"steps where accuracy matters more than raw throughput."
|
||||
),
|
||||
})
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "claude-sonnet-4",
|
||||
"endpoint": "anthropic",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"description": (
|
||||
"Anthropic Claude Sonnet 4. Strong general-purpose reasoner "
|
||||
"with careful instruction following and reliable formatting. "
|
||||
"Good default for code, structured writing, and decisive "
|
||||
"steps where accuracy matters more than raw throughput."
|
||||
),
|
||||
}
|
||||
)
|
||||
if not os.environ.get("OJ_CONDUCTOR_DISABLE_OPENAI"):
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "gpt-5",
|
||||
"endpoint": "openai",
|
||||
"model": "gpt-5",
|
||||
"description": (
|
||||
"OpenAI GPT-5. Frontier-tier broad-knowledge model. Best for "
|
||||
"open-domain factual recall, creative generation, and "
|
||||
"ambiguous questions where coverage matters. Expensive; use "
|
||||
"for steps where breadth of world knowledge is the bottleneck."
|
||||
),
|
||||
})
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "gpt-5",
|
||||
"endpoint": "openai",
|
||||
"model": "gpt-5",
|
||||
"description": (
|
||||
"OpenAI GPT-5. Frontier-tier broad-knowledge model. Best for "
|
||||
"open-domain factual recall, creative generation, and "
|
||||
"ambiguous questions where coverage matters. Expensive; use "
|
||||
"for steps where breadth of world knowledge is the bottleneck."
|
||||
),
|
||||
}
|
||||
)
|
||||
if not os.environ.get("OJ_CONDUCTOR_DISABLE_OPENROUTER"):
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "deepseek-r1-distill-qwen-32b",
|
||||
"endpoint": "openrouter",
|
||||
"model": "deepseek/deepseek-r1-distill-qwen-32b",
|
||||
"description": (
|
||||
"DeepSeek R1 distilled into Qwen-32B (open weights via "
|
||||
"OpenRouter). Specialized for chain-of-thought math, logic, "
|
||||
"and competitive-programming-style problems. Verbose; "
|
||||
"produces extensive reasoning traces before the final answer."
|
||||
),
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "gemma3-27b-it",
|
||||
"endpoint": "openrouter",
|
||||
"model": "google/gemma-3-27b-it",
|
||||
"description": (
|
||||
"Google Gemma 3 27B Instruct (open weights via OpenRouter). "
|
||||
"Mid-size instruction-tuned model. Cheap and fast; solid at "
|
||||
"concise summarization, extraction, and short-form Q&A on "
|
||||
"given context. Weaker than the frontier workers on multi-step "
|
||||
"reasoning."
|
||||
),
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "qwen3-32b",
|
||||
"endpoint": "openrouter",
|
||||
"model": "qwen/qwen3-32b",
|
||||
"description": (
|
||||
"Qwen3-32B in non-thinking mode (open weights via OpenRouter). "
|
||||
"Fast general-purpose dialogue and instruction following. "
|
||||
"Use when the step is straightforward generation, "
|
||||
"summarization, or formatting — does NOT spend tokens on "
|
||||
"internal reasoning."
|
||||
),
|
||||
})
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "qwen3-32b-thinking",
|
||||
"endpoint": "openrouter",
|
||||
"model": "qwen/qwen3-32b",
|
||||
"extra_body": {"reasoning": {"effort": "medium"}},
|
||||
"description": (
|
||||
"Qwen3-32B with reasoning enabled (open weights via "
|
||||
"OpenRouter). Same backbone as 'qwen3-32b' but spends tokens "
|
||||
"on an internal chain of thought before answering. Stronger "
|
||||
"on math, code, and multi-step logic; slower and consumes "
|
||||
"more completion tokens. Prefer this for hard reasoning "
|
||||
"steps; prefer the non-thinking variant for plain dialogue."
|
||||
),
|
||||
})
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "deepseek-r1-distill-qwen-32b",
|
||||
"endpoint": "openrouter",
|
||||
"model": "deepseek/deepseek-r1-distill-qwen-32b",
|
||||
"description": (
|
||||
"DeepSeek R1 distilled into Qwen-32B (open weights via "
|
||||
"OpenRouter). Specialized for chain-of-thought math, logic, "
|
||||
"and competitive-programming-style problems. Verbose; "
|
||||
"produces extensive reasoning traces before the final answer."
|
||||
),
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "gemma3-27b-it",
|
||||
"endpoint": "openrouter",
|
||||
"model": "google/gemma-3-27b-it",
|
||||
"description": (
|
||||
"Google Gemma 3 27B Instruct (open weights via OpenRouter). "
|
||||
"Mid-size instruction-tuned model. Cheap and fast; solid at "
|
||||
"concise summarization, extraction, and short-form Q&A on "
|
||||
"given context. Weaker than the frontier workers on multi-step "
|
||||
"reasoning."
|
||||
),
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "qwen3-32b",
|
||||
"endpoint": "openrouter",
|
||||
"model": "qwen/qwen3-32b",
|
||||
"description": (
|
||||
"Qwen3-32B in non-thinking mode (open weights via OpenRouter). "
|
||||
"Fast general-purpose dialogue and instruction following. "
|
||||
"Use when the step is straightforward generation, "
|
||||
"summarization, or formatting — does NOT spend tokens on "
|
||||
"internal reasoning."
|
||||
),
|
||||
}
|
||||
)
|
||||
pool.append(
|
||||
{
|
||||
"id": len(pool),
|
||||
"name": "qwen3-32b-thinking",
|
||||
"endpoint": "openrouter",
|
||||
"model": "qwen/qwen3-32b",
|
||||
"extra_body": {"reasoning": {"effort": "medium"}},
|
||||
"description": (
|
||||
"Qwen3-32B with reasoning enabled (open weights via "
|
||||
"OpenRouter). Same backbone as 'qwen3-32b' but spends tokens "
|
||||
"on an internal chain of thought before answering. Stronger "
|
||||
"on math, code, and multi-step logic; slower and consumes "
|
||||
"more completion tokens. Prefer this for hard reasoning "
|
||||
"steps; prefer the non-thinking variant for plain dialogue."
|
||||
),
|
||||
}
|
||||
)
|
||||
# Reassign ids contiguously in case env-gates skipped some entries.
|
||||
for new_id, entry in enumerate(pool):
|
||||
entry["id"] = new_id
|
||||
@@ -362,16 +377,17 @@ def _resolve_worker_pool(
|
||||
f"Invalid worker_pool entry [{wid_repr}]: 'id' must be an int"
|
||||
)
|
||||
if wid in seen_ids:
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: duplicate id"
|
||||
)
|
||||
raise ValueError(f"Invalid worker_pool entry [{wid}]: duplicate id")
|
||||
seen_ids.add(wid)
|
||||
if not entry.get("name") or not isinstance(entry["name"], str):
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: 'name' must be a non-empty string"
|
||||
)
|
||||
endpoint = entry.get("endpoint") or entry.get("type")
|
||||
if not isinstance(endpoint, str) or endpoint.lower() not in _CONDUCTOR_VALID_ENDPOINTS:
|
||||
if (
|
||||
not isinstance(endpoint, str)
|
||||
or endpoint.lower() not in _CONDUCTOR_VALID_ENDPOINTS
|
||||
):
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: 'endpoint' must be one of "
|
||||
f"{_CONDUCTOR_VALID_ENDPOINTS} (got {endpoint!r})"
|
||||
@@ -456,12 +472,18 @@ 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()
|
||||
in _SEARCH_CAPABLE_WORKER_ENDPOINTS
|
||||
w["id"]
|
||||
for w in workers
|
||||
if (w.get("endpoint") or "openai").lower() in _SEARCH_CAPABLE_WORKER_ENDPOINTS
|
||||
]
|
||||
|
||||
|
||||
@@ -470,6 +492,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 +508,18 @@ 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 +579,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 +594,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 +620,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 +629,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 +652,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 +665,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 +677,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}")
|
||||
|
||||
|
||||
@@ -659,12 +704,18 @@ def _swe_worker_step(
|
||||
ep = (worker.get("endpoint") or "openai").lower()
|
||||
if ep == "vllm":
|
||||
backbone, model, endpoint, is_local = (
|
||||
"local", worker["model"], worker.get("base_url"), True,
|
||||
"local",
|
||||
worker["model"],
|
||||
worker.get("base_url"),
|
||||
True,
|
||||
)
|
||||
cloud_endpoint = "anthropic" # unused on the local path
|
||||
elif ep == "anthropic":
|
||||
backbone, model, endpoint, is_local = (
|
||||
"cloud", worker["model"], None, False,
|
||||
"cloud",
|
||||
worker["model"],
|
||||
None,
|
||||
False,
|
||||
)
|
||||
cloud_endpoint = "anthropic"
|
||||
else:
|
||||
@@ -672,7 +723,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,
|
||||
@@ -690,7 +741,11 @@ def _swe_worker_step(
|
||||
)
|
||||
return (
|
||||
out["final_summary"] or out["answer"],
|
||||
out["tokens_in"], out["tokens_out"], is_local, 0, int(out["turns"]),
|
||||
out["tokens_in"],
|
||||
out["tokens_out"],
|
||||
is_local,
|
||||
0,
|
||||
int(out["turns"]),
|
||||
)
|
||||
|
||||
|
||||
@@ -753,13 +808,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,
|
||||
@@ -787,21 +846,22 @@ class ConductorAgent(LocalCloudAgent):
|
||||
if plan is None:
|
||||
fallback_used = True
|
||||
plan = {
|
||||
"model_id": [len(workers) - 1],
|
||||
"subtasks": [question],
|
||||
"model_id": [len(workers) - 1],
|
||||
"subtasks": [question],
|
||||
"access_list": [[]],
|
||||
}
|
||||
|
||||
self.record_trace_event({
|
||||
"kind": "conductor_plan",
|
||||
"plan": plan,
|
||||
"fallback_used": fallback_used,
|
||||
"parse_attempts": parse_attempts,
|
||||
"workers": [
|
||||
{k: v for k, v in w.items() if k != "api_key"}
|
||||
for w in workers
|
||||
],
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "conductor_plan",
|
||||
"plan": plan,
|
||||
"fallback_used": fallback_used,
|
||||
"parse_attempts": parse_attempts,
|
||||
"workers": [
|
||||
{k: v for k, v in w.items() if k != "api_key"} for w in workers
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# 2. Execute
|
||||
# If we're on a SWE-bench task AND cfg["swe_use_agent_loop"] is on,
|
||||
@@ -833,16 +893,17 @@ 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
|
||||
w
|
||||
for w in workers
|
||||
if (w.get("endpoint") or "openai").lower()
|
||||
in _SEARCH_CAPABLE_WORKER_ENDPOINTS
|
||||
]
|
||||
if not search_workers:
|
||||
endpoints = sorted({
|
||||
(w.get("endpoint") or "openai").lower() for w in workers
|
||||
})
|
||||
endpoints = sorted(
|
||||
{(w.get("endpoint") or "openai").lower() for w in workers}
|
||||
)
|
||||
raise ValueError(
|
||||
f"web_search.enabled=true but the worker pool has no "
|
||||
f"search-capable worker (endpoints present: {endpoints}); "
|
||||
@@ -853,39 +914,43 @@ class ConductorAgent(LocalCloudAgent):
|
||||
)
|
||||
# ``ws_tool`` doubles as the enable marker passed to `_call_worker`
|
||||
# (truthy => route search-capable workers through their agent loop).
|
||||
ws_tool = (
|
||||
build_web_search_tool(ws_max_uses) if ws_enabled else None
|
||||
)
|
||||
ws_tool = build_web_search_tool(ws_max_uses) if ws_enabled else None
|
||||
|
||||
try:
|
||||
if swe_mode:
|
||||
shared_workdir = Path(tempfile.mkdtemp(
|
||||
prefix=f"conductor-swe-{task_meta.get('task_id','x')}-"
|
||||
))
|
||||
shared_workdir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f"conductor-swe-{task_meta.get('task_id', 'x')}-"
|
||||
)
|
||||
)
|
||||
_clone_repo(task_meta["repo"], task_meta["base_commit"], shared_workdir)
|
||||
self.record_trace_event({
|
||||
"kind": "conductor_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "conductor_swe_workdir",
|
||||
"workdir": str(shared_workdir),
|
||||
"repo": task_meta["repo"],
|
||||
"base_commit": task_meta["base_commit"],
|
||||
}
|
||||
)
|
||||
|
||||
for i, (mid, subtask, access) in enumerate(
|
||||
zip(plan["model_id"], plan["subtasks"], plan["access_list"])
|
||||
):
|
||||
worker = workers[mid]
|
||||
prompt = _build_step_prompt(question, subtask, steps, access)
|
||||
self.record_trace_event({
|
||||
"kind": "conductor_step_dispatch",
|
||||
"step_idx": i,
|
||||
"worker_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"subtask": subtask,
|
||||
"access": access,
|
||||
"prompt": prompt,
|
||||
"swe_mode": swe_mode,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "conductor_step_dispatch",
|
||||
"step_idx": i,
|
||||
"worker_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"subtask": subtask,
|
||||
"access": access,
|
||||
"prompt": prompt,
|
||||
"swe_mode": swe_mode,
|
||||
}
|
||||
)
|
||||
|
||||
worker_ep = (worker.get("endpoint") or "openai").lower()
|
||||
# Post-hoc routing check: if web_search is on but the
|
||||
@@ -894,35 +959,49 @@ 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({
|
||||
"kind": "conductor_search_routing_warning",
|
||||
"step_idx": i,
|
||||
"worker_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_endpoint": worker_ep,
|
||||
"warning": (
|
||||
f"web_search enabled but step {i} routed to "
|
||||
f"search-incapable worker {worker['name']!r} "
|
||||
f"(endpoint {worker_ep!r}); this step cannot "
|
||||
"ground and may answer blind."
|
||||
),
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "conductor_search_routing_warning",
|
||||
"step_idx": i,
|
||||
"worker_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_endpoint": worker_ep,
|
||||
"warning": (
|
||||
f"web_search enabled but step {i} routed to "
|
||||
f"search-incapable worker {worker['name']!r} "
|
||||
f"(endpoint {worker_ep!r}); this step cannot "
|
||||
"ground and may answer blind."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
extra_cost = 0.0
|
||||
if swe_mode:
|
||||
text, w_in, w_out, is_local, n_searches, bash_turns = (
|
||||
_swe_worker_step(
|
||||
worker, task_meta, prompt, cfg, shared_workdir, i,
|
||||
worker,
|
||||
task_meta,
|
||||
prompt,
|
||||
cfg,
|
||||
shared_workdir,
|
||||
i,
|
||||
)
|
||||
)
|
||||
tool_calls += bash_turns
|
||||
else:
|
||||
text, w_in, w_out, is_local, n_searches = _call_worker(
|
||||
worker, prompt, cfg,
|
||||
web_search_tool=ws_tool,
|
||||
web_search_max_uses=ws_max_uses,
|
||||
(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,
|
||||
)
|
||||
)
|
||||
|
||||
if is_local:
|
||||
@@ -930,20 +1009,25 @@ 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({
|
||||
"step_idx": i,
|
||||
"model_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"subtask": subtask,
|
||||
"access": access,
|
||||
"output": text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
})
|
||||
steps.append(
|
||||
{
|
||||
"step_idx": i,
|
||||
"model_id": mid,
|
||||
"worker_name": worker["name"],
|
||||
"worker_model": worker["model"],
|
||||
"subtask": subtask,
|
||||
"access": access,
|
||||
"output": text,
|
||||
"tokens_in": w_in,
|
||||
"tokens_out": w_out,
|
||||
}
|
||||
)
|
||||
final_answer = text
|
||||
|
||||
# For SWE mode, the authoritative patch is whatever lives in
|
||||
@@ -954,7 +1038,8 @@ class ConductorAgent(LocalCloudAgent):
|
||||
if patch.strip():
|
||||
final_answer = (
|
||||
f"{final_answer}\n\n```diff\n{patch}```"
|
||||
if final_answer else f"```diff\n{patch}```"
|
||||
if final_answer
|
||||
else f"```diff\n{patch}```"
|
||||
)
|
||||
finally:
|
||||
if shared_workdir is not None:
|
||||
@@ -965,8 +1050,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
tokens_cloud += conductor_p_in + conductor_p_out
|
||||
|
||||
traces = [
|
||||
(s["step_idx"], s["model_id"], s["subtask"], s["output"])
|
||||
for s in steps
|
||||
(s["step_idx"], s["model_id"], s["subtask"], s["output"]) for s in steps
|
||||
]
|
||||
|
||||
meta = {
|
||||
@@ -981,11 +1065,11 @@ 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": [
|
||||
{k: v for k, v in w.items() if k != "api_key"}
|
||||
for w in workers
|
||||
{k: v for k, v in w.items() if k != "api_key"} for w in workers
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -172,11 +172,16 @@ def _clone_repo(repo: str, base_commit: str, dest: Path) -> None:
|
||||
url = f"https://github.com/{repo}.git"
|
||||
subprocess.run(
|
||||
["git", "clone", "--quiet", url, str(dest)],
|
||||
check=True, timeout=300, capture_output=True,
|
||||
check=True,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "--quiet", base_commit],
|
||||
cwd=str(dest), check=True, timeout=120, capture_output=True,
|
||||
cwd=str(dest),
|
||||
check=True,
|
||||
timeout=120,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -265,10 +270,14 @@ def _run_bash(
|
||||
stderr = _decode_bash_output(stderr_b, exit_code)
|
||||
truncated = False
|
||||
if len(stdout) > output_cap:
|
||||
stdout = stdout[:output_cap] + f"\n…[+{len(stdout) - output_cap} chars truncated]"
|
||||
stdout = (
|
||||
stdout[:output_cap] + f"\n…[+{len(stdout) - output_cap} chars truncated]"
|
||||
)
|
||||
truncated = True
|
||||
if len(stderr) > output_cap:
|
||||
stderr = stderr[:output_cap] + f"\n…[+{len(stderr) - output_cap} chars truncated]"
|
||||
stderr = (
|
||||
stderr[:output_cap] + f"\n…[+{len(stderr) - output_cap} chars truncated]"
|
||||
)
|
||||
truncated = True
|
||||
return {
|
||||
"stdout": stdout,
|
||||
@@ -297,7 +306,10 @@ def _extract_diff(workdir: Path) -> str:
|
||||
"""``git diff`` against the base commit — the final SWE-bench patch."""
|
||||
proc = subprocess.run(
|
||||
["git", "diff", "--no-color"],
|
||||
cwd=str(workdir), capture_output=True, text=True, timeout=60,
|
||||
cwd=str(workdir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
@@ -320,10 +332,11 @@ def _anthropic_assistant_block(block: Any) -> Dict[str, Any]:
|
||||
|
||||
# ---------- Reusable agent-loop entry point ----------
|
||||
|
||||
|
||||
def run_swe_agent_loop(
|
||||
task: Dict[str, Any],
|
||||
*,
|
||||
backbone: str, # "cloud" or "local"
|
||||
backbone: str, # "cloud" or "local"
|
||||
backbone_model: str,
|
||||
cloud_endpoint: str = "anthropic",
|
||||
local_endpoint: Optional[str] = None,
|
||||
@@ -387,32 +400,33 @@ def run_swe_agent_loop(
|
||||
|
||||
own_workdir = workdir is None
|
||||
if own_workdir:
|
||||
workdir = Path(tempfile.mkdtemp(
|
||||
prefix=f"mini-swe-{task.get('task_id','x')}-"
|
||||
))
|
||||
workdir = Path(tempfile.mkdtemp(prefix=f"mini-swe-{task.get('task_id', 'x')}-"))
|
||||
try:
|
||||
_clone_repo(repo, base_commit, workdir)
|
||||
except Exception:
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_setup",
|
||||
"repo": repo,
|
||||
"base_commit": base_commit,
|
||||
"workdir": str(workdir),
|
||||
"owns_workdir": own_workdir,
|
||||
"backbone": backbone,
|
||||
"backbone_model": backbone_model,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_setup",
|
||||
"repo": repo,
|
||||
"base_commit": base_commit,
|
||||
"workdir": str(workdir),
|
||||
"owns_workdir": own_workdir,
|
||||
"backbone": backbone,
|
||||
"backbone_model": backbone_model,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
user_prompt = initial_prompt or task.get("problem_statement") or ""
|
||||
|
||||
try:
|
||||
if backbone == "cloud":
|
||||
result = _loop_cloud(
|
||||
user_prompt, workdir,
|
||||
user_prompt,
|
||||
workdir,
|
||||
model=backbone_model,
|
||||
cloud_endpoint=cloud_endpoint,
|
||||
max_turns=max_turns,
|
||||
@@ -423,9 +437,12 @@ def run_swe_agent_loop(
|
||||
)
|
||||
elif backbone == "local":
|
||||
if not local_endpoint:
|
||||
raise ValueError("run_swe_agent_loop(backbone='local') needs local_endpoint")
|
||||
raise ValueError(
|
||||
"run_swe_agent_loop(backbone='local') needs local_endpoint"
|
||||
)
|
||||
result = _loop_local(
|
||||
user_prompt, workdir,
|
||||
user_prompt,
|
||||
workdir,
|
||||
model=backbone_model,
|
||||
endpoint=local_endpoint,
|
||||
max_turns=max_turns,
|
||||
@@ -440,7 +457,7 @@ def run_swe_agent_loop(
|
||||
raise ValueError(f"unsupported backbone: {backbone!r}")
|
||||
|
||||
patch = _extract_diff(workdir)
|
||||
framed = (result["final_summary"] or "[mini-swe-agent produced no summary text]")
|
||||
framed = result["final_summary"] or "[mini-swe-agent produced no summary text]"
|
||||
if patch.strip():
|
||||
framed = f"{framed}\n\n```diff\n{patch}```"
|
||||
|
||||
@@ -450,11 +467,16 @@ def run_swe_agent_loop(
|
||||
"final_summary": result["final_summary"],
|
||||
"tokens_in": result["tokens_in"],
|
||||
"tokens_out": result["tokens_out"],
|
||||
"tokens_local": result["tokens_in"] + result["tokens_out"] if backbone == "local" else 0,
|
||||
"tokens_cloud": result["tokens_in"] + result["tokens_out"] if backbone == "cloud" else 0,
|
||||
"tokens_local": result["tokens_in"] + result["tokens_out"]
|
||||
if backbone == "local"
|
||||
else 0,
|
||||
"tokens_cloud": result["tokens_in"] + result["tokens_out"]
|
||||
if backbone == "cloud"
|
||||
else 0,
|
||||
"cost_usd": (
|
||||
estimate_cost(backbone_model, result["tokens_in"], result["tokens_out"])
|
||||
if backbone == "cloud" else 0.0
|
||||
if backbone == "cloud"
|
||||
else 0.0
|
||||
),
|
||||
"turns": result["turns"],
|
||||
"max_turns_hit": result["max_turns_hit"],
|
||||
@@ -467,6 +489,7 @@ def run_swe_agent_loop(
|
||||
|
||||
# ---------- Cloud loop (dispatcher → per-endpoint multi-turn tool loops) ----------
|
||||
|
||||
|
||||
def _loop_cloud(
|
||||
problem: str,
|
||||
workdir: Path,
|
||||
@@ -485,24 +508,36 @@ def _loop_cloud(
|
||||
to unblock the 8 SWE cells that were stuck on Anthropic-only support."""
|
||||
if cloud_endpoint == "anthropic":
|
||||
return _loop_cloud_anthropic(
|
||||
problem, workdir,
|
||||
model=model, max_turns=max_turns,
|
||||
bash_timeout=bash_timeout, output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens, trace_prefix=trace_prefix,
|
||||
problem,
|
||||
workdir,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
bash_timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens,
|
||||
trace_prefix=trace_prefix,
|
||||
)
|
||||
if cloud_endpoint == "openai":
|
||||
return _loop_cloud_openai(
|
||||
problem, workdir,
|
||||
model=model, max_turns=max_turns,
|
||||
bash_timeout=bash_timeout, output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens, trace_prefix=trace_prefix,
|
||||
problem,
|
||||
workdir,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
bash_timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens,
|
||||
trace_prefix=trace_prefix,
|
||||
)
|
||||
if cloud_endpoint == "gemini":
|
||||
return _loop_cloud_gemini(
|
||||
problem, workdir,
|
||||
model=model, max_turns=max_turns,
|
||||
bash_timeout=bash_timeout, output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens, trace_prefix=trace_prefix,
|
||||
problem,
|
||||
workdir,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
bash_timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
turn_max_tokens=turn_max_tokens,
|
||||
trace_prefix=trace_prefix,
|
||||
)
|
||||
raise ValueError(
|
||||
f"mini-SWE-agent cloud backbone unsupported endpoint: {cloud_endpoint!r}"
|
||||
@@ -521,6 +556,7 @@ def _loop_cloud_anthropic(
|
||||
trace_prefix: str,
|
||||
) -> Dict[str, Any]:
|
||||
import anthropic
|
||||
|
||||
client = anthropic.Anthropic(timeout=600.0, max_retries=5)
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": problem}]
|
||||
|
||||
@@ -553,30 +589,39 @@ def _loop_cloud_anthropic(
|
||||
btype = getattr(block, "type", None)
|
||||
if btype == "tool_use":
|
||||
tool_uses.append((block.id, block.name, dict(block.input or {})))
|
||||
content_blocks.append({
|
||||
"type": "tool_use", "id": block.id, "name": block.name,
|
||||
"input": dict(block.input or {}),
|
||||
})
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": block.id,
|
||||
"name": block.name,
|
||||
"input": dict(block.input or {}),
|
||||
}
|
||||
)
|
||||
elif hasattr(block, "text"):
|
||||
text_parts.append(block.text)
|
||||
content_blocks.append({"type": "text", "text": block.text})
|
||||
else:
|
||||
content_blocks.append({"type": btype or "unknown"})
|
||||
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"stop_reason": msg.stop_reason,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"latency_s": latency,
|
||||
"content_blocks": content_blocks,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"stop_reason": msg.stop_reason,
|
||||
"tokens_in": msg.usage.input_tokens,
|
||||
"tokens_out": msg.usage.output_tokens,
|
||||
"latency_s": latency,
|
||||
"content_blocks": content_blocks,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
messages.append({"role": "assistant", "content": [
|
||||
_anthropic_assistant_block(b) for b in msg.content
|
||||
]})
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [_anthropic_assistant_block(b) for b in msg.content],
|
||||
}
|
||||
)
|
||||
|
||||
if not tool_uses:
|
||||
final_text = "\n".join(text_parts).strip()
|
||||
@@ -586,28 +631,40 @@ def _loop_cloud_anthropic(
|
||||
for tu_id, tu_name, tu_input in tool_uses:
|
||||
if tu_name != "bash":
|
||||
obs = f"unknown tool: {tu_name!r}"
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn, "name": tu_name, "input": tu_input,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn,
|
||||
"name": tu_name,
|
||||
"input": tu_input,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
command = str(tu_input.get("command", ""))
|
||||
result = _run_bash(
|
||||
command, workdir,
|
||||
timeout=bash_timeout, output_cap=output_cap,
|
||||
command,
|
||||
workdir,
|
||||
timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn,
|
||||
"command": command,
|
||||
**result,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn, "command": command,
|
||||
**result, "ts": time.time(),
|
||||
})
|
||||
obs = _format_observation(result)
|
||||
tool_result_blocks.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tu_id,
|
||||
"content": obs,
|
||||
})
|
||||
tool_result_blocks.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tu_id,
|
||||
"content": obs,
|
||||
}
|
||||
)
|
||||
messages.append({"role": "user", "content": tool_result_blocks})
|
||||
|
||||
return {
|
||||
@@ -621,6 +678,7 @@ def _loop_cloud_anthropic(
|
||||
|
||||
# ---------- Cloud loop (OpenAI multi-turn with function tools) ----------
|
||||
|
||||
|
||||
def _loop_cloud_openai(
|
||||
problem: str,
|
||||
workdir: Path,
|
||||
@@ -646,6 +704,7 @@ def _loop_cloud_openai(
|
||||
``_loop_local`` behavior).
|
||||
"""
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(timeout=600.0)
|
||||
|
||||
messages: List[Dict[str, Any]] = [
|
||||
@@ -681,21 +740,27 @@ def _loop_cloud_openai(
|
||||
tool_calls = list(getattr(message, "tool_calls", None) or [])
|
||||
text = message.content or ""
|
||||
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"endpoint": "openai",
|
||||
"finish_reason": choice.finish_reason,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": latency,
|
||||
"text": text,
|
||||
"tool_calls": [
|
||||
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
|
||||
for tc in tool_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"endpoint": "openai",
|
||||
"finish_reason": choice.finish_reason,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": latency,
|
||||
"text": text,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
}
|
||||
for tc in tool_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
# Append the assistant turn (including any tool_calls) so the
|
||||
# follow-up tool messages have the right call ids to reference.
|
||||
@@ -711,7 +776,8 @@ def _loop_cloud_openai(
|
||||
if tool_calls:
|
||||
assistant_msg["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id, "type": "function",
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
@@ -734,21 +800,26 @@ def _loop_cloud_openai(
|
||||
and not text.strip()
|
||||
and turn < max_turns
|
||||
):
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Your previous response was truncated by the token limit "
|
||||
"before producing a tool call or final summary. Retry: "
|
||||
"either issue ONE bash tool call (short command, no large "
|
||||
"output) or send a brief one-line final summary with no "
|
||||
"tool calls to end the loop."
|
||||
),
|
||||
})
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_recover",
|
||||
"turn": turn, "reason": "length_truncation_no_tool_call",
|
||||
"ts": time.time(),
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Your previous response was truncated by the token limit "
|
||||
"before producing a tool call or final summary. Retry: "
|
||||
"either issue ONE bash tool call (short command, no large "
|
||||
"output) or send a brief one-line final summary with no "
|
||||
"tool calls to end the loop."
|
||||
),
|
||||
}
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_recover",
|
||||
"turn": turn,
|
||||
"reason": "length_truncation_no_tool_call",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
continue
|
||||
# No tool call → the model is done. Same termination rule as
|
||||
# the Anthropic branch.
|
||||
@@ -762,28 +833,40 @@ def _loop_cloud_openai(
|
||||
args = {}
|
||||
if tc.function.name != "bash":
|
||||
obs = f"unknown tool: {tc.function.name!r}"
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn, "name": tc.function.name, "input": args,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn,
|
||||
"name": tc.function.name,
|
||||
"input": args,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
command = str(args.get("command", ""))
|
||||
result = _run_bash(
|
||||
command, workdir,
|
||||
timeout=bash_timeout, output_cap=output_cap,
|
||||
command,
|
||||
workdir,
|
||||
timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn,
|
||||
"command": command,
|
||||
**result,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn, "command": command,
|
||||
**result, "ts": time.time(),
|
||||
})
|
||||
obs = _format_observation(result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": obs,
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": obs,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"tokens_in": tokens_in,
|
||||
@@ -796,6 +879,7 @@ def _loop_cloud_openai(
|
||||
|
||||
# ---------- Cloud loop (Gemini multi-turn with function tools) ----------
|
||||
|
||||
|
||||
def _loop_cloud_gemini(
|
||||
problem: str,
|
||||
workdir: Path,
|
||||
@@ -831,13 +915,15 @@ def _loop_cloud_gemini(
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(http_options=types.HttpOptions(timeout=600_000))
|
||||
bash_tool = types.Tool(function_declarations=[
|
||||
types.FunctionDeclaration(
|
||||
name="bash",
|
||||
description=BASH_TOOL_ANTHROPIC["description"],
|
||||
parameters=BASH_TOOL_GEMINI_PARAMETERS,
|
||||
),
|
||||
])
|
||||
bash_tool = types.Tool(
|
||||
function_declarations=[
|
||||
types.FunctionDeclaration(
|
||||
name="bash",
|
||||
description=BASH_TOOL_ANTHROPIC["description"],
|
||||
parameters=BASH_TOOL_GEMINI_PARAMETERS,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
contents: List[types.Content] = [
|
||||
types.Content(role="user", parts=[types.Part(text=problem)]),
|
||||
@@ -859,7 +945,9 @@ def _loop_cloud_gemini(
|
||||
)
|
||||
t0 = time.time()
|
||||
resp = client.models.generate_content(
|
||||
model=model, contents=contents, config=cfg,
|
||||
model=model,
|
||||
contents=contents,
|
||||
config=cfg,
|
||||
)
|
||||
_bump_cloud_calls()
|
||||
latency = time.time() - t0
|
||||
@@ -896,21 +984,22 @@ def _loop_cloud_gemini(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"endpoint": "gemini",
|
||||
"finish_reason": finish_reason,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"latency_s": latency,
|
||||
"text": "\n".join(text_parts),
|
||||
"tool_calls": [
|
||||
{"name": name, "arguments": args}
|
||||
for name, args in function_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"endpoint": "gemini",
|
||||
"finish_reason": finish_reason,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
"latency_s": latency,
|
||||
"text": "\n".join(text_parts),
|
||||
"tool_calls": [
|
||||
{"name": name, "arguments": args} for name, args in function_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
# Append the model's content as-is so the next turn sees its own
|
||||
# prior function_call parts (Gemini requires this for the
|
||||
@@ -934,28 +1023,37 @@ def _loop_cloud_gemini(
|
||||
# treat genuine ``STOP`` with text as a final answer.
|
||||
fr_str = str(finish_reason or "")
|
||||
empty_text = not any(t.strip() for t in text_parts)
|
||||
recoverable = empty_text and turn < max_turns and (
|
||||
"MALFORMED_FUNCTION_CALL" in fr_str
|
||||
or "MAX_TOKENS" in fr_str
|
||||
recoverable = (
|
||||
empty_text
|
||||
and turn < max_turns
|
||||
and ("MALFORMED_FUNCTION_CALL" in fr_str or "MAX_TOKENS" in fr_str)
|
||||
)
|
||||
if recoverable:
|
||||
contents.append(types.Content(
|
||||
role="user",
|
||||
parts=[types.Part(text=(
|
||||
"Your previous response had no parsable function call "
|
||||
"and no final text (finish_reason="
|
||||
f"{fr_str}). Retry: either issue ONE well-formed "
|
||||
"`bash` function call (short command, valid JSON-ish "
|
||||
"args) or send a brief final text message with no "
|
||||
"function call to end the loop."
|
||||
))],
|
||||
))
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_recover",
|
||||
"turn": turn,
|
||||
"reason": f"empty_response_{fr_str}",
|
||||
"ts": time.time(),
|
||||
})
|
||||
contents.append(
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part(
|
||||
text=(
|
||||
"Your previous response had no parsable function call "
|
||||
"and no final text (finish_reason="
|
||||
f"{fr_str}). Retry: either issue ONE well-formed "
|
||||
"`bash` function call (short command, valid JSON-ish "
|
||||
"args) or send a brief final text message with no "
|
||||
"function call to end the loop."
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_recover",
|
||||
"turn": turn,
|
||||
"reason": f"empty_response_{fr_str}",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
continue
|
||||
final_text = "\n".join(text_parts).strip()
|
||||
break
|
||||
@@ -964,26 +1062,39 @@ def _loop_cloud_gemini(
|
||||
for name, args in function_calls:
|
||||
if name != "bash":
|
||||
obs = f"unknown tool: {name!r}"
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn, "name": name, "input": args,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_unknown_tool",
|
||||
"turn": turn,
|
||||
"name": name,
|
||||
"input": args,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
command = str(args.get("command", ""))
|
||||
result = _run_bash(
|
||||
command, workdir,
|
||||
timeout=bash_timeout, output_cap=output_cap,
|
||||
command,
|
||||
workdir,
|
||||
timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn,
|
||||
"command": command,
|
||||
**result,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn, "command": command,
|
||||
**result, "ts": time.time(),
|
||||
})
|
||||
obs = _format_observation(result)
|
||||
response_parts.append(types.Part.from_function_response(
|
||||
name=name, response={"output": obs},
|
||||
))
|
||||
response_parts.append(
|
||||
types.Part.from_function_response(
|
||||
name=name,
|
||||
response={"output": obs},
|
||||
)
|
||||
)
|
||||
contents.append(types.Content(role="user", parts=response_parts))
|
||||
|
||||
return {
|
||||
@@ -1013,10 +1124,14 @@ def _get_tiktoken_enc() -> Any:
|
||||
return _TIKTOKEN_ENC
|
||||
try:
|
||||
import tiktoken
|
||||
|
||||
_TIKTOKEN_ENC = tiktoken.get_encoding("cl100k_base")
|
||||
except Exception as exc:
|
||||
if not _TIKTOKEN_WARNED:
|
||||
print(f"[mini_swe_agent] tiktoken unavailable ({exc!r}); falling back to len(s)//4", flush=True)
|
||||
print(
|
||||
f"[mini_swe_agent] tiktoken unavailable ({exc!r}); falling back to len(s)//4",
|
||||
flush=True,
|
||||
)
|
||||
_TIKTOKEN_WARNED = True
|
||||
_TIKTOKEN_ENC = False
|
||||
return _TIKTOKEN_ENC
|
||||
@@ -1038,7 +1153,7 @@ def _estimate_prompt_tokens(messages: List[Dict[str, Any]]) -> int:
|
||||
s = "\n".join(parts)
|
||||
else:
|
||||
s = ""
|
||||
for tc in (m.get("tool_calls") or []):
|
||||
for tc in m.get("tool_calls") or []:
|
||||
try:
|
||||
s += "\n" + (tc["function"]["arguments"] or "")
|
||||
s += "\n" + (tc["function"].get("name") or "")
|
||||
@@ -1119,7 +1234,7 @@ def _compact_local_messages(
|
||||
before_tokens = _estimate_prompt_tokens(messages)
|
||||
new_messages: List[Dict[str, Any]] = list(messages)
|
||||
n_tool_elided = 0
|
||||
for (s, e) in old_turns:
|
||||
for s, e in old_turns:
|
||||
for k in range(s, e):
|
||||
m = new_messages[k]
|
||||
if m.get("role") != "tool":
|
||||
@@ -1140,17 +1255,19 @@ def _compact_local_messages(
|
||||
n_tool_elided += 1
|
||||
|
||||
after_stage1_tokens = _estimate_prompt_tokens(new_messages)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_compact",
|
||||
"stage": "1",
|
||||
"msgs_before": len(messages),
|
||||
"msgs_after": len(new_messages),
|
||||
"before_tokens": before_tokens,
|
||||
"after_tokens": after_stage1_tokens,
|
||||
"n_tool_elided": n_tool_elided,
|
||||
"n_turns_folded": 0,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_compact",
|
||||
"stage": "1",
|
||||
"msgs_before": len(messages),
|
||||
"msgs_after": len(new_messages),
|
||||
"before_tokens": before_tokens,
|
||||
"after_tokens": after_stage1_tokens,
|
||||
"n_tool_elided": n_tool_elided,
|
||||
"n_turns_folded": 0,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
if after_stage1_tokens <= compact_at_tokens:
|
||||
return new_messages
|
||||
@@ -1163,12 +1280,21 @@ def _compact_local_messages(
|
||||
|
||||
summary_input = [
|
||||
{"role": "system", "content": _COMPACT_PROMPT},
|
||||
{"role": "user", "content": json.dumps(
|
||||
[{"role": m.get("role"),
|
||||
"content": m.get("content") if isinstance(m.get("content"), str) else str(m.get("content"))[:4000]}
|
||||
for m in middle],
|
||||
default=str,
|
||||
)[:60_000]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
[
|
||||
{
|
||||
"role": m.get("role"),
|
||||
"content": m.get("content")
|
||||
if isinstance(m.get("content"), str)
|
||||
else str(m.get("content"))[:4000],
|
||||
}
|
||||
for m in middle
|
||||
],
|
||||
default=str,
|
||||
)[:60_000],
|
||||
},
|
||||
]
|
||||
summary = ""
|
||||
try:
|
||||
@@ -1194,18 +1320,20 @@ def _compact_local_messages(
|
||||
}
|
||||
folded = [system_msg, initial_user, synthetic, *tail]
|
||||
after_stage2_tokens = _estimate_prompt_tokens(folded)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_compact",
|
||||
"stage": "2",
|
||||
"msgs_before": len(new_messages),
|
||||
"msgs_after": len(folded),
|
||||
"before_tokens": after_stage1_tokens,
|
||||
"after_tokens": after_stage2_tokens,
|
||||
"n_tool_elided": n_tool_elided,
|
||||
"n_turns_folded": n_turns_folded,
|
||||
"summary_chars": len(summary),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_compact",
|
||||
"stage": "2",
|
||||
"msgs_before": len(new_messages),
|
||||
"msgs_after": len(folded),
|
||||
"before_tokens": after_stage1_tokens,
|
||||
"after_tokens": after_stage2_tokens,
|
||||
"n_tool_elided": n_tool_elided,
|
||||
"n_turns_folded": n_turns_folded,
|
||||
"summary_chars": len(summary),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return folded
|
||||
|
||||
|
||||
@@ -1231,6 +1359,7 @@ def _loop_local(
|
||||
# but still saw 28k-input 400s on the n=100 SWE sweep (the keep window
|
||||
# alone routinely exceeded the budget once bash outputs piled up).
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(base_url=endpoint, api_key="EMPTY", timeout=600.0)
|
||||
|
||||
messages: List[Dict[str, Any]] = [
|
||||
@@ -1243,10 +1372,16 @@ def _loop_local(
|
||||
turns = 0
|
||||
for turn in range(1, max_turns + 1):
|
||||
turns = turn
|
||||
if compact_at_tokens > 0 and _estimate_prompt_tokens(messages) > compact_at_tokens:
|
||||
if (
|
||||
compact_at_tokens > 0
|
||||
and _estimate_prompt_tokens(messages) > compact_at_tokens
|
||||
):
|
||||
messages = _compact_local_messages(
|
||||
messages, client=client, model=model,
|
||||
keep_last=compact_keep_last, trace_prefix=trace_prefix,
|
||||
messages,
|
||||
client=client,
|
||||
model=model,
|
||||
keep_last=compact_keep_last,
|
||||
trace_prefix=trace_prefix,
|
||||
compact_at_tokens=compact_at_tokens,
|
||||
)
|
||||
t0 = time.time()
|
||||
@@ -1268,23 +1403,27 @@ def _loop_local(
|
||||
# server walled the call. Compact aggressively (keep_last=1)
|
||||
# and retry once. Re-raise on anything else or on a second
|
||||
# failure — the runner records the row as errored.
|
||||
from openjarvis.engine._base import looks_like_context_length_error
|
||||
|
||||
msg = str(exc)
|
||||
is_ctx = (
|
||||
"maximum context length" in msg
|
||||
or "context length" in msg.lower() and "exceed" in msg.lower()
|
||||
)
|
||||
is_ctx = looks_like_context_length_error(msg)
|
||||
if not is_ctx:
|
||||
raise
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_emergency_compact",
|
||||
"turn": turn,
|
||||
"error": msg[:300],
|
||||
"tokens_before": _estimate_prompt_tokens(messages),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_emergency_compact",
|
||||
"turn": turn,
|
||||
"error": msg[:300],
|
||||
"tokens_before": _estimate_prompt_tokens(messages),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
messages = _compact_local_messages(
|
||||
messages, client=client, model=model,
|
||||
keep_last=1, trace_prefix=trace_prefix,
|
||||
messages,
|
||||
client=client,
|
||||
model=model,
|
||||
keep_last=1,
|
||||
trace_prefix=trace_prefix,
|
||||
compact_at_tokens=max(8_000, compact_at_tokens // 2),
|
||||
)
|
||||
resp = client.chat.completions.create(
|
||||
@@ -1306,20 +1445,26 @@ def _loop_local(
|
||||
tool_calls = list(getattr(message, "tool_calls", None) or [])
|
||||
text = message.content or ""
|
||||
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"finish_reason": choice.finish_reason,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": latency,
|
||||
"text": text,
|
||||
"tool_calls": [
|
||||
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
|
||||
for tc in tool_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
})
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_turn",
|
||||
"turn": turn,
|
||||
"finish_reason": choice.finish_reason,
|
||||
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
|
||||
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
|
||||
"latency_s": latency,
|
||||
"text": text,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
}
|
||||
for tc in tool_calls
|
||||
],
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
# Match the OpenAI cloud branch: content="" (not None) when only
|
||||
# tool_calls are present; omit ``tool_calls`` entirely when there
|
||||
@@ -1333,7 +1478,8 @@ def _loop_local(
|
||||
if tool_calls:
|
||||
assistant_local_msg["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id, "type": "function",
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
@@ -1357,20 +1503,28 @@ def _loop_local(
|
||||
else:
|
||||
command = str(args.get("command", ""))
|
||||
result = _run_bash(
|
||||
command, workdir,
|
||||
timeout=bash_timeout, output_cap=output_cap,
|
||||
command,
|
||||
workdir,
|
||||
timeout=bash_timeout,
|
||||
output_cap=output_cap,
|
||||
)
|
||||
_record_event(
|
||||
{
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn,
|
||||
"command": command,
|
||||
**result,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
_record_event({
|
||||
"kind": f"{trace_prefix}_bash",
|
||||
"turn": turn, "command": command,
|
||||
**result, "ts": time.time(),
|
||||
})
|
||||
obs = _format_observation(result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": obs,
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": obs,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"tokens_in": tokens_in,
|
||||
@@ -1383,6 +1537,7 @@ def _loop_local(
|
||||
|
||||
# ---------- Standalone agent ----------
|
||||
|
||||
|
||||
@AgentRegistry.register("mini_swe_agent")
|
||||
class MiniSWEAgent(LocalCloudAgent):
|
||||
"""Single-model bash-loop agent for SWE-bench-shaped tasks.
|
||||
@@ -1410,10 +1565,7 @@ class MiniSWEAgent(LocalCloudAgent):
|
||||
task = context.metadata.get("task") or {}
|
||||
|
||||
backbone = cfg.get("backbone", "cloud")
|
||||
model = (
|
||||
self._cloud_model if backbone == "cloud"
|
||||
else (self._local_model or "")
|
||||
)
|
||||
model = self._cloud_model if backbone == "cloud" else (self._local_model or "")
|
||||
|
||||
out = run_swe_agent_loop(
|
||||
task,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,7 +87,7 @@ MINIONS_FIRST_TURN_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reasoning": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
},
|
||||
"required": ["reasoning", "message"],
|
||||
"additionalProperties": False,
|
||||
@@ -103,7 +104,7 @@ MINIONS_CONVERSATION_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decision": {"const": "request_additional_info"},
|
||||
"message": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
},
|
||||
"required": ["decision", "message"],
|
||||
"additionalProperties": False,
|
||||
@@ -112,7 +113,7 @@ MINIONS_CONVERSATION_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decision": {"const": "provide_final_answer"},
|
||||
"answer": {"type": "string"},
|
||||
"answer": {"type": "string"},
|
||||
},
|
||||
"required": ["decision", "answer"],
|
||||
"additionalProperties": False,
|
||||
@@ -125,8 +126,8 @@ MINIONS_CONVERSATION_SCHEMA = {
|
||||
# Markers from Minions's supervisor prompts (prompts/minion.py). Any one
|
||||
# being present in the call's messages/system is a strong Minions signal.
|
||||
MINIONS_PROMPT_MARKERS = (
|
||||
"small language model that has read", # SUPERVISOR_INITIAL_PROMPT
|
||||
"provide_final_answer", # SUPERVISOR_CONVERSATION_PROMPT
|
||||
"small language model that has read", # SUPERVISOR_INITIAL_PROMPT
|
||||
"provide_final_answer", # SUPERVISOR_CONVERSATION_PROMPT
|
||||
"request_additional_info",
|
||||
)
|
||||
|
||||
@@ -170,6 +171,7 @@ def _stub_missing_imports() -> None:
|
||||
"""
|
||||
try:
|
||||
import mistralai
|
||||
|
||||
if not hasattr(mistralai, "Mistral"):
|
||||
mistralai.Mistral = type("Mistral", (), {}) # type: ignore[attr-defined]
|
||||
except ImportError:
|
||||
@@ -214,12 +216,12 @@ def _patch_anthropic_globally() -> None:
|
||||
model = kwargs.get("model", "")
|
||||
if model.startswith(NO_TEMP_PREFIXES):
|
||||
kwargs.pop("temperature", None)
|
||||
if (
|
||||
"output_config" not in kwargs
|
||||
and _looks_like_minions_call(kwargs)
|
||||
if "output_config" not in kwargs and _looks_like_minions_call(
|
||||
kwargs
|
||||
):
|
||||
kwargs["output_config"] = _minions_turn_schema(kwargs)
|
||||
return orig(self, **kwargs)
|
||||
|
||||
patched._hybrid_patched = True # type: ignore[attr-defined]
|
||||
return patched
|
||||
|
||||
@@ -357,11 +359,14 @@ def _apply_patches_once() -> None:
|
||||
|
||||
# ---------- Pre-fetch helper (GAIA only) ----------
|
||||
|
||||
|
||||
def _prefetch_context(
|
||||
question: str,
|
||||
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.
|
||||
|
||||
@@ -373,8 +378,27 @@ def _prefetch_context(
|
||||
and zeros — the protocol still runs.
|
||||
"""
|
||||
out: Dict[str, Any] = {
|
||||
"text": "", "tokens": 0, "cost_usd": 0.0, "n_searches": 0,
|
||||
"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:
|
||||
@@ -393,10 +417,12 @@ def _prefetch_context(
|
||||
tool_choice={"type": "any"},
|
||||
)
|
||||
from openjarvis.agents.hybrid._prices import cost as _cost_usd
|
||||
|
||||
out.update(
|
||||
text=text,
|
||||
tokens=p + c,
|
||||
cost_usd=_cost_usd(cloud_model, p, c) + n_searches * WEB_SEARCH_COST_PER_CALL,
|
||||
cost_usd=_cost_usd(cloud_model, p, c)
|
||||
+ n_searches * WEB_SEARCH_COST_PER_CALL,
|
||||
n_searches=n_searches,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -404,9 +430,7 @@ def _prefetch_context(
|
||||
return out
|
||||
|
||||
|
||||
def _context_for(
|
||||
task: Optional[Dict[str, Any]], prefetched: str = ""
|
||||
) -> List[str]:
|
||||
def _context_for(task: Optional[Dict[str, Any]], prefetched: str = "") -> List[str]:
|
||||
"""Minions wants a context list."""
|
||||
bits: List[str] = []
|
||||
task = task or {}
|
||||
@@ -421,6 +445,7 @@ def _context_for(
|
||||
|
||||
# ---------- Main agent ----------
|
||||
|
||||
|
||||
@AgentRegistry.register("minions")
|
||||
class MinionsAgent(LocalCloudAgent):
|
||||
"""HazyResearch Minions supervisor/worker protocol. See module docstring."""
|
||||
@@ -432,6 +457,7 @@ class MinionsAgent(LocalCloudAgent):
|
||||
# 400/529, KeyError on missing schema fields.
|
||||
try:
|
||||
import anthropic
|
||||
|
||||
if isinstance(exc, anthropic.BadRequestError):
|
||||
return f"{type(exc).__name__}: {str(exc)[:120]}"
|
||||
except Exception:
|
||||
@@ -498,18 +524,21 @@ 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 +549,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}")
|
||||
@@ -543,9 +572,14 @@ class MinionsAgent(LocalCloudAgent):
|
||||
# - enabled = false → prefetch OFF
|
||||
# - enabled = true → prefetch ON (honors max_uses)
|
||||
prefetch: Dict[str, Any] = {
|
||||
"text": "", "tokens": 0, "cost_usd": 0.0, "n_searches": 0,
|
||||
"text": "",
|
||||
"tokens": 0,
|
||||
"cost_usd": 0.0,
|
||||
"n_searches": 0,
|
||||
}
|
||||
ws_block = cfg.get("web_search") if isinstance(cfg.get("web_search"), dict) else None
|
||||
ws_block = (
|
||||
cfg.get("web_search") if isinstance(cfg.get("web_search"), dict) else None
|
||||
)
|
||||
ws_enabled, ws_max_uses = web_search_cfg(cfg)
|
||||
# If the cell explicitly set web_search.enabled = false, honor that.
|
||||
# If it set web_search.enabled = true, honor max_uses. If it didn't
|
||||
@@ -560,17 +594,21 @@ 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"):
|
||||
self.record_trace_event({
|
||||
"kind": "minions_prefetch",
|
||||
"n_searches": prefetch["n_searches"],
|
||||
"tokens": prefetch["tokens"],
|
||||
"cost_usd": prefetch["cost_usd"],
|
||||
"text": prefetch["text"],
|
||||
"error": prefetch.get("error"),
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "minions_prefetch",
|
||||
"n_searches": prefetch["n_searches"],
|
||||
"tokens": prefetch["tokens"],
|
||||
"cost_usd": prefetch["cost_usd"],
|
||||
"text": prefetch["text"],
|
||||
"error": prefetch.get("error"),
|
||||
}
|
||||
)
|
||||
|
||||
out = protocol(
|
||||
task=input, # full formatted prompt (with bench instruction)
|
||||
@@ -582,15 +620,17 @@ class MinionsAgent(LocalCloudAgent):
|
||||
# The Minions library doesn't go through our SDK helpers, so the
|
||||
# auto-trace missed every turn. Record the protocol output directly —
|
||||
# supervisor_messages + worker_messages contain the full conversation.
|
||||
self.record_trace_event({
|
||||
"kind": "minions_protocol",
|
||||
"mode": mode,
|
||||
"supervisor_messages": out.get("supervisor_messages"),
|
||||
"worker_messages": out.get("worker_messages"),
|
||||
"timing": out.get("timing"),
|
||||
"log_file": out.get("log_file"),
|
||||
"final_answer": out.get("final_answer", ""),
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "minions_protocol",
|
||||
"mode": mode,
|
||||
"supervisor_messages": out.get("supervisor_messages"),
|
||||
"worker_messages": out.get("worker_messages"),
|
||||
"timing": out.get("timing"),
|
||||
"log_file": out.get("log_file"),
|
||||
"final_answer": out.get("final_answer", ""),
|
||||
}
|
||||
)
|
||||
|
||||
local_usage = out.get("local_usage")
|
||||
remote_usage = out.get("remote_usage")
|
||||
@@ -625,7 +665,6 @@ class MinionsAgent(LocalCloudAgent):
|
||||
}
|
||||
return out.get("final_answer", ""), meta
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SWE-bench variant
|
||||
# ------------------------------------------------------------------
|
||||
@@ -643,21 +682,23 @@ class MinionsAgent(LocalCloudAgent):
|
||||
# 1. Cloud supervisor writes a high-level plan (no tools).
|
||||
plan_text, p_in, p_out = self._call_cloud(
|
||||
user=(
|
||||
f"Issue:\n{task.get('problem_statement','')}\n\n"
|
||||
f"Repo: {task.get('repo','')}\n"
|
||||
f"Base commit: {task.get('base_commit','')}\n\n"
|
||||
f"{task.get('hints_text','')}"
|
||||
f"Issue:\n{task.get('problem_statement', '')}\n\n"
|
||||
f"Repo: {task.get('repo', '')}\n"
|
||||
f"Base commit: {task.get('base_commit', '')}\n\n"
|
||||
f"{task.get('hints_text', '')}"
|
||||
),
|
||||
system=MINIONS_SWE_PLANNER_SYS,
|
||||
max_tokens=int(cfg.get("supervisor_max_tokens", 1024)),
|
||||
temperature=0.0,
|
||||
)
|
||||
self.record_trace_event({
|
||||
"kind": "minions_swe_plan",
|
||||
"plan": plan_text,
|
||||
"tokens_in": p_in,
|
||||
"tokens_out": p_out,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "minions_swe_plan",
|
||||
"plan": plan_text,
|
||||
"tokens_in": p_in,
|
||||
"tokens_out": p_out,
|
||||
}
|
||||
)
|
||||
supervisor_cost = self.cost_usd(self._cloud_model, p_in, p_out)
|
||||
|
||||
# 2. Local worker runs mini-SWE-agent with the plan as context.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,33 +47,33 @@ from openjarvis.core.registry import AgentRegistry
|
||||
# would seed before any oracle update.
|
||||
|
||||
SKILL_CATALOG: Dict[str, str] = {
|
||||
"factual_recall": "Recall named entities, dates, places, well-known facts from training data without external lookup.",
|
||||
"factual_recall": "Recall named entities, dates, places, well-known facts from training data without external lookup.",
|
||||
"multi_step_reasoning": "Chain several inference steps together (e.g. compose dates, traverse relationships, decompose then aggregate).",
|
||||
"arithmetic": "Exact numeric computation on values already given in the question.",
|
||||
"web_grounding": "Question needs information likely NOT in a small model's parametric memory (rare facts, recent events, niche sources).",
|
||||
"arithmetic": "Exact numeric computation on values already given in the question.",
|
||||
"web_grounding": "Question needs information likely NOT in a small model's parametric memory (rare facts, recent events, niche sources).",
|
||||
"long_text_extraction": "Read a long supplied document/context and extract a specific piece.",
|
||||
"format_compliance": "Strict output formatting (e.g. GAIA's `FINAL ANSWER: <answer>` rule, comma-separated lists with no units).",
|
||||
"code_or_logic": "Write or trace code, or apply logical/symbolic constraints precisely.",
|
||||
"format_compliance": "Strict output formatting (e.g. GAIA's `FINAL ANSWER: <answer>` rule, comma-separated lists with no units).",
|
||||
"code_or_logic": "Write or trace code, or apply logical/symbolic constraints precisely.",
|
||||
}
|
||||
|
||||
DEFAULT_AGENT_COMPETENCE: Dict[str, Dict[str, float]] = {
|
||||
"local-qwen-27b": {
|
||||
"factual_recall": 0.25,
|
||||
"factual_recall": 0.25,
|
||||
"multi_step_reasoning": 0.30,
|
||||
"arithmetic": 0.55,
|
||||
"web_grounding": 0.10,
|
||||
"arithmetic": 0.55,
|
||||
"web_grounding": 0.10,
|
||||
"long_text_extraction": 0.55,
|
||||
"format_compliance": 0.65,
|
||||
"code_or_logic": 0.45,
|
||||
"format_compliance": 0.65,
|
||||
"code_or_logic": 0.45,
|
||||
},
|
||||
"cloud-opus-4-7": {
|
||||
"factual_recall": 0.85,
|
||||
"factual_recall": 0.85,
|
||||
"multi_step_reasoning": 0.88,
|
||||
"arithmetic": 0.85,
|
||||
"web_grounding": 0.70,
|
||||
"arithmetic": 0.85,
|
||||
"web_grounding": 0.70,
|
||||
"long_text_extraction": 0.90,
|
||||
"format_compliance": 0.92,
|
||||
"code_or_logic": 0.90,
|
||||
"format_compliance": 0.92,
|
||||
"code_or_logic": 0.90,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -178,9 +189,7 @@ def _score_agents(
|
||||
lam = 0.5
|
||||
scores: Dict[str, Dict[str, float]] = {}
|
||||
for aid, comps in competence.items():
|
||||
comp = sum(
|
||||
skill_weights.get(sid, 0.0) * comps[sid] for sid in SKILL_CATALOG
|
||||
)
|
||||
comp = sum(skill_weights.get(sid, 0.0) * comps[sid] for sid in SKILL_CATALOG)
|
||||
cost_pen = lam * cost.get(aid, 0.0)
|
||||
scores[aid] = {
|
||||
"competence": comp,
|
||||
@@ -196,6 +205,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 +293,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 {}
|
||||
@@ -258,14 +311,16 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
if chosen not in competence:
|
||||
chosen = max(scored, key=lambda a: scored[a]["final_score"])
|
||||
|
||||
self.record_trace_event({
|
||||
"kind": "skillorchestra_route",
|
||||
"chosen_agent": chosen,
|
||||
"skill_weights": skill_weights,
|
||||
"agent_scores": scored,
|
||||
"reasoning": decision.get("reasoning", ""),
|
||||
"router_raw": router_text,
|
||||
})
|
||||
self.record_trace_event(
|
||||
{
|
||||
"kind": "skillorchestra_route",
|
||||
"chosen_agent": chosen,
|
||||
"skill_weights": skill_weights,
|
||||
"agent_scores": scored,
|
||||
"reasoning": decision.get("reasoning", ""),
|
||||
"router_raw": router_text,
|
||||
}
|
||||
)
|
||||
|
||||
tokens_local = 0
|
||||
tokens_cloud = r_in + r_out
|
||||
@@ -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)
|
||||
|
||||
@@ -41,8 +41,12 @@ from .orchestrator import run_orchestrator
|
||||
from .stage_router import StageSkillHandbook
|
||||
|
||||
_VALID_STRATEGIES = {
|
||||
"none", "router_decides", "analyze_model_decide",
|
||||
"weighted_avg", "weakest_skill", "strongest_skill",
|
||||
"none",
|
||||
"router_decides",
|
||||
"analyze_model_decide",
|
||||
"weighted_avg",
|
||||
"weakest_skill",
|
||||
"strongest_skill",
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +150,11 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
strategy = "none"
|
||||
|
||||
return run_orchestrator(
|
||||
self, input, cfg=cfg, handbook=handbook, strategy=strategy,
|
||||
self,
|
||||
input,
|
||||
cfg=cfg,
|
||||
handbook=handbook,
|
||||
strategy=strategy,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
@@ -51,6 +58,7 @@ _STAGE_DEFAULT_ALIAS = {
|
||||
# Orchestrator decision step (raw SDK — needs tool_use blocks back)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _orchestrate_step(
|
||||
agent: Any,
|
||||
*,
|
||||
@@ -115,24 +123,63 @@ 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)."
|
||||
)
|
||||
|
||||
cost = agent.cost_usd(model, p, c)
|
||||
agent.record_trace_event({
|
||||
"kind": "skillorchestra_orchestrate",
|
||||
"model": model,
|
||||
"endpoint": endpoint,
|
||||
"prompt": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
})
|
||||
agent.record_trace_event(
|
||||
{
|
||||
"kind": "skillorchestra_orchestrate",
|
||||
"model": model,
|
||||
"endpoint": endpoint,
|
||||
"prompt": user,
|
||||
"response": text,
|
||||
"tool_calls": tool_calls,
|
||||
"tokens_in": p,
|
||||
"tokens_out": c,
|
||||
}
|
||||
)
|
||||
return text, tool_calls, p, c, cost
|
||||
|
||||
|
||||
@@ -140,6 +187,7 @@ def _orchestrate_step(
|
||||
# Context assembly — eval_frames.py:1305-1351
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_context(
|
||||
doc_list: List[Tuple[str, str]],
|
||||
code_list: List[Tuple[str, str]],
|
||||
@@ -177,6 +225,7 @@ def _build_context(
|
||||
# Main loop — eval_frames.py:run_single
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_orchestrator(
|
||||
agent: Any,
|
||||
problem: str,
|
||||
@@ -192,18 +241,22 @@ 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
|
||||
# supports tool calls, else Opus. ``router_model`` / ``router_endpoint``
|
||||
# are accepted as back-compat aliases (pre-restructure cfg key names).
|
||||
orch_endpoint = (cfg.get("orchestrator_endpoint")
|
||||
or cfg.get("router_endpoint")
|
||||
or agent._cloud_endpoint).lower()
|
||||
orch_model = (cfg.get("orchestrator_model")
|
||||
or cfg.get("router_model")
|
||||
or agent._cloud_model)
|
||||
if orch_endpoint not in ("anthropic", "openai"):
|
||||
orch_endpoint = (
|
||||
cfg.get("orchestrator_endpoint")
|
||||
or cfg.get("router_endpoint")
|
||||
or agent._cloud_endpoint
|
||||
).lower()
|
||||
orch_model = (
|
||||
cfg.get("orchestrator_model") or cfg.get("router_model") or agent._cloud_model
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -233,7 +286,9 @@ def run_orchestrator(
|
||||
if handbook is not None and strategy != "none":
|
||||
sa = parse_skill_analysis(orch_text)
|
||||
rr = get_routing_strategy(strategy, handbook).select_model(
|
||||
stage, sa, tool_call_model=tool_alias,
|
||||
stage,
|
||||
sa,
|
||||
tool_call_model=tool_alias,
|
||||
)
|
||||
return rr.model_alias
|
||||
return tool_alias or _STAGE_DEFAULT_ALIAS[stage]
|
||||
@@ -242,7 +297,10 @@ def run_orchestrator(
|
||||
used_rounds = step + 1
|
||||
is_last = step == max_rounds - 1
|
||||
context_str = _build_context(
|
||||
doc_list, code_list, attempt_list, char_cap=char_cap,
|
||||
doc_list,
|
||||
code_list,
|
||||
attempt_list,
|
||||
char_cap=char_cap,
|
||||
)
|
||||
|
||||
if handbook is not None and strategy != "none":
|
||||
@@ -253,14 +311,14 @@ def run_orchestrator(
|
||||
handbook=handbook,
|
||||
)
|
||||
else:
|
||||
user = (
|
||||
f"Problem: {problem}\n\n{context_str}\n\n"
|
||||
"Choose an appropriate tool."
|
||||
)
|
||||
user = f"Problem: {problem}\n\n{context_str}\n\nChoose an appropriate tool."
|
||||
|
||||
text, tcalls, p, c, ocost = _orchestrate_step(
|
||||
agent, user=user, model=orch_model,
|
||||
endpoint=orch_endpoint, max_tokens=orch_max_tokens,
|
||||
agent,
|
||||
user=user,
|
||||
model=orch_model,
|
||||
endpoint=orch_endpoint,
|
||||
max_tokens=orch_max_tokens,
|
||||
)
|
||||
tokens_cloud += p + c
|
||||
cost_usd += ocost
|
||||
@@ -289,23 +347,31 @@ def run_orchestrator(
|
||||
tool_alias = (tc.get("input") or {}).get("model")
|
||||
stage = _TOOL_STAGE.get(tool, "answer")
|
||||
chosen_alias = _route(stage, tool_alias, text)
|
||||
spec: ModelSpec = pool.get(chosen_alias) or pool[
|
||||
_STAGE_DEFAULT_ALIAS[stage]
|
||||
]
|
||||
route_log.append({
|
||||
"step": step,
|
||||
"tool": tool,
|
||||
"orchestrator_alias": tool_alias,
|
||||
"routed_alias": chosen_alias,
|
||||
"routed_model": spec.model,
|
||||
"is_local": spec.is_local,
|
||||
})
|
||||
spec: ModelSpec = (
|
||||
pool.get(chosen_alias) or pool[_STAGE_DEFAULT_ALIAS[stage]]
|
||||
)
|
||||
route_log.append(
|
||||
{
|
||||
"step": step,
|
||||
"tool": tool,
|
||||
"orchestrator_alias": tool_alias,
|
||||
"routed_alias": chosen_alias,
|
||||
"routed_model": spec.model,
|
||||
"is_local": spec.is_local,
|
||||
}
|
||||
)
|
||||
tool_calls_n += 1
|
||||
|
||||
if tool == "search":
|
||||
res = run_search(
|
||||
agent, spec, context_str=context_str, problem=problem,
|
||||
retriever_url=retriever_url, web_search_max_uses=ws_max_uses,
|
||||
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]
|
||||
@@ -313,13 +379,19 @@ def run_orchestrator(
|
||||
web_uses += res.get("web_search_uses", 0)
|
||||
elif tool in ("enhance_reasoning", "code"):
|
||||
res = run_code(
|
||||
agent, spec, context_str=context_str, problem=problem,
|
||||
agent,
|
||||
spec,
|
||||
context_str=context_str,
|
||||
problem=problem,
|
||||
bash_timeout_s=code_timeout,
|
||||
)
|
||||
code_list.append((res["generated_code"], res["exec_result"]))
|
||||
else: # answer
|
||||
res = run_answer(
|
||||
agent, spec, context_str=context_str, problem=problem,
|
||||
agent,
|
||||
spec,
|
||||
context_str=context_str,
|
||||
problem=problem,
|
||||
max_tokens=answer_max_tokens,
|
||||
)
|
||||
final_pred = res["pred"]
|
||||
@@ -335,12 +407,14 @@ def run_orchestrator(
|
||||
if finish:
|
||||
break
|
||||
|
||||
agent.record_trace_event({
|
||||
"kind": "skillorchestra_route_log",
|
||||
"strategy": strategy,
|
||||
"rounds_used": used_rounds,
|
||||
"routes": route_log,
|
||||
})
|
||||
agent.record_trace_event(
|
||||
{
|
||||
"kind": "skillorchestra_route_log",
|
||||
"strategy": strategy,
|
||||
"rounds_used": used_rounds,
|
||||
"routes": route_log,
|
||||
}
|
||||
)
|
||||
|
||||
meta = {
|
||||
"tokens_local": tokens_local,
|
||||
|
||||
@@ -32,8 +32,14 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
STAGE_ALIASES: Dict[str, List[str]] = {
|
||||
"search": ["search-1", "search-2", "search-3"],
|
||||
"reasoning": ["reasoner-1", "reasoner-2", "reasoner-3"],
|
||||
"answer": ["answer-1", "answer-2", "answer-3", "answer-4",
|
||||
"answer-math-1", "answer-math-2"],
|
||||
"answer": [
|
||||
"answer-1",
|
||||
"answer-2",
|
||||
"answer-3",
|
||||
"answer-4",
|
||||
"answer-math-1",
|
||||
"answer-math-2",
|
||||
],
|
||||
}
|
||||
|
||||
# Every alias the orchestrator can emit, flat.
|
||||
@@ -42,9 +48,13 @@ ALL_ALIASES: List[str] = [a for aliases in STAGE_ALIASES.values() for a in alias
|
||||
# Default tier: which aliases collapse onto the cloud model vs the local
|
||||
# model. Dearer ``-1``/``-2`` (+ answer-math-1) -> cloud; cheaper -> local.
|
||||
_CLOUD_ALIASES = {
|
||||
"search-1", "search-2",
|
||||
"reasoner-1", "reasoner-2",
|
||||
"answer-1", "answer-2", "answer-math-1",
|
||||
"search-1",
|
||||
"search-2",
|
||||
"reasoner-1",
|
||||
"reasoner-2",
|
||||
"answer-1",
|
||||
"answer-2",
|
||||
"answer-math-1",
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +64,8 @@ class ModelSpec:
|
||||
|
||||
alias: str
|
||||
model: str
|
||||
endpoint: str # "anthropic" | "openai" | "gemini" | "http://..."
|
||||
kind: str # "cloud" | "local"
|
||||
endpoint: str # "anthropic" | "openai" | "gemini" | "http://..."
|
||||
kind: str # "cloud" | "local"
|
||||
|
||||
@property
|
||||
def is_local(self) -> bool:
|
||||
@@ -87,7 +97,10 @@ def build_pool(
|
||||
pool[alias] = ModelSpec(alias, cloud_model, cloud_endpoint, "cloud")
|
||||
else:
|
||||
pool[alias] = ModelSpec(
|
||||
alias, local_model, local_endpoint, "local" # type: ignore[arg-type]
|
||||
alias,
|
||||
local_model,
|
||||
local_endpoint,
|
||||
"local", # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
for alias, spec in (overrides or {}).items():
|
||||
@@ -133,18 +146,30 @@ def call_alias(
|
||||
ep = spec.endpoint.lower()
|
||||
if ep == "anthropic":
|
||||
text, p, c, _ = agent._call_anthropic(
|
||||
spec.model, user=user, system=system,
|
||||
max_tokens=max_tokens, temperature=temperature, trace_role="cloud",
|
||||
spec.model,
|
||||
user=user,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
trace_role="cloud",
|
||||
)
|
||||
elif ep == "openai":
|
||||
text, p, c = agent._call_openai(
|
||||
spec.model, user=user, system=system,
|
||||
max_tokens=max_tokens, temperature=temperature, trace_role="cloud",
|
||||
spec.model,
|
||||
user=user,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
trace_role="cloud",
|
||||
)
|
||||
elif ep == "gemini":
|
||||
text, p, c = agent._call_gemini(
|
||||
spec.model, user=user, system=system,
|
||||
max_tokens=max_tokens, temperature=temperature, trace_role="cloud",
|
||||
spec.model,
|
||||
user=user,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
trace_role="cloud",
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unsupported pool endpoint: {spec.endpoint!r}")
|
||||
|
||||
@@ -94,7 +94,12 @@ class StageSkillHandbook:
|
||||
"answer": {},
|
||||
}
|
||||
self.model_profiles: Dict[str, ModelProfile] = {}
|
||||
self.usage_patterns: Dict[str, Any] = {"stages": {}, "guidelines": {}, "models": {}, "raw": {}}
|
||||
self.usage_patterns: Dict[str, Any] = {
|
||||
"stages": {},
|
||||
"guidelines": {},
|
||||
"models": {},
|
||||
"raw": {},
|
||||
}
|
||||
self.routing_insights: List[str] = []
|
||||
self.learning_history: List[Dict[str, Any]] = []
|
||||
self.version = "1.0.0"
|
||||
@@ -102,7 +107,10 @@ class StageSkillHandbook:
|
||||
self.updated_at = ""
|
||||
|
||||
def get_model_skill_scores(self) -> Dict[str, Dict[str, float]]:
|
||||
return {alias: profile.skill_scores for alias, profile in self.model_profiles.items()}
|
||||
return {
|
||||
alias: profile.skill_scores
|
||||
for alias, profile in self.model_profiles.items()
|
||||
}
|
||||
|
||||
def get_models_for_stage(self, stage: str) -> List[ModelProfile]:
|
||||
return [p for p in self.model_profiles.values() if p.stage == stage]
|
||||
@@ -135,19 +143,29 @@ class StageSkillHandbook:
|
||||
|
||||
def format_model_performance(self, stage: str) -> str:
|
||||
profiles = self.get_models_for_stage(stage)
|
||||
valid_prefixes = {"search": ["search-"], "code": ["reasoner-", "code-"], "answer": ["answer-"]}
|
||||
valid_prefixes = {
|
||||
"search": ["search-"],
|
||||
"code": ["reasoner-", "code-"],
|
||||
"answer": ["answer-"],
|
||||
}
|
||||
prefixes = valid_prefixes.get(stage, [])
|
||||
|
||||
lines = []
|
||||
for p in profiles:
|
||||
if not any(p.model_alias.startswith(prefix) for prefix in prefixes):
|
||||
continue
|
||||
has_data = (p.skill_scores and len(p.skill_scores) > 0) or p.strengths or p.weaknesses
|
||||
has_data = (
|
||||
(p.skill_scores and len(p.skill_scores) > 0)
|
||||
or p.strengths
|
||||
or p.weaknesses
|
||||
)
|
||||
if p.total_attempts > 0 or has_data:
|
||||
lines.append(f"\n### {p.model_alias} ({p.actual_model})")
|
||||
if p.total_attempts > 0:
|
||||
rate = p.total_successes / p.total_attempts
|
||||
lines.append(f"Overall: {rate:.0%} success ({p.total_successes}/{p.total_attempts})")
|
||||
lines.append(
|
||||
f"Overall: {rate:.0%} success ({p.total_successes}/{p.total_attempts})"
|
||||
)
|
||||
else:
|
||||
lines.append("Overall: 0% overall")
|
||||
if p.skill_scores:
|
||||
@@ -157,7 +175,9 @@ class StageSkillHandbook:
|
||||
for sid, s in p.skill_scores.items()
|
||||
if (sid.split(".")[0] if "." in sid else sid) in ("code", stage)
|
||||
}
|
||||
for skill_id, score in sorted(stage_skill_scores.items(), key=lambda x: x[1], reverse=True):
|
||||
for skill_id, score in sorted(
|
||||
stage_skill_scores.items(), key=lambda x: x[1], reverse=True
|
||||
):
|
||||
lines.append(f" - {skill_id}: {score:.0%}")
|
||||
if p.strengths:
|
||||
lines.append(f"Strengths: {', '.join(p.strengths[:3])}")
|
||||
@@ -220,7 +240,9 @@ def parse_skill_analysis(output: str) -> Optional[SkillAnalysis]:
|
||||
try:
|
||||
data = json.loads(match.group(1).strip())
|
||||
required_skills = [
|
||||
SkillWeight(skill_id=s.get("skill_id", ""), percentage=float(s.get("percentage", 0)))
|
||||
SkillWeight(
|
||||
skill_id=s.get("skill_id", ""), percentage=float(s.get("percentage", 0))
|
||||
)
|
||||
for s in data.get("required_skills", [])
|
||||
]
|
||||
return SkillAnalysis(
|
||||
@@ -272,7 +294,14 @@ class RoutingStrategy:
|
||||
if stage == "reasoning":
|
||||
return ["reasoner-1", "reasoner-2", "reasoner-3"]
|
||||
if stage == "answer":
|
||||
return ["answer-1", "answer-2", "answer-3", "answer-4", "answer-math-1", "answer-math-2"]
|
||||
return [
|
||||
"answer-1",
|
||||
"answer-2",
|
||||
"answer-3",
|
||||
"answer-4",
|
||||
"answer-math-1",
|
||||
"answer-math-2",
|
||||
]
|
||||
return []
|
||||
|
||||
def select_model(
|
||||
@@ -292,9 +321,17 @@ class RouterDecidesStrategy(RoutingStrategy):
|
||||
tool_call_model: Optional[str] = None,
|
||||
) -> ModelRoutingResult:
|
||||
if tool_call_model:
|
||||
return ModelRoutingResult(tool_call_model, "router_decides_from_tool_call", 1.0)
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "router_decides_fallback", 0.5)
|
||||
return ModelRoutingResult(
|
||||
tool_call_model, "router_decides_from_tool_call", 1.0
|
||||
)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "router_decides_fallback", 0.5
|
||||
)
|
||||
|
||||
|
||||
class AnalyzeModelDecideStrategy(RoutingStrategy):
|
||||
@@ -305,17 +342,33 @@ class AnalyzeModelDecideStrategy(RoutingStrategy):
|
||||
tool_call_model: Optional[str] = None,
|
||||
) -> ModelRoutingResult:
|
||||
if tool_call_model:
|
||||
return ModelRoutingResult(tool_call_model, "analyze_model_decide_with_skill_analysis", 1.0)
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "analyze_model_decide_fallback", 0.5)
|
||||
return ModelRoutingResult(
|
||||
tool_call_model, "analyze_model_decide_with_skill_analysis", 1.0
|
||||
)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "analyze_model_decide_fallback", 0.5
|
||||
)
|
||||
|
||||
|
||||
class WeightedAverageStrategy(RoutingStrategy):
|
||||
COST_TIERS = {
|
||||
"search-3": 1, "search-2": 2, "search-1": 3,
|
||||
"reasoner-3": 1, "reasoner-2": 2, "reasoner-1": 3,
|
||||
"answer-math-2": 1, "answer-4": 1, "answer-3": 2,
|
||||
"answer-math-1": 2, "answer-2": 3, "answer-1": 4,
|
||||
"search-3": 1,
|
||||
"search-2": 2,
|
||||
"search-1": 3,
|
||||
"reasoner-3": 1,
|
||||
"reasoner-2": 2,
|
||||
"reasoner-1": 3,
|
||||
"answer-math-2": 1,
|
||||
"answer-4": 1,
|
||||
"answer-3": 2,
|
||||
"answer-math-1": 2,
|
||||
"answer-2": 3,
|
||||
"answer-1": 4,
|
||||
}
|
||||
|
||||
def select_model(
|
||||
@@ -326,9 +379,17 @@ class WeightedAverageStrategy(RoutingStrategy):
|
||||
) -> ModelRoutingResult:
|
||||
if not skill_analysis or not skill_analysis.required_skills:
|
||||
if tool_call_model:
|
||||
return ModelRoutingResult(tool_call_model, "weighted_avg_no_skills_use_tool_call", 0.7)
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weighted_avg_no_skills_fallback", 0.5)
|
||||
return ModelRoutingResult(
|
||||
tool_call_model, "weighted_avg_no_skills_use_tool_call", 0.7
|
||||
)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "weighted_avg_no_skills_fallback", 0.5
|
||||
)
|
||||
|
||||
models = self._get_models_for_stage(stage)
|
||||
model_scores = {}
|
||||
@@ -341,15 +402,25 @@ class WeightedAverageStrategy(RoutingStrategy):
|
||||
score = scores.get(sid, 0.0)
|
||||
weighted_sum += weight * score
|
||||
total_weight += weight
|
||||
model_scores[model] = weighted_sum / total_weight if total_weight > 0 else 0.5
|
||||
model_scores[model] = (
|
||||
weighted_sum / total_weight if total_weight > 0 else 0.5
|
||||
)
|
||||
|
||||
if not model_scores:
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weighted_avg_no_model_scores", 0.5)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "weighted_avg_no_model_scores", 0.5
|
||||
)
|
||||
max_score = max(model_scores.values())
|
||||
best = [m for m, s in model_scores.items() if abs(s - max_score) < 0.001]
|
||||
best.sort(key=lambda m: self.COST_TIERS.get(m, 999))
|
||||
return ModelRoutingResult(best[0], "weighted_avg_from_skill_analysis", max_score, model_scores)
|
||||
return ModelRoutingResult(
|
||||
best[0], "weighted_avg_from_skill_analysis", max_score, model_scores
|
||||
)
|
||||
|
||||
|
||||
class WeakestSkillStrategy(RoutingStrategy):
|
||||
@@ -361,18 +432,36 @@ class WeakestSkillStrategy(RoutingStrategy):
|
||||
) -> ModelRoutingResult:
|
||||
if not skill_analysis or not skill_analysis.required_skills:
|
||||
if tool_call_model:
|
||||
return ModelRoutingResult(tool_call_model, "weakest_skill_no_skills_use_tool_call", 0.7)
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weakest_skill_no_skills_fallback", 0.5)
|
||||
return ModelRoutingResult(
|
||||
tool_call_model, "weakest_skill_no_skills_use_tool_call", 0.7
|
||||
)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "weakest_skill_no_skills_fallback", 0.5
|
||||
)
|
||||
weakest = min(skill_analysis.required_skills, key=lambda s: s.percentage)
|
||||
sid = self._find_skill_id(stage, weakest.skill_id) or weakest.skill_id
|
||||
models = self._get_models_for_stage(stage)
|
||||
model_scores = {m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models}
|
||||
model_scores = {
|
||||
m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models
|
||||
}
|
||||
if not model_scores:
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weakest_skill_no_model_scores", 0.5)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "weakest_skill_no_model_scores", 0.5
|
||||
)
|
||||
best = max(model_scores, key=model_scores.get)
|
||||
return ModelRoutingResult(best, f"weakest_skill_{weakest.skill_id}", model_scores[best], model_scores)
|
||||
return ModelRoutingResult(
|
||||
best, f"weakest_skill_{weakest.skill_id}", model_scores[best], model_scores
|
||||
)
|
||||
|
||||
|
||||
class StrongestSkillStrategy(RoutingStrategy):
|
||||
@@ -384,18 +473,41 @@ class StrongestSkillStrategy(RoutingStrategy):
|
||||
) -> ModelRoutingResult:
|
||||
if not skill_analysis or not skill_analysis.required_skills:
|
||||
if tool_call_model:
|
||||
return ModelRoutingResult(tool_call_model, "strongest_skill_no_skills_use_tool_call", 0.7)
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "strongest_skill_no_skills_fallback", 0.5)
|
||||
return ModelRoutingResult(
|
||||
tool_call_model, "strongest_skill_no_skills_use_tool_call", 0.7
|
||||
)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"),
|
||||
"strongest_skill_no_skills_fallback",
|
||||
0.5,
|
||||
)
|
||||
strongest = max(skill_analysis.required_skills, key=lambda s: s.percentage)
|
||||
sid = self._find_skill_id(stage, strongest.skill_id) or strongest.skill_id
|
||||
models = self._get_models_for_stage(stage)
|
||||
model_scores = {m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models}
|
||||
model_scores = {
|
||||
m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models
|
||||
}
|
||||
if not model_scores:
|
||||
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
|
||||
return ModelRoutingResult(defaults.get(stage, "answer-1"), "strongest_skill_no_model_scores", 0.5)
|
||||
defaults = {
|
||||
"search": "search-1",
|
||||
"reasoning": "reasoner-1",
|
||||
"answer": "answer-1",
|
||||
}
|
||||
return ModelRoutingResult(
|
||||
defaults.get(stage, "answer-1"), "strongest_skill_no_model_scores", 0.5
|
||||
)
|
||||
best = max(model_scores, key=model_scores.get)
|
||||
return ModelRoutingResult(best, f"strongest_skill_{strongest.skill_id}", model_scores[best], model_scores)
|
||||
return ModelRoutingResult(
|
||||
best,
|
||||
f"strongest_skill_{strongest.skill_id}",
|
||||
model_scores[best],
|
||||
model_scores,
|
||||
)
|
||||
|
||||
|
||||
ROUTING_STRATEGIES = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,8 +41,7 @@ _SEARCH_CAPABLE_ENDPOINTS = ("anthropic", "openai", "gemini")
|
||||
|
||||
_SEARCH_DESC = "Search for missing information."
|
||||
_CODE_DESC = (
|
||||
"Write and execute Python code to compute intermediate results for "
|
||||
"the problem."
|
||||
"Write and execute Python code to compute intermediate results for the problem."
|
||||
)
|
||||
_ANSWER_DESC = (
|
||||
"Extract the final answer when you have gathered enough information "
|
||||
@@ -51,8 +51,14 @@ _ANSWER_DESC = (
|
||||
_ENUMS = {
|
||||
"search": ["search-1", "search-2", "search-3"],
|
||||
"enhance_reasoning": ["reasoner-1", "reasoner-2", "reasoner-3"],
|
||||
"answer": ["answer-1", "answer-2", "answer-3", "answer-4",
|
||||
"answer-math-1", "answer-math-2"],
|
||||
"answer": [
|
||||
"answer-1",
|
||||
"answer-2",
|
||||
"answer-3",
|
||||
"answer-4",
|
||||
"answer-math-1",
|
||||
"answer-math-2",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -75,15 +81,17 @@ def anthropic_tools() -> List[Dict[str, Any]]:
|
||||
("enhance_reasoning", _CODE_DESC),
|
||||
("answer", _ANSWER_DESC),
|
||||
):
|
||||
out.append({
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
})
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -95,9 +103,33 @@ def openai_tools() -> List[Dict[str, Any]]:
|
||||
("enhance_reasoning", _CODE_DESC),
|
||||
("answer", _ANSWER_DESC),
|
||||
):
|
||||
out.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
out.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
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": {
|
||||
@@ -105,8 +137,8 @@ def openai_tools() -> List[Dict[str, Any]]:
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -114,6 +146,7 @@ def openai_tools() -> List[Dict[str, Any]]:
|
||||
# enhance_reasoning / code — eval_frames.py:659-812
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_code(
|
||||
agent: Any,
|
||||
spec: ModelSpec,
|
||||
@@ -129,7 +162,8 @@ def run_code(
|
||||
rather than raising — the orchestrator learns the model can't code.
|
||||
"""
|
||||
prompt = (
|
||||
context_str.strip() + "\n\n"
|
||||
context_str.strip()
|
||||
+ "\n\n"
|
||||
+ f"Question: {problem}\nInstead of directly answering the question, "
|
||||
"please write additional python code that will give intermidiate "
|
||||
"results after execution. Wrap the code within ```python and ```. "
|
||||
@@ -137,7 +171,11 @@ def run_code(
|
||||
"initialization."
|
||||
)
|
||||
text, p, c, cost = call_alias(
|
||||
agent, spec, user=prompt, max_tokens=8000, temperature=1.0,
|
||||
agent,
|
||||
spec,
|
||||
user=prompt,
|
||||
max_tokens=8000,
|
||||
temperature=1.0,
|
||||
)
|
||||
generated_code = ""
|
||||
if "```python" in text:
|
||||
@@ -176,6 +214,7 @@ def run_code(
|
||||
# answer — eval_frames.py:814-997
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_answer(
|
||||
agent: Any,
|
||||
spec: ModelSpec,
|
||||
@@ -198,11 +237,15 @@ def run_answer(
|
||||
boxed = False
|
||||
|
||||
if "qwen3" in model_l and "235" not in model_l:
|
||||
system = "Please reason step by step, and put your final answer within \\boxed{}."
|
||||
system = (
|
||||
"Please reason step by step, and put your final answer within \\boxed{}."
|
||||
)
|
||||
user = base
|
||||
boxed = True
|
||||
elif "qwen2.5-math" in model_l or "qwen-2.5-math" in model_l:
|
||||
system = "Please reason step by step, and put your final answer within \\boxed{}."
|
||||
system = (
|
||||
"Please reason step by step, and put your final answer within \\boxed{}."
|
||||
)
|
||||
user = base
|
||||
boxed = True
|
||||
else:
|
||||
@@ -215,8 +258,12 @@ def run_answer(
|
||||
)
|
||||
|
||||
text, p, c, cost = call_alias(
|
||||
agent, spec, user=user, system=system,
|
||||
max_tokens=max_tokens, temperature=1.0,
|
||||
agent,
|
||||
spec,
|
||||
user=user,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=1.0,
|
||||
)
|
||||
|
||||
pred = ""
|
||||
@@ -247,6 +294,7 @@ def run_answer(
|
||||
# search — eval_frames.py:999-1096
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_search(
|
||||
agent: Any,
|
||||
spec: ModelSpec,
|
||||
@@ -256,6 +304,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.
|
||||
|
||||
@@ -265,13 +315,18 @@ def run_search(
|
||||
OpenJarvis substitution for the missing FAISS wiki index).
|
||||
"""
|
||||
prompt = (
|
||||
context_str.strip() + "\n\n"
|
||||
context_str.strip()
|
||||
+ "\n\n"
|
||||
+ f"Question: {problem}\nInstead of directly answering the question, "
|
||||
"please think hard and write a concise query to search Wikipedia. "
|
||||
"Wrap the query within <query> and </query>."
|
||||
)
|
||||
text, p, c, cost = call_alias(
|
||||
agent, spec, user=prompt, max_tokens=8000, temperature=1.0,
|
||||
agent,
|
||||
spec,
|
||||
user=prompt,
|
||||
max_tokens=8000,
|
||||
temperature=1.0,
|
||||
)
|
||||
if "<query>" in text:
|
||||
query = text.split("<query>")[-1].split("</query>")[0].strip()
|
||||
@@ -283,7 +338,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
|
||||
|
||||
@@ -294,7 +354,9 @@ def run_search(
|
||||
}
|
||||
try:
|
||||
results = requests.post(
|
||||
f"{retriever_url.rstrip('/')}/retrieve", json=payload, timeout=120,
|
||||
f"{retriever_url.rstrip('/')}/retrieve",
|
||||
json=payload,
|
||||
timeout=120,
|
||||
).json()
|
||||
for r in results[0]:
|
||||
doc = r.get("document", {})
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import Any, Dict, List, Optional
|
||||
# BetaCompetence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class BetaCompetence:
|
||||
"""Bayesian competence estimate for an agent on a specific skill.
|
||||
@@ -80,6 +81,7 @@ class BetaCompetence:
|
||||
# CostStats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostStats:
|
||||
"""Execution cost statistics for an agent under a specific mode.
|
||||
@@ -110,11 +112,17 @@ class CostStats:
|
||||
"""Incremental running-average update."""
|
||||
n = self.total_executions
|
||||
self.avg_prompt_tokens = (self.avg_prompt_tokens * n + prompt_tokens) / (n + 1)
|
||||
self.avg_completion_tokens = (self.avg_completion_tokens * n + completion_tokens) / (n + 1)
|
||||
self.avg_completion_tokens = (
|
||||
self.avg_completion_tokens * n + completion_tokens
|
||||
) / (n + 1)
|
||||
self.avg_latency_s = (self.avg_latency_s * n + latency_s) / (n + 1)
|
||||
self.avg_cost_usd = (self.avg_cost_usd * n + cost_usd) / (n + 1)
|
||||
self.avg_completion_cost_usd = (self.avg_completion_cost_usd * n + completion_cost_usd) / (n + 1)
|
||||
self.avg_prompt_cost_usd = (self.avg_prompt_cost_usd * n + prompt_cost_usd) / (n + 1)
|
||||
self.avg_completion_cost_usd = (
|
||||
self.avg_completion_cost_usd * n + completion_cost_usd
|
||||
) / (n + 1)
|
||||
self.avg_prompt_cost_usd = (self.avg_prompt_cost_usd * n + prompt_cost_usd) / (
|
||||
n + 1
|
||||
)
|
||||
self.total_executions = n + 1
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -137,6 +145,7 @@ class CostStats:
|
||||
# RoutingInsight
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoutingInsight:
|
||||
"""A single routing insight learned from execution traces"""
|
||||
@@ -165,6 +174,7 @@ class RoutingInsight:
|
||||
# ModeMetadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModeMetadata:
|
||||
"""Mode-level routing metadata."""
|
||||
@@ -197,6 +207,7 @@ class ModeMetadata:
|
||||
# Skill
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillProvenance:
|
||||
"""Tracks how and why a skill was discovered."""
|
||||
@@ -231,7 +242,7 @@ class Skill:
|
||||
indicators: List[str] = field(default_factory=list)
|
||||
examples: List[str] = field(default_factory=list)
|
||||
mode: str = ""
|
||||
parent_skill_id: Optional[str] = None # for hierarchical skills
|
||||
parent_skill_id: Optional[str] = None # for hierarchical skills
|
||||
provenance: SkillProvenance = field(default_factory=SkillProvenance)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -273,6 +284,7 @@ class Skill:
|
||||
# AgentProfile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentProfile:
|
||||
"""Agent profile for skill-aware orchestration."""
|
||||
@@ -309,9 +321,7 @@ class AgentProfile:
|
||||
"""Update competence estimate for a skill."""
|
||||
self.get_competence_dist(skill_id).update(success)
|
||||
|
||||
def weighted_competence(
|
||||
self, skill_weights: Dict[str, float]
|
||||
) -> float:
|
||||
def weighted_competence(self, skill_weights: Dict[str, float]) -> float:
|
||||
"""Compute weighted competence: sum w_{t,sigma} * alpha/(alpha+beta)."""
|
||||
if not skill_weights:
|
||||
return 0.5
|
||||
@@ -334,9 +344,7 @@ class AgentProfile:
|
||||
]
|
||||
return sum(scores) / len(scores) if scores else 0.0
|
||||
|
||||
def category_competence_for_skills(
|
||||
self, active_skill_ids: List[str]
|
||||
) -> float:
|
||||
def category_competence_for_skills(self, active_skill_ids: List[str]) -> float:
|
||||
"""Category-level competence for hierarchical tie-breaking.
|
||||
|
||||
Extracts parent categories from active_skill_ids (e.g. 'entertainment_knowledge'
|
||||
@@ -349,7 +357,9 @@ class AgentProfile:
|
||||
categories.add(cat)
|
||||
if not categories:
|
||||
return 0.0
|
||||
return sum(self.category_competence(cat) for cat in categories) / len(categories)
|
||||
return sum(self.category_competence(cat) for cat in categories) / len(
|
||||
categories
|
||||
)
|
||||
|
||||
@property
|
||||
def overall_success_rate(self) -> float:
|
||||
@@ -390,8 +400,12 @@ class AgentProfile:
|
||||
"skill_scores": skill_scores,
|
||||
"skill_attempts": skill_attempts,
|
||||
"skill_successes": skill_successes,
|
||||
"total_attempts": self.total_attempts if self.total_attempts > 0 else skill_total_attempts,
|
||||
"total_successes": self.total_successes if self.total_attempts > 0 else skill_total_successes,
|
||||
"total_attempts": self.total_attempts
|
||||
if self.total_attempts > 0
|
||||
else skill_total_attempts,
|
||||
"total_successes": self.total_successes
|
||||
if self.total_attempts > 0
|
||||
else skill_total_successes,
|
||||
"cost_stats": self.cost_stats.to_dict(),
|
||||
"routing_signals": self.routing_signals,
|
||||
"strengths": self.strengths,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -19,6 +19,7 @@ from openjarvis.agents.prompt_loader import (
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
|
||||
from openjarvis.engine._base import estimate_prompt_tokens
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
from openjarvis.tools._stubs import BaseTool, build_tool_descriptions
|
||||
|
||||
@@ -116,8 +117,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
max_prompt_tokens: int = 3000,
|
||||
) -> list[Message]:
|
||||
"""Truncate messages if estimated token count exceeds limit."""
|
||||
total_chars = sum(len(m.content) for m in messages)
|
||||
estimated_tokens = total_chars // 4
|
||||
estimated_tokens = estimate_prompt_tokens(messages)
|
||||
if estimated_tokens <= max_prompt_tokens:
|
||||
return messages
|
||||
# Find the last user message and truncate its content
|
||||
@@ -125,7 +125,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
if messages[i].role == Role.USER:
|
||||
excess_tokens = estimated_tokens - max_prompt_tokens
|
||||
excess_chars = excess_tokens * 4
|
||||
original = messages[i].content
|
||||
original = messages[i].content or ""
|
||||
if len(original) > excess_chars + 200:
|
||||
truncated = original[: len(original) - excess_chars]
|
||||
messages[i] = Message(
|
||||
@@ -258,7 +258,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
# still emitted before re-raising.
|
||||
self._emit_turn_end(turns=1, error=True)
|
||||
raise
|
||||
content = self._strip_think_tags(result.get("content", ""))
|
||||
content = self._strip_think_tags(result.get("content") or "")
|
||||
usage = result.get("usage", {})
|
||||
self._emit_turn_end(turns=1)
|
||||
return AgentResult(
|
||||
@@ -315,7 +315,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
for k in total_usage:
|
||||
total_usage[k] += usage.get(k, 0)
|
||||
|
||||
content = result.get("content", "")
|
||||
content = result.get("content") or ""
|
||||
# Strip think tags so they don't interfere with parsing
|
||||
content = self._strip_think_tags(content)
|
||||
last_content = content
|
||||
|
||||
@@ -327,9 +327,7 @@ class OpenCodeAgent(BaseAgent):
|
||||
self._ensure_server()
|
||||
except RuntimeError as exc:
|
||||
self._emit_turn_end(turns=1, error=True)
|
||||
return AgentResult(
|
||||
content=str(exc), turns=1, metadata={"error": True}
|
||||
)
|
||||
return AgentResult(content=str(exc), turns=1, metadata={"error": True})
|
||||
|
||||
data: dict = {}
|
||||
turn_parts: List[dict] = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
A small, self-contained planner-executor loop:
|
||||
|
||||
* the planner is a local Ollama chat model (default ``gemma4:31b``),
|
||||
* the planner is supplied by the caller (the web endpoint resolves it from
|
||||
config, falling back to ``gemma4:31b`` on Ollama for legacy installs),
|
||||
* the only tool it can call is :meth:`HybridSearch.search`,
|
||||
* it gets up to ``max_iterations`` tool calls,
|
||||
* tool results are trimmed before re-entering the context window, and
|
||||
@@ -105,8 +106,8 @@ SEARCH_TOOL_SPEC: Dict[str, Any] = {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Restrict the search to one or more connectors. Use this "
|
||||
"whenever the user names a data source (e.g. \"in my "
|
||||
"Granola notes\" → ['granola']; \"check Slack and Gmail\" "
|
||||
'whenever the user names a data source (e.g. "in my '
|
||||
'Granola notes" → [\'granola\']; "check Slack and Gmail" '
|
||||
"→ ['slack', 'gmail']). Valid IDs include: gmail, slack, "
|
||||
"granola, notion, obsidian, gcalendar, gdrive, gmail_imap, "
|
||||
"outlook, imessage, whatsapp, apple_notes, apple_contacts, "
|
||||
@@ -142,10 +143,11 @@ Strategy:
|
||||
3. The `time_range` argument is a JSON object: `{{"start": "<ISO 8601>", "end": "<ISO 8601>"}}`. Either bound may be omitted, but pass at least one whenever the user gave you a temporal cue.
|
||||
4. When the user names a specific data source — "my Granola notes", "in Slack", "from my email" — you MUST pass `sources=[...]` with the matching connector ID. Only use IDs that appear in the connected-sources list above; do NOT invent or assume sources that are not connected. Common synonyms: "meeting notes"/"meetings"/"transcripts" → granola; "email"/"inbox" → gmail; "DMs"/"channels" → slack. Without this filter the search returns mail/messages ABOUT a tool instead of records FROM that tool.
|
||||
4a. Never apologize about sources that aren't in the connected-sources list — if the user asks about "Notion" but Notion isn't connected, just say "Notion isn't connected, but here's what I found in {available_sources}" and answer from what is available.
|
||||
5. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
|
||||
6. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching — always try first.
|
||||
7. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Never send an empty query or a query with no parameters — extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
|
||||
8. Tool calls — search AND clarify — share a budget of 5 total. Spend wisely.
|
||||
5. When the user asks for "next", "upcoming", "future", or "soon" calendar events/meetings/appointments, use `sources=["gcalendar"]` if gcalendar is connected, set `time_range={{"start": "{today}"}}`, and use `query=""` unless the user gave a specific topic such as "dentist" or "music lesson". This returns the nearest upcoming calendar items across calendars instead of keyword-matching only birthdays or event titles.
|
||||
6. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
|
||||
7. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching — always try first.
|
||||
8. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Only use an empty query when structured filters carry the request; never send a search with no concrete parameters. Extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
|
||||
9. Tool calls — search AND clarify — share a budget of 5 total. Spend wisely.
|
||||
|
||||
Synthesis rules:
|
||||
- Cite sources as individual numbers in square brackets. Always separate — write [4] [7] [20], never [4, 7, 20]. Never format citations as markdown links. Just the number in brackets: [1]. The `ref` field on each hit is the citation number.
|
||||
@@ -204,7 +206,9 @@ def shape_results_for_model(
|
||||
if i < detailed_top:
|
||||
base["snippet"] = h.content_snippet
|
||||
if h.thread_context:
|
||||
base["thread"] = _trim_thread_context(h.thread_context, thread_ctx_per_hit)
|
||||
base["thread"] = _trim_thread_context(
|
||||
h.thread_context, thread_ctx_per_hit
|
||||
)
|
||||
out_hits.append(base)
|
||||
return {
|
||||
"num_results": len(hits),
|
||||
@@ -219,7 +223,9 @@ def _hit_date(timestamp: str) -> str:
|
||||
if not timestamp:
|
||||
return ""
|
||||
try:
|
||||
return datetime.fromisoformat(timestamp.replace("Z", "+00:00")).date().isoformat()
|
||||
return (
|
||||
datetime.fromisoformat(timestamp.replace("Z", "+00:00")).date().isoformat()
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
return str(timestamp)[:10]
|
||||
|
||||
@@ -237,7 +243,7 @@ def _bare_doc_id(source: str, document_id: str) -> str:
|
||||
return ""
|
||||
prefix = f"{source}:"
|
||||
if source and document_id.startswith(prefix):
|
||||
return document_id[len(prefix):]
|
||||
return document_id[len(prefix) :]
|
||||
return document_id
|
||||
|
||||
|
||||
@@ -518,6 +524,7 @@ class ResearchAgent:
|
||||
def _parse_time_range(raw: Any):
|
||||
if not raw or not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
def _maybe(v):
|
||||
if not v:
|
||||
return None
|
||||
@@ -525,6 +532,7 @@ class ResearchAgent:
|
||||
return datetime.fromisoformat(str(v).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
start = _maybe(raw.get("start"))
|
||||
end = _maybe(raw.get("end"))
|
||||
if start is None and end is None:
|
||||
@@ -555,9 +563,16 @@ class ResearchAgent:
|
||||
"query": query,
|
||||
"person": person,
|
||||
"time_range": (
|
||||
{"start": time_range[0].isoformat() if time_range and time_range[0] else None,
|
||||
"end": time_range[1].isoformat() if time_range and time_range[1] else None}
|
||||
if time_range else None
|
||||
{
|
||||
"start": time_range[0].isoformat()
|
||||
if time_range and time_range[0]
|
||||
else None,
|
||||
"end": time_range[1].isoformat()
|
||||
if time_range and time_range[1]
|
||||
else None,
|
||||
}
|
||||
if time_range
|
||||
else None
|
||||
),
|
||||
"sources": sources,
|
||||
"limit": limit,
|
||||
@@ -688,9 +703,7 @@ class ResearchAgent:
|
||||
)
|
||||
continue
|
||||
fallback = "(model returned no content and no tool calls)"
|
||||
self._emit(
|
||||
{"type": "final_answer", "text": fallback, "sources": []}
|
||||
)
|
||||
self._emit({"type": "final_answer", "text": fallback, "sources": []})
|
||||
return ResearchResult(
|
||||
answer=fallback,
|
||||
iterations=iterations,
|
||||
@@ -716,7 +729,11 @@ class ResearchAgent:
|
||||
name = tc.get("name", "")
|
||||
raw_args = tc.get("arguments", "{}") or "{}"
|
||||
try:
|
||||
args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args)
|
||||
args = (
|
||||
json.loads(raw_args)
|
||||
if isinstance(raw_args, str)
|
||||
else dict(raw_args)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
@@ -764,7 +781,10 @@ class ResearchAgent:
|
||||
)
|
||||
else:
|
||||
self._emit(
|
||||
{"type": "clarify_call", "question": str(args.get("question", ""))}
|
||||
{
|
||||
"type": "clarify_call",
|
||||
"question": str(args.get("question", "")),
|
||||
}
|
||||
)
|
||||
inv = self._execute_clarify(args)
|
||||
invocations.append(inv)
|
||||
@@ -843,9 +863,7 @@ class ResearchAgent:
|
||||
"and the model returned no text response)"
|
||||
)
|
||||
answer, final_sources = _finalize(answer)
|
||||
self._emit(
|
||||
{"type": "final_answer", "text": answer, "sources": final_sources}
|
||||
)
|
||||
self._emit({"type": "final_answer", "text": answer, "sources": final_sources})
|
||||
return ResearchResult(
|
||||
answer=answer,
|
||||
iterations=iterations,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -72,15 +72,16 @@ class _OAuth1Auth:
|
||||
all_params[key] = value
|
||||
|
||||
param_str = "&".join(
|
||||
f"{_pct(k)}={_pct(v)}"
|
||||
for k, v in sorted(all_params.items())
|
||||
f"{_pct(k)}={_pct(v)}" for k, v in sorted(all_params.items())
|
||||
)
|
||||
base_string = f"{method}&{_pct(base_url)}&{_pct(param_str)}"
|
||||
|
||||
signing_key = f"{_pct(self._consumer_secret)}&{_pct(self._access_secret)}"
|
||||
signature = base64.b64encode(
|
||||
hmac.new(
|
||||
signing_key.encode(), base_string.encode(), hashlib.sha1,
|
||||
signing_key.encode(),
|
||||
base_string.encode(),
|
||||
hashlib.sha1,
|
||||
).digest(),
|
||||
).decode()
|
||||
|
||||
@@ -146,7 +147,8 @@ class TwitterChannel(BaseChannel):
|
||||
self._api_secret = api_secret or os.environ.get("TWITTER_API_SECRET", "")
|
||||
self._access_token = access_token or os.environ.get("TWITTER_ACCESS_TOKEN", "")
|
||||
self._access_secret = access_secret or os.environ.get(
|
||||
"TWITTER_ACCESS_SECRET", "",
|
||||
"TWITTER_ACCESS_SECRET",
|
||||
"",
|
||||
)
|
||||
self._bot_user_id = bot_user_id or os.environ.get("TWITTER_BOT_USER_ID", "")
|
||||
self._poll_interval = poll_interval
|
||||
@@ -162,8 +164,10 @@ class TwitterChannel(BaseChannel):
|
||||
|
||||
def _oauth(self) -> _OAuth1Auth:
|
||||
return _OAuth1Auth(
|
||||
self._api_key, self._api_secret,
|
||||
self._access_token, self._access_secret,
|
||||
self._api_key,
|
||||
self._api_secret,
|
||||
self._access_token,
|
||||
self._access_secret,
|
||||
)
|
||||
|
||||
# -- connection lifecycle -----------------------------------------------
|
||||
@@ -179,7 +183,8 @@ class TwitterChannel(BaseChannel):
|
||||
self._status = ChannelStatus.CONNECTING
|
||||
|
||||
self._listener_thread = threading.Thread(
|
||||
target=self._poll_mentions, daemon=True,
|
||||
target=self._poll_mentions,
|
||||
daemon=True,
|
||||
)
|
||||
self._listener_thread.start()
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
@@ -276,7 +281,10 @@ class TwitterChannel(BaseChannel):
|
||||
params["since_id"] = self._since_id
|
||||
|
||||
resp = httpx.get(
|
||||
url, headers=headers, params=params, timeout=10.0,
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
data = resp.json()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -9,12 +9,12 @@ from __future__ import annotations
|
||||
# readable capital J (the bottom-left \___/ hook), unlike the cramped prior
|
||||
# art where the J read as an I.
|
||||
_WORDMARK = (
|
||||
' ___ _ _ ',
|
||||
' / _ \\ _ __ ___ _ __ | | __ _ _ ____ _(_)___ ',
|
||||
" ___ _ _ ",
|
||||
" / _ \\ _ __ ___ _ __ | | __ _ _ ____ _(_)___ ",
|
||||
"| | | | '_ \\ / _ \\ '_ \\ _ | |/ _` | '__\\ \\ / / / __|",
|
||||
'| |_| | |_) | __/ | | | |_| | (_| | | \\ V /| \\__ \\',
|
||||
' \\___/| .__/ \\___|_| |_|\\___/ \\__,_|_| \\_/ |_|___/',
|
||||
' |_| ',
|
||||
"| |_| | |_) | __/ | | | |_| | (_| | | \\ V /| \\__ \\",
|
||||
" \\___/| .__/ \\___|_| |_|\\___/ \\__,_|_| \\_/ |_|___/",
|
||||
" |_| ",
|
||||
)
|
||||
|
||||
_TAGLINE = "Personal AI, On Personal Devices"
|
||||
|
||||
@@ -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
|
||||
|
||||
+102
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json as json_mod
|
||||
import logging
|
||||
import sys
|
||||
@@ -19,6 +20,7 @@ from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.engine import (
|
||||
EngineConnectionError,
|
||||
EngineContextLengthError,
|
||||
discover_engines,
|
||||
discover_models,
|
||||
get_engine,
|
||||
@@ -619,6 +621,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 +662,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 +671,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 +711,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 +769,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"
|
||||
@@ -821,6 +882,11 @@ def ask(
|
||||
capability_policy=sec.capability_policy,
|
||||
memory_files_config=effective_mf,
|
||||
)
|
||||
except EngineContextLengthError as exc:
|
||||
# Not a reachability problem — pointing the user at server/host
|
||||
# config (hint_no_engine) would be misleading here.
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
sys.exit(1)
|
||||
except EngineConnectionError as exc:
|
||||
console.print(f"[red]Engine error:[/red] {exc}")
|
||||
console.print(hint_no_engine())
|
||||
@@ -865,6 +931,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 +978,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]"):
|
||||
@@ -900,6 +996,11 @@ def ask(
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
except EngineContextLengthError as exc:
|
||||
# Not a reachability problem — pointing the user at server/host
|
||||
# config (hint_no_engine) would be misleading here.
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
sys.exit(1)
|
||||
except EngineConnectionError as exc:
|
||||
console.print(f"[red]Engine error:[/red] {exc}")
|
||||
console.print(hint_no_engine())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user