Compare commits

...
102 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
248 changed files with 13507 additions and 3342 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "137,874",
"message": "190,252",
"color": "green",
"namedLogo": "git"
}
+49 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 137874,
"last_updated": "2026-06-29T07:46:59Z",
"total_clones": 190252,
"last_updated": "2026-08-14T07:19:51Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -95,6 +95,52 @@
"2026-06-25": 1640,
"2026-06-26": 1338,
"2026-06-27": 1338,
"2026-06-28": 1028
"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:
+98
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: |
+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",
]
+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
+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>
+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
+206 -14
View File
@@ -8,6 +8,8 @@ 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 used when startup needs a default Ollama tag.
const STARTUP_MODEL: &str = "qwen3.5:4b";
@@ -731,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,
)
}
@@ -786,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)
// ---------------------------------------------------------------------------
@@ -1171,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 \
@@ -1183,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;
}
@@ -1193,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
@@ -1220,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() => {
@@ -1242,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());
@@ -2720,11 +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,
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,
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;
@@ -2768,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]
@@ -2791,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
+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 },
];
@@ -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,
};
+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 }),
+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;
+9 -1
View File
@@ -54,7 +54,15 @@ export default defineConfig({
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',
},
+1
View File
@@ -196,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 = {
+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())
+55 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import sys
from typing import List, Optional
@@ -15,6 +16,8 @@ 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]:
"""Read user input with graceful EOF handling."""
@@ -194,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
@@ -262,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)
+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.
+81 -80
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,23 +472,24 @@ 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
@@ -586,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,
@@ -594,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)
@@ -685,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,
@@ -717,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
+3 -2
View File
@@ -213,12 +213,13 @@ def _parse_event_timestamp(event: Dict[str, Any]) -> datetime:
"""
start = event.get("start", {})
date_time_str: str = start.get("dateTime", "")
if not date_time_str:
date_str: str = start.get("date", "")
if not date_time_str and not date_str:
return datetime.now()
try:
# RFC3339 — Python 3.11+ fromisoformat handles the trailing 'Z'.
# For older versions we replace 'Z' with '+00:00'.
normalized = date_time_str.replace("Z", "+00:00")
normalized = (date_time_str or date_str).replace("Z", "+00:00")
return datetime.fromisoformat(normalized)
except (ValueError, TypeError):
return datetime.now()
+1 -3
View File
@@ -345,9 +345,7 @@ class GranolaConnector(BaseConnector):
attendees: List[Dict[str, Any]] = note.get("attendees") or []
participants: List[str] = [
(a.get("email") or "").lower()
for a in attendees
if a.get("email")
(a.get("email") or "").lower() for a in attendees if a.get("email")
]
participants_raw: List[str] = [
a.get("name") or a.get("email") or ""
+320 -21
View File
@@ -20,8 +20,9 @@ from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime
from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Sequence, Tuple
# numpy imported lazily inside _vector_recall (see embeddings.py) so importing
@@ -32,6 +33,65 @@ from openjarvis.connectors.store import KnowledgeStore
logger = logging.getLogger(__name__)
_UPCOMING_TERMS = {
"next",
"upcoming",
"future",
"forthcoming",
"coming",
"soon",
}
_CALENDAR_TERMS = {
"calendar",
"calendars",
"event",
"events",
}
_CALENDAR_REQUEST_TERMS = _CALENDAR_TERMS | {
"appointment",
"appointments",
"meeting",
"meetings",
"schedule",
}
_GCALENDAR_GENERIC_TERMS = (
_UPCOMING_TERMS
| _CALENDAR_TERMS
| {
"appointment",
"appointments",
"meeting",
"meetings",
"schedule",
}
)
_QUERY_STOPWORDS = {
"a",
"all",
"am",
"are",
"do",
"for",
"have",
"i",
"in",
"is",
"list",
"me",
"my",
"on",
"s",
"show",
"tell",
"the",
"there",
"to",
"what",
"whats",
"when",
}
# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------
@@ -120,6 +180,101 @@ def _snippet(content: str, max_chars: int = 500) -> str:
return flat[:max_chars].rstrip() + ""
def _query_tokens(query: str) -> set[str]:
return set(re.findall(r"[a-z0-9_]+", query.lower()))
def _sources_include_gcalendar(sources: Optional[Sequence[str]]) -> bool:
return any(str(source).lower() == "gcalendar" for source in sources or [])
def _has_upcoming_calendar_intent(
query: str,
sources: Optional[Sequence[str]],
) -> bool:
tokens = _query_tokens(query)
if not tokens or not (tokens & _UPCOMING_TERMS):
return False
if _sources_include_gcalendar(sources):
return True
if sources:
return False
return bool(tokens & _CALENDAR_REQUEST_TERMS)
def _is_generic_calendar_timeline_query(query: str) -> bool:
tokens = _query_tokens(query)
if not tokens:
return True
topic_tokens = tokens - _GCALENDAR_GENERIC_TERMS - _QUERY_STOPWORDS
return not topic_tokens
def _start_is_nowish_or_future(start: Optional[datetime]) -> bool:
if start is None:
return False
now = datetime.now(tz=start.tzinfo) if start.tzinfo else datetime.now()
return start >= now - timedelta(days=1)
def _start_of_day(ts: datetime) -> datetime:
return ts.replace(hour=0, minute=0, second=0, microsecond=0)
def _as_utc(ts: Optional[datetime]) -> Optional[datetime]:
if ts is None:
return None
if ts.tzinfo is None:
return ts.replace(tzinfo=timezone.utc)
return ts.astimezone(timezone.utc)
def _parse_timestamp_for_timeline(
raw: Any,
) -> Tuple[Optional[datetime], Optional[date]]:
if raw is None:
return None, None
text = str(raw).strip()
if not text:
return None, None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None, None
is_naive_midnight = (
parsed.tzinfo is None
and parsed.hour == 0
and parsed.minute == 0
and parsed.second == 0
and parsed.microsecond == 0
)
return _as_utc(parsed), parsed.date() if is_naive_midnight else None
def _timestamp_in_range(
timestamp: Optional[datetime],
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
*,
all_day_date: Optional[date] = None,
) -> bool:
if timestamp is None or time_range is None:
return timestamp is not None
start, end = time_range
if all_day_date is not None:
if start is not None and all_day_date < start.date():
return False
if end is not None and all_day_date > end.date():
return False
return True
start_utc = _as_utc(start)
end_utc = _as_utc(end)
if start_utc is not None and timestamp < start_utc:
return False
if end_utc is not None and timestamp > end_utc:
return False
return True
# ---------------------------------------------------------------------------
# HybridSearch
# ---------------------------------------------------------------------------
@@ -377,6 +532,127 @@ class HybridSearch:
for r in rows
]
def _normalise_calendar_timeline_scope(
self,
query: str,
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
sources: Optional[Sequence[str]],
) -> Tuple[
Optional[Tuple[Optional[datetime], Optional[datetime]]],
Optional[Sequence[str]],
bool,
bool,
]:
"""Fill in structured filters for generic upcoming-calendar requests.
Queries like "what are my next calendar events?" often have no useful
lexical terms in the stored event text, so BM25/vector ranking can miss
nearby events. Treat that shape as a source-filtered timeline request.
"""
scoped_sources = list(sources) if sources else None
has_upcoming_intent = _has_upcoming_calendar_intent(query, scoped_sources)
if has_upcoming_intent and (
scoped_sources is None or _sources_include_gcalendar(scoped_sources)
):
scoped_sources = ["gcalendar"]
if not _sources_include_gcalendar(scoped_sources):
return time_range, scoped_sources, False, False
if has_upcoming_intent:
if time_range is None:
time_range = (_start_of_day(datetime.now(timezone.utc)), None)
else:
start, end = time_range
if start is None:
time_range = (_start_of_day(datetime.now(timezone.utc)), end)
else:
time_range = (_start_of_day(start), end)
chronological = has_upcoming_intent or (
time_range is not None
and time_range[1] is None
and _start_is_nowish_or_future(time_range[0])
)
metadata_only = chronological and _is_generic_calendar_timeline_query(query)
return time_range, scoped_sources, chronological, metadata_only
def _calendar_timeline_ids(
self,
*,
person: Optional[str],
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
sources: Optional[Sequence[str]],
limit: int,
) -> List[str]:
"""Return gcalendar rows sorted by normalized event start time."""
filter_sql, filter_params = self._build_filters(
person=person,
time_range=None,
sources=sources,
)
rows = self._store._conn.execute(
f"""
SELECT id, timestamp, created_at
FROM knowledge_chunks
WHERE {filter_sql}
""",
filter_params,
).fetchall()
candidates: List[Tuple[str, datetime, float]] = []
for row in rows:
timestamp, all_day_date = _parse_timestamp_for_timeline(row["timestamp"])
if not _timestamp_in_range(
timestamp,
time_range,
all_day_date=all_day_date,
):
continue
candidates.append(
(
row["id"],
timestamp or datetime.max.replace(tzinfo=timezone.utc),
float(row["created_at"] or 0.0),
)
)
candidates.sort(key=lambda item: (item[1], item[2]))
return [chunk_id for chunk_id, *_ in candidates[:limit]]
def _filter_calendar_timeline_fused(
self,
fused: List[Tuple[str, float, float, float]],
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
) -> List[Tuple[str, float, float, float]]:
"""Apply normalized timestamp filtering to ranked calendar candidates."""
if not fused:
return fused
ids = [chunk_id for chunk_id, *_ in fused]
placeholders = ",".join("?" for _ in ids)
rows = self._store._conn.execute(
f"""
SELECT id, timestamp
FROM knowledge_chunks
WHERE id IN ({placeholders})
""",
ids,
).fetchall()
timestamps = {
row["id"]: _parse_timestamp_for_timeline(row["timestamp"]) for row in rows
}
def _keeps_item(item: Tuple[str, float, float, float]) -> bool:
timestamp, all_day_date = timestamps.get(item[0], (None, None))
return _timestamp_in_range(
timestamp,
time_range,
all_day_date=all_day_date,
)
return [item for item in fused if _keeps_item(item)]
# ------------------------------------------------------------------
# Public entry point
# ------------------------------------------------------------------
@@ -396,42 +672,65 @@ class HybridSearch:
when callers want a pure metadata filter (e.g. "all mail from X in
May") — in that case only the vector leg runs (and only if an
embedder is configured); if neither leg yields anything the
structured filter is applied directly and the most recent rows are
returned.
structured filter is applied directly. Upcoming calendar timelines are
returned nearest-first; other fallbacks return the most recent rows.
"""
time_range, sources, chronological_order, metadata_only = (
self._normalise_calendar_timeline_scope(query, time_range, sources)
)
rank_query = "" if metadata_only else query
calendar_timeline = chronological_order and _sources_include_gcalendar(sources)
recall_time_range = None if calendar_timeline else time_range
bm25_filter_sql, bm25_filter_params = self._build_filters(
person=person, time_range=time_range, sources=sources, alias="kc"
person=person, time_range=recall_time_range, sources=sources, alias="kc"
)
unaliased_filter_sql, unaliased_filter_params = self._build_filters(
person=person, time_range=time_range, sources=sources
person=person, time_range=recall_time_range, sources=sources
)
bm25 = (
self._bm25_recall(query, bm25_filter_sql, bm25_filter_params)
if query.strip()
self._bm25_recall(rank_query, bm25_filter_sql, bm25_filter_params)
if rank_query.strip()
else []
)
vector = (
self._vector_recall(query, unaliased_filter_sql, unaliased_filter_params)
if query.strip()
self._vector_recall(
rank_query,
unaliased_filter_sql,
unaliased_filter_params,
)
if rank_query.strip()
else []
)
fused = self._fuse(bm25, vector)
if calendar_timeline:
fused = self._filter_calendar_timeline_fused(fused, time_range)
# Metadata-only fallback: empty query, or both legs produced nothing
# despite a non-empty query. Return the most recent rows matching the
# filter so the agent still gets a useful corpus snapshot.
# despite a non-empty query. Calendar timeline requests use start-time
# ascending; other searches use recency so the agent still gets a
# useful corpus snapshot.
if not fused:
sql = f"""
SELECT id FROM knowledge_chunks
WHERE {unaliased_filter_sql}
ORDER BY timestamp DESC, created_at DESC
LIMIT ?
"""
rows = self._store._conn.execute(
sql, [*unaliased_filter_params, limit]
).fetchall()
fused = [(row["id"], 0.0, 0.0, 0.0) for row in rows]
if calendar_timeline:
chunk_ids = self._calendar_timeline_ids(
person=person,
time_range=time_range,
sources=sources,
limit=limit,
)
fused = [(chunk_id, 0.0, 0.0, 0.0) for chunk_id in chunk_ids]
else:
sql = f"""
SELECT id FROM knowledge_chunks
WHERE {unaliased_filter_sql}
ORDER BY timestamp DESC, created_at DESC
LIMIT ?
"""
rows = self._store._conn.execute(
sql, [*unaliased_filter_params, limit]
).fetchall()
fused = [(row["id"], 0.0, 0.0, 0.0) for row in rows]
# Materialise the top-N rows in one IN-clause round trip.
top = fused[:limit]
+2 -1
View File
@@ -48,7 +48,7 @@ def _derive_source_id(doc: Document) -> str:
return doc.source_id
prefix = f"{doc.source}:"
if doc.doc_id.startswith(prefix):
return doc.doc_id[len(prefix):]
return doc.doc_id[len(prefix) :]
return doc.doc_id
@@ -56,6 +56,7 @@ def _content_hash(text: str) -> str:
"""SHA-256 hex digest of UTF-8-encoded chunk content."""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
if TYPE_CHECKING:
from openjarvis.connectors.attachment_store import AttachmentStore
+3 -7
View File
@@ -205,8 +205,7 @@ def _validate_user_token(token: str) -> None:
raise SlackTokenError("Slack token is empty.")
if token.startswith(_BOT_TOKEN_PREFIX):
raise SlackTokenError(
"Bot tokens (xoxb-) can't read DMs. "
"Use a User OAuth Token (xoxp-) instead."
"Bot tokens (xoxb-) can't read DMs. Use a User OAuth Token (xoxp-) instead."
)
if not token.startswith(_USER_TOKEN_PREFIX):
raise SlackTokenError(
@@ -436,9 +435,7 @@ class SlackConnector(BaseConnector):
all_channels: List[Dict[str, Any]] = []
channels_cursor = ""
while True:
channels_resp = _slack_api_conversations_list(
token, cursor=channels_cursor
)
channels_resp = _slack_api_conversations_list(token, cursor=channels_cursor)
if not channels_resp.get("ok", True):
err = str(channels_resp.get("error", "list_failed"))
self._last_error = f"Slack conversations.list failed: {err}"
@@ -446,8 +443,7 @@ class SlackConnector(BaseConnector):
return
all_channels.extend(channels_resp.get("channels", []))
channels_cursor = (
channels_resp.get("response_metadata", {}).get("next_cursor", "")
or ""
channels_resp.get("response_metadata", {}).get("next_cursor", "") or ""
)
if not channels_cursor:
break
+60 -9
View File
@@ -12,9 +12,18 @@ import os
import platform
import shutil
import subprocess
from dataclasses import dataclass, field
from dataclasses import dataclass, field, is_dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
Optional,
get_args,
get_origin,
get_type_hints,
)
from openjarvis.core.paths import (
ConfigurationError,
@@ -593,6 +602,14 @@ class IntelligenceConfig:
stop_sequences: str = "" # Comma-separated stop strings
@dataclass(slots=True)
class DeepResearchConfig:
"""Planner settings for the web Deep Research endpoint."""
engine: str = "" # Empty means use the active chat engine.
model: str = "" # Empty means use the active chat model.
@dataclass(slots=True)
class RoutingLearningConfig:
"""Routing sub-policy config within Learning."""
@@ -1578,6 +1595,7 @@ class JarvisConfig:
hardware: HardwareInfo = field(default_factory=HardwareInfo)
engine: EngineConfig = field(default_factory=EngineConfig)
intelligence: IntelligenceConfig = field(default_factory=IntelligenceConfig)
deep_research: DeepResearchConfig = field(default_factory=DeepResearchConfig)
learning: LearningConfig = field(default_factory=LearningConfig)
tools: ToolsConfig = field(default_factory=ToolsConfig)
agent: AgentConfig = field(default_factory=AgentConfig)
@@ -1701,10 +1719,16 @@ def _apply_toml_section(target: Any, section: Dict[str, Any]) -> None:
"""Overlay TOML key/value pairs onto a dataclass instance.
Recursively handles nested dicts when the target attribute is itself
a dataclass. Normalises TOML arrays to comma-separated strings both
for dataclass fields annotated as ``str`` and for backward-compat
property setters that expect string input.
a dataclass, including dict entries in lists of dataclasses. Normalises
TOML arrays to comma-separated strings both for dataclass fields annotated
as ``str`` and for backward-compat property setters that expect string input.
"""
try:
type_hints = get_type_hints(type(target))
except (NameError, TypeError):
# Some config types contain optional runtime-only forward references.
type_hints = {}
for key, value in section.items():
if hasattr(target, key):
if isinstance(value, dict):
@@ -1719,14 +1743,35 @@ def _apply_toml_section(target: Any, section: Dict[str, Any]) -> None:
# property setters (e.g. reward_weights, default_tools).
if isinstance(value, list):
is_str_field = False
item_dataclass = None
if hasattr(target, "__dataclass_fields__"):
field_obj = target.__dataclass_fields__.get(key)
if field_obj is not None and field_obj.type in ("str", str):
is_str_field = True
elif field_obj is None:
if field_obj is not None:
field_type = type_hints.get(key, field_obj.type)
type_args = get_args(field_type)
if (
get_origin(field_type) is list
and len(type_args) == 1
and is_dataclass(type_args[0])
):
item_dataclass = type_args[0]
elif field_obj.type in ("str", str):
is_str_field = True
else:
# Property, not a real field — normalise to string
is_str_field = True
if is_str_field:
if item_dataclass is not None:
converted = []
for item in value:
if isinstance(item, dict):
nested = item_dataclass()
_apply_toml_section(nested, item)
converted.append(nested)
else:
converted.append(item)
value = converted
elif is_str_field:
value = ",".join(str(v) for v in value)
setattr(target, key, value)
@@ -1839,6 +1884,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
top_sections = (
"engine",
"intelligence",
"deep_research",
"learning",
"agent",
"server",
@@ -2007,6 +2053,10 @@ max_tokens = 1024
# repetition_penalty = 1.0
# stop_sequences = ""
# [deep_research]
# engine = "" # empty = use [engine].default
# model = "" # empty = use [intelligence].default_model
[agent]
default_agent = "simple"
max_turns = 10
@@ -2177,6 +2227,7 @@ __all__ = [
"DEFAULT_CONFIG_DIR",
"DEFAULT_CONFIG_PATH",
"DiscordChannelConfig",
"DeepResearchConfig",
"get_cache_dir",
"get_config_dir",
"get_config_path",
+41 -13
View File
@@ -67,6 +67,24 @@ def load_credentials(path: Path | None = None) -> dict[str, dict[str, str]]:
return tomllib.load(f)
def _validate_credential_key(tool_name: str, key: str) -> None:
allowed = TOOL_CREDENTIALS.get(tool_name, [])
if key not in allowed:
raise ValueError(f"Unknown credential key '{key}' for tool '{tool_name}'")
def _write_credentials(creds: dict[str, dict[str, str]], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
lines: list[str] = []
for section, kvs in creds.items():
lines.append(f"[{section}]")
for k, v in kvs.items():
lines.append(f'{k} = "{v}"')
lines.append("")
path.write_text("\n".join(lines))
os.chmod(path, 0o600)
def save_credential(
tool_name: str,
key: str,
@@ -75,9 +93,7 @@ def save_credential(
path: Path | None = None,
) -> None:
"""Save a single credential key, validate, write file, and set os.environ."""
allowed = TOOL_CREDENTIALS.get(tool_name, [])
if key not in allowed:
raise ValueError(f"Unknown credential key '{key}' for tool '{tool_name}'")
_validate_credential_key(tool_name, key)
stripped = value.strip()
if not stripped:
raise ValueError("Credential value must not be empty")
@@ -88,20 +104,32 @@ def save_credential(
if tool_name not in creds:
creds[tool_name] = {}
creds[tool_name][key] = stripped
p.parent.mkdir(parents=True, exist_ok=True)
lines: list[str] = []
for section, kvs in creds.items():
lines.append(f"[{section}]")
for k, v in kvs.items():
lines.append(f'{k} = "{v}"')
lines.append("")
p.write_text("\n".join(lines))
os.chmod(p, 0o600)
_write_credentials(creds, p)
os.environ[key] = stripped
def delete_credential(
tool_name: str,
key: str,
*,
path: Path | None = None,
) -> None:
"""Delete a persisted credential and remove it from the running process."""
_validate_credential_key(tool_name, key)
p = Path(path) if path else _default_path()
with _LOCK:
creds = load_credentials(path=p)
tool_creds = creds.get(tool_name)
if tool_creds is not None:
tool_creds.pop(key, None)
if not tool_creds:
creds.pop(tool_name, None)
_write_credentials(creds, p)
os.environ.pop(key, None)
def get_credential_status(tool_name: str) -> dict[str, bool]:
"""Return {KEY: bool} for each required key indicating if set in env."""
keys = TOOL_CREDENTIALS.get(tool_name, [])
+4
View File
@@ -9,7 +9,9 @@ import openjarvis.engine.ollama # noqa: F401
import openjarvis.engine.openai_compat_engines # noqa: F401
from openjarvis.engine._base import (
EngineConnectionError,
EngineContextLengthError,
InferenceEngine,
looks_like_context_length_error,
messages_to_dicts,
)
from openjarvis.engine._discovery import discover_engines, discover_models, get_engine
@@ -23,9 +25,11 @@ for _optional in ("cloud", "litellm", "gemma_cpp"):
__all__ = [
"EngineConnectionError",
"EngineContextLengthError",
"InferenceEngine",
"discover_engines",
"discover_models",
"get_engine",
"looks_like_context_length_error",
"messages_to_dicts",
]
+43
View File
@@ -13,6 +13,46 @@ class EngineConnectionError(Exception):
"""Raised when an engine is unreachable."""
class EngineContextLengthError(EngineConnectionError):
"""The prompt exceeds the served model's maximum context window.
Subclasses ``EngineConnectionError`` so existing ``except
EngineConnectionError`` handlers keep catching it, while callers that want a
distinct, user-facing "conversation too long" message can branch on this type
(or the ``is_context_length_error`` marker) instead of surfacing a generic
engine failure.
"""
is_context_length_error: bool = True
# Substrings that identify an error body as a context-window overflow (vLLM,
# SGLang, and OpenAI-compatible servers phrase this a few different ways).
# Every marker is anchored on "context" on purpose: generic phrases like
# "please reduce" or "too many tokens" also appear in unrelated 400 bodies
# (max_tokens validation, rate limiting, oversized images) and would
# misclassify those as "conversation too long".
CONTEXT_LENGTH_MARKERS = (
"context length",
"maximum context",
"context window",
"maximum_context",
"context_length_exceeded",
)
def looks_like_context_length_error(text: str) -> bool:
"""True when *text* reads like a context-window overflow error.
The single shared heuristic for recognizing vendor context-overflow
phrasings used by the engine layer (typing upstream 400s), agent error
classification, and the server stream bridge, so a new vendor phrasing
only ever needs to be added here.
"""
low = (text or "").lower()
return any(marker in low for marker in CONTEXT_LENGTH_MARKERS)
_REASONING_METADATA_KEYS = ("reasoning_content", "thinking")
@@ -80,8 +120,11 @@ def estimate_prompt_tokens(messages: Sequence[Message]) -> int:
__all__ = [
"CONTEXT_LENGTH_MARKERS",
"EngineConnectionError",
"EngineContextLengthError",
"InferenceEngine",
"estimate_prompt_tokens",
"looks_like_context_length_error",
"messages_to_dicts",
]
+6
View File
@@ -35,6 +35,12 @@ def _make_engine(key: str, config: JarvisConfig) -> InferenceEngine:
"""Instantiate a registered engine with the appropriate config host."""
cls = EngineRegistry.get(key)
# LiteLLM cannot enumerate every model supported by every provider. Its
# list_models() contract therefore advertises the configured default
# model, which must be supplied when discovery constructs the engine.
if key == "litellm":
return cls(default_model=config.intelligence.default_model or None)
# gemma_cpp: pass config fields instead of host
if key == "gemma_cpp":
cfg = config.engine.gemma_cpp
+128
View File
@@ -0,0 +1,128 @@
"""Shared async-HTTP plumbing for engines that stream over httpx.
Home of the pieces the OpenAI-compat and Ollama engines were each hand-rolling:
the async-client factory (with the configured timeout applied), a cached
long-lived client so consecutive streams reuse pooled connections instead of
paying a fresh TCP/TLS handshake per turn, the transport-error set that maps to
``EngineConnectionError``, and the non-2xx engine-error translation.
"""
from __future__ import annotations
import asyncio
import logging
from typing import NoReturn
import httpx
from openjarvis.engine._base import (
EngineConnectionError,
EngineContextLengthError,
looks_like_context_length_error,
)
logger = logging.getLogger(__name__)
# Transport failures that map to EngineConnectionError on the streaming paths.
# ``RemoteProtocolError``/``ReadError`` cover a server dying MID-STREAM (peer
# closed between tokens); a wedged read trips the configured timeout
# (TimeoutException). Kept exactly this narrow on purpose:
# ``asyncio.CancelledError``/``GeneratorExit`` are NOT ``httpx.TransportError``
# subclasses and must keep propagating for correct cancellation.
STREAM_TRANSPORT_ERRORS = (
httpx.ConnectError,
httpx.TimeoutException,
httpx.RemoteProtocolError,
httpx.ReadError,
)
_CONTEXT_LENGTH_USER_MESSAGE = (
"The conversation is too long for the model's context window. "
"Start a new chat or shorten the conversation, then try again."
)
class AsyncHTTPEngineMixin:
"""Async streaming plumbing shared by httpx-backed engines.
Expects the engine to provide ``engine_id``, ``_host``, ``_timeout``, an
``_async_transport`` test seam (``httpx.MockTransport`` in tests, ``None``
in production), and optionally ``_headers``.
"""
engine_id: str
_host: str
_timeout: float
_async_transport: httpx.AsyncBaseTransport | None
# Set True by engines whose upstream reports context-window overflows in
# 400 bodies (OpenAI-compat servers). Ollama has no such signal.
_stream_400_signals_context_length: bool = False
# Lazily-created shared client (and the loop it belongs to). Class-level
# ``None`` defaults keep engine ``__init__``s free of mixin bookkeeping.
_async_client: httpx.AsyncClient | None = None
_async_client_loop: asyncio.AbstractEventLoop | None = None
def _make_async_client(self) -> httpx.AsyncClient:
"""Build an async client that honours the configured timeout."""
return httpx.AsyncClient(
base_url=self._host,
timeout=self._timeout,
headers=getattr(self, "_headers", None),
transport=self._async_transport,
)
def _get_async_client(self) -> httpx.AsyncClient:
"""Return the shared async client for the running event loop.
Reusing one client across calls preserves connection pooling without
it every conversation turn pays a fresh TCP (and TLS) handshake. The
client is cached per event loop: pooled connections die with their
loop, so CLI flows that run ``asyncio.run()`` per turn transparently
get a fresh client while a long-lived server loop keeps one pool.
"""
loop = asyncio.get_running_loop()
client = self._async_client
if client is None or client.is_closed or self._async_client_loop is not loop:
# Any previous client belonged to a finished loop; its pooled
# connections are already dead, so just drop the reference.
client = self._make_async_client()
self._async_client = client
self._async_client_loop = loop
return client
def _close_async_client(self) -> None:
"""Best-effort close of the shared async client (for ``close()``)."""
client = self._async_client
loop = self._async_client_loop
self._async_client = None
self._async_client_loop = None
if client is None or client.is_closed:
return
try:
if loop is not None and not loop.is_closed():
if loop.is_running():
loop.create_task(client.aclose())
else:
loop.run_until_complete(client.aclose())
except Exception: # noqa: BLE001 — cleanup must never mask the close
logger.debug("Async client did not close cleanly", exc_info=True)
def _raise_stream_http_error(self, status: int, detail: str) -> NoReturn:
"""Map a non-success streaming HTTP response to a clean engine error."""
detail = (detail or "").strip()
if (
status == 400
and self._stream_400_signals_context_length
and looks_like_context_length_error(detail)
):
raise EngineContextLengthError(_CONTEXT_LENGTH_USER_MESSAGE)
detail_suffix = f": {detail}" if detail else ""
raise EngineConnectionError(
f"{self.engine_id} engine at {self._host} returned HTTP "
f"{status}{detail_suffix}"
)
__all__ = ["AsyncHTTPEngineMixin", "STREAM_TRANSPORT_ERRORS"]
+65 -12
View File
@@ -12,18 +12,27 @@ import httpx
from openjarvis.core.types import Message
from openjarvis.engine._base import (
EngineConnectionError,
EngineContextLengthError,
InferenceEngine,
estimate_prompt_tokens,
messages_to_dicts,
)
from openjarvis.engine._http_async import (
STREAM_TRANSPORT_ERRORS,
AsyncHTTPEngineMixin,
)
from openjarvis.engine._stubs import StreamChunk
logger = logging.getLogger(__name__)
class _OpenAICompatibleEngine(InferenceEngine):
class _OpenAICompatibleEngine(AsyncHTTPEngineMixin, InferenceEngine):
"""Base for engines that serve the OpenAI ``/v1/chat/completions`` API."""
# vLLM/SGLang report context-window overflows in 400 bodies; the shared
# ``_raise_stream_http_error`` types those as ``EngineContextLengthError``.
_stream_400_signals_context_length = True
engine_id: str = ""
_default_host: str = "http://localhost:8000"
_api_prefix: str = "/v1"
@@ -50,6 +59,16 @@ class _OpenAICompatibleEngine(InferenceEngine):
headers = (
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
)
# Used by the shared async streaming plumbing (AsyncHTTPEngineMixin) so
# the bounded request timeout is applied to streaming reads, not just
# the synchronous methods (a wedged token read fails at ``timeout``
# rather than hanging the caller for the httpx default).
self._timeout = timeout
self._headers = headers
# Injection seam for tests: an ``httpx.MockTransport`` swapped in here lets
# the async stream path be exercised with a mocked transport and no real
# server. ``None`` in production so httpx uses its default networking.
self._async_transport: httpx.AsyncBaseTransport | None = None
self._client = httpx.Client(
base_url=self._host, timeout=timeout, headers=headers
)
@@ -168,11 +187,26 @@ class _OpenAICompatibleEngine(InferenceEngine):
# Default to tool_choice=auto when tools are provided
if "tools" in payload and "tool_choice" not in payload:
payload["tool_choice"] = "auto"
url = f"{self._api_prefix}/chat/completions"
try:
url = f"{self._api_prefix}/chat/completions"
with self._client.stream("POST", url, json=payload) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
# ASYNC streaming: ``httpx.AsyncClient`` + ``aiter_lines`` never
# blocks the event loop between tokens (the previous SYNC
# ``httpx.Client`` + ``iter_lines`` inside this ``async def`` blocked
# the single uvicorn worker on every inter-token wait, serializing all
# concurrent chats and letting one wedged read freeze the whole API).
# The shared client keeps pooled connections across turns.
client = self._get_async_client()
async with client.stream("POST", url, json=payload) as resp:
# ``not is_success`` covers 3xx as well as 4xx/5xx. With
# ``follow_redirects`` off (the default) an unexpected redirect
# would otherwise fall through to ``aiter_lines`` and surface as
# a silent EMPTY stream instead of a clean engine error.
if not resp.is_success:
# Load the (short) error body before touching ``.text``:
# a streaming response is otherwise unread.
await resp.aread()
self._raise_stream_http_error(resp.status_code, resp.text)
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue
data_str = line[len("data:") :].strip()
@@ -186,7 +220,11 @@ class _OpenAICompatibleEngine(InferenceEngine):
content = delta.get("content")
if content:
yield content
except (httpx.ConnectError, httpx.TimeoutException) as exc:
except STREAM_TRANSPORT_ERRORS as exc:
# A wedged upstream read trips ``timeout`` (ReadTimeout) and is mapped
# here, so the request fails cleanly at the configured bound instead
# of hanging indefinitely (see STREAM_TRANSPORT_ERRORS for why the
# set is exactly this narrow).
raise EngineConnectionError(
f"{self.engine_id} engine not reachable at {self._host}"
) from exc
@@ -212,11 +250,20 @@ class _OpenAICompatibleEngine(InferenceEngine):
}
if "tools" in payload and "tool_choice" not in payload:
payload["tool_choice"] = "auto"
url = f"{self._api_prefix}/chat/completions"
try:
url = f"{self._api_prefix}/chat/completions"
with self._client.stream("POST", url, json=payload) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
# ASYNC streaming (see ``stream``): non-blocking shared client so
# rich streaming never stalls the event loop and honours ``timeout``.
client = self._get_async_client()
async with client.stream("POST", url, json=payload) as resp:
# ``not is_success`` covers 3xx as well as 4xx/5xx. With
# ``follow_redirects`` off (the default) an unexpected redirect
# would otherwise fall through to ``aiter_lines`` and surface as
# a silent EMPTY stream instead of a clean engine error.
if not resp.is_success:
await resp.aread()
self._raise_stream_http_error(resp.status_code, resp.text)
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue
data_str = line[len("data:") :].strip()
@@ -240,7 +287,10 @@ class _OpenAICompatibleEngine(InferenceEngine):
finish_reason=finish,
usage=usage,
)
except (httpx.ConnectError, httpx.TimeoutException) as exc:
except STREAM_TRANSPORT_ERRORS as exc:
# See ``stream``: transport failures (incl. a mid-stream server
# disconnect) map to a clean error; the set is kept narrow so
# cancellation still propagates.
raise EngineConnectionError(
f"{self.engine_id} engine not reachable at {self._host}"
) from exc
@@ -279,6 +329,9 @@ class _OpenAICompatibleEngine(InferenceEngine):
def close(self) -> None:
self._client.close()
self._close_async_client()
__all__ = ["_OpenAICompatibleEngine"]
# ``EngineContextLengthError`` moved to ``openjarvis.engine._base``; re-exported
# here for callers/tests that import it from this module.
__all__ = ["_OpenAICompatibleEngine", "EngineContextLengthError"]
+156 -1
View File
@@ -9,6 +9,7 @@ import json
import logging
import os
import time
import uuid
from collections.abc import AsyncIterator, Sequence
from typing import Any, Dict, List, Tuple
@@ -1305,6 +1306,160 @@ class CloudEngine(InferenceEngine):
if chunk.text:
yield chunk.text
async def _stream_full_google(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float,
max_tokens: int,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""Stream Google text and function-call parts as full chunks."""
if self._google_client is None:
raise EngineConnectionError("Google client not available")
system_text = ""
contents: List[Dict[str, Any]] = []
for message in messages:
if message.role.value == "system":
system_text = message.content
elif message.role.value == "tool":
function_response = {
"function_response": {
"name": message.name or "unknown",
"response": {"result": message.content},
}
}
if (
contents
and contents[-1]["role"] == "user"
and contents[-1]["parts"]
and "function_response" in contents[-1]["parts"][-1]
):
contents[-1]["parts"].append(function_response)
else:
contents.append({"role": "user", "parts": [function_response]})
elif message.role.value == "assistant" and message.tool_calls:
parts: List[Dict[str, Any]] = []
if message.content:
parts.append({"text": message.content})
for tool_call in message.tool_calls:
args = tool_call.arguments
if isinstance(args, str):
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {"input": args}
function_call_part: Dict[str, Any] = {
"function_call": {
"name": tool_call.name,
"args": args if isinstance(args, dict) else {},
}
}
signature = self._thought_sigs.get(tool_call.id)
if signature is not None:
function_call_part["thought_signature"] = signature
parts.append(function_call_part)
contents.append({"role": "model", "parts": parts})
elif message.role.value == "assistant":
contents.append({"role": "model", "parts": [{"text": message.content}]})
else:
contents.append({"role": "user", "parts": [{"text": message.content}]})
from google.genai import types as genai_types
config = genai_types.GenerateContentConfig(
temperature=temperature,
max_output_tokens=max_tokens,
)
if system_text:
config.system_instruction = system_text
tools = kwargs.pop("tools", None)
if tools:
config.tools = [{"function_declarations": _convert_tools_to_google(tools)}]
tool_call_count = 0
stream_id = uuid.uuid4().hex
final_usage: Dict[str, Any] | None = None
for chunk in self._google_client.models.generate_content_stream(
model=model,
contents=contents,
config=config,
):
usage_metadata = getattr(chunk, "usage_metadata", None)
if usage_metadata is not None:
prompt_tokens = getattr(usage_metadata, "prompt_token_count", 0) or 0
completion_tokens = (
getattr(usage_metadata, "candidates_token_count", 0) or 0
)
final_usage = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
candidates = getattr(chunk, "candidates", None)
parts = []
if candidates:
parts = getattr(candidates[0].content, "parts", []) or []
if parts:
text_found = False
calls: List[Dict[str, Any]] = []
for part in parts:
text = getattr(part, "text", None)
if text:
text_found = True
yield StreamChunk(content=text)
function_call = getattr(part, "function_call", None)
if function_call:
name = getattr(function_call, "name", "")
raw_args = getattr(function_call, "args", {})
args = dict(raw_args) if hasattr(raw_args, "items") else {}
# Gemini emits complete function-call parts, so each part is
# a distinct invocation. The same function may legitimately
# be called more than once in a parallel response.
tool_index = tool_call_count
# The engine is shared across server requests, and saved
# thought signatures are keyed by tool-call ID. Include a
# per-stream nonce so concurrent conversations cannot
# overwrite each other's signatures.
tool_id = f"google_{stream_id}_{tool_index}"
tool_call_count += 1
tool_call = {
"index": tool_index,
"id": tool_id,
"type": "function",
"function": {
"name": name,
"arguments": json.dumps(args),
},
}
calls.append(tool_call)
signature = getattr(part, "thought_signature", None)
if signature is not None:
tool_call["thought_signature"] = signature
self._thought_sigs[tool_id] = signature
if calls:
yield StreamChunk(tool_calls=calls)
if text_found:
continue
try:
text = chunk.text
except (AttributeError, ValueError):
text = None
if text:
yield StreamChunk(content=text)
yield StreamChunk(
finish_reason="tool_calls" if tool_call_count else "stop",
usage=final_usage,
)
async def _stream_openrouter(
self,
messages: Sequence[Message],
@@ -1600,7 +1755,7 @@ class CloudEngine(InferenceEngine):
async for chunk in self._stream_full_anthropic(messages, **kw):
yield chunk
elif _is_google_model(model):
async for chunk in super().stream_full(messages, **kw):
async for chunk in self._stream_full_google(messages, **kw):
yield chunk
else:
async for chunk in self._stream_full_openai(messages, **kw):

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