Compare commits

..
Author SHA1 Message Date
Elliot Slusky d4eb6308b1 Fix desktop speech dependency setup (#574) 2026-06-20 17:08:24 -07:00
github-actions[bot] 2853a0001d chore: update clone traffic data [skip ci] 2026-06-20 07:23:14 +00:00
github-actions[bot] 3c99481975 chore: update clone traffic data [skip ci] 2026-06-19 07:55:20 +00:00
Elliot Slusky 4bf39af9bd Add provider-aware search support to hybrid orchestration agents (#558) 2026-06-18 13:40:06 -07:00
github-actions[bot] 0a3e812751 chore: update clone traffic data [skip ci] 2026-06-18 07:44:28 +00:00
github-actions[bot] eb46febad5 chore: update clone traffic data [skip ci] 2026-06-17 07:54:02 +00:00
github-actions[bot] 81482b45d4 chore: update clone traffic data [skip ci] 2026-06-16 08:00:49 +00:00
Jon Saad-FalconandClaude Opus 4.8 3e2f4bcdb4 feat(core): consolidate all state under a single env-aware home directory (#462) (#549)
Previously `core/config.py` defined `DEFAULT_CONFIG_DIR = Path.home() /
".openjarvis"` as a by-value module constant imported into ~45 modules, and 34
modules hardcoded `Path.home() / ".openjarvis"` directly. The installer honored
`OPENJARVIS_HOME` but the Python runtime ignored it, producing a split-brain
layout (some modules honored the override, the core config dir did not). Eval
dataset caches also scattered into `~/.cache/<benchmark>`.

This introduces a single env-aware resolver in `openjarvis/core/paths.py` and
routes every state/config/cache path through it. OpenJarvis now keeps ALL of
its state under ONE root, resolved in priority order:

  1. $OPENJARVIS_HOME
  2. $XDG_DATA_HOME/openjarvis   (single nested dir, when XDG_DATA_HOME is set)
  3. ~/.openjarvis               (default — unchanged, so existing installs are
                                  untouched and no data migration is required)

Implementation:
- New `core/paths.py`: get_config_dir / get_config_path / get_data_dir /
  get_cache_dir, with a source-tree rejection guard (fails loudly per
  REVIEW.md if the root resolves inside the repo).
- `core/config.py`: DEFAULT_CONFIG_DIR / DEFAULT_CONFIG_PATH are now resolved
  via the env-aware resolver at import (real attributes, so existing
  monkeypatch.setattr-based tests keep working). All dataclass field defaults
  that pointed at ~/.openjarvis converted to default_factory so they honor the
  override at instantiation.
- Routed all 34 hardcoders plus several string-literal escapees the original
  audit missed: prompt_loader / description_loader (were OPENJARVIS_HOME-only,
  no XDG), swebench_harness cache, tools/{memory,skill,user_profile}_manage
  defaults, server trace.db fallbacks, doctor_cmd hints.
- spec_search storage/paths now delegates to the unified resolver (gains XDG);
  its ConfigurationError is aliased to the core one.
- Eval dataset caches moved from ~/.cache/<name> to <root>/cache/<name>
  (~/.cache/huggingface left alone — it is HF's own cache).
- Docs + installer comment + `jarvis config path` to show resolved dirs.

Read-only macOS connectors and OS service files (LaunchAgents/systemd) are
intentionally left untouched.

Fixes #462

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:32:02 -07:00
github-actions[bot] a35b21195f chore: update clone traffic data [skip ci] 2026-06-15 08:03:30 +00:00
Jon Saad-FalconandClaude Opus 4.8 f9d1bc8c27 fix(connectors): complete Google OAuth and register Drive in Data Sources (fixes #512) (#548)
Pasting a Google Client ID / Secret never completed OAuth: Drive (and its
Google siblings) accepted the credentials, showed no error, opened no browser,
and never appeared in Data Sources. Root cause is three coupled defects, all
reproduced at the unit level against main with a FastAPI TestClient (no Google
creds, network-free):

(A/B) POST /connect routed a `client_id:client_secret` pair into the
  connector's handle_callback, which spawned a daemon thread that popped a
  browser and ran its own localhost:8789 callback server. That thread fails
  silently in the bundled desktop context (`except Exception: pass`), so the
  connector never gained an access_token; /connect returned status "pending"
  and the UI's 20x2s poll timed out with no error.
  Fix: in POST /connect, an OAuth `client_id:client_secret` pair now persists
  the client credentials to every Google credential file and returns an
  `oauth_required` directive pointing at the in-process server flow, instead of
  the silent background thread. The Google connectors' handle_callback no longer
  spawns the browser thread for the pair case — it only persists the creds; the
  server's /oauth/start -> /oauth/callback owns the consent round-trip.

(C) The would-be-correct server flow was itself broken: under
  `from __future__ import annotations` plus a `Request` import local to the
  router factory, FastAPI could not resolve the stringized `request: Request`
  annotation. /oauth/start returned HTTP 422 (request mis-bound as a query
  param) and /oauth/callback injected None -> AttributeError on
  `request.base_url`. Fix: import `Request` at module scope and make the
  callback's `request` a required injected dependency.

A malformed/blank client pair now raises HTTP 400 with the provider setup URL
instead of a perpetual silent "pending" (REVIEW.md silent-failure discipline).

Frontend: DataSourcesPage now opens the server OAuth window when /connect
returns `oauth_required`, then polls until connected; connect errors surface the
backend detail; the Drive setup steps document the "Web application" OAuth
client + server-callback redirect URI the in-process flow requires.

Tests (run on the main venv, hermetic — no ~/.openjarvis pollution):
- test_oauth_flow.py: the three handle_callback tests now assert NO browser is
  opened and only client creds are persisted (was: assert background flow ran).
- test_connectors_router_oauth.py (new): reproduces + fixes all three defects via
  TestClient with mocked token exchange; parametrized over gdrive/gcalendar/
  gcontacts/gmail/google_tasks to prove the shared OAuth path is fixed for every
  sibling and that a single consent writes the access_token to all six Google
  credential files and flips is_connected() to True.
Full tests/connectors suite: 355 passed.

Relationship to PR #510: #510 rewrites all of these files (account-scoped
retrieval) but still carries all three defects. This fix is intentionally scoped
to the OAuth path and does not modify oauth.py, to minimize collision. A
maintainer can either merge this and rebase #510 on top, or port these changes
into #510. See PR body for details.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:55:22 -07:00
Aditya MalikandJon Saad-Falcon dfa908c358 Adopt hatch-vcs dynamic versioning (reworks autotag, pypi-publish, desktop) (#538)
* build: adopt hatch-vcs dynamic versioning (#526)

Replace the static `version = "1.0.2"` with `dynamic = ["version"]` and
derive the version from git tags via hatch-vcs, so source and editable
checkouts report their true git describe version (e.g.
1.0.3.dev110+g<sha>) instead of a stale constant.

Config notes:
- Exclude .dev/.rc/desktop-* tags from derivation. setuptools_scm cannot
  bump custom .devN tags, so the base is taken from the latest plain
  release tag (vX.Y.Z) and the dev distance from commit count.
- Add fallback_version so builds without a git checkout (shallow CI
  clones, Docker COPY src/, source-zip installs) resolve to a sentinel
  instead of hard-failing. CI release builds inject the exact version via
  SETUPTOOLS_SCM_PRETEND_VERSION.

* ci(autotag): derive dev base from the latest release tag (#526)

pyproject no longer carries a static version, so read the base from the
latest plain release tag (vX.Y.Z) reachable from HEAD instead of grepping
pyproject. .dev/.rc/desktop-* tags are excluded so they cannot be mistaken
for the release base. The computed tag (vX.Y.Z.devN) is unchanged.

* ci(pypi-publish): pin build version from tag, drop sed injection (#526)

With dynamic versioning there is no static line to sed. Pin the exact
build version from the pushed tag via SETUPTOOLS_SCM_PRETEND_VERSION so
the published version equals the tag. This is required, not cosmetic: a
naive hatch-vcs build emits 1.0.3.devN+g<sha>, and PyPI rejects local
version segments on upload.

Also add a dry_run input that targets TestPyPI instead of PyPI, for
validating the release path without a production upload.

* ci(desktop): derive dispatch-fallback version from release tag (#526)

The workflow_dispatch fallback grepped the now-removed static pyproject
version. Derive its base from the latest release tag instead (matching
autotag), and give the build-and-release checkout full history and tags
so the derivation works.

---------

Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
2026-06-14 19:35:17 -07:00
Jon Saad-FalconandClaude Opus 4.8 8ef1ab1928 fix(ci): gate secret-bearing Claude workflows by author association (fixes #218) (#547)
The Claude automation workflows (claude-issues.yml, claude-review.yml)
trigger on public, attacker-controllable events (issues, issue_comment,
pull_request_review_comment) and grant the job secrets.ANTHROPIC_API_KEY
plus a write-scoped GITHUB_TOKEN with NO author-association gate.

Because issues / issue_comment / pull_request_review_comment always run in
the base-repo context with full secret access (unlike fork pull_request,
from which GitHub withholds secrets), any external GitHub user could fire
these jobs — draining the API budget and, via contents:write +
pull-requests:write, creating branches/PRs.

Fix:
- Add an author-association gate to every human-triggered, secret-bearing
  if: clause, restricting to OWNER / MEMBER / COLLABORATOR. Uses the correct
  event payload field per trigger: github.event.issue.author_association for
  the `issues` event, github.event.comment.author_association for
  issue_comment and pull_request_review_comment. workflow_dispatch stays
  trusted (requires repo write to invoke).
- Drop unused id-token: write from both workflows (claude-code-action@v1 is
  passed github_token directly, so OIDC is unused).
- Reduce claude-issues.yml timeout-minutes 60 -> 15.

desktop.yml and take-assign.yml are intentionally NOT touched: independently
verified as not exploitable for ANTHROPIC_API_KEY (desktop.yml's only
pull_request job uses no secrets and the trigger is plain pull_request, not
pull_request_target; take-assign.yml uses only GITHUB_TOKEN with issues:write
and no checkout/no Anthropic key). claude-review.yml's stale pull_request
auto-trigger was already removed in 3f2f46e4.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:31:51 -07:00
Jon Saad-FalconandClaude Opus 4.8 28e75cb513 fix(server): inject OpenJarvis identity system prompt on the desktop chat path (fixes #540) (#546)
The OpenAI-compatible POST /v1/chat/completions endpoint — the desktop UI's
chat backend — never injected OpenJarvis's agent.default_system_prompt when the
client omits a system message. The frontend (Chat/InputArea.tsx) posts only
user/assistant turns, so the model answered from its training identity
("I'm Claude", "I am Qwen", ...). The CLI paths ground identity via
SystemPromptBuilder / BaseAgent; the engine-direct server handlers did not.

Fix:
- Add _ensure_identity_prompt(messages, app_config) in server/routes.py: returns
  messages unchanged when any has role==SYSTEM, else prepends a SYSTEM message
  with the resolved identity prompt (app.state.config.agent.default_system_prompt,
  else load_config()), wrapped in try/except that debug-logs on failure (no crash,
  no silent swallow per REVIEW.md).
- Apply it after _to_messages() in all three engine-direct handlers:
  _handle_stream, _handle_stream_tools, and _handle_direct; thread app.state.config
  through. _handle_agent is left untouched (BaseAgent already injects the default).
- Harden AgentConfig.default_system_prompt so distilled models stop claiming to be
  Claude/ChatGPT/Gemini and self-identify as OpenJarvis.

Tests (tests/server/test_routes.py, tests/core/test_config.py): identity prompt IS
prepended when no system message is present (stream / direct / tools paths) and is
NOT duplicated when the client supplies one; config wording anchors "OpenJarvis"
and "not Claude". Verified fail-on-unfixed against main (3 inject tests + config
wording test fail there).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:38:05 -07:00
4b9948250b feat(engine): add DeepSeek as a first-class cloud provider + fix #335 over-permissive cloud fallback (#545)
* feat(engine): add DeepSeek as a first-class cloud provider

Adds DEEPSEEK_API_KEY support to the cloud engine, wiring DeepSeek's
OpenAI-compatible API (api.deepseek.com/v1) alongside the existing
MiniMax, OpenRouter, Anthropic, and Google providers.

- Add _DEEPSEEK_MODELS list (deepseek-v4-flash, deepseek-v4-pro)
- Add _is_deepseek_model() routing predicate
- Init self._deepseek_client from DEEPSEEK_API_KEY in _init_clients()
- Add _generate_deepseek() and _stream_deepseek() methods
- Wire DeepSeek into generate(), stream(), _stream_full_openai(),
  list_models(), and health()
- Add approximate pricing entries for both models

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(engine): strict cloud model routing + deepseek can_serve branch

Builds on the DeepSeek provider (PR #504) with two routing-correctness
fixes to CloudEngine._client_for_model:

1. Add the missing DeepSeek branch so can_serve('deepseek-*') agrees with
   list_models()/health() when only DEEPSEEK_API_KEY is set (mirrors the
   minimax branch). Without it the engine advertised deepseek models via
   list_models() but refused to serve them (the #532 can_serve contract).

2. Fix #335: _client_for_model previously fell through to the OpenAI client
   for ANY unrecognized model name, so an OpenAI key (even a dummy
   sk-dummy... one) made can_serve('qwen3.5:0.8b') return True. With the
   local engine transiently down (classic post-Windows-restart Ollama not
   yet up), model-aware get_engine then mis-selected the cloud engine for a
   local model and died with "OpenAI client not available". Add a positive
   _is_openai_model predicate (gpt-/chatgpt-/o1/o3/o4 + _OPENAI_MODELS) and
   return None for unrecognized names, so can_serve declines them. generate()
   and stream() keep their OpenAI fall-through, preserving loud failure for an
   explicitly-requested unknown cloud model.

Tests: DeepSeek detection/pricing/health/list_models/generate-routing/
can_serve and a #335 regression (can_serve rejects local names with an
OpenAI key; unknown model not served even with all clients set; end-to-end
get_engine does not misroute a local model with a dummy OpenAI key).

Fixes #335

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Jen Huls <me@jenhuls.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 18:21:56 -07:00
79e23719d4 feat(vision): add image + screen capture input for vision models (#486)
OpenJarvis can run vision-capable local models (gemma3, qwen2.5-vl), but the
CLI had no way to send them a picture -- the Ollama engine only serialized
text. This adds end-to-end image input.

What's new
- `jarvis ask -i/--image <file>` attaches one or more images to the query.
- `jarvis ask -S/--screen` captures the primary monitor (dependency-free on
  Windows via .NET; mss/Pillow fallback elsewhere).
- Vision auto-routes to direct-to-engine mode; with an explicit --agent it
  warns rather than silently dropping the image.
- Privacy guard: warns before sending an image to a non-local engine,
  keeping OpenJarvis local-first by default.
- Context-window default raised 8k -> 16k (JARVIS_NUM_CTX) so an image plus
  a conversation fit.

Implementation
- Message.images carries base64 data; messages_to_dicts() forwards it to
  Ollama's /api/chat "images" field. Text-only messages are unchanged.
- GuardrailsEngine preserves images when it rewrites a flagged message.

Tests (tests/test_vision.py, 6/6 pass, ruff-clean)
- payload forwarding, text path untouched, num_ctx override, guardrail
  image preservation.

Verified on AMD RX 9070 XT (Ollama/Vulkan, 100% GPU) with gemma3:4b:
solid-color image, file image, and live screen capture all described.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
2026-06-14 18:19:00 -07:00
SANJAYandsanjayravit 7ba334b5f0 fix(gui): inject bearer token into streaming chat/research routes (#499)
Ensures that the desktop GUI correctly sends the Authorization header
when an API key is configured. This resolves the 'Failed to get response'
bug on Windows systems with enabled authentication.

Ref: #266

Co-authored-by: sanjayravit <sanjay@example.com>
2026-06-14 17:32:43 -07:00
github-actions[bot] 48a2627c9a chore: update clone traffic data [skip ci] 2026-06-14 07:38:47 +00:00
github-actions[bot] 8625f4f95f chore: update clone traffic data [skip ci] 2026-06-13 07:21:38 +00:00
github-actions[bot] cf08f164c0 chore: update clone traffic data [skip ci] 2026-06-12 07:41:03 +00:00
Jon Saad-FalconandClaude Opus 4.8 b21463aab6 fix(evals): harden terminalbench-native harness against tmux death and setup hangs (#536)
Two failure classes hit by a downstream team:

1) tmux/task-env death (TerminalBenchTaskEnv.__enter__, terminalbench_env.py):
   create_session ran inside the spin_up_terminal generator-CM with no
   exception safety — a tmux failure leaked the docker compose project
   (down deferred to GC, never if the env was retained) and surfaced as an
   opaque mid-run death. Now: exception-safe __enter__ with an idempotent
   _teardown(), a tmux/asciinema preflight in the container BEFORE the
   agent loop (TaskEnvironmentError naming the task image + remedy), and
   fail-that-task-cleanly semantics — the failure is recorded as a harness
   error (QueryTrace.error_kind="harness_error"), the container is downed,
   and the run continues.

2) OpenHands in-container SETUP hang misattributed as a model result:
   harness.run() had no bound (terminal-bench runs installed-agent setup
   with max_timeout_sec=inf inside the per-trial agent budget), and the
   summary conversion read the nonexistent results.trial_results attr and
   hardcoded errors=0, folding zero-model-request trials into resolve-rate
   as model misses. Now: global_agent_timeout_sec / global_timeout_multiplier
   are threaded config -> backend -> Harness kwargs (default 1800 s bound on
   SETUP+RUN; configurable per [run]/[[benchmarks]] TOML), and
   summarize_benchmark_results() classifies harness/infra failures out of
   the accuracy denominator keyed on token usage (zero/missing tokens +
   unresolved = the agent never contacted the model), NOT failure_mode —
   terminal-bench 0.2.18 leaves failure_mode UNSET on success AND on
   genuine misses, so a failure_mode-based check would misflag every real
   model miss. Genuine misses (tokens>0, is_resolved=false) stay in the
   denominator.

QueryTrace gains error/error_kind (wired through to_dict/from_dict so the
fields actually reach traces.jsonl; backward-compatible loads), the
AgenticRunner flags zero-model-contact traces and TaskEnvironmentError as
harness errors, and export/summary/console output exclude harness errors
from resolve-rate while reporting them loudly.

terminal-bench stays an undeclared dep on purpose: it requires Python
>=3.12 while this project supports >=3.10,<3.14, so an unmarked extra
would break uv lock. pyproject/uv.lock untouched.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:30:10 -07:00
Jon Saad-FalconandClaude Opus 4.8 0cac61d3bb docs(evals): rewrite evaluations guide to match the real CLI surface; add openjarvis-eval alias (#534)
docs/user-guide/evaluations.md documented a standalone "openjarvis-evals"
package, a "uv sync --extra eval" install, and an "openjarvis-eval" console
script — a layout from commit bd493832 that was never an ancestor of main.
Rewrite the page against the real surface (jarvis eval / python -m
openjarvis.evals), document all 40 registered benchmark keys and 4 backends,
fix the judge-model default, correct run-all semantics, and split the option
reference into the jarvis-eval subset and the module CLI's research-only
options.

Add the one-line [project.scripts] alias
openjarvis-eval = "openjarvis.evals.cli:main" (the click group the module
CLI already dispatches to) so the long-documented command name works again.

The page was a complete orphan: add it (and the equally orphaned
benchmarks.md) to the mkdocs nav and link it from docs/index.md.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:50:17 -07:00
Jon Saad-FalconandClaude Opus 4.8 50993dfa4d fix(evals): honor --base-url/--api-key for first-party eval backends (#535)
`jarvis eval run --base-url ... --api-key ...` was silently dropped for
jarvis-direct/jarvis-agent (_build_backend only forwarded the flags to
hermes/openclaw) and ignored by terminalbench-native, which hardcoded
api_base="http://localhost:8000/v1". Worse, with --base-url set the
engine-discovery fallback silently substituted ANY healthy local engine
(observed: requested vllm + healthy endpoint at --base-url, got
OllamaEngine@localhost:11434 — the requested URL was never contacted).

Changes:
- _OpenAICompatibleEngine gains an api_key param (Bearer Authorization
  header on the httpx client; {ENGINE_ID}_API_KEY env fallback with
  hyphen-sanitized names; no header when unset).
- New non-registered OpenAICompatEngine + normalize_openai_base_url()
  (strips a single literal trailing "/v1" so request paths don't double).
- SystemBuilder.engine_instance() injects a pre-built engine; build()
  health-checks it and fails loudly naming the host instead of falling
  back to discovery. Discovery substitution after an explicit -e key now
  logs a warning.
- JarvisDirectBackend/JarvisAgentBackend accept base_url/api_key; on
  base_url they pin an OpenAICompatEngine to that endpoint with a
  fail-fast pre-flight (actionable error naming the URL and probe).
- _build_backend forwards base_url/api_key to first-party backends on
  the CLI path; _run_terminalbench_native receives --base-url as
  api_base (single /v1 suffix) and exports OPENAI_API_KEY around the
  in-process harness run (terminus-2 routes via LiteLLM).
- Suite TOML [backend.external] stays scoped to hermes/openclaw
  (suite_mode=True in the suite drivers) — first-party suite semantics
  are explicitly deferred. The config-host path is untouched.
- Help text updated on both CLI surfaces; KNOWN_BACKENDS now lists
  hermes/openclaw/terminalbench-native.

Fixes the eval-CLI endpoint gap reported by the downstream team.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:49:37 -07:00
github-actions[bot] 527f84f960 chore: update clone traffic data [skip ci] 2026-06-11 07:44:18 +00:00
8eaeb3a754 fix(windows): desktop backend spawn + model-aware engine selection (#533)
* fix(windows): desktop backend spawn (#531) + model-aware engine selection (#532)

Two runtime bugs found during end-to-end testing on a clean Windows 11
24H2 Azure VM.

#531 - Desktop "Failed to get response": run_jarvis_command spawned the
backend with .output(), which waits for the process to exit. `jarvis
serve` never exits, so the Tauri command hung forever (the Start button
never resolved); and it ran `uv run jarvis` with no cwd, so in a packaged
install -- where the cwd isn't the checkout -- `jarvis` wasn't found and
the server never started. Now: run from find_project_root(), and for
`serve` spawn detached (.spawn()), drain stderr, and poll /health for
readiness (mirrors start_backend); short commands keep .output().

The server layer itself was verified healthy on Windows (/health and
/v1/chat/completions both 200, localhost included) -- the fault was the
Tauri spawn path.

#532 - "OpenAI client not available" after reboot: when the local engine
is down, get_engine's fallback selected CloudEngine because health() is
True if ANY provider client exists -- without checking the resolved
model's provider has a client. A user with e.g. OPENROUTER_API_KEY and a
gpt-* model then hit the OpenAI path with no client. Add
CloudEngine.can_serve(model) (checks the specific provider client via the
same routing generate()/stream() use) + a default can_serve->True on the
base engine, and make get_engine model-aware so it skips an engine that
can't serve the model -- the user falls through to the helpful "no engine
available / start ollama" message instead.

Tests: engine discovery/cloud/model-matrix + cli serve/ask suites pass
(the one ask_e2e failure is a pre-existing version-banner flake, fails
identically on main). The Tauri crate couldn't be compiled locally (no
GTK/webkit sys-libs in this env); relies on CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(engine): cover model-aware engine selection + CloudEngine.can_serve (#532)

#533 added a `model` arg to get_engine and a can_serve() gate but shipped no
tests. Add them:
- get_engine skips a healthy engine that can't serve the requested model
  (the cloud-fallback-for-unservable-model case behind #532),
- model=None preserves the legacy model-agnostic selection,
- CloudEngine.can_serve gates on the per-provider client (gpt->OpenAI,
  claude->Anthropic, ...), verified empirically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
2026-06-10 19:52:36 -07:00
Robby ManihaniandClaude Opus 4.8 90b7d0cb9b fix(windows): installer encoding + Ollama readiness loop (#523)
Two bugs found during end-to-end testing on a clean Windows 11 24H2
Azure VM (closes #522). Both are dodged by the canonical `irm | iex`
one-liner but hit by the documented `-OutFile` fallback and any
non-interactive run.

1. Encoding. install.ps1 was UTF-8 without a BOM and contained em-dashes
   plus a box-drawing banner. Windows PowerShell 5.1 decodes BOM-less
   files with the legacy ANSI/OEM code page, mis-decoding the multi-byte
   sequences and desyncing the parser into cascading here-string parse
   errors. Converted the file to pure ASCII (em-dashes -> hyphens, banner
   -> ASCII art) so it parses no matter how it's read.

2. Ollama readiness loop. With $ErrorActionPreference='Stop', the probe
   `& $ollamaExe list 2>&1 | Out-Null` turned the daemon-not-up stderr
   into a terminating NativeCommandError, aborting the install on the
   first iteration and making the loop's own Start-Process serve retry +
   Write-Warn2 fallback dead code. Wrapped the probe in try/catch so it
   falls through to the self-start path as intended.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:15:57 -07:00
d7053c35d5 security: harden network-exposed surface (#509)
* security: harden network-exposed surface

Hardening for the network-reachable attack surface, prioritizing fixes
that are strong but do not change working local/loopback defaults.

- auth_middleware: constant-time API key comparison (secrets.compare_digest)
  for the HTTP path, and gate /metrics behind auth so operational counters
  are not readable unauthenticated. /health stays open.
- webhook_routes: fail closed when a channel's secret/token is unset. Twilio,
  BlueBubbles, WhatsApp (verify + inbound), and SendBlue now reject (403)
  instead of processing unsigned/unauthenticated input. Constant-time
  comparisons for BlueBubbles/SendBlue/WhatsApp verify token.
- http_request: follow redirects manually and re-run the SSRF check on every
  hop (capped at 5) so an allowed public URL cannot 30x-redirect to an
  internal/metadata address.
- api_routes /v1/memory/index: restrict indexing to OPENJARVIS_WORKSPACE roots
  when configured and refuse sensitive files (.env, keys, credentials).
- config.toml: default [server] host to 127.0.0.1 (loopback) with a comment
  on how to safely expose to a LAN (0.0.0.0 + API key).

Tests: new fail-closed webhook tests, /metrics auth tests, and SSRF
redirect block/follow tests; updated SendBlue tests for the new
secret-required behavior. Affected suites pass (95 tests), ruff clean.

* fix(http): keep SSRF redirect-following patchable via httpx.request

The manual redirect-following loop used a private httpx.Client, which
bypassed the `http_request.httpx.request` mock seam that consumers' tests
rely on (e.g. the twitter-bot GitHub-issue tests escaped to the real
network and 401'd). Issue each hop via module-level httpx.request with
follow_redirects=False instead — same per-hop SSRF re-check, restored
testability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:32:28 -07:00
03c5ec3e40 fix(chat): wire SystemPromptBuilder so persona files load in jarvis chat (fixes #458) (#497)
* fix(chat): wire SystemPromptBuilder so persona files load in jarvis chat (fixes #458)

* fix(chat): make `--persona none` actually disable persona files

This PR exposes `--persona none`, but SystemPromptBuilder._load_file read
empty paths as "." (Path("") -> ".") and raised IsADirectoryError, so the
documented opt-out crashed. Guard empty path_str so the "none" opt-out
(which _resolve_persona maps to empty file paths) cleanly injects no
persona. Adds an end-to-end regression test (building with persona
"none" must not raise). Also merges current main (branch was stale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:10:13 -07:00
Jon Saad-FalconandClaude Opus 4.8 4218258486 perf(serve): build the system once — drop duplicate SystemBuilder.build() (#263) (#529)
`jarvis serve` constructed every heavy component inline (engine discovery +
instrumentation, telemetry, memory, agent manager, per-agent tools) and then
called `SystemBuilder(config).build()` a second time inside the scheduler
block purely to feed `AgentExecutor.set_system()`. That second build
re-discovered and re-connected the engine, re-instrumented it, re-resolved
tools, re-opened the configured channel and re-created the agent manager —
~30-40s of fully redundant startup work (the headline remaining cost in #263
after engine probes were parallelised and the version check moved off the hot
path in #470).

Fix: assemble the executor's `JarvisSystem` from the components already built
inline instead of rebuilding from scratch. `AgentExecutor` only reads
`engine`, `model`, `config`, `memory_backend`, `tool_executor`,
`session_store` and `channel_backend` off the system; all are wired here. The
memory backend is now constructed just before the scheduler block (it was
built later) so the executor's system can reference it, and the primary
agent's resolved tool list is reused to build the scheduler's `ToolExecutor`
(preserving the MCP-discovered-tool pool the executor reads via
`tool_executor._tools`). `skill_manager` / the learning orchestrator are only
consumed by the orchestrator's `system.ask()` path, which the executor never
invokes, so they are intentionally omitted.

Tests: new `tests/cli/test_serve_single_build.py` patches
`SystemBuilder.build` and asserts it is never called during `jarvis serve`
startup, and that the executor still receives a system exposing
`tool_executor` / `session_store` / `memory_backend` (plus engine/model/config).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:10:10 -07:00
Jon Saad-FalconandClaude Opus 4.8 176ed3029b fix(channels): unify send() destination/reply contract (Discord #515/#516) (#528)
The channel `send()` contract was inconsistent across adapters. Almost
every adapter (Discord, Slack, email, WhatsApp, ...) treats the first
positional `channel` arg as the real DESTINATION id and `conversation_id`
as an optional reply/thread reference. Telegram alone treated
`conversation_id` as the destination (`chat_id = conversation_id or
channel`).

`JarvisSystem._on_channel_message` hard-coded the Telegram-shaped mapping
for ALL channels: `send(cm.channel, reply, conversation_id=cm.conversation_id)`.
Since inbound `ChannelMessage`s carry the channel TYPE label in `.channel`
("discord") and the real destination id in `.conversation_id`, this sent
the literal "discord" as the Discord channel id (HTTP 400
NUMBER_TYPE_COERCE, #515) and passed the channel id as a Discord
`message_reference` (MESSAGE_REFERENCE_UNKNOWN_MESSAGE, #516).

Fix: define and document ONE canonical contract on `BaseChannel.send` —
positional `channel` = destination id, `conversation_id` = inbound message
id used as a reply reference — and dispatch it from `_on_channel_message`
as `send(cm.conversation_id, reply, conversation_id=cm.message_id)`,
matching the already-fixed `ChannelAgent` path (#495/#459). Telegram's
`send()` is brought into line (destination = `channel`, with a
`reply_to_message_id` reply ref and a legacy `conversation_id`-only
fallback) so it keeps working unchanged.

The DiscordChannel `_gateway_loop` ChannelMessage shape locked by #495 is
untouched; `tests/agents/test_channel_agent.py` passes unchanged. The
stale `test_serve_channel_wiring.py` assertions (which encoded the old
buggy mapping from #94) are updated, and regression tests are added for
Discord (real channel id + correct message_reference), Telegram (chat id
+ reply ref + legacy fallback), and per-channel dispatch in
`_on_channel_message`.

Fixes #515
Fixes #516

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:10:06 -07:00
Jon Saad-FalconandClaude Opus 4.8 590addae4c test(engine): expect EngineConnectionError for vllm 404 (fix main CI) (#530)
#463 made the OpenAI-compatible engine wrap upstream HTTP errors (incl.
404) in EngineConnectionError with an actionable message, but
test_invalid_model_404 still asserted the raw httpx.HTTPStatusError, so it
broke on main once #463 landed (#463 was a stale fork PR with no CI, so it
wasn't caught pre-merge). Expect EngineConnectionError now, asserting the
httpx.HTTPStatusError is preserved as the chained cause. Whole tests/engine
suite is green again.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:09:37 -07:00
Jon Saad-FalconandClaude Opus 4.8 bc9fa6b3c8 fix(memory): surface clear error when openjarvis_rust missing instead of silent no-op (#527)
Memory tools degraded silently and misleadingly when the mandatory
`openjarvis_rust` extension was absent from the *serving* venv:

- `POST /v1/memory/store` returned HTTP 200 `{"status":"stored","note":
  "no backend available"}` and stored nothing (silent data loss).
- `POST /v1/memory/index` returned a generic "No memory backend available",
  and the desktop frontend discarded the server `detail` and threw a blanket
  "Failed to index path", blaming the path instead of the real cause.
- `GET /v1/memory/config` reported `backend_type: sqlite` even though no
  backend could be constructed.

Root cause: `SQLiteMemory.__init__` calls `get_rust_module()` (which raises
ImportError by design — the Rust ext is mandatory, no Python fallback), and
`_get_memory_backend` swallowed that ImportError and returned `None`,
conflating "native extension missing" (a hard install error) with "memory
intentionally disabled" (benign). A chunking floor also silently dropped whole
short documents, and the installer never verified the extension imported from
the serving venv before writing its success marker.

Fix (no fake Python fallback — the Rust ext stays mandatory by design):

- Add `MemoryBackendUnavailable` + `RUST_MISSING_HINT` in tools/storage/_stubs.
  `SQLiteMemory.__init__` translates the bridge ImportError into this clear,
  actionable error ("run `uv run maturin develop ...`").
- `_get_memory_backend` distinguishes the two cases: a missing native ext
  raises HTTP 503 with the actionable hint; a benign unconfigured backend
  still returns `None` (graceful path preserved for search/stats).
- `/store` now returns 503 instead of a 200 silent no-op.
- `/config` reports `available: false` + `detail` instead of falsely claiming
  a healthy `backend_type`.
- `/index` adds a `note` when `chunks_indexed == 0` so "indexed" never
  silently means "stored nothing".
- chunk_text no longer drops an entire short document below `min_chunk_size`
  (the floor only discards tiny *trailing* fragments now).
- Frontend `storeMemory`/`indexMemoryPath` surface the server `detail` instead
  of blanket strings; `MemoryConfig` gains optional `available`/`detail`.
  (Left the pre-existing `backend` vs `backend_type` mismatch untouched.)
- build-extension.sh verifies `import openjarvis_rust` succeeds in the serving
  venv before writing the `extension-built` marker.

Regression tests: tests/server/test_api_routes.py::TestMemoryRustMissing mocks
`get_rust_module` to raise ImportError and asserts /store (503, not 200 no-op),
/index (actionable detail, not "Failed to index path"), and /config
(available:false) all surface the clear error; tests/memory/test_chunking.py
asserts short-only docs are kept while tiny trailing fragments are still
filtered.

Fixes #502

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:25:10 -07:00
170 changed files with 6956 additions and 828 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "107,695",
"message": "125,214",
"color": "green",
"namedLogo": "git"
}
+14 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 107695,
"last_updated": "2026-06-10T07:32:26Z",
"total_clones": 125214,
"last_updated": "2026-06-20T07:23:13Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -75,6 +75,17 @@
"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
}
}
+10 -4
View File
@@ -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].*$//')
+13 -2
View File
@@ -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 -1
View File
@@ -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:
+15 -1
View File
@@ -114,6 +114,11 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
# Full history + tags so the workflow_dispatch fallback in
# "Determine release info" can derive the dev version from the
# latest release tag (#526).
fetch-depth: 0
- name: Install system dependencies (Linux)
if: matrix.platform == 'ubuntu-22.04'
@@ -183,7 +188,16 @@ jobs:
# workflow_dispatch fallback (manual UI dispatch without --ref).
# Derive a PEP 440 dev version aligned with autotag.yml so we
# don't burn the X.Y.Z release-version namespace.
BASE=$(grep -E '^version = "' pyproject.toml | head -1 | sed -E 's/^version = "([^"]+)"/\1/')
# pyproject.toml no longer carries a static version (#526), so the
# base comes from the latest plain release tag (vX.Y.Z), matching
# autotag.yml. .dev/.rc/desktop-* tags are excluded.
LATEST_RELEASE=$(git tag --list 'v[0-9]*' --merged HEAD \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
if [[ -z "$LATEST_RELEASE" ]]; then
echo "::error::No release tag (vX.Y.Z) reachable from HEAD"
exit 1
fi
BASE="${LATEST_RELEASE#v}"
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
+30 -11
View File
@@ -12,6 +12,11 @@ on:
description: 'Tag to publish (e.g. v1.0.2.dev500). Overrides github.ref.'
required: false
type: string
dry_run:
description: 'Dry run: build + validate, then publish to TestPyPI instead of PyPI (no production upload).'
required: false
default: false
type: boolean
permissions:
contents: read
@@ -67,27 +72,41 @@ jobs:
exit 1
}
- name: Set version from tag
- name: Resolve build version from tag
env:
REF: ${{ steps.ref.outputs.ref }}
run: |
set -euo pipefail
# Strip leading "v" if present (e.g. v1.0.2.dev500 -> 1.0.2.dev500)
# Strip leading "v" (e.g. v1.0.3.dev825 -> 1.0.3.dev825).
VERSION="${REF#v}"
if [[ -z "$VERSION" ]]; then
echo "::error::Could not resolve version from ref '$REF'"
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
echo "::error::ref '$REF' is not a version tag (expected vX.Y.Z[.devN]); pass -f tag=vX.Y.Z"
exit 1
fi
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
# Sanity check the substitution actually took
grep -q "^version = \"${VERSION}\"" pyproject.toml || {
echo "::error::sed failed to update pyproject.toml version"
exit 1
}
echo "Building version $VERSION"
# pyproject.toml is now dynamic = ["version"] via hatch-vcs (#526), so
# there is no static line to sed. setuptools_scm cannot bump custom
# `.devN` tags, so we pin the exact build version explicitly — the
# published version always equals the pushed tag.
echo "SETUPTOOLS_SCM_PRETEND_VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "Building version ${VERSION}"
- name: Build package
run: uv build
- name: Publish to TestPyPI (dry run)
if: ${{ inputs.dry_run }}
env:
UV_PUBLISH_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }}
run: |
set -euo pipefail
if [[ -z "${UV_PUBLISH_TOKEN:-}" ]]; then
echo "::warning::TEST_PYPI_API_TOKEN is not set — skipping the TestPyPI upload."
echo "Build + twine check passed, which validated version derivation and packaging end to end."
echo "To exercise a real upload, add a TEST_PYPI_API_TOKEN secret (or a TestPyPI trusted publisher)."
exit 0
fi
uv publish --publish-url https://test.pypi.org/legacy/
- name: Publish to PyPI
if: ${{ !inputs.dry_run }}
run: uv publish
+13
View File
@@ -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
+6 -1
View File
@@ -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"
+2 -2
View File
@@ -20,7 +20,7 @@ What it does:
4. Installs `uv` (https://astral.sh/uv) if absent.
5. Clones the OpenJarvis repository to `%LOCALAPPDATA%\OpenJarvis`
(override with `$env:OPENJARVIS_HOME`).
6. Runs `uv sync --extra server` so the FastAPI server entry point is
6. Runs `uv sync --extra desktop` so the FastAPI server and speech backend are
importable.
7. Optionally prompts to register a scheduled task that auto-starts the
server at logon.
@@ -105,7 +105,7 @@ To pull the latest:
```powershell
cd "$env:LOCALAPPDATA\OpenJarvis\src"
git pull --ff-only
uv sync --extra server
uv sync --extra desktop
```
Or re-run the installer with `-Force`:
+35 -30
View File
@@ -5,19 +5,19 @@
.DESCRIPTION
Phase-1 of the native-Windows-support RFC (#298). Mirrors the
behavior of scripts/install/install.sh (the curl-pipe-bash installer
for Linux/WSL2/macOS) but for native Windows PowerShell no WSL,
for Linux/WSL2/macOS) but for native Windows PowerShell - no WSL,
no Docker, no MSYS2.
Steps:
1. Refuse non-Windows / Windows < 10.
2. Check Python 3.10 3.13 on PATH (3.14 has no numpy wheels yet,
2. Check Python 3.10 - 3.13 on PATH (3.14 has no numpy wheels yet,
see #432).
3. Check git on PATH.
4. Install uv (https://astral.sh/uv) if absent.
5. Clone the OpenJarvis repository to $env:LOCALAPPDATA\OpenJarvis
(override with $env:OPENJARVIS_HOME).
6. Run `uv sync --extra server` so the FastAPI server entry point
is importable.
6. Run `uv sync --extra desktop` so the FastAPI server and speech
backend are importable.
7. Optionally register the scheduled-task service (see
deploy/windows/jarvis-service.ps1).
@@ -65,7 +65,7 @@ if (-not $Service -and $env:OPENJARVIS_SERVICE) { $Service = $true
if (-not $Force -and $env:OPENJARVIS_FORCE) { $Force = $true }
# ---------------------------------------------------------------------------
# Output helpers coloured but plain enough for Constrained Language Mode.
# Output helpers - coloured but plain enough for Constrained Language Mode.
# ---------------------------------------------------------------------------
function Write-Info ($msg) { Write-Host "[info] $msg" -ForegroundColor Cyan }
@@ -77,13 +77,13 @@ function Write-Fail ($msg) {
}
# ---------------------------------------------------------------------------
# Shared helpers winget bootstrap + PATH refresh
# Shared helpers - winget bootstrap + PATH refresh
# ---------------------------------------------------------------------------
# Pull the latest Machine + User PATH from the registry into the current
# PowerShell session. Tools installed by `winget install` (Python, git,
# Ollama, etc.) update the User PATH, but the running process inherits
# the parent shell's environment so without this refresh the just-
# the parent shell's environment - so without this refresh the just-
# installed tool stays invisible to subsequent `Get-Command` calls.
#
# CRITICAL: registry PATH entries can be REG_EXPAND_SZ (with literal
@@ -157,7 +157,7 @@ function Get-PythonCommand {
Write-Info "Checking Python (3.10 - 3.13)..."
$pythonExe = Get-PythonCommand
if (-not $pythonExe) {
Write-Info "Python not on PATH attempting auto-install via winget..."
Write-Info "Python not on PATH - attempting auto-install via winget..."
$pythonExe = Install-WithWinget -WingetId 'Python.Python.3.13' -CommandName 'python'
if (-not $pythonExe) {
Write-Fail @"
@@ -196,7 +196,7 @@ Write-Ok "Python $pyMajor.$pyMinor ($pythonExe)"
Write-Info "Checking git..."
$gitExe = (Get-Command git -ErrorAction SilentlyContinue).Source
if (-not $gitExe) {
Write-Info "git not on PATH attempting auto-install via winget..."
Write-Info "git not on PATH - attempting auto-install via winget..."
$gitExe = Install-WithWinget -WingetId 'Git.Git' -CommandName 'git'
if (-not $gitExe) {
Write-Fail @"
@@ -227,7 +227,7 @@ if (-not $uvExe) {
}
# The astral installer puts uv at %USERPROFILE%\.local\bin\uv.exe and
# adds that dir to the User PATH. The current process's PATH isn't
# refreshed automatically prepend the install dir so the rest of
# refreshed automatically - prepend the install dir so the rest of
# this script picks it up.
$uvDir = Join-Path $env:USERPROFILE '.local\bin'
if (Test-Path (Join-Path $uvDir 'uv.exe')) {
@@ -279,13 +279,13 @@ if (Test-Path (Join-Path $srcDir '.git')) {
}
# ---------------------------------------------------------------------------
# 6. uv sync --extra server
# 6. uv sync --extra desktop
# ---------------------------------------------------------------------------
Write-Info "Running 'uv sync --extra server' in $srcDir (this can take a few minutes)..."
Write-Info "Running 'uv sync --extra desktop' in $srcDir (this can take a few minutes)..."
Push-Location $srcDir
try {
& $uvExe sync --extra server
& $uvExe sync --extra desktop
if ($LASTEXITCODE -ne 0) {
Write-Fail "uv sync failed with exit code $LASTEXITCODE. Check the output above."
}
@@ -295,13 +295,13 @@ try {
Write-Ok "Dependencies installed"
# ---------------------------------------------------------------------------
# 7. Ollama install + start + wait for daemon
# 7. Ollama - install + start + wait for daemon
# ---------------------------------------------------------------------------
Write-Info "Checking Ollama..."
$ollamaExe = (Get-Command ollama -ErrorAction SilentlyContinue).Source
if (-not $ollamaExe) {
Write-Info " Ollama not on PATH downloading the official installer (~150 MB)..."
Write-Info " Ollama not on PATH - downloading the official installer (~150 MB)..."
$ollamaSetup = Join-Path $env:TEMP 'OllamaSetup.exe'
# SilentlyContinue is load-bearing in PS 5.1: the default progress
# bar renderer slows Invoke-WebRequest down 30x on large downloads
@@ -340,13 +340,18 @@ Write-Ok "Ollama ($ollamaExe)"
Write-Info "Waiting for Ollama daemon..."
$ollamaReady = $false
for ($i = 0; $i -lt 60; $i++) {
& $ollamaExe list 2>&1 | Out-Null
# 'ollama list' writes to stderr until the daemon is reachable; under
# $ErrorActionPreference='Stop' the 2>&1 merge surfaces that as a
# terminating NativeCommandError that would abort the whole install on
# the very first probe. Swallow it and rely on $LASTEXITCODE so the
# Start-Process serve fallback below actually runs (issue #522).
try { & $ollamaExe list 2>&1 | Out-Null } catch { }
if ($LASTEXITCODE -eq 0) {
$ollamaReady = $true
break
}
if ($i -eq 5) {
# Daemon clearly isn't auto-running start it ourselves. Ollama
# Daemon clearly isn't auto-running - start it ourselves. Ollama
# for Windows uses the tray app `ollama app.exe`; falling back to
# `ollama serve` works headless.
Start-Process -FilePath $ollamaExe -ArgumentList 'serve' -WindowStyle Hidden -ErrorAction SilentlyContinue
@@ -354,11 +359,11 @@ for ($i = 0; $i -lt 60; $i++) {
Start-Sleep -Seconds 1
}
if (-not $ollamaReady) {
Write-Warn2 "Ollama daemon didn't become ready in 60s. Continuing bg-orchestrator will retry later."
Write-Warn2 "Ollama daemon didn't become ready in 60s. Continuing - bg-orchestrator will retry later."
}
# ---------------------------------------------------------------------------
# 8. Pull a starter model (qwen3.5:2b ~1.5 GB)
# 8. Pull a starter model (qwen3.5:2b - ~1.5 GB)
# ---------------------------------------------------------------------------
$modelPullOk = $false
@@ -372,11 +377,11 @@ if ($ollamaReady) {
Write-Warn2 "ollama pull failed; the bg-orchestrator will retry once Ollama is reachable."
}
} else {
Write-Warn2 "Skipping model pull daemon wasn't ready."
Write-Warn2 "Skipping model pull - daemon wasn't ready."
}
# ---------------------------------------------------------------------------
# 9. jarvis.cmd shim so bare `jarvis` works in any new PowerShell
# 9. jarvis.cmd shim - so bare `jarvis` works in any new PowerShell
# ---------------------------------------------------------------------------
$binDir = Join-Path $installRoot 'bin'
@@ -387,7 +392,7 @@ if (-not (Test-Path $binDir)) {
}
# %~dp0 in a .cmd file resolves to the directory containing the script,
# so the shim is self-locating moving %LOCALAPPDATA%\OpenJarvis won't
# so the shim is self-locating - moving %LOCALAPPDATA%\OpenJarvis won't
# break it as long as the user moves the whole tree. `uv` is resolved
# from PATH at runtime (astral installer adds it to User PATH); avoids
# pinning to the install-time uv.exe path which can shift on uv updates.
@@ -400,7 +405,7 @@ uv run --project "%SRC%" jarvis %*
Set-Content -Path $shimPath -Value $shimContent -Encoding ASCII
# Add %LOCALAPPDATA%\OpenJarvis\bin to User PATH if it isn't already
# there. The current process won't see it until restart handled in the
# there. The current process won't see it until restart - handled in the
# final banner.
#
# Compare against the EXPANDED form: a previous install may have written
@@ -430,7 +435,7 @@ Write-Ok "jarvis shim installed at $shimPath"
$serviceScript = Join-Path $srcDir 'deploy\windows\jarvis-service.ps1'
$shouldInstallService = $false
# Pre-check admin if the user wants the service Register-ScheduledTask
# Pre-check admin if the user wants the service - Register-ScheduledTask
# requires elevation. We do this before the prompt so we don't ask "do
# you want the service?" only to fail with Access Denied after they say
# yes.
@@ -439,7 +444,7 @@ $isAdmin = ([Security.Principal.WindowsPrincipal] `
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if ($Service -and -not $isAdmin) {
Write-Fail "-Service was requested, but this PowerShell is not elevated. Register-ScheduledTask needs admin rights re-run from an elevated PowerShell, or drop -Service."
Write-Fail "-Service was requested, but this PowerShell is not elevated. Register-ScheduledTask needs admin rights - re-run from an elevated PowerShell, or drop -Service."
}
if ($Service) {
$shouldInstallService = $true
@@ -448,7 +453,7 @@ if ($Service) {
} elseif (-not $isAdmin) {
# Default to skip-with-explanation when we can't elevate, rather
# than prompting and then failing at Register-ScheduledTask.
Write-Warn2 "Skipping scheduled-task setup this PowerShell is not elevated."
Write-Warn2 "Skipping scheduled-task setup - this PowerShell is not elevated."
Write-Warn2 " Register-ScheduledTask requires admin. To install the service later:"
Write-Warn2 " Right-click PowerShell -> Run as administrator, then run:"
Write-Warn2 " powershell -ExecutionPolicy Bypass -File `"$serviceScript`" install"
@@ -464,7 +469,7 @@ if ($Service) {
$reply = Read-Host "Register OpenJarvis as a Windows scheduled task (auto-start at logon, loopback only)? [y/N]"
$shouldInstallService = ($reply -match '^[yY]')
} else {
Write-Warn2 "Non-interactive install skipping scheduled-task setup."
Write-Warn2 "Non-interactive install - skipping scheduled-task setup."
Write-Warn2 "To register the service later, run (from an elevated PowerShell):"
Write-Warn2 " powershell -ExecutionPolicy Bypass -File `"$serviceScript`" install"
}
@@ -487,9 +492,9 @@ if ($shouldInstallService) {
# ---------------------------------------------------------------------------
Write-Host ""
Write-Host " ┌──────────────────────────────────┐" -ForegroundColor Green
Write-Host " OpenJarvis install complete " -ForegroundColor Green
Write-Host " └──────────────────────────────────┘" -ForegroundColor Green
Write-Host " +----------------------------------+" -ForegroundColor Green
Write-Host " | OpenJarvis install complete |" -ForegroundColor Green
Write-Host " +----------------------------------+" -ForegroundColor Green
Write-Host ""
Write-Host " Repo: $srcDir"
+1 -1
View File
@@ -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 ..
```
+40
View File
@@ -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
+3 -2
View File
@@ -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
+2 -2
View File
@@ -8,7 +8,7 @@ avoid a Linux VM; WSL2 remains the smoother experience for most users.
## What you get
- A PowerShell installer that probes prerequisites, installs `uv`,
clones the repo, and runs `uv sync --extra server`.
clones the repo, and runs `uv sync --extra desktop`.
- An optional Windows scheduled-task service equivalent to the systemd
unit and launchd plist.
- Loopback default — the service binds `127.0.0.1` so no API key is
@@ -38,7 +38,7 @@ The installer will:
4. Install `uv` if absent (via the official `astral.sh/uv` PowerShell
installer).
5. Clone the repo to `%LOCALAPPDATA%\OpenJarvis\src`.
6. Run `uv sync --extra server`.
6. Run `uv sync --extra desktop`.
7. Prompt to register the scheduled-task service (skip with
`-SkipService`).
+1 -1
View File
@@ -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)**
+35
View File
@@ -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
View File
@@ -1,14 +1,14 @@
# Evaluations
The OpenJarvis evaluation framework (`openjarvis-evals`) measures model **correctness and accuracy** on academic datasets. It is a separate package from the main OpenJarvis library and is designed specifically for research workflows where you need reproducible, dataset-driven quality assessments.
The OpenJarvis evaluation framework (`openjarvis.evals`) measures model **correctness and accuracy** on academic datasets. It ships inside the main `openjarvis` package (at `src/openjarvis/evals/`) and is designed specifically for research workflows where you need reproducible, dataset-driven quality assessments.
!!! info "Evals vs. Benchmarks"
OpenJarvis has two distinct measurement systems that complement each other:
| System | Package | Measures | Entry Point |
|--------|---------|----------|-------------|
| **Evaluations** | `openjarvis-evals` | Correctness on academic datasets (accuracy, pass rate) | `openjarvis-eval` |
| **Benchmarks** | `openjarvis` | Engine performance (latency, throughput) | `jarvis bench` |
| System | Module | Measures | Entry Point |
|--------|--------|----------|-------------|
| **Evaluations** | `openjarvis.evals` | Correctness on academic datasets (accuracy, pass rate) | `jarvis eval` |
| **Benchmarks** | `openjarvis.bench` | Engine performance (latency, throughput) | `jarvis bench` |
Use evaluations to answer "does this model get the right answer?" and benchmarks to answer "how fast does this model respond?". See the [Benchmarks guide](benchmarks.md) for the performance measurement system.
@@ -18,22 +18,38 @@ The OpenJarvis evaluation framework (`openjarvis-evals`) measures model **correc
## Installation
The evaluation framework is a standalone package in the `evals/` directory. Install it alongside OpenJarvis:
The evaluation framework is part of the main `openjarvis` package — no separate install or extra is required. The standard dev setup is enough:
```bash
uv sync --extra eval
uv sync --extra dev
```
This installs the `openjarvis-eval` CLI entry point and all required dependencies (`datasets`, `huggingface-hub`, `tqdm`, `rich`).
The framework's core dependencies (`click`, `datasets`, `rich`) are base dependencies of `openjarvis`. Two optional extras enable experiment tracking integrations:
```bash
uv sync --extra dev --extra eval-wandb # Weights & Biases run tracking
uv sync --extra dev --extra eval-sheets # Google Sheets results export
```
!!! note "Python version requirement"
Python 3.10 requires the `tomli` package for TOML config parsing. The `evals/pyproject.toml` includes this as a conditional dependency, so it is installed automatically.
Python 3.10 requires the `tomli` package for TOML config parsing. `openjarvis` declares it as a conditional dependency, so it is installed automatically.
## Entry Points
Two equivalent entry points expose the framework:
| Command | Surface |
|---------|---------|
| `jarvis eval {list,run,compare,report}` | Canonical CLI. `run` covers the common options; `compare` and `report` post-process result files. |
| `python -m openjarvis.evals {list,run,run-all,summarize,reparse-judge}` | Full research surface, including judge configuration, the agentic runner, and episode mode. |
The `openjarvis-eval` console script is an alias for `python -m openjarvis.evals` — same commands, same options. This guide uses `jarvis eval` wherever its option set suffices and the module form for research-only options.
---
## Datasets
The framework ships with **30+ datasets** covering academic reasoning, agentic tasks, retrieval, conversation quality, and practical use-case benchmarks. Datasets are grouped by category below.
The framework ships with **40 registered benchmarks** covering academic reasoning, agentic tasks, coding, retrieval, conversation quality, and practical use-case benchmarks. Datasets are grouped by category below; `uv run python -m openjarvis.evals list` prints the authoritative registry.
### Use-Case Benchmarks
@@ -64,6 +80,7 @@ These benchmarks measure reasoning and knowledge on established academic dataset
| **MATH-500** | `math500` | reasoning | Competition-level math problems |
| **NaturalReasoning** | `natural-reasoning` | reasoning | Natural language reasoning |
| **HLE** | `hle` | reasoning | Humanity's Last Exam hard challenges |
| **LiveResearchBench** | `liveresearchbench` | reasoning | Recent research comprehension (Salesforce) |
| **SimpleQA** | `simpleqa` | chat | Short-form factual question answering |
| **IPW** | `ipw` | chat | Intelligence Per Watt mixed benchmark |
@@ -79,6 +96,11 @@ These benchmarks test multi-step agent capabilities including tool use, code gen
| **TerminalBench** | `terminalbench` | agentic | Terminal-based task completion |
| **TerminalBench Native** | `terminalbench-native` | agentic | TerminalBench with native Docker execution |
| **TerminalBench V2.1** | `terminalbench-v2.1` | agentic | TB v2.1 Harbor-style Docker tasks |
| **PinchBench** | `pinchbench` | agentic | Real-world agent tasks |
| **TauBench** | `taubench` | agentic | Multi-turn customer service |
| **DeepResearchBench** | `liveresearch` | agentic | Deep research report generation |
| **DeepResearchBench (alias)** | `deepresearch` | agentic | Same benchmark as `liveresearch` |
| **ToolCall-15** | `toolcall15` | agentic | Tool calling benchmark |
| **LifelongAgent** | `lifelong-agent` | agentic | Sequential task learning across sessions |
| **PaperArena** | `paperarena` | agentic | Scientific paper analysis |
| **DeepPlanning** | `deepplanning` | agentic | Shopping constraint planning |
@@ -87,6 +109,14 @@ These benchmarks test multi-step agent capabilities including tool use, code gen
| **WebChoreArena** | `webchorearena` | agentic | Web chore tasks |
| **WorkArena** | `workarena` | agentic | WorkArena++ enterprise workflows |
Both `liveresearch` and `deepresearch` are registered keys for the DeepResearchBench report-generation benchmark.
### Coding Benchmarks
| Dataset | Key | Category | Description |
|---------|-----|----------|-------------|
| **LiveCodeBench** | `livecodebench` | coding | Competitive programming |
### Retrieval Benchmarks
| Dataset | Key | Category | Description |
@@ -123,7 +153,7 @@ The framework includes two pre-built configs for evaluating models on the five c
### Cloud models
```bash
uv run python -m openjarvis.evals --config src/openjarvis/evals/configs/use_case_v2_cloud.toml
uv run jarvis eval run --config src/openjarvis/evals/configs/use_case_v2_cloud.toml
```
This config evaluates **6 cloud models** (Claude Opus 4.6, Claude Haiku 4.5, Gemini 3.1 Pro, Gemini 3.1 Flash Lite, GPT-5.4, GPT-5 Mini) against all 5 use-case benchmarks with 30 samples each, producing a 6x5 = 30-run matrix. Results are written to `results/use-cases-v2-cloud/`.
@@ -131,7 +161,7 @@ This config evaluates **6 cloud models** (Claude Opus 4.6, Claude Haiku 4.5, Gem
### Local models
```bash
uv run python -m openjarvis.evals --config src/openjarvis/evals/configs/use_case_v2_local.toml
uv run jarvis eval run --config src/openjarvis/evals/configs/use_case_v2_local.toml
```
This config evaluates **5 local models** via Ollama (Qwen3.5 122B-A10B, GPT-OSS 120B, GLM4, Qwen3.5 35B-A3B, GLM-4.7-Flash) against the same 5 benchmarks, producing a 5x5 = 25-run matrix. Uses 2 workers (suitable for single-GPU setups). Results are written to `results/use-cases-v2-local/`.
@@ -143,15 +173,22 @@ This config evaluates **5 local models** via Ollama (Qwen3.5 122B-A10B, GPT-OSS
## Inference Backends
Every evaluation run routes model calls through one of two backends:
Every evaluation run routes model calls through one of four backends:
| Backend | Key | Description |
|---------|-----|-------------|
| **jarvis-direct** | `jarvis-direct` | Engine-level inference via `SystemBuilder`. Works for local (Ollama, vLLM, llama.cpp) and cloud models. |
| **jarvis-agent** | `jarvis-agent` | Agent-level inference with tool calling. Uses `JarvisSystem.ask()` with the specified agent and tools. |
| **hermes** | `hermes` | Real Hermes Agent (Nous Research) via subprocess. Requires `--base-url` and `--api-key`. |
| **openclaw** | `openclaw` | Real OpenClaw via Node subprocess. Requires `--base-url` and `--api-key`. |
Use `jarvis-direct` for most evaluations. Use `jarvis-agent` when the benchmark requires tool use — for example, GAIA tasks that reference files that must be read with `file_read`, or arithmetic tasks that benefit from `calculator`.
The `hermes` and `openclaw` backends shell out to external agent frameworks and need an OpenAI-compatible endpoint for their model calls: pass `--base-url`/`--api-key`, set the `JARVIS_BACKEND_BASE_URL`/`JARVIS_BACKEND_API_KEY` environment variables, or add a `[backend.external]` section to your config (see [Config Reference](#backendexternal)).
!!! note "TerminalBench Native"
`jarvis eval run --backend` additionally accepts `terminalbench-native`, a Docker-based execution backend used by the TerminalBench Native benchmark.
---
## CLI Usage
@@ -159,73 +196,106 @@ Use `jarvis-direct` for most evaluations. Use `jarvis-agent` when the benchmark
### List available benchmarks and backends
```bash
openjarvis-eval list
uv run python -m openjarvis.evals list
```
Output:
Abridged output (40 benchmarks, 4 backends):
```
Benchmarks:
supergpqa [reasoning ] SuperGPQA multiple-choice
gaia [agentic ] GAIA agentic benchmark
frames [rag ] FRAMES multi-hop RAG
wildchat [chat ] WildChat conversation quality
Backends:
jarvis-direct Engine-level inference (local or cloud)
jarvis-agent Agent-level inference with tool calling
Available Benchmarks
┌──────────────────────┬───────────┬───────────────────────────────────┐
│ Name │ Category │ Description │
├──────────────────────┼───────────┼───────────────────────────────────┤
│ supergpqa │ reasoning │ SuperGPQA multiple-choice │
│ gpqa │ reasoning │ GPQA graduate-level MCQ │
│ ... │ ... │ ... │
│ livecodebench │ coding │ LiveCodeBench competitive progr. │
│ toolcall15 │ agentic │ ToolCall-15 tool calling benchmark│
└──────────────────────┴───────────┴───────────────────────────────────┘
Available Backends
┌───────────────┬──────────────────────────────────────────────────┐
│ jarvis-direct │ Engine-level inference (local or cloud) │
│ jarvis-agent │ Agent-level inference with tool calling │
│ hermes │ Real Hermes Agent (Nous Research) via subprocess │
│ openclaw │ Real OpenClaw via Node subprocess │
└───────────────┴──────────────────────────────────────────────────┘
```
`jarvis eval list` prints a similar table but currently shows a curated subset of the registry; the module form above is the authoritative listing.
### Run a single benchmark
```bash
# Evaluate qwen3:8b on SuperGPQA (engine-level, 10 samples default)
openjarvis-eval run -b supergpqa -m qwen3:8b
# Evaluate qwen3:8b on SuperGPQA (engine-level, 10 samples)
uv run jarvis eval run -b supergpqa -m qwen3:8b -n 10
# Evaluate GPT-4o on GAIA using the agent backend with tools
openjarvis-eval run -b gaia -m gpt-4o --backend jarvis-agent \
# Evaluate GPT-5 Mini on GAIA using the agent backend with tools
uv run jarvis eval run -b gaia -m gpt-5-mini --backend jarvis-agent \
--agent orchestrator --tools calculator,file_read -n 50
# Run FRAMES with vLLM engine, write output to a file
openjarvis-eval run -b frames -m llama3:70b -e vllm \
# Run FRAMES with the vLLM engine, write output to a file
uv run jarvis eval run -b frames -m llama3:70b -e vllm \
-o results/frames_llama70b.jsonl
# Run WildChat with a higher temperature for chat quality
openjarvis-eval run -b wildchat -m qwen3:8b --temperature 0.7 -n 100
uv run jarvis eval run -b wildchat -m qwen3:8b --temperature 0.7 -n 100
```
#### Full option reference
#### `jarvis eval run` option reference
| Option | Short | Type | Default | Description |
|--------|-------|------|---------|-------------|
| `--config` | `-c` | path | — | TOML config file; when provided, `-b` and `-m` are not required |
| `--benchmark` | `-b` | choice | required* | `supergpqa`, `gaia`, `frames`, or `wildchat` |
| `--backend` | | choice | `jarvis-direct` | `jarvis-direct` or `jarvis-agent` |
| `--model` | `-m` | str | required* | Model identifier (e.g., `qwen3:8b`, `gpt-4o`) |
| `--engine` | `-e` | str | auto | Engine key (`ollama`, `vllm`, `cloud`, ...) |
| `--agent` | | str | `orchestrator` | Agent name for `jarvis-agent` backend |
| `--tools` | | str | `""` | Comma-separated tool names (e.g., `calculator,file_read`) |
| `--benchmark` | `-b` | str | required* | Any registered benchmark key (see `... list`) |
| `--model` | `-m` | str | required* | Model identifier (e.g., `qwen3:8b`, `gpt-5-mini`) |
| `--max-samples` | `-n` | int | all | Limit the number of samples evaluated |
| `--max-workers` | `-w` | int | `4` | Parallel evaluation workers |
| `--judge-model` | | str | `gpt-4o` | LLM used for judge-based scoring |
| `--output` | `-o` | path | auto-generated | Output JSONL file path |
| `--backend` | | choice | `jarvis-direct` | `jarvis-direct`, `jarvis-agent`, `hermes`, `openclaw`, or `terminalbench-native` |
| `--base-url` | | str | — | OpenAI-compatible endpoint URL (env: `JARVIS_BACKEND_BASE_URL`) |
| `--api-key` | | str | — | API key for the endpoint (env: `JARVIS_BACKEND_API_KEY`) |
| `--agent` | | str | — | Agent name for `jarvis-agent` backend (e.g., `orchestrator`) |
| `--engine` | `-e` | str | auto | Engine key (`ollama`, `vllm`, `cloud`, ...) |
| `--tools` | | str | `""` | Comma-separated tool names (e.g., `calculator,file_read`) |
| `--telemetry/--no-telemetry` | | flag | off | Enable telemetry collection during eval |
| `--gpu-metrics/--no-gpu-metrics` | | flag | off | Enable GPU metric polling |
| `--seed` | | int | `42` | Random seed for dataset shuffling |
| `--split` | | str | dataset default | Override the dataset split |
| `--temperature` | | float | `0.0` | Generation temperature |
| `--max-tokens` | | int | `2048` | Maximum output tokens |
| `--model-filter` | | str | — | Filter models by name substring (multi-model configs) |
| `--output` | `-o` | path | auto-generated | Output JSONL file path |
| `--wandb-project` / `--wandb-entity` / `--wandb-tags` / `--wandb-group` | | str | `""` | Weights & Biases tracking (requires `eval-wandb` extra) |
| `--sheets-id` / `--sheets-worksheet` / `--sheets-creds` | | str | `""` | Google Sheets export (requires `eval-sheets` extra) |
| `--verbose` | `-v` | flag | off | Enable debug logging |
*Required when `--config` is not provided.
#### Research-only options (`python -m openjarvis.evals run`)
The module CLI accepts everything above plus research-grade options that `jarvis eval run` does not expose:
| Option | Short | Type | Default | Description |
|--------|-------|------|---------|-------------|
| `--max-workers` | `-w` | int | `4` | Parallel evaluation workers |
| `--judge-model` | | str | `gpt-5-mini-2025-08-07` | LLM used for judge-based scoring (see `--help` for the current default) |
| `--judge-engine` | | str | `cloud` | Engine key for the LLM judge; use `vllm` to judge locally |
| `--split` | | str | dataset default | Override the dataset split |
| `--compact` | | flag | off | Dense single-table output |
| `--trace-detail` | | flag | off | Full per-step trace listing |
| `--agentic` | | flag | off | Use `AgenticRunner` for multi-turn agent execution |
| `--episode-mode` | | flag | off | Sequential episode processing with lifelong learning (required for `lifelong-agent` and similar benchmarks) |
| `--concurrency` | | int | `1` | Parallel query execution (AgenticRunner only) |
| `--query-timeout` | | float | — | Per-query wall-clock timeout in seconds (AgenticRunner only) |
Note: the module CLI's `--backend` choice covers `jarvis-direct`, `jarvis-agent`, `hermes`, and `openclaw`; `terminalbench-native` as a backend is available via `jarvis eval run` and TOML configs.
### Run all benchmarks at once
The `run-all` command evaluates a single model against all four benchmarks sequentially and writes results to an output directory:
The `run-all` command (module CLI only) evaluates a single model against **every registered benchmark** sequentially and writes results to an output directory:
```bash
openjarvis-eval run-all -m qwen3:8b
uv run python -m openjarvis.evals run-all -m qwen3:8b
# With options
openjarvis-eval run-all -m gpt-4o -n 100 --output-dir results/gpt4o/
uv run python -m openjarvis.evals run-all -m gpt-5-mini -n 100 --output-dir results/gpt5mini/
```
Output files are written as `{output_dir}/{benchmark}_{model-slug}.jsonl`. The model slug replaces `/` and `:` with `-`, so `qwen3:8b` becomes `qwen3-8b`.
@@ -235,7 +305,7 @@ Output files are written as `{output_dir}/{benchmark}_{model-slug}.jsonl`. The m
After a run, inspect a JSONL results file:
```bash
openjarvis-eval summarize results/supergpqa_qwen3-8b.jsonl
uv run python -m openjarvis.evals summarize results/supergpqa_qwen3-8b.jsonl
```
Output:
@@ -251,6 +321,55 @@ Accuracy: 0.7222
Errors: 2
```
The module CLI also provides `reparse-judge`, which re-parses stored judge output in a results file and recovers records whose judge verdicts initially failed to parse — useful after improving the judge-output parser without re-running inference.
### Compare and report
`jarvis eval` adds two post-processing commands for result files:
```bash
# Side-by-side metric comparison across runs
uv run jarvis eval compare results/supergpqa_qwen3-8b.jsonl results/supergpqa_gpt-5-mini.jsonl
# Detailed report (accuracy, latency, cost, per-subject breakdown) for one run
uv run jarvis eval report results/supergpqa_qwen3-8b.jsonl
```
---
## Evaluating an Already-Running Endpoint
If you already have an OpenAI-compatible server running — `jarvis serve`, vLLM, SGLang, llama.cpp's server, or a hosted endpoint — point an eval directly at it with `--base-url` and `--api-key`:
```bash
# A vLLM server is already serving Qwen/Qwen3-8B on a GPU node:
# vllm serve Qwen/Qwen3-8B --port 8000
uv run jarvis eval run -b supergpqa -m Qwen/Qwen3-8B \
--base-url http://gpu-node:8000/v1 \
--api-key local-key \
-n 50
```
The `-m` value must match a model id the server reports at `GET /v1/models`. Both flags fall back to the `JARVIS_BACKEND_BASE_URL` and `JARVIS_BACKEND_API_KEY` environment variables, so CI jobs can set them once:
```bash
export JARVIS_BACKEND_BASE_URL=http://gpu-node:8000/v1
export JARVIS_BACKEND_API_KEY=local-key
uv run jarvis eval run -b gaia -m Qwen/Qwen3-8B --backend jarvis-agent -n 25
```
For the external `hermes` and `openclaw` backends these values are **required** (the foreign frameworks need an endpoint to send model calls to).
!!! tip "Engine-level alternative for vLLM"
The vLLM engine also honors the `VLLM_HOST` environment variable (default `http://localhost:8000`):
```bash
VLLM_HOST=http://gpu-node:8000 uv run python -m openjarvis.evals run \
-b supergpqa -m Qwen/Qwen3-8B -e vllm -n 50
```
`VLLM_HOST` is process-global — if the candidate and the judge both use the `vllm` engine, they share the same endpoint. Prefer `--base-url` when you need them separate.
---
## TOML Config System
@@ -260,7 +379,7 @@ For research workflows that compare multiple models across multiple benchmarks,
### Running from a config
```bash
openjarvis-eval run --config src/openjarvis/evals/configs/full-suite.toml
uv run jarvis eval run --config src/openjarvis/evals/configs/full-suite.toml
```
When `--config` is provided, the `-b`/`--benchmark` and `-m`/`--model` options are not required. All settings come from the config file. The CLI expands the matrix, prints a progress table, and writes results to the configured `output_dir`.
@@ -269,7 +388,7 @@ When `--config` is provided, the `-b`/`--benchmark` and `-m`/`--model` options a
A config file has six sections: `[meta]`, `[defaults]`, `[judge]`, `[run]`, `[[models]]`, and `[[benchmarks]]`. Only `[[models]]` and `[[benchmarks]]` are required — all other sections are optional and fall back to built-in defaults.
```toml title="evals/configs/full-suite.toml"
```toml title="src/openjarvis/evals/configs/full-suite.toml"
# Suite-level metadata (optional)
[meta]
name = "full-suite-v1"
@@ -353,7 +472,7 @@ For example, `temperature` is resolved as: use `[defaults].temperature` (0.0), t
A config requires only one `[[models]]` and one `[[benchmarks]]` entry:
```toml title="evals/configs/minimal.toml"
```toml title="src/openjarvis/evals/configs/minimal.toml"
[[models]]
name = "qwen3:8b"
@@ -365,7 +484,7 @@ This runs SuperGPQA against qwen3:8b with all default settings. Use this as a st
### Single-run config with full options
```toml title="evals/configs/single-run.toml"
```toml title="src/openjarvis/evals/configs/single-run.toml"
[meta]
name = "single-run-example"
description = "Evaluate SuperGPQA with a single model and full configuration"
@@ -425,7 +544,8 @@ Configuration for the LLM used as a judge in GAIA, FRAMES, and WildChat scoring.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `model` | str | `"gpt-4o"` | Judge model identifier |
| `model` | str | `"gpt-5-mini-2025-08-07"` | Judge model identifier |
| `engine` | str | `None` | Engine key for the judge (e.g., `"vllm"` to judge locally; defaults to cloud) |
| `provider` | str | `None` | Provider override (e.g., `"openai"`) |
| `temperature` | float | `0.0` | Judge sampling temperature |
| `max_tokens` | int | `1024` | Maximum judge output tokens |
@@ -444,6 +564,20 @@ Execution settings that apply to the entire suite.
| `seed` | int | `42` | Random seed for dataset shuffling |
| `telemetry` | bool | `false` | Enable GPU telemetry capture (energy, power, utilization, throughput) |
| `gpu_metrics` | bool | `false` | Enable GPU metric polling via `pynvml` (requires `pynvml` or `nvidia-ml-py`) |
| `warmup_samples` | int | `0` | Untimed warmup samples before measurement |
| `energy_vendor` | str | `""` | GPU energy vendor override |
| `max_turns` | int | `None` | Maximum agent turns per query |
| `wandb_project` / `wandb_entity` / `wandb_tags` / `wandb_group` | str | `""` | Weights & Biases tracking |
| `sheets_spreadsheet_id` / `sheets_worksheet` / `sheets_credentials_path` | str | `""` / `"Results"` / `""` | Google Sheets export |
### `[backend.external]`
Endpoint settings for the `hermes` and `openclaw` backends. Environment variables override TOML values.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `base_url` | str | `None` | OpenAI-compatible endpoint URL (env: `JARVIS_BACKEND_BASE_URL`) |
| `api_key` | str | `None` | API key for the endpoint (env: `JARVIS_BACKEND_API_KEY`) |
### `[[models]]`
@@ -451,7 +585,7 @@ One block per model. The `name` field is required.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `name` | str | required | Model identifier (e.g., `"qwen3:8b"`, `"gpt-4o"`) |
| `name` | str | required | Model identifier (e.g., `"qwen3:8b"`, `"gpt-5-mini"`) |
| `engine` | str | `None` | Engine key to use (`"ollama"`, `"vllm"`, `"cloud"`, ...) |
| `provider` | str | `None` | Provider override for cloud models (e.g., `"openai"`) |
| `temperature` | float | `None` | Override `[defaults].temperature` for this model |
@@ -468,10 +602,12 @@ One block per benchmark. The `name` field is required.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `name` | str | required | Benchmark key: `supergpqa`, `gaia`, `frames`, or `wildchat` |
| `backend` | str | `"jarvis-direct"` | Inference backend: `jarvis-direct` or `jarvis-agent` |
| `name` | str | required | Any registered benchmark key (see `uv run python -m openjarvis.evals list`) |
| `backend` | str | `"jarvis-direct"` | `jarvis-direct`, `jarvis-agent`, `hermes`, `openclaw`, or `terminalbench-native` |
| `max_samples` | int | `None` | Limit number of samples; `None` evaluates the full dataset |
| `split` | str | `None` | Override the default dataset split |
| `subset` | str | `None` | Dataset subset/variant (benchmark-specific) |
| `record_ids` | list[str] | `None` | Evaluate only these record ids |
| `agent` | str | `None` | Agent name for `jarvis-agent` backend (e.g., `"orchestrator"`) |
| `tools` | list[str] | `[]` | Tool names for `jarvis-agent` backend |
| `judge_model` | str | `None` | Override `[judge].model` for this benchmark only |
@@ -647,7 +783,7 @@ The `EvalRunner` processes samples concurrently using a `ThreadPoolExecutor`. Re
```bash
# Use more workers for faster evaluation (if the engine supports concurrent requests)
openjarvis-eval run -b supergpqa -m qwen3:8b -w 8 -n 500
uv run python -m openjarvis.evals run -b supergpqa -m qwen3:8b -w 8 -n 500
```
!!! warning "Worker count and engine load"
+105 -17
View File
@@ -682,7 +682,7 @@ fn format_uv_sync_failure(
format!(
"`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\
Try opening a terminal in that directory and running \
`uv sync --extra server` manually for the full output.",
`uv sync --extra desktop` manually for the full output.",
root.display(),
code,
uv_sync_stderr_tail(stderr, 800),
@@ -1143,7 +1143,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
sync_cmd
.args([
"sync",
"--extra", "server",
"--extra", "desktop",
"--extra", "inference-cloud",
"--extra", "inference-google",
])
@@ -1540,19 +1540,89 @@ async fn fetch_models(api_url: String) -> Result<serde_json::Value, String> {
#[tauri::command]
async fn run_jarvis_command(args: Vec<String>) -> Result<String, String> {
let mut cmd_args = vec!["run".to_string(), "jarvis".to_string()];
cmd_args.extend(args);
let uv_bin = resolve_bin("uv");
let output = tokio::process::Command::new(&uv_bin)
.args(&cmd_args)
.output()
.await
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
let mut cmd_args = vec!["run".to_string(), "jarvis".to_string()];
cmd_args.extend(args.iter().cloned());
let mut cmd = tokio::process::Command::new(&uv_bin);
cmd.args(&cmd_args);
// Run from the project root so `uv run jarvis` resolves the OpenJarvis
// project regardless of the app's launch cwd. In a packaged install the
// cwd isn't the checkout, so without this `jarvis` isn't found and the
// backend never starts — the UI then shows "Failed to get response"
// (see #531).
if let Some(ref root) = find_project_root() {
cmd.current_dir(root);
}
let is_serve = args.first().map(|a| a.as_str() == "serve").unwrap_or(false);
if !is_serve {
// Short-lived command (e.g. `stop`, `status`): wait for it and return
// its captured output.
let output = cmd
.output()
.await
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
return if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
};
}
// `jarvis serve` is a long-running server that never exits. The old code
// used `.output()`, which waits for the process to exit and so hung this
// command forever — the "Start" button never resolved (#531). Spawn it
// detached instead, drain stderr (a full 4 KB Windows pipe can otherwise
// stall the child mid-startup, #309), and poll /health for readiness.
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to launch jarvis serve: {}", e))?;
let tail: StderrTail = Arc::new(Mutex::new(Vec::new()));
if let Some(stderr) = child.stderr.take() {
spawn_jarvis_stderr_drainer(stderr, tail.clone());
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
let url = format!("http://127.0.0.1:{}/health", JARVIS_PORT);
let deadline = tokio::time::Instant::now() + Duration::from_secs(120);
loop {
// Surface an early crash (bad venv, missing Rust ext, etc.) right away
// instead of waiting out the full readiness timeout.
if let Ok(Some(status)) = child.try_wait() {
let stderr = String::from_utf8_lossy(tail.lock().await.as_slice()).into_owned();
return Err(format!(
"jarvis serve exited (code {:?}) before becoming healthy:\n{}",
status.code(),
stderr.trim()
));
}
if let Ok(resp) = client.get(&url).send().await {
if resp.status().is_success() {
// Leave the server running (the Child is detached on drop —
// kill_on_drop defaults to false); `stop` tears it down.
return Ok(format!(
"jarvis serve is ready on http://127.0.0.1:{}",
JARVIS_PORT
));
}
}
if tokio::time::Instant::now() >= deadline {
return Err(format!(
"jarvis serve did not become healthy on port {} within 120s.",
JARVIS_PORT
));
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
@@ -1594,11 +1664,29 @@ async fn transcribe_audio(
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
if !status.is_success() {
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|value| {
value
.get("detail")
.and_then(|detail| detail.as_str())
.map(str::to_string)
})
.filter(|detail| !detail.is_empty())
.unwrap_or(body);
return Err(format!(
"Transcription failed ({}): {}",
status.as_u16(),
detail
));
}
serde_json::from_str(&body).map_err(|e| format!("Invalid response: {}", e))
}
/// Submit savings to Supabase leaderboard.
@@ -2486,7 +2574,7 @@ mod tests {
assert!(msg.contains("exit 2"));
assert!(msg.contains("/home/u/.openjarvis/src"));
assert!(msg.contains("failed to resolve numpy==2.1.3"));
assert!(msg.contains("uv sync --extra server")); // actionable next step
assert!(msg.contains("uv sync --extra desktop")); // actionable next step
}
#[test]
+13 -1
View File
@@ -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 {
+35 -6
View File
@@ -317,8 +317,9 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
audioData: Array.from(new Uint8Array(buffer)),
filename,
});
} catch {
// Fall through to fetch
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(msg || 'Transcription failed');
}
}
const formData = new FormData();
@@ -327,7 +328,16 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
method: 'POST',
body: formData,
});
if (!res.ok) throw new Error(`Transcription failed: ${res.status}`);
if (!res.ok) {
let detail = "";
try {
const body = await res.json();
detail = typeof body.detail === 'string' ? body.detail : "";
} catch {
// Keep the status-only message below when the body is not JSON.
}
throw new Error(detail || `Transcription failed: ${res.status}`);
}
return res.json();
}
@@ -948,12 +958,31 @@ export interface MemoryStats {
export interface MemoryConfig {
backend: string;
// Set by the server when the native `openjarvis_rust` extension is missing,
// so the UI can show the real cause instead of a healthy-looking config.
available?: boolean;
detail?: string | null;
context_from_memory: boolean;
context_top_k: number;
context_min_score: number;
context_max_tokens: number;
}
/**
* Extract the server's `detail` message from a failed JSON response so the UI
* surfaces the real cause (e.g. "openjarvis_rust extension is not installed")
* instead of a blanket fallback string (#502).
*/
async function memoryErrorDetail(res: Response, fallback: string): Promise<string> {
try {
const data = await res.json();
if (data && typeof data.detail === 'string' && data.detail) return data.detail;
} catch {
// Non-JSON body — fall through to the generic message below.
}
return fallback;
}
export async function getMemoryStats(): Promise<MemoryStats> {
const res = await apiFetch(`/v1/memory/stats`);
if (!res.ok) throw new Error('Failed to fetch memory stats');
@@ -977,16 +1006,16 @@ export async function storeMemory(content: string, metadata?: Record<string, unk
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, metadata }),
});
if (!res.ok) throw new Error('Failed to store memory');
if (!res.ok) throw new Error(await memoryErrorDetail(res, 'Failed to store memory'));
}
export async function indexMemoryPath(path: string): Promise<{ chunks_indexed: number }> {
export async function indexMemoryPath(path: string): Promise<{ chunks_indexed: number; note?: string }> {
const res = await apiFetch(`/v1/memory/index`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (!res.ok) throw new Error('Failed to index path');
if (!res.ok) throw new Error(await memoryErrorDetail(res, 'Failed to index path'));
return res.json();
}
+34 -3
View File
@@ -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',
+4 -3
View File
@@ -1,5 +1,5 @@
import type { ResearchEvent, SSEEvent } from '../types';
import { getBase } from './api';
import { getBase, authHeaders } from './api';
export interface ChatRequest {
model: string;
@@ -16,7 +16,7 @@ export async function* streamChat(
const base = getBase();
const response = await fetch(`${base}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(request),
signal,
});
@@ -67,7 +67,7 @@ export async function* streamResearch(
const base = getBase().replace(/\/v1\/?$/, '');
const response = await fetch(`${base}/api/research`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ query }),
signal,
});
@@ -106,3 +106,4 @@ export async function* streamResearch(
reader.releaseLock();
}
}
+14 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+15 -2
View File
@@ -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: [
+2
View File
@@ -193,6 +193,8 @@ nav:
- External MCP Servers: user-guide/mcp-external-servers.md
- Scheduler: user-guide/scheduler.md
- Telemetry: user-guide/telemetry.md
- Evaluations: user-guide/evaluations.md
- Benchmarks: user-guide/benchmarks.md
- Security: user-guide/security.md
- LLM-guided spec search: user-guide/llm-guided-spec-search.md
- Leaderboard: leaderboard.md
+34 -2
View File
@@ -1,10 +1,10 @@
[build-system]
requires = ["hatchling"]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "OpenJarvis"
version = "1.0.2"
dynamic = ["version"]
description = "OpenJarvis — modular AI assistant backend with composable intelligence primitives"
readme = "README.md"
# Upper bound: numpy 2.2.x (pinned transitively via datasets/pandas) ships no
@@ -84,6 +84,13 @@ server = [
"pydantic>=2.0",
"python-multipart>=0.0.9",
]
desktop = [
"fastapi>=0.110",
"uvicorn>=0.30",
"pydantic>=2.0",
"python-multipart>=0.0.9",
"faster-whisper>=1.0",
]
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
gpu-metrics = ["pynvml>=12.0"]
energy-amd = ["amdsmi>=6.1"]
@@ -152,6 +159,31 @@ Issues = "https://github.com/open-jarvis/OpenJarvis/issues"
[project.scripts]
jarvis = "openjarvis.cli:main"
openjarvis-eval = "openjarvis.evals.cli:main"
# Version is derived from git tags by hatch-vcs (see #526). For source/editable
# checkouts this yields the true `git describe` version (e.g. 1.0.3.dev109+g<sha>)
# rather than a stale static string. CI release builds override this with
# SETUPTOOLS_SCM_PRETEND_VERSION so the published version equals the pushed tag.
#
# setuptools_scm cannot bump custom `.devN` tags (only `.dev0`), so the autotag
# `vX.Y.Z.devN` tags are deliberately EXCLUDED from version derivation here; the
# base is taken from the latest plain release tag (vX.Y.Z) and the dev distance
# is computed from commit count since that release.
[tool.hatch.version]
source = "vcs"
[tool.hatch.version.raw-options]
tag_regex = '^v(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$'
git_describe_command = [
"git", "describe", "--dirty", "--tags", "--long",
"--match", "v[0-9]*", "--exclude", "*dev*", "--exclude", "*rc*", "--exclude", "desktop-*",
]
# Builds without a git checkout (e.g. the `COPY src/ src/` Docker stages, which
# never include .git) can't run `git describe`. Without a fallback that would
# hard-fail the build. Mirror the runtime sentinel in src/openjarvis/__init__.py.
# Such builds can inject the real version via SETUPTOOLS_SCM_PRETEND_VERSION.
fallback_version = "0.0.0+unknown"
[tool.hatch.build.targets.wheel]
packages = ["src/openjarvis"]
+16
View File
@@ -28,6 +28,22 @@ fi
cd "$SRC_DIR"
if uv run maturin develop -m "$MANIFEST" >>"$LOG" 2>&1; then
# Verify the extension actually imports from THIS venv before declaring
# success. `maturin develop` can report success while installing the .so
# into a different venv than the one that runs the server, which leaves
# memory silently broken at runtime (#502). Only the import check below
# proves the serving venv can load it.
if ! uv run python -c "import openjarvis_rust" >>"$LOG" 2>&1; then
rc=$?
{
echo "build-extension.sh: maturin succeeded but 'import openjarvis_rust'"
echo "failed in the serving venv ($SRC_DIR/.venv) — the extension was"
echo "not installed where the server runs. (exit=$rc)"
tail -n 50 "$LOG" 2>/dev/null || true
} > "$FAILED"
rm -f "$BUILT"
exit "$rc"
fi
tmp="$BUILT.tmp"
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$tmp"
mv "$tmp" "$BUILT"
+8
View File
@@ -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"
+1 -1
View File
@@ -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 ──────────────────────────────────────
+2 -1
View File
@@ -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
+3 -1
View File
@@ -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")
+51 -6
View File
@@ -73,12 +73,19 @@ WEB_SEARCH_COST_PER_CALL = 0.01
# $0.01/call number — kept as a separate constant so it can drift.
OPENAI_WEB_SEARCH_COST_PER_CALL = 0.01
# Gemini Google-Search grounding: billed at $35 per 1000 grounded
# *requests* (2025-12 public list price for the Grounding-with-Google-Search
# tool, charged once per request that uses the tool regardless of how many
# internal queries it issues). We charge per grounded request, not per
# `web_search_queries` entry.
GEMINI_SEARCH_COST_PER_CALL = 0.035
# Gemini 3 Google-Search grounding: billed at $14 per 1000 search queries.
# `_call_gemini_agent` reports the model's `web_search_queries`, so this is
# charged per query, not per outer generate_content request.
GEMINI_SEARCH_COST_PER_CALL = 0.014
# Tavily Search, advanced depth: 2 API credits per search request at $0.008
# per credit on the public pay-as-you-go plan. WebSearchTool captures actual
# credits when Tavily returns usage metadata; this is the fallback estimate.
TAVILY_SEARCH_COST_PER_CREDIT = 0.008
TAVILY_ADVANCED_SEARCH_CREDITS = 2
TAVILY_SEARCH_COST_PER_CALL = (
TAVILY_SEARCH_COST_PER_CREDIT * TAVILY_ADVANCED_SEARCH_CREDITS
)
ANTHROPIC_WEB_SEARCH_TOOL = {
"type": "web_search_20250305",
@@ -101,6 +108,40 @@ def build_web_search_tool(max_uses: int = 8) -> Dict[str, Any]:
}
def tavily_search_context(
query: str,
*,
max_results: int = 5,
) -> Dict[str, Any]:
"""Run OpenJarvis WebSearchTool and return accounting-friendly metadata."""
from openjarvis.tools.web_search import WebSearchTool
tool = WebSearchTool(max_results=max_results)
res = tool.execute(query=query, max_results=max_results)
meta = dict(res.metadata or {})
engine = str(meta.get("engine") or "unknown")
credits = 0
cost_usd = 0.0
if engine == "tavily":
try:
credits = int(meta.get("credits") or TAVILY_ADVANCED_SEARCH_CREDITS)
except (TypeError, ValueError):
credits = TAVILY_ADVANCED_SEARCH_CREDITS
cost_usd = credits * TAVILY_SEARCH_COST_PER_CREDIT
text = res.content or ""
if not res.success and not text:
text = "(no search results)"
return {
"text": text,
"success": bool(res.success),
"engine": engine,
"credits": credits,
"cost_usd": cost_usd,
"n_searches": 1 if (query or "").strip() else 0,
"error": None if res.success else text,
}
def web_search_cfg(method_cfg: Optional[Dict[str, Any]]) -> Tuple[bool, int]:
"""Parse ``method_cfg.web_search = { enabled, max_uses }``.
@@ -1322,6 +1363,9 @@ __all__ = [
"LocalCloudAgent",
"NO_TEMP_PREFIXES",
"OPENAI_WEB_SEARCH_COST_PER_CALL",
"TAVILY_ADVANCED_SEARCH_CREDITS",
"TAVILY_SEARCH_COST_PER_CALL",
"TAVILY_SEARCH_COST_PER_CREDIT",
"WEB_SEARCH_COST_PER_CALL",
"_bump_cloud_calls",
"_bump_local_calls",
@@ -1329,5 +1373,6 @@ __all__ = [
"estimate_cost",
"is_gpt5_family",
"supports_temperature",
"tavily_search_context",
"web_search_cfg",
]
+11 -4
View File
@@ -15,13 +15,16 @@ PRICES: dict[str, tuple[float, float]] = {
"claude-sonnet-4-6": (3.00, 15.0),
"claude-haiku-4-5": (1.00, 5.00),
"claude-haiku-4-5-20251001": (1.00, 5.00),
"gpt-5.5": (5.00, 30.0),
"gpt-5": (1.25, 10.0),
"gpt-5-mini": (0.25, 2.00),
"gpt-5-mini-2025-08-07": (0.25, 2.00),
"gpt-4o": (0.15, 0.60),
# Gemini Developer API prices (USD per 1M tokens), 2025-12 list price.
# 2.5 Pro uses tiered pricing (>200K context = $2.50/$15); we charge the
# low-context tier since GAIA / SWE-bench prompts stay well under 200K.
# Gemini Developer API prices (USD per 1M tokens). Pro models use tiered
# pricing above 200K prompt tokens; GAIA prompts stay under that tier, so
# charge the low-context standard rate.
"gemini-3.1-pro-preview": (2.00, 12.0),
"gemini-3.1-pro-preview-customtools": (2.00, 12.0),
"gemini-2.5-pro": (1.25, 10.0),
"gemini-2.5-flash": (0.30, 2.50),
"gemini-2.5-flash-lite": (0.10, 0.40),
@@ -61,7 +64,11 @@ def is_reasoning_model(model: str) -> bool:
before emitting visible answer text. At max_tokens=4096 these silently
truncate with empty answers on GAIA (26/100 GPT-5, 18/100 Gemini Pro)."""
m = (model or "").lower()
return is_gpt5_family(model) or "gemini-2.5-pro" in m
return (
is_gpt5_family(model)
or "gemini-2.5-pro" in m
or "gemini-3.1-pro" in m
)
def default_max_output_tokens(model: str) -> int:
+56 -11
View File
@@ -34,6 +34,7 @@ from openjarvis.agents.hybrid._base import (
WEB_SEARCH_COST_PER_CALL,
LocalCloudAgent,
build_web_search_tool,
tavily_search_context,
web_search_cfg,
)
from openjarvis.agents.hybrid.mini_swe_agent import (
@@ -135,7 +136,12 @@ class AdvisorsAgent(LocalCloudAgent):
advisor_temperature = float(cfg.get("advisor_temperature", 0.2))
ws_enabled, ws_max_uses = web_search_cfg(cfg)
if ws_enabled and self._cloud_endpoint not in _SEARCH_CAPABLE_ENDPOINTS:
search_backend = str(cfg.get("search_backend", "provider")).lower()
if (
ws_enabled
and search_backend != "tavily"
and self._cloud_endpoint not in _SEARCH_CAPABLE_ENDPOINTS
):
raise ValueError(
f"web_search.enabled=true but cloud_endpoint={self._cloud_endpoint!r}; "
"server-side web_search is wired for anthropic / openai / gemini "
@@ -146,19 +152,23 @@ class AdvisorsAgent(LocalCloudAgent):
use_ws = ws_enabled
gaia_max_turns = int(cfg.get("gaia_max_turns", 8))
n_searches_total = 0
search_cost_total = 0.0
# 1. Initial executor pass — advisor (Qwen) doesn't get tools;
# only the cloud executor passes do. With web_search on, dispatch
# to the search-capable agent loop for the configured provider.
if use_ws:
initial_resp, e1_in, e1_out, n_s1, e1_turns = self._executor_search(
(initial_resp, e1_in, e1_out, n_s1, e1_turns,
e1_search_cost) = self._executor_search(
user=f"Question:\n{question}",
system=EXECUTOR_INITIAL_SYS,
max_tokens=executor_max_tokens,
ws_max_uses=ws_max_uses,
max_turns=gaia_max_turns,
query=question,
)
n_searches_total += n_s1
search_cost_total += e1_search_cost
else:
initial_resp, e1_in, e1_out = self._call_cloud(
user=f"Question:\n{question}",
@@ -196,14 +206,17 @@ class AdvisorsAgent(LocalCloudAgent):
f"answer-format rules."
)
if use_ws:
final_answer, e2_in, e2_out, n_s2, e2_turns = self._executor_search(
(final_answer, e2_in, e2_out, n_s2, e2_turns,
e2_search_cost) = self._executor_search(
user=final_user,
system=EXECUTOR_FINAL_SYS,
max_tokens=executor_max_tokens,
ws_max_uses=ws_max_uses,
max_turns=gaia_max_turns,
query=question,
)
n_searches_total += n_s2
search_cost_total += e2_search_cost
else:
final_answer, e2_in, e2_out = self._call_cloud(
user=final_user,
@@ -216,7 +229,10 @@ class AdvisorsAgent(LocalCloudAgent):
tokens_local = adv_in + adv_out
tokens_cloud = e1_in + e1_out + e2_in + e2_out
cost = self.cost_usd(self._cloud_model, e1_in + e2_in, e1_out + e2_out)
cost += n_searches_total * _search_cost_per_call(self._cloud_endpoint)
if search_backend == "tavily":
cost += search_cost_total
else:
cost += n_searches_total * _search_cost_per_call(self._cloud_endpoint)
meta: Dict[str, Any] = {
"tokens_local": tokens_local,
@@ -233,7 +249,9 @@ class AdvisorsAgent(LocalCloudAgent):
"initial_response": initial_resp,
"advisor_feedback": advisor_text,
"web_search_enabled": use_ws,
"search_backend": search_backend,
"n_web_searches": n_searches_total,
"search_cost_usd": search_cost_total,
"note": "inference-only advisor (untrained); lower bound on the technique.",
},
}
@@ -251,16 +269,40 @@ class AdvisorsAgent(LocalCloudAgent):
max_tokens: int,
ws_max_uses: int,
max_turns: int,
) -> Tuple[str, int, int, int, int]:
query: Optional[str] = None,
) -> Tuple[str, int, int, int, int, float]:
"""Run a search-capable executor pass for the configured cloud.
Dispatches by ``self._cloud_endpoint`` to the matching ``_base``
agent loop. Returns the shared 5-tuple ``(text, p_tok, c_tok,
n_searches, turns)``. The endpoint is assumed already validated
against ``_SEARCH_CAPABLE_ENDPOINTS`` by the caller.
agent loop, or through Tavily when ``method_cfg.search_backend`` is
``"tavily"``. Returns ``(text, p_tok, c_tok, n_searches, turns,
search_cost_usd)``.
"""
if str(self._cfg.get("search_backend", "provider")).lower() == "tavily":
res = tavily_search_context(
query or user,
max_results=int(self._cfg.get("tavily_max_results", 5)),
)
grounded_user = (
f"Web search results:\n{res['text']}\n\n"
f"Using the search results above, answer this request:\n{user}"
)
text, p, c = self._call_cloud(
user=grounded_user,
system=system,
max_tokens=max_tokens,
temperature=0.0,
)
return (
text,
p,
c,
int(res["n_searches"]),
1,
float(res["cost_usd"]),
)
if self._cloud_endpoint == "anthropic":
return self._call_anthropic_agent(
text, p, c, n_searches, turns = self._call_anthropic_agent(
self._cloud_model,
user=user,
system=system,
@@ -269,8 +311,9 @@ class AdvisorsAgent(LocalCloudAgent):
tools=[build_web_search_tool(ws_max_uses)],
max_turns=max_turns,
)
return text, p, c, n_searches, turns, 0.0
if self._cloud_endpoint == "openai":
return self._call_openai_agent(
text, p, c, n_searches, turns = self._call_openai_agent(
self._cloud_model,
user=user,
system=system,
@@ -278,8 +321,9 @@ class AdvisorsAgent(LocalCloudAgent):
temperature=0.0,
max_turns=max_turns,
)
return text, p, c, n_searches, turns, 0.0
if self._cloud_endpoint == "gemini":
return self._call_gemini_agent(
text, p, c, n_searches, turns = self._call_gemini_agent(
self._cloud_model,
user=user,
system=system,
@@ -287,6 +331,7 @@ class AdvisorsAgent(LocalCloudAgent):
temperature=0.0,
max_turns=max_turns,
)
return text, p, c, n_searches, turns, 0.0
# Genuinely unsupported (openrouter / vllm / unknown). The caller
# guard should have caught this; raise defensively.
raise ValueError(
+57 -18
View File
@@ -46,6 +46,7 @@ from openjarvis.agents.hybrid._base import (
WEB_SEARCH_COST_PER_CALL,
LocalCloudAgent,
build_web_search_tool,
tavily_search_context,
web_search_cfg,
)
from openjarvis.agents.hybrid._prices import (
@@ -456,8 +457,14 @@ def _format_worker_pool(workers: List[Dict[str, Any]]) -> str:
)
def _search_capable_indices(workers: List[Dict[str, Any]]) -> List[int]:
def _search_capable_indices(
workers: List[Dict[str, Any]],
*,
search_backend: str = "provider",
) -> List[int]:
"""Indices of workers whose endpoint can run server-side web search."""
if search_backend == "tavily":
return [w["id"] for w in workers]
return [
w["id"] for w in workers
if (w.get("endpoint") or "openai").lower()
@@ -470,6 +477,7 @@ def _build_conductor_prompt(
workers: List[Dict[str, Any]],
*,
web_search_enabled: bool = False,
search_backend: str = "provider",
) -> str:
"""Build the planner prompt.
@@ -485,12 +493,16 @@ def _build_conductor_prompt(
)
if not web_search_enabled:
return base
capable = _search_capable_indices(workers)
capable = _search_capable_indices(workers, search_backend=search_backend)
if capable:
cap_str = ", ".join(str(i) for i in capable)
if search_backend == "tavily":
capability = "External Tavily search results will be prepended to worker prompts"
else:
capability = "Only these model indices can perform live web search"
constraint = (
"\n\nWEB SEARCH CONSTRAINT:\n"
f"Only these model indices can perform live web search: [{cap_str}]. "
f"{capability}: [{cap_str}]. "
"Any step that needs to look up facts, current events, or other "
"information not reliably known from memory MUST be routed to one "
"of those indices. Steps routed to any other model can only use "
@@ -550,8 +562,8 @@ def _call_worker(
*,
web_search_tool: Optional[Dict[str, Any]] = None,
web_search_max_uses: int = 8,
) -> Tuple[str, int, int, bool, int]:
"""Returns (text, p_tok, c_tok, is_local, n_web_searches).
) -> Tuple[str, int, int, bool, int, float]:
"""Returns (text, p_tok, c_tok, is_local, n_web_searches, extra_cost).
``web_search_tool``: a truthy marker that web_search is enabled for
this run. When set AND the worker endpoint is search-capable
@@ -565,6 +577,22 @@ def _call_worker(
max_tok = int(cfg.get("worker_max_tokens", 4096))
temp = float(cfg.get("worker_temperature", 0.2))
use_ws = web_search_tool is not None
search_backend = str(cfg.get("search_backend", "provider")).lower()
extra_cost = 0.0
if use_ws and search_backend == "tavily":
res = tavily_search_context(
prompt,
max_results=int(cfg.get("tavily_max_results", 5)),
)
prompt = (
f"Web search results:\n{res['text']}\n\n"
f"Using the search results above, answer this request:\n{prompt}"
)
extra_cost = float(res["cost_usd"])
use_ws = False
tavily_searches = int(res["n_searches"])
else:
tavily_searches = 0
if ep == "vllm":
text, p, c = LocalCloudAgent._call_vllm(
@@ -575,7 +603,7 @@ def _call_worker(
temperature=temp,
enable_thinking=False,
)
return text, p, c, True, 0
return text, p, c, True, tavily_searches, extra_cost
if ep == "openai":
if use_ws:
text, p, c, n_searches, _ = LocalCloudAgent._call_openai_agent(
@@ -584,14 +612,14 @@ def _call_worker(
max_tokens=max_tok,
temperature=(1.0 if is_gpt5_family(worker["model"]) else temp),
)
return text, p, c, False, n_searches
return text, p, c, False, n_searches, 0.0
text, p, c = LocalCloudAgent._call_openai(
worker["model"],
user=prompt,
max_tokens=max_tok,
temperature=(1.0 if is_gpt5_family(worker["model"]) else temp),
)
return text, p, c, False, 0
return text, p, c, False, tavily_searches, extra_cost
if ep == "openrouter":
# OpenRouter is OpenAI-compatible; the helper handles the
# base_url + OPENROUTER_API_KEY plumbing. No server-side web
@@ -607,7 +635,7 @@ def _call_worker(
temperature=temp,
extra_body=extra_body if isinstance(extra_body, dict) else None,
)
return text, p, c, False, 0
return text, p, c, False, tavily_searches, extra_cost
if ep == "anthropic":
eff_temp = temp if supports_temperature(worker["model"]) else 0.0
anthropic_kwargs: Dict[str, Any] = dict(
@@ -620,7 +648,7 @@ def _call_worker(
text, p, c, n_searches = LocalCloudAgent._call_anthropic(
worker["model"], **anthropic_kwargs
)
return text, p, c, False, n_searches
return text, p, c, False, n_searches or tavily_searches, extra_cost
if ep == "gemini":
# Gemini Developer API via google-genai. With web_search on, route
# through the Google-Search-grounded agent loop; otherwise plain
@@ -632,14 +660,14 @@ def _call_worker(
max_tokens=max_tok,
temperature=temp,
)
return text, p, c, False, n_searches
return text, p, c, False, n_searches, 0.0
text, p, c = LocalCloudAgent._call_gemini(
worker["model"],
user=prompt,
max_tokens=max_tok,
temperature=temp,
)
return text, p, c, False, 0
return text, p, c, False, tavily_searches, extra_cost
raise ValueError(f"unsupported worker endpoint: {ep!r}")
@@ -672,7 +700,7 @@ def _swe_worker_step(
# backbones today (the loop's tool-call format is Anthropic- or
# OpenAI-via-vllm-shaped only). Fall back to one-shot for those —
# SWE-bench-wise they were already weak; this preserves behavior.
text, p, c, is_local, n_searches = _call_worker(worker, prompt, cfg)
text, p, c, is_local, n_searches, _extra = _call_worker(worker, prompt, cfg)
return text, p, c, is_local, n_searches, 0
out = run_swe_agent_loop(
task,
@@ -753,13 +781,17 @@ class ConductorAgent(LocalCloudAgent):
and bool(task_meta_early.get("base_commit"))
)
ws_enabled, ws_max_uses = web_search_cfg(cfg)
search_backend = str(cfg.get("search_backend", "provider")).lower()
planner_ws = ws_enabled and not swe_mode_early
# 1. Plan — when web_search is on (GAIA), the prompt names which
# worker indices can actually search, so the planner routes
# research steps to a search-capable worker.
user = _build_conductor_prompt(
question, workers, web_search_enabled=planner_ws,
question,
workers,
web_search_enabled=planner_ws,
search_backend=search_backend,
)
plan_text, p_in, p_out = self._call_cloud(
user=user,
@@ -833,7 +865,7 @@ class ConductorAgent(LocalCloudAgent):
# memory. Fail loud instead of degrading silently.
# ``ws_enabled`` / ``ws_max_uses`` computed up front for the planner
# constraint — reuse them here.
if ws_enabled and not swe_mode:
if ws_enabled and search_backend != "tavily" and not swe_mode:
search_workers = [
w for w in workers
if (w.get("endpoint") or "openai").lower()
@@ -894,7 +926,7 @@ class ConductorAgent(LocalCloudAgent):
# may legitimately not need search; see Task-3 planner
# constraint that tries to prevent this upfront).
if (
ws_enabled and not swe_mode
ws_enabled and search_backend != "tavily" and not swe_mode
and worker_ep not in _SEARCH_CAPABLE_WORKER_ENDPOINTS
):
self.record_trace_event({
@@ -911,6 +943,7 @@ class ConductorAgent(LocalCloudAgent):
),
})
extra_cost = 0.0
if swe_mode:
text, w_in, w_out, is_local, n_searches, bash_turns = (
_swe_worker_step(
@@ -919,7 +952,9 @@ class ConductorAgent(LocalCloudAgent):
)
tool_calls += bash_turns
else:
text, w_in, w_out, is_local, n_searches = _call_worker(
(
text, w_in, w_out, is_local, n_searches, extra_cost
) = _call_worker(
worker, prompt, cfg,
web_search_tool=ws_tool,
web_search_max_uses=ws_max_uses,
@@ -930,7 +965,10 @@ class ConductorAgent(LocalCloudAgent):
else:
tokens_cloud += w_in + w_out
cost += self.cost_usd(worker["model"], w_in, w_out)
cost += n_searches * _worker_search_cost_per_call(worker_ep)
if search_backend != "tavily":
cost += n_searches * _worker_search_cost_per_call(worker_ep)
if search_backend == "tavily":
cost += extra_cost
n_web_searches_total += n_searches
tool_calls += n_searches
steps.append({
@@ -981,6 +1019,7 @@ class ConductorAgent(LocalCloudAgent):
"plan": plan,
"fallback_used": fallback_used,
"web_search_enabled": ws_enabled,
"search_backend": search_backend,
"n_web_searches": n_web_searches_total,
"parse_attempts": parse_attempts,
"workers": [
+29 -4
View File
@@ -48,12 +48,13 @@ from openjarvis.agents.hybrid._base import (
WEB_SEARCH_COST_PER_CALL,
LocalCloudAgent,
build_web_search_tool,
tavily_search_context,
web_search_cfg,
)
from openjarvis.agents.hybrid._openai_retry import (
patch_openai_globally as _patch_openai_globally,
)
from openjarvis.agents.hybrid._prices import NO_TEMP_PREFIXES
from openjarvis.agents.hybrid._prices import NO_TEMP_PREFIXES, default_max_output_tokens
from openjarvis.agents.hybrid.mini_swe_agent import run_swe_agent_loop
from openjarvis.core.registry import AgentRegistry
@@ -362,6 +363,8 @@ def _prefetch_context(
cloud_endpoint: str,
cloud_model: str,
max_uses: int = 8,
search_backend: str = "provider",
tavily_max_results: int = 5,
) -> Dict[str, Any]:
"""Use Anthropic web_search to fetch real source material the worker can read.
@@ -375,6 +378,22 @@ def _prefetch_context(
out: Dict[str, Any] = {
"text": "", "tokens": 0, "cost_usd": 0.0, "n_searches": 0,
}
if search_backend == "tavily":
try:
res = tavily_search_context(question, max_results=tavily_max_results)
out.update(
text=res["text"],
cost_usd=float(res["cost_usd"]),
n_searches=int(res["n_searches"]),
tokens=0,
engine=res.get("engine"),
credits=res.get("credits"),
)
if res.get("error"):
out["error"] = res["error"]
except Exception as e:
out["error"] = f"{type(e).__name__}: {e}"
return out
if cloud_endpoint != "anthropic" or not (question or "").strip():
return out
try:
@@ -498,18 +517,22 @@ class MinionsAgent(LocalCloudAgent):
max_tokens=cfg.get("worker_max_tokens", 4096),
local=True,
)
cloud_max_tokens = int(
cfg.get("cloud_max_tokens")
or default_max_output_tokens(self._cloud_model)
)
if self._cloud_endpoint == "openai":
cloud_client = OpenAIClient(
model_name=self._cloud_model,
temperature=0.0,
max_tokens=4096,
max_tokens=cloud_max_tokens,
)
elif self._cloud_endpoint == "anthropic":
# Temperature stripping is handled by the global patch above for Opus 4.7+.
cloud_client = AnthropicClient(
model_name=self._cloud_model,
temperature=0.0,
max_tokens=4096,
max_tokens=cloud_max_tokens,
)
elif self._cloud_endpoint == "gemini":
# The vendored Minion library already special-cases GeminiClient
@@ -520,7 +543,7 @@ class MinionsAgent(LocalCloudAgent):
cloud_client = GeminiClient(
model_name=self._cloud_model,
temperature=0.0,
max_tokens=4096,
max_tokens=cloud_max_tokens,
)
else:
raise ValueError(f"unsupported cloud endpoint: {self._cloud_endpoint!r}")
@@ -560,6 +583,8 @@ class MinionsAgent(LocalCloudAgent):
self._cloud_endpoint,
self._cloud_model,
max_uses=ws_max_uses,
search_backend=str(cfg.get("search_backend", "provider")).lower(),
tavily_max_results=int(cfg.get("tavily_max_results", 5)),
)
if prefetch.get("text"):
+85 -48
View File
@@ -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,
+83 -30
View File
@@ -149,6 +149,17 @@ def _build_router_schema(agent_ids: List[str]) -> Dict[str, Any]:
}
def _openai_response_format(schema: Dict[str, Any]) -> Dict[str, Any]:
return {
"type": "json_schema",
"json_schema": {
"name": "skillorchestra_route",
"schema": schema["format"]["schema"],
"strict": True,
},
}
def _parse_router_json(text: str) -> Dict[str, Any]:
s = (text or "").strip()
try:
@@ -196,6 +207,70 @@ class SkillOrchestraAgent(LocalCloudAgent):
agent_id = "skillorchestra"
def _route_call(
self,
*,
question: str,
router_sys: str,
router_schema: Dict[str, Any],
router_max: int,
) -> Tuple[str, int, int]:
user = f"Question:\n{question}"
if self._cloud_endpoint == "anthropic":
kwargs: Dict[str, Any] = {
"user": user,
"system": router_sys,
"max_tokens": router_max,
"output_config": router_schema,
}
if supports_temperature(self._cloud_model):
kwargs["temperature"] = 0.0
text, r_in, r_out, _ = self._call_anthropic(
self._cloud_model,
**kwargs,
)
return text, r_in, r_out
if self._cloud_endpoint == "openai":
return self._call_openai(
self._cloud_model,
user=user,
system=router_sys,
max_tokens=router_max,
temperature=0.0,
response_format=_openai_response_format(router_schema),
)
if self._cloud_endpoint == "gemini":
return self._call_gemini(
self._cloud_model,
user=user,
system=router_sys,
max_tokens=router_max,
temperature=0.0,
)
raise ValueError(
f"SkillOrchestra router unsupported cloud_endpoint={self._cloud_endpoint!r}"
)
def _executor_call(
self,
*,
question: str,
max_tokens: int,
) -> Tuple[str, int, int]:
if self._cloud_endpoint == "anthropic":
text, w_in, w_out, _ = self._call_anthropic(
self._cloud_model,
user=question,
max_tokens=max_tokens,
temperature=0.0,
)
return text, w_in, w_out
return self._call_cloud(
user=question,
max_tokens=max_tokens,
temperature=0.0,
)
def _is_soft_failure(self, exc: BaseException) -> Optional[str]:
# Empty/unbalanced router JSON — treat as soft failure to match the
# hybrid adapter's behavior (matches `err=1` rows in the n=30 cell).
@@ -220,33 +295,13 @@ class SkillOrchestraAgent(LocalCloudAgent):
router_sys = _build_router_sys(competence, cost)
router_schema = _build_router_schema(agent_ids)
# 1. Route — Anthropic only (output_config schema is Anthropic-specific
# in the hybrid adapter). If you need OpenAI routing, swap the prompt
# to JSON-mode and bypass output_config.
if self._cloud_endpoint != "anthropic":
raise ValueError(
"SkillOrchestra router requires cloud_endpoint='anthropic'; "
f"got {self._cloud_endpoint!r}"
)
router_max = int(cfg.get("router_max_tokens", 1024))
# Strip temperature for Opus 4.7+; Anthropic's output_config does the schema.
if supports_temperature(self._cloud_model):
router_text, r_in, r_out, _ = self._call_anthropic(
self._cloud_model,
user=f"Question:\n{question}",
system=router_sys,
max_tokens=router_max,
temperature=0.0,
output_config=router_schema,
)
else:
router_text, r_in, r_out, _ = self._call_anthropic(
self._cloud_model,
user=f"Question:\n{question}",
system=router_sys,
max_tokens=router_max,
output_config=router_schema,
)
router_text, r_in, r_out = self._route_call(
question=question,
router_sys=router_sys,
router_schema=router_schema,
router_max=router_max,
)
decision = _parse_router_json(router_text)
skill_weights: Dict[str, float] = decision.get("skill_weights") or {}
@@ -329,11 +384,9 @@ class SkillOrchestraAgent(LocalCloudAgent):
tokens_cloud += out["tokens_in"] + out["tokens_out"]
run_cost += out["cost_usd"]
else:
ans, w_in, w_out, _ = self._call_anthropic(
self._cloud_model,
user=question,
ans, w_in, w_out = self._executor_call(
question=question,
max_tokens=int(cfg.get("cloud_max_tokens", 4096)),
temperature=0.0,
)
tokens_cloud += w_in + w_out
run_cost += self.cost_usd(self._cloud_model, w_in, w_out)
@@ -31,7 +31,14 @@ from .stage_router import (
get_routing_strategy,
parse_skill_analysis,
)
from .tools import anthropic_tools, openai_tools, run_answer, run_code, run_search
from .tools import (
anthropic_tools,
gemini_tools,
openai_tools,
run_answer,
run_code,
run_search,
)
# tool name -> routing stage (stage_router uses "reasoning" for code).
_TOOL_STAGE = {
@@ -115,10 +122,49 @@ def _orchestrate_step(
u = resp.usage
p = getattr(u, "prompt_tokens", 0) if u else 0
c = getattr(u, "completion_tokens", 0) if u else 0
elif endpoint == "gemini":
from google import genai
from google.genai import types
client = genai.Client(
http_options=types.HttpOptions(timeout=600_000)
)
cfg = types.GenerateContentConfig(
temperature=1.0,
max_output_tokens=max_tokens,
tools=[types.Tool(function_declarations=gemini_tools())],
)
resp = client.models.generate_content(
model=model,
contents=user,
config=cfg,
)
text = (resp.text or "") if hasattr(resp, "text") else ""
tool_calls = []
try:
parts = resp.candidates[0].content.parts or []
except Exception: # noqa: BLE001
parts = []
for part in parts:
fc = getattr(part, "function_call", None)
if fc is None:
continue
name = getattr(fc, "name", None)
if not isinstance(name, str) or not name:
continue
args = getattr(fc, "args", None) or {}
try:
args = dict(args)
except Exception: # noqa: BLE001
args = {}
tool_calls.append({"name": name, "input": args})
um = getattr(resp, "usage_metadata", None)
p = int(getattr(um, "prompt_token_count", 0) or 0) if um else 0
c = int(getattr(um, "candidates_token_count", 0) or 0) if um else 0
else:
raise ValueError(
f"orchestrator endpoint {endpoint!r} unsupported — route the "
"orchestrator through anthropic/openai (set method_cfg."
"orchestrator through anthropic/openai/gemini (set method_cfg."
"orchestrator_endpoint)."
)
@@ -192,6 +238,8 @@ def run_orchestrator(
code_timeout = int(cfg.get("code_timeout_s", 60))
answer_max_tokens = int(cfg.get("answer_max_tokens", 40000))
ws_max_uses = int(cfg.get("web_search_max_uses", 5))
search_backend = str(cfg.get("search_backend", "provider")).lower()
tavily_max_results = int(cfg.get("tavily_max_results", 5))
# The orchestrator model: a fixed model per run (the original's
# MODEL_NAME). Defaults to the cell's cloud model when that endpoint
@@ -203,7 +251,7 @@ def run_orchestrator(
orch_model = (cfg.get("orchestrator_model")
or cfg.get("router_model")
or agent._cloud_model)
if orch_endpoint not in ("anthropic", "openai"):
if orch_endpoint not in ("anthropic", "openai", "gemini"):
orch_endpoint, orch_model = "anthropic", "claude-opus-4-7"
orch_max_tokens = int(cfg.get("orchestrator_max_tokens", 4096))
@@ -306,6 +354,8 @@ def run_orchestrator(
res = run_search(
agent, spec, context_str=context_str, problem=problem,
retriever_url=retriever_url, web_search_max_uses=ws_max_uses,
search_backend=search_backend,
tavily_max_results=tavily_max_results,
)
docs = res["search_results_data"]
joined = "\n---\n".join(d for d in docs if d)[:char_cap]
@@ -27,6 +27,7 @@ from .._base import (
OPENAI_WEB_SEARCH_COST_PER_CALL,
WEB_SEARCH_COST_PER_CALL,
build_web_search_tool,
tavily_search_context,
)
from .pool import ModelSpec, call_alias
@@ -110,6 +111,26 @@ def openai_tools() -> List[Dict[str, Any]]:
return out
def gemini_tools() -> List[Dict[str, Any]]:
"""The 3 orchestrator tools in Gemini function-declaration shape."""
out = []
for name, desc in (
("search", _SEARCH_DESC),
("enhance_reasoning", _CODE_DESC),
("answer", _ANSWER_DESC),
):
out.append({
"name": name,
"description": desc,
"parameters": {
"type": "object",
"properties": {"model": _model_prop(name)},
"required": ["model"],
},
})
return out
# ---------------------------------------------------------------------------
# enhance_reasoning / code — eval_frames.py:659-812
# ---------------------------------------------------------------------------
@@ -256,6 +277,8 @@ def run_search(
retriever_url: Optional[str] = None,
topk: int = 150,
web_search_max_uses: int = 5,
search_backend: str = "provider",
tavily_max_results: int = 5,
) -> Dict[str, Any]:
"""Write a search query with ``spec``, then retrieve documents.
@@ -283,7 +306,12 @@ def run_search(
contents: List[str] = []
search_uses = 0
if retriever_url:
if search_backend == "tavily":
res = tavily_search_context(query, max_results=tavily_max_results)
contents.append(res["text"])
search_uses = int(res["n_searches"])
cost += float(res["cost_usd"])
elif retriever_url:
# Faithful path — the original FAISS retriever service.
import requests
+96 -34
View File
@@ -16,7 +16,8 @@ Two modes, gated by ``method_cfg.orchestrator_mode``:
(``answer-1``, ``reasoner-2``, ``search-3``, ) is mapped to a real
backend through ``EXPERT_MODEL_MAPPING`` by default the frontier
Anthropic worker for `*-1` slots, gpt-5-mini for `*-2`, local Qwen
for `*-3`. Search routes to the Anthropic server-side web_search.
for `*-3`. Search routes to the configured provider's server-side
web-search helper when available.
We do NOT reproduce the upstream Tavily / FAISS-wiki retriever, the
code-interpreter sandbox, or the multi-vLLM mix (Llama-3.3-70B,
@@ -43,8 +44,8 @@ Prompted-mode pipeline:
prompt; fallback to strongest worker on parse failure.
Workers come from ``cfg["workers"]`` or a sensible default pool (local
Qwen if vLLM up, plus a web-search tool via Anthropic, Opus 4.7,
gpt-5-mini).
Qwen if vLLM up, plus provider-native web search, the configured frontier
cloud model, and gpt-5-mini).
"""
from __future__ import annotations
@@ -59,8 +60,11 @@ from typing import Any, Dict, List, Optional, Tuple
from openjarvis.agents._stubs import AgentContext
from openjarvis.agents.hybrid._base import (
ANTHROPIC_WEB_SEARCH_TOOL,
GEMINI_SEARCH_COST_PER_CALL,
OPENAI_WEB_SEARCH_COST_PER_CALL,
WEB_SEARCH_COST_PER_CALL,
LocalCloudAgent,
tavily_search_context,
)
from openjarvis.agents.hybrid._prices import (
PRICES,
@@ -197,10 +201,23 @@ def _expert_for(slot: str, local_model: Optional[str],
cost tier for mid OpenAI calls)
- `*-3` (local tier) -> local vLLM (`local_model`)
- `answer-math-*` -> same tiers as the numeric suffix
- `search-*` -> always the Anthropic web_search tool (the
upstream uses Tavily; we have web_search)
- `search-*` -> provider-native web search when the cloud
endpoint supports it; otherwise Anthropic
"""
if slot.startswith("search"):
ep = (cloud_endpoint or "anthropic").lower()
if ep == "openai":
return {
"name": f"search:{slot}",
"type": "openai-web-search",
"model": cloud_model,
}
if ep == "gemini":
return {
"name": f"search:{slot}",
"type": "gemini-web-search",
"model": cloud_model,
}
return {
"name": f"search:{slot}",
"type": "anthropic-web-search",
@@ -340,21 +357,18 @@ def _paper_expert_for(
# ---- Tavily + Modal helpers -------------------------------------------------
def _call_tavily_search(query: str, max_results: int = 5) -> Tuple[str, int, int]:
"""One-shot Tavily search. Returns (text, p_tok=0, c_tok=0).
def _call_tavily_search(
query: str,
max_results: int = 5,
) -> Tuple[str, int, int, float, int]:
"""One-shot Tavily search. Returns (text, p_tok=0, c_tok=0, cost, uses).
Token counts are reported as zero (no LLM was billed); the OpenJarvis
accounting layer separately tallies tool-call counts. Falls back to
DuckDuckGo if Tavily is unreachable (see ``WebSearchTool``).
"""
from openjarvis.tools.web_search import WebSearchTool
tool = WebSearchTool(max_results=max_results)
res = tool.execute(query=query, max_results=max_results)
text = res.content or ""
if not res.success and not text:
text = "(no results)"
return text, 0, 0
res = tavily_search_context(query, max_results=max_results)
return res["text"], 0, 0, float(res["cost_usd"]), int(res["n_searches"])
_MODAL_APP_NAME = "openjarvis-toolorchestra-sandbox"
@@ -674,13 +688,25 @@ def _default_pool(
"concise extraction, formatting, arithmetic on given data."
),
})
if ep == "openai":
search_type = "openai-web-search"
search_model = cloud_model
search_desc = "OpenAI hosted web search on the configured frontier model."
elif ep == "gemini":
search_type = "gemini-web-search"
search_model = cloud_model
search_desc = "Gemini Google Search grounding on the configured frontier model."
else:
search_type = "anthropic-web-search"
search_model = _DEFAULT_WEB_SEARCH_MODEL
search_desc = "Anthropic server-side web_search."
pool.append({
"id": len(pool),
"name": "web-search",
"type": "anthropic-web-search",
"model": "claude-haiku-4-5",
"type": search_type,
"model": search_model,
"description": (
"Anthropic server-side web_search. Use for facts that need a lookup "
f"{search_desc} Use for facts that need a lookup "
"(recent events, rare names/dates, niche sources). Returns a digest."
),
})
@@ -717,8 +743,13 @@ def _default_pool(
# `modal-python` — One-shot Python exec in a fresh Modal Sandbox (the
# paper's "Python sandbox" inside `enhance_reasoning`).
_TOOLORCH_VALID_TYPES = (
"vllm", "openai", "anthropic", "anthropic-web-search", "gemini",
"tavily-search", "openrouter", "modal-python",
"vllm", "openai", "anthropic", "anthropic-web-search",
"openai-web-search", "gemini", "gemini-web-search", "tavily-search",
"openrouter", "modal-python",
)
_TOOLORCH_SEARCH_TYPES = (
"anthropic-web-search", "openai-web-search", "gemini-web-search",
"tavily-search",
)
# Default model used when an `anthropic-web-search` entry omits `model`.
@@ -739,10 +770,12 @@ def _resolve_worker_pool(
the override is absent.
Each user-supplied entry must be a dict with keys ``id``, ``name``,
``type``, and (for non-search types) ``model``. ``type`` must be one
of ``vllm`` / ``openai`` / ``anthropic`` / ``anthropic-web-search``.
``anthropic-web-search`` entries may omit ``model`` it defaults to
``claude-haiku-4-5``.
``type``, and (for non-search types) ``model``. Search worker types are
``anthropic-web-search``, ``openai-web-search``, ``gemini-web-search``,
and ``tavily-search``. ``anthropic-web-search`` entries may omit
``model`` it defaults to ``claude-haiku-4-5``. OpenAI and Gemini
search workers default to the configured cloud model. Tavily does not
require a model.
Substitution: ``model = "$local"`` (or ``"<local>"``) resolves to
``local_model``; ``model = "$cloud"`` / ``"<cloud>"`` to ``cloud_model``.
@@ -804,14 +837,24 @@ def _resolve_worker_pool(
elif isinstance(model, str) and model in ("$cloud", "<cloud>"):
model = cloud_model
entry["model"] = model
if wtype == "anthropic-web-search":
if wtype in _TOOLORCH_SEARCH_TYPES:
if model in (None, ""):
model = _DEFAULT_WEB_SEARCH_MODEL
if wtype == "anthropic-web-search":
model = _DEFAULT_WEB_SEARCH_MODEL
elif wtype in ("openai-web-search", "gemini-web-search"):
model = cloud_model
else:
model = wtype
entry["model"] = model
elif not isinstance(model, str):
raise ValueError(
f"Invalid worker_pool entry [{wid}]: 'model' must be a string when set"
)
if wtype in ("openai-web-search", "gemini-web-search") and model not in PRICES:
raise ValueError(
f"Invalid worker_pool entry [{wid}]: model {model!r} "
f"is not in PRICES (known: {sorted(PRICES)})"
)
# Search workers don't satisfy the "needs a solver" requirement.
else:
if not isinstance(model, str) or not model:
@@ -843,7 +886,7 @@ def _resolve_worker_pool(
if not has_non_search:
raise ValueError(
"Invalid worker_pool entry [-]: worker_pool must contain at least "
"one non-search worker (vllm / openai / anthropic)"
"one non-search worker (vllm / openai / anthropic / gemini)"
)
return resolved
@@ -910,13 +953,31 @@ def _call_worker(
)
extra = n_searches * WEB_SEARCH_COST_PER_CALL
return text, p, c, False, extra, n_searches
if wtype == "openai-web-search":
eff_temp = 1.0 if is_gpt5_family(worker["model"]) else temp
text, p, c, n_searches, _ = LocalCloudAgent._call_openai_agent(
worker["model"],
user=prompt,
max_tokens=max(max_tok, 16384) if is_gpt5_family(worker["model"]) else max_tok,
temperature=eff_temp,
)
extra = n_searches * OPENAI_WEB_SEARCH_COST_PER_CALL
return text, p, c, False, extra, n_searches
if wtype == "gemini-web-search":
text, p, c, n_searches, _ = LocalCloudAgent._call_gemini_agent(
worker["model"],
user=prompt,
max_tokens=max_tok,
temperature=temp,
)
extra = n_searches * GEMINI_SEARCH_COST_PER_CALL
return text, p, c, False, extra, n_searches
if wtype == "tavily-search":
# Tavily costs are flat per call; charge `WEB_SEARCH_COST_PER_CALL`
# for parity with the Anthropic web-search worker. One call = one
# "n_search" for accounting.
max_results = int(cfg.get("tavily_max_results", 5))
text, p, c = _call_tavily_search(str(prompt), max_results=max_results)
return text, p, c, False, WEB_SEARCH_COST_PER_CALL, 1
text, p, c, extra, n_searches = _call_tavily_search(
str(prompt), max_results=max_results,
)
return text, p, c, False, extra, n_searches
if wtype == "openrouter":
text, p, c = LocalCloudAgent._call_openrouter(
worker["model"],
@@ -951,7 +1012,7 @@ def _swe_call_worker(
caller can surface ``tool_calls`` per row. Fallbacks to one-shot
workers return 0 bash turns (no agent loop ran)."""
wtype = worker.get("type", "openai")
if wtype == "anthropic-web-search":
if wtype in _TOOLORCH_SEARCH_TYPES:
# Search workers stay one-shot.
text, p, c, is_local, extra, n_searches = _call_worker(worker, prompt, cfg)
return text, p, c, is_local, extra, n_searches, 0
@@ -1197,7 +1258,8 @@ class ToolOrchestraAgent(LocalCloudAgent):
# Search workers are excluded — they answer fact-lookup
# questions, not synthesis.
non_search = [
w for w in workers if w.get("type") != "anthropic-web-search"
w for w in workers
if w.get("type") not in _TOOLORCH_SEARCH_TYPES
] or workers
worker = max(
non_search,
+3 -2
View File
@@ -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:
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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:
+4 -3
View File
@@ -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:
+21 -1
View File
@@ -60,7 +60,27 @@ class BaseChannel(ABC):
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send a message to a specific channel. Returns True on success."""
"""Send a message to a specific channel. Returns True on success.
Canonical send contract shared by **every** channel adapter:
``channel``
The DESTINATION identifier the per-adapter native id of the
place the message goes (Discord/Slack channel id, Telegram chat
id, email recipient address, ...). This is *not* the channel
TYPE label. An incoming :class:`ChannelMessage` carries that
destination in its ``conversation_id`` field (``channel`` there
is only the type label such as ``"discord"``), so dispatch code
replying to a message must pass ``cm.conversation_id`` here.
``conversation_id``
An optional reply/thread reference the native id of the
message being replied to (Discord ``message_reference``, Slack
``thread_ts``, Telegram ``reply_to_message_id``, email
``In-Reply-To``, ...). When replying to an inbound message this
should be ``cm.message_id``, never the channel id. Passing a
channel id here yields broken references (e.g. Discord
``MESSAGE_REFERENCE_UNKNOWN_MESSAGE``).
"""
@abstractmethod
def status(self) -> ChannelStatus:
+3 -1
View File
@@ -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(
+3 -1
View File
@@ -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:
+11 -1
View File
@@ -112,7 +112,15 @@ class TelegramChannel(BaseChannel):
_TELEGRAM_MAX_LEN = 4096
url = f"https://api.telegram.org/bot{self._token}/sendMessage"
chat_id = conversation_id or channel
# Canonical channel send contract (see BaseChannel.send): the first
# positional ``channel`` arg is the DESTINATION (the Telegram chat
# id). ``conversation_id`` is the inbound message id used as a
# reply/thread reference (``reply_to_message_id``). We fall back to
# ``conversation_id`` as the chat id only when ``channel`` is empty,
# for backwards compatibility with legacy callers that passed the
# chat id via ``conversation_id``.
chat_id = channel or conversation_id
reply_to = conversation_id if (channel and conversation_id) else ""
chunks = textwrap.wrap(
content,
width=_TELEGRAM_MAX_LEN,
@@ -126,6 +134,8 @@ class TelegramChannel(BaseChannel):
}
if self._parse_mode:
payload["parse_mode"] = self._parse_mode
if reply_to:
payload["reply_to_message_id"] = reply_to
resp = httpx.post(url, json=payload, timeout=10.0)
if resp.status_code >= 300:
+2 -1
View File
@@ -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")
+79
View File
@@ -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"]
+4 -2
View File
@@ -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
+9 -11
View File
@@ -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
+91 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import base64
import json as json_mod
import logging
import sys
@@ -619,6 +620,21 @@ def _print_profile(
"(default: ~/.openjarvis/knowledge.db)."
),
)
@click.option(
"-i",
"--image",
"image_paths",
multiple=True,
type=click.Path(exists=True, dir_okay=False),
help="Image file for a vision model (e.g. gemma3). Repeatable.",
)
@click.option(
"-S",
"--screen",
"capture_screen",
is_flag=True,
help="Capture the current screen and send it to the vision model.",
)
@click.option(
"--persona",
"persona_name",
@@ -645,6 +661,8 @@ def ask(
research_mode: bool,
knowledge_db: str | None,
persona_name: str | None,
image_paths: tuple[str, ...] = (),
capture_screen: bool = False,
) -> None:
"""Ask Jarvis a question."""
quiet = (ctx.obj or {}).get("quiet", False) or output_json
@@ -652,6 +670,27 @@ def ask(
console = Console(stderr=True)
query_text = " ".join(query)
# Vision: collect base64 images from --image files and/or --screen.
image_b64: list[str] = []
for _img_path in image_paths:
try:
with open(_img_path, "rb") as _fh:
image_b64.append(base64.b64encode(_fh.read()).decode("ascii"))
except OSError as exc:
console.print(f"[red]Could not read image {_img_path}: {exc}[/red]")
sys.exit(1)
if capture_screen:
try:
from openjarvis.cli._screen import capture_screen_to_temp
_shot = capture_screen_to_temp()
with open(_shot, "rb") as _fh:
image_b64.append(base64.b64encode(_fh.read()).decode("ascii"))
logger.debug("Captured screen to %s", _shot)
except Exception as exc: # noqa: BLE001
console.print(f"[red]Screen capture failed:[/red] {exc}")
sys.exit(1)
wall_start = time.monotonic() if enable_profile else None
# Load config
@@ -671,11 +710,26 @@ def ask(
# Without this fallback, `[agent].default_system_prompt` and the
# SOUL.md / MEMORY.md / USER.md persona system are silently bypassed for
# the most common command (`jarvis ask "..."`).
agent_explicitly_set = agent_name is not None
if agent_name is None:
configured_default = (config.agent.default_agent or "").strip()
if configured_default:
agent_name = configured_default
# Vision flows only through direct-to-engine mode. If an image/screenshot
# was supplied without an explicit --agent, route to direct mode so the
# picture reaches the model; if an agent was explicitly requested, say
# plainly that the image is being skipped rather than dropping it silently.
if image_b64:
if not agent_explicitly_set:
agent_name = ""
else:
console.print(
"[yellow]Note:[/yellow] --image/--screen only works in direct "
"mode; the image is ignored with --agent set. Re-run with "
'`--agent ""` to use vision.'
)
# Track whether the user explicitly set --max-tokens
user_set_max_tokens = max_tokens is not None
@@ -714,7 +768,13 @@ def ask(
register_builtin_models()
effective_engine_key = engine_key or config.intelligence.preferred_engine or None
resolved = get_engine(config, effective_engine_key)
# Pass the model we intend to run so engine selection can skip an engine
# that can't actually serve it (e.g. the cloud fallback when the local
# engine is down but only a non-OpenAI key is set — see #532). This is the
# -m flag or the configured default; when neither is set we leave it None
# and a model is chosen per-engine below.
selection_model = model_name or config.intelligence.default_model or None
resolved = get_engine(config, effective_engine_key, model=selection_model)
if resolved is None:
console.print(
"[red bold]No inference engine available.[/red bold]\n\n"
@@ -865,6 +925,27 @@ def ask(
return
# Direct-to-engine mode (no agent)
# Privacy guard: a screenshot/image is sensitive, and OpenJarvis is
# local-first. If the active engine isn't local, warn before the image
# leaves the machine rather than silently uploading it to a third party.
_LOCAL_ENGINES = {
"ollama",
"llamacpp",
"vllm",
"sglang",
"exo",
"nexa",
"uzu",
"apple_fm",
"gemma_cpp",
}
if image_b64 and engine_name not in _LOCAL_ENGINES:
console.print(
f"[yellow]Privacy warning:[/yellow] sending {len(image_b64)} "
f"image(s) to a non-local engine ('{engine_name}'). The image will "
"leave this machine. Use a local engine (e.g. ollama) to keep "
"vision on-device."
)
messages = [Message(role=Role.USER, content=query_text)]
# Memory-augmented context injection
@@ -891,6 +972,15 @@ def ask(
except Exception as exc:
logger.debug("Failed to inject memory context: %s", exc)
# Vision: attach images to the final user message *after* any context
# injection (which may rebuild the list). messages_to_dicts() forwards
# the "images" field to Ollama's /api/chat.
if image_b64:
for _m in reversed(messages):
if _m.role == Role.USER:
_m.images = image_b64
break
# Generate (InstrumentedEngine handles telemetry + energy recording)
try:
with console.status("[bold green]Generating...[/bold green]"):
+43
View File
@@ -28,12 +28,22 @@ def _read_input(prompt: str = "You> ") -> Optional[str]:
@click.option("-a", "--agent", "agent_name", default=None, help="Agent type.")
@click.option("--tools", default=None, help="Comma-separated tool names.")
@click.option("--system", "system_prompt", default=None, help="Custom system prompt.")
@click.option(
"--persona",
"persona_name",
default=None,
help=(
"Named persona dir under ~/.openjarvis/personas/<name>/ "
"(overrides config). Pass 'none' to disable all persona files."
),
)
def chat(
engine_key: str | None,
model_name: str | None,
agent_name: str | None,
tools: str | None,
system_prompt: str | None,
persona_name: str | None,
) -> None:
"""Start an interactive multi-turn chat session.
@@ -48,6 +58,14 @@ def chat(
config = load_config()
import dataclasses as _dc
effective_mf = (
_dc.replace(config.memory_files, persona_name=persona_name)
if persona_name is not None
else config.memory_files
)
# Resolve engine
from openjarvis.engine import get_engine
from openjarvis.intelligence import register_builtin_models
@@ -121,6 +139,21 @@ def chat(
kwargs["interactive"] = True
kwargs["confirm_callback"] = _confirm
import inspect as _inspect
if (
"prompt_builder"
in _inspect.signature(agent_cls.__init__).parameters
):
from openjarvis.prompt.builder import SystemPromptBuilder
kwargs["prompt_builder"] = SystemPromptBuilder(
agent_template=config.agent.default_system_prompt or "",
memory_files_config=effective_mf,
system_prompt_config=config.system_prompt,
)
agent = agent_cls(engine, model, **kwargs)
except Exception as exc:
console.print(f"[yellow]Agent '{agent_key}' failed: {exc}[/yellow]")
@@ -147,6 +180,16 @@ def chat(
_notifications = NotificationDispatcher(get_status())
# Conversation state
if not system_prompt:
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template=config.agent.default_system_prompt or "",
memory_files_config=effective_mf,
system_prompt_config=config.system_prompt,
)
system_prompt = builder.build()
history: List[Message] = []
if system_prompt:
history.append(Message(role=Role.SYSTEM, content=system_prompt))
+1 -1
View File
@@ -332,7 +332,7 @@ def compose_bench(
for i, rc in enumerate(run_configs, 1):
console.print(f"\n[bold]Run {i}/{len(run_configs)}:[/bold] {rc.benchmark}")
try:
summary = _run_single(rc, console=console)
summary = _run_single(rc, console=console, suite_mode=True)
results_table.add_row(
rc.benchmark,
f"{summary.accuracy:.4f}",
+30
View File
@@ -276,6 +276,36 @@ def hardware() -> None:
config.add_command(show_group, "show")
@config.command("path")
def show_path() -> None:
"""Print the resolved OpenJarvis directories (home, config, cache).
All OpenJarvis state lives under a single root, resolved in priority
order: ``$OPENJARVIS_HOME`` > ``$XDG_DATA_HOME/openjarvis`` >
``~/.openjarvis``. Use this to confirm where your data is stored after
setting an override.
"""
from openjarvis.core.paths import get_cache_dir, get_config_dir, get_config_path
console = Console(stderr=True)
home = get_config_dir()
override = (
"OPENJARVIS_HOME"
if os.environ.get("OPENJARVIS_HOME")
else "XDG_DATA_HOME"
if os.environ.get("XDG_DATA_HOME")
else "default (~/.openjarvis)"
)
table = Table(show_header=True, header_style="bold")
table.add_column("Directory")
table.add_column("Path", style="cyan")
table.add_row("Home (root)", str(home))
table.add_row("Config file", str(get_config_path()))
table.add_row("Cache", str(get_cache_dir()))
console.print(table)
console.print(f"[dim]Resolved via: {override}[/dim]")
def _probe_engine_host(url: str, console: Console) -> None:
"""Probe an engine host URL and print reachability status."""
try:
+47 -3
View File
@@ -223,6 +223,47 @@ def _check_optional_deps() -> List[CheckResult]:
return results
def _check_speech_backend() -> CheckResult:
"""Check whether the configured speech backend can load."""
try:
from openjarvis.speech._discovery import get_speech_backend
config = _get_config()
backend = get_speech_backend(config)
if backend is None:
return CheckResult(
"Speech backend",
"warn",
"Not configured",
details="Install desktop dependencies with `uv sync --extra desktop`.",
)
if backend.health():
return CheckResult(
"Speech backend",
"ok",
f"{backend.backend_id} ready",
)
details = None
last_error = getattr(backend, "last_error", None)
if callable(last_error):
details = last_error()
return CheckResult(
"Speech backend",
"warn",
f"{backend.backend_id} unavailable",
details=details
or "Install desktop dependencies with `uv sync --extra desktop`.",
)
except Exception as exc:
return CheckResult(
"Speech backend",
"warn",
f"Could not check: {exc}",
)
def _check_security_profile() -> CheckResult:
"""Check if a security profile is configured."""
try:
@@ -306,6 +347,7 @@ def _run_all_checks() -> List[CheckResult]:
checks.extend(_check_models())
checks.append(_check_default_model())
checks.extend(_check_optional_deps())
checks.append(_check_speech_backend())
checks.append(_check_nodejs())
checks.append(_check_security_profile())
return checks
@@ -354,7 +396,9 @@ def doctor(as_json: bool) -> None:
# Background tasks section
from openjarvis.cli._bg_state import get_status
from openjarvis.core.paths import get_config_dir
scripts_dir = get_config_dir() / ".scripts"
console.print("[bold]Background tasks[/bold]")
bg = get_status()
bg_failed = False
@@ -364,8 +408,8 @@ def doctor(as_json: bool) -> None:
elif bg.rust_extension == "failed":
console.print(f" [red]✗[/red] Rust extension: failed — {bg.rust_error[:80]}")
console.print(
" retry: ~/.openjarvis/.scripts/install-rust.sh && "
"~/.openjarvis/.scripts/build-extension.sh"
f" retry: {scripts_dir}/install-rust.sh && "
f"{scripts_dir}/build-extension.sh"
)
bg_failed = True
else:
@@ -380,7 +424,7 @@ def doctor(as_json: bool) -> None:
console.print(f" [green]✓[/green] {model_id}: ready")
elif state == "failed":
console.print(f" [red]✗[/red] {model_id}: failed")
console.print(f" retry: ~/.openjarvis/.scripts/pull-model.sh {model_id}")
console.print(f" retry: {scripts_dir}/pull-model.sh {model_id}")
bg_failed = True
else:
console.print(f" [yellow]…[/yellow] {model_id}: downloading")
+19 -5
View File
@@ -61,6 +61,12 @@ KNOWN_BENCHMARKS = {
KNOWN_BACKENDS = {
"jarvis-direct": "Engine-level inference (local or cloud)",
"jarvis-agent": "Agent-level inference with tool calling",
"hermes": "Real Hermes Agent (Nous Research) via subprocess",
"openclaw": "Real OpenClaw via Node subprocess",
"terminalbench-native": (
"TerminalBench V2.1 via terminal-bench Harness "
"(selected with -b terminalbench-native)"
),
}
@@ -146,7 +152,9 @@ def eval_list() -> None:
"base_url",
default=None,
help=(
"OpenAI-compat endpoint URL for hermes/openclaw backends "
"OpenAI-compatible endpoint for the model under eval. Required for "
"hermes/openclaw; for jarvis-direct/jarvis-agent/terminalbench-native "
"it bypasses engine discovery and targets this URL directly "
"(env: JARVIS_BACKEND_BASE_URL)."
),
)
@@ -154,7 +162,11 @@ def eval_list() -> None:
"--api-key",
"api_key",
default=None,
help=("API key for the hermes/openclaw endpoint (env: JARVIS_BACKEND_API_KEY)."),
help=(
"API key for the --base-url endpoint, sent as a Bearer token. "
"Required for hermes/openclaw; optional for first-party backends "
"(env: JARVIS_BACKEND_API_KEY)."
),
)
@click.option(
"--agent",
@@ -347,7 +359,7 @@ def eval_run(
f"{rc.benchmark} / {rc.model}"
)
try:
summary = _run_single(rc, console=console)
summary = _run_single(rc, console=console, suite_mode=True)
console.print(
f" [green]{summary.accuracy:.4f}[/green] "
f"({summary.correct}/{summary.scored_samples})"
@@ -399,8 +411,10 @@ def eval_run(
sheets_spreadsheet_id=sheets_spreadsheet_id,
sheets_worksheet=sheets_worksheet,
sheets_credentials_path=sheets_credentials_path,
# Spec §6.2 — for hermes/openclaw external backends. Falls back to env vars
# so users can also set JARVIS_BACKEND_BASE_URL/JARVIS_BACKEND_API_KEY.
# OpenAI-compatible endpoint for the model under eval. Required for
# hermes/openclaw (Spec §6.2); honored by first-party backends too on
# this CLI path. Falls back to env vars so users can also set
# JARVIS_BACKEND_BASE_URL/JARVIS_BACKEND_API_KEY.
base_url=base_url or os.environ.get("JARVIS_BACKEND_BASE_URL"),
api_key=api_key or os.environ.get("JARVIS_BACKEND_API_KEY"),
)
+2 -1
View File
@@ -7,6 +7,7 @@ from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Optional, Union
from openjarvis.core.paths import get_config_dir
from openjarvis.security.credential_stripper import CredentialStripper
_stripper = CredentialStripper()
@@ -68,7 +69,7 @@ def setup_logging(
if log_file is None:
from openjarvis.security.file_utils import secure_mkdir
log_dir = Path.home() / ".openjarvis"
log_dir = get_config_dir()
secure_mkdir(log_dir)
log_file = log_dir / "cli.log"
file_handler = RotatingFileHandler(
+3 -1
View File
@@ -11,6 +11,8 @@ from typing import Callable, List
import click
from openjarvis.core.paths import get_config_dir
# Engine ports that should only be listening on localhost.
_ENGINE_PORTS = {11434, 8080, 8000, 30000, 1234, 52415, 18181}
@@ -132,7 +134,7 @@ class PrivacyScanner:
def check_icloud_sync(self) -> ScanResult:
"""Check whether ~/.openjarvis is inside iCloud Drive sync scope."""
try:
config_path = Path("~/.openjarvis").expanduser().resolve()
config_path = get_config_dir().resolve()
icloud_path = Path("~/Library/Mobile Documents/").expanduser().resolve()
if str(config_path).startswith(str(icloud_path)):
return ScanResult(
+83 -28
View File
@@ -11,6 +11,7 @@ from rich.console import Console
from openjarvis.cli._banner import print_banner
from openjarvis.core.config import load_config
from openjarvis.core.events import EventBus
from openjarvis.core.paths import get_config_dir
from openjarvis.engine import (
discover_engines,
discover_models,
@@ -146,7 +147,13 @@ def serve(
except Exception as exc:
logger.debug("Telemetry store init failed: %s", exc)
resolved = get_engine(config, engine_key)
# Select with the model we'll actually serve so an engine that can't
# serve it (e.g. the cloud fallback without the matching provider key) is
# skipped rather than chosen and failing per-request later (see #532).
selection_model = (
model_name or config.server.model or config.intelligence.default_model or None
)
resolved = get_engine(config, engine_key, model=selection_model)
if resolved is None:
console.print(
"[red bold]No inference engine available.[/red bold]\n\n"
@@ -261,6 +268,11 @@ def serve(
# Resolve agent
agent = None
agent_key = agent_name or config.server.agent
# Tool instances resolved for the primary agent are reused below to build
# the scheduler's ToolExecutor — avoiding a second full SystemBuilder.build()
# (which would re-discover the engine, re-resolve tools, re-open the channel,
# etc.). See the scheduler block near the bottom of this function (#263).
resolved_tools: list = []
if agent_key:
try:
import openjarvis.agents # noqa: F401
@@ -328,6 +340,8 @@ def serve(
if tools:
agent_kwargs["tools"] = tools
# Reuse these for the scheduler's ToolExecutor (#263).
resolved_tools = tools
if getattr(agent_cls, "accepts_tools", False):
agent_kwargs["max_turns"] = config.agent.max_turns
@@ -461,17 +475,31 @@ def serve(
# Create app
from openjarvis.server.app import create_app
# Set up memory backend for context injection. Built before the scheduler
# block so the executor's JarvisSystem can reference it (#263).
memory_backend = None
if config.agent.context_from_memory:
try:
import openjarvis.tools.storage # noqa: F401
from openjarvis.core.registry import MemoryRegistry
mem_key = config.memory.default_backend
if MemoryRegistry.contains(mem_key):
memory_backend = MemoryRegistry.create(
mem_key,
db_path=config.memory.db_path,
)
console.print(" Memory: [cyan]active[/cyan]")
except Exception as exc:
logger.debug("Memory backend init failed: %s", exc)
# Set up agent manager
agent_manager = None
if config.agent_manager.enabled:
try:
from pathlib import Path
from openjarvis.agents.manager import AgentManager
am_db = config.agent_manager.db_path or str(
Path("~/.openjarvis/agents.db").expanduser()
)
am_db = config.agent_manager.db_path or str(get_config_dir() / "agents.db")
# The server owns the scheduler and is the authoritative tick
# runner — on boot it holds no locks, so it (and only it) sweeps
# any zombie running→idle left by a previous crash.
@@ -500,9 +528,55 @@ def serve(
event_bus=bus,
trace_store=_trace_store,
)
from openjarvis.system import SystemBuilder
# Reuse the components already built inline above instead of a
# second full SystemBuilder.build() — the original double-build
# re-discovered the engine, re-instrumented it, re-resolved tools,
# re-opened the channel and re-created the agent manager, costing
# ~30-40s on top of an already-paid startup (#263). The executor
# only reads engine/model/config/memory_backend/tool_executor/
# session_store/channel_backend from the system (see
# AgentExecutor), all of which are wired here.
from openjarvis.sessions.session import SessionStore
from openjarvis.system import JarvisSystem
from openjarvis.tools._stubs import ToolExecutor
system = SystemBuilder(config).build()
_sched_session_store = None
if config.sessions.enabled:
try:
from pathlib import Path as _SchedPath
_sched_session_store = SessionStore(
db_path=_SchedPath(config.sessions.db_path).expanduser(),
max_age_hours=config.sessions.max_age_hours,
consolidation_threshold=(
config.sessions.consolidation_threshold
),
)
except Exception as exc:
logger.debug("Scheduler session store init failed: %s", exc)
_sched_tool_executor = (
ToolExecutor(resolved_tools, bus) if resolved_tools else None
)
system = JarvisSystem(
config=config,
bus=bus,
engine=engine,
engine_key=engine_name,
model=model_name,
agent=agent,
agent_name=agent_key or "",
tools=resolved_tools,
tool_executor=_sched_tool_executor,
memory_backend=memory_backend,
telemetry_store=telem_store,
trace_store=_trace_store,
session_store=_sched_session_store,
capability_policy=sec.capability_policy,
agent_manager=agent_manager,
agent_executor=executor,
)
executor.set_system(system)
agent_scheduler = AgentScheduler(
@@ -522,23 +596,6 @@ def serve(
except Exception as exc:
logger.debug("Agent scheduler init failed: %s", exc)
# Set up memory backend for context injection
memory_backend = None
if config.agent.context_from_memory:
try:
import openjarvis.tools.storage # noqa: F401
from openjarvis.core.registry import MemoryRegistry
mem_key = config.memory.default_backend
if MemoryRegistry.contains(mem_key):
memory_backend = MemoryRegistry.create(
mem_key,
db_path=config.memory.db_path,
)
console.print(" Memory: [cyan]active[/cyan]")
except Exception as exc:
logger.debug("Memory backend init failed: %s", exc)
# --- Channel Gateway: API key, sessions, ChannelBridge ---
import os as _os
@@ -547,9 +604,7 @@ def serve(
try:
import tomllib
_cfg_path = str(
__import__("pathlib").Path.home() / ".openjarvis" / "config.toml"
)
_cfg_path = str(get_config_dir() / "config.toml")
with open(_cfg_path, "rb") as _f:
_raw = tomllib.load(_f)
api_key = _raw.get("server", {}).get("auth", {}).get("api_key", "")
+8 -5
View File
@@ -11,6 +11,7 @@ from rich.table import Table
from openjarvis.core.config import load_config
from openjarvis.core.events import EventBus
from openjarvis.core.paths import get_config_dir
from openjarvis.skills.manager import SkillManager
@@ -28,12 +29,12 @@ def _get_trace_store():
def _get_discovered_dir() -> Path:
"""Return the directory where discovered skill manifests are written."""
return Path("~/.openjarvis/skills/discovered/").expanduser()
return get_config_dir() / "skills" / "discovered"
def _get_overlay_dir() -> Path:
"""Return the directory where optimization overlays are stored."""
return Path("~/.openjarvis/learning/skills/").expanduser()
return get_config_dir() / "learning" / "skills"
def _get_skill_paths() -> List[Path]:
@@ -41,7 +42,7 @@ def _get_skill_paths() -> List[Path]:
workspace = Path("./skills")
if workspace.exists():
paths.append(workspace)
user_dir = Path("~/.openjarvis/skills/").expanduser()
user_dir = get_config_dir() / "skills"
paths.append(user_dir)
return paths
@@ -174,8 +175,10 @@ def _get_resolver(source: str, url: str = ""):
from openjarvis.skills.sources.github import GitHubResolver
cache = _Path(
"~/.openjarvis/skill-cache/github/" + url.rstrip("/").rsplit("/", 1)[-1]
).expanduser()
str(get_config_dir() / "skill-cache" / "github")
+ "/"
+ url.rstrip("/").rsplit("/", 1)[-1]
)
return GitHubResolver(cache_root=cache, repo_url=url)
raise click.BadParameter(f"Unknown source: {source!r}")
+10 -19
View File
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
delete_tokens,
load_tokens,
resolve_google_credentials,
run_oauth_flow,
save_tokens,
)
from openjarvis.core.config import DEFAULT_CONFIG_DIR
@@ -290,12 +289,18 @@ class GCalendarConnector(BaseConnector):
"""Handle the OAuth callback.
If *code* looks like a ``client_id:client_secret`` pair (containing
``.apps.googleusercontent.com``), store the credentials and trigger
the full browser-based OAuth flow. Otherwise treat it as a raw
token / auth code.
``.apps.googleusercontent.com``), persist the client credentials only.
The browser consent + codetoken exchange is owned by the in-process
server flow (``/v1/connectors/{id}/oauth/start`` ``/oauth/callback``),
which writes the real ``access_token`` to every Google credential file.
The previous daemon-thread browser flow (its own ``localhost:8789``
callback server) failed silently in the bundled desktop context and is
intentionally removed here (issue #512).
Any other *code* is treated as a raw token / auth code.
"""
code = code.strip()
# If user pastes client_id:client_secret, store and run OAuth flow
if ":" in code and ".apps.googleusercontent.com" in code:
client_id, client_secret = code.split(":", 1)
save_tokens(
@@ -305,20 +310,6 @@ class GCalendarConnector(BaseConnector):
"client_secret": client_secret.strip(),
},
)
import threading
def _run() -> None:
try:
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=GOOGLE_ALL_SCOPES,
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
pass
threading.Thread(target=_run, daemon=True).start()
else:
# Raw token or auth code
save_tokens(self._credentials_path, {"token": code})
+10 -19
View File
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
delete_tokens,
load_tokens,
resolve_google_credentials,
run_oauth_flow,
save_tokens,
)
from openjarvis.core.config import DEFAULT_CONFIG_DIR
@@ -195,12 +194,18 @@ class GContactsConnector(BaseConnector):
"""Handle the OAuth callback.
If *code* looks like a ``client_id:client_secret`` pair (containing
``.apps.googleusercontent.com``), store the credentials and trigger
the full browser-based OAuth flow. Otherwise treat it as a raw
token / auth code.
``.apps.googleusercontent.com``), persist the client credentials only.
The browser consent + codetoken exchange is owned by the in-process
server flow (``/v1/connectors/{id}/oauth/start`` ``/oauth/callback``),
which writes the real ``access_token`` to every Google credential file.
The previous daemon-thread browser flow (its own ``localhost:8789``
callback server) failed silently in the bundled desktop context and is
intentionally removed here (issue #512).
Any other *code* is treated as a raw token / auth code.
"""
code = code.strip()
# If user pastes client_id:client_secret, store and run OAuth flow
if ":" in code and ".apps.googleusercontent.com" in code:
client_id, client_secret = code.split(":", 1)
save_tokens(
@@ -210,20 +215,6 @@ class GContactsConnector(BaseConnector):
"client_secret": client_secret.strip(),
},
)
import threading
def _run() -> None:
try:
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=GOOGLE_ALL_SCOPES,
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
pass
threading.Thread(target=_run, daemon=True).start()
else:
# Raw token or auth code
save_tokens(self._credentials_path, {"token": code})
+15 -21
View File
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
delete_tokens,
load_tokens,
resolve_google_credentials,
run_oauth_flow,
save_tokens,
)
from openjarvis.core.config import DEFAULT_CONFIG_DIR
@@ -178,15 +177,25 @@ class GDriveConnector(BaseConnector):
"""Handle the OAuth callback.
If *code* looks like a ``client_id:client_secret`` pair (containing
``.apps.googleusercontent.com``), store the credentials and trigger
the full browser-based OAuth flow. Otherwise treat it as a raw
token / auth code.
``.apps.googleusercontent.com``), persist the client credentials only.
The actual browser consent + codetoken exchange is owned by the
in-process server flow (``/v1/connectors/{id}/oauth/start``
``/oauth/callback``), which writes the real ``access_token`` to every
Google credential file.
Previously this spawned a daemon thread that popped a browser and ran
its own ``localhost:8789`` callback server; that thread failed silently
in the bundled desktop context, so the connector never gained an access
token and never appeared in Data Sources (issue #512). The background
flow is intentionally removed here.
Any other *code* is treated as a raw token / auth code.
"""
code = code.strip()
# If user pastes client_id:client_secret, store and run OAuth flow
# A pasted client_id:client_secret pair is the app registration, not a
# completed credential — persist it and let the server flow finish auth.
if ":" in code and ".apps.googleusercontent.com" in code:
client_id, client_secret = code.split(":", 1)
# Save credentials immediately
save_tokens(
self._credentials_path,
{
@@ -194,21 +203,6 @@ class GDriveConnector(BaseConnector):
"client_secret": client_secret.strip(),
},
)
# Run OAuth flow in background thread to avoid blocking
import threading
def _run() -> None:
try:
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=GOOGLE_ALL_SCOPES,
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
pass
threading.Thread(target=_run, daemon=True).start()
else:
# Raw token or auth code
save_tokens(self._credentials_path, {"token": code})
+59 -23
View File
@@ -16,6 +16,14 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from openjarvis.core.paths import (
ConfigurationError,
get_cache_dir,
get_config_dir,
get_config_path,
get_data_dir,
)
if TYPE_CHECKING:
# Only used by type-checkers (mypy/pyright) for the ``JarvisConfig.mining``
# field annotation. The runtime import is deferred inside
@@ -33,15 +41,24 @@ except ModuleNotFoundError:
# Hardware dataclasses
# ---------------------------------------------------------------------------
DEFAULT_CONFIG_DIR = Path.home() / ".openjarvis"
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
# Legacy names, kept for the ~45 modules that import them. They are resolved
# once at import via the env-aware resolver in ``openjarvis.core.paths`` (the
# install-script model: ``OPENJARVIS_HOME`` / ``XDG_DATA_HOME`` are set before
# the process starts). They are real module attributes — not computed lazily —
# so existing tests can ``monkeypatch.setattr`` them and so dataclass-instance
# defaults stay consistent. Code that must react to a mid-process env change
# (or wants the override regardless of import order) should call
# ``get_config_dir()`` / ``get_config_path()`` directly; the dataclass field
# defaults below already do this via ``default_factory``.
DEFAULT_CONFIG_DIR = get_config_dir()
DEFAULT_CONFIG_PATH = get_config_path()
def _ensure_config_dir() -> Path:
"""Ensure the config directory exists with restrictive permissions."""
from openjarvis.security.file_utils import secure_mkdir
return secure_mkdir(DEFAULT_CONFIG_DIR)
return secure_mkdir(get_config_dir())
@dataclass(slots=True)
@@ -742,7 +759,9 @@ class SkillsLearningConfig:
optimizer: str = "dspy" # "dspy" or "gepa"
min_traces_per_skill: int = 20
optimization_interval_seconds: int = 86400
overlay_dir: str = "~/.openjarvis/learning/skills/"
overlay_dir: str = field(
default_factory=lambda: str(get_config_dir() / "learning" / "skills")
)
@dataclass(slots=True)
@@ -894,7 +913,7 @@ class StorageConfig:
"""Storage (memory) backend settings."""
default_backend: str = "sqlite"
db_path: str = str(DEFAULT_CONFIG_DIR / "memory.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "memory.db"))
context_top_k: int = 5
context_min_score: float = 0.0
context_max_tokens: int = 2048
@@ -946,8 +965,10 @@ class AgentConfig:
system_prompt_path: str = "" # path to system prompt file (.txt, .md)
context_from_memory: bool = True # inject relevant memory context into prompts
default_system_prompt: str = (
"You are a helpful AI assistant running locally on the user's own "
"hardware through OpenJarvis. You are not a cloud service. Respond "
"You are OpenJarvis, a helpful AI assistant running locally on the "
"user's own hardware. You are not a cloud service, and you are not "
"Claude, ChatGPT, Gemini, or any other branded assistant. If asked "
"who or what you are, identify yourself as OpenJarvis. Respond "
"helpfully, concisely, and accurately."
)
@@ -996,7 +1017,7 @@ class TelemetryConfig:
"""Telemetry persistence settings."""
enabled: bool = True
db_path: str = str(DEFAULT_CONFIG_DIR / "telemetry.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "telemetry.db"))
gpu_metrics: bool = False
gpu_poll_interval_ms: int = 50
energy_vendor: str = "" # auto-detect or force "nvidia"/"amd"/"apple"/"cpu_rapl"
@@ -1021,7 +1042,7 @@ class AnalyticsConfig:
enabled: bool = True
host: str = "https://34.231.106.201.sslip.io"
key: str = "phc_ysKu72QaxzYNmDpHFcesD2ZZAe68zkdWJEKoYYkc5e3n"
anon_id_path: str = str(DEFAULT_CONFIG_DIR / "anon_id")
anon_id_path: str = field(default_factory=lambda: str(get_config_dir() / "anon_id"))
flush_interval_seconds: int = 30
flush_at_size: int = 100
@@ -1031,7 +1052,7 @@ class TracesConfig:
"""Trace system settings."""
enabled: bool = True
db_path: str = str(DEFAULT_CONFIG_DIR / "traces.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "traces.db"))
@dataclass(slots=True)
@@ -1233,7 +1254,9 @@ class SecurityConfig:
mode: str = "redact" # "redact" | "warn" | "block"
secret_scanner: bool = True
pii_scanner: bool = True
audit_log_path: str = str(DEFAULT_CONFIG_DIR / "audit.db")
audit_log_path: str = field(
default_factory=lambda: str(get_config_dir() / "audit.db")
)
enforce_tool_confirmation: bool = True
merkle_audit: bool = True
signing_key_path: str = ""
@@ -1244,7 +1267,9 @@ class SecurityConfig:
local_engine_bypass: bool = False
local_tool_bypass: bool = False
profile: str = ""
vault_key_path: str = str(DEFAULT_CONFIG_DIR / ".vault_key")
vault_key_path: str = field(
default_factory=lambda: str(get_config_dir() / ".vault_key")
)
capabilities: CapabilitiesConfig = field(default_factory=CapabilitiesConfig)
@@ -1365,7 +1390,7 @@ class SessionConfig:
enabled: bool = False
max_age_hours: float = 24.0
consolidation_threshold: int = 100
db_path: str = str(DEFAULT_CONFIG_DIR / "sessions.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "sessions.db"))
@dataclass(slots=True)
@@ -1383,7 +1408,9 @@ class OperatorsConfig:
"""Operator lifecycle settings."""
enabled: bool = False
manifests_dir: str = "~/.openjarvis/operators"
manifests_dir: str = field(
default_factory=lambda: str(get_config_dir() / "operators")
)
auto_activate: str = "" # Comma-separated operator IDs
@@ -1409,7 +1436,7 @@ class OptimizeConfig:
benchmark: str = ""
max_samples: int = 50
judge_model: str = "gpt-5-mini-2025-08-07"
db_path: str = str(DEFAULT_CONFIG_DIR / "optimize.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "optimize.db"))
@dataclass(slots=True)
@@ -1417,18 +1444,20 @@ class AgentManagerConfig:
"""Persistent agent manager settings."""
enabled: bool = True
db_path: str = str(DEFAULT_CONFIG_DIR / "agents.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "agents.db"))
@dataclass(slots=True)
class MemoryFilesConfig:
"""Persistent memory-file paths and nudge settings."""
soul_path: str = "~/.openjarvis/SOUL.md"
memory_path: str = "~/.openjarvis/MEMORY.md"
user_path: str = "~/.openjarvis/USER.md"
soul_path: str = field(default_factory=lambda: str(get_config_dir() / "SOUL.md"))
memory_path: str = field(
default_factory=lambda: str(get_config_dir() / "MEMORY.md")
)
user_path: str = field(default_factory=lambda: str(get_config_dir() / "USER.md"))
nudge_interval: int = 10
persona_name: str = "" # named persona dir under ~/.openjarvis/personas/<name>/
persona_name: str = "" # named persona dir under <config-dir>/personas/<name>/
@dataclass(slots=True)
@@ -1467,13 +1496,15 @@ class SkillsConfig:
"""Configuration for agent-authored procedural skills."""
enabled: bool = True
skills_dir: str = "~/.openjarvis/skills/"
skills_dir: str = field(default_factory=lambda: str(get_config_dir() / "skills"))
active: str = "*"
auto_discover: bool = True
auto_sync: bool = False
nudge_interval: int = 15
index_repo: str = "https://github.com/openjarvis/skill-index.git"
index_dir: str = "~/.openjarvis/skill-index/"
index_dir: str = field(
default_factory=lambda: str(get_config_dir() / "skill-index")
)
max_depth: int = 5
sandbox_dangerous: bool = True
sources: List[SkillSourceConfig] = field(default_factory=list)
@@ -1779,7 +1810,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
elif os.environ.get("OPENJARVIS_CONFIG"):
config_path = Path(os.environ["OPENJARVIS_CONFIG"]).expanduser().resolve()
else:
config_path = DEFAULT_CONFIG_PATH
config_path = get_config_path()
if config_path.exists():
with open(config_path, "rb") as fh:
data = tomllib.load(fh)
@@ -2117,9 +2148,14 @@ __all__ = [
"BrowserConfig",
"CapabilitiesConfig",
"ChannelConfig",
"ConfigurationError",
"DEFAULT_CONFIG_DIR",
"DEFAULT_CONFIG_PATH",
"DiscordChannelConfig",
"get_cache_dir",
"get_config_dir",
"get_config_path",
"get_data_dir",
"EmailChannelConfig",
"EngineConfig",
"FeishuChannelConfig",
+10 -3
View File
@@ -10,13 +10,20 @@ import os
import threading
from pathlib import Path
from openjarvis.core.paths import get_config_dir
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib # type: ignore[no-redef]
_LOCK = threading.Lock()
_DEFAULT_PATH = Path.home() / ".openjarvis" / "credentials.toml"
def _default_path() -> Path:
"""Resolve the credentials file under the OpenJarvis root (env-aware)."""
return get_config_dir() / "credentials.toml"
TOOL_CREDENTIALS: dict[str, list[str]] = {
"web_search": ["TAVILY_API_KEY"],
@@ -53,7 +60,7 @@ TOOL_CREDENTIALS: dict[str, list[str]] = {
def load_credentials(path: Path | None = None) -> dict[str, dict[str, str]]:
"""Load credentials from TOML file."""
p = Path(path) if path else _DEFAULT_PATH
p = Path(path) if path else _default_path()
if not p.exists():
return {}
with open(p, "rb") as f:
@@ -75,7 +82,7 @@ def save_credential(
if not stripped:
raise ValueError("Credential value must not be empty")
p = Path(path) if path else _DEFAULT_PATH
p = Path(path) if path else _default_path()
with _LOCK:
creds = load_credentials(path=p)
if tool_name not in creds:
+122
View File
@@ -0,0 +1,122 @@
"""Central, env-aware resolution of OpenJarvis' home directory.
OpenJarvis keeps all of its runtime state (config, databases, caches, logs,
credentials, skills, recipes, ) under a single root so it never clutters the
user's home directory beyond one directory. That root is resolved here, with
the following precedence (highest first):
1. ``$OPENJARVIS_HOME`` explicit override (also honored by the shell
installer, see ``scripts/install/install.sh``).
2. ``$XDG_DATA_HOME/openjarvis`` when ``$XDG_DATA_HOME`` is set, follow the
XDG Base Directory spec by nesting a single ``openjarvis`` directory under
it. We deliberately use ONE directory rather than splitting across XDG
config/data/cache so the install tree stays self-contained and relocatable.
3. ``~/.openjarvis`` the historical default. With no env vars set, the
resolved path is exactly this, so existing installs are untouched.
``config.py`` re-exports :func:`get_config_dir` results through the legacy
``DEFAULT_CONFIG_DIR``/``DEFAULT_CONFIG_PATH`` names (computed dynamically) so
the ~45 modules that import those names keep working while honoring the
override. Modules that previously hardcoded ``Path.home() / ".openjarvis"``
should call :func:`get_config_dir` (or :func:`get_data_dir` /
:func:`get_cache_dir`) instead.
Defense in depth: the resolved root must never live inside the OpenJarvis
source tree (a misconfigured ``$OPENJARVIS_HOME`` pointing at the repo would
otherwise scatter runtime artifacts into the working tree). This mirrors the
guard in ``learning/spec_search/storage/paths.py`` and fails loudly per
REVIEW.md's no-silent-failure discipline.
"""
from __future__ import annotations
import os
from pathlib import Path
_DEFAULT_DIR_NAME = ".openjarvis"
_XDG_SUBDIR_NAME = "openjarvis"
class ConfigurationError(RuntimeError):
"""Raised when the resolved home directory would violate isolation guarantees."""
def _find_source_root() -> Path | None:
"""Walk upward from this module to find the OpenJarvis source root.
Returns the directory containing the OpenJarvis ``pyproject.toml`` (the one
whose ``name = "openjarvis"``), or ``None`` when running from an installed
wheel rather than a source checkout.
"""
here = Path(__file__).resolve()
for candidate in (here, *here.parents):
py = candidate / "pyproject.toml"
if py.exists():
try:
content = py.read_text(encoding="utf-8")
except OSError:
continue
if 'name = "openjarvis"' in content.lower():
return candidate
return None
def _reject_source_tree(path: Path) -> Path:
"""Raise if ``path`` resolves inside the OpenJarvis source tree."""
source_root = _find_source_root()
if source_root is not None:
try:
path.relative_to(source_root)
except ValueError:
pass # Good — not inside the source tree.
else:
raise ConfigurationError(
f"OpenJarvis home ({path}) is inside the source tree "
f"({source_root}). OpenJarvis refuses to write runtime state "
"inside its own repo. Set OPENJARVIS_HOME (or XDG_DATA_HOME) "
"to a directory outside the repo (default: ~/.openjarvis)."
)
return path
def get_config_dir() -> Path:
"""Resolve OpenJarvis' single root directory, honoring env overrides.
Precedence: ``$OPENJARVIS_HOME`` > ``$XDG_DATA_HOME/openjarvis`` >
``~/.openjarvis``. The result is always absolute and is rejected if it
falls inside the OpenJarvis source tree.
"""
env_home = os.environ.get("OPENJARVIS_HOME")
if env_home:
resolved = Path(env_home).expanduser().resolve()
return _reject_source_tree(resolved)
xdg_data = os.environ.get("XDG_DATA_HOME")
if xdg_data:
resolved = (Path(xdg_data).expanduser() / _XDG_SUBDIR_NAME).resolve()
return _reject_source_tree(resolved)
return (Path.home() / _DEFAULT_DIR_NAME).resolve()
def get_config_path() -> Path:
"""Resolve the path to ``config.toml`` under the OpenJarvis root."""
return get_config_dir() / "config.toml"
def get_data_dir() -> Path:
"""Resolve the directory for persistent data (databases, blobs, …).
Consolidated under the single root; identical to :func:`get_config_dir`.
Provided as a distinct name so call sites read intentionally.
"""
return get_config_dir()
def get_cache_dir() -> Path:
"""Resolve the directory for regenerable caches (eval datasets, etc.).
Lives at ``<root>/cache`` so caches stay inside the single OpenJarvis
directory instead of scattering across ``~/.cache``.
"""
return get_config_dir() / "cache"
+4
View File
@@ -68,6 +68,10 @@ class Message:
tool_calls: Optional[List[ToolCall]] = None
tool_call_id: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
# Base64-encoded image data for vision-capable models (e.g. gemma3,
# qwen2.5-vl). Forwarded to Ollama's /api/chat "images" field; None or
# empty for text-only messages (the common case).
images: Optional[List[str]] = None
@dataclass(slots=True)
+4
View File
@@ -34,6 +34,10 @@ def messages_to_dicts(messages: Sequence[Message]) -> List[Dict[str, Any]]:
]
if m.tool_call_id:
d["tool_call_id"] = m.tool_call_id
# Vision: forward base64 images to the engine. Ollama's /api/chat
# accepts an "images" array on a message; text messages skip this.
if getattr(m, "images", None):
d["images"] = list(m.images)
out.append(d)
return out
+21 -5
View File
@@ -156,12 +156,26 @@ def discover_models(
def get_engine(
config: JarvisConfig, engine_key: str | None = None
config: JarvisConfig,
engine_key: str | None = None,
model: str | None = None,
) -> Tuple[str, InferenceEngine] | None:
"""Get a specific engine by key, or the default with fallback.
When *model* is given, an engine is selected only if it can actually
serve that model (``engine.can_serve(model)``). This stops the cloud
fallback from being chosen when the local engine is down for a model
whose provider client is missing, which otherwise surfaces as a confusing
"OpenAI client not available" instead of a helpful "start your local
engine" message (see #532). When *model* is ``None`` selection stays
model-agnostic (unchanged behaviour).
Returns ``(key, engine_instance)`` or ``None`` if no engine is available.
"""
def _usable(engine: InferenceEngine) -> bool:
return engine.health() and (model is None or engine.can_serve(model))
# Build an ordered list of keys to try, then fall back to full discovery.
keys_to_try: list[str] = []
if engine_key:
@@ -176,14 +190,16 @@ def get_engine(
continue
try:
engine = _make_engine(key, config)
if engine.health():
if _usable(engine):
return (key, engine)
except Exception as exc:
logger.debug("Engine %r health check failed: %s", key, exc)
# Fallback to any healthy engine
healthy = discover_engines(config)
return healthy[0] if healthy else None
# Fallback to the first healthy engine that can serve the model.
for key, engine in discover_engines(config):
if model is None or engine.can_serve(model):
return (key, engine)
return None
__all__ = ["discover_engines", "discover_models", "get_engine"]
+23 -4
View File
@@ -28,12 +28,31 @@ class _OpenAICompatibleEngine(InferenceEngine):
_default_host: str = "http://localhost:8000"
_api_prefix: str = "/v1"
def __init__(self, host: str | None = None, *, timeout: float = 600.0) -> None:
def __init__(
self,
host: str | None = None,
*,
api_key: str | None = None,
timeout: float = 600.0,
) -> None:
import os
env_key = f"{self.engine_id.upper()}_HOST"
self._host = (host or os.environ.get(env_key) or self._default_host).rstrip("/")
self._client = httpx.Client(base_url=self._host, timeout=timeout)
# Sanitize the engine id for env-var lookup ("openai-compat" ->
# "OPENAI_COMPAT_..."); shells cannot set hyphenated variable names.
env_prefix = self.engine_id.upper().replace("-", "_")
self._host = (
host or os.environ.get(f"{env_prefix}_HOST") or self._default_host
).rstrip("/")
# Bearer auth for endpoints started with e.g. ``vllm serve --api-key``.
# Setting it on the client covers generate/stream/stream_full/
# list_models/health alike; ``None`` keeps requests header-free.
self._api_key = api_key or os.environ.get(f"{env_prefix}_API_KEY") or None
headers = (
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
)
self._client = httpx.Client(
base_url=self._host, timeout=timeout, headers=headers
)
# -- InferenceEngine interface ------------------------------------------
+11
View File
@@ -119,6 +119,17 @@ class InferenceEngine(ABC):
def health(self) -> bool:
"""Return ``True`` when the engine is reachable and healthy."""
def can_serve(self, model: str) -> bool:
"""Return ``True`` if this engine can serve *model*.
Defaults to ``True``: local engines accept any model id (whether a
specific model is *installed* is a separate concern from engine
selection). Engines that multiplex provider-specific clients (e.g.
the cloud engine) override this so selection can skip an engine whose
client for the model's provider isn't configured (see #532).
"""
return True
def close(self) -> None:
"""Release resources (HTTP clients, connections, threads, etc.)."""
+192 -2
View File
@@ -1,4 +1,7 @@
"""Cloud inference engine — OpenAI, Anthropic, Google, and MiniMax API backends."""
"""Cloud inference engine.
OpenAI, Anthropic, Google, MiniMax, and DeepSeek API backends.
"""
from __future__ import annotations
@@ -48,6 +51,8 @@ PRICING: Dict[str, tuple[float, float]] = {
"MiniMax-M2.7-highspeed": (0.60, 2.40),
"MiniMax-M2.5": (0.30, 1.20),
"MiniMax-M2.5-highspeed": (0.60, 2.40),
"deepseek-v4-flash": (0.27, 1.10),
"deepseek-v4-pro": (0.55, 2.19),
}
# Well-known model IDs per provider
@@ -83,6 +88,10 @@ _MINIMAX_MODELS = [
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
]
_DEEPSEEK_MODELS = [
"deepseek-v4-flash",
"deepseek-v4-pro",
]
# OpenRouter models — prefixed with "openrouter/" so they can be identified
_OPENROUTER_POPULAR = [
@@ -111,6 +120,10 @@ def _is_minimax_model(model: str) -> bool:
return model.lower().startswith("minimax")
def _is_deepseek_model(model: str) -> bool:
return model.lower().startswith("deepseek")
def _is_openrouter_model(model: str) -> bool:
return model.startswith("openrouter/")
@@ -127,6 +140,35 @@ def _is_google_model(model: str) -> bool:
return "gemini" in model.lower() and not _is_openrouter_model(model)
# Positive prefix predicate for genuine OpenAI models. Kept in sync with
# ``server/cloud_router.py:_OPENAI_PREFIXES`` so local-vs-cloud classification
# agrees across the codebase. Used by ``_client_for_model``/``can_serve`` so the
# cloud engine never claims it can serve an unrecognized (e.g. local Ollama)
# model name just because an OpenAI key happens to be present (see #335).
_OPENAI_PREFIXES = ("gpt-", "chatgpt-", "o1", "o3", "o4")
def _is_openai_model(model: str) -> bool:
"""True only for genuine OpenAI models (gpt-*, chatgpt-*, o1/o3/o4 series).
Defined positively so that an unrecognized model name (a local Ollama model
like ``qwen3.5:0.8b``, or a typo) is NOT treated as an OpenAI model. This is
the routing surface ``can_serve`` relies on; ``generate``/``stream`` keep
their OpenAI fall-through so an explicitly-requested unknown cloud model
still errors loudly at call time.
Caveat: a user may repoint the OpenAI client at an OpenAI-compatible server
(vLLM/LM Studio) via ``OPENAI_BASE_URL`` and legitimately serve non-gpt
names. That path is undocumented/untested in this engine; if it is added,
this predicate (or ``_client_for_model``) should treat a configured custom
base_url as "serves anything".
"""
m = model.lower()
if m in (name.lower() for name in _OPENAI_MODELS):
return True
return m.startswith(_OPENAI_PREFIXES)
def _is_openai_reasoning_model(model: str) -> bool:
"""Check if model is an OpenAI reasoning model that restricts temperature."""
m = model.lower()
@@ -269,7 +311,7 @@ def _convert_tools_to_google(
@EngineRegistry.register("cloud")
class CloudEngine(InferenceEngine):
"""Cloud inference via OpenAI, Anthropic, Google, and MiniMax SDKs."""
"""Cloud inference via OpenAI, Anthropic, Google, MiniMax, and DeepSeek SDKs."""
engine_id = "cloud"
is_cloud = True
@@ -280,6 +322,7 @@ class CloudEngine(InferenceEngine):
self._google_client: Any = None
self._openrouter_client: Any = None
self._minimax_client: Any = None
self._deepseek_client: Any = None
self._codex_client: Any = None
# Gemini thought_signatures: tool_call_id -> signature bytes
self._thought_sigs: Dict[str, bytes] = {}
@@ -332,6 +375,17 @@ class CloudEngine(InferenceEngine):
)
except ImportError:
pass
deepseek_key = os.environ.get("DEEPSEEK_API_KEY")
if deepseek_key:
try:
import openai
self._deepseek_client = openai.OpenAI(
base_url="https://api.deepseek.com/v1",
api_key=deepseek_key,
)
except ImportError:
pass
# Codex — uses the OpenAI Responses API.
# Supports both standard API keys (api.openai.com) and ChatGPT
# OAuth tokens (chatgpt.com) via OPENAI_CODEX_BASE_URL override.
@@ -985,6 +1039,56 @@ class CloudEngine(InferenceEngine):
]
return result
def _generate_deepseek(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float,
max_tokens: int,
**kwargs: Any,
) -> Dict[str, Any]:
if self._deepseek_client is None:
raise EngineConnectionError(
"DeepSeek client not available — set DEEPSEEK_API_KEY"
)
kwargs.pop("response_format", None)
create_kwargs: Dict[str, Any] = {
"model": model,
"messages": messages_to_dicts(messages),
"max_tokens": max_tokens,
"temperature": temperature,
}
t0 = time.monotonic()
resp = self._deepseek_client.chat.completions.create(**create_kwargs)
elapsed = time.monotonic() - t0
choice = resp.choices[0]
usage = resp.usage
prompt_tokens = usage.prompt_tokens if usage else 0
completion_tokens = usage.completion_tokens if usage else 0
result: Dict[str, Any] = {
"content": choice.message.content or "",
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": (usage.total_tokens if usage else 0),
},
"model": resp.model,
"finish_reason": choice.finish_reason or "stop",
"cost_usd": estimate_cost(model, prompt_tokens, completion_tokens),
"ttft": elapsed,
}
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
result["tool_calls"] = [
{
"id": tc.id,
"name": tc.function.name,
"arguments": tc.function.arguments,
}
for tc in choice.message.tool_calls
]
return result
def generate(
self,
messages: Sequence[Message],
@@ -1006,6 +1110,8 @@ class CloudEngine(InferenceEngine):
return self._generate_openrouter(messages, **kw)
if _is_minimax_model(model):
return self._generate_minimax(messages, **kw)
if _is_deepseek_model(model):
return self._generate_deepseek(messages, **kw)
if _is_anthropic_model(model):
return self._generate_anthropic(messages, **kw)
if _is_google_model(model):
@@ -1036,6 +1142,9 @@ class CloudEngine(InferenceEngine):
elif _is_minimax_model(model):
async for token in self._stream_minimax(messages, **kw):
yield token
elif _is_deepseek_model(model):
async for token in self._stream_deepseek(messages, **kw):
yield token
elif _is_anthropic_model(model):
async for token in self._stream_anthropic(messages, **kw):
yield token
@@ -1254,6 +1363,30 @@ class CloudEngine(InferenceEngine):
if delta and delta.content:
yield delta.content
async def _stream_deepseek(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float,
max_tokens: int,
**kwargs: Any,
) -> AsyncIterator[str]:
if self._deepseek_client is None:
raise EngineConnectionError("DeepSeek client not available")
create_kwargs: Dict[str, Any] = {
"model": model,
"messages": messages_to_dicts(messages),
"max_tokens": max_tokens,
"temperature": temperature,
"stream": True,
}
resp = self._deepseek_client.chat.completions.create(**create_kwargs)
for chunk in resp:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield delta.content
# -- stream_full: rich streaming with tool_calls support ----------------
async def _stream_full_openai(
@@ -1307,6 +1440,18 @@ class CloudEngine(InferenceEngine):
"stream": True,
**kwargs,
}
elif _is_deepseek_model(model):
client = self._deepseek_client
if client is None:
raise EngineConnectionError("DeepSeek client not available")
create_kwargs = {
"model": model,
"messages": messages_to_dicts(messages),
"max_tokens": max_tokens,
"temperature": temperature,
"stream": True,
**kwargs,
}
else:
client = self._openai_client
if client is None:
@@ -1473,10 +1618,54 @@ class CloudEngine(InferenceEngine):
models.extend(_OPENROUTER_POPULAR)
if self._minimax_client is not None:
models.extend(_MINIMAX_MODELS)
if self._deepseek_client is not None:
models.extend(_DEEPSEEK_MODELS)
if self._codex_client is not None:
models.extend(_CODEX_MODELS)
return models
def _client_for_model(self, model: str) -> Any:
"""Return the provider client ``generate``/``stream`` will dispatch to
for *model*, or ``None`` for a model this engine cannot route.
Mirrors the routing in ``generate``/``stream``, but is intentionally
*stricter* on the OpenAI fall-through: only genuine OpenAI models map to
the OpenAI client. Unrecognized names (e.g. a local Ollama model like
``qwen3.5:0.8b``) return ``None`` so ``can_serve`` declines them and the
cloud engine is not mis-selected as a fallback when the local engine is
transiently down and any (even dummy) ``OPENAI_API_KEY`` is set (#335).
``generate``/``stream`` keep their OpenAI fall-through, so an
explicitly-requested unknown cloud model still fails loudly at call time.
"""
if _is_codex_model(model):
return self._codex_client
if _is_openrouter_model(model):
return self._openrouter_client
if _is_minimax_model(model):
return self._minimax_client
if _is_deepseek_model(model):
return self._deepseek_client
if _is_anthropic_model(model):
return self._anthropic_client
if _is_google_model(model):
return self._google_client
if _is_openai_model(model):
return self._openai_client
return None
def can_serve(self, model: str) -> bool:
"""Return ``True`` only if the provider client for *model* exists.
``health()`` is ``True`` whenever *any* provider client is configured,
but a request for, say, a ``gpt-*`` model still needs the OpenAI
client specifically. Without this check the cloud engine gets picked
as a fallback (when the local engine is down) for a model it can't
serve, then dies at call time with "<provider> client not available"
instead of the user getting a helpful "start your local engine"
message (see #532).
"""
return self._client_for_model(model) is not None
def health(self) -> bool:
return (
self._openai_client is not None
@@ -1484,6 +1673,7 @@ class CloudEngine(InferenceEngine):
or self._google_client is not None
or self._openrouter_client is not None
or self._minimax_client is not None
or self._deepseek_client is not None
or self._codex_client is not None
)
+16 -3
View File
@@ -23,6 +23,19 @@ from openjarvis.engine._stubs import StreamChunk
logger = logging.getLogger(__name__)
def _default_num_ctx() -> int:
"""Default context window (tokens). Override with ``JARVIS_NUM_CTX``.
Raised above Ollama's 4k default so an image (which costs many tokens)
plus a real conversation fit. 16k is comfortable for small models on a
typical consumer GPU.
"""
try:
return int(os.environ.get("JARVIS_NUM_CTX", "16384"))
except ValueError:
return 16384
@EngineRegistry.register("ollama")
class OllamaEngine(InferenceEngine):
"""Ollama backend via its native HTTP API."""
@@ -73,7 +86,7 @@ class OllamaEngine(InferenceEngine):
"options": {
"temperature": temperature,
"num_predict": max_tokens,
"num_ctx": kwargs.get("num_ctx", 8192),
"num_ctx": kwargs.get("num_ctx", _default_num_ctx()),
},
}
# Disable extended thinking by default (Qwen3.5 etc.).
@@ -189,7 +202,7 @@ class OllamaEngine(InferenceEngine):
"options": {
"temperature": temperature,
"num_predict": max_tokens,
"num_ctx": kwargs.get("num_ctx", 8192),
"num_ctx": kwargs.get("num_ctx", _default_num_ctx()),
},
}
# Mirror generate()'s default: disable extended thinking unless the
@@ -268,7 +281,7 @@ class OllamaEngine(InferenceEngine):
"options": {
"temperature": temperature,
"num_predict": max_tokens,
"num_ctx": kwargs.get("num_ctx", 8192),
"num_ctx": kwargs.get("num_ctx", _default_num_ctx()),
},
}
if "think" not in kwargs:
+34 -1
View File
@@ -1,5 +1,7 @@
"""Data-driven registration of OpenAI-compatible inference engines."""
from __future__ import annotations
from openjarvis.core.registry import EngineRegistry
from openjarvis.engine._openai_compat import _OpenAICompatibleEngine
@@ -25,4 +27,35 @@ for _key, (_cls_name, _default_host, _api_prefix) in _ENGINES.items():
EngineRegistry.register(_key)(_cls)
globals()[_cls_name] = _cls
__all__ = [name for name, _, _ in _ENGINES.values()]
def normalize_openai_base_url(url: str) -> str:
"""Strip a single trailing ``/v1`` segment from a user-supplied base URL.
Users habitually pass ``http://host:8000/v1`` (the full OpenAI-compatible
prefix); the engine's ``_api_prefix`` re-appends ``/v1`` to every request
path, so a trailing copy would double up as ``/v1/v1``. Only a literal
trailing ``/v1`` is stripped proxy/gateway path prefixes are preserved.
"""
base = url.rstrip("/")
if base.endswith("/v1"):
base = base[: -len("/v1")]
return base
class OpenAICompatEngine(_OpenAICompatibleEngine):
"""Generic engine for an explicitly-provided OpenAI-compatible endpoint.
Deliberately NOT registered in ``EngineRegistry``: it is only ever
constructed with an explicit host (e.g. ``jarvis eval --base-url``), so
registering it would just add a useless localhost discovery probe and
interact with the per-test registry wipe.
"""
engine_id = "openai-compat"
_api_prefix = "/v1"
__all__ = [name for name, _, _ in _ENGINES.values()] + [
"OpenAICompatEngine",
"normalize_openai_base_url",
]
@@ -0,0 +1,52 @@
"""Shared helper for targeting an explicit OpenAI-compatible endpoint.
Used by the first-party eval backends (jarvis-direct, jarvis-agent) when
``--base-url`` is given: the eval must use exactly that endpoint, with no
silent fallback to whatever other engine discovery happens to find.
"""
from __future__ import annotations
import logging
from typing import Optional
logger = logging.getLogger(__name__)
def build_endpoint_engine(
base_url: str,
api_key: Optional[str] = None,
engine_key: Optional[str] = None,
):
"""Construct an :class:`OpenAICompatEngine` pinned to ``base_url``.
Pre-flight health-checks the endpoint and raises a loud, actionable
error when it is unreachable engine discovery is never consulted.
"""
from openjarvis.engine.openai_compat_engines import (
OpenAICompatEngine,
normalize_openai_base_url,
)
if engine_key:
logger.warning(
"Both an engine key (%r) and base_url (%r) were given; "
"base_url wins — targeting the endpoint directly.",
engine_key,
base_url,
)
host = normalize_openai_base_url(base_url)
engine = OpenAICompatEngine(host=host, api_key=api_key)
if not engine.health():
engine.close()
raise RuntimeError(
f"--base-url endpoint not reachable: {base_url} "
f"(GET {host}/v1/models failed). Is an OpenAI-compatible server "
"(e.g. `vllm serve`) running at that address? If it requires "
"authentication (HTTP 401), pass --api-key or set "
"JARVIS_BACKEND_API_KEY."
)
return engine
__all__ = ["build_endpoint_engine"]
+13 -1
View File
@@ -31,6 +31,8 @@ class JarvisAgentBackend(InferenceBackend):
max_turns: Optional[int] = None,
skills_enabled: bool = True,
overlay_dir: Optional[Path] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> None:
from openjarvis.system import SystemBuilder
@@ -40,7 +42,17 @@ class JarvisAgentBackend(InferenceBackend):
self._gpu_metrics = gpu_metrics
builder = SystemBuilder()
if engine_key:
if base_url:
# Explicit endpoint targeting (--base-url): pin the eval to
# exactly this OpenAI-compatible endpoint. Fails fast if it is
# unreachable; never falls back to a discovered engine.
from openjarvis.evals.backends._endpoint_util import (
build_endpoint_engine,
)
engine = build_endpoint_engine(base_url, api_key, engine_key)
builder.engine_instance(engine, key=engine_key or "openai-compat")
elif engine_key:
builder.engine(engine_key)
if model:
builder.model(model)
+13 -1
View File
@@ -24,6 +24,8 @@ class JarvisDirectBackend(InferenceBackend):
engine_key: Optional[str] = None,
telemetry: bool = False,
gpu_metrics: bool = False,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> None:
from openjarvis.system import SystemBuilder
@@ -31,7 +33,17 @@ class JarvisDirectBackend(InferenceBackend):
self._gpu_metrics = gpu_metrics
builder = SystemBuilder()
if engine_key:
if base_url:
# Explicit endpoint targeting (--base-url): pin the eval to
# exactly this OpenAI-compatible endpoint. Fails fast if it is
# unreachable; never falls back to a discovered engine.
from openjarvis.evals.backends._endpoint_util import (
build_endpoint_engine,
)
engine = build_endpoint_engine(base_url, api_key, engine_key)
builder.engine_instance(engine, key=engine_key or "openai-compat")
elif engine_key:
builder.engine(engine_key)
# Propagate gpu_metrics to the runtime config so SystemBuilder
# creates an EnergyMonitor / GpuMonitor for the InstrumentedEngine.
@@ -5,11 +5,13 @@ Uses Harness for Docker-based execution and scoring.
from __future__ import annotations
import inspect
import logging
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional, Tuple
from openjarvis.evals.core.backend import InferenceBackend
from openjarvis.evals.core.types import RunSummary
LOGGER = logging.getLogger(__name__)
@@ -20,6 +22,97 @@ try:
except ImportError:
_HAS_TB = False
# terminal-bench FailureMode values that are definitionally infrastructure
# failures (the harness broke before/while driving the agent), never a
# judgment on the model's answer. NOTE: clean trials leave failure_mode
# "unset" in terminal-bench 0.2.18 — both on success AND on genuine
# unresolved misses — so failure_mode alone can NOT be used to detect
# harness errors (it would misflag every real model miss).
_INFRA_FAILURE_MODES = frozenset({"agent_installation_failed", "unknown_agent_error"})
# Harness kwargs that older terminal-bench versions may not support.
_TIMEOUT_KWARGS = ("global_agent_timeout_sec", "global_timeout_multiplier")
def summarize_benchmark_results(
results: Any,
*,
model: str,
benchmark: str = "terminalbench-native",
) -> Tuple[RunSummary, List[Dict[str, str]]]:
"""Convert terminal-bench ``BenchmarkResults`` into a ``RunSummary``.
Trials are classified into three buckets:
- resolved: ``is_resolved`` is True -> counted correct.
- model miss: unresolved, but the model was actually contacted ->
counted in the accuracy denominator.
- harness/infra failure: excluded from the accuracy denominator and
reported in ``RunSummary.errors`` plus the returned failure list.
Zero-model-contact signal choice: terminal-bench 0.2.18 leaves
``failure_mode`` UNSET both on clean success and on genuine unresolved
misses, so failure_mode cannot distinguish "the model tried and failed"
from "the agent never called the model". Token usage can: this backend
always runs terminus-2, which reports real LiteLLM usage, so an
unresolved trial with zero/missing input+output tokens means no model
request ever completed an infrastructure failure (in-container setup
hang/death, tmux failure), not a model miss. CAVEAT: terminal-bench
"installed agents" (openhands, claude-code, ...) hardcode 0 tokens even
on success; if this backend ever honors ``agent_name`` for installed
agents, this heuristic must be gated on the agent type.
"""
trials = list(getattr(results, "results", None) or [])
harness_failures: List[Dict[str, str]] = []
scored = 0
correct = 0
for tr in trials:
task_id = getattr(tr, "task_id", None) or getattr(tr, "trial_name", "unknown")
is_resolved = getattr(tr, "is_resolved", None) is True
fm = getattr(tr, "failure_mode", None)
fm_value = str(getattr(fm, "value", fm) or "unset").lower()
tokens = (getattr(tr, "total_input_tokens", None) or 0) + (
getattr(tr, "total_output_tokens", None) or 0
)
zero_model_contact = not is_resolved and tokens == 0
infra_failure_mode = fm_value in _INFRA_FAILURE_MODES
if zero_model_contact or infra_failure_mode:
harness_failures.append(
{
"task_id": str(task_id),
"failure_mode": fm_value,
"reason": (
"zero_model_requests" if zero_model_contact else fm_value
),
}
)
continue
scored += 1
if is_resolved:
correct += 1
return (
RunSummary(
benchmark=benchmark,
category="agentic",
backend="terminalbench-native",
model=model,
total_samples=len(trials),
scored_samples=scored,
correct=correct,
accuracy=correct / scored if scored else 0.0,
errors=len(harness_failures),
mean_latency_seconds=0.0,
total_cost_usd=0.0,
),
harness_failures,
)
class TerminalBenchNativeBackend(InferenceBackend):
"""Runs terminal-bench tasks natively via Harness with Docker execution.
@@ -44,7 +137,22 @@ class TerminalBenchNativeBackend(InferenceBackend):
system_prompt: str = "",
max_tokens: int = 16384,
n_concurrent: int = 4,
global_agent_timeout_sec: Optional[float] = 1800.0,
global_timeout_multiplier: Optional[float] = None,
) -> None:
"""Args of note:
global_agent_timeout_sec: Hard wall-clock bound for each trial's
agent phase. terminal-bench runs installed-agent SETUP inside
this same budget with an infinite tmux timeout, so this bounds
SETUP+RUN together (a setup-only timeout needs an upstream
terminal-bench change). When set, it REPLACES each task's own
``max_agent_timeout_sec``. Set ``None`` or ``0`` to fall back
to per-task budgets.
global_timeout_multiplier: Scales per-task budgets when
``global_agent_timeout_sec`` is not set. ``None`` keeps
terminal-bench's default (1.0).
"""
if not _HAS_TB:
raise ImportError("terminal-bench is required: pip install terminal-bench")
@@ -59,6 +167,8 @@ class TerminalBenchNativeBackend(InferenceBackend):
self._system_prompt = system_prompt
self._max_tokens = max_tokens
self._n_concurrent = n_concurrent
self._global_agent_timeout_sec = global_agent_timeout_sec
self._global_timeout_multiplier = global_timeout_multiplier
self._results: Optional[BenchmarkResults] = None
def run_harness(self, run_id: str) -> BenchmarkResults:
@@ -91,10 +201,53 @@ class TerminalBenchNativeBackend(InferenceBackend):
if self._max_samples is not None:
harness_kwargs["n_tasks"] = self._max_samples
# Bound each trial's agent phase. Without this, an in-container
# installed-agent SETUP hang runs with an infinite tmux timeout,
# bounded only by whatever budget the task happens to declare.
if self._global_agent_timeout_sec:
harness_kwargs["global_agent_timeout_sec"] = float(
self._global_agent_timeout_sec
)
if self._global_timeout_multiplier is not None:
harness_kwargs["global_timeout_multiplier"] = float(
self._global_timeout_multiplier
)
self._check_timeout_kwargs_supported(harness_kwargs)
harness = Harness(**harness_kwargs)
self._results = harness.run()
return self._results
@staticmethod
def _check_timeout_kwargs_supported(harness_kwargs: Dict[str, Any]) -> None:
"""Fail loudly if this terminal-bench build lacks the timeout kwargs.
terminal-bench is an undeclared, unpinned dependency, so installs may
predate the global timeout kwargs (added by 0.2.x). Passing an
unknown kwarg raises an opaque TypeError; dropping it silently would
re-create the unbounded-setup hang. Detect and explain instead.
"""
try:
params = inspect.signature(Harness.__init__).parameters
except (TypeError, ValueError):
return
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
return
unsupported = [
key
for key in _TIMEOUT_KWARGS
if key in harness_kwargs and key not in params
]
if unsupported:
raise RuntimeError(
"The installed terminal-bench does not support "
f"{', '.join(unsupported)} (requires terminal-bench >= "
"0.2.18). Upgrade terminal-bench, or disable the bound by "
"setting global_agent_timeout_sec = 0 in the eval config "
"[run] section."
)
def generate(
self,
prompt: str,
@@ -121,4 +274,4 @@ class TerminalBenchNativeBackend(InferenceBackend):
pass
__all__ = ["TerminalBenchNativeBackend"]
__all__ = ["TerminalBenchNativeBackend", "summarize_benchmark_results"]
+155 -55
View File
@@ -183,14 +183,28 @@ def _build_backend(
max_turns: Optional[int] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
first_party_endpoint: bool = True,
):
"""Construct the appropriate backend.
For "hermes" and "openclaw" backends, ``base_url`` and ``api_key`` are
REQUIRED these foreign frameworks need an OpenAI-compatible endpoint
to send model calls to. Pass them via the eval config's
``[backend.external]`` section or env vars.
``base_url``/``api_key`` point at the OpenAI-compatible endpoint serving
the model under eval:
- For "hermes" and "openclaw" they are REQUIRED these foreign
frameworks always call out to an external endpoint.
- "jarvis-direct" and "jarvis-agent" honor them when
``first_party_endpoint`` is True (the CLI ``--base-url`` path): the
eval targets exactly that endpoint no engine-discovery fallback
and fails fast if it is unreachable. Suite mode passes
``first_party_endpoint=False`` so the suite TOML's
``[backend.external]`` section stays scoped to hermes/openclaw
(extending it to first-party backends is explicitly deferred).
"""
if not first_party_endpoint:
fp_base_url = fp_api_key = None
else:
fp_base_url, fp_api_key = base_url, api_key
if backend_name == "jarvis-agent":
from openjarvis.evals.backends.jarvis_agent import JarvisAgentBackend
@@ -202,6 +216,8 @@ def _build_backend(
gpu_metrics=gpu_metrics,
model=model,
max_turns=max_turns,
base_url=fp_base_url,
api_key=fp_api_key,
)
elif backend_name == "jarvis-direct":
from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend
@@ -210,6 +226,8 @@ def _build_backend(
engine_key=engine_key,
telemetry=telemetry,
gpu_metrics=gpu_metrics,
base_url=fp_base_url,
api_key=fp_api_key,
)
elif backend_name == "hermes":
from openjarvis.evals.backends.external import HermesBackend
@@ -656,25 +674,53 @@ def _build_trackers(config) -> list:
return trackers
def _run_terminalbench_native(config, console: Console) -> object:
"""Run TerminalBench V2.1 natively via terminal-bench Harness."""
def _run_terminalbench_native(
config,
console: Console,
*,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> object:
"""Run TerminalBench V2.1 natively via terminal-bench Harness.
``base_url`` (from ``--base-url`` / JARVIS_BACKEND_BASE_URL) targets an
already-running OpenAI-compatible endpoint; when unset, the legacy local
vLLM default (http://localhost:8000/v1) is used.
"""
from openjarvis.engine.openai_compat_engines import normalize_openai_base_url
from openjarvis.evals.backends.terminalbench_native import (
TerminalBenchNativeBackend,
summarize_benchmark_results,
)
from openjarvis.evals.core.types import RunSummary
model = config.model
# LiteLLM expects "openai/<model>" for OpenAI-compatible servers
litellm_model = f"openai/{model}"
output_dir = getattr(config, "output_path", None) or "results/terminalbench-native/"
# Harness budgets: only forward explicit config values so the backend
# defaults (global_agent_timeout_sec=1800) apply otherwise.
timeout_kwargs = {}
if getattr(config, "global_agent_timeout_sec", None) is not None:
timeout_kwargs["global_agent_timeout_sec"] = config.global_agent_timeout_sec
if getattr(config, "global_timeout_multiplier", None) is not None:
timeout_kwargs["global_timeout_multiplier"] = config.global_timeout_multiplier
# Normalize to exactly one trailing "/v1" — LiteLLM's api_base wants the
# full OpenAI-compatible prefix, and users pass both forms of the URL.
if base_url:
api_base = normalize_openai_base_url(base_url) + "/v1"
else:
api_base = "http://localhost:8000/v1"
backend = TerminalBenchNativeBackend(
model=litellm_model,
api_base="http://localhost:8000/v1",
api_base=api_base,
temperature=config.temperature,
max_samples=config.max_samples,
output_dir=output_dir,
n_concurrent=config.max_workers or 4,
**timeout_kwargs,
)
import re
@@ -683,46 +729,83 @@ def _run_terminalbench_native(config, console: Console) -> object:
model_slug = re.sub(r"[^a-z0-9_-]", "-", model.lower().replace("/", "-"))
run_id = f"tb21-{model_slug}"
console.print(f" Running TerminalBench V2.1 natively: {model}")
console.print(f" API base: {api_base}")
console.print(f" Harness run_id: {run_id}")
results = backend.run_harness(run_id)
if api_key:
# terminus-2 routes model calls through LiteLLM with the "openai/"
# prefix, which reads OPENAI_API_KEY from the environment. The
# harness runs in-process, so set the var for the duration of the
# run and restore the previous value afterwards.
prev_key = os.environ.get("OPENAI_API_KEY")
os.environ["OPENAI_API_KEY"] = api_key
try:
results = backend.run_harness(run_id)
finally:
if prev_key is None:
os.environ.pop("OPENAI_API_KEY", None)
else:
os.environ["OPENAI_API_KEY"] = prev_key
else:
results = backend.run_harness(run_id)
# Convert BenchmarkResults to RunSummary
total = len(results.trial_results) if hasattr(results, "trial_results") else 0
correct = 0
if hasattr(results, "trial_results"):
for tr in results.trial_results:
if getattr(tr, "is_resolved", False):
correct += 1
accuracy = correct / total if total > 0 else 0.0
return RunSummary(
benchmark="terminalbench-native",
category="agentic",
backend="terminalbench-native",
model=model,
total_samples=total,
scored_samples=total,
correct=correct,
accuracy=accuracy,
errors=0,
mean_latency_seconds=0.0,
total_cost_usd=0.0,
)
# Convert BenchmarkResults to RunSummary, classifying harness/infra
# failures (e.g. zero-model-contact setup hangs) out of the resolve-rate.
summary, harness_failures = summarize_benchmark_results(results, model=model)
if harness_failures:
console.print(
f" [red bold]{len(harness_failures)} harness/infra failure(s) "
"excluded from resolve-rate:[/red bold]"
)
for failure in harness_failures:
console.print(
f" [red]- {failure['task_id']}: {failure['reason']} "
f"(failure_mode={failure['failure_mode']})[/red]"
)
return summary
def _run_single(config, console: Optional[Console] = None) -> object:
"""Run a single eval from a RunConfig and return the summary."""
def _run_single(
config,
console: Optional[Console] = None,
*,
suite_mode: bool = False,
) -> object:
"""Run a single eval from a RunConfig and return the summary.
``suite_mode=True`` (TOML-suite drivers) scopes ``config.base_url`` /
``config.api_key`` stamped from the suite's ``[backend.external]``
section onto every RunConfig to the hermes/openclaw backends only;
extending suite-level endpoint targeting to first-party backends is
explicitly deferred. The CLI single-run path (``suite_mode=False``)
honors ``--base-url``/``--api-key`` for every backend.
"""
from openjarvis.evals.core.runner import EvalRunner
if console is None:
console = Console()
_metadata = getattr(config, "metadata", None) or {}
base_url = (
getattr(config, "base_url", None)
or _metadata.get("base_url")
or os.environ.get("JARVIS_BACKEND_BASE_URL")
)
api_key = (
getattr(config, "api_key", None)
or _metadata.get("api_key")
or os.environ.get("JARVIS_BACKEND_API_KEY")
)
# TerminalBench V2.1 native: use terminal-bench Harness directly
if config.benchmark == "terminalbench-native":
return _run_terminalbench_native(config, console)
return _run_terminalbench_native(
config,
console,
base_url=None if suite_mode else base_url,
api_key=None if suite_mode else api_key,
)
_metadata = getattr(config, "metadata", None) or {}
eval_backend = _build_backend(
config.backend,
config.engine_key,
@@ -732,16 +815,9 @@ def _run_single(config, console: Optional[Console] = None) -> object:
gpu_metrics=getattr(config, "gpu_metrics", False),
model=config.model,
max_turns=getattr(config, "max_turns", None),
base_url=(
getattr(config, "base_url", None)
or _metadata.get("base_url")
or os.environ.get("JARVIS_BACKEND_BASE_URL")
),
api_key=(
getattr(config, "api_key", None)
or _metadata.get("api_key")
or os.environ.get("JARVIS_BACKEND_API_KEY")
),
base_url=base_url,
api_key=api_key,
first_party_endpoint=not suite_mode,
)
dataset = _build_dataset(config.benchmark)
# Inject engine config for benchmarks that run their own simulation
@@ -964,7 +1040,9 @@ def _print_agentic_summary(console: Console, traces, config) -> None:
from rich.table import Table
completed = sum(1 for t in traces if t.completed)
resolved = sum(1 for t in traces if t.is_resolved is True)
harness_errors = [t for t in traces if t.error_kind == "harness_error"]
model_traces = [t for t in traces if t.error_kind != "harness_error"]
resolved = sum(1 for t in model_traces if t.is_resolved is True)
timed_out = sum(1 for t in traces if t.timed_out)
total_turns = sum(t.num_turns for t in traces)
total_tool_calls = sum(t.total_tool_calls for t in traces)
@@ -992,8 +1070,11 @@ def _print_agentic_summary(console: Console, traces, config) -> None:
table.add_row("Queries", str(len(traces)))
table.add_row("Completed", f"{completed}/{len(traces)}")
if any(t.is_resolved is not None for t in traces):
table.add_row("Resolved", f"{resolved}/{len(traces)}")
# Harness errors are excluded from the resolve-rate denominator:
# they are infra failures, not model misses.
table.add_row("Resolved", f"{resolved}/{len(model_traces)}")
table.add_row("Timed out", str(timed_out))
table.add_row("Harness errors", str(len(harness_errors)))
table.add_row("Total turns", str(total_turns))
avg_t = f"{total_turns / len(traces):.1f}" if traces else "0"
table.add_row("Avg turns/query", avg_t)
@@ -1020,6 +1101,19 @@ def _print_agentic_summary(console: Console, traces, config) -> None:
console.print(table)
if harness_errors:
console.print(
f"[red bold]{len(harness_errors)} harness/infra failure(s) "
"excluded from resolve-rate:[/red bold]"
)
for t in harness_errors[:5]:
console.print(f"[red] {t.query_id}: {(t.error or '')[:300]}[/red]")
if len(harness_errors) > 5:
console.print(
f"[red] ... and {len(harness_errors) - 5} more "
"(see traces.jsonl)[/red]"
)
def _run_from_config(
config_path: str,
@@ -1070,7 +1164,7 @@ def _run_from_config(
f"Run {i}/{len(run_configs)}: {rc.benchmark} / {rc.model}",
)
try:
summary = _run_single(rc, console=console)
summary = _run_single(rc, console=console, suite_mode=True)
summaries.append(summary)
console.print(
f" [green]{summary.accuracy:.4f}[/green] "
@@ -1115,12 +1209,21 @@ def main():
@click.option(
"--base-url",
default=None,
help="OpenAI-compat endpoint for hermes/openclaw",
help=(
"OpenAI-compatible endpoint for the model under eval. Required for "
"hermes/openclaw; for jarvis-direct/jarvis-agent/terminalbench-native "
"it bypasses engine discovery and targets this URL directly "
"(env: JARVIS_BACKEND_BASE_URL)."
),
)
@click.option(
"--api-key",
default=None,
help="API key for hermes/openclaw endpoint",
help=(
"API key for the --base-url endpoint, sent as a Bearer token. "
"Required for hermes/openclaw; optional for first-party backends "
"(env: JARVIS_BACKEND_API_KEY)."
),
)
@click.option("-m", "--model", default=None, help="Model identifier")
@click.option(
@@ -1526,8 +1629,7 @@ def summarize(jsonl_path):
default=None,
type=click.Path(),
help=(
"Output JSONL path. Defaults to <jsonl>.reparsed when "
"--in-place is not set."
"Output JSONL path. Defaults to <jsonl>.reparsed when --in-place is not set."
),
)
@click.option(
@@ -1642,9 +1744,7 @@ def reparse_judge(jsonl_path, out_path, in_place, summary_out):
_json.dump(summary, f, indent=2)
old_cont = [float(s) for s in old_scores if s is not None]
old_acc = (
sum(1 for s in old_cont if s >= 0.5) / len(old_cont) if old_cont else 0.0
)
old_acc = sum(1 for s in old_cont if s >= 0.5) / len(old_cont) if old_cont else 0.0
old_mean = sum(old_cont) / len(old_cont) if old_cont else 0.0
new_mean = sum(cont) / len(cont) if cont else 0.0
mean_shift = new_mean - old_mean
+76 -9
View File
@@ -20,6 +20,7 @@ from contextlib import nullcontext
from pathlib import Path
from typing import Any, Callable, Optional
from openjarvis.evals.core.environment import TaskEnvironmentError
from openjarvis.evals.core.event_recorder import AgentEvent, EventRecorder, EventType
from openjarvis.evals.core.trace import QueryTrace, TurnTrace
@@ -176,9 +177,12 @@ class AgenticRunner:
)
self._traces.append(trace)
status = (
"TIMEOUT" if trace.timed_out else ("OK" if trace.completed else "FAIL")
)
if trace.timed_out:
status = "TIMEOUT"
elif trace.error_kind == "harness_error":
status = "HARNESS_ERROR"
else:
status = "OK" if trace.completed else "FAIL"
LOGGER.info(
"Task %s: %s in %.1fs",
query_id,
@@ -260,11 +264,12 @@ class AgenticRunner:
is_resolved=record.metadata.get("is_resolved"),
)
status = (
"TIMEOUT"
if trace.timed_out
else ("OK" if trace.completed else "FAIL")
)
if trace.timed_out:
status = "TIMEOUT"
elif trace.error_kind == "harness_error":
status = "HARNESS_ERROR"
else:
status = "OK" if trace.completed else "FAIL"
LOGGER.info(
"Task %s: %s in %.1fs",
query_id,
@@ -444,7 +449,23 @@ class AgenticRunner:
_run_body()
except Exception as exc:
LOGGER.warning("Agent failed on query %s: %s", query_id, exc)
# Distinguish infrastructure breakage (task env failed to start,
# Docker/tmux death) from agent failures: harness errors must be
# recorded distinctly so scoring excludes them from resolve-rate
# instead of silently counting a model miss. Either way the run
# continues with the next record (fail THIS task, not the run).
is_harness_error = isinstance(exc, TaskEnvironmentError) or bool(
record.metadata.get("harness_error")
)
if is_harness_error:
LOGGER.error(
"Harness/environment failure on query %s (record %s): %s",
query_id,
getattr(record, "record_id", "?"),
exc,
)
else:
LOGGER.warning("Agent failed on query %s: %s", query_id, exc)
end_time = time.time()
# Unsubscribe EventBus relays
if agent_bus is not None:
@@ -461,6 +482,8 @@ class AgenticRunner:
total_wall_clock_s=end_time - start_time,
completed=False,
is_resolved=record.metadata.get("is_resolved"),
error=str(exc),
error_kind="harness_error" if is_harness_error else "agent_error",
)
# Unsubscribe EventBus relays
@@ -534,6 +557,48 @@ class AgenticRunner:
model, turn.input_tokens, turn.output_tokens
)
# --- Zero-model-contact sanity check ----------------------------
# Failed in-container setups (e.g. an installed agent's SETUP phase
# hanging or dying) historically produced traces that looked like
# model results: completed=True, a synthetic 1-event turn,
# is_resolved=False from run_tests, and zero tokens — silently
# dragging resolve-rate down as a fake model miss. Signal choice:
# token usage plus LM inference events is the reliable discriminator
# here — a genuine model miss has token usage (and/or LM events),
# while "the agent never called the model" has neither. We do NOT
# key off completion status or is_resolved, which are identical in
# both cases. run_agent_loop envs drive the model directly
# (bypassing usage reporting), so their turn_wall_clocks count as
# model contact.
had_lm_events = any(
e.event_type in (EventType.LM_INFERENCE_START, EventType.LM_INFERENCE_END)
for e in events
)
had_loop_turns = bool(
task_env is not None and getattr(task_env, "turn_wall_clocks", None)
)
turn_tokens = sum(t.input_tokens + t.output_tokens for t in turns)
error: Optional[str] = None
error_kind: Optional[str] = None
if (
not had_lm_events
and not had_loop_turns
and in_tok + out_tok == 0
and turn_tokens == 0
):
error = (
"zero_model_requests: the agent produced no LM inference "
"events and reported zero token usage — the model was never "
"contacted. This is a harness/infrastructure failure (e.g. "
"in-container agent setup hang/death), not a model miss, and "
"is excluded from resolve-rate. If your agent genuinely "
"contacted the model, make it report token usage or emit "
"LM_INFERENCE events. Response tail: "
f"{(response_text or '')[-500:]!r}"
)
error_kind = "harness_error"
LOGGER.error("Query %s: %s", query_id, error)
# Query-level energy from telemetry window
query_gpu_energy = _compute_energy_delta(readings, "gpu_energy_j")
query_cpu_energy = _compute_energy_delta(readings, "cpu_energy_j")
@@ -563,6 +628,8 @@ class AgenticRunner:
is_resolved=record.metadata.get("is_resolved"),
query_mbu_avg_pct=query_mbu_avg,
query_mbu_max_pct=query_mbu_max,
error=error,
error_kind=error_kind,
)
# Correlate energy with trace
+30
View File
@@ -143,6 +143,16 @@ def load_eval_config(path: str | Path) -> EvalSuiteConfig:
sheets_worksheet=run_raw.get("sheets_worksheet", "Results"),
sheets_credentials_path=run_raw.get("sheets_credentials_path", ""),
max_turns=(int(run_raw["max_turns"]) if "max_turns" in run_raw else None),
global_agent_timeout_sec=(
float(run_raw["global_agent_timeout_sec"])
if "global_agent_timeout_sec" in run_raw
else None
),
global_timeout_multiplier=(
float(run_raw["global_timeout_multiplier"])
if "global_timeout_multiplier" in run_raw
else None
),
)
# Parse [[models]]
@@ -212,6 +222,16 @@ def load_eval_config(path: str | Path) -> EvalSuiteConfig:
max_tokens=int(b["max_tokens"]) if "max_tokens" in b else None,
subset=b.get("subset"),
record_ids=record_ids,
global_agent_timeout_sec=(
float(b["global_agent_timeout_sec"])
if "global_agent_timeout_sec" in b
else None
),
global_timeout_multiplier=(
float(b["global_timeout_multiplier"])
if "global_timeout_multiplier" in b
else None
),
)
)
@@ -275,6 +295,14 @@ def expand_suite(suite: EvalSuiteConfig) -> List[RunConfig]:
if bench.judge_model is not None:
judge_model = bench.judge_model
# terminal-bench harness budgets: benchmark > [run]
global_agent_timeout_sec = suite.run.global_agent_timeout_sec
if bench.global_agent_timeout_sec is not None:
global_agent_timeout_sec = bench.global_agent_timeout_sec
global_timeout_multiplier = suite.run.global_timeout_multiplier
if bench.global_timeout_multiplier is not None:
global_timeout_multiplier = bench.global_timeout_multiplier
# Auto-generate output path
model_slug = model.name.replace("/", "-").replace(":", "-")
output_path = f"{output_dir}/{bench.name}_{model_slug}.jsonl"
@@ -328,6 +356,8 @@ def expand_suite(suite: EvalSuiteConfig) -> List[RunConfig]:
base_url=suite.backend_external_base_url,
api_key=suite.backend_external_api_key,
record_ids=bench.record_ids,
global_agent_timeout_sec=global_agent_timeout_sec,
global_timeout_multiplier=global_timeout_multiplier,
)
)
+15
View File
@@ -8,6 +8,18 @@ from typing import Any, Dict, Tuple
from openjarvis.evals.core.types import EvalRecord
class TaskEnvironmentError(RuntimeError):
"""A task execution environment failed to start or operate.
Raised when infrastructure backing a task (Docker container, docker
compose project, tmux session, recording binaries, ...) breaks. This is
a harness/environment failure, **not** a model failure: runners record
it distinctly (``QueryTrace.error_kind == "harness_error"``) so scoring
can exclude the sample from resolve-rate instead of silently counting
it as a model miss.
"""
class EnvironmentProvider(ABC):
"""Manages an external environment for evaluation benchmarks.
@@ -50,3 +62,6 @@ class EnvironmentProvider(ABC):
@abstractmethod
def teardown(self) -> None:
"""Stop the environment and release resources."""
__all__ = ["EnvironmentProvider", "TaskEnvironmentError"]
+24 -4
View File
@@ -26,13 +26,22 @@ def _agg_stats(values: Sequence[Optional[float]]) -> dict[str, Optional[float]]:
}
def _model_attributable(traces: list[QueryTrace]) -> list[QueryTrace]:
"""Traces whose outcome is attributable to the model.
Harness errors (infra/setup failures, zero-model-contact runs) are
excluded so they never count as model misses in resolve-rate.
"""
return [t for t in traces if t.error_kind != "harness_error"]
def _compute_efficiency(
traces: list[QueryTrace],
total_gpu_energy: Optional[float],
total_cpu_energy: Optional[float],
) -> dict[str, Optional[float]]:
"""Compute efficiency metrics from traces and aggregate energy."""
scored = [t for t in traces if t.is_resolved is not None]
scored = [t for t in _model_attributable(traces) if t.is_resolved is not None]
resolved = sum(1 for t in scored if t.is_resolved is True)
accuracy = resolved / len(scored) if scored else None
gpu_powers = [
@@ -247,8 +256,12 @@ def export_summary_json(
cpu_energy_values.append(sum(cpu_vals))
total_cpu_energy = sum(cpu_energy_values) if cpu_energy_values else None
resolved = sum(1 for t in traces if t.is_resolved is True)
unresolved = sum(1 for t in traces if t.is_resolved is False)
# Harness errors (infra failures, zero-model-contact runs) are excluded
# from the resolve-rate denominator: they are not model misses.
harness_error_traces = [t for t in traces if t.error_kind == "harness_error"]
model_traces = _model_attributable(traces)
resolved = sum(1 for t in model_traces if t.is_resolved is True)
unresolved = sum(1 for t in model_traces if t.is_resolved is False)
cost_values = [t.total_cost_usd for t in traces if t.total_cost_usd is not None]
total_cost = sum(cost_values) if cost_values else None
@@ -345,6 +358,7 @@ def export_summary_json(
"completed": completed,
"resolved": resolved,
"unresolved": unresolved,
"harness_errors": len(harness_error_traces),
"accuracy": accuracy,
"turns": total_turns,
"tool_calls": total_tool_calls,
@@ -365,6 +379,12 @@ def export_summary_json(
"efficiency": efficiency,
}
if harness_error_traces:
summary["harness_error_details"] = [
{"query_id": t.query_id, "error": (t.error or "")[:500]}
for t in harness_error_traces
]
if action_totals:
summary["action_energy_summary"] = action_totals
@@ -399,7 +419,7 @@ def export_summary_json(
accuracy_vals: list[float] = [
1.0 if t.is_resolved is True else 0.0
for t in traces
for t in _model_attributable(traces)
if t.is_resolved is not None
]
latency_vals = [t.total_wall_clock_s for t in traces if t.total_wall_clock_s > 0]
+11
View File
@@ -91,6 +91,13 @@ class QueryTrace:
is_resolved: Optional[bool] = None
query_mbu_avg_pct: Optional[float] = None
query_mbu_max_pct: Optional[float] = None
# Error taxonomy. ``error_kind`` distinguishes infrastructure failures
# ("harness_error": task env / Docker / tmux broke, or the agent never
# contacted the model) from agent failures ("agent_error"). Harness
# errors are excluded from resolve-rate by export/summary code so they
# are never silently counted as model misses.
error: Optional[str] = None
error_kind: Optional[str] = None
@property
def num_turns(self) -> int:
@@ -196,6 +203,8 @@ class QueryTrace:
"is_resolved": self.is_resolved,
"query_mbu_avg_pct": self.query_mbu_avg_pct,
"query_mbu_max_pct": self.query_mbu_max_pct,
"error": self.error,
"error_kind": self.error_kind,
}
@classmethod
@@ -216,6 +225,8 @@ class QueryTrace:
is_resolved=d.get("is_resolved"),
query_mbu_avg_pct=d.get("query_mbu_avg_pct"),
query_mbu_max_pct=d.get("query_mbu_max_pct"),
error=d.get("error"),
error_kind=d.get("error_kind"),
)
def save_jsonl(self, path: Path) -> None:
+16
View File
@@ -102,6 +102,15 @@ class RunConfig:
# specific records (e.g. recovering silent-fake records without
# re-running the entire benchmark).
record_ids: Optional[List[str]] = None
# terminal-bench harness budgets (terminalbench-native backend).
# global_agent_timeout_sec bounds each trial's agent phase — SETUP+RUN
# together, since terminal-bench runs installed-agent setup inside the
# agent budget with an infinite tmux timeout. When set it REPLACES the
# per-task max_agent_timeout_sec; 0 disables the bound (per-task budgets
# apply); None uses the backend default (1800 s).
global_agent_timeout_sec: Optional[float] = None
# Scales per-task budgets when global_agent_timeout_sec is not set.
global_timeout_multiplier: Optional[float] = None
@dataclass(slots=True)
@@ -232,6 +241,9 @@ class ExecutionConfig:
# to JarvisConfig.agent.max_turns (default 10). Bump to 30-50 for
# thinking/reasoning models on agentic benchmarks (GAIA, LiveResearch).
max_turns: Optional[int] = None
# terminal-bench harness budgets (see RunConfig for semantics).
global_agent_timeout_sec: Optional[float] = None
global_timeout_multiplier: Optional[float] = None
@dataclass(slots=True)
@@ -265,6 +277,10 @@ class BenchmarkConfig:
max_tokens: Optional[int] = None
subset: Optional[str] = None
record_ids: Optional[List[str]] = None
# Per-benchmark override of the terminal-bench harness budgets
# (see RunConfig for semantics).
global_agent_timeout_sec: Optional[float] = None
global_timeout_multiplier: Optional[float] = None
@dataclass(slots=True)
+2 -1
View File
@@ -11,11 +11,12 @@ import shutil
from pathlib import Path
from typing import Iterable, List, MutableMapping, Optional, Sequence
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.splits import apply_split
from openjarvis.evals.core.types import EvalRecord
_DEFAULT_CACHE_DIR = Path.home() / ".cache" / "gaia_benchmark"
_DEFAULT_CACHE_DIR = get_cache_dir() / "gaia_benchmark"
_DEFAULT_INPUT_PROMPT = """Please answer the question below. You should:
@@ -17,6 +17,7 @@ import subprocess
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.splits import apply_split
from openjarvis.evals.core.types import EvalRecord
@@ -24,7 +25,7 @@ from openjarvis.evals.core.types import EvalRecord
LOGGER = logging.getLogger(__name__)
LIVERESEARCH_REPO = "https://github.com/Ayanami0730/deep_research_bench.git"
CACHE_DIR = Path.home() / ".cache" / "liveresearch_bench"
CACHE_DIR = get_cache_dir() / "liveresearch_bench"
def _load_jsonl(path: Path) -> List[Dict[str, Any]]:
+2 -3
View File
@@ -13,6 +13,7 @@ import re
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.types import EvalRecord
@@ -64,9 +65,7 @@ class LogHubDataset(DatasetProvider):
f"Choose from: {list(_DATASETS.keys())}"
)
self._subset = subset
self._cache_dir = (
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "loghub"
)
self._cache_dir = Path(cache_dir) if cache_dir else get_cache_dir() / "loghub"
self._records: List[EvalRecord] = []
def load(
+2 -1
View File
@@ -15,6 +15,7 @@ import random
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.types import EvalRecord
@@ -41,7 +42,7 @@ class PaperArenaDataset(DatasetProvider):
cache_dir: Optional[str] = None,
) -> None:
self._cache_dir = (
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "paperarena"
Path(cache_dir) if cache_dir else get_cache_dir() / "paperarena"
)
self._records: List[EvalRecord] = []
+2 -1
View File
@@ -18,6 +18,7 @@ from typing import Any, Dict, Iterable, List, Optional
import yaml
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.splits import apply_split
from openjarvis.evals.core.types import EvalRecord
@@ -25,7 +26,7 @@ from openjarvis.evals.core.types import EvalRecord
LOGGER = logging.getLogger(__name__)
PINCHBENCH_REPO = "https://github.com/pinchbench/skill.git"
CACHE_DIR = Path.home() / ".cache" / "pinchbench"
CACHE_DIR = get_cache_dir() / "pinchbench"
def _parse_task_markdown(content: str, filename: str = "") -> Dict[str, Any]:
+2 -2
View File
@@ -12,9 +12,9 @@ import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import Iterable, List, Optional
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.splits import apply_split
from openjarvis.evals.core.types import EvalRecord
@@ -22,7 +22,7 @@ from openjarvis.evals.core.types import EvalRecord
LOGGER = logging.getLogger(__name__)
TAU2_REPO = "https://github.com/sierra-research/tau2-bench.git"
CACHE_DIR = Path.home() / ".cache" / "tau2-bench"
CACHE_DIR = get_cache_dir() / "tau2-bench"
DOMAINS = ("airline", "retail", "telecom")
@@ -21,6 +21,7 @@ import subprocess
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.types import EvalRecord
@@ -56,7 +57,7 @@ class WebChoreArenaDataset(DatasetProvider):
) -> None:
self._subset = subset # "all", "small", or a site name
self._cache_dir = (
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "webchorearena"
Path(cache_dir) if cache_dir else get_cache_dir() / "webchorearena"
)
self._headless = headless
self._records: List[EvalRecord] = []
@@ -8,6 +8,8 @@ from pathlib import Path
from types import TracebackType
from typing import Any, MutableMapping, Optional, Type
from openjarvis.evals.core.environment import TaskEnvironmentError
LOGGER = logging.getLogger(__name__)
@@ -29,8 +31,6 @@ class TerminalBenchTaskEnv:
# ------------------------------------------------------------------
def __enter__(self) -> TerminalBenchTaskEnv:
from terminal_bench.terminal.terminal import spin_up_terminal
task = self._metadata.get("task")
task_paths = self._metadata.get("task_paths")
task_id = self._metadata.get("task_id", "unknown")
@@ -41,6 +41,8 @@ class TerminalBenchTaskEnv:
"Use the 'terminalbench-native' dataset."
)
from terminal_bench.terminal.terminal import spin_up_terminal
docker_image_prefix = f"tb__{task_id}".replace(".", "-")
client_image_name = f"{docker_image_prefix}__client"
client_container_name = f"oj-{task_id}".replace(".", "-")
@@ -48,19 +50,59 @@ class TerminalBenchTaskEnv:
self._logs_tmpdir = tempfile.TemporaryDirectory(prefix="oj_tb_logs_")
logs_path = Path(self._logs_tmpdir.name)
self._terminal_cm = spin_up_terminal(
client_container_name=client_container_name,
client_image_name=client_image_name,
docker_compose_path=task_paths.docker_compose_path,
docker_image_name_prefix=docker_image_prefix,
sessions_logs_path=logs_path,
disable_recording=task.disable_asciinema,
)
self._terminal = self._terminal_cm.__enter__()
# Everything below is exception-safe: a failure mid-startup (docker
# compose, tmux, asciinema) tears the spun-up terminal back down
# immediately instead of leaking the docker compose project until GC
# (or forever, when the env object is retained), and re-raises as a
# loud TaskEnvironmentError naming the task image so the runner can
# record a harness error for THIS task and continue with the rest.
try:
self._terminal_cm = spin_up_terminal(
client_container_name=client_container_name,
client_image_name=client_image_name,
docker_compose_path=task_paths.docker_compose_path,
docker_image_name_prefix=docker_image_prefix,
sessions_logs_path=logs_path,
disable_recording=task.disable_asciinema,
)
self._terminal = self._terminal_cm.__enter__()
session = self._terminal.create_session(
"agent", is_active_stream=False, as_configured_user=True
)
# Preflight BEFORE the agent loop: terminal-bench drives the
# agent through tmux (and records via asciinema unless the task
# disables it). A missing binary otherwise surfaces mid-run as
# an opaque RuntimeError or a fake TimeoutError.
self._preflight_container_binaries(
task, task_id, client_image_name, client_container_name
)
session = self._terminal.create_session(
"agent", is_active_stream=False, as_configured_user=True
)
except BaseException as exc:
self._teardown(type(exc), exc, exc.__traceback__)
if not isinstance(exc, Exception):
# KeyboardInterrupt / SystemExit: clean up but never mask.
raise
if isinstance(exc, TaskEnvironmentError):
self._metadata["harness_error"] = str(exc)
raise
message = (
f"Task '{task_id}': failed to start the task environment "
f"(image '{client_image_name}', container "
f"'{client_container_name}'): {exc}. This is a harness/"
"environment failure, not a model failure. Check that the "
"Docker daemon is healthy and that tmux is installed in the "
"task image."
)
# docker compose stderr is only logged at DEBUG by
# terminal-bench; surface it here so the failure is actionable.
stderr = getattr(exc, "stderr", None)
if stderr:
if isinstance(stderr, bytes):
stderr = stderr.decode("utf-8", errors="replace")
message += f"\ndocker compose stderr (tail):\n{stderr[-2000:]}"
self._metadata["harness_error"] = message
raise TaskEnvironmentError(message) from exc
self._metadata["terminal"] = self._terminal
self._metadata["session"] = session
@@ -68,24 +110,99 @@ class TerminalBenchTaskEnv:
return self
def _preflight_container_binaries(
self,
task: Any,
task_id: str,
client_image_name: str,
client_container_name: str,
) -> None:
"""Verify tmux (and asciinema if recording) exist in the container.
Raises:
TaskEnvironmentError: naming the task image and the missing
binary, with the remedy, before any agent work starts.
"""
container = getattr(self._terminal, "container", None)
if container is None:
# Terminal implementation without a container handle (e.g. a
# future terminal-bench version); fall through to terminal-bench's
# own checks rather than guessing.
return
checks: list[tuple[str, list[str], str]] = [
(
"tmux",
["tmux", "-V"],
f"install tmux in the task image '{client_image_name}'",
),
]
if not getattr(task, "disable_asciinema", False):
checks.append(
(
"asciinema",
["asciinema", "--version"],
f"install asciinema in the task image '{client_image_name}' "
"or set disable_asciinema in task.yaml",
)
)
for binary, cmd, remedy in checks:
result = container.exec_run(cmd)
exit_code = getattr(result, "exit_code", 0)
if exit_code == 0:
continue
output = getattr(result, "output", b"")
if isinstance(output, bytes):
output = output.decode("utf-8", errors="replace")
raise TaskEnvironmentError(
f"Task '{task_id}': required binary '{binary}' is not usable "
f"in task image '{client_image_name}' (container "
f"'{client_container_name}'): exec exit code {exit_code}, "
f"output {str(output).strip()!r}. Remedy: {remedy}. This is "
"a harness/environment failure, not a model failure."
)
def _teardown(
self,
exc_type: Optional[Type[BaseException]] = None,
exc_val: Optional[BaseException] = None,
exc_tb: Optional[TracebackType] = None,
) -> None:
"""Idempotent cleanup shared by ``__exit__`` and failed ``__enter__``.
Secondary cleanup errors are logged, never raised, so they cannot
mask the original failure.
"""
self._metadata.pop("terminal", None)
self._metadata.pop("session", None)
self._metadata.pop("container", None)
terminal_cm, self._terminal_cm, self._terminal = self._terminal_cm, None, None
if terminal_cm is not None:
try:
terminal_cm.__exit__(exc_type, exc_val, exc_tb)
except Exception:
LOGGER.exception(
"Secondary error while tearing down the terminal for "
"task %s (original error, if any, is re-raised)",
self._metadata.get("task_id", "unknown"),
)
logs_tmpdir, self._logs_tmpdir = self._logs_tmpdir, None
if logs_tmpdir is not None:
try:
logs_tmpdir.cleanup()
except Exception:
LOGGER.exception("Failed to clean up session logs tmpdir")
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
self._metadata.pop("terminal", None)
self._metadata.pop("session", None)
self._metadata.pop("container", None)
if self._terminal_cm is not None:
self._terminal_cm.__exit__(exc_type, exc_val, exc_tb)
self._terminal_cm = None
self._terminal = None
if self._logs_tmpdir is not None:
self._logs_tmpdir.cleanup()
self._logs_tmpdir = None
self._teardown(exc_type, exc_val, exc_tb)
# ------------------------------------------------------------------
# Test execution
@@ -93,12 +210,6 @@ class TerminalBenchTaskEnv:
def run_tests(self) -> tuple[bool, dict[str, Any]]:
"""Copy test scripts into container, execute, parse results."""
from terminal_bench.parsers.base_parser import UnitTestStatus
from terminal_bench.parsers.parser_factory import ParserFactory
from terminal_bench.terminal.docker_compose_manager import (
DockerComposeManager,
)
task = self._metadata["task"]
task_paths = self._metadata["task_paths"]
terminal = self._terminal
@@ -110,6 +221,12 @@ class TerminalBenchTaskEnv:
self._metadata["test_results"] = results
return False, results
from terminal_bench.parsers.base_parser import UnitTestStatus
from terminal_bench.parsers.parser_factory import ParserFactory
from terminal_bench.terminal.docker_compose_manager import (
DockerComposeManager,
)
try:
paths_to_copy = [task_paths.run_tests_path]
if task_paths.test_dir.exists():
@@ -49,6 +49,7 @@ import tempfile
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.scorer import Scorer
from openjarvis.evals.core.types import EvalRecord
@@ -93,9 +94,7 @@ def _run_subprocess_hard_timeout(
)
try:
stdout, stderr = proc.communicate(timeout=timeout_s)
return subprocess.CompletedProcess(
cmd, proc.returncode, stdout, stderr
)
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
except subprocess.TimeoutExpired:
# Kill the whole group, not just the direct child — Modal harness
# subprocesses fork workers that would otherwise keep pipes open.
@@ -115,9 +114,7 @@ def _run_subprocess_hard_timeout(
stdout, stderr = proc.communicate(timeout=10)
except subprocess.TimeoutExpired:
stdout, stderr = "", ""
raise subprocess.TimeoutExpired(
cmd, timeout_s, output=stdout, stderr=stderr
)
raise subprocess.TimeoutExpired(cmd, timeout_s, output=stdout, stderr=stderr)
# ---------- Patch tracking ----------
@@ -208,11 +205,11 @@ def _patch_modal_sandbox_source() -> None:
# Upstream changed the line — bail rather than apply blindly.
return
replacement = (
' # ' + _CGROUP_SOURCE_SENTINEL + '\n'
' try:\n'
" # " + _CGROUP_SOURCE_SENTINEL + "\n"
" try:\n"
' self.write_file("/sys/fs/cgroup/cpu/cpu.shares", "2048")\n'
' except FileNotFoundError:\n'
' pass # cgroup v2 Modal sandbox — path missing is fine\n'
" except FileNotFoundError:\n"
" pass # cgroup v2 Modal sandbox — path missing is fine\n"
)
new_src = src.replace(needle + "\n", replacement, 1)
try:
@@ -324,22 +321,23 @@ def extract_patch(text: str) -> Optional[str]:
# ---------- Harness invocation ----------
def _harness_cache_dir() -> Path:
"""Where the swebench subprocess writes its report JSON + logs/ tree.
Defaults to ``$OPENJARVIS_HOME/.swebench-cache`` if set, otherwise to a
process-shared tempdir. Pin both so we don't pollute the project root.
Consolidated under the env-aware OpenJarvis cache root
(``<openjarvis-home>/cache/swebench``) so it never pollutes the project
root or scatters across ``$HOME``. Honors ``OPENJARVIS_HOME`` /
``XDG_DATA_HOME`` via :func:`openjarvis.core.paths.get_cache_dir`.
"""
home = os.environ.get("OPENJARVIS_HOME")
if home:
cache = Path(home) / ".swebench-cache"
else:
cache = Path(tempfile.gettempdir()) / "openjarvis-swebench-cache"
cache = get_cache_dir() / "swebench"
cache.mkdir(parents=True, exist_ok=True)
return cache
def _find_report(cache: Path, instance_id: str, run_id: str) -> Optional[Dict[str, Any]]:
def _find_report(
cache: Path, instance_id: str, run_id: str
) -> Optional[Dict[str, Any]]:
"""Find the harness's report JSON for one instance.
swebench writes ``<model_name_or_path>.<run_id>.json`` inside the
@@ -427,26 +425,40 @@ def _run_harness(
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
preds_path = tmp_path / "predictions.jsonl"
preds_path.write_text(json.dumps({
"instance_id": instance_id,
"model_name_or_path": "openjarvis-harness",
"model_patch": patch,
}) + "\n")
preds_path.write_text(
json.dumps(
{
"instance_id": instance_id,
"model_name_or_path": "openjarvis-harness",
"model_patch": patch,
}
)
+ "\n"
)
cmd = [
sys.executable, "-m", "swebench.harness.run_evaluation",
"--predictions_path", str(preds_path),
"--max_workers", "1",
"--run_id", run_id,
"--dataset_name", "SWE-bench/SWE-bench_Verified",
"--instance_ids", instance_id,
sys.executable,
"-m",
"swebench.harness.run_evaluation",
"--predictions_path",
str(preds_path),
"--max_workers",
"1",
"--run_id",
run_id,
"--dataset_name",
"SWE-bench/SWE-bench_Verified",
"--instance_ids",
instance_id,
]
if backend == "modal":
cmd += ["--modal", "true"]
try:
proc = _run_subprocess_hard_timeout(
cmd, timeout_s=timeout_s, cwd=str(cache),
cmd,
timeout_s=timeout_s,
cwd=str(cache),
)
except subprocess.TimeoutExpired as exc:
# The harness subprocess (and its Modal grandchildren) exceeded
@@ -497,6 +509,7 @@ def _run_harness(
# ---------- Scorer ----------
class SWEBenchHarnessScorer(Scorer):
"""SWE-bench Verified scorer that runs the official harness.
@@ -516,7 +529,7 @@ class SWEBenchHarnessScorer(Scorer):
timeout_s: int = 1800,
cell_name: Optional[str] = None,
judge_backend: object = None, # noqa: ARG002 — CLI factory compat
judge_model: str = "", # noqa: ARG002 — CLI factory compat
judge_model: str = "", # noqa: ARG002 — CLI factory compat
) -> None:
self._timeout_s = int(timeout_s)
# ``cell_name`` namespaces the ``run_id`` so concurrent cells scoring
@@ -538,16 +551,15 @@ class SWEBenchHarnessScorer(Scorer):
if patch is None:
return False, {"reason": "no_patch_extracted"}
instance_id = (
record.metadata.get("instance_id")
or record.record_id
or ""
)
instance_id = record.metadata.get("instance_id") or record.record_id or ""
if not instance_id:
return False, {"reason": "missing_instance_id"}
result = _run_harness(
instance_id, patch, self._timeout_s, cell_name=self._cell_name,
instance_id,
patch,
self._timeout_s,
cell_name=self._cell_name,
)
details = dict(result.get("details", {}))
details["patch"] = patch
+5 -5
View File
@@ -16,6 +16,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from openjarvis.core.paths import get_config_dir
LOGGER = logging.getLogger(__name__)
CONDITIONS = (
@@ -52,14 +54,12 @@ class SkillBenchmarkConfig:
seeds: List[int] = field(default_factory=lambda: [42, 43, 44])
max_samples: Optional[int] = None
output_dir: Path = field(default_factory=lambda: Path("docs/superpowers/results/"))
skills_dir: Path = field(
default_factory=lambda: Path("~/.openjarvis/skills/").expanduser()
)
skills_dir: Path = field(default_factory=lambda: get_config_dir() / "skills")
overlay_dir_dspy: Path = field(
default_factory=lambda: Path("~/.openjarvis/learning/skills-dspy/").expanduser()
default_factory=lambda: get_config_dir() / "learning" / "skills-dspy"
)
overlay_dir_gepa: Path = field(
default_factory=lambda: Path("~/.openjarvis/learning/skills-gepa/").expanduser()
default_factory=lambda: get_config_dir() / "learning" / "skills-gepa"
)

Some files were not shown because too many files have changed in this diff Show More