Compare commits

..
118 Commits
Author SHA1 Message Date
github-actions[bot] a97c64c67b chore: update clone traffic data [skip ci] 2026-08-14 07:19:51 +00:00
Jon Saad-Falcon 64333651d1 Delete tools/pearl-reference-oracle directory 2026-08-13 19:27:37 -07:00
Elliot Slusky 9bee016c82 fix(server): preserve grounded agent stream content (#736)
* fix(server): preserve grounded agent stream content

* test(server): cover active grounded stream path

* fix(server): retain agent stream bridge
2026-08-13 18:15:36 -07:00
Elliot Slusky c9942961ad fix(evals): make TauBench dependency explicit (#739)
* fix(evals): make TauBench dependency explicit

* fix(evals): verify TauBench install provenance
2026-08-13 18:14:55 -07:00
Elliot Slusky c3a7ffebff fix(memory): recall auto-captured facts across sessions (#740)
* fix(memory): recall auto-captured facts

* fix(memory): harden recalled context injection

* fix(memory): preserve mixed context history

* fix(agents): preserve caller system context
2026-08-13 17:07:10 -07:00
Elliot Slusky b3c57468ae fix(cli): honor enabled tools when serving (#737)
* fix(cli): honor enabled tools when serving

* fix(server): preserve tools for streaming agents
2026-08-13 17:03:51 -07:00
Elliot Slusky ff69797135 fix(web): normalize tool call arguments (#738)
* fix(web): normalize tool call arguments

* fix(web): preserve chats when repair writeback fails
2026-08-13 17:03:10 -07:00
github-actions[bot] 465dba4b3f chore: update clone traffic data [skip ci] 2026-08-13 07:22:13 +00:00
github-actions[bot] 4f857b0abb chore: update clone traffic data [skip ci] 2026-08-12 07:20:09 +00:00
Jon Saad-FalconandClaude Opus 5 20aa08ef04 ci(desktop): preflight Apple notarization credentials before build (#724)
Notarization is the last thing tauri-action does, so any credential or
account-state fault surfaced ~10 minutes into the macOS job -- after the
Rust toolchain, npm install, two Ollama sidecar downloads and a universal
cargo build -- as one opaque line:

  failed to bundle project: failed codesign application: failed to
  notarize app: Error: HTTP status code: 403. ...

That message conflates three unrelated causes, and the signing step
succeeds in all of them, so the log actively misleads: the certificate is
clearly valid right up until the failure.

Add a read-only `notarytool history` call immediately after checkout. It
submits nothing and exercises the identical auth path, so all three
failures reach us in ~2s with the specific cause and fix named:

  401 invalid credentials  -> APPLE_PASSWORD is not an app-specific
                              password, or was minted under a different
                              Apple ID than APPLE_ID
  403 inaccessible team    -> APPLE_ID is not a member of APPLE_TEAM_ID
  403 required agreement   -> the Program License Agreement lapsed; only
                              the Account Holder can accept it

xcrun is preinstalled on macOS runners, hence placement before the
toolchain steps rather than beside "Configure Apple signing".

Skips cleanly when APPLE_CERTIFICATE is unset (unsigned builds never
notarize), mirroring the existing signing step, and errors when a
certificate is present but notarization secrets are missing -- previously
that combination signed successfully and then failed at the very end.
Transient network faults retry 3x; credential errors are deterministic
and exit on the first definitive answer.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:49:52 -07:00
github-actions[bot] 9a63561db8 chore: update clone traffic data [skip ci] 2026-08-11 07:02:06 +00:00
Elliot Slusky 7959285ad8 fix(cli): preserve extras during self-update (#718)
* fix(cli): preserve extras during self-update

* style(cli): format install detection
2026-08-10 17:22:29 -07:00
goatoush 26e1741059 fix(desktop): keep background streams out of active chat (#654) 2026-08-10 17:15:33 -07:00
kelliott-cloudandElliot Slusky 2ed885eb11 fix: never auto-select embed-only models for chat (#659)
* fix: never auto-select embed-only models for chat

Ollama lists nomic-embed-text alongside chat models. Auto-picking
models[0] / recommending the only available id selected the embedder
and every generation failed with HTTP 400 "does not support chat".

- Filter embed-only ids out of GET /v1/models (chat picker)
- Exclude them from /v1/recommended-model; return empty when none left
- Frontend setModels prefers chat models and clears a bad embed selection
- Regression tests for mixed, embed-only, and classifier cases

* fix: harden chat model capability filtering

---------

Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-08-10 17:06:48 -07:00
07fcf35276 fix: use a signal-free liveness probe for the daemon on Windows (#681)
* fix: use a signal-free liveness probe for the daemon on Windows

`_read_pid()` probed the recorded pid with `os.kill(pid, 0)`. That is a
POSIX idiom: on Windows signal 0 is `CTRL_C_EVENT`, so the call routes to
`GenerateConsoleCtrlEvent` rather than testing for existence, and raises
`OSError` (WinError 87, "The parameter is incorrect") for any pid that is
not a live console process-group leader — which includes both dead pids
and the detached server `jarvis start` creates.

That single call produced three symptoms. `jarvis status` propagated the
error and crashed. `_read_pid`'s `except OSError` swallowed it for a
running server, so `status` and `stop` reported "not running" and deleted
a live pid file. And because the probe *sends* a console control event
rather than merely asking, running `status` against the daemon could
terminate it.

Add `_pid_alive()`, which opens a process handle and checks it on Windows
and keeps the signal-0 probe on POSIX, and use it for both liveness
checks. `SIGKILL` in the stop path is now reached on Windows for the
first time, so guard it — it is POSIX-only, and `SIGTERM` already maps to
`TerminateProcess` there.

The existing round-trip test mocked `os.kill` to succeed, which is why
this passed CI on Linux while failing on every Windows run. Point it at
the new seam and add `TestPidLiveness`, which exercises real pids so the
platform behaviour is actually covered.

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

* style: format daemon tests with CI Ruff

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-08-10 16:56:53 -07:00
Haotian Zheng 4efdb07dae fix(cli): use UTF-8 for config files 2026-08-10 16:44:17 -07:00
Elliot Slusky 7feda3dad3 fix(cloud): isolate Gemini stream metadata 2026-08-10 16:32:14 -07:00
Elliot Slusky f08ad574d3 fix(cloud): handle parallel Gemini tool calls 2026-08-10 16:32:14 -07:00
Dustin Zander 1bfc25a860 fix: preserve Gemini tool calls while streaming 2026-08-10 16:32:14 -07:00
Elliot Slusky 063dd8ea75 fix: unify managed-agent tool resolution (#705)
* fix: unify managed-agent tool resolution

* fix: harden managed-agent tool lifecycle
2026-08-10 15:09:41 -07:00
Elliot Slusky 6af9317556 fix: route configured LiteLLM models by engine ownership (#714) 2026-08-10 12:51:16 -07:00
Elliot Slusky 410562409d fix: make websocket bridge race cancellation-safe 2026-08-10 11:04:12 -07:00
Ari 9498adc7c4 fix: close ws_bridge send loop on client disconnect
Previously the ws_bridge send loop blocked forever on queue.get(),
never learning the client left. When the server service stopped,
uvicorn waited for open WebSocket tasks until systemd SIGKILLed
after TimeoutStopSec=90s. Now each iteration races recv+send; a
completed receive means the client disconnected => break the loop.
2026-08-10 11:04:12 -07:00
github-actions[bot] ebf370595d chore: update clone traffic data [skip ci] 2026-08-10 07:26:44 +00:00
Elliot Slusky bcdbf13d02 test(tools): cover eager deep-research registration 2026-08-09 22:59:45 -07:00
Ari 3dc621618f fix(tools): eager-import scan_chunks and knowledge_sql at package load
Every other built-in tool is imported here specifically to fire its
@ToolRegistry.register() decorator at package-load time; these two
were missing, so their test_registered tests only passed when some
unrelated test (via agent_manager_routes.py, channels_cmd.py, or
deep_research_setup_cmd.py) happened to import the module first in
the same process. Under pytest-xdist that's worker-distribution
dependent, so adding an unrelated test file could flip either test
from pass to fail.
2026-08-09 22:59:45 -07:00
github-actions[bot] fd0b60fefc chore: update clone traffic data [skip ci] 2026-08-09 06:51:50 +00:00
github-actions[bot] 95a9857984 chore: update clone traffic data [skip ci] 2026-08-08 06:46:50 +00:00
github-actions[bot] f9c89308fc chore: update clone traffic data [skip ci] 2026-08-07 07:10:58 +00:00
Elliot Slusky 65d08e9d94 Fix proactive cron reconciliation and notifications 2026-08-06 13:40:00 -07:00
Loma 45717780fa Fix proactive agent cron duplicating on every server restart
register_cron() ran unconditionally on every 'jarvis serve' startup and
create_task() persists to scheduler.db, so each restart added another
copy of the daily proactive cron. On a real install 68 duplicates
accumulated; when due they fired back-to-back and monopolized the
single-slot local inference queue, stalling interactive chat.

register_cron() is now idempotent: an existing active task is reused and
surplus duplicates are cancelled. Also stop the notification channel
from calling connect() — a second getUpdates poll loop on the same bot
token makes Telegram return Conflict and kills the main listener.
2026-08-06 13:40:00 -07:00
goatoush 9da7c30880 Fixing text wrap for agent creator tool selector (#655) 2026-08-06 11:23:27 -07:00
github-actions[bot] 98e791f258 chore: update clone traffic data [skip ci] 2026-08-06 08:31:13 +00:00
Elliot Slusky b9e0928aef fix(telemetry): record cloud inference cost (#704) 2026-08-05 20:51:06 -07:00
Elliot Slusky 652a522e50 fix(config): deserialize skill source tables (#703) 2026-08-05 20:50:42 -07:00
github-actions[bot] ce1a9ce133 chore: update clone traffic data [skip ci] 2026-08-05 08:31:05 +00:00
github-actions[bot] ae45a4f67c chore: update clone traffic data [skip ci] 2026-08-04 08:31:51 +00:00
github-actions[bot] 697eed23d4 chore: update clone traffic data [skip ci] 2026-08-03 09:38:25 +00:00
github-actions[bot] 100595f8aa chore: update clone traffic data [skip ci] 2026-08-02 08:20:07 +00:00
github-actions[bot] dd03a55028 chore: update clone traffic data [skip ci] 2026-08-01 08:15:52 +00:00
github-actions[bot] a72218f99f chore: update clone traffic data [skip ci] 2026-07-31 08:45:27 +00:00
github-actions[bot] eaa76032d5 chore: update clone traffic data [skip ci] 2026-07-30 08:21:39 +00:00
CurryrajandClaude Opus 5 ed01ab8c8d fix: proxy WebSocket upgrades to the API in dev (#692)
The Vite dev proxy forwards `/v1` as plain HTTP with no `ws: true`, so the
WebSocket upgrade for `/v1/agents/events` is never proxied. The socket does not
open, does not error, and does not close — it just sits silent — so every live
agent view is empty under `npm run dev` while working in a production build,
where the frontend is served from the same origin as the API.

That covers the per-agent live trace on the Agents page, which subscribes via
`useAgentEvents` (`frontend/src/lib/useAgentEvents.ts`).

The silence is what makes it costly: with no error to see, it reads as "no
events are being emitted" rather than "the transport never connected", so the
search starts on the server side.

Verified on Windows 11 with `jarvis start` running: before, a
`new WebSocket('ws://localhost:5173/v1/agents/events')` from the dev page never
fired open, error or close within 6s. After, it opens, and a real agent tick
delivers 8 events (agent_tick_start, inference_start/end, tool_call_start/end,
agent_tick_end).

`changeOrigin` is set alongside so the upgrade request carries the target's
host, which some setups require when the API is not on localhost.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:04:41 -07:00
CurryrajandClaude Opus 5 a65f663d2e fix: stop SessionStore treating ":memory:" as a file path (#684)
``SessionStore.__init__`` passed its ``db_path`` straight to
``secure_create()``, which touches the path and chmods it. ``:memory:`` is
a SQLite sentinel, not a filename, so this tried to create a file literally
named ``:memory:``.

On Windows ``:`` is illegal in a filename, so construction raised
``OSError: [Errno 22] Invalid argument: ':memory:'`` and the two
``tests/server/test_channel_bridge_deep_research.py`` tests failed there.
Elsewhere it succeeds and is merely wrong: it leaves a stray ``:memory:``
file in the working directory, and because ``Path(":memory:").parent`` is
``.``, ``secure_mkdir`` chmods the working directory itself to 0o700.

``KnowledgeStore``, ``TelemetryStore`` and ``TraceStore`` already guard this
exact case; ``SessionStore`` was the one store missing the check. Apply the
same guard, with the same comment.

Adds two regression tests: one that an in-memory store is usable, one that
constructing it creates no file. The second fails on every platform without
the fix, so the bug cannot silently return on Linux or macOS.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:44:18 -07:00
Elliot Slusky c6382f2473 fix web search setup in browser quickstart (#691) 2026-07-29 08:38:04 -07:00
github-actions[bot] 2922a2b154 chore: update clone traffic data [skip ci] 2026-07-29 08:35:42 +00:00
Elliot Slusky 403dec8e98 Merge pull request #687 from Curryraj/fix/windows-daemon-detach
fix: detach the daemon from its console on Windows
2026-07-28 23:34:01 -07:00
Elliot Slusky 2c7cf6118c test: make Windows daemon test cross-platform 2026-07-28 23:27:25 -07:00
CurryrajandClaude Opus 5 2bdd860b54 test: tolerate a 503 memory backend in the route-wiring tests (#685)
``TestMemoryRoutes.test_search`` and ``test_stats`` assert the status code
is in ``(200, 500)``. That list dates from the initial commit; #527 later
made the memory routes raise 503 when the native ``openjarvis_rust``
extension is missing, so both tests now fail on any checkout where the
extension has not been built — which is every contributor who has not run
``maturin develop``.

The failure is spurious: these two tests only check that the routes are
wired up, and their own comment ("May fail if SQLite not set up, that's
ok") says an unavailable backend is tolerated. 503 is exactly that case,
and it is already asserted deliberately in ``TestMemoryRustMissing``
directly below.

Add 503 to the tolerated set via a named constant, so the reason is stated
once rather than repeated as a bare literal.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:17:25 -07:00
Robby Manihani 81f1ffbb4f docs: document how to give an agent OS-level access (#689)
Someone asked in Discord how to give Jarvis access to their whole
machine and hit a confusing failure. With no ~/.openjarvis/config.toml,
tools.enabled and agent.tools both come back empty, and SystemBuilder
builds the agent with no tools at all. It reads like a permissions
problem but it's just missing config, and nothing in the docs points
you anywhere useful.

Adds docs/user-guide/system-access.md, covering the empty tool list as
the usual cause, what shell_exec and the file tools actually reach,
which entry points prompt for confirmation and which quietly
auto-approve, Full Disk Access on macOS and which process needs it, and
the fact that there's no computer use at all, so Accessibility and
Screen Recording grants buy you nothing on their own.

Also adds a full-system-access.toml example to copy from.

Fixes two config tables that describe security.enforce_tool_confirmation
as requiring confirmation before tools run. The loader accepts the key
but nothing on the execution path reads it, so anyone setting it gets
assurance they don't actually have.
2026-07-28 20:11:27 -07:00
github-actions[bot] 93fc7b9e77 chore: update clone traffic data [skip ci] 2026-07-28 08:29:18 +00:00
Jaiydaan RajandClaude Opus 5 9fc5b875d1 fix: detach the daemon from its console on Windows
``jarvis start`` spawned the server with ``start_new_session=True``. That is
POSIX-only — CPython's Windows ``_execute_child`` names the parameter
``unused_start_new_session`` and ignores it — so on Windows the server
inherited the launching console instead of detaching from it.

Closing that console, or logging off, therefore delivered CTRL_CLOSE_EVENT
to the server. Observed in the wild as the daemon dying overnight, with

    forrtl: error (200): program aborting due to window-CLOSE event

in server.log (the Fortran runtime under NumPy handles the event and
aborts). ``jarvis start`` looked like it worked: it printed a PID, wrote the
pid file and exited 0, and the server ran for as long as the console stayed
open. Registered as a log-on scheduled task, this means the machine comes
back up with no backend.

Pass DETACHED_PROCESS on Windows so the child gets no console at all, plus
CREATE_NEW_PROCESS_GROUP so a Ctrl-C in the parent console cannot reach it.
POSIX keeps start_new_session.

Verified by attaching to each spawned process with AttachConsole():
start_new_session=True attaches successfully (the child shares a console);
DETACHED_PROCESS fails with ERROR_INVALID_HANDLE (no console exists).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:17:06 +08:00
github-actions[bot] 08279e6b99 chore: update clone traffic data [skip ci] 2026-07-27 09:46:21 +00:00
github-actions[bot] a7c31e89b4 chore: update clone traffic data [skip ci] 2026-07-26 08:22:36 +00:00
github-actions[bot] 04014c658a chore: update clone traffic data [skip ci] 2026-07-25 08:02:21 +00:00
github-actions[bot] c1238d3e7e chore: update clone traffic data [skip ci] 2026-07-24 08:23:24 +00:00
github-actions[bot] 687e80a55a chore: update clone traffic data [skip ci] 2026-07-23 08:25:07 +00:00
github-actions[bot] b90fd01af2 chore: update clone traffic data [skip ci] 2026-07-22 08:24:05 +00:00
github-actions[bot] bbe7df7d33 chore: update clone traffic data [skip ci] 2026-07-21 08:23:44 +00:00
Arush WadhawanandElliot Slusky 9685b9b78f fix(telemetry): enable WAL and batch writes in TelemetryStore to avoid SQLITE_BUSY under concurrency (#597)
TelemetryStore opened SQLite without WAL, so concurrent readers (server, aggregator, dashboard) hitting the database under inference load raised SQLITE_BUSY, and every insert committed immediately, paying fsync on each record.

- Enable PRAGMA journal_mode=WAL with synchronous=NORMAL and busy_timeout=5000, matching TraceStore.
- Batch inserts in memory under a lock and flush via executemany() when a batch reaches batch_size (default 50), when a batch goes stale, on any read through the store, and on close().
- Run a background flusher thread (default 5s interval) so a partial batch written just before traffic stops still becomes visible to other connections; close() stops the thread with an ordering that prevents touching a closed connection.
- Tests cover batching deferral, read-triggered flushes, stale-batch flushes, and close() behavior.

Fixes #560

Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-07-20 14:11:10 -07:00
goatoush aa2d127de4 fix(frontend): remove jitter when scrolling up during chat autoscroll (#646)
The chat area re-armed autoscroll whenever the user was within 100px of the bottom, so scrolling up during a streaming response fought the incoming content ticks and produced jitter.

Autoscroll now disengages on any upward scroll (direction-based, no distance threshold), re-engages when scrolled back within 2px of the bottom (tolerating sub-pixel rounding at fractional zoom levels, where the at-bottom residual can reach 1px), and ignores sub-1px upward movement so macOS elastic-bounce settling does not disengage it. Sending a message pins the view to the bottom even if the user had scrolled up to read earlier messages.
2026-07-20 13:45:12 -07:00
github-actions[bot] 87f6238338 chore: update clone traffic data [skip ci] 2026-07-20 08:53:13 +00:00
github-actions[bot] 452bcc38cf chore: update clone traffic data [skip ci] 2026-07-19 08:12:04 +00:00
goatoush b35a4c8113 fix(desktop): preserve active chat when switching models (#648)
Switching models from the command palette called createConversation() on every change, creating a persisted empty "New chat" entry and pulling the user out of their active conversation. Because updateLastAssistant writes the visible messages array without checking the active conversation, a mid-stream switch could also clobber the new chat's view with the old conversation's messages.

Remove the conversation-creation side effect. Model switching now preserves the active chat (matching the pull-completion and delete-fallback paths, which already switched silently); the next request uses the newly selected model with the current conversation context. Preloading, loading state, and logging are unchanged.
2026-07-18 12:51:38 -07:00
github-actions[bot] f001e3b0ca chore: update clone traffic data [skip ci] 2026-07-18 07:44:44 +00:00
Cesar Schneider b6dba93ae5 fix: make pytest suite hermetic against local dev-machine state (#647)
Two test-isolation fixes: (1) an autouse conftest fixture sets OPENJARVIS_NO_UPDATE_CHECK=1 so the CLI's PyPI update-check banner (stderr, merged into CliRunner output) can never pollute JSON/CSV-parsing CLI tests on local runs; CI was already covered by CI=true. (2) test_dense.py's Ollama skip-guard now queries /api/tags and requires nomic-embed-text to be pulled instead of a bare TCP connect, so machines running Ollama without the embed model skip instead of erroring. The probe normalizes all documented OLLAMA_HOST forms (full URL, host:port, bare host) and the Ollama-backed tests construct DenseMemory against that same endpoint rather than the embedder's hard-coded localhost default, with unit tests covering the probe. Related: #645.
2026-07-17 13:21:36 -07:00
github-actions[bot] 3000116d18 chore: update clone traffic data [skip ci] 2026-07-17 08:05:34 +00:00
Elliot Slusky 9db21d37ef style: apply Ruff formatting to recently merged tests (#644)
Fix the Ruff formatter check on main by formatting tests changed in #639 and #640 with the repository's pinned Ruff 0.15.1. No behavior change.
2026-07-16 18:13:36 -07:00
Elliot Slusky 95480363b7 style(skills): wrap importer manifest parse call (#643)
Fix the Ruff E501 failure on main introduced during the #639 fix-up. The call was 89 characters against the repository's 88-character limit. No behavior change.
2026-07-16 18:07:44 -07:00
CurryrajandElliot Slusky 4419b76412 fix: catch ImportError in git tools, fall back to CLI when Rust ext missing (#636)
get_rust_module() was called outside the try block in GitStatusTool/GitDiffTool/GitLogTool.execute(), so on installs without the compiled openjarvis-rust extension (e.g. plain pip installs, where openjarvis-rust is a uv-only group since #624) the ImportError escaped uncaught instead of degrading. Move the call inside try and fall back to the git CLI via the existing _run_git helper on ImportError, matching the fallback git_log already had. Adds regression tests covering the fallback path for all three tools.

Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-07-16 17:59:25 -07:00
Jon Saad-FalconandClaude Opus 4.8 99bbc2054a Add arXiv badge to README (#642)
Add a red arXiv badge linking to the OpenJarvis paper (2605.17172) as
the first item in the header badge row, matching the style used on the
Intelligence-Per-Watt repo.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:42:20 -07:00
Jon Saad-FalconandClaude Opus 4.8 d5d8fddc94 Fix leaderboard savings lookup after provider-key rename (#635)
PR #634 renamed the Anthropic cost-comparison provider key
`claude-opus-4.6` -> `claude-fable-5` but missed one consumer:
App.tsx looks up the Anthropic entry by that key to compute the
`dollar_savings` value submitted to the leaderboard. After the rename
`per_provider.find(p => p.provider === 'claude-opus-4.6')` returned
undefined, so this path silently submitted dollar_savings = 0.

Point the lookup at the new key.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:33:38 -07:00
jaaaaorr06 cadb3e2ae6 feat(skills): enforce capability/trust-tier checks at install and run time (#639)
Wires the previously-dead skills/security.py checks into two places. SkillImporter.import_skill() classifies trust tier before writing to disk, refuses unreviewed skills requesting dangerous capabilities unless confirmed (confirm_dangerous=True, or --yes-dangerous on skill install/sync), and persists tier/capabilities to the .source sidecar. SkillExecutor.run() gains opt-in capability enforcement: allowed_capabilities=None (the default, used by all existing call sites) means no policy; passing a set blocks skills whose required_capabilities are not covered before any step runs. Bulk sync reports refused skills instead of silently skipping them. Both enforcement points are covered by tests.
2026-07-16 17:23:27 -07:00
Trevor Willard 7dc904c1b2 fix: wire persona files into default chat endpoint, fix FTS5 apostrophe crash (#637)
Two fixes: (1) /v1/chat/completions now builds its injected system prompt via SystemPromptBuilder, so SOUL.md/MEMORY.md/USER.md persona files apply to the OpenAI-compatible endpoint exactly as they do on the managed-agent path; injection still only happens when the client omits a system message. (2) FTS5 query tokens are now split on any non-alphanumeric character, so apostrophes (user's) and quotes can no longer reach the MATCH string unescaped; all-punctuation queries return empty instead of erroring.
2026-07-16 17:10:51 -07:00
ScottyAmr d9725fbb6a fix(speech): close temp file before transcribing to fix Windows EACCES in faster-whisper backend (#638)
faster-whisper's transcribe() was handed the path of a still-open NamedTemporaryFile; on Windows the open handle is exclusive, so PyAV's reopen failed with EACCES and local STT was broken. Switch to delete=False, close before transcribing, and unlink in a finally. The write is wrapped in the file's context manager so the handle closes even if write() raises, and unlink failures are logged at debug.
2026-07-16 17:09:45 -07:00
Syed Osama Ali ShahandElliot Slusky 23f04264f9 fix(knowledge_sql): match write keywords on word boundaries (allow valid SELECTs) (#640)
* fix(knowledge_sql): match write keywords on word boundaries

The read-only guard rejected a query if any of DROP/DELETE/INSERT/UPDATE/
ALTER/CREATE/ATTACH appeared as a bare substring of the uppercased text. That
wrongly blocks valid SELECTs whose column/alias/literal merely contains one --
e.g. "deleted_at" (DELETE), "created_at" (CREATE), "updated_content" (UPDATE).
The knowledge_chunks table actually has deleted_at/created_at columns and the
store's own retrieval filters on "WHERE deleted_at IS NULL", so realistic
read queries were refused. Match on word boundaries with a compiled regex,
mirroring the sibling tool db_query.py. Add a regression test.

* fix(knowledge_sql): ignore string literals in keyword scan, broaden error handling

- Strip single-quoted literals before the forbidden-keyword scan so
  SELECTs whose data merely mentions a write keyword (e.g. LIKE
  '%delete%') are not rejected.
- Catch sqlite3.Error instead of only OperationalError so multi-
  statement strings return a failed ToolResult instead of raising.
- Document created_at/deleted_at in the tool's schema description.

---------

Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-07-16 16:36:31 -07:00
github-actions[bot] 8b59eb87e0 chore: update clone traffic data [skip ci] 2026-07-16 08:08:14 +00:00
github-actions[bot] 2e68e227b7 chore: update clone traffic data [skip ci] 2026-07-15 08:04:26 +00:00
github-actions[bot] fc98614437 chore: update clone traffic data [skip ci] 2026-07-14 07:59:08 +00:00
github-actions[bot] b1c5aba6fd chore: update clone traffic data [skip ci] 2026-07-13 09:20:55 +00:00
Jon Saad-FalconandClaude Opus 4.8 6240c59ca3 Update cost-comparison models: GPT-5.6 Sol, Claude Fable 5 (#634)
Refresh the cost-comparison / savings surfaces to current frontier cloud
pricing (per 1M tokens):

- OpenAI:    GPT-5.3 ($2/$10)        -> GPT-5.6 Sol ($5/$30)
- Anthropic: Claude Opus 4.6 ($5/$25) -> Claude Fable 5 ($10/$50)
- Google:    Gemini 3.1 Pro ($2/$12)  -> unchanged

Internal provider keys are renamed in lockstep (gpt-5.3 -> gpt-5.6-sol,
claude-opus-4.6 -> claude-fable-5) across the canonical CLOUD_PRICING
dict, the two server-rendered HTML pages, and the frontend color/label
maps so backend, dashboard, and UI stay consistent. Energy/FLOPs
metadata is carried over unchanged. Model catalog and eval configs are
untouched (real model/benchmark entries, not the cost comparison).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 14:48:22 -07:00
github-actions[bot] 9f3c7fd086 chore: update clone traffic data [skip ci] 2026-07-12 08:12:51 +00:00
github-actions[bot] 657c8dd26b chore: update clone traffic data [skip ci] 2026-07-11 07:51:15 +00:00
github-actions[bot] 4ef296e9d0 chore: update clone traffic data [skip ci] 2026-07-10 09:30:29 +00:00
github-actions[bot] d5d06ca0e5 chore: update clone traffic data [skip ci] 2026-07-09 09:39:27 +00:00
github-actions[bot] 213ee4ff7e chore: update clone traffic data [skip ci] 2026-07-08 08:23:57 +00:00
Jon Saad-FalconandClaude Opus 4.8 215ab76e5f docs: point the Project Site link to openjarvis.stanford.edu (#628)
The project's canonical site moved from the Scaling Intelligence Lab blog
(scalingintelligence.stanford.edu/blogs/openjarvis/) to
https://openjarvis.stanford.edu/. Update every reference to that URL:

- README: the "Project" badge and the "Project Site" link
- docs/index.md: the research write-up link
- desktop Settings: the "Project site" link (SettingsPage.tsx)
- the Twitter-bot operator prompt

The bare Scaling Intelligence Lab homepage links (the lab itself, not the
project site) are intentionally left unchanged, as are the github.io
documentation and installer URLs.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 13:27:32 -07:00
github-actions[bot] 0812ee0701 chore: update clone traffic data [skip ci] 2026-07-07 09:36:44 +00:00
talyaseenandClaude Opus 4.8 0b140110d8 Make OpenAI-compat and Ollama engine streaming truly async (#626)
The OpenAI-compat and Ollama engines exposed stream()/stream_full() as async def but iterated a synchronous httpx.Client.iter_lines() internally, blocking the single event loop on every inter-token read (serializing concurrent chats; one wedged upstream read froze the whole API). Convert both to a shared AsyncHTTPEngineMixin using httpx.AsyncClient + aiter_lines() with a per-event-loop pooled client and the configured timeout applied; map mid-stream transport errors (RemoteProtocolError/ReadError) to EngineConnectionError via a deliberately narrow set that keeps CancelledError/GeneratorExit propagating; handle non-2xx explicitly (incl. 3xx and a typed EngineContextLengthError for context-window overflow 400s); switch litellm streaming to acompletion; and offload the blocking non-streaming handlers and websocket generate() to asyncio.to_thread. No public API change. Strong MockTransport-based tests, including a pin that the async path never touches the sync client. Complements #618.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 14:10:22 -07:00
github-actions[bot] 2623f9e0f4 chore: update clone traffic data [skip ci] 2026-07-06 10:14:22 +00:00
github-actions[bot] d454c41500 chore: update clone traffic data [skip ci] 2026-07-05 08:49:16 +00:00
github-actions[bot] e3fb816d12 chore: update clone traffic data [skip ci] 2026-07-04 08:33:11 +00:00
github-actions[bot] 3486f27357 chore: update clone traffic data [skip ci] 2026-07-03 08:58:35 +00:00
Elliot SluskyandClaude Opus 4.8 928776a71c ci: enforce ruff format in CI, add Makefile matching the CI test lane (#625)
CI's lint job ran ruff check but never ruff format --check, letting format drift land silently (79 files had drifted from the pinned ruff 0.15.1). Add the ruff format --check step to ci.yml, reformat the 79 drifted files with the pinned ruff (mechanical only — verified AST-identical to before across all files, no logic changes), and add a Makefile whose test target mirrors the actual CI lane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:49:18 -07:00
github-actions[bot] 2c2a4b6ae4 chore: update clone traffic data [skip ci] 2026-07-02 07:10:21 +00:00
26bc7efb09 fix(packaging): make openjarvis-rust a uv-only group to unblock pip install openjarvis[desktop] (#624)
* fix(packaging): make openjarvis-rust a uv-only dependency group (unblock pip[desktop])

#615 added openjarvis-rust to the published `desktop` extra so
`uv sync --extra desktop` builds the native PyO3 extension for the desktop
app. But openjarvis-rust is not on PyPI and `[tool.uv.sources]` is stripped
from published wheel metadata, so `pip install openjarvis[desktop]` from PyPI
failed at install trying to resolve openjarvis-rust from PyPI (#584).

Move openjarvis-rust into a uv `desktop-native` dependency group (PEP 735 —
excluded from wheel metadata) and sync it in the desktop app via
`uv sync --group desktop-native`. The extension is still built from the local
path source; only the published metadata changes.

Verified: the built wheel no longer lists openjarvis-rust in any Requires-Dist
(nowhere in the metadata), and uv.lock still resolves it from the local path
source under the group. Adds tests/deployment/test_packaging.py to guard the
split (not in the published extra, present in the group, path source, and the
desktop app syncs the group).

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

* fix(packaging): sync native group in install paths

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-07-01 13:02:13 -07:00
github-actions[bot] f3954e087a chore: update clone traffic data [skip ci] 2026-07-01 07:33:10 +00:00
Elliot SluskyandClaude Opus 4.8 d865b4bed4 Fix blocking async server handlers (#618)
Closes #219. Replace synchronous httpx calls in async SendBlue and model-management handlers with awaited httpx.AsyncClient (context-managed close); run Whisper transcription and engine.list_models via asyncio.to_thread so they don't block the event loop; and harden TelemetryStore/aggregator SQLite for concurrency (WAL, synchronous=NORMAL, busy_timeout=5000, plus a write-serializing lock on the shared connection). Adds async-usage assertions and a real 8-thread concurrent-write test. Related: #570 (async httpx, different issue #559).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:37:20 -07:00
Elliot SluskyandClaude Opus 4.8 c686517cc7 Fix upcoming Google Calendar event retrieval (#617)
Closes #388. Parse Google Calendar all-day events from start.date instead of stamping them with the current time; treat generic next/upcoming calendar-event queries as gcalendar timeline requests that return nearest-future events first (UTC-normalized, instant-aware comparison that handles tz offsets and all-day events); and update the research planner guidance to route such queries with sources=[gcalendar] + a today-onward time_range. Real in-memory KnowledgeStore integration tests cover ordering, tz normalization, all-day inclusion, and source narrowing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:18:09 -07:00
Elliot SluskyandClaude Opus 4.8 904133cb25 fix(research): respect configured engine for Deep Research (#616)
Fixes #575. Web Deep Research was hardcoded to OllamaEngine + DEFAULT_PLANNER_MODEL, ignoring the user's configured/active engine and model. Resolve the planner from [deep_research] override -> live app chat engine + selected model -> config defaults -> legacy Ollama, pass the chat picker's model from the frontend into /api/research, record the actual planner engine in telemetry, and refuse to silently fall back to a different engine (raise an actionable error instead). Adds config support and focused tests for resolution and the route. Related: #576 (duplicate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:46:18 -07:00
Elliot SluskyandClaude Opus 4.8 299dee1f40 fix(desktop): verify Rust extension before server startup (#615)
Fixes #505. Make the desktop backend resilient to a missing/unbuilt openjarvis_rust extension: declare openjarvis-rust as a uv-managed desktop path dependency so 'uv sync --extra desktop' owns the PyO3 build (instead of pruning an undeclared package); add ~/.cargo/bin to the subprocess PATH and fail early with Rust / Windows Build Tools guidance when the toolchain is missing; verify 'import openjarvis_rust' before starting jarvis serve; and add a TCP bind preflight for port 8000 to catch non-HTTP listeners the /health probe can't classify. Includes the uv.lock entry for the new path dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:24:24 -07:00
github-actions[bot] 44ff286005 chore: update clone traffic data [skip ci] 2026-06-30 07:21:15 +00:00
Gilbert Barajas be51eb8684 docs(user-guide): document SOUL/MEMORY/USER.md persona files (#604) (#610)
* docs(user-guide): document SOUL/MEMORY/USER.md persona files (#604)

The persistent-memory showcase links to the User Guide: Agents page for how
SOUL.md / MEMORY.md / USER.md are loaded at conversation start, but that page
never covered them (site search for the filenames returns nothing).

Add a "Persistent Persona" section to user-guide/agents.md: the three files and
what each holds, where they live (config dir + [memory_files]), how they load
(after the agent template, cached per conversation, per-section truncation),
named personas (--persona / personas/<name>/), and editing by hand or via the
memory_manage / user_profile_manage tools. Cross-links the distinct retrieval
memory backend to resolve the reporter's confusion.

Closes #604

* docs(user-guide): clarify memory_manage/user_profile_manage target the default MEMORY.md/USER.md
2026-06-29 17:38:22 -07:00
Elliot Slusky 420908401c fix(engine): count tool call payloads in token estimates (#614)
Closes #608. Make Message.content officially Optional (str | None) with a Message.text accessor that treats None as empty, and count tool-call IDs/names/arguments, tool-result IDs, and reasoning/thinking metadata in estimate_prompt_tokens — all are replayed into later prompt turns, so they belong in the estimate. Extends estimator and message-type regression tests.
2026-06-29 17:34:10 -07:00
Elliot Slusky b70be55681 fix(openhands): handle none content in token estimates (#612)
Closes #607. Assistant tool-call turns can carry content=None, which crashed token estimation (len(m.content)) and think-tag stripping. Normalize with 'content or ""', route native OpenHands truncation through the shared estimate_prompt_tokens, and add tests for the estimator, the truncation helper, and an end-to-end tool-call run with None content.
2026-06-29 16:51:52 -07:00
Elliot Slusky a0187e40e6 Fix desktop startup fallback to installed Ollama models (#611)
Addresses #605. Prefer an already-installed Ollama model before attempting a startup download (matching the requested tag, else a preferred non-embedding installed model); fall back through installed -> FALLBACK_MODEL -> error, reusing installed models at each failure point; persist the resolved model only for first-run/default so an explicit user choice is never overwritten. Refactors the model logic into testable helpers with unit coverage.
2026-06-29 16:51:49 -07:00
Jon Saad-FalconandClaude Opus 4.8 d32f20f9b3 fix(desktop): align @tauri-apps npm packages with the 2.11 Rust crate (#613)
Desktop release builds failed on all platforms with 'Found version mismatched Tauri packages' because @tauri-apps/api and @tauri-apps/cli were pinned at 2.10.1 while the tauri Rust crate resolved to 2.11.3. Bump both npm packages to the 2.11 line (api 2.11.1, cli 2.11.4) so they share the crate's major.minor. Plugins were already aligned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:51:46 -07:00
github-actions[bot] 0b552cbcb5 chore: update clone traffic data [skip ci] 2026-06-29 07:46:59 +00:00
github-actions[bot] 19fd3c8d2b chore: update clone traffic data [skip ci] 2026-06-28 07:24:04 +00:00
Jon Saad-FalconandClaude Opus 4.8 1fa80d8ecd fix(docs): wire the savings leaderboard's Supabase anon key into the docs build (#596)
The docs-site leaderboard (docs/javascripts/leaderboard.js) reads the public
Supabase anon key from window.OPENJARVIS_SUPABASE_ANON_KEY, but nothing set it,
so the published leaderboard always rendered "Leaderboard not configured yet".

Add a generated config file (leaderboard-config.js) loaded before
leaderboard.js that supplies the global, and inject its value at docs-build
time from the existing VITE_SUPABASE_ANON_KEY repo secret. The committed
default is empty, so local `mkdocs build` and fork PRs (no secret) degrade
gracefully. The anon key is public by design (Supabase RLS protects the data).

- docs/javascripts/leaderboard-config.js: empty-default global declaration.
- mkdocs.yml: load leaderboard-config.js before leaderboard.js.
- docs.yml: write the config from the secret (read via env, JSON-encoded into a
  JS string literal to avoid injection) before `mkdocs build`.
- tests/deployment/test_docs_leaderboard.py: guard the wiring + load order.

Verified with a local `mkdocs build`: the generated config ships in site/ and
loads before leaderboard.js.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:49:18 -07:00
github-actions[bot] b3f90691bf chore: update clone traffic data [skip ci] 2026-06-27 07:07:41 +00:00
github-actions[bot] 4ebf0839e7 chore: update clone traffic data [skip ci] 2026-06-26 07:21:03 +00:00
github-actions[bot] b1e93d4ed0 chore: update clone traffic data [skip ci] 2026-06-25 07:14:52 +00:00
Elliot SluskyandClaude Opus 4.8 eb2b612c7c fix(memory): address service follow-ups (#591)
Closes #582. Route fact-store construction through a new FactStoreRegistry (local backend registered by default); align the default facts path with get_config_dir(); wire completed chat exchanges (streamed and non-streamed) through the EventBus so the memory service captures them consistently; reload the local fact store from disk before operations so external clears don't resurrect stale facts; make the affected config/persona/memory/CLI/route tests hermetic; and refresh uv.lock with the current resolver (locks pytest-xdist + transitive deps, drops py3.14 artifacts since the project constrains Python <3.14).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:58:49 -07:00
Elliot SluskyandClaude Opus 4.8 560ec860df fix(docker): build native Rust extension into images (#590)
Build and install the mandatory openjarvis_rust wheel in the CPU, NVIDIA, ROCm, and sandbox Docker images. Rust 1.88 (matching the workspace MSRV / rust-toolchain.toml) and maturin are installed only in the builder stage, the module's import is verified during the build, and maturin is removed before the runtime artifacts are copied so build tooling never ships. The frontend leaderboard anon key is an optional empty-by-default build arg (post-#589), so default images cleanly disable the leaderboard. Adds static deployment coverage for the native build path. Closes #584.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:27:17 -07:00
Jon Saad-FalconandClaude Opus 4.8 e7c46c1985 fix(frontend): make Supabase anon key optional to unblock PyPI publishing (#589)
PyPI publishing had been broken since v1.0.3.dev851: #587 made VITE_SUPABASE_ANON_KEY a hard build-time requirement, but no such secret exists, so the frontend build aborted every publish run before the PyPI upload. Decouple package buildability from the leaderboard credential: a missing anon key now disables the savings leaderboard at runtime instead of failing the build, and auto-enables when the secret is provided. Verified: npm run build with the key unset succeeds; tsc + vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:15:06 -07:00
github-actions[bot] 00d1e39b6d chore: update clone traffic data [skip ci] 2026-06-24 07:14:44 +00:00
Elliot SluskyandClaude Opus 4.8 843375d6ef Fix Supabase frontend build env for release builds (#588)
Follow-up to #587. Pass VITE_SUPABASE_ANON_KEY into the frontend builds of both release paths: the PyPI publish workflow (wheel-bundled frontend) and the desktop tauri-action build (npm run build:tauri -> vite build). Kept strict: a missing/empty secret fails the release by design rather than shipping a placeholder key. Requires the VITE_SUPABASE_ANON_KEY repo secret to be set for releases to succeed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:32:13 -07:00
273 changed files with 14990 additions and 5541 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "129,427",
"message": "190,252",
"color": "green",
"namedLogo": "git"
}
+55 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 129427,
"last_updated": "2026-06-23T07:18:12Z",
"total_clones": 190252,
"last_updated": "2026-08-14T07:19:51Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -89,6 +89,58 @@
"2026-06-19": 1350,
"2026-06-20": 1437,
"2026-06-21": 1426,
"2026-06-22": 1350
"2026-06-22": 1350,
"2026-06-23": 1468,
"2026-06-24": 1635,
"2026-06-25": 1640,
"2026-06-26": 1338,
"2026-06-27": 1338,
"2026-06-28": 1028,
"2026-06-29": 765,
"2026-06-30": 951,
"2026-07-01": 1134,
"2026-07-02": 593,
"2026-07-03": 537,
"2026-07-04": 411,
"2026-07-05": 485,
"2026-07-06": 555,
"2026-07-07": 905,
"2026-07-08": 1171,
"2026-07-09": 1857,
"2026-07-10": 1181,
"2026-07-11": 2185,
"2026-07-12": 1917,
"2026-07-13": 2102,
"2026-07-14": 2337,
"2026-07-15": 2362,
"2026-07-16": 2497,
"2026-07-17": 1773,
"2026-07-18": 1542,
"2026-07-19": 1445,
"2026-07-20": 1481,
"2026-07-21": 1528,
"2026-07-22": 1529,
"2026-07-23": 1209,
"2026-07-24": 1118,
"2026-07-25": 928,
"2026-07-26": 740,
"2026-07-27": 799,
"2026-07-28": 665,
"2026-07-29": 745,
"2026-07-30": 591,
"2026-07-31": 783,
"2026-08-01": 567,
"2026-08-02": 1248,
"2026-08-03": 724,
"2026-08-04": 708,
"2026-08-05": 647,
"2026-08-06": 604,
"2026-08-07": 624,
"2026-08-08": 706,
"2026-08-09": 1076,
"2026-08-10": 1060,
"2026-08-11": 2182,
"2026-08-12": 641,
"2026-08-13": 770
}
}
+3
View File
@@ -32,6 +32,9 @@ jobs:
- name: Ruff check
run: uv run ruff check src/ tests/
- name: Ruff format check
run: uv run ruff format --check src/ tests/
test:
runs-on: ubuntu-latest
steps:
+102
View File
@@ -121,6 +121,104 @@ jobs:
# latest release tag (#526).
fetch-depth: 0
# Validate Apple credentials BEFORE the expensive work. Notarization is
# the very last thing `tauri-action` does, so a bad credential or a
# lapsed account agreement previously surfaced ~10 minutes in — after the
# Rust toolchain, npm install, two Ollama sidecar downloads and a
# universal cargo build — as a single opaque line:
#
# failed to bundle project: failed codesign application: failed to
# notarize app: Error: HTTP status code: 403. ...
#
# `notarytool history` is a read-only call (it submits nothing) that
# exercises the identical auth path, so every credential/account failure
# mode reaches us here first, in seconds, with the specific cause named.
# `xcrun` is preinstalled on macOS runners, hence placement before the
# toolchain steps rather than next to "Configure Apple signing".
- name: Preflight Apple notarization credentials
if: matrix.platform == 'macos-14'
env:
CERT: ${{ secrets.APPLE_CERTIFICATE }}
A_ID: ${{ secrets.APPLE_ID }}
A_PASS: ${{ secrets.APPLE_PASSWORD }}
A_TEAM: ${{ secrets.APPLE_TEAM_ID }}
shell: bash
run: |
set -uo pipefail
# Mirror the skip logic in "Configure Apple signing": without a
# certificate the build is unsigned and never notarizes, so there is
# nothing to preflight. Tag builds still hard-fail there.
if [ -z "$CERT" ]; then
echo "No Apple certificate configured; skipping notarization preflight."
exit 0
fi
missing=""
[ -z "$A_ID" ] && missing="$missing APPLE_ID"
[ -z "$A_PASS" ] && missing="$missing APPLE_PASSWORD"
[ -z "$A_TEAM" ] && missing="$missing APPLE_TEAM_ID"
if [ -n "$missing" ]; then
echo "::error::APPLE_CERTIFICATE is set but notarization secrets are missing:$missing"
echo "::error::Signing would succeed and notarization would then fail. Set them or clear APPLE_CERTIFICATE."
exit 1
fi
# Retry only to absorb transient network faults. Credential and
# account errors are deterministic, so we classify and exit on the
# first definitive answer rather than retrying into the same wall.
attempt=1
while [ "$attempt" -le 3 ]; do
out=$(xcrun notarytool history \
--apple-id "$A_ID" \
--team-id "$A_TEAM" \
--password "$A_PASS" \
--output-format json 2>&1)
rc=$?
if [ $rc -eq 0 ]; then
echo "Apple notarization preflight OK — credentials valid, team reachable, agreements in effect."
exit 0
fi
case "$out" in
*"Invalid credentials"*|*"401"*)
echo "::error::Apple notarization preflight failed: invalid credentials (HTTP 401)."
echo "::error::APPLE_PASSWORD must be an app-specific password from appleid.apple.com,"
echo "::error::generated while signed in as the SAME Apple ID as APPLE_ID. A regular"
echo "::error::Apple ID password will not work, and a password minted under a different"
echo "::error::Apple ID authenticates as that other account."
exit 1
;;
*"Invalid or inaccessible developer team ID"*)
echo "::error::Apple notarization preflight failed: APPLE_ID is not a member of team APPLE_TEAM_ID (HTTP 403)."
echo "::error::The Team ID must match the signing certificate. Read it from the cert's"
echo "::error::subject, where it appears as: Developer ID Application: NAME (TEAMID)."
echo "::error::If you belong to several teams, confirm APPLE_ID is a member of this one."
exit 1
;;
*"required agreement"*|*"agreement"*)
echo "::error::Apple notarization preflight failed: the team has no in-effect agreement (HTTP 403)."
echo "::error::Apple reissues the Developer Program License Agreement periodically and"
echo "::error::notarization is refused until it is accepted. ONLY THE ACCOUNT HOLDER can"
echo "::error::accept it — team Admins cannot. Sign in to the account that owns this team:"
echo "::error:: 1. https://developer.apple.com/account -> review any pending agreement"
echo "::error:: 2. App Store Connect -> Business -> accept anything pending there too"
echo "::error::Certificates stay valid while this is outstanding, so signing still works."
exit 1
;;
esac
echo "Preflight attempt ${attempt}/3 failed with a non-credential error."
echo "$out" | tail -5
attempt=$((attempt + 1))
[ "$attempt" -le 3 ] && sleep 10
done
echo "::error::Apple notarization preflight failed after 3 attempts. Last output:"
echo "$out" | tail -20
exit 1
- name: Install system dependencies (Linux)
if: matrix.platform == 'ubuntu-22.04'
run: |
@@ -254,6 +352,10 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
TAURI_CONFIG: '{"version":"${{ steps.release-info.outputs.tauri_version }}","bundle":{"externalBin":["binaries/ollama"]}}'
# tauri-action runs beforeBuildCommand (npm run build:tauri -> vite
# build), which requires this at build time (#587). Strict for
# releases: a missing/empty secret fails the build by design.
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
with:
projectPath: frontend
tauriScript: npx tauri
+20
View File
@@ -41,6 +41,26 @@ jobs:
- name: Install dependencies
run: uv sync --extra docs
# Inject the public Supabase anon key so the savings leaderboard works on
# the published docs site. Missing/empty (e.g. fork PRs) leaves the
# leaderboard gracefully disabled. The key is read from env (not inlined)
# and JSON-encoded into a JS string literal to avoid any injection.
- name: Inject leaderboard Supabase anon key
env:
OPENJARVIS_LEADERBOARD_ANON: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
run: |
python3 - <<'PY'
import json, os, pathlib
key = os.environ.get("OPENJARVIS_LEADERBOARD_ANON", "")
pathlib.Path("docs/javascripts/leaderboard-config.js").write_text(
"// Generated at docs-build time from the VITE_SUPABASE_ANON_KEY secret.\n"
"window.OPENJARVIS_SUPABASE_ANON_KEY = " + json.dumps(key) + ";\n",
encoding="utf-8",
)
print("leaderboard anon key:", "set" if key else "empty (leaderboard disabled)")
PY
- name: Build documentation
run: uv run mkdocs build
+4 -5
View File
@@ -36,8 +36,7 @@ jobs:
- run: npx tsc --noEmit
- run: npm run build
env:
# vite.config/supabase.ts require this at build time. The real value
# comes from the repo secret; CI falls back to a placeholder so the
# build check passes (the leaderboard is not exercised in CI builds).
# Release/docs builds MUST set the secret to the real anon key.
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY || 'ci-placeholder-anon-key' }}
# Optional: when the secret is unset the build still succeeds and the
# leaderboard is disabled (see src/lib/supabase.ts). No placeholder,
# so a keyless CI build doesn't bake in a bogus anon key.
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
+2
View File
@@ -55,6 +55,8 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Build frontend and bundle into package
env:
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
run: |
set -euo pipefail
cd frontend
+19
View File
@@ -0,0 +1,19 @@
.PHONY: setup build test lint format
# Mirrors .github/workflows/ci.yml so `make test` matches CI locally.
setup:
uv sync --extra dev --extra framework-comparison --extra server
build:
uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
test: build
uv run pytest tests/ -n auto -q --tb=short -m "not live and not cloud and not hub"
lint:
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
format:
uv run ruff format src/ tests/
+3 -2
View File
@@ -4,7 +4,8 @@
<p><i>Personal AI, On Personal Devices.</i></p>
<p>
<a href="https://scalingintelligence.stanford.edu/blogs/openjarvis/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
<a href="https://arxiv.org/abs/2605.17172"><img src="https://img.shields.io/badge/arXiv-2605.17172-b31b1b.svg" alt="arXiv"></a>
<a href="https://openjarvis.stanford.edu/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
<a href="https://open-jarvis.github.io/OpenJarvis/"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python">
<img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License">
@@ -23,7 +24,7 @@
> **[Documentation](https://open-jarvis.github.io/OpenJarvis/)**
>
> **[Project Site](https://scalingintelligence.stanford.edu/blogs/openjarvis/)**
> **[Project Site](https://openjarvis.stanford.edu/)**
>
> **[Paper](https://arxiv.org/abs/2605.17172)**
>
@@ -0,0 +1,39 @@
# Full system access: unrestricted shell and filesystem
# Copy to ~/.openjarvis/config.toml
#
# WARNING: shell_exec runs arbitrary commands as your user. No command
# allowlist, no denylist, no working-directory restriction. file_read and
# file_write aren't restricted to any directory either. Only enable what you
# actually want the agent to have. tools.enabled is the whole permission grant;
# there's no second allowlist to configure.
#
# On macOS, this config alone does not reach TCC-protected data (Messages,
# Mail, Photos, Safari). That requires Full Disk Access granted to the process
# hosting the backend. See docs/user-guide/system-access.md.
#
# Usage:
# jarvis ask "What's using the most disk space in my home directory?"
# jarvis chat # prompts before each shell_exec call
[engine]
default = "ollama"
[intelligence]
default_model = "qwen3.5:9b"
[agent]
default_agent = "orchestrator"
max_turns = 10
[tools]
enabled = [
"shell_exec",
"file_read",
"file_write",
"apply_patch",
"code_interpreter",
"git_status",
"git_diff",
"think",
"calculator",
]
+27 -3
View File
@@ -4,16 +4,30 @@
# Stage 1: Build frontend SPA
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
# Public Supabase anon key for the savings leaderboard; empty by default so
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
# Stage 2: Build Python package
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
# Install dependencies from the committed lockfile (#567). `uv export --frozen`
@@ -24,11 +38,13 @@ WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
# Copy the source and the non-src force-include paths (see pyproject
# [tool.hatch.build.targets.wheel.force-include]) before building the project.
COPY src/ src/
COPY rust/ rust/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
@@ -36,7 +52,15 @@ COPY deploy/windows deploy/windows
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
# Install the project itself without re-resolving dependencies.
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust
# Stage 3: Runtime
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
+31 -4
View File
@@ -4,20 +4,37 @@
# Stage 1: Build frontend SPA
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
# Public Supabase anon key for the savings leaderboard; empty by default so
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
# Stage 2: Build Python package (NVIDIA CUDA 12.4)
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3 AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
python3 \
python3-dev \
python3-pip \
python3-venv && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
@@ -25,15 +42,25 @@ WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
COPY src/ src/
COPY rust/ rust/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust
# Stage 3: Runtime
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3
+31 -4
View File
@@ -4,20 +4,37 @@
# Stage 1: Build frontend SPA
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
# Public Supabase anon key for the savings leaderboard; empty by default so
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
# Stage 2: Build Python package (AMD ROCm 7.2)
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644 AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
python3 \
python3-dev \
python3-pip \
python3-venv && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
@@ -25,15 +42,25 @@ WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
COPY src/ src/
COPY rust/ rust/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust
# Stage 3: Runtime
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644
+39 -12
View File
@@ -7,20 +7,18 @@
# image digest is the integrity check, and the copy is architecture-agnostic.
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS node
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
# libstdc++6 + ca-certificates are the only runtime requirements of the Node
# binary copied below (the python slim image already provides libc/libgcc).
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
# Transplant the Node.js runtime from the official image. Both images are Debian
# bookworm, so the glibc/libstdc++ ABI matches.
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
@@ -31,12 +29,41 @@ WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
COPY . .
# Install the project itself without re-resolving dependencies.
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust/target
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
# libstdc++6 + ca-certificates are the only runtime requirements of the Node
# binary copied below (the python slim image already provides libc/libgcc).
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local /usr/local
COPY --from=builder /app /app
# Transplant the Node.js runtime from the official image. Both images are Debian
# bookworm, so the glibc/libstdc++ ABI matches.
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
WORKDIR /app
LABEL openjarvis-sandbox=true
+3 -3
View File
@@ -20,8 +20,8 @@ What it does:
4. Installs `uv` (https://astral.sh/uv) if absent.
5. Clones the OpenJarvis repository to `%LOCALAPPDATA%\OpenJarvis`
(override with `$env:OPENJARVIS_HOME`).
6. Runs `uv sync --extra desktop` so the FastAPI server and speech backend are
importable.
6. Runs `uv sync --extra desktop --group desktop-native` so the FastAPI server,
speech backend, and native extension are importable.
7. Optionally prompts to register a scheduled task that auto-starts the
server at logon.
@@ -105,7 +105,7 @@ To pull the latest:
```powershell
cd "$env:LOCALAPPDATA\OpenJarvis\src"
git pull --ff-only
uv sync --extra desktop
uv sync --extra desktop --group desktop-native
```
Or re-run the installer with `-Force`:
+5 -5
View File
@@ -16,8 +16,8 @@
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 desktop` so the FastAPI server and speech
backend are importable.
6. Run `uv sync --extra desktop --group desktop-native` so the FastAPI
server, speech backend, and native extension are importable.
7. Optionally register the scheduled-task service (see
deploy/windows/jarvis-service.ps1).
@@ -279,13 +279,13 @@ if (Test-Path (Join-Path $srcDir '.git')) {
}
# ---------------------------------------------------------------------------
# 6. uv sync --extra desktop
# 6. uv sync --extra desktop --group desktop-native
# ---------------------------------------------------------------------------
Write-Info "Running 'uv sync --extra desktop' in $srcDir (this can take a few minutes)..."
Write-Info "Running 'uv sync --extra desktop --group desktop-native' in $srcDir (this can take a few minutes)..."
Push-Location $srcDir
try {
& $uvExe sync --extra desktop
& $uvExe sync --extra desktop --group desktop-native
if ($LASTEXITCODE -ne 0) {
Write-Fail "uv sync failed with exit code $LASTEXITCODE. Check the output above."
}
+1 -1
View File
@@ -604,7 +604,7 @@ enforce_tool_confirmation = true
| `scan_output` | bool | `true` | Whether to scan model output. |
| `secret_scanner` | bool | `true` | Enable secret detection (API keys, tokens, passwords). |
| `pii_scanner` | bool | `true` | Enable PII detection (emails, SSNs, credit cards). |
| `enforce_tool_confirmation` | bool | `true` | Require confirmation before executing tools. |
| `enforce_tool_confirmation` | bool | `true` | Accepted but **not currently enforced**. Whether you get prompts depends on the entry point. See [System Access](../user-guide/system-access.md#confirmation-behaviour). |
!!! tip "Choosing a security mode"
Use `"warn"` during development to see what would be flagged without disrupting output.
+13
View File
@@ -135,6 +135,19 @@ cd OpenJarvis
This launches the backend API server and a React frontend at [http://localhost:5173](http://localhost:5173).
You get a ChatGPT-like interface with streaming responses, tool use, energy monitoring, and a telemetry dashboard — all running locally on your hardware.
Web search is available through the built-in DuckDuckGo fallback. To use
Tavily, add `TAVILY_API_KEY` under **Settings → Tools → Web Search** after the
app starts, or export it before starting quickstart:
```bash
export TAVILY_API_KEY="tvly-..."
./scripts/quickstart.sh
```
The script does not automatically source `.env` files. Run `source .env`
first if that is where you keep the key. Stop any existing OpenJarvis server
before restarting so it inherits the updated environment.
To stop all services, press ++ctrl+c++ in the terminal.
!!! tip "Environment variable"
+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 desktop`.
clones the repo, and runs `uv sync --extra desktop --group desktop-native`.
- An optional Windows scheduled-task service equivalent to the systemd
unit and launchd plist.
- Loopback default — the service binds `127.0.0.1` so no API key is
@@ -38,7 +38,7 @@ The installer will:
4. Install `uv` if absent (via the official `astral.sh/uv` PowerShell
installer).
5. Clone the repo to `%LOCALAPPDATA%\OpenJarvis\src`.
6. Run `uv sync --extra desktop`.
6. Run `uv sync --extra desktop --group desktop-native`.
7. Prompt to register the scheduled-task service (skip with
`-SkipService`).
+1 -1
View File
@@ -215,7 +215,7 @@ OpenJarvis is built around five composable layers. Each has a clean interface an
OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.ai/), a research initiative studying the efficiency of on-device AI systems. Developed at [Hazy Research](https://hazyresearch.stanford.edu/) and the [Scaling Intelligence Lab](https://scalingintelligence.stanford.edu/) at [Stanford SAIL](https://ai.stanford.edu/).
Read the [blog post](https://scalingintelligence.stanford.edu/blogs/openjarvis/) for the full research motivation, architecture details, and experimental results.
Read the [blog post](https://openjarvis.stanford.edu/) for the full research motivation, architecture details, and experimental results.
## Citation
+12
View File
@@ -0,0 +1,12 @@
// Public Supabase config for the savings leaderboard.
//
// This file is loaded *before* leaderboard.js and supplies the anon key it
// reads from `window.OPENJARVIS_SUPABASE_ANON_KEY`. The key is injected at
// docs-build time from the VITE_SUPABASE_ANON_KEY repo secret (see
// .github/workflows/docs.yml). It is intentionally empty here so that local
// `mkdocs build` and fork pull requests — which have no secret — render the
// graceful "Leaderboard not configured yet" message instead of failing.
//
// The anon key is public by design: Supabase Row-Level Security protects the
// data, so shipping it in the public docs bundle is expected.
window.OPENJARVIS_SUPABASE_ANON_KEY = "";
+1 -1
View File
@@ -55,5 +55,5 @@ See how the OpenJarvis community saves money, energy, and compute by running AI
<div id="leaderboard-pagination" class="lb-pagination"></div>
<p style="font-size:12px;opacity:0.6;margin-top:12px">
*Dollar savings estimated vs. Claude Opus 4.6 API pricing ($5/1M input, $25/1M output tokens). Assumes local open-source models produce roughly the same number of tokens per request as cloud models.
*Dollar savings estimated vs. Claude Fable 5 API pricing ($10/1M input, $50/1M output tokens). Assumes local open-source models produce roughly the same number of tokens per request as cloud models.
</p>
+65
View File
@@ -19,6 +19,71 @@ Agents are the agentic logic layer of OpenJarvis. They determine how a query is
---
## Persistent Persona: SOUL.md, MEMORY.md, USER.md
Every agent's system prompt is assembled at conversation start by the `SystemPromptBuilder`, which injects up to three optional Markdown files -- the **persistent persona**. They are plain text you own and edit, loaded at the start of each conversation. There is no vector database or embedding cache behind them.
| File | What it holds | Example line |
|------|---------------|--------------|
| `SOUL.md` | How the agent should behave -- tone, length, what to push back on | `Be concise. Challenge weak assumptions.` |
| `MEMORY.md` | Facts about you, your projects, your preferences | `I deploy to Postgres, never MySQL.` |
| `USER.md` | Who you are -- role, team, context | `Backend engineer at Acme, on the payments team.` |
This persona is distinct from the retrieval [memory backend](memory.md): the persona is always-on Markdown context loaded into the prompt, while the memory backend is searchable long-term storage the agent queries on demand.
### Where they live
By default the files are read from the config directory:
```
~/.openjarvis/SOUL.md
~/.openjarvis/MEMORY.md
~/.openjarvis/USER.md
```
(The config directory honors `$OPENJARVIS_HOME` / `$XDG_DATA_HOME` when set.) The paths are configurable under `[memory_files]`:
```toml
[memory_files]
soul_path = "~/.openjarvis/SOUL.md"
memory_path = "~/.openjarvis/MEMORY.md"
user_path = "~/.openjarvis/USER.md"
persona_name = "" # optional named persona -- see below
```
### How they're loaded
At the start of each conversation, `SystemPromptBuilder` reads each file as UTF-8 and adds its contents as a section of the system prompt, after the agent template and before the skill catalog:
- **All three are optional.** A missing or empty file is skipped, so any subset works and an install with no persona files behaves exactly as before.
- **Edits apply to the next conversation.** The files are read once when a conversation's prompt is built, so there is no restart or re-indexing -- edit or delete a line and it takes effect the next time you start a conversation.
- **Each section is length-capped.** Files are truncated to a per-section character budget so a large `MEMORY.md` cannot crowd out the rest of the prompt.
### Named personas
A single install can answer as different personas without changing global config. A named persona lives in its own directory:
```
~/.openjarvis/personas/<name>/SOUL.md
~/.openjarvis/personas/<name>/MEMORY.md
~/.openjarvis/personas/<name>/USER.md
```
Select one per invocation, or opt out entirely:
```bash
jarvis ask --persona work "summarize my open PRs"
jarvis ask --persona none "what is 2 + 2?" # inject no persona
```
Set `persona_name` under `[memory_files]` to make a named persona the default. `persona_name = "none"` (equivalently `--persona none`) disables persona injection for that run.
### Editing them
`SOUL.md`, `MEMORY.md`, and `USER.md` are plain Markdown -- open them in any editor. `MEMORY.md` and `USER.md` can also be updated by the agent itself through the `memory_manage` and `user_profile_manage` tools when those are enabled, so the agent can record a new fact mid-conversation. These tools always target the default `MEMORY.md` and `USER.md` (under `~/.openjarvis/`), never a named persona's copies -- edit those by hand.
---
## BaseAgent ABC
All agents extend the abstract `BaseAgent` class.
+10
View File
@@ -31,6 +31,16 @@ uv sync --extra dev --extra eval-wandb # Weights & Biases run tracking
uv sync --extra dev --extra eval-sheets # Google Sheets results export
```
TauBench additionally requires Python 3.12 or newer and the upstream `tau2`
package. Install the pinned revision explicitly before running that benchmark:
```bash
uv pip install "tau2 @ git+https://github.com/sierra-research/tau2-bench.git@fc0055dc4e0a316c3f83133267fbd6faaa770992"
```
OpenJarvis does not install third-party packages automatically when an
evaluation is imported or run.
!!! note "Python version requirement"
Python 3.10 requires the `tomli` package for TOML config parsing. `openjarvis` declares it as a conditional dependency, so it is installed automatically.
+1 -1
View File
@@ -392,7 +392,7 @@ enforce_tool_confirmation = true
| `secret_scanner` | `bool` | `true` | Run `SecretScanner` on all text |
| `pii_scanner` | `bool` | `true` | Run `PIIScanner` on all text |
| `audit_log_path` | `str` | `~/.openjarvis/audit.db` | Path to the SQLite audit log |
| `enforce_tool_confirmation` | `bool` | `true` | Require explicit confirmation before tool execution |
| `enforce_tool_confirmation` | `bool` | `true` | Accepted by the loader but **not currently enforced**. See [System Access](system-access.md#confirmation-behaviour) for when prompts actually happen |
!!! tip "Start with warn, tighten later"
`mode = "warn"` is a good starting point. It lets you observe what patterns are being triggered without disrupting normal usage. Switch to `"redact"` once you are satisfied that the scanner isn't producing too many false positives for your workload.
+198
View File
@@ -0,0 +1,198 @@
# System Access
How to give an agent access to the machine it runs on, and where the real
limits are.
!!! warning
`shell_exec` runs arbitrary commands as your user. There is no command
allowlist, no denylist, and no sandbox unless you turn one on. An agent
holding this tool can do anything you can do from a terminal.
---
## Start here: you probably have no tools enabled
If the agent tells you it can't run commands or read files, that's usually not
a permissions problem. It means no tools were enabled in the first place.
Tools come from `tools.enabled`, falling back to `agent.tools`. Both default to
empty, and an empty value builds the agent with **zero tools**. Nothing is
enabled by default.
First check whether you have a config file at all:
```bash
cat ~/.openjarvis/config.toml
```
If it isn't there, that's your answer. Create it:
```toml
[engine]
default = "ollama"
[intelligence]
default_model = "qwen3.5:9b"
[agent]
default_agent = "orchestrator"
[tools]
enabled = ["shell_exec", "file_read", "file_write", "think"]
```
There's a fuller version at
`configs/openjarvis/examples/full-system-access.toml`.
Then confirm the list actually resolved:
```bash
python -c "from openjarvis.core.config import load_config; print(load_config().tools.enabled)"
```
---
## What the tools reach
| Tool | Scope |
|------|-------|
| `shell_exec` | Any command, as your user. 30s default timeout, 300s max, output capped at 100 KB per stream. |
| `file_read` | Any readable path. 1 MB cap. |
| `file_write` | Any writable path. 10 MB cap, can create parent directories. |
| `apply_patch` | Applies unified diffs to any path. |
| `code_interpreter` | Python in a subprocess, behind a coarse pattern blocklist. |
`file_read` and `file_write` take an `allowed_dirs` argument that limits them to
a set of directories, but no config key populates it. When it's empty every path
is allowed. If you want a filesystem jail today, use the container sandbox
instead of relying on these tools to enforce one.
### Sensitive filenames
`file_read` and `file_write` refuse names matching a short glob list: `.env`,
`*.pem`, `id_rsa`, `credentials.*` and a dozen or so others. It matches on the
filename only, not the path or the contents, and only those two tools consult
it. `shell_exec`, `apply_patch` and `code_interpreter` skip it entirely, so
`cat ~/.ssh/id_rsa` through `shell_exec` works fine. Treat it as protection
against fat fingers, not as a security boundary.
---
## Confirmation behaviour
`shell_exec`, `git_commit` and `agent_kill` are marked `requires_confirmation`.
What that translates to depends entirely on how you launched the agent:
| Entry point | Behaviour |
|-------------|-----------|
| `jarvis chat` | Prompts before each call. |
| `jarvis ask` | Auto-approves. |
| `jarvis agent ask` | Auto-approves. Pass `--no-yes` if you want prompts. |
| HTTP server, desktop app | Auto-approves. Tools you added to an agent's toolkit count as pre-approved. |
| Embedded via `SystemBuilder` | No callback is wired, so these tools fail closed. |
That last row catches people out. If `shell_exec` returns "requires
confirmation but no confirmation callback is available", you're constructing the
agent yourself and need to pass a `confirm_callback`.
!!! note "`enforce_tool_confirmation` doesn't do anything"
The config loader accepts `security.enforce_tool_confirmation`, but nothing
on the tool execution path reads it. Setting it won't change confirmation
behaviour anywhere. Use the table above instead.
---
## macOS: Full Disk Access
On macOS the operating system is the real boundary, not the config. Shell
access and ordinary file access start working as soon as you enable the tools.
TCC-protected data does not: Messages, Mail, Photos, Safari history, Contacts
and Calendar all stay locked, and no config key will change that.
Grant Full Disk Access to whichever process hosts the backend. Child processes
inherit it:
| How you run OpenJarvis | Grant access to |
|------------------------|-----------------|
| CLI (`jarvis ask`, `jarvis chat`) | Your terminal (Terminal, iTerm, Warp) |
| Desktop app | `OpenJarvis.app`, which spawns `jarvis serve` beneath it |
| launchd (`deploy/launchd/com.openjarvis.plist`) | The `jarvis` binary, as its own entry |
System Settings, then Privacy & Security, then Full Disk Access, then **+**.
A launchd daemon gets its own TCC context, so granting access to Terminal does
nothing for it. Add `/usr/local/bin/jarvis` separately.
To check whether the grant took:
```bash
head -c 16 ~/Library/Messages/chat.db >/dev/null 2>&1 \
&& echo "granted" || echo "denied"
```
Restart the host process after you change the setting.
### Driving Mac apps
AppleScript works through `shell_exec`:
```
osascript -e 'tell application "Music" to play'
```
macOS asks for Automation permission once per target app, the first time you
touch it.
---
## What you can't do
There's no computer use. OpenJarvis can't see your screen, move the pointer or
send keystrokes. No tool for it is registered and no input automation library
appears anywhere in the codebase, so granting Accessibility or Screen Recording
buys you nothing on its own.
The `click` and `type` actions you'll find are Playwright, scoped to a browser
page rather than the desktop.
Some of this is reachable through `shell_exec` if you bring the tooling
yourself. `screencapture` will take screenshots once you've granted Screen
Recording, and something like `cliclick` will move the pointer. That gets you
scripted actions. It doesn't get you an agent that looks at the screen and
works out where to click.
---
## Narrowing access
Access widens and narrows through `tools.enabled`. Drop entries to take
capabilities away. That list is the whole grant.
Two stronger isolation options exist. Both are off by default:
```toml
[sandbox]
enabled = true # run tools inside a container
runtime = "docker"
[security.capabilities]
enabled = true # RBAC over declared tool capabilities
policy_path = "~/.openjarvis/policy.yaml"
```
!!! note "Capabilities are open by default even once enabled"
`CapabilityPolicy` is built with `default_deny=False` and no config key
exposes that flag, so an agent with no explicit policy entry gets every
capability. Write entries for every agent you mean to restrict.
For anything untrusted, reach for `docker_shell_exec` and
`code_interpreter_docker` rather than the host-side versions.
---
## See also
- [Security](security.md) for scanners, the audit log and guardrails
- [Tools](tools.md) for the full registry
- [Code Assistant](code-assistant.md) for a narrower shell-enabled setup
- [External MCP Servers](mcp-external-servers.md) for capabilities OpenJarvis doesn't ship
+67 -52
View File
@@ -11,7 +11,7 @@
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-global-shortcut": "^2",
@@ -42,7 +42,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
@@ -3720,9 +3720,9 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -3730,9 +3730,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -3746,23 +3746,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.1",
"@tauri-apps/cli-darwin-x64": "2.10.1",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
"@tauri-apps/cli-darwin-arm64": "2.11.4",
"@tauri-apps/cli-darwin-x64": "2.11.4",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
"cpu": [
"arm64"
],
@@ -3777,9 +3777,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
"cpu": [
"x64"
],
@@ -3794,9 +3794,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
"cpu": [
"arm"
],
@@ -3811,13 +3811,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -3828,13 +3831,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -3845,13 +3851,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -3862,13 +3871,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -3879,13 +3891,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -3896,9 +3911,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
"cpu": [
"arm64"
],
@@ -3913,9 +3928,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
"cpu": [
"ia32"
],
@@ -3930,9 +3945,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
"cpu": [
"x64"
],
+2 -2
View File
@@ -18,7 +18,7 @@
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-global-shortcut": "^2",
@@ -49,7 +49,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
+413 -54
View File
@@ -8,8 +8,10 @@ use tokio::sync::Mutex;
const OLLAMA_PORT: u16 = 11434;
const JARVIS_PORT: u16 = 8000;
const DESKTOP_UV_SYNC_COMMAND: &str =
"uv sync --extra desktop --extra inference-cloud --extra inference-google --group desktop-native";
/// Small, fast model pulled at startup so the app opens quickly.
/// Small, fast model used when startup needs a default Ollama tag.
const STARTUP_MODEL: &str = "qwen3.5:4b";
/// Tiny fallback model if even the startup model can't be pulled.
@@ -104,7 +106,7 @@ fn default_local_model(ram_gb: f64) -> &'static str {
struct BootPlan {
/// Whether to start and wait for the bundled Ollama.
launch_ollama: bool,
/// The single Ollama model to pull (None for custom endpoints).
/// The preferred Ollama model (None for custom endpoints).
model_to_pull: Option<String>,
/// Optional `(engine_key, bare_host)` override for a custom endpoint,
/// e.g. `("lmstudio", "http://localhost:1234")`. Written into
@@ -608,6 +610,69 @@ async fn wait_for_jarvis_health(
}
async fn ollama_has_model(model: &str) -> bool {
let models = ollama_model_names().await;
matching_installed_model(&models, model).is_some()
}
fn parse_ollama_model_names(body: &serde_json::Value) -> Vec<String> {
body.get("models")
.and_then(|m| m.as_array())
.map(|models| {
models
.iter()
.filter_map(|m| {
m.get("name")
.or_else(|| m.get("model"))
.and_then(|n| n.as_str())
})
.filter(|name| !name.trim().is_empty())
.map(|name| name.to_string())
.collect()
})
.unwrap_or_default()
}
fn model_names_match(installed: &str, requested: &str) -> bool {
installed == requested
|| installed.strip_suffix(":latest") == Some(requested)
|| requested.strip_suffix(":latest") == Some(installed)
}
fn matching_installed_model(models: &[String], requested: &str) -> Option<String> {
models
.iter()
.find(|model| model_names_match(model, requested))
.cloned()
}
fn model_name_looks_embedding_only(model: &str) -> bool {
let name = model.to_ascii_lowercase();
["embed", "embedding", "rerank", "minilm", "bge-", "bge_", "e5-", "e5_"]
.iter()
.any(|marker| name.contains(marker))
}
fn preferred_installed_model(models: &[String]) -> Option<String> {
models
.iter()
.find(|model| !model.trim().is_empty() && !model_name_looks_embedding_only(model))
.or_else(|| models.iter().find(|model| !model.trim().is_empty()))
.cloned()
}
fn startup_installed_model(requested_model: &str, installed_models: &[String]) -> Option<String> {
matching_installed_model(installed_models, requested_model)
.or_else(|| preferred_installed_model(installed_models))
}
fn should_persist_resolved_model(cfg: &InferenceConfig) -> bool {
cfg.model
.as_deref()
.map(|model| model.trim().is_empty())
.unwrap_or(true)
}
async fn ollama_model_names() -> Vec<String> {
let url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
@@ -615,21 +680,10 @@ async fn ollama_has_model(model: &str) -> bool {
.unwrap();
if let Ok(resp) = client.get(&url).send().await {
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(models) = body.get("models").and_then(|m| m.as_array()) {
return models.iter().any(|m| {
m.get("name")
.and_then(|n| n.as_str())
.map(|n| {
n == model
|| n.strip_suffix(":latest") == Some(model)
|| model.strip_suffix(":latest") == Some(n)
})
.unwrap_or(false)
});
}
return parse_ollama_model_names(&body);
}
}
false
Vec::new()
}
async fn pull_model(model: &str) -> Result<(), String> {
@@ -679,13 +733,21 @@ fn format_uv_sync_failure(
let code = exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "unknown".to_string());
let tail = uv_sync_stderr_tail(stderr, 800);
let rust_hint = if looks_like_rust_extension_build_error(stderr) {
format!("\n\n{}", rust_toolchain_install_hint())
} else {
String::new()
};
format!(
"`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\
Try opening a terminal in that directory and running \
`uv sync --extra desktop` manually for the full output.",
`{}` manually for the full output.{}",
root.display(),
code,
uv_sync_stderr_tail(stderr, 800),
tail,
DESKTOP_UV_SYNC_COMMAND,
rust_hint,
)
}
@@ -734,6 +796,122 @@ fn format_uv_sync_spawn_error(root: &std::path::Path, uv_bin: &str, err: &str) -
)
}
fn rust_toolchain_install_hint() -> &'static str {
"The desktop app needs the Rust toolchain to build `openjarvis_rust`. \
Install Rust from https://rustup.rs. On Windows, also install Visual Studio \
Build Tools with the C++ workload, then relaunch."
}
fn looks_like_rust_extension_build_error(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
[
"openjarvis-rust",
"openjarvis_rust",
"maturin",
"cargo",
"rustc",
"link.exe",
"visual studio",
]
.iter()
.any(|marker| lower.contains(marker))
}
fn format_missing_rust_toolchain() -> String {
format!(
"Could not find Rust's `cargo` command. {}\n\n\
If Rust is already installed, close and relaunch the desktop app so \
PATH includes `~/.cargo/bin`.",
rust_toolchain_install_hint(),
)
}
fn format_extension_import_failure(root: &std::path::Path, stderr: &str) -> String {
let tail = uv_sync_stderr_tail(stderr, 4000);
format!(
"`openjarvis_rust` is still not importable after building. Last output:\n\n{}\n\n\
Run these manually for the full build log:\n\n\
cd {}\n\
{}\n\
uv run python -c \"import openjarvis_rust\"",
if tail.is_empty() {
"(no stderr output)"
} else {
&tail
},
root.display(),
DESKTOP_UV_SYNC_COMMAND,
)
}
fn add_cargo_bin_to_path(cmd: &mut tokio::process::Command) {
let mut paths: Vec<std::path::PathBuf> = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect())
.unwrap_or_default();
paths.insert(
0,
std::path::PathBuf::from(home_dir())
.join(".cargo")
.join("bin"),
);
if let Ok(joined) = std::env::join_paths(paths) {
cmd.env("PATH", joined);
}
}
async fn verify_openjarvis_rust_extension(
root: &std::path::Path,
uv_bin: &str,
) -> Result<(), String> {
let mut cmd = tokio::process::Command::new(uv_bin);
cmd.args(["run", "python", "-c", "import openjarvis_rust"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.current_dir(root);
prepare_subprocess_for_appimage(&mut cmd);
add_cargo_bin_to_path(&mut cmd);
match cmd.output().await {
Ok(out) if out.status.success() => Ok(()),
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
Err(format_extension_import_failure(root, &stderr))
}
Err(e) => Err(format!(
"Could not verify `openjarvis_rust`: {}. Verify uv is installed at `{}`.",
e, uv_bin
)),
}
}
fn port_owner_hint() -> String {
if cfg!(target_os = "windows") {
format!("netstat -ano | findstr :{}", JARVIS_PORT)
} else {
format!("lsof -i :{}", JARVIS_PORT)
}
}
fn format_port_unavailable(port: u16, reason: &str) -> String {
format!(
"Port {} is not available: {}. Stop the process using that port or \
change the OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
port,
reason,
port_owner_hint(),
)
}
fn check_jarvis_port_available() -> Result<(), String> {
match std::net::TcpListener::bind(("127.0.0.1", JARVIS_PORT)) {
Ok(listener) => {
drop(listener);
Ok(())
}
Err(err) => Err(format_port_unavailable(JARVIS_PORT, &err.to_string())),
}
}
// ---------------------------------------------------------------------------
// Backend boot sequence (runs in background after app launch)
// ---------------------------------------------------------------------------
@@ -751,7 +929,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
.into();
}
// For the Ollama path, the model pull may fall back to FALLBACK_MODEL; we
// For the Ollama path, model resolution may fall back to FALLBACK_MODEL; we
// record what is actually available here so the serve command below uses
// it instead of the originally-planned tag. None on the custom path.
let mut serve_model_override: Option<String> = None;
@@ -798,8 +976,8 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
s.detail = "Inference engine ready.".into();
}
// Phase 2: Pull the single default model (see default_local_model /
// boot_plan). We deliberately do NOT pull any others.
// Phase 2: Resolve one model to serve. Prefer an installed model on
// first run so startup does not depend on a download succeeding.
let model = plan
.model_to_pull
.clone()
@@ -810,41 +988,63 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
s.detail = format!("Checking for {}...", model);
}
if !ollama_has_model(&model).await {
let installed_models = ollama_model_names().await;
let resolved_model = if let Some(installed) = startup_installed_model(&model, &installed_models) {
installed
} else {
{
let mut s = status.lock().await;
s.detail = format!("Downloading {}... (this may take a minute)", model);
}
if let Err(e) = pull_model(&model).await {
// If the chosen model fails, try the tiny fallback
eprintln!("Warning: failed to pull {}: {}", model, e);
if !ollama_has_model(FALLBACK_MODEL).await {
{
let mut s = status.lock().await;
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
}
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
let mut s = status.lock().await;
s.error = Some(format!("Failed to download model: {}", e2));
return;
match pull_model(&model).await {
Ok(()) => model.clone(),
Err(e) => {
eprintln!("Warning: failed to pull {}: {}", model, e);
// If a local model appeared while pulling, use it instead of
// making startup depend on another network pull.
if let Some(installed) = preferred_installed_model(&ollama_model_names().await) {
installed
} else if ollama_has_model(FALLBACK_MODEL).await {
FALLBACK_MODEL.to_string()
} else {
{
let mut s = status.lock().await;
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
}
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
if let Some(installed) =
preferred_installed_model(&ollama_model_names().await)
{
installed
} else {
let mut s = status.lock().await;
s.error = Some(format!("Failed to download model: {}", e2));
return;
}
} else {
FALLBACK_MODEL.to_string()
}
}
}
}
};
if resolved_model != model {
let mut s = status.lock().await;
s.detail = format!("Using installed model {}.", resolved_model);
}
// The pull may have fallen back to FALLBACK_MODEL; serve and persist
// whatever is actually available now, not the originally-planned tag.
let resolved_model = if ollama_has_model(&model).await {
model
} else {
FALLBACK_MODEL.to_string()
};
serve_model_override = Some(resolved_model.clone());
// Persist the resolved model so Settings shows it and future boots reuse it.
let mut persisted = cfg.clone();
persisted.model = Some(resolved_model);
let _ = write_inference_config(&persisted);
// Persist only first-run/default resolution. If the user explicitly
// configured a model, do not overwrite that choice with a temporary
// fallback selected just to keep startup nonfatal.
if should_persist_resolved_model(&cfg) {
let mut persisted = cfg.clone();
persisted.model = Some(resolved_model);
let _ = write_inference_config(&persisted);
}
{
let mut s = status.lock().await;
@@ -1097,11 +1297,6 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
// Something else (a different web server, a stale process,
// a 4xx-returning instance) is on our port. Don't kill it —
// give the user actionable info instead.
let lsof_hint = if cfg!(target_os = "windows") {
format!("netstat -ano | findstr :{}", JARVIS_PORT)
} else {
format!("lsof -i :{}", JARVIS_PORT)
};
let mut s = status.lock().await;
s.error = Some(format!(
"Port {} is already in use by another service (it answered \
@@ -1109,7 +1304,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
JARVIS_PORT,
resp.status(),
lsof_hint,
port_owner_hint(),
));
return;
}
@@ -1119,8 +1314,21 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
}
}
if let Err(err) = check_jarvis_port_available() {
let mut s = status.lock().await;
s.error = Some(err);
return;
}
let root = project_root.as_ref().unwrap();
let cargo_bin = resolve_bin("cargo");
if !std::path::Path::new(&cargo_bin).exists() && cargo_bin == "cargo" {
let mut s = status.lock().await;
s.error = Some(format_missing_rust_toolchain());
return;
}
// Install dependencies automatically (handles fresh clones).
//
// Previously we ran `uv sync` with both stdout AND stderr piped to
@@ -1146,12 +1354,16 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
"--extra", "desktop",
"--extra", "inference-cloud",
"--extra", "inference-google",
// openjarvis_rust lives in a uv dependency group (not the published
// `desktop` extra) so pip installs from PyPI don't require it (#584).
"--group", "desktop-native",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.current_dir(root);
// Avoid LD_LIBRARY_PATH leak when running inside an AppImage (#455).
prepare_subprocess_for_appimage(&mut sync_cmd);
add_cargo_bin_to_path(&mut sync_cmd);
let sync_output = sync_cmd.output().await;
match sync_output {
Ok(out) if !out.status.success() => {
@@ -1168,6 +1380,16 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
Ok(_) => {} // success — fall through
}
{
let mut s = status.lock().await;
s.detail = "Verifying Rust extension (openjarvis_rust)...".into();
}
if let Err(err) = verify_openjarvis_rust_extension(root, &uv_bin).await {
let mut s = status.lock().await;
s.error = Some(err);
return;
}
{
let mut s = status.lock().await;
s.detail = format!("Starting API server from {}...", root.display());
@@ -2646,9 +2868,12 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::{
boot_plan, default_local_model, format_uv_sync_failure, format_uv_sync_spawn_error,
normalize_host, parse_inference_config, upsert_engine_host, uv_sync_stderr_tail,
InferenceConfig, SourceKind,
boot_plan, default_local_model, format_extension_import_failure,
format_missing_rust_toolchain, format_port_unavailable, format_uv_sync_failure,
format_uv_sync_spawn_error, matching_installed_model, model_names_match, normalize_host,
parse_inference_config, parse_ollama_model_names, preferred_installed_model,
should_persist_resolved_model, startup_installed_model, upsert_engine_host,
uv_sync_stderr_tail, InferenceConfig, SourceKind, DESKTOP_UV_SYNC_COMMAND,
};
use std::path::Path;
@@ -2692,7 +2917,7 @@ mod tests {
assert!(msg.contains("exit 2"));
assert!(msg.contains("/home/u/.openjarvis/src"));
assert!(msg.contains("failed to resolve numpy==2.1.3"));
assert!(msg.contains("uv sync --extra desktop")); // actionable next step
assert!(msg.contains(DESKTOP_UV_SYNC_COMMAND)); // actionable next step
}
#[test]
@@ -2715,6 +2940,49 @@ mod tests {
assert!(msg.contains("No such file or directory"));
}
#[test]
fn missing_rust_toolchain_message_names_cargo_and_installer() {
let msg = format_missing_rust_toolchain();
assert!(msg.contains("cargo"));
assert!(msg.contains("https://rustup.rs"));
assert!(msg.contains("openjarvis_rust"));
assert!(msg.contains("Visual Studio Build Tools"));
}
#[test]
fn uv_sync_rust_failure_mentions_toolchain() {
let msg = format_uv_sync_failure(
Path::new("C:\\Users\\me\\OpenJarvis"),
Some(1),
"maturin failed: linker `link.exe` not found while building openjarvis-rust",
);
assert!(msg.contains("exit 1"));
assert!(msg.contains("link.exe"));
assert!(msg.contains("https://rustup.rs"));
assert!(msg.contains("Visual Studio Build Tools"));
}
#[test]
fn extension_import_failure_names_verification_command() {
let msg = format_extension_import_failure(
Path::new("C:\\Users\\me\\OpenJarvis"),
"ModuleNotFoundError: No module named 'openjarvis_rust'",
);
assert!(msg.contains("openjarvis_rust"));
assert!(msg.contains(DESKTOP_UV_SYNC_COMMAND));
assert!(msg.contains("uv run python -c \"import openjarvis_rust\""));
assert!(msg.contains("ModuleNotFoundError"));
}
#[test]
fn port_unavailable_message_names_port_and_owner_hint() {
let msg = format_port_unavailable(8000, "address already in use");
assert!(msg.contains("Port 8000 is not available"));
assert!(msg.contains("address already in use"));
assert!(msg.contains("To identify it"));
assert!(msg.contains("8000"));
}
#[test]
fn default_local_model_picks_second_largest_that_fits() {
// QWEN35_MODELS min_ram ladder: 4,6,8,12,24,32,96 GB
@@ -2730,6 +2998,97 @@ mod tests {
assert_eq!(default_local_model(1.0), super::FALLBACK_MODEL);
}
#[test]
fn parse_ollama_model_names_reads_nonempty_names() {
let body = serde_json::json!({
"models": [
{"name": "llama3.2:latest"},
{"name": ""},
{"name": "qwen3.5:4b"},
{"model": "mistral:latest"}
]
});
assert_eq!(
parse_ollama_model_names(&body),
vec![
"llama3.2:latest".to_string(),
"qwen3.5:4b".to_string(),
"mistral:latest".to_string()
]
);
}
#[test]
fn model_names_match_treats_latest_as_optional() {
assert!(model_names_match("llama3.2:latest", "llama3.2"));
assert!(model_names_match("llama3.2", "llama3.2:latest"));
assert!(model_names_match("qwen3.5:4b", "qwen3.5:4b"));
assert!(!model_names_match("llama3.2:latest", "qwen3.5:4b"));
}
#[test]
fn installed_model_helpers_pick_matching_or_first_model() {
let models = vec!["llama3.2:latest".to_string(), "qwen3.5:4b".to_string()];
assert_eq!(
matching_installed_model(&models, "llama3.2"),
Some("llama3.2:latest".to_string())
);
assert_eq!(
preferred_installed_model(&models),
Some("llama3.2:latest".to_string())
);
}
#[test]
fn preferred_installed_model_skips_embedding_names_when_chat_model_exists() {
let models = vec![
"nomic-embed-text:latest".to_string(),
"llama3.2:latest".to_string(),
];
assert_eq!(
preferred_installed_model(&models),
Some("llama3.2:latest".to_string())
);
}
#[test]
fn startup_installed_model_uses_existing_model_for_defaults() {
let models = vec!["llama3.2:latest".to_string()];
assert_eq!(
startup_installed_model("qwen3.5:4b", &models),
Some("llama3.2:latest".to_string())
);
}
#[test]
fn startup_installed_model_uses_existing_model_when_configured_model_missing() {
let models = vec!["llama3.2:latest".to_string()];
assert_eq!(
startup_installed_model("qwen3.5:4b", &models),
Some("llama3.2:latest".to_string())
);
}
#[test]
fn resolved_model_is_only_persisted_when_no_model_was_configured() {
let default_cfg = InferenceConfig { kind: SourceKind::Ollama, ..Default::default() };
assert!(should_persist_resolved_model(&default_cfg));
let empty_cfg = InferenceConfig {
kind: SourceKind::Ollama,
model: Some(" ".into()),
..Default::default()
};
assert!(should_persist_resolved_model(&empty_cfg));
let user_cfg = InferenceConfig {
kind: SourceKind::Ollama,
model: Some("qwen3.5:9b".into()),
..Default::default()
};
assert!(!should_persist_resolved_model(&user_cfg));
}
#[test]
fn parse_defaults_to_ollama_when_file_missing_or_garbage() {
assert!(matches!(parse_inference_config("").kind, SourceKind::Ollama));
+1 -3
View File
@@ -31,7 +31,6 @@ export default function App() {
const prevModelRef = useRef<string>('');
const setModels = useAppStore((s) => s.setModels);
const setModelsLoading = useAppStore((s) => s.setModelsLoading);
const setSelectedModel = useAppStore((s) => s.setSelectedModel);
const selectedModel = useAppStore((s) => s.selectedModel);
const setServerInfo = useAppStore((s) => s.setServerInfo);
const setSavings = useAppStore((s) => s.setSavings);
@@ -70,7 +69,6 @@ export default function App() {
fetchModels()
.then((m) => {
setModels(m);
if (!selectedModel && m.length > 0) setSelectedModel(m[0].id);
})
.catch(() => setModels([]))
.finally(() => setModelsLoading(false));
@@ -89,7 +87,7 @@ export default function App() {
setSavings(data);
if (optInEnabled && optInDisplayName && data) {
const claudeEntry = data.per_provider.find(
(p) => p.provider === 'claude-opus-4.6',
(p) => p.provider === 'claude-fable-5',
);
const dollarSavings = claudeEntry ? claudeEntry.total_cost : 0;
const energySaved = data.per_provider.reduce(
+29 -5
View File
@@ -15,6 +15,7 @@ function getGreeting(): string {
}
export function ChatArea() {
const activeId = useAppStore((s) => s.activeId);
const messages = useAppStore((s) => s.messages);
const streamState = useAppStore((s) => s.streamState);
const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
@@ -22,6 +23,10 @@ export function ChatArea() {
const navigate = useNavigate();
const listRef = useRef<HTMLDivElement>(null);
const shouldAutoScroll = useRef(true);
const wasStreaming = useRef(false);
const lastScrollTop = useRef(0);
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
const currentStreamContent = isCurrentChatStreaming ? streamState.content : '';
// Check if any data sources are connected
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
@@ -34,18 +39,37 @@ export function ChatArea() {
}, []);
useEffect(() => {
// Sending a message always pins the view to the bottom, even if the
// user had scrolled up to read earlier messages.
if (isCurrentChatStreaming && !wasStreaming.current) {
shouldAutoScroll.current = true;
}
wasStreaming.current = isCurrentChatStreaming;
if (shouldAutoScroll.current && listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages, streamState.content]);
}, [messages, currentStreamContent, isCurrentChatStreaming]);
const handleScroll = () => {
if (!listRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100;
const distance = scrollHeight - scrollTop - clientHeight;
const scrolledUp = scrollTop < lastScrollTop.current;
lastScrollTop.current = scrollTop;
if (scrolledUp && distance >= 1) {
// Any upward scroll away from the bottom stops autoscroll immediately,
// so streaming content never fights the user (no jitter). Sub-1px
// upward movement (elastic bounce settling at the bottom) is ignored.
shouldAutoScroll.current = false;
} else if (!scrolledUp) {
// Re-engage when scrolled back to the bottom. < 2 rather than < 1:
// at fractional zoom levels the at-bottom residual can reach 1px,
// which would otherwise leave autoscroll permanently disengaged.
shouldAutoScroll.current = distance < 2;
}
};
const isEmpty = messages.length === 0 && !streamState.isStreaming;
const isEmpty = messages.length === 0 && !isCurrentChatStreaming;
const PanelIcon = systemPanelOpen ? PanelRightClose : PanelRightOpen;
@@ -153,12 +177,12 @@ export function ChatArea() {
<MessageBubble
key={msg.id}
message={msg}
isLive={isLastAssistant && streamState.isStreaming}
isLive={isLastAssistant && isCurrentChatStreaming}
/>
);
})}
{(() => {
if (!streamState.isStreaming || streamState.content !== '') return null;
if (!isCurrentChatStreaming || streamState.content !== '') return null;
// For research messages the ResearchTimeline handles its own
// pre-content loading state — suppress the generic dots.
const last = messages[messages.length - 1];
+16 -6
View File
@@ -5,6 +5,7 @@ import { useAppStore, generateId } from '../../lib/store';
import { streamChat, streamResearch } from '../../lib/sse';
import { fetchSavings, getBase } from '../../lib/api';
import { listConnectors, getSyncStatus } from '../../lib/connectors-api';
import { serializeToolCallArguments } from '../../lib/tool-call';
import { MicButton } from './MicButton';
import { useSpeech } from '../../hooks/useSpeech';
import type {
@@ -96,6 +97,7 @@ export function InputArea() {
const deepResearch = useAppStore((s) => s.deepResearch);
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
const corpusSync = useResearchCorpusSync(deepResearch);
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
const {
state: speechState,
@@ -226,6 +228,7 @@ export function InputArea() {
let ttftMs: number | undefined;
setStreamState({
conversationId: convId,
isStreaming: true,
phase: deepResearch ? 'Researching...' : 'Generating...',
elapsedMs: 0,
@@ -243,7 +246,11 @@ export function InputArea() {
try {
if (deepResearch) {
for await (const ev of streamResearch(content, controller.signal)) {
for await (const ev of streamResearch(
content,
selectedModel,
controller.signal,
)) {
if (ev.type === 'search_call') {
const trace: ResearchSearchTrace = {
id: generateId(),
@@ -383,7 +390,7 @@ export function InputArea() {
const tc: ToolCallInfo = {
id: generateId(),
tool: data.tool,
arguments: data.arguments || '',
arguments: serializeToolCallArguments(data.arguments),
status: 'running',
};
toolCalls.push(tc);
@@ -394,7 +401,7 @@ export function InputArea() {
updateLastAssistant(convId, accumulatedContent, [...toolCalls]);
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'info', category: 'tool',
message: `Calling ${data.tool}(${data.arguments || ''})`,
message: `Calling ${data.tool}(${serializeToolCallArguments(data.arguments)})`,
});
} catch {}
} else if (eventName === 'tool_call_end') {
@@ -462,7 +469,10 @@ export function InputArea() {
}
const totalMs = Date.now() - startTime;
const _CLOUD_PREFIXES = ['gpt-', 'o1-', 'o3-', 'o4-', 'claude-', 'gemini-', 'openrouter/', 'MiniMax-', 'chatgpt-'];
const engineLabel = _CLOUD_PREFIXES.some(p => selectedModel.startsWith(p)) ? 'cloud' : 'ollama';
const selectedOwner = useAppStore.getState().models.find((m) => m.id === selectedModel)?.owned_by;
const engineLabel = selectedOwner === 'litellm'
? 'litellm'
: _CLOUD_PREFIXES.some(p => selectedModel.startsWith(p)) ? 'cloud' : 'ollama';
const telemetry: MessageTelemetry = {
engine: engineLabel,
model_id: selectedModel,
@@ -595,7 +605,7 @@ export function InputArea() {
style={{ color: 'var(--color-text)', maxHeight: '200px' }}
disabled={streamState.isStreaming || modelLoading}
/>
{streamState.isStreaming ? (
{isCurrentChatStreaming ? (
<button
onClick={stopStreaming}
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer"
@@ -614,7 +624,7 @@ export function InputArea() {
/>
<button
onClick={sendMessage}
disabled={!input.trim() || modelLoading || !selectedModel}
disabled={streamState.isStreaming || !input.trim() || modelLoading || !selectedModel}
title={selectedModel ? 'Send message' : 'Pick a model first (⌘K)'}
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
style={{
+2 -2
View File
@@ -29,8 +29,8 @@ interface TelemetryStats {
}
const CLOUD_PRICING = [
{ name: 'GPT-5.3', input: 2.00, output: 10.00, primary: true },
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00, primary: false },
{ name: 'GPT-5.6 Sol', input: 5.00, output: 30.00, primary: true },
{ name: 'Claude Fable 5', input: 10.00, output: 50.00, primary: false },
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00, primary: false },
];
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Loader2, CheckCircle2, XCircle } from 'lucide-react';
import type { ToolCallInfo } from '../../types';
import { serializeToolCallArguments } from '../../lib/tool-call';
interface Props {
toolCall: ToolCallInfo;
@@ -35,7 +36,10 @@ export function ToolCallCard({ toolCall }: Props) {
const [expanded, setExpanded] = useState(false);
const config = statusConfig[toolCall.status];
const StatusIcon = config.icon;
const preview = previewArgs(toolCall.arguments);
// Persisted conversations may contain the pre-fix object payload despite
// the TypeScript contract, so normalize again at the final render boundary.
const argumentsText = serializeToolCallArguments(toolCall.arguments);
const preview = previewArgs(argumentsText);
return (
<div
@@ -95,7 +99,7 @@ export function ToolCallCard({ toolCall }: Props) {
className="px-2.5 pb-2 pt-0.5"
style={{ borderTop: '1px solid var(--color-border-subtle, var(--color-border))' }}
>
{toolCall.arguments && (
{argumentsText && (
<div className="mt-1.5">
<div
style={{
@@ -120,7 +124,7 @@ export function ToolCallCard({ toolCall }: Props) {
wordBreak: 'break-all',
}}
>
{formatJson(toolCall.arguments)}
{formatJson(argumentsText)}
</pre>
</div>
)}
+24 -18
View File
@@ -143,18 +143,17 @@ export function CommandPalette() {
}
}, [pullSuccess]);
const handleSelect = async (modelId: string) => {
const handleSelect = async (modelId: string, owner?: string) => {
const previousModel = selectedModel;
setSelectedModel(modelId);
setCommandPaletteOpen(false);
if (modelId !== previousModel) {
const { createConversation, setModelLoading, addLogEntry } = useAppStore.getState();
createConversation(modelId);
const { setModelLoading, addLogEntry } = useAppStore.getState();
setModelLoading(true);
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `Switching to ${modelId}...` });
try {
await preloadModel(modelId);
await preloadModel(modelId, owner);
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `${modelId} loaded` });
} catch (e: any) {
addLogEntry({ timestamp: Date.now(), level: 'error', category: 'model', message: `Failed to load ${modelId}: ${e.message}` });
@@ -256,7 +255,8 @@ export function CommandPalette() {
setSelectedIdx((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && tab === 'installed' && filtered.length > 0) {
e.preventDefault();
handleSelect((filtered[selectedIdx] as any).id);
const model = filtered[selectedIdx] as (typeof models)[number];
handleSelect(model.id, model.owned_by);
}
};
@@ -366,11 +366,15 @@ export function CommandPalette() {
onMouseEnter={() => setSelectedIdx(idx)}
>
<button
onClick={() => handleSelect(model.id)}
onClick={() => handleSelect(model.id, model.owned_by)}
className="flex items-center gap-3 flex-1 min-w-0 text-left cursor-pointer"
style={{ background: 'none', border: 'none', padding: 0 }}
>
<Cpu size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
{model.owned_by === 'litellm' ? (
<Cloud size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
) : (
<Cpu size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
)}
<div className="flex-1 min-w-0">
<div className="text-sm truncate" style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text)', fontWeight: isActive ? 500 : 400 }}>
{model.id}
@@ -382,17 +386,19 @@ export function CommandPalette() {
</span>
)}
</button>
<button
onClick={() => handleDelete(model.id)}
disabled={isDeleting}
className="p-1 rounded transition-colors cursor-pointer"
style={{ color: 'var(--color-text-tertiary)', opacity: 0 }}
title="Delete model"
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = 'var(--color-error)'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; e.currentTarget.style.color = 'var(--color-text-tertiary)'; }}
>
{isDeleting ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
</button>
{model.owned_by !== 'litellm' && (
<button
onClick={() => handleDelete(model.id)}
disabled={isDeleting}
className="p-1 rounded transition-colors cursor-pointer"
style={{ color: 'var(--color-text-tertiary)', opacity: 0 }}
title="Delete model"
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = 'var(--color-error)'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; e.currentTarget.style.color = 'var(--color-text-tertiary)'; }}
>
{isDeleting ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
</button>
)}
</div>
);
})
@@ -2,8 +2,8 @@ import { DollarSign, TrendingDown, Cloud, HardDrive } from 'lucide-react';
import { useAppStore } from '../../lib/store';
const CLOUD_PRICING = [
{ name: 'GPT-5.3', input: 2.00, output: 10.00 },
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00 },
{ name: 'GPT-5.6 Sol', input: 5.00, output: 30.00 },
{ name: 'Claude Fable 5', input: 10.00, output: 50.00 },
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00 },
];
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import type React from 'react';
import { invoke } from '@tauri-apps/api/core';
import { SUPABASE_ANON_KEY, SUPABASE_URL } from '../../lib/supabase';
import { LEADERBOARD_ENABLED, SUPABASE_ANON_KEY, SUPABASE_URL } from '../../lib/supabase';
// ---------------------------------------------------------------------------
// Types
@@ -222,8 +222,8 @@ const styles: Record<string, React.CSSProperties> = {
};
const PROVIDER_COLORS: Record<string, string> = {
'gpt-5.3': colors.green,
'claude-opus-4.6': colors.yellow,
'gpt-5.6-sol': colors.green,
'claude-fable-5': colors.yellow,
'gemini-3.1-pro': colors.accent,
};
@@ -316,9 +316,10 @@ export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
return () => clearInterval(timer);
}, [fetchData]);
// Share savings to Supabase when opted in and data changes
// Share savings to Supabase when opted in and data changes. Skipped entirely
// when no anon key was built in (leaderboard disabled).
useEffect(() => {
if (!optInEnabled || !displayName || !data) return;
if (!LEADERBOARD_ENABLED || !optInEnabled || !displayName || !data) return;
const dollarSavings = data.per_provider.reduce((s, p) => s + p.total_cost, 0);
const energySaved = data.per_provider.reduce((s, p) => s + (p.energy_wh || 0), 0);
const flopsSaved = data.per_provider.reduce((s, p) => s + (p.flops || 0), 0);
+6 -3
View File
@@ -7,6 +7,7 @@ import {
type SetupStatus,
} from '../lib/api';
import { useAppStore } from '../lib/store';
import { isEmbedOnlyModel } from '../lib/model-capabilities';
const STEPS = [
{ key: 'ollama_ready', label: 'Inference Engine', icon: Cpu, detail: 'Starting Ollama...' },
@@ -91,12 +92,14 @@ export function SetupScreen({ onReady }: { onReady: () => void }) {
fetchRecommendedModel().catch(() => ({ model: '', reason: '' })),
]);
const store = useAppStore.getState();
const hadSelection = !!store.selectedModel;
store.setModels(models);
store.setModelsLoading(false);
const recommended = rec.model && models.some((m) => m.id === rec.model)
const chatModels = models.filter((m) => !isEmbedOnlyModel(m.id));
const recommended = rec.model && chatModels.some((m) => m.id === rec.model)
? rec.model
: models[0]?.id || '';
if (recommended && !store.selectedModel) {
: chatModels[0]?.id || '';
if (recommended && !hadSelection) {
store.setSelectedModel(recommended);
}
} catch {
@@ -22,6 +22,9 @@ export function ConversationList({ searchQuery }: Props) {
const navigate = useNavigate();
const conversations = useAppStore((s) => s.conversations);
const activeId = useAppStore((s) => s.activeId);
const streamingConversationId = useAppStore((s) =>
s.streamState.isStreaming ? s.streamState.conversationId : null,
);
const selectConversation = useAppStore((s) => s.selectConversation);
const deleteConversation = useAppStore((s) => s.deleteConversation);
@@ -43,6 +46,7 @@ export function ConversationList({ searchQuery }: Props) {
<div className="flex flex-col gap-0.5 py-1">
{filtered.map((conv) => {
const isActive = conv.id === activeId;
const isStreaming = conv.id === streamingConversationId;
return (
<div
key={conv.id}
@@ -82,11 +86,18 @@ export function ConversationList({ searchQuery }: Props) {
e.stopPropagation();
deleteConversation(conv.id);
}}
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
disabled={isStreaming}
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-30"
style={{ color: 'var(--color-text-tertiary)' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-error)')}
onMouseEnter={(e) => {
if (!isStreaming) e.currentTarget.style.color = 'var(--color-error)';
}}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
title="Delete conversation"
title={
isStreaming
? 'Stop generating before deleting this conversation'
: 'Delete conversation'
}
>
<Trash2 size={14} />
</button>
+50
View File
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// authHeaders) that source the key and build the header.
const SETTINGS_KEY = 'openjarvis-settings';
const fetchMock = vi.fn<typeof fetch>();
// Minimal in-memory localStorage stub so the helpers can run under node
// (no jsdom dependency).
@@ -28,6 +29,8 @@ class MemoryStorage {
beforeEach(() => {
vi.resetModules();
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
fetchMock.mockReset();
globalThis.fetch = fetchMock;
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
new MemoryStorage();
});
@@ -86,3 +89,50 @@ describe('authHeaders', () => {
});
});
});
describe('tool credentials', () => {
it('reads credential status from the local server', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ TAVILY_API_KEY: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const { fetchToolCredentialStatus } = await freshApi();
await expect(fetchToolCredentialStatus('web_search')).resolves.toEqual({
TAVILY_API_KEY: true,
});
expect(fetchMock).toHaveBeenCalledWith(
'/v1/tools/web_search/credentials/status',
{ headers: {} },
);
});
it('saves a tool credential through the local server', async () => {
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
const { saveToolCredentials } = await freshApi();
await saveToolCredentials('web_search', {
TAVILY_API_KEY: 'tvly-test',
});
expect(fetchMock).toHaveBeenCalledWith('/v1/tools/web_search/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ TAVILY_API_KEY: 'tvly-test' }),
});
});
it('deletes a tool credential through the local server', async () => {
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
const { deleteToolCredential } = await freshApi();
await deleteToolCredential('web_search', 'TAVILY_API_KEY');
expect(fetchMock).toHaveBeenCalledWith(
'/v1/tools/web_search/credentials/TAVILY_API_KEY',
{ method: 'DELETE', headers: {} },
);
});
});
+23 -3
View File
@@ -1,5 +1,6 @@
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
import { SUPABASE_ANON_KEY, SUPABASE_URL } from './supabase';
import { serializeToolCallArguments } from './tool-call';
// ---------------------------------------------------------------------------
// Supabase config
@@ -218,9 +219,9 @@ export async function deleteModel(modelName: string): Promise<void> {
const _CLOUD_PREFIXES = ['gpt-', 'o1-', 'o3-', 'o4-', 'claude-', 'gemini-', 'openrouter/'];
export async function preloadModel(modelName: string): Promise<void> {
export async function preloadModel(modelName: string, owner?: string): Promise<void> {
// Cloud models don't need Ollama preloading
if (_CLOUD_PREFIXES.some(p => modelName.startsWith(p))) {
if (owner === 'litellm' || _CLOUD_PREFIXES.some(p => modelName.startsWith(p))) {
return;
}
// Trigger Ollama to load the model into memory (empty prompt, no generation).
@@ -741,7 +742,7 @@ export async function sendAgentMessage(
const parsed = JSON.parse(data);
callbacks?.onToolCallStart?.({
tool: parsed.tool,
arguments: parsed.arguments ?? '',
arguments: serializeToolCallArguments(parsed.arguments),
});
} catch {
/* skip */
@@ -885,6 +886,25 @@ export async function saveToolCredentials(
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
export async function fetchToolCredentialStatus(
toolName: string,
): Promise<Record<string, boolean>> {
const res = await apiFetch(`/v1/tools/${toolName}/credentials/status`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return await res.json();
}
export async function deleteToolCredential(
toolName: string,
keyName: string,
): Promise<void> {
const res = await apiFetch(
`/v1/tools/${encodeURIComponent(toolName)}/credentials/${encodeURIComponent(keyName)}`,
{ method: 'DELETE' },
);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
export interface AgentTraceDetail {
id: string;
agent: string;
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isEmbedOnlyModel } from './model-capabilities';
describe('isEmbedOnlyModel', () => {
it.each([
'nomic-embed-text',
'mxbai-embed-large',
'text-embedding-3-small',
'all-minilm:latest',
'hf.co/BAAI/bge-m3:latest',
])('classifies %s as embedding-only', (modelId) => {
expect(isEmbedOnlyModel(modelId)).toBe(true);
});
it.each(['qwen3.5:4b', 'codegemma:7b'])('keeps %s available for chat', (modelId) => {
expect(isEmbedOnlyModel(modelId)).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
const EMBEDDING_MODEL_PREFIXES = [
'all-minilm',
'bge-',
'bge_',
'e5-',
'e5_',
'gte-',
'gte_',
'jina-embeddings',
'nomic-bert',
'sentence-transformers',
];
export function isEmbedOnlyModel(modelId: string): boolean {
const name = (modelId || '').trim().toLowerCase();
const leaf = name.slice(name.lastIndexOf('/') + 1).split(':')[0];
return (
leaf.includes('embed') ||
leaf.includes('minilm') ||
EMBEDDING_MODEL_PREFIXES.some((prefix) => leaf.startsWith(prefix))
);
}
+2 -2
View File
@@ -60,6 +60,7 @@ export async function* streamChat(
export async function* streamResearch(
query: string,
model?: string,
signal?: AbortSignal,
): AsyncGenerator<ResearchEvent> {
// /api/research is mounted at the server root — strip any trailing /v1
@@ -68,7 +69,7 @@ export async function* streamResearch(
const response = await fetch(`${base}/api/research`, {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ query }),
body: JSON.stringify({ query, ...(model ? { model } : {}) }),
signal,
});
@@ -106,4 +107,3 @@ export async function* streamResearch(
reader.releaseLock();
}
}
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ModelInfo } from '../types';
class MemoryStorage {
private store = new Map<string, string>();
getItem(key: string): string | null {
return this.store.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
}
const model = (id: string): ModelInfo => ({
id,
object: 'model',
created: 0,
owned_by: 'openjarvis',
});
beforeEach(() => {
vi.resetModules();
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
new MemoryStorage();
});
afterEach(() => {
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
undefined;
});
describe('setModels', () => {
it('does not select an embedding-only model', async () => {
const { useAppStore } = await import('./store');
useAppStore.getState().setModels([model('nomic-embed-text')]);
expect(useAppStore.getState().selectedModel).toBe('');
});
it('clears a missing selection when no chat fallback exists', async () => {
const { useAppStore } = await import('./store');
useAppStore.getState().setSelectedModel('deleted-chat-model');
useAppStore.getState().setModels([model('nomic-embed-text')]);
expect(useAppStore.getState().selectedModel).toBe('');
});
it('replaces an embedding selection with an available chat model', async () => {
const { useAppStore } = await import('./store');
useAppStore.getState().setSelectedModel('all-minilm:latest');
useAppStore.getState().setModels([
model('all-minilm:latest'),
model('qwen3.5:4b'),
]);
expect(useAppStore.getState().selectedModel).toBe('qwen3.5:4b');
});
});
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
class MemoryStorage {
private store = new Map<string, string>();
getItem(key: string): string | null {
return this.store.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
removeItem(key: string): void {
this.store.delete(key);
}
}
beforeEach(() => {
vi.resetModules();
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
new MemoryStorage();
});
afterEach(() => {
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
undefined;
});
async function freshStore() {
return (await import('./store')).useAppStore;
}
describe('conversation stream ownership', () => {
it('persists background stream updates without replacing the active messages', async () => {
const store = await freshStore();
const sourceId = store.getState().createConversation('test-model');
store.getState().addMessage(sourceId, {
id: 'assistant',
role: 'assistant',
content: '',
timestamp: 1,
});
const activeId = store.getState().createConversation('test-model');
store.getState().setStreamState({
conversationId: sourceId,
isStreaming: true,
content: 'streamed response',
});
store.getState().updateLastAssistant(sourceId, 'streamed response');
expect(store.getState().activeId).toBe(activeId);
expect(store.getState().messages).toEqual([]);
store.getState().selectConversation(sourceId);
expect(store.getState().messages).toHaveLength(1);
expect(store.getState().messages[0].content).toBe('streamed response');
});
it('keeps the stream-owning conversation until generation stops', async () => {
const store = await freshStore();
const sourceId = store.getState().createConversation('test-model');
const activeId = store.getState().createConversation('test-model');
store.getState().setStreamState({
conversationId: sourceId,
isStreaming: true,
});
store.getState().deleteConversation(sourceId);
expect(
store.getState().conversations.map((conversation) => conversation.id),
).toContain(sourceId);
expect(store.getState().activeId).toBe(activeId);
store.getState().resetStream();
store.getState().deleteConversation(sourceId);
expect(
store.getState().conversations.map((conversation) => conversation.id),
).not.toContain(sourceId);
});
});
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const CONVERSATIONS_KEY = 'openjarvis-conversations';
class MemoryStorage {
private store = new Map<string, string>();
getItem(key: string): string | null {
return this.store.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
removeItem(key: string): void {
this.store.delete(key);
}
}
beforeEach(() => {
vi.resetModules();
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
new MemoryStorage();
});
afterEach(() => {
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
undefined;
});
describe('persisted tool calls', () => {
it('repairs parsed argument objects while loading conversations', async () => {
localStorage.setItem(
CONVERSATIONS_KEY,
JSON.stringify({
version: 1,
activeId: 'conversation-1',
conversations: {
'conversation-1': {
id: 'conversation-1',
title: 'Broken chat',
createdAt: 1,
updatedAt: 1,
model: 'test-model',
messages: [
{
id: 'assistant-1',
role: 'assistant',
content: '',
timestamp: 1,
toolCalls: [
{
id: 'call-1',
tool: 'web_search',
arguments: { query: 'python' },
status: 'success',
},
],
},
],
},
},
}),
);
const { useAppStore } = await import('./store');
expect(useAppStore.getState().messages[0].toolCalls?.[0].arguments).toBe(
'{"query":"python"}',
);
const repaired = JSON.parse(localStorage.getItem(CONVERSATIONS_KEY) ?? '{}');
expect(
repaired.conversations['conversation-1'].messages[0].toolCalls[0].arguments,
).toBe('{"query":"python"}');
});
it('keeps repaired conversations in memory when writeback fails', async () => {
localStorage.setItem(
CONVERSATIONS_KEY,
JSON.stringify({
version: 1,
activeId: 'conversation-1',
conversations: {
'conversation-1': {
id: 'conversation-1',
title: 'Readable chat',
createdAt: 1,
updatedAt: 1,
model: 'test-model',
messages: [
{
id: 'assistant-1',
role: 'assistant',
content: '',
timestamp: 1,
toolCalls: [
{
id: 'call-1',
tool: 'web_search',
arguments: { query: 'python' },
status: 'success',
},
],
},
],
},
},
}),
);
vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new DOMException('Storage quota exceeded', 'QuotaExceededError');
});
const { useAppStore } = await import('./store');
expect(useAppStore.getState().messages).toHaveLength(1);
expect(useAppStore.getState().messages[0].toolCalls?.[0].arguments).toBe(
'{"query":"python"}',
);
});
});
+71 -13
View File
@@ -15,6 +15,8 @@ import type {
TokenUsage,
} from '../types';
import type { ManagedAgent } from './api';
import { isEmbedOnlyModel } from './model-capabilities';
import { serializeToolCallArguments } from './tool-call';
export interface CachedConnector {
connector_id: string;
@@ -54,7 +56,30 @@ function loadConversations(): ConversationStore {
const raw = localStorage.getItem(CONVERSATIONS_KEY);
if (!raw) return { version: 1, conversations: {}, activeId: null };
const parsed = JSON.parse(raw);
if (parsed.version === 1) return parsed;
if (parsed.version === 1) {
let repaired = false;
for (const conversation of Object.values(parsed.conversations ?? {}) as Conversation[]) {
for (const message of conversation.messages ?? []) {
for (const toolCall of message.toolCalls ?? []) {
const argumentsText = serializeToolCallArguments(toolCall.arguments);
if (argumentsText !== toolCall.arguments) {
toolCall.arguments = argumentsText;
repaired = true;
}
}
}
}
if (repaired) {
try {
localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(parsed));
} catch {
// Keep the repaired conversations usable in memory when storage is
// read-only or full. A failed best-effort writeback must not make
// otherwise readable conversation history disappear from the UI.
}
}
return parsed;
}
return { version: 1, conversations: {}, activeId: null };
} catch {
return { version: 1, conversations: {}, activeId: null };
@@ -110,6 +135,7 @@ function saveSettings(settings: Settings): void {
// ── Store ─────────────────────────────────────────────────────────────
const INITIAL_STREAM: StreamState = {
conversationId: null,
isStreaming: false,
phase: '',
elapsedMs: 0,
@@ -351,6 +377,9 @@ export const useAppStore = create<AppState>((set, get) => {
},
deleteConversation: (id: string) => {
const streamState = get().streamState;
if (streamState.isStreaming && streamState.conversationId === id) return;
const store = loadConversations();
delete store.conversations[id];
if (store.activeId === id) {
@@ -393,12 +422,14 @@ export const useAppStore = create<AppState>((set, get) => {
(message.content.length > 50 ? '...' : '');
}
saveConversations(store);
set({
messages: [...conv.messages],
conversations: Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
),
});
const conversations = Object.values(store.conversations).sort(
(a, b) => b.updatedAt - a.updatedAt,
);
if (get().activeId === conversationId) {
set({ messages: [...conv.messages], conversations });
} else {
set({ conversations });
}
},
updateLastAssistant: (
@@ -425,7 +456,9 @@ export const useAppStore = create<AppState>((set, get) => {
if (researchSources) lastMsg.researchSources = researchSources;
conv.updatedAt = Date.now();
saveConversations(store);
set({ messages: [...conv.messages] });
if (get().activeId === conversationId) {
set({ messages: [...conv.messages] });
}
}
},
@@ -444,11 +477,36 @@ export const useAppStore = create<AppState>((set, get) => {
// ── Models & server ────────────────────────────────────────────
setModels: (models: ModelInfo[]) =>
set((state) =>
!state.selectedModel && models.length > 0
? { models, selectedModel: models[0].id }
: { models },
),
set((state) => {
// Ollama returns embed-only models (e.g. nomic-embed-text) in the
// same list as chat models. Auto-picking models[0] selected the
// embedder and every chat failed with HTTP 400 "does not support
// chat". Prefer a real chat model for selection / fallback.
const chatModels = models.filter((m) => !isEmbedOnlyModel(m.id));
const preferred =
(state.settings.defaultModel &&
chatModels.some((m) => m.id === state.settings.defaultModel) &&
state.settings.defaultModel) ||
chatModels[0]?.id ||
models.find((m) => !isEmbedOnlyModel(m.id))?.id ||
'';
const currentIsBad =
!!state.selectedModel && isEmbedOnlyModel(state.selectedModel);
const currentMissing =
!!state.selectedModel &&
!models.some((m) => m.id === state.selectedModel);
if (!state.selectedModel || currentIsBad || currentMissing) {
// Prefer a real chat model. If none exist, clear a bad/missing
// selection rather than keeping an embed-only id that 400s on chat.
return {
models,
selectedModel: preferred,
};
}
return { models };
}),
setModelsLoading: (loading: boolean) => set({ modelsLoading: loading }),
setSelectedModel: (model: string) => set({ selectedModel: model }),
setServerInfo: (info: ServerInfo | null) => set({ serverInfo: info }),
+7 -4
View File
@@ -1,8 +1,11 @@
export const SUPABASE_URL =
import.meta.env.VITE_SUPABASE_URL || 'https://mtbtgpwzrbostweaanpr.supabase.co';
export const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY;
// The Supabase anon key is optional at build time. When it is unset the public
// savings leaderboard is disabled rather than failing the build — this keeps
// the `openjarvis` package and desktop app buildable without coupling
// publishability to a leaderboard credential. Set VITE_SUPABASE_ANON_KEY at
// build time (from a repo secret) to enable the leaderboard.
export const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY ?? '';
if (!SUPABASE_ANON_KEY) {
throw new Error('VITE_SUPABASE_ANON_KEY is required');
}
export const LEADERBOARD_ENABLED = SUPABASE_ANON_KEY.length > 0;
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { serializeToolCallArguments } from './tool-call';
describe('serializeToolCallArguments', () => {
it('preserves JSON strings', () => {
expect(serializeToolCallArguments('{"query":"python"}')).toBe(
'{"query":"python"}',
);
});
it('serializes parsed argument objects', () => {
expect(serializeToolCallArguments({ query: 'python' })).toBe(
'{"query":"python"}',
);
});
it('uses an empty string for missing arguments', () => {
expect(serializeToolCallArguments(null)).toBe('');
expect(serializeToolCallArguments(undefined)).toBe('');
});
});
+11
View File
@@ -0,0 +1,11 @@
/** Convert tool-call arguments from API or persisted data into display-safe text. */
export function serializeToolCallArguments(value: unknown): string {
if (typeof value === 'string') return value;
if (value == null) return '';
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
+5 -5
View File
@@ -23,7 +23,6 @@ import {
fetchAgentTrace,
fetchManagedAgent,
fetchAvailableTools,
saveToolCredentials,
fetchModels,
updateManagedAgent,
fetchRecommendedModel,
@@ -575,7 +574,7 @@ function ToolsPicker({
</div>
{/* Live description strip */}
<div
className="flex items-center gap-2 px-2.5 py-1.5"
className="flex items-start gap-2 px-2.5 py-1.5"
style={{
borderTop: '1px solid var(--color-border)',
background: 'var(--color-bg)',
@@ -609,10 +608,11 @@ function ToolsPicker({
</span>
)}
<span
className="truncate"
className="min-w-0 whitespace-normal break-words"
style={{
flex: 1,
color: 'var(--color-text-tertiary)',
lineHeight: 1.4,
}}
>
{hovered ? `${hint}` : hint}
@@ -3740,8 +3740,8 @@ export function AgentsPage() {
const paramsB = paramMatch ? parseFloat(paramMatch[1]) : 9;
const flops = 2 * paramsB * 1e9 * (inTok + outTok);
const providers = [
{ label: 'GPT-5.3', inPer1M: 2.0, outPer1M: 10.0 },
{ label: 'Claude Opus 4.6', inPer1M: 5.0, outPer1M: 25.0 },
{ label: 'GPT-5.6 Sol', inPer1M: 5.0, outPer1M: 30.0 },
{ label: 'Claude Fable 5', inPer1M: 10.0, outPer1M: 50.0 },
{ label: 'Gemini 3.1 Pro', inPer1M: 2.0, outPer1M: 12.0 },
];
const energyWh = (inTok + outTok) / 1000 * 0.4;
+37 -10
View File
@@ -27,6 +27,9 @@ import {
setInferenceSource,
getCloudKeyStatus,
saveCloudKey,
fetchToolCredentialStatus,
saveToolCredentials,
deleteToolCredential,
isTauri,
type InferenceSource,
} from '../lib/api';
@@ -56,25 +59,37 @@ function OllamaModelList() {
);
}
function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: string }) {
function ApiKeyInput({
keyName,
placeholder,
toolName,
}: {
keyName: string;
placeholder: string;
toolName?: string;
}) {
const [value, setValue] = useState('');
const [saved, setSaved] = useState(false);
const [hasKey, setHasKey] = useState(false);
const [error, setError] = useState('');
const desktopKeyStorage = isTauri();
const serverToolStorage = !desktopKeyStorage && !!toolName;
const canManage = desktopKeyStorage || serverToolStorage;
const refresh = useCallback(async () => {
if (!desktopKeyStorage) {
if (!canManage) {
setHasKey(false);
return;
}
try {
const status = await getCloudKeyStatus();
const status = desktopKeyStorage
? await getCloudKeyStatus()
: await fetchToolCredentialStatus(toolName!);
setHasKey(!!status[keyName]);
} catch {
setHasKey(false);
}
}, [desktopKeyStorage, keyName]);
}, [canManage, desktopKeyStorage, keyName, toolName]);
useEffect(() => {
void refresh();
@@ -87,7 +102,13 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
if (!next) return;
setError('');
try {
await saveCloudKey(keyName, next);
if (desktopKeyStorage) {
await saveCloudKey(keyName, next);
} else if (toolName) {
await saveToolCredentials(toolName, { [keyName]: next });
} else {
return;
}
setValue('');
setHasKey(true);
setSaved(true);
@@ -101,7 +122,13 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
const remove = async () => {
setError('');
try {
await saveCloudKey(keyName, '');
if (desktopKeyStorage) {
await saveCloudKey(keyName, '');
} else if (toolName) {
await deleteToolCredential(toolName, keyName);
} else {
return;
}
setValue('');
setHasKey(false);
setSaved(true);
@@ -119,8 +146,8 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
value={value}
onChange={e => setValue(e.target.value)}
onBlur={() => { if (value.trim()) void save(value); }}
placeholder={hasKey ? 'Saved in secure storage' : placeholder}
disabled={!desktopKeyStorage}
placeholder={hasKey ? (desktopKeyStorage ? 'Saved in secure storage' : 'Saved by local server') : placeholder}
disabled={!canManage}
className="w-48 px-2 py-1 rounded text-xs"
style={{ background: 'var(--color-bg)', border: '1px solid var(--color-border)', color: 'var(--color-text)' }} />
{hasKey && (
@@ -542,7 +569,7 @@ export function SettingsPage() {
{/* Tools */}
<Section title="Tools">
<SettingRow label="Web Search" description="Tavily key for web search tool">
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." />
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." toolName="web_search" />
</SettingRow>
</Section>
@@ -805,7 +832,7 @@ export function SettingsPage() {
</p>
<div className="flex gap-3 mt-3 text-xs">
<a
href="https://scalingintelligence.stanford.edu/blogs/openjarvis/"
href="https://openjarvis.stanford.edu/"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-accent)' }}
+1
View File
@@ -147,6 +147,7 @@ export interface ConversationStore {
// --- Stream State ---
export interface StreamState {
conversationId: string | null;
isStreaming: boolean;
phase: string;
elapsedMs: number;
+14 -10
View File
@@ -1,16 +1,13 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig(({ command, mode }) => {
const env = loadEnv(mode, __dirname, '');
if (command === 'build' && !env.VITE_SUPABASE_ANON_KEY) {
throw new Error('VITE_SUPABASE_ANON_KEY is required');
}
return {
// VITE_SUPABASE_ANON_KEY is intentionally NOT required here: a missing key
// disables the savings leaderboard at runtime (see src/lib/supabase.ts) rather
// than failing the build, so the package/app stays publishable without it.
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
@@ -57,10 +54,17 @@ export default defineConfig(({ command, mode }) => {
server: {
port: 5173,
proxy: {
'/v1': process.env.VITE_API_URL || 'http://localhost:8000',
// ws: true is required for the /v1/agents/events WebSocket. Without it
// Vite proxies the HTTP request but not the upgrade, so the socket never
// opens — no error, no close event, just silence — and every live agent
// view sits empty in dev while working in a production build.
'/v1': {
target: process.env.VITE_API_URL || 'http://localhost:8000',
changeOrigin: true,
ws: true,
},
'/health': process.env.VITE_API_URL || 'http://localhost:8000',
'/api': process.env.VITE_API_URL || 'http://localhost:8000',
},
},
};
});
+2
View File
@@ -127,6 +127,7 @@ markdown_extensions:
- pymdownx.tilde
extra_javascript:
- javascripts/leaderboard-config.js
- javascripts/leaderboard.js
- https://cdn.jsdelivr.net/npm/@docsearch/js@3
- javascripts/docsearch-init.js
@@ -195,6 +196,7 @@ nav:
- Telemetry: user-guide/telemetry.md
- Evaluations: user-guide/evaluations.md
- Benchmarks: user-guide/benchmarks.md
- System Access: user-guide/system-access.md
- Security: user-guide/security.md
- LLM-guided spec search: user-guide/llm-guided-spec-search.md
- Leaderboard: leaderboard.md
+12
View File
@@ -186,6 +186,9 @@ git_describe_command = [
# Such builds can inject the real version via SETUPTOOLS_SCM_PRETEND_VERSION.
fallback_version = "0.0.0+unknown"
[tool.uv.sources]
openjarvis-rust = { path = "rust/crates/openjarvis-python" }
[tool.hatch.build.targets.wheel]
packages = ["src/openjarvis"]
@@ -235,3 +238,12 @@ select = ["E", "F", "I", "W"]
dev = [
"maturin>=1.12.6",
]
# openjarvis_rust is the native PyO3 extension, built from the local Rust
# workspace. It lives in a uv dependency group (PEP 735) — not the published
# `desktop` extra — so `uv sync --group desktop-native` builds it from source
# for the desktop app, while `pip install openjarvis[desktop]` from PyPI does
# NOT try to resolve openjarvis-rust from PyPI, where it isn't published
# (dependency groups are excluded from wheel metadata). See #584 / #615.
desktop-native = [
"openjarvis-rust",
]
@@ -144,16 +144,21 @@ impl MemoryBackend for SQLiteMemory {
) -> Result<Vec<RetrievalResult>, OpenJarvisError> {
let conn = self.conn.lock();
// Split on any non-alphanumeric character (not just whitespace) so
// internal punctuation — apostrophes in particular ("user's") — never
// reaches the FTS5 MATCH string. FTS5's query grammar treats an
// unescaped `'` as a string delimiter, so passing a raw token like
// `user's` through silently fails to parse and yields zero rows with
// no visible error. Splitting fully avoids needing to escape anything.
let words: Vec<String> = query
.split_whitespace()
.map(|w| w.trim_matches(|c: char| "?.,!;:'\"()[]{}/ ".contains(c)).to_string())
.split(|c: char| !c.is_alphanumeric())
.map(|w| w.to_string())
.filter(|w| !w.is_empty())
.collect();
let fts_query = if words.len() == 1 {
words[0].clone()
} else {
words.join(" OR ")
};
if words.is_empty() {
return Ok(Vec::new());
}
let fts_query = words.join(" OR ");
let mut stmt = conn
.prepare(
@@ -320,6 +325,27 @@ mod tests {
assert_eq!(mixed.len(), 2, "mixed-case query should find both documents");
}
#[test]
fn test_sqlite_apostrophe_in_query() {
let mem = SQLiteMemory::in_memory().unwrap();
mem.store("The user's name is Trev.", "identity", None).unwrap();
// A query containing an internal apostrophe must not break FTS5's
// MATCH syntax (an unescaped `'` is a string delimiter in FTS5's
// query grammar), which previously caused this to silently return
// zero results instead of matching or erroring.
let multi_word = mem.retrieve("what is the user's name", 5).unwrap();
assert!(
!multi_word.is_empty(),
"query with an internal apostrophe should not silently return zero results"
);
// Bare single-word possessive: exercises the (former) single-word
// bypass path that skipped the OR-join entirely.
let bare = mem.retrieve("user's", 5).unwrap();
assert!(!bare.is_empty(), "single-word possessive query should still match");
}
#[test]
fn test_sqlite_scores_are_positive() {
let mem = SQLiteMemory::in_memory().unwrap();
+10 -3
View File
@@ -148,7 +148,8 @@ fi
# ── 7. Install Python dependencies ──────────────────────────────────
info "Installing Python dependencies..."
uv sync --extra desktop --quiet 2>/dev/null || uv sync --extra desktop
uv sync --extra desktop --extra tools-search --quiet 2>/dev/null \
|| uv sync --extra desktop --extra tools-search
ok "Python dependencies installed"
# ── 7b. Build Rust extension ──────────────────────────────────────
@@ -164,11 +165,17 @@ ok "Frontend dependencies installed"
# ── 9. Start backend ────────────────────────────────────────────────
info "Starting backend API server on port 8000..."
if curl -sf http://localhost:8000/health &>/dev/null; then
fail "An OpenJarvis server is already running on port 8000. Stop it before re-running quickstart so updated environment variables are applied."
fi
uv run jarvis serve --port 8000 &>/dev/null &
CLEANUP_PIDS+=($!)
BACKEND_PID=$!
CLEANUP_PIDS+=("$BACKEND_PID")
sleep 3
if curl -sf http://localhost:8000/health &>/dev/null; then
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
fail "Backend exited during startup. Run 'uv run jarvis serve --port 8000' to see the error."
elif curl -sf http://localhost:8000/health &>/dev/null; then
ok "Backend running at http://localhost:8000"
else
warn "Backend may still be starting..."
+28 -2
View File
@@ -57,6 +57,10 @@ class BaseAgent(ABC):
agent_id: str
accepts_tools: bool = False
# Plain conversational agents may opt into the managed runtime's generic
# function-calling loop. Specialized agents keep their own execution
# class even when process-wide MCP tools are available.
supports_managed_tool_fallback: bool = False
def __init__(
self,
@@ -151,6 +155,9 @@ class BaseAgent(ABC):
conversation messages, and finally the user input.
"""
messages: list[Message] = []
context_messages = (
list(context.conversation.messages) if context is not None else []
)
# Check if the context already supplies a system message
_context_has_system = (
context
@@ -172,9 +179,28 @@ class BaseAgent(ABC):
except Exception:
effective_system_prompt = None
if effective_system_prompt:
context_system_text = "\n\n".join(
message.text
for message in context_messages
if message.role == Role.SYSTEM
and message.metadata.get("memory_context")
and message.text
)
if context_system_text:
effective_system_prompt = (
f"{effective_system_prompt}\n\n{context_system_text}"
)
context_messages = [
message
for message in context_messages
if not (
message.role == Role.SYSTEM
and message.metadata.get("memory_context")
)
]
messages.append(Message(role=Role.SYSTEM, content=effective_system_prompt))
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
if context_messages:
messages.extend(context_messages)
messages.append(Message(role=Role.USER, content=input))
return messages
+15
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from openjarvis.engine._base import looks_like_context_length_error
class AgentTickError(Exception):
"""Base class for agent tick errors."""
@@ -64,6 +66,14 @@ def classify_error(exc: Exception) -> AgentTickError:
msg = str(exc).lower()
# A context-window overflow is deterministic: retrying the identical
# over-length request can never succeed, so fail fast instead of burning
# the retry budget on it.
if getattr(exc, "is_context_length_error", False) or (
looks_like_context_length_error(msg)
):
return FatalError(str(exc))
# Check fatal patterns first (more specific)
if isinstance(exc, PermissionError):
return FatalError(str(exc))
@@ -90,6 +100,11 @@ def retry_delay(attempt: int) -> int:
def suggest_action(error: AgentTickError) -> str:
"""Return a human-readable suggested action for the given error."""
msg = str(error).lower()
if looks_like_context_length_error(msg):
return (
"Conversation too long for the model's context window \u2014 "
"start a new chat or shorten the conversation"
)
if any(p in msg for p in ("rate limit", "rate_limit", "429", "too many requests")):
return "Rate limited \u2014 agent will auto-retry on next tick"
if any(p in msg for p in ("timeout", "timed out", "connection", "unavailable")):
+192 -109
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import json
import logging
import threading
import time
from typing import TYPE_CHECKING, Any
@@ -14,6 +16,7 @@ from openjarvis.agents.errors import (
classify_error,
retry_delay,
)
from openjarvis.agents.tool_resolver import resolve_agent_tools
from openjarvis.core.events import EventBus, EventType
if TYPE_CHECKING:
@@ -33,6 +36,32 @@ _MAX_RETRIES = 3
_AGENT_TICK_DEFAULT_MODEL = "gemma4:31b"
def _tool_calls_for_storage(result: AgentResult) -> list[dict[str, Any]] | None:
"""Convert executor tool results to the managed-message storage contract."""
calls: list[dict[str, Any]] = []
for tool_result in result.tool_results:
metadata = getattr(tool_result, "metadata", {}) or {}
arguments = metadata.get("arguments", "")
if not isinstance(arguments, str):
try:
arguments = json.dumps(arguments, sort_keys=True)
except (TypeError, ValueError):
arguments = json.dumps(str(arguments))
calls.append(
{
"tool": getattr(tool_result, "tool_name", ""),
"arguments": arguments,
"result": getattr(tool_result, "content", "") or "",
"success": bool(getattr(tool_result, "success", False)),
# SSE and the frontend persist/display latency in milliseconds.
"latency": float(getattr(tool_result, "latency_seconds", 0.0) or 0.0)
* 1000.0,
}
)
return calls or None
class AgentExecutor:
"""Executes a single tick for a managed agent.
@@ -51,6 +80,7 @@ class AgentExecutor:
self._manager = manager
self._bus = event_bus
self._trace_store = trace_store
self._toolkit_local = threading.local()
def set_system(self, system: Any) -> None:
"""Deferred system injection — called after JarvisSystem is constructed."""
@@ -63,27 +93,6 @@ class AgentExecutor:
except Exception:
pass # Non-critical
def _inject_tool_deps(self, tool: Any) -> None:
"""Inject runtime dependencies into a tool instance.
Mirrors SystemBuilder._inject_tool_deps (system.py:920-945)
but uses the lightweight system's references.
"""
if self._system is None:
return
name = getattr(getattr(tool, "spec", None), "name", "")
if name == "llm":
if hasattr(tool, "_engine"):
tool._engine = self._system.engine
if hasattr(tool, "_model"):
tool._model = self._system.model
elif name == "retrieval" or name.startswith("memory_"):
if hasattr(tool, "_backend"):
tool._backend = getattr(self._system, "memory_backend", None)
elif name.startswith("channel_"):
if hasattr(tool, "_channel"):
tool._channel = getattr(self._system, "channel_backend", None)
def run_ephemeral(
self,
agent_type: str,
@@ -102,9 +111,7 @@ class AgentExecutor:
)
return agent.run(input_text)
def execute_tick(
self, agent_id: str, *, lock_already_held: bool = False
) -> None:
def execute_tick(self, agent_id: str, *, lock_already_held: bool = False) -> None:
"""Run one tick for the given agent.
1. Acquire concurrency guard (start_tick)
@@ -126,9 +133,7 @@ class AgentExecutor:
self._manager.start_tick(agent_id)
self._set_activity(agent_id, "Preparing tick...")
except ValueError:
logger.warning(
"Agent %s already running, skipping tick", agent_id
)
logger.warning("Agent %s already running, skipping tick", agent_id)
return
agent = self._manager.get_agent(agent_id)
@@ -252,7 +257,20 @@ class AgentExecutor:
raise last_error or FatalError("max retries exhausted")
def _invoke_agent(self, agent: dict) -> AgentResult:
"""Invoke the actual agent run. Tests mock this method."""
"""Invoke one agent while owning every resource its resolver opens."""
previous = getattr(self._toolkit_local, "current", None)
self._toolkit_local.current = None
try:
return self._invoke_agent_impl(agent)
finally:
current = getattr(self._toolkit_local, "current", None)
if current is not None:
current.close()
self._toolkit_local.current = previous
def _invoke_agent_impl(self, agent: dict) -> AgentResult:
"""Implementation split out so the wrapper owns resolver lifetime."""
from openjarvis.agents import AgentRegistry
agent_type = agent.get("agent_type", "monitor_operative")
@@ -261,6 +279,10 @@ class AgentExecutor:
raise FatalError(f"Unknown agent type: {agent_type}")
config = agent.get("config", {})
agent_accepts_tools = bool(getattr(agent_cls, "accepts_tools", False))
supports_tool_fallback = bool(
getattr(agent_cls, "supports_managed_tool_fallback", False)
)
# Resolve engine + model from JarvisSystem
engine = self._system.engine if self._system else None
@@ -304,64 +326,88 @@ class AgentExecutor:
except Exception:
pass # Fall back to configured model
# Resolve tools from config via ToolRegistry
tool_names = config.get("tools", [])
if isinstance(tool_names, str):
tool_names = [t.strip() for t in tool_names.split(",") if t.strip()]
mcp_tools: list[Any] = []
mcp_clients: list[Any] = []
if (
config.get("mcp_tools", True) is not False
and self._system is not None
and (agent_accepts_tools or supports_tool_fallback)
):
provider = getattr(
self._system,
"get_managed_agent_mcp_tools",
None,
)
if callable(provider):
try:
mcp_tools, mcp_clients = provider()
except Exception as exc:
logger.warning("Managed-agent MCP discovery failed: %s", exc)
else:
mcp_tools = list(getattr(self._system, "mcp_tools", []) or [])
mcp_clients = list(getattr(self._system, "_mcp_clients", []) or [])
tool_instances: list[Any] = []
if tool_names:
try:
from openjarvis.server.agent_manager_routes import (
_ensure_registries_populated,
)
if not mcp_tools:
try:
from openjarvis.tools.mcp_adapter import MCPToolAdapter
_ensure_registries_populated()
except ImportError:
pass
from openjarvis.core.registry import ToolRegistry
pool = (
getattr(
getattr(self._system, "tool_executor", None),
"_tools",
{},
)
or {}
)
mcp_tools = [
tool
for tool in pool.values()
if isinstance(tool, MCPToolAdapter)
]
except Exception:
mcp_tools = []
for tname in tool_names:
if ToolRegistry.contains(tname):
try:
tool_cls = ToolRegistry.get(tname)
tool = tool_cls()
self._inject_tool_deps(tool)
tool_instances.append(tool)
except Exception:
logger.warning("Failed to instantiate tool %s", tname)
resolved_toolkit = resolve_agent_tools(
agent,
engine=engine,
model=model,
memory_backend=getattr(self._system, "memory_backend", None),
channel_backend=getattr(self._system, "channel_backend", None),
mcp_tools=mcp_tools,
mcp_clients=mcp_clients,
knowledge_db_path=getattr(self._system, "knowledge_db_path", None),
)
self._toolkit_local.current = resolved_toolkit
tool_instances = resolved_toolkit.instances
logger.info(
"Agent %s: resolved %d tools (%s)",
agent["name"],
len(tool_instances),
", ".join(resolved_toolkit.by_name) or "none",
)
# Pull tools already discovered by SystemBuilder (e.g. external MCP
# adapters) that aren't in the static ToolRegistry. Without this,
# agents declaring MCP-discovered tools in their template would
# silently fall back to natives only.
if (
self._system is not None
and getattr(self._system, "tool_executor", None) is not None
):
mcp_pool = getattr(self._system.tool_executor, "_tools", {}) or {}
existing = {t.spec.name for t in tool_instances}
for tname in tool_names:
if tname in existing:
continue
pooled = mcp_pool.get(tname)
if pooled is not None:
tool_instances.append(pooled)
execution_agent_cls = agent_cls
if tool_instances and not agent_accepts_tools and supports_tool_fallback:
# Managed SSE already runs configured tools through a native
# function-calling loop regardless of the selected class. Use the
# same capability for immediate/scheduled ticks instead of
# silently discarding the resolved toolkit for SimpleAgent and
# other explicitly compatible non-tool classes.
from openjarvis.agents.orchestrator import OrchestratorAgent
if tool_instances:
logger.info(
"Agent %s: resolved %d/%d tools",
agent["name"],
len(tool_instances),
len(tool_names),
)
execution_agent_cls = OrchestratorAgent
logger.info(
"Agent %s: %s does not accept tools; using %s for this "
"tool-enabled tick",
agent["name"],
agent_cls.__name__,
execution_agent_cls.__name__,
)
# Construct agent instance
agent_kwargs: dict[str, Any] = {}
sys_prompt = config.get("system_prompt")
if sys_prompt is not None:
agent_kwargs["system_prompt"] = sys_prompt
if getattr(agent_cls, "accepts_tools", False) and tool_instances:
if getattr(execution_agent_cls, "accepts_tools", False) and tool_instances:
agent_kwargs["tools"] = tool_instances
# Hand the agent our EventBus so its ToolExecutor can publish
# TOOL_CALL_START/END — without this, ToolExecutor's ``self._bus``
@@ -383,7 +429,7 @@ class AgentExecutor:
# recall / persistence paths.
import inspect
init_sig = inspect.signature(agent_cls.__init__)
init_sig = inspect.signature(execution_agent_cls.__init__)
accepts_var_kw = any(
p.kind == inspect.Parameter.VAR_KEYWORD
for p in init_sig.parameters.values()
@@ -392,6 +438,16 @@ class AgentExecutor:
def _accepts(name: str) -> bool:
return accepts_var_kw or name in init_sig.parameters
# Unsupported kwargs used to trigger the broad TypeError fallback
# below, which retried with a bare constructor and silently discarded
# valid prompt/state wiring. Filter by the selected class's signature
# before construction instead.
if sys_prompt is not None and _accepts("system_prompt"):
agent_kwargs["system_prompt"] = sys_prompt
agent_kwargs = {
name: value for name, value in agent_kwargs.items() if _accepts(name)
}
state_kwargs: dict[str, Any] = {}
if _accepts("operator_id"):
state_kwargs["operator_id"] = agent["id"]
@@ -408,27 +464,49 @@ class AgentExecutor:
# agents, mirroring the one-shot `jarvis ask` path so they no
# longer apply to CLI calls only (#376).
cfg = getattr(self._system, "config", None)
if cfg is not None and _accepts("prompt_builder"):
if _accepts("prompt_builder") and (
cfg is not None or sys_prompt is not None
):
from openjarvis.prompt.builder import SystemPromptBuilder
state_kwargs["prompt_builder"] = SystemPromptBuilder(
agent_template=getattr(
cfg.agent, "default_system_prompt", ""
)
or "",
memory_files_config=cfg.memory_files,
system_prompt_config=cfg.system_prompt,
agent_template=(
sys_prompt
if sys_prompt is not None
else getattr(
getattr(cfg, "agent", None),
"default_system_prompt",
"",
)
or ""
),
memory_files_config=getattr(cfg, "memory_files", None),
system_prompt_config=getattr(cfg, "system_prompt", None),
)
try:
agent_instance = agent_cls(
engine, model, **agent_kwargs, **state_kwargs
)
except TypeError:
try:
agent_instance = agent_cls(engine, model, **agent_kwargs)
agent_instance = execution_agent_cls(
engine,
model,
**agent_kwargs,
**state_kwargs,
)
except TypeError:
agent_instance = agent_cls(engine, model)
try:
agent_instance = execution_agent_cls(
engine,
model,
**agent_kwargs,
)
except TypeError:
agent_instance = execution_agent_cls(engine, model)
except Exception:
resolved_toolkit.close()
raise
if resolved_toolkit.mcp_clients:
agent_instance._mcp_clients = resolved_toolkit.mcp_clients
# Inject the managed-agent UUID into the agent's ToolExecutor so
# emitted TOOL_CALL_START/END events carry it; the trace subscriber
@@ -444,7 +522,7 @@ class AgentExecutor:
agent["name"],
len(tool_instances),
", ".join(t.spec.name for t in tool_instances) or "none",
agent_cls.__name__,
execution_agent_cls.__name__,
)
# Build input from instruction + summary_memory + pending messages.
@@ -474,9 +552,7 @@ class AgentExecutor:
tick_note = f"Previous tick: {first_sentence}"
if instruction:
input_text = (
f"Current date: {today}\n\nStanding instruction: {instruction}"
)
input_text = f"Current date: {today}\n\nStanding instruction: {instruction}"
if tick_note:
input_text += f"\n\n{tick_note}"
else:
@@ -561,21 +637,24 @@ class AgentExecutor:
len(input_text),
)
_t0 = time.time()
result = agent_instance.run(input_text, context=agent_ctx)
# Retry once if the model returned empty content (common with
# Qwen3.5 thinking mode consuming all tokens).
if not (result.content or "").strip():
self._set_activity(
agent["id"],
"Retrying (empty response)...",
)
logger.warning(
"Agent %s: empty content, retrying once",
agent["name"],
)
try:
result = agent_instance.run(input_text, context=agent_ctx)
# Retry once if the model returned empty content (common with
# Qwen3.5 thinking mode consuming all tokens).
if not (result.content or "").strip():
self._set_activity(
agent["id"],
"Retrying (empty response)...",
)
logger.warning(
"Agent %s: empty content, retrying once",
agent["name"],
)
result = agent_instance.run(input_text, context=agent_ctx)
finally:
resolved_toolkit.close()
_elapsed = time.time() - _t0
logger.info(
"Agent %s: agent.run() completed in %.1fs, "
@@ -665,7 +744,11 @@ class AgentExecutor:
# message keeps the complete report. The old [:2000] slices
# double-truncated and cut findings off mid-sentence.
self._manager.update_summary_memory(agent_id, result.content)
self._manager.store_agent_response(agent_id, result.content)
self._manager.store_agent_response(
agent_id,
result.content,
tool_calls=_tool_calls_for_storage(result),
)
# Budget enforcement (post-tick check)
agent_data = self._manager.get_agent(agent_id)
+232 -182
View File
@@ -303,7 +303,9 @@ def _openrouter_limiter() -> _OpenRouterLimiter:
if _OPENROUTER_LIMITER is None:
with _OPENROUTER_LIMITER_LOCK:
if _OPENROUTER_LIMITER is None:
max_concurrent = int(os.environ.get("OJ_OPENROUTER_MAX_CONCURRENT", "20") or 20)
max_concurrent = int(
os.environ.get("OJ_OPENROUTER_MAX_CONCURRENT", "20") or 20
)
rpm = int(os.environ.get("OJ_OPENROUTER_RPM", "60") or 60)
_OPENROUTER_LIMITER = _OpenRouterLimiter(max_concurrent, rpm)
return _OPENROUTER_LIMITER
@@ -319,8 +321,14 @@ def _serialize_block(block: Any) -> Dict[str, Any]:
"""
out: Dict[str, Any] = {"type": getattr(block, "type", type(block).__name__)}
for attr in (
"id", "name", "input", "text", "thinking", "signature",
"tool_use_id", "content",
"id",
"name",
"input",
"text",
"thinking",
"signature",
"tool_use_id",
"content",
):
if hasattr(block, attr):
val = getattr(block, attr)
@@ -341,14 +349,16 @@ def _serialize_openai_tool_calls(tool_calls: Any) -> List[Dict[str, Any]]:
return out
for tc in tool_calls:
fn = getattr(tc, "function", None)
out.append({
"id": getattr(tc, "id", None),
"type": getattr(tc, "type", "function"),
"function": {
"name": getattr(fn, "name", None) if fn else None,
"arguments": getattr(fn, "arguments", None) if fn else None,
},
})
out.append(
{
"id": getattr(tc, "id", None),
"type": getattr(tc, "type", "function"),
"function": {
"name": getattr(fn, "name", None) if fn else None,
"arguments": getattr(fn, "arguments", None) if fn else None,
},
}
)
return out
@@ -473,32 +483,46 @@ class LocalCloudAgent(BaseAgent):
srv = getattr(msg.usage, "server_tool_use", None)
n_searches = getattr(srv, "web_search_requests", 0) if srv else 0
content_blocks = [_serialize_block(b) for b in msg.content]
tool_use_blocks = [b for b in content_blocks if b.get("type") in (
"tool_use", "server_tool_use",
)]
tool_result_blocks = [b for b in content_blocks if b.get("type") in (
"web_search_tool_result", "tool_result",
)]
_record_event({
"kind": "anthropic",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"content_blocks": content_blocks,
"tool_calls": tool_use_blocks,
"tool_results": tool_result_blocks,
"tokens_in": msg.usage.input_tokens,
"tokens_out": msg.usage.output_tokens,
"n_web_searches": n_searches,
"tools_declared": tools,
"tool_choice": tool_choice,
"output_config": output_config,
"stop_reason": getattr(msg, "stop_reason", None),
"latency_s": latency,
"ts": time.time(),
})
tool_use_blocks = [
b
for b in content_blocks
if b.get("type")
in (
"tool_use",
"server_tool_use",
)
]
tool_result_blocks = [
b
for b in content_blocks
if b.get("type")
in (
"web_search_tool_result",
"tool_result",
)
]
_record_event(
{
"kind": "anthropic",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"content_blocks": content_blocks,
"tool_calls": tool_use_blocks,
"tool_results": tool_result_blocks,
"tokens_in": msg.usage.input_tokens,
"tokens_out": msg.usage.output_tokens,
"n_web_searches": n_searches,
"tools_declared": tools,
"tool_choice": tool_choice,
"output_config": output_config,
"stop_reason": getattr(msg, "stop_reason", None),
"latency_s": latency,
"ts": time.time(),
}
)
return text, msg.usage.input_tokens, msg.usage.output_tokens, n_searches
@staticmethod
@@ -550,24 +574,26 @@ class LocalCloudAgent(BaseAgent):
u = resp.usage
p = getattr(u, "prompt_tokens", 0) if u else 0
c = getattr(u, "completion_tokens", 0) if u else 0
_record_event({
"kind": "openai",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"tool_calls": tool_calls,
"reasoning_content": reasoning,
"tokens_in": p,
"tokens_out": c,
"response_format": response_format,
"tools_declared": tools,
"tool_choice": tool_choice,
"finish_reason": getattr(choice, "finish_reason", None),
"latency_s": latency,
"ts": time.time(),
})
_record_event(
{
"kind": "openai",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"tool_calls": tool_calls,
"reasoning_content": reasoning,
"tokens_in": p,
"tokens_out": c,
"response_format": response_format,
"tools_declared": tools,
"tool_choice": tool_choice,
"finish_reason": getattr(choice, "finish_reason", None),
"latency_s": latency,
"ts": time.time(),
}
)
return text, p, c
@staticmethod
@@ -614,12 +640,10 @@ class LocalCloudAgent(BaseAgent):
from openai import OpenAI
if model.startswith("openrouter/"):
model = model[len("openrouter/"):]
model = model[len("openrouter/") :]
api_key = os.environ.get("OPENROUTER_API_KEY")
if not api_key:
raise RuntimeError(
"OPENROUTER_API_KEY is not set; cannot call OpenRouter."
)
raise RuntimeError("OPENROUTER_API_KEY is not set; cannot call OpenRouter.")
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=api_key,
@@ -658,21 +682,23 @@ class LocalCloudAgent(BaseAgent):
u = resp.usage
p = getattr(u, "prompt_tokens", 0) if u else 0
c = getattr(u, "completion_tokens", 0) if u else 0
_record_event({
"kind": "openrouter",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"tool_calls": tool_calls,
"reasoning_content": reasoning,
"tokens_in": p,
"tokens_out": c,
"finish_reason": getattr(choice, "finish_reason", None),
"latency_s": latency,
"ts": time.time(),
})
_record_event(
{
"kind": "openrouter",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"tool_calls": tool_calls,
"reasoning_content": reasoning,
"tokens_in": p,
"tokens_out": c,
"finish_reason": getattr(choice, "finish_reason", None),
"latency_s": latency,
"ts": time.time(),
}
)
return text, p, c
@staticmethod
@@ -701,7 +727,9 @@ class LocalCloudAgent(BaseAgent):
from google import genai
from google.genai import types
client = genai.Client(http_options=types.HttpOptions(timeout=int(timeout * 1000)))
client = genai.Client(
http_options=types.HttpOptions(timeout=int(timeout * 1000))
)
cfg = types.GenerateContentConfig(
temperature=temperature,
max_output_tokens=max_tokens,
@@ -728,21 +756,23 @@ class LocalCloudAgent(BaseAgent):
finish_reason = str(resp.candidates[0].finish_reason)
except Exception:
pass
_record_event({
"kind": "gemini",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"tokens_in": p,
"tokens_out": c,
"temperature": temperature,
"max_tokens": max_tokens,
"finish_reason": finish_reason,
"latency_s": latency,
"ts": time.time(),
})
_record_event(
{
"kind": "gemini",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"tokens_in": p,
"tokens_out": c,
"temperature": temperature,
"max_tokens": max_tokens,
"finish_reason": finish_reason,
"latency_s": latency,
"ts": time.time(),
}
)
return text, p, c
@staticmethod
@@ -797,27 +827,29 @@ class LocalCloudAgent(BaseAgent):
u = resp.usage
p = getattr(u, "prompt_tokens", 0) if u else 0
c = getattr(u, "completion_tokens", 0) if u else 0
_record_event({
"kind": "vllm",
"role": trace_role,
"model": model,
"endpoint": endpoint,
"system": system,
"user": user,
"response": text,
"tool_calls": tool_calls,
"reasoning_content": reasoning,
"tokens_in": p,
"tokens_out": c,
"temperature": temperature,
"max_tokens": max_tokens,
"enable_thinking": enable_thinking,
"tools_declared": tools,
"tool_choice": tool_choice,
"finish_reason": getattr(choice, "finish_reason", None),
"latency_s": latency,
"ts": time.time(),
})
_record_event(
{
"kind": "vllm",
"role": trace_role,
"model": model,
"endpoint": endpoint,
"system": system,
"user": user,
"response": text,
"tool_calls": tool_calls,
"reasoning_content": reasoning,
"tokens_in": p,
"tokens_out": c,
"temperature": temperature,
"max_tokens": max_tokens,
"enable_thinking": enable_thinking,
"tools_declared": tools,
"tool_choice": tool_choice,
"finish_reason": getattr(choice, "finish_reason", None),
"latency_s": latency,
"ts": time.time(),
}
)
return text, p, c
@staticmethod
@@ -884,33 +916,37 @@ class LocalCloudAgent(BaseAgent):
n_searches = getattr(srv, "web_search_requests", 0) if srv else 0
content_blocks = [_serialize_block(b) for b in msg.content]
tool_use_blocks = [
b for b in content_blocks
b
for b in content_blocks
if b.get("type") in ("tool_use", "server_tool_use")
]
tool_result_blocks = [
b for b in content_blocks
b
for b in content_blocks
if b.get("type") in ("web_search_tool_result", "tool_result")
]
stop_reason = getattr(msg, "stop_reason", None)
_record_event({
"kind": "anthropic",
"role": trace_role,
"model": model,
"system": system if turn == 0 else None,
"user": user if turn == 0 else None,
"turn": turn,
"response": text,
"content_blocks": content_blocks,
"tool_calls": tool_use_blocks,
"tool_results": tool_result_blocks,
"tokens_in": msg.usage.input_tokens,
"tokens_out": msg.usage.output_tokens,
"n_web_searches": n_searches,
"tools_declared": tools,
"stop_reason": stop_reason,
"latency_s": latency,
"ts": time.time(),
})
_record_event(
{
"kind": "anthropic",
"role": trace_role,
"model": model,
"system": system if turn == 0 else None,
"user": user if turn == 0 else None,
"turn": turn,
"response": text,
"content_blocks": content_blocks,
"tool_calls": tool_use_blocks,
"tool_results": tool_result_blocks,
"tokens_in": msg.usage.input_tokens,
"tokens_out": msg.usage.output_tokens,
"n_web_searches": n_searches,
"tools_declared": tools,
"stop_reason": stop_reason,
"latency_s": latency,
"ts": time.time(),
}
)
p_total += msg.usage.input_tokens
c_total += msg.usage.output_tokens
n_searches_total += n_searches
@@ -920,9 +956,7 @@ class LocalCloudAgent(BaseAgent):
# here — break and let the caller (or future loop variant)
# handle it. Only ``server_tool_use`` blocks (web_search)
# are auto-continued by Anthropic itself.
client_tool_use = any(
b.get("type") == "tool_use" for b in content_blocks
)
client_tool_use = any(b.get("type") == "tool_use" for b in content_blocks)
if client_tool_use:
break
if stop_reason == "end_turn" or stop_reason is None:
@@ -931,10 +965,12 @@ class LocalCloudAgent(BaseAgent):
# (server side) — Anthropic returned mid-thought. Append the
# assistant turn and ask it to continue.
messages.append({"role": "assistant", "content": msg.content})
messages.append({
"role": "user",
"content": "Continue.",
})
messages.append(
{
"role": "user",
"content": "Continue.",
}
)
return last_text, p_total, c_total, n_searches_total, turns
@staticmethod
@@ -1008,8 +1044,12 @@ class LocalCloudAgent(BaseAgent):
continue
raise
if resp is None:
raise last_exc if last_exc is not None else RuntimeError(
"openai responses.create failed for all web_search tool names"
raise (
last_exc
if last_exc is not None
else RuntimeError(
"openai responses.create failed for all web_search tool names"
)
)
_bump_cloud_calls()
latency = time.time() - t0
@@ -1033,30 +1073,35 @@ class LocalCloudAgent(BaseAgent):
text = "".join(chunks)
n_searches = sum(
1 for item in output_items
if getattr(item, "type", None) in (
"web_search_call", "web_search_tool_call",
1
for item in output_items
if getattr(item, "type", None)
in (
"web_search_call",
"web_search_tool_call",
)
)
u = getattr(resp, "usage", None)
p = int(getattr(u, "input_tokens", 0) or 0) if u else 0
c = int(getattr(u, "output_tokens", 0) or 0) if u else 0
_record_event({
"kind": "openai_agent",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"output_items": _jsonable(output_items),
"tokens_in": p,
"tokens_out": c,
"n_web_searches": n_searches,
"tools_declared": [{"type": used_tool_name}],
"stop_reason": getattr(resp, "status", None),
"latency_s": latency,
"ts": time.time(),
})
_record_event(
{
"kind": "openai_agent",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"output_items": _jsonable(output_items),
"tokens_in": p,
"tokens_out": c,
"n_web_searches": n_searches,
"tools_declared": [{"type": used_tool_name}],
"stop_reason": getattr(resp, "status", None),
"latency_s": latency,
"ts": time.time(),
}
)
return text, p, c, n_searches, 1
@staticmethod
@@ -1129,23 +1174,25 @@ class LocalCloudAgent(BaseAgent):
n_searches = len(web_search_queries)
except Exception: # noqa: BLE001
pass
_record_event({
"kind": "gemini_agent",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"tokens_in": p,
"tokens_out": c,
"n_web_searches": n_searches,
"web_search_queries": web_search_queries,
"temperature": temperature,
"max_tokens": max_tokens,
"finish_reason": finish_reason,
"latency_s": latency,
"ts": time.time(),
})
_record_event(
{
"kind": "gemini_agent",
"role": trace_role,
"model": model,
"system": system,
"user": user,
"response": text,
"tokens_in": p,
"tokens_out": c,
"n_web_searches": n_searches,
"web_search_queries": web_search_queries,
"temperature": temperature,
"max_tokens": max_tokens,
"finish_reason": finish_reason,
"latency_s": latency,
"ts": time.time(),
}
)
return text, p, c, n_searches, 1
def _call_cloud(
@@ -1269,8 +1316,13 @@ class LocalCloudAgent(BaseAgent):
# Persist the trace before the trace state is closed (and even on
# hard failure, so we get a record of what we did before it broke).
self._write_trace_log(
context, input, answer, meta if "meta" in locals() else {},
events, soft_reason, exc_obj,
context,
input,
answer,
meta if "meta" in locals() else {},
events,
soft_reason,
exc_obj,
)
_close_trace()
_close_call_counts()
@@ -1326,9 +1378,7 @@ class LocalCloudAgent(BaseAgent):
"metadata": meta,
"events": events,
"soft_error": soft_reason,
"error": (
f"{type(exc).__name__}: {exc}" if exc is not None else None
),
"error": (f"{type(exc).__name__}: {exc}" if exc is not None else None),
}
(out_dir / f"{task_id}.json").write_text(
json.dumps(blob, indent=2, default=str)
+1
View File
@@ -96,6 +96,7 @@ class EnergyCollector:
return self
try:
import pynvml # type: ignore[import-not-found]
pynvml.nvmlInit()
total = pynvml.nvmlDeviceGetCount()
self.gpu_indices = _resolve_gpu_indices(total)
+21 -16
View File
@@ -174,12 +174,15 @@ def _is_retryable(exc: BaseException) -> bool:
import openai
except ImportError:
return False
if isinstance(exc, (
openai.RateLimitError,
openai.APITimeoutError,
openai.APIConnectionError,
openai.InternalServerError,
)):
if isinstance(
exc,
(
openai.RateLimitError,
openai.APITimeoutError,
openai.APIConnectionError,
openai.InternalServerError,
),
):
return True
if isinstance(exc, openai.APIStatusError):
status = getattr(exc, "status_code", None)
@@ -196,7 +199,7 @@ def _sleep_for(attempt: int, exc: BaseException) -> float:
# Respect a server-provided hint, but clamp to our cap so a
# pathological header can't stall the run for hours.
return min(_RETRY_CAP, hinted) + random.uniform(0, 0.5)
base = min(_RETRY_CAP, _RETRY_BASE * (2 ** attempt))
base = min(_RETRY_CAP, _RETRY_BASE * (2**attempt))
# Full jitter — better tail behavior than equal jitter when many
# workers wake at the same moment.
return random.uniform(0.0, base)
@@ -237,16 +240,19 @@ def _wrap_create(orig: Callable[..., Any]) -> Callable[..., Any]:
import openai
except ImportError:
raise
if not isinstance(exc, (
openai.APIConnectionError,
openai.APITimeoutError,
openai.InternalServerError,
)):
if not isinstance(
exc,
(
openai.APIConnectionError,
openai.APITimeoutError,
openai.InternalServerError,
),
):
raise
local_last_exc = exc
if attempt >= 2:
break
time.sleep(2 ** attempt)
time.sleep(2**attempt)
assert local_last_exc is not None
raise local_last_exc
@@ -266,6 +272,7 @@ def _wrap_create(orig: Callable[..., Any]) -> Callable[..., Any]:
# stay parseable in the runner log.
try:
import sys
print(
f"[openai-retry] attempt {attempt + 1}/{_MAX_RETRIES} "
f"{type(exc).__name__}: {str(exc)[:120]}"
@@ -333,9 +340,7 @@ def patch_openai_globally() -> None:
from openai.resources.chat import completions as _comp_mod_async
cls = getattr(_comp_mod_async, "AsyncCompletions", None)
if cls is not None and not getattr(
cls.create, "_hybrid_patched", False
):
if cls is not None and not getattr(cls.create, "_hybrid_patched", False):
# Async wrapper is structurally different — only patch
# the bumped defaults via __init__; full retry loop on
# async would need an async wrapper. Leave that for the
+16 -20
View File
@@ -11,27 +11,27 @@ from __future__ import annotations
# USD per million tokens, (input, output). Local models = 0.
PRICES: dict[str, tuple[float, float]] = {
"claude-opus-4-7": (5.00, 25.0),
"claude-sonnet-4-6": (3.00, 15.0),
"claude-haiku-4-5": (1.00, 5.00),
"claude-haiku-4-5-20251001": (1.00, 5.00),
"gpt-5.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),
"claude-opus-4-7": (5.00, 25.0),
"claude-sonnet-4-6": (3.00, 15.0),
"claude-haiku-4-5": (1.00, 5.00),
"claude-haiku-4-5-20251001": (1.00, 5.00),
"gpt-5.5": (5.00, 30.0),
"gpt-5": (1.25, 10.0),
"gpt-5-mini": (0.25, 2.00),
"gpt-5-mini-2025-08-07": (0.25, 2.00),
"gpt-4o": (0.15, 0.60),
# Gemini Developer API prices (USD per 1M tokens). Pro models use tiered
# pricing above 200K prompt tokens; GAIA prompts stay under that tier, so
# charge the low-context standard rate.
"gemini-3.1-pro-preview": (2.00, 12.0),
"gemini-3.1-pro-preview": (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),
"gemini-2.5-pro": (1.25, 10.0),
"gemini-2.5-flash": (0.30, 2.50),
"gemini-2.5-flash-lite": (0.10, 0.40),
# OpenRouter slugs (used by toolorchestra paper-match pool).
# Prices are OpenRouter list (USD/1M tokens), 2026-05 snapshot.
"qwen/qwen-2.5-coder-32b-instruct": (0.08, 0.18),
"qwen/qwen3-32b": (0.10, 0.30),
"qwen/qwen-2.5-coder-32b-instruct": (0.08, 0.18),
"qwen/qwen3-32b": (0.10, 0.30),
"meta-llama/llama-3.3-70b-instruct": (0.13, 0.39),
}
@@ -64,11 +64,7 @@ def is_reasoning_model(model: str) -> bool:
before emitting visible answer text. At max_tokens=4096 these silently
truncate with empty answers on GAIA (26/100 GPT-5, 18/100 Gemini Pro)."""
m = (model or "").lower()
return (
is_gpt5_family(model)
or "gemini-2.5-pro" in m
or "gemini-3.1-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:
+25 -22
View File
@@ -80,9 +80,7 @@ def _resolve_local_model(endpoint: str, registry_model: str) -> str:
a model id (e.g. ``Qwen3.5-9B``) that's different from what's loaded.
"""
try:
with urllib.request.urlopen(
endpoint.rstrip("/") + "/models", timeout=5
) as r:
with urllib.request.urlopen(endpoint.rstrip("/") + "/models", timeout=5) as r:
data = json.loads(r.read())
served = [m["id"] for m in data.get("data", [])]
except Exception:
@@ -158,14 +156,15 @@ class AdvisorsAgent(LocalCloudAgent):
# 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,
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,
(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
@@ -186,7 +185,8 @@ class AdvisorsAgent(LocalCloudAgent):
)
local_model = _resolve_local_model(self._local_endpoint, self._local_model)
advisor_prompt = ADVISOR_TEMPLATE.format(
question=question, initial_response=initial_resp,
question=question,
initial_response=initial_resp,
)
advisor_text, adv_in, adv_out = self._call_vllm(
local_model,
@@ -206,14 +206,15 @@ class AdvisorsAgent(LocalCloudAgent):
f"answer-format rules."
)
if use_ws:
(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,
(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
@@ -424,8 +425,10 @@ class AdvisorsAgent(LocalCloudAgent):
tokens_local = adv_in + adv_out
tokens_cloud = (
initial_out["tokens_in"] + initial_out["tokens_out"]
+ final_out["tokens_in"] + final_out["tokens_out"]
initial_out["tokens_in"]
+ initial_out["tokens_out"]
+ final_out["tokens_in"]
+ final_out["tokens_out"]
)
cost = initial_out["cost_usd"] + final_out["cost_usd"]
meta: Dict[str, Any] = {
+174 -117
View File
@@ -73,6 +73,7 @@ ARCHON_SWE_RANKER_SYS = (
# ---------- Stubs for Archon's eager-imported heavy deps we don't need ----------
def _stub_archon_imports() -> None:
"""``utils.py`` imports groq/google/litellm/dotenv at module load. Stub
the ones we don't use so the import chain doesn't fail when those
@@ -97,6 +98,7 @@ def _add_archon_to_path() -> None:
# ---------- Anthropic patch for Opus 4.7 ----------
def _patch_anthropic_for_opus() -> None:
from anthropic.resources.messages import messages as _msgs_mod
@@ -129,8 +131,10 @@ def _tally() -> Dict[str, int]:
counts = getattr(_TALLY_LOCAL, "counts", None)
if counts is None:
counts = {
"cloud_prompt": 0, "cloud_completion": 0,
"local_prompt": 0, "local_completion": 0,
"cloud_prompt": 0,
"cloud_completion": 0,
"local_prompt": 0,
"local_completion": 0,
"n_web_searches": 0,
}
_TALLY_LOCAL.counts = counts
@@ -141,8 +145,10 @@ def _tally() -> Dict[str, int]:
def _reset_tally() -> None:
_TALLY_LOCAL.counts = {
"cloud_prompt": 0, "cloud_completion": 0,
"local_prompt": 0, "local_completion": 0,
"cloud_prompt": 0,
"cloud_completion": 0,
"local_prompt": 0,
"local_completion": 0,
"n_web_searches": 0,
}
@@ -169,6 +175,7 @@ def _make_local_generator(local_endpoint: str, local_model: str):
def local_gen(model, messages, max_tokens=2048, temperature=0.7, **_kw): # type: ignore[no-untyped-def]
import time as _time
t0 = _time.time()
try:
resp = client.chat.completions.create(
@@ -179,31 +186,35 @@ def _make_local_generator(local_endpoint: str, local_model: str):
)
_bump_local_calls()
except Exception as e:
_record_event({
"kind": "archon_local_gen_error",
"model": local_model,
"messages": messages,
"error": f"{type(e).__name__}: {e}",
"ts": _time.time(),
})
_record_event(
{
"kind": "archon_local_gen_error",
"model": local_model,
"messages": messages,
"error": f"{type(e).__name__}: {e}",
"ts": _time.time(),
}
)
return f"[local-vllm error: {e!r}]"
u = resp.usage
if u:
_tally()["local_prompt"] += getattr(u, "prompt_tokens", 0) or 0
_tally()["local_completion"] += getattr(u, "completion_tokens", 0) or 0
text = (resp.choices[0].message.content or "").strip()
_record_event({
"kind": "archon_local_gen",
"model": local_model,
"messages": messages,
"response": text,
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
"temperature": temperature,
"max_tokens": max_tokens,
"latency_s": _time.time() - t0,
"ts": _time.time(),
})
_record_event(
{
"kind": "archon_local_gen",
"model": local_model,
"messages": messages,
"response": text,
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
"temperature": temperature,
"max_tokens": max_tokens,
"latency_s": _time.time() - t0,
"ts": _time.time(),
}
)
return text
return local_gen
@@ -218,10 +229,13 @@ def _wrap_archon_cloud_generators() -> None:
def gen_openai(model, messages, max_tokens=2048, temperature=0.7, **_kw): # type: ignore[no-untyped-def]
import time as _time
client = _OAI()
kwargs: Dict[str, Any] = dict(
model=model, messages=messages,
max_tokens=max_tokens, temperature=temperature,
model=model,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
)
# GPT-5/o1/o3 reject non-default temperature and use max_completion_tokens.
if model.startswith(("gpt-5", "o1", "o3")):
@@ -236,20 +250,23 @@ def _wrap_archon_cloud_generators() -> None:
_tally()["cloud_prompt"] += getattr(u, "prompt_tokens", 0) or 0
_tally()["cloud_completion"] += getattr(u, "completion_tokens", 0) or 0
text = (resp.choices[0].message.content or "").strip()
_record_event({
"kind": "archon_cloud_openai",
"model": model,
"messages": messages,
"response": text,
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
"latency_s": _time.time() - t0,
"ts": _time.time(),
})
_record_event(
{
"kind": "archon_cloud_openai",
"model": model,
"messages": messages,
"response": text,
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
"latency_s": _time.time() - t0,
"ts": _time.time(),
}
)
return text
def gen_anthropic(model, messages, max_tokens=2048, temperature=0.7, **_kw): # type: ignore[no-untyped-def]
import time as _time
client = _anth.Anthropic(timeout=600.0)
system = ""
msgs = []
@@ -259,7 +276,10 @@ def _wrap_archon_cloud_generators() -> None:
else:
msgs.append(m)
kwargs: Dict[str, Any] = dict(
model=model, system=system, messages=msgs, max_tokens=max_tokens,
model=model,
system=system,
messages=msgs,
max_tokens=max_tokens,
)
if not model.startswith(NO_TEMP_PREFIXES):
kwargs["temperature"] = temperature
@@ -277,24 +297,27 @@ def _wrap_archon_cloud_generators() -> None:
srv = getattr(u, "server_tool_use", None) if u else None
n_searches = getattr(srv, "web_search_requests", 0) if srv else 0
_tally()["n_web_searches"] += int(n_searches)
_record_event({
"kind": "archon_cloud_anthropic",
"model": model,
"system": system,
"messages": msgs,
"response": text.strip(),
"tokens_in": getattr(u, "input_tokens", 0) if u else 0,
"tokens_out": getattr(u, "output_tokens", 0) if u else 0,
"n_web_searches": int(n_searches),
"tools_declared": kwargs.get("tools"),
"latency_s": _time.time() - t0,
"ts": _time.time(),
})
_record_event(
{
"kind": "archon_cloud_anthropic",
"model": model,
"system": system,
"messages": msgs,
"response": text.strip(),
"tokens_in": getattr(u, "input_tokens", 0) if u else 0,
"tokens_out": getattr(u, "output_tokens", 0) if u else 0,
"n_web_searches": int(n_searches),
"tools_declared": kwargs.get("tools"),
"latency_s": _time.time() - t0,
"ts": _time.time(),
}
)
return text.strip()
from archon.completions.components.Generator import (
GENERATE_MAP as _GMAP, # type: ignore[import-not-found]
)
_GMAP["OpenAI_API"] = gen_openai
_GMAP["Anthropic_API"] = gen_anthropic
@@ -330,7 +353,9 @@ def _patch_archon_prompts() -> None:
orig = _p.make_fuser_prompt
def patched(conv, references, critiques=None, length_control=False): # type: ignore[no-untyped-def]
base = orig(conv, references, critiques=critiques, length_control=length_control)
base = orig(
conv, references, critiques=critiques, length_control=length_control
)
return base + _FUSER_FORMAT_REMINDER
patched._hybrid_format_patched = True # type: ignore[attr-defined]
@@ -339,6 +364,7 @@ def _patch_archon_prompts() -> None:
from archon.completions.components import (
Fuser as _F, # type: ignore[import-not-found]
)
_F.make_fuser_prompt = patched
@@ -354,6 +380,7 @@ def _apply_patches_once() -> None:
_patch_anthropic_for_opus()
# Trigger Archon imports so GENERATE_MAP exists.
import archon.completions.components.Generator # type: ignore[import-not-found] # noqa: F401
_wrap_archon_cloud_generators()
_patch_archon_prompts()
_PATCHES_APPLIED = True
@@ -361,49 +388,62 @@ def _apply_patches_once() -> None:
# ---------- Architecture presets ----------
def _presets():
return {
"ensemble_rank_fuse": lambda K, local_model, ranker_model, fuser_model, max_tokens, temperature: [
[{
"type": "generator",
"model": local_model,
"model_type": "vllm_local",
"top_k": 1,
"temperature": temperature,
"max_tokens": max_tokens,
"samples": K,
}],
[{
"type": "ranker",
"model": ranker_model,
"model_type": "Anthropic_API" if ranker_model.startswith("claude") else "OpenAI_API",
"top_k": min(K, 5),
"temperature": 0.0,
"max_tokens": max_tokens,
}],
[{
"type": "fuser",
"model": fuser_model,
"model_type": "Anthropic_API" if fuser_model.startswith("claude") else "OpenAI_API",
"temperature": 0.0,
"max_tokens": max_tokens,
"samples": 1,
}],
[
{
"type": "generator",
"model": local_model,
"model_type": "vllm_local",
"top_k": 1,
"temperature": temperature,
"max_tokens": max_tokens,
"samples": K,
}
],
[
{
"type": "ranker",
"model": ranker_model,
"model_type": "Anthropic_API"
if ranker_model.startswith("claude")
else "OpenAI_API",
"top_k": min(K, 5),
"temperature": 0.0,
"max_tokens": max_tokens,
}
],
[
{
"type": "fuser",
"model": fuser_model,
"model_type": "Anthropic_API"
if fuser_model.startswith("claude")
else "OpenAI_API",
"temperature": 0.0,
"max_tokens": max_tokens,
"samples": 1,
}
],
],
# ``single_local`` honors the cfg ``max_tokens`` (passed positionally
# like ``ensemble_rank_fuse``). Previously it hard-coded 2048, which
# cut Qwen off mid-reasoning before it could emit the GAIA
# ``FINAL ANSWER:`` line — the scorer then had nothing to extract.
"single_local": lambda K, local_model, ranker_model, fuser_model, max_tokens, temperature: [
[{
"type": "generator",
"model": local_model,
"model_type": "vllm_local",
"top_k": 1,
"temperature": 0.0,
"max_tokens": max_tokens,
"samples": 1,
}],
[
{
"type": "generator",
"model": local_model,
"model_type": "vllm_local",
"top_k": 1,
"temperature": 0.0,
"max_tokens": max_tokens,
"samples": 1,
}
],
],
}
@@ -464,7 +504,12 @@ class ArchonAgent(LocalCloudAgent):
)
layers = presets[arch](
K, self._local_model, ranker_model, fuser_model, max_tokens, temperature,
K,
self._local_model,
ranker_model,
fuser_model,
max_tokens,
temperature,
)
archon_cfg = {"name": f"hybrid-archon-{arch}", "layers": layers}
@@ -480,10 +525,12 @@ class ArchonAgent(LocalCloudAgent):
archon = Archon(archon_cfg)
try:
answer = archon.generate([
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": input},
])
answer = archon.generate(
[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": input},
]
)
except Exception:
# Re-raise so the base ``run()`` / runner's ``_run_one_inner``
# records this in the row's ``error`` field instead of stashing
@@ -518,10 +565,10 @@ class ArchonAgent(LocalCloudAgent):
"tool_calls": int(n_searches),
"traces": {
"architecture": arch,
"n_samples": K,
"n_samples": K,
"ranker_model": ranker_model,
"fuser_model": fuser_model,
"local_model": self._local_model,
"fuser_model": fuser_model,
"local_model": self._local_model,
"tokens_breakdown": dict(_tally()),
"web_search_enabled": ws_enabled,
"n_web_searches": n_searches,
@@ -568,26 +615,30 @@ class ArchonAgent(LocalCloudAgent):
turn_max_tokens=turn_max_tokens,
trace_prefix=f"archon_gen{k}",
)
candidates.append({
"idx": k,
"summary": out["final_summary"],
"patch": out["patch"],
"framed": out["answer"],
"tokens_in": out["tokens_in"],
"tokens_out": out["tokens_out"],
"turns": out["turns"],
})
candidates.append(
{
"idx": k,
"summary": out["final_summary"],
"patch": out["patch"],
"framed": out["answer"],
"tokens_in": out["tokens_in"],
"tokens_out": out["tokens_out"],
"turns": out["turns"],
}
)
total_tokens_local += out["tokens_in"] + out["tokens_out"]
self.record_trace_event({
"kind": "archon_swe_candidate",
"idx": k,
"patch_chars": len(out["patch"]),
"summary": out["final_summary"],
})
self.record_trace_event(
{
"kind": "archon_swe_candidate",
"idx": k,
"patch_chars": len(out["patch"]),
"summary": out["final_summary"],
}
)
# Ranker: cloud picks the best candidate.
ranker_user = (
f"Issue:\n{task.get('problem_statement','')}\n\n"
f"Issue:\n{task.get('problem_statement', '')}\n\n"
f"K = {K} candidate patches:\n\n"
+ "\n\n".join(
f"=== Candidate {c['idx']} ===\nSummary: {c['summary']}\n"
@@ -615,13 +666,15 @@ class ArchonAgent(LocalCloudAgent):
chosen_idx = 0
chosen = candidates[chosen_idx]
self.record_trace_event({
"kind": "archon_swe_rank",
"chosen_idx": chosen_idx,
"ranker_raw": ranker_text,
"tokens_in": r_in,
"tokens_out": r_out,
})
self.record_trace_event(
{
"kind": "archon_swe_rank",
"chosen_idx": chosen_idx,
"ranker_raw": ranker_text,
"tokens_in": r_in,
"tokens_out": r_out,
}
)
meta = {
"tokens_local": total_tokens_local,
@@ -635,8 +688,12 @@ class ArchonAgent(LocalCloudAgent):
"swe_mode": True,
"K": K,
"candidates": [
{"idx": c["idx"], "summary": c["summary"],
"patch_chars": len(c["patch"]), "turns": c["turns"]}
{
"idx": c["idx"],
"summary": c["summary"],
"patch_chars": len(c["patch"]),
"turns": c["turns"],
}
for c in candidates
],
"chosen_idx": chosen_idx,
+22 -8
View File
@@ -82,7 +82,11 @@ class BaselineCloudAgent(LocalCloudAgent):
max_turns=int(cfg.get("swe_max_turns", 30)),
bash_timeout=int(cfg.get("swe_bash_timeout_s", 120)),
output_cap=int(cfg.get("swe_output_cap", 10_000)),
turn_max_tokens=int(cfg.get("cloud_max_tokens", default_max_output_tokens(self._cloud_model))),
turn_max_tokens=int(
cfg.get(
"cloud_max_tokens", default_max_output_tokens(self._cloud_model)
)
),
trace_prefix="baseline_cloud",
)
meta = {
@@ -112,7 +116,11 @@ class BaselineCloudAgent(LocalCloudAgent):
text, p_tok, c_tok, n_searches, turns = self._call_anthropic_agent(
self._cloud_model,
user=input,
max_tokens=int(cfg.get("cloud_max_tokens", default_max_output_tokens(self._cloud_model))),
max_tokens=int(
cfg.get(
"cloud_max_tokens", default_max_output_tokens(self._cloud_model)
)
),
temperature=0.0,
tools=[build_web_search_tool(ws_max_uses)],
max_turns=gaia_max_turns,
@@ -145,17 +153,23 @@ class BaselineCloudAgent(LocalCloudAgent):
# wired here. Skip cleanly rather than fake one. Cells that
# want web_search must run on Anthropic until those backends
# are wired.
self.record_trace_event({
"kind": "web_search_skipped",
"reason": "non_anthropic_endpoint",
"endpoint": self._cloud_endpoint,
})
self.record_trace_event(
{
"kind": "web_search_skipped",
"reason": "non_anthropic_endpoint",
"endpoint": self._cloud_endpoint,
}
)
# One-shot direct cloud call. GAIA only — SWE goes through the
# mini-SWE-agent loop above (now supports anthropic/openai/gemini).
text, p_tok, c_tok = self._call_cloud(
user=input,
max_tokens=int(cfg.get("cloud_max_tokens", default_max_output_tokens(self._cloud_model))),
max_tokens=int(
cfg.get(
"cloud_max_tokens", default_max_output_tokens(self._cloud_model)
)
),
temperature=0.0,
)
meta = {
+231 -186
View File
@@ -89,19 +89,20 @@ CONDUCTOR_STRICTER = (
"Your previous response was not valid JSON or was missing required fields. "
"Reply with ONLY a single JSON object — no prose, no code fences, no commentary "
"— containing exactly the three keys model_id (list[int]), subtasks (list[str]), "
"and access_list (list[list[int] or \"all\"]) of equal length, at most 5 entries, "
'and access_list (list[list[int] or "all"]) of equal length, at most 5 entries, '
"and access_list[0] must be [] (an empty list)."
)
# ---------- Plan parsing ----------
def _strip_fences(s: str) -> str:
s = s.strip()
if s.startswith("```"):
first_nl = s.find("\n")
if first_nl != -1:
s = s[first_nl + 1:]
s = s[first_nl + 1 :]
if s.endswith("```"):
s = s[:-3]
s = s.strip()
@@ -119,9 +120,7 @@ def _try_literal(s: str):
"""Fallback for the paper's literal Python-list output style."""
out = {}
for key in ("model_id", "subtasks", "access_list"):
m = re.search(
rf"{key}\s*=\s*(\[[^\]]*\](?:\s*\+\s*\[[^\]]*\])*)", s, re.DOTALL
)
m = re.search(rf"{key}\s*=\s*(\[[^\]]*\](?:\s*\+\s*\[[^\]]*\])*)", s, re.DOTALL)
if not m:
return None
try:
@@ -154,7 +153,7 @@ def _validate_plan(plan: Any, n_workers: int) -> Optional[str]:
if a == "all":
continue
if not isinstance(a, list):
return f"access_list[{i}] must be list or \"all\""
return f'access_list[{i}] must be list or "all"'
for j in a:
if not isinstance(j, int) or not (0 <= j < i):
return f"access_list[{i}] has bad ref {j!r}"
@@ -174,17 +173,18 @@ def _parse_plan(text: str, n_workers: int):
# ---------- Worker pool ----------
def _vllm_alive(base_url: str) -> bool:
try:
with urllib.request.urlopen(
base_url.rstrip("/") + "/models", timeout=3
) as r:
with urllib.request.urlopen(base_url.rstrip("/") + "/models", timeout=3) as r:
return r.status == 200
except Exception:
return False
def _default_pool(local_model: Optional[str], local_endpoint: Optional[str]) -> List[Dict[str, Any]]:
def _default_pool(
local_model: Optional[str], local_endpoint: Optional[str]
) -> List[Dict[str, Any]]:
"""Default worker pool — faithful to the Sakana Conductor paper (arXiv 2512.04388).
The paper composes a heterogeneous 7-worker pool spanning three frontier
@@ -206,98 +206,112 @@ def _default_pool(local_model: Optional[str], local_endpoint: Optional[str]) ->
del local_model, local_endpoint # paper default carries no local worker
pool: List[Dict[str, Any]] = []
if not os.environ.get("OJ_CONDUCTOR_DISABLE_GEMINI"):
pool.append({
"id": len(pool),
"name": "gemini-pro",
"endpoint": "gemini",
"model": "gemini-2.5-pro",
"description": (
"Google Gemini 2.5 Pro. Frontier multimodal reasoner with a "
"very large context window. Strong at long-document synthesis, "
"multi-hop factual reasoning, and tasks that benefit from "
"wide retrieval. Slower and pricier than mid-tier workers."
),
})
pool.append(
{
"id": len(pool),
"name": "gemini-pro",
"endpoint": "gemini",
"model": "gemini-2.5-pro",
"description": (
"Google Gemini 2.5 Pro. Frontier multimodal reasoner with a "
"very large context window. Strong at long-document synthesis, "
"multi-hop factual reasoning, and tasks that benefit from "
"wide retrieval. Slower and pricier than mid-tier workers."
),
}
)
if not os.environ.get("OJ_CONDUCTOR_DISABLE_ANTHROPIC"):
pool.append({
"id": len(pool),
"name": "claude-sonnet-4",
"endpoint": "anthropic",
"model": "claude-sonnet-4-6",
"description": (
"Anthropic Claude Sonnet 4. Strong general-purpose reasoner "
"with careful instruction following and reliable formatting. "
"Good default for code, structured writing, and decisive "
"steps where accuracy matters more than raw throughput."
),
})
pool.append(
{
"id": len(pool),
"name": "claude-sonnet-4",
"endpoint": "anthropic",
"model": "claude-sonnet-4-6",
"description": (
"Anthropic Claude Sonnet 4. Strong general-purpose reasoner "
"with careful instruction following and reliable formatting. "
"Good default for code, structured writing, and decisive "
"steps where accuracy matters more than raw throughput."
),
}
)
if not os.environ.get("OJ_CONDUCTOR_DISABLE_OPENAI"):
pool.append({
"id": len(pool),
"name": "gpt-5",
"endpoint": "openai",
"model": "gpt-5",
"description": (
"OpenAI GPT-5. Frontier-tier broad-knowledge model. Best for "
"open-domain factual recall, creative generation, and "
"ambiguous questions where coverage matters. Expensive; use "
"for steps where breadth of world knowledge is the bottleneck."
),
})
pool.append(
{
"id": len(pool),
"name": "gpt-5",
"endpoint": "openai",
"model": "gpt-5",
"description": (
"OpenAI GPT-5. Frontier-tier broad-knowledge model. Best for "
"open-domain factual recall, creative generation, and "
"ambiguous questions where coverage matters. Expensive; use "
"for steps where breadth of world knowledge is the bottleneck."
),
}
)
if not os.environ.get("OJ_CONDUCTOR_DISABLE_OPENROUTER"):
pool.append({
"id": len(pool),
"name": "deepseek-r1-distill-qwen-32b",
"endpoint": "openrouter",
"model": "deepseek/deepseek-r1-distill-qwen-32b",
"description": (
"DeepSeek R1 distilled into Qwen-32B (open weights via "
"OpenRouter). Specialized for chain-of-thought math, logic, "
"and competitive-programming-style problems. Verbose; "
"produces extensive reasoning traces before the final answer."
),
})
pool.append({
"id": len(pool),
"name": "gemma3-27b-it",
"endpoint": "openrouter",
"model": "google/gemma-3-27b-it",
"description": (
"Google Gemma 3 27B Instruct (open weights via OpenRouter). "
"Mid-size instruction-tuned model. Cheap and fast; solid at "
"concise summarization, extraction, and short-form Q&A on "
"given context. Weaker than the frontier workers on multi-step "
"reasoning."
),
})
pool.append({
"id": len(pool),
"name": "qwen3-32b",
"endpoint": "openrouter",
"model": "qwen/qwen3-32b",
"description": (
"Qwen3-32B in non-thinking mode (open weights via OpenRouter). "
"Fast general-purpose dialogue and instruction following. "
"Use when the step is straightforward generation, "
"summarization, or formatting — does NOT spend tokens on "
"internal reasoning."
),
})
pool.append({
"id": len(pool),
"name": "qwen3-32b-thinking",
"endpoint": "openrouter",
"model": "qwen/qwen3-32b",
"extra_body": {"reasoning": {"effort": "medium"}},
"description": (
"Qwen3-32B with reasoning enabled (open weights via "
"OpenRouter). Same backbone as 'qwen3-32b' but spends tokens "
"on an internal chain of thought before answering. Stronger "
"on math, code, and multi-step logic; slower and consumes "
"more completion tokens. Prefer this for hard reasoning "
"steps; prefer the non-thinking variant for plain dialogue."
),
})
pool.append(
{
"id": len(pool),
"name": "deepseek-r1-distill-qwen-32b",
"endpoint": "openrouter",
"model": "deepseek/deepseek-r1-distill-qwen-32b",
"description": (
"DeepSeek R1 distilled into Qwen-32B (open weights via "
"OpenRouter). Specialized for chain-of-thought math, logic, "
"and competitive-programming-style problems. Verbose; "
"produces extensive reasoning traces before the final answer."
),
}
)
pool.append(
{
"id": len(pool),
"name": "gemma3-27b-it",
"endpoint": "openrouter",
"model": "google/gemma-3-27b-it",
"description": (
"Google Gemma 3 27B Instruct (open weights via OpenRouter). "
"Mid-size instruction-tuned model. Cheap and fast; solid at "
"concise summarization, extraction, and short-form Q&A on "
"given context. Weaker than the frontier workers on multi-step "
"reasoning."
),
}
)
pool.append(
{
"id": len(pool),
"name": "qwen3-32b",
"endpoint": "openrouter",
"model": "qwen/qwen3-32b",
"description": (
"Qwen3-32B in non-thinking mode (open weights via OpenRouter). "
"Fast general-purpose dialogue and instruction following. "
"Use when the step is straightforward generation, "
"summarization, or formatting — does NOT spend tokens on "
"internal reasoning."
),
}
)
pool.append(
{
"id": len(pool),
"name": "qwen3-32b-thinking",
"endpoint": "openrouter",
"model": "qwen/qwen3-32b",
"extra_body": {"reasoning": {"effort": "medium"}},
"description": (
"Qwen3-32B with reasoning enabled (open weights via "
"OpenRouter). Same backbone as 'qwen3-32b' but spends tokens "
"on an internal chain of thought before answering. Stronger "
"on math, code, and multi-step logic; slower and consumes "
"more completion tokens. Prefer this for hard reasoning "
"steps; prefer the non-thinking variant for plain dialogue."
),
}
)
# Reassign ids contiguously in case env-gates skipped some entries.
for new_id, entry in enumerate(pool):
entry["id"] = new_id
@@ -363,16 +377,17 @@ def _resolve_worker_pool(
f"Invalid worker_pool entry [{wid_repr}]: 'id' must be an int"
)
if wid in seen_ids:
raise ValueError(
f"Invalid worker_pool entry [{wid}]: duplicate id"
)
raise ValueError(f"Invalid worker_pool entry [{wid}]: duplicate id")
seen_ids.add(wid)
if not entry.get("name") or not isinstance(entry["name"], str):
raise ValueError(
f"Invalid worker_pool entry [{wid}]: 'name' must be a non-empty string"
)
endpoint = entry.get("endpoint") or entry.get("type")
if not isinstance(endpoint, str) or endpoint.lower() not in _CONDUCTOR_VALID_ENDPOINTS:
if (
not isinstance(endpoint, str)
or endpoint.lower() not in _CONDUCTOR_VALID_ENDPOINTS
):
raise ValueError(
f"Invalid worker_pool entry [{wid}]: 'endpoint' must be one of "
f"{_CONDUCTOR_VALID_ENDPOINTS} (got {endpoint!r})"
@@ -466,9 +481,9 @@ def _search_capable_indices(
if search_backend == "tavily":
return [w["id"] for w in workers]
return [
w["id"] for w in workers
if (w.get("endpoint") or "openai").lower()
in _SEARCH_CAPABLE_WORKER_ENDPOINTS
w["id"]
for w in workers
if (w.get("endpoint") or "openai").lower() in _SEARCH_CAPABLE_WORKER_ENDPOINTS
]
@@ -497,7 +512,9 @@ def _build_conductor_prompt(
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"
capability = (
"External Tavily search results will be prepended to worker prompts"
)
else:
capability = "Only these model indices can perform live web search"
constraint = (
@@ -687,12 +704,18 @@ def _swe_worker_step(
ep = (worker.get("endpoint") or "openai").lower()
if ep == "vllm":
backbone, model, endpoint, is_local = (
"local", worker["model"], worker.get("base_url"), True,
"local",
worker["model"],
worker.get("base_url"),
True,
)
cloud_endpoint = "anthropic" # unused on the local path
elif ep == "anthropic":
backbone, model, endpoint, is_local = (
"cloud", worker["model"], None, False,
"cloud",
worker["model"],
None,
False,
)
cloud_endpoint = "anthropic"
else:
@@ -718,7 +741,11 @@ def _swe_worker_step(
)
return (
out["final_summary"] or out["answer"],
out["tokens_in"], out["tokens_out"], is_local, 0, int(out["turns"]),
out["tokens_in"],
out["tokens_out"],
is_local,
0,
int(out["turns"]),
)
@@ -819,21 +846,22 @@ class ConductorAgent(LocalCloudAgent):
if plan is None:
fallback_used = True
plan = {
"model_id": [len(workers) - 1],
"subtasks": [question],
"model_id": [len(workers) - 1],
"subtasks": [question],
"access_list": [[]],
}
self.record_trace_event({
"kind": "conductor_plan",
"plan": plan,
"fallback_used": fallback_used,
"parse_attempts": parse_attempts,
"workers": [
{k: v for k, v in w.items() if k != "api_key"}
for w in workers
],
})
self.record_trace_event(
{
"kind": "conductor_plan",
"plan": plan,
"fallback_used": fallback_used,
"parse_attempts": parse_attempts,
"workers": [
{k: v for k, v in w.items() if k != "api_key"} for w in workers
],
}
)
# 2. Execute
# If we're on a SWE-bench task AND cfg["swe_use_agent_loop"] is on,
@@ -867,14 +895,15 @@ class ConductorAgent(LocalCloudAgent):
# constraint — reuse them here.
if ws_enabled and search_backend != "tavily" and not swe_mode:
search_workers = [
w for w in workers
w
for w in workers
if (w.get("endpoint") or "openai").lower()
in _SEARCH_CAPABLE_WORKER_ENDPOINTS
]
if not search_workers:
endpoints = sorted({
(w.get("endpoint") or "openai").lower() for w in workers
})
endpoints = sorted(
{(w.get("endpoint") or "openai").lower() for w in workers}
)
raise ValueError(
f"web_search.enabled=true but the worker pool has no "
f"search-capable worker (endpoints present: {endpoints}); "
@@ -885,39 +914,43 @@ class ConductorAgent(LocalCloudAgent):
)
# ``ws_tool`` doubles as the enable marker passed to `_call_worker`
# (truthy => route search-capable workers through their agent loop).
ws_tool = (
build_web_search_tool(ws_max_uses) if ws_enabled else None
)
ws_tool = build_web_search_tool(ws_max_uses) if ws_enabled else None
try:
if swe_mode:
shared_workdir = Path(tempfile.mkdtemp(
prefix=f"conductor-swe-{task_meta.get('task_id','x')}-"
))
shared_workdir = Path(
tempfile.mkdtemp(
prefix=f"conductor-swe-{task_meta.get('task_id', 'x')}-"
)
)
_clone_repo(task_meta["repo"], task_meta["base_commit"], shared_workdir)
self.record_trace_event({
"kind": "conductor_swe_workdir",
"workdir": str(shared_workdir),
"repo": task_meta["repo"],
"base_commit": task_meta["base_commit"],
})
self.record_trace_event(
{
"kind": "conductor_swe_workdir",
"workdir": str(shared_workdir),
"repo": task_meta["repo"],
"base_commit": task_meta["base_commit"],
}
)
for i, (mid, subtask, access) in enumerate(
zip(plan["model_id"], plan["subtasks"], plan["access_list"])
):
worker = workers[mid]
prompt = _build_step_prompt(question, subtask, steps, access)
self.record_trace_event({
"kind": "conductor_step_dispatch",
"step_idx": i,
"worker_id": mid,
"worker_name": worker["name"],
"worker_model": worker["model"],
"subtask": subtask,
"access": access,
"prompt": prompt,
"swe_mode": swe_mode,
})
self.record_trace_event(
{
"kind": "conductor_step_dispatch",
"step_idx": i,
"worker_id": mid,
"worker_name": worker["name"],
"worker_model": worker["model"],
"subtask": subtask,
"access": access,
"prompt": prompt,
"swe_mode": swe_mode,
}
)
worker_ep = (worker.get("endpoint") or "openai").lower()
# Post-hoc routing check: if web_search is on but the
@@ -926,38 +959,49 @@ class ConductorAgent(LocalCloudAgent):
# may legitimately not need search; see Task-3 planner
# constraint that tries to prevent this upfront).
if (
ws_enabled and search_backend != "tavily" and not swe_mode
ws_enabled
and search_backend != "tavily"
and not swe_mode
and worker_ep not in _SEARCH_CAPABLE_WORKER_ENDPOINTS
):
self.record_trace_event({
"kind": "conductor_search_routing_warning",
"step_idx": i,
"worker_id": mid,
"worker_name": worker["name"],
"worker_endpoint": worker_ep,
"warning": (
f"web_search enabled but step {i} routed to "
f"search-incapable worker {worker['name']!r} "
f"(endpoint {worker_ep!r}); this step cannot "
"ground and may answer blind."
),
})
self.record_trace_event(
{
"kind": "conductor_search_routing_warning",
"step_idx": i,
"worker_id": mid,
"worker_name": worker["name"],
"worker_endpoint": worker_ep,
"warning": (
f"web_search enabled but step {i} routed to "
f"search-incapable worker {worker['name']!r} "
f"(endpoint {worker_ep!r}); this step cannot "
"ground and may answer blind."
),
}
)
extra_cost = 0.0
if swe_mode:
text, w_in, w_out, is_local, n_searches, bash_turns = (
_swe_worker_step(
worker, task_meta, prompt, cfg, shared_workdir, i,
worker,
task_meta,
prompt,
cfg,
shared_workdir,
i,
)
)
tool_calls += bash_turns
else:
(
text, w_in, w_out, is_local, n_searches, extra_cost
) = _call_worker(
worker, prompt, cfg,
web_search_tool=ws_tool,
web_search_max_uses=ws_max_uses,
(text, w_in, w_out, is_local, n_searches, extra_cost) = (
_call_worker(
worker,
prompt,
cfg,
web_search_tool=ws_tool,
web_search_max_uses=ws_max_uses,
)
)
if is_local:
@@ -971,17 +1015,19 @@ class ConductorAgent(LocalCloudAgent):
cost += extra_cost
n_web_searches_total += n_searches
tool_calls += n_searches
steps.append({
"step_idx": i,
"model_id": mid,
"worker_name": worker["name"],
"worker_model": worker["model"],
"subtask": subtask,
"access": access,
"output": text,
"tokens_in": w_in,
"tokens_out": w_out,
})
steps.append(
{
"step_idx": i,
"model_id": mid,
"worker_name": worker["name"],
"worker_model": worker["model"],
"subtask": subtask,
"access": access,
"output": text,
"tokens_in": w_in,
"tokens_out": w_out,
}
)
final_answer = text
# For SWE mode, the authoritative patch is whatever lives in
@@ -992,7 +1038,8 @@ class ConductorAgent(LocalCloudAgent):
if patch.strip():
final_answer = (
f"{final_answer}\n\n```diff\n{patch}```"
if final_answer else f"```diff\n{patch}```"
if final_answer
else f"```diff\n{patch}```"
)
finally:
if shared_workdir is not None:
@@ -1003,8 +1050,7 @@ class ConductorAgent(LocalCloudAgent):
tokens_cloud += conductor_p_in + conductor_p_out
traces = [
(s["step_idx"], s["model_id"], s["subtask"], s["output"])
for s in steps
(s["step_idx"], s["model_id"], s["subtask"], s["output"]) for s in steps
]
meta = {
@@ -1023,8 +1069,7 @@ class ConductorAgent(LocalCloudAgent):
"n_web_searches": n_web_searches_total,
"parse_attempts": parse_attempts,
"workers": [
{k: v for k, v in w.items() if k != "api_key"}
for w in workers
{k: v for k, v in w.items() if k != "api_key"} for w in workers
],
},
}
+409 -257
View File
@@ -172,11 +172,16 @@ def _clone_repo(repo: str, base_commit: str, dest: Path) -> None:
url = f"https://github.com/{repo}.git"
subprocess.run(
["git", "clone", "--quiet", url, str(dest)],
check=True, timeout=300, capture_output=True,
check=True,
timeout=300,
capture_output=True,
)
subprocess.run(
["git", "checkout", "--quiet", base_commit],
cwd=str(dest), check=True, timeout=120, capture_output=True,
cwd=str(dest),
check=True,
timeout=120,
capture_output=True,
)
@@ -265,10 +270,14 @@ def _run_bash(
stderr = _decode_bash_output(stderr_b, exit_code)
truncated = False
if len(stdout) > output_cap:
stdout = stdout[:output_cap] + f"\n…[+{len(stdout) - output_cap} chars truncated]"
stdout = (
stdout[:output_cap] + f"\n…[+{len(stdout) - output_cap} chars truncated]"
)
truncated = True
if len(stderr) > output_cap:
stderr = stderr[:output_cap] + f"\n…[+{len(stderr) - output_cap} chars truncated]"
stderr = (
stderr[:output_cap] + f"\n…[+{len(stderr) - output_cap} chars truncated]"
)
truncated = True
return {
"stdout": stdout,
@@ -297,7 +306,10 @@ def _extract_diff(workdir: Path) -> str:
"""``git diff`` against the base commit — the final SWE-bench patch."""
proc = subprocess.run(
["git", "diff", "--no-color"],
cwd=str(workdir), capture_output=True, text=True, timeout=60,
cwd=str(workdir),
capture_output=True,
text=True,
timeout=60,
)
return proc.stdout
@@ -320,10 +332,11 @@ def _anthropic_assistant_block(block: Any) -> Dict[str, Any]:
# ---------- Reusable agent-loop entry point ----------
def run_swe_agent_loop(
task: Dict[str, Any],
*,
backbone: str, # "cloud" or "local"
backbone: str, # "cloud" or "local"
backbone_model: str,
cloud_endpoint: str = "anthropic",
local_endpoint: Optional[str] = None,
@@ -387,32 +400,33 @@ def run_swe_agent_loop(
own_workdir = workdir is None
if own_workdir:
workdir = Path(tempfile.mkdtemp(
prefix=f"mini-swe-{task.get('task_id','x')}-"
))
workdir = Path(tempfile.mkdtemp(prefix=f"mini-swe-{task.get('task_id', 'x')}-"))
try:
_clone_repo(repo, base_commit, workdir)
except Exception:
shutil.rmtree(workdir, ignore_errors=True)
raise
_record_event({
"kind": f"{trace_prefix}_setup",
"repo": repo,
"base_commit": base_commit,
"workdir": str(workdir),
"owns_workdir": own_workdir,
"backbone": backbone,
"backbone_model": backbone_model,
"ts": time.time(),
})
_record_event(
{
"kind": f"{trace_prefix}_setup",
"repo": repo,
"base_commit": base_commit,
"workdir": str(workdir),
"owns_workdir": own_workdir,
"backbone": backbone,
"backbone_model": backbone_model,
"ts": time.time(),
}
)
user_prompt = initial_prompt or task.get("problem_statement") or ""
try:
if backbone == "cloud":
result = _loop_cloud(
user_prompt, workdir,
user_prompt,
workdir,
model=backbone_model,
cloud_endpoint=cloud_endpoint,
max_turns=max_turns,
@@ -423,9 +437,12 @@ def run_swe_agent_loop(
)
elif backbone == "local":
if not local_endpoint:
raise ValueError("run_swe_agent_loop(backbone='local') needs local_endpoint")
raise ValueError(
"run_swe_agent_loop(backbone='local') needs local_endpoint"
)
result = _loop_local(
user_prompt, workdir,
user_prompt,
workdir,
model=backbone_model,
endpoint=local_endpoint,
max_turns=max_turns,
@@ -440,7 +457,7 @@ def run_swe_agent_loop(
raise ValueError(f"unsupported backbone: {backbone!r}")
patch = _extract_diff(workdir)
framed = (result["final_summary"] or "[mini-swe-agent produced no summary text]")
framed = result["final_summary"] or "[mini-swe-agent produced no summary text]"
if patch.strip():
framed = f"{framed}\n\n```diff\n{patch}```"
@@ -450,11 +467,16 @@ def run_swe_agent_loop(
"final_summary": result["final_summary"],
"tokens_in": result["tokens_in"],
"tokens_out": result["tokens_out"],
"tokens_local": result["tokens_in"] + result["tokens_out"] if backbone == "local" else 0,
"tokens_cloud": result["tokens_in"] + result["tokens_out"] if backbone == "cloud" else 0,
"tokens_local": result["tokens_in"] + result["tokens_out"]
if backbone == "local"
else 0,
"tokens_cloud": result["tokens_in"] + result["tokens_out"]
if backbone == "cloud"
else 0,
"cost_usd": (
estimate_cost(backbone_model, result["tokens_in"], result["tokens_out"])
if backbone == "cloud" else 0.0
if backbone == "cloud"
else 0.0
),
"turns": result["turns"],
"max_turns_hit": result["max_turns_hit"],
@@ -467,6 +489,7 @@ def run_swe_agent_loop(
# ---------- Cloud loop (dispatcher → per-endpoint multi-turn tool loops) ----------
def _loop_cloud(
problem: str,
workdir: Path,
@@ -485,24 +508,36 @@ def _loop_cloud(
to unblock the 8 SWE cells that were stuck on Anthropic-only support."""
if cloud_endpoint == "anthropic":
return _loop_cloud_anthropic(
problem, workdir,
model=model, max_turns=max_turns,
bash_timeout=bash_timeout, output_cap=output_cap,
turn_max_tokens=turn_max_tokens, trace_prefix=trace_prefix,
problem,
workdir,
model=model,
max_turns=max_turns,
bash_timeout=bash_timeout,
output_cap=output_cap,
turn_max_tokens=turn_max_tokens,
trace_prefix=trace_prefix,
)
if cloud_endpoint == "openai":
return _loop_cloud_openai(
problem, workdir,
model=model, max_turns=max_turns,
bash_timeout=bash_timeout, output_cap=output_cap,
turn_max_tokens=turn_max_tokens, trace_prefix=trace_prefix,
problem,
workdir,
model=model,
max_turns=max_turns,
bash_timeout=bash_timeout,
output_cap=output_cap,
turn_max_tokens=turn_max_tokens,
trace_prefix=trace_prefix,
)
if cloud_endpoint == "gemini":
return _loop_cloud_gemini(
problem, workdir,
model=model, max_turns=max_turns,
bash_timeout=bash_timeout, output_cap=output_cap,
turn_max_tokens=turn_max_tokens, trace_prefix=trace_prefix,
problem,
workdir,
model=model,
max_turns=max_turns,
bash_timeout=bash_timeout,
output_cap=output_cap,
turn_max_tokens=turn_max_tokens,
trace_prefix=trace_prefix,
)
raise ValueError(
f"mini-SWE-agent cloud backbone unsupported endpoint: {cloud_endpoint!r}"
@@ -521,6 +556,7 @@ def _loop_cloud_anthropic(
trace_prefix: str,
) -> Dict[str, Any]:
import anthropic
client = anthropic.Anthropic(timeout=600.0, max_retries=5)
messages: List[Dict[str, Any]] = [{"role": "user", "content": problem}]
@@ -553,30 +589,39 @@ def _loop_cloud_anthropic(
btype = getattr(block, "type", None)
if btype == "tool_use":
tool_uses.append((block.id, block.name, dict(block.input or {})))
content_blocks.append({
"type": "tool_use", "id": block.id, "name": block.name,
"input": dict(block.input or {}),
})
content_blocks.append(
{
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": dict(block.input or {}),
}
)
elif hasattr(block, "text"):
text_parts.append(block.text)
content_blocks.append({"type": "text", "text": block.text})
else:
content_blocks.append({"type": btype or "unknown"})
_record_event({
"kind": f"{trace_prefix}_turn",
"turn": turn,
"stop_reason": msg.stop_reason,
"tokens_in": msg.usage.input_tokens,
"tokens_out": msg.usage.output_tokens,
"latency_s": latency,
"content_blocks": content_blocks,
"ts": time.time(),
})
_record_event(
{
"kind": f"{trace_prefix}_turn",
"turn": turn,
"stop_reason": msg.stop_reason,
"tokens_in": msg.usage.input_tokens,
"tokens_out": msg.usage.output_tokens,
"latency_s": latency,
"content_blocks": content_blocks,
"ts": time.time(),
}
)
messages.append({"role": "assistant", "content": [
_anthropic_assistant_block(b) for b in msg.content
]})
messages.append(
{
"role": "assistant",
"content": [_anthropic_assistant_block(b) for b in msg.content],
}
)
if not tool_uses:
final_text = "\n".join(text_parts).strip()
@@ -586,28 +631,40 @@ def _loop_cloud_anthropic(
for tu_id, tu_name, tu_input in tool_uses:
if tu_name != "bash":
obs = f"unknown tool: {tu_name!r}"
_record_event({
"kind": f"{trace_prefix}_unknown_tool",
"turn": turn, "name": tu_name, "input": tu_input,
"ts": time.time(),
})
_record_event(
{
"kind": f"{trace_prefix}_unknown_tool",
"turn": turn,
"name": tu_name,
"input": tu_input,
"ts": time.time(),
}
)
else:
command = str(tu_input.get("command", ""))
result = _run_bash(
command, workdir,
timeout=bash_timeout, output_cap=output_cap,
command,
workdir,
timeout=bash_timeout,
output_cap=output_cap,
)
_record_event(
{
"kind": f"{trace_prefix}_bash",
"turn": turn,
"command": command,
**result,
"ts": time.time(),
}
)
_record_event({
"kind": f"{trace_prefix}_bash",
"turn": turn, "command": command,
**result, "ts": time.time(),
})
obs = _format_observation(result)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tu_id,
"content": obs,
})
tool_result_blocks.append(
{
"type": "tool_result",
"tool_use_id": tu_id,
"content": obs,
}
)
messages.append({"role": "user", "content": tool_result_blocks})
return {
@@ -621,6 +678,7 @@ def _loop_cloud_anthropic(
# ---------- Cloud loop (OpenAI multi-turn with function tools) ----------
def _loop_cloud_openai(
problem: str,
workdir: Path,
@@ -646,6 +704,7 @@ def _loop_cloud_openai(
``_loop_local`` behavior).
"""
from openai import OpenAI
client = OpenAI(timeout=600.0)
messages: List[Dict[str, Any]] = [
@@ -681,21 +740,27 @@ def _loop_cloud_openai(
tool_calls = list(getattr(message, "tool_calls", None) or [])
text = message.content or ""
_record_event({
"kind": f"{trace_prefix}_turn",
"turn": turn,
"endpoint": "openai",
"finish_reason": choice.finish_reason,
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
"latency_s": latency,
"text": text,
"tool_calls": [
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
for tc in tool_calls
],
"ts": time.time(),
})
_record_event(
{
"kind": f"{trace_prefix}_turn",
"turn": turn,
"endpoint": "openai",
"finish_reason": choice.finish_reason,
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
"latency_s": latency,
"text": text,
"tool_calls": [
{
"id": tc.id,
"name": tc.function.name,
"arguments": tc.function.arguments,
}
for tc in tool_calls
],
"ts": time.time(),
}
)
# Append the assistant turn (including any tool_calls) so the
# follow-up tool messages have the right call ids to reference.
@@ -711,7 +776,8 @@ def _loop_cloud_openai(
if tool_calls:
assistant_msg["tool_calls"] = [
{
"id": tc.id, "type": "function",
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
@@ -734,21 +800,26 @@ def _loop_cloud_openai(
and not text.strip()
and turn < max_turns
):
messages.append({
"role": "user",
"content": (
"Your previous response was truncated by the token limit "
"before producing a tool call or final summary. Retry: "
"either issue ONE bash tool call (short command, no large "
"output) or send a brief one-line final summary with no "
"tool calls to end the loop."
),
})
_record_event({
"kind": f"{trace_prefix}_recover",
"turn": turn, "reason": "length_truncation_no_tool_call",
"ts": time.time(),
})
messages.append(
{
"role": "user",
"content": (
"Your previous response was truncated by the token limit "
"before producing a tool call or final summary. Retry: "
"either issue ONE bash tool call (short command, no large "
"output) or send a brief one-line final summary with no "
"tool calls to end the loop."
),
}
)
_record_event(
{
"kind": f"{trace_prefix}_recover",
"turn": turn,
"reason": "length_truncation_no_tool_call",
"ts": time.time(),
}
)
continue
# No tool call → the model is done. Same termination rule as
# the Anthropic branch.
@@ -762,28 +833,40 @@ def _loop_cloud_openai(
args = {}
if tc.function.name != "bash":
obs = f"unknown tool: {tc.function.name!r}"
_record_event({
"kind": f"{trace_prefix}_unknown_tool",
"turn": turn, "name": tc.function.name, "input": args,
"ts": time.time(),
})
_record_event(
{
"kind": f"{trace_prefix}_unknown_tool",
"turn": turn,
"name": tc.function.name,
"input": args,
"ts": time.time(),
}
)
else:
command = str(args.get("command", ""))
result = _run_bash(
command, workdir,
timeout=bash_timeout, output_cap=output_cap,
command,
workdir,
timeout=bash_timeout,
output_cap=output_cap,
)
_record_event(
{
"kind": f"{trace_prefix}_bash",
"turn": turn,
"command": command,
**result,
"ts": time.time(),
}
)
_record_event({
"kind": f"{trace_prefix}_bash",
"turn": turn, "command": command,
**result, "ts": time.time(),
})
obs = _format_observation(result)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": obs,
})
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": obs,
}
)
return {
"tokens_in": tokens_in,
@@ -796,6 +879,7 @@ def _loop_cloud_openai(
# ---------- Cloud loop (Gemini multi-turn with function tools) ----------
def _loop_cloud_gemini(
problem: str,
workdir: Path,
@@ -831,13 +915,15 @@ def _loop_cloud_gemini(
from google.genai import types
client = genai.Client(http_options=types.HttpOptions(timeout=600_000))
bash_tool = types.Tool(function_declarations=[
types.FunctionDeclaration(
name="bash",
description=BASH_TOOL_ANTHROPIC["description"],
parameters=BASH_TOOL_GEMINI_PARAMETERS,
),
])
bash_tool = types.Tool(
function_declarations=[
types.FunctionDeclaration(
name="bash",
description=BASH_TOOL_ANTHROPIC["description"],
parameters=BASH_TOOL_GEMINI_PARAMETERS,
),
]
)
contents: List[types.Content] = [
types.Content(role="user", parts=[types.Part(text=problem)]),
@@ -859,7 +945,9 @@ def _loop_cloud_gemini(
)
t0 = time.time()
resp = client.models.generate_content(
model=model, contents=contents, config=cfg,
model=model,
contents=contents,
config=cfg,
)
_bump_cloud_calls()
latency = time.time() - t0
@@ -896,21 +984,22 @@ def _loop_cloud_gemini(
except Exception:
pass
_record_event({
"kind": f"{trace_prefix}_turn",
"turn": turn,
"endpoint": "gemini",
"finish_reason": finish_reason,
"tokens_in": p,
"tokens_out": c,
"latency_s": latency,
"text": "\n".join(text_parts),
"tool_calls": [
{"name": name, "arguments": args}
for name, args in function_calls
],
"ts": time.time(),
})
_record_event(
{
"kind": f"{trace_prefix}_turn",
"turn": turn,
"endpoint": "gemini",
"finish_reason": finish_reason,
"tokens_in": p,
"tokens_out": c,
"latency_s": latency,
"text": "\n".join(text_parts),
"tool_calls": [
{"name": name, "arguments": args} for name, args in function_calls
],
"ts": time.time(),
}
)
# Append the model's content as-is so the next turn sees its own
# prior function_call parts (Gemini requires this for the
@@ -934,28 +1023,37 @@ def _loop_cloud_gemini(
# treat genuine ``STOP`` with text as a final answer.
fr_str = str(finish_reason or "")
empty_text = not any(t.strip() for t in text_parts)
recoverable = empty_text and turn < max_turns and (
"MALFORMED_FUNCTION_CALL" in fr_str
or "MAX_TOKENS" in fr_str
recoverable = (
empty_text
and turn < max_turns
and ("MALFORMED_FUNCTION_CALL" in fr_str or "MAX_TOKENS" in fr_str)
)
if recoverable:
contents.append(types.Content(
role="user",
parts=[types.Part(text=(
"Your previous response had no parsable function call "
"and no final text (finish_reason="
f"{fr_str}). Retry: either issue ONE well-formed "
"`bash` function call (short command, valid JSON-ish "
"args) or send a brief final text message with no "
"function call to end the loop."
))],
))
_record_event({
"kind": f"{trace_prefix}_recover",
"turn": turn,
"reason": f"empty_response_{fr_str}",
"ts": time.time(),
})
contents.append(
types.Content(
role="user",
parts=[
types.Part(
text=(
"Your previous response had no parsable function call "
"and no final text (finish_reason="
f"{fr_str}). Retry: either issue ONE well-formed "
"`bash` function call (short command, valid JSON-ish "
"args) or send a brief final text message with no "
"function call to end the loop."
)
)
],
)
)
_record_event(
{
"kind": f"{trace_prefix}_recover",
"turn": turn,
"reason": f"empty_response_{fr_str}",
"ts": time.time(),
}
)
continue
final_text = "\n".join(text_parts).strip()
break
@@ -964,26 +1062,39 @@ def _loop_cloud_gemini(
for name, args in function_calls:
if name != "bash":
obs = f"unknown tool: {name!r}"
_record_event({
"kind": f"{trace_prefix}_unknown_tool",
"turn": turn, "name": name, "input": args,
"ts": time.time(),
})
_record_event(
{
"kind": f"{trace_prefix}_unknown_tool",
"turn": turn,
"name": name,
"input": args,
"ts": time.time(),
}
)
else:
command = str(args.get("command", ""))
result = _run_bash(
command, workdir,
timeout=bash_timeout, output_cap=output_cap,
command,
workdir,
timeout=bash_timeout,
output_cap=output_cap,
)
_record_event(
{
"kind": f"{trace_prefix}_bash",
"turn": turn,
"command": command,
**result,
"ts": time.time(),
}
)
_record_event({
"kind": f"{trace_prefix}_bash",
"turn": turn, "command": command,
**result, "ts": time.time(),
})
obs = _format_observation(result)
response_parts.append(types.Part.from_function_response(
name=name, response={"output": obs},
))
response_parts.append(
types.Part.from_function_response(
name=name,
response={"output": obs},
)
)
contents.append(types.Content(role="user", parts=response_parts))
return {
@@ -1013,10 +1124,14 @@ def _get_tiktoken_enc() -> Any:
return _TIKTOKEN_ENC
try:
import tiktoken
_TIKTOKEN_ENC = tiktoken.get_encoding("cl100k_base")
except Exception as exc:
if not _TIKTOKEN_WARNED:
print(f"[mini_swe_agent] tiktoken unavailable ({exc!r}); falling back to len(s)//4", flush=True)
print(
f"[mini_swe_agent] tiktoken unavailable ({exc!r}); falling back to len(s)//4",
flush=True,
)
_TIKTOKEN_WARNED = True
_TIKTOKEN_ENC = False
return _TIKTOKEN_ENC
@@ -1038,7 +1153,7 @@ def _estimate_prompt_tokens(messages: List[Dict[str, Any]]) -> int:
s = "\n".join(parts)
else:
s = ""
for tc in (m.get("tool_calls") or []):
for tc in m.get("tool_calls") or []:
try:
s += "\n" + (tc["function"]["arguments"] or "")
s += "\n" + (tc["function"].get("name") or "")
@@ -1119,7 +1234,7 @@ def _compact_local_messages(
before_tokens = _estimate_prompt_tokens(messages)
new_messages: List[Dict[str, Any]] = list(messages)
n_tool_elided = 0
for (s, e) in old_turns:
for s, e in old_turns:
for k in range(s, e):
m = new_messages[k]
if m.get("role") != "tool":
@@ -1140,17 +1255,19 @@ def _compact_local_messages(
n_tool_elided += 1
after_stage1_tokens = _estimate_prompt_tokens(new_messages)
_record_event({
"kind": f"{trace_prefix}_compact",
"stage": "1",
"msgs_before": len(messages),
"msgs_after": len(new_messages),
"before_tokens": before_tokens,
"after_tokens": after_stage1_tokens,
"n_tool_elided": n_tool_elided,
"n_turns_folded": 0,
"ts": time.time(),
})
_record_event(
{
"kind": f"{trace_prefix}_compact",
"stage": "1",
"msgs_before": len(messages),
"msgs_after": len(new_messages),
"before_tokens": before_tokens,
"after_tokens": after_stage1_tokens,
"n_tool_elided": n_tool_elided,
"n_turns_folded": 0,
"ts": time.time(),
}
)
if after_stage1_tokens <= compact_at_tokens:
return new_messages
@@ -1163,12 +1280,21 @@ def _compact_local_messages(
summary_input = [
{"role": "system", "content": _COMPACT_PROMPT},
{"role": "user", "content": json.dumps(
[{"role": m.get("role"),
"content": m.get("content") if isinstance(m.get("content"), str) else str(m.get("content"))[:4000]}
for m in middle],
default=str,
)[:60_000]},
{
"role": "user",
"content": json.dumps(
[
{
"role": m.get("role"),
"content": m.get("content")
if isinstance(m.get("content"), str)
else str(m.get("content"))[:4000],
}
for m in middle
],
default=str,
)[:60_000],
},
]
summary = ""
try:
@@ -1194,18 +1320,20 @@ def _compact_local_messages(
}
folded = [system_msg, initial_user, synthetic, *tail]
after_stage2_tokens = _estimate_prompt_tokens(folded)
_record_event({
"kind": f"{trace_prefix}_compact",
"stage": "2",
"msgs_before": len(new_messages),
"msgs_after": len(folded),
"before_tokens": after_stage1_tokens,
"after_tokens": after_stage2_tokens,
"n_tool_elided": n_tool_elided,
"n_turns_folded": n_turns_folded,
"summary_chars": len(summary),
"ts": time.time(),
})
_record_event(
{
"kind": f"{trace_prefix}_compact",
"stage": "2",
"msgs_before": len(new_messages),
"msgs_after": len(folded),
"before_tokens": after_stage1_tokens,
"after_tokens": after_stage2_tokens,
"n_tool_elided": n_tool_elided,
"n_turns_folded": n_turns_folded,
"summary_chars": len(summary),
"ts": time.time(),
}
)
return folded
@@ -1231,6 +1359,7 @@ def _loop_local(
# but still saw 28k-input 400s on the n=100 SWE sweep (the keep window
# alone routinely exceeded the budget once bash outputs piled up).
from openai import OpenAI
client = OpenAI(base_url=endpoint, api_key="EMPTY", timeout=600.0)
messages: List[Dict[str, Any]] = [
@@ -1243,10 +1372,16 @@ def _loop_local(
turns = 0
for turn in range(1, max_turns + 1):
turns = turn
if compact_at_tokens > 0 and _estimate_prompt_tokens(messages) > compact_at_tokens:
if (
compact_at_tokens > 0
and _estimate_prompt_tokens(messages) > compact_at_tokens
):
messages = _compact_local_messages(
messages, client=client, model=model,
keep_last=compact_keep_last, trace_prefix=trace_prefix,
messages,
client=client,
model=model,
keep_last=compact_keep_last,
trace_prefix=trace_prefix,
compact_at_tokens=compact_at_tokens,
)
t0 = time.time()
@@ -1268,23 +1403,27 @@ def _loop_local(
# server walled the call. Compact aggressively (keep_last=1)
# and retry once. Re-raise on anything else or on a second
# failure — the runner records the row as errored.
from openjarvis.engine._base import looks_like_context_length_error
msg = str(exc)
is_ctx = (
"maximum context length" in msg
or "context length" in msg.lower() and "exceed" in msg.lower()
)
is_ctx = looks_like_context_length_error(msg)
if not is_ctx:
raise
_record_event({
"kind": f"{trace_prefix}_emergency_compact",
"turn": turn,
"error": msg[:300],
"tokens_before": _estimate_prompt_tokens(messages),
"ts": time.time(),
})
_record_event(
{
"kind": f"{trace_prefix}_emergency_compact",
"turn": turn,
"error": msg[:300],
"tokens_before": _estimate_prompt_tokens(messages),
"ts": time.time(),
}
)
messages = _compact_local_messages(
messages, client=client, model=model,
keep_last=1, trace_prefix=trace_prefix,
messages,
client=client,
model=model,
keep_last=1,
trace_prefix=trace_prefix,
compact_at_tokens=max(8_000, compact_at_tokens // 2),
)
resp = client.chat.completions.create(
@@ -1306,20 +1445,26 @@ def _loop_local(
tool_calls = list(getattr(message, "tool_calls", None) or [])
text = message.content or ""
_record_event({
"kind": f"{trace_prefix}_turn",
"turn": turn,
"finish_reason": choice.finish_reason,
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
"latency_s": latency,
"text": text,
"tool_calls": [
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
for tc in tool_calls
],
"ts": time.time(),
})
_record_event(
{
"kind": f"{trace_prefix}_turn",
"turn": turn,
"finish_reason": choice.finish_reason,
"tokens_in": getattr(u, "prompt_tokens", 0) if u else 0,
"tokens_out": getattr(u, "completion_tokens", 0) if u else 0,
"latency_s": latency,
"text": text,
"tool_calls": [
{
"id": tc.id,
"name": tc.function.name,
"arguments": tc.function.arguments,
}
for tc in tool_calls
],
"ts": time.time(),
}
)
# Match the OpenAI cloud branch: content="" (not None) when only
# tool_calls are present; omit ``tool_calls`` entirely when there
@@ -1333,7 +1478,8 @@ def _loop_local(
if tool_calls:
assistant_local_msg["tool_calls"] = [
{
"id": tc.id, "type": "function",
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
@@ -1357,20 +1503,28 @@ def _loop_local(
else:
command = str(args.get("command", ""))
result = _run_bash(
command, workdir,
timeout=bash_timeout, output_cap=output_cap,
command,
workdir,
timeout=bash_timeout,
output_cap=output_cap,
)
_record_event(
{
"kind": f"{trace_prefix}_bash",
"turn": turn,
"command": command,
**result,
"ts": time.time(),
}
)
_record_event({
"kind": f"{trace_prefix}_bash",
"turn": turn, "command": command,
**result, "ts": time.time(),
})
obs = _format_observation(result)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": obs,
})
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": obs,
}
)
return {
"tokens_in": tokens_in,
@@ -1383,6 +1537,7 @@ def _loop_local(
# ---------- Standalone agent ----------
@AgentRegistry.register("mini_swe_agent")
class MiniSWEAgent(LocalCloudAgent):
"""Single-model bash-loop agent for SWE-bench-shaped tasks.
@@ -1410,10 +1565,7 @@ class MiniSWEAgent(LocalCloudAgent):
task = context.metadata.get("task") or {}
backbone = cfg.get("backbone", "cloud")
model = (
self._cloud_model if backbone == "cloud"
else (self._local_model or "")
)
model = self._cloud_model if backbone == "cloud" else (self._local_model or "")
out = run_swe_agent_loop(
task,
+61 -45
View File
@@ -87,7 +87,7 @@ MINIONS_FIRST_TURN_SCHEMA = {
"type": "object",
"properties": {
"reasoning": {"type": "string"},
"message": {"type": "string"},
"message": {"type": "string"},
},
"required": ["reasoning", "message"],
"additionalProperties": False,
@@ -104,7 +104,7 @@ MINIONS_CONVERSATION_SCHEMA = {
"type": "object",
"properties": {
"decision": {"const": "request_additional_info"},
"message": {"type": "string"},
"message": {"type": "string"},
},
"required": ["decision", "message"],
"additionalProperties": False,
@@ -113,7 +113,7 @@ MINIONS_CONVERSATION_SCHEMA = {
"type": "object",
"properties": {
"decision": {"const": "provide_final_answer"},
"answer": {"type": "string"},
"answer": {"type": "string"},
},
"required": ["decision", "answer"],
"additionalProperties": False,
@@ -126,8 +126,8 @@ MINIONS_CONVERSATION_SCHEMA = {
# Markers from Minions's supervisor prompts (prompts/minion.py). Any one
# being present in the call's messages/system is a strong Minions signal.
MINIONS_PROMPT_MARKERS = (
"small language model that has read", # SUPERVISOR_INITIAL_PROMPT
"provide_final_answer", # SUPERVISOR_CONVERSATION_PROMPT
"small language model that has read", # SUPERVISOR_INITIAL_PROMPT
"provide_final_answer", # SUPERVISOR_CONVERSATION_PROMPT
"request_additional_info",
)
@@ -171,6 +171,7 @@ def _stub_missing_imports() -> None:
"""
try:
import mistralai
if not hasattr(mistralai, "Mistral"):
mistralai.Mistral = type("Mistral", (), {}) # type: ignore[attr-defined]
except ImportError:
@@ -215,12 +216,12 @@ def _patch_anthropic_globally() -> None:
model = kwargs.get("model", "")
if model.startswith(NO_TEMP_PREFIXES):
kwargs.pop("temperature", None)
if (
"output_config" not in kwargs
and _looks_like_minions_call(kwargs)
if "output_config" not in kwargs and _looks_like_minions_call(
kwargs
):
kwargs["output_config"] = _minions_turn_schema(kwargs)
return orig(self, **kwargs)
patched._hybrid_patched = True # type: ignore[attr-defined]
return patched
@@ -358,6 +359,7 @@ def _apply_patches_once() -> None:
# ---------- Pre-fetch helper (GAIA only) ----------
def _prefetch_context(
question: str,
cloud_endpoint: str,
@@ -376,7 +378,10 @@ def _prefetch_context(
and zeros the protocol still runs.
"""
out: Dict[str, Any] = {
"text": "", "tokens": 0, "cost_usd": 0.0, "n_searches": 0,
"text": "",
"tokens": 0,
"cost_usd": 0.0,
"n_searches": 0,
}
if search_backend == "tavily":
try:
@@ -412,10 +417,12 @@ def _prefetch_context(
tool_choice={"type": "any"},
)
from openjarvis.agents.hybrid._prices import cost as _cost_usd
out.update(
text=text,
tokens=p + c,
cost_usd=_cost_usd(cloud_model, p, c) + n_searches * WEB_SEARCH_COST_PER_CALL,
cost_usd=_cost_usd(cloud_model, p, c)
+ n_searches * WEB_SEARCH_COST_PER_CALL,
n_searches=n_searches,
)
except Exception as e:
@@ -423,9 +430,7 @@ def _prefetch_context(
return out
def _context_for(
task: Optional[Dict[str, Any]], prefetched: str = ""
) -> List[str]:
def _context_for(task: Optional[Dict[str, Any]], prefetched: str = "") -> List[str]:
"""Minions wants a context list."""
bits: List[str] = []
task = task or {}
@@ -440,6 +445,7 @@ def _context_for(
# ---------- Main agent ----------
@AgentRegistry.register("minions")
class MinionsAgent(LocalCloudAgent):
"""HazyResearch Minions supervisor/worker protocol. See module docstring."""
@@ -451,6 +457,7 @@ class MinionsAgent(LocalCloudAgent):
# 400/529, KeyError on missing schema fields.
try:
import anthropic
if isinstance(exc, anthropic.BadRequestError):
return f"{type(exc).__name__}: {str(exc)[:120]}"
except Exception:
@@ -518,8 +525,7 @@ class MinionsAgent(LocalCloudAgent):
local=True,
)
cloud_max_tokens = int(
cfg.get("cloud_max_tokens")
or default_max_output_tokens(self._cloud_model)
cfg.get("cloud_max_tokens") or default_max_output_tokens(self._cloud_model)
)
if self._cloud_endpoint == "openai":
cloud_client = OpenAIClient(
@@ -566,9 +572,14 @@ class MinionsAgent(LocalCloudAgent):
# - enabled = false → prefetch OFF
# - enabled = true → prefetch ON (honors max_uses)
prefetch: Dict[str, Any] = {
"text": "", "tokens": 0, "cost_usd": 0.0, "n_searches": 0,
"text": "",
"tokens": 0,
"cost_usd": 0.0,
"n_searches": 0,
}
ws_block = cfg.get("web_search") if isinstance(cfg.get("web_search"), dict) else None
ws_block = (
cfg.get("web_search") if isinstance(cfg.get("web_search"), dict) else None
)
ws_enabled, ws_max_uses = web_search_cfg(cfg)
# If the cell explicitly set web_search.enabled = false, honor that.
# If it set web_search.enabled = true, honor max_uses. If it didn't
@@ -588,14 +599,16 @@ class MinionsAgent(LocalCloudAgent):
)
if prefetch.get("text"):
self.record_trace_event({
"kind": "minions_prefetch",
"n_searches": prefetch["n_searches"],
"tokens": prefetch["tokens"],
"cost_usd": prefetch["cost_usd"],
"text": prefetch["text"],
"error": prefetch.get("error"),
})
self.record_trace_event(
{
"kind": "minions_prefetch",
"n_searches": prefetch["n_searches"],
"tokens": prefetch["tokens"],
"cost_usd": prefetch["cost_usd"],
"text": prefetch["text"],
"error": prefetch.get("error"),
}
)
out = protocol(
task=input, # full formatted prompt (with bench instruction)
@@ -607,15 +620,17 @@ class MinionsAgent(LocalCloudAgent):
# The Minions library doesn't go through our SDK helpers, so the
# auto-trace missed every turn. Record the protocol output directly —
# supervisor_messages + worker_messages contain the full conversation.
self.record_trace_event({
"kind": "minions_protocol",
"mode": mode,
"supervisor_messages": out.get("supervisor_messages"),
"worker_messages": out.get("worker_messages"),
"timing": out.get("timing"),
"log_file": out.get("log_file"),
"final_answer": out.get("final_answer", ""),
})
self.record_trace_event(
{
"kind": "minions_protocol",
"mode": mode,
"supervisor_messages": out.get("supervisor_messages"),
"worker_messages": out.get("worker_messages"),
"timing": out.get("timing"),
"log_file": out.get("log_file"),
"final_answer": out.get("final_answer", ""),
}
)
local_usage = out.get("local_usage")
remote_usage = out.get("remote_usage")
@@ -650,7 +665,6 @@ class MinionsAgent(LocalCloudAgent):
}
return out.get("final_answer", ""), meta
# ------------------------------------------------------------------
# SWE-bench variant
# ------------------------------------------------------------------
@@ -668,21 +682,23 @@ class MinionsAgent(LocalCloudAgent):
# 1. Cloud supervisor writes a high-level plan (no tools).
plan_text, p_in, p_out = self._call_cloud(
user=(
f"Issue:\n{task.get('problem_statement','')}\n\n"
f"Repo: {task.get('repo','')}\n"
f"Base commit: {task.get('base_commit','')}\n\n"
f"{task.get('hints_text','')}"
f"Issue:\n{task.get('problem_statement', '')}\n\n"
f"Repo: {task.get('repo', '')}\n"
f"Base commit: {task.get('base_commit', '')}\n\n"
f"{task.get('hints_text', '')}"
),
system=MINIONS_SWE_PLANNER_SYS,
max_tokens=int(cfg.get("supervisor_max_tokens", 1024)),
temperature=0.0,
)
self.record_trace_event({
"kind": "minions_swe_plan",
"plan": plan_text,
"tokens_in": p_in,
"tokens_out": p_out,
})
self.record_trace_event(
{
"kind": "minions_swe_plan",
"plan": plan_text,
"tokens_in": p_in,
"tokens_out": p_out,
}
)
supervisor_cost = self.cost_usd(self._cloud_model, p_in, p_out)
# 2. Local worker runs mini-SWE-agent with the plan as context.
+26 -26
View File
@@ -47,33 +47,33 @@ from openjarvis.core.registry import AgentRegistry
# would seed before any oracle update.
SKILL_CATALOG: Dict[str, str] = {
"factual_recall": "Recall named entities, dates, places, well-known facts from training data without external lookup.",
"factual_recall": "Recall named entities, dates, places, well-known facts from training data without external lookup.",
"multi_step_reasoning": "Chain several inference steps together (e.g. compose dates, traverse relationships, decompose then aggregate).",
"arithmetic": "Exact numeric computation on values already given in the question.",
"web_grounding": "Question needs information likely NOT in a small model's parametric memory (rare facts, recent events, niche sources).",
"arithmetic": "Exact numeric computation on values already given in the question.",
"web_grounding": "Question needs information likely NOT in a small model's parametric memory (rare facts, recent events, niche sources).",
"long_text_extraction": "Read a long supplied document/context and extract a specific piece.",
"format_compliance": "Strict output formatting (e.g. GAIA's `FINAL ANSWER: <answer>` rule, comma-separated lists with no units).",
"code_or_logic": "Write or trace code, or apply logical/symbolic constraints precisely.",
"format_compliance": "Strict output formatting (e.g. GAIA's `FINAL ANSWER: <answer>` rule, comma-separated lists with no units).",
"code_or_logic": "Write or trace code, or apply logical/symbolic constraints precisely.",
}
DEFAULT_AGENT_COMPETENCE: Dict[str, Dict[str, float]] = {
"local-qwen-27b": {
"factual_recall": 0.25,
"factual_recall": 0.25,
"multi_step_reasoning": 0.30,
"arithmetic": 0.55,
"web_grounding": 0.10,
"arithmetic": 0.55,
"web_grounding": 0.10,
"long_text_extraction": 0.55,
"format_compliance": 0.65,
"code_or_logic": 0.45,
"format_compliance": 0.65,
"code_or_logic": 0.45,
},
"cloud-opus-4-7": {
"factual_recall": 0.85,
"factual_recall": 0.85,
"multi_step_reasoning": 0.88,
"arithmetic": 0.85,
"web_grounding": 0.70,
"arithmetic": 0.85,
"web_grounding": 0.70,
"long_text_extraction": 0.90,
"format_compliance": 0.92,
"code_or_logic": 0.90,
"format_compliance": 0.92,
"code_or_logic": 0.90,
},
}
@@ -189,9 +189,7 @@ def _score_agents(
lam = 0.5
scores: Dict[str, Dict[str, float]] = {}
for aid, comps in competence.items():
comp = sum(
skill_weights.get(sid, 0.0) * comps[sid] for sid in SKILL_CATALOG
)
comp = sum(skill_weights.get(sid, 0.0) * comps[sid] for sid in SKILL_CATALOG)
cost_pen = lam * cost.get(aid, 0.0)
scores[aid] = {
"competence": comp,
@@ -313,14 +311,16 @@ class SkillOrchestraAgent(LocalCloudAgent):
if chosen not in competence:
chosen = max(scored, key=lambda a: scored[a]["final_score"])
self.record_trace_event({
"kind": "skillorchestra_route",
"chosen_agent": chosen,
"skill_weights": skill_weights,
"agent_scores": scored,
"reasoning": decision.get("reasoning", ""),
"router_raw": router_text,
})
self.record_trace_event(
{
"kind": "skillorchestra_route",
"chosen_agent": chosen,
"skill_weights": skill_weights,
"agent_scores": scored,
"reasoning": decision.get("reasoning", ""),
"router_raw": router_text,
}
)
tokens_local = 0
tokens_cloud = r_in + r_out
@@ -41,8 +41,12 @@ from .orchestrator import run_orchestrator
from .stage_router import StageSkillHandbook
_VALID_STRATEGIES = {
"none", "router_decides", "analyze_model_decide",
"weighted_avg", "weakest_skill", "strongest_skill",
"none",
"router_decides",
"analyze_model_decide",
"weighted_avg",
"weakest_skill",
"strongest_skill",
}
@@ -146,7 +150,11 @@ class SkillOrchestraAgent(LocalCloudAgent):
strategy = "none"
return run_orchestrator(
self, input, cfg=cfg, handbook=handbook, strategy=strategy,
self,
input,
cfg=cfg,
handbook=handbook,
strategy=strategy,
)
@@ -58,6 +58,7 @@ _STAGE_DEFAULT_ALIAS = {
# Orchestrator decision step (raw SDK — needs tool_use blocks back)
# ---------------------------------------------------------------------------
def _orchestrate_step(
agent: Any,
*,
@@ -126,9 +127,7 @@ def _orchestrate_step(
from google import genai
from google.genai import types
client = genai.Client(
http_options=types.HttpOptions(timeout=600_000)
)
client = genai.Client(http_options=types.HttpOptions(timeout=600_000))
cfg = types.GenerateContentConfig(
temperature=1.0,
max_output_tokens=max_tokens,
@@ -169,16 +168,18 @@ def _orchestrate_step(
)
cost = agent.cost_usd(model, p, c)
agent.record_trace_event({
"kind": "skillorchestra_orchestrate",
"model": model,
"endpoint": endpoint,
"prompt": user,
"response": text,
"tool_calls": tool_calls,
"tokens_in": p,
"tokens_out": c,
})
agent.record_trace_event(
{
"kind": "skillorchestra_orchestrate",
"model": model,
"endpoint": endpoint,
"prompt": user,
"response": text,
"tool_calls": tool_calls,
"tokens_in": p,
"tokens_out": c,
}
)
return text, tool_calls, p, c, cost
@@ -186,6 +187,7 @@ def _orchestrate_step(
# Context assembly — eval_frames.py:1305-1351
# ---------------------------------------------------------------------------
def _build_context(
doc_list: List[Tuple[str, str]],
code_list: List[Tuple[str, str]],
@@ -223,6 +225,7 @@ def _build_context(
# Main loop — eval_frames.py:run_single
# ---------------------------------------------------------------------------
def run_orchestrator(
agent: Any,
problem: str,
@@ -245,12 +248,14 @@ def run_orchestrator(
# MODEL_NAME). Defaults to the cell's cloud model when that endpoint
# supports tool calls, else Opus. ``router_model`` / ``router_endpoint``
# are accepted as back-compat aliases (pre-restructure cfg key names).
orch_endpoint = (cfg.get("orchestrator_endpoint")
or cfg.get("router_endpoint")
or agent._cloud_endpoint).lower()
orch_model = (cfg.get("orchestrator_model")
or cfg.get("router_model")
or agent._cloud_model)
orch_endpoint = (
cfg.get("orchestrator_endpoint")
or cfg.get("router_endpoint")
or agent._cloud_endpoint
).lower()
orch_model = (
cfg.get("orchestrator_model") or cfg.get("router_model") or agent._cloud_model
)
if orch_endpoint not in ("anthropic", "openai", "gemini"):
orch_endpoint, orch_model = "anthropic", "claude-opus-4-7"
orch_max_tokens = int(cfg.get("orchestrator_max_tokens", 4096))
@@ -281,7 +286,9 @@ def run_orchestrator(
if handbook is not None and strategy != "none":
sa = parse_skill_analysis(orch_text)
rr = get_routing_strategy(strategy, handbook).select_model(
stage, sa, tool_call_model=tool_alias,
stage,
sa,
tool_call_model=tool_alias,
)
return rr.model_alias
return tool_alias or _STAGE_DEFAULT_ALIAS[stage]
@@ -290,7 +297,10 @@ def run_orchestrator(
used_rounds = step + 1
is_last = step == max_rounds - 1
context_str = _build_context(
doc_list, code_list, attempt_list, char_cap=char_cap,
doc_list,
code_list,
attempt_list,
char_cap=char_cap,
)
if handbook is not None and strategy != "none":
@@ -301,14 +311,14 @@ def run_orchestrator(
handbook=handbook,
)
else:
user = (
f"Problem: {problem}\n\n{context_str}\n\n"
"Choose an appropriate tool."
)
user = f"Problem: {problem}\n\n{context_str}\n\nChoose an appropriate tool."
text, tcalls, p, c, ocost = _orchestrate_step(
agent, user=user, model=orch_model,
endpoint=orch_endpoint, max_tokens=orch_max_tokens,
agent,
user=user,
model=orch_model,
endpoint=orch_endpoint,
max_tokens=orch_max_tokens,
)
tokens_cloud += p + c
cost_usd += ocost
@@ -337,23 +347,29 @@ def run_orchestrator(
tool_alias = (tc.get("input") or {}).get("model")
stage = _TOOL_STAGE.get(tool, "answer")
chosen_alias = _route(stage, tool_alias, text)
spec: ModelSpec = pool.get(chosen_alias) or pool[
_STAGE_DEFAULT_ALIAS[stage]
]
route_log.append({
"step": step,
"tool": tool,
"orchestrator_alias": tool_alias,
"routed_alias": chosen_alias,
"routed_model": spec.model,
"is_local": spec.is_local,
})
spec: ModelSpec = (
pool.get(chosen_alias) or pool[_STAGE_DEFAULT_ALIAS[stage]]
)
route_log.append(
{
"step": step,
"tool": tool,
"orchestrator_alias": tool_alias,
"routed_alias": chosen_alias,
"routed_model": spec.model,
"is_local": spec.is_local,
}
)
tool_calls_n += 1
if tool == "search":
res = run_search(
agent, spec, context_str=context_str, problem=problem,
retriever_url=retriever_url, web_search_max_uses=ws_max_uses,
agent,
spec,
context_str=context_str,
problem=problem,
retriever_url=retriever_url,
web_search_max_uses=ws_max_uses,
search_backend=search_backend,
tavily_max_results=tavily_max_results,
)
@@ -363,13 +379,19 @@ def run_orchestrator(
web_uses += res.get("web_search_uses", 0)
elif tool in ("enhance_reasoning", "code"):
res = run_code(
agent, spec, context_str=context_str, problem=problem,
agent,
spec,
context_str=context_str,
problem=problem,
bash_timeout_s=code_timeout,
)
code_list.append((res["generated_code"], res["exec_result"]))
else: # answer
res = run_answer(
agent, spec, context_str=context_str, problem=problem,
agent,
spec,
context_str=context_str,
problem=problem,
max_tokens=answer_max_tokens,
)
final_pred = res["pred"]
@@ -385,12 +407,14 @@ def run_orchestrator(
if finish:
break
agent.record_trace_event({
"kind": "skillorchestra_route_log",
"strategy": strategy,
"rounds_used": used_rounds,
"routes": route_log,
})
agent.record_trace_event(
{
"kind": "skillorchestra_route_log",
"strategy": strategy,
"rounds_used": used_rounds,
"routes": route_log,
}
)
meta = {
"tokens_local": tokens_local,
@@ -32,8 +32,14 @@ from typing import Any, Dict, List, Optional, Tuple
STAGE_ALIASES: Dict[str, List[str]] = {
"search": ["search-1", "search-2", "search-3"],
"reasoning": ["reasoner-1", "reasoner-2", "reasoner-3"],
"answer": ["answer-1", "answer-2", "answer-3", "answer-4",
"answer-math-1", "answer-math-2"],
"answer": [
"answer-1",
"answer-2",
"answer-3",
"answer-4",
"answer-math-1",
"answer-math-2",
],
}
# Every alias the orchestrator can emit, flat.
@@ -42,9 +48,13 @@ ALL_ALIASES: List[str] = [a for aliases in STAGE_ALIASES.values() for a in alias
# Default tier: which aliases collapse onto the cloud model vs the local
# model. Dearer ``-1``/``-2`` (+ answer-math-1) -> cloud; cheaper -> local.
_CLOUD_ALIASES = {
"search-1", "search-2",
"reasoner-1", "reasoner-2",
"answer-1", "answer-2", "answer-math-1",
"search-1",
"search-2",
"reasoner-1",
"reasoner-2",
"answer-1",
"answer-2",
"answer-math-1",
}
@@ -54,8 +64,8 @@ class ModelSpec:
alias: str
model: str
endpoint: str # "anthropic" | "openai" | "gemini" | "http://..."
kind: str # "cloud" | "local"
endpoint: str # "anthropic" | "openai" | "gemini" | "http://..."
kind: str # "cloud" | "local"
@property
def is_local(self) -> bool:
@@ -87,7 +97,10 @@ def build_pool(
pool[alias] = ModelSpec(alias, cloud_model, cloud_endpoint, "cloud")
else:
pool[alias] = ModelSpec(
alias, local_model, local_endpoint, "local" # type: ignore[arg-type]
alias,
local_model,
local_endpoint,
"local", # type: ignore[arg-type]
)
for alias, spec in (overrides or {}).items():
@@ -133,18 +146,30 @@ def call_alias(
ep = spec.endpoint.lower()
if ep == "anthropic":
text, p, c, _ = agent._call_anthropic(
spec.model, user=user, system=system,
max_tokens=max_tokens, temperature=temperature, trace_role="cloud",
spec.model,
user=user,
system=system,
max_tokens=max_tokens,
temperature=temperature,
trace_role="cloud",
)
elif ep == "openai":
text, p, c = agent._call_openai(
spec.model, user=user, system=system,
max_tokens=max_tokens, temperature=temperature, trace_role="cloud",
spec.model,
user=user,
system=system,
max_tokens=max_tokens,
temperature=temperature,
trace_role="cloud",
)
elif ep == "gemini":
text, p, c = agent._call_gemini(
spec.model, user=user, system=system,
max_tokens=max_tokens, temperature=temperature, trace_role="cloud",
spec.model,
user=user,
system=system,
max_tokens=max_tokens,
temperature=temperature,
trace_role="cloud",
)
else:
raise ValueError(f"unsupported pool endpoint: {spec.endpoint!r}")
@@ -94,7 +94,12 @@ class StageSkillHandbook:
"answer": {},
}
self.model_profiles: Dict[str, ModelProfile] = {}
self.usage_patterns: Dict[str, Any] = {"stages": {}, "guidelines": {}, "models": {}, "raw": {}}
self.usage_patterns: Dict[str, Any] = {
"stages": {},
"guidelines": {},
"models": {},
"raw": {},
}
self.routing_insights: List[str] = []
self.learning_history: List[Dict[str, Any]] = []
self.version = "1.0.0"
@@ -102,7 +107,10 @@ class StageSkillHandbook:
self.updated_at = ""
def get_model_skill_scores(self) -> Dict[str, Dict[str, float]]:
return {alias: profile.skill_scores for alias, profile in self.model_profiles.items()}
return {
alias: profile.skill_scores
for alias, profile in self.model_profiles.items()
}
def get_models_for_stage(self, stage: str) -> List[ModelProfile]:
return [p for p in self.model_profiles.values() if p.stage == stage]
@@ -135,19 +143,29 @@ class StageSkillHandbook:
def format_model_performance(self, stage: str) -> str:
profiles = self.get_models_for_stage(stage)
valid_prefixes = {"search": ["search-"], "code": ["reasoner-", "code-"], "answer": ["answer-"]}
valid_prefixes = {
"search": ["search-"],
"code": ["reasoner-", "code-"],
"answer": ["answer-"],
}
prefixes = valid_prefixes.get(stage, [])
lines = []
for p in profiles:
if not any(p.model_alias.startswith(prefix) for prefix in prefixes):
continue
has_data = (p.skill_scores and len(p.skill_scores) > 0) or p.strengths or p.weaknesses
has_data = (
(p.skill_scores and len(p.skill_scores) > 0)
or p.strengths
or p.weaknesses
)
if p.total_attempts > 0 or has_data:
lines.append(f"\n### {p.model_alias} ({p.actual_model})")
if p.total_attempts > 0:
rate = p.total_successes / p.total_attempts
lines.append(f"Overall: {rate:.0%} success ({p.total_successes}/{p.total_attempts})")
lines.append(
f"Overall: {rate:.0%} success ({p.total_successes}/{p.total_attempts})"
)
else:
lines.append("Overall: 0% overall")
if p.skill_scores:
@@ -157,7 +175,9 @@ class StageSkillHandbook:
for sid, s in p.skill_scores.items()
if (sid.split(".")[0] if "." in sid else sid) in ("code", stage)
}
for skill_id, score in sorted(stage_skill_scores.items(), key=lambda x: x[1], reverse=True):
for skill_id, score in sorted(
stage_skill_scores.items(), key=lambda x: x[1], reverse=True
):
lines.append(f" - {skill_id}: {score:.0%}")
if p.strengths:
lines.append(f"Strengths: {', '.join(p.strengths[:3])}")
@@ -220,7 +240,9 @@ def parse_skill_analysis(output: str) -> Optional[SkillAnalysis]:
try:
data = json.loads(match.group(1).strip())
required_skills = [
SkillWeight(skill_id=s.get("skill_id", ""), percentage=float(s.get("percentage", 0)))
SkillWeight(
skill_id=s.get("skill_id", ""), percentage=float(s.get("percentage", 0))
)
for s in data.get("required_skills", [])
]
return SkillAnalysis(
@@ -272,7 +294,14 @@ class RoutingStrategy:
if stage == "reasoning":
return ["reasoner-1", "reasoner-2", "reasoner-3"]
if stage == "answer":
return ["answer-1", "answer-2", "answer-3", "answer-4", "answer-math-1", "answer-math-2"]
return [
"answer-1",
"answer-2",
"answer-3",
"answer-4",
"answer-math-1",
"answer-math-2",
]
return []
def select_model(
@@ -292,9 +321,17 @@ class RouterDecidesStrategy(RoutingStrategy):
tool_call_model: Optional[str] = None,
) -> ModelRoutingResult:
if tool_call_model:
return ModelRoutingResult(tool_call_model, "router_decides_from_tool_call", 1.0)
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
return ModelRoutingResult(defaults.get(stage, "answer-1"), "router_decides_fallback", 0.5)
return ModelRoutingResult(
tool_call_model, "router_decides_from_tool_call", 1.0
)
defaults = {
"search": "search-1",
"reasoning": "reasoner-1",
"answer": "answer-1",
}
return ModelRoutingResult(
defaults.get(stage, "answer-1"), "router_decides_fallback", 0.5
)
class AnalyzeModelDecideStrategy(RoutingStrategy):
@@ -305,17 +342,33 @@ class AnalyzeModelDecideStrategy(RoutingStrategy):
tool_call_model: Optional[str] = None,
) -> ModelRoutingResult:
if tool_call_model:
return ModelRoutingResult(tool_call_model, "analyze_model_decide_with_skill_analysis", 1.0)
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
return ModelRoutingResult(defaults.get(stage, "answer-1"), "analyze_model_decide_fallback", 0.5)
return ModelRoutingResult(
tool_call_model, "analyze_model_decide_with_skill_analysis", 1.0
)
defaults = {
"search": "search-1",
"reasoning": "reasoner-1",
"answer": "answer-1",
}
return ModelRoutingResult(
defaults.get(stage, "answer-1"), "analyze_model_decide_fallback", 0.5
)
class WeightedAverageStrategy(RoutingStrategy):
COST_TIERS = {
"search-3": 1, "search-2": 2, "search-1": 3,
"reasoner-3": 1, "reasoner-2": 2, "reasoner-1": 3,
"answer-math-2": 1, "answer-4": 1, "answer-3": 2,
"answer-math-1": 2, "answer-2": 3, "answer-1": 4,
"search-3": 1,
"search-2": 2,
"search-1": 3,
"reasoner-3": 1,
"reasoner-2": 2,
"reasoner-1": 3,
"answer-math-2": 1,
"answer-4": 1,
"answer-3": 2,
"answer-math-1": 2,
"answer-2": 3,
"answer-1": 4,
}
def select_model(
@@ -326,9 +379,17 @@ class WeightedAverageStrategy(RoutingStrategy):
) -> ModelRoutingResult:
if not skill_analysis or not skill_analysis.required_skills:
if tool_call_model:
return ModelRoutingResult(tool_call_model, "weighted_avg_no_skills_use_tool_call", 0.7)
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weighted_avg_no_skills_fallback", 0.5)
return ModelRoutingResult(
tool_call_model, "weighted_avg_no_skills_use_tool_call", 0.7
)
defaults = {
"search": "search-1",
"reasoning": "reasoner-1",
"answer": "answer-1",
}
return ModelRoutingResult(
defaults.get(stage, "answer-1"), "weighted_avg_no_skills_fallback", 0.5
)
models = self._get_models_for_stage(stage)
model_scores = {}
@@ -341,15 +402,25 @@ class WeightedAverageStrategy(RoutingStrategy):
score = scores.get(sid, 0.0)
weighted_sum += weight * score
total_weight += weight
model_scores[model] = weighted_sum / total_weight if total_weight > 0 else 0.5
model_scores[model] = (
weighted_sum / total_weight if total_weight > 0 else 0.5
)
if not model_scores:
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weighted_avg_no_model_scores", 0.5)
defaults = {
"search": "search-1",
"reasoning": "reasoner-1",
"answer": "answer-1",
}
return ModelRoutingResult(
defaults.get(stage, "answer-1"), "weighted_avg_no_model_scores", 0.5
)
max_score = max(model_scores.values())
best = [m for m, s in model_scores.items() if abs(s - max_score) < 0.001]
best.sort(key=lambda m: self.COST_TIERS.get(m, 999))
return ModelRoutingResult(best[0], "weighted_avg_from_skill_analysis", max_score, model_scores)
return ModelRoutingResult(
best[0], "weighted_avg_from_skill_analysis", max_score, model_scores
)
class WeakestSkillStrategy(RoutingStrategy):
@@ -361,18 +432,36 @@ class WeakestSkillStrategy(RoutingStrategy):
) -> ModelRoutingResult:
if not skill_analysis or not skill_analysis.required_skills:
if tool_call_model:
return ModelRoutingResult(tool_call_model, "weakest_skill_no_skills_use_tool_call", 0.7)
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weakest_skill_no_skills_fallback", 0.5)
return ModelRoutingResult(
tool_call_model, "weakest_skill_no_skills_use_tool_call", 0.7
)
defaults = {
"search": "search-1",
"reasoning": "reasoner-1",
"answer": "answer-1",
}
return ModelRoutingResult(
defaults.get(stage, "answer-1"), "weakest_skill_no_skills_fallback", 0.5
)
weakest = min(skill_analysis.required_skills, key=lambda s: s.percentage)
sid = self._find_skill_id(stage, weakest.skill_id) or weakest.skill_id
models = self._get_models_for_stage(stage)
model_scores = {m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models}
model_scores = {
m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models
}
if not model_scores:
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
return ModelRoutingResult(defaults.get(stage, "answer-1"), "weakest_skill_no_model_scores", 0.5)
defaults = {
"search": "search-1",
"reasoning": "reasoner-1",
"answer": "answer-1",
}
return ModelRoutingResult(
defaults.get(stage, "answer-1"), "weakest_skill_no_model_scores", 0.5
)
best = max(model_scores, key=model_scores.get)
return ModelRoutingResult(best, f"weakest_skill_{weakest.skill_id}", model_scores[best], model_scores)
return ModelRoutingResult(
best, f"weakest_skill_{weakest.skill_id}", model_scores[best], model_scores
)
class StrongestSkillStrategy(RoutingStrategy):
@@ -384,18 +473,41 @@ class StrongestSkillStrategy(RoutingStrategy):
) -> ModelRoutingResult:
if not skill_analysis or not skill_analysis.required_skills:
if tool_call_model:
return ModelRoutingResult(tool_call_model, "strongest_skill_no_skills_use_tool_call", 0.7)
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
return ModelRoutingResult(defaults.get(stage, "answer-1"), "strongest_skill_no_skills_fallback", 0.5)
return ModelRoutingResult(
tool_call_model, "strongest_skill_no_skills_use_tool_call", 0.7
)
defaults = {
"search": "search-1",
"reasoning": "reasoner-1",
"answer": "answer-1",
}
return ModelRoutingResult(
defaults.get(stage, "answer-1"),
"strongest_skill_no_skills_fallback",
0.5,
)
strongest = max(skill_analysis.required_skills, key=lambda s: s.percentage)
sid = self._find_skill_id(stage, strongest.skill_id) or strongest.skill_id
models = self._get_models_for_stage(stage)
model_scores = {m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models}
model_scores = {
m: self._model_skill_scores.get(m, {}).get(sid, 0.5) for m in models
}
if not model_scores:
defaults = {"search": "search-1", "reasoning": "reasoner-1", "answer": "answer-1"}
return ModelRoutingResult(defaults.get(stage, "answer-1"), "strongest_skill_no_model_scores", 0.5)
defaults = {
"search": "search-1",
"reasoning": "reasoner-1",
"answer": "answer-1",
}
return ModelRoutingResult(
defaults.get(stage, "answer-1"), "strongest_skill_no_model_scores", 0.5
)
best = max(model_scores, key=model_scores.get)
return ModelRoutingResult(best, f"strongest_skill_{strongest.skill_id}", model_scores[best], model_scores)
return ModelRoutingResult(
best,
f"strongest_skill_{strongest.skill_id}",
model_scores[best],
model_scores,
)
ROUTING_STRATEGIES = {
@@ -41,8 +41,7 @@ _SEARCH_CAPABLE_ENDPOINTS = ("anthropic", "openai", "gemini")
_SEARCH_DESC = "Search for missing information."
_CODE_DESC = (
"Write and execute Python code to compute intermediate results for "
"the problem."
"Write and execute Python code to compute intermediate results for the problem."
)
_ANSWER_DESC = (
"Extract the final answer when you have gathered enough information "
@@ -52,8 +51,14 @@ _ANSWER_DESC = (
_ENUMS = {
"search": ["search-1", "search-2", "search-3"],
"enhance_reasoning": ["reasoner-1", "reasoner-2", "reasoner-3"],
"answer": ["answer-1", "answer-2", "answer-3", "answer-4",
"answer-math-1", "answer-math-2"],
"answer": [
"answer-1",
"answer-2",
"answer-3",
"answer-4",
"answer-math-1",
"answer-math-2",
],
}
@@ -76,15 +81,17 @@ def anthropic_tools() -> List[Dict[str, Any]]:
("enhance_reasoning", _CODE_DESC),
("answer", _ANSWER_DESC),
):
out.append({
"name": name,
"description": desc,
"input_schema": {
"type": "object",
"properties": {"model": _model_prop(name)},
"required": ["model"],
},
})
out.append(
{
"name": name,
"description": desc,
"input_schema": {
"type": "object",
"properties": {"model": _model_prop(name)},
"required": ["model"],
},
}
)
return out
@@ -96,18 +103,20 @@ def openai_tools() -> List[Dict[str, Any]]:
("enhance_reasoning", _CODE_DESC),
("answer", _ANSWER_DESC),
):
out.append({
"type": "function",
"function": {
"name": name,
"description": desc,
"parameters": {
"type": "object",
"properties": {"model": _model_prop(name)},
"required": ["model"],
out.append(
{
"type": "function",
"function": {
"name": name,
"description": desc,
"parameters": {
"type": "object",
"properties": {"model": _model_prop(name)},
"required": ["model"],
},
},
},
})
}
)
return out
@@ -119,15 +128,17 @@ def gemini_tools() -> List[Dict[str, Any]]:
("enhance_reasoning", _CODE_DESC),
("answer", _ANSWER_DESC),
):
out.append({
"name": name,
"description": desc,
"parameters": {
"type": "object",
"properties": {"model": _model_prop(name)},
"required": ["model"],
},
})
out.append(
{
"name": name,
"description": desc,
"parameters": {
"type": "object",
"properties": {"model": _model_prop(name)},
"required": ["model"],
},
}
)
return out
@@ -135,6 +146,7 @@ def gemini_tools() -> List[Dict[str, Any]]:
# enhance_reasoning / code — eval_frames.py:659-812
# ---------------------------------------------------------------------------
def run_code(
agent: Any,
spec: ModelSpec,
@@ -150,7 +162,8 @@ def run_code(
rather than raising the orchestrator learns the model can't code.
"""
prompt = (
context_str.strip() + "\n\n"
context_str.strip()
+ "\n\n"
+ f"Question: {problem}\nInstead of directly answering the question, "
"please write additional python code that will give intermidiate "
"results after execution. Wrap the code within ```python and ```. "
@@ -158,7 +171,11 @@ def run_code(
"initialization."
)
text, p, c, cost = call_alias(
agent, spec, user=prompt, max_tokens=8000, temperature=1.0,
agent,
spec,
user=prompt,
max_tokens=8000,
temperature=1.0,
)
generated_code = ""
if "```python" in text:
@@ -197,6 +214,7 @@ def run_code(
# answer — eval_frames.py:814-997
# ---------------------------------------------------------------------------
def run_answer(
agent: Any,
spec: ModelSpec,
@@ -219,11 +237,15 @@ def run_answer(
boxed = False
if "qwen3" in model_l and "235" not in model_l:
system = "Please reason step by step, and put your final answer within \\boxed{}."
system = (
"Please reason step by step, and put your final answer within \\boxed{}."
)
user = base
boxed = True
elif "qwen2.5-math" in model_l or "qwen-2.5-math" in model_l:
system = "Please reason step by step, and put your final answer within \\boxed{}."
system = (
"Please reason step by step, and put your final answer within \\boxed{}."
)
user = base
boxed = True
else:
@@ -236,8 +258,12 @@ def run_answer(
)
text, p, c, cost = call_alias(
agent, spec, user=user, system=system,
max_tokens=max_tokens, temperature=1.0,
agent,
spec,
user=user,
system=system,
max_tokens=max_tokens,
temperature=1.0,
)
pred = ""
@@ -268,6 +294,7 @@ def run_answer(
# search — eval_frames.py:999-1096
# ---------------------------------------------------------------------------
def run_search(
agent: Any,
spec: ModelSpec,
@@ -288,13 +315,18 @@ def run_search(
OpenJarvis substitution for the missing FAISS wiki index).
"""
prompt = (
context_str.strip() + "\n\n"
context_str.strip()
+ "\n\n"
+ f"Question: {problem}\nInstead of directly answering the question, "
"please think hard and write a concise query to search Wikipedia. "
"Wrap the query within <query> and </query>."
)
text, p, c, cost = call_alias(
agent, spec, user=prompt, max_tokens=8000, temperature=1.0,
agent,
spec,
user=prompt,
max_tokens=8000,
temperature=1.0,
)
if "<query>" in text:
query = text.split("<query>")[-1].split("</query>")[0].strip()
@@ -322,7 +354,9 @@ def run_search(
}
try:
results = requests.post(
f"{retriever_url.rstrip('/')}/retrieve", json=payload, timeout=120,
f"{retriever_url.rstrip('/')}/retrieve",
json=payload,
timeout=120,
).json()
for r in results[0]:
doc = r.get("document", {})
@@ -20,6 +20,7 @@ from typing import Any, Dict, List, Optional
# BetaCompetence
# ---------------------------------------------------------------------------
@dataclass
class BetaCompetence:
"""Bayesian competence estimate for an agent on a specific skill.
@@ -80,6 +81,7 @@ class BetaCompetence:
# CostStats
# ---------------------------------------------------------------------------
@dataclass
class CostStats:
"""Execution cost statistics for an agent under a specific mode.
@@ -110,11 +112,17 @@ class CostStats:
"""Incremental running-average update."""
n = self.total_executions
self.avg_prompt_tokens = (self.avg_prompt_tokens * n + prompt_tokens) / (n + 1)
self.avg_completion_tokens = (self.avg_completion_tokens * n + completion_tokens) / (n + 1)
self.avg_completion_tokens = (
self.avg_completion_tokens * n + completion_tokens
) / (n + 1)
self.avg_latency_s = (self.avg_latency_s * n + latency_s) / (n + 1)
self.avg_cost_usd = (self.avg_cost_usd * n + cost_usd) / (n + 1)
self.avg_completion_cost_usd = (self.avg_completion_cost_usd * n + completion_cost_usd) / (n + 1)
self.avg_prompt_cost_usd = (self.avg_prompt_cost_usd * n + prompt_cost_usd) / (n + 1)
self.avg_completion_cost_usd = (
self.avg_completion_cost_usd * n + completion_cost_usd
) / (n + 1)
self.avg_prompt_cost_usd = (self.avg_prompt_cost_usd * n + prompt_cost_usd) / (
n + 1
)
self.total_executions = n + 1
def to_dict(self) -> Dict[str, Any]:
@@ -137,6 +145,7 @@ class CostStats:
# RoutingInsight
# ---------------------------------------------------------------------------
@dataclass
class RoutingInsight:
"""A single routing insight learned from execution traces"""
@@ -165,6 +174,7 @@ class RoutingInsight:
# ModeMetadata
# ---------------------------------------------------------------------------
@dataclass
class ModeMetadata:
"""Mode-level routing metadata."""
@@ -197,6 +207,7 @@ class ModeMetadata:
# Skill
# ---------------------------------------------------------------------------
@dataclass
class SkillProvenance:
"""Tracks how and why a skill was discovered."""
@@ -231,7 +242,7 @@ class Skill:
indicators: List[str] = field(default_factory=list)
examples: List[str] = field(default_factory=list)
mode: str = ""
parent_skill_id: Optional[str] = None # for hierarchical skills
parent_skill_id: Optional[str] = None # for hierarchical skills
provenance: SkillProvenance = field(default_factory=SkillProvenance)
def to_dict(self) -> Dict[str, Any]:
@@ -273,6 +284,7 @@ class Skill:
# AgentProfile
# ---------------------------------------------------------------------------
@dataclass
class AgentProfile:
"""Agent profile for skill-aware orchestration."""
@@ -309,9 +321,7 @@ class AgentProfile:
"""Update competence estimate for a skill."""
self.get_competence_dist(skill_id).update(success)
def weighted_competence(
self, skill_weights: Dict[str, float]
) -> float:
def weighted_competence(self, skill_weights: Dict[str, float]) -> float:
"""Compute weighted competence: sum w_{t,sigma} * alpha/(alpha+beta)."""
if not skill_weights:
return 0.5
@@ -334,9 +344,7 @@ class AgentProfile:
]
return sum(scores) / len(scores) if scores else 0.0
def category_competence_for_skills(
self, active_skill_ids: List[str]
) -> float:
def category_competence_for_skills(self, active_skill_ids: List[str]) -> float:
"""Category-level competence for hierarchical tie-breaking.
Extracts parent categories from active_skill_ids (e.g. 'entertainment_knowledge'
@@ -349,7 +357,9 @@ class AgentProfile:
categories.add(cat)
if not categories:
return 0.0
return sum(self.category_competence(cat) for cat in categories) / len(categories)
return sum(self.category_competence(cat) for cat in categories) / len(
categories
)
@property
def overall_success_rate(self) -> float:
@@ -390,8 +400,12 @@ class AgentProfile:
"skill_scores": skill_scores,
"skill_attempts": skill_attempts,
"skill_successes": skill_successes,
"total_attempts": self.total_attempts if self.total_attempts > 0 else skill_total_attempts,
"total_successes": self.total_successes if self.total_attempts > 0 else skill_total_successes,
"total_attempts": self.total_attempts
if self.total_attempts > 0
else skill_total_attempts,
"total_successes": self.total_successes
if self.total_attempts > 0
else skill_total_successes,
"cost_stats": self.cost_stats.to_dict(),
"routing_signals": self.routing_signals,
"strengths": self.strengths,
+382 -242
View File
@@ -172,8 +172,12 @@ RL_ALL_TOOLS: Dict[str, Dict[str, List[str]]] = {
"enhance_reasoning": {"model": ["reasoner-1", "reasoner-2", "reasoner-3"]},
"answer": {
"model": [
"answer-1", "answer-2", "answer-3", "answer-4",
"answer-math-1", "answer-math-2",
"answer-1",
"answer-2",
"answer-3",
"answer-4",
"answer-math-1",
"answer-math-2",
],
},
"search": {"model": ["search-1", "search-2", "search-3"]},
@@ -188,10 +192,14 @@ RL_ALL_TOOLS: Dict[str, Dict[str, List[str]]] = {
# so the substitution is deferred until we know the cell's resolved local/cloud
# pair. Worker dicts share the schema validated by `_resolve_worker_pool`.
def _expert_for(slot: str, local_model: Optional[str],
local_endpoint: Optional[str],
cloud_model: str,
cloud_endpoint: str = "anthropic") -> Dict[str, Any]:
def _expert_for(
slot: str,
local_model: Optional[str],
local_endpoint: Optional[str],
cloud_model: str,
cloud_endpoint: str = "anthropic",
) -> Dict[str, Any]:
"""Map an upstream model slot (`answer-1`, `search-3`, …) to a worker spec.
Routing policy:
@@ -357,6 +365,7 @@ def _paper_expert_for(
# ---- Tavily + Modal helpers -------------------------------------------------
def _call_tavily_search(
query: str,
max_results: int = 5,
@@ -390,7 +399,9 @@ def _call_modal_python(code: str, timeout_s: int = 60) -> Tuple[str, int]:
# Python image too. We rely on stdlib only — no extra pip installs.
image = modal.Image.debian_slim(python_version="3.12")
sb = modal.Sandbox.create(
"python", "-c", code,
"python",
"-c",
code,
app=app,
image=image,
timeout=int(timeout_s),
@@ -485,53 +496,77 @@ def _paper_pool(
"""
pool: List[Dict[str, Any]] = []
if local_model and local_endpoint:
pool.append({
pool.append(
{
"id": len(pool),
"name": "local-qwen",
"type": "vllm",
"model": local_model,
"base_url": local_endpoint,
"description": "Local Qwen vLLM (paper uses Qwen3-32B).",
}
)
pool.append(
{
"id": len(pool),
"name": "local-qwen",
"type": "vllm",
"model": local_model,
"base_url": local_endpoint,
"description": "Local Qwen vLLM (paper uses Qwen3-32B).",
})
pool.append({
"id": len(pool), "name": "tavily-search",
"type": "tavily-search", "model": "tavily",
"description": "Tavily web search.",
})
pool.append({
"id": len(pool), "name": "modal-python",
"type": "modal-python", "model": "modal-python",
"description": "Modal Sandbox for one-shot Python exec.",
})
pool.append({
"id": len(pool), "name": "code-specialist",
"type": "openrouter", "model": _PAPER_CODER_OPENROUTER,
"description": "Qwen-2.5-Coder-32B via OpenRouter (paper).",
})
pool.append({
"id": len(pool), "name": "generalist-llama",
"type": "openrouter", "model": _PAPER_GENERALIST_TIER3_OPENROUTER,
"description": "Llama-3.3-70B-Instruct via OpenRouter (paper tier-3).",
})
pool.append({
"id": len(pool), "name": "generalist-gpt5",
"type": "openai", "model": "gpt-5",
"description": "GPT-5 frontier generalist.",
})
pool.append({
"id": len(pool), "name": "generalist-gpt5-mini",
"type": "openai", "model": "gpt-5-mini",
"description": "GPT-5-mini mid generalist.",
})
"name": "tavily-search",
"type": "tavily-search",
"model": "tavily",
"description": "Tavily web search.",
}
)
pool.append(
{
"id": len(pool),
"name": "modal-python",
"type": "modal-python",
"model": "modal-python",
"description": "Modal Sandbox for one-shot Python exec.",
}
)
pool.append(
{
"id": len(pool),
"name": "code-specialist",
"type": "openrouter",
"model": _PAPER_CODER_OPENROUTER,
"description": "Qwen-2.5-Coder-32B via OpenRouter (paper).",
}
)
pool.append(
{
"id": len(pool),
"name": "generalist-llama",
"type": "openrouter",
"model": _PAPER_GENERALIST_TIER3_OPENROUTER,
"description": "Llama-3.3-70B-Instruct via OpenRouter (paper tier-3).",
}
)
pool.append(
{
"id": len(pool),
"name": "generalist-gpt5",
"type": "openai",
"model": "gpt-5",
"description": "GPT-5 frontier generalist.",
}
)
pool.append(
{
"id": len(pool),
"name": "generalist-gpt5-mini",
"type": "openai",
"model": "gpt-5-mini",
"description": "GPT-5-mini mid generalist.",
}
)
return pool
# Regex for ``<tool_call>{...}</tool_call>`` blocks emitted by Orchestrator-8B
# when the vLLM tool parser doesn't catch them (e.g. `qwen3_xml` parser on a
# hermes-style template). Captures the JSON payload.
_TOOL_CALL_TAG_RE = re.compile(
r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL
)
_TOOL_CALL_TAG_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
def _parse_rl_tool_call(content: str, sdk_tool_calls: Any) -> Optional[Dict[str, Any]]:
@@ -608,7 +643,7 @@ def _strip_fences(s: str) -> str:
if s.startswith("```"):
first_nl = s.find("\n")
if first_nl != -1:
s = s[first_nl + 1:]
s = s[first_nl + 1 :]
if s.endswith("```"):
s = s[:-3]
s = s.strip()
@@ -660,6 +695,7 @@ def _extract_final_answer_text(text: str) -> str:
# ---------- Worker pool ----------
def _default_pool(
local_model: Optional[str],
local_endpoint: Optional[str],
@@ -677,17 +713,19 @@ def _default_pool(
ep = "anthropic"
pool: List[Dict[str, Any]] = []
if local_model and local_endpoint:
pool.append({
"id": len(pool),
"name": "local-qwen",
"type": "vllm",
"model": local_model,
"base_url": local_endpoint,
"description": (
"Open-weights Qwen3.5 served locally. Cheap and fast. Good at "
"concise extraction, formatting, arithmetic on given data."
),
})
pool.append(
{
"id": len(pool),
"name": "local-qwen",
"type": "vllm",
"model": local_model,
"base_url": local_endpoint,
"description": (
"Open-weights Qwen3.5 served locally. Cheap and fast. Good at "
"concise extraction, formatting, arithmetic on given data."
),
}
)
if ep == "openai":
search_type = "openai-web-search"
search_model = cloud_model
@@ -700,36 +738,42 @@ def _default_pool(
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": search_type,
"model": search_model,
"description": (
f"{search_desc} Use for facts that need a lookup "
"(recent events, rare names/dates, niche sources). Returns a digest."
),
})
pool.append({
"id": len(pool),
"name": f"frontier-{ep}",
"type": ep,
"model": cloud_model,
"description": (
"Frontier reasoning model. Use for hard multi-step reasoning, "
"code review, or a final synthesis pass. Expensive — use sparingly."
),
})
pool.append({
"id": len(pool),
"name": "frontier-openai-mini",
"type": "openai",
"model": "gpt-5-mini",
"description": (
"Mid-tier OpenAI model. Solid general knowledge and reasoning at a "
"fraction of frontier cost."
),
})
pool.append(
{
"id": len(pool),
"name": "web-search",
"type": search_type,
"model": search_model,
"description": (
f"{search_desc} Use for facts that need a lookup "
"(recent events, rare names/dates, niche sources). Returns a digest."
),
}
)
pool.append(
{
"id": len(pool),
"name": f"frontier-{ep}",
"type": ep,
"model": cloud_model,
"description": (
"Frontier reasoning model. Use for hard multi-step reasoning, "
"code review, or a final synthesis pass. Expensive — use sparingly."
),
}
)
pool.append(
{
"id": len(pool),
"name": "frontier-openai-mini",
"type": "openai",
"model": "gpt-5-mini",
"description": (
"Mid-tier OpenAI model. Solid general knowledge and reasoning at a "
"fraction of frontier cost."
),
}
)
return pool
@@ -743,12 +787,21 @@ 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",
"openai-web-search", "gemini", "gemini-web-search", "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",
"anthropic-web-search",
"openai-web-search",
"gemini-web-search",
"tavily-search",
)
@@ -808,9 +861,7 @@ def _resolve_worker_pool(
f"Invalid worker_pool entry [{wid_repr}]: 'id' must be an int"
)
if wid in seen_ids:
raise ValueError(
f"Invalid worker_pool entry [{wid}]: duplicate id"
)
raise ValueError(f"Invalid worker_pool entry [{wid}]: duplicate id")
seen_ids.add(wid)
if not entry.get("name") or not isinstance(entry["name"], str):
raise ValueError(
@@ -850,7 +901,10 @@ def _resolve_worker_pool(
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:
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)})"
@@ -958,7 +1012,9 @@ def _call_worker(
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,
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
@@ -975,7 +1031,8 @@ def _call_worker(
if wtype == "tavily-search":
max_results = int(cfg.get("tavily_max_results", 5))
text, p, c, extra, n_searches = _call_tavily_search(
str(prompt), max_results=max_results,
str(prompt),
max_results=max_results,
)
return text, p, c, False, extra, n_searches
if wtype == "openrouter":
@@ -1045,8 +1102,12 @@ def _swe_call_worker(
is_local = backbone == "local"
return (
out["final_summary"] or out["answer"],
out["tokens_in"], out["tokens_out"],
is_local, 0.0, 0, int(out["turns"]),
out["tokens_in"],
out["tokens_out"],
is_local,
0.0,
0,
int(out["turns"]),
)
@@ -1134,20 +1195,24 @@ class ToolOrchestraAgent(LocalCloudAgent):
)
shared_workdir: Optional[Path] = None
if swe_mode:
shared_workdir = Path(tempfile.mkdtemp(
prefix=f"toolorch-swe-{task_meta.get('task_id','x')}-"
))
shared_workdir = Path(
tempfile.mkdtemp(
prefix=f"toolorch-swe-{task_meta.get('task_id', 'x')}-"
)
)
try:
_clone_repo(task_meta["repo"], task_meta["base_commit"], shared_workdir)
except Exception:
shutil.rmtree(shared_workdir, ignore_errors=True)
raise
self.record_trace_event({
"kind": "toolorchestra_swe_workdir",
"workdir": str(shared_workdir),
"repo": task_meta["repo"],
"base_commit": task_meta["base_commit"],
})
self.record_trace_event(
{
"kind": "toolorchestra_swe_workdir",
"workdir": str(shared_workdir),
"repo": task_meta["repo"],
"base_commit": task_meta["base_commit"],
}
)
# try/finally guards ``shared_workdir`` against exceptions raised
# anywhere in the turn loop, the worker calls, the fallback, or
@@ -1185,15 +1250,22 @@ class ToolOrchestraAgent(LocalCloudAgent):
cost += self.cost_usd(self._cloud_model, o_in, o_out)
action = _parse_action(text)
history.append({
"role": "orchestrator", "turn": turn, "raw": text, "action": action,
})
self.record_trace_event({
"kind": "toolorchestra_action",
"turn": turn,
"action": action,
"raw": text,
})
history.append(
{
"role": "orchestrator",
"turn": turn,
"raw": text,
"action": action,
}
)
self.record_trace_event(
{
"kind": "toolorchestra_action",
"turn": turn,
"action": action,
"raw": text,
}
)
if action is None:
parse_failures += 1
@@ -1217,12 +1289,21 @@ class ToolOrchestraAgent(LocalCloudAgent):
continue
worker = workers[wid]
if swe_mode and shared_workdir is not None:
(w_text, w_in, w_out, is_local, extra_cost,
n_searches, bash_turns) = (
_swe_call_worker(
worker, str(w_input), cfg, task_meta,
shared_workdir, turn,
)
(
w_text,
w_in,
w_out,
is_local,
extra_cost,
n_searches,
bash_turns,
) = _swe_call_worker(
worker,
str(w_input),
cfg,
task_meta,
shared_workdir,
turn,
)
tool_calls += bash_turns
else:
@@ -1236,17 +1317,19 @@ class ToolOrchestraAgent(LocalCloudAgent):
cost += self.cost_usd(worker["model"], w_in, w_out) + extra_cost
n_web_searches_total += n_searches
tool_calls += n_searches
history.append({
"role": "worker",
"turn": turn,
"worker_id": wid,
"worker_name": worker["name"],
"worker_model": worker["model"],
"output": w_text,
"tokens_in": w_in,
"tokens_out": w_out,
"n_web_searches": n_searches,
})
history.append(
{
"role": "worker",
"turn": turn,
"worker_id": wid,
"worker_name": worker["name"],
"worker_model": worker["model"],
"output": w_text,
"tokens_in": w_in,
"tokens_out": w_out,
"n_web_searches": n_searches,
}
)
continue
# Unknown action kind — treat as parse failure.
parse_failures += 1
@@ -1258,18 +1341,22 @@ 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") not in _TOOLORCH_SEARCH_TYPES
w for w in workers if w.get("type") not in _TOOLORCH_SEARCH_TYPES
] or workers
worker = max(
non_search,
key=lambda w: PRICES.get(w.get("model", ""), (0.0, 0.0))[1],
)
if swe_mode and shared_workdir is not None:
(ans, w_in, w_out, is_local, extra_cost, _,
bash_turns) = _swe_call_worker(
worker, question, cfg, task_meta,
shared_workdir, max_turns + 1,
(ans, w_in, w_out, is_local, extra_cost, _, bash_turns) = (
_swe_call_worker(
worker,
question,
cfg,
task_meta,
shared_workdir,
max_turns + 1,
)
)
tool_calls += bash_turns
else:
@@ -1281,17 +1368,19 @@ class ToolOrchestraAgent(LocalCloudAgent):
else:
tokens_cloud += w_in + w_out
cost += self.cost_usd(worker["model"], w_in, w_out) + extra_cost
history.append({
"role": "worker",
"turn": max_turns + 1,
"worker_id": worker["id"],
"worker_name": worker["name"],
"worker_model": worker["model"],
"output": ans,
"tokens_in": w_in,
"tokens_out": w_out,
"fallback": True,
})
history.append(
{
"role": "worker",
"turn": max_turns + 1,
"worker_id": worker["id"],
"worker_name": worker["name"],
"worker_model": worker["model"],
"output": ans,
"tokens_in": w_in,
"tokens_out": w_out,
"fallback": True,
}
)
final_answer = ans
# In SWE mode, the authoritative output is the working-tree diff —
@@ -1301,7 +1390,8 @@ class ToolOrchestraAgent(LocalCloudAgent):
if patch.strip():
final_answer = (
f"{final_answer}\n\n```diff\n{patch}```"
if final_answer else f"```diff\n{patch}```"
if final_answer
else f"```diff\n{patch}```"
)
meta = {
@@ -1371,20 +1461,24 @@ class ToolOrchestraAgent(LocalCloudAgent):
)
shared_workdir: Optional[Path] = None
if swe_mode:
shared_workdir = Path(tempfile.mkdtemp(
prefix=f"toolorch-rl-swe-{task_meta.get('task_id','x')}-"
))
shared_workdir = Path(
tempfile.mkdtemp(
prefix=f"toolorch-rl-swe-{task_meta.get('task_id', 'x')}-"
)
)
try:
_clone_repo(task_meta["repo"], task_meta["base_commit"], shared_workdir)
except Exception:
shutil.rmtree(shared_workdir, ignore_errors=True)
raise
self.record_trace_event({
"kind": "toolorchestra_rl_swe_workdir",
"workdir": str(shared_workdir),
"repo": task_meta["repo"],
"base_commit": task_meta["base_commit"],
})
self.record_trace_event(
{
"kind": "toolorchestra_rl_swe_workdir",
"workdir": str(shared_workdir),
"repo": task_meta["repo"],
"base_commit": task_meta["base_commit"],
}
)
# ``context_str`` mirrors the upstream's running context — accumulates
# search documents and code/exec snippets across turns. We keep this
@@ -1433,40 +1527,53 @@ class ToolOrchestraAgent(LocalCloudAgent):
temperature=orch_temp,
tools=RL_TOOLS_SPEC,
)
self.record_trace_event({
"kind": "vllm",
"role": "orchestrator",
"model": orch_model,
"endpoint": orch_endpoint,
"system": RL_ORCHESTRATOR_SYS,
"user": user,
"response": text,
"tool_calls": [
{
"id": getattr(tc, "id", None),
"type": getattr(tc, "type", None),
"function": {
"name": getattr(getattr(tc, "function", None), "name", None),
"arguments": getattr(getattr(tc, "function", None), "arguments", None),
},
}
for tc in (sdk_tool_calls or [])
],
"tokens_in": o_in,
"tokens_out": o_out,
})
self.record_trace_event(
{
"kind": "vllm",
"role": "orchestrator",
"model": orch_model,
"endpoint": orch_endpoint,
"system": RL_ORCHESTRATOR_SYS,
"user": user,
"response": text,
"tool_calls": [
{
"id": getattr(tc, "id", None),
"type": getattr(tc, "type", None),
"function": {
"name": getattr(
getattr(tc, "function", None), "name", None
),
"arguments": getattr(
getattr(tc, "function", None), "arguments", None
),
},
}
for tc in (sdk_tool_calls or [])
],
"tokens_in": o_in,
"tokens_out": o_out,
}
)
tokens_local += o_in + o_out
action = _parse_rl_tool_call(text, sdk_tool_calls)
history.append({
"role": "orchestrator", "turn": turn, "raw": text, "action": action,
})
self.record_trace_event({
"kind": "toolorchestra_rl_action",
"turn": turn,
"action": action,
"raw": text,
})
history.append(
{
"role": "orchestrator",
"turn": turn,
"raw": text,
"action": action,
}
)
self.record_trace_event(
{
"kind": "toolorchestra_rl_action",
"turn": turn,
"action": action,
"raw": text,
}
)
if action is None:
parse_failures += 1
@@ -1479,8 +1586,10 @@ class ToolOrchestraAgent(LocalCloudAgent):
slot = args.get("model", "")
# Validate against the upstream tool/arg schema.
valid = name in RL_ALL_TOOLS and isinstance(slot, str) and (
slot in RL_ALL_TOOLS[name]["model"]
valid = (
name in RL_ALL_TOOLS
and isinstance(slot, str)
and (slot in RL_ALL_TOOLS[name]["model"])
)
if not valid:
parse_failures += 1
@@ -1501,8 +1610,11 @@ class ToolOrchestraAgent(LocalCloudAgent):
# framing).
if paper_mode:
worker = _paper_expert_for(
slot, self._local_model, self._local_endpoint,
self._cloud_model, self._cloud_endpoint,
slot,
self._local_model,
self._local_endpoint,
self._cloud_model,
self._cloud_endpoint,
)
# In paper mode, `enhance_reasoning` is always the coder
# specialist regardless of the orchestrator's chosen tier.
@@ -1516,7 +1628,10 @@ class ToolOrchestraAgent(LocalCloudAgent):
}
else:
worker = _expert_for(
slot, self._local_model, self._local_endpoint, self._cloud_model,
slot,
self._local_model,
self._local_endpoint,
self._cloud_model,
self._cloud_endpoint,
)
@@ -1572,13 +1687,25 @@ class ToolOrchestraAgent(LocalCloudAgent):
# bash_turns=0; vllm/anthropic-typed workers run the loop.
bash_turns = 0
if swe_mode and shared_workdir is not None and name != "search":
(w_text, w_in, w_out, is_local, extra_cost,
n_searches, bash_turns) = _swe_call_worker(
worker, w_input, cfg, task_meta, shared_workdir, turn,
(
w_text,
w_in,
w_out,
is_local,
extra_cost,
n_searches,
bash_turns,
) = _swe_call_worker(
worker,
w_input,
cfg,
task_meta,
shared_workdir,
turn,
)
else:
w_text, w_in, w_out, is_local, extra_cost, n_searches = _call_worker(
worker, w_input, cfg
w_text, w_in, w_out, is_local, extra_cost, n_searches = (
_call_worker(worker, w_input, cfg)
)
if is_local:
tokens_local += w_in + w_out
@@ -1597,13 +1724,13 @@ class ToolOrchestraAgent(LocalCloudAgent):
# when no python block is found.
modal_exec_output: Optional[str] = None
modal_exec_rc: Optional[int] = None
if (paper_mode and name == "enhance_reasoning"
and not swe_mode):
if paper_mode and name == "enhance_reasoning" and not swe_mode:
code = _extract_first_python_block(w_text)
if code:
timeout_s = int(cfg.get("modal_python_timeout_s", 60))
modal_exec_output, modal_exec_rc = _call_modal_python(
code, timeout_s=timeout_s,
code,
timeout_s=timeout_s,
)
tool_calls += 1
w_text = (
@@ -1611,27 +1738,29 @@ class ToolOrchestraAgent(LocalCloudAgent):
f"(rc={modal_exec_rc})]\n{modal_exec_output}"
)
history.append({
"role": "worker",
"turn": turn,
"tool": name,
"slot": slot,
"worker_model": worker["model"],
"worker_type": worker["type"],
"output": w_text,
"tokens_in": w_in,
"tokens_out": w_out,
"n_web_searches": n_searches,
"bash_turns": bash_turns,
"modal_exec_rc": modal_exec_rc,
})
history.append(
{
"role": "worker",
"turn": turn,
"tool": name,
"slot": slot,
"worker_model": worker["model"],
"worker_type": worker["type"],
"output": w_text,
"tokens_in": w_in,
"tokens_out": w_out,
"n_web_searches": n_searches,
"bash_turns": bash_turns,
"modal_exec_rc": modal_exec_rc,
}
)
# Update accumulated context for the next turn.
if name == "search":
# Treat the search worker's response as a document.
doc_list.append(w_text)
ctx_docs = "\n\n".join(
f"Doc {i+1}: {d}" for i, d in enumerate(doc_list)
f"Doc {i + 1}: {d}" for i, d in enumerate(doc_list)
)
# Crude char-level cap mirrors the upstream's ~24k token cap.
context_str = ("Documents:\n" + ctx_docs)[-24000:]
@@ -1648,15 +1777,23 @@ class ToolOrchestraAgent(LocalCloudAgent):
# it can still touch the workdir and emit a diff.
expert_fn = _paper_expert_for if paper_mode else _expert_for
worker = expert_fn(
"answer-1", self._local_model, self._local_endpoint,
self._cloud_model, self._cloud_endpoint,
"answer-1",
self._local_model,
self._local_endpoint,
self._cloud_model,
self._cloud_endpoint,
)
fb_bash_turns = 0
if swe_mode and shared_workdir is not None:
(ans, w_in, w_out, is_local, extra_cost,
_, fb_bash_turns) = _swe_call_worker(
worker, question, cfg, task_meta,
shared_workdir, max_turns + 1,
(ans, w_in, w_out, is_local, extra_cost, _, fb_bash_turns) = (
_swe_call_worker(
worker,
question,
cfg,
task_meta,
shared_workdir,
max_turns + 1,
)
)
tool_calls += fb_bash_turns
else:
@@ -1668,19 +1805,21 @@ class ToolOrchestraAgent(LocalCloudAgent):
else:
tokens_cloud += w_in + w_out
cost += self.cost_usd(worker["model"], w_in, w_out) + extra_cost
history.append({
"role": "worker",
"turn": max_turns + 1,
"tool": "answer",
"slot": "answer-1",
"worker_model": worker["model"],
"worker_type": worker["type"],
"output": ans,
"tokens_in": w_in,
"tokens_out": w_out,
"bash_turns": fb_bash_turns,
"fallback": True,
})
history.append(
{
"role": "worker",
"turn": max_turns + 1,
"tool": "answer",
"slot": "answer-1",
"worker_model": worker["model"],
"worker_type": worker["type"],
"output": ans,
"tokens_in": w_in,
"tokens_out": w_out,
"bash_turns": fb_bash_turns,
"fallback": True,
}
)
final_answer = ans
# In SWE mode, the authoritative output is the working-tree diff —
@@ -1690,7 +1829,8 @@ class ToolOrchestraAgent(LocalCloudAgent):
if patch.strip():
final_answer = (
f"{final_answer}\n\n```diff\n{patch}```"
if final_answer else f"```diff\n{patch}```"
if final_answer
else f"```diff\n{patch}```"
)
meta = {
+5 -5
View File
@@ -19,6 +19,7 @@ from openjarvis.agents.prompt_loader import (
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
from openjarvis.engine._base import estimate_prompt_tokens
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.tools._stubs import BaseTool, build_tool_descriptions
@@ -116,8 +117,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
max_prompt_tokens: int = 3000,
) -> list[Message]:
"""Truncate messages if estimated token count exceeds limit."""
total_chars = sum(len(m.content) for m in messages)
estimated_tokens = total_chars // 4
estimated_tokens = estimate_prompt_tokens(messages)
if estimated_tokens <= max_prompt_tokens:
return messages
# Find the last user message and truncate its content
@@ -125,7 +125,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
if messages[i].role == Role.USER:
excess_tokens = estimated_tokens - max_prompt_tokens
excess_chars = excess_tokens * 4
original = messages[i].content
original = messages[i].content or ""
if len(original) > excess_chars + 200:
truncated = original[: len(original) - excess_chars]
messages[i] = Message(
@@ -258,7 +258,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
# still emitted before re-raising.
self._emit_turn_end(turns=1, error=True)
raise
content = self._strip_think_tags(result.get("content", ""))
content = self._strip_think_tags(result.get("content") or "")
usage = result.get("usage", {})
self._emit_turn_end(turns=1)
return AgentResult(
@@ -315,7 +315,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
for k in total_usage:
total_usage[k] += usage.get(k, 0)
content = result.get("content", "")
content = result.get("content") or ""
# Strip think tags so they don't interfere with parsing
content = self._strip_think_tags(content)
last_content = content
+1 -3
View File
@@ -327,9 +327,7 @@ class OpenCodeAgent(BaseAgent):
self._ensure_server()
except RuntimeError as exc:
self._emit_turn_end(turns=1, error=True)
return AgentResult(
content=str(exc), turns=1, metadata={"error": True}
)
return AgentResult(content=str(exc), turns=1, metadata={"error": True})
data: dict = {}
turn_parts: List[dict] = []
+7 -1
View File
@@ -57,6 +57,7 @@ class OrchestratorAgent(ToolUsingAgent):
max_tokens: Optional[int] = None,
mode: str = "function_calling",
system_prompt: Optional[str] = None,
prompt_builder: Optional[Any] = None,
parallel_tools: bool = True,
interactive: bool = False,
confirm_callback=None,
@@ -71,6 +72,7 @@ class OrchestratorAgent(ToolUsingAgent):
max_tokens=max_tokens,
interactive=interactive,
confirm_callback=confirm_callback,
prompt_builder=prompt_builder,
)
self._mode = mode
self._system_prompt = system_prompt
@@ -214,7 +216,11 @@ class OrchestratorAgent(ToolUsingAgent):
self._emit_turn_start(input)
# Build initial messages
messages = self._build_messages(input, context)
messages = self._build_messages(
input,
context,
system_prompt=self._system_prompt,
)
# Get OpenAI-format tool definitions
openai_tools = self._executor.get_openai_tools() if self._tools else []
+99 -12
View File
@@ -37,6 +37,7 @@ called from your app startup:
from __future__ import annotations
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
@@ -56,6 +57,15 @@ from openjarvis.tools.approval_store import (
)
from openjarvis.tools.proactive_tools import get_store
logger = logging.getLogger(__name__)
_PROACTIVE_CRON_PROMPT = (
"Run the proactive agent: collect overnight data, execute approved actions, "
"notify pending approvals."
)
_PROACTIVE_TASK_KEY = "proactive-daily"
_PROACTIVE_TASK_KEY_FIELD = "openjarvis_task_key"
_SYSTEM_PROMPT = """You are a proactive personal assistant agent. You have already collected
data from the user's connected sources (email, messages, calendar). Your job is to:
@@ -252,14 +262,31 @@ def _build_notification_channel(channel_spec: str) -> Optional[Any]:
if ChannelRegistry.contains(channel_type):
channel_cls = ChannelRegistry.get(channel_type)
instance = channel_cls()
# Load credentials from config so the channel uses bot_token from
# config.toml rather than falling back to a bare env var.
try:
instance.connect()
from openjarvis.core.config import load_config
from openjarvis.system._channel_kwargs import build_channel_kwargs
_cfg = load_config()
_kwargs = build_channel_kwargs(_cfg.channel, channel_type)
except Exception:
pass
_kwargs = {}
instance = channel_cls(**_kwargs)
# Telegram.send() is self-contained, while connect() starts a
# getUpdates loop. A second loop for the same bot token conflicts
# with the server's main listener. Other channel implementations
# may initialize resources required by send() in connect(), so keep
# their established lifecycle intact.
if channel_type != "telegram":
instance.connect()
return instance
except Exception:
pass
logger.warning(
"Failed to build proactive notification channel %s",
channel_type,
exc_info=True,
)
return None
@@ -299,6 +326,7 @@ class ProactiveAgent(ToolUsingAgent):
self._notification_channel_id
)
self._notification_channel = notification_channel
self._notification_destination = self._notification_channel_id.partition(":")[2]
from openjarvis.tools.channel_tools import ChannelSendTool
from openjarvis.tools.digest_collect import DigestCollectTool
@@ -484,13 +512,13 @@ class ProactiveAgent(ToolUsingAgent):
# --- Step 5: Build and send notification ---
notification = self._build_notification(executed_results, pending_actions)
if notification and self._notification_channel_id:
if notification and self._notification_destination:
send_call = ToolCall(
id="proactive-notify-1",
name="channel_send",
arguments=json.dumps(
{
"channel": self._notification_channel_id,
"channel": self._notification_destination,
"content": notification,
}
),
@@ -592,15 +620,74 @@ def register_cron(
hours_back = hours_back or 24
timezone = timezone or "America/Los_Angeles"
metadata = {
"notification_channel_id": notification_channel_id,
"hours_back": hours_back,
"timezone": timezone,
_PROACTIVE_TASK_KEY_FIELD: _PROACTIVE_TASK_KEY,
}
# Match the stable key for tasks created by this version and the historical
# agent+prompt signature so existing installations are migrated on startup.
existing = [
task
for task in scheduler.list_tasks()
if task.status in {"active", "paused"}
and task.agent == "proactive"
and (
task.metadata.get(_PROACTIVE_TASK_KEY_FIELD) == _PROACTIVE_TASK_KEY
or (task.prompt == _PROACTIVE_CRON_PROMPT and task.schedule_type == "cron")
)
]
# A scheduler pause is an explicit user choice and must survive restart.
# Keep one deterministically and remove any active or paused duplicates.
paused = [task for task in existing if task.status == "paused"]
if paused:
keep = min(paused, key=lambda task: task.id)
_cancel_proactive_duplicates(scheduler, existing, keep=keep)
return keep
matching = [
task
for task in existing
if task.prompt == _PROACTIVE_CRON_PROMPT
and task.schedule_type == "cron"
and task.schedule_value == cron_expr
and task.context_mode == "isolated"
and task.metadata == metadata
]
if matching:
keep = min(matching, key=lambda task: task.id)
_cancel_proactive_duplicates(scheduler, existing, keep=keep)
return keep
# Configuration changed. Replace stale active tasks so the schedule and
# notification settings from config.toml take effect on this startup.
_cancel_proactive_duplicates(scheduler, existing)
return scheduler.create_task(
prompt="Run the proactive agent: collect overnight data, execute approved actions, notify pending approvals.",
prompt=_PROACTIVE_CRON_PROMPT,
schedule_type="cron",
schedule_value=cron_expr,
agent="proactive",
context_mode="isolated",
metadata={
"notification_channel_id": notification_channel_id,
"hours_back": hours_back,
"timezone": timezone,
},
metadata=metadata,
)
def _cancel_proactive_duplicates(
scheduler: Any, tasks: List[Any], *, keep: Optional[Any] = None
) -> None:
"""Cancel managed proactive tasks other than *keep*."""
for task in tasks:
if keep is not None and task.id == keep.id:
continue
try:
scheduler.cancel_task(task.id)
except Exception:
logger.warning(
"Failed to cancel duplicate proactive task %s",
task.id,
exc_info=True,
)
+39 -21
View File
@@ -2,7 +2,8 @@
A small, self-contained planner-executor loop:
* the planner is a local Ollama chat model (default ``gemma4:31b``),
* the planner is supplied by the caller (the web endpoint resolves it from
config, falling back to ``gemma4:31b`` on Ollama for legacy installs),
* the only tool it can call is :meth:`HybridSearch.search`,
* it gets up to ``max_iterations`` tool calls,
* tool results are trimmed before re-entering the context window, and
@@ -105,8 +106,8 @@ SEARCH_TOOL_SPEC: Dict[str, Any] = {
"type": "array",
"description": (
"Restrict the search to one or more connectors. Use this "
"whenever the user names a data source (e.g. \"in my "
"Granola notes\" → ['granola']; \"check Slack and Gmail\" "
'whenever the user names a data source (e.g. "in my '
'Granola notes" → [\'granola\']; "check Slack and Gmail" '
"→ ['slack', 'gmail']). Valid IDs include: gmail, slack, "
"granola, notion, obsidian, gcalendar, gdrive, gmail_imap, "
"outlook, imessage, whatsapp, apple_notes, apple_contacts, "
@@ -142,10 +143,11 @@ Strategy:
3. The `time_range` argument is a JSON object: `{{"start": "<ISO 8601>", "end": "<ISO 8601>"}}`. Either bound may be omitted, but pass at least one whenever the user gave you a temporal cue.
4. When the user names a specific data source "my Granola notes", "in Slack", "from my email" you MUST pass `sources=[...]` with the matching connector ID. Only use IDs that appear in the connected-sources list above; do NOT invent or assume sources that are not connected. Common synonyms: "meeting notes"/"meetings"/"transcripts" granola; "email"/"inbox" gmail; "DMs"/"channels" slack. Without this filter the search returns mail/messages ABOUT a tool instead of records FROM that tool.
4a. Never apologize about sources that aren't in the connected-sources list — if the user asks about "Notion" but Notion isn't connected, just say "Notion isn't connected, but here's what I found in {available_sources}" and answer from what is available.
5. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
6. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching always try first.
7. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Never send an empty query or a query with no parameters extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
8. Tool calls search AND clarify share a budget of 5 total. Spend wisely.
5. When the user asks for "next", "upcoming", "future", or "soon" calendar events/meetings/appointments, use `sources=["gcalendar"]` if gcalendar is connected, set `time_range={{"start": "{today}"}}`, and use `query=""` unless the user gave a specific topic such as "dentist" or "music lesson". This returns the nearest upcoming calendar items across calendars instead of keyword-matching only birthdays or event titles.
6. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
7. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching always try first.
8. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Only use an empty query when structured filters carry the request; never send a search with no concrete parameters. Extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
9. Tool calls search AND clarify share a budget of 5 total. Spend wisely.
Synthesis rules:
- Cite sources as individual numbers in square brackets. Always separate write [4] [7] [20], never [4, 7, 20]. Never format citations as markdown links. Just the number in brackets: [1]. The `ref` field on each hit is the citation number.
@@ -204,7 +206,9 @@ def shape_results_for_model(
if i < detailed_top:
base["snippet"] = h.content_snippet
if h.thread_context:
base["thread"] = _trim_thread_context(h.thread_context, thread_ctx_per_hit)
base["thread"] = _trim_thread_context(
h.thread_context, thread_ctx_per_hit
)
out_hits.append(base)
return {
"num_results": len(hits),
@@ -219,7 +223,9 @@ def _hit_date(timestamp: str) -> str:
if not timestamp:
return ""
try:
return datetime.fromisoformat(timestamp.replace("Z", "+00:00")).date().isoformat()
return (
datetime.fromisoformat(timestamp.replace("Z", "+00:00")).date().isoformat()
)
except (ValueError, AttributeError):
return str(timestamp)[:10]
@@ -237,7 +243,7 @@ def _bare_doc_id(source: str, document_id: str) -> str:
return ""
prefix = f"{source}:"
if source and document_id.startswith(prefix):
return document_id[len(prefix):]
return document_id[len(prefix) :]
return document_id
@@ -518,6 +524,7 @@ class ResearchAgent:
def _parse_time_range(raw: Any):
if not raw or not isinstance(raw, dict):
return None
def _maybe(v):
if not v:
return None
@@ -525,6 +532,7 @@ class ResearchAgent:
return datetime.fromisoformat(str(v).replace("Z", "+00:00"))
except ValueError:
return None
start = _maybe(raw.get("start"))
end = _maybe(raw.get("end"))
if start is None and end is None:
@@ -555,9 +563,16 @@ class ResearchAgent:
"query": query,
"person": person,
"time_range": (
{"start": time_range[0].isoformat() if time_range and time_range[0] else None,
"end": time_range[1].isoformat() if time_range and time_range[1] else None}
if time_range else None
{
"start": time_range[0].isoformat()
if time_range and time_range[0]
else None,
"end": time_range[1].isoformat()
if time_range and time_range[1]
else None,
}
if time_range
else None
),
"sources": sources,
"limit": limit,
@@ -688,9 +703,7 @@ class ResearchAgent:
)
continue
fallback = "(model returned no content and no tool calls)"
self._emit(
{"type": "final_answer", "text": fallback, "sources": []}
)
self._emit({"type": "final_answer", "text": fallback, "sources": []})
return ResearchResult(
answer=fallback,
iterations=iterations,
@@ -716,7 +729,11 @@ class ResearchAgent:
name = tc.get("name", "")
raw_args = tc.get("arguments", "{}") or "{}"
try:
args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args)
args = (
json.loads(raw_args)
if isinstance(raw_args, str)
else dict(raw_args)
)
except json.JSONDecodeError:
args = {}
@@ -764,7 +781,10 @@ class ResearchAgent:
)
else:
self._emit(
{"type": "clarify_call", "question": str(args.get("question", ""))}
{
"type": "clarify_call",
"question": str(args.get("question", "")),
}
)
inv = self._execute_clarify(args)
invocations.append(inv)
@@ -843,9 +863,7 @@ class ResearchAgent:
"and the model returned no text response)"
)
answer, final_sources = _finalize(answer)
self._emit(
{"type": "final_answer", "text": answer, "sources": final_sources}
)
self._emit({"type": "final_answer", "text": answer, "sources": final_sources})
return ResearchResult(
answer=answer,
iterations=iterations,
+27 -5
View File
@@ -123,15 +123,35 @@ class AgentScheduler:
self._thread.start()
logger.info("Agent scheduler started")
def stop(self) -> None:
"""Stop the scheduler background thread."""
def request_stop(self) -> None:
"""Prevent new scheduled ticks without waiting for the worker."""
self._stop_event.set()
if self._bus:
self._bus.unsubscribe(EventType.AGENT_TICK_END, self._on_tick_event)
if self._thread is not None:
self._thread.join(timeout=10)
def wait_stopped(self, timeout: float = 10.0) -> bool:
"""Wait for an active tick to finish, retaining live thread state."""
thread = self._thread
if thread is None:
return True
if thread is threading.current_thread():
return False
thread.join(timeout=timeout)
if thread.is_alive():
logger.warning("Agent scheduler did not stop within %.1fs", timeout)
return False
if self._thread is thread:
self._thread = None
logger.info("Agent scheduler stopped")
return True
def stop(self, timeout: float = 10.0) -> None:
"""Stop dispatching and wait for the scheduler worker."""
self.request_stop()
if self.wait_stopped(timeout=timeout):
logger.info("Agent scheduler stopped")
def _loop(self) -> None:
"""Main scheduler loop."""
@@ -160,6 +180,8 @@ class AgentScheduler:
]
for agent_id, info in due:
if self._stop_event.is_set():
break
agent = self._manager.get_agent(agent_id)
if agent is None or agent["status"] in (
"paused",
+1
View File
@@ -13,6 +13,7 @@ class SimpleAgent(BaseAgent):
"""Single-turn agent: query -> model -> response. No tool calling."""
agent_id = "simple"
supports_managed_tool_fallback = True
def run(
self,
+502
View File
@@ -0,0 +1,502 @@
"""Canonical managed-agent tool resolution.
Managed agents can run through streaming HTTP, immediate/scheduled ticks, or
the persistent-agent CLI. Those paths must bind the same live tool instances:
agent-type grants first, then configured native tools, then MCP adapters.
"""
from __future__ import annotations
import importlib
import logging
import sys
import weakref
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Iterable, Mapping
logger = logging.getLogger(__name__)
BROWSER_SUB_TOOLS = (
"browser_navigate",
"browser_click",
"browser_type",
"browser_screenshot",
"browser_extract",
"browser_axtree",
)
_MEMORY_TOOLS = frozenset(
{"retrieval", "memory_store", "memory_search", "memory_index", "memory_retrieve"}
)
_CHANNEL_TOOLS = frozenset({"channel_send", "channel_list", "channel_status"})
class _SpecOverrideTool:
"""Delegate execution while exposing an agent-configured OpenAI schema."""
def __init__(self, wrapped: Any, advertised_spec: dict[str, Any]) -> None:
self._wrapped = wrapped
self._advertised_spec = advertised_spec
@property
def spec(self) -> Any:
base = self._wrapped.spec
function = self._advertised_spec.get("function", {})
return replace(
base,
name=function.get("name", base.name),
description=function.get("description", base.description),
parameters=function.get("parameters", base.parameters),
)
def execute(self, **params: Any) -> Any:
return self._wrapped.execute(**params)
def to_openai_function(self) -> dict[str, Any]:
return self._advertised_spec
def __getattr__(self, name: str) -> Any:
return getattr(self._wrapped, name)
def _tool_name(tool: Any) -> str:
try:
return str(tool.spec.name)
except Exception:
return ""
def _spec_name(spec: Mapping[str, Any]) -> str:
function = spec.get("function")
if not isinstance(function, Mapping):
return ""
name = function.get("name")
return str(name) if name else ""
def _openai_spec(tool: Any) -> dict[str, Any]:
to_openai_function = getattr(tool, "to_openai_function", None)
if callable(to_openai_function):
try:
advertised = to_openai_function()
except Exception:
logger.debug(
"Failed to build advertised schema for tool %r; falling back "
"to its ToolSpec",
_tool_name(tool),
exc_info=True,
)
else:
if isinstance(advertised, Mapping) and _spec_name(advertised):
return dict(advertised)
logger.debug(
"Tool %r returned an invalid advertised schema; falling back "
"to its ToolSpec",
_tool_name(tool),
)
spec = tool.spec
return {
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": spec.parameters,
},
}
def _close_resources(resources: tuple[Any, ...]) -> None:
for resource in reversed(resources):
close = getattr(resource, "close", None)
if callable(close):
try:
close()
except Exception:
logger.debug("Failed to close resolved tool resource", exc_info=True)
@dataclass
class ResolvedAgentTools:
"""One resolved toolkit, with views for agent loops and raw streaming."""
instances: list[Any] = field(default_factory=list)
extra_specs: list[dict[str, Any]] = field(default_factory=list)
advertised_specs: list[dict[str, Any]] = field(default_factory=list)
mcp_clients: list[Any] = field(default_factory=list)
owned_resources: list[Any] = field(default_factory=list, repr=False)
_closed: bool = field(default=False, init=False, repr=False)
_finalizer: weakref.finalize = field(init=False, repr=False)
def __post_init__(self) -> None:
# This fallback covers exceptions anywhere after resolution, including
# before an executor/response installs its normal explicit cleanup.
self._finalizer = weakref.finalize(
self,
_close_resources,
tuple(self.owned_resources),
)
@property
def by_name(self) -> dict[str, Any]:
return {name: tool for tool in self.instances if (name := _tool_name(tool))}
@property
def openai_specs(self) -> list[dict[str, Any]]:
specs: list[dict[str, Any]] = []
seen: set[str] = set()
advertised = self.advertised_specs
if not advertised:
advertised = [*map(_openai_spec, self.instances), *self.extra_specs]
for spec in advertised:
name = _spec_name(spec)
if name and name in seen:
continue
specs.append(spec)
if name:
seen.add(name)
return specs
def close(self) -> None:
"""Close request-local resources without touching shared MCP clients."""
if self._closed:
return
self._closed = True
self._finalizer()
def __enter__(self) -> ResolvedAgentTools:
return self
def __exit__(self, *exc_info: object) -> None:
self.close()
def ensure_registries_populated() -> None:
"""Populate tool/channel registries, including after tests clear them."""
from openjarvis.core.registry import ChannelRegistry, ToolRegistry
try:
import openjarvis.channels # noqa: F401
except Exception:
pass
try:
import openjarvis.tools # noqa: F401
except Exception:
pass
browser_modules = ("openjarvis.tools.browser", "openjarvis.tools.browser_axtree")
for module_name in browser_modules:
try:
importlib.import_module(module_name)
except Exception:
pass
if not ChannelRegistry.keys():
for module_name in list(sys.modules):
if module_name.startswith(
"openjarvis.channels."
) and not module_name.endswith("_stubs"):
try:
importlib.reload(sys.modules[module_name])
except Exception:
pass
if not ToolRegistry.keys():
for module_name in list(sys.modules):
if (
module_name.startswith("openjarvis.tools.")
and not module_name.endswith("_stubs")
and not module_name.endswith("agent_tools")
):
try:
importlib.reload(sys.modules[module_name])
except Exception:
pass
if not any(ToolRegistry.contains(name) for name in BROWSER_SUB_TOOLS):
for module_name in browser_modules:
module = sys.modules.get(module_name)
if module is not None:
try:
importlib.reload(module)
except Exception:
pass
def instantiate_registered_tool(
tool_cls: Any,
name: str,
*,
engine: Any,
model: str,
memory_backend: Any = None,
channel_backend: Any = None,
) -> Any:
"""Instantiate a registry tool with its runtime dependencies."""
if name in _MEMORY_TOOLS:
if memory_backend is None:
logger.warning(
"Memory tool %r instantiated without a backend — calls will "
"return no results.",
name,
)
return tool_cls(backend=memory_backend)
if name in _CHANNEL_TOOLS:
if channel_backend is None:
logger.warning(
"Channel tool %r instantiated without a channel — calls will "
"fail with 'No channel backend configured'.",
name,
)
return tool_cls(channel=channel_backend)
if name == "llm":
return tool_cls(engine=engine, model=model)
return tool_cls()
def build_deep_research_tools(
engine: Any,
model: str,
knowledge_db_path: str | Path | None = None,
) -> list[Any]:
"""Construct the live knowledge tools granted to ``deep_research``."""
if not knowledge_db_path:
from openjarvis.core.config import DEFAULT_CONFIG_DIR
knowledge_db_path = DEFAULT_CONFIG_DIR / "knowledge.db"
path = Path(knowledge_db_path)
if not path.exists():
return []
from openjarvis.connectors.retriever import TwoStageRetriever
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.tools.knowledge_search import KnowledgeSearchTool
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
from openjarvis.tools.scan_chunks import ScanChunksTool
from openjarvis.tools.think import ThinkTool
store = KnowledgeStore(str(path))
try:
retriever = TwoStageRetriever(store)
return [
KnowledgeSearchTool(retriever=retriever),
KnowledgeSQLTool(store=store),
ScanChunksTool(store=store, engine=engine, model=model),
ThinkTool(),
]
except Exception:
store.close()
raise
def _normalized_tool_config(tool_config: Any) -> list[Any]:
if not tool_config:
return []
if isinstance(tool_config, str):
return [part.strip() for part in tool_config.split(",") if part.strip()]
if isinstance(tool_config, Mapping):
return [dict(tool_config)]
try:
return list(tool_config)
except TypeError:
return []
def resolve_agent_tools(
agent_record: Mapping[str, Any],
*,
engine: Any,
model: str,
memory_backend: Any = None,
channel_backend: Any = None,
mcp_tools: Iterable[Any] = (),
mcp_clients: Iterable[Any] = (),
knowledge_db_path: str | Path | None = None,
) -> ResolvedAgentTools:
"""Resolve the effective live toolkit for a managed agent.
Resolution is stable and first-wins: agent-type grants take precedence
over configured registry tools, which take precedence over MCP adapters.
``config["mcp_tools"] = false`` excludes MCP adapters from this agent;
process-wide runtimes may still own connections used by other agents.
"""
ensure_registries_populated()
from openjarvis.core.registry import ChannelRegistry, ToolRegistry
config = agent_record.get("config") or {}
if not isinstance(config, Mapping):
config = {}
instances: list[Any] = []
extra_specs: list[dict[str, Any]] = []
advertised_specs: list[dict[str, Any]] = []
owned_resources: list[Any] = []
seen: set[str] = set()
def add_instance(
tool: Any,
*,
advertised_spec: dict[str, Any] | None = None,
) -> None:
name = _tool_name(tool)
if not name or name in seen:
return
instances.append(tool)
advertised_specs.append(advertised_spec or _openai_spec(tool))
seen.add(name)
use_mcp = config.get("mcp_tools", True) is not False
mcp_tool_list = list(mcp_tools) if use_mcp else []
mcp_by_name: dict[str, Any] = {}
for tool in mcp_tool_list:
name = _tool_name(tool)
if name and name not in mcp_by_name:
mcp_by_name[name] = tool
if agent_record.get("agent_type") == "deep_research":
granted_tools = build_deep_research_tools(
engine=engine,
model=model,
knowledge_db_path=knowledge_db_path,
)
owned_ids: set[int] = set()
for tool in granted_tools:
resource = getattr(tool, "_store", None)
if (
resource is not None
and callable(getattr(resource, "close", None))
and id(resource) not in owned_ids
):
owned_resources.append(resource)
owned_ids.add(id(resource))
add_instance(tool)
for entry in _normalized_tool_config(config.get("tools")):
if isinstance(entry, Mapping):
raw_spec = entry if isinstance(entry, dict) else dict(entry)
name = _spec_name(raw_spec)
if name and name in seen:
continue
backing_tool = None
if name and not ChannelRegistry.contains(name):
if ToolRegistry.contains(name):
try:
backing_tool = instantiate_registered_tool(
ToolRegistry.get(name),
name,
engine=engine,
model=model,
memory_backend=memory_backend,
channel_backend=channel_backend,
)
except Exception as exc:
logger.warning(
"Could not instantiate tool '%s' (%s) — "
"advertising its custom spec without execution",
name,
exc,
)
elif name in mcp_by_name:
backing_tool = mcp_by_name[name]
if backing_tool is not None:
add_instance(
_SpecOverrideTool(backing_tool, raw_spec),
advertised_spec=raw_spec,
)
else:
logger.warning(
"Custom tool spec '%s' has no registered or MCP execution "
"backend — dropping",
name or "<unnamed>",
)
continue
if not isinstance(entry, str):
continue
names = BROWSER_SUB_TOOLS if entry == "browser" else (entry,)
for name in names:
if name in seen:
continue
if ChannelRegistry.contains(name):
continue
if not ToolRegistry.contains(name):
logger.warning(
"Tool '%s' referenced in agent config but not in ToolRegistry",
name,
)
continue
try:
add_instance(
instantiate_registered_tool(
ToolRegistry.get(name),
name,
engine=engine,
model=model,
memory_backend=memory_backend,
channel_backend=channel_backend,
)
)
except Exception as exc:
logger.warning(
"Could not instantiate tool '%s' (%s) — dropping", name, exc
)
if use_mcp:
for tool in mcp_tool_list:
add_instance(tool)
return ResolvedAgentTools(
instances=instances,
extra_specs=extra_specs,
advertised_specs=advertised_specs,
mcp_clients=list(mcp_clients) if use_mcp else [],
owned_resources=owned_resources,
)
def resolve_tool_specs(tool_config: Any) -> list[dict[str, Any]]:
"""Compatibility view for callers that only need configured specs."""
specs: list[dict[str, Any]] = []
seen: set[str] = set()
for entry in _normalized_tool_config(tool_config):
if isinstance(entry, dict):
specs.append(entry)
name = _spec_name(entry)
if name:
seen.add(name)
continue
resolved = resolve_agent_tools(
{"config": {"tools": [entry]}},
engine=None,
model="",
)
for spec in resolved.openai_specs:
name = _spec_name(spec)
if name and name in seen:
continue
specs.append(spec)
if name:
seen.add(name)
return specs
__all__ = [
"BROWSER_SUB_TOOLS",
"ResolvedAgentTools",
"build_deep_research_tools",
"ensure_registries_populated",
"instantiate_registered_tool",
"resolve_agent_tools",
"resolve_tool_specs",
]
+16 -8
View File
@@ -72,15 +72,16 @@ class _OAuth1Auth:
all_params[key] = value
param_str = "&".join(
f"{_pct(k)}={_pct(v)}"
for k, v in sorted(all_params.items())
f"{_pct(k)}={_pct(v)}" for k, v in sorted(all_params.items())
)
base_string = f"{method}&{_pct(base_url)}&{_pct(param_str)}"
signing_key = f"{_pct(self._consumer_secret)}&{_pct(self._access_secret)}"
signature = base64.b64encode(
hmac.new(
signing_key.encode(), base_string.encode(), hashlib.sha1,
signing_key.encode(),
base_string.encode(),
hashlib.sha1,
).digest(),
).decode()
@@ -146,7 +147,8 @@ class TwitterChannel(BaseChannel):
self._api_secret = api_secret or os.environ.get("TWITTER_API_SECRET", "")
self._access_token = access_token or os.environ.get("TWITTER_ACCESS_TOKEN", "")
self._access_secret = access_secret or os.environ.get(
"TWITTER_ACCESS_SECRET", "",
"TWITTER_ACCESS_SECRET",
"",
)
self._bot_user_id = bot_user_id or os.environ.get("TWITTER_BOT_USER_ID", "")
self._poll_interval = poll_interval
@@ -162,8 +164,10 @@ class TwitterChannel(BaseChannel):
def _oauth(self) -> _OAuth1Auth:
return _OAuth1Auth(
self._api_key, self._api_secret,
self._access_token, self._access_secret,
self._api_key,
self._api_secret,
self._access_token,
self._access_secret,
)
# -- connection lifecycle -----------------------------------------------
@@ -179,7 +183,8 @@ class TwitterChannel(BaseChannel):
self._status = ChannelStatus.CONNECTING
self._listener_thread = threading.Thread(
target=self._poll_mentions, daemon=True,
target=self._poll_mentions,
daemon=True,
)
self._listener_thread.start()
self._status = ChannelStatus.CONNECTED
@@ -276,7 +281,10 @@ class TwitterChannel(BaseChannel):
params["since_id"] = self._since_id
resp = httpx.get(
url, headers=headers, params=params, timeout=10.0,
url,
headers=headers,
params=params,
timeout=10.0,
)
if resp.status_code < 300:
data = resp.json()
+5 -5
View File
@@ -9,12 +9,12 @@ from __future__ import annotations
# readable capital J (the bottom-left \___/ hook), unlike the cramped prior
# art where the J read as an I.
_WORDMARK = (
' ___ _ _ ',
' / _ \\ _ __ ___ _ __ | | __ _ _ ____ _(_)___ ',
" ___ _ _ ",
" / _ \\ _ __ ___ _ __ | | __ _ _ ____ _(_)___ ",
"| | | | '_ \\ / _ \\ '_ \\ _ | |/ _` | '__\\ \\ / / / __|",
'| |_| | |_) | __/ | | | |_| | (_| | | \\ V /| \\__ \\',
' \\___/| .__/ \\___|_| |_|\\___/ \\__,_|_| \\_/ |_|___/',
' |_| ',
"| |_| | |_) | __/ | | | |_| | (_| | | \\ V /| \\__ \\",
" \\___/| .__/ \\___|_| |_|\\___/ \\__,_|_| \\_/ |_|___/",
" |_| ",
)
_TAGLINE = "Personal AI, On Personal Devices"
+4 -2
View File
@@ -11,7 +11,9 @@ Three install paths are supported today:
- **Editable git checkout** (``uv sync`` / ``pip install -e .`` from a
cloned repo). The package's ``__file__`` is inside a working tree
with a ``.git`` directory at the repo root. Upgrade with
``git pull && uv sync`` from the checkout.
``git pull && uv sync --inexact`` from the checkout. ``--inexact`` is
important here: a bare ``uv sync`` removes packages installed by extras or
dependency groups that are not part of the base project.
We detect by inspecting ``openjarvis.__file__``. If we can't tell with
confidence we fall back to the PyPI command that's the most common
@@ -68,7 +70,7 @@ def detect_install() -> InstallInfo:
if (candidate / ".git").exists() and (candidate / "pyproject.toml").exists():
return InstallInfo(
kind="editable-git",
upgrade_command=f"cd {candidate} && git pull && uv sync",
upgrade_command=(f"cd {candidate} && git pull && uv sync --inexact"),
repo_root=candidate,
)
if candidate.parent == candidate:
+28 -2
View File
@@ -20,6 +20,7 @@ from openjarvis.core.events import EventBus, EventType
from openjarvis.core.types import Message, Role
from openjarvis.engine import (
EngineConnectionError,
EngineContextLengthError,
discover_engines,
discover_models,
get_engine,
@@ -247,6 +248,17 @@ def _get_memory_backend(config):
return None
def _get_memory_facts(config):
"""Load facts captured by the automatic memory service."""
try:
from openjarvis.memory import load_configured_facts
return load_configured_facts(config)
except Exception as exc:
logger.debug("Automatic memory facts unavailable (optional): %s", exc)
return []
_MEMORY_TOOLS = frozenset(
{"retrieval", "memory_store", "memory_search", "memory_index", "memory_retrieve"}
)
@@ -415,7 +427,8 @@ def _run_agent(
from openjarvis.tools.storage.context import ContextConfig, inject_context
backend = _get_memory_backend(config)
if backend is not None:
facts = _get_memory_facts(config)
if backend is not None or facts:
ctx_cfg = ContextConfig(
top_k=config.memory.context_top_k,
min_score=config.memory.context_min_score,
@@ -426,6 +439,7 @@ def _run_agent(
[],
backend,
config=ctx_cfg,
facts=facts,
)
for msg in context_messages:
ctx.conversation.add(msg)
@@ -881,6 +895,11 @@ def ask(
capability_policy=sec.capability_policy,
memory_files_config=effective_mf,
)
except EngineContextLengthError as exc:
# Not a reachability problem — pointing the user at server/host
# config (hint_no_engine) would be misleading here.
console.print(f"[red]{exc}[/red]")
sys.exit(1)
except EngineConnectionError as exc:
console.print(f"[red]Engine error:[/red] {exc}")
console.print(hint_no_engine())
@@ -957,7 +976,8 @@ def ask(
)
backend = _get_memory_backend(config)
if backend is not None:
facts = _get_memory_facts(config)
if backend is not None or facts:
ctx_cfg = ContextConfig(
top_k=config.memory.context_top_k,
min_score=config.memory.context_min_score,
@@ -968,6 +988,7 @@ def ask(
messages,
backend,
config=ctx_cfg,
facts=facts,
)
except Exception as exc:
logger.debug("Failed to inject memory context: %s", exc)
@@ -990,6 +1011,11 @@ def ask(
temperature=temperature,
max_tokens=max_tokens,
)
except EngineContextLengthError as exc:
# Not a reachability problem — pointing the user at server/host
# config (hint_no_engine) would be misleading here.
console.print(f"[red]{exc}[/red]")
sys.exit(1)
except EngineConnectionError as exc:
console.print(f"[red]Engine error:[/red] {exc}")
console.print(hint_no_engine())
+66 -9
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import sys
from typing import List, Optional
@@ -11,7 +12,11 @@ from rich.markdown import Markdown
from openjarvis.cli._tool_names import resolve_tool_names
from openjarvis.core.config import load_config
from openjarvis.core.events import EventBus
from openjarvis.core.types import Message, Role
from openjarvis.memory import publish_completed_exchange
logger = logging.getLogger(__name__)
def _read_input(prompt: str = "You> ") -> Optional[str]:
@@ -57,6 +62,7 @@ def chat(
console = Console(stderr=True)
config = load_config()
bus = EventBus(record_history=False)
import dataclasses as _dc
@@ -97,12 +103,11 @@ def chat(
if agent_key and agent_key != "none":
try:
import openjarvis.agents # noqa: F401 — trigger registration
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry
if AgentRegistry.contains(agent_key):
agent_cls = AgentRegistry.get(agent_key)
kwargs: dict = {"bus": EventBus()}
kwargs: dict = {"bus": bus}
if getattr(agent_cls, "accepts_tools", False):
tool_names_list = resolve_tool_names(
@@ -184,7 +189,7 @@ def chat(
try:
from openjarvis.memory import build_memory_service
memory_service = build_memory_service(config, engine, model)
memory_service = build_memory_service(config, engine, model, event_bus=bus)
if memory_service is not None:
memory_service.start()
console.print("[dim] Memory: active[/dim]")
@@ -192,6 +197,15 @@ def chat(
console.print(f"[yellow]Memory service unavailable: {exc}[/yellow]")
memory_service = None
# The document backend and automatic fact store are separate persistence
# mechanisms. Context injection combines both at read time so facts from
# previous sessions are immediately available without a manual index step.
memory_backend = None
if config.agent.context_from_memory:
from openjarvis.cli.ask import _get_memory_backend
memory_backend = _get_memory_backend(config)
# Conversation state
if not system_prompt:
from openjarvis.prompt.builder import SystemPromptBuilder
@@ -260,15 +274,55 @@ def chat(
# Add user message
history.append(Message(role=Role.USER, content=user_input))
# Generate response
generation_history = history
agent_context_message = None
if config.agent.context_from_memory:
try:
from openjarvis.memory import load_configured_facts
from openjarvis.tools.storage.context import (
ContextConfig,
inject_context,
)
if memory_service is not None and hasattr(memory_service, "list_facts"):
facts = memory_service.list_facts()
else:
facts = load_configured_facts(config)
ctx_cfg = ContextConfig(
top_k=config.memory.context_top_k,
min_score=config.memory.context_min_score,
max_context_tokens=config.memory.context_max_tokens,
)
context_messages = inject_context(
user_input,
[] if agent is not None else history,
memory_backend,
config=ctx_cfg,
facts=facts,
)
if agent is not None:
if context_messages:
agent_context_message = context_messages[0]
else:
generation_history = context_messages
except Exception:
logger.debug("Failed to inject memory context", exc_info=True)
# Generate response even when optional memory context is unavailable.
try:
if agent is not None:
response = agent.run(user_input)
agent_context = None
if agent_context_message is not None:
from openjarvis.agents._stubs import AgentContext
agent_context = AgentContext()
agent_context.conversation.add(agent_context_message)
response = agent.run(user_input, context=agent_context)
content = (
response.content if hasattr(response, "content") else str(response)
)
else:
result = engine.generate(history, model=model)
result = engine.generate(generation_history, model=model)
content = (
result.get("content", "")
if isinstance(result, dict)
@@ -280,9 +334,12 @@ def chat(
console.print(Markdown(content))
console.print()
# Hand the exchange to the memory service (non-blocking).
if memory_service is not None:
memory_service.submit(user_input, content)
publish_completed_exchange(
bus,
user_input,
content,
source="cli.chat",
)
except KeyboardInterrupt:
console.print("\n[dim]Generation interrupted.[/dim]")
except Exception as exc:
+4 -4
View File
@@ -158,7 +158,7 @@ def _show_toml_config(console: Console, config_path: Path) -> None:
console.print(f"[dim]Loading config from: {config_path}[/dim]")
if config_path.exists():
config_content = config_path.read_text()
config_content = config_path.read_text(encoding="utf-8")
syntax = Syntax(config_content, "toml", theme="monokai", line_numbers=True)
console.print(Panel(syntax, title="Config File", border_style="cyan"))
else:
@@ -170,7 +170,7 @@ def _show_json_config(console: Console, config_path: Path) -> None:
console.print(f"[dim]Loading config from: {config_path}[/dim]")
if config_path.exists():
config_content = config_path.read_text()
config_content = config_path.read_text(encoding="utf-8")
try:
import tomllib # Python 3.11+
@@ -375,7 +375,7 @@ def set_config(key: str, value: str) -> None:
os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_DIR / "config.toml")
)
if config_path.exists():
doc = tomlkit.parse(config_path.read_text())
doc = tomlkit.parse(config_path.read_text(encoding="utf-8"))
else:
doc = tomlkit.document()
config_path.parent.mkdir(parents=True, exist_ok=True)
@@ -390,7 +390,7 @@ def set_config(key: str, value: str) -> None:
current[parts[-1]] = typed_value
# Write back
config_path.write_text(tomlkit.dumps(doc))
config_path.write_text(tomlkit.dumps(doc), encoding="utf-8")
console.print(f"[green]Set[/green] {key} = {value!r}")
+70 -11
View File
@@ -17,18 +17,64 @@ _PID_FILE = DEFAULT_CONFIG_DIR / "server.pid"
_LOG_FILE = DEFAULT_CONFIG_DIR / "server.log"
def _pid_alive(pid: int) -> bool:
"""Return whether *pid* identifies a running process without signaling it."""
if pid <= 0:
return False
if os.name == "nt":
import ctypes
from ctypes import wintypes
error_invalid_parameter = 87
synchronize = 0x00100000
wait_object_0 = 0x00000000
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
kernel32.WaitForSingleObject.restype = wintypes.DWORD
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
handle = kernel32.OpenProcess(synchronize, False, pid)
if not handle:
# OpenProcess reports ERROR_INVALID_PARAMETER when the PID does not
# exist. For access-denied and other inconclusive failures, retain
# the PID file rather than declaring a potentially live daemon dead.
return ctypes.get_last_error() != error_invalid_parameter
try:
wait_result = kernel32.WaitForSingleObject(handle, 0)
# WAIT_OBJECT_0 proves the process exited. WAIT_TIMEOUT proves it
# is live; unexpected failures are inconclusive, so retain the PID.
return wait_result != wait_object_0
finally:
kernel32.CloseHandle(handle)
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _read_pid() -> int | None:
"""Read PID from pid file, return None if not found or stale."""
if not _PID_FILE.exists():
return None
try:
pid = int(_PID_FILE.read_text().strip())
# Check if process is still running
os.kill(pid, 0)
return pid
except (ValueError, OSError):
except (OSError, ValueError):
_PID_FILE.unlink(missing_ok=True)
return None
if not _pid_alive(pid):
_PID_FILE.unlink(missing_ok=True)
return None
return pid
def _write_pid(pid: int) -> None:
@@ -81,14 +127,28 @@ def start(
if agent_name:
cmd.extend(["--agent", agent_name])
# Start as background process
# Start as background process, fully detached from the launching terminal.
#
# ``start_new_session`` is POSIX-only: CPython's Windows ``_execute_child``
# names the parameter ``unused_start_new_session`` and ignores it. Relying
# on it there leaves the server sharing its parent's console, so closing
# that console — or logging off — delivers CTRL_CLOSE_EVENT and kills the
# daemon. DETACHED_PROCESS gives it no console at all; the new process
# group additionally stops a Ctrl-C in the parent reaching it.
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
log_fh = open(_LOG_FILE, "a") # noqa: SIM115
spawn_kwargs: dict = {}
if sys.platform == "win32":
spawn_kwargs["creationflags"] = (
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
)
else:
spawn_kwargs["start_new_session"] = True
proc = subprocess.Popen(
cmd,
stdout=log_fh,
stderr=log_fh,
start_new_session=True,
**spawn_kwargs,
)
_write_pid(proc.pid)
@@ -113,14 +173,13 @@ def stop() -> None:
# Wait up to 10 seconds for graceful shutdown
for _ in range(20):
time.sleep(0.5)
try:
os.kill(pid, 0)
except OSError:
if not _pid_alive(pid):
break
else:
# Force kill if still running
# SIGKILL is POSIX-only. On Windows SIGTERM already maps to
# TerminateProcess, so repeating it is the available escalation.
try:
os.kill(pid, signal.SIGKILL)
os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
except OSError:
pass
except OSError:
+3 -1
View File
@@ -344,7 +344,9 @@ def init(
console.print(f" Looked in: {examples_dir}")
raise SystemExit(1)
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
DEFAULT_CONFIG_PATH.write_text(preset_path.read_text())
DEFAULT_CONFIG_PATH.write_text(
preset_path.read_text(encoding="utf-8"), encoding="utf-8"
)
console.print(
f"[green]Preset '{preset}' installed to {DEFAULT_CONFIG_PATH}[/green]"
)
+3 -1
View File
@@ -4,7 +4,9 @@ Runs the right upgrade command for how the user installed OpenJarvis:
- PyPI installs get ``pip install --upgrade openjarvis``.
- uv-tool installs get ``uv tool upgrade openjarvis``.
- Editable git checkouts get ``git pull && uv sync`` in the checkout.
- Editable git checkouts get ``git pull && uv sync --inexact`` in the checkout.
The inexact sync preserves packages previously installed through extras and
dependency groups.
The detection logic is shared with the post-command "new version
available" hint in ``_version_check.py`` so both surfaces stay in sync.
+87 -81
View File
@@ -10,6 +10,7 @@ from rich.console import Console
from openjarvis.cli._banner import print_banner
from openjarvis.core.config import load_config
from openjarvis.core.credentials import inject_credentials
from openjarvis.core.events import EventBus
from openjarvis.core.paths import get_config_dir
from openjarvis.engine import (
@@ -24,6 +25,30 @@ from openjarvis.intelligence import (
logger = logging.getLogger(__name__)
_DEFAULT_TOOLS = frozenset({"think", "calculator", "web_search"})
def _resolve_allowed_tools(config: object) -> tuple[set[str], bool]:
"""Return configured tool names and whether the selection was explicit.
``tools.enabled`` is the canonical setting used by ``SystemBuilder`` and
the interactive CLI. ``agent.tools`` remains as a backward-compatible
fallback, followed by the server's default tool set when neither is set.
"""
configured = config.tools.enabled or config.agent.tools
if not configured:
return set(_DEFAULT_TOOLS), False
if isinstance(configured, list):
allowed = {
tool.strip()
for tool in configured
if isinstance(tool, str) and tool.strip()
}
else:
allowed = {tool.strip() for tool in configured.split(",") if tool.strip()}
return allowed, True
def _unique_model_ids(model_ids: list[str]) -> list[str]:
"""Return model ids in first-seen order without duplicates."""
@@ -95,7 +120,7 @@ def _resolve_server_model(
"--agent",
"agent_name",
default=None,
help="Agent for non-streaming requests (simple, orchestrator, react, openhands).",
help="Agent for chat requests (simple, orchestrator, react, openhands).",
)
@click.pass_context
def serve(
@@ -122,6 +147,11 @@ def serve(
)
sys.exit(1)
# Tool credentials saved through the browser UI live in the OpenJarvis
# credential store. Restore them before engines and tools are constructed
# so availability checks and tool instances see the same environment.
inject_credentials()
config = load_config()
# Resolve host/port from CLI args or config
@@ -273,6 +303,15 @@ def serve(
# (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 = []
managed_mcp_tools: list = []
mcp_clients: list = []
try:
from openjarvis.mcp.loader import load_mcp_tools_from_config
managed_mcp_tools, mcp_clients = load_mcp_tools_from_config(config.tools.mcp)
except Exception as exc:
logger.warning("Managed-agent MCP tools failed to load: %s", exc)
if agent_key:
try:
import openjarvis.agents # noqa: F401
@@ -284,32 +323,13 @@ def serve(
if sec.capability_policy is not None:
agent_kwargs["capability_policy"] = sec.capability_policy
# MCP transports persisted on the agent at the bottom of
# this block — initialise here so the reference is valid
# even when accepts_tools is False (#461).
mcp_clients: list = []
# Load tools for agents that support them
if getattr(agent_cls, "accepts_tools", False):
import openjarvis.tools # noqa: F401 # trigger registration
from openjarvis.core.registry import ToolRegistry
from openjarvis.tools._stubs import BaseTool
_DEFAULT_TOOLS = {"think", "calculator", "web_search"}
configured = config.agent.tools
if configured:
if isinstance(configured, list):
allowed = {
t.strip()
for t in configured
if isinstance(t, str) and t.strip()
}
else:
allowed = {
t.strip() for t in configured.split(",") if t.strip()
}
else:
allowed = _DEFAULT_TOOLS
allowed, tools_configured = _resolve_allowed_tools(config)
tools = []
for name in ToolRegistry.keys():
@@ -325,12 +345,13 @@ def serve(
# MCP server tools from config.tools.mcp.servers
# (#461 — these were silently dropped).
from openjarvis.mcp.loader import load_mcp_tools_from_config
mcp_tools, mcp_clients = load_mcp_tools_from_config(
config.tools.mcp,
allowed_names=allowed if configured else None,
)
mcp_tools = managed_mcp_tools
if tools_configured:
mcp_tools = [
tool
for tool in managed_mcp_tools
if tool.spec.name in allowed
]
if mcp_tools:
existing = {t.spec.name for t in tools}
for t in mcp_tools:
@@ -383,10 +404,6 @@ def serve(
channel_agent = config.channel.default_agent or agent_key or "simple"
_channel_tools: list = []
# MCP transports persisted at function scope (= server-process
# lifetime); see the comment near the channel-MCP-load block
# below. Initialise here so it's always bound. #461.
_channel_mcp_clients: list = []
if channel_agent:
try:
import openjarvis.agents
@@ -399,23 +416,7 @@ def serve(
from openjarvis.core.registry import ToolRegistry
from openjarvis.tools._stubs import BaseTool
_DEFAULT_TOOLS = {"think", "calculator", "web_search"}
configured = config.agent.tools
if configured:
if isinstance(configured, list):
_allowed = {
t.strip()
for t in configured
if isinstance(t, str) and t.strip()
}
else:
_allowed = {
t.strip()
for t in configured.split(",")
if t.strip()
}
else:
_allowed = _DEFAULT_TOOLS
_allowed, _tools_configured = _resolve_allowed_tools(config)
for _tname in ToolRegistry.keys():
if _tname not in _allowed:
@@ -426,29 +427,23 @@ def serve(
elif isinstance(_tcls, BaseTool):
_channel_tools.append(_tcls)
# MCP tools for the channel agent too (#461).
from openjarvis.mcp.loader import (
load_mcp_tools_from_config,
)
_ch_mcp_tools, _ch_mcp_clients = load_mcp_tools_from_config(
config.tools.mcp,
allowed_names=_allowed if configured else None,
)
# Reuse the process-owned MCP pool so channels do not
# open a second transport to every configured server.
_ch_mcp_tools = managed_mcp_tools
if _tools_configured:
_ch_mcp_tools = [
tool
for tool in managed_mcp_tools
if tool.spec.name in _allowed
]
if _ch_mcp_tools:
_existing = {t.spec.name for t in _channel_tools}
for t in _ch_mcp_tools:
if t.spec.name not in _existing:
_channel_tools.append(t)
_existing.add(t.spec.name)
# Hold a reference at module / function scope —
# the channel agent is constructed inside
# JarvisSystem below; we extend its lifetime by
# keeping the list bound here.
_channel_mcp_clients = _ch_mcp_clients
except Exception as exc:
logger.warning("Channel tools failed to load: %s", exc)
_channel_mcp_clients = []
_wire_system = JarvisSystem(
config=config,
@@ -458,6 +453,8 @@ def serve(
model=model_name,
agent_name=channel_agent,
tools=_channel_tools,
mcp_tools=managed_mcp_tools,
_mcp_clients=mcp_clients,
)
_wire_system.wire_channel(channel_bridge)
@@ -475,30 +472,36 @@ 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).
# Set up the memory backend for storage tools, API routes, and optional
# prompt-context injection. ``context_from_memory`` controls only the last
# of those, so disabling it must not leave explicit memory_* tools with a
# null backend. Built before the scheduler so AgentExecutor can reuse it.
memory_backend = None
if config.agent.context_from_memory:
try:
import openjarvis.tools.storage # noqa: F401
from openjarvis.core.registry import MemoryRegistry
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)
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)
# Automatic long-term memory service (background fact extraction).
memory_service = None
try:
from openjarvis.memory import build_memory_service
memory_service = build_memory_service(config, engine, model_name)
memory_service = build_memory_service(
config,
engine,
model_name,
event_bus=bus,
)
if memory_service is not None:
memory_service.start()
console.print(" Memory svc: [cyan]active[/cyan]")
@@ -581,6 +584,7 @@ def serve(
agent=agent,
agent_name=agent_key or "",
tools=resolved_tools,
mcp_tools=managed_mcp_tools,
tool_executor=_sched_tool_executor,
memory_backend=memory_backend,
telemetry_store=telem_store,
@@ -589,6 +593,7 @@ def serve(
capability_policy=sec.capability_policy,
agent_manager=agent_manager,
agent_executor=executor,
_mcp_clients=mcp_clients,
)
executor.set_system(system)
@@ -680,10 +685,13 @@ def serve(
channel_bridge=channel_bridge,
config=config,
memory_backend=memory_backend,
own_memory_backend=memory_backend is not None,
memory_service=memory_service,
speech_backend=speech_backend,
agent_manager=agent_manager,
agent_scheduler=agent_scheduler,
mcp_tools=managed_mcp_tools,
mcp_clients=mcp_clients,
api_key=api_key,
webhook_config=webhook_config,
cors_origins=config.server.cors_origins,
@@ -712,6 +720,4 @@ def serve(
"authenticated requests to your instance."
)
import uvicorn
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
+38 -3
View File
@@ -199,7 +199,16 @@ def _get_resolver(source: str, url: str = ""):
default="",
help="Repo URL (required when source is 'github').",
)
def install(query: str, with_scripts: bool, force: bool, url: str):
@click.option(
"--yes-dangerous",
is_flag=True,
default=False,
help=(
"Confirm installing an unreviewed skill that requests dangerous "
"capabilities (shell/network-listen/filesystem-write)."
),
)
def install(query: str, with_scripts: bool, force: bool, url: str, yes_dangerous: bool):
"""Install a skill from a source.
Example: ``jarvis skill install hermes:apple-notes``
@@ -233,7 +242,12 @@ def install(query: str, with_scripts: bool, force: bool, url: str):
from openjarvis.skills.tool_translator import ToolTranslator
importer = SkillImporter(parser=SkillParser(), tool_translator=ToolTranslator())
result = importer.import_skill(matches[0], with_scripts=with_scripts, force=force)
result = importer.import_skill(
matches[0],
with_scripts=with_scripts,
force=force,
confirm_dangerous=yes_dangerous,
)
if result.success:
if result.skipped:
@@ -270,6 +284,15 @@ def install(query: str, with_scripts: bool, force: bool, url: str):
help="Import scripts/ directories.",
)
@click.option("--force", is_flag=True, default=False, help="Re-import existing skills.")
@click.option(
"--yes-dangerous",
is_flag=True,
default=False,
help=(
"Confirm installing unreviewed skills that request dangerous "
"capabilities (shell/network-listen/filesystem-write)."
),
)
def sync(
source: str,
category: str,
@@ -277,6 +300,7 @@ def sync(
search: str,
with_scripts: bool,
force: bool,
yes_dangerous: bool,
):
"""Bulk install + update from a source (or all configured sources)."""
console = Console()
@@ -343,9 +367,20 @@ def sync(
installed_count = 0
for resolved in skills_to_import:
r = importer.import_skill(resolved, with_scripts=with_scripts, force=force)
r = importer.import_skill(
resolved,
with_scripts=with_scripts,
force=force,
confirm_dangerous=yes_dangerous,
)
if r.success and not r.skipped:
installed_count += 1
elif not r.success and r.requires_confirmation:
console.print(
f" [yellow]Skipped {resolved.name}: requests dangerous "
f"capabilities {r.dangerous_capabilities} "
"(re-run with --yes-dangerous to install)[/yellow]"
)
console.print(f" Imported {installed_count}/{len(skills_to_import)} skills")
total_installed += installed_count
+3 -4
View File
@@ -124,7 +124,8 @@ class OllamaEmbedder:
elif arr.shape[0] != self._dim:
logger.warning(
"OllamaEmbedder.embed: dim drift (expected %d, got %d)",
self._dim, arr.shape[0],
self._dim,
arr.shape[0],
)
return None
return arr.tobytes()
@@ -143,9 +144,7 @@ class OllamaEmbedder:
# ---------------------------------------------------------------------------
def decode_embedding(
blob: Optional[bytes], *, dtype=None
) -> Optional[np.ndarray]:
def decode_embedding(blob: Optional[bytes], *, dtype=None) -> Optional[np.ndarray]:
"""Reconstruct a 1-D vector from a BLOB written by ``OllamaEmbedder.embed``.
Returns ``None`` when the input is missing or zero-length so callers can

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