mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a97c64c67b | ||
|
|
64333651d1 | ||
|
|
9bee016c82 | ||
|
|
c9942961ad | ||
|
|
c3a7ffebff | ||
|
|
b3c57468ae | ||
|
|
ff69797135 | ||
|
|
465dba4b3f | ||
|
|
4f857b0abb | ||
|
|
20aa08ef04 | ||
|
|
9a63561db8 | ||
|
|
7959285ad8 | ||
|
|
26e1741059 | ||
|
|
2ed885eb11 | ||
|
|
07fcf35276 | ||
|
|
4efdb07dae | ||
|
|
7feda3dad3 | ||
|
|
f08ad574d3 | ||
|
|
1bfc25a860 | ||
|
|
063dd8ea75 | ||
|
|
6af9317556 | ||
|
|
410562409d | ||
|
|
9498adc7c4 | ||
|
|
ebf370595d | ||
|
|
bcdbf13d02 | ||
|
|
3dc621618f | ||
|
|
fd0b60fefc | ||
|
|
95a9857984 | ||
|
|
f9c89308fc | ||
|
|
65d08e9d94 | ||
|
|
45717780fa | ||
|
|
9da7c30880 | ||
|
|
98e791f258 | ||
|
|
b9e0928aef | ||
|
|
652a522e50 | ||
|
|
ce1a9ce133 | ||
|
|
ae45a4f67c | ||
|
|
697eed23d4 | ||
|
|
100595f8aa | ||
|
|
dd03a55028 | ||
|
|
a72218f99f | ||
|
|
eaa76032d5 | ||
|
|
ed01ab8c8d | ||
|
|
a65f663d2e | ||
|
|
c6382f2473 | ||
|
|
2922a2b154 | ||
|
|
403dec8e98 | ||
|
|
2c7cf6118c | ||
|
|
2bdd860b54 | ||
|
|
81f1ffbb4f | ||
|
|
93fc7b9e77 | ||
|
|
9fc5b875d1 | ||
|
|
08279e6b99 | ||
|
|
a7c31e89b4 | ||
|
|
04014c658a | ||
|
|
c1238d3e7e | ||
|
|
687e80a55a | ||
|
|
b90fd01af2 | ||
|
|
bbe7df7d33 | ||
|
|
9685b9b78f | ||
|
|
aa2d127de4 | ||
|
|
87f6238338 | ||
|
|
452bcc38cf | ||
|
|
b35a4c8113 | ||
|
|
f001e3b0ca | ||
|
|
b6dba93ae5 | ||
|
|
3000116d18 | ||
|
|
9db21d37ef | ||
|
|
95480363b7 | ||
|
|
4419b76412 | ||
|
|
99bbc2054a | ||
|
|
d5d8fddc94 | ||
|
|
cadb3e2ae6 | ||
|
|
7dc904c1b2 | ||
|
|
d9725fbb6a | ||
|
|
23f04264f9 | ||
|
|
8b59eb87e0 | ||
|
|
2e68e227b7 | ||
|
|
fc98614437 | ||
|
|
b1c5aba6fd | ||
|
|
6240c59ca3 | ||
|
|
9f3c7fd086 | ||
|
|
657c8dd26b | ||
|
|
4ef296e9d0 | ||
|
|
d5d06ca0e5 | ||
|
|
213ee4ff7e |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "143,305",
|
||||
"message": "190,252",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 143305,
|
||||
"last_updated": "2026-07-07T09:36:44Z",
|
||||
"total_clones": 190252,
|
||||
"last_updated": "2026-08-14T07:19:51Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -103,6 +103,44 @@
|
||||
"2026-07-03": 537,
|
||||
"2026-07-04": 411,
|
||||
"2026-07-05": 485,
|
||||
"2026-07-06": 555
|
||||
"2026-07-06": 555,
|
||||
"2026-07-07": 905,
|
||||
"2026-07-08": 1171,
|
||||
"2026-07-09": 1857,
|
||||
"2026-07-10": 1181,
|
||||
"2026-07-11": 2185,
|
||||
"2026-07-12": 1917,
|
||||
"2026-07-13": 2102,
|
||||
"2026-07-14": 2337,
|
||||
"2026-07-15": 2362,
|
||||
"2026-07-16": 2497,
|
||||
"2026-07-17": 1773,
|
||||
"2026-07-18": 1542,
|
||||
"2026-07-19": 1445,
|
||||
"2026-07-20": 1481,
|
||||
"2026-07-21": 1528,
|
||||
"2026-07-22": 1529,
|
||||
"2026-07-23": 1209,
|
||||
"2026-07-24": 1118,
|
||||
"2026-07-25": 928,
|
||||
"2026-07-26": 740,
|
||||
"2026-07-27": 799,
|
||||
"2026-07-28": 665,
|
||||
"2026-07-29": 745,
|
||||
"2026-07-30": 591,
|
||||
"2026-07-31": 783,
|
||||
"2026-08-01": 567,
|
||||
"2026-08-02": 1248,
|
||||
"2026-08-03": 724,
|
||||
"2026-08-04": 708,
|
||||
"2026-08-05": 647,
|
||||
"2026-08-06": 604,
|
||||
"2026-08-07": 624,
|
||||
"2026-08-08": 706,
|
||||
"2026-08-09": 1076,
|
||||
"2026-08-10": 1060,
|
||||
"2026-08-11": 2182,
|
||||
"2026-08-12": 641,
|
||||
"2026-08-13": 770
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,104 @@ jobs:
|
||||
# latest release tag (#526).
|
||||
fetch-depth: 0
|
||||
|
||||
# Validate Apple credentials BEFORE the expensive work. Notarization is
|
||||
# the very last thing `tauri-action` does, so a bad credential or a
|
||||
# lapsed account agreement previously surfaced ~10 minutes in — after the
|
||||
# Rust toolchain, npm install, two Ollama sidecar downloads and a
|
||||
# universal cargo build — as a single opaque line:
|
||||
#
|
||||
# failed to bundle project: failed codesign application: failed to
|
||||
# notarize app: Error: HTTP status code: 403. ...
|
||||
#
|
||||
# `notarytool history` is a read-only call (it submits nothing) that
|
||||
# exercises the identical auth path, so every credential/account failure
|
||||
# mode reaches us here first, in seconds, with the specific cause named.
|
||||
# `xcrun` is preinstalled on macOS runners, hence placement before the
|
||||
# toolchain steps rather than next to "Configure Apple signing".
|
||||
- name: Preflight Apple notarization credentials
|
||||
if: matrix.platform == 'macos-14'
|
||||
env:
|
||||
CERT: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
A_ID: ${{ secrets.APPLE_ID }}
|
||||
A_PASS: ${{ secrets.APPLE_PASSWORD }}
|
||||
A_TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
# Mirror the skip logic in "Configure Apple signing": without a
|
||||
# certificate the build is unsigned and never notarizes, so there is
|
||||
# nothing to preflight. Tag builds still hard-fail there.
|
||||
if [ -z "$CERT" ]; then
|
||||
echo "No Apple certificate configured; skipping notarization preflight."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
missing=""
|
||||
[ -z "$A_ID" ] && missing="$missing APPLE_ID"
|
||||
[ -z "$A_PASS" ] && missing="$missing APPLE_PASSWORD"
|
||||
[ -z "$A_TEAM" ] && missing="$missing APPLE_TEAM_ID"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::APPLE_CERTIFICATE is set but notarization secrets are missing:$missing"
|
||||
echo "::error::Signing would succeed and notarization would then fail. Set them or clear APPLE_CERTIFICATE."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Retry only to absorb transient network faults. Credential and
|
||||
# account errors are deterministic, so we classify and exit on the
|
||||
# first definitive answer rather than retrying into the same wall.
|
||||
attempt=1
|
||||
while [ "$attempt" -le 3 ]; do
|
||||
out=$(xcrun notarytool history \
|
||||
--apple-id "$A_ID" \
|
||||
--team-id "$A_TEAM" \
|
||||
--password "$A_PASS" \
|
||||
--output-format json 2>&1)
|
||||
rc=$?
|
||||
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "Apple notarization preflight OK — credentials valid, team reachable, agreements in effect."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$out" in
|
||||
*"Invalid credentials"*|*"401"*)
|
||||
echo "::error::Apple notarization preflight failed: invalid credentials (HTTP 401)."
|
||||
echo "::error::APPLE_PASSWORD must be an app-specific password from appleid.apple.com,"
|
||||
echo "::error::generated while signed in as the SAME Apple ID as APPLE_ID. A regular"
|
||||
echo "::error::Apple ID password will not work, and a password minted under a different"
|
||||
echo "::error::Apple ID authenticates as that other account."
|
||||
exit 1
|
||||
;;
|
||||
*"Invalid or inaccessible developer team ID"*)
|
||||
echo "::error::Apple notarization preflight failed: APPLE_ID is not a member of team APPLE_TEAM_ID (HTTP 403)."
|
||||
echo "::error::The Team ID must match the signing certificate. Read it from the cert's"
|
||||
echo "::error::subject, where it appears as: Developer ID Application: NAME (TEAMID)."
|
||||
echo "::error::If you belong to several teams, confirm APPLE_ID is a member of this one."
|
||||
exit 1
|
||||
;;
|
||||
*"required agreement"*|*"agreement"*)
|
||||
echo "::error::Apple notarization preflight failed: the team has no in-effect agreement (HTTP 403)."
|
||||
echo "::error::Apple reissues the Developer Program License Agreement periodically and"
|
||||
echo "::error::notarization is refused until it is accepted. ONLY THE ACCOUNT HOLDER can"
|
||||
echo "::error::accept it — team Admins cannot. Sign in to the account that owns this team:"
|
||||
echo "::error:: 1. https://developer.apple.com/account -> review any pending agreement"
|
||||
echo "::error:: 2. App Store Connect -> Business -> accept anything pending there too"
|
||||
echo "::error::Certificates stay valid while this is outstanding, so signing still works."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Preflight attempt ${attempt}/3 failed with a non-credential error."
|
||||
echo "$out" | tail -5
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -le 3 ] && sleep 10
|
||||
done
|
||||
|
||||
echo "::error::Apple notarization preflight failed after 3 attempts. Last output:"
|
||||
echo "$out" | tail -20
|
||||
exit 1
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -55,5 +55,5 @@ See how the OpenJarvis community saves money, energy, and compute by running AI
|
||||
<div id="leaderboard-pagination" class="lb-pagination"></div>
|
||||
|
||||
<p style="font-size:12px;opacity:0.6;margin-top:12px">
|
||||
*Dollar savings estimated vs. Claude Opus 4.6 API pricing ($5/1M input, $25/1M output tokens). Assumes local open-source models produce roughly the same number of tokens per request as cloud models.
|
||||
*Dollar savings estimated vs. Claude Fable 5 API pricing ($10/1M input, $50/1M output tokens). Assumes local open-source models produce roughly the same number of tokens per request as cloud models.
|
||||
</p>
|
||||
|
||||
@@ -31,6 +31,16 @@ uv sync --extra dev --extra eval-wandb # Weights & Biases run tracking
|
||||
uv sync --extra dev --extra eval-sheets # Google Sheets results export
|
||||
```
|
||||
|
||||
TauBench additionally requires Python 3.12 or newer and the upstream `tau2`
|
||||
package. Install the pinned revision explicitly before running that benchmark:
|
||||
|
||||
```bash
|
||||
uv pip install "tau2 @ git+https://github.com/sierra-research/tau2-bench.git@fc0055dc4e0a316c3f83133267fbd6faaa770992"
|
||||
```
|
||||
|
||||
OpenJarvis does not install third-party packages automatically when an
|
||||
evaluation is imported or run.
|
||||
|
||||
!!! note "Python version requirement"
|
||||
Python 3.10 requires the `tomli` package for TOML config parsing. `openjarvis` declares it as a conditional dependency, so it is installed automatically.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -15,6 +15,7 @@ function getGreeting(): string {
|
||||
}
|
||||
|
||||
export function ChatArea() {
|
||||
const activeId = useAppStore((s) => s.activeId);
|
||||
const messages = useAppStore((s) => s.messages);
|
||||
const streamState = useAppStore((s) => s.streamState);
|
||||
const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
|
||||
@@ -22,6 +23,10 @@ export function ChatArea() {
|
||||
const navigate = useNavigate();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const shouldAutoScroll = useRef(true);
|
||||
const wasStreaming = useRef(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
|
||||
const currentStreamContent = isCurrentChatStreaming ? streamState.content : '';
|
||||
|
||||
// Check if any data sources are connected
|
||||
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
|
||||
@@ -34,18 +39,37 @@ export function ChatArea() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Sending a message always pins the view to the bottom, even if the
|
||||
// user had scrolled up to read earlier messages.
|
||||
if (isCurrentChatStreaming && !wasStreaming.current) {
|
||||
shouldAutoScroll.current = true;
|
||||
}
|
||||
wasStreaming.current = isCurrentChatStreaming;
|
||||
if (shouldAutoScroll.current && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamState.content]);
|
||||
}, [messages, currentStreamContent, isCurrentChatStreaming]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
const distance = scrollHeight - scrollTop - clientHeight;
|
||||
const scrolledUp = scrollTop < lastScrollTop.current;
|
||||
lastScrollTop.current = scrollTop;
|
||||
if (scrolledUp && distance >= 1) {
|
||||
// Any upward scroll away from the bottom stops autoscroll immediately,
|
||||
// so streaming content never fights the user (no jitter). Sub-1px
|
||||
// upward movement (elastic bounce settling at the bottom) is ignored.
|
||||
shouldAutoScroll.current = false;
|
||||
} else if (!scrolledUp) {
|
||||
// Re-engage when scrolled back to the bottom. < 2 rather than < 1:
|
||||
// at fractional zoom levels the at-bottom residual can reach 1px,
|
||||
// which would otherwise leave autoscroll permanently disengaged.
|
||||
shouldAutoScroll.current = distance < 2;
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = messages.length === 0 && !streamState.isStreaming;
|
||||
const isEmpty = messages.length === 0 && !isCurrentChatStreaming;
|
||||
|
||||
const PanelIcon = systemPanelOpen ? PanelRightClose : PanelRightOpen;
|
||||
|
||||
@@ -153,12 +177,12 @@ export function ChatArea() {
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isLive={isLastAssistant && streamState.isStreaming}
|
||||
isLive={isLastAssistant && isCurrentChatStreaming}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
if (!streamState.isStreaming || streamState.content !== '') return null;
|
||||
if (!isCurrentChatStreaming || streamState.content !== '') return null;
|
||||
// For research messages the ResearchTimeline handles its own
|
||||
// pre-content loading state — suppress the generic dots.
|
||||
const last = messages[messages.length - 1];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAppStore, generateId } from '../../lib/store';
|
||||
import { streamChat, streamResearch } from '../../lib/sse';
|
||||
import { fetchSavings, getBase } from '../../lib/api';
|
||||
import { listConnectors, getSyncStatus } from '../../lib/connectors-api';
|
||||
import { serializeToolCallArguments } from '../../lib/tool-call';
|
||||
import { MicButton } from './MicButton';
|
||||
import { useSpeech } from '../../hooks/useSpeech';
|
||||
import type {
|
||||
@@ -96,6 +97,7 @@ export function InputArea() {
|
||||
const deepResearch = useAppStore((s) => s.deepResearch);
|
||||
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
|
||||
const corpusSync = useResearchCorpusSync(deepResearch);
|
||||
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
|
||||
|
||||
const {
|
||||
state: speechState,
|
||||
@@ -226,6 +228,7 @@ export function InputArea() {
|
||||
let ttftMs: number | undefined;
|
||||
|
||||
setStreamState({
|
||||
conversationId: convId,
|
||||
isStreaming: true,
|
||||
phase: deepResearch ? 'Researching...' : 'Generating...',
|
||||
elapsedMs: 0,
|
||||
@@ -387,7 +390,7 @@ export function InputArea() {
|
||||
const tc: ToolCallInfo = {
|
||||
id: generateId(),
|
||||
tool: data.tool,
|
||||
arguments: data.arguments || '',
|
||||
arguments: serializeToolCallArguments(data.arguments),
|
||||
status: 'running',
|
||||
};
|
||||
toolCalls.push(tc);
|
||||
@@ -398,7 +401,7 @@ export function InputArea() {
|
||||
updateLastAssistant(convId, accumulatedContent, [...toolCalls]);
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(), level: 'info', category: 'tool',
|
||||
message: `Calling ${data.tool}(${data.arguments || ''})`,
|
||||
message: `Calling ${data.tool}(${serializeToolCallArguments(data.arguments)})`,
|
||||
});
|
||||
} catch {}
|
||||
} else if (eventName === 'tool_call_end') {
|
||||
@@ -466,7 +469,10 @@ export function InputArea() {
|
||||
}
|
||||
const totalMs = Date.now() - startTime;
|
||||
const _CLOUD_PREFIXES = ['gpt-', 'o1-', 'o3-', 'o4-', 'claude-', 'gemini-', 'openrouter/', 'MiniMax-', 'chatgpt-'];
|
||||
const engineLabel = _CLOUD_PREFIXES.some(p => selectedModel.startsWith(p)) ? 'cloud' : 'ollama';
|
||||
const selectedOwner = useAppStore.getState().models.find((m) => m.id === selectedModel)?.owned_by;
|
||||
const engineLabel = selectedOwner === 'litellm'
|
||||
? 'litellm'
|
||||
: _CLOUD_PREFIXES.some(p => selectedModel.startsWith(p)) ? 'cloud' : 'ollama';
|
||||
const telemetry: MessageTelemetry = {
|
||||
engine: engineLabel,
|
||||
model_id: selectedModel,
|
||||
@@ -599,7 +605,7 @@ export function InputArea() {
|
||||
style={{ color: 'var(--color-text)', maxHeight: '200px' }}
|
||||
disabled={streamState.isStreaming || modelLoading}
|
||||
/>
|
||||
{streamState.isStreaming ? (
|
||||
{isCurrentChatStreaming ? (
|
||||
<button
|
||||
onClick={stopStreaming}
|
||||
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer"
|
||||
@@ -618,7 +624,7 @@ export function InputArea() {
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || modelLoading || !selectedModel}
|
||||
disabled={streamState.isStreaming || !input.trim() || modelLoading || !selectedModel}
|
||||
title={selectedModel ? 'Send message' : 'Pick a model first (⌘K)'}
|
||||
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
style={{
|
||||
|
||||
@@ -29,8 +29,8 @@ interface TelemetryStats {
|
||||
}
|
||||
|
||||
const CLOUD_PRICING = [
|
||||
{ name: 'GPT-5.3', input: 2.00, output: 10.00, primary: true },
|
||||
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00, primary: false },
|
||||
{ name: 'GPT-5.6 Sol', input: 5.00, output: 30.00, primary: true },
|
||||
{ name: 'Claude Fable 5', input: 10.00, output: 50.00, primary: false },
|
||||
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00, primary: false },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import type { ToolCallInfo } from '../../types';
|
||||
import { serializeToolCallArguments } from '../../lib/tool-call';
|
||||
|
||||
interface Props {
|
||||
toolCall: ToolCallInfo;
|
||||
@@ -35,7 +36,10 @@ export function ToolCallCard({ toolCall }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const config = statusConfig[toolCall.status];
|
||||
const StatusIcon = config.icon;
|
||||
const preview = previewArgs(toolCall.arguments);
|
||||
// Persisted conversations may contain the pre-fix object payload despite
|
||||
// the TypeScript contract, so normalize again at the final render boundary.
|
||||
const argumentsText = serializeToolCallArguments(toolCall.arguments);
|
||||
const preview = previewArgs(argumentsText);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -95,7 +99,7 @@ export function ToolCallCard({ toolCall }: Props) {
|
||||
className="px-2.5 pb-2 pt-0.5"
|
||||
style={{ borderTop: '1px solid var(--color-border-subtle, var(--color-border))' }}
|
||||
>
|
||||
{toolCall.arguments && (
|
||||
{argumentsText && (
|
||||
<div className="mt-1.5">
|
||||
<div
|
||||
style={{
|
||||
@@ -120,7 +124,7 @@ export function ToolCallCard({ toolCall }: Props) {
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{formatJson(toolCall.arguments)}
|
||||
{formatJson(argumentsText)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -143,18 +143,17 @@ export function CommandPalette() {
|
||||
}
|
||||
}, [pullSuccess]);
|
||||
|
||||
const handleSelect = async (modelId: string) => {
|
||||
const handleSelect = async (modelId: string, owner?: string) => {
|
||||
const previousModel = selectedModel;
|
||||
setSelectedModel(modelId);
|
||||
setCommandPaletteOpen(false);
|
||||
|
||||
if (modelId !== previousModel) {
|
||||
const { createConversation, setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
createConversation(modelId);
|
||||
const { setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
setModelLoading(true);
|
||||
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `Switching to ${modelId}...` });
|
||||
try {
|
||||
await preloadModel(modelId);
|
||||
await preloadModel(modelId, owner);
|
||||
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `${modelId} loaded` });
|
||||
} catch (e: any) {
|
||||
addLogEntry({ timestamp: Date.now(), level: 'error', category: 'model', message: `Failed to load ${modelId}: ${e.message}` });
|
||||
@@ -256,7 +255,8 @@ export function CommandPalette() {
|
||||
setSelectedIdx((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && tab === 'installed' && filtered.length > 0) {
|
||||
e.preventDefault();
|
||||
handleSelect((filtered[selectedIdx] as any).id);
|
||||
const model = filtered[selectedIdx] as (typeof models)[number];
|
||||
handleSelect(model.id, model.owned_by);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -366,11 +366,15 @@ export function CommandPalette() {
|
||||
onMouseEnter={() => setSelectedIdx(idx)}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSelect(model.id)}
|
||||
onClick={() => handleSelect(model.id, model.owned_by)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left cursor-pointer"
|
||||
style={{ background: 'none', border: 'none', padding: 0 }}
|
||||
>
|
||||
<Cpu size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
|
||||
{model.owned_by === 'litellm' ? (
|
||||
<Cloud size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
|
||||
) : (
|
||||
<Cpu size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate" style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text)', fontWeight: isActive ? 500 : 400 }}>
|
||||
{model.id}
|
||||
@@ -382,17 +386,19 @@ export function CommandPalette() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(model.id)}
|
||||
disabled={isDeleting}
|
||||
className="p-1 rounded transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--color-text-tertiary)', opacity: 0 }}
|
||||
title="Delete model"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = 'var(--color-error)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; e.currentTarget.style.color = 'var(--color-text-tertiary)'; }}
|
||||
>
|
||||
{isDeleting ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
|
||||
</button>
|
||||
{model.owned_by !== 'litellm' && (
|
||||
<button
|
||||
onClick={() => handleDelete(model.id)}
|
||||
disabled={isDeleting}
|
||||
className="p-1 rounded transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--color-text-tertiary)', opacity: 0 }}
|
||||
title="Delete model"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = 'var(--color-error)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; e.currentTarget.style.color = 'var(--color-text-tertiary)'; }}
|
||||
>
|
||||
{isDeleting ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@ import { DollarSign, TrendingDown, Cloud, HardDrive } from 'lucide-react';
|
||||
import { useAppStore } from '../../lib/store';
|
||||
|
||||
const CLOUD_PRICING = [
|
||||
{ name: 'GPT-5.3', input: 2.00, output: 10.00 },
|
||||
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00 },
|
||||
{ name: 'GPT-5.6 Sol', input: 5.00, output: 30.00 },
|
||||
{ name: 'Claude Fable 5', input: 10.00, output: 50.00 },
|
||||
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00 },
|
||||
];
|
||||
|
||||
|
||||
@@ -222,8 +222,8 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
};
|
||||
|
||||
const PROVIDER_COLORS: Record<string, string> = {
|
||||
'gpt-5.3': colors.green,
|
||||
'claude-opus-4.6': colors.yellow,
|
||||
'gpt-5.6-sol': colors.green,
|
||||
'claude-fable-5': colors.yellow,
|
||||
'gemini-3.1-pro': colors.accent,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type SetupStatus,
|
||||
} from '../lib/api';
|
||||
import { useAppStore } from '../lib/store';
|
||||
import { isEmbedOnlyModel } from '../lib/model-capabilities';
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'ollama_ready', label: 'Inference Engine', icon: Cpu, detail: 'Starting Ollama...' },
|
||||
@@ -91,12 +92,14 @@ export function SetupScreen({ onReady }: { onReady: () => void }) {
|
||||
fetchRecommendedModel().catch(() => ({ model: '', reason: '' })),
|
||||
]);
|
||||
const store = useAppStore.getState();
|
||||
const hadSelection = !!store.selectedModel;
|
||||
store.setModels(models);
|
||||
store.setModelsLoading(false);
|
||||
const recommended = rec.model && models.some((m) => m.id === rec.model)
|
||||
const chatModels = models.filter((m) => !isEmbedOnlyModel(m.id));
|
||||
const recommended = rec.model && chatModels.some((m) => m.id === rec.model)
|
||||
? rec.model
|
||||
: models[0]?.id || '';
|
||||
if (recommended && !store.selectedModel) {
|
||||
: chatModels[0]?.id || '';
|
||||
if (recommended && !hadSelection) {
|
||||
store.setSelectedModel(recommended);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -22,6 +22,9 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const conversations = useAppStore((s) => s.conversations);
|
||||
const activeId = useAppStore((s) => s.activeId);
|
||||
const streamingConversationId = useAppStore((s) =>
|
||||
s.streamState.isStreaming ? s.streamState.conversationId : null,
|
||||
);
|
||||
const selectConversation = useAppStore((s) => s.selectConversation);
|
||||
const deleteConversation = useAppStore((s) => s.deleteConversation);
|
||||
|
||||
@@ -43,6 +46,7 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
<div className="flex flex-col gap-0.5 py-1">
|
||||
{filtered.map((conv) => {
|
||||
const isActive = conv.id === activeId;
|
||||
const isStreaming = conv.id === streamingConversationId;
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
@@ -82,11 +86,18 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
e.stopPropagation();
|
||||
deleteConversation(conv.id);
|
||||
}}
|
||||
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
disabled={isStreaming}
|
||||
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-30"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-error)')}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isStreaming) e.currentTarget.style.color = 'var(--color-error)';
|
||||
}}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
|
||||
title="Delete conversation"
|
||||
title={
|
||||
isStreaming
|
||||
? 'Stop generating before deleting this conversation'
|
||||
: 'Delete conversation'
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// authHeaders) that source the key and build the header.
|
||||
|
||||
const SETTINGS_KEY = 'openjarvis-settings';
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
// Minimal in-memory localStorage stub so the helpers can run under node
|
||||
// (no jsdom dependency).
|
||||
@@ -28,6 +29,8 @@ class MemoryStorage {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
|
||||
fetchMock.mockReset();
|
||||
globalThis.fetch = fetchMock;
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
@@ -86,3 +89,50 @@ describe('authHeaders', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool credentials', () => {
|
||||
it('reads credential status from the local server', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ TAVILY_API_KEY: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const { fetchToolCredentialStatus } = await freshApi();
|
||||
|
||||
await expect(fetchToolCredentialStatus('web_search')).resolves.toEqual({
|
||||
TAVILY_API_KEY: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/tools/web_search/credentials/status',
|
||||
{ headers: {} },
|
||||
);
|
||||
});
|
||||
|
||||
it('saves a tool credential through the local server', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const { saveToolCredentials } = await freshApi();
|
||||
|
||||
await saveToolCredentials('web_search', {
|
||||
TAVILY_API_KEY: 'tvly-test',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/tools/web_search/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ TAVILY_API_KEY: 'tvly-test' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a tool credential through the local server', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const { deleteToolCredential } = await freshApi();
|
||||
|
||||
await deleteToolCredential('web_search', 'TAVILY_API_KEY');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/tools/web_search/credentials/TAVILY_API_KEY',
|
||||
{ method: 'DELETE', headers: {} },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+23
-3
@@ -1,5 +1,6 @@
|
||||
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
|
||||
import { SUPABASE_ANON_KEY, SUPABASE_URL } from './supabase';
|
||||
import { serializeToolCallArguments } from './tool-call';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supabase config
|
||||
@@ -218,9 +219,9 @@ export async function deleteModel(modelName: string): Promise<void> {
|
||||
|
||||
const _CLOUD_PREFIXES = ['gpt-', 'o1-', 'o3-', 'o4-', 'claude-', 'gemini-', 'openrouter/'];
|
||||
|
||||
export async function preloadModel(modelName: string): Promise<void> {
|
||||
export async function preloadModel(modelName: string, owner?: string): Promise<void> {
|
||||
// Cloud models don't need Ollama preloading
|
||||
if (_CLOUD_PREFIXES.some(p => modelName.startsWith(p))) {
|
||||
if (owner === 'litellm' || _CLOUD_PREFIXES.some(p => modelName.startsWith(p))) {
|
||||
return;
|
||||
}
|
||||
// Trigger Ollama to load the model into memory (empty prompt, no generation).
|
||||
@@ -741,7 +742,7 @@ export async function sendAgentMessage(
|
||||
const parsed = JSON.parse(data);
|
||||
callbacks?.onToolCallStart?.({
|
||||
tool: parsed.tool,
|
||||
arguments: parsed.arguments ?? '',
|
||||
arguments: serializeToolCallArguments(parsed.arguments),
|
||||
});
|
||||
} catch {
|
||||
/* skip */
|
||||
@@ -885,6 +886,25 @@ export async function saveToolCredentials(
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function fetchToolCredentialStatus(
|
||||
toolName: string,
|
||||
): Promise<Record<string, boolean>> {
|
||||
const res = await apiFetch(`/v1/tools/${toolName}/credentials/status`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function deleteToolCredential(
|
||||
toolName: string,
|
||||
keyName: string,
|
||||
): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`/v1/tools/${encodeURIComponent(toolName)}/credentials/${encodeURIComponent(keyName)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export interface AgentTraceDetail {
|
||||
id: string;
|
||||
agent: string;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isEmbedOnlyModel } from './model-capabilities';
|
||||
|
||||
describe('isEmbedOnlyModel', () => {
|
||||
it.each([
|
||||
'nomic-embed-text',
|
||||
'mxbai-embed-large',
|
||||
'text-embedding-3-small',
|
||||
'all-minilm:latest',
|
||||
'hf.co/BAAI/bge-m3:latest',
|
||||
])('classifies %s as embedding-only', (modelId) => {
|
||||
expect(isEmbedOnlyModel(modelId)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['qwen3.5:4b', 'codegemma:7b'])('keeps %s available for chat', (modelId) => {
|
||||
expect(isEmbedOnlyModel(modelId)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ModelInfo } from '../types';
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const model = (id: string): ModelInfo => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: 0,
|
||||
owned_by: 'openjarvis',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
describe('setModels', () => {
|
||||
it('does not select an embedding-only model', async () => {
|
||||
const { useAppStore } = await import('./store');
|
||||
|
||||
useAppStore.getState().setModels([model('nomic-embed-text')]);
|
||||
|
||||
expect(useAppStore.getState().selectedModel).toBe('');
|
||||
});
|
||||
|
||||
it('clears a missing selection when no chat fallback exists', async () => {
|
||||
const { useAppStore } = await import('./store');
|
||||
useAppStore.getState().setSelectedModel('deleted-chat-model');
|
||||
|
||||
useAppStore.getState().setModels([model('nomic-embed-text')]);
|
||||
|
||||
expect(useAppStore.getState().selectedModel).toBe('');
|
||||
});
|
||||
|
||||
it('replaces an embedding selection with an available chat model', async () => {
|
||||
const { useAppStore } = await import('./store');
|
||||
useAppStore.getState().setSelectedModel('all-minilm:latest');
|
||||
|
||||
useAppStore.getState().setModels([
|
||||
model('all-minilm:latest'),
|
||||
model('qwen3.5:4b'),
|
||||
]);
|
||||
|
||||
expect(useAppStore.getState().selectedModel).toBe('qwen3.5:4b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
async function freshStore() {
|
||||
return (await import('./store')).useAppStore;
|
||||
}
|
||||
|
||||
describe('conversation stream ownership', () => {
|
||||
it('persists background stream updates without replacing the active messages', async () => {
|
||||
const store = await freshStore();
|
||||
const sourceId = store.getState().createConversation('test-model');
|
||||
store.getState().addMessage(sourceId, {
|
||||
id: 'assistant',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
});
|
||||
|
||||
const activeId = store.getState().createConversation('test-model');
|
||||
store.getState().setStreamState({
|
||||
conversationId: sourceId,
|
||||
isStreaming: true,
|
||||
content: 'streamed response',
|
||||
});
|
||||
store.getState().updateLastAssistant(sourceId, 'streamed response');
|
||||
|
||||
expect(store.getState().activeId).toBe(activeId);
|
||||
expect(store.getState().messages).toEqual([]);
|
||||
|
||||
store.getState().selectConversation(sourceId);
|
||||
expect(store.getState().messages).toHaveLength(1);
|
||||
expect(store.getState().messages[0].content).toBe('streamed response');
|
||||
});
|
||||
|
||||
it('keeps the stream-owning conversation until generation stops', async () => {
|
||||
const store = await freshStore();
|
||||
const sourceId = store.getState().createConversation('test-model');
|
||||
const activeId = store.getState().createConversation('test-model');
|
||||
store.getState().setStreamState({
|
||||
conversationId: sourceId,
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
store.getState().deleteConversation(sourceId);
|
||||
expect(
|
||||
store.getState().conversations.map((conversation) => conversation.id),
|
||||
).toContain(sourceId);
|
||||
expect(store.getState().activeId).toBe(activeId);
|
||||
|
||||
store.getState().resetStream();
|
||||
store.getState().deleteConversation(sourceId);
|
||||
expect(
|
||||
store.getState().conversations.map((conversation) => conversation.id),
|
||||
).not.toContain(sourceId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const CONVERSATIONS_KEY = 'openjarvis-conversations';
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
describe('persisted tool calls', () => {
|
||||
it('repairs parsed argument objects while loading conversations', async () => {
|
||||
localStorage.setItem(
|
||||
CONVERSATIONS_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeId: 'conversation-1',
|
||||
conversations: {
|
||||
'conversation-1': {
|
||||
id: 'conversation-1',
|
||||
title: 'Broken chat',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
model: 'test-model',
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
tool: 'web_search',
|
||||
arguments: { query: 'python' },
|
||||
status: 'success',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { useAppStore } = await import('./store');
|
||||
|
||||
expect(useAppStore.getState().messages[0].toolCalls?.[0].arguments).toBe(
|
||||
'{"query":"python"}',
|
||||
);
|
||||
const repaired = JSON.parse(localStorage.getItem(CONVERSATIONS_KEY) ?? '{}');
|
||||
expect(
|
||||
repaired.conversations['conversation-1'].messages[0].toolCalls[0].arguments,
|
||||
).toBe('{"query":"python"}');
|
||||
});
|
||||
|
||||
it('keeps repaired conversations in memory when writeback fails', async () => {
|
||||
localStorage.setItem(
|
||||
CONVERSATIONS_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeId: 'conversation-1',
|
||||
conversations: {
|
||||
'conversation-1': {
|
||||
id: 'conversation-1',
|
||||
title: 'Readable chat',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
model: 'test-model',
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
tool: 'web_search',
|
||||
arguments: { query: 'python' },
|
||||
status: 'success',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('Storage quota exceeded', 'QuotaExceededError');
|
||||
});
|
||||
|
||||
const { useAppStore } = await import('./store');
|
||||
|
||||
expect(useAppStore.getState().messages).toHaveLength(1);
|
||||
expect(useAppStore.getState().messages[0].toolCalls?.[0].arguments).toBe(
|
||||
'{"query":"python"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
+71
-13
@@ -15,6 +15,8 @@ import type {
|
||||
TokenUsage,
|
||||
} from '../types';
|
||||
import type { ManagedAgent } from './api';
|
||||
import { isEmbedOnlyModel } from './model-capabilities';
|
||||
import { serializeToolCallArguments } from './tool-call';
|
||||
|
||||
export interface CachedConnector {
|
||||
connector_id: string;
|
||||
@@ -54,7 +56,30 @@ function loadConversations(): ConversationStore {
|
||||
const raw = localStorage.getItem(CONVERSATIONS_KEY);
|
||||
if (!raw) return { version: 1, conversations: {}, activeId: null };
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.version === 1) return parsed;
|
||||
if (parsed.version === 1) {
|
||||
let repaired = false;
|
||||
for (const conversation of Object.values(parsed.conversations ?? {}) as Conversation[]) {
|
||||
for (const message of conversation.messages ?? []) {
|
||||
for (const toolCall of message.toolCalls ?? []) {
|
||||
const argumentsText = serializeToolCallArguments(toolCall.arguments);
|
||||
if (argumentsText !== toolCall.arguments) {
|
||||
toolCall.arguments = argumentsText;
|
||||
repaired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (repaired) {
|
||||
try {
|
||||
localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(parsed));
|
||||
} catch {
|
||||
// Keep the repaired conversations usable in memory when storage is
|
||||
// read-only or full. A failed best-effort writeback must not make
|
||||
// otherwise readable conversation history disappear from the UI.
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
return { version: 1, conversations: {}, activeId: null };
|
||||
} catch {
|
||||
return { version: 1, conversations: {}, activeId: null };
|
||||
@@ -110,6 +135,7 @@ function saveSettings(settings: Settings): void {
|
||||
// ── Store ─────────────────────────────────────────────────────────────
|
||||
|
||||
const INITIAL_STREAM: StreamState = {
|
||||
conversationId: null,
|
||||
isStreaming: false,
|
||||
phase: '',
|
||||
elapsedMs: 0,
|
||||
@@ -351,6 +377,9 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
},
|
||||
|
||||
deleteConversation: (id: string) => {
|
||||
const streamState = get().streamState;
|
||||
if (streamState.isStreaming && streamState.conversationId === id) return;
|
||||
|
||||
const store = loadConversations();
|
||||
delete store.conversations[id];
|
||||
if (store.activeId === id) {
|
||||
@@ -393,12 +422,14 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
(message.content.length > 50 ? '...' : '');
|
||||
}
|
||||
saveConversations(store);
|
||||
set({
|
||||
messages: [...conv.messages],
|
||||
conversations: Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
),
|
||||
});
|
||||
const conversations = Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
);
|
||||
if (get().activeId === conversationId) {
|
||||
set({ messages: [...conv.messages], conversations });
|
||||
} else {
|
||||
set({ conversations });
|
||||
}
|
||||
},
|
||||
|
||||
updateLastAssistant: (
|
||||
@@ -425,7 +456,9 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
if (researchSources) lastMsg.researchSources = researchSources;
|
||||
conv.updatedAt = Date.now();
|
||||
saveConversations(store);
|
||||
set({ messages: [...conv.messages] });
|
||||
if (get().activeId === conversationId) {
|
||||
set({ messages: [...conv.messages] });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -444,11 +477,36 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
// ── Models & server ────────────────────────────────────────────
|
||||
|
||||
setModels: (models: ModelInfo[]) =>
|
||||
set((state) =>
|
||||
!state.selectedModel && models.length > 0
|
||||
? { models, selectedModel: models[0].id }
|
||||
: { models },
|
||||
),
|
||||
set((state) => {
|
||||
// Ollama returns embed-only models (e.g. nomic-embed-text) in the
|
||||
// same list as chat models. Auto-picking models[0] selected the
|
||||
// embedder and every chat failed with HTTP 400 "does not support
|
||||
// chat". Prefer a real chat model for selection / fallback.
|
||||
const chatModels = models.filter((m) => !isEmbedOnlyModel(m.id));
|
||||
const preferred =
|
||||
(state.settings.defaultModel &&
|
||||
chatModels.some((m) => m.id === state.settings.defaultModel) &&
|
||||
state.settings.defaultModel) ||
|
||||
chatModels[0]?.id ||
|
||||
models.find((m) => !isEmbedOnlyModel(m.id))?.id ||
|
||||
'';
|
||||
|
||||
const currentIsBad =
|
||||
!!state.selectedModel && isEmbedOnlyModel(state.selectedModel);
|
||||
const currentMissing =
|
||||
!!state.selectedModel &&
|
||||
!models.some((m) => m.id === state.selectedModel);
|
||||
|
||||
if (!state.selectedModel || currentIsBad || currentMissing) {
|
||||
// Prefer a real chat model. If none exist, clear a bad/missing
|
||||
// selection rather than keeping an embed-only id that 400s on chat.
|
||||
return {
|
||||
models,
|
||||
selectedModel: preferred,
|
||||
};
|
||||
}
|
||||
return { models };
|
||||
}),
|
||||
setModelsLoading: (loading: boolean) => set({ modelsLoading: loading }),
|
||||
setSelectedModel: (model: string) => set({ selectedModel: model }),
|
||||
setServerInfo: (info: ServerInfo | null) => set({ serverInfo: info }),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { serializeToolCallArguments } from './tool-call';
|
||||
|
||||
describe('serializeToolCallArguments', () => {
|
||||
it('preserves JSON strings', () => {
|
||||
expect(serializeToolCallArguments('{"query":"python"}')).toBe(
|
||||
'{"query":"python"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes parsed argument objects', () => {
|
||||
expect(serializeToolCallArguments({ query: 'python' })).toBe(
|
||||
'{"query":"python"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses an empty string for missing arguments', () => {
|
||||
expect(serializeToolCallArguments(null)).toBe('');
|
||||
expect(serializeToolCallArguments(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Convert tool-call arguments from API or persisted data into display-safe text. */
|
||||
export function serializeToolCallArguments(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value == null) return '';
|
||||
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
fetchAgentTrace,
|
||||
fetchManagedAgent,
|
||||
fetchAvailableTools,
|
||||
saveToolCredentials,
|
||||
fetchModels,
|
||||
updateManagedAgent,
|
||||
fetchRecommendedModel,
|
||||
@@ -575,7 +574,7 @@ function ToolsPicker({
|
||||
</div>
|
||||
{/* Live description strip */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2.5 py-1.5"
|
||||
className="flex items-start gap-2 px-2.5 py-1.5"
|
||||
style={{
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)',
|
||||
@@ -609,10 +608,11 @@ function ToolsPicker({
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="truncate"
|
||||
className="min-w-0 whitespace-normal break-words"
|
||||
style={{
|
||||
flex: 1,
|
||||
color: 'var(--color-text-tertiary)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{hovered ? `— ${hint}` : hint}
|
||||
@@ -3740,8 +3740,8 @@ export function AgentsPage() {
|
||||
const paramsB = paramMatch ? parseFloat(paramMatch[1]) : 9;
|
||||
const flops = 2 * paramsB * 1e9 * (inTok + outTok);
|
||||
const providers = [
|
||||
{ label: 'GPT-5.3', inPer1M: 2.0, outPer1M: 10.0 },
|
||||
{ label: 'Claude Opus 4.6', inPer1M: 5.0, outPer1M: 25.0 },
|
||||
{ label: 'GPT-5.6 Sol', inPer1M: 5.0, outPer1M: 30.0 },
|
||||
{ label: 'Claude Fable 5', inPer1M: 10.0, outPer1M: 50.0 },
|
||||
{ label: 'Gemini 3.1 Pro', inPer1M: 2.0, outPer1M: 12.0 },
|
||||
];
|
||||
const energyWh = (inTok + outTok) / 1000 * 0.4;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface ConversationStore {
|
||||
// --- Stream State ---
|
||||
|
||||
export interface StreamState {
|
||||
conversationId: string | null;
|
||||
isStreaming: boolean;
|
||||
phase: string;
|
||||
elapsedMs: number;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
@@ -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..."
|
||||
|
||||
@@ -57,6 +57,10 @@ class BaseAgent(ABC):
|
||||
|
||||
agent_id: str
|
||||
accepts_tools: bool = False
|
||||
# Plain conversational agents may opt into the managed runtime's generic
|
||||
# function-calling loop. Specialized agents keep their own execution
|
||||
# class even when process-wide MCP tools are available.
|
||||
supports_managed_tool_fallback: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -151,6 +155,9 @@ class BaseAgent(ABC):
|
||||
conversation messages, and finally the user input.
|
||||
"""
|
||||
messages: list[Message] = []
|
||||
context_messages = (
|
||||
list(context.conversation.messages) if context is not None else []
|
||||
)
|
||||
# Check if the context already supplies a system message
|
||||
_context_has_system = (
|
||||
context
|
||||
@@ -172,9 +179,28 @@ class BaseAgent(ABC):
|
||||
except Exception:
|
||||
effective_system_prompt = None
|
||||
if effective_system_prompt:
|
||||
context_system_text = "\n\n".join(
|
||||
message.text
|
||||
for message in context_messages
|
||||
if message.role == Role.SYSTEM
|
||||
and message.metadata.get("memory_context")
|
||||
and message.text
|
||||
)
|
||||
if context_system_text:
|
||||
effective_system_prompt = (
|
||||
f"{effective_system_prompt}\n\n{context_system_text}"
|
||||
)
|
||||
context_messages = [
|
||||
message
|
||||
for message in context_messages
|
||||
if not (
|
||||
message.role == Role.SYSTEM
|
||||
and message.metadata.get("memory_context")
|
||||
)
|
||||
]
|
||||
messages.append(Message(role=Role.SYSTEM, content=effective_system_prompt))
|
||||
if context and context.conversation.messages:
|
||||
messages.extend(context.conversation.messages)
|
||||
if context_messages:
|
||||
messages.extend(context_messages)
|
||||
messages.append(Message(role=Role.USER, content=input))
|
||||
return messages
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -11,7 +11,9 @@ Three install paths are supported today:
|
||||
- **Editable git checkout** (``uv sync`` / ``pip install -e .`` from a
|
||||
cloned repo). The package's ``__file__`` is inside a working tree
|
||||
with a ``.git`` directory at the repo root. Upgrade with
|
||||
``git pull && uv sync`` from the checkout.
|
||||
``git pull && uv sync --inexact`` from the checkout. ``--inexact`` is
|
||||
important here: a bare ``uv sync`` removes packages installed by extras or
|
||||
dependency groups that are not part of the base project.
|
||||
|
||||
We detect by inspecting ``openjarvis.__file__``. If we can't tell with
|
||||
confidence we fall back to the PyPI command — that's the most common
|
||||
@@ -68,7 +70,7 @@ def detect_install() -> InstallInfo:
|
||||
if (candidate / ".git").exists() and (candidate / "pyproject.toml").exists():
|
||||
return InstallInfo(
|
||||
kind="editable-git",
|
||||
upgrade_command=f"cd {candidate} && git pull && uv sync",
|
||||
upgrade_command=(f"cd {candidate} && git pull && uv sync --inexact"),
|
||||
repo_root=candidate,
|
||||
)
|
||||
if candidate.parent == candidate:
|
||||
|
||||
@@ -248,6 +248,17 @@ def _get_memory_backend(config):
|
||||
return None
|
||||
|
||||
|
||||
def _get_memory_facts(config):
|
||||
"""Load facts captured by the automatic memory service."""
|
||||
try:
|
||||
from openjarvis.memory import load_configured_facts
|
||||
|
||||
return load_configured_facts(config)
|
||||
except Exception as exc:
|
||||
logger.debug("Automatic memory facts unavailable (optional): %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
_MEMORY_TOOLS = frozenset(
|
||||
{"retrieval", "memory_store", "memory_search", "memory_index", "memory_retrieve"}
|
||||
)
|
||||
@@ -416,7 +427,8 @@ def _run_agent(
|
||||
from openjarvis.tools.storage.context import ContextConfig, inject_context
|
||||
|
||||
backend = _get_memory_backend(config)
|
||||
if backend is not None:
|
||||
facts = _get_memory_facts(config)
|
||||
if backend is not None or facts:
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=config.memory.context_top_k,
|
||||
min_score=config.memory.context_min_score,
|
||||
@@ -427,6 +439,7 @@ def _run_agent(
|
||||
[],
|
||||
backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
for msg in context_messages:
|
||||
ctx.conversation.add(msg)
|
||||
@@ -963,7 +976,8 @@ def ask(
|
||||
)
|
||||
|
||||
backend = _get_memory_backend(config)
|
||||
if backend is not None:
|
||||
facts = _get_memory_facts(config)
|
||||
if backend is not None or facts:
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=config.memory.context_top_k,
|
||||
min_score=config.memory.context_min_score,
|
||||
@@ -974,6 +988,7 @@ def ask(
|
||||
messages,
|
||||
backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to inject memory context: %s", exc)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -15,6 +16,8 @@ from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.memory import publish_completed_exchange
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _read_input(prompt: str = "You> ") -> Optional[str]:
|
||||
"""Read user input with graceful EOF handling."""
|
||||
@@ -194,6 +197,15 @@ def chat(
|
||||
console.print(f"[yellow]Memory service unavailable: {exc}[/yellow]")
|
||||
memory_service = None
|
||||
|
||||
# The document backend and automatic fact store are separate persistence
|
||||
# mechanisms. Context injection combines both at read time so facts from
|
||||
# previous sessions are immediately available without a manual index step.
|
||||
memory_backend = None
|
||||
if config.agent.context_from_memory:
|
||||
from openjarvis.cli.ask import _get_memory_backend
|
||||
|
||||
memory_backend = _get_memory_backend(config)
|
||||
|
||||
# Conversation state
|
||||
if not system_prompt:
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
@@ -262,15 +274,55 @@ def chat(
|
||||
# Add user message
|
||||
history.append(Message(role=Role.USER, content=user_input))
|
||||
|
||||
# Generate response
|
||||
generation_history = history
|
||||
agent_context_message = None
|
||||
if config.agent.context_from_memory:
|
||||
try:
|
||||
from openjarvis.memory import load_configured_facts
|
||||
from openjarvis.tools.storage.context import (
|
||||
ContextConfig,
|
||||
inject_context,
|
||||
)
|
||||
|
||||
if memory_service is not None and hasattr(memory_service, "list_facts"):
|
||||
facts = memory_service.list_facts()
|
||||
else:
|
||||
facts = load_configured_facts(config)
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=config.memory.context_top_k,
|
||||
min_score=config.memory.context_min_score,
|
||||
max_context_tokens=config.memory.context_max_tokens,
|
||||
)
|
||||
context_messages = inject_context(
|
||||
user_input,
|
||||
[] if agent is not None else history,
|
||||
memory_backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
if agent is not None:
|
||||
if context_messages:
|
||||
agent_context_message = context_messages[0]
|
||||
else:
|
||||
generation_history = context_messages
|
||||
except Exception:
|
||||
logger.debug("Failed to inject memory context", exc_info=True)
|
||||
|
||||
# Generate response even when optional memory context is unavailable.
|
||||
try:
|
||||
if agent is not None:
|
||||
response = agent.run(user_input)
|
||||
agent_context = None
|
||||
if agent_context_message is not None:
|
||||
from openjarvis.agents._stubs import AgentContext
|
||||
|
||||
agent_context = AgentContext()
|
||||
agent_context.conversation.add(agent_context_message)
|
||||
response = agent.run(user_input, context=agent_context)
|
||||
content = (
|
||||
response.content if hasattr(response, "content") else str(response)
|
||||
)
|
||||
else:
|
||||
result = engine.generate(history, model=model)
|
||||
result = engine.generate(generation_history, model=model)
|
||||
content = (
|
||||
result.get("content", "")
|
||||
if isinstance(result, dict)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,9 @@ Runs the right upgrade command for how the user installed OpenJarvis:
|
||||
|
||||
- PyPI installs get ``pip install --upgrade openjarvis``.
|
||||
- uv-tool installs get ``uv tool upgrade openjarvis``.
|
||||
- Editable git checkouts get ``git pull && uv sync`` in the checkout.
|
||||
- Editable git checkouts get ``git pull && uv sync --inexact`` in the checkout.
|
||||
The inexact sync preserves packages previously installed through extras and
|
||||
dependency groups.
|
||||
|
||||
The detection logic is shared with the post-command "new version
|
||||
available" hint in ``_version_check.py`` so both surfaces stay in sync.
|
||||
|
||||
+81
-80
@@ -10,6 +10,7 @@ from rich.console import Console
|
||||
|
||||
from openjarvis.cli._banner import print_banner
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.credentials import inject_credentials
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.engine import (
|
||||
@@ -24,6 +25,30 @@ from openjarvis.intelligence import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TOOLS = frozenset({"think", "calculator", "web_search"})
|
||||
|
||||
|
||||
def _resolve_allowed_tools(config: object) -> tuple[set[str], bool]:
|
||||
"""Return configured tool names and whether the selection was explicit.
|
||||
|
||||
``tools.enabled`` is the canonical setting used by ``SystemBuilder`` and
|
||||
the interactive CLI. ``agent.tools`` remains as a backward-compatible
|
||||
fallback, followed by the server's default tool set when neither is set.
|
||||
"""
|
||||
configured = config.tools.enabled or config.agent.tools
|
||||
if not configured:
|
||||
return set(_DEFAULT_TOOLS), False
|
||||
|
||||
if isinstance(configured, list):
|
||||
allowed = {
|
||||
tool.strip()
|
||||
for tool in configured
|
||||
if isinstance(tool, str) and tool.strip()
|
||||
}
|
||||
else:
|
||||
allowed = {tool.strip() for tool in configured.split(",") if tool.strip()}
|
||||
return allowed, True
|
||||
|
||||
|
||||
def _unique_model_ids(model_ids: list[str]) -> list[str]:
|
||||
"""Return model ids in first-seen order without duplicates."""
|
||||
@@ -95,7 +120,7 @@ def _resolve_server_model(
|
||||
"--agent",
|
||||
"agent_name",
|
||||
default=None,
|
||||
help="Agent for non-streaming requests (simple, orchestrator, react, openhands).",
|
||||
help="Agent for chat requests (simple, orchestrator, react, openhands).",
|
||||
)
|
||||
@click.pass_context
|
||||
def serve(
|
||||
@@ -122,6 +147,11 @@ def serve(
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Tool credentials saved through the browser UI live in the OpenJarvis
|
||||
# credential store. Restore them before engines and tools are constructed
|
||||
# so availability checks and tool instances see the same environment.
|
||||
inject_credentials()
|
||||
|
||||
config = load_config()
|
||||
|
||||
# Resolve host/port from CLI args or config
|
||||
@@ -273,6 +303,15 @@ def serve(
|
||||
# (which would re-discover the engine, re-resolve tools, re-open the channel,
|
||||
# etc.). See the scheduler block near the bottom of this function (#263).
|
||||
resolved_tools: list = []
|
||||
managed_mcp_tools: list = []
|
||||
mcp_clients: list = []
|
||||
try:
|
||||
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
||||
|
||||
managed_mcp_tools, mcp_clients = load_mcp_tools_from_config(config.tools.mcp)
|
||||
except Exception as exc:
|
||||
logger.warning("Managed-agent MCP tools failed to load: %s", exc)
|
||||
|
||||
if agent_key:
|
||||
try:
|
||||
import openjarvis.agents # noqa: F401
|
||||
@@ -284,32 +323,13 @@ def serve(
|
||||
if sec.capability_policy is not None:
|
||||
agent_kwargs["capability_policy"] = sec.capability_policy
|
||||
|
||||
# MCP transports persisted on the agent at the bottom of
|
||||
# this block — initialise here so the reference is valid
|
||||
# even when accepts_tools is False (#461).
|
||||
mcp_clients: list = []
|
||||
|
||||
# Load tools for agents that support them
|
||||
if getattr(agent_cls, "accepts_tools", False):
|
||||
import openjarvis.tools # noqa: F401 # trigger registration
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
from openjarvis.tools._stubs import BaseTool
|
||||
|
||||
_DEFAULT_TOOLS = {"think", "calculator", "web_search"}
|
||||
configured = config.agent.tools
|
||||
if configured:
|
||||
if isinstance(configured, list):
|
||||
allowed = {
|
||||
t.strip()
|
||||
for t in configured
|
||||
if isinstance(t, str) and t.strip()
|
||||
}
|
||||
else:
|
||||
allowed = {
|
||||
t.strip() for t in configured.split(",") if t.strip()
|
||||
}
|
||||
else:
|
||||
allowed = _DEFAULT_TOOLS
|
||||
allowed, tools_configured = _resolve_allowed_tools(config)
|
||||
|
||||
tools = []
|
||||
for name in ToolRegistry.keys():
|
||||
@@ -325,12 +345,13 @@ def serve(
|
||||
|
||||
# MCP server tools from config.tools.mcp.servers
|
||||
# (#461 — these were silently dropped).
|
||||
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
||||
|
||||
mcp_tools, mcp_clients = load_mcp_tools_from_config(
|
||||
config.tools.mcp,
|
||||
allowed_names=allowed if configured else None,
|
||||
)
|
||||
mcp_tools = managed_mcp_tools
|
||||
if tools_configured:
|
||||
mcp_tools = [
|
||||
tool
|
||||
for tool in managed_mcp_tools
|
||||
if tool.spec.name in allowed
|
||||
]
|
||||
if mcp_tools:
|
||||
existing = {t.spec.name for t in tools}
|
||||
for t in mcp_tools:
|
||||
@@ -383,10 +404,6 @@ def serve(
|
||||
channel_agent = config.channel.default_agent or agent_key or "simple"
|
||||
|
||||
_channel_tools: list = []
|
||||
# MCP transports persisted at function scope (= server-process
|
||||
# lifetime); see the comment near the channel-MCP-load block
|
||||
# below. Initialise here so it's always bound. #461.
|
||||
_channel_mcp_clients: list = []
|
||||
if channel_agent:
|
||||
try:
|
||||
import openjarvis.agents
|
||||
@@ -399,23 +416,7 @@ def serve(
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
from openjarvis.tools._stubs import BaseTool
|
||||
|
||||
_DEFAULT_TOOLS = {"think", "calculator", "web_search"}
|
||||
configured = config.agent.tools
|
||||
if configured:
|
||||
if isinstance(configured, list):
|
||||
_allowed = {
|
||||
t.strip()
|
||||
for t in configured
|
||||
if isinstance(t, str) and t.strip()
|
||||
}
|
||||
else:
|
||||
_allowed = {
|
||||
t.strip()
|
||||
for t in configured.split(",")
|
||||
if t.strip()
|
||||
}
|
||||
else:
|
||||
_allowed = _DEFAULT_TOOLS
|
||||
_allowed, _tools_configured = _resolve_allowed_tools(config)
|
||||
|
||||
for _tname in ToolRegistry.keys():
|
||||
if _tname not in _allowed:
|
||||
@@ -426,29 +427,23 @@ def serve(
|
||||
elif isinstance(_tcls, BaseTool):
|
||||
_channel_tools.append(_tcls)
|
||||
|
||||
# MCP tools for the channel agent too (#461).
|
||||
from openjarvis.mcp.loader import (
|
||||
load_mcp_tools_from_config,
|
||||
)
|
||||
|
||||
_ch_mcp_tools, _ch_mcp_clients = load_mcp_tools_from_config(
|
||||
config.tools.mcp,
|
||||
allowed_names=_allowed if configured else None,
|
||||
)
|
||||
# Reuse the process-owned MCP pool so channels do not
|
||||
# open a second transport to every configured server.
|
||||
_ch_mcp_tools = managed_mcp_tools
|
||||
if _tools_configured:
|
||||
_ch_mcp_tools = [
|
||||
tool
|
||||
for tool in managed_mcp_tools
|
||||
if tool.spec.name in _allowed
|
||||
]
|
||||
if _ch_mcp_tools:
|
||||
_existing = {t.spec.name for t in _channel_tools}
|
||||
for t in _ch_mcp_tools:
|
||||
if t.spec.name not in _existing:
|
||||
_channel_tools.append(t)
|
||||
_existing.add(t.spec.name)
|
||||
# Hold a reference at module / function scope —
|
||||
# the channel agent is constructed inside
|
||||
# JarvisSystem below; we extend its lifetime by
|
||||
# keeping the list bound here.
|
||||
_channel_mcp_clients = _ch_mcp_clients
|
||||
except Exception as exc:
|
||||
logger.warning("Channel tools failed to load: %s", exc)
|
||||
_channel_mcp_clients = []
|
||||
|
||||
_wire_system = JarvisSystem(
|
||||
config=config,
|
||||
@@ -458,6 +453,8 @@ def serve(
|
||||
model=model_name,
|
||||
agent_name=channel_agent,
|
||||
tools=_channel_tools,
|
||||
mcp_tools=managed_mcp_tools,
|
||||
_mcp_clients=mcp_clients,
|
||||
)
|
||||
_wire_system.wire_channel(channel_bridge)
|
||||
|
||||
@@ -475,23 +472,24 @@ def serve(
|
||||
# Create app
|
||||
from openjarvis.server.app import create_app
|
||||
|
||||
# Set up memory backend for context injection. Built before the scheduler
|
||||
# block so the executor's JarvisSystem can reference it (#263).
|
||||
# Set up the memory backend for storage tools, API routes, and optional
|
||||
# prompt-context injection. ``context_from_memory`` controls only the last
|
||||
# of those, so disabling it must not leave explicit memory_* tools with a
|
||||
# null backend. Built before the scheduler so AgentExecutor can reuse it.
|
||||
memory_backend = None
|
||||
if config.agent.context_from_memory:
|
||||
try:
|
||||
import openjarvis.tools.storage # noqa: F401
|
||||
from openjarvis.core.registry import MemoryRegistry
|
||||
try:
|
||||
import openjarvis.tools.storage # noqa: F401
|
||||
from openjarvis.core.registry import MemoryRegistry
|
||||
|
||||
mem_key = config.memory.default_backend
|
||||
if MemoryRegistry.contains(mem_key):
|
||||
memory_backend = MemoryRegistry.create(
|
||||
mem_key,
|
||||
db_path=config.memory.db_path,
|
||||
)
|
||||
console.print(" Memory: [cyan]active[/cyan]")
|
||||
except Exception as exc:
|
||||
logger.debug("Memory backend init failed: %s", exc)
|
||||
mem_key = config.memory.default_backend
|
||||
if MemoryRegistry.contains(mem_key):
|
||||
memory_backend = MemoryRegistry.create(
|
||||
mem_key,
|
||||
db_path=config.memory.db_path,
|
||||
)
|
||||
console.print(" Memory: [cyan]active[/cyan]")
|
||||
except Exception as exc:
|
||||
logger.debug("Memory backend init failed: %s", exc)
|
||||
|
||||
# Automatic long-term memory service (background fact extraction).
|
||||
memory_service = None
|
||||
@@ -586,6 +584,7 @@ def serve(
|
||||
agent=agent,
|
||||
agent_name=agent_key or "",
|
||||
tools=resolved_tools,
|
||||
mcp_tools=managed_mcp_tools,
|
||||
tool_executor=_sched_tool_executor,
|
||||
memory_backend=memory_backend,
|
||||
telemetry_store=telem_store,
|
||||
@@ -594,6 +593,7 @@ def serve(
|
||||
capability_policy=sec.capability_policy,
|
||||
agent_manager=agent_manager,
|
||||
agent_executor=executor,
|
||||
_mcp_clients=mcp_clients,
|
||||
)
|
||||
executor.set_system(system)
|
||||
|
||||
@@ -685,10 +685,13 @@ def serve(
|
||||
channel_bridge=channel_bridge,
|
||||
config=config,
|
||||
memory_backend=memory_backend,
|
||||
own_memory_backend=memory_backend is not None,
|
||||
memory_service=memory_service,
|
||||
speech_backend=speech_backend,
|
||||
agent_manager=agent_manager,
|
||||
agent_scheduler=agent_scheduler,
|
||||
mcp_tools=managed_mcp_tools,
|
||||
mcp_clients=mcp_clients,
|
||||
api_key=api_key,
|
||||
webhook_config=webhook_config,
|
||||
cors_origins=config.server.cors_origins,
|
||||
@@ -717,6 +720,4 @@ def serve(
|
||||
"authenticated requests to your instance."
|
||||
)
|
||||
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -8,13 +8,12 @@ Reference: https://github.com/sierra-research/tau2-bench
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from importlib import metadata
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
@@ -22,48 +21,50 @@ from openjarvis.evals.core.types import EvalRecord
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TAU2_REPO = "https://github.com/sierra-research/tau2-bench.git"
|
||||
CACHE_DIR = get_cache_dir() / "tau2-bench"
|
||||
# v1.0.1. Keep the full commit SHA here (rather than a movable tag) so every
|
||||
# TauBench setup uses the same third-party code.
|
||||
TAU2_REVISION = "fc0055dc4e0a316c3f83133267fbd6faaa770992"
|
||||
TAU2_INSTALL_SPEC = f"tau2 @ git+{TAU2_REPO}@{TAU2_REVISION}"
|
||||
|
||||
DOMAINS = ("airline", "retail", "telecom")
|
||||
|
||||
|
||||
def _ensure_tau2() -> None:
|
||||
"""Ensure tau2 package is importable; install from cache if needed."""
|
||||
"""Ensure the explicitly installed, pinned tau2 package is importable."""
|
||||
try:
|
||||
distribution = metadata.distribution("tau2")
|
||||
except metadata.PackageNotFoundError as exc:
|
||||
raise ImportError(
|
||||
"TauBench requires tau2, which OpenJarvis does not install at "
|
||||
"runtime. Install the pinned dependency explicitly (Python >=3.12): "
|
||||
f'uv pip install "{TAU2_INSTALL_SPEC}"'
|
||||
) from exc
|
||||
|
||||
try:
|
||||
direct_url_text = distribution.read_text("direct_url.json")
|
||||
direct_url = json.loads(direct_url_text or "")
|
||||
vcs_info = direct_url.get("vcs_info", {})
|
||||
installed_repo = direct_url.get("url")
|
||||
installed_revision = vcs_info.get("commit_id")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
installed_repo = None
|
||||
installed_revision = None
|
||||
|
||||
if installed_repo != TAU2_REPO or installed_revision != TAU2_REVISION:
|
||||
raise ImportError(
|
||||
"The installed tau2 package does not match OpenJarvis's pinned "
|
||||
"source revision. Reinstall it explicitly (Python >=3.12): "
|
||||
f'uv pip install --force-reinstall "{TAU2_INSTALL_SPEC}"'
|
||||
)
|
||||
|
||||
try:
|
||||
import tau2 # noqa: F401
|
||||
except ImportError:
|
||||
# Clone and install from source
|
||||
if not CACHE_DIR.exists():
|
||||
LOGGER.info("Cloning tau2-bench from %s ...", TAU2_REPO)
|
||||
CACHE_DIR.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
["git", "clone", "--depth", "1", TAU2_REPO, str(CACHE_DIR)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
LOGGER.info("Installing tau2-bench ...")
|
||||
# Try `python -m pip` first; fall back to `uv pip` for uv-managed venvs
|
||||
# which don't ship pip by default.
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-e", str(CACHE_DIR)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
subprocess.run(
|
||||
[
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--python",
|
||||
sys.executable,
|
||||
"-e",
|
||||
str(CACHE_DIR),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"The pinned tau2 package is installed but cannot be imported. "
|
||||
"Reinstall it explicitly (Python >=3.12): "
|
||||
f'uv pip install --force-reinstall "{TAU2_INSTALL_SPEC}"'
|
||||
) from exc
|
||||
|
||||
|
||||
class TauBenchDataset(DatasetProvider):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ from openjarvis.memory.store import (
|
||||
FactStore,
|
||||
LocalFactStore,
|
||||
create_fact_store,
|
||||
load_configured_facts,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -29,5 +30,6 @@ __all__ = [
|
||||
"MemoryService",
|
||||
"build_memory_service",
|
||||
"create_fact_store",
|
||||
"load_configured_facts",
|
||||
"publish_completed_exchange",
|
||||
]
|
||||
|
||||
@@ -16,7 +16,7 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List
|
||||
from typing import Any, Iterable, List
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import FactStoreRegistry
|
||||
@@ -205,4 +205,30 @@ def create_fact_store(
|
||||
return FactStoreRegistry.create(key, path, max_facts=max_facts)
|
||||
|
||||
|
||||
__all__ = ["Fact", "FactStore", "LocalFactStore", "create_fact_store"]
|
||||
def load_configured_facts(config: Any) -> List[Fact]:
|
||||
"""Load automatic-memory facts from *config* when the service is enabled.
|
||||
|
||||
Context injection is also used by short-lived commands such as
|
||||
``jarvis ask``, where no :class:`MemoryService` instance exists. This
|
||||
helper gives those callers the same configured fact-store view without
|
||||
coupling them to the service lifecycle.
|
||||
"""
|
||||
memory = getattr(config, "memory", None)
|
||||
if memory is None or not getattr(memory, "enabled", False):
|
||||
return []
|
||||
|
||||
store = create_fact_store(
|
||||
getattr(memory, "backend", "local"),
|
||||
path=getattr(memory, "facts_path", None),
|
||||
max_facts=getattr(memory, "max_facts", 1000),
|
||||
)
|
||||
return store.list()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Fact",
|
||||
"FactStore",
|
||||
"LocalFactStore",
|
||||
"create_fact_store",
|
||||
"load_configured_facts",
|
||||
]
|
||||
|
||||
+14
-5
@@ -522,14 +522,15 @@ class Jarvis:
|
||||
# Context injection
|
||||
if context and self._config.agent.context_from_memory:
|
||||
try:
|
||||
from openjarvis.cli.ask import _get_memory_backend
|
||||
from openjarvis.cli.ask import _get_memory_backend, _get_memory_facts
|
||||
from openjarvis.tools.storage.context import (
|
||||
ContextConfig,
|
||||
inject_context,
|
||||
)
|
||||
|
||||
backend = _get_memory_backend(self._config)
|
||||
if backend is not None:
|
||||
facts = _get_memory_facts(self._config)
|
||||
if backend is not None or facts:
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=self._config.memory.context_top_k,
|
||||
min_score=self._config.memory.context_min_score,
|
||||
@@ -540,6 +541,7 @@ class Jarvis:
|
||||
[],
|
||||
backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
for msg in context_messages:
|
||||
ctx.conversation.add(msg)
|
||||
@@ -570,17 +572,24 @@ class Jarvis:
|
||||
) -> List[Message]:
|
||||
"""Inject memory context into messages."""
|
||||
try:
|
||||
from openjarvis.cli.ask import _get_memory_backend
|
||||
from openjarvis.cli.ask import _get_memory_backend, _get_memory_facts
|
||||
from openjarvis.tools.storage.context import ContextConfig, inject_context
|
||||
|
||||
backend = _get_memory_backend(self._config)
|
||||
if backend is not None:
|
||||
facts = _get_memory_facts(self._config)
|
||||
if backend is not None or facts:
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=self._config.memory.context_top_k,
|
||||
min_score=self._config.memory.context_min_score,
|
||||
max_context_tokens=self._config.memory.context_max_tokens,
|
||||
)
|
||||
return inject_context(query, messages, backend, config=ctx_cfg)
|
||||
return inject_context(
|
||||
query,
|
||||
messages,
|
||||
backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to inject memory context: %s", exc)
|
||||
return messages
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -215,8 +215,8 @@ COMPARISON_HTML = """\
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>OpenJarvis (Local)</th>
|
||||
<th>GPT-5.3</th>
|
||||
<th>Claude Opus 4.6</th>
|
||||
<th>GPT-5.6 Sol</th>
|
||||
<th>Claude Fable 5</th>
|
||||
<th>Gemini 3.1 Pro</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -261,11 +261,11 @@ COMPARISON_HTML = """\
|
||||
<div class="cc-value">$0.00/mo</div>
|
||||
</div>
|
||||
<div class="calc-card cloud">
|
||||
<div class="cc-label">GPT-5.3</div>
|
||||
<div class="cc-label">GPT-5.6 Sol</div>
|
||||
<div class="cc-value" id="calc-gpt">--</div>
|
||||
</div>
|
||||
<div class="calc-card cloud">
|
||||
<div class="cc-label">Claude Opus 4.6</div>
|
||||
<div class="cc-label">Claude Fable 5</div>
|
||||
<div class="cc-value" id="calc-claude">--</div>
|
||||
</div>
|
||||
<div class="calc-card cloud">
|
||||
@@ -292,13 +292,13 @@ COMPARISON_HTML = """\
|
||||
<script>
|
||||
// Embedded data -- avoids API calls, keeps the page static and fast.
|
||||
const CLOUD_PRICING = {
|
||||
"gpt-5.3": {
|
||||
input_per_1m: 2.00, output_per_1m: 10.00,
|
||||
label: "GPT-5.3"
|
||||
"gpt-5.6-sol": {
|
||||
input_per_1m: 5.00, output_per_1m: 30.00,
|
||||
label: "GPT-5.6 Sol"
|
||||
},
|
||||
"claude-opus-4.6": {
|
||||
input_per_1m: 5.00, output_per_1m: 25.00,
|
||||
label: "Claude Opus 4.6"
|
||||
"claude-fable-5": {
|
||||
input_per_1m: 10.00, output_per_1m: 50.00,
|
||||
label: "Claude Fable 5"
|
||||
},
|
||||
"gemini-3.1-pro": {
|
||||
input_per_1m: 2.00, output_per_1m: 12.00,
|
||||
@@ -376,8 +376,8 @@ function updateTable() {
|
||||
const sc = SCENARIOS[activeScenario];
|
||||
const i = sc.avg_input_tokens, o = sc.avg_output_tokens;
|
||||
const c = sc.calls_per_month;
|
||||
const gpt = calcMonthlyCost(c, i, o, 'gpt-5.3');
|
||||
const claude = calcMonthlyCost(c, i, o, 'claude-opus-4.6');
|
||||
const gpt = calcMonthlyCost(c, i, o, 'gpt-5.6-sol');
|
||||
const claude = calcMonthlyCost(c, i, o, 'claude-fable-5');
|
||||
const gemini = calcMonthlyCost(c, i, o, 'gemini-3.1-pro');
|
||||
|
||||
document.getElementById('t-gpt-m').textContent = fmtDollar(gpt);
|
||||
@@ -410,8 +410,8 @@ function updateCalc() {
|
||||
const avgOut = tpc - avgIn;
|
||||
const callsPerMonth = cpd * 30;
|
||||
|
||||
const gpt = calcMonthlyCost(callsPerMonth, avgIn, avgOut, 'gpt-5.3');
|
||||
const claude = calcMonthlyCost(callsPerMonth, avgIn, avgOut, 'claude-opus-4.6');
|
||||
const gpt = calcMonthlyCost(callsPerMonth, avgIn, avgOut, 'gpt-5.6-sol');
|
||||
const claude = calcMonthlyCost(callsPerMonth, avgIn, avgOut, 'claude-fable-5');
|
||||
const gemini = calcMonthlyCost(callsPerMonth, avgIn, avgOut, 'gemini-3.1-pro');
|
||||
|
||||
document.getElementById('calc-gpt').textContent = fmtDollar(gpt) + '/mo';
|
||||
|
||||
@@ -184,7 +184,7 @@ DASHBOARD_HTML = """\
|
||||
<div class="providers">
|
||||
<div class="provider-card openai">
|
||||
<div class="pname">OpenAI</div>
|
||||
<div class="pmodel">GPT-5.3 — $2.00 / $10.00 per 1M tokens</div>
|
||||
<div class="pmodel">GPT-5.6 Sol — $5.00 / $30.00 per 1M tokens</div>
|
||||
<div class="savings-amount" id="save-openai">$0.00</div>
|
||||
<div class="breakdown">
|
||||
<div class="item">
|
||||
@@ -199,7 +199,7 @@ DASHBOARD_HTML = """\
|
||||
</div>
|
||||
<div class="provider-card anthropic">
|
||||
<div class="pname">Anthropic</div>
|
||||
<div class="pmodel">Claude Opus 4.6 — $5.00 / $25.00 per 1M tokens</div>
|
||||
<div class="pmodel">Claude Fable 5 — $10.00 / $50.00 per 1M tokens</div>
|
||||
<div class="savings-amount" id="save-anthropic">$0.00</div>
|
||||
<div class="breakdown">
|
||||
<div class="item">
|
||||
@@ -281,12 +281,12 @@ DASHBOARD_HTML = """\
|
||||
<div class="providers-heading">Energy & Compute Avoided</div>
|
||||
<div class="metrics-row">
|
||||
<div class="metric-card">
|
||||
<div class="mheading">Energy Saved (vs GPT-5.3)</div>
|
||||
<div class="mheading">Energy Saved (vs GPT-5.6 Sol)</div>
|
||||
<div class="mvalue green" id="energy-joules">0 <span class="munit">J</span></div>
|
||||
<div class="msub" id="energy-kwh">0 kWh of cloud datacenter energy avoided</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="mheading">FLOPs Avoided (vs GPT-5.3)</div>
|
||||
<div class="mheading">FLOPs Avoided (vs GPT-5.6 Sol)</div>
|
||||
<div class="mvalue purple" id="flops-val">0 <span class="munit">FLOP</span></div>
|
||||
<div class="msub" id="flops-sub">cloud compute operations not needed</div>
|
||||
</div>
|
||||
@@ -354,8 +354,8 @@ async function refresh() {
|
||||
providerMap[p.provider] = p;
|
||||
});
|
||||
|
||||
// OpenAI / GPT-5.3
|
||||
const oa = providerMap['gpt-5.3'] || {};
|
||||
// OpenAI / GPT-5.6 Sol
|
||||
const oa = providerMap['gpt-5.6-sol'] || {};
|
||||
document.getElementById('save-openai')
|
||||
.textContent = fmtDollar(oa.total_cost || 0);
|
||||
document.getElementById('save-openai-in')
|
||||
@@ -363,8 +363,8 @@ async function refresh() {
|
||||
document.getElementById('save-openai-out')
|
||||
.textContent = fmtDollar(oa.output_cost || 0);
|
||||
|
||||
// Anthropic / Claude Opus 4.6
|
||||
const an = providerMap['claude-opus-4.6'] || {};
|
||||
// Anthropic / Claude Fable 5
|
||||
const an = providerMap['claude-fable-5'] || {};
|
||||
document.getElementById('save-anthropic')
|
||||
.textContent = fmtDollar(an.total_cost || 0);
|
||||
document.getElementById('save-anthropic-in')
|
||||
@@ -384,13 +384,13 @@ async function refresh() {
|
||||
// Monthly projections
|
||||
const proj = d.monthly_projection || {};
|
||||
document.getElementById('proj-openai')
|
||||
.textContent = fmtDollar(proj['gpt-5.3'] || 0);
|
||||
.textContent = fmtDollar(proj['gpt-5.6-sol'] || 0);
|
||||
document.getElementById('proj-anthropic')
|
||||
.textContent = fmtDollar(proj['claude-opus-4.6'] || 0);
|
||||
.textContent = fmtDollar(proj['claude-fable-5'] || 0);
|
||||
document.getElementById('proj-google')
|
||||
.textContent = fmtDollar(proj['gemini-3.1-pro'] || 0);
|
||||
|
||||
// Energy / FLOPs (use GPT-5.3 as reference)
|
||||
// Energy / FLOPs (use GPT-5.6 Sol as reference)
|
||||
const ej = oa.energy_joules || 0;
|
||||
const eWh = oa.energy_wh || 0;
|
||||
const fl = oa.flops || 0;
|
||||
|
||||
@@ -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"]
|
||||
+256
-51
@@ -11,7 +11,8 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
from openjarvis.server.model_capabilities import is_embed_only_model
|
||||
from openjarvis.server.models import (
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionRequest,
|
||||
@@ -39,6 +40,15 @@ def _to_messages(chat_messages) -> list[Message]:
|
||||
role=role,
|
||||
content=m.content or "",
|
||||
name=m.name,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id=tool_call.get("id", ""),
|
||||
name=tool_call.get("function", {}).get("name", ""),
|
||||
arguments=tool_call.get("function", {}).get("arguments", "{}"),
|
||||
)
|
||||
for tool_call in (m.tool_calls or [])
|
||||
]
|
||||
or None,
|
||||
tool_call_id=m.tool_call_id,
|
||||
)
|
||||
)
|
||||
@@ -59,23 +69,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; "
|
||||
@@ -102,13 +123,15 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
memory_backend = getattr(request.app.state, "memory_backend", None)
|
||||
if (
|
||||
config is not None
|
||||
and memory_backend is not None
|
||||
and config.agent.context_from_memory
|
||||
and request_body.messages
|
||||
):
|
||||
try:
|
||||
from openjarvis.tools.storage.context import ContextConfig, inject_context
|
||||
|
||||
memory_service = getattr(request.app.state, "memory_service", None)
|
||||
facts = memory_service.list_facts() if memory_service is not None else []
|
||||
|
||||
# Extract query from the last user message
|
||||
query_text = ""
|
||||
for m in reversed(request_body.messages):
|
||||
@@ -118,6 +141,7 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
|
||||
if query_text:
|
||||
messages = _to_messages(request_body.messages)
|
||||
messages = _ensure_identity_prompt(messages, config)
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=config.memory.context_top_k,
|
||||
min_score=config.memory.context_min_score,
|
||||
@@ -128,22 +152,35 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
messages,
|
||||
memory_backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
# Rebuild request messages from enriched Message objects
|
||||
if len(enriched) > len(messages):
|
||||
from openjarvis.server.models import ChatMessage
|
||||
# Rebuild after identity/context merging so downstream engine
|
||||
# adapters always receive exactly one system message.
|
||||
from openjarvis.server.models import ChatMessage
|
||||
|
||||
new_msgs = []
|
||||
for msg in enriched:
|
||||
new_msgs.append(
|
||||
ChatMessage(
|
||||
role=msg.role.value,
|
||||
content=msg.content,
|
||||
name=msg.name,
|
||||
tool_call_id=getattr(msg, "tool_call_id", None),
|
||||
)
|
||||
new_msgs = []
|
||||
for msg in enriched:
|
||||
new_msgs.append(
|
||||
ChatMessage(
|
||||
role=msg.role.value,
|
||||
content=msg.content,
|
||||
name=msg.name,
|
||||
tool_calls=[
|
||||
{
|
||||
"id": tool_call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_call.name,
|
||||
"arguments": tool_call.arguments,
|
||||
},
|
||||
}
|
||||
for tool_call in (msg.tool_calls or [])
|
||||
]
|
||||
or None,
|
||||
tool_call_id=getattr(msg, "tool_call_id", None),
|
||||
)
|
||||
request_body.messages = new_msgs
|
||||
)
|
||||
request_body.messages = new_msgs
|
||||
except Exception:
|
||||
logging.getLogger("openjarvis.server").debug(
|
||||
"Memory context injection failed",
|
||||
@@ -188,12 +225,14 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
# When the client passes `tools`, stream the model's raw
|
||||
# OpenAI-compat function-calling decision directly from the engine
|
||||
# (bypassing the agent) — the streaming mirror of the non-streaming
|
||||
# #454 fix. Routing tools through the agent stream bridge ignored
|
||||
# `request_body.tools`, ran the agent's own tool loop, and
|
||||
# word-split generic filler content into fake token deltas, so the
|
||||
# caller's tool_calls were dropped entirely (the streaming analog of
|
||||
# #414). For plain chat (no tools), stream token-by-token directly
|
||||
# from the engine for true real-time output.
|
||||
# #454 fix. Routing client-supplied tools through a server-side agent
|
||||
# would execute the agent's different tool set and drop the raw tool
|
||||
# call the caller expects (#414).
|
||||
#
|
||||
# Without client-supplied tools, keep streaming requests on the
|
||||
# configured server agent so its server-side tool loop is available
|
||||
# to the desktop UI and other stream:true clients (#735). Fall back to
|
||||
# direct token streaming when no tool-bearing agent is configured.
|
||||
if request_body.tools:
|
||||
return await _handle_stream_tools(
|
||||
engine,
|
||||
@@ -204,6 +243,16 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
bus=getattr(request.app.state, "bus", None),
|
||||
memory_service=getattr(request.app.state, "memory_service", None),
|
||||
)
|
||||
if agent is not None and getattr(agent, "_tools", None):
|
||||
return await _handle_agent_stream(
|
||||
agent,
|
||||
model,
|
||||
request_body,
|
||||
complexity_info,
|
||||
trace_store=getattr(request.app.state, "trace_store", None),
|
||||
bus=getattr(request.app.state, "bus", None),
|
||||
memory_service=getattr(request.app.state, "memory_service", None),
|
||||
)
|
||||
return await _handle_stream(
|
||||
engine,
|
||||
model,
|
||||
@@ -325,6 +374,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,
|
||||
@@ -507,6 +584,114 @@ def _handle_agent(
|
||||
)
|
||||
|
||||
|
||||
async def _handle_agent_stream(
|
||||
agent,
|
||||
model: str,
|
||||
req: ChatCompletionRequest,
|
||||
complexity_info=None,
|
||||
*,
|
||||
trace_store=None,
|
||||
bus=None,
|
||||
memory_service=None,
|
||||
):
|
||||
"""Run the configured agent and return its result as an SSE response.
|
||||
|
||||
Agents own the tool-execution loop, which is synchronous today. Run that
|
||||
loop in a worker thread and stream its final answer once complete. This
|
||||
keeps ``stream:true`` clients (including the desktop UI) on the same agent
|
||||
and configured toolkit as non-streaming requests instead of bypassing the
|
||||
agent and silently dropping server-side tools.
|
||||
|
||||
Requests that explicitly supply OpenAI ``tools`` continue to use
|
||||
``_handle_stream_tools`` so their raw tool-call deltas are preserved.
|
||||
"""
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
||||
query_text = ""
|
||||
for message in reversed(req.messages):
|
||||
if message.role == "user" and message.content:
|
||||
query_text = message.content
|
||||
break
|
||||
|
||||
async def generate():
|
||||
first_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
model=model,
|
||||
choices=[StreamChoice(delta=DeltaMessage(role="assistant"))],
|
||||
)
|
||||
yield f"data: {first_chunk.model_dump_json()}\n\n"
|
||||
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
_handle_agent,
|
||||
agent,
|
||||
model,
|
||||
req,
|
||||
complexity_info,
|
||||
trace_store=trace_store,
|
||||
bus=bus,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.getLogger("openjarvis.server").error(
|
||||
"Agent stream error: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
error_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
model=model,
|
||||
choices=[
|
||||
StreamChoice(
|
||||
delta=DeltaMessage(
|
||||
content=f"Sorry, an error occurred: {exc}",
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
yield f"data: {error_chunk.model_dump_json()}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
content = _response_content(response)
|
||||
if content:
|
||||
content_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
model=model,
|
||||
choices=[StreamChoice(delta=DeltaMessage(content=content))],
|
||||
)
|
||||
yield f"data: {content_chunk.model_dump_json()}\n\n"
|
||||
|
||||
import json as _json
|
||||
|
||||
finish_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
model=model,
|
||||
choices=[
|
||||
StreamChoice(delta=DeltaMessage(), finish_reason="stop"),
|
||||
],
|
||||
)
|
||||
finish_data = _json.loads(finish_chunk.model_dump_json())
|
||||
finish_data["usage"] = response.usage.model_dump()
|
||||
if complexity_info is not None:
|
||||
finish_data["complexity"] = complexity_info.model_dump()
|
||||
yield f"data: {_json.dumps(finish_data)}\n\n"
|
||||
|
||||
_record_completed_exchange(
|
||||
memory_service,
|
||||
query_text,
|
||||
content,
|
||||
bus=bus,
|
||||
source="server.chat.stream",
|
||||
)
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
|
||||
async def _handle_stream_tools(
|
||||
engine,
|
||||
model: str,
|
||||
@@ -530,12 +715,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 +801,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"
|
||||
@@ -649,19 +835,14 @@ async def _handle_stream(
|
||||
):
|
||||
"""Stream response using SSE format.
|
||||
|
||||
This path streams straight from the engine, bypassing the agent /
|
||||
This no-agent fallback streams straight from the engine, bypassing the
|
||||
``TraceCollector``. When *trace_store* is set we accumulate the streamed
|
||||
tokens and record a minimal ``Trace`` once the stream completes
|
||||
successfully — otherwise streamed chats (the desktop GUI's main path)
|
||||
would never populate ``traces.db``.
|
||||
successfully.
|
||||
"""
|
||||
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 +857,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 +965,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 +998,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 +1015,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
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,19 +23,19 @@ from openjarvis.core.types import TOKEN_COUNTING_VERSION # noqa: E402,F401
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CLOUD_PRICING: Dict[str, Dict[str, float]] = {
|
||||
"gpt-5.3": {
|
||||
"input_per_1m": 2.00,
|
||||
"output_per_1m": 10.00,
|
||||
"label": "GPT-5.3",
|
||||
"gpt-5.6-sol": {
|
||||
"input_per_1m": 5.00,
|
||||
"output_per_1m": 30.00,
|
||||
"label": "GPT-5.6 Sol",
|
||||
"provider": "OpenAI",
|
||||
"params_b": 200.0,
|
||||
"energy_wh_per_1k_tokens": 0.4,
|
||||
"flops_per_token": 3.0e12,
|
||||
},
|
||||
"claude-opus-4.6": {
|
||||
"input_per_1m": 5.00,
|
||||
"output_per_1m": 25.00,
|
||||
"label": "Claude Opus 4.6",
|
||||
"claude-fable-5": {
|
||||
"input_per_1m": 10.00,
|
||||
"output_per_1m": 50.00,
|
||||
"label": "Claude Fable 5",
|
||||
"provider": "Anthropic",
|
||||
"params_b": 137.0,
|
||||
"energy_wh_per_1k_tokens": 0.5,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -110,6 +110,12 @@ class AgentStreamBridge:
|
||||
|
||||
def _format_named_event(self, name: str, data: dict) -> str:
|
||||
"""Format an SSE event with an explicit ``event:`` field."""
|
||||
if name == "tool_call_start" and not isinstance(data.get("arguments"), str):
|
||||
# The in-process event bus uses parsed arguments for trace/eval
|
||||
# consumers, while the web SSE contract expects their JSON text.
|
||||
# Copy before normalizing so other subscribers keep the object.
|
||||
data = dict(data)
|
||||
data["arguments"] = json.dumps(data.get("arguments"))
|
||||
return f"event: {name}\ndata: {json.dumps(data)}\n\n"
|
||||
|
||||
def _run_agent(self) -> object:
|
||||
@@ -240,62 +246,15 @@ class AgentStreamBridge:
|
||||
{"results": tool_results_data},
|
||||
)
|
||||
|
||||
# Stream content using real LLM token streaming via
|
||||
# engine.stream_full() when the engine is available.
|
||||
# ``agent.run()`` already produced the authoritative, grounded
|
||||
# response. Do not call the engine again here: a second inference
|
||||
# would not have the agent's system prompt, tool transcript, or
|
||||
# other internal context and could therefore contradict the
|
||||
# result reported by the agent events. Replay the final content
|
||||
# in chunks so the OpenAI-compatible streaming response stays
|
||||
# consistent with the completed agent run.
|
||||
content = agent_result.content or ""
|
||||
engine = getattr(self._agent, "_engine", None)
|
||||
used_real_streaming = False
|
||||
|
||||
if engine is not None and hasattr(engine, "stream_full") and content:
|
||||
# Re-stream using the engine for real token delivery.
|
||||
# Build the same messages the agent used for its final turn.
|
||||
try:
|
||||
from openjarvis.core.types import Message as MsgType
|
||||
from openjarvis.core.types import Role as RoleType
|
||||
|
||||
replay_messages = []
|
||||
for m in self._request.messages:
|
||||
role = (
|
||||
RoleType(m.role)
|
||||
if m.role in {r.value for r in RoleType}
|
||||
else RoleType.USER
|
||||
)
|
||||
replay_messages.append(
|
||||
MsgType(
|
||||
role=role,
|
||||
content=m.content or "",
|
||||
name=m.name,
|
||||
tool_call_id=m.tool_call_id,
|
||||
)
|
||||
)
|
||||
|
||||
async for sc in engine.stream_full(
|
||||
replay_messages,
|
||||
model=self._model,
|
||||
):
|
||||
if sc.content:
|
||||
chunk = ChatCompletionChunk(
|
||||
id=self._chunk_id,
|
||||
model=self._model,
|
||||
choices=[
|
||||
StreamChoice(
|
||||
delta=DeltaMessage(content=sc.content),
|
||||
)
|
||||
],
|
||||
)
|
||||
yield f"data: {chunk.model_dump_json()}\n\n"
|
||||
used_real_streaming = True
|
||||
except Exception as stream_exc:
|
||||
import logging as _logging
|
||||
|
||||
_logger = _logging.getLogger("openjarvis.server")
|
||||
_logger.warning(
|
||||
"Real streaming failed, falling back to word replay: %s",
|
||||
stream_exc,
|
||||
)
|
||||
|
||||
# Fallback: word-by-word replay if real streaming was not used
|
||||
if not used_real_streaming and content:
|
||||
if content:
|
||||
words = content.split(" ")
|
||||
for i, word in enumerate(words):
|
||||
token = word if i == 0 else " " + word
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -40,8 +40,9 @@ class QueryOrchestrator:
|
||||
|
||||
messages = [Message(role=Role.USER, content=query)]
|
||||
|
||||
if context and s.memory_backend and s.config.agent.context_from_memory:
|
||||
if context and s.config.agent.context_from_memory:
|
||||
try:
|
||||
from openjarvis.memory import load_configured_facts
|
||||
from openjarvis.tools.storage.context import (
|
||||
ContextConfig,
|
||||
inject_context,
|
||||
@@ -52,11 +53,13 @@ class QueryOrchestrator:
|
||||
min_score=s.config.memory.context_min_score,
|
||||
max_context_tokens=s.config.memory.context_max_tokens,
|
||||
)
|
||||
facts = load_configured_facts(s.config)
|
||||
messages = inject_context(
|
||||
query,
|
||||
messages,
|
||||
s.memory_backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to inject memory context: %s", exc)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,6 +6,7 @@ and filtering operations that BM25 search cannot handle.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -16,10 +17,25 @@ from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
_MAX_ROWS = 50
|
||||
|
||||
# Write keywords are matched on word boundaries (mirroring db_query.py) so that
|
||||
# a read-only SELECT is not rejected just because a column/alias/literal happens
|
||||
# to contain one as a substring (e.g. "deleted_at", "created_at").
|
||||
_FORBIDDEN_RE = re.compile(
|
||||
r"\b(DROP|DELETE|INSERT|UPDATE|ALTER|CREATE|TRUNCATE|ATTACH)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# String literals are stripped before the keyword scan so that data mentioning
|
||||
# a write keyword (e.g. WHERE content LIKE '%delete%') is not rejected. A write
|
||||
# "hidden" in a literal still cannot execute: the query must start with SELECT
|
||||
# and sqlite3 refuses multi-statement strings.
|
||||
_STRING_LITERAL_RE = re.compile(r"'[^']*'")
|
||||
|
||||
_SCHEMA_DESCRIPTION = (
|
||||
"Table: knowledge_chunks\n"
|
||||
"Columns: id, content, source, doc_type, doc_id, title, author, "
|
||||
"participants, timestamp, thread_id, url, metadata, chunk_index"
|
||||
"participants, timestamp, thread_id, url, metadata, chunk_index, "
|
||||
"created_at, deleted_at (NULL for active rows)"
|
||||
)
|
||||
|
||||
|
||||
@@ -84,21 +100,20 @@ class KnowledgeSQLTool(BaseTool):
|
||||
success=False,
|
||||
)
|
||||
|
||||
_FORBIDDEN = ("DROP", "DELETE", "INSERT", "UPDATE", "ALTER", "CREATE", "ATTACH")
|
||||
for forbidden in _FORBIDDEN:
|
||||
if forbidden in normalized:
|
||||
return ToolResult(
|
||||
tool_name="knowledge_sql",
|
||||
content=(
|
||||
f"Query contains forbidden keyword: {forbidden}."
|
||||
" Only SELECT queries allowed."
|
||||
),
|
||||
success=False,
|
||||
)
|
||||
forbidden = _FORBIDDEN_RE.search(_STRING_LITERAL_RE.sub("''", query))
|
||||
if forbidden:
|
||||
return ToolResult(
|
||||
tool_name="knowledge_sql",
|
||||
content=(
|
||||
f"Query contains forbidden keyword: {forbidden.group(1).upper()}."
|
||||
" Only SELECT queries allowed."
|
||||
),
|
||||
success=False,
|
||||
)
|
||||
|
||||
try:
|
||||
rows = self._store._conn.execute(query).fetchmany(_MAX_ROWS)
|
||||
except sqlite3.OperationalError as exc:
|
||||
except sqlite3.Error as exc:
|
||||
return ToolResult(
|
||||
tool_name="knowledge_sql",
|
||||
content=f"SQL error: {exc}",
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, List, Optional, Sequence
|
||||
|
||||
from openjarvis.core.events import EventType, get_event_bus
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openjarvis.memory.store import Fact
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ContextConfig:
|
||||
@@ -46,28 +49,75 @@ def format_context(results: List[RetrievalResult]) -> str:
|
||||
|
||||
def build_context_message(
|
||||
results: List[RetrievalResult],
|
||||
facts: Sequence[Fact] = (),
|
||||
) -> Message:
|
||||
"""Create a system message with formatted context."""
|
||||
context_text = format_context(results)
|
||||
content = (
|
||||
"The following context was retrieved from the knowledge"
|
||||
" base. Use it to inform your response, citing sources"
|
||||
" where applicable:\n\n" + context_text
|
||||
sections = []
|
||||
if facts:
|
||||
fact_text = "\n".join(f"- {fact.text}" for fact in facts)
|
||||
sections.append(
|
||||
"The following durable facts were remembered from prior "
|
||||
"conversations. Use them when relevant to the user's request:\n\n"
|
||||
+ fact_text
|
||||
)
|
||||
if results:
|
||||
sections.append(
|
||||
"The following context was retrieved from the knowledge"
|
||||
" base. Use it to inform your response, citing sources"
|
||||
" where applicable:\n\n" + format_context(results)
|
||||
)
|
||||
content = "\n\n".join(sections)
|
||||
return Message(
|
||||
role=Role.SYSTEM,
|
||||
content=content,
|
||||
metadata={"memory_context": True},
|
||||
)
|
||||
return Message(role=Role.SYSTEM, content=content)
|
||||
|
||||
|
||||
def _merge_context_message(
|
||||
messages: List[Message],
|
||||
context_message: Message,
|
||||
) -> List[Message]:
|
||||
"""Return a copy with context folded into the existing system prompt."""
|
||||
system_messages = [message for message in messages if message.role == Role.SYSTEM]
|
||||
if not system_messages:
|
||||
return [context_message, *messages]
|
||||
|
||||
content = "\n\n".join(
|
||||
part
|
||||
for part in (
|
||||
*(message.text for message in system_messages),
|
||||
context_message.text,
|
||||
)
|
||||
if part
|
||||
)
|
||||
combined = replace(system_messages[0], content=content)
|
||||
merged: List[Message] = []
|
||||
inserted = False
|
||||
for message in messages:
|
||||
if message.role == Role.SYSTEM:
|
||||
if not inserted:
|
||||
merged.append(combined)
|
||||
inserted = True
|
||||
continue
|
||||
merged.append(message)
|
||||
return merged
|
||||
|
||||
|
||||
def inject_context(
|
||||
query: str,
|
||||
messages: List[Message],
|
||||
backend: MemoryBackend,
|
||||
backend: Optional[MemoryBackend],
|
||||
*,
|
||||
config: Optional[ContextConfig] = None,
|
||||
facts: Sequence[Fact] = (),
|
||||
) -> List[Message]:
|
||||
"""Retrieve relevant context and prepend it to *messages*.
|
||||
|
||||
Returns a **new** list — the original list is not mutated.
|
||||
If no results pass the score threshold, returns the original
|
||||
Automatic-memory facts are included independently of the retrieval
|
||||
backend, so persisted facts remain recallable even when the document
|
||||
store is empty. If no facts or results are available, returns the original
|
||||
messages unchanged.
|
||||
|
||||
Parameters
|
||||
@@ -77,33 +127,55 @@ def inject_context(
|
||||
messages:
|
||||
The existing message list.
|
||||
backend:
|
||||
The memory backend to search.
|
||||
The memory backend to search, or ``None`` when only facts are available.
|
||||
config:
|
||||
Context injection settings (uses defaults if ``None``).
|
||||
facts:
|
||||
Durable facts captured by the automatic memory service.
|
||||
"""
|
||||
cfg = config or ContextConfig()
|
||||
if not cfg.enabled:
|
||||
return messages
|
||||
|
||||
results = backend.retrieve(query, top_k=cfg.top_k)
|
||||
results = backend.retrieve(query, top_k=cfg.top_k) if backend is not None else []
|
||||
|
||||
# Filter by minimum score
|
||||
results = [r for r in results if r.score >= cfg.min_score]
|
||||
|
||||
if not results:
|
||||
return messages
|
||||
|
||||
# Truncate to max_context_tokens
|
||||
truncated: List[RetrievalResult] = []
|
||||
# When both sources have data, cap facts at half the total budget so they
|
||||
# cannot starve query-specific document retrieval. Unused fact budget is
|
||||
# still available to documents. Newest facts win within the fact budget.
|
||||
fact_budget = cfg.max_context_tokens
|
||||
if results:
|
||||
fact_budget //= 2
|
||||
selected_facts: List[Fact] = []
|
||||
total_tokens = 0
|
||||
for fact in reversed(facts):
|
||||
tokens = _count_tokens(fact.text)
|
||||
if total_tokens + tokens > fact_budget:
|
||||
continue
|
||||
selected_facts.append(fact)
|
||||
total_tokens += tokens
|
||||
|
||||
# Fill the remaining context budget with retrieved documents.
|
||||
truncated: List[RetrievalResult] = []
|
||||
for r in results:
|
||||
tokens = _count_tokens(r.content)
|
||||
if total_tokens + tokens > cfg.max_context_tokens:
|
||||
# A large top result should not disappear solely because facts
|
||||
# consumed their reserved share. Prefer that result when it fits
|
||||
# the total budget on its own.
|
||||
if not truncated and selected_facts and tokens <= cfg.max_context_tokens:
|
||||
selected_facts = []
|
||||
total_tokens = 0
|
||||
else:
|
||||
break
|
||||
if total_tokens + tokens > cfg.max_context_tokens:
|
||||
break
|
||||
truncated.append(r)
|
||||
total_tokens += tokens
|
||||
|
||||
if not truncated:
|
||||
if not selected_facts and not truncated:
|
||||
return messages
|
||||
|
||||
# Publish event
|
||||
@@ -114,13 +186,14 @@ def inject_context(
|
||||
"context_injection": True,
|
||||
"query": query,
|
||||
"num_results": len(truncated),
|
||||
"num_facts": len(selected_facts),
|
||||
"total_tokens": total_tokens,
|
||||
},
|
||||
)
|
||||
|
||||
# Build context message and prepend
|
||||
ctx_msg = build_context_message(truncated)
|
||||
return [ctx_msg] + list(messages)
|
||||
ctx_msg = build_context_message(truncated, selected_facts)
|
||||
return _merge_context_message(messages, ctx_msg)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -205,6 +205,40 @@ class TestBuildMessages:
|
||||
assert messages[1].content == "prev"
|
||||
assert messages[2].content == "new"
|
||||
|
||||
def test_prompt_builder_merges_context_system_message(self):
|
||||
engine = MagicMock()
|
||||
prompt_builder = MagicMock()
|
||||
prompt_builder.build.return_value = "You are OpenJarvis."
|
||||
agent = _ConcreteAgent(engine, "m", prompt_builder=prompt_builder)
|
||||
conv = Conversation()
|
||||
conv.add(
|
||||
Message(
|
||||
role=Role.SYSTEM,
|
||||
content="Remember: user likes jazz.",
|
||||
metadata={"memory_context": True},
|
||||
)
|
||||
)
|
||||
ctx = AgentContext(conversation=conv)
|
||||
|
||||
messages = agent._build_messages("new", ctx)
|
||||
|
||||
system_messages = [m for m in messages if m.role == Role.SYSTEM]
|
||||
assert len(system_messages) == 1
|
||||
assert "You are OpenJarvis." in system_messages[0].content
|
||||
assert "user likes jazz" in system_messages[0].content
|
||||
|
||||
def test_prompt_builder_preserves_caller_system_context(self):
|
||||
engine = MagicMock()
|
||||
prompt_builder = MagicMock()
|
||||
prompt_builder.build.return_value = "Agent instructions."
|
||||
agent = _ConcreteAgent(engine, "m", prompt_builder=prompt_builder)
|
||||
conv = Conversation()
|
||||
conv.add(Message(role=Role.SYSTEM, content="You are helpful."))
|
||||
|
||||
messages = agent._build_messages("new", AgentContext(conversation=conv))
|
||||
|
||||
assert any(message.content == "You are helpful." for message in messages)
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
def test_delegates_to_engine(self):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 == []
|
||||
@@ -18,6 +18,7 @@ from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.core.events import Event, EventBus, EventType
|
||||
from openjarvis.core.registry import AgentRegistry, ToolRegistry
|
||||
from openjarvis.core.types import ToolCall, ToolResult
|
||||
from openjarvis.memory.store import LocalFactStore
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
|
||||
@@ -97,6 +98,79 @@ class TestReadInput:
|
||||
|
||||
|
||||
class TestChatAgents:
|
||||
def test_direct_chat_injects_auto_memory_facts(self, tmp_path) -> None:
|
||||
facts_path = tmp_path / "facts.jsonl"
|
||||
LocalFactStore(facts_path).add(
|
||||
"The user's favorite color is blue",
|
||||
source="auto",
|
||||
)
|
||||
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.return_value = {"content": "Blue."}
|
||||
config = JarvisConfig()
|
||||
config.intelligence.default_model = "test-model"
|
||||
config.memory.enabled = True
|
||||
config.memory.facts_path = str(facts_path)
|
||||
config.agent.context_from_memory = True
|
||||
|
||||
with (
|
||||
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
|
||||
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
|
||||
patch("openjarvis.intelligence.register_builtin_models"),
|
||||
patch("openjarvis.memory.build_memory_service", return_value=None),
|
||||
patch("openjarvis.cli.ask._get_memory_backend", return_value=None),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
chat,
|
||||
["--model", "test-model"],
|
||||
input="What is my favorite color?\n/quit\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
messages = engine.generate.call_args.args[0]
|
||||
assert messages[0].role.value == "system"
|
||||
assert "favorite color is blue" in messages[0].content
|
||||
|
||||
def test_chat_generation_survives_fact_store_failure(self) -> None:
|
||||
class _FailingMemoryService:
|
||||
def start(self) -> None:
|
||||
pass
|
||||
|
||||
def stop(self, timeout: float = 2.0) -> None:
|
||||
pass
|
||||
|
||||
def list_facts(self):
|
||||
raise OSError("fact store unavailable")
|
||||
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.return_value = {"content": "Still working."}
|
||||
config = JarvisConfig()
|
||||
config.intelligence.default_model = "test-model"
|
||||
config.memory.enabled = True
|
||||
config.agent.context_from_memory = True
|
||||
|
||||
with (
|
||||
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
|
||||
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
|
||||
patch("openjarvis.intelligence.register_builtin_models"),
|
||||
patch(
|
||||
"openjarvis.memory.build_memory_service",
|
||||
return_value=_FailingMemoryService(),
|
||||
),
|
||||
patch("openjarvis.cli.ask._get_memory_backend", return_value=None),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
chat,
|
||||
["--model", "test-model"],
|
||||
input="hello\n/quit\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Still working." in result.output
|
||||
engine.generate.assert_called_once()
|
||||
|
||||
def test_simple_agent_does_not_receive_tool_only_kwargs(self) -> None:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_editable_git_install_detected(tmp_path, monkeypatch):
|
||||
info = detect_install()
|
||||
assert info.kind == "editable-git"
|
||||
assert "git pull" in info.upgrade_command
|
||||
assert "uv sync" in info.upgrade_command
|
||||
assert info.upgrade_command.endswith("uv sync --inexact")
|
||||
assert info.repo_root == repo
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ def _mock_info(kind: str = "pypi") -> InstallInfo:
|
||||
upgrade_command={
|
||||
"pypi": "pip install --upgrade openjarvis",
|
||||
"uv-tool": "uv tool upgrade openjarvis",
|
||||
"editable-git": "cd /tmp/repo && git pull && uv sync",
|
||||
"editable-git": "cd /tmp/repo && git pull && uv sync --inexact",
|
||||
"unknown": "pip install --upgrade openjarvis",
|
||||
}[kind],
|
||||
)
|
||||
@@ -90,6 +90,26 @@ def test_editable_git_uses_shell_true():
|
||||
assert kwargs.get("shell") is True
|
||||
|
||||
|
||||
def test_editable_git_preserves_extra_dependencies():
|
||||
"""The update sync must not remove packages from prior extras/groups."""
|
||||
mock_proc = MagicMock(returncode=0)
|
||||
with (
|
||||
patch(
|
||||
"openjarvis.cli.self_update_cmd.detect_install",
|
||||
return_value=_mock_info("editable-git"),
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.self_update_cmd.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
) as mock_run,
|
||||
):
|
||||
result = CliRunner().invoke(self_update, ["-y"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "uv sync --inexact" in result.output
|
||||
assert "uv sync --inexact" in mock_run.call_args.args[0]
|
||||
|
||||
|
||||
def test_failed_upgrade_propagates_exit_code():
|
||||
mock_proc = MagicMock(returncode=3)
|
||||
with (
|
||||
|
||||
@@ -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):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user