Compare commits

...
Author SHA1 Message Date
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
102 changed files with 6617 additions and 797 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "159,322",
"message": "185,599",
"color": "green",
"namedLogo": "git"
}
+28 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 159322,
"last_updated": "2026-07-16T08:08:14Z",
"total_clones": 185599,
"last_updated": "2026-08-10T07:26:44Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -112,6 +112,31 @@
"2026-07-12": 1917,
"2026-07-13": 2102,
"2026-07-14": 2337,
"2026-07-15": 2362
"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
}
}
+1
View File
@@ -4,6 +4,7 @@
<p><i>Personal AI, On Personal Devices.</i></p>
<p>
<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">
@@ -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",
]
+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"
+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
+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(
+23 -2
View File
@@ -22,6 +22,8 @@ export function ChatArea() {
const navigate = useNavigate();
const listRef = useRef<HTMLDivElement>(null);
const shouldAutoScroll = useRef(true);
const wasStreaming = useRef(false);
const lastScrollTop = useRef(0);
// Check if any data sources are connected
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
@@ -34,15 +36,34 @@ 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 (streamState.isStreaming && !wasStreaming.current) {
shouldAutoScroll.current = true;
}
wasStreaming.current = streamState.isStreaming;
if (shouldAutoScroll.current && listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages, streamState.content]);
}, [messages, streamState.content, streamState.isStreaming]);
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;
+4 -1
View File
@@ -466,7 +466,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,
+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>
);
})
+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 {
+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: {} },
);
});
});
+21 -2
View File
@@ -218,9 +218,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).
@@ -885,6 +885,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))
);
}
+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');
});
});
+31 -5
View File
@@ -15,6 +15,7 @@ import type {
TokenUsage,
} from '../types';
import type { ManagedAgent } from './api';
import { isEmbedOnlyModel } from './model-capabilities';
export interface CachedConnector {
connector_id: string;
@@ -444,11 +445,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 }),
+3 -3
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}
+36 -9
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>
+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
@@ -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..."
+4
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,
+189 -96
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,
@@ -248,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")
@@ -257,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
@@ -300,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``
@@ -379,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()
@@ -388,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"]
@@ -404,23 +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
@@ -436,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.
@@ -551,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, "
@@ -655,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)
+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,
)
+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",
]
+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]"
)
+54 -47
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 (
@@ -122,6 +123,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 +279,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,11 +299,6 @@ 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
@@ -325,12 +335,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 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 +394,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
@@ -426,29 +433,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 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 +459,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 +478,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 +590,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 +599,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 +691,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 +726,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
+45 -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,
@@ -1710,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):
@@ -1728,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)
+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, [])
+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
+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):
+13 -2
View File
@@ -26,16 +26,19 @@ class MultiEngine(InferenceEngine):
def __init__(self, engines: list[tuple[str, InferenceEngine]]) -> None:
self._engines = engines
self._model_map: Dict[str, InferenceEngine] = {}
self._model_key_map: Dict[str, str] = {}
self._refresh_map()
def _refresh_map(self) -> None:
self._model_map.clear()
for _key, engine in self._engines:
self._model_key_map.clear()
for key, engine in self._engines:
try:
for model_id in engine.list_models():
self._model_map[model_id] = engine
self._model_key_map[model_id] = key
except Exception as exc:
logger.debug("Failed to list models for %s: %s", _key, exc)
logger.debug("Failed to list models for %s: %s", key, exc)
_CLOUD_PREFIXES = ("gpt-", "o1-", "o3-", "o4-", "claude-", "gemini-", "openrouter/")
@@ -117,6 +120,14 @@ class MultiEngine(InferenceEngine):
self._refresh_map()
return list(self._model_map.keys())
def engine_key_for(self, model: str) -> str | None:
"""Return the registry key of the engine advertising *model*."""
key = self._model_key_map.get(model)
if key is not None:
return key
self._refresh_map()
return self._model_key_map.get(model)
def health(self) -> bool:
return any(engine.health() for _key, engine in self._engines)
+36 -8
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import itertools
import threading
from typing import Any, Dict, List
from openjarvis.mcp.protocol import MCPError, MCPRequest, MCPResponse
@@ -24,18 +25,31 @@ class MCPClient:
self._initialized = False
self._capabilities: Dict[str, Any] = {}
self._id_counter = itertools.count(1)
# A client may be shared by server, scheduled, and channel agents.
# Keep each transport request/response exchange atomic so stdio
# readers cannot consume another thread's JSON-RPC response.
self._request_lock = threading.RLock()
# Closing must not wait for ``_request_lock``: transport.close() is
# what interrupts a request that is blocked in a transport read.
# An event lets queued requests fail before touching that transport,
# while this separate lock keeps close itself idempotent.
self._closed = threading.Event()
self._transport_closed = threading.Event()
self._close_lock = threading.Lock()
def _next_id(self) -> int:
return next(self._id_counter)
def _send(self, method: str, params: Dict[str, Any] | None = None) -> MCPResponse:
"""Send a request and check for errors."""
request = MCPRequest(
method=method,
params=params or {},
id=self._next_id(),
)
response = self._transport.send(request)
with self._request_lock:
self._raise_if_closed()
request = MCPRequest(
method=method,
params=params or {},
id=self._next_id(),
)
response = self._transport.send(request)
if response.error is not None:
raise MCPError(
code=response.error.get("code", -1),
@@ -44,6 +58,10 @@ class MCPClient:
)
return response
def _raise_if_closed(self) -> None:
if self._closed.is_set():
raise RuntimeError("MCP client is closed")
def initialize(self) -> Dict[str, Any]:
"""Perform the MCP initialize handshake.
@@ -75,7 +93,9 @@ class MCPClient:
params=params or {},
id=None, # None → no id field in JSON (notification)
)
self._transport.send_notification(request)
with self._request_lock:
self._raise_if_closed()
self._transport.send_notification(request)
def list_tools(self) -> List[ToolSpec]:
"""Discover available tools from the server.
@@ -114,7 +134,15 @@ class MCPClient:
def close(self) -> None:
"""Close the transport connection."""
self._transport.close()
# Do not acquire _request_lock here. A transport request can be stuck
# waiting for a server response, and closing the underlying transport
# is the mechanism that unblocks it.
with self._close_lock:
if self._transport_closed.is_set():
return
self._closed.set()
self._transport.close()
self._transport_closed.set()
def __enter__(self) -> MCPClient:
return self
File diff suppressed because it is too large Load Diff
+119
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import pathlib
import threading
import time
from fastapi import FastAPI
@@ -21,6 +22,8 @@ from openjarvis.server.routes import router
from openjarvis.server.upload_router import router as upload_router
logger = logging.getLogger(__name__)
_MANAGED_SHUTDOWN_GRACE_SECONDS = 0.25
_MANAGED_SHUTDOWN_DRAIN_SECONDS = 10.0
def _restore_sendblue_bindings(app: FastAPI) -> None:
@@ -151,10 +154,13 @@ def create_app(
channel_bridge=None,
config=None,
memory_backend=None,
own_memory_backend: bool = False,
memory_service=None,
speech_backend=None,
agent_manager=None,
agent_scheduler=None,
mcp_tools=None,
mcp_clients=None,
api_key: str = "",
webhook_config: dict | None = None,
cors_origins: list[str] | None = None,
@@ -221,16 +227,129 @@ def create_app(
)
app.state.channel_bridge = channel_bridge
app.state.config = config
app.state._memory_backend_lock = threading.Lock()
app.state.memory_backend = memory_backend
app.state._owns_memory_backend = bool(own_memory_backend)
app.state.memory_service = memory_service
app.state.speech_backend = speech_backend
app.state.agent_manager = agent_manager
app.state.agent_scheduler = agent_scheduler
app.state.mcp_tools = list(mcp_tools or [])
app.state._mcp_discovery_lock = threading.Lock()
app.state._mcp_clients_lock = threading.Lock()
app.state._mcp_clients = list(mcp_clients or [])
app.state._managed_worker_lock = threading.Lock()
app.state._managed_workers: set[threading.Thread] = set()
app.state._managed_runtime_stopping = False
app.state.session_start = time.time()
# Exposed so WebSocket handlers can authenticate the handshake (the HTTP
# AuthMiddleware never sees WS upgrade requests). Empty = auth disabled.
app.state.api_key = api_key
@app.on_event("shutdown")
async def _shutdown_managed_runtime() -> None:
# Quiesce every producer before touching the shared MCP pool. Route
# workers are registered under this lock, so none can slip in after
# the snapshot. The scheduler has a two-phase stop because closing an
# MCP transport may be what releases an in-flight tick.
with app.state._managed_worker_lock:
app.state._managed_runtime_stopping = True
managed_workers = list(app.state._managed_workers)
# Stop external listener threads before draining ticks or closing the
# shared MCP pool. Channel callbacks are wired to that same pool by
# ``serve`` and otherwise could race teardown or survive app restart.
channel_bridge = getattr(app.state, "channel_bridge", None)
disconnect_channels = getattr(channel_bridge, "disconnect", None)
if callable(disconnect_channels):
try:
disconnect_channels()
except Exception:
logger.debug("Channel bridge shutdown failed", exc_info=True)
def _join_workers(timeout: float) -> None:
deadline = time.monotonic() + timeout
for thread in managed_workers:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
thread.join(timeout=remaining)
scheduler = getattr(app.state, "agent_scheduler", None)
scheduler_wait = None
scheduler_drained = True
if scheduler is not None:
try:
request_stop = getattr(scheduler, "request_stop", None)
wait_stopped = getattr(scheduler, "wait_stopped", None)
if callable(request_stop) and callable(wait_stopped):
request_stop()
scheduler_wait = wait_stopped
scheduler_drained = bool(
wait_stopped(timeout=_MANAGED_SHUTDOWN_GRACE_SECONDS)
)
else:
scheduler.stop()
scheduler_drained = not bool(
getattr(scheduler, "is_running", False)
)
except Exception:
scheduler_drained = False
logger.debug("Agent scheduler shutdown failed", exc_info=True)
# Give normal work a brief chance to finish before cancellation.
_join_workers(timeout=_MANAGED_SHUTDOWN_GRACE_SECONDS)
with app.state._mcp_clients_lock:
mcp_clients_to_close = list(app.state._mcp_clients)
for client in mcp_clients_to_close:
try:
client.close()
except Exception:
logger.debug("MCP client shutdown failed", exc_info=True)
# Transport closure interrupts blocked MCP reads. Drain the workers a
# second time so shutdown does not return while they still own runtime
# state. Any stragglers can no longer issue transport requests because
# MCPClient marks itself closed before closing its transport.
if scheduler_wait is not None:
try:
scheduler_drained = bool(
scheduler_wait(timeout=_MANAGED_SHUTDOWN_DRAIN_SECONDS)
)
except Exception:
scheduler_drained = False
logger.debug("Agent scheduler drain failed", exc_info=True)
_join_workers(timeout=_MANAGED_SHUTDOWN_DRAIN_SECONDS)
alive = [thread.name for thread in managed_workers if thread.is_alive()]
if alive:
logger.warning("Managed workers did not stop during shutdown: %s", alive)
# A backend created by ``serve`` or lazily by a managed route belongs
# to this app process. Close it only after every tracked consumer has
# been drained; injected/borrowed backends remain the caller's concern.
owned_memory_backend = None
runtime_drained = scheduler_drained and not alive
if runtime_drained:
with app.state._memory_backend_lock:
if app.state._owns_memory_backend:
owned_memory_backend = app.state.memory_backend
app.state.memory_backend = None
app.state._owns_memory_backend = False
else:
# A live worker may itself hold _memory_backend_lock while opening
# the backend. Respect the bounded shutdown deadline: do not wait
# on that lock or mutate ownership until every consumer is gone.
logger.warning(
"Skipping memory backend cleanup because managed runtime "
"consumers did not stop"
)
close_memory = getattr(owned_memory_backend, "close", None)
if callable(close_memory):
try:
close_memory()
except Exception:
logger.debug("Memory backend shutdown failed", exc_info=True)
# Wire up trace store if traces are enabled.
#
# We deliberately do NOT subscribe the trace store to the bus. The chat
@@ -0,0 +1,34 @@
"""Model capability helpers shared by server model-selection routes."""
_EMBEDDING_MODEL_PREFIXES = (
"all-minilm",
"bge-",
"bge_",
"e5-",
"e5_",
"gte-",
"gte_",
"jina-embeddings",
"nomic-bert",
"sentence-transformers",
)
def is_embed_only_model(model_name: str) -> bool:
"""Return whether a model identifier denotes a non-chat embedder.
Ollama does not expose capabilities through its model-list response, so
model selection needs a conservative name-based guard. Most embedding
models contain ``embed``; the explicit prefixes cover common families
such as MiniLM, BGE, E5, and GTE whose names do not.
"""
name = (model_name or "").strip().lower()
leaf = name.rsplit("/", 1)[-1].split(":", 1)[0]
return (
"embed" in leaf
or "minilm" in leaf
or leaf.startswith(_EMBEDDING_MODEL_PREFIXES)
)
__all__ = ["is_embed_only_model"]
+88 -27
View File
@@ -12,6 +12,7 @@ from fastapi.responses import StreamingResponse
from openjarvis.core.paths import get_config_dir
from openjarvis.core.types import Message, Role
from openjarvis.server.model_capabilities import is_embed_only_model
from openjarvis.server.models import (
ChatCompletionChunk,
ChatCompletionRequest,
@@ -59,23 +60,34 @@ def _ensure_identity_prompt(messages: list[Message], app_config) -> list[Message
If any message already carries a system role, the caller has supplied
their own grounding and we leave the list untouched (no double-prompting).
Resolution of the identity text: ``app_config.agent.default_system_prompt``
when a config is wired onto ``app.state``; otherwise fall back to
``load_config()``. Config resolution is wrapped so a broken/missing
config degrades to "no injection" rather than crashing the endpoint, but
the failure is logged (per REVIEW.md never silently swallow).
Resolution of the identity text: the config comes from ``app.state`` when
wired, otherwise ``load_config()``; the prompt itself is assembled by
``SystemPromptBuilder`` from ``agent.default_system_prompt`` plus the
persona files (SOUL.md/MEMORY.md/USER.md), matching
``_build_managed_system_prompt`` in ``agent_manager_routes.py``. Config
resolution is wrapped so a broken/missing config degrades to "no
injection" rather than crashing the endpoint, but the failure is logged
(per REVIEW.md never silently swallow).
"""
if any(m.role == Role.SYSTEM for m in messages):
return messages
prompt = ""
try:
if app_config is not None:
prompt = app_config.agent.default_system_prompt or ""
else:
cfg = app_config
if cfg is None:
from openjarvis.core.config import load_config
prompt = load_config().agent.default_system_prompt or ""
cfg = load_config()
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template=cfg.agent.default_system_prompt or "",
memory_files_config=getattr(cfg, "memory_files", None),
system_prompt_config=getattr(cfg, "system_prompt", None),
)
prompt = builder.build()
except Exception:
logging.getLogger("openjarvis.server").debug(
"Identity system prompt resolution failed; "
@@ -325,6 +337,34 @@ def _remember_exchange(
)
def _engine_key_for_model(engine: Any, model: str) -> str | None:
"""Resolve the engine that advertised *model* through wrapper layers."""
from openjarvis.engine.multi import MultiEngine
from openjarvis.security.guardrails import GuardrailsEngine
from openjarvis.telemetry.instrumented_engine import InstrumentedEngine
current = engine
while current is not None:
if isinstance(current, MultiEngine):
return current.engine_key_for(model)
if isinstance(current, InstrumentedEngine):
current = current._inner
continue
if isinstance(current, GuardrailsEngine):
current = current._engine
continue
engine_id = getattr(current, "engine_id", None)
return engine_id if isinstance(engine_id, str) else None
return None
def _uses_direct_cloud_router(engine: Any, model: str) -> bool:
"""Whether *model* should bypass the configured engine for direct cloud."""
from openjarvis.server.cloud_router import is_cloud_model
return is_cloud_model(model) and _engine_key_for_model(engine, model) != "litellm"
def _handle_direct(
engine,
model: str,
@@ -530,12 +570,13 @@ async def _handle_stream_tools(
tool_calls) identical to the prior plain-stream behaviour, so this never
regresses non-tool-capable engines.
"""
from openjarvis.server.cloud_router import is_cloud_model
messages = _to_messages(req.messages)
messages = _ensure_identity_prompt(messages, app_config)
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
use_cloud = is_cloud_model(model)
use_cloud = _uses_direct_cloud_router(engine, model)
telemetry_engine = (
"cloud" if use_cloud else (_engine_key_for_model(engine, model) or "ollama")
)
query_text = ""
for _m in reversed(req.messages):
if _m.role == "user" and _m.content:
@@ -615,7 +656,7 @@ async def _handle_stream_tools(
# Tag the finish chunk with the engine label, matching _handle_stream
# so UI/telemetry consumers see the same field on the tools path.
finish_dict.setdefault("telemetry", {})
finish_dict["telemetry"]["engine"] = "cloud" if use_cloud else "ollama"
finish_dict["telemetry"]["engine"] = telemetry_engine
if complexity_info is not None:
finish_dict["complexity"] = complexity_info.model_dump()
yield f"data: {_json.dumps(finish_dict)}\n\n"
@@ -657,11 +698,7 @@ async def _handle_stream(
"""
import time
from openjarvis.server.cloud_router import (
is_cloud_model,
stream_cloud,
stream_local,
)
from openjarvis.server.cloud_router import stream_cloud, stream_local
messages = _to_messages(req.messages)
messages = _ensure_identity_prompt(messages, app_config)
@@ -676,7 +713,10 @@ async def _handle_stream(
# Route directly to the right backend — bypasses engine routing entirely
# so broken MultiEngine state can never misdirect requests.
use_cloud = is_cloud_model(model)
use_cloud = _uses_direct_cloud_router(engine, model)
telemetry_engine = (
"cloud" if use_cloud else (_engine_key_for_model(engine, model) or "ollama")
)
async def generate():
started_at = time.time()
@@ -781,7 +821,7 @@ async def _handle_stream(
query=query_text,
result=full_content,
model=model,
engine="cloud" if use_cloud else "ollama",
engine=telemetry_engine,
started_at=started_at,
ended_at=time.time(),
)
@@ -814,7 +854,7 @@ async def _handle_stream(
# We use the routing decision (use_cloud) directly rather than
# unwrapping the engine chain, which can be in a broken state.
finish_dict.setdefault("telemetry", {})
finish_dict["telemetry"]["engine"] = "cloud" if use_cloud else "ollama"
finish_dict["telemetry"]["engine"] = telemetry_engine
if complexity_info is not None:
finish_dict["complexity"] = complexity_info.model_dump()
@@ -831,24 +871,45 @@ async def _handle_stream(
@router.get("/v1/models")
async def list_models(request: Request) -> ModelListResponse:
"""List locally installed models (Ollama).
"""List selectable engine models for the installed-model picker.
Cloud models are not included here they live in the Cloud Models tab
of the UI and are selected there, not from this endpoint.
Direct cloud models live in the Cloud Models tab. Models advertised by a
configured LiteLLM engine remain here because LiteLLM owns their routing
and may use provider-qualified IDs that resemble OpenRouter IDs.
"""
from openjarvis.server.cloud_router import is_cloud_model, list_local_models
# Prefer engine.list_models() so mock engines work in tests.
# Filter out any cloud model IDs that may appear via MultiEngine.
# Filter out direct-cloud model IDs that may appear via MultiEngine, but
# retain provider-qualified IDs owned by the configured LiteLLM engine.
# Fall back to direct Ollama query only when the engine returns nothing.
engine = request.app.state.engine
all_ids = await asyncio.to_thread(engine.list_models)
model_ids = [m for m in all_ids if not is_cloud_model(m)]
model_ids = [
m
for m in all_ids
if not is_cloud_model(m) or _engine_key_for_model(engine, m) == "litellm"
]
if not model_ids:
model_ids = await list_local_models()
# Keep embed-only models out of the chat model picker. They still work for
# memory/retrieval via the embedder path; putting them in /v1/models made
# the UI auto-select nomic-embed-text and fail every generation with 400.
model_ids = [m for m in model_ids if not is_embed_only_model(m)]
return ModelListResponse(
data=[ModelObject(id=mid) for mid in model_ids],
data=[
ModelObject(
id=mid,
owned_by=(
"litellm"
if _engine_key_for_model(engine, mid) == "litellm"
else "openjarvis"
),
)
for mid in model_ids
],
)
+4 -2
View File
@@ -25,9 +25,11 @@ class SessionStore:
def __init__(self, db_path: str = "") -> None:
if not db_path:
db_path = str(get_config_dir() / "sessions.db")
from openjarvis.security.file_utils import secure_create
# Ensure the parent directory exists (skip for :memory:)
if db_path != ":memory:":
from openjarvis.security.file_utils import secure_create
secure_create(Path(db_path))
secure_create(Path(db_path))
self._db = sqlite3.connect(db_path, check_same_thread=False)
self._db.row_factory = sqlite3.Row
self._create_tables()
+30 -3
View File
@@ -79,14 +79,41 @@ def create_ws_router(event_bus: EventBus) -> Any:
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
loop = asyncio.get_running_loop()
clients[websocket] = (queue, loop)
recv: asyncio.Task | None = None
payload: asyncio.Task | None = None
disconnected = False
try:
recv = asyncio.create_task(websocket.receive())
payload = asyncio.create_task(queue.get())
while True:
payload = await queue.get()
await websocket.send_json(payload)
done, _ = await asyncio.wait(
{recv, payload}, return_when=asyncio.FIRST_COMPLETED
)
if recv in done:
# Starlette surfaces a disconnect message only when the app
# reads from the socket. Without this receive, the handler
# can stay parked on queue.get() after the client leaves.
message = await recv
if message.get("type") == "websocket.disconnect":
disconnected = True
break
recv = asyncio.create_task(websocket.receive())
if payload in done:
await websocket.send_json(payload.result())
payload = asyncio.create_task(queue.get())
except WebSocketDisconnect:
pass
disconnected = True
finally:
clients.pop(websocket, None)
pending = [task for task in (recv, payload) if task is not None]
for task in pending:
task.cancel()
cleanup = asyncio.gather(*pending, return_exceptions=True)
try:
await asyncio.shield(cleanup)
except asyncio.CancelledError:
if not disconnected:
raise
return router
+40 -1
View File
@@ -5,10 +5,11 @@ from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional, Set
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.types import ToolCall, ToolResult
from openjarvis.skills.security import validate_capabilities
from openjarvis.skills.types import SkillManifest
from openjarvis.tools._stubs import ToolExecutor
@@ -37,10 +38,16 @@ class SkillExecutor:
tool_executor: ToolExecutor,
*,
bus: Optional[EventBus] = None,
allowed_capabilities: Optional[Set[str]] = None,
) -> None:
self._tool_executor = tool_executor
self._bus = bus
self._skill_resolver: Optional[SkillResolver] = None
# None means "no capability policy" — every skill runs, matching the
# behavior before capability enforcement existed. Pass a set (even an
# empty one) to enforce: skills whose required_capabilities are not a
# subset of it are blocked before any step runs.
self._allowed_capabilities: Optional[Set[str]] = allowed_capabilities
def set_skill_resolver(self, resolver: SkillResolver) -> None:
"""Register a callback used to delegate ``skill_name`` steps."""
@@ -53,6 +60,38 @@ class SkillExecutor:
initial_context: Optional[Dict[str, Any]] = None,
) -> SkillResult:
"""Execute all steps in a skill manifest."""
missing = (
validate_capabilities(manifest, self._allowed_capabilities)
if self._allowed_capabilities is not None
else []
)
if missing:
if self._bus:
self._bus.publish(
EventType.SKILL_EXECUTE_START,
{"skill": manifest.name, "steps": len(manifest.steps)},
)
self._bus.publish(
EventType.SKILL_EXECUTE_END,
{"skill": manifest.name, "success": False},
)
return SkillResult(
skill_name=manifest.name,
success=False,
step_results=[
ToolResult(
tool_name=manifest.name,
content=(
f"Blocked: skill '{manifest.name}' requires "
f"capabilities {missing} that were not granted "
"for this session."
),
success=False,
)
],
context=dict(initial_context or {}),
)
ctx: Dict[str, Any] = dict(initial_context or {})
all_results: List[ToolResult] = []
+46 -1
View File
@@ -25,6 +25,11 @@ import yaml
from openjarvis.core.paths import get_config_dir
from openjarvis.skills.parser import SkillParser
from openjarvis.skills.security import (
TrustTier,
classify_trust_tier,
has_dangerous_capabilities,
)
from openjarvis.skills.sources.base import ResolvedSkill
from openjarvis.skills.tool_translator import ToolTranslator
@@ -43,6 +48,9 @@ class ImportResult:
untranslated_tools: List[str] = field(default_factory=list)
scripts_imported: bool = False
warnings: List[str] = field(default_factory=list)
trust_tier: TrustTier = TrustTier.UNREVIEWED
dangerous_capabilities: List[str] = field(default_factory=list)
requires_confirmation: bool = False
class SkillImporter:
@@ -66,6 +74,7 @@ class SkillImporter:
*,
with_scripts: bool = False,
force: bool = False,
confirm_dangerous: bool = False,
) -> ImportResult:
"""Install *resolved* into ``<target_root>/<source>/<name>/``.
@@ -95,12 +104,45 @@ class SkillImporter:
try:
frontmatter, body = self._read_skill_md(source_md)
self._parser.parse_frontmatter(frontmatter, markdown_content=body)
manifest = self._parser.parse_frontmatter(
frontmatter, markdown_content=body
)
except Exception as exc:
result.success = False
result.warnings.append(f"Parse error: {exc}")
return result
# 1a. Classify trust and check for dangerous capabilities *before*
# writing anything to disk. Everything the importer handles comes from
# an external source (github/hermes/openclaw), so the BUNDLED and
# WORKSPACE tiers never apply here, and no resolver verifies index
# membership yet — a signature alone still classifies as UNREVIEWED.
# Community skills get no special treatment just because they came
# from a named source.
result.trust_tier = classify_trust_tier(
has_signature=bool(manifest.signature),
)
result.dangerous_capabilities = has_dangerous_capabilities(manifest)
if result.dangerous_capabilities and result.trust_tier == TrustTier.UNREVIEWED:
result.requires_confirmation = True
if not confirm_dangerous:
result.success = False
result.warnings.append(
"Refusing to install: this unreviewed skill requests "
f"dangerous capabilities {result.dangerous_capabilities}. "
"Re-run with confirm_dangerous=True (or `--yes-dangerous` "
"on the CLI) only if you trust the source and have "
"reviewed what it does."
)
return result
result.warnings.append(
"Installed with dangerous capabilities "
f"{result.dangerous_capabilities} — confirmed by caller. "
"This skill can run shell commands, open network listeners, "
"and/or write to the filesystem."
)
# 2. Translate tool references
translated_body, untranslated = self._translator.translate_markdown(body)
result.untranslated_tools = untranslated
@@ -180,6 +222,7 @@ class SkillImporter:
translated_str = ", ".join(f'"{t}"' for t in result.translated_tools)
missing_str = ", ".join(f'"{t}"' for t in result.untranslated_tools)
scripts_lower = "true" if result.scripts_imported else "false"
dangerous_str = ", ".join(f'"{c}"' for c in result.dangerous_capabilities)
content = (
f'source = "{resolved.source}:{resolved.name}"\n'
@@ -189,6 +232,8 @@ class SkillImporter:
f"translated_tools = [{translated_str}]\n"
f"missing_tools = [{missing_str}]\n"
f"scripts_imported = {scripts_lower}\n"
f'trust_tier = "{result.trust_tier.value}"\n'
f"dangerous_capabilities = [{dangerous_str}]\n"
)
(target_dir / ".source").write_text(content, encoding="utf-8")
+18 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import os
import tempfile
from typing import List, Optional
@@ -105,11 +106,15 @@ class FasterWhisperBackend(SpeechBackend):
try:
model = self._ensure_model()
# Write audio to a temp file (faster-whisper needs a file path)
# Write audio to a temp file (faster-whisper needs a file path).
# delete=False + manual unlink: on Windows an open
# NamedTemporaryFile holds an exclusive handle, so PyAV's reopen
# of tmp.name inside model.transcribe() fails with EACCES.
suffix = f".{format}" if not format.startswith(".") else format
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
tmp.write(audio)
tmp.flush()
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
try:
with tmp:
tmp.write(audio)
kwargs = {}
if language:
@@ -117,6 +122,15 @@ class FasterWhisperBackend(SpeechBackend):
segments_iter, info = model.transcribe(tmp.name, **kwargs)
segments_list = list(segments_iter)
finally:
try:
os.unlink(tmp.name)
except OSError as unlink_exc:
logger.debug(
"Could not remove temp audio file %s: %s",
tmp.name,
unlink_exc,
)
except Exception as exc:
self._last_error = str(exc)
raise
+31 -1
View File
@@ -48,6 +48,7 @@ class SystemBuilder:
self._sessions: Optional[bool] = None
self._speech: Optional[bool] = None
self._mcp_clients: List = []
self._mcp_tools: List[BaseTool] = []
def engine(self, key: str) -> SystemBuilder:
self._engine_key = key
@@ -113,6 +114,33 @@ class SystemBuilder:
def build(self) -> JarvisSystem:
"""Construct a fully wired JarvisSystem."""
# Discovery state belongs to one build only. Once a system is
# returned, that system owns the clients and adapters captured below;
# retaining them here would make a reused builder hand closed clients
# from an earlier system to the next one.
self._clear_mcp_discovery_state(close_clients=True)
try:
system = self._build()
except BaseException:
# No system took ownership, so release any clients opened before
# the build failed.
self._clear_mcp_discovery_state(close_clients=True)
raise
self._clear_mcp_discovery_state(close_clients=False)
return system
def _clear_mcp_discovery_state(self, *, close_clients: bool) -> None:
if close_clients:
for client in getattr(self, "_mcp_clients", []):
try:
client.close()
except Exception:
logger.debug("Error closing unowned MCP client", exc_info=True)
self._mcp_clients = []
self._mcp_tools = []
def _build(self) -> JarvisSystem:
"""Build one system using fresh, build-local MCP discovery state."""
config = self._config
bus = self._bus or get_event_bus()
@@ -291,6 +319,7 @@ class SystemBuilder:
model=model,
agent_name=agent_name,
tools=tool_list,
mcp_tools=list(self._mcp_tools),
tool_executor=tool_executor,
memory_backend=memory_backend,
channel_backend=channel_backend,
@@ -440,7 +469,7 @@ class SystemBuilder:
else:
tools = []
if config.tools.mcp.servers:
if config.tools.mcp.enabled and config.tools.mcp.servers:
try:
import json
@@ -449,6 +478,7 @@ class SystemBuilder:
for server_cfg in server_list:
try:
external_tools = self._discover_external_mcp(server_cfg)
self._mcp_tools.extend(external_tools)
if tool_names:
external_tools = [
t
+3
View File
@@ -86,6 +86,9 @@ class JarvisSystem:
skill_manager: Optional[SkillManager] = None
_learning_orchestrator: Optional[LearningOrchestrator] = None
_mcp_clients: List[MCPClient] = field(default_factory=list)
# Keep newly added fields after every pre-existing positional field so
# older positional JarvisSystem(...) calls retain their original meaning.
mcp_tools: List[BaseTool] = field(default_factory=list)
@property
def security(self) -> SecurityContext:
@@ -212,6 +212,7 @@ class InstrumentedEngine(InferenceEngine):
completion_tokens=completion_tokens,
total_tokens=prompt_tok + completion_tokens,
latency_seconds=latency,
cost_usd=result.get("cost_usd", 0.0),
ttft=ttft,
throughput_tok_per_sec=throughput,
energy_per_output_token_joules=energy_per_output_token,
+192 -82
View File
@@ -108,6 +108,13 @@ INSERT INTO telemetry (
)
"""
_INSERT_MINING = """\
INSERT INTO mining_stats (
recorded_at, provider_id, shares_submitted, shares_accepted, blocks_found,
hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
_MIGRATE_COLUMNS = [
("gpu_utilization_pct", "REAL NOT NULL DEFAULT 0.0"),
("gpu_memory_used_gb", "REAL NOT NULL DEFAULT 0.0"),
@@ -144,10 +151,35 @@ _MIGRATE_COLUMNS = [
class TelemetryStore:
"""Append-only SQLite store for inference telemetry records."""
"""Append-only SQLite store for inference telemetry records.
Writes are batched in memory and flushed to SQLite when a batch reaches
``batch_size``, when ``flush_interval_seconds`` elapses (a background
flusher thread guarantees this even with no further writes), on any read
through this store, and on ``close()``. Readers that open their OWN
connection to the database file (e.g. ``TelemetryAggregator``) therefore
see new rows within ``flush_interval_seconds`` at the latest; call
``flush()`` first for immediate visibility. Pass
``flush_interval_seconds=0`` to disable time-based flushing (batch-size
and read/close flushes still apply).
"""
def __init__(
self,
db_path: str | Path,
batch_size: int = 50,
flush_interval_seconds: float = 5.0,
) -> None:
if batch_size < 1:
raise ValueError("batch_size must be >= 1")
if flush_interval_seconds < 0:
raise ValueError("flush_interval_seconds must be >= 0")
def __init__(self, db_path: str | Path) -> None:
self._db_path = str(db_path)
if self._db_path != ":memory:":
from openjarvis.security.file_utils import secure_create
secure_create(Path(self._db_path))
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._lock = threading.Lock()
self._conn.execute("PRAGMA journal_mode=WAL")
@@ -158,6 +190,38 @@ class TelemetryStore:
self._conn.commit()
self._migrate_schema()
self._batch_size = batch_size
self._flush_interval_seconds = flush_interval_seconds
self._last_flush_time = time.monotonic()
self._telemetry_batch: list[tuple[Any, ...]] = []
self._mining_batch: list[tuple[Any, ...]] = []
self._closed = False
# Background flusher: without it, a partial batch written just before
# traffic stops would stay invisible to other connections until the
# NEXT write arrived (the stale check in ``_maybe_flush_unlocked``
# only runs inside record calls). Daemon so it never blocks exit.
self._stop_flusher = threading.Event()
self._flusher: threading.Thread | None = None
if flush_interval_seconds > 0:
self._flusher = threading.Thread(
target=self._flush_loop,
name="telemetry-store-flusher",
daemon=True,
)
self._flusher.start()
def _flush_loop(self) -> None:
"""Periodically flush pending batches until ``close()`` stops us."""
while not self._stop_flusher.wait(self._flush_interval_seconds):
with self._lock:
# ``close()`` sets the event BEFORE taking the lock, so seeing
# it unset here means the connection is still open.
if self._stop_flusher.is_set():
break
if self._telemetry_batch or self._mining_batch:
self._flush_unlocked()
def _migrate_schema(self) -> None:
"""Add new columns to existing databases (idempotent)."""
for col_name, col_def in _MIGRATE_COLUMNS:
@@ -171,54 +235,52 @@ class TelemetryStore:
def record(self, rec: TelemetryRecord) -> None:
"""Persist a single telemetry record."""
row = (
rec.timestamp,
rec.model_id,
rec.engine,
rec.agent,
rec.prompt_tokens,
rec.prompt_tokens_evaluated,
rec.completion_tokens,
rec.total_tokens,
rec.latency_seconds,
rec.ttft,
rec.cost_usd,
rec.energy_joules,
rec.power_watts,
rec.gpu_utilization_pct,
rec.gpu_memory_used_gb,
rec.gpu_temperature_c,
rec.throughput_tok_per_sec,
rec.prefill_latency_seconds,
rec.decode_latency_seconds,
rec.energy_method,
rec.energy_vendor,
rec.batch_id,
1 if rec.is_warmup else 0,
rec.cpu_energy_joules,
rec.gpu_energy_joules,
rec.dram_energy_joules,
rec.tokens_per_joule,
rec.energy_per_output_token_joules,
rec.throughput_per_watt,
rec.prefill_energy_joules,
rec.decode_energy_joules,
rec.mean_itl_ms,
rec.median_itl_ms,
rec.p90_itl_ms,
rec.p95_itl_ms,
rec.p99_itl_ms,
rec.std_itl_ms,
1 if rec.is_streaming else 0,
rec.token_counting_version,
rec.mining_session_id,
json.dumps(rec.metadata),
)
with self._lock:
self._conn.execute(
_INSERT,
(
rec.timestamp,
rec.model_id,
rec.engine,
rec.agent,
rec.prompt_tokens,
rec.prompt_tokens_evaluated,
rec.completion_tokens,
rec.total_tokens,
rec.latency_seconds,
rec.ttft,
rec.cost_usd,
rec.energy_joules,
rec.power_watts,
rec.gpu_utilization_pct,
rec.gpu_memory_used_gb,
rec.gpu_temperature_c,
rec.throughput_tok_per_sec,
rec.prefill_latency_seconds,
rec.decode_latency_seconds,
rec.energy_method,
rec.energy_vendor,
rec.batch_id,
1 if rec.is_warmup else 0,
rec.cpu_energy_joules,
rec.gpu_energy_joules,
rec.dram_energy_joules,
rec.tokens_per_joule,
rec.energy_per_output_token_joules,
rec.throughput_per_watt,
rec.prefill_energy_joules,
rec.decode_energy_joules,
rec.mean_itl_ms,
rec.median_itl_ms,
rec.p90_itl_ms,
rec.p95_itl_ms,
rec.p99_itl_ms,
rec.std_itl_ms,
1 if rec.is_streaming else 0,
rec.token_counting_version,
rec.mining_session_id,
json.dumps(rec.metadata),
),
)
self._conn.commit()
self._telemetry_batch.append(row)
self._maybe_flush_unlocked()
def record_mining_stats(self, stats: Any) -> None:
"""Persist one mining stats snapshot.
@@ -226,43 +288,70 @@ class TelemetryStore:
``stats`` is duck-typed to keep telemetry usable without importing the
optional mining package at module import time.
"""
row = (
time.time(),
stats.provider_id,
stats.shares_submitted,
stats.shares_accepted,
stats.blocks_found,
stats.hashrate,
stats.uptime_seconds,
stats.last_share_at,
stats.last_error,
stats.payout_target,
stats.fees_owed,
)
with self._lock:
self._conn.execute(
"""\
INSERT INTO mining_stats (
recorded_at, provider_id, shares_submitted, shares_accepted, blocks_found,
hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
time.time(),
stats.provider_id,
stats.shares_submitted,
stats.shares_accepted,
stats.blocks_found,
stats.hashrate,
stats.uptime_seconds,
stats.last_share_at,
stats.last_error,
stats.payout_target,
stats.fees_owed,
),
)
self._conn.commit()
self._mining_batch.append(row)
self._maybe_flush_unlocked()
def flush(self) -> None:
"""Write all pending records to the database."""
with self._lock:
self._flush_unlocked()
def _flush_unlocked(self) -> None:
if self._telemetry_batch:
self._conn.executemany(_INSERT, self._telemetry_batch)
self._telemetry_batch.clear()
if self._mining_batch:
self._conn.executemany(_INSERT_MINING, self._mining_batch)
self._mining_batch.clear()
self._conn.commit()
self._last_flush_time = time.monotonic()
def _maybe_flush_unlocked(self) -> None:
"""Flush when the batch is full or has been pending too long."""
if not self._telemetry_batch and not self._mining_batch:
return
batch_full = (
len(self._telemetry_batch) >= self._batch_size
or len(self._mining_batch) >= self._batch_size
)
stale = (
self._flush_interval_seconds > 0
and time.monotonic() - self._last_flush_time >= self._flush_interval_seconds
)
if batch_full or stale:
self._flush_unlocked()
def list_recent(self, limit: int = 50) -> list[dict[str, Any]]:
"""Return recent telemetry rows as dictionaries."""
return self._select_dicts(
"SELECT * FROM telemetry ORDER BY timestamp DESC LIMIT ?",
(limit,),
)
with self._lock:
self._flush_unlocked()
return self._select_dicts_unlocked(
"SELECT * FROM telemetry ORDER BY timestamp DESC LIMIT ?",
(limit,),
)
def list_recent_mining_stats(self, limit: int = 50) -> list[dict[str, Any]]:
"""Return recent mining stats snapshots as dictionaries."""
return self._select_dicts(
"SELECT * FROM mining_stats ORDER BY recorded_at DESC LIMIT ?",
(limit,),
)
with self._lock:
self._flush_unlocked()
return self._select_dicts_unlocked(
"SELECT * FROM mining_stats ORDER BY recorded_at DESC LIMIT ?",
(limit,),
)
def subscribe_to_bus(self, bus: EventBus) -> None:
"""Subscribe to ``TELEMETRY_RECORD`` events on *bus*."""
@@ -277,15 +366,36 @@ INSERT INTO mining_stats (
logger.debug("Failed to record telemetry event: %s", exc)
def close(self) -> None:
"""Close the underlying SQLite connection."""
self._conn.close()
"""Flush pending records and close the underlying SQLite connection."""
# Set the stop event BEFORE taking the lock: a flusher iteration
# already waiting on the lock re-checks the event after acquiring it
# and exits instead of touching the closed connection.
self._stop_flusher.set()
with self._lock:
if self._closed:
return
self._flush_unlocked()
self._conn.close()
self._closed = True
if self._flusher is not None:
self._flusher.join(timeout=1.0)
self._flusher = None
# -- helpers for querying (used by tests) --------------------------------
def _fetchall(self, sql: str = "SELECT * FROM telemetry") -> list:
return self._conn.execute(sql).fetchall()
with self._lock:
self._flush_unlocked()
return self._conn.execute(sql).fetchall()
def _select_dicts(self, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
with self._lock:
self._flush_unlocked()
return self._select_dicts_unlocked(sql, params)
def _select_dicts_unlocked(
self, sql: str, params: tuple[Any, ...]
) -> list[dict[str, Any]]:
cur = self._conn.execute(sql, params)
columns = [desc[0] for desc in cur.description]
return [dict(zip(columns, row)) for row in cur.fetchall()]
+10
View File
@@ -142,4 +142,14 @@ try:
except ImportError:
pass
try:
import openjarvis.tools.scan_chunks # noqa: F401
except ImportError:
pass
try:
import openjarvis.tools.knowledge_sql # noqa: F401
except ImportError:
pass
__all__ = ["BaseTool", "ToolExecutor", "ToolSpec"]
+9 -3
View File
@@ -139,8 +139,8 @@ class GitStatusTool(BaseTool):
def execute(self, **params: Any) -> ToolResult:
repo_path = params.get("repo_path", ".")
_rust = get_rust_module()
try:
_rust = get_rust_module()
output = _rust.GitStatusTool().execute(repo_path)
return ToolResult(
tool_name="git_status",
@@ -148,6 +148,8 @@ class GitStatusTool(BaseTool):
success=True,
metadata={"returncode": 0},
)
except ImportError as exc:
logger.debug("Rust git_status fallback to CLI: %s", exc)
except Exception as exc:
return ToolResult(
tool_name="git_status",
@@ -155,6 +157,8 @@ class GitStatusTool(BaseTool):
success=False,
)
return _run_git(["git", "status", "--porcelain"], cwd=repo_path)
# ---------------------------------------------------------------------------
# GitDiffTool
@@ -208,9 +212,9 @@ class GitDiffTool(BaseTool):
staged = params.get("staged", False)
file_path = params.get("path")
_rust = get_rust_module()
if not staged and not file_path:
try:
_rust = get_rust_module()
output = _rust.GitDiffTool().execute(repo_path)
return ToolResult(
tool_name="git_diff",
@@ -218,6 +222,8 @@ class GitDiffTool(BaseTool):
success=True,
metadata={"returncode": 0},
)
except ImportError as exc:
logger.debug("Rust git_diff fallback to CLI: %s", exc)
except Exception as exc:
return ToolResult(
tool_name="git_diff",
@@ -371,8 +377,8 @@ class GitLogTool(BaseTool):
count = params.get("count", 10)
oneline = params.get("oneline", True)
_rust = get_rust_module()
try:
_rust = get_rust_module()
output = _rust.GitLogTool().execute(repo_path, count)
return ToolResult(
tool_name="git_log",
@@ -0,0 +1,127 @@
"""Regression tests for managed-agent tool-call persistence."""
from __future__ import annotations
import json
import pytest
from openjarvis.agents._stubs import AgentResult
from openjarvis.agents.executor import AgentExecutor, _tool_calls_for_storage
from openjarvis.agents.manager import AgentManager
from openjarvis.core.events import EventBus
from openjarvis.core.types import ToolResult
def test_tool_results_are_serialized_for_managed_messages() -> None:
result = AgentResult(
content="Finished",
tool_results=[
ToolResult(
tool_name="knowledge_search",
content="Found the requested note",
success=True,
latency_seconds=0.42,
metadata={
"arguments": {
"query": "financial independence",
"limit": 3,
}
},
),
ToolResult(
tool_name="shell_exec",
content="Permission denied",
success=False,
latency_seconds=1.25,
metadata={"arguments": '{"command":"whoami"}'},
),
],
)
calls = _tool_calls_for_storage(result)
assert calls is not None
assert len(calls) == 2
knowledge_call = calls[0]
assert knowledge_call["tool"] == "knowledge_search"
assert isinstance(knowledge_call["arguments"], str)
assert json.loads(knowledge_call["arguments"]) == {
"query": "financial independence",
"limit": 3,
}
assert knowledge_call["result"] == "Found the requested note"
assert knowledge_call["success"] is True
assert knowledge_call["latency"] == pytest.approx(420.0)
failed_call = calls[1]
assert failed_call["arguments"] == '{"command":"whoami"}'
assert failed_call["result"] == "Permission denied"
assert failed_call["success"] is False
assert failed_call["latency"] == pytest.approx(1250.0)
def test_no_tool_results_serialize_as_none() -> None:
assert _tool_calls_for_storage(AgentResult(content="Plain response")) is None
def test_finalize_tick_persists_tool_calls_round_trip(tmp_path) -> None:
manager = AgentManager(str(tmp_path / "agents.db"))
try:
agent = manager.create_agent("researcher")
manager.start_tick(agent["id"])
result = AgentResult(
content="Answer grounded in the knowledge base",
tool_results=[
ToolResult(
tool_name="knowledge_search",
content="Matching source text",
success=True,
latency_seconds=0.007,
metadata={"arguments": {"query": "grounded answer"}},
)
],
)
executor = AgentExecutor(manager, EventBus())
executor._finalize_tick(
agent["id"],
result,
error=None,
duration=0.01,
)
messages = manager.list_messages(agent["id"])
assert len(messages) == 1
stored = messages[0]
assert stored["content"] == result.content
assert stored["direction"] == "agent_to_user"
assert stored["tool_calls"] == _tool_calls_for_storage(result)
assert isinstance(stored["tool_calls"][0]["arguments"], str)
assert json.loads(stored["tool_calls"][0]["arguments"]) == {
"query": "grounded answer"
}
assert stored["tool_calls"][0]["latency"] == pytest.approx(7.0)
finally:
manager.close()
def test_finalize_tick_without_tools_stores_null_tool_calls(tmp_path) -> None:
manager = AgentManager(str(tmp_path / "agents.db"))
try:
agent = manager.create_agent("plain-agent")
manager.start_tick(agent["id"])
executor = AgentExecutor(manager, EventBus())
executor._finalize_tick(
agent["id"],
AgentResult(content="No tools needed"),
error=None,
duration=0.01,
)
stored = manager.list_messages(agent["id"])[0]
assert stored["tool_calls"] is None
finally:
manager.close()
+471
View File
@@ -2,13 +2,91 @@
from __future__ import annotations
import gc
import sqlite3
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from openjarvis.agents._stubs import AgentResult
from openjarvis.agents.executor import AgentExecutor
from openjarvis.agents.manager import AgentManager
from openjarvis.agents.tool_resolver import ResolvedAgentTools
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.core.config import MemoryFilesConfig, SystemPromptConfig
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry, ToolRegistry
from openjarvis.core.types import Role, ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
from tests.agents.fake_engine import FakeEngine
from tests.agents.scenario_harness import FakeSystem
class _CapturingToolAgent:
"""Minimal agent that exposes the toolkit received by AgentExecutor."""
accepts_tools = True
captured_tools = []
captured_search_result = None
def __init__(self, engine, model, *, tools=None, **kwargs):
self.engine = engine
self.model = model
type(self).captured_tools = list(tools or [])
def run(self, input_text, context=None):
tools_by_name = {tool.spec.name: tool for tool in self.captured_tools}
search = tools_by_name.get("knowledge_search")
if search is not None:
type(self).captured_search_result = search.execute(
query="EXECUTOR_RESOLVER_SENTINEL"
)
return AgentResult(content="captured")
class _NonToolAgent:
"""Agent class whose run method must not swallow a configured toolkit."""
accepts_tools = False
supports_managed_tool_fallback = True
runs = 0
def __init__(self, engine, model, **kwargs):
pass
def run(self, input_text, context=None):
type(self).runs += 1
raise AssertionError("non-tool agent should use the managed tool loop")
class _SpecializedNonToolAgent:
"""Non-tool agent that must retain its specialized execution path."""
accepts_tools = False
runs = 0
def __init__(self, engine, model, **kwargs):
pass
def run(self, input_text, context=None):
type(self).runs += 1
return AgentResult(content="specialized response")
class _ExecutorProbeTool(BaseTool):
tool_id = "executor_probe"
calls = 0
@property
def spec(self) -> ToolSpec:
return ToolSpec(name=self.tool_id, description="Executor parity probe")
def execute(self, **params) -> ToolResult:
type(self).calls += 1
return ToolResult(tool_name=self.tool_id, content="probe-result")
def _register_agent():
"""Re-register MonitorOperativeAgent (cleared by autouse fixture)."""
from openjarvis.agents.monitor_operative import MonitorOperativeAgent
@@ -102,3 +180,396 @@ def test_executor_handles_string_tools(tmp_path):
result_agent = mgr.get_agent(agent["id"])
assert result_agent["status"] == "idle"
mgr.close()
def test_executor_uses_tool_loop_for_non_tool_agent_with_configured_tools(tmp_path):
"""Immediate/scheduled ticks match SSE instead of discarding tools."""
AgentRegistry.register_value("non_tool_probe", _NonToolAgent)
ToolRegistry.register_value(_ExecutorProbeTool.tool_id, _ExecutorProbeTool)
_NonToolAgent.runs = 0
_ExecutorProbeTool.calls = 0
engine = FakeEngine(
[
{
"tool_calls": [
{
"id": "call-executor-probe",
"name": _ExecutorProbeTool.tool_id,
"arguments": "{}",
}
]
},
{"content": "tool-backed final response"},
]
)
system = FakeSystem(engine=engine)
system.config = SimpleNamespace(
agent=SimpleNamespace(default_system_prompt="GLOBAL_DEFAULT"),
memory_files=MemoryFilesConfig(persona_name="none"),
system_prompt=SystemPromptConfig(),
)
manager = AgentManager(db_path=str(tmp_path / "agents.db"))
agent = manager.create_agent(
"non-tool with tools",
agent_type="non_tool_probe",
config={
"model": "test-model",
"tools": [_ExecutorProbeTool.tool_id],
"instruction": "Use the probe.",
"system_prompt": "NON_TOOL_SYSTEM_SENTINEL",
},
)
try:
AgentExecutor(manager, EventBus(), system=system).execute_tick(agent["id"])
assert _NonToolAgent.runs == 0
assert _ExecutorProbeTool.calls == 1
assert engine.call_count == 2
assert any(
message.role is Role.SYSTEM
and message.content == "NON_TOOL_SYSTEM_SENTINEL"
for message in engine.last_messages or []
)
refreshed = manager.get_agent(agent["id"])
assert refreshed["status"] == "idle"
assert refreshed["total_runs"] == 1
responses = [
message
for message in manager.list_messages(agent["id"])
if message["direction"] == "agent_to_user"
]
assert responses[-1]["content"] == "tool-backed final response"
assert responses[-1]["tool_calls"][0]["tool"] == "executor_probe"
finally:
manager.close()
def test_simple_agent_uses_global_mcp_tools_without_native_tool_config(tmp_path):
"""Fallback-compatible simple agents preserve SSE/global-MCP parity."""
from openjarvis.agents.simple import SimpleAgent
AgentRegistry.register_value("simple", SimpleAgent)
_ExecutorProbeTool.calls = 0
provider = MagicMock(return_value=([_ExecutorProbeTool()], []))
engine = FakeEngine(
[
{
"tool_calls": [
{
"id": "call-global-mcp-probe",
"name": _ExecutorProbeTool.tool_id,
"arguments": "{}",
}
]
},
{"content": "global MCP response"},
]
)
system = SimpleNamespace(
engine=engine,
model="test-model",
config=None,
memory_backend=None,
channel_backend=None,
session_store=None,
knowledge_db_path=None,
get_managed_agent_mcp_tools=provider,
)
manager = AgentManager(db_path=str(tmp_path / "agents.db"))
agent = manager.create_agent(
"simple global MCP",
agent_type="simple",
config={"model": "test-model", "instruction": "Use MCP."},
)
try:
AgentExecutor(manager, EventBus(), system=system).execute_tick(agent["id"])
provider.assert_called_once_with()
assert _ExecutorProbeTool.calls == 1
assert engine.call_count == 2
responses = [
message
for message in manager.list_messages(agent["id"])
if message["direction"] == "agent_to_user"
]
assert responses[-1]["content"] == "global MCP response"
finally:
manager.close()
def test_simple_agent_without_tools_keeps_its_custom_system_prompt(tmp_path):
"""Signature filtering must not discard prompt-builder state on retry."""
from openjarvis.agents.simple import SimpleAgent
AgentRegistry.register_value("simple", SimpleAgent)
engine = FakeEngine([{"content": "custom prompt response"}])
system = FakeSystem(engine=engine)
system.config = SimpleNamespace(
agent=SimpleNamespace(default_system_prompt="GLOBAL_DEFAULT"),
memory_files=MemoryFilesConfig(persona_name="none"),
system_prompt=SystemPromptConfig(),
)
manager = AgentManager(db_path=str(tmp_path / "agents.db"))
agent = manager.create_agent(
"simple custom prompt",
agent_type="simple",
config={
"model": "test-model",
"instruction": "Answer directly.",
"system_prompt": "SIMPLE_CUSTOM_SYSTEM_SENTINEL",
"mcp_tools": False,
},
)
try:
AgentExecutor(manager, EventBus(), system=system).execute_tick(agent["id"])
assert engine.call_count == 1
assert any(
message.role is Role.SYSTEM
and message.content == "SIMPLE_CUSTOM_SYSTEM_SENTINEL"
for message in engine.last_messages or []
)
finally:
manager.close()
def test_specialized_non_tool_agent_is_not_replaced_by_generic_tool_loop(tmp_path):
"""Configured/global tools never replace a non-opted-in agent class."""
AgentRegistry.register_value("specialized_non_tool", _SpecializedNonToolAgent)
ToolRegistry.register_value(_ExecutorProbeTool.tool_id, _ExecutorProbeTool)
_SpecializedNonToolAgent.runs = 0
_ExecutorProbeTool.calls = 0
provider = MagicMock(return_value=([_ExecutorProbeTool()], []))
system = SimpleNamespace(
engine=FakeEngine([{"content": "unused"}]),
model="test-model",
config=None,
memory_backend=None,
channel_backend=None,
session_store=None,
knowledge_db_path=None,
get_managed_agent_mcp_tools=provider,
)
manager = AgentManager(db_path=str(tmp_path / "agents.db"))
agent = manager.create_agent(
"specialized with configured tool",
agent_type="specialized_non_tool",
config={
"model": "test-model",
"instruction": "Keep the specialized path.",
"tools": [_ExecutorProbeTool.tool_id],
},
)
try:
AgentExecutor(manager, EventBus(), system=system).execute_tick(agent["id"])
provider.assert_not_called()
assert _SpecializedNonToolAgent.runs == 1
assert _ExecutorProbeTool.calls == 0
responses = [
message
for message in manager.list_messages(agent["id"])
if message["direction"] == "agent_to_user"
]
assert responses[-1]["content"] == "specialized response"
finally:
manager.close()
def test_executor_grants_deep_research_live_knowledge_tools(tmp_path):
"""Immediate ticks receive the same live Deep Research grant as SSE."""
AgentRegistry.register_value("deep_research", _CapturingToolAgent)
_CapturingToolAgent.captured_tools = []
_CapturingToolAgent.captured_search_result = None
knowledge_db_path = tmp_path / "knowledge.db"
with KnowledgeStore(db_path=knowledge_db_path) as store:
store.store(
"The EXECUTOR_RESOLVER_SENTINEL decision was approved.",
source="test",
doc_type="note",
)
manager = AgentManager(db_path=str(tmp_path / "agents.db"))
agent = manager.create_agent(
"researcher",
agent_type="deep_research",
config={
"model": "agent-selected-model",
# These duplicate two agent-type grants and must not replace them.
"tools": ["knowledge_search", "think"],
"instruction": "Find the sentinel.",
},
)
manager.send_message(agent["id"], "Search the knowledge base.", mode="immediate")
system = SimpleNamespace(
engine=FakeEngine([{"content": "unused"}]),
model="system-model",
memory_backend=None,
channel_backend=None,
tool_executor=None,
_mcp_clients=[],
knowledge_db_path=knowledge_db_path,
config=None,
session_store=None,
)
executor = AgentExecutor(manager=manager, event_bus=EventBus(), system=system)
try:
executor.execute_tick(agent["id"])
tools_by_name = {
tool.spec.name: tool for tool in _CapturingToolAgent.captured_tools
}
assert set(tools_by_name) == {
"knowledge_search",
"knowledge_sql",
"scan_chunks",
"think",
}
result = _CapturingToolAgent.captured_search_result
assert result is not None
assert result.success is True
assert "EXECUTOR_RESOLVER_SENTINEL" in result.content
assert tools_by_name["scan_chunks"]._model == "agent-selected-model"
assert manager.get_agent(agent["id"])["status"] == "idle"
with pytest.raises(sqlite3.ProgrammingError):
tools_by_name["knowledge_sql"]._store._conn.execute("SELECT 1")
finally:
manager.close()
def test_executor_mcp_opt_out_does_not_call_lazy_provider(tmp_path):
"""An opted-out tick must not trigger request-local MCP discovery."""
AgentRegistry.register_value("capturing", _CapturingToolAgent)
provider = MagicMock(side_effect=AssertionError("MCP discovery must stay lazy"))
system = SimpleNamespace(
engine=FakeEngine([{"content": "unused"}]),
model="system-model",
memory_backend=None,
channel_backend=None,
tool_executor=None,
_mcp_clients=[],
config=None,
session_store=None,
get_managed_agent_mcp_tools=provider,
)
manager = AgentManager(db_path=str(tmp_path / "agents.db"))
agent = manager.create_agent(
"no-mcp",
agent_type="capturing",
config={"model": "test-model", "mcp_tools": False},
)
try:
AgentExecutor(manager, EventBus(), system=system).execute_tick(agent["id"])
provider.assert_not_called()
assert manager.get_agent(agent["id"])["status"] == "idle"
finally:
manager.close()
def test_executor_preserves_custom_dict_tool_schema(tmp_path):
"""Executor-based agents see the same custom schema advertised by SSE."""
from openjarvis.tools.think import ThinkTool
AgentRegistry.register_value("capturing", _CapturingToolAgent)
ToolRegistry.register_value("think", ThinkTool)
_CapturingToolAgent.captured_tools = []
custom_spec = {
"type": "function",
"function": {
"name": "think",
"description": "Agent-specific thinking schema",
"parameters": {
"type": "object",
"properties": {"thought": {"type": "string"}},
"required": ["thought"],
},
},
}
system = SimpleNamespace(
engine=FakeEngine([{"content": "unused"}]),
model="test-model",
memory_backend=None,
channel_backend=None,
tool_executor=None,
mcp_tools=[],
_mcp_clients=[],
config=None,
session_store=None,
)
manager = AgentManager(db_path=str(tmp_path / "agents.db"))
agent = manager.create_agent(
"custom-schema",
agent_type="capturing",
config={"model": "test-model", "tools": [custom_spec]},
)
try:
AgentExecutor(manager, EventBus(), system=system).execute_tick(agent["id"])
assert len(_CapturingToolAgent.captured_tools) == 1
configured_tool = _CapturingToolAgent.captured_tools[0]
assert configured_tool.to_openai_function() == custom_spec
assert configured_tool.spec.description == "Agent-specific thinking schema"
assert configured_tool.execute(thought="same instance").success is True
finally:
manager.close()
def test_executor_closes_resolver_resources_when_pre_run_setup_fails(
tmp_path,
monkeypatch,
):
"""The resolver finalizer covers failures before agent.run is reached."""
AgentRegistry.register_value("capturing", _CapturingToolAgent)
resource = MagicMock()
def _resolve(*args, **kwargs):
return ResolvedAgentTools(owned_resources=[resource])
monkeypatch.setattr("openjarvis.agents.executor.resolve_agent_tools", _resolve)
system = SimpleNamespace(
engine=FakeEngine([{"content": "unused"}]),
model="test-model",
memory_backend=None,
channel_backend=None,
tool_executor=None,
mcp_tools=[],
_mcp_clients=[],
config=None,
session_store=None,
)
manager = AgentManager(db_path=str(tmp_path / "agents.db"))
agent = manager.create_agent(
"cleanup",
agent_type="capturing",
config={"model": "test-model"},
)
monkeypatch.setattr(
manager,
"get_pending_messages",
MagicMock(side_effect=RuntimeError("pre-run setup failed")),
)
try:
with pytest.raises(RuntimeError, match="pre-run setup failed"):
AgentExecutor(manager, EventBus(), system=system)._invoke_agent(agent)
gc.collect()
resource.close.assert_called_once_with()
finally:
manager.close()
+131
View File
@@ -0,0 +1,131 @@
"""Regression tests for proactive scheduling and notification setup."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from openjarvis.agents.proactive_agent import (
_PROACTIVE_CRON_PROMPT,
_build_notification_channel,
register_cron,
)
from openjarvis.core.registry import ChannelRegistry
from openjarvis.scheduler.scheduler import TaskScheduler
from openjarvis.scheduler.store import SchedulerStore
@pytest.fixture()
def scheduler(tmp_path):
store = SchedulerStore(tmp_path / "scheduler.db")
scheduler = TaskScheduler(store)
yield scheduler
scheduler.stop()
store.close()
def _register(scheduler, *, schedule="0 5 * * *", channel="telegram:123"):
return register_cron(
scheduler,
notification_channel_id=channel,
cron_expr=schedule,
hours_back=24,
timezone="UTC",
)
class TestRegisterCron:
def test_reuses_exact_task_and_cancels_duplicates(self, scheduler):
first = _register(scheduler)
duplicate = scheduler.create_task(
_PROACTIVE_CRON_PROMPT,
"cron",
"0 5 * * *",
agent="proactive",
metadata=first.metadata,
)
returned = _register(scheduler)
assert returned.id in {first.id, duplicate.id}
assert [task.id for task in scheduler.list_tasks(status="active")] == [
returned.id
]
cancelled_id = scheduler.list_tasks(status="cancelled")[0].id
assert cancelled_id == ({first.id, duplicate.id} - {returned.id}).pop()
def test_replaces_task_when_configuration_changes(self, scheduler):
old = _register(scheduler, schedule="0 5 * * *", channel="telegram:old")
new = _register(scheduler, schedule="0 7 * * *", channel="telegram:new")
assert new.id != old.id
assert new.schedule_value == "0 7 * * *"
assert new.metadata["notification_channel_id"] == "telegram:new"
assert scheduler.list_tasks(status="cancelled")[0].id == old.id
def test_preserves_pause_across_restart(self, scheduler):
paused = _register(scheduler)
scheduler.pause_task(paused.id)
returned = _register(scheduler, schedule="0 7 * * *")
assert returned.id == paused.id
assert returned.status == "paused"
assert scheduler.list_tasks(status="active") == []
def test_migrates_legacy_tasks_without_stable_key(self, scheduler):
legacy = scheduler.create_task(
_PROACTIVE_CRON_PROMPT,
"cron",
"0 5 * * *",
agent="proactive",
metadata={
"notification_channel_id": "telegram:123",
"hours_back": 24,
"timezone": "UTC",
},
)
current = _register(scheduler)
assert current.id != legacy.id
assert current.metadata["openjarvis_task_key"] == "proactive-daily"
assert scheduler.list_tasks(status="cancelled")[0].id == legacy.id
class TestNotificationChannel:
def test_telegram_is_configured_without_starting_polling(self):
class FakeTelegram:
def __init__(self, **kwargs):
self.kwargs = kwargs
self.connect = MagicMock()
config = MagicMock()
with (
patch.object(ChannelRegistry, "contains", return_value=True),
patch.object(ChannelRegistry, "get", return_value=FakeTelegram),
patch("openjarvis.core.config.load_config", return_value=config),
patch(
"openjarvis.system._channel_kwargs.build_channel_kwargs",
return_value={"bot_token": "configured-token"},
),
):
channel = _build_notification_channel("telegram:123")
assert channel.kwargs == {"bot_token": "configured-token"}
channel.connect.assert_not_called()
def test_non_telegram_channel_keeps_connect_lifecycle(self):
class FakeChannel:
def __init__(self, **kwargs):
self.connect = MagicMock()
with (
patch.object(ChannelRegistry, "contains", return_value=True),
patch.object(ChannelRegistry, "get", return_value=FakeChannel),
):
channel = _build_notification_channel("twilio:15551234567")
channel.connect.assert_called_once_with()
+43
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import tempfile
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock
@@ -117,6 +118,48 @@ class TestSchedulerBasic:
assert executor.execute_tick.call_count >= 1
executor.execute_tick.assert_called_with(agent["id"])
def test_two_phase_stop_retains_and_drains_active_worker(self, manager):
"""Shutdown quiesces later ticks and can wait again after cancellation."""
from openjarvis.agents.scheduler import AgentScheduler
started = threading.Event()
release = threading.Event()
calls: list[str] = []
class _BlockingExecutor:
def execute_tick(self, agent_id):
calls.append(agent_id)
started.set()
release.wait(timeout=2)
scheduler = AgentScheduler(
manager=manager,
executor=_BlockingExecutor(),
tick_interval=0.01,
)
agents = [
manager.create_agent(
name=f"test-{index}",
agent_type="monitor_operative",
config={"schedule_type": "interval", "schedule_value": 0},
)
for index in range(2)
]
for agent in agents:
scheduler.register_agent(agent["id"])
scheduler.start()
assert started.wait(timeout=1)
scheduler.request_stop()
assert scheduler.wait_stopped(timeout=0.01) is False
assert scheduler._thread is not None
release.set()
assert scheduler.wait_stopped(timeout=1) is True
assert scheduler._thread is None
assert calls == [agents[0]["id"]]
def test_skips_paused_agents(self, manager):
from openjarvis.agents.scheduler import AgentScheduler
+284
View File
@@ -0,0 +1,284 @@
"""Focused tests for canonical managed-agent tool resolution (#688)."""
from __future__ import annotations
from collections import Counter
import pytest
from openjarvis.agents import tool_resolver
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools import description_loader
from openjarvis.tools._stubs import BaseTool, ToolSpec
class _AlphaTool(BaseTool):
tool_id = "alpha"
@property
def spec(self) -> ToolSpec:
return ToolSpec(name="alpha", description="Alpha test tool")
def execute(self, **params) -> ToolResult:
return ToolResult(tool_name="alpha", content="alpha", success=True)
class _BetaTool(BaseTool):
tool_id = "beta"
@property
def spec(self) -> ToolSpec:
return ToolSpec(name="beta", description="Beta test tool")
def execute(self, **params) -> ToolResult:
return ToolResult(tool_name="beta", content="beta", success=True)
class _NativeSharedTool(BaseTool):
tool_id = "shared"
@property
def spec(self) -> ToolSpec:
return ToolSpec(name="shared", description="Native shared tool")
def execute(self, **params) -> ToolResult:
return ToolResult(tool_name="shared", content="native", success=True)
class _MCPSharedTool(BaseTool):
tool_id = "shared"
@property
def spec(self) -> ToolSpec:
return ToolSpec(name="shared", description="MCP name collision")
def execute(self, **params) -> ToolResult:
return ToolResult(tool_name="shared", content="mcp", success=True)
class _MCPOnlyTool(BaseTool):
tool_id = "mcp_only"
@property
def spec(self) -> ToolSpec:
return ToolSpec(name="mcp_only", description="MCP-only test tool")
def execute(self, **params) -> ToolResult:
return ToolResult(tool_name="mcp_only", content="mcp-only", success=True)
@pytest.fixture(autouse=True)
def _use_explicit_test_registrations(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep these unit tests independent of import-time registry population."""
monkeypatch.setattr(tool_resolver, "ensure_registries_populated", lambda: None)
def test_deep_research_grants_are_live_deduplicated_and_use_selected_model(
tmp_path,
) -> None:
"""Agent-type grants must beat duplicate bare configured tools."""
db_path = tmp_path / "knowledge.db"
with KnowledgeStore(db_path=db_path) as store:
store.store(
"The RESOLVER_SENTINEL decision was approved.",
source="test",
doc_type="note",
)
engine = object()
resolved = tool_resolver.resolve_agent_tools(
{
"agent_type": "deep_research",
"config": {
# Both names are already supplied by the agent-type grant.
"tools": ["knowledge_search", "think", "think"],
},
},
engine=engine,
model="agent-selected-model",
knowledge_db_path=db_path,
)
try:
names = [tool.spec.name for tool in resolved.instances]
assert set(names) == {
"knowledge_search",
"knowledge_sql",
"scan_chunks",
"think",
}
assert all(count == 1 for count in Counter(names).values())
search = resolved.by_name["knowledge_search"]
result = search.execute(query="RESOLVER_SENTINEL")
assert result.success is True
assert "RESOLVER_SENTINEL" in result.content
scan = resolved.by_name["scan_chunks"]
assert scan._engine is engine
assert scan._model == "agent-selected-model"
finally:
# All three knowledge tools share this store connection.
resolved.by_name["knowledge_sql"]._store.close()
@pytest.mark.parametrize(
"tool_config",
[
["alpha", "beta", "alpha"],
" alpha, beta, alpha ",
],
)
def test_configured_tools_normalize_lists_and_comma_separated_strings(
tool_config,
) -> None:
ToolRegistry.register_value("alpha", _AlphaTool)
ToolRegistry.register_value("beta", _BetaTool)
resolved = tool_resolver.resolve_agent_tools(
{"agent_type": "simple", "config": {"tools": tool_config}},
engine=object(),
model="test-model",
)
assert [tool.spec.name for tool in resolved.instances] == ["alpha", "beta"]
assert [spec["function"]["name"] for spec in resolved.openai_specs] == [
"alpha",
"beta",
]
def test_registered_tool_advertisement_matches_to_openai_function(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Runtime description overrides must reach canonical advertisements."""
ToolRegistry.register_value("alpha", _AlphaTool)
monkeypatch.setattr(
description_loader,
"get_tool_description_override",
lambda name: "Runtime alpha description" if name == "alpha" else None,
)
resolved = tool_resolver.resolve_agent_tools(
{"agent_type": "simple", "config": {"tools": ["alpha"]}},
engine=object(),
model="test-model",
)
tool = resolved.by_name["alpha"]
assert resolved.openai_specs == [tool.to_openai_function()]
assert (
resolved.openai_specs[0]["function"]["description"]
== "Runtime alpha description"
)
def test_explicit_config_schema_takes_priority_over_tool_advertisement(
monkeypatch: pytest.MonkeyPatch,
) -> None:
ToolRegistry.register_value("alpha", _AlphaTool)
monkeypatch.setattr(
description_loader,
"get_tool_description_override",
lambda name: "Runtime alpha description" if name == "alpha" else None,
)
custom_spec = {
"type": "function",
"function": {
"name": "alpha",
"description": "Agent-specific alpha description",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
resolved = tool_resolver.resolve_agent_tools(
{"agent_type": "simple", "config": {"tools": [custom_spec]}},
engine=object(),
model="test-model",
)
assert resolved.openai_specs == [custom_spec]
assert resolved.by_name["alpha"].to_openai_function() == custom_spec
def test_invalid_tool_advertisement_falls_back_to_tool_spec() -> None:
class _InvalidAdvertisementTool(_AlphaTool):
def to_openai_function(self) -> dict[str, object]:
raise RuntimeError("broken advertisement")
ToolRegistry.register_value("alpha", _InvalidAdvertisementTool)
resolved = tool_resolver.resolve_agent_tools(
{"agent_type": "simple", "config": {"tools": ["alpha"]}},
engine=object(),
model="test-model",
)
assert resolved.openai_specs == [
{
"type": "function",
"function": {
"name": "alpha",
"description": "Alpha test tool",
"parameters": {},
},
}
]
def test_mcp_tools_merge_after_native_tools_without_name_collisions() -> None:
ToolRegistry.register_value("shared", _NativeSharedTool)
mcp_shared = _MCPSharedTool()
mcp_only = _MCPOnlyTool()
client = object()
resolved = tool_resolver.resolve_agent_tools(
{
"agent_type": "simple",
"config": {"tools": ["shared", "shared"]},
},
engine=object(),
model="test-model",
mcp_tools=[mcp_shared, mcp_only, mcp_only],
mcp_clients=[client],
)
assert [tool.spec.name for tool in resolved.instances] == ["shared", "mcp_only"]
assert isinstance(resolved.by_name["shared"], _NativeSharedTool)
assert resolved.by_name["mcp_only"] is mcp_only
assert resolved.mcp_clients == [client]
assert [spec["function"]["name"] for spec in resolved.openai_specs] == [
"shared",
"mcp_only",
]
def test_mcp_tools_can_be_disabled_per_agent() -> None:
ToolRegistry.register_value("shared", _NativeSharedTool)
class _MustNotIterate:
def __iter__(self):
raise AssertionError("MCP tools must not be inspected after opt-out")
resolved = tool_resolver.resolve_agent_tools(
{
"agent_type": "simple",
"config": {"tools": ["shared"], "mcp_tools": False},
},
engine=object(),
model="test-model",
mcp_tools=_MustNotIterate(),
mcp_clients=_MustNotIterate(),
)
assert [tool.spec.name for tool in resolved.instances] == ["shared"]
assert resolved.mcp_clients == []
+32
View File
@@ -138,6 +138,38 @@ class TestCLI:
content = config_path.read_text()
assert "[engine]" in content
def test_init_preset_uses_utf8_for_config_copy(self, tmp_path: Path) -> None:
"""Preset installation reads and writes shipped TOML as UTF-8."""
config_dir = tmp_path / ".openjarvis"
config_path = config_dir / "config.toml"
original_read_text = Path.read_text
original_write_text = Path.write_text
def read_text(path: Path, *args: object, **kwargs: object) -> str:
if path.name == "chat-simple.toml":
assert kwargs.get("encoding") == "utf-8"
return original_read_text(path, *args, **kwargs)
def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int:
if path == config_path:
assert kwargs.get("encoding") == "utf-8"
return original_write_text(path, data, *args, **kwargs)
with (
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text),
mock.patch.object(
Path, "write_text", autospec=True, side_effect=write_text
),
):
result = CliRunner().invoke(cli, ["init", "--preset", "chat-simple"])
assert result.exit_code == 0
assert "lightweight conversational AI" in config_path.read_text(
encoding="utf-8"
)
class TestStartupResilience:
"""Importing the CLI must not force heavy/native deps (#404, #309).
+24 -7
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
from pathlib import Path
from unittest import mock
import pytest
from click.testing import CliRunner
@@ -109,18 +110,34 @@ temperature = 0.7
except json.JSONDecodeError:
pytest.fail(f"Output is not valid JSON: {result.output}")
def test_config_show_toml_displays_raw_content(self, tmp_path: Path) -> None:
"""Test that config show toml displays the raw TOML content."""
@pytest.mark.parametrize("output_format", ["toml", "json"])
def test_config_show_uses_utf8_for_config_file(
self, tmp_path: Path, output_format: str
) -> None:
"""Test that config show reads UTF-8 config files explicitly."""
# Create a temporary config file
config_file = tmp_path / "test_config.toml"
config_file.write_text('[engine]\ndefault = "ollama"\n')
result = CliRunner().invoke(
cli, ["config", "show", "toml", "--path", str(config_file)]
config_file.write_text(
'# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n',
encoding="utf-8",
)
original_read_text = Path.read_text
def read_text(path: Path, *args: object, **kwargs: object) -> str:
if path == config_file:
assert kwargs.get("encoding") == "utf-8"
return original_read_text(path, *args, **kwargs)
with mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text):
result = CliRunner().invoke(
cli, ["config", "show", output_format, "--path", str(config_file)]
)
assert result.exit_code == 0
assert "[engine]" in result.output
if output_format == "toml":
assert "[engine]" in result.output
else:
assert '"engine"' in result.output
assert "ollama" in result.output
def test_config_show_json_displays_parsed_content(self, tmp_path: Path) -> None:
+36
View File
@@ -60,6 +60,42 @@ class TestConfigSet:
assert "vllm" in content
assert "qwen2.5:3b" in content
def test_set_uses_utf8_for_existing_config(self, tmp_path: Path) -> None:
"""config set preserves a UTF-8 config regardless of the system locale."""
config_file = tmp_path / "config.toml"
config_file.write_text(
'# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n',
encoding="utf-8",
)
original_read_text = Path.read_text
original_write_text = Path.write_text
def read_text(path: Path, *args: object, **kwargs: object) -> str:
if path == config_file:
assert kwargs.get("encoding") == "utf-8"
return original_read_text(path, *args, **kwargs)
def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int:
if path == config_file:
assert kwargs.get("encoding") == "utf-8"
return original_write_text(path, data, *args, **kwargs)
with (
mock.patch.dict(os.environ, {"OPENJARVIS_CONFIG": str(config_file)}),
mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text),
mock.patch.object(
Path, "write_text", autospec=True, side_effect=write_text
),
):
result = CliRunner().invoke(
cli, ["config", "set", "engine.default", "vllm"]
)
assert result.exit_code == 0
content = config_file.read_text(encoding="utf-8")
assert "Preset comment — stored as UTF-8" in content
assert "vllm" in content
def test_set_invalid_key_rejected(self, tmp_path: Path) -> None:
"""config set rejects unknown keys."""
config_file = tmp_path / "config.toml"
+131 -3
View File
@@ -2,13 +2,17 @@
from __future__ import annotations
import os
import subprocess
import sys
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from openjarvis.cli import cli
from openjarvis.cli.daemon_cmd import _read_pid, _write_pid
from openjarvis.cli.daemon_cmd import _pid_alive, _read_pid, _write_pid
class TestDaemonCommands:
@@ -44,12 +48,12 @@ class TestDaemonCommands:
assert _read_pid() is None
def test_write_and_read_pid(self, tmp_path: Path) -> None:
"""Write a PID, then read it back (mock os.kill to succeed)."""
"""Write a PID, then read it back with a successful liveness probe."""
pid_file = tmp_path / "server.pid"
with (
patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file),
patch("openjarvis.cli.daemon_cmd.DEFAULT_CONFIG_DIR", tmp_path),
patch("os.kill", return_value=None),
patch("openjarvis.cli.daemon_cmd._pid_alive", return_value=True),
):
_write_pid(12345)
assert pid_file.exists()
@@ -79,3 +83,127 @@ class TestDaemonCommands:
result = CliRunner().invoke(cli, ["start"])
assert result.exit_code != 0
assert "already running" in result.output
class TestPidLiveness:
"""Regression coverage for Windows-safe PID liveness checks."""
def test_pid_alive_current_process(self) -> None:
assert _pid_alive(os.getpid()) is True
def test_pid_alive_nonpositive(self) -> None:
assert _pid_alive(0) is False
assert _pid_alive(-1) is False
def test_pid_alive_dead_pid(self) -> None:
proc = subprocess.Popen([sys.executable, "-c", "pass"])
proc.wait()
for _ in range(20):
if not _pid_alive(proc.pid):
break
time.sleep(0.1)
assert _pid_alive(proc.pid) is False
def test_read_pid_stale_pid_returns_none(self, tmp_path: Path) -> None:
proc = subprocess.Popen([sys.executable, "-c", "pass"])
proc.wait()
pid_file = tmp_path / "server.pid"
pid_file.write_text(str(proc.pid))
with patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file):
assert _read_pid() is None
assert not pid_file.exists()
def test_read_pid_live_pid_returns_it(self, tmp_path: Path) -> None:
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(10)"])
try:
pid_file = tmp_path / "server.pid"
pid_file.write_text(str(proc.pid))
with patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file):
assert _read_pid() == proc.pid
assert pid_file.exists()
finally:
proc.terminate()
proc.wait()
class TestDaemonDetachment:
"""The spawned server must outlive the console that started it.
``start_new_session`` is POSIX-only CPython's Windows ``_execute_child``
names the parameter ``unused_start_new_session``. 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.
"""
@staticmethod
def _spawn_kwargs(platform: str) -> dict:
"""Return the kwargs ``start`` passes to Popen when spawning the server.
``load_config`` is stubbed because it shells out for GPU detection
patching Popen wholesale would otherwise break config loading before
the spawn is reached.
"""
with (
patch("openjarvis.cli.daemon_cmd._read_pid", return_value=None),
patch("openjarvis.cli.daemon_cmd._write_pid"),
patch("openjarvis.cli.daemon_cmd.load_config"),
patch("openjarvis.cli.daemon_cmd.sys.platform", platform),
patch("openjarvis.cli.daemon_cmd.subprocess.Popen") as popen,
patch("builtins.open", MagicMock()),
):
popen.return_value = MagicMock(pid=4321)
result = CliRunner().invoke(cli, ["start"])
assert result.exit_code == 0, result.output
spawns = [
c for c in popen.call_args_list if c.args and "serve" in c.args[0]
]
assert spawns, f"start did not spawn the server: {popen.call_args_list}"
return spawns[-1].kwargs
def test_windows_spawn_is_detached_from_the_console(self) -> None:
# These constants are only exported by ``subprocess`` on Windows.
# Supply their documented values so the simulated Windows branch is
# still exercised by the POSIX test job.
detached_process = getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
create_new_process_group = getattr(
subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200
)
with (
patch.object(
subprocess,
"DETACHED_PROCESS",
detached_process,
create=True,
),
patch.object(
subprocess,
"CREATE_NEW_PROCESS_GROUP",
create_new_process_group,
create=True,
),
):
kwargs = self._spawn_kwargs("win32")
flags = kwargs.get("creationflags", 0)
assert flags & detached_process, (
"server must be spawned with DETACHED_PROCESS on Windows, otherwise "
"closing the launching console kills it"
)
assert flags & create_new_process_group, (
"server must be in its own process group so Ctrl-C in the parent "
"console does not propagate to it"
)
assert not kwargs.get("start_new_session"), (
"start_new_session is ignored on Windows; it must not be relied on"
)
def test_posix_spawn_still_uses_start_new_session(self) -> None:
kwargs = self._spawn_kwargs("linux")
assert kwargs.get("start_new_session") is True
assert "creationflags" not in kwargs or kwargs["creationflags"] == 0
+17
View File
@@ -101,6 +101,7 @@ def _run_serve(tmp_path, monkeypatch, *, build_spy, set_system_spy):
(``uvicorn.run`` is a no-op) and no real engine is contacted.
"""
from openjarvis.core.config import JarvisConfig
from openjarvis.core.registry import MemoryRegistry
_repopulate_registries()
@@ -112,6 +113,9 @@ def _run_serve(tmp_path, monkeypatch, *, build_spy, set_system_spy):
config.sessions.enabled = True
config.sessions.db_path = str(tmp_path / "sessions.db")
config.memory.db_path = str(tmp_path / "memory.db")
# Disabling prompt-context injection must not disable the backend needed
# by explicitly configured memory tools in managed-agent ticks.
config.agent.context_from_memory = False
config.telemetry.enabled = False
config.traces.enabled = False
config.channel.enabled = False
@@ -122,6 +126,16 @@ def _run_serve(tmp_path, monkeypatch, *, build_spy, set_system_spy):
config.intelligence.default_model = "test-model"
engine = _fake_engine()
# Keep this wiring test independent of the optional native memory runtime.
# The assertion is that serve resolves and passes a backend even when
# prompt-context injection is disabled, not that SQLite itself works.
memory_backend = MagicMock(name="memory_backend")
monkeypatch.setattr(MemoryRegistry, "contains", MagicMock(return_value=True))
monkeypatch.setattr(
MemoryRegistry,
"create",
MagicMock(return_value=memory_backend),
)
monkeypatch.setattr(serve_mod, "load_config", lambda *a, **k: config)
monkeypatch.setattr(serve_mod, "get_engine", lambda *a, **k: ("mock", engine))
@@ -159,6 +173,8 @@ def test_serve_does_not_call_systembuilder_build(tmp_path, monkeypatch):
)
)
set_system_spy = MagicMock()
inject_spy = MagicMock()
monkeypatch.setattr(serve_mod, "inject_credentials", inject_spy)
result = _run_serve(
tmp_path,
@@ -169,6 +185,7 @@ def test_serve_does_not_call_systembuilder_build(tmp_path, monkeypatch):
assert result.exit_code == 0, result.output
build_spy.assert_not_called()
inject_spy.assert_called_once_with()
def test_executor_receives_required_system_attrs(tmp_path, monkeypatch):
+13
View File
@@ -30,6 +30,19 @@ from openjarvis.core.registry import (
)
@pytest.fixture(autouse=True)
def _no_update_check(monkeypatch: pytest.MonkeyPatch) -> None:
"""Never let the CLI's PyPI update-check nag run during tests.
``check_for_updates`` writes its banner to stderr, which ``CliRunner``
merges into ``result.output`` polluting JSON/CSV output of any test
that invokes a CLI command. It already self-disables when ``CI`` is
set, but that only helps in CI; locally (e.g. a dev with a stale
version-check cache and network access) it fires for real.
"""
monkeypatch.setenv("OPENJARVIS_NO_UPDATE_CHECK", "1")
@pytest.fixture(autouse=True)
def _clean_registries() -> None:
"""Ensure each test starts with empty registries and a fresh event bus."""
+36 -1
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
from openjarvis.core.config import SkillsConfig, SkillSourceConfig
from pathlib import Path
from openjarvis.core.config import SkillsConfig, SkillSourceConfig, load_config
class TestSkillSourceConfig:
@@ -41,3 +43,36 @@ class TestSkillsConfigWithSources:
)
assert len(cfg.sources) == 2
assert cfg.sources[0].source == "hermes"
def test_loads_source_tables_as_config_objects(
self, tmp_path: Path, monkeypatch
) -> None:
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
toml_file = tmp_path / "config.toml"
toml_file.write_text(
"[[skills.sources]]\n"
'source = "hermes"\n'
'filter = { category = ["productivity"] }\n\n'
"[[skills.sources]]\n"
'source = "github"\n'
'url = "https://github.com/example/skill-library"\n'
"auto_update = true\n"
)
load_config.cache_clear()
try:
cfg = load_config(toml_file)
finally:
load_config.cache_clear()
assert cfg.skills.sources == [
SkillSourceConfig(
source="hermes",
filter={"category": ["productivity"]},
),
SkillSourceConfig(
source="github",
url="https://github.com/example/skill-library",
auto_update=True,
),
]
+25
View File
@@ -5,7 +5,9 @@ import os
import pytest
from openjarvis.core.credentials import (
delete_credential,
get_credential_status,
inject_credentials,
load_credentials,
save_credential,
)
@@ -54,3 +56,26 @@ def test_file_permissions(cred_path):
save_credential("web_search", "TAVILY_API_KEY", "tvly-x", path=cred_path)
mode = oct(cred_path.stat().st_mode & 0o777)
assert mode == "0o600"
def test_inject_credentials_restores_saved_value(cred_path, monkeypatch):
save_credential("web_search", "TAVILY_API_KEY", "tvly-persisted", path=cred_path)
monkeypatch.delenv("TAVILY_API_KEY")
inject_credentials(path=cred_path)
assert os.environ["TAVILY_API_KEY"] == "tvly-persisted"
def test_delete_credential_removes_file_value_and_env(cred_path, monkeypatch):
save_credential("web_search", "TAVILY_API_KEY", "tvly-delete", path=cred_path)
delete_credential("web_search", "TAVILY_API_KEY", path=cred_path)
assert load_credentials(path=cred_path) == {}
assert "TAVILY_API_KEY" not in os.environ
def test_delete_rejects_unknown_key(cred_path):
with pytest.raises(ValueError, match="Unknown credential key"):
delete_credential("web_search", "BOGUS_KEY", path=cred_path)
+7
View File
@@ -18,6 +18,7 @@ ROOT = Path(__file__).resolve().parent.parent.parent
PYPROJECT = ROOT / "pyproject.toml"
DESKTOP_LIB_RS = ROOT / "frontend" / "src-tauri" / "src" / "lib.rs"
WINDOWS_INSTALL_PS1 = ROOT / "deploy" / "windows" / "install.ps1"
QUICKSTART_SH = ROOT / "scripts" / "quickstart.sh"
def _pyproject() -> dict:
@@ -59,3 +60,9 @@ def test_windows_installer_syncs_the_native_group() -> None:
"the Windows installer must include `--group desktop-native` so "
"openjarvis_rust is built during source install."
)
def test_quickstart_installs_web_search_dependencies() -> None:
quickstart = QUICKSTART_SH.read_text()
assert "--extra tools-search" in quickstart
assert "already running on port 8000" in quickstart
+342
View File
@@ -3,6 +3,8 @@ and _prepare_anthropic_messages."""
from __future__ import annotations
import sys
from types import ModuleType, SimpleNamespace
from typing import Any, List
from unittest.mock import MagicMock
@@ -63,6 +65,36 @@ def _openai_tool_call_delta(
return tc
class _GoogleConfig:
def __init__(self, **kwargs: Any) -> None:
self.__dict__.update(kwargs)
def _google_stream_chunk(
*parts: Any,
text: str | None = None,
usage_metadata: Any = None,
) -> Any:
candidates = []
if parts:
candidates = [SimpleNamespace(content=SimpleNamespace(parts=list(parts)))]
return SimpleNamespace(
text=text,
candidates=candidates,
usage_metadata=usage_metadata,
)
def _google_types_modules() -> dict[str, ModuleType]:
types = ModuleType("google.genai.types")
types.GenerateContentConfig = _GoogleConfig
genai = ModuleType("google.genai")
genai.types = types
google = ModuleType("google")
google.genai = genai
return {"google": google, "google.genai": genai, "google.genai.types": types}
# ---------------------------------------------------------------------------
# _stream_full_openai tests
# ---------------------------------------------------------------------------
@@ -413,6 +445,316 @@ def test_prepare_anthropic_messages_tool_calls():
assert blocks[1]["input"] == {"city": "Berlin"}
# ---------------------------------------------------------------------------
# _stream_full_google tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_full_google_text_only(monkeypatch: pytest.MonkeyPatch):
"""Google text chunks retain their content and finish normally."""
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[_google_stream_chunk(text="Hello"), _google_stream_chunk(text=" world")]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
messages = [Message(role=Role.USER, content="hi")]
modules = _google_types_modules()
with monkeypatch.context() as patch:
for name, module in modules.items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(messages, model="gemini-2.5-flash")
]
assert [chunk.content for chunk in result[:-1]] == ["Hello", " world"]
assert result[-1].finish_reason == "stop"
@pytest.mark.asyncio
async def test_stream_full_google_preserves_tool_calls(monkeypatch: pytest.MonkeyPatch):
"""Google function_call parts become OpenAI-compatible tool call chunks."""
function_call = SimpleNamespace(name="get_weather", args={"city": "Berlin"})
part = SimpleNamespace(
function_call=function_call, text=None, thought_signature=b"sig"
)
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[_google_stream_chunk(part)]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
messages = [Message(role=Role.USER, content="weather")]
modules = _google_types_modules()
with monkeypatch.context() as patch:
for name, module in modules.items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
messages,
model="gemini-2.5-flash",
tools=[
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}},
},
}
],
)
]
tool_call = result[0].tool_calls[0]
assert tool_call["index"] == 0
assert tool_call["id"].startswith("google_")
assert tool_call["type"] == "function"
assert tool_call["function"] == {
"name": "get_weather",
"arguments": '{"city": "Berlin"}',
}
assert tool_call["thought_signature"] == b"sig"
assert engine._thought_sigs[tool_call["id"]] == b"sig"
assert result[-1].finish_reason == "tool_calls"
config = client.models.generate_content_stream.call_args.kwargs["config"]
assert config.tools == [
{
"function_declarations": [
{
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}},
}
]
}
]
@pytest.mark.asyncio
async def test_stream_full_google_preserves_mixed_and_multiple_calls(
monkeypatch: pytest.MonkeyPatch,
):
"""Google streams retain mixed text and multiple tool calls."""
weather = SimpleNamespace(name="get_weather", args={"city": "Berlin"})
calendar = SimpleNamespace(name="get_calendar", args={"day": "Monday"})
text_part = SimpleNamespace(text="I'll check.", function_call=None)
weather_part = SimpleNamespace(
function_call=weather, text=None, thought_signature=None
)
calendar_part = SimpleNamespace(
function_call=calendar, text=None, thought_signature=None
)
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[
_google_stream_chunk(text_part, weather_part),
_google_stream_chunk(calendar_part),
]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
modules = _google_types_modules()
with monkeypatch.context() as patch:
for name, module in modules.items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="plan")], model="gemini-2.5-flash"
)
]
assert result[0].content == "I'll check."
weather_call = result[1].tool_calls[0]
calendar_call = result[2].tool_calls[0]
assert weather_call["index"] == 0
assert weather_call["function"] == {
"name": "get_weather",
"arguments": '{"city": "Berlin"}',
}
assert calendar_call["index"] == 1
assert calendar_call["function"] == {
"name": "get_calendar",
"arguments": '{"day": "Monday"}',
}
assert weather_call["id"] != calendar_call["id"]
assert result[-1].finish_reason == "tool_calls"
@pytest.mark.asyncio
async def test_stream_full_google_keeps_parallel_same_name_calls_distinct(
monkeypatch: pytest.MonkeyPatch,
):
"""Parallel invocations of one function receive unique indexes and IDs."""
paris = SimpleNamespace(name="get_weather", args={"city": "Paris"})
london = SimpleNamespace(name="get_weather", args={"city": "London"})
parts = [
SimpleNamespace(function_call=paris, text=None, thought_signature=b"sig"),
SimpleNamespace(function_call=london, text=None, thought_signature=None),
]
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[_google_stream_chunk(*parts)]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="Weather in Paris and London")],
model="gemini-3-flash-preview",
)
]
calls = result[0].tool_calls
assert [call["index"] for call in calls] == [0, 1]
assert calls[0]["id"] != calls[1]["id"]
assert [call["function"]["arguments"] for call in calls] == [
'{"city": "Paris"}',
'{"city": "London"}',
]
@pytest.mark.asyncio
async def test_stream_full_google_ids_are_unique_across_requests(
monkeypatch: pytest.MonkeyPatch,
):
"""Shared engines keep signatures isolated between conversations."""
first_part = SimpleNamespace(
function_call=SimpleNamespace(name="get_weather", args={"city": "Paris"}),
text=None,
thought_signature=b"paris-sig",
)
second_part = SimpleNamespace(
function_call=SimpleNamespace(name="get_weather", args={"city": "London"}),
text=None,
thought_signature=b"london-sig",
)
client = MagicMock()
client.models.generate_content_stream.side_effect = [
iter([_google_stream_chunk(first_part)]),
iter([_google_stream_chunk(second_part)]),
]
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
first = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="Weather in Paris")],
model="gemini-3-flash-preview",
)
]
second = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="Weather in London")],
model="gemini-3-flash-preview",
)
]
first_id = first[0].tool_calls[0]["id"]
second_id = second[0].tool_calls[0]["id"]
assert first_id != second_id
assert engine._thought_sigs[first_id] == b"paris-sig"
assert engine._thought_sigs[second_id] == b"london-sig"
@pytest.mark.asyncio
async def test_stream_full_google_emits_final_usage(monkeypatch: pytest.MonkeyPatch):
"""Google's final usage metadata is normalized onto the terminal chunk."""
usage = SimpleNamespace(prompt_token_count=12, candidates_token_count=5)
client = MagicMock()
client.models.generate_content_stream.return_value = iter(
[
_google_stream_chunk(text="Hello"),
_google_stream_chunk(usage_metadata=usage),
]
)
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {}
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="hi")],
model="gemini-2.5-flash",
)
]
assert result[-1].finish_reason == "stop"
assert result[-1].usage == {
"prompt_tokens": 12,
"completion_tokens": 5,
"total_tokens": 17,
}
@pytest.mark.asyncio
async def test_stream_full_google_replays_signature_on_part(
monkeypatch: pytest.MonkeyPatch,
):
"""A saved Gemini signature is replayed beside, not inside, function_call."""
client = MagicMock()
client.models.generate_content_stream.return_value = iter([])
engine = _make_cloud_engine(google_client=client)
engine._thought_sigs = {"google_get_weather_0": b"sig"}
messages = [
Message(role=Role.USER, content="weather"),
Message(
role=Role.ASSISTANT,
content=None,
tool_calls=[
ToolCall(
id="google_get_weather_0",
name="get_weather",
arguments='{"city": "Berlin"}',
)
],
),
Message(role=Role.TOOL, name="get_weather", content='{"temp": 20}'),
]
with monkeypatch.context() as patch:
for name, module in _google_types_modules().items():
patch.setitem(sys.modules, name, module)
result = [
chunk
async for chunk in engine.stream_full(
messages, model="gemini-3-flash-preview"
)
]
contents = client.models.generate_content_stream.call_args.kwargs["contents"]
assert contents[1]["parts"] == [
{
"function_call": {
"name": "get_weather",
"args": {"city": "Berlin"},
},
"thought_signature": b"sig",
}
]
assert result[-1].finish_reason == "stop"
# ---------------------------------------------------------------------------
# stream_full routing tests
# ---------------------------------------------------------------------------
+20
View File
@@ -8,10 +8,12 @@ from openjarvis.core.config import JarvisConfig
from openjarvis.core.registry import EngineRegistry
from openjarvis.engine._base import InferenceEngine
from openjarvis.engine._discovery import (
_make_engine,
discover_engines,
discover_models,
get_engine,
)
from openjarvis.engine.litellm import LiteLLMEngine
class _FakeEngine(InferenceEngine):
@@ -131,6 +133,24 @@ class TestDiscoverModels:
assert result == {"ollama": ["m1", "m2"], "vllm": ["m3"]}
class TestLiteLLMDiscovery:
def test_configured_default_model_is_advertised(self) -> None:
"""Regression for #713: discovery must configure LiteLLM's model.
LiteLLM cannot enumerate every model supported by every provider, so
``LiteLLMEngine.list_models()`` advertises the configured default
model. Dropping that value while constructing the engine leaves the
API and Web UI with an empty model list.
"""
cfg = JarvisConfig()
cfg.intelligence.default_model = "groq/llama-3.3-70b-versatile"
EngineRegistry.register_value("litellm", LiteLLMEngine)
engine = _make_engine("litellm", cfg)
assert engine.list_models() == ["groq/llama-3.3-70b-versatile"]
class TestGetEngine:
def test_fallback_when_default_unhealthy(self) -> None:
_reg("bad", "bad")
+3
View File
@@ -120,6 +120,9 @@ async def test_multi_routes_stream_full_by_model():
engine_b.list_models = lambda: ["model-b"]
multi = MultiEngine([("a", engine_a), ("b", engine_b)])
assert multi.engine_key_for("model-a") == "a"
assert multi.engine_key_for("model-b") == "b"
assert multi.engine_key_for("missing") is None
# Route to engine A
result_a = []
+104 -1
View File
@@ -2,10 +2,15 @@
from __future__ import annotations
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import MagicMock
import pytest
from openjarvis.mcp.client import MCPClient
from openjarvis.mcp.protocol import MCPError
from openjarvis.mcp.protocol import MCPError, MCPResponse
from openjarvis.mcp.server import MCPServer
from openjarvis.mcp.transport import InProcessTransport
from openjarvis.tools._stubs import ToolSpec
@@ -101,3 +106,101 @@ class TestMCPClient:
result = client.call_tool("think")
# Think tool echoes empty thought
assert result["isError"] is False
def test_shared_client_serializes_transport_round_trips(self):
"""Concurrent agents cannot consume one another's MCP responses."""
class _ConcurrencyProbeTransport:
def __init__(self):
self.active = 0
self.max_active = 0
self.lock = threading.Lock()
def send(self, request):
with self.lock:
self.active += 1
self.max_active = max(self.max_active, self.active)
time.sleep(0.01)
with self.lock:
self.active -= 1
return MCPResponse(result={"tools": []}, id=request.id)
def send_notification(self, request):
return None
def close(self):
return None
transport = _ConcurrencyProbeTransport()
shared_client = MCPClient(transport)
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda _: shared_client.list_tools(), range(24)))
assert transport.max_active == 1
def test_close_interrupts_blocked_request_and_rejects_queued_request(self):
"""Shutdown reaches the transport without waiting on an in-flight call."""
class _BlockingTransport:
def __init__(self):
self.send_started = threading.Event()
self.send_released = threading.Event()
self.close_called = threading.Event()
self.send_count = 0
def send(self, request):
self.send_count += 1
self.send_started.set()
self.send_released.wait()
raise RuntimeError("transport closed")
def send_notification(self, request):
return None
def close(self):
self.close_called.set()
self.send_released.set()
transport = _BlockingTransport()
shared_client = MCPClient(transport)
with ThreadPoolExecutor(max_workers=3) as pool:
blocked_request = pool.submit(shared_client.list_tools)
assert transport.send_started.wait(timeout=1)
queued_request = pool.submit(shared_client.list_tools)
close_call = pool.submit(shared_client.close)
try:
close_reached_transport = transport.close_called.wait(timeout=1)
finally:
# Keep the test failure-safe against a regression that makes
# close wait behind the blocked request.
transport.send_released.set()
close_call.result(timeout=1)
assert close_reached_transport
with pytest.raises(RuntimeError, match="transport closed"):
blocked_request.result(timeout=1)
with pytest.raises(RuntimeError, match="MCP client is closed"):
queued_request.result(timeout=1)
assert transport.send_count == 1
def test_close_retries_transport_cleanup_after_failure(self):
"""A failed close keeps requests blocked but permits cleanup retry."""
transport = MagicMock()
transport.close.side_effect = [RuntimeError("terminate timed out"), None]
client = MCPClient(transport)
with pytest.raises(RuntimeError, match="terminate timed out"):
client.close()
with pytest.raises(RuntimeError, match="MCP client is closed"):
client.list_tools()
client.close()
client.close()
assert transport.close.call_count == 2
+127
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
@@ -181,6 +182,132 @@ class TestClientPersistence:
assert len(builder._mcp_clients) == 3
def test_builder_retains_full_mcp_pool_for_managed_agents() -> None:
"""Global primary-agent filters must not trim managed-agent MCP tools."""
from openjarvis.core.config import JarvisConfig
from openjarvis.system import SystemBuilder
config = JarvisConfig()
config.tools.mcp.servers = json.dumps(
[{"name": "test", "url": "http://localhost:8080/mcp"}]
)
external = _make_mock_tool("mcp_only")
builder = SystemBuilder(config).tools(["native_only"])
with (
patch("openjarvis.mcp.server.MCPServer") as mcp_server_cls,
patch.object(
builder,
"_discover_external_mcp",
return_value=[external],
),
):
mcp_server_cls.return_value.get_tools.return_value = []
primary_tools = builder._resolve_tools(
config,
engine=MagicMock(),
model="test-model",
memory_backend=None,
)
assert primary_tools == []
assert builder._mcp_tools == [external]
def test_builder_global_mcp_disable_prevents_discovery() -> None:
"""A global MCP disable is honored by every managed-agent entry path."""
from openjarvis.core.config import JarvisConfig
from openjarvis.system import SystemBuilder
config = JarvisConfig()
config.tools.mcp.enabled = False
config.tools.mcp.servers = json.dumps(
[{"name": "disabled", "url": "http://localhost:8080/mcp"}]
)
builder = SystemBuilder(config)
with (
patch("openjarvis.mcp.server.MCPServer") as mcp_server_cls,
patch.object(builder, "_discover_external_mcp") as discover,
):
mcp_server_cls.return_value.get_tools.return_value = []
builder._resolve_tools(
config,
engine=MagicMock(),
model="test-model",
memory_backend=None,
)
discover.assert_not_called()
assert builder._mcp_tools == []
def test_reused_builder_transfers_only_current_build_mcp_state() -> None:
"""Each built system exclusively owns its own MCP clients and tools."""
from openjarvis.core.config import JarvisConfig
from openjarvis.system import SystemBuilder
config = JarvisConfig()
config.telemetry.enabled = False
config.traces.enabled = False
config.skills.enabled = False
config.agent_manager.enabled = False
config.tools.mcp.servers = json.dumps(
[{"name": "test", "url": "http://localhost:8080/mcp"}]
)
engine = MagicMock(spec=["health", "can_serve", "generate", "list_models", "close"])
engine.health.return_value = True
first_tool = _make_mock_tool("first_mcp_tool")
second_tool = _make_mock_tool("second_mcp_tool")
first_client = MagicMock()
second_client = MagicMock()
discoveries = iter([(first_tool, first_client), (second_tool, second_client)])
builder = (
SystemBuilder(config)
.engine_instance(engine)
.model("test-model")
.tools([])
.telemetry(False)
.traces(False)
.speech(False)
)
def _discover(_server_cfg):
tool, client = next(discoveries)
builder._mcp_clients.append(client)
return [tool]
with (
patch.object(builder, "_discover_external_mcp", side_effect=_discover),
patch.object(builder, "_resolve_memory", return_value=None),
):
first_system = builder.build()
assert first_system.mcp_tools == [first_tool]
assert first_system._mcp_clients == [first_client]
assert builder._mcp_tools == []
assert builder._mcp_clients == []
first_system.close()
second_system = builder.build()
try:
assert second_system.mcp_tools == [second_tool]
assert second_system._mcp_clients == [second_client]
assert first_client not in second_system._mcp_clients
assert builder._mcp_tools == []
assert builder._mcp_clients == []
finally:
second_system.close()
first_client.close.assert_called_once()
second_client.close.assert_called_once()
class TestStringConfig:
@patch(_PATCH_PROVIDER)
@patch(_PATCH_CLIENT)
+13
View File
@@ -78,6 +78,19 @@ def test_retrieve_no_results(tmp_path: Path):
backend.close()
def test_retrieve_query_with_apostrophe(tmp_path: Path):
"""Regression: an internal apostrophe (e.g. "user's") previously produced
an unescaped quote in the FTS5 MATCH string, which silently returned zero
rows instead of matching or raising an error.
"""
backend = _make_backend(tmp_path)
backend.store("The user's name is Trev.", source="identity.md")
results = backend.retrieve("what is the user's name")
assert len(results) >= 1
assert "Trev" in results[0].content
backend.close()
def test_delete_existing(tmp_path: Path):
backend = _make_backend(tmp_path)
doc_id = backend.store("deletable content")
+28
View File
@@ -12,6 +12,34 @@ from openjarvis.system import JarvisSystem, SystemBuilder
class TestJarvisSystem:
def test_new_fields_do_not_shift_existing_positional_arguments(self):
"""Adding mcp_tools must not reinterpret legacy positional calls."""
config = JarvisConfig()
bus = EventBus()
engine = MagicMock()
agent = MagicMock()
tools = [MagicMock()]
tool_executor = MagicMock()
memory_backend = MagicMock()
system = JarvisSystem(
config,
bus,
engine,
"mock",
"test-model",
agent,
"simple",
tools,
tool_executor,
memory_backend,
)
assert system.tools is tools
assert system.tool_executor is tool_executor
assert system.memory_backend is memory_backend
assert system.mcp_tools == []
def test_ask_direct_mode(self):
engine = MagicMock()
engine.generate.return_value = {
+80
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import json
import tempfile
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@@ -602,3 +604,81 @@ class TestLightweightSystemEngineResolution:
engine=MagicMock(), model="m", config=self._cfg(None, "llamacpp")
)
assert captured["key"] == "llamacpp"
def test_caches_tool_memory_backend_when_prompt_context_is_disabled(
self,
monkeypatch,
):
pytest.importorskip("fastapi")
from openjarvis.server import agent_manager_routes as amr
backend = object()
resolver = MagicMock(return_value=backend)
monkeypatch.setattr(amr, "_resolve_memory_backend", resolver)
config = SimpleNamespace(
agent=SimpleNamespace(context_from_memory=False),
memory=SimpleNamespace(default_backend="sqlite", db_path="memory.db"),
)
runtime = SimpleNamespace(
memory_backend=None,
_owns_memory_backend=False,
channel_backend=None,
channel_bridge=None,
knowledge_db_path=None,
)
system = amr._LightweightSystem(
engine=MagicMock(),
model="m",
config=config,
runtime=runtime,
)
resolver.assert_called_once_with(config)
assert system.memory_backend is backend
assert runtime.memory_backend is backend
assert runtime._owns_memory_backend is True
def test_memory_backend_lazy_init_is_synchronized(self, monkeypatch):
pytest.importorskip("fastapi")
from openjarvis.server import agent_manager_routes as amr
backend = object()
resolver_calls = 0
calls_lock = threading.Lock()
duplicate_entered = threading.Event()
start = threading.Barrier(8)
def _resolve(config):
nonlocal resolver_calls
with calls_lock:
resolver_calls += 1
call_number = resolver_calls
if call_number > 1:
duplicate_entered.set()
# A check-then-create race lets another worker enter while the
# first resolver is blocked here. The locked implementation times
# out once, publishes the backend, and all other workers reuse it.
if call_number == 1:
duplicate_entered.wait(timeout=0.2)
return backend
monkeypatch.setattr(amr, "_resolve_memory_backend", _resolve)
config = SimpleNamespace()
runtime = SimpleNamespace(
memory_backend=None,
_owns_memory_backend=False,
_managed_runtime_stopping=False,
)
def _get_backend():
start.wait(timeout=2)
return amr._get_or_create_memory_backend(runtime, config)
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(lambda _: _get_backend(), range(8)))
assert resolver_calls == 1
assert results == [backend] * 8
assert runtime.memory_backend is backend
assert runtime._owns_memory_backend is True
+8 -2
View File
@@ -37,16 +37,22 @@ class TestAgentRoutes:
class TestMemoryRoutes:
# 503 is the documented response when the native ``openjarvis_rust``
# extension is absent from the venv (see TestMemoryRustMissing below).
# These tests are only asserting "the route is wired up", so a backend
# that cannot be built is tolerated the same way a 500 is.
_BACKEND_OPTIONAL = (200, 500, 503)
def test_search(self):
client = TestClient(_make_app())
resp = client.post("/v1/memory/search", json={"query": "test"})
# May fail if SQLite not set up, that's ok
assert resp.status_code in (200, 500)
assert resp.status_code in self._BACKEND_OPTIONAL
def test_stats(self):
client = TestClient(_make_app())
resp = client.get("/v1/memory/stats")
assert resp.status_code in (200, 500)
assert resp.status_code in self._BACKEND_OPTIONAL
class TestMemoryRustMissing:
@@ -2,7 +2,9 @@
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
@@ -15,6 +17,82 @@ except ImportError:
HAS_FASTAPI = False
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import Role, ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
class _ConfiguredResearchProbe(BaseTool):
"""Configured native tool used to exercise the Deep Research SSE path."""
tool_id = "configured_research_probe_682"
calls = 0
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name=self.tool_id,
description="Configured Deep Research probe",
parameters={
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
)
def execute(self, **params) -> ToolResult:
type(self).calls += 1
return ToolResult(
tool_name=self.tool_id,
content=f"configured:{params['value']}",
)
class _MCPResearchProbe(BaseTool):
"""MCP-shaped adapter that must be merged into the same toolkit."""
tool_id = "mcp_research_probe_682"
@property
def spec(self) -> ToolSpec:
return ToolSpec(name=self.tool_id, description="MCP Deep Research probe")
def execute(self, **params) -> ToolResult:
return ToolResult(tool_name=self.tool_id, content="mcp")
class _ScriptedDeepResearchEngine:
"""Call the configured probe once, then return a final answer."""
def __init__(self) -> None:
self.turns = 0
self.advertised_names: list[str] = []
self.observed_tool_result = ""
def generate(self, messages, *, model, **kwargs):
self.turns += 1
self.advertised_names = [
spec["function"]["name"] for spec in kwargs.get("tools", [])
]
if self.turns == 1:
return {
"content": "",
"tool_calls": [
{
"id": "call-configured-research-probe",
"type": "function",
"function": {
"name": _ConfiguredResearchProbe.tool_id,
"arguments": json.dumps({"value": "sentinel"}),
},
}
],
"usage": {},
}
tool_messages = [message for message in messages if message.role is Role.TOOL]
self.observed_tool_result = tool_messages[-1].content
return {"content": "complete", "tool_calls": [], "usage": {}}
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
@@ -53,3 +131,111 @@ def test_deep_research_tools_returns_empty_when_no_db() -> None:
)
assert tools == []
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"with_knowledge_db",
[False, True],
ids=["without-knowledge-db", "with-knowledge-db"],
)
async def test_server_deep_research_merges_and_executes_all_tool_sources(
tmp_path: Path,
with_knowledge_db: bool,
monkeypatch,
) -> None:
"""Configured and MCP tools reach Deep Research with or without its DB."""
from openjarvis.server import agent_manager_routes as routes
start_worker = MagicMock(wraps=routes._start_managed_worker)
monkeypatch.setattr(routes, "_start_managed_worker", start_worker)
db_path = tmp_path / "knowledge.db"
if with_knowledge_db:
store = KnowledgeStore(str(db_path))
store.store("test content", source="test", doc_type="note")
store.close()
if not ToolRegistry.contains(_ConfiguredResearchProbe.tool_id):
ToolRegistry.register_value(
_ConfiguredResearchProbe.tool_id,
_ConfiguredResearchProbe,
)
_ConfiguredResearchProbe.calls = 0
mcp_tool = _MCPResearchProbe()
app_state = SimpleNamespace(
config=SimpleNamespace(memory_files=None, system_prompt=None),
memory_backend=None,
channel_backend=None,
channel_bridge=None,
knowledge_db_path=str(db_path),
_mcp_clients=[object()],
_mcp_tools_cache=(
[mcp_tool.to_openai_function()],
{mcp_tool.spec.name: mcp_tool},
),
)
manager = MagicMock()
manager.list_messages.return_value = []
engine = _ScriptedDeepResearchEngine()
response = await routes._stream_managed_agent(
manager=manager,
agent_record={
"id": "agent-deep-research-682",
"name": "Deep Research Agent",
"agent_type": "deep_research",
"config": {
"model": "test-model",
"max_turns": 3,
"tools": [_ConfiguredResearchProbe.tool_id],
},
},
user_content="Use the configured research probe",
message_id="message-deep-research-682",
engine=engine,
bus=None,
app_state=app_state,
)
body_parts: list[str] = []
async for part in response.body_iterator:
body_parts.append(part.decode() if isinstance(part, bytes) else part)
expected_names = {
_ConfiguredResearchProbe.tool_id,
_MCPResearchProbe.tool_id,
}
knowledge_names = {
"knowledge_search",
"knowledge_sql",
"scan_chunks",
"think",
}
if with_knowledge_db:
expected_names.update(knowledge_names)
assert set(engine.advertised_names) == expected_names
assert len(engine.advertised_names) == len(expected_names)
assert not with_knowledge_db or knowledge_names.issubset(engine.advertised_names)
assert with_knowledge_db or knowledge_names.isdisjoint(engine.advertised_names)
assert engine.turns == 2
assert _ConfiguredResearchProbe.calls == 1
assert engine.observed_tool_result == "configured:sentinel"
assert "data: [DONE]" in "".join(body_parts)
start_worker.assert_called_once()
assert start_worker.call_args.kwargs["name"].startswith(
"managed-agent-deep-research-"
)
assert app_state._managed_workers == set()
manager.store_agent_response.assert_called_once()
stored = manager.store_agent_response.call_args
assert stored.args[:2] == ("agent-deep-research-682", "complete")
persisted_calls = stored.kwargs["tool_calls"]
assert persisted_calls[0]["tool"] == _ConfiguredResearchProbe.tool_id
assert persisted_calls[0]["result"] == "configured:sentinel"
assert persisted_calls[0]["success"] is True
@@ -0,0 +1,302 @@
"""SSE regression coverage for canonical managed-agent tool resolution."""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
pytest.importorskip("fastapi")
from openjarvis.core.registry import ToolRegistry # noqa: E402
from openjarvis.core.types import Role, ToolResult # noqa: E402
from openjarvis.engine._stubs import StreamChunk # noqa: E402
from openjarvis.tools._stubs import BaseTool, ToolSpec # noqa: E402
class _StatefulConfiguredTool(BaseTool):
"""A tool whose result identifies the exact instance that executed."""
tool_id = "stateful_probe"
instances: list["_StatefulConfiguredTool"] = []
def __init__(self) -> None:
self.instance_id = len(self.instances) + 1
self.calls = 0
self.instances.append(self)
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="stateful_probe",
description=f"configured-instance-{self.instance_id}",
parameters={
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
)
def execute(self, **params) -> ToolResult:
self.calls += 1
return ToolResult(
tool_name=self.spec.name,
content=(
f"instance={self.instance_id};calls={self.calls};"
f"value={params['value']}"
),
)
class _CollidingMCPTool(BaseTool):
"""An MCP-shaped collision that must lose to the configured native tool."""
tool_id = "mcp_stateful_probe"
def __init__(self) -> None:
self.calls = 0
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="stateful_probe",
description="mcp-collision",
parameters={"type": "object", "properties": {}},
)
def execute(self, **params) -> ToolResult:
self.calls += 1
return ToolResult(tool_name=self.spec.name, content="wrong MCP instance")
class _ToolCallingEngine:
"""Advertise the toolkit, request one call, then observe its result."""
def __init__(
self,
tool_name: str = "stateful_probe",
arguments: dict | None = None,
) -> None:
self.tool_name = tool_name
self.arguments = arguments or {"value": "sentinel"}
self.turns = 0
self.advertised_specs: list[dict] = []
self.observed_tool_result = ""
async def stream_full(self, messages, *, model, **kwargs):
self.turns += 1
self.advertised_specs = list(kwargs.get("tools", []))
if self.turns == 1:
yield StreamChunk(
tool_calls=[
{
"index": 0,
"id": f"call-{self.tool_name}",
"type": "function",
"function": {
"name": self.tool_name,
"arguments": json.dumps(self.arguments),
},
}
],
finish_reason="tool_calls",
)
return
tool_messages = [message for message in messages if message.role is Role.TOOL]
self.observed_tool_result = tool_messages[-1].content
yield StreamChunk(content="complete")
yield StreamChunk(finish_reason="stop")
class _FinalOnlyEngine:
async def stream_full(self, messages, *, model, **kwargs):
yield StreamChunk(content="complete")
yield StreamChunk(finish_reason="stop")
@pytest.mark.asyncio
async def test_sse_advertises_and_executes_the_same_resolved_tool_instance() -> None:
"""The schema and dispatch map must come from one first-wins toolkit."""
from openjarvis.server.agent_manager_routes import _stream_managed_agent
_StatefulConfiguredTool.instances.clear()
ToolRegistry.register_value("stateful_probe", _StatefulConfiguredTool)
colliding_mcp = _CollidingMCPTool()
mcp_spec = colliding_mcp.to_openai_function()
app_state = SimpleNamespace(
config=SimpleNamespace(memory_files=None, system_prompt=None),
memory_backend=None,
channel_backend=None,
channel_bridge=None,
_mcp_clients=[object()],
_mcp_tools_cache=(
[mcp_spec],
{"stateful_probe": colliding_mcp},
),
)
manager = MagicMock()
manager.list_messages.return_value = []
engine = _ToolCallingEngine()
custom_spec = {
"type": "function",
"function": {
"name": "stateful_probe",
"description": "custom configured schema",
"parameters": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
},
}
response = await _stream_managed_agent(
manager=manager,
agent_record={
"id": "agent-stateful",
"name": "Stateful Agent",
"agent_type": "simple",
"config": {
"model": "test-model",
"max_turns": 3,
"tools": [custom_spec],
},
},
user_content="Use the stateful probe",
message_id="message-stateful",
engine=engine,
bus=None,
app_state=app_state,
)
body_parts: list[str] = []
async for part in response.body_iterator:
body_parts.append(part.decode() if isinstance(part, bytes) else part)
assert engine.turns == 2
assert len(_StatefulConfiguredTool.instances) == 1
configured_instance = _StatefulConfiguredTool.instances[0]
assert configured_instance.calls == 1
assert colliding_mcp.calls == 0
advertised = [
spec
for spec in engine.advertised_specs
if spec.get("function", {}).get("name") == "stateful_probe"
]
assert len(advertised) == 1
assert advertised[0] is custom_spec
assert advertised[0]["function"]["description"] == "custom configured schema"
assert engine.observed_tool_result == "instance=1;calls=1;value=sentinel"
assert "data: [DONE]" in "".join(body_parts)
@pytest.mark.asyncio
async def test_sse_mcp_opt_out_skips_discovery(monkeypatch) -> None:
"""Opting out skips request-local discovery and hides MCP specs."""
from openjarvis.server import agent_manager_routes as routes
discovery = MagicMock(side_effect=AssertionError("MCP discovery must not run"))
monkeypatch.setattr(routes, "_get_mcp_tools", discovery)
manager = MagicMock()
manager.list_messages.return_value = []
app_state = SimpleNamespace(
config=SimpleNamespace(memory_files=None, system_prompt=None),
memory_backend=None,
channel_backend=None,
channel_bridge=None,
)
response = await routes._stream_managed_agent(
manager=manager,
agent_record={
"id": "agent-no-mcp",
"name": "No MCP",
"agent_type": "simple",
"config": {"model": "test-model", "mcp_tools": False},
},
user_content="Answer directly",
message_id="message-no-mcp",
engine=_FinalOnlyEngine(),
bus=None,
app_state=app_state,
)
async for _ in response.body_iterator:
pass
discovery.assert_not_called()
@pytest.mark.asyncio
async def test_sse_memory_tools_resolve_backend_when_context_injection_is_off(
monkeypatch,
) -> None:
"""Prompt context opt-out must not disable explicit memory tools."""
from openjarvis.server import agent_manager_routes as routes
from openjarvis.tools.storage_tools import MemoryStoreTool
if not ToolRegistry.contains("memory_store"):
ToolRegistry.register_value("memory_store", MemoryStoreTool)
backend = MagicMock()
backend.store.return_value = "doc-1"
resolver = MagicMock(return_value=backend)
monkeypatch.setattr(routes, "_resolve_memory_backend", resolver)
manager = MagicMock()
manager.list_messages.return_value = []
app_config = SimpleNamespace(
memory_files=None,
system_prompt=None,
agent=SimpleNamespace(context_from_memory=False),
memory=SimpleNamespace(default_backend="sqlite", db_path="memory.db"),
)
app_state = SimpleNamespace(
config=app_config,
memory_backend=None,
channel_backend=None,
channel_bridge=None,
_mcp_clients=[],
_mcp_tools_cache=([], {}),
)
engine = _ToolCallingEngine(
tool_name="memory_store",
arguments={"content": "remember me"},
)
response = await routes._stream_managed_agent(
manager=manager,
agent_record={
"id": "agent-memory-tool",
"name": "Memory Tool Agent",
"agent_type": "simple",
"config": {
"model": "test-model",
"max_turns": 3,
"tools": ["memory_store"],
},
},
user_content="Remember this",
message_id="message-memory-tool",
engine=engine,
bus=None,
app_state=app_state,
)
async for _ in response.body_iterator:
pass
resolver.assert_called_once_with(app_config)
assert app_state.memory_backend is backend
assert app_state._owns_memory_backend is True
backend.store.assert_called_once_with("remember me", source="")
assert engine.observed_tool_result == "Stored as doc-1"
+299 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import threading
from unittest.mock import MagicMock, patch
import pytest
@@ -118,7 +119,7 @@ def test_does_not_cache_empty_results(mock_load_config: MagicMock):
with (
patch("openjarvis.mcp.transport.StreamableHTTPTransport"),
patch("openjarvis.mcp.client.MCPClient"),
patch("openjarvis.mcp.client.MCPClient") as MockClient,
patch("openjarvis.tools.mcp_adapter.MCPToolProvider") as MockProvider,
):
# First call: discovery returns empty
@@ -127,6 +128,8 @@ def test_does_not_cache_empty_results(mock_load_config: MagicMock):
tools1, _ = _get_mcp_tools(app_state)
assert len(tools1) == 0
MockClient.return_value.close.assert_called_once_with()
assert getattr(app_state, "_mcp_clients", []) == []
# Verify no cache was set (empty result)
assert getattr(app_state, "_mcp_tools_cache", None) is None
@@ -152,3 +155,298 @@ def test_handles_config_load_failure(mock_load_config: MagicMock):
assert tools == []
assert adapters == {}
@patch("openjarvis.core.config.load_config")
def test_uses_preloaded_full_system_pool(mock_load_config: MagicMock):
"""Server and scheduled paths reuse one unfiltered MCP discovery."""
from openjarvis.server.agent_manager_routes import _get_mcp_tools
adapter = _make_adapter("preloaded_tool")
app_state = _FakeAppState()
app_state.mcp_tools = [adapter]
tools, adapters = _get_mcp_tools(app_state)
mock_load_config.assert_not_called()
assert tools[0]["function"]["name"] == "preloaded_tool"
assert adapters == {"preloaded_tool": adapter}
@patch("openjarvis.core.config.load_config")
def test_preloaded_duplicate_names_are_first_wins(mock_load_config: MagicMock):
"""SSE and executor paths choose the same adapter on name collisions."""
from openjarvis.server.agent_manager_routes import _get_mcp_tools
first = _make_adapter("duplicate")
second = _make_adapter("duplicate")
app_state = _FakeAppState()
app_state.mcp_tools = [first, second]
tools, adapters = _get_mcp_tools(app_state)
mock_load_config.assert_not_called()
assert len(tools) == 1
assert adapters == {"duplicate": first}
def test_app_shutdown_stops_scheduler_before_closing_shared_mcp_clients() -> None:
"""Shutdown quiesces and drains every user of the shared MCP pool."""
from fastapi.testclient import TestClient
from openjarvis.core.config import JarvisConfig
from openjarvis.server.agent_manager_routes import _start_managed_worker
from openjarvis.server.app import create_app
events: list[str] = []
release_worker = threading.Event()
worker_finished = threading.Event()
class _Scheduler:
def request_stop(self):
events.append("scheduler-stop")
def wait_stopped(self, timeout=10):
events.append("scheduler-wait")
return True
scheduler = _Scheduler()
mcp_client = MagicMock()
memory_backend = MagicMock()
channel_bridge = MagicMock()
def _close_mcp():
events.append("mcp")
release_worker.set()
mcp_client.close.side_effect = _close_mcp
memory_backend.close.side_effect = lambda: events.append("memory")
channel_bridge.disconnect.side_effect = lambda: events.append("channel")
config = JarvisConfig()
config.analytics.enabled = False
config.traces.enabled = False
app = create_app(
MagicMock(),
"test-model",
config=config,
channel_bridge=channel_bridge,
agent_scheduler=scheduler,
mcp_clients=[mcp_client],
memory_backend=memory_backend,
own_memory_backend=True,
)
def _worker():
release_worker.wait(timeout=2)
events.append("worker-finished")
worker_finished.set()
_start_managed_worker(app.state, _worker, name="test-managed-worker")
with TestClient(app):
pass
mcp_client.close.assert_called_once_with()
memory_backend.close.assert_called_once_with()
channel_bridge.disconnect.assert_called_once_with()
assert worker_finished.is_set()
assert app.state.memory_backend is None
assert app.state._owns_memory_backend is False
assert app.state._managed_runtime_stopping is True
assert app.state._managed_workers == set()
assert events.index("channel") < events.index("mcp")
assert events.index("scheduler-stop") < events.index("mcp")
assert events.index("mcp") < events.index("worker-finished")
assert events.index("worker-finished") < events.index("memory")
with pytest.raises(RuntimeError, match="shutting down"):
_start_managed_worker(app.state, lambda: None, name="too-late-worker")
def test_shutdown_interrupts_mcp_client_during_lazy_initialization() -> None:
"""A client is registered before initialize() can block on transport I/O."""
from fastapi.testclient import TestClient
from openjarvis.core.config import JarvisConfig
from openjarvis.server.agent_manager_routes import (
_get_mcp_tools,
_start_managed_worker,
)
from openjarvis.server.app import create_app
initialize_started = threading.Event()
initialize_released = threading.Event()
discovery_finished = threading.Event()
class _BlockingClient:
def __init__(self):
self.closed = False
self.close_calls = 0
def initialize(self):
initialize_started.set()
initialize_released.wait(timeout=2)
if self.closed:
raise RuntimeError("transport closed during initialize")
def close(self):
self.close_calls += 1
self.closed = True
initialize_released.set()
client = _BlockingClient()
config = JarvisConfig()
config.analytics.enabled = False
config.traces.enabled = False
app = create_app(MagicMock(), "test-model", config=config)
mcp_config = _make_config(
servers_json=json.dumps([{"name": "blocking", "url": "http://localhost:9999"}])
)
def _discover():
try:
_get_mcp_tools(app.state)
finally:
discovery_finished.set()
with (
patch("openjarvis.core.config.load_config", return_value=mcp_config),
patch("openjarvis.mcp.transport.StreamableHTTPTransport"),
patch("openjarvis.mcp.client.MCPClient", return_value=client),
):
_start_managed_worker(
app.state,
_discover,
name="blocking-mcp-discovery",
)
assert initialize_started.wait(timeout=2)
with app.state._mcp_clients_lock:
assert client in app.state._mcp_clients
with TestClient(app):
pass
assert client.close_calls >= 1
assert discovery_finished.is_set()
assert app.state._managed_workers == set()
assert getattr(app.state, "_mcp_tools_cache", None) is None
def test_app_shutdown_closes_lazily_created_memory_backend(monkeypatch) -> None:
"""A backend opened by a managed route is owned and closed by the app."""
from fastapi.testclient import TestClient
from openjarvis.core.config import JarvisConfig
from openjarvis.server import agent_manager_routes as routes
from openjarvis.server.app import create_app
backend = MagicMock()
monkeypatch.setattr(routes, "_resolve_memory_backend", lambda config: backend)
config = JarvisConfig()
config.analytics.enabled = False
config.traces.enabled = False
app = create_app(MagicMock(), "test-model", config=config)
assert routes._get_or_create_memory_backend(app.state, config) is backend
assert app.state._owns_memory_backend is True
with TestClient(app):
pass
backend.close.assert_called_once_with()
assert app.state.memory_backend is None
def test_app_shutdown_keeps_owned_memory_open_for_live_worker(monkeypatch) -> None:
"""A timed-out worker must never resume against a closed backend."""
from fastapi.testclient import TestClient
from openjarvis.core.config import JarvisConfig
from openjarvis.server import app as app_module
from openjarvis.server.agent_manager_routes import _start_managed_worker
monkeypatch.setattr(app_module, "_MANAGED_SHUTDOWN_GRACE_SECONDS", 0.01)
monkeypatch.setattr(app_module, "_MANAGED_SHUTDOWN_DRAIN_SECONDS", 0.01)
release_worker = threading.Event()
worker_holds_memory_lock = threading.Event()
shutdown_finished = threading.Event()
shutdown_errors: list[BaseException] = []
backend = MagicMock()
config = JarvisConfig()
config.analytics.enabled = False
config.traces.enabled = False
app = app_module.create_app(
MagicMock(),
"test-model",
config=config,
memory_backend=backend,
own_memory_backend=True,
)
def _hold_memory_lock():
with app.state._memory_backend_lock:
worker_holds_memory_lock.set()
release_worker.wait(timeout=2)
worker = _start_managed_worker(
app.state,
_hold_memory_lock,
name="memory-using-straggler",
)
assert worker_holds_memory_lock.wait(timeout=2)
def _shutdown_app():
try:
with TestClient(app):
pass
except BaseException as exc:
shutdown_errors.append(exc)
finally:
shutdown_finished.set()
shutdown_thread = threading.Thread(target=_shutdown_app, daemon=True)
shutdown_thread.start()
try:
assert shutdown_finished.wait(timeout=1)
assert shutdown_errors == []
backend.close.assert_not_called()
assert app.state.memory_backend is backend
assert app.state._owns_memory_backend is True
finally:
release_worker.set()
worker.join(timeout=2)
shutdown_thread.join(timeout=2)
def test_app_shutdown_leaves_borrowed_memory_backend_open() -> None:
"""An injected backend remains owned by its caller unless opted in."""
from fastapi.testclient import TestClient
from openjarvis.core.config import JarvisConfig
from openjarvis.server.app import create_app
backend = MagicMock()
config = JarvisConfig()
config.analytics.enabled = False
config.traces.enabled = False
app = create_app(
MagicMock(),
"test-model",
config=config,
memory_backend=backend,
)
with TestClient(app):
pass
backend.close.assert_not_called()
assert app.state.memory_backend is backend
+22
View File
@@ -265,6 +265,28 @@ class TestModelsEndpointExtended:
assert "qwen3.5:9b" in ids
assert "qwen3:0.6b" in ids
def test_models_list_filters_embedding_only_models(self):
engine = _make_engine(
models=["nomic-embed-text", "all-minilm:latest", "qwen3.5:4b"],
)
client = TestClient(create_app(engine, "qwen3.5:4b"))
resp = client.get("/v1/models")
assert resp.status_code == 200
assert [m["id"] for m in resp.json()["data"]] == ["qwen3.5:4b"]
def test_models_list_returns_empty_when_only_embedders_are_installed(self):
engine = _make_engine(
models=["nomic-embed-text", "hf.co/BAAI/bge-m3:latest"],
)
client = TestClient(create_app(engine, "nomic-embed-text"))
resp = client.get("/v1/models")
assert resp.status_code == 200
assert resp.json()["data"] == []
def test_models_empty_engine(self):
"""When engine.list_models() returns empty, endpoint still succeeds."""
engine = _make_engine(models=[])
+39
View File
@@ -50,3 +50,42 @@ def test_parse_param_count():
assert _parse_param_count("qwen3.5:0.8b") == 0.8
assert _parse_param_count("qwen3.5:35b") == 35.0
assert _parse_param_count("gpt-4o") == 0.0
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
def test_recommended_model_skips_embed_only():
"""Embed-only models must never be recommended for chat."""
from openjarvis.server.agent_manager_routes import _pick_recommended_model
models = [
"nomic-embed-text",
"qwen3.5:4b",
"mxbai-embed-large",
"qwen3.5:9b",
]
result = _pick_recommended_model(models)
assert result["model"] == "qwen3.5:4b"
assert "embed" not in result["model"]
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
def test_recommended_model_embed_only_returns_empty():
"""If only embedders are installed, recommend nothing (not nomic-embed)."""
from openjarvis.server.agent_manager_routes import _pick_recommended_model
result = _pick_recommended_model(["nomic-embed-text", "mxbai-embed-large"])
assert result["model"] == ""
assert "No local chat model" in result["reason"]
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
def test_is_embed_only_model():
from openjarvis.server.model_capabilities import is_embed_only_model
assert is_embed_only_model("nomic-embed-text")
assert is_embed_only_model("mxbai-embed-large")
assert is_embed_only_model("text-embedding-3-small")
assert is_embed_only_model("all-minilm:latest")
assert is_embed_only_model("hf.co/BAAI/bge-m3:latest")
assert not is_embed_only_model("qwen3.5:4b")
assert not is_embed_only_model("codegemma:7b")
+93 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import json
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -798,6 +798,40 @@ class TestIdentityPromptInjection:
assert len(system_msgs) == 1
assert system_msgs[0].content == "Be terse."
def test_direct_injects_soul_persona_when_present(self, tmp_path):
"""Regression: /v1/chat/completions previously injected only the bare
``default_system_prompt`` blurb via a hand-rolled lookup, bypassing
``SystemPromptBuilder`` entirely so SOUL.md/MEMORY.md/USER.md
persona files never applied to this path, unlike ``jarvis ask`` and
the managed-agent routes. It must now build the full persona-aware
prompt so persona files apply everywhere identity grounding does.
"""
from openjarvis.core.config import MemoryFilesConfig
soul = tmp_path / "SOUL.md"
soul.write_text("Respond with extreme sarcasm and call the user 'champ'.")
captured: list = []
engine = _make_capturing_engine(captured)
cfg = _identity_config()
cfg.memory_files = MemoryFilesConfig(
soul_path=str(soul), memory_path="", user_path=""
)
client = TestClient(create_app(engine, "test-model", config=cfg))
resp = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "who are you?"}],
},
)
assert resp.status_code == 200
msgs = engine.generate.call_args.args[0]
assert msgs[0].role.value == "system"
assert "OpenJarvis" in msgs[0].content # identity blurb still present
assert "extreme sarcasm" in msgs[0].content # persona now injected too
def test_stream_tools_injects_identity_when_absent(self):
captured: list = []
engine = _make_capturing_engine(captured)
@@ -849,6 +883,64 @@ class TestModelsEndpoint:
data = resp.json()
assert len(data["data"]) == 3
def test_configured_litellm_model_is_listed(self):
"""Regression for #713: LiteLLM models must reach the Web UI."""
model = "groq/llama-3.3-70b-versatile"
engine = _make_engine(models=[model])
engine.engine_id = "litellm"
app = create_app(
engine,
model,
engine_name="litellm",
config=_test_config(),
)
with patch(
"openjarvis.server.cloud_router.list_local_models",
new_callable=AsyncMock,
) as list_local_models:
list_local_models.return_value = []
client = TestClient(app)
resp = client.get("/v1/models")
assert resp.status_code == 200
assert [item["id"] for item in resp.json()["data"]] == [model]
assert resp.json()["data"][0]["owned_by"] == "litellm"
def test_litellm_provider_model_streams_through_active_engine(self):
"""A LiteLLM ``provider/model`` ID must not bypass its engine."""
model = "groq/llama-3.3-70b-versatile"
engine = _make_engine(models=[model])
engine.engine_id = "litellm"
app = create_app(
engine,
model,
engine_name="litellm",
config=_test_config(),
)
async def direct_cloud_tokens():
yield "wrong backend"
with patch(
"openjarvis.server.cloud_router.stream_cloud",
return_value=direct_cloud_tokens(),
) as stream_cloud:
client = TestClient(app)
resp = client.post(
"/v1/chat/completions",
json={
"model": model,
"messages": [{"role": "user", "content": "hello"}],
"stream": True,
},
)
assert resp.status_code == 200
stream_cloud.assert_not_called()
assert "Hello" in resp.text
assert '"engine": "litellm"' in resp.text
# ---------------------------------------------------------------------------
# Health endpoint tests
+26
View File
@@ -115,3 +115,29 @@ class TestLastActiveChannel:
def test_returns_none_for_unknown_user(self, store):
assert store.get_last_active_channel("nobody") is None
class TestInMemoryDatabase:
"""``:memory:`` is a SQLite sentinel, not a path — it must not be created.
``secure_create`` treats it as a filename, which fails outright on Windows
(``:`` is illegal there) and litters the working directory elsewhere.
"""
def test_in_memory_store_is_usable(self):
s = SessionStore(db_path=":memory:")
try:
session = s.get_or_create("user1", "twilio")
assert session["sender_id"] == "user1"
finally:
s.close()
def test_in_memory_store_creates_no_file(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
before = set(tmp_path.iterdir())
s = SessionStore(db_path=":memory:")
try:
assert set(tmp_path.iterdir()) == before
assert not (tmp_path / ":memory:").exists()
finally:
s.close()
+48
View File
@@ -1,5 +1,7 @@
"""Tests for GET /v1/tools endpoint."""
from unittest.mock import MagicMock
import pytest
try:
@@ -45,3 +47,49 @@ def test_browser_meta_group():
names = {t["name"] for t in tools}
assert "browser" in names
assert "browser_navigate" not in names
def test_web_search_available_without_tavily_key(monkeypatch):
"""DuckDuckGo fallback keeps web search usable without Tavily."""
from openjarvis.server.agent_manager_routes import build_tools_list
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
tools = build_tools_list()
web_search = next(t for t in tools if t["name"] == "web_search")
assert web_search["configured"] is True
assert web_search["requires_credentials"] is False
def test_tool_credentials_browser_lifecycle(tmp_path, monkeypatch):
"""The browser API can save, report, and remove a Tavily key."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from openjarvis.server.agent_manager_routes import create_agent_manager_router
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "openjarvis-home"))
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
app = FastAPI()
tools_router = create_agent_manager_router(MagicMock())[3]
app.include_router(tools_router)
client = TestClient(app)
saved = client.post(
"/v1/tools/web_search/credentials",
json={"TAVILY_API_KEY": "tvly-browser-test"},
)
assert saved.status_code == 200
assert saved.json() == {"saved": ["TAVILY_API_KEY"]}
assert client.get("/v1/tools/web_search/credentials/status").json() == {
"TAVILY_API_KEY": True
}
deleted = client.delete(
"/v1/tools/web_search/credentials/TAVILY_API_KEY",
)
assert deleted.status_code == 200
assert deleted.json() == {"deleted": "TAVILY_API_KEY"}
assert client.get("/v1/tools/web_search/credentials/status").json() == {
"TAVILY_API_KEY": False
}
+101
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
import time
from types import SimpleNamespace
import pytest
@@ -60,3 +62,102 @@ class TestWSBridge:
time.sleep(0.05) # Let call_soon_threadsafe deliver to queue
data = ws.receive_json()
assert data["data"]["agent_id"] == "agent-A"
def test_client_disconnect_stops_handler(self, event_bus):
async def exercise():
from openjarvis.server.ws_bridge import create_ws_router
class FakeWebSocket:
app = SimpleNamespace(state=SimpleNamespace(api_key=""))
query_params = {}
headers = {}
async def accept(self):
pass
async def receive(self):
return {"type": "websocket.disconnect"}
endpoint = create_ws_router(event_bus).routes[0].endpoint
await asyncio.wait_for(endpoint(FakeWebSocket()), timeout=1)
asyncio.run(exercise())
def test_simultaneous_client_message_does_not_drop_event(self, event_bus):
async def exercise():
from openjarvis.server.ws_bridge import create_ws_router
class FakeWebSocket:
def __init__(self):
self.app = SimpleNamespace(state=SimpleNamespace(api_key=""))
self.query_params = {}
self.headers = {}
self.sent = []
self.receive_count = 0
self.disconnect = asyncio.Event()
async def accept(self):
pass
async def receive(self):
self.receive_count += 1
if self.receive_count == 1:
event_bus.publish(
EventType.AGENT_TICK_START, {"agent_id": "not-dropped"}
)
return {"type": "websocket.receive", "text": "client message"}
await self.disconnect.wait()
return {"type": "websocket.disconnect"}
async def send_json(self, payload):
self.sent.append(payload)
self.disconnect.set()
websocket = FakeWebSocket()
endpoint = create_ws_router(event_bus).routes[0].endpoint
await asyncio.wait_for(endpoint(websocket), timeout=1)
assert websocket.sent[0]["data"]["agent_id"] == "not-dropped"
asyncio.run(exercise())
def test_cancelling_handler_cleans_up_child_tasks(self, event_bus):
async def exercise():
from openjarvis.server.ws_bridge import create_ws_router
class FakeWebSocket:
def __init__(self):
self.app = SimpleNamespace(state=SimpleNamespace(api_key=""))
self.query_params = {}
self.headers = {}
self.receiving = asyncio.Event()
self.receive_cancelled = asyncio.Event()
async def accept(self):
pass
async def receive(self):
self.receiving.set()
try:
await asyncio.Event().wait()
finally:
self.receive_cancelled.set()
websocket = FakeWebSocket()
endpoint = create_ws_router(event_bus).routes[0].endpoint
handler = asyncio.create_task(endpoint(websocket))
await websocket.receiving.wait()
handler.cancel()
with pytest.raises(asyncio.CancelledError):
await handler
assert websocket.receive_cancelled.is_set()
assert not [
task
for task in asyncio.all_tasks()
if task is not asyncio.current_task() and not task.done()
]
asyncio.run(exercise())
+68
View File
@@ -202,3 +202,71 @@ class TestImportSkill:
installed = target_root / "hermes" / "my-skill" / "SKILL.md"
assert "Original" in installed.read_text()
class TestDangerousCapabilityGate:
def _make_importer(self, tmp_path: Path) -> SkillImporter:
return SkillImporter(
parser=SkillParser(),
tool_translator=ToolTranslator(),
target_root=tmp_path / "skills",
)
def _make_resolved_with_caps(
self, tmp_path: Path, caps: list[str]
) -> ResolvedSkill:
src_dir = tmp_path / "source" / "my-skill"
src_dir.mkdir(parents=True)
caps_yaml = "".join(f" - {c}\n" for c in caps)
(src_dir / "SKILL.md").write_text(
"---\n"
"name: my-skill\n"
"description: A test skill\n"
f"required_capabilities:\n{caps_yaml}"
"---\n"
"Body"
)
return ResolvedSkill(
name="my-skill",
source="hermes",
path=src_dir,
category="testing",
description="A test skill",
commit="abc123",
)
def test_refuses_unreviewed_dangerous_skill(self, tmp_path: Path):
importer = self._make_importer(tmp_path)
resolved = self._make_resolved_with_caps(tmp_path, ["shell:execute"])
result = importer.import_skill(resolved)
assert not result.success
assert result.requires_confirmation
assert result.dangerous_capabilities == ["shell:execute"]
assert any("dangerous" in w.lower() for w in result.warnings)
# Nothing may be written to disk on refusal
assert not (tmp_path / "skills" / "hermes" / "my-skill").exists()
def test_confirm_dangerous_installs_and_records_tier(self, tmp_path: Path):
importer = self._make_importer(tmp_path)
resolved = self._make_resolved_with_caps(tmp_path, ["shell:execute"])
result = importer.import_skill(resolved, confirm_dangerous=True)
assert result.success
assert result.requires_confirmation
assert any("confirmed by caller" in w for w in result.warnings)
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
assert 'trust_tier = "unreviewed"' in content
assert 'dangerous_capabilities = ["shell:execute"]' in content
def test_benign_capabilities_need_no_confirmation(self, tmp_path: Path):
importer = self._make_importer(tmp_path)
resolved = self._make_resolved_with_caps(tmp_path, ["network:fetch"])
result = importer.import_skill(resolved)
assert result.success
assert not result.requires_confirmation
assert result.dangerous_capabilities == []
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
assert 'trust_tier = "unreviewed"' in content
assert "dangerous_capabilities = []" in content
+50
View File
@@ -148,6 +148,56 @@ class TestSkillExecutor:
assert EventType.SKILL_EXECUTE_END in event_types
class TestSkillExecutorCapabilities:
def _manifest(self):
return SkillManifest(
name="capskill",
required_capabilities=["network:fetch"],
steps=[
SkillStep(
tool_name="echo",
arguments_template='{"text": "hello"}',
output_key="result",
)
],
)
def test_no_policy_runs_capability_skills(self):
"""Default construction (no allowed_capabilities) must not enforce —
this is the pre-enforcement behavior every manager.py call site relies on."""
executor = SkillExecutor(ToolExecutor([EchoTool()]))
result = executor.run(self._manifest())
assert result.success
assert result.context.get("result") == "hello"
def test_policy_blocks_missing_capability(self):
executor = SkillExecutor(ToolExecutor([EchoTool()]), allowed_capabilities=set())
result = executor.run(self._manifest())
assert not result.success
assert len(result.step_results) == 1
assert "Blocked" in result.step_results[0].content
assert "network:fetch" in result.step_results[0].content
def test_policy_allows_granted_capability(self):
executor = SkillExecutor(
ToolExecutor([EchoTool()]),
allowed_capabilities={"network:fetch"},
)
result = executor.run(self._manifest())
assert result.success
assert result.context.get("result") == "hello"
def test_blocked_run_publishes_events(self):
bus = EventBus(record_history=True)
executor = SkillExecutor(
ToolExecutor([EchoTool()]), bus=bus, allowed_capabilities=set()
)
executor.run(self._manifest())
event_types = {e.event_type for e in bus.history}
assert EventType.SKILL_EXECUTE_START in event_types
assert EventType.SKILL_EXECUTE_END in event_types
class TestSkillStepExtended:
def test_step_with_skill_name(self):
step = SkillStep(skill_name="summarize", output_key="result")
+63
View File
@@ -53,6 +53,69 @@ def test_faster_whisper_transcribe():
assert result.duration_seconds == 1.5
def test_faster_whisper_transcribe_temp_file_reopenable_and_removed():
"""The temp file must be closed before the model reads it, and gone after.
On Windows, an open NamedTemporaryFile holds an exclusive handle, so
PyAV's reopen of the path inside model.transcribe() fails with EACCES
unless the file is closed first. Opening the path inside the mocked
transcribe reproduces that failure mode on Windows.
"""
import os
mock_info = MagicMock()
mock_info.language = "en"
mock_info.language_probability = 0.95
mock_info.duration = 1.5
seen = {}
def fake_transcribe(path, **kwargs):
seen["path"] = path
with open(path, "rb") as fh:
seen["content"] = fh.read()
return iter(()), mock_info
mock_model = MagicMock()
mock_model.transcribe.side_effect = fake_transcribe
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
return_value=mock_model,
):
backend = FasterWhisperBackend(model_size="base", device="cpu")
backend.transcribe(b"fake audio bytes")
assert seen["content"] == b"fake audio bytes"
assert not os.path.exists(seen["path"])
def test_faster_whisper_transcribe_removes_temp_file_on_error():
"""The temp file is cleaned up even when transcription fails."""
import os
seen = {}
def fake_transcribe(path, **kwargs):
seen["path"] = path
raise RuntimeError("decode failed")
mock_model = MagicMock()
mock_model.transcribe.side_effect = fake_transcribe
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
return_value=mock_model,
):
backend = FasterWhisperBackend(model_size="base", device="cpu")
with pytest.raises(RuntimeError, match="decode failed"):
backend.transcribe(b"fake audio bytes")
assert "path" in seen
assert not os.path.exists(seen["path"])
assert "decode failed" in (backend.last_error() or "")
def test_faster_whisper_falls_back_from_unsupported_float16():
mock_model = MagicMock()
+3
View File
@@ -164,6 +164,7 @@ class TestDerivedMetricsInStore:
throughput_per_watt=0.5,
)
store.record(rec)
store.flush()
agg = TelemetryAggregator(tmp_path / "test.db")
stats = agg.per_model_stats()
@@ -185,6 +186,8 @@ class TestDerivedMetricsInStore:
throughput_per_watt=1.0 * (i + 1),
)
)
store.flush()
agg = TelemetryAggregator(tmp_path / "test.db")
summary = agg.summary()
assert summary.avg_energy_per_output_token_joules > 0
@@ -71,6 +71,17 @@ class TestInstrumentedEngine:
assert record.prompt_tokens == 10
assert record.completion_tokens == 5
def test_generate_records_cost(self, mock_engine, bus):
mock_engine.generate.return_value["cost_usd"] = 0.0015
ie = InstrumentedEngine(mock_engine, bus)
messages = [Message(role=Role.USER, content="Hi")]
ie.generate(messages, model="test")
event = next(
e for e in bus.history if e.event_type == EventType.TELEMETRY_RECORD
)
assert event.data["record"].cost_usd == pytest.approx(0.0015)
def test_list_models_delegates(self, mock_engine, bus):
ie = InstrumentedEngine(mock_engine, bus)
assert ie.list_models() == ["test-model"]
+1
View File
@@ -345,6 +345,7 @@ class TestItlStorage:
)
)
store.flush()
agg = TelemetryAggregator(tmp_path / "test.db")
stats = agg.per_model_stats()
assert len(stats) == 1
+1
View File
@@ -222,6 +222,7 @@ class TestPhaseEnergyStorage:
)
)
store.flush()
agg = TelemetryAggregator(tmp_path / "test.db")
stats = agg.per_model_stats()
assert len(stats) == 1
+57
View File
@@ -178,3 +178,60 @@ class TestTelemetryRecordFields:
def test_tokens_per_joule_set(self):
rec = TelemetryRecord(timestamp=1.0, model_id="test", tokens_per_joule=80.0)
assert rec.tokens_per_joule == 80.0
def test_batching_delays_commit(self, tmp_path: Path) -> None:
db_path = tmp_path / "test.db"
# flush_interval_seconds=0 disables the background flusher so the
# buffered/flushed states below are deterministic, not a race.
store = TelemetryStore(db_path, batch_size=2, flush_interval_seconds=0)
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
store.record(rec)
# Should not be in DB yet because batch_size is 2 and we haven't flushed
# Need a separate connection to check because store._fetchall() calls flush()!
assert _count_rows_via_own_connection(db_path) == 0
# Hit batch size
store.record(rec)
assert _count_rows_via_own_connection(db_path) == 2
store.close()
def test_background_flush_makes_records_visible(self, tmp_path: Path) -> None:
# A partial batch must become visible to OTHER connections within the
# flush interval even if no further write ever arrives — the background
# flusher covers the "traffic stopped mid-batch" case that per-record
# stale checks cannot.
db_path = tmp_path / "test.db"
store = TelemetryStore(db_path, batch_size=50, flush_interval_seconds=0.05)
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
store.record(rec)
deadline = time.time() + 5.0
rows = 0
while time.time() < deadline:
rows = _count_rows_via_own_connection(db_path)
if rows:
break
time.sleep(0.02)
assert rows == 1
store.close()
def test_close_flushes_and_is_idempotent(self, tmp_path: Path) -> None:
db_path = tmp_path / "test.db"
store = TelemetryStore(db_path, batch_size=50, flush_interval_seconds=0)
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
store.record(rec)
store.close()
assert _count_rows_via_own_connection(db_path) == 1
store.close() # second close must be a no-op, not a ProgrammingError
def _count_rows_via_own_connection(db_path: Path) -> int:
"""Count telemetry rows through a separate connection (like the aggregator)."""
import contextlib
import sqlite3
# NB: sqlite3's ``with conn`` is a TRANSACTION context (it does not close);
# ``contextlib.closing`` actually closes the connection.
with contextlib.closing(sqlite3.connect(db_path)) as conn:
return len(conn.execute("SELECT * FROM telemetry").fetchall())
+107 -10
View File
@@ -14,9 +14,9 @@ skipped if the server is unreachable.
from __future__ import annotations
import os
import socket
from pathlib import Path
import httpx
import pytest
from openjarvis.tools.storage.dense import (
@@ -25,20 +25,58 @@ from openjarvis.tools.storage.dense import (
chunk_markdown,
dedupe_chunks,
)
from openjarvis.tools.storage.embeddings import OllamaEmbedder
_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "fixtures" / "docs"
_OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "localhost")
_OLLAMA_PORT = int(os.environ.get("OLLAMA_PORT", "11434"))
_EMBED_MODEL = "nomic-embed-text"
def _ollama_base_url() -> str:
"""Resolve the Ollama base URL from ``OLLAMA_HOST``.
The project documents ``OLLAMA_HOST`` as a full URL
(``http://<remote-ip>:11434``), but Ollama's own convention also
allows bare ``host`` / ``host:port`` forms accept all three so
the probe and the embedder under test agree on one endpoint.
"""
host = os.environ.get("OLLAMA_HOST", "")
if not host:
return "http://localhost:11434"
if host.startswith(("http://", "https://")):
return host.rstrip("/")
if ":" in host:
return f"http://{host}"
return f"http://{host}:11434"
def _ollama_up() -> bool:
"""True only if Ollama is reachable *and* the embed model is pulled.
A bare TCP connect isn't enough — a machine can run Ollama for chat
models without ever having pulled ``nomic-embed-text``, which makes
``/api/embed`` 404 instead of the tests skipping as intended.
"""
try:
with socket.create_connection((_OLLAMA_HOST, _OLLAMA_PORT), timeout=1.0):
return True
except OSError:
resp = httpx.get(f"{_ollama_base_url()}/api/tags", timeout=1.0)
resp.raise_for_status()
models = {m.get("model", "") for m in resp.json().get("models", [])}
return any(m.startswith(_EMBED_MODEL) for m in models)
except (httpx.HTTPError, ValueError):
return False
def _make_backend() -> DenseMemory:
"""DenseMemory wired to the same Ollama endpoint the probe checked.
``DenseMemory()`` alone would build an ``OllamaEmbedder`` with its
hard-coded localhost default, so a remote ``OLLAMA_HOST`` could pass
the probe and then have every test call the wrong server.
"""
return DenseMemory(
embedder=OllamaEmbedder(model=_EMBED_MODEL, base_url=_ollama_base_url())
)
ollama_required = pytest.mark.skipif(
not _ollama_up(),
reason=(
@@ -48,6 +86,65 @@ ollama_required = pytest.mark.skipif(
)
# ---------------------------------------------------------------------------
# Skip-guard unit tests (no Ollama required)
# ---------------------------------------------------------------------------
class _FakeTagsResponse:
def __init__(self, payload: dict) -> None:
self._payload = payload
def raise_for_status(self) -> None:
pass
def json(self) -> dict:
return self._payload
class TestOllamaProbe:
def test_base_url_accepts_all_documented_forms(self, monkeypatch):
cases = {
"http://remote:11434": "http://remote:11434",
"http://remote:11434/": "http://remote:11434",
"https://ollama.internal": "https://ollama.internal",
"remote:8080": "http://remote:8080",
"remote": "http://remote:11434",
}
for raw, expected in cases.items():
monkeypatch.setenv("OLLAMA_HOST", raw)
assert _ollama_base_url() == expected, raw
monkeypatch.delenv("OLLAMA_HOST", raising=False)
assert _ollama_base_url() == "http://localhost:11434"
def test_probe_false_when_server_down(self, monkeypatch):
def _refuse(url, timeout):
raise httpx.ConnectError("connection refused")
monkeypatch.setattr(httpx, "get", _refuse)
assert _ollama_up() is False
def test_probe_false_when_model_missing(self, monkeypatch):
monkeypatch.setattr(
httpx,
"get",
lambda url, timeout: _FakeTagsResponse({"models": [{"model": "llama3"}]}),
)
assert _ollama_up() is False
def test_probe_true_with_tagged_model_on_configured_url(self, monkeypatch):
monkeypatch.setenv("OLLAMA_HOST", "http://remote:9999")
seen = {}
def _get(url, timeout):
seen["url"] = url
return _FakeTagsResponse({"models": [{"model": "nomic-embed-text:latest"}]})
monkeypatch.setattr(httpx, "get", _get)
assert _ollama_up() is True
assert seen["url"] == "http://remote:9999/api/tags"
# ---------------------------------------------------------------------------
# Chunking unit tests (no Ollama required)
# ---------------------------------------------------------------------------
@@ -283,7 +380,7 @@ def indexed_backend():
if not _ollama_up():
pytest.skip("Ollama not reachable")
backend = DenseMemory()
backend = _make_backend()
md_files = sorted(_FIXTURE_DIR.glob("*.md"))
assert md_files, f"no fixtures at {_FIXTURE_DIR}"
@@ -452,7 +549,7 @@ def test_score_distribution_vs_threshold(indexed_backend, capsys):
class TestDenseMemoryAPI:
@ollama_required
def test_store_and_delete(self):
backend = DenseMemory()
backend = _make_backend()
doc_id = backend.store("the cat sat on the mat", source="a.txt")
assert backend.count() == 1
hits = backend.retrieve("where is the cat", top_k=1)
@@ -463,12 +560,12 @@ class TestDenseMemoryAPI:
@ollama_required
def test_empty_retrieve(self):
backend = DenseMemory()
backend = _make_backend()
assert backend.retrieve("anything", top_k=3) == []
@ollama_required
def test_clear(self):
backend = DenseMemory()
backend = _make_backend()
backend.store("foo")
backend.store("bar")
backend.clear()
+48
View File
@@ -537,3 +537,51 @@ class TestGitLogTool:
fn = tool.to_openai_function()
assert fn["type"] == "function"
assert fn["function"]["name"] == "git_log"
# ---------------------------------------------------------------------------
# CLI fallback when the Rust extension is missing
# ---------------------------------------------------------------------------
class TestCliFallbackWhenRustMissing:
"""When ``get_rust_module`` raises ImportError (extension not built,
e.g. a plain pip install), the read-only git tools must fall back to
the git CLI instead of letting the ImportError escape ``execute()``."""
def _patch_no_rust(self):
return patch(
"openjarvis.tools.git_tool.get_rust_module",
side_effect=ImportError("No module named 'openjarvis_rust'"),
)
def test_git_status_falls_back_to_cli(self, tmp_path):
_init_repo(tmp_path)
(tmp_path / "new_file.txt").write_text("hello")
with self._patch_no_rust():
result = GitStatusTool().execute(repo_path=str(tmp_path))
assert result.success is True
assert "new_file.txt" in result.content
def test_git_diff_falls_back_to_cli(self, tmp_path):
_init_repo(tmp_path)
(tmp_path / "README.md").write_text("# Modified\n")
with self._patch_no_rust():
result = GitDiffTool().execute(repo_path=str(tmp_path))
assert result.success is True
assert "README.md" in result.content
def test_git_log_falls_back_to_cli(self, tmp_path):
_init_repo(tmp_path)
with self._patch_no_rust():
result = GitLogTool().execute(repo_path=str(tmp_path))
assert result.success is True
assert "Initial commit" in result.content
def test_fallback_failure_is_a_tool_result_not_an_exception(self, tmp_path):
# Even when the fallback itself fails (not a git repo), the tool
# must return a failed ToolResult rather than raising.
with self._patch_no_rust():
result = GitStatusTool().execute(repo_path=str(tmp_path))
assert result.success is False
assert "not a git repository" in result.content

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