mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
215ab76e5f | ||
|
|
0812ee0701 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "142,750",
|
||||
"message": "185,599",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 142750,
|
||||
"last_updated": "2026-07-06T10:14:21Z",
|
||||
"total_clones": 185599,
|
||||
"last_updated": "2026-08-10T07:26:44Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -102,6 +102,41 @@
|
||||
"2026-07-02": 593,
|
||||
"2026-07-03": 537,
|
||||
"2026-07-04": 411,
|
||||
"2026-07-05": 485
|
||||
"2026-07-05": 485,
|
||||
"2026-07-06": 555,
|
||||
"2026-07-07": 905,
|
||||
"2026-07-08": 1171,
|
||||
"2026-07-09": 1857,
|
||||
"2026-07-10": 1181,
|
||||
"2026-07-11": 2185,
|
||||
"2026-07-12": 1917,
|
||||
"2026-07-13": 2102,
|
||||
"2026-07-14": 2337,
|
||||
"2026-07-15": 2362,
|
||||
"2026-07-16": 2497,
|
||||
"2026-07-17": 1773,
|
||||
"2026-07-18": 1542,
|
||||
"2026-07-19": 1445,
|
||||
"2026-07-20": 1481,
|
||||
"2026-07-21": 1528,
|
||||
"2026-07-22": 1529,
|
||||
"2026-07-23": 1209,
|
||||
"2026-07-24": 1118,
|
||||
"2026-07-25": 928,
|
||||
"2026-07-26": 740,
|
||||
"2026-07-27": 799,
|
||||
"2026-07-28": 665,
|
||||
"2026-07-29": 745,
|
||||
"2026-07-30": 591,
|
||||
"2026-07-31": 783,
|
||||
"2026-08-01": 567,
|
||||
"2026-08-02": 1248,
|
||||
"2026-08-03": 724,
|
||||
"2026-08-04": 708,
|
||||
"2026-08-05": 647,
|
||||
"2026-08-06": 604,
|
||||
"2026-08-07": 624,
|
||||
"2026-08-08": 706,
|
||||
"2026-08-09": 1076
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<p><i>Personal AI, On Personal Devices.</i></p>
|
||||
|
||||
<p>
|
||||
<a href="https://scalingintelligence.stanford.edu/blogs/openjarvis/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
|
||||
<a href="https://arxiv.org/abs/2605.17172"><img src="https://img.shields.io/badge/arXiv-2605.17172-b31b1b.svg" alt="arXiv"></a>
|
||||
<a href="https://openjarvis.stanford.edu/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
|
||||
<a href="https://open-jarvis.github.io/OpenJarvis/"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
|
||||
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python">
|
||||
<img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License">
|
||||
@@ -23,7 +24,7 @@
|
||||
|
||||
> **[Documentation](https://open-jarvis.github.io/OpenJarvis/)**
|
||||
>
|
||||
> **[Project Site](https://scalingintelligence.stanford.edu/blogs/openjarvis/)**
|
||||
> **[Project Site](https://openjarvis.stanford.edu/)**
|
||||
>
|
||||
> **[Paper](https://arxiv.org/abs/2605.17172)**
|
||||
>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Full system access: unrestricted shell and filesystem
|
||||
# Copy to ~/.openjarvis/config.toml
|
||||
#
|
||||
# WARNING: shell_exec runs arbitrary commands as your user. No command
|
||||
# allowlist, no denylist, no working-directory restriction. file_read and
|
||||
# file_write aren't restricted to any directory either. Only enable what you
|
||||
# actually want the agent to have. tools.enabled is the whole permission grant;
|
||||
# there's no second allowlist to configure.
|
||||
#
|
||||
# On macOS, this config alone does not reach TCC-protected data (Messages,
|
||||
# Mail, Photos, Safari). That requires Full Disk Access granted to the process
|
||||
# hosting the backend. See docs/user-guide/system-access.md.
|
||||
#
|
||||
# Usage:
|
||||
# jarvis ask "What's using the most disk space in my home directory?"
|
||||
# jarvis chat # prompts before each shell_exec call
|
||||
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[intelligence]
|
||||
default_model = "qwen3.5:9b"
|
||||
|
||||
[agent]
|
||||
default_agent = "orchestrator"
|
||||
max_turns = 10
|
||||
|
||||
[tools]
|
||||
enabled = [
|
||||
"shell_exec",
|
||||
"file_read",
|
||||
"file_write",
|
||||
"apply_patch",
|
||||
"code_interpreter",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"think",
|
||||
"calculator",
|
||||
]
|
||||
@@ -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
@@ -215,7 +215,7 @@ OpenJarvis is built around five composable layers. Each has a clean interface an
|
||||
|
||||
OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.ai/), a research initiative studying the efficiency of on-device AI systems. Developed at [Hazy Research](https://hazyresearch.stanford.edu/) and the [Scaling Intelligence Lab](https://scalingintelligence.stanford.edu/) at [Stanford SAIL](https://ai.stanford.edu/).
|
||||
|
||||
Read the [blog post](https://scalingintelligence.stanford.edu/blogs/openjarvis/) for the full research motivation, architecture details, and experimental results.
|
||||
Read the [blog post](https://openjarvis.stanford.edu/) for the full research motivation, architecture details, and experimental results.
|
||||
|
||||
## Citation
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -89,7 +89,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(
|
||||
|
||||
@@ -22,6 +22,8 @@ export function ChatArea() {
|
||||
const navigate = useNavigate();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const shouldAutoScroll = useRef(true);
|
||||
const wasStreaming = useRef(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
|
||||
// Check if any data sources are connected
|
||||
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
|
||||
@@ -34,15 +36,34 @@ export function ChatArea() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Sending a message always pins the view to the bottom, even if the
|
||||
// user had scrolled up to read earlier messages.
|
||||
if (streamState.isStreaming && !wasStreaming.current) {
|
||||
shouldAutoScroll.current = true;
|
||||
}
|
||||
wasStreaming.current = streamState.isStreaming;
|
||||
if (shouldAutoScroll.current && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamState.content]);
|
||||
}, [messages, streamState.content, streamState.isStreaming]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
const distance = scrollHeight - scrollTop - clientHeight;
|
||||
const scrolledUp = scrollTop < lastScrollTop.current;
|
||||
lastScrollTop.current = scrollTop;
|
||||
if (scrolledUp && distance >= 1) {
|
||||
// Any upward scroll away from the bottom stops autoscroll immediately,
|
||||
// so streaming content never fights the user (no jitter). Sub-1px
|
||||
// upward movement (elastic bounce settling at the bottom) is ignored.
|
||||
shouldAutoScroll.current = false;
|
||||
} else if (!scrolledUp) {
|
||||
// Re-engage when scrolled back to the bottom. < 2 rather than < 1:
|
||||
// at fractional zoom levels the at-bottom residual can reach 1px,
|
||||
// which would otherwise leave autoscroll permanently disengaged.
|
||||
shouldAutoScroll.current = distance < 2;
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = messages.length === 0 && !streamState.isStreaming;
|
||||
|
||||
@@ -466,7 +466,10 @@ export function InputArea() {
|
||||
}
|
||||
const totalMs = Date.now() - startTime;
|
||||
const _CLOUD_PREFIXES = ['gpt-', 'o1-', 'o3-', 'o4-', 'claude-', 'gemini-', 'openrouter/', 'MiniMax-', 'chatgpt-'];
|
||||
const engineLabel = _CLOUD_PREFIXES.some(p => selectedModel.startsWith(p)) ? 'cloud' : 'ollama';
|
||||
const selectedOwner = useAppStore.getState().models.find((m) => m.id === selectedModel)?.owned_by;
|
||||
const engineLabel = selectedOwner === 'litellm'
|
||||
? 'litellm'
|
||||
: _CLOUD_PREFIXES.some(p => selectedModel.startsWith(p)) ? 'cloud' : 'ollama';
|
||||
const telemetry: MessageTelemetry = {
|
||||
engine: engineLabel,
|
||||
model_id: selectedModel,
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -143,18 +143,17 @@ export function CommandPalette() {
|
||||
}
|
||||
}, [pullSuccess]);
|
||||
|
||||
const handleSelect = async (modelId: string) => {
|
||||
const handleSelect = async (modelId: string, owner?: string) => {
|
||||
const previousModel = selectedModel;
|
||||
setSelectedModel(modelId);
|
||||
setCommandPaletteOpen(false);
|
||||
|
||||
if (modelId !== previousModel) {
|
||||
const { createConversation, setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
createConversation(modelId);
|
||||
const { setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
setModelLoading(true);
|
||||
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `Switching to ${modelId}...` });
|
||||
try {
|
||||
await preloadModel(modelId);
|
||||
await preloadModel(modelId, owner);
|
||||
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `${modelId} loaded` });
|
||||
} catch (e: any) {
|
||||
addLogEntry({ timestamp: Date.now(), level: 'error', category: 'model', message: `Failed to load ${modelId}: ${e.message}` });
|
||||
@@ -256,7 +255,8 @@ export function CommandPalette() {
|
||||
setSelectedIdx((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && tab === 'installed' && filtered.length > 0) {
|
||||
e.preventDefault();
|
||||
handleSelect((filtered[selectedIdx] as any).id);
|
||||
const model = filtered[selectedIdx] as (typeof models)[number];
|
||||
handleSelect(model.id, model.owned_by);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -366,11 +366,15 @@ export function CommandPalette() {
|
||||
onMouseEnter={() => setSelectedIdx(idx)}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSelect(model.id)}
|
||||
onClick={() => handleSelect(model.id, model.owned_by)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left cursor-pointer"
|
||||
style={{ background: 'none', border: 'none', padding: 0 }}
|
||||
>
|
||||
<Cpu size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
|
||||
{model.owned_by === 'litellm' ? (
|
||||
<Cloud size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
|
||||
) : (
|
||||
<Cpu size={16} style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text-tertiary)' }} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate" style={{ color: isActive ? 'var(--color-accent)' : 'var(--color-text)', fontWeight: isActive ? 500 : 400 }}>
|
||||
{model.id}
|
||||
@@ -382,17 +386,19 @@ export function CommandPalette() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(model.id)}
|
||||
disabled={isDeleting}
|
||||
className="p-1 rounded transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--color-text-tertiary)', opacity: 0 }}
|
||||
title="Delete model"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = 'var(--color-error)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; e.currentTarget.style.color = 'var(--color-text-tertiary)'; }}
|
||||
>
|
||||
{isDeleting ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
|
||||
</button>
|
||||
{model.owned_by !== 'litellm' && (
|
||||
<button
|
||||
onClick={() => handleDelete(model.id)}
|
||||
disabled={isDeleting}
|
||||
className="p-1 rounded transition-colors cursor-pointer"
|
||||
style={{ color: 'var(--color-text-tertiary)', opacity: 0 }}
|
||||
title="Delete model"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = 'var(--color-error)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; e.currentTarget.style.color = 'var(--color-text-tertiary)'; }}
|
||||
>
|
||||
{isDeleting ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@ import { DollarSign, TrendingDown, Cloud, HardDrive } from 'lucide-react';
|
||||
import { useAppStore } from '../../lib/store';
|
||||
|
||||
const CLOUD_PRICING = [
|
||||
{ name: 'GPT-5.3', input: 2.00, output: 10.00 },
|
||||
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00 },
|
||||
{ name: 'GPT-5.6 Sol', input: 5.00, output: 30.00 },
|
||||
{ name: 'Claude Fable 5', input: 10.00, output: 50.00 },
|
||||
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00 },
|
||||
];
|
||||
|
||||
|
||||
@@ -222,8 +222,8 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
};
|
||||
|
||||
const PROVIDER_COLORS: Record<string, string> = {
|
||||
'gpt-5.3': colors.green,
|
||||
'claude-opus-4.6': colors.yellow,
|
||||
'gpt-5.6-sol': colors.green,
|
||||
'claude-fable-5': colors.yellow,
|
||||
'gemini-3.1-pro': colors.accent,
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// authHeaders) that source the key and build the header.
|
||||
|
||||
const SETTINGS_KEY = 'openjarvis-settings';
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
// Minimal in-memory localStorage stub so the helpers can run under node
|
||||
// (no jsdom dependency).
|
||||
@@ -28,6 +29,8 @@ class MemoryStorage {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
|
||||
fetchMock.mockReset();
|
||||
globalThis.fetch = fetchMock;
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
@@ -86,3 +89,50 @@ describe('authHeaders', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool credentials', () => {
|
||||
it('reads credential status from the local server', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ TAVILY_API_KEY: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const { fetchToolCredentialStatus } = await freshApi();
|
||||
|
||||
await expect(fetchToolCredentialStatus('web_search')).resolves.toEqual({
|
||||
TAVILY_API_KEY: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/tools/web_search/credentials/status',
|
||||
{ headers: {} },
|
||||
);
|
||||
});
|
||||
|
||||
it('saves a tool credential through the local server', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const { saveToolCredentials } = await freshApi();
|
||||
|
||||
await saveToolCredentials('web_search', {
|
||||
TAVILY_API_KEY: 'tvly-test',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/tools/web_search/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ TAVILY_API_KEY: 'tvly-test' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a tool credential through the local server', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const { deleteToolCredential } = await freshApi();
|
||||
|
||||
await deleteToolCredential('web_search', 'TAVILY_API_KEY');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/tools/web_search/credentials/TAVILY_API_KEY',
|
||||
{ method: 'DELETE', headers: {} },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+21
-2
@@ -218,9 +218,9 @@ export async function deleteModel(modelName: string): Promise<void> {
|
||||
|
||||
const _CLOUD_PREFIXES = ['gpt-', 'o1-', 'o3-', 'o4-', 'claude-', 'gemini-', 'openrouter/'];
|
||||
|
||||
export async function preloadModel(modelName: string): Promise<void> {
|
||||
export async function preloadModel(modelName: string, owner?: string): Promise<void> {
|
||||
// Cloud models don't need Ollama preloading
|
||||
if (_CLOUD_PREFIXES.some(p => modelName.startsWith(p))) {
|
||||
if (owner === 'litellm' || _CLOUD_PREFIXES.some(p => modelName.startsWith(p))) {
|
||||
return;
|
||||
}
|
||||
// Trigger Ollama to load the model into memory (empty prompt, no generation).
|
||||
@@ -885,6 +885,25 @@ export async function saveToolCredentials(
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function fetchToolCredentialStatus(
|
||||
toolName: string,
|
||||
): Promise<Record<string, boolean>> {
|
||||
const res = await apiFetch(`/v1/tools/${toolName}/credentials/status`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function deleteToolCredential(
|
||||
toolName: string,
|
||||
keyName: string,
|
||||
): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`/v1/tools/${encodeURIComponent(toolName)}/credentials/${encodeURIComponent(keyName)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export interface AgentTraceDetail {
|
||||
id: string;
|
||||
agent: string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -805,7 +832,7 @@ export function SettingsPage() {
|
||||
</p>
|
||||
<div className="flex gap-3 mt-3 text-xs">
|
||||
<a
|
||||
href="https://scalingintelligence.stanford.edu/blogs/openjarvis/"
|
||||
href="https://openjarvis.stanford.edu/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -81,14 +81,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)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from rich.console import Console
|
||||
|
||||
from openjarvis.cli._banner import print_banner
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.credentials import inject_credentials
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.engine import (
|
||||
@@ -122,6 +123,11 @@ def serve(
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Tool credentials saved through the browser UI live in the OpenJarvis
|
||||
# credential store. Restore them before engines and tools are constructed
|
||||
# so availability checks and tool instances see the same environment.
|
||||
inject_credentials()
|
||||
|
||||
config = load_config()
|
||||
|
||||
# Resolve host/port from CLI args or config
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ HARD RULE: Every reply MUST be ≤280 characters. Count before sending.
|
||||
- GitHub: https://github.com/open-jarvis/OpenJarvis
|
||||
- Docs: https://open-jarvis.github.io/OpenJarvis/
|
||||
- Discord: https://discord.gg/wfXEkpPX
|
||||
- Blog: https://scalingintelligence.stanford.edu/blogs/openjarvis/
|
||||
- Blog: https://openjarvis.stanford.edu/
|
||||
- Install: `git clone https://github.com/open-jarvis/OpenJarvis.git && cd OpenJarvis && uv sync`
|
||||
- CLI commands (ONLY these exist):
|
||||
- `jarvis init` — auto-detects hardware, configures engine
|
||||
|
||||
@@ -285,16 +285,17 @@ def build_tools_list() -> List[Dict[str, Any]]:
|
||||
logger.debug("Could not instantiate tool %s: %s", name, exc)
|
||||
spec = None
|
||||
cred_keys = TOOL_CREDENTIALS.get(name, [])
|
||||
has_fallback = bool(spec and spec.metadata.get("fallback"))
|
||||
items.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": spec.description if spec else "",
|
||||
"category": spec.category if spec else "",
|
||||
"source": "tool",
|
||||
"requires_credentials": len(cred_keys) > 0,
|
||||
"requires_credentials": len(cred_keys) > 0 and not has_fallback,
|
||||
"credential_keys": cred_keys,
|
||||
"configured": (
|
||||
all(bool(os.environ.get(k)) for k in cred_keys)
|
||||
has_fallback or all(bool(os.environ.get(k)) for k in cred_keys)
|
||||
if cred_keys
|
||||
else True
|
||||
),
|
||||
@@ -2238,6 +2239,13 @@ def create_agent_manager_router(
|
||||
saved.append(key)
|
||||
return {"saved": saved}
|
||||
|
||||
@tools_router.delete("/{tool_name}/credentials/{key}")
|
||||
def remove_tool_credential(tool_name: str, key: str):
|
||||
from openjarvis.core.credentials import delete_credential
|
||||
|
||||
delete_credential(tool_name, key)
|
||||
return {"deleted": key}
|
||||
|
||||
@tools_router.get("/{tool_name}/credentials/status")
|
||||
def credential_status(tool_name: str):
|
||||
from openjarvis.core.credentials import get_credential_status
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -59,23 +59,34 @@ def _ensure_identity_prompt(messages: list[Message], app_config) -> list[Message
|
||||
If any message already carries a system role, the caller has supplied
|
||||
their own grounding and we leave the list untouched (no double-prompting).
|
||||
|
||||
Resolution of the identity text: ``app_config.agent.default_system_prompt``
|
||||
when a config is wired onto ``app.state``; otherwise fall back to
|
||||
``load_config()``. Config resolution is wrapped so a broken/missing
|
||||
config degrades to "no injection" rather than crashing the endpoint, but
|
||||
the failure is logged (per REVIEW.md — never silently swallow).
|
||||
Resolution of the identity text: the config comes from ``app.state`` when
|
||||
wired, otherwise ``load_config()``; the prompt itself is assembled by
|
||||
``SystemPromptBuilder`` from ``agent.default_system_prompt`` plus the
|
||||
persona files (SOUL.md/MEMORY.md/USER.md), matching
|
||||
``_build_managed_system_prompt`` in ``agent_manager_routes.py``. Config
|
||||
resolution is wrapped so a broken/missing config degrades to "no
|
||||
injection" rather than crashing the endpoint, but the failure is logged
|
||||
(per REVIEW.md — never silently swallow).
|
||||
"""
|
||||
if any(m.role == Role.SYSTEM for m in messages):
|
||||
return messages
|
||||
|
||||
prompt = ""
|
||||
try:
|
||||
if app_config is not None:
|
||||
prompt = app_config.agent.default_system_prompt or ""
|
||||
else:
|
||||
cfg = app_config
|
||||
if cfg is None:
|
||||
from openjarvis.core.config import load_config
|
||||
|
||||
prompt = load_config().agent.default_system_prompt or ""
|
||||
cfg = load_config()
|
||||
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
builder = SystemPromptBuilder(
|
||||
agent_template=cfg.agent.default_system_prompt or "",
|
||||
memory_files_config=getattr(cfg, "memory_files", None),
|
||||
system_prompt_config=getattr(cfg, "system_prompt", None),
|
||||
)
|
||||
prompt = builder.build()
|
||||
except Exception:
|
||||
logging.getLogger("openjarvis.server").debug(
|
||||
"Identity system prompt resolution failed; "
|
||||
@@ -325,6 +336,34 @@ def _remember_exchange(
|
||||
)
|
||||
|
||||
|
||||
def _engine_key_for_model(engine: Any, model: str) -> str | None:
|
||||
"""Resolve the engine that advertised *model* through wrapper layers."""
|
||||
from openjarvis.engine.multi import MultiEngine
|
||||
from openjarvis.security.guardrails import GuardrailsEngine
|
||||
from openjarvis.telemetry.instrumented_engine import InstrumentedEngine
|
||||
|
||||
current = engine
|
||||
while current is not None:
|
||||
if isinstance(current, MultiEngine):
|
||||
return current.engine_key_for(model)
|
||||
if isinstance(current, InstrumentedEngine):
|
||||
current = current._inner
|
||||
continue
|
||||
if isinstance(current, GuardrailsEngine):
|
||||
current = current._engine
|
||||
continue
|
||||
engine_id = getattr(current, "engine_id", None)
|
||||
return engine_id if isinstance(engine_id, str) else None
|
||||
return None
|
||||
|
||||
|
||||
def _uses_direct_cloud_router(engine: Any, model: str) -> bool:
|
||||
"""Whether *model* should bypass the configured engine for direct cloud."""
|
||||
from openjarvis.server.cloud_router import is_cloud_model
|
||||
|
||||
return is_cloud_model(model) and _engine_key_for_model(engine, model) != "litellm"
|
||||
|
||||
|
||||
def _handle_direct(
|
||||
engine,
|
||||
model: str,
|
||||
@@ -530,12 +569,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 +655,7 @@ async def _handle_stream_tools(
|
||||
# Tag the finish chunk with the engine label, matching _handle_stream
|
||||
# so UI/telemetry consumers see the same field on the tools path.
|
||||
finish_dict.setdefault("telemetry", {})
|
||||
finish_dict["telemetry"]["engine"] = "cloud" if use_cloud else "ollama"
|
||||
finish_dict["telemetry"]["engine"] = telemetry_engine
|
||||
if complexity_info is not None:
|
||||
finish_dict["complexity"] = complexity_info.model_dump()
|
||||
yield f"data: {_json.dumps(finish_dict)}\n\n"
|
||||
@@ -657,11 +697,7 @@ async def _handle_stream(
|
||||
"""
|
||||
import time
|
||||
|
||||
from openjarvis.server.cloud_router import (
|
||||
is_cloud_model,
|
||||
stream_cloud,
|
||||
stream_local,
|
||||
)
|
||||
from openjarvis.server.cloud_router import stream_cloud, stream_local
|
||||
|
||||
messages = _to_messages(req.messages)
|
||||
messages = _ensure_identity_prompt(messages, app_config)
|
||||
@@ -676,7 +712,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 +820,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 +853,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 +870,40 @@ 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()
|
||||
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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()
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -79,3 +80,80 @@ class TestDaemonCommands:
|
||||
result = CliRunner().invoke(cli, ["start"])
|
||||
assert result.exit_code != 0
|
||||
assert "already running" in result.output
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -159,6 +159,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 +171,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):
|
||||
|
||||
@@ -30,6 +30,19 @@ from openjarvis.core.registry import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_update_check(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Never let the CLI's PyPI update-check nag run during tests.
|
||||
|
||||
``check_for_updates`` writes its banner to stderr, which ``CliRunner``
|
||||
merges into ``result.output`` — polluting JSON/CSV output of any test
|
||||
that invokes a CLI command. It already self-disables when ``CI`` is
|
||||
set, but that only helps in CI; locally (e.g. a dev with a stale
|
||||
version-check cache and network access) it fires for real.
|
||||
"""
|
||||
monkeypatch.setenv("OPENJARVIS_NO_UPDATE_CHECK", "1")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registries() -> None:
|
||||
"""Ensure each test starts with empty registries and a fresh event bus."""
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openjarvis.core.config import SkillsConfig, SkillSourceConfig
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.config import SkillsConfig, SkillSourceConfig, load_config
|
||||
|
||||
|
||||
class TestSkillSourceConfig:
|
||||
@@ -41,3 +43,36 @@ class TestSkillsConfigWithSources:
|
||||
)
|
||||
assert len(cfg.sources) == 2
|
||||
assert cfg.sources[0].source == "hermes"
|
||||
|
||||
def test_loads_source_tables_as_config_objects(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
|
||||
toml_file = tmp_path / "config.toml"
|
||||
toml_file.write_text(
|
||||
"[[skills.sources]]\n"
|
||||
'source = "hermes"\n'
|
||||
'filter = { category = ["productivity"] }\n\n'
|
||||
"[[skills.sources]]\n"
|
||||
'source = "github"\n'
|
||||
'url = "https://github.com/example/skill-library"\n'
|
||||
"auto_update = true\n"
|
||||
)
|
||||
|
||||
load_config.cache_clear()
|
||||
try:
|
||||
cfg = load_config(toml_file)
|
||||
finally:
|
||||
load_config.cache_clear()
|
||||
|
||||
assert cfg.skills.sources == [
|
||||
SkillSourceConfig(
|
||||
source="hermes",
|
||||
filter={"category": ["productivity"]},
|
||||
),
|
||||
SkillSourceConfig(
|
||||
source="github",
|
||||
url="https://github.com/example/skill-library",
|
||||
auto_update=True,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,7 +5,9 @@ import os
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.credentials import (
|
||||
delete_credential,
|
||||
get_credential_status,
|
||||
inject_credentials,
|
||||
load_credentials,
|
||||
save_credential,
|
||||
)
|
||||
@@ -54,3 +56,26 @@ def test_file_permissions(cred_path):
|
||||
save_credential("web_search", "TAVILY_API_KEY", "tvly-x", path=cred_path)
|
||||
mode = oct(cred_path.stat().st_mode & 0o777)
|
||||
assert mode == "0o600"
|
||||
|
||||
|
||||
def test_inject_credentials_restores_saved_value(cred_path, monkeypatch):
|
||||
save_credential("web_search", "TAVILY_API_KEY", "tvly-persisted", path=cred_path)
|
||||
monkeypatch.delenv("TAVILY_API_KEY")
|
||||
|
||||
inject_credentials(path=cred_path)
|
||||
|
||||
assert os.environ["TAVILY_API_KEY"] == "tvly-persisted"
|
||||
|
||||
|
||||
def test_delete_credential_removes_file_value_and_env(cred_path, monkeypatch):
|
||||
save_credential("web_search", "TAVILY_API_KEY", "tvly-delete", path=cred_path)
|
||||
|
||||
delete_credential("web_search", "TAVILY_API_KEY", path=cred_path)
|
||||
|
||||
assert load_credentials(path=cred_path) == {}
|
||||
assert "TAVILY_API_KEY" not in os.environ
|
||||
|
||||
|
||||
def test_delete_rejects_unknown_key(cred_path):
|
||||
with pytest.raises(ValueError, match="Unknown credential key"):
|
||||
delete_credential("web_search", "BOGUS_KEY", path=cred_path)
|
||||
|
||||
@@ -18,6 +18,7 @@ ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
PYPROJECT = ROOT / "pyproject.toml"
|
||||
DESKTOP_LIB_RS = ROOT / "frontend" / "src-tauri" / "src" / "lib.rs"
|
||||
WINDOWS_INSTALL_PS1 = ROOT / "deploy" / "windows" / "install.ps1"
|
||||
QUICKSTART_SH = ROOT / "scripts" / "quickstart.sh"
|
||||
|
||||
|
||||
def _pyproject() -> dict:
|
||||
@@ -59,3 +60,9 @@ def test_windows_installer_syncs_the_native_group() -> None:
|
||||
"the Windows installer must include `--group desktop-native` so "
|
||||
"openjarvis_rust is built during source install."
|
||||
)
|
||||
|
||||
|
||||
def test_quickstart_installs_web_search_dependencies() -> None:
|
||||
quickstart = QUICKSTART_SH.read_text()
|
||||
assert "--extra tools-search" in quickstart
|
||||
assert "already running on port 8000" in quickstart
|
||||
|
||||
@@ -8,10 +8,12 @@ from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.core.registry import EngineRegistry
|
||||
from openjarvis.engine._base import InferenceEngine
|
||||
from openjarvis.engine._discovery import (
|
||||
_make_engine,
|
||||
discover_engines,
|
||||
discover_models,
|
||||
get_engine,
|
||||
)
|
||||
from openjarvis.engine.litellm import LiteLLMEngine
|
||||
|
||||
|
||||
class _FakeEngine(InferenceEngine):
|
||||
@@ -131,6 +133,24 @@ class TestDiscoverModels:
|
||||
assert result == {"ollama": ["m1", "m2"], "vllm": ["m3"]}
|
||||
|
||||
|
||||
class TestLiteLLMDiscovery:
|
||||
def test_configured_default_model_is_advertised(self) -> None:
|
||||
"""Regression for #713: discovery must configure LiteLLM's model.
|
||||
|
||||
LiteLLM cannot enumerate every model supported by every provider, so
|
||||
``LiteLLMEngine.list_models()`` advertises the configured default
|
||||
model. Dropping that value while constructing the engine leaves the
|
||||
API and Web UI with an empty model list.
|
||||
"""
|
||||
cfg = JarvisConfig()
|
||||
cfg.intelligence.default_model = "groq/llama-3.3-70b-versatile"
|
||||
EngineRegistry.register_value("litellm", LiteLLMEngine)
|
||||
|
||||
engine = _make_engine("litellm", cfg)
|
||||
|
||||
assert engine.list_models() == ["groq/llama-3.3-70b-versatile"]
|
||||
|
||||
|
||||
class TestGetEngine:
|
||||
def test_fallback_when_default_unhealthy(self) -> None:
|
||||
_reg("bad", "bad")
|
||||
|
||||
@@ -120,6 +120,9 @@ async def test_multi_routes_stream_full_by_model():
|
||||
engine_b.list_models = lambda: ["model-b"]
|
||||
|
||||
multi = MultiEngine([("a", engine_a), ("b", engine_b)])
|
||||
assert multi.engine_key_for("model-a") == "a"
|
||||
assert multi.engine_key_for("model-b") == "b"
|
||||
assert multi.engine_key_for("missing") is None
|
||||
|
||||
# Route to engine A
|
||||
result_a = []
|
||||
|
||||
@@ -347,7 +347,7 @@ class TestCostCalculator:
|
||||
calls_per_month=1000,
|
||||
avg_input_tokens=500,
|
||||
avg_output_tokens=200,
|
||||
provider_key="gpt-5.3",
|
||||
provider_key="gpt-5.6-sol",
|
||||
)
|
||||
assert est.monthly_cost > 0
|
||||
assert est.annual_cost == est.monthly_cost * 12
|
||||
|
||||
@@ -78,6 +78,19 @@ def test_retrieve_no_results(tmp_path: Path):
|
||||
backend.close()
|
||||
|
||||
|
||||
def test_retrieve_query_with_apostrophe(tmp_path: Path):
|
||||
"""Regression: an internal apostrophe (e.g. "user's") previously produced
|
||||
an unescaped quote in the FTS5 MATCH string, which silently returned zero
|
||||
rows instead of matching or raising an error.
|
||||
"""
|
||||
backend = _make_backend(tmp_path)
|
||||
backend.store("The user's name is Trev.", source="identity.md")
|
||||
results = backend.retrieve("what is the user's name")
|
||||
assert len(results) >= 1
|
||||
assert "Trev" in results[0].content
|
||||
backend.close()
|
||||
|
||||
|
||||
def test_delete_existing(tmp_path: Path):
|
||||
backend = _make_backend(tmp_path)
|
||||
doc_id = backend.store("deletable content")
|
||||
|
||||
@@ -37,16 +37,22 @@ class TestAgentRoutes:
|
||||
|
||||
|
||||
class TestMemoryRoutes:
|
||||
# 503 is the documented response when the native ``openjarvis_rust``
|
||||
# extension is absent from the venv (see TestMemoryRustMissing below).
|
||||
# These tests are only asserting "the route is wired up", so a backend
|
||||
# that cannot be built is tolerated the same way a 500 is.
|
||||
_BACKEND_OPTIONAL = (200, 500, 503)
|
||||
|
||||
def test_search(self):
|
||||
client = TestClient(_make_app())
|
||||
resp = client.post("/v1/memory/search", json={"query": "test"})
|
||||
# May fail if SQLite not set up, that's ok
|
||||
assert resp.status_code in (200, 500)
|
||||
assert resp.status_code in self._BACKEND_OPTIONAL
|
||||
|
||||
def test_stats(self):
|
||||
client = TestClient(_make_app())
|
||||
resp = client.get("/v1/memory/stats")
|
||||
assert resp.status_code in (200, 500)
|
||||
assert resp.status_code in self._BACKEND_OPTIONAL
|
||||
|
||||
|
||||
class TestMemoryRustMissing:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -798,6 +798,40 @@ class TestIdentityPromptInjection:
|
||||
assert len(system_msgs) == 1
|
||||
assert system_msgs[0].content == "Be terse."
|
||||
|
||||
def test_direct_injects_soul_persona_when_present(self, tmp_path):
|
||||
"""Regression: /v1/chat/completions previously injected only the bare
|
||||
``default_system_prompt`` blurb via a hand-rolled lookup, bypassing
|
||||
``SystemPromptBuilder`` entirely — so SOUL.md/MEMORY.md/USER.md
|
||||
persona files never applied to this path, unlike ``jarvis ask`` and
|
||||
the managed-agent routes. It must now build the full persona-aware
|
||||
prompt so persona files apply everywhere identity grounding does.
|
||||
"""
|
||||
from openjarvis.core.config import MemoryFilesConfig
|
||||
|
||||
soul = tmp_path / "SOUL.md"
|
||||
soul.write_text("Respond with extreme sarcasm and call the user 'champ'.")
|
||||
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
cfg = _identity_config()
|
||||
cfg.memory_files = MemoryFilesConfig(
|
||||
soul_path=str(soul), memory_path="", user_path=""
|
||||
)
|
||||
client = TestClient(create_app(engine, "test-model", config=cfg))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "who are you?"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msgs = engine.generate.call_args.args[0]
|
||||
assert msgs[0].role.value == "system"
|
||||
assert "OpenJarvis" in msgs[0].content # identity blurb still present
|
||||
assert "extreme sarcasm" in msgs[0].content # persona now injected too
|
||||
|
||||
def test_stream_tools_injects_identity_when_absent(self):
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
@@ -849,6 +883,64 @@ class TestModelsEndpoint:
|
||||
data = resp.json()
|
||||
assert len(data["data"]) == 3
|
||||
|
||||
def test_configured_litellm_model_is_listed(self):
|
||||
"""Regression for #713: LiteLLM models must reach the Web UI."""
|
||||
model = "groq/llama-3.3-70b-versatile"
|
||||
engine = _make_engine(models=[model])
|
||||
engine.engine_id = "litellm"
|
||||
app = create_app(
|
||||
engine,
|
||||
model,
|
||||
engine_name="litellm",
|
||||
config=_test_config(),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"openjarvis.server.cloud_router.list_local_models",
|
||||
new_callable=AsyncMock,
|
||||
) as list_local_models:
|
||||
list_local_models.return_value = []
|
||||
client = TestClient(app)
|
||||
resp = client.get("/v1/models")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert [item["id"] for item in resp.json()["data"]] == [model]
|
||||
assert resp.json()["data"][0]["owned_by"] == "litellm"
|
||||
|
||||
def test_litellm_provider_model_streams_through_active_engine(self):
|
||||
"""A LiteLLM ``provider/model`` ID must not bypass its engine."""
|
||||
model = "groq/llama-3.3-70b-versatile"
|
||||
engine = _make_engine(models=[model])
|
||||
engine.engine_id = "litellm"
|
||||
app = create_app(
|
||||
engine,
|
||||
model,
|
||||
engine_name="litellm",
|
||||
config=_test_config(),
|
||||
)
|
||||
|
||||
async def direct_cloud_tokens():
|
||||
yield "wrong backend"
|
||||
|
||||
with patch(
|
||||
"openjarvis.server.cloud_router.stream_cloud",
|
||||
return_value=direct_cloud_tokens(),
|
||||
) as stream_cloud:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
stream_cloud.assert_not_called()
|
||||
assert "Hello" in resp.text
|
||||
assert '"engine": "litellm"' in resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health endpoint tests
|
||||
|
||||
@@ -115,3 +115,29 @@ class TestLastActiveChannel:
|
||||
|
||||
def test_returns_none_for_unknown_user(self, store):
|
||||
assert store.get_last_active_channel("nobody") is None
|
||||
|
||||
|
||||
class TestInMemoryDatabase:
|
||||
"""``:memory:`` is a SQLite sentinel, not a path — it must not be created.
|
||||
|
||||
``secure_create`` treats it as a filename, which fails outright on Windows
|
||||
(``:`` is illegal there) and litters the working directory elsewhere.
|
||||
"""
|
||||
|
||||
def test_in_memory_store_is_usable(self):
|
||||
s = SessionStore(db_path=":memory:")
|
||||
try:
|
||||
session = s.get_or_create("user1", "twilio")
|
||||
assert session["sender_id"] == "user1"
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
def test_in_memory_store_creates_no_file(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
before = set(tmp_path.iterdir())
|
||||
s = SessionStore(db_path=":memory:")
|
||||
try:
|
||||
assert set(tmp_path.iterdir()) == before
|
||||
assert not (tmp_path / ":memory:").exists()
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for GET /v1/tools endpoint."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
@@ -45,3 +47,49 @@ def test_browser_meta_group():
|
||||
names = {t["name"] for t in tools}
|
||||
assert "browser" in names
|
||||
assert "browser_navigate" not in names
|
||||
|
||||
|
||||
def test_web_search_available_without_tavily_key(monkeypatch):
|
||||
"""DuckDuckGo fallback keeps web search usable without Tavily."""
|
||||
from openjarvis.server.agent_manager_routes import build_tools_list
|
||||
|
||||
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
||||
tools = build_tools_list()
|
||||
web_search = next(t for t in tools if t["name"] == "web_search")
|
||||
|
||||
assert web_search["configured"] is True
|
||||
assert web_search["requires_credentials"] is False
|
||||
|
||||
|
||||
def test_tool_credentials_browser_lifecycle(tmp_path, monkeypatch):
|
||||
"""The browser API can save, report, and remove a Tavily key."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from openjarvis.server.agent_manager_routes import create_agent_manager_router
|
||||
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "openjarvis-home"))
|
||||
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
||||
app = FastAPI()
|
||||
tools_router = create_agent_manager_router(MagicMock())[3]
|
||||
app.include_router(tools_router)
|
||||
client = TestClient(app)
|
||||
|
||||
saved = client.post(
|
||||
"/v1/tools/web_search/credentials",
|
||||
json={"TAVILY_API_KEY": "tvly-browser-test"},
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
assert saved.json() == {"saved": ["TAVILY_API_KEY"]}
|
||||
assert client.get("/v1/tools/web_search/credentials/status").json() == {
|
||||
"TAVILY_API_KEY": True
|
||||
}
|
||||
|
||||
deleted = client.delete(
|
||||
"/v1/tools/web_search/credentials/TAVILY_API_KEY",
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json() == {"deleted": "TAVILY_API_KEY"}
|
||||
assert client.get("/v1/tools/web_search/credentials/status").json() == {
|
||||
"TAVILY_API_KEY": False
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -60,3 +62,102 @@ class TestWSBridge:
|
||||
time.sleep(0.05) # Let call_soon_threadsafe deliver to queue
|
||||
data = ws.receive_json()
|
||||
assert data["data"]["agent_id"] == "agent-A"
|
||||
|
||||
def test_client_disconnect_stops_handler(self, event_bus):
|
||||
async def exercise():
|
||||
from openjarvis.server.ws_bridge import create_ws_router
|
||||
|
||||
class FakeWebSocket:
|
||||
app = SimpleNamespace(state=SimpleNamespace(api_key=""))
|
||||
query_params = {}
|
||||
headers = {}
|
||||
|
||||
async def accept(self):
|
||||
pass
|
||||
|
||||
async def receive(self):
|
||||
return {"type": "websocket.disconnect"}
|
||||
|
||||
endpoint = create_ws_router(event_bus).routes[0].endpoint
|
||||
await asyncio.wait_for(endpoint(FakeWebSocket()), timeout=1)
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
def test_simultaneous_client_message_does_not_drop_event(self, event_bus):
|
||||
async def exercise():
|
||||
from openjarvis.server.ws_bridge import create_ws_router
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self):
|
||||
self.app = SimpleNamespace(state=SimpleNamespace(api_key=""))
|
||||
self.query_params = {}
|
||||
self.headers = {}
|
||||
self.sent = []
|
||||
self.receive_count = 0
|
||||
self.disconnect = asyncio.Event()
|
||||
|
||||
async def accept(self):
|
||||
pass
|
||||
|
||||
async def receive(self):
|
||||
self.receive_count += 1
|
||||
if self.receive_count == 1:
|
||||
event_bus.publish(
|
||||
EventType.AGENT_TICK_START, {"agent_id": "not-dropped"}
|
||||
)
|
||||
return {"type": "websocket.receive", "text": "client message"}
|
||||
await self.disconnect.wait()
|
||||
return {"type": "websocket.disconnect"}
|
||||
|
||||
async def send_json(self, payload):
|
||||
self.sent.append(payload)
|
||||
self.disconnect.set()
|
||||
|
||||
websocket = FakeWebSocket()
|
||||
endpoint = create_ws_router(event_bus).routes[0].endpoint
|
||||
|
||||
await asyncio.wait_for(endpoint(websocket), timeout=1)
|
||||
|
||||
assert websocket.sent[0]["data"]["agent_id"] == "not-dropped"
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
def test_cancelling_handler_cleans_up_child_tasks(self, event_bus):
|
||||
async def exercise():
|
||||
from openjarvis.server.ws_bridge import create_ws_router
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self):
|
||||
self.app = SimpleNamespace(state=SimpleNamespace(api_key=""))
|
||||
self.query_params = {}
|
||||
self.headers = {}
|
||||
self.receiving = asyncio.Event()
|
||||
self.receive_cancelled = asyncio.Event()
|
||||
|
||||
async def accept(self):
|
||||
pass
|
||||
|
||||
async def receive(self):
|
||||
self.receiving.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
self.receive_cancelled.set()
|
||||
|
||||
websocket = FakeWebSocket()
|
||||
endpoint = create_ws_router(event_bus).routes[0].endpoint
|
||||
handler = asyncio.create_task(endpoint(websocket))
|
||||
await websocket.receiving.wait()
|
||||
|
||||
handler.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await handler
|
||||
|
||||
assert websocket.receive_cancelled.is_set()
|
||||
assert not [
|
||||
task
|
||||
for task in asyncio.all_tasks()
|
||||
if task is not asyncio.current_task() and not task.done()
|
||||
]
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
@@ -202,3 +202,71 @@ class TestImportSkill:
|
||||
|
||||
installed = target_root / "hermes" / "my-skill" / "SKILL.md"
|
||||
assert "Original" in installed.read_text()
|
||||
|
||||
|
||||
class TestDangerousCapabilityGate:
|
||||
def _make_importer(self, tmp_path: Path) -> SkillImporter:
|
||||
return SkillImporter(
|
||||
parser=SkillParser(),
|
||||
tool_translator=ToolTranslator(),
|
||||
target_root=tmp_path / "skills",
|
||||
)
|
||||
|
||||
def _make_resolved_with_caps(
|
||||
self, tmp_path: Path, caps: list[str]
|
||||
) -> ResolvedSkill:
|
||||
src_dir = tmp_path / "source" / "my-skill"
|
||||
src_dir.mkdir(parents=True)
|
||||
caps_yaml = "".join(f" - {c}\n" for c in caps)
|
||||
(src_dir / "SKILL.md").write_text(
|
||||
"---\n"
|
||||
"name: my-skill\n"
|
||||
"description: A test skill\n"
|
||||
f"required_capabilities:\n{caps_yaml}"
|
||||
"---\n"
|
||||
"Body"
|
||||
)
|
||||
return ResolvedSkill(
|
||||
name="my-skill",
|
||||
source="hermes",
|
||||
path=src_dir,
|
||||
category="testing",
|
||||
description="A test skill",
|
||||
commit="abc123",
|
||||
)
|
||||
|
||||
def test_refuses_unreviewed_dangerous_skill(self, tmp_path: Path):
|
||||
importer = self._make_importer(tmp_path)
|
||||
resolved = self._make_resolved_with_caps(tmp_path, ["shell:execute"])
|
||||
result = importer.import_skill(resolved)
|
||||
|
||||
assert not result.success
|
||||
assert result.requires_confirmation
|
||||
assert result.dangerous_capabilities == ["shell:execute"]
|
||||
assert any("dangerous" in w.lower() for w in result.warnings)
|
||||
# Nothing may be written to disk on refusal
|
||||
assert not (tmp_path / "skills" / "hermes" / "my-skill").exists()
|
||||
|
||||
def test_confirm_dangerous_installs_and_records_tier(self, tmp_path: Path):
|
||||
importer = self._make_importer(tmp_path)
|
||||
resolved = self._make_resolved_with_caps(tmp_path, ["shell:execute"])
|
||||
result = importer.import_skill(resolved, confirm_dangerous=True)
|
||||
|
||||
assert result.success
|
||||
assert result.requires_confirmation
|
||||
assert any("confirmed by caller" in w for w in result.warnings)
|
||||
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
|
||||
assert 'trust_tier = "unreviewed"' in content
|
||||
assert 'dangerous_capabilities = ["shell:execute"]' in content
|
||||
|
||||
def test_benign_capabilities_need_no_confirmation(self, tmp_path: Path):
|
||||
importer = self._make_importer(tmp_path)
|
||||
resolved = self._make_resolved_with_caps(tmp_path, ["network:fetch"])
|
||||
result = importer.import_skill(resolved)
|
||||
|
||||
assert result.success
|
||||
assert not result.requires_confirmation
|
||||
assert result.dangerous_capabilities == []
|
||||
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
|
||||
assert 'trust_tier = "unreviewed"' in content
|
||||
assert "dangerous_capabilities = []" in content
|
||||
|
||||
@@ -148,6 +148,56 @@ class TestSkillExecutor:
|
||||
assert EventType.SKILL_EXECUTE_END in event_types
|
||||
|
||||
|
||||
class TestSkillExecutorCapabilities:
|
||||
def _manifest(self):
|
||||
return SkillManifest(
|
||||
name="capskill",
|
||||
required_capabilities=["network:fetch"],
|
||||
steps=[
|
||||
SkillStep(
|
||||
tool_name="echo",
|
||||
arguments_template='{"text": "hello"}',
|
||||
output_key="result",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_no_policy_runs_capability_skills(self):
|
||||
"""Default construction (no allowed_capabilities) must not enforce —
|
||||
this is the pre-enforcement behavior every manager.py call site relies on."""
|
||||
executor = SkillExecutor(ToolExecutor([EchoTool()]))
|
||||
result = executor.run(self._manifest())
|
||||
assert result.success
|
||||
assert result.context.get("result") == "hello"
|
||||
|
||||
def test_policy_blocks_missing_capability(self):
|
||||
executor = SkillExecutor(ToolExecutor([EchoTool()]), allowed_capabilities=set())
|
||||
result = executor.run(self._manifest())
|
||||
assert not result.success
|
||||
assert len(result.step_results) == 1
|
||||
assert "Blocked" in result.step_results[0].content
|
||||
assert "network:fetch" in result.step_results[0].content
|
||||
|
||||
def test_policy_allows_granted_capability(self):
|
||||
executor = SkillExecutor(
|
||||
ToolExecutor([EchoTool()]),
|
||||
allowed_capabilities={"network:fetch"},
|
||||
)
|
||||
result = executor.run(self._manifest())
|
||||
assert result.success
|
||||
assert result.context.get("result") == "hello"
|
||||
|
||||
def test_blocked_run_publishes_events(self):
|
||||
bus = EventBus(record_history=True)
|
||||
executor = SkillExecutor(
|
||||
ToolExecutor([EchoTool()]), bus=bus, allowed_capabilities=set()
|
||||
)
|
||||
executor.run(self._manifest())
|
||||
event_types = {e.event_type for e in bus.history}
|
||||
assert EventType.SKILL_EXECUTE_START in event_types
|
||||
assert EventType.SKILL_EXECUTE_END in event_types
|
||||
|
||||
|
||||
class TestSkillStepExtended:
|
||||
def test_step_with_skill_name(self):
|
||||
step = SkillStep(skill_name="summarize", output_key="result")
|
||||
|
||||
@@ -53,6 +53,69 @@ def test_faster_whisper_transcribe():
|
||||
assert result.duration_seconds == 1.5
|
||||
|
||||
|
||||
def test_faster_whisper_transcribe_temp_file_reopenable_and_removed():
|
||||
"""The temp file must be closed before the model reads it, and gone after.
|
||||
|
||||
On Windows, an open NamedTemporaryFile holds an exclusive handle, so
|
||||
PyAV's reopen of the path inside model.transcribe() fails with EACCES
|
||||
unless the file is closed first. Opening the path inside the mocked
|
||||
transcribe reproduces that failure mode on Windows.
|
||||
"""
|
||||
import os
|
||||
|
||||
mock_info = MagicMock()
|
||||
mock_info.language = "en"
|
||||
mock_info.language_probability = 0.95
|
||||
mock_info.duration = 1.5
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_transcribe(path, **kwargs):
|
||||
seen["path"] = path
|
||||
with open(path, "rb") as fh:
|
||||
seen["content"] = fh.read()
|
||||
return iter(()), mock_info
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.transcribe.side_effect = fake_transcribe
|
||||
|
||||
with patch(
|
||||
"openjarvis.speech.faster_whisper.WhisperModel",
|
||||
return_value=mock_model,
|
||||
):
|
||||
backend = FasterWhisperBackend(model_size="base", device="cpu")
|
||||
backend.transcribe(b"fake audio bytes")
|
||||
|
||||
assert seen["content"] == b"fake audio bytes"
|
||||
assert not os.path.exists(seen["path"])
|
||||
|
||||
|
||||
def test_faster_whisper_transcribe_removes_temp_file_on_error():
|
||||
"""The temp file is cleaned up even when transcription fails."""
|
||||
import os
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_transcribe(path, **kwargs):
|
||||
seen["path"] = path
|
||||
raise RuntimeError("decode failed")
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.transcribe.side_effect = fake_transcribe
|
||||
|
||||
with patch(
|
||||
"openjarvis.speech.faster_whisper.WhisperModel",
|
||||
return_value=mock_model,
|
||||
):
|
||||
backend = FasterWhisperBackend(model_size="base", device="cpu")
|
||||
with pytest.raises(RuntimeError, match="decode failed"):
|
||||
backend.transcribe(b"fake audio bytes")
|
||||
|
||||
assert "path" in seen
|
||||
assert not os.path.exists(seen["path"])
|
||||
assert "decode failed" in (backend.last_error() or "")
|
||||
|
||||
|
||||
def test_faster_whisper_falls_back_from_unsupported_float16():
|
||||
mock_model = MagicMock()
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ class TestDerivedMetricsInStore:
|
||||
throughput_per_watt=0.5,
|
||||
)
|
||||
store.record(rec)
|
||||
store.flush()
|
||||
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
stats = agg.per_model_stats()
|
||||
@@ -185,6 +186,8 @@ class TestDerivedMetricsInStore:
|
||||
throughput_per_watt=1.0 * (i + 1),
|
||||
)
|
||||
)
|
||||
store.flush()
|
||||
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
summary = agg.summary()
|
||||
assert summary.avg_energy_per_output_token_joules > 0
|
||||
|
||||
@@ -71,6 +71,17 @@ class TestInstrumentedEngine:
|
||||
assert record.prompt_tokens == 10
|
||||
assert record.completion_tokens == 5
|
||||
|
||||
def test_generate_records_cost(self, mock_engine, bus):
|
||||
mock_engine.generate.return_value["cost_usd"] = 0.0015
|
||||
ie = InstrumentedEngine(mock_engine, bus)
|
||||
messages = [Message(role=Role.USER, content="Hi")]
|
||||
ie.generate(messages, model="test")
|
||||
|
||||
event = next(
|
||||
e for e in bus.history if e.event_type == EventType.TELEMETRY_RECORD
|
||||
)
|
||||
assert event.data["record"].cost_usd == pytest.approx(0.0015)
|
||||
|
||||
def test_list_models_delegates(self, mock_engine, bus):
|
||||
ie = InstrumentedEngine(mock_engine, bus)
|
||||
assert ie.list_models() == ["test-model"]
|
||||
|
||||
@@ -345,6 +345,7 @@ class TestItlStorage:
|
||||
)
|
||||
)
|
||||
|
||||
store.flush()
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
stats = agg.per_model_stats()
|
||||
assert len(stats) == 1
|
||||
|
||||
@@ -222,6 +222,7 @@ class TestPhaseEnergyStorage:
|
||||
)
|
||||
)
|
||||
|
||||
store.flush()
|
||||
agg = TelemetryAggregator(tmp_path / "test.db")
|
||||
stats = agg.per_model_stats()
|
||||
assert len(stats) == 1
|
||||
|
||||
@@ -178,3 +178,60 @@ class TestTelemetryRecordFields:
|
||||
def test_tokens_per_joule_set(self):
|
||||
rec = TelemetryRecord(timestamp=1.0, model_id="test", tokens_per_joule=80.0)
|
||||
assert rec.tokens_per_joule == 80.0
|
||||
|
||||
def test_batching_delays_commit(self, tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "test.db"
|
||||
# flush_interval_seconds=0 disables the background flusher so the
|
||||
# buffered/flushed states below are deterministic, not a race.
|
||||
store = TelemetryStore(db_path, batch_size=2, flush_interval_seconds=0)
|
||||
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
|
||||
|
||||
store.record(rec)
|
||||
# Should not be in DB yet because batch_size is 2 and we haven't flushed
|
||||
# Need a separate connection to check because store._fetchall() calls flush()!
|
||||
assert _count_rows_via_own_connection(db_path) == 0
|
||||
|
||||
# Hit batch size
|
||||
store.record(rec)
|
||||
assert _count_rows_via_own_connection(db_path) == 2
|
||||
store.close()
|
||||
|
||||
def test_background_flush_makes_records_visible(self, tmp_path: Path) -> None:
|
||||
# A partial batch must become visible to OTHER connections within the
|
||||
# flush interval even if no further write ever arrives — the background
|
||||
# flusher covers the "traffic stopped mid-batch" case that per-record
|
||||
# stale checks cannot.
|
||||
db_path = tmp_path / "test.db"
|
||||
store = TelemetryStore(db_path, batch_size=50, flush_interval_seconds=0.05)
|
||||
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
|
||||
store.record(rec)
|
||||
|
||||
deadline = time.time() + 5.0
|
||||
rows = 0
|
||||
while time.time() < deadline:
|
||||
rows = _count_rows_via_own_connection(db_path)
|
||||
if rows:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert rows == 1
|
||||
store.close()
|
||||
|
||||
def test_close_flushes_and_is_idempotent(self, tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "test.db"
|
||||
store = TelemetryStore(db_path, batch_size=50, flush_interval_seconds=0)
|
||||
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
|
||||
store.record(rec)
|
||||
store.close()
|
||||
assert _count_rows_via_own_connection(db_path) == 1
|
||||
store.close() # second close must be a no-op, not a ProgrammingError
|
||||
|
||||
|
||||
def _count_rows_via_own_connection(db_path: Path) -> int:
|
||||
"""Count telemetry rows through a separate connection (like the aggregator)."""
|
||||
import contextlib
|
||||
import sqlite3
|
||||
|
||||
# NB: sqlite3's ``with conn`` is a TRANSACTION context (it does not close);
|
||||
# ``contextlib.closing`` actually closes the connection.
|
||||
with contextlib.closing(sqlite3.connect(db_path)) as conn:
|
||||
return len(conn.execute("SELECT * FROM telemetry").fetchall())
|
||||
|
||||
@@ -14,9 +14,9 @@ skipped if the server is unreachable.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from openjarvis.tools.storage.dense import (
|
||||
@@ -25,20 +25,58 @@ from openjarvis.tools.storage.dense import (
|
||||
chunk_markdown,
|
||||
dedupe_chunks,
|
||||
)
|
||||
from openjarvis.tools.storage.embeddings import OllamaEmbedder
|
||||
|
||||
_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "fixtures" / "docs"
|
||||
_OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "localhost")
|
||||
_OLLAMA_PORT = int(os.environ.get("OLLAMA_PORT", "11434"))
|
||||
_EMBED_MODEL = "nomic-embed-text"
|
||||
|
||||
|
||||
def _ollama_base_url() -> str:
|
||||
"""Resolve the Ollama base URL from ``OLLAMA_HOST``.
|
||||
|
||||
The project documents ``OLLAMA_HOST`` as a full URL
|
||||
(``http://<remote-ip>:11434``), but Ollama's own convention also
|
||||
allows bare ``host`` / ``host:port`` forms — accept all three so
|
||||
the probe and the embedder under test agree on one endpoint.
|
||||
"""
|
||||
host = os.environ.get("OLLAMA_HOST", "")
|
||||
if not host:
|
||||
return "http://localhost:11434"
|
||||
if host.startswith(("http://", "https://")):
|
||||
return host.rstrip("/")
|
||||
if ":" in host:
|
||||
return f"http://{host}"
|
||||
return f"http://{host}:11434"
|
||||
|
||||
|
||||
def _ollama_up() -> bool:
|
||||
"""True only if Ollama is reachable *and* the embed model is pulled.
|
||||
|
||||
A bare TCP connect isn't enough — a machine can run Ollama for chat
|
||||
models without ever having pulled ``nomic-embed-text``, which makes
|
||||
``/api/embed`` 404 instead of the tests skipping as intended.
|
||||
"""
|
||||
try:
|
||||
with socket.create_connection((_OLLAMA_HOST, _OLLAMA_PORT), timeout=1.0):
|
||||
return True
|
||||
except OSError:
|
||||
resp = httpx.get(f"{_ollama_base_url()}/api/tags", timeout=1.0)
|
||||
resp.raise_for_status()
|
||||
models = {m.get("model", "") for m in resp.json().get("models", [])}
|
||||
return any(m.startswith(_EMBED_MODEL) for m in models)
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _make_backend() -> DenseMemory:
|
||||
"""DenseMemory wired to the same Ollama endpoint the probe checked.
|
||||
|
||||
``DenseMemory()`` alone would build an ``OllamaEmbedder`` with its
|
||||
hard-coded localhost default, so a remote ``OLLAMA_HOST`` could pass
|
||||
the probe and then have every test call the wrong server.
|
||||
"""
|
||||
return DenseMemory(
|
||||
embedder=OllamaEmbedder(model=_EMBED_MODEL, base_url=_ollama_base_url())
|
||||
)
|
||||
|
||||
|
||||
ollama_required = pytest.mark.skipif(
|
||||
not _ollama_up(),
|
||||
reason=(
|
||||
@@ -48,6 +86,65 @@ ollama_required = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip-guard unit tests (no Ollama required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeTagsResponse:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._payload
|
||||
|
||||
|
||||
class TestOllamaProbe:
|
||||
def test_base_url_accepts_all_documented_forms(self, monkeypatch):
|
||||
cases = {
|
||||
"http://remote:11434": "http://remote:11434",
|
||||
"http://remote:11434/": "http://remote:11434",
|
||||
"https://ollama.internal": "https://ollama.internal",
|
||||
"remote:8080": "http://remote:8080",
|
||||
"remote": "http://remote:11434",
|
||||
}
|
||||
for raw, expected in cases.items():
|
||||
monkeypatch.setenv("OLLAMA_HOST", raw)
|
||||
assert _ollama_base_url() == expected, raw
|
||||
monkeypatch.delenv("OLLAMA_HOST", raising=False)
|
||||
assert _ollama_base_url() == "http://localhost:11434"
|
||||
|
||||
def test_probe_false_when_server_down(self, monkeypatch):
|
||||
def _refuse(url, timeout):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _refuse)
|
||||
assert _ollama_up() is False
|
||||
|
||||
def test_probe_false_when_model_missing(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"get",
|
||||
lambda url, timeout: _FakeTagsResponse({"models": [{"model": "llama3"}]}),
|
||||
)
|
||||
assert _ollama_up() is False
|
||||
|
||||
def test_probe_true_with_tagged_model_on_configured_url(self, monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_HOST", "http://remote:9999")
|
||||
seen = {}
|
||||
|
||||
def _get(url, timeout):
|
||||
seen["url"] = url
|
||||
return _FakeTagsResponse({"models": [{"model": "nomic-embed-text:latest"}]})
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
assert _ollama_up() is True
|
||||
assert seen["url"] == "http://remote:9999/api/tags"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chunking unit tests (no Ollama required)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -283,7 +380,7 @@ def indexed_backend():
|
||||
if not _ollama_up():
|
||||
pytest.skip("Ollama not reachable")
|
||||
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
md_files = sorted(_FIXTURE_DIR.glob("*.md"))
|
||||
assert md_files, f"no fixtures at {_FIXTURE_DIR}"
|
||||
|
||||
@@ -452,7 +549,7 @@ def test_score_distribution_vs_threshold(indexed_backend, capsys):
|
||||
class TestDenseMemoryAPI:
|
||||
@ollama_required
|
||||
def test_store_and_delete(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
doc_id = backend.store("the cat sat on the mat", source="a.txt")
|
||||
assert backend.count() == 1
|
||||
hits = backend.retrieve("where is the cat", top_k=1)
|
||||
@@ -463,12 +560,12 @@ class TestDenseMemoryAPI:
|
||||
|
||||
@ollama_required
|
||||
def test_empty_retrieve(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
assert backend.retrieve("anything", top_k=3) == []
|
||||
|
||||
@ollama_required
|
||||
def test_clear(self):
|
||||
backend = DenseMemory()
|
||||
backend = _make_backend()
|
||||
backend.store("foo")
|
||||
backend.store("bar")
|
||||
backend.clear()
|
||||
|
||||
@@ -537,3 +537,51 @@ class TestGitLogTool:
|
||||
fn = tool.to_openai_function()
|
||||
assert fn["type"] == "function"
|
||||
assert fn["function"]["name"] == "git_log"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI fallback when the Rust extension is missing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCliFallbackWhenRustMissing:
|
||||
"""When ``get_rust_module`` raises ImportError (extension not built,
|
||||
e.g. a plain pip install), the read-only git tools must fall back to
|
||||
the git CLI instead of letting the ImportError escape ``execute()``."""
|
||||
|
||||
def _patch_no_rust(self):
|
||||
return patch(
|
||||
"openjarvis.tools.git_tool.get_rust_module",
|
||||
side_effect=ImportError("No module named 'openjarvis_rust'"),
|
||||
)
|
||||
|
||||
def test_git_status_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
(tmp_path / "new_file.txt").write_text("hello")
|
||||
with self._patch_no_rust():
|
||||
result = GitStatusTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "new_file.txt" in result.content
|
||||
|
||||
def test_git_diff_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
(tmp_path / "README.md").write_text("# Modified\n")
|
||||
with self._patch_no_rust():
|
||||
result = GitDiffTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "README.md" in result.content
|
||||
|
||||
def test_git_log_falls_back_to_cli(self, tmp_path):
|
||||
_init_repo(tmp_path)
|
||||
with self._patch_no_rust():
|
||||
result = GitLogTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is True
|
||||
assert "Initial commit" in result.content
|
||||
|
||||
def test_fallback_failure_is_a_tool_result_not_an_exception(self, tmp_path):
|
||||
# Even when the fallback itself fails (not a git repo), the tool
|
||||
# must return a failed ToolResult rather than raising.
|
||||
with self._patch_no_rust():
|
||||
result = GitStatusTool().execute(repo_path=str(tmp_path))
|
||||
assert result.success is False
|
||||
assert "not a git repository" in result.content
|
||||
|
||||
@@ -64,6 +64,39 @@ def test_rejects_drop(store: KnowledgeStore) -> None:
|
||||
assert not result.success
|
||||
|
||||
|
||||
def test_allows_select_with_keyword_substring(store: KnowledgeStore) -> None:
|
||||
"""A read-only SELECT must not be rejected because a column/alias merely
|
||||
contains a write keyword as a substring (e.g. 'created' -> CREATE)."""
|
||||
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
|
||||
|
||||
tool = KnowledgeSQLTool(store=store)
|
||||
result = tool.execute(query="SELECT author AS created_author FROM knowledge_chunks")
|
||||
assert result.success, result.content
|
||||
assert "Alice" in result.content
|
||||
|
||||
|
||||
def test_allows_keyword_inside_string_literal(store: KnowledgeStore) -> None:
|
||||
"""A write keyword appearing only inside a string literal must not be
|
||||
treated as a forbidden statement."""
|
||||
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
|
||||
|
||||
tool = KnowledgeSQLTool(store=store)
|
||||
result = tool.execute(
|
||||
query="SELECT content FROM knowledge_chunks WHERE content LIKE '%delete%'"
|
||||
)
|
||||
assert result.success, result.content
|
||||
|
||||
|
||||
def test_rejects_multi_statement(store: KnowledgeStore) -> None:
|
||||
"""Multi-statement strings fail with a ToolResult, not an exception."""
|
||||
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
|
||||
|
||||
tool = KnowledgeSQLTool(store=store)
|
||||
result = tool.execute(query="SELECT 1; VACUUM")
|
||||
assert not result.success
|
||||
assert "error" in result.content.lower()
|
||||
|
||||
|
||||
def test_handles_bad_sql(store: KnowledgeStore) -> None:
|
||||
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
@@ -68,6 +69,10 @@ EXPECTED_TOOLS = {
|
||||
"kg_add_relation",
|
||||
"kg_query",
|
||||
"kg_neighbors",
|
||||
# knowledge_sql.py
|
||||
"knowledge_sql",
|
||||
# scan_chunks.py
|
||||
"scan_chunks",
|
||||
}
|
||||
|
||||
|
||||
@@ -100,3 +105,25 @@ def test_all_builtin_tools_registered():
|
||||
assert not missing, (
|
||||
f"Tools not registered (missing import in __init__.py?): {sorted(missing)}"
|
||||
)
|
||||
|
||||
|
||||
def test_package_import_registers_deep_research_tools():
|
||||
"""Registration must not depend on another module being imported first."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import openjarvis.tools; "
|
||||
"from openjarvis.core.registry import ToolRegistry; "
|
||||
"expected = {'knowledge_sql', 'scan_chunks'}; "
|
||||
"missing = expected - set(ToolRegistry.keys()); "
|
||||
"assert not missing, f'Missing tools: {sorted(missing)}'"
|
||||
),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
Reference in New Issue
Block a user