merge: resolve conflicts with main — keep both DeepResearch tools + MCP functions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-03-27 13:09:12 -07:00
co-authored by Claude Opus 4.6
108 changed files with 9316 additions and 2046 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
run: uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
- name: Run tests
run: uv run pytest tests/ -v --tb=short
run: uv run pytest tests/ -v --tb=short -m "not live and not cloud"
rust:
runs-on: ubuntu-latest
+89 -14
View File
@@ -10,7 +10,7 @@ const OLLAMA_PORT: u16 = 11434;
const JARVIS_PORT: u16 = 8222;
/// Small, fast model pulled at startup so the app opens quickly.
const STARTUP_MODEL: &str = "qwen3.5:2b";
const STARTUP_MODEL: &str = "qwen3.5:4b";
/// Tiny fallback model if even the startup model can't be pulled.
const FALLBACK_MODEL: &str = "qwen3:0.6b";
@@ -86,12 +86,14 @@ fn models_that_fit() -> Vec<&'static str> {
.collect()
}
/// Pick the third-largest Qwen3.5 model that fits on this machine.
/// This leaves comfortable headroom for the OS / other apps while
/// still providing a capable model. Falls back gracefully when
/// fewer models fit.
/// Pick the default model — prefers STARTUP_MODEL if it fits, otherwise
/// falls back to the third-largest model that fits on this machine.
fn preferred_model() -> &'static str {
let fitting = models_that_fit();
// Prefer STARTUP_MODEL when it fits (fast, good quality)
if fitting.contains(&STARTUP_MODEL) {
return STARTUP_MODEL;
}
match fitting.len() {
0 => FALLBACK_MODEL,
1 => fitting[0],
@@ -460,17 +462,90 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
return;
}
let project_root = find_project_root();
let mut project_root = find_project_root();
if project_root.is_none() {
let mut s = status.lock().await;
s.error = Some(
"Could not find the OpenJarvis project directory. \
Clone it with: git clone https://github.com/open-jarvis/OpenJarvis.git ~/OpenJarvis \
then relaunch."
.into(),
);
return;
// Auto-clone on first launch
let git_bin = resolve_bin("git");
// Check that git is installed
if !std::path::Path::new(&git_bin).exists() && git_bin == "git" {
let mut s = status.lock().await;
s.error = Some(
"Could not find 'git'. \
Install it from https://git-scm.com then relaunch."
.into(),
);
return;
}
let target_path = std::path::PathBuf::from(home_dir()).join("OpenJarvis");
let clone_target = target_path.display().to_string();
// If the directory exists but is not a valid project, don't overwrite
if target_path.exists() && !target_path.join("pyproject.toml").exists() {
let mut s = status.lock().await;
s.error = Some(format!(
"{} exists but is not a valid OpenJarvis project. \
Remove it and relaunch, or set OPENJARVIS_ROOT to the correct path.",
clone_target,
));
return;
}
{
let mut s = status.lock().await;
s.detail = "Downloading OpenJarvis (first launch)...".into();
}
let clone_result = tokio::process::Command::new(&git_bin)
.args([
"clone",
"--depth",
"1",
"https://github.com/open-jarvis/OpenJarvis.git",
&clone_target,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn();
match clone_result {
Ok(child) => match child.wait_with_output().await {
Ok(output) if output.status.success() => {
project_root = Some(target_path);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let mut s = status.lock().await;
s.error = Some(format!(
"Failed to download OpenJarvis: {}. \
Clone manually: git clone https://github.com/open-jarvis/OpenJarvis.git {}",
stderr.trim(),
clone_target,
));
return;
}
Err(e) => {
let mut s = status.lock().await;
s.error = Some(format!(
"Failed to download OpenJarvis: {}. \
Clone manually: git clone https://github.com/open-jarvis/OpenJarvis.git {}",
e, clone_target,
));
return;
}
},
Err(e) => {
let mut s = status.lock().await;
s.error = Some(format!(
"Could not run git: {}. \
Install git from https://git-scm.com then relaunch.",
e,
));
return;
}
}
}
// Kill any leftover server on our port from a previous run
+421
View File
@@ -0,0 +1,421 @@
---
title: macOS Installation Guide
description: Complete step-by-step guide to installing OpenJarvis on macOS with llama.cpp, including common pitfalls and fixes
search:
boost: 2
---
# macOS Installation Guide
This guide walks through a complete OpenJarvis installation on macOS using **llama.cpp** as
the inference engine. It covers every step from scratch — including pitfalls not documented
elsewhere — and is suitable for both Apple Silicon and Intel Macs.
!!! tip "Prefer Ollama?"
If you want the fastest possible setup, use [Ollama](installation.md#ollama-recommended)
instead. This guide is for users who want to run GGUF models directly with llama.cpp,
or who want a deeper understanding of the full stack.
---
## What You'll Install
| Tool | Purpose |
|------|---------|
| Homebrew | macOS package manager — installs everything else |
| uv | Python version and dependency manager |
| Git | Clones the OpenJarvis repo |
| Node.js | Required for the browser UI |
| Rust | Compiles the OpenJarvis security and memory extension |
| llama.cpp | Local inference engine that runs GGUF model files |
| OpenJarvis | The framework itself |
| A GGUF model | The actual AI model (downloaded separately) |
---
## Step-by-Step Installation
### Step 1 — Install Homebrew
Homebrew is the standard macOS package manager. Everything else in this guide is installed
through it.
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
If you already have Homebrew, skip this step.
---
### Step 2 — Install uv
`uv` replaces pip, virtualenv, and pyenv in one tool. OpenJarvis uses it to manage Python
versions, virtual environments, and project dependencies.
```bash
brew install uv
```
---
### Step 3 — Install Git
Git is used to clone the OpenJarvis source code. It may already be present if you have
Xcode Command Line Tools installed.
```bash
brew install git
```
---
### Step 4 — Install Node.js
Node.js is required to build and run the browser frontend. Without it you can still use
the CLI, but not the web UI.
```bash
brew install node
```
---
### Step 5 — Install Rust
OpenJarvis includes a Rust extension that provides security scanning, memory indexing,
rate limiting, and tool execution. It must be compiled from source.
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
After the installer finishes, reload your shell so `rustc` is available:
```bash
source "$HOME/.cargo/env"
```
Verify:
```bash
rustc --version
```
---
### Step 6 — Install llama.cpp
llama.cpp is the inference engine that loads and runs GGUF model files. It is not a model
itself — think of it as a media player and the `.gguf` file as the content.
```bash
brew install llama.cpp
```
---
### Step 7 — Clone the OpenJarvis repo
Run this from your home directory or any neutral parent folder.
```bash
cd ~
git clone https://github.com/open-jarvis/OpenJarvis.git
cd OpenJarvis
```
!!! warning "Do not clone from inside an existing OpenJarvis folder"
A common mistake is running `git clone` while already inside the repo, creating deeply
nested duplicates (`OpenJarvis/OpenJarvis/OpenJarvis`). Always clone from `~` or a
neutral parent directory.
---
### Step 8 — Pin Python to 3.12
!!! warning "Critical step — do not skip"
OpenJarvis requires Python 3.103.13. Its Rust extension uses PyO3, which does not yet
support Python 3.14. If `uv` has Python 3.14 available, it will use it by default,
causing the Rust extension build to fail silently and resulting in ~250 test failures
with `ModuleNotFoundError: No module named 'openjarvis_rust'`.
Pin the project to Python 3.12:
```bash
echo "3.12" > .python-version
uv python install 3.12
rm -rf .venv
uv venv
```
**Restart your terminal**, then verify:
```bash
uv run python --version
# Must show: Python 3.12.x
```
!!! tip "Why restart the terminal?"
Without restarting, the shell may still reference the old virtual environment. This is
the most common reason the version pin appears not to work.
---
### Step 9 — Install Python dependencies
```bash
uv sync --extra dev --extra server
```
The `--extra server` flag adds the FastAPI backend required for the browser UI.
---
### Step 10 — Build the Rust extension
This compiles the Rust extension and installs it into the virtual environment. It provides
security scanning, memory indexing, MCP tool execution, and rate limiting. This step takes
a few minutes on first run.
```bash
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
```
Verify it built correctly:
```bash
uv run python -c "import openjarvis_rust; print('Rust extension OK')"
```
---
### Step 11 — Install frontend dependencies
```bash
cd frontend && npm install && cd ..
```
---
### Step 12 — Download a model
OpenJarvis needs a GGUF model file to run inference. First install the Hugging Face CLI,
then download your chosen model.
```bash
uv tool install huggingface_hub
```
!!! note "The CLI command is `hf`, not `huggingface-cli`"
When installed via `uv tool`, the Hugging Face CLI is invoked as `hf`.
=== "Qwen3 4B (~2.5 GB)"
Faster, lower RAM requirement. Good for most everyday tasks.
```bash
hf download bartowski/Qwen_Qwen3-4B-GGUF \
--include "Qwen_Qwen3-4B-Q4_K_M.gguf" \
--local-dir ~/models
```
=== "Qwen3 8B (~4.7 GB)"
Better reasoning and instruction following. Requires more RAM.
```bash
hf download bartowski/Qwen_Qwen3-8B-GGUF \
--include "Qwen_Qwen3-8B-Q4_K_M.gguf" \
--local-dir ~/models
```
!!! warning "Use the `Qwen_` prefix"
bartowski's Qwen3 repos use the `Qwen_` prefix (e.g. `Qwen_Qwen3-4B-GGUF`). Using
the shorter name without the prefix returns a "repository not found" error.
!!! tip "Apple Silicon vs Intel"
On Apple Silicon, both models benefit from Metal GPU acceleration when using the MLX
engine. On Intel, inference runs on CPU — the 4B model is recommended for speed.
---
### Step 13 — Configure OpenJarvis
Run the init command to detect your hardware and generate a config file:
```bash
uv run jarvis init
```
Then open the config and set the default model to match the filename you downloaded:
```bash
nano ~/.openjarvis/config.toml
```
Find the `default_model` line and update it, for example:
```toml
default_model = "Qwen_Qwen3-4B-Q4_K_M.gguf"
```
---
### Step 14 — Verify the installation
```bash
uv run jarvis doctor
```
A healthy setup looks like this:
```
✓ Python version 3.12.x
✓ Config file ~/.openjarvis/config.toml
✓ Config parsing Config loaded successfully
✓ Engine: llamacpp Reachable
✓ Models: llamacpp Qwen_Qwen3-4B-Q4_K_M.gguf
✓ Default model Qwen_Qwen3-4B-Q4_K_M.gguf (on llamacpp)
```
!!! note "Warnings for other engines are normal"
The `!` warnings for engines like `ollama`, `vllm`, and `lmstudio` simply mean those
backends are not running. You only need `llamacpp` to be reachable.
---
## Running OpenJarvis
### CLI
Start llama-server in one terminal, then run queries in another:
```bash
# Terminal 1 — start the inference engine
llama-server -m ~/models/Qwen_Qwen3-4B-Q4_K_M.gguf -c 4096 -t 8
# Terminal 2 — ask a question
cd ~/OpenJarvis
uv run jarvis ask "What is the capital of France?"
```
### Browser UI
```bash
# Terminal 1 — inference engine
llama-server -m ~/models/Qwen_Qwen3-4B-Q4_K_M.gguf -c 4096 -t 8
# Terminal 2 — backend
cd ~/OpenJarvis && uv run jarvis serve --port 8000
# Terminal 3 — frontend
cd ~/OpenJarvis/frontend && npm run dev
```
Then open [http://localhost:5173](http://localhost:5173).
### Skip typing `uv run` every time
Activate the virtual environment for your current terminal session:
```bash
source ~/OpenJarvis/.venv/bin/activate
```
Your prompt will show `(openjarvis)` when active, and you can type `jarvis ask "..."` directly.
---
## Performance Tips
These tips apply when using llama.cpp for CPU inference.
| Flag | Effect |
|------|--------|
| `-c 4096` | Reduces context window from the 32,768 default, freeing RAM for faster inference |
| `-t 8` | Uses all available CPU threads (default is only 4) — adjust to your machine's thread count |
| `Q4_K_M` quantization | Best balance of size, speed, and quality for CPU inference |
On Apple Silicon, switching to the [MLX engine](../architecture/engine.md) gives
significantly better performance than llama.cpp for most models.
---
## Common Errors
### `No such file or directory` when loading model
The path `path/to/model.gguf` in examples is a placeholder. Replace it with your actual
model path, e.g.:
```bash
llama-server -m ~/models/Qwen_Qwen3-4B-Q4_K_M.gguf
```
---
### `No module named 'openjarvis_rust'`
The Rust extension did not build correctly, or was built against the wrong Python version.
1. Confirm Python 3.12 is active: `uv run python --version`
2. Rebuild: `uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml`
If the version shows 3.14, go back to [Step 8](#step-8--pin-python-to-312).
---
### `PyO3 version error — Python 3.14 too new`
```
error: the configured Python interpreter version (3.14) is newer than
PyO3's maximum supported version (3.13)
```
PyO3 0.23.5 supports Python up to 3.13. Follow [Step 8](#step-8--pin-python-to-312) to
pin to 3.12, then delete `.venv`, recreate it, and restart your terminal before retrying.
---
### `Repository not found` when downloading model
bartowski's Qwen3 repos use the `Qwen_` prefix. Use:
```
bartowski/Qwen_Qwen3-4B-GGUF ✓
bartowski/Qwen3-4B-GGUF ✗
```
---
### `No inference engine available`
llama-server is not running. Start it in a separate terminal before running any `jarvis`
commands, and wait until you see `model loaded` in the output.
---
### Python version still shows 3.14 after recreating the venv
Close the terminal completely and reopen it. The old venv path is cached in the shell
environment and persists across commands until the session ends.
---
### `zsh: command not found: huggingface-cli`
When installed via `uv tool`, the CLI is invoked as `hf`, not `huggingface-cli`:
```bash
hf download ... # ✓
huggingface-cli download ... # ✗
```
---
## Next Steps
- [Quick Start](quickstart.md) — Run your first query and explore agents and tools
- [Configuration](configuration.md) — Customize engine hosts, model routing, memory, and more
- [Architecture](../architecture/overview.md) — Understand how OpenJarvis is structured
+23 -19
View File
@@ -9,20 +9,24 @@
var allRows = [];
var currentPage = 0;
// No-KV-cache formula: FLOPs = params_b * 1e9 * N * (N+1)
// Reference values only (GPT-5.3 constants) — recompute functions below
// are defined but not called; the leaderboard displays database values directly.
var DEFAULT_PARAMS_B = 137;
var ENERGY_WH_PER_FLOP = 0.4 / (1000 * 3e12);
// Outlier detection — hide entries with values that are physically
// implausible relative to their token count. Thresholds are ~1000x
// above legitimate per-token values to avoid false positives.
var MAX_ENERGY_WH_PER_TOKEN = 10; // legit ≈ 0.001 Wh/tok
var MAX_FLOPS_PER_TOKEN = 1e17; // legit ≈ 1e12 /tok
var MAX_DOLLAR_PER_TOKEN = 25.0 / 1e6; // hard ceiling: $25/1M output
function recomputeFlopsNoKvCache(totalTokens) {
var n = Number(totalTokens) || 0;
if (n <= 0) return 0;
return DEFAULT_PARAMS_B * 1e9 * n * (n + 1);
}
function recomputeEnergyNoKvCache(flops) {
return flops * ENERGY_WH_PER_FLOP;
function isOutlier(row) {
var tokens = Number(row.total_tokens) || 0;
if (tokens <= 0) return false;
var energy = Number(row.energy_wh_saved) || 0;
var flops = Number(row.flops_saved) || 0;
var dollars = Number(row.dollar_savings) || 0;
return (
energy / tokens > MAX_ENERGY_WH_PER_TOKEN ||
flops / tokens > MAX_FLOPS_PER_TOKEN ||
dollars / tokens > MAX_DOLLAR_PER_TOKEN
);
}
function escapeHtml(s) {
@@ -133,17 +137,17 @@
return;
}
allRows = rows;
allRows = rows.filter(function (r) { return !isOutlier(r); });
currentPage = 0;
var totalMembers = rows.length;
var totalMembers = allRows.length;
var totalDollars = 0;
var totalRequests = 0;
var totalTokens = 0;
for (var i = 0; i < rows.length; i++) {
totalDollars += Number(rows[i].dollar_savings || 0);
totalRequests += Number(rows[i].total_calls || 0);
totalTokens += Number(rows[i].total_tokens || 0);
for (var i = 0; i < allRows.length; i++) {
totalDollars += Number(allRows[i].dollar_savings || 0);
totalRequests += Number(allRows[i].total_calls || 0);
totalTokens += Number(allRows[i].total_tokens || 0);
}
var elMembers = document.getElementById("stat-members");
+3 -3
View File
@@ -16,7 +16,7 @@ See how the OpenJarvis community saves money, energy, and compute by running AI
<div class="lb-stat-value" id="stat-members">—</div>
</div>
<div class="lb-stat-card">
<div class="lb-stat-label">Total Saved</div>
<div class="lb-stat-label">Total Saved*</div>
<div class="lb-stat-value" id="stat-dollars">—</div>
</div>
<div class="lb-stat-card">
@@ -35,7 +35,7 @@ See how the OpenJarvis community saves money, energy, and compute by running AI
<tr>
<th style="width:50px">#</th>
<th>Name</th>
<th style="text-align:right">$ Saved</th>
<th style="text-align:right">$ Saved*</th>
<th style="text-align:right">Energy (Wh)</th>
<th style="text-align:right">FLOPs</th>
<th style="text-align:right">Requests</th>
@@ -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 estimates assume local open-source models (e.g. Qwen, Nemotron, Kimi) produce roughly the same number of tokens per request, on average, as closed-source cloud models.
*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.
</p>
+75
View File
@@ -0,0 +1,75 @@
# Agent QA Runbook
Manual testing scenarios for persistent agents in the CLI and desktop app.
## Environment Setup
| Prerequisite | Command / Check |
|---|---|
| Ollama running with model | `ollama list` shows `qwen3:8b` |
| OpenJarvis initialized | `uv run jarvis doctor` all green |
| Rust extension built | `uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml` |
| Desktop app running | `uv run jarvis serve` + `cd frontend && npm run dev` |
| Slack credentials | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` set, bot invited to test channel |
| Gmail credentials | OAuth credentials.json downloaded, token generated |
| Twitter credentials | All 5 env vars set (bearer + OAuth 1.0a) |
| Discord credentials | Bot token set, bot invited to test server |
| Telegram credentials | Bot token from @BotFather, test chat ID known |
| Email credentials | SMTP/IMAP host + credentials for test account |
## CLI Agent Scenarios
| # | Scenario | Steps | Expected Result | Pass |
|---|----------|-------|-----------------|------|
| 1 | Template launch | `jarvis agents launch`, pick research_monitor | Agent created, config printed with curated tools | [ ] |
| 2 | Manual run | `jarvis agents run <id>` | Output shows reasoning + tool calls, status -> idle | [ ] |
| 3 | Immediate ask | `jarvis agents ask <id> "summarize recent AI news"` | Synchronous response in terminal | [ ] |
| 4 | Queued instruct | `jarvis agents instruct <id> "focus on diffusion"`, then `jarvis agents run <id>` | Queued -> delivered, response in `jarvis agents messages <id>` | [ ] |
| 5 | Status check | `jarvis agents status` after 3+ runs | total_runs, total_cost, last_run_at populated | [ ] |
| 6 | Pause/resume | `jarvis agents pause <id>`, verify skipped, `jarvis agents resume <id>`, verify fires | Status toggles correctly | [ ] |
| 7 | Daemon scheduling | `jarvis agents daemon` with interval agent (60s) | 3+ ticks fire on schedule, memory accumulates | [ ] |
| 8 | Budget exhaustion | Set max_cost=0.001, run until exceeded | Status becomes budget_exceeded | [ ] |
| 9 | Error recovery | Kill Ollama mid-tick, then `jarvis agents recover <id>` | Error -> recover -> idle with checkpoint | [ ] |
| 10 | Channel binding | `jarvis agents bind <id> --slack #test`, run tick | Agent sends to Slack | [ ] |
| 11 | Multi-agent | Launch 3 agents, different intervals, run daemon | All fire independently | [ ] |
| 12 | Template tools | Create from each template, `jarvis agents info <id>` | All curated default tools listed | [ ] |
## Desktop App Scenarios
| # | Scenario | Steps | Expected Result | Pass |
|---|----------|-------|-----------------|------|
| 1 | Template wizard | New Agent -> pick each template -> complete wizard | Agent appears in grid with correct config | [ ] |
| 2 | Custom agent | New Agent -> Custom -> manual schedule, pick tools, set credentials | Tools + creds saved, agent created | [ ] |
| 3 | Run Now | Click Run Now on agent card | Status dot: green -> blue -> green, stats increment | [ ] |
| 4 | Immediate chat | Interact tab -> type message -> send (immediate mode) | Response appears in chat UI | [ ] |
| 5 | Queued chat | Interact tab -> send (queued mode) -> click Run Now | Message delivered on tick, response appears | [ ] |
| 6 | Task management | Tasks tab -> create task -> run agent | Task status updates, findings populated | [ ] |
| 7 | Memory inspection | Run 3+ ticks -> Memory tab | Summary memory reflects agent's accumulated knowledge | [ ] |
| 8 | Trace inspection | Run tick -> Logs tab | Trace steps visible with tool calls and results | [ ] |
| 9 | Learning | Enable trace-driven learning -> Learning tab -> trigger | Learning log entries appear | [ ] |
| 10 | Error + recovery | Stop Ollama -> run agent -> verify error badge -> click Recover | Error state shown, recovery resets to idle | [ ] |
## Channel-Specific QA Matrix
| Channel | Send Test | Receive Test | Thread/Reply Test | Agent Template | Pass |
|---------|-----------|-------------|-------------------|----------------|------|
| Slack | Post to #test-channel | Socket Mode incoming msg | Reply in thread (thread_ts) | inbox_triager | [ ] |
| Gmail | Send email to test recipient | Poll unread -> handler fires | Reply in thread (threadId) | inbox_triager | [ ] |
| Email (SMTP/IMAP) | Send via SMTP | IMAP poll UNSEEN | In-Reply-To header | inbox_triager | [ ] |
| iMessage (BlueBubbles) | Send to phone number | N/A (send-only) | N/A | research_monitor | [ ] |
| Twitter/X | Post tweet + send DM | Poll mentions | Reply (in_reply_to_tweet_id) | research_monitor | [ ] |
| Discord | Post to #test-channel | Gateway message event | N/A | code_reviewer | [ ] |
| Telegram | Send to test chat | Long-poll update | reply_to_message_id | research_monitor | [ ] |
| WhatsApp (Baileys) | Send to test number | Baileys incoming msg | N/A | inbox_triager | [ ] |
## Stress & Edge Cases
| # | Scenario | How to Test | Pass Criteria | Pass |
|---|----------|-------------|---------------|------|
| 1 | Message flood | Queue 50 messages via CLI, run tick | All 50 delivered, response generated | [ ] |
| 2 | Long-running daemon | Run daemon for 1 hour, 60s interval agent | No memory leak, no stall, ~60 ticks | [ ] |
| 3 | Rapid pause/resume | Script: pause -> resume -> pause -> resume during tick | Clean state, no corruption | [ ] |
| 4 | Credential revocation | Revoke Slack token mid-tick | Agent gets tool error, tick completes, status not corrupted | [ ] |
| 5 | Multi-agent load | 10 agents on daemon, mix of intervals | All fire on schedule, no interference | [ ] |
| 6 | Large response handling | Agent produces 10k+ char response | summary_memory truncated to 2000 chars, full response in messages | [ ] |
| 7 | Checkpoint integrity | Kill process mid-tick, restart, recover | Checkpoint restored, agent resumes cleanly | [ ] |
+23 -8
View File
@@ -626,7 +626,20 @@ These events enable the telemetry and trace systems to record detailed interacti
## Managed Agent Streaming
The Managed Agent API (`/v1/managed-agents/{id}/messages`) supports real-time SSE streaming. Send a message with `stream: true` to receive the agent's response as a Server-Sent Events stream instead of the default asynchronous queue mode.
The Managed Agent API (`/v1/managed-agents/{id}/messages`) supports **real LLM token streaming** via SSE. Send a message with `stream: true` to receive the model's response tokens as they are generated, rather than waiting for the full response.
### How It Works
The streaming endpoint calls `engine.stream_full()` directly, which yields `StreamChunk` objects containing content tokens, tool-call fragments, and finish reasons. This provides genuine token-by-token streaming from the LLM -- not a post-hoc word replay.
For multi-turn tool-calling agents, the streaming loop automatically:
1. Yields content tokens to the client as they arrive.
2. Accumulates tool-call fragments (OpenAI sends these incrementally).
3. Executes tools when `finish_reason="tool_calls"` is received.
4. Emits tool results as named SSE events (`event: tool_result`).
5. Feeds results back to the LLM for the next turn.
6. Repeats until the model produces a final text response or `max_turns` is reached.
### Streaming Messages
@@ -639,19 +652,21 @@ curl -N -X POST http://localhost:8000/v1/managed-agents/{id}/messages \
The response follows the OpenAI SSE format:
1. **Content chunks** -- `data: {"choices": [{"delta": {"content": "token"}}]}`
2. **Tool results** (if the agent used tools) -- `event: tool_results\ndata: {"results": [...]}`
3. **Final chunk** -- `data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}`
4. **Done sentinel** -- `data: [DONE]`
2. **Tool calls** (if the model requests tool use) -- `event: tool_calls\ndata: {"calls": [{"tool_name": "...", "arguments": "..."}]}`
3. **Tool results** -- `event: tool_result\ndata: {"tool_name": "...", "output": "..."}`
4. **Final chunk** -- `data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}`
5. **Done sentinel** -- `data: [DONE]`
When `stream: false` (the default), the endpoint behaves exactly as before -- the message is queued and the agent must be triggered separately via `/run`.
### Behavior Details
- The user message is always stored in the database before the agent runs.
- After streaming completes, the full agent response is persisted as an `agent_to_user` message.
- The agent is instantiated from the managed agent's stored `agent_type` and `config`.
- Conversation history from prior messages is automatically loaded as context.
- The user message is always stored in the database before streaming starts.
- After streaming completes, the full collected response is persisted as an `agent_to_user` message.
- Conversation history from prior messages is automatically loaded as LLM context.
- The engine's `stream_full()` method is used for real token streaming. Engines that do not override it fall back to the default implementation which wraps the plain `stream()` method.
- If the engine is not available on the server, a `503` error is returned.
- Tool execution during streaming uses the `ToolRegistry` to find and instantiate tools.
### Python Example
+158
View File
@@ -0,0 +1,158 @@
# External MCP Server Integration
OpenJarvis can extend agent capabilities by connecting to external [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers. This allows agents to use tools provided by services like Home Assistant, databases, custom APIs, or any MCP-compatible server -- without writing custom tool code.
## How It Works
When OpenJarvis starts, it reads the `[tools.mcp]` section in `config.toml`. For each configured server, it:
1. Opens a connection using the appropriate transport (Streamable HTTP or stdio).
2. Performs the MCP initialize handshake (protocol version negotiation and `initialized` notification).
3. Discovers available tools via `tools/list`.
4. Wraps each discovered tool as a standard `BaseTool` so agents can call them like any built-in tool.
If a server is unreachable or returns an error, OpenJarvis logs a warning and continues loading the remaining servers. One broken server does not prevent other tools from being available.
## Configuration
External MCP servers are configured in `config.toml` under `[tools.mcp]`:
```toml
[tools.mcp]
enabled = true
servers = '[{"name": "homeassistant", "url": "http://172.16.3.1:9583/private_abc123"}]'
```
The `servers` value is a **JSON-encoded string** containing an array of server objects. Each object defines one external MCP server.
!!! note
The value must be a JSON string (with single-quote TOML delimiters around it), not a native TOML array. This is because the configuration system passes it through as a single string field.
## Server Config Schema
Each server object supports the following fields:
| Field | Type | Required | Description |
|------------------|----------------|----------|----------------------------------------------------------|
| `name` | string | No | Human-readable name used in log messages. Defaults to `<unnamed>`. |
| `url` | string | No* | URL for Streamable HTTP transport. |
| `command` | string | No* | Command to launch a stdio-based MCP server. |
| `args` | list of strings| No | Arguments passed to the stdio command. |
| `include_tools` | list of strings| No | Whitelist of tool names to import. Only these tools are loaded. |
| `exclude_tools` | list of strings| No | Blacklist of tool names to skip. All other tools are loaded. |
*Either `url` or `command` must be provided. If neither is set, the server is skipped with a warning.
When both `include_tools` and `exclude_tools` are specified, the whitelist is applied first, then the blacklist filters the result.
## Examples
### Home Assistant via Streamable HTTP
Connect to the [ha-mcp](https://github.com/tevonsb/ha-mcp) Home Assistant add-on:
```toml
[tools.mcp]
enabled = true
servers = '[{"name": "homeassistant", "url": "http://172.16.3.1:9583/private_abc123"}]'
```
This discovers all HA tools (entity control, automations, history, etc.) and makes them available to agents.
### Stdio Server
Launch a local MCP server as a subprocess:
```toml
[tools.mcp]
enabled = true
servers = '[{"name": "myserver", "command": "python", "args": ["-m", "my_mcp_server"]}]'
```
OpenJarvis starts the process automatically, communicates via JSON-RPC over stdin/stdout, and terminates it on shutdown.
### Multiple Servers
```toml
[tools.mcp]
enabled = true
servers = '[{"name": "homeassistant", "url": "http://172.16.3.1:9583/private_abc123"}, {"name": "database", "command": "db-mcp-server", "args": ["--db", "postgres://localhost/mydb"]}]'
```
### Tool Filtering
When a server exposes many tools but you only need a few, use `include_tools` to whitelist:
```toml
[tools.mcp]
enabled = true
servers = '[{"name": "ha", "url": "http://172.16.3.1:9583/private_abc123", "include_tools": ["hassTurnOn", "hassTurnOff", "hassGetState"]}]'
```
To load everything except specific tools, use `exclude_tools`:
```toml
[tools.mcp]
enabled = true
servers = '[{"name": "ha", "url": "http://172.16.3.1:9583/private_abc123", "exclude_tools": ["hassCreateBackup", "hassDeleteBackup"]}]'
```
## Transport Types
### Streamable HTTP
Used when the `url` field is set. The transport sends JSON-RPC requests as HTTP POST to the given URL using `httpx`. It tracks the `Mcp-Session-Id` header across requests as required by the MCP Streamable HTTP specification.
**When to use:** Remote MCP servers, services running as HTTP endpoints (e.g., Home Assistant MCP add-on, cloud-hosted MCP servers).
**Connection parameters:**
- Connect timeout: 10 seconds
- Request timeout: 60 seconds
### Stdio
Used when the `command` field is set. OpenJarvis spawns the command as a subprocess and communicates via JSON-RPC lines on stdin/stdout.
**When to use:** Local MCP servers distributed as CLI tools, development/testing, servers that require filesystem access on the same machine.
!!! info "SSETransport alias"
`SSETransport` is provided as a backward-compatible alias for `StreamableHTTPTransport`. Both refer to the same implementation.
## Error Handling
OpenJarvis handles MCP server failures gracefully:
- **Server unreachable:** A warning is logged and the server is skipped. All other servers and built-in tools continue to load normally.
- **Timeout:** HTTP requests time out after 60 seconds. The server is skipped with a warning.
- **Invalid config:** If the `servers` JSON is malformed or a server entry has neither `url` nor `command`, a warning is logged and that entry is skipped.
- **Tool discovery failure:** If `tools/list` fails on a server, the error is caught and the server is skipped.
- **Runtime tool call failure:** If a tool call to an external MCP server fails at runtime, it returns a `ToolResult` with `success=False` and the error message.
No single server failure causes OpenJarvis to crash or prevents other tools from working.
## Troubleshooting
### Server not discovered
1. Check that `[tools.mcp]` has `enabled = true`.
2. Verify the `servers` JSON is valid. A common mistake is using TOML arrays instead of a JSON string.
3. Check the OpenJarvis logs for warnings like `Failed to discover external MCP tools`.
### Connection refused / timeout
1. Verify the server is running and reachable from the OpenJarvis host: `curl -v http://host:port/`.
2. Check firewall rules between the OpenJarvis container and the MCP server.
3. For Docker deployments, ensure both containers are on the same network or use host IPs.
### Tools not appearing
1. Run with debug logging to see which tools were discovered.
2. Check if `include_tools` or `exclude_tools` filters are too restrictive.
3. Verify the MCP server actually exposes tools via `tools/list` (some servers only expose resources or prompts).
### Stdio server crashes immediately
1. Test the command manually: `python -m my_mcp_server` should start and wait for input on stdin.
2. Check stderr output in the OpenJarvis logs for error messages from the subprocess.
3. Ensure all dependencies for the MCP server are installed in the same environment.
+1 -1
View File
@@ -204,7 +204,7 @@ All built-in tools are registered via `@ToolRegistry.register()` and are availab
| **Scheduler** | `pause_scheduled_task` | Pause an active scheduled task |
| **Scheduler** | `resume_scheduled_task` | Resume a paused scheduled task |
| **Scheduler** | `cancel_scheduled_task` | Cancel a scheduled task permanently |
| **Integration** | `mcp_adapter` | Bridge to external MCP tool servers |
| **Integration** | `mcp_adapter` | Bridge to external MCP tool servers (see [External MCP Servers](mcp-external-servers.md)) |
---
+3 -3
View File
@@ -67,10 +67,10 @@ export default function App() {
.then((data) => {
setSavings(data);
if (optInEnabled && optInDisplayName && data) {
const dollarSavings = data.per_provider.reduce(
(sum, p) => sum + p.total_cost,
0,
const claudeEntry = data.per_provider.find(
(p) => p.provider === 'claude-opus-4.6',
);
const dollarSavings = claudeEntry ? claudeEntry.total_cost : 0;
const energySaved = data.per_provider.reduce(
(sum, p) => sum + (p.energy_wh || 0),
0,
+10
View File
@@ -100,6 +100,12 @@ export async function fetchModels(): Promise<ModelInfo[]> {
return data.data || [];
}
export async function fetchRecommendedModel(): Promise<{ model: string; reason: string }> {
const res = await fetch(`${getBase()}/v1/recommended-model`);
if (!res.ok) return { model: '', reason: 'Failed to fetch' };
return res.json();
}
export async function pullModel(modelName: string): Promise<void> {
// In Tauri, go through the Rust backend directly (avoids CORS / timeout
// issues with long model downloads via fetch).
@@ -296,6 +302,8 @@ export interface ManagedAgent {
total_runs?: number;
total_cost?: number;
total_tokens?: number;
input_tokens?: number;
output_tokens?: number;
last_run_at?: number | null;
// Schedule
schedule_type?: string;
@@ -304,6 +312,8 @@ export interface ManagedAgent {
budget?: number;
// Learning
learning_enabled?: boolean;
// Live progress
current_activity?: string;
}
export interface AgentTask {
File diff suppressed because it is too large Load Diff
-417
View File
@@ -1,417 +0,0 @@
# Agent Runtime Manual Test Plan
**Branch:** `main`
**PR Reference:** [#32](https://github.com/open-jarvis/OpenJarvis/pull/32)
---
## Setup
```bash
git checkout main && git pull
uv sync --extra dev
```
Create `~/.openjarvis/config.toml`:
```toml
[engine]
type = "cloud"
[intelligence]
default_model = "Qwen/Qwen3.5-35B-A3B"
[engine.cloud]
provider = "openai"
api_key = "sk-..."
```
For every test case, record: **Pass / Fail / Partial / Blocked**, what you actually saw, and screenshots for any UI issues.
---
## Part 1: CLI (`jarvis agents`)
### 1.1 Commands exist
| # | Test | Expected |
|---|------|----------|
| 1 | `jarvis agents --help` | Shows all subcommands: `launch`, `start`, `stop`, `run`, `status`, `logs`, `daemon`, `watch`, `recover`, `errors`, `ask`, `instruct`, `messages`, `list`, `info`, `create`, `pause`, `resume`, `delete`, `bind`, `channels`, `search`, `templates`, `tasks` |
### 1.2 Agent lifecycle: create → run → pause → resume → delete
| # | Test | Expected |
|---|------|----------|
| 2 | `jarvis agents launch` | Wizard: template list → name/schedule/tools/budget/learning prompts → creates agent, prints ID |
| 3 | `jarvis agents list` | Agent appears, status=`idle` |
| 4 | `jarvis agents status` | Table: name, status dot, schedule, last run, runs=0, cost=$0 |
| 5 | `jarvis agents run <id>` | Prints progress then "Tick complete. Status: idle, runs: 1" |
| 6 | `jarvis agents status` | runs=1, last run time updated |
| 7 | `jarvis agents pause <id>` then `status` | Status shows `paused` |
| 8 | `jarvis agents resume <id>` then `status` | Status back to `idle` |
| 9 | `jarvis agents delete <id>` then `list` | Agent gone (soft-deleted/archived, not in list) |
### 1.3 Agent creation variants
| # | Test | Expected |
|---|------|----------|
| 10 | `jarvis agents create "Test Agent"` | Creates agent by name, prints ID |
| 11 | `jarvis agents create --template <template_name>` | Creates from template, inherits template config |
| 12 | `jarvis agents launch` → pick a template | Wizard pre-fills config from template |
| 13 | `jarvis agents launch` → pick "Custom Agent" | Wizard starts with blank config |
| 14 | `jarvis agents templates` | Lists built-in + user templates with descriptions |
### 1.4 Scheduling
| # | Test | Expected |
|---|------|----------|
| 15 | Create agent with `schedule_type=interval`, `schedule_value=30` | Created |
| 16 | `jarvis agents start <id>` | "Agent registered with scheduler" |
| 17 | `jarvis agents stop <id>` | "Agent deregistered from scheduler" |
| 18 | `jarvis agents daemon` | Starts, prints agent count, blocks. Ctrl+C → "Daemon stopped." clean exit |
| 19 | Create agent with `schedule_type=cron`, `schedule_value="*/5 * * * *"` | Created |
| 20 | `jarvis agents start <id>` (cron agent) | Registered, next fire time displayed or logged |
| 21 | Create agent with `schedule_type=manual` then `start <id>` | Agent registered but never auto-fires |
### 1.5 Interaction: ask / instruct / messages
| # | Test | Expected |
|---|------|----------|
| 22 | `jarvis agents ask <id> "What is 2+2?"` | Runs tick, prints agent response inline |
| 23 | `jarvis agents messages <id>` | Shows user→agent ask + agent→user response |
| 24 | `jarvis agents instruct <id> "Focus on ML papers"` | "Instruction queued for next tick" |
| 25 | `jarvis agents messages <id>` | Queued instruction shows `[queued]`, status=pending |
| 26 | `jarvis agents run <id>` then `messages <id>` | Queued message now delivered, status changes from pending |
| 27 | `jarvis agents ask <id> ""` (empty message) | Graceful error or rejection, no crash |
| 28 | `jarvis agents instruct <id>` with very long message (>1000 chars) | Accepted and stored correctly |
### 1.6 Error recovery & monitoring
| # | Test | Expected |
|---|------|----------|
| 29 | `jarvis agents errors` | Lists agents in error/needs_attention/stalled/budget_exceeded (or empty table) |
| 30 | `jarvis agents recover <id>` (on errored agent) | Restores checkpoint, status → `idle` |
| 31 | `jarvis agents recover <id>` (on idle agent) | Clear message: "Agent is not in error state" or similar |
| 32 | `jarvis agents logs <id>` | Recent traces with tick IDs and timestamps |
| 33 | `jarvis agents logs <nonexistent_id>` | Clear error: "Agent not found" |
| 34 | `jarvis agents watch` (then run a tick in another terminal) | Events stream live: AGENT_TICK_START, AGENT_TICK_END visible. Ctrl+C to stop. |
| 35 | `jarvis agents watch <id>` | Same, filtered to one agent only |
| 36 | `jarvis agents watch` then Ctrl+C | Clean exit, no traceback, no hanging threads |
### 1.7 Agent info & inspection
| # | Test | Expected |
|---|------|----------|
| 37 | `jarvis agents info <id>` | Shows agent type, status, memory snippet, tasks, channels, config details |
| 38 | `jarvis agents tasks <id>` | Lists tasks with statuses (or empty state) |
| 39 | `jarvis agents channels <id>` | Lists channel bindings (or empty state) |
| 40 | `jarvis agents search "keyword"` | Searches across agent traces, returns relevant results |
### 1.8 Edge cases & invalid input
| # | Test | Expected |
|---|------|----------|
| 41 | `jarvis agents run <nonexistent_id>` | Clear error: "Agent not found" — no Python traceback |
| 42 | `jarvis agents pause <id>` twice | Second pause is no-op or clear message, no crash |
| 43 | `jarvis agents resume <id>` (already idle) | No-op or clear message, no crash |
| 44 | `jarvis agents run <id>` while another tick is running | Concurrency guard: "Agent is already running" error |
| 45 | `jarvis agents delete <id>` then `run <id>` | Clear error about deleted/archived agent |
| 46 | Create agent with invalid cron expression | Rejected with clear validation error |
| 47 | Create agent with negative budget | Rejected or clamped to 0 |
### 1.9 CLI aesthetics
| # | Check | Expected |
|---|-------|----------|
| 48 | `status` table formatting | Columns aligned, readable at 80-char terminal width |
| 49 | Error messages (run with no engine configured) | Clear human-readable message, no Python tracebacks |
| 50 | `launch` wizard prompts | Clear labels, sensible defaults, no confusing jargon |
| 51 | `watch` event stream | Color-coded, event type + agent name visible, timestamps |
| 52 | `list` table with 0 agents | "No agents found" or empty table — not a crash |
| 53 | `list` table with 10+ agents | Table remains readable, no column overflow |
| 54 | All commands with `--help` | Every subcommand has a help string |
---
## Part 2: Web Frontend
### 2.0 Setup
```bash
# Terminal 1 # Terminal 2
uv run jarvis serve cd frontend && npm install && npm run dev
```
Open http://localhost:5173, navigate to **Agents** page via sidebar.
### 2.1 Navigation & routing
| # | Test | Expected |
|---|------|----------|
| 55 | Click "Agents" in sidebar | AgentsPage renders, URL is `/agents` |
| 56 | Direct navigation to `/agents` | Page loads correctly (no blank screen) |
| 57 | Browser back/forward after visiting agent detail | Navigation works, state preserved |
### 2.2 List view
| # | Test | Expected |
|---|------|----------|
| 58 | Page loads with backend running | No console errors, agent list renders |
| 59 | Page loads with backend **down** | User-visible error message (not blank white screen), no console exceptions |
| 60 | Agent cards | Name, color status dot, schedule description, last run time, runs count, cost |
| 61 | "Run Now" button | Triggers tick, card updates (runs count increments, last run time updates) |
| 62 | Pause/Resume button | Toggles status, dot color changes immediately |
| 63 | Agent list auto-refresh | After running a tick via CLI, the web list eventually reflects the updated state |
| 64 | 10+ agents in list | Cards render without performance issues, scroll works |
### 2.3 Launch wizard
| # | Test | Expected |
|---|------|----------|
| 65 | Click "Launch Agent" | Modal appears: Step 1 template picker with templates + "Custom Agent" option |
| 66 | Templates load from API | Template cards display with names and descriptions |
| 67 | Select template → Next → Step 2 | Config form: name (pre-filled from template), schedule_type dropdown, schedule_value, tools checkboxes, budget, learning toggle (off) |
| 68 | Select "Custom Agent" → Next → Step 2 | Config form with blank name, no pre-filled values |
| 69 | Next → Step 3 | Review summary of all config values |
| 70 | Click Launch | Agent created, modal closes, new agent appears in list |
| 71 | Back button at Step 2 | Returns to Step 1, template selection preserved |
| 72 | Back button at Step 3 | Returns to Step 2, all form inputs preserved |
| 73 | Launch with empty name | Inline error: "Agent name is required" — modal stays open |
| 74 | Launch with all tools selected | All tools included in review and in created agent config |
| 75 | Click outside modal / press Escape | Modal closes (or stays open — document behavior) |
| 76 | Schedule type = "Manual" | schedule_value input is disabled/hidden |
| 77 | Schedule type = "Cron" | schedule_value placeholder shows cron example |
| 78 | Schedule type = "Interval" | schedule_value placeholder shows seconds example |
### 2.4 Detail view (click an agent)
| # | Test | Expected |
|---|------|----------|
| 79 | Click agent card | Detail view opens with tabbed interface |
| 80 | **Overview** tab | Stat cards (Total Runs, Success Rate, Total Cost), config display, channels list, action buttons |
| 81 | **Overview** action buttons | Run Now, Pause, Resume visible and functional |
| 82 | **Interact** tab | Chat message list, textarea, "Immediate" and "Queue" send buttons |
| 83 | Send immediate message | Appears in chat with user styling, agent responds after tick |
| 84 | Send queued message | Shows with "queued" badge, status=pending |
| 85 | Send empty message | Button disabled or graceful rejection — no empty message sent |
| 86 | Rapid-fire send (click Send multiple times quickly) | No duplicate messages, no race condition errors |
| 87 | Chat auto-scroll | New messages scroll into view automatically |
| 88 | **Tasks** tab | Task list with status badges (completed=green, failed=red, active=blue, pending=gray) |
| 89 | **Tasks** tab (no tasks) | Empty state: "No tasks assigned." |
| 90 | **Memory** tab | summary_memory text displayed in readable format |
| 91 | **Memory** tab (no memory) | Empty state: "Agent has no stored memory yet." |
| 92 | **Learning** tab | Toggle switch (read-only, off by default), placeholder text for future events |
| 93 | **Logs** tab | Placeholder / empty state message (not a crash or blank) |
| 94 | Tab switching — rapid clicks | All 6 tabs render instantly, no layout shift, no flash of wrong content |
### 2.5 Error states
| # | Test | Expected |
|---|------|----------|
| 95 | Agent in `error` status | Red status dot/badge, "Recover" button visible |
| 96 | Click Recover | Status resets to `idle`, dot turns green |
| 97 | Agent in `needs_attention` status | Amber badge visible |
| 98 | Agent in `budget_exceeded` status | Orange badge visible |
| 99 | Agent in `stalled` status | Yellow badge visible |
| 100 | Backend goes down while page is open | Next refresh/action shows error — not silent failure |
| 101 | Delete agent → confirm it disappears from list | Agent removed from list immediately (or on next refresh) |
| 102 | Delete agent (no confirmation dialog in web) | **Document:** Is instant delete OK or should there be a confirm? |
### 2.6 Overflow menu
| # | Test | Expected |
|---|------|----------|
| 103 | Click "..." menu on agent card | Dropdown with Delete + other options |
| 104 | Click Delete from menu | Agent deleted, list updates |
| 105 | Click outside dropdown | Dropdown closes |
### 2.7 Web aesthetics & UX
| # | Check | Expected |
|---|-------|----------|
| 106 | Status dot colors | idle=#22c55e, running=#3b82f6, paused=#6b7280, error=#ef4444, needs_attention=#f59e0b, budget_exceeded=#f97316, stalled=#eab308 |
| 107 | Launch wizard spacing/alignment | Modal centered, steps clearly numbered, form inputs aligned, no overlap |
| 108 | Detail view tab switching | Instant, no layout shift or flash |
| 109 | Interact tab chat feel | Messages visually distinct (user=right vs agent=left or different colors), auto-scroll, clear input area |
| 110 | Responsive at 1024px width | No overflow or cut-off content, agent cards reflow |
| 111 | Responsive at 1440px width | Proper use of space, no excessive stretching |
| 112 | Responsive at 768px width (tablet) | Still usable, no broken layout |
| 113 | Empty states | "No agents yet" + CTA button / "No messages" / "No tasks" — not blank white space |
| 114 | Loading states | "Loading agents..." shown during fetch, spinner or skeleton |
| 115 | Page title / browser tab | Meaningful title (not just "Vite App") |
| 116 | Console errors | Zero console errors during normal usage flow |
---
## Part 3: Desktop App
### 3.0 Setup
```bash
# Terminal 1 # Terminal 2
uv run jarvis serve cd desktop && npm install && npm run tauri dev
```
Navigate to the **Agents** tab.
### 3.1 Functionality
| # | Test | Expected |
|---|------|----------|
| 117 | Left panel: agent list | Status dots, schedule descriptions, last run times |
| 118 | Click agent → right panel | Tabbed detail view (Overview, Interact, Tasks, Memory, Learning, Logs) |
| 119 | No agent selected | Right panel shows "Select an agent to view details" |
| 120 | "Launch Agent" button | Opens wizard, same 3-step flow as web |
| 121 | Launch wizard → Create agent | Agent appears in left panel list |
| 122 | **Overview** tab | Key-value stats (Status, Agent Type, Schedule, Last Run, Total Runs, Total Cost, Budget) + action buttons (Run Now, Pause, Resume, Recover) |
| 123 | **Interact** tab | Chat UI, mode toggle (immediate/queued), Enter shortcut sends message |
| 124 | Send immediate message | Response appears in chat |
| 125 | Send queued message | Shows as pending |
| 126 | **Tasks** tab | Task list with colored status badges + created-at timestamps |
| 127 | **Memory** tab | summary_memory in monospace font |
| 128 | **Learning** tab | Shows enabled/disabled status + placeholder text |
| 129 | **Logs** tab | Placeholder: "Log streaming not yet connected." |
| 130 | Auto-refresh | Agent list refreshes on ~10s interval (verify with CLI-triggered state change) |
| 131 | Delete agent via desktop | Confirmation dialog appears, agent removed on confirm |
### 3.2 Desktop edge cases
| # | Test | Expected |
|---|------|----------|
| 132 | Backend not running → open desktop app | Error state shown, not a crash |
| 133 | Backend dies while desktop is open | Graceful degradation on next action/refresh |
| 134 | Selected agent deleted via CLI → desktop refreshes | Selected agent deselects, list updates |
### 3.3 Desktop aesthetics
| # | Check | Expected |
|---|-------|----------|
| 135 | Catppuccin color scheme consistent | idle=#a6e3a1, running=#89b4fa, paused=#6c7086, error=#f38ba8, needs_attention=#fab387, stalled=#f9e2af |
| 136 | Left/right panel split | Resizable or fixed at reasonable ratio, no overlap |
| 137 | Tab switching | Smooth, no flicker |
| 138 | Launch wizard modal | Properly overlays content, dismissible with Escape or outside click |
| 139 | Text readability | Font sizes consistent, sufficient contrast against dark background |
| 140 | Window resize | Layout adapts, no overflow or clipping |
| 141 | Status badge consistency with web | Same statuses map to same semantic colors (green=idle, blue=running, etc.) |
---
## Part 4: API Backend (Direct)
### 4.1 REST endpoint smoke tests
Run with `uv run jarvis serve` and test via curl or Postman.
| # | Test | Expected |
|---|------|----------|
| 142 | `GET /v1/managed-agents` | 200, returns `[]` or agent list JSON |
| 143 | `POST /v1/managed-agents` with valid body | 200/201, returns created agent JSON with `id` |
| 144 | `POST /v1/managed-agents` with empty body | 422 or 400 with validation error |
| 145 | `GET /v1/managed-agents/<id>` | 200, returns single agent |
| 146 | `GET /v1/managed-agents/<bad_id>` | 404, returns error JSON |
| 147 | `POST /v1/managed-agents/<id>/run` | 200, tick executes |
| 148 | `POST /v1/managed-agents/<id>/pause` | 200, status changes to paused |
| 149 | `POST /v1/managed-agents/<id>/resume` | 200, status changes to idle |
| 150 | `POST /v1/managed-agents/<id>/recover` | 200 if errored, appropriate error if not |
| 151 | `DELETE /v1/managed-agents/<id>` | 200, agent archived |
| 152 | `GET /v1/templates` | 200, returns template list |
| 153 | `POST /v1/templates/<id>/instantiate` | 200, creates agent from template |
| 154 | `GET /v1/agents/errors` | 200, returns list of problem agents |
| 155 | `GET /v1/agents/health` | 200, returns health summary |
### 4.2 Message endpoints
| # | Test | Expected |
|---|------|----------|
| 156 | `POST /v1/managed-agents/<id>/messages` with `{"content":"hi","direction":"user_to_agent","mode":"immediate"}` | 200, message stored |
| 157 | `GET /v1/managed-agents/<id>/messages` | 200, returns message list |
| 158 | `POST /v1/managed-agents/<id>/messages` with `{"content":"","direction":"user_to_agent","mode":"immediate"}` | 422 or graceful handling |
| 159 | `POST /v1/managed-agents/<id>/messages` with `{"content":"cmd","direction":"user_to_agent","mode":"queued"}` | 200, message has status=pending |
### 4.3 Task & channel endpoints
| # | Test | Expected |
|---|------|----------|
| 160 | `GET /v1/managed-agents/<id>/tasks` | 200, returns task list |
| 161 | `POST /v1/managed-agents/<id>/tasks` | 200, creates task |
| 162 | `GET /v1/managed-agents/<id>/channels` | 200, returns channel bindings |
| 163 | `GET /v1/managed-agents/<id>/state` | 200, returns full agent state |
### 4.4 WebSocket events
| # | Test | Expected |
|---|------|----------|
| 164 | Connect to `ws://localhost:8222/v1/agents/events` | Connection established |
| 165 | Trigger a tick → observe WS messages | Receive AGENT_TICK_START and AGENT_TICK_END events |
| 166 | Connect with `?agent_id=<id>` filter | Only events for that agent |
| 167 | Disconnect cleanly | No server error logs |
---
## Part 5: Cross-Platform Consistency
| # | Test | Expected |
|---|------|----------|
| 168 | Create agent via CLI → check web + desktop | Same name, status, config everywhere |
| 169 | Run tick via CLI → check web + desktop | Run count and last run time update in both UIs |
| 170 | Send message via web Interact → check CLI `messages` | Same content, direction, mode |
| 171 | Pause via desktop → check CLI `status` + web | `paused` everywhere |
| 172 | Delete via web → check CLI `list` + desktop | Gone everywhere |
| 173 | Create via web wizard → check CLI `list` + desktop | Agent visible in all three |
| 174 | Recover via CLI → check web + desktop | Status back to idle in all UIs |
| 175 | Send queued message via CLI `instruct` → check web Interact | Message shows with pending/queued status |
| 176 | Multiple agents created from different clients | All agents appear correctly in all views |
---
## Part 6: Stress & Concurrency
| # | Test | Expected |
|---|------|----------|
| 177 | Run tick on same agent from two terminals simultaneously | Concurrency guard blocks second tick: "Agent is already running" |
| 178 | Create 20+ agents → check list performance | All clients render list without lag |
| 179 | Rapidly pause/resume same agent | All state transitions correct, no stuck states |
| 180 | Run daemon + manual `run` at same time | No double-ticking, concurrency guard holds |
| 181 | Delete agent while tick is in progress | Tick completes or fails gracefully, agent ends up archived |
---
## Part 7: Deferred Features (Placeholder Verification)
Confirm these show placeholders (not crashes):
| # | Feature | CLI | Web | Desktop |
|---|---------|-----|-----|---------|
| 182 | Budget enforcement | `run` still works even if cost > budget | No enforcement, budget is display-only | Same |
| 183 | Stall detection | No automatic stall detection fires | N/A | N/A |
| 184 | Learning event timeline | `Learning` tab shows placeholder text | Same | Same |
| 185 | Logs trace replay | `Logs` tab shows placeholder text | Same | Same |
| 186 | `POST /v1/skills` | N/A | N/A | Returns `"not_implemented"` |
| 187 | `POST /v1/optimize/runs` | N/A | N/A | Returns placeholder `run_id` |
| 188 | `GET /v1/feedback/stats` | N/A | N/A | Returns `{total: 0, mean_score: 0.0}` |
---
## Deliverables
**1. Test results** — Spreadsheet with columns: #, Status (Pass/Fail/Partial/Blocked), Actual Behavior, Screenshot (for UI issues).
**2. Bug list** — Each bug: steps to reproduce, expected vs actual, severity (Critical/Major/Minor), screenshot.
**3. UX & aesthetics feedback** — Is the launch wizard clear? Are status colors distinguishable? Does the Interact tab feel like chat? Is CLI output readable? Are error messages helpful? Is the delete-without-confirm behavior in web acceptable?
**4. API error handling audit** — Document all cases where the frontend silently swallows errors (currently: agent list fetch, interact tab sends). Recommend which should show user-visible errors.
**5. Deferred features check** — Confirm placeholder items in Part 7 show graceful stubs (not crashes or blank screens).
---
## Notes
- Backend (`jarvis serve`) must be running for web and desktop (default port 8222).
- Without an engine configured, `run`/`ask` will error — document whether the error message is clear.
- `daemon` and `watch` block — Ctrl+C to exit.
- Web frontend API client has unused functions (`updateManagedAgent`, `createAgentTask`, `fetchAgentState`, `fetchErrorAgents`) — not a bug, but note for future.
- Desktop API client is missing some endpoints that the web client has (`fetchAgentChannels`, `fetchAgentState`, `fetchErrorAgents`) — may affect feature parity.
- Frontend has **no automated tests** — all testing is manual per this plan.
- Both frontends silently catch API errors (`.catch(() => {})`) — this is a known UX gap to evaluate.
+4
View File
@@ -143,6 +143,7 @@ nav:
- Home: index.md
- Getting Started:
- Installation: getting-started/installation.md
- macOS Guide: getting-started/macos.md
- Quick Start: getting-started/quickstart.md
- Code Snippets: getting-started/snippets.md
- Configuration: getting-started/configuration.md
@@ -163,5 +164,8 @@ nav:
- Security: architecture/security.md
- Design Principles: architecture/design-principles.md
- API Reference: api-reference/
- User Guide:
- Tools: user-guide/tools.md
- External MCP Servers: user-guide/mcp-external-servers.md
- Leaderboard: leaderboard.md
- Roadmap: development/roadmap.md
+8
View File
@@ -17,6 +17,7 @@ dependencies = [
"python-telegram-bot>=22.6",
"rich>=13",
"tomli>=2.0; python_version < '3.11'",
"tomlkit>=0.12",
]
[project.optional-dependencies]
@@ -83,6 +84,12 @@ channel-zulip = ["zulip>=0.9"]
channel-twitch = ["twitchio>=2.6"]
channel-nostr = ["pynostr>=0.6"]
channel-twilio = ["twilio>=9.0"]
channel-gmail = [
"google-api-python-client>=2.0",
"google-auth-oauthlib>=1.0",
"google-auth-httplib2>=0.2",
]
channel-twitter = ["tweepy>=4.14"]
browser = ["playwright>=1.40"]
media = ["openai>=1.30"]
pdf = ["pdfplumber>=0.10"]
@@ -123,6 +130,7 @@ markers = [
"apple: requires Apple Silicon",
"macos15: requires macOS 15+ (Sequoia) for Apple FM",
"slow: long-running test",
"live_channel: requires real channel credentials (env vars)",
]
[tool.ruff]
@@ -0,0 +1,37 @@
-- Migration: Recompute dollar_savings to use only Claude Opus 4.6 pricing
-- -----------------------------------------------------------------------
-- Previously the frontend summed hypothetical costs across all 3 cloud
-- providers (GPT-5.3 + Claude Opus 4.6 + Gemini 3.1 Pro). This
-- migration recalculates dollar_savings using Claude Opus 4.6 only.
--
-- Derivation
-- ----------
-- Let P = prompt_tokens, C = completion_tokens, T = total_tokens = P + C.
--
-- old = (P/1M)*(2+5+2) + (C/1M)*(10+25+12) = (P/1M)*9 + (C/1M)*47
-- new = (P/1M)*5 + (C/1M)*25
--
-- Solving the system {T = P + C, old = 9P/1M + 47C/1M} for P and C and
-- substituting into the "new" formula gives:
--
-- new = T / 3_800_000 + 10 * old / 19
--
-- Run this in the Supabase SQL Editor (Dashboard > SQL Editor).
-- -----------------------------------------------------------------------
BEGIN;
-- Preview the changes first (uncomment the SELECT, comment the UPDATE)
-- SELECT
-- display_name,
-- dollar_savings AS old_savings,
-- total_tokens / 3800000.0 + 10.0 * dollar_savings / 19.0 AS new_savings
-- FROM savings_entries
-- WHERE dollar_savings > 0
-- ORDER BY dollar_savings DESC;
UPDATE savings_entries
SET dollar_savings = total_tokens / 3800000.0 + 10.0 * dollar_savings / 19.0
WHERE dollar_savings > 0;
COMMIT;
+142 -4
View File
@@ -47,6 +47,34 @@ class AgentExecutor:
"""Deferred system injection — called after JarvisSystem is constructed."""
self._system = system
def _set_activity(self, agent_id: str, activity: str) -> None:
"""Update the agent's current_activity for progress visibility."""
try:
self._manager.update_agent(agent_id, current_activity=activity)
except Exception:
pass # Non-critical
def _inject_tool_deps(self, tool: Any) -> None:
"""Inject runtime dependencies into a tool instance.
Mirrors SystemBuilder._inject_tool_deps (system.py:920-945)
but uses the lightweight system's references.
"""
if self._system is None:
return
name = getattr(getattr(tool, "spec", None), "name", "")
if name == "llm":
if hasattr(tool, "_engine"):
tool._engine = self._system.engine
if hasattr(tool, "_model"):
tool._model = self._system.model
elif name == "retrieval" or name.startswith("memory_"):
if hasattr(tool, "_backend"):
tool._backend = getattr(self._system, "memory_backend", None)
elif name.startswith("channel_"):
if hasattr(tool, "_channel"):
tool._channel = getattr(self._system, "channel_backend", None)
def run_ephemeral(
self,
agent_type: str,
@@ -75,6 +103,7 @@ class AgentExecutor:
"""
try:
self._manager.start_tick(agent_id)
self._set_activity(agent_id, "Preparing tick...")
except ValueError:
logger.warning("Agent %s already running, skipping tick", agent_id)
return
@@ -203,6 +232,13 @@ class AgentExecutor:
if not model:
raise FatalError("No model configured for agent")
logger.info(
"Agent %s [%s]: using model=%s, engine=%s",
agent["name"], agent["id"],
model, type(engine).__name__,
)
self._set_activity(agent["id"], f"Loading model {model}...")
# Optionally override model via router policy
router_policy_key = config.get("router_policy")
if router_policy_key and self._system:
@@ -224,12 +260,44 @@ class AgentExecutor:
except Exception:
pass # Fall back to configured model
# Construct agent instance (BaseAgent requires engine, model as positional args)
# Resolve tools from config via ToolRegistry
tool_names = config.get("tools", [])
if isinstance(tool_names, str):
tool_names = [t.strip() for t in tool_names.split(",") if t.strip()]
tool_instances: list[Any] = []
if tool_names:
try:
from openjarvis.server.agent_manager_routes import (
_ensure_registries_populated,
)
_ensure_registries_populated()
except ImportError:
pass
from openjarvis.core.registry import ToolRegistry
for tname in tool_names:
if ToolRegistry.contains(tname):
try:
tool_cls = ToolRegistry.get(tname)
tool = tool_cls()
self._inject_tool_deps(tool)
tool_instances.append(tool)
except Exception:
logger.warning("Failed to instantiate tool %s", tname)
if tool_instances:
logger.info(
"Agent %s: resolved %d/%d tools",
agent["name"], len(tool_instances), len(tool_names),
)
# Construct agent instance
agent_instance = agent_cls(
engine,
model,
system_prompt=config.get("system_prompt"),
tools=[],
tools=tool_instances,
)
# Build input from instruction + summary_memory + pending messages
@@ -247,6 +315,20 @@ class AgentExecutor:
input_text = f"{input_text}\n\nNew instructions:\n{user_msgs}"
for m in pending:
self._manager.mark_message_delivered(m["id"])
logger.info(
"Agent %s: delivering %d pending message(s)",
agent["name"], len(pending),
)
self._set_activity(
agent["id"],
f"Delivering {len(pending)} message(s)...",
)
else:
logger.info(
"Agent %s: no pending messages, running with "
"instruction only",
agent["name"],
)
# Build AgentContext with memory results from FTS5 backend
from openjarvis.agents._stubs import AgentContext
@@ -298,7 +380,36 @@ class AgentExecutor:
pass # Don't break agent tick if memory retrieval fails
agent_ctx.memory_results = memory_results
return agent_instance.run(input_text, context=agent_ctx)
self._set_activity(agent["id"], "Generating response...")
logger.info(
"Agent %s: calling agent.run() with %d chars input",
agent["name"], len(input_text),
)
_t0 = time.time()
result = agent_instance.run(input_text, context=agent_ctx)
# Retry once if the model returned empty content (common with
# Qwen3.5 thinking mode consuming all tokens).
if not (result.content or "").strip():
self._set_activity(
agent["id"], "Retrying (empty response)...",
)
logger.warning(
"Agent %s: empty content, retrying once",
agent["name"],
)
result = agent_instance.run(input_text, context=agent_ctx)
_elapsed = time.time() - _t0
logger.info(
"Agent %s: agent.run() completed in %.1fs, "
"content_len=%d, turns=%d, tokens=%s",
agent["name"], _elapsed,
len(result.content or ""),
result.turns,
result.metadata.get("total_tokens", "?"),
)
return result
def _build_error_detail(self, error: AgentTickError) -> dict[str, Any]:
"""Build structured error detail for trace metadata."""
@@ -334,18 +445,37 @@ class AgentExecutor:
duration: float,
) -> None:
"""Update agent state after tick completion or failure."""
self._set_activity(agent_id, "Finalizing...")
if error is None:
# Success
logger.info(
"Tick succeeded for agent %s in %.1fs, "
"response_len=%d",
agent_id, duration,
len(result.content or "") if result else 0,
)
self._manager.end_tick(agent_id)
self._manager.update_agent(agent_id, total_runs_increment=1)
# Accumulate budget metrics from AgentResult metadata
if result:
tokens = result.metadata.get("tokens_used", 0)
tokens = (
result.metadata.get("total_tokens")
or result.metadata.get("tokens_used")
or 0
)
in_tokens = result.metadata.get("prompt_tokens", 0)
out_tokens = result.metadata.get(
"completion_tokens", 0,
)
cost = result.metadata.get("cost", 0.0)
budget_kwargs: dict[str, Any] = {"stall_retries": 0}
if tokens > 0:
budget_kwargs["total_tokens_increment"] = tokens
if in_tokens > 0:
budget_kwargs["input_tokens_increment"] = in_tokens
if out_tokens > 0:
budget_kwargs["output_tokens_increment"] = out_tokens
if cost > 0:
budget_kwargs["total_cost_increment"] = cost
self._manager.update_agent(agent_id, **budget_kwargs)
@@ -381,6 +511,10 @@ class AgentExecutor:
"status": "ok",
})
elif isinstance(error, EscalateError):
logger.warning(
"Tick escalated for agent %s after %.1fs: %s",
agent_id, duration, error,
)
self._manager.end_tick(agent_id)
self._manager.update_agent(agent_id, status="needs_attention")
self._bus.publish(EventType.AGENT_TICK_ERROR, {
@@ -390,6 +524,10 @@ class AgentExecutor:
"duration": duration,
})
else:
logger.error(
"Tick failed for agent %s after %.1fs: %s",
agent_id, duration, error, exc_info=error,
)
self._manager.end_tick(agent_id)
self._manager.update_agent(agent_id, status="error")
# Write error detail to summary_memory so frontend can display it
+30 -2
View File
@@ -111,6 +111,9 @@ class AgentManager:
"ALTER TABLE managed_agents ADD COLUMN last_run_at REAL",
"ALTER TABLE managed_agents ADD COLUMN last_activity_at REAL",
"ALTER TABLE managed_agents ADD COLUMN stall_retries INTEGER DEFAULT 0",
"ALTER TABLE managed_agents ADD COLUMN current_activity TEXT DEFAULT ''",
"ALTER TABLE managed_agents ADD COLUMN input_tokens INTEGER DEFAULT 0",
"ALTER TABLE managed_agents ADD COLUMN output_tokens INTEGER DEFAULT 0",
]
for migration in _MIGRATIONS:
try:
@@ -160,7 +163,7 @@ class AgentManager:
def update_agent(self, agent_id: str, **kwargs: Any) -> Dict[str, Any]:
sets: List[str] = []
vals: List[Any] = []
for key in ("name", "agent_type", "status"):
for key in ("name", "agent_type", "status", "current_activity"):
if key in kwargs:
sets.append(f"{key} = ?")
vals.append(kwargs[key])
@@ -181,6 +184,14 @@ class AgentManager:
if total_tokens_increment:
sets.append("total_tokens = total_tokens + ?")
vals.append(total_tokens_increment)
input_tokens_increment = kwargs.get("input_tokens_increment", 0)
if input_tokens_increment:
sets.append("input_tokens = input_tokens + ?")
vals.append(input_tokens_increment)
output_tokens_increment = kwargs.get("output_tokens_increment", 0)
if output_tokens_increment:
sets.append("output_tokens = output_tokens + ?")
vals.append(output_tokens_increment)
if "last_activity_at" in kwargs:
sets.append("last_activity_at = ?")
vals.append(kwargs["last_activity_at"])
@@ -222,7 +233,12 @@ class AgentManager:
self._set_status(agent_id, "running")
def end_tick(self, agent_id: str) -> None:
self._set_status(agent_id, "idle")
self._conn.execute(
"UPDATE managed_agents SET status = 'idle', "
"current_activity = '', updated_at = ? WHERE id = ?",
(time.time(), agent_id),
)
self._conn.commit()
# ── Checkpoints ───────────────────────────────────────────────
@@ -472,6 +488,15 @@ class AgentManager:
if overrides:
config.update(overrides)
agent_type = config.pop("agent_type", "monitor_operative")
# Expand system_prompt_template with instruction
prompt_tpl = config.pop("system_prompt_template", "")
if prompt_tpl:
instruction = config.get("instruction", "")
config["system_prompt"] = prompt_tpl.format(
instruction=instruction or "(No specific instruction provided)",
)
return self.create_agent(name=name, agent_type=agent_type, config=config)
# ── Message queue ─────────────────────────────────────────────
@@ -638,6 +663,9 @@ class AgentManager:
"last_run_at": row["last_run_at"],
"last_activity_at": row["last_activity_at"],
"stall_retries": row["stall_retries"] or 0,
"current_activity": row["current_activity"] or "",
"input_tokens": row["input_tokens"] or 0,
"output_tokens": row["output_tokens"] or 0,
}
@staticmethod
+52 -1
View File
@@ -283,14 +283,25 @@ class NativeOpenHandsAgent(ToolUsingAgent):
last_content = ""
total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
# Build OpenAI-format tool schemas for native function calling
openai_tools = (
self._executor.get_openai_tools() if self._tools else []
)
# Side dict for Gemini thought_signatures (ToolCall uses slots)
_thought_sigs: dict[str, bytes] = {}
for _turn in range(self._max_turns):
turns += 1
# Truncate before every generate call -- tool results may have
# expanded the context beyond what the model supports.
messages = self._truncate_if_needed(messages)
gen_kwargs: dict[str, Any] = {}
if openai_tools:
gen_kwargs["tools"] = openai_tools
try:
result = self._generate(messages)
result = self._generate(messages, **gen_kwargs)
except Exception as exc:
error_str = str(exc)
if "400" in error_str:
@@ -318,6 +329,46 @@ class NativeOpenHandsAgent(ToolUsingAgent):
content = self._strip_think_tags(content)
last_content = content
# --- Native function-calling path (OpenAI, Anthropic, etc.) ---
raw_tool_calls = result.get("tool_calls", [])
if raw_tool_calls:
native_calls = []
for i, tc in enumerate(raw_tool_calls):
call = ToolCall(
id=tc.get("id", f"call_{turns}_{i}"),
name=tc.get("name", ""),
arguments=tc.get("arguments", "{}"),
)
# Preserve thought_signature for Gemini reasoning
sig = tc.get("thought_signature")
if sig is not None:
_thought_sigs[call.id] = sig
native_calls.append(call)
messages.append(
Message(
role=Role.ASSISTANT,
content=content,
tool_calls=native_calls,
)
)
for tc in native_calls:
tool_result = self._executor.execute(tc)
all_tool_results.append(tool_result)
obs_text = tool_result.content
if len(obs_text) > 4000:
obs_text = obs_text[:4000] + "\n\n[Output truncated]"
messages.append(
Message(
role=Role.TOOL,
content=obs_text,
tool_call_id=tc.id,
name=tc.name,
)
)
continue
# --- Text-based fallback (CodeAct / Action-Input format) ---
# Try to extract code
code = self._extract_code(content)
if code:
@@ -1,15 +1,48 @@
[template]
id = "code_reviewer"
name = "Code Reviewer"
description = "Monitors a repository for changes, reviews code, and stores feedback."
description = "Monitors a repository for changes, reviews code quality, and identifies bugs."
icon = "🔍"
agent_type = "monitor_operative"
schedule_type = "interval"
schedule_value = "3600"
tools = ["file_read", "shell_exec", "memory_store", "think"]
tools = ["file_read", "file_write", "shell_exec", "git_status", "git_diff", "git_commit", "git_log", "apply_patch", "code_interpreter", "memory_store", "memory_retrieve", "think"]
max_turns = 15
temperature = 0.2
temperature = 0.1
memory_extraction = "scratchpad"
observation_compression = "summarize"
retrieval_strategy = "keyword"
retrieval_strategy = "sqlite"
task_decomposition = "monolithic"
system_prompt = "You are a code review agent. Monitor the repository for recent changes, review code quality, identify potential bugs, and store your findings."
system_prompt_template = """You are a Code Reviewer agent. Your job is to monitor a repository for recent changes, review code quality, identify potential bugs, suggest improvements, and store your findings.
## Your Review Focus
{instruction}
## Available Tools
You have access to these tools. Use them systematically:
- **git_status()** — Check the current state of the repository. Start here.
- **git_diff()** — View uncommitted changes or diffs between commits.
- **git_log()** — View recent commit history to understand what changed.
- **file_read(path)** — Read source files for detailed review.
- **file_write(path, content)** — Write review notes or suggested patches.
- **shell_exec(command)** — Run linters, tests, or other analysis commands.
- **code_interpreter(code)** — Execute code snippets to verify behavior.
- **apply_patch(patch)** — Apply a suggested fix as a patch.
- **memory_store(key, content)** / **memory_retrieve(query)** — Track review history across sessions.
- **think(thought)** — Reason through complex code logic before commenting.
## How to Work
1. Run git_status and git_log to understand recent changes.
2. For each significant change, read the affected files with file_read.
3. Analyze for: bugs, security issues, performance problems, readability.
4. Run relevant tests or linters via shell_exec if available.
5. Store findings in memory for tracking across sessions.
6. Produce a summary: what changed, what's good, what needs attention.
## Quality Standards
- Focus on substance: real bugs and security issues over style nitpicks.
- Be specific: reference exact file paths and line numbers.
- Be constructive: suggest fixes, not just problems.
- Prioritize severity: critical bugs > performance > readability.
- Don't comment on formatting if a linter handles it."""
@@ -1,15 +1,44 @@
[template]
id = "inbox_triager"
name = "Inbox Triager"
description = "Monitors email and messaging channels, categorizes and summarizes incoming messages."
description = "Monitors email and messaging channels, categorizes and summarizes by priority."
icon = "📥"
agent_type = "monitor_operative"
schedule_type = "interval"
schedule_value = "1800"
tools = ["web_search", "memory_store", "memory_retrieve", "think"]
tools = ["channel_send", "channel_list", "memory_store", "memory_retrieve", "think", "web_search", "file_write"]
max_turns = 20
temperature = 0.3
memory_extraction = "structured_json"
observation_compression = "summarize"
retrieval_strategy = "semantic"
retrieval_strategy = "sqlite"
task_decomposition = "phased"
system_prompt = "You are an inbox triage agent. Monitor incoming messages, categorize them by priority and topic, summarize key information, and flag items that need attention."
system_prompt_template = """You are an Inbox Triager agent. Your job is to monitor incoming messages across email and messaging channels, categorize them by priority and topic, summarize key information, and flag items that need immediate attention.
## Your Triage Instructions
{instruction}
## Available Tools
You have access to these tools. Use them to process incoming messages:
- **channel_list()** — List available messaging channels and their recent messages.
- **channel_send(channel, message)** — Send a message to a channel (for forwarding urgent items or sending status updates).
- **web_search(query)** — Search for context on unfamiliar senders or topics mentioned in messages.
- **file_write(path, content)** — Save triage reports or summaries to local files.
- **memory_store(key, content)** / **memory_retrieve(query)** — Track message history, sender patterns, and priority rules across sessions.
- **think(thought)** — Reason through priority decisions before categorizing.
## How to Work
1. Check memory for your existing triage rules and sender patterns.
2. List channels to see new incoming messages.
3. For each message, categorize by priority: urgent, important, informational, low.
4. For urgent items, forward via channel_send with a brief summary.
5. Store triage decisions in memory for pattern learning.
6. Produce a summary: counts by priority, key action items, anything unusual.
## Quality Standards
- Never miss urgent items: err on the side of flagging too much.
- Be concise: triage summaries should be scannable in 30 seconds.
- Learn patterns: remember which senders/topics are usually important.
- Respect context: a message from your boss is higher priority than a newsletter.
- Group related messages: thread continuations should be triaged together."""
@@ -1,15 +1,45 @@
[template]
id = "research_monitor"
name = "Research Monitor"
description = "Periodically searches for papers/news on a topic, stores findings in memory, reports via channel."
description = "Searches papers, news, blogs on your topic. Stores findings in memory."
icon = "🔬"
agent_type = "monitor_operative"
schedule_type = "cron"
schedule_value = "0 9 * * *"
tools = ["web_search", "memory_store", "memory_retrieve", "think"]
tools = ["web_search", "http_request", "file_read", "file_write", "memory_store", "memory_retrieve", "think"]
max_turns = 25
temperature = 0.3
memory_extraction = "structured_json"
observation_compression = "summarize"
retrieval_strategy = "hybrid_with_self_eval"
retrieval_strategy = "sqlite"
task_decomposition = "phased"
system_prompt = "You are a research monitor agent. Your job is to search for new papers, articles, and developments on your assigned topics. Store important findings in memory. Be thorough but concise in your summaries."
system_prompt_template = """You are a Research Monitor agent. Your job is to systematically search for new papers, articles, and developments on your assigned topics, store important findings in memory, and produce concise summaries.
## Your Assigned Topic
{instruction}
## Available Tools
You have access to these tools. Use them proactively:
- **web_search(query)** — Search the web for recent articles, papers, and news. Use specific, targeted queries. Try multiple search angles to get comprehensive coverage.
- **http_request(url, method)** — Fetch a specific URL to read full article content. Use after finding promising URLs via web_search.
- **file_read(path)** / **file_write(path, content)** — Read and write local files. Use to save detailed reports or read reference material.
- **memory_store(key, content)** — Store findings for future reference across sessions. Use structured keys like "finding:YYYY-MM-DD:topic-name".
- **memory_retrieve(query)** — Recall previously stored findings. Always check what you already know before searching again.
- **think(thought)** — Reason through complex decisions before acting. Use when planning search strategy or evaluating source quality.
## How to Work
1. Start by checking memory (memory_retrieve) for what you already know about this topic.
2. Search the web with 2-3 different query angles using web_search.
3. For promising results, fetch the full content via http_request.
4. Extract key findings: title, authors/source, date, main contribution, relevance to your assigned topic.
5. Store each significant finding in memory with a structured key.
6. Produce a concise summary of new developments found.
## Quality Standards
- Be thorough: try multiple search queries, not just one.
- Be concise: summaries should be 2-4 paragraphs, not essays.
- Be structured: use headers, bullet points, and dates.
- Be honest: if you cannot find recent information, say so clearly. Never fabricate or hallucinate sources.
- Prioritize recency: newer findings are more valuable than old ones.
- Cite sources: include URLs or paper titles for every claim."""
+2
View File
@@ -39,6 +39,8 @@ _CHANNEL_MODULES = [
"twitch_channel",
"nostr_channel",
"twilio_sms",
"twitter",
"gmail",
]
for _mod in _CHANNEL_MODULES:
+298
View File
@@ -0,0 +1,298 @@
"""GmailChannel — native Gmail API adapter using OAuth2."""
from __future__ import annotations
import base64
import logging
import os
import threading
from email.mime.text import MIMEText
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelMessage,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("gmail")
class GmailChannel(BaseChannel):
"""Native Gmail channel adapter using the Gmail API with OAuth2.
Parameters
----------
credentials_path:
Path to the OAuth2 ``credentials.json`` file.
Falls back to ``GMAIL_CREDENTIALS_PATH`` env var.
token_path:
Path to the stored OAuth2 token file.
Falls back to ``GMAIL_TOKEN_PATH`` env var.
user_id:
Gmail user ID (default ``"me"`` for the authenticated user).
poll_interval:
Seconds between inbox polls (default 30).
bus:
Optional event bus for publishing channel events.
"""
channel_id = "gmail"
def __init__(
self,
credentials_path: str = "",
*,
token_path: str = "",
user_id: str = "me",
poll_interval: int = 30,
bus: Optional[EventBus] = None,
) -> None:
self._credentials_path = credentials_path or os.environ.get(
"GMAIL_CREDENTIALS_PATH", "",
)
self._token_path = token_path or os.environ.get(
"GMAIL_TOKEN_PATH", "",
)
self._user_id = user_id
self._poll_interval = poll_interval
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
self._service: Any = None
self._listener_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Load OAuth2 credentials and build the Gmail API service."""
if not self._credentials_path and not self._token_path:
logger.warning("No Gmail credentials configured")
self._status = ChannelStatus.ERROR
return
self._stop_event.clear()
self._status = ChannelStatus.CONNECTING
try:
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
SCOPES = ["https://www.googleapis.com/auth/gmail.modify"]
creds: Any = None
if self._token_path and os.path.exists(self._token_path):
creds = Credentials.from_authorized_user_file(
self._token_path, SCOPES,
)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
from google.auth.transport.requests import Request
creds.refresh(Request())
elif self._credentials_path and os.path.exists(
self._credentials_path,
):
flow = InstalledAppFlow.from_client_secrets_file(
self._credentials_path, SCOPES,
)
creds = flow.run_local_server(port=0)
else:
logger.warning("Gmail credentials not found or invalid")
self._status = ChannelStatus.ERROR
return
# Save token for future use
if self._token_path and creds:
with open(self._token_path, "w") as token_file:
token_file.write(creds.to_json())
self._service = build("gmail", "v1", credentials=creds)
self._status = ChannelStatus.CONNECTED
logger.info("Gmail channel connected")
# Start polling thread
self._listener_thread = threading.Thread(
target=self._poll_loop, daemon=True,
)
self._listener_thread.start()
except ImportError:
logger.warning(
"Google API libraries not installed; "
"install with: pip install openjarvis[channel-gmail]",
)
self._status = ChannelStatus.ERROR
except Exception:
logger.debug("Gmail connect failed", exc_info=True)
self._status = ChannelStatus.ERROR
def disconnect(self) -> None:
"""Stop the polling thread and clear the service."""
self._stop_event.set()
if self._listener_thread is not None:
self._listener_thread.join(timeout=5.0)
self._listener_thread = None
self._service = None
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send an email via the Gmail API.
``channel`` is the recipient email address.
"""
if self._service is None:
logger.warning("Cannot send: Gmail service not connected")
return False
try:
msg = MIMEText(content)
msg["To"] = channel
msg["From"] = self._user_id
msg["Subject"] = (metadata or {}).get(
"subject", "Message from OpenJarvis",
)
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
body: Dict[str, Any] = {"raw": raw}
if conversation_id:
body["threadId"] = conversation_id
self._service.users().messages().send(
userId=self._user_id, body=body,
).execute()
self._publish_sent(channel, content, conversation_id)
return True
except Exception:
logger.debug("Gmail send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
if self._status == ChannelStatus.CONNECTED and self._service is None:
return ChannelStatus.ERROR
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["inbox"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming Gmail messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _poll_loop(self) -> None:
"""Poll Gmail inbox for unread messages."""
while not self._stop_event.is_set():
try:
if self._service is None:
break
results = (
self._service.users()
.messages()
.list(userId=self._user_id, q="is:unread")
.execute()
)
messages = results.get("messages", [])
for msg_ref in messages:
msg_id = msg_ref["id"]
msg = (
self._service.users()
.messages()
.get(userId=self._user_id, id=msg_id, format="full")
.execute()
)
headers = {
h["name"]: h["value"]
for h in msg.get("payload", {}).get("headers", [])
}
# Extract body text
body = ""
payload = msg.get("payload", {})
if payload.get("body", {}).get("data"):
body = base64.urlsafe_b64decode(
payload["body"]["data"],
).decode("utf-8", errors="replace")
elif payload.get("parts"):
for part in payload["parts"]:
if part.get("mimeType") == "text/plain":
data = part.get("body", {}).get("data", "")
if data:
body = base64.urlsafe_b64decode(
data,
).decode("utf-8", errors="replace")
break
cm = ChannelMessage(
channel="gmail",
sender=headers.get("From", ""),
content=body,
message_id=msg_id,
conversation_id=msg.get("threadId", ""),
)
for handler in self._handlers:
try:
handler(cm)
except Exception:
logger.exception("Gmail handler error")
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_RECEIVED,
{
"channel": cm.channel,
"sender": cm.sender,
"content": cm.content,
"message_id": cm.message_id,
},
)
# Mark message as read
self._service.users().messages().modify(
userId=self._user_id,
id=msg_id,
body={"removeLabelIds": ["UNREAD"]},
).execute()
except Exception:
logger.debug("Gmail poll error", exc_info=True)
self._stop_event.wait(self._poll_interval)
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["GmailChannel"]
+242
View File
@@ -0,0 +1,242 @@
"""TwitterChannel — native Twitter/X API adapter using tweepy."""
from __future__ import annotations
import logging
import os
import threading
from typing import Any, Dict, List, Optional
from openjarvis.channels._stubs import (
BaseChannel,
ChannelHandler,
ChannelMessage,
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@ChannelRegistry.register("twitter")
class TwitterChannel(BaseChannel):
"""Native Twitter/X channel adapter using tweepy.
Parameters
----------
bearer_token:
Twitter API v2 Bearer Token. Falls back to ``TWITTER_BEARER_TOKEN``.
api_key:
Twitter API Key (consumer key). Falls back to ``TWITTER_API_KEY``.
api_secret:
Twitter API Secret (consumer secret). Falls back to ``TWITTER_API_SECRET``.
access_token:
Twitter Access Token. Falls back to ``TWITTER_ACCESS_TOKEN``.
access_secret:
Twitter Access Token Secret. Falls back to ``TWITTER_ACCESS_SECRET``.
poll_interval:
Seconds between mention polls (default 60).
bus:
Optional event bus for publishing channel events.
"""
channel_id = "twitter"
def __init__(
self,
bearer_token: str = "",
*,
api_key: str = "",
api_secret: str = "",
access_token: str = "",
access_secret: str = "",
poll_interval: int = 60,
bus: Optional[EventBus] = None,
) -> None:
self._bearer_token = bearer_token or os.environ.get(
"TWITTER_BEARER_TOKEN", "",
)
self._api_key = api_key or os.environ.get("TWITTER_API_KEY", "")
self._api_secret = api_secret or os.environ.get("TWITTER_API_SECRET", "")
self._access_token = access_token or os.environ.get(
"TWITTER_ACCESS_TOKEN", "",
)
self._access_secret = access_secret or os.environ.get(
"TWITTER_ACCESS_SECRET", "",
)
self._poll_interval = poll_interval
self._bus = bus
self._handlers: List[ChannelHandler] = []
self._status = ChannelStatus.DISCONNECTED
self._client: Any = None
self._poll_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
# -- connection lifecycle ---------------------------------------------------
def connect(self) -> None:
"""Build a tweepy Client and optionally start polling for mentions."""
if not self._bearer_token:
logger.warning("No Twitter bearer token configured")
self._status = ChannelStatus.ERROR
return
self._stop_event.clear()
try:
import tweepy # noqa: F401
self._client = tweepy.Client(
bearer_token=self._bearer_token,
consumer_key=self._api_key or None,
consumer_secret=self._api_secret or None,
access_token=self._access_token or None,
access_token_secret=self._access_secret or None,
)
self._status = ChannelStatus.CONNECTED
logger.info("Twitter channel connected")
if self._access_token:
self._poll_thread = threading.Thread(
target=self._poll_loop, daemon=True,
)
self._poll_thread.start()
except ImportError:
logger.info("tweepy not installed; Twitter channel unavailable")
self._status = ChannelStatus.ERROR
except Exception:
logger.debug("Twitter connect failed", exc_info=True)
self._status = ChannelStatus.ERROR
def disconnect(self) -> None:
"""Stop the polling thread and disconnect."""
self._stop_event.set()
if self._poll_thread is not None:
self._poll_thread.join(timeout=5.0)
self._poll_thread = None
self._client = None
self._status = ChannelStatus.DISCONNECTED
# -- send / receive --------------------------------------------------------
def send(
self,
channel: str,
content: str,
*,
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send a tweet or direct message.
If *channel* is numeric, sends a DM via ``create_direct_message()``.
Otherwise sends a tweet via ``create_tweet()``. If *conversation_id*
is provided, it is used as ``in_reply_to_tweet_id``.
"""
if self._client is None:
logger.warning("Cannot send: Twitter client not connected")
return False
try:
if channel.isdigit():
# Direct message to a user ID
self._client.create_direct_message(
participant_id=int(channel),
text=content,
)
else:
kwargs: Dict[str, Any] = {"text": content}
if conversation_id:
kwargs["in_reply_to_tweet_id"] = int(conversation_id)
self._client.create_tweet(**kwargs)
self._publish_sent(channel, content, conversation_id)
return True
except Exception:
logger.debug("Twitter send failed", exc_info=True)
return False
def status(self) -> ChannelStatus:
"""Return the current connection status."""
return self._status
def list_channels(self) -> List[str]:
"""Return available channel identifiers."""
return ["timeline", "dm"]
def on_message(self, handler: ChannelHandler) -> None:
"""Register a callback for incoming messages."""
self._handlers.append(handler)
# -- internal helpers -------------------------------------------------------
def _poll_loop(self) -> None:
"""Poll for new mentions in a background thread."""
since_id: Optional[str] = None
while not self._stop_event.is_set():
try:
kwargs: Dict[str, Any] = {}
if since_id:
kwargs["since_id"] = since_id
me = self._client.get_me()
if me and me.data:
user_id = me.data.id
else:
self._stop_event.wait(self._poll_interval)
continue
response = self._client.get_users_mentions(
id=user_id, **kwargs,
)
if response and response.data:
for tweet in response.data:
since_id = str(tweet.id)
cm = ChannelMessage(
channel="twitter",
sender=str(getattr(tweet, "author_id", "")),
content=tweet.text,
message_id=str(tweet.id),
conversation_id=str(
getattr(tweet, "conversation_id", ""),
),
)
for handler in self._handlers:
try:
handler(cm)
except Exception:
logger.exception("Twitter handler error")
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_RECEIVED,
{
"channel": cm.channel,
"sender": cm.sender,
"content": cm.content,
"message_id": cm.message_id,
},
)
except Exception:
logger.debug("Twitter poll error", exc_info=True)
self._stop_event.wait(self._poll_interval)
def _publish_sent(
self, channel: str, content: str, conversation_id: str,
) -> None:
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
if self._bus is not None:
self._bus.publish(
EventType.CHANNEL_MESSAGE_SENT,
{
"channel": channel,
"content": content,
"conversation_id": conversation_id,
},
)
__all__ = ["TwitterChannel"]
+83 -41
View File
@@ -44,7 +44,8 @@ def _get_memory_backend(config):
if key == "sqlite":
backend = MemoryRegistry.create(
key, db_path=config.memory.db_path,
key,
db_path=config.memory.db_path,
)
else:
backend = MemoryRegistry.create(key)
@@ -106,8 +107,7 @@ def _run_agent(
if not AgentRegistry.contains(agent_name):
raise click.ClickException(
f"Unknown agent: {agent_name}. "
f"Available: {', '.join(AgentRegistry.keys())}"
f"Unknown agent: {agent_name}. Available: {', '.join(AgentRegistry.keys())}"
)
agent_cls = AgentRegistry.get(agent_name)
@@ -117,6 +117,7 @@ def _run_agent(
if tool_names:
# Trigger tool registration
import openjarvis.tools # noqa: F401
tools = _build_tools(tool_names, config, engine, model_name)
# Build agent with appropriate kwargs
@@ -149,7 +150,10 @@ def _run_agent(
max_context_tokens=config.memory.context_max_tokens,
)
context_messages = inject_context(
query_text, [], backend, config=ctx_cfg,
query_text,
[],
backend,
config=ctx_cfg,
)
for msg in context_messages:
ctx.conversation.add(msg)
@@ -169,9 +173,7 @@ def _print_profile(
) -> None:
"""Print an inference telemetry profile table from EventBus history."""
# Collect all INFERENCE_END events (agents may fire multiple)
inf_events = [
e for e in bus.history if e.event_type == EventType.INFERENCE_END
]
inf_events = [e for e in bus.history if e.event_type == EventType.INFERENCE_END]
if not inf_events:
console.print("[dim]No inference telemetry recorded.[/dim]")
return
@@ -186,13 +188,15 @@ def _print_profile(
for e in inf_events
)
total_prompt = sum(
e.data.get("usage", {}).get("prompt_tokens", 0)
for e in inf_events
e.data.get("usage", {}).get("prompt_tokens", 0) for e in inf_events
)
total_energy = sum(e.data.get("energy_joules", 0.0) for e in inf_events)
avg_power = 0.0
power_vals = [e.data.get("power_watts", 0.0) for e in inf_events
if e.data.get("power_watts", 0.0) > 0]
power_vals = [
e.data.get("power_watts", 0.0)
for e in inf_events
if e.data.get("power_watts", 0.0) > 0
]
if power_vals:
avg_power = sum(power_vals) / len(power_vals)
@@ -284,29 +288,42 @@ def _print_profile(
@click.option("-m", "--model", "model_name", default=None, help="Model to use.")
@click.option("-e", "--engine", "engine_key", default=None, help="Engine backend.")
@click.option(
"-t", "--temperature", default=None, type=float,
"-t",
"--temperature",
default=None,
type=float,
help="Sampling temperature (default: from config).",
)
@click.option(
"--max-tokens", default=None, type=int,
"--max-tokens",
default=None,
type=int,
help="Max tokens to generate (default: from config).",
)
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON result.")
@click.option("--no-stream", is_flag=True, help="Disable streaming (sync mode).")
@click.option(
"--no-context", is_flag=True,
"--no-context",
is_flag=True,
help="Disable memory context injection.",
)
@click.option(
"-a", "--agent", "agent_name", default=None,
"-a",
"--agent",
"agent_name",
default=None,
help="Agent to use (simple, orchestrator).",
)
@click.option(
"--tools", "tool_names", default=None,
"--tools",
"tool_names",
default=None,
help="Comma-separated tool names to enable (e.g. calculator,think).",
)
@click.option(
"--profile", "enable_profile", is_flag=True,
"--profile",
"enable_profile",
is_flag=True,
help="Print inference telemetry profile (latency, tokens, energy, IPW).",
)
def ask(
@@ -377,7 +394,10 @@ def ask(
" [cyan]ollama serve[/cyan] — start Ollama\n"
" [cyan]vllm serve <model>[/cyan] — start vLLM\n"
" [cyan]llama-server -m <gguf>[/cyan] — start llama.cpp\n\n"
"Or set OPENAI_API_KEY / ANTHROPIC_API_KEY for cloud inference."
"Or set OPENAI_API_KEY / ANTHROPIC_API_KEY for cloud inference.\n\n"
"[dim]To use a remote engine:[/dim]\n"
" [cyan]jarvis config set engine.ollama.host http://<remote-ip>:11434[/cyan]\n"
" [dim]or[/dim] [cyan]export OLLAMA_HOST=http://<remote-ip>:11434[/cyan]"
)
sys.exit(1)
@@ -385,6 +405,7 @@ def ask(
# Apply security guardrails
from openjarvis.security import setup_security
sec = setup_security(config, engine, bus)
engine = sec.engine
@@ -427,12 +448,14 @@ def ask(
# the user would have gotten without the analyzer.
if not user_set_max_tokens:
suggested = adjust_tokens_for_model(
complexity_result.suggested_max_tokens, model_name,
complexity_result.suggested_max_tokens,
model_name,
)
max_tokens = max(suggested, config.intelligence.max_tokens)
logger.debug(
"Using complexity-suggested max_tokens=%d (model=%s)",
max_tokens, model_name,
max_tokens,
model_name,
)
# Agent mode
@@ -444,8 +467,15 @@ def ask(
)
try:
result = _run_agent(
agent_name, query_text, engine, model_name,
parsed_tools, config, bus, temperature, max_tokens,
agent_name,
query_text,
engine,
model_name,
parsed_tools,
config,
bus,
temperature,
max_tokens,
capability_policy=sec.capability_policy,
)
except EngineConnectionError as exc:
@@ -454,25 +484,33 @@ def ask(
sys.exit(1)
if output_json:
click.echo(json_mod.dumps({
"content": result.content,
"turns": result.turns,
"tool_results": [
click.echo(
json_mod.dumps(
{
"tool_name": tr.tool_name,
"content": tr.content,
"success": tr.success,
}
for tr in result.tool_results
],
}, indent=2))
"content": result.content,
"turns": result.turns,
"tool_results": [
{
"tool_name": tr.tool_name,
"content": tr.content,
"success": tr.success,
}
for tr in result.tool_results
],
},
indent=2,
)
)
else:
click.echo(result.content)
if enable_profile:
_print_profile(
bus, time.monotonic() - wall_start,
engine_name, model_name, console,
bus,
time.monotonic() - wall_start,
engine_name,
model_name,
console,
complexity_result=complexity_result,
)
@@ -493,17 +531,18 @@ def ask(
ContextConfig,
inject_context,
)
backend = _get_memory_backend(config)
if backend is not None:
ctx_cfg = ContextConfig(
top_k=config.memory.context_top_k,
min_score=config.memory.context_min_score,
max_context_tokens=(
config.memory.context_max_tokens
),
max_context_tokens=(config.memory.context_max_tokens),
)
messages = inject_context(
query_text, messages, backend,
query_text,
messages,
backend,
config=ctx_cfg,
)
except Exception as exc:
@@ -531,8 +570,11 @@ def ask(
if enable_profile:
_print_profile(
bus, time.monotonic() - wall_start,
engine_name, model_name, console,
bus,
time.monotonic() - wall_start,
engine_name,
model_name,
console,
complexity_result=complexity_result,
)
+98
View File
@@ -4,9 +4,11 @@ from __future__ import annotations
import json
import os
import re
from pathlib import Path
import click
import httpx
from rich.console import Console
from rich.panel import Panel
from rich.syntax import Syntax
@@ -274,4 +276,100 @@ def hardware() -> None:
config.add_command(show_group, "show")
def _probe_engine_host(url: str, console: Console) -> None:
"""Probe an engine host URL and print reachability status."""
try:
resp = httpx.get(url.rstrip("/") + "/", timeout=2.0)
if resp.status_code < 500:
console.print(f" [green]Reachable[/green] ({url})")
else:
console.print(
f" [yellow]Warning:[/yellow] Host returned status "
f"{resp.status_code} — config saved anyway."
)
except Exception:
console.print(
f" [yellow]Warning:[/yellow] Host unreachable ({url}) "
f"— config saved anyway."
)
def _coerce_value(value: str, target_type: type) -> object:
"""Coerce a CLI string value to the target Python type."""
if target_type is bool:
low = value.lower()
if low in ("true", "1", "yes"):
return True
if low in ("false", "0", "no"):
return False
raise ValueError(
f"Invalid boolean value: {value!r} (expected: true/false, yes/no, 1/0)"
)
if target_type is int:
return int(value)
if target_type is float:
return float(value)
return value
@click.command("set")
@click.argument("key")
@click.argument("value")
def set_config(key: str, value: str) -> None:
"""Set a configuration value (e.g. jarvis config set engine.ollama.host URL)."""
import tomlkit
from openjarvis.core.config import DEFAULT_CONFIG_DIR, validate_config_key
console = Console(stderr=True)
# Validate key
try:
target_type = validate_config_key(key)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise SystemExit(1)
# Coerce value
try:
typed_value = _coerce_value(value, target_type)
except (ValueError, TypeError) as exc:
console.print(
f"[red]Error:[/red] Cannot convert {value!r} to "
f"{target_type.__name__}: {exc}"
)
raise SystemExit(1)
# Load or create TOML document
config_path = Path(
os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_DIR / "config.toml")
)
if config_path.exists():
doc = tomlkit.parse(config_path.read_text())
else:
doc = tomlkit.document()
config_path.parent.mkdir(parents=True, exist_ok=True)
# Set nested key
parts = key.split(".")
current = doc
for part in parts[:-1]:
if part not in current:
current.add(part, tomlkit.table())
current = current[part]
current[parts[-1]] = typed_value
# Write back
config_path.write_text(tomlkit.dumps(doc))
console.print(f"[green]Set[/green] {key} = {value!r}")
# Probe engine host if applicable
if re.match(r"^engine\.\w+\.host$", key):
_probe_engine_host(value, console)
config.add_command(set_config, "set")
__all__ = ["config"]
+12 -33
View File
@@ -35,17 +35,13 @@ def _check_python_version() -> CheckResult:
version_str = f"{ver.major}.{ver.minor}.{ver.micro}"
if (ver.major, ver.minor) >= (3, 10):
return CheckResult("Python version", "ok", version_str)
return CheckResult(
"Python version", "fail", f"{version_str} (requires >= 3.10)"
)
return CheckResult("Python version", "fail", f"{version_str} (requires >= 3.10)")
def _check_config_exists() -> CheckResult:
"""Check that the config file exists."""
if DEFAULT_CONFIG_PATH.exists():
return CheckResult(
"Config file", "ok", str(DEFAULT_CONFIG_PATH)
)
return CheckResult("Config file", "ok", str(DEFAULT_CONFIG_PATH))
return CheckResult(
"Config file",
"warn",
@@ -57,16 +53,12 @@ def _check_config_exists() -> CheckResult:
def _check_config_parses() -> CheckResult:
"""Check that the config file parses successfully."""
if not DEFAULT_CONFIG_PATH.exists():
return CheckResult(
"Config parsing", "warn", "Skipped (no config file)"
)
return CheckResult("Config parsing", "warn", "Skipped (no config file)")
try:
load_config()
return CheckResult("Config parsing", "ok", "Config loaded successfully")
except Exception as exc:
return CheckResult(
"Config parsing", "fail", f"Parse error: {exc}"
)
return CheckResult("Config parsing", "fail", f"Parse error: {exc}")
def _ensure_engines_imported() -> None:
@@ -102,22 +94,16 @@ def _check_engines() -> List[CheckResult]:
try:
engine = _discovery._make_engine(key, config)
if engine.health():
results.append(
CheckResult(f"Engine: {key}", "ok", "Reachable")
)
results.append(CheckResult(f"Engine: {key}", "ok", "Reachable"))
else:
results.append(
CheckResult(f"Engine: {key}", "warn", "Unreachable")
)
results.append(CheckResult(f"Engine: {key}", "warn", "Unreachable"))
except Exception as exc:
results.append(
CheckResult(f"Engine: {key}", "warn", f"Unreachable ({exc})")
)
if not results:
results.append(
CheckResult("Engines", "warn", "No engines registered")
)
results.append(CheckResult("Engines", "warn", "No engines registered"))
return results
@@ -154,7 +140,7 @@ def _check_models() -> List[CheckResult]:
f"Models: {key}",
"warn",
"No models available",
details="Pull a model (e.g. `ollama pull qwen3.5:3b`).",
details="Pull a model (e.g. `ollama pull qwen3.5:2b`).",
)
)
except Exception:
@@ -168,9 +154,7 @@ def _check_default_model() -> CheckResult:
try:
config = load_config()
except Exception:
return CheckResult(
"Default model", "warn", "Skipped (config unavailable)"
)
return CheckResult("Default model", "warn", "Skipped (config unavailable)")
default_model = config.intelligence.default_model
if not default_model:
@@ -221,9 +205,7 @@ def _check_optional_deps() -> List[CheckResult]:
for pkg, install_hint, description in optional_packages:
try:
__import__(pkg)
results.append(
CheckResult(f"Optional: {description}", "ok", "Installed")
)
results.append(CheckResult(f"Optional: {description}", "ok", "Installed"))
except Exception:
results.append(
CheckResult(
@@ -266,8 +248,7 @@ def _check_nodejs() -> CheckResult:
"warn",
f"{version_str} (requires >= v22)",
details=(
"Upgrade Node.js for ClaudeCodeAgent and WhatsApp "
"Baileys support."
"Upgrade Node.js for ClaudeCodeAgent and WhatsApp Baileys support."
),
)
except Exception as exc:
@@ -335,7 +316,5 @@ def doctor(as_json: bool) -> None:
warn_count = sum(1 for c in checks if c.status == "warn")
fail_count = sum(1 for c in checks if c.status == "fail")
console.print()
console.print(
f" {ok_count} passed, {warn_count} warnings, {fail_count} failures"
)
console.print(f" {ok_count} passed, {warn_count} warnings, {fail_count} failures")
console.print()
+6 -2
View File
@@ -22,7 +22,11 @@ def hint_no_engine(engine_name: Optional[str] = None) -> str:
f"[yellow]Hint:[/yellow] Engine '{name}' is not reachable.\n"
f" Make sure the {name} server is running.\n"
" Run [bold]jarvis doctor[/bold] to check all engines.\n"
" Run [bold]jarvis quickstart[/bold] for guided setup."
" Run [bold]jarvis quickstart[/bold] for guided setup.\n"
"\n"
" [dim]To use a remote engine:[/dim]\n"
f" [cyan]jarvis config set engine.{name}.host http://<remote-ip>:<port>[/cyan]\n"
f" [dim]or[/dim] [cyan]export OLLAMA_HOST=http://<remote-ip>:11434[/cyan]"
)
@@ -36,6 +40,6 @@ def hint_no_model(model_name: Optional[str] = None) -> str:
)
return (
"[yellow]Hint:[/yellow] No models available.\n"
" Pull a model first: [bold]ollama pull qwen3.5:3b[/bold]\n"
" Pull a model first: [bold]ollama pull qwen3.5:2b[/bold]\n"
" Run [bold]jarvis model list[/bold] to see available models."
)
+57 -19
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Optional
import click
import httpx
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
@@ -25,8 +26,14 @@ from openjarvis.core.config import (
# Engines supported by ``jarvis init --engine``.
_SUPPORTED_ENGINES = [
"ollama", "vllm", "sglang", "llamacpp", "mlx", "lmstudio",
"exo", "nexa",
"ollama",
"vllm",
"sglang",
"llamacpp",
"mlx",
"lmstudio",
"exo",
"nexa",
]
@@ -57,7 +64,7 @@ def _detect_running_engines() -> list[str]:
def _next_steps_text(engine: str, model: str = "") -> str:
"""Return engine-specific next-steps guidance after init."""
pull_model = model or "qwen3.5:3b"
pull_model = model or "qwen3.5:2b"
steps: dict[str, str] = {
"ollama": (
"Next steps:\n"
@@ -70,7 +77,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
f" ollama pull {pull_model}\n"
"\n"
" 3. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
@@ -82,7 +89,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" vllm serve Qwen/Qwen3-4B\n"
"\n"
" 2. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
@@ -94,7 +101,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" llama-server -m path/to/model.gguf\n"
"\n"
" 2. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
@@ -106,7 +113,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" python -m sglang.launch_server --model-path Qwen/Qwen3-8B\n"
"\n"
" 2. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
@@ -118,7 +125,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" mlx_lm.server --model mlx-community/Qwen2.5-7B-4bit\n"
"\n"
" 2. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
@@ -131,7 +138,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" 2. Load a model and start the local server (port 1234)\n"
"\n"
" 3. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
@@ -141,7 +148,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" pip install exo\n"
" exo\n\n"
" 2. Try it out:\n"
" jarvis ask \"Hello\"\n\n"
' jarvis ask "Hello"\n\n'
" Run `jarvis doctor` to verify your setup."
),
"nexa": (
@@ -150,7 +157,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" pip install nexaai\n"
" nexa server\n\n"
" 2. Try it out:\n"
" jarvis ask \"Hello\"\n\n"
' jarvis ask "Hello"\n\n'
" Run `jarvis doctor` to verify your setup."
),
}
@@ -177,6 +184,7 @@ def _quick_privacy_check(console: Console) -> None:
def _do_download(engine: str, model: str, spec, console: Console) -> None:
"""Dispatch model download based on engine type."""
import os
if engine == "ollama":
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
ollama_pull(host, model, console)
@@ -228,12 +236,26 @@ def _do_download(engine: str, model: str, spec, console: Console) -> None:
@click.option(
"--no-download", is_flag=True, default=False, help="Skip the model download prompt."
)
@click.option(
"--no-scan",
"skip_scan",
is_flag=True,
default=False,
help="Skip the post-init security environment audit.",
)
@click.option(
"--host",
default=None,
help="Remote engine host URL (e.g. http://192.168.1.50:11434).",
)
def init(
force: bool,
config: Optional[Path],
full_config: bool = False,
engine: Optional[str] = None,
no_download: bool = False,
skip_scan: bool = False,
host: Optional[str] = None,
) -> None:
"""Detect hardware and generate ~/.openjarvis/config.toml."""
console = Console()
@@ -267,9 +289,7 @@ def init(
console.print("[bold]Detecting running inference engines...[/bold]")
running = _detect_running_engines()
if running:
console.print(
f" Found running: [green]{', '.join(running)}[/green]"
)
console.print(f" Found running: [green]{', '.join(running)}[/green]")
else:
console.print(" No running engines detected.")
@@ -313,13 +333,31 @@ def init(
default=default,
)
# Probe remote host if specified
if host:
console.print("\n[bold]Checking remote host...[/bold]")
try:
resp = httpx.get(host.rstrip("/") + "/", timeout=2.0)
if resp.status_code < 500:
console.print(f" [green]Reachable[/green] ({host})")
else:
console.print(
f" [yellow]Warning:[/yellow] Host returned status "
f"{resp.status_code} — writing config anyway."
)
except Exception:
console.print(
f" [yellow]Warning:[/yellow] Host unreachable ({host}) "
f"— writing config anyway."
)
if config:
toml_content = config.read_text()
else:
if full_config:
toml_content = generate_default_toml(hw, engine=engine)
toml_content = generate_default_toml(hw, engine=engine, host=host)
else:
toml_content = generate_minimal_toml(hw, engine=engine)
toml_content = generate_minimal_toml(hw, engine=engine, host=host)
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if config:
@@ -341,8 +379,7 @@ def init(
soul_path = DEFAULT_CONFIG_DIR / "SOUL.md"
if not soul_path.exists():
soul_path.write_text(
"# Agent Persona\n\n"
"You are Jarvis, a helpful personal AI assistant.\n"
"# Agent Persona\n\nYou are Jarvis, a helpful personal AI assistant.\n"
)
memory_path = DEFAULT_CONFIG_DIR / "MEMORY.md"
@@ -376,7 +413,8 @@ def init(
if click.confirm(prompt, default=True):
_do_download(selected_engine, model, spec, console)
_quick_privacy_check(console)
if not skip_scan:
_quick_privacy_check(console)
console.print()
console.print(
Panel(
+3 -5
View File
@@ -106,8 +106,7 @@ def quickstart(force: bool) -> None:
console.print("[bold cyan][2/5][/bold cyan] Writing config...")
if DEFAULT_CONFIG_PATH.exists() and not force:
console.print(
f" [dim]Config already exists at"
f" {DEFAULT_CONFIG_PATH} (skip)[/dim]"
f" [dim]Config already exists at {DEFAULT_CONFIG_PATH} (skip)[/dim]"
)
else:
toml_content = generate_default_toml(hw)
@@ -144,7 +143,7 @@ def quickstart(force: bool) -> None:
if not _check_model_available(active_engine):
console.print(" [yellow]No models found.[/yellow]")
console.print(
" Pull a model first (e.g. [bold]ollama pull qwen3.5:3b[/bold])."
" Pull a model first (e.g. [bold]ollama pull qwen3.5:2b[/bold])."
)
raise SystemExit(1)
console.print(" [green]Models available.[/green]")
@@ -157,6 +156,5 @@ def quickstart(force: bool) -> None:
console.print()
console.print(
'[bold green]Setup complete![/bold green]'
' Try: [bold]jarvis ask "Hello"[/bold]'
'[bold green]Setup complete![/bold green] Try: [bold]jarvis ask "Hello"[/bold]'
)
+163 -27
View File
@@ -19,11 +19,24 @@ _CLOUD_SYNC_PROCS = ["Dropbox", "OneDrive", "Google Drive", "iCloudDrive"]
# Screen-recording / remote-access processes (macOS).
_SCREEN_RECORDING_PROCS = [
"TeamViewer", "AnyDesk", "ScreenConnect", "vncviewer", "Vine"
"TeamViewer",
"AnyDesk",
"ScreenConnect",
"vncviewer",
"Vine",
]
# Remote-access processes (Linux).
_REMOTE_ACCESS_PROCS = ["xrdp", "x11vnc", "vncserver", "AnyDesk"]
# Remote-access processes (cross-platform).
_REMOTE_ACCESS_PROCS = [
"xrdp",
"x11vnc",
"vncserver",
"AnyDesk",
"ngrok",
"tailscaled",
"cloudflared",
"ZeroTier",
]
# ---------------------------------------------------------------------------
@@ -295,12 +308,83 @@ class PrivacyScanner:
)
def check_remote_access(self) -> ScanResult:
"""Check for running remote-access processes (Linux)."""
"""Check for running remote-access / tunneling processes."""
return self._check_processes(
names=_REMOTE_ACCESS_PROCS,
check_name="Remote Access",
warn_msg="{name} is running — system may be accessible remotely.",
platform="linux",
platform="all",
)
def check_dns(self) -> ScanResult:
"""Check DNS configuration for encrypted resolvers (macOS)."""
if sys.platform != "darwin":
return ScanResult(
name="DNS Configuration",
status="skip",
message="DNS check not yet implemented for this platform.",
platform="darwin",
)
try:
proc = self._run(["scutil", "--dns"])
output = proc.stdout
except Exception:
return ScanResult(
name="DNS Configuration",
status="skip",
message="scutil command not available.",
platform="darwin",
)
_DOH_INDICATORS = [
"dns-over-https",
"dns-over-tls",
"encrypted",
"doh",
"dot",
]
if any(ind in output.lower() for ind in _DOH_INDICATORS):
return ScanResult(
name="DNS Configuration",
status="ok",
message="Encrypted DNS (DoH/DoT) appears to be active.",
platform="darwin",
)
# Extract nameserver IPs
nameservers: list[str] = []
for line in output.splitlines():
stripped = line.strip()
if stripped.startswith("nameserver["):
parts = stripped.split(":", 1)
if len(parts) == 2:
nameservers.append(parts[1].strip())
if not nameservers:
return ScanResult(
name="DNS Configuration",
status="ok",
message="Could not parse DNS nameservers from scutil output.",
platform="darwin",
)
_PLAIN_DNS = {"8.8.8.8", "8.8.4.4", "1.1.1.1", "1.0.0.1", "9.9.9.9"}
plain = [ns for ns in nameservers if ns in _PLAIN_DNS]
if plain:
return ScanResult(
name="DNS Configuration",
status="warn",
message=(
f"Plain DNS in use ({', '.join(plain)}). "
"Queries may be visible to your ISP or resolver."
),
platform="darwin",
)
return ScanResult(
name="DNS Configuration",
status="ok",
message=f"DNS resolvers: {', '.join(nameservers[:3])}.",
platform="darwin",
)
# -- Orchestration -------------------------------------------------------
@@ -315,6 +399,7 @@ class PrivacyScanner:
self.check_luks,
self.check_screen_recording,
self.check_remote_access,
self.check_dns,
]
def run_all(self) -> list[ScanResult]:
@@ -350,37 +435,88 @@ class PrivacyScanner:
# CLI command
# ---------------------------------------------------------------------------
_STATUS_ICONS = {"ok": "", "warn": "!", "fail": "", "skip": "-"}
_RICH_ICONS = {
"ok": "[green]\u2713[/green]",
"warn": "[yellow]![/yellow]",
"fail": "[red]\u2717[/red]",
"skip": "[dim]-[/dim]",
}
def _render_results(results: List[ScanResult]) -> None:
"""Render scan results as a Rich table."""
from rich.console import Console
from rich.table import Table
console = Console()
console.print()
console.print("[bold]OpenJarvis Security Scan[/bold]")
console.print()
table = Table(show_header=True, header_style="bold", show_lines=True)
table.add_column("", width=3, justify="center")
table.add_column("Check")
table.add_column("Finding")
for r in results:
icon = _RICH_ICONS.get(r.status, "?")
style = {"ok": "green", "warn": "yellow", "fail": "red"}.get(r.status, "white")
table.add_row(icon, r.name, f"[{style}]{r.message}[/{style}]")
console.print(table)
ok_count = sum(1 for r in results if r.status == "ok")
warn_count = sum(1 for r in results if r.status == "warn")
fail_count = sum(1 for r in results if r.status == "fail")
console.print()
parts = [f"[green]{ok_count} ok[/green]"]
if warn_count:
parts.append(f"[yellow]{warn_count} warning(s)[/yellow]")
if fail_count:
parts.append(f"[red]{fail_count} issue(s)[/red]")
console.print(" " + ", ".join(parts))
console.print()
if fail_count:
console.print(
"[red bold]Action required:[/red bold] address critical findings "
"before storing sensitive data with OpenJarvis."
)
console.print()
elif warn_count:
console.print(
"[yellow]Review warnings above[/yellow] — they may not block "
"usage but could affect your privacy posture."
)
console.print()
@click.command()
@click.option("--quick", is_flag=True, default=False, help="Run only critical checks.")
def scan(quick: bool) -> None:
@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.")
def scan(quick: bool, as_json: bool) -> None:
"""Audit your environment for privacy and security risks."""
scanner = PrivacyScanner()
results: List[ScanResult] = scanner.run_quick() if quick else scanner.run_all()
if as_json:
import json as json_mod
output = [
{
"name": r.name,
"status": r.status,
"message": r.message,
"platform": r.platform,
}
for r in results
]
click.echo(json_mod.dumps(output, indent=2))
return
if not results:
click.echo("No applicable checks for this platform.")
return
warnings = 0
failures = 0
for r in results:
icon = _STATUS_ICONS.get(r.status, "?")
click.echo(f" [{icon}] {r.name}: {r.message}")
if r.status == "warn":
warnings += 1
elif r.status == "fail":
failures += 1
click.echo("")
parts = []
if warnings:
parts.append(f"{warnings} warning(s)")
if failures:
parts.append(f"{failures} issue(s)")
if parts:
click.echo("Summary: " + ", ".join(parts) + ".")
else:
click.echo("Summary: all checks passed.")
_render_results(results)
+10 -4
View File
@@ -181,10 +181,16 @@ def serve(
_DEFAULT_TOOLS = {"think", "calculator", "web_search"}
configured = config.agent.tools
if configured:
allowed = {
t.strip() for t in configured.split(",")
if t.strip()
}
if isinstance(configured, list):
allowed = {
t.strip() for t in configured
if isinstance(t, str) and t.strip()
}
else:
allowed = {
t.strip() for t in configured.split(",")
if t.strip()
}
else:
allowed = _DEFAULT_TOOLS
+115 -5
View File
@@ -1157,6 +1157,80 @@ class JarvisConfig:
self.tools.storage = value
# ---------------------------------------------------------------------------
# Config key validation
# ---------------------------------------------------------------------------
# Sections that users may set via ``jarvis config set``.
# ``hardware`` is auto-detected and not user-settable.
_SETTABLE_SECTIONS = frozenset(JarvisConfig.__dataclass_fields__.keys()) - {"hardware"}
def validate_config_key(dotted_key: str) -> type:
"""Validate a dotted config key and return the leaf field's Python type.
Raises :class:`ValueError` when the key does not map to a known field.
The function walks the ``JarvisConfig`` dataclass hierarchy using
``dataclasses.fields()``.
Examples::
validate_config_key("engine.ollama.host") # -> str
validate_config_key("intelligence.temperature") # -> float
"""
from dataclasses import fields as dc_fields
parts = dotted_key.split(".")
if len(parts) < 2:
raise ValueError(
f"Config key must have at least two segments (e.g. engine.default), "
f"got: {dotted_key!r}"
)
if parts[0] not in _SETTABLE_SECTIONS:
raise ValueError(
f"Unknown config key: {dotted_key!r} "
f"(valid top-level sections: {sorted(_SETTABLE_SECTIONS)})"
)
# Walk the dataclass tree
current_cls = JarvisConfig
for i, part in enumerate(parts):
field_map = {f.name: f for f in dc_fields(current_cls)}
if part not in field_map:
path_so_far = ".".join(parts[: i + 1])
raise ValueError(
f"Unknown config key: {dotted_key!r} "
f"(no field {part!r} at {path_so_far}; "
f"valid fields: {sorted(field_map.keys())})"
)
fld = field_map[part]
# Resolve the type — unwrap Optional, etc.
fld_type = fld.type
if isinstance(fld_type, str):
# Evaluate forward references in the config module namespace
import openjarvis.core.config as _cfg_mod
fld_type = eval(fld_type, vars(_cfg_mod)) # noqa: S307
if i == len(parts) - 1:
# Leaf — return the primitive type
return fld_type
else:
# Must be a nested dataclass
if not hasattr(fld_type, "__dataclass_fields__"):
path_so_far = ".".join(parts[: i + 1])
raise ValueError(
f"Unknown config key: {dotted_key!r} "
f"({path_so_far} is a leaf of type {fld_type.__name__}, "
f"not a section)"
)
current_cls = fld_type
# Should not reach here, but satisfy type checker
raise ValueError(f"Unknown config key: {dotted_key!r}")
# ---------------------------------------------------------------------------
# TOML loading
# ---------------------------------------------------------------------------
@@ -1166,7 +1240,9 @@ 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.
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.
"""
for key, value in section.items():
if hasattr(target, key):
@@ -1177,6 +1253,20 @@ def _apply_toml_section(target: Any, section: Dict[str, Any]) -> None:
else:
setattr(target, key, value)
else:
# Normalise TOML arrays → comma-separated string.
# Covers both real dataclass fields and backward-compat
# property setters (e.g. reward_weights, default_tools).
if isinstance(value, list):
is_str_field = False
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:
# Property, not a real field — normalise to string
is_str_field = True
if is_str_field:
value = ",".join(str(v) for v in value)
setattr(target, key, value)
@@ -1283,7 +1373,9 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
# ---------------------------------------------------------------------------
def generate_minimal_toml(hw: HardwareInfo, engine: str | None = None) -> str:
def generate_minimal_toml(
hw: HardwareInfo, engine: str | None = None, *, host: str | None = None
) -> str:
"""Render a minimal TOML config with only essential settings."""
engine = engine or recommend_engine(hw)
model = recommend_model(hw, engine)
@@ -1291,6 +1383,14 @@ def generate_minimal_toml(hw: HardwareInfo, engine: str | None = None) -> str:
if hw.gpu:
mem_label = "unified memory" if hw.gpu.vendor == "apple" else "VRAM"
gpu_comment = f"\n# GPU: {hw.gpu.name} ({hw.gpu.vram_gb} GB {mem_label})"
if host:
engine_host_section = f'\n[engine.{engine}]\nhost = "{host}"\n'
else:
engine_host_section = (
f"\n[engine.{engine}]\n"
f'# host = "http://localhost:11434" '
f"# set to remote URL if engine runs elsewhere\n"
)
return f"""\
# OpenJarvis configuration
# Hardware: {hw.cpu_brand} ({hw.cpu_count} cores, {hw.ram_gb} GB RAM){gpu_comment}
@@ -1298,7 +1398,7 @@ def generate_minimal_toml(hw: HardwareInfo, engine: str | None = None) -> str:
[engine]
default = "{engine}"
{engine_host_section}
[intelligence]
default_model = "{model}"
@@ -1310,7 +1410,9 @@ enabled = ["code_interpreter", "web_search", "file_read", "shell_exec"]
"""
def generate_default_toml(hw: HardwareInfo, engine: str | None = None) -> str:
def generate_default_toml(
hw: HardwareInfo, engine: str | None = None, *, host: str | None = None
) -> str:
"""Render a commented TOML string suitable for ``~/.openjarvis/config.toml``."""
engine = engine or recommend_engine(hw)
model = recommend_model(hw, engine)
@@ -1322,7 +1424,7 @@ def generate_default_toml(hw: HardwareInfo, engine: str | None = None) -> str:
if model:
model_comment = " # recommended for your hardware"
return f"""\
result = f"""\
# OpenJarvis configuration
# Generated by `jarvis init`
#
@@ -1517,6 +1619,13 @@ ssrf_protection = true
# assistant_name = "Jarvis"
# assistant_has_own_number = false
"""
if host:
import re as _re
pattern = _re.escape(f"[engine.{engine}]") + r"\nhost = \"[^\"]*\""
replacement = f'[engine.{engine}]\\nhost = "{host}"'
result = _re.sub(pattern, replacement, result)
return result
__all__ = [
@@ -1581,4 +1690,5 @@ __all__ = [
"load_config",
"recommend_engine",
"recommend_model",
"validate_config_key",
]
+71 -4
View File
@@ -16,6 +16,7 @@ from openjarvis.engine._base import (
estimate_prompt_tokens,
messages_to_dicts,
)
from openjarvis.engine._stubs import StreamChunk
logger = logging.getLogger(__name__)
@@ -50,6 +51,9 @@ class _OpenAICompatibleEngine(InferenceEngine):
"stream": False,
**kwargs,
}
# Default to tool_choice=auto when tools are provided
if "tools" in payload and "tool_choice" not in payload:
payload["tool_choice"] = "auto"
try:
url = f"{self._api_prefix}/chat/completions"
resp = self._client.post(url, json=payload)
@@ -122,6 +126,9 @@ class _OpenAICompatibleEngine(InferenceEngine):
"stream": True,
**kwargs,
}
# Default to tool_choice=auto when tools are provided
if "tools" in payload and "tool_choice" not in payload:
payload["tool_choice"] = "auto"
try:
url = f"{self._api_prefix}/chat/completions"
with self._client.stream("POST", url, json=payload) as resp:
@@ -129,7 +136,7 @@ class _OpenAICompatibleEngine(InferenceEngine):
for line in resp.iter_lines():
if not line.startswith("data:"):
continue
data_str = line[len("data:"):].strip()
data_str = line[len("data:") :].strip()
if data_str == "[DONE]":
break
try:
@@ -145,16 +152,74 @@ class _OpenAICompatibleEngine(InferenceEngine):
f"{self.engine_id} engine not reachable at {self._host}"
) from exc
async def stream_full(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
max_tokens: int = 1024,
**kwargs: Any,
) -> AsyncIterator["StreamChunk"]:
"""Yield StreamChunks with content, tool_calls, and finish_reason."""
msg_dicts = messages_to_dicts(messages)
payload: Dict[str, Any] = {
"model": model,
"messages": msg_dicts,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
**kwargs,
}
if "tools" in payload and "tool_choice" not in payload:
payload["tool_choice"] = "auto"
try:
url = f"{self._api_prefix}/chat/completions"
with self._client.stream("POST", url, json=payload) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if not line.startswith("data:"):
continue
data_str = line[len("data:") :].strip()
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
continue
choice = chunk.get("choices", [{}])[0]
delta = choice.get("delta", {})
finish = choice.get("finish_reason")
content = delta.get("content")
tool_calls = delta.get("tool_calls")
usage = chunk.get("usage")
if content or tool_calls or finish or usage:
yield StreamChunk(
content=content,
tool_calls=tool_calls,
finish_reason=finish,
usage=usage,
)
except (httpx.ConnectError, httpx.TimeoutException) as exc:
raise EngineConnectionError(
f"{self.engine_id} engine not reachable at {self._host}"
) from exc
def list_models(self) -> List[str]:
try:
resp = self._client.get(f"{self._api_prefix}/models")
resp.raise_for_status()
except (
httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError,
httpx.ConnectError,
httpx.TimeoutException,
httpx.HTTPStatusError,
) as exc:
logger.warning(
"Failed to list models from %s at %s: %s",
self.engine_id, self._host, exc,
self.engine_id,
self._host,
exc,
)
return []
data = resp.json()
@@ -167,7 +232,9 @@ class _OpenAICompatibleEngine(InferenceEngine):
except Exception as exc:
logger.debug(
"%s health check failed at %s: %s",
self.engine_id, self._host, exc,
self.engine_id,
self._host,
exc,
)
return False
+40 -1
View File
@@ -14,6 +14,21 @@ from typing import Any, Dict, List, Optional, Sequence
from openjarvis.core.types import Message
@dataclass(slots=True)
class StreamChunk:
"""A single chunk from a streaming LLM response.
Used by ``stream_full()`` to yield rich streaming data including
tool_calls fragments and finish_reason, unlike ``stream()`` which
only yields plain content strings.
"""
content: Optional[str] = None
tool_calls: Optional[List[Dict[str, Any]]] = None
finish_reason: Optional[str] = None
usage: Optional[Dict[str, Any]] = None
@dataclass(slots=True)
class ResponseFormat:
"""Structured output configuration for inference engines.
@@ -65,6 +80,30 @@ class InferenceEngine(ABC):
# NOTE: must contain a yield to satisfy the type checker
yield "" # pragma: no cover
async def stream_full(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
max_tokens: int = 1024,
**kwargs: Any,
) -> AsyncIterator["StreamChunk"]:
"""Yield full StreamChunks including tool_calls and finish_reason.
Default implementation wraps ``stream()`` for backward compatibility.
Engines with native tool-call streaming should override this.
"""
async for token in self.stream(
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
):
yield StreamChunk(content=token)
yield StreamChunk(finish_reason="stop")
@abstractmethod
def list_models(self) -> List[str]:
"""Return identifiers of models available on this engine."""
@@ -80,4 +119,4 @@ class InferenceEngine(ABC):
"""Optional warm-up hook called before the first request."""
__all__ = ["InferenceEngine", "ResponseFormat"]
__all__ = ["InferenceEngine", "ResponseFormat", "StreamChunk"]
+497 -101
View File
@@ -6,7 +6,9 @@ import json
import os
import time
from collections.abc import AsyncIterator, Sequence
from typing import Any, Dict, List
from typing import Any, Dict, List, Tuple
import httpx
from openjarvis.core.registry import EngineRegistry
from openjarvis.core.types import Message
@@ -15,6 +17,7 @@ from openjarvis.engine._base import (
InferenceEngine,
messages_to_dicts,
)
from openjarvis.engine._stubs import StreamChunk
# Pricing per million tokens (input, output)
PRICING: Dict[str, tuple[float, float]] = {
@@ -46,7 +49,12 @@ PRICING: Dict[str, tuple[float, float]] = {
# Well-known model IDs per provider
_OPENAI_MODELS = [
"gpt-4o", "gpt-4o-mini", "gpt-5", "gpt-5.4", "gpt-5-mini", "o3-mini",
"gpt-4o",
"gpt-4o-mini",
"gpt-5",
"gpt-5.4",
"gpt-5-mini",
"o3-mini",
]
_ANTHROPIC_MODELS = [
"claude-sonnet-4-20250514",
@@ -85,6 +93,16 @@ _OPENROUTER_POPULAR = [
"openrouter/qwen/qwen3-235b-a22b",
]
# Codex models — prefixed with "codex/" for ChatGPT Plus/Pro subscribers.
# Uses the Responses API at chatgpt.com, not the standard OpenAI API.
_CODEX_MODELS = [
"codex/gpt-4o",
"codex/gpt-4o-mini",
"codex/o3-mini",
"codex/gpt-5-mini",
"codex/gpt-5-mini-2025-08-07",
]
def _is_minimax_model(model: str) -> bool:
return model.lower().startswith("minimax")
@@ -94,6 +112,10 @@ def _is_openrouter_model(model: str) -> bool:
return model.startswith("openrouter/")
def _is_codex_model(model: str) -> bool:
return model.startswith("codex/")
def _is_anthropic_model(model: str) -> bool:
return "claude" in model.lower() and not _is_openrouter_model(model)
@@ -159,11 +181,13 @@ def _convert_tools_to_anthropic(
result = []
for tool in openai_tools:
func = tool.get("function", {})
result.append({
"name": func.get("name", ""),
"description": func.get("description", ""),
"input_schema": func.get("parameters", {}),
})
result.append(
{
"name": func.get("name", ""),
"description": func.get("description", ""),
"input_schema": func.get("parameters", {}),
}
)
return result
@@ -174,11 +198,13 @@ def _convert_tools_to_google(
declarations = []
for tool in openai_tools:
func = tool.get("function", {})
declarations.append({
"name": func.get("name", ""),
"description": func.get("description", ""),
"parameters": func.get("parameters", {}),
})
declarations.append(
{
"name": func.get("name", ""),
"description": func.get("description", ""),
"parameters": func.get("parameters", {}),
}
)
return declarations
@@ -194,28 +220,33 @@ class CloudEngine(InferenceEngine):
self._google_client: Any = None
self._openrouter_client: Any = None
self._minimax_client: Any = None
self._codex_client: Any = None
# Gemini thought_signatures: tool_call_id -> signature bytes
self._thought_sigs: Dict[str, bytes] = {}
self._init_clients()
def _init_clients(self) -> None:
if os.environ.get("OPENAI_API_KEY"):
try:
import openai
self._openai_client = openai.OpenAI()
except ImportError:
pass
if os.environ.get("ANTHROPIC_API_KEY"):
try:
import anthropic
self._anthropic_client = anthropic.Anthropic()
except ImportError:
pass
gemini_key = (
os.environ.get("GEMINI_API_KEY")
or os.environ.get("GOOGLE_API_KEY")
gemini_key = os.environ.get("GEMINI_API_KEY") or os.environ.get(
"GOOGLE_API_KEY"
)
if gemini_key:
try:
from google import genai
self._google_client = genai.Client(api_key=gemini_key)
except ImportError:
pass
@@ -223,6 +254,7 @@ class CloudEngine(InferenceEngine):
if openrouter_key:
try:
import openai
self._openrouter_client = openai.OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=openrouter_key,
@@ -233,12 +265,180 @@ class CloudEngine(InferenceEngine):
if minimax_key:
try:
import openai
self._minimax_client = openai.OpenAI(
base_url="https://api.minimax.io/v1",
api_key=minimax_key,
)
except ImportError:
pass
# Codex — uses the OpenAI Responses API.
# Supports both standard API keys (api.openai.com) and ChatGPT
# OAuth tokens (chatgpt.com) via OPENAI_CODEX_BASE_URL override.
codex_token = os.environ.get("OPENAI_CODEX_API_KEY")
if codex_token:
codex_url = os.environ.get(
"OPENAI_CODEX_BASE_URL",
"https://api.openai.com/v1",
).rstrip("/")
if not codex_url.endswith("/responses"):
codex_url += "/responses"
self._codex_client = {
"token": codex_token,
"url": codex_url,
}
def _prepare_anthropic_messages(
self,
messages: Sequence[Message],
) -> Tuple[str, List[Dict[str, Any]]]:
"""Extract system text and convert messages to Anthropic format."""
system_text = ""
chat_msgs: List[Dict[str, Any]] = []
for m in messages:
if m.role.value == "system":
system_text = m.content
elif m.role.value == "tool":
tool_result_block = {
"type": "tool_result",
"tool_use_id": m.tool_call_id or "",
"content": m.content,
}
if (
chat_msgs
and chat_msgs[-1]["role"] == "user"
and isinstance(chat_msgs[-1]["content"], list)
and chat_msgs[-1]["content"]
and chat_msgs[-1]["content"][-1].get("type") == "tool_result"
):
chat_msgs[-1]["content"].append(tool_result_block)
else:
chat_msgs.append(
{
"role": "user",
"content": [tool_result_block],
}
)
elif m.role.value == "assistant" and m.tool_calls:
content_blocks: List[Dict[str, Any]] = []
if m.content:
content_blocks.append({"type": "text", "text": m.content})
for tc in m.tool_calls:
args = tc.arguments
if isinstance(args, str):
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {"input": args}
content_blocks.append(
{
"type": "tool_use",
"id": tc.id,
"name": tc.name,
"input": args if isinstance(args, dict) else {},
}
)
chat_msgs.append({"role": "assistant", "content": content_blocks})
else:
chat_msgs.append({"role": m.role.value, "content": m.content})
return system_text, chat_msgs
@staticmethod
def _codex_build_input(
messages: Sequence[Message],
) -> tuple[str, List[Dict[str, Any]]]:
"""Convert Message list to Codex Responses API format.
Returns (system_instructions, input_messages).
"""
instructions = ""
input_msgs: List[Dict[str, Any]] = []
for m in messages:
if m.role.value == "system":
instructions = m.content
elif m.role.value in ("user", "assistant"):
input_msgs.append(
{
"role": m.role.value,
"content": [{"type": "input_text", "text": m.content}],
}
)
return instructions, input_msgs
def _generate_codex(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float,
max_tokens: int,
**kwargs: Any,
) -> Dict[str, Any]:
"""Generate via Codex Responses API (ChatGPT Plus/Pro)."""
if self._codex_client is None:
raise EngineConnectionError(
"Codex client not available — set OPENAI_CODEX_API_KEY"
)
actual_model = model.removeprefix("codex/")
instructions, input_msgs = self._codex_build_input(messages)
body: Dict[str, Any] = {
"model": actual_model,
"input": input_msgs,
"store": False,
"stream": False,
}
if instructions:
body["instructions"] = instructions
headers = {
"Authorization": f"Bearer {self._codex_client['token']}",
"Content-Type": "application/json",
"OpenAI-Beta": "responses=experimental",
}
t0 = time.monotonic()
resp = httpx.post(
self._codex_client["url"],
json=body,
headers=headers,
timeout=120.0,
)
elapsed = time.monotonic() - t0
resp.raise_for_status()
data = resp.json()
# Extract text from Responses API output.
# The output array contains items of type "reasoning" and "message";
# we want the "message" item's content blocks.
content = data.get("output_text", "")
if not content:
for item in data.get("output", []):
if item.get("type") not in ("message", None):
continue
for block in item.get("content", []):
if block.get("type") == "output_text":
content = block.get("text", "")
break
if content:
break
usage_data = data.get("usage", {})
prompt_tokens = usage_data.get("input_tokens", 0)
completion_tokens = usage_data.get("output_tokens", 0)
return {
"content": content,
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
"model": actual_model,
"finish_reason": "stop",
"cost_usd": 0.0,
"ttft": elapsed,
}
def _generate_openai(
self,
@@ -333,56 +533,7 @@ class CloudEngine(InferenceEngine):
"ANTHROPIC_API_KEY and install "
"openjarvis[inference-cloud]"
)
# Separate system message and convert to Anthropic message format
system_text = ""
chat_msgs: List[Dict[str, Any]] = []
for m in messages:
if m.role.value == "system":
system_text = m.content
elif m.role.value == "tool":
# Anthropic expects tool results as role="user" with
# tool_result content blocks
tool_result_block = {
"type": "tool_result",
"tool_use_id": m.tool_call_id or "",
"content": m.content,
}
# Merge consecutive tool results into a single user message
if (
chat_msgs
and chat_msgs[-1]["role"] == "user"
and isinstance(chat_msgs[-1]["content"], list)
and chat_msgs[-1]["content"]
and chat_msgs[-1]["content"][-1].get("type") == "tool_result"
):
chat_msgs[-1]["content"].append(tool_result_block)
else:
chat_msgs.append({
"role": "user",
"content": [tool_result_block],
})
elif m.role.value == "assistant" and m.tool_calls:
# Convert assistant messages with tool_calls to Anthropic
# content blocks (text + tool_use)
content_blocks: List[Dict[str, Any]] = []
if m.content:
content_blocks.append({"type": "text", "text": m.content})
for tc in m.tool_calls:
args = tc.arguments
if isinstance(args, str):
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {"input": args}
content_blocks.append({
"type": "tool_use",
"id": tc.id,
"name": tc.name,
"input": args if isinstance(args, dict) else {},
})
chat_msgs.append({"role": "assistant", "content": content_blocks})
else:
chat_msgs.append({"role": m.role.value, "content": m.content})
system_text, chat_msgs = self._prepare_anthropic_messages(messages)
create_kwargs: Dict[str, Any] = {
"model": model,
"messages": chat_msgs,
@@ -426,13 +577,15 @@ class CloudEngine(InferenceEngine):
tool_calls: list[Dict[str, Any]] = []
for block in resp.content:
if getattr(block, "type", None) == "tool_use":
tool_calls.append({
"id": block.id,
"name": block.name,
"arguments": json.dumps(block.input)
if isinstance(block.input, dict)
else str(block.input),
})
tool_calls.append(
{
"id": block.id,
"name": block.name,
"arguments": json.dumps(block.input)
if isinstance(block.input, dict)
else str(block.input),
}
)
elif hasattr(block, "text"):
content_parts.append(block.text)
@@ -510,12 +663,17 @@ class CloudEngine(InferenceEngine):
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {"input": args}
parts.append({
fc_part: Dict[str, Any] = {
"function_call": {
"name": tc.name,
"args": args if isinstance(args, dict) else {},
}
})
}
# Replay thought_signature for Gemini reasoning models
sig = self._thought_sigs.get(tc.id)
if sig is not None:
fc_part["thought_signature"] = sig
parts.append(fc_part)
contents.append({"role": "model", "parts": parts})
elif m.role.value == "assistant":
contents.append({"role": "model", "parts": [{"text": m.content}]})
@@ -564,14 +722,18 @@ class CloudEngine(InferenceEngine):
for part in parts:
if hasattr(part, "function_call") and part.function_call:
fc = part.function_call
fc_args = (
dict(fc.args) if hasattr(fc.args, "items") else {}
)
tool_calls.append({
fc_args = dict(fc.args) if hasattr(fc.args, "items") else {}
tc_dict: Dict[str, Any] = {
"id": f"google_{fc.name}",
"name": fc.name,
"arguments": json.dumps(fc_args),
})
}
# Preserve thought_signature for Gemini reasoning models
sig = getattr(part, "thought_signature", None)
if sig is not None:
tc_dict["thought_signature"] = sig
self._thought_sigs[tc_dict["id"]] = sig
tool_calls.append(tc_dict)
elif hasattr(part, "text") and part.text:
text_parts.append(part.text)
@@ -585,12 +747,8 @@ class CloudEngine(InferenceEngine):
content = ""
um = resp.usage_metadata
prompt_tokens = (
getattr(um, "prompt_token_count", 0) if um else 0
)
completion_tokens = (
getattr(um, "candidates_token_count", 0) if um else 0
)
prompt_tokens = getattr(um, "prompt_token_count", 0) if um else 0
completion_tokens = getattr(um, "candidates_token_count", 0) if um else 0
result: Dict[str, Any] = {
"content": content,
@@ -719,6 +877,8 @@ class CloudEngine(InferenceEngine):
max_tokens=max_tokens,
**kwargs,
)
if _is_codex_model(model):
return self._generate_codex(messages, **kw)
if _is_openrouter_model(model):
return self._generate_openrouter(messages, **kw)
if _is_minimax_model(model):
@@ -744,32 +904,81 @@ class CloudEngine(InferenceEngine):
max_tokens=max_tokens,
**kwargs,
)
if _is_openrouter_model(model):
async for token in self._stream_openrouter(
messages, **kw
):
if _is_codex_model(model):
async for token in self._stream_codex(messages, **kw):
yield token
elif _is_openrouter_model(model):
async for token in self._stream_openrouter(messages, **kw):
yield token
elif _is_minimax_model(model):
async for token in self._stream_minimax(
messages, **kw
):
async for token in self._stream_minimax(messages, **kw):
yield token
elif _is_anthropic_model(model):
async for token in self._stream_anthropic(
messages, **kw
):
async for token in self._stream_anthropic(messages, **kw):
yield token
elif _is_google_model(model):
async for token in self._stream_google(
messages, **kw
):
async for token in self._stream_google(messages, **kw):
yield token
else:
async for token in self._stream_openai(
messages, **kw
):
async for token in self._stream_openai(messages, **kw):
yield token
async def _stream_codex(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float,
max_tokens: int,
**kwargs: Any,
) -> AsyncIterator[str]:
"""Stream via Codex Responses API (SSE)."""
if self._codex_client is None:
raise EngineConnectionError("Codex client not available")
actual_model = model.removeprefix("codex/")
instructions, input_msgs = self._codex_build_input(messages)
body: Dict[str, Any] = {
"model": actual_model,
"input": input_msgs,
"store": False,
"stream": True,
}
if instructions:
body["instructions"] = instructions
headers = {
"Authorization": f"Bearer {self._codex_client['token']}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
"OpenAI-Beta": "responses=experimental",
}
async with httpx.AsyncClient() as client:
async with client.stream(
"POST",
self._codex_client["url"],
json=body,
headers=headers,
timeout=120.0,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
try:
event = json.loads(payload)
except (json.JSONDecodeError, ValueError):
continue
etype = event.get("type", "")
if etype == "response.output_text.delta":
delta = event.get("delta", "")
if delta:
yield delta
async def _stream_openai(
self,
messages: Sequence[Message],
@@ -915,6 +1124,188 @@ class CloudEngine(InferenceEngine):
if delta and delta.content:
yield delta.content
# -- stream_full: rich streaming with tool_calls support ----------------
async def _stream_full_openai(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float,
max_tokens: int,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""Yield StreamChunks from an OpenAI-compatible streaming response.
Works for OpenAI, OpenRouter, MiniMax, and Codex.
"""
if _is_codex_model(model):
# Codex uses Responses API — fall back to base stream_full wrapper
async for chunk in super().stream_full(
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
):
yield chunk
return
if _is_openrouter_model(model):
client = self._openrouter_client
if client is None:
raise EngineConnectionError("OpenRouter client not available")
actual_model = model.removeprefix("openrouter/")
create_kwargs: Dict[str, Any] = {
"model": actual_model,
"messages": messages_to_dicts(messages),
"max_tokens": max_tokens,
"temperature": temperature,
"stream": True,
**kwargs,
}
elif _is_minimax_model(model):
client = self._minimax_client
if client is None:
raise EngineConnectionError("MiniMax client not available")
temperature = max(temperature, 0.01)
temperature = min(temperature, 1.0)
create_kwargs = {
"model": model,
"messages": messages_to_dicts(messages),
"max_tokens": max_tokens,
"temperature": temperature,
"stream": True,
**kwargs,
}
else:
client = self._openai_client
if client is None:
raise EngineConnectionError("OpenAI client not available")
create_kwargs = {
"model": model,
"messages": messages_to_dicts(messages),
"max_completion_tokens": max_tokens,
"stream": True,
**kwargs,
}
if not _is_openai_reasoning_model(model):
create_kwargs["temperature"] = temperature
resp = client.chat.completions.create(**create_kwargs)
for chunk in resp:
choice = chunk.choices[0] if chunk.choices else None
if not choice:
continue
delta = choice.delta
content = delta.content if delta else None
tool_calls = None
if delta and delta.tool_calls:
tool_calls = [
{
"index": tc.index,
"id": tc.id or "",
"function": {
"name": (tc.function.name or "") if tc.function else "",
"arguments": (
(tc.function.arguments or "") if tc.function else ""
),
},
}
for tc in delta.tool_calls
]
finish = choice.finish_reason
if content or tool_calls or finish:
yield StreamChunk(
content=content,
tool_calls=tool_calls,
finish_reason=finish,
)
async def _stream_full_anthropic(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float,
max_tokens: int,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""Yield StreamChunks from an Anthropic streaming response."""
if self._anthropic_client is None:
raise EngineConnectionError("Anthropic client not available")
system_text, chat_msgs = self._prepare_anthropic_messages(messages)
create_kwargs: Dict[str, Any] = {
"model": model,
"messages": chat_msgs,
"temperature": temperature,
"max_tokens": max_tokens,
}
if system_text:
create_kwargs["system"] = system_text
raw_tools = kwargs.pop("tools", None)
if raw_tools:
create_kwargs["tools"] = _convert_tools_to_anthropic(raw_tools)
kwargs.pop("tool_choice", None)
with self._anthropic_client.messages.stream(**create_kwargs) as stream:
tool_index = -1
for event in stream:
if event.type == "content_block_start":
block = event.content_block
if block.type == "tool_use":
tool_index += 1
yield StreamChunk(
tool_calls=[
{
"index": tool_index,
"id": block.id,
"function": {"name": block.name, "arguments": ""},
}
]
)
elif event.type == "content_block_delta":
delta = event.delta
if delta.type == "text_delta":
yield StreamChunk(content=delta.text)
elif delta.type == "input_json_delta":
yield StreamChunk(
tool_calls=[
{
"index": tool_index,
"function": {"arguments": delta.partial_json},
}
]
)
elif event.type == "message_delta":
stop_reason = event.delta.stop_reason
finish = "tool_calls" if stop_reason == "tool_use" else "stop"
yield StreamChunk(finish_reason=finish)
async def stream_full(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
max_tokens: int = 1024,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""Yield StreamChunks with content, tool_calls, and finish_reason."""
kw = dict(
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
)
if _is_anthropic_model(model):
async for chunk in self._stream_full_anthropic(messages, **kw):
yield chunk
elif _is_google_model(model):
async for chunk in super().stream_full(messages, **kw):
yield chunk
else:
async for chunk in self._stream_full_openai(messages, **kw):
yield chunk
def list_models(self) -> List[str]:
models: List[str] = []
if self._openai_client is not None:
@@ -927,6 +1318,8 @@ class CloudEngine(InferenceEngine):
models.extend(_OPENROUTER_POPULAR)
if self._minimax_client is not None:
models.extend(_MINIMAX_MODELS)
if self._codex_client is not None:
models.extend(_CODEX_MODELS)
return models
def health(self) -> bool:
@@ -936,6 +1329,7 @@ class CloudEngine(InferenceEngine):
or self._google_client is not None
or self._openrouter_client is not None
or self._minimax_client is not None
or self._codex_client is not None
)
def close(self) -> None:
@@ -957,6 +1351,8 @@ class CloudEngine(InferenceEngine):
if hasattr(self._minimax_client, "close"):
self._minimax_client.close()
self._minimax_client = None
if self._codex_client is not None:
self._codex_client = None
__all__ = ["CloudEngine", "PRICING", "_annotate_anthropic_cache", "estimate_cost"]
+26 -6
View File
@@ -6,7 +6,9 @@ import logging
from collections.abc import AsyncIterator, Sequence
from typing import Any, Dict, List
from openjarvis.core.types import Message
from openjarvis.engine._base import InferenceEngine
from openjarvis.engine._stubs import StreamChunk
logger = logging.getLogger(__name__)
@@ -65,7 +67,7 @@ class MultiEngine(InferenceEngine):
def generate(
self,
messages: Sequence[Any],
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
@@ -73,13 +75,16 @@ class MultiEngine(InferenceEngine):
**kwargs: Any,
) -> Dict[str, Any]:
return self._engine_for(model).generate(
messages, model=model, temperature=temperature,
max_tokens=max_tokens, **kwargs,
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
)
async def stream(
self,
messages: Sequence[Any],
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
@@ -87,11 +92,26 @@ class MultiEngine(InferenceEngine):
**kwargs: Any,
) -> AsyncIterator[str]:
async for token in self._engine_for(model).stream(
messages, model=model, temperature=temperature,
max_tokens=max_tokens, **kwargs,
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
):
yield token
async def stream_full(
self,
messages: Sequence[Message],
*,
model: str,
**kwargs: Any,
) -> AsyncIterator["StreamChunk"]:
"""Delegate stream_full() to the engine that owns the model."""
engine = self._engine_for(model)
async for chunk in engine.stream_full(messages, model=model, **kwargs):
yield chunk
def list_models(self) -> List[str]:
self._refresh_map()
return list(self._model_map.keys())
+7
View File
@@ -75,6 +75,13 @@ class OllamaEngine(InferenceEngine):
"num_ctx": kwargs.get("num_ctx", 8192),
},
}
# Disable extended thinking by default (Qwen3.5 etc.).
# When enabled, thinking tokens consume the entire budget and
# the visible content comes back empty.
if "think" not in kwargs:
payload["think"] = False
elif kwargs["think"] is not None:
payload["think"] = kwargs["think"]
# Pass tools if provided
tools = kwargs.get("tools")
if tools:
@@ -0,0 +1,34 @@
# PinchBench eval: Claude Opus 4.6 (cloud)
# Agent: native_openhands — all PinchBench-required tools enabled
[meta]
name = "pinchbench-claude-opus"
description = "PinchBench on claude-opus-4-6"
[defaults]
temperature = 0.6
max_tokens = 8192
[judge]
model = "claude-opus-4-5"
temperature = 0.0
engine = "cloud"
[run]
max_workers = 1
output_dir = "results/pinchbench-claude-opus/"
seed = 42
[[models]]
name = "claude-opus-4-6"
engine = "cloud"
[[benchmarks]]
name = "pinchbench"
backend = "jarvis-agent"
agent = "native_openhands"
tools = [
"think", "file_read", "file_write", "web_search", "shell_exec",
"code_interpreter", "browser_navigate", "image_generate",
"calculator", "http_request", "pdf_extract",
]
@@ -0,0 +1,34 @@
# PinchBench eval: Gemini 3.1 Pro Preview (cloud)
# Agent: native_openhands — all PinchBench-required tools enabled
[meta]
name = "pinchbench-gemini-31-pro"
description = "PinchBench on gemini-3.1-pro-preview"
[defaults]
temperature = 0.6
max_tokens = 8192
[judge]
model = "claude-opus-4-5"
temperature = 0.0
engine = "cloud"
[run]
max_workers = 1
output_dir = "results/pinchbench-gemini-31-pro/"
seed = 42
[[models]]
name = "gemini-3.1-pro-preview"
engine = "cloud"
[[benchmarks]]
name = "pinchbench"
backend = "jarvis-agent"
agent = "native_openhands"
tools = [
"think", "file_read", "file_write", "web_search", "shell_exec",
"code_interpreter", "browser_navigate", "image_generate",
"calculator", "http_request", "pdf_extract",
]
@@ -0,0 +1,34 @@
# PinchBench eval: gpt-5.4 (cloud)
# Agent: native_openhands — all PinchBench-required tools enabled
[meta]
name = "pinchbench-gpt54"
description = "PinchBench on gpt-5.4-2026-03-05"
[defaults]
temperature = 0.6
max_tokens = 8192
[judge]
model = "claude-opus-4-5"
temperature = 0.0
engine = "cloud"
[run]
max_workers = 1
output_dir = "results/pinchbench-gpt54/"
seed = 42
[[models]]
name = "gpt-5.4-2026-03-05"
engine = "cloud"
[[benchmarks]]
name = "pinchbench"
backend = "jarvis-agent"
agent = "native_openhands"
tools = [
"think", "file_read", "file_write", "web_search", "shell_exec",
"code_interpreter", "browser_navigate", "image_generate",
"calculator", "http_request", "pdf_extract",
]
@@ -0,0 +1,35 @@
# PinchBench eval: NVIDIA-Nemotron-3-Super-120B-A12B-FP8 (vLLM, TP=4)
# Agent: native_openhands — all PinchBench-required tools enabled
[meta]
name = "pinchbench-nemotron3-super"
description = "PinchBench on NVIDIA-Nemotron-3-Super-120B-A12B-FP8 (vLLM, TP=4)"
[defaults]
temperature = 0.6
max_tokens = 8192
[judge]
model = "claude-opus-4-5"
temperature = 0.0
engine = "cloud"
[run]
max_workers = 1
output_dir = "results/pinchbench-nemotron3-super/"
seed = 42
[[models]]
name = "nemotron"
engine = "vllm"
num_gpus = 4
[[benchmarks]]
name = "pinchbench"
backend = "jarvis-agent"
agent = "native_openhands"
tools = [
"think", "file_read", "file_write", "web_search", "shell_exec",
"code_interpreter", "browser_navigate", "image_generate",
"calculator", "http_request", "pdf_extract",
]
@@ -0,0 +1,35 @@
# PinchBench eval: Qwen3.5-122B-A10B-FP8 (vLLM, TP=4)
# Agent: native_openhands — all PinchBench-required tools enabled
[meta]
name = "pinchbench-qwen122b"
description = "PinchBench on Qwen/Qwen3.5-122B-A10B-FP8 (vLLM, TP=4)"
[defaults]
temperature = 0.6
max_tokens = 8192
[judge]
model = "claude-opus-4-5"
temperature = 0.0
engine = "cloud"
[run]
max_workers = 1
output_dir = "results/pinchbench-qwen122b/"
seed = 42
[[models]]
name = "Qwen/Qwen3.5-122B-A10B-FP8"
engine = "vllm"
num_gpus = 4
[[benchmarks]]
name = "pinchbench"
backend = "jarvis-agent"
agent = "native_openhands"
tools = [
"think", "file_read", "file_write", "web_search", "shell_exec",
"code_interpreter", "browser_navigate", "image_generate",
"calculator", "http_request", "pdf_extract",
]
@@ -0,0 +1,35 @@
# PinchBench eval: Qwen3.5-35B-A3B-FP8 (vLLM, TP=4)
# Agent: native_openhands — all PinchBench-required tools enabled
[meta]
name = "pinchbench-qwen35b"
description = "PinchBench on Qwen/Qwen3.5-35B-A3B-FP8 (vLLM, TP=4)"
[defaults]
temperature = 0.6
max_tokens = 8192
[judge]
model = "claude-opus-4-5"
temperature = 0.0
engine = "cloud"
[run]
max_workers = 1
output_dir = "results/pinchbench-qwen35b/"
seed = 42
[[models]]
name = "Qwen/Qwen3.5-35B-A3B-FP8"
engine = "vllm"
num_gpus = 4
[[benchmarks]]
name = "pinchbench"
backend = "jarvis-agent"
agent = "native_openhands"
tools = [
"think", "file_read", "file_write", "web_search", "shell_exec",
"code_interpreter", "browser_navigate", "image_generate",
"calculator", "http_request", "pdf_extract",
]
@@ -0,0 +1,35 @@
# PinchBench eval: Qwen3.5-397B-A17B-FP8 (vLLM, TP=8)
# Agent: native_openhands — all PinchBench-required tools enabled
[meta]
name = "pinchbench-qwen397b"
description = "PinchBench on Qwen/Qwen3.5-397B-A17B-FP8 (vLLM, TP=8)"
[defaults]
temperature = 0.6
max_tokens = 8192
[judge]
model = "claude-opus-4-5"
temperature = 0.0
engine = "cloud"
[run]
max_workers = 1
output_dir = "results/pinchbench-qwen397b/"
seed = 42
[[models]]
name = "Qwen/Qwen3.5-397B-A17B-FP8"
engine = "vllm"
num_gpus = 8
[[benchmarks]]
name = "pinchbench"
backend = "jarvis-agent"
agent = "native_openhands"
tools = [
"think", "file_read", "file_write", "web_search", "shell_exec",
"code_interpreter", "browser_navigate", "image_generate",
"calculator", "http_request", "pdf_extract",
]
@@ -32,9 +32,9 @@ name = "mlx-community/Qwen2.5-7B-4bit"
engine = "mlx"
param_count_b = 7.0
# Ollama GGUF — pull with: ollama pull qwen3.5:3b
# Ollama GGUF — pull with: ollama pull qwen3.5:2b
[[models]]
name = "qwen3.5:3b"
name = "qwen3.5:2b"
engine = "ollama"
# ── Benchmarks ────────────────────────────────────────────────────────────────
+1
View File
@@ -43,6 +43,7 @@ KNOWN_BENCHMARKS = {
"knowledge_base", "coding_task",
"coding_assistant", "security_scanner", "daily_digest",
"doc_qa", "browser_assistant",
"pinchbench",
}
+81 -12
View File
@@ -96,11 +96,19 @@ class EvalRunner:
seed=cfg.seed,
)
# Auto-enable episode_mode when the dataset has iter_episodes()
# (i.e. it is a lifelong/sequential benchmark like LifelongAgentBench).
# This is enforced at the runner level so it applies regardless of
# how the runner is invoked (CLI, SDK, tests, etc.).
if not cfg.episode_mode and hasattr(self._dataset, "iter_episodes"):
# Auto-enable episode_mode when the dataset *overrides*
# iter_episodes() (i.e. it is a lifelong/sequential benchmark like
# LifelongAgentBench). The base DatasetProvider always defines a
# default iter_episodes() that wraps each record in its own episode,
# so hasattr() is always True — we must check for a real override.
from openjarvis.evals.core.dataset import DatasetProvider as _DP
try:
_overrides_episodes = (
type(self._dataset).iter_episodes is not _DP.iter_episodes
)
except AttributeError:
_overrides_episodes = False
if not cfg.episode_mode and _overrides_episodes:
LOGGER.info(
"%s requires sequential episode processing — "
"auto-enabling episode_mode.",
@@ -109,6 +117,15 @@ class EvalRunner:
cfg = dataclasses.replace(cfg, episode_mode=True)
self._config = cfg
# Detect if dataset provides task environments (e.g. PinchBench)
try:
self._has_task_env = (
type(self._dataset).create_task_env
is not _DP.create_task_env
)
except AttributeError:
self._has_task_env = False
records = list(self._dataset.iter_records())
LOGGER.info(
"Running %s: %d samples, backend=%s, model=%s, workers=%d, "
@@ -145,6 +162,15 @@ class EvalRunner:
try:
if cfg.episode_mode:
self._run_episode_mode(records, progress_callback, total)
elif self._has_task_env:
# Task environments (PinchBench etc.) change CWD —
# must process sequentially for thread safety.
for record in records:
result = self._process_one(record)
self._results.append(result)
self._flush_result(result)
if progress_callback is not None:
progress_callback(len(self._results), total)
else:
with ThreadPoolExecutor(max_workers=cfg.max_workers) as pool:
futures = {
@@ -224,17 +250,60 @@ class EvalRunner:
)
if cfg.system_prompt:
gen_kwargs["system"] = cfg.system_prompt
full = self._backend.generate_full(
record.problem,
**gen_kwargs,
)
content = full.get("content", "")
if getattr(self, "_has_task_env", False):
from contextlib import nullcontext
task_env = self._dataset.create_task_env(record)
ctx = task_env if task_env is not None else nullcontext()
with ctx:
full = self._backend.generate_full(
record.problem,
**gen_kwargs,
)
full = full or {}
all_tool_results = list(full.get(
"tool_results", [],
))
# Multi-session: execute remaining sessions
sessions = record.metadata.get("sessions", [])
if (
record.metadata.get("multi_session")
and len(sessions) > 1
):
for session in sessions[1:]:
prompt = session.get("prompt", "")
if not prompt:
continue
sfull = self._backend.generate_full(
prompt, **gen_kwargs,
)
sfull = sfull or {}
all_tool_results.extend(
sfull.get("tool_results", []),
)
record.metadata["tool_results"] = all_tool_results
# Score INSIDE context so workspace files still exist
content = full.get("content", "")
is_correct, scoring_meta = self._scorer.score(
record, content,
)
else:
full = self._backend.generate_full(
record.problem,
**gen_kwargs,
)
full = full or {}
content = full.get("content", "")
is_correct, scoring_meta = self._scorer.score(
record, content,
)
usage = full.get("usage", {})
latency = full.get("latency_seconds", 0.0)
cost = full.get("cost_usd", 0.0)
is_correct, scoring_meta = self._scorer.score(record, content)
energy_j = full.get("energy_joules", 0.0)
power_w = full.get("power_watts", 0.0)
throughput = full.get("throughput_tok_per_sec", 0.0)
+9 -1
View File
@@ -74,6 +74,8 @@ def _parse_task_markdown(content: str, filename: str = "") -> Dict[str, Any]:
"timeout_seconds": frontmatter.get("timeout_seconds", 180),
"workspace_files": frontmatter.get("workspace_files", []),
"grading_weights": frontmatter.get("grading_weights"),
"multi_session": frontmatter.get("multi_session", False),
"sessions": frontmatter.get("sessions", []),
"prompt": sections.get("Prompt", ""),
"expected_behavior": sections.get("Expected Behavior", ""),
"grading_criteria": sections.get("Grading Criteria", ""),
@@ -160,7 +162,11 @@ class PinchBenchDataset(DatasetProvider):
self._records = [
EvalRecord(
record_id=t["id"],
problem=t["prompt"],
problem=(
t["sessions"][0]["prompt"]
if t.get("multi_session") and t.get("sessions")
else t["prompt"]
),
reference=t["expected_behavior"],
category=t["category"],
subject=t["name"],
@@ -172,6 +178,8 @@ class PinchBenchDataset(DatasetProvider):
"timeout_seconds": t["timeout_seconds"],
"workspace_files": t["workspace_files"],
"pinchbench_repo_dir": str(repo_dir),
"multi_session": t.get("multi_session", False),
"sessions": t.get("sessions", []),
},
)
for t in tasks
+64 -6
View File
@@ -52,12 +52,12 @@ def events_to_transcript(events: List[Any]) -> List[Dict[str, Any]]:
if etype == EventType.TOOL_CALL_START or etype == EventType.TOOL_CALL_START.value:
tool_name = event.metadata.get("tool", "unknown")
mapped = _TOOL_NAME_MAP.get(tool_name, tool_name)
arguments = event.metadata.get("arguments", {})
arguments = event.metadata.get("arguments") or {}
transcript.append({
"type": "message",
"message": {
"role": "assistant",
"content": [{"type": "toolCall", "name": mapped, "arguments": arguments}],
"content": [{"type": "toolCall", "name": mapped, "params": arguments}],
},
})
elif etype == EventType.TOOL_CALL_END or etype == EventType.TOOL_CALL_END.value:
@@ -78,12 +78,14 @@ def _trace_to_transcript(trace: Any) -> List[Dict[str, Any]]:
transcript: List[Dict[str, Any]] = []
for turn in trace.turns:
for tc in getattr(turn, "tool_calls", []):
if tc is None:
continue
mapped = _TOOL_NAME_MAP.get(tc["name"], tc["name"])
transcript.append({
"type": "message",
"message": {
"role": "assistant",
"content": [{"type": "toolCall", "name": mapped, "arguments": tc.get("arguments", {})}],
"content": [{"type": "toolCall", "name": mapped, "params": tc.get("arguments") or {}}],
},
})
transcript.append({
@@ -93,6 +95,42 @@ def _trace_to_transcript(trace: Any) -> List[Dict[str, Any]]:
"content": [{"text": tc.get("result", "")}],
},
})
# Capture final assistant text response (for tasks graded on text output)
response_text = getattr(trace, "response_text", "")
if response_text:
transcript.append({
"type": "message",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": response_text}],
},
})
return transcript
def _tool_results_to_transcript(
tool_results: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Build transcript from JarvisAgentBackend tool_results list."""
transcript: List[Dict[str, Any]] = []
for tr in tool_results:
tool_name = tr.get("tool_name", "unknown")
mapped = _TOOL_NAME_MAP.get(tool_name, tool_name)
transcript.append({
"type": "message",
"message": {
"role": "assistant",
"content": [{"type": "toolCall", "name": mapped, "params": tr.get("arguments", {})}],
},
})
transcript.append({
"type": "message",
"message": {
"role": "toolResult",
"content": [{"text": tr.get("content", "")}],
},
})
return transcript
@@ -118,8 +156,10 @@ def _summarize_transcript(transcript: List[Dict[str, Any]]) -> str:
for item in msg.get("content", []):
if item.get("type") == "toolCall":
parts.append(
f"Tool: {item.get('name')}({json.dumps(item.get('arguments', {}))})"
f"Tool: {item.get('name')}({json.dumps(item.get('params', {}))})"
)
elif item.get("type") == "text":
parts.append(f"Assistant: {item.get('text', '')}")
elif role == "toolResult":
content = msg.get("content", [])
if content:
@@ -372,7 +412,7 @@ def _grade_hybrid(
judge_model: str,
) -> Dict[str, Any]:
"""Run both automated and LLM judge grading, combine with weights."""
weights = record.metadata.get("grading_weights", {"automated": 0.5, "llm_judge": 0.5})
weights = record.metadata.get("grading_weights") or {"automated": 0.5, "llm_judge": 0.5}
auto = _grade_automated(record, transcript, workspace_path)
llm = _grade_llm_judge(record, transcript, workspace_path, judge_backend, judge_model)
@@ -426,7 +466,24 @@ class PinchBenchScorer(LLMJudgeScorer):
self, record: EvalRecord, model_answer: str,
) -> Tuple[Optional[bool], Dict[str, Any]]:
trace = record.metadata.get("query_trace")
transcript = _trace_to_transcript(trace) if trace else []
if trace:
transcript = _trace_to_transcript(trace)
else:
# No trace — build transcript from tool_results if available
tool_results = record.metadata.get("tool_results", [])
transcript = _tool_results_to_transcript(tool_results)
# Always append final model answer as assistant text message
# so grading functions that check for text responses can find it
if model_answer:
transcript.append({
"type": "message",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": model_answer}],
},
})
result = grade_pinchbench_task(
record=record,
transcript=transcript,
@@ -442,4 +499,5 @@ __all__ = [
"PinchBenchScorer",
"events_to_transcript",
"grade_pinchbench_task",
"_tool_results_to_transcript",
]
+120 -24
View File
@@ -40,48 +40,62 @@ BUILTIN_MODELS: List[ModelSpec] = [
# Local models — Qwen3.5 (MoE)
# -----------------------------------------------------------------------
ModelSpec(
model_id="qwen3.5:3b",
name="Qwen3.5 3B",
parameter_count_b=3.0,
active_parameter_count_b=0.6,
model_id="qwen3.5:0.8b",
name="Qwen3.5 0.8B",
parameter_count_b=0.8,
active_parameter_count_b=0.15,
context_length=131072,
supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"),
provider="alibaba",
metadata={
"architecture": "moe",
"hf_repo": "Qwen/Qwen3.5-3B",
"gguf_file": "qwen3.5-3b-q4_k_m.gguf",
"mlx_repo": "mlx-community/Qwen3.5-3B-4bit",
"hf_repo": "Qwen/Qwen3.5-0.8B",
"mlx_repo": "mlx-community/Qwen3.5-0.8B-OptiQ-4bit",
},
),
ModelSpec(
model_id="qwen3.5:8b",
name="Qwen3.5 8B",
parameter_count_b=8.0,
active_parameter_count_b=1.0,
model_id="qwen3.5:2b",
name="Qwen3.5 2B",
parameter_count_b=2.0,
active_parameter_count_b=0.4,
context_length=131072,
supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"),
provider="alibaba",
metadata={
"architecture": "moe",
"hf_repo": "Qwen/Qwen3.5-8B",
"gguf_file": "qwen3.5-8b-q4_k_m.gguf",
"mlx_repo": "mlx-community/Qwen3.5-8B-4bit",
"hf_repo": "Qwen/Qwen3.5-2B",
"mlx_repo": "mlx-community/Qwen3.5-2B-OptiQ-4bit",
},
),
ModelSpec(
model_id="qwen3.5:14b",
name="Qwen3.5 14B",
parameter_count_b=14.0,
active_parameter_count_b=2.0,
model_id="qwen3.5:9b",
name="Qwen3.5 9B",
parameter_count_b=9.0,
active_parameter_count_b=1.5,
context_length=131072,
supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"),
provider="alibaba",
metadata={
"architecture": "moe",
"hf_repo": "Qwen/Qwen3.5-14B",
"gguf_file": "qwen3.5-14b-q4_k_m.gguf",
"mlx_repo": "mlx-community/Qwen3.5-14B-4bit",
"hf_repo": "Qwen/Qwen3.5-9B",
"gguf_file": "qwen3.5-9b-q4_k_m.gguf",
"mlx_repo": "mlx-community/Qwen3.5-9B-MLX-4bit",
},
),
ModelSpec(
model_id="qwen3.5:27b",
name="Qwen3.5 27B",
parameter_count_b=27.0,
active_parameter_count_b=3.0,
context_length=131072,
min_vram_gb=16.0,
supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"),
provider="alibaba",
metadata={
"architecture": "moe",
"hf_repo": "Qwen/Qwen3.5-27B",
"gguf_file": "qwen3.5-27b-q4_k_m.gguf",
"mlx_repo": "mlx-community/Qwen3.5-27B-4bit-DWQ",
},
),
ModelSpec(
@@ -235,7 +249,7 @@ BUILTIN_MODELS: List[ModelSpec] = [
"architecture": "moe",
"hf_repo": "Qwen/Qwen3.5-4B",
"gguf_file": "qwen3.5-4b-q4_k_m.gguf",
"mlx_repo": "mlx-community/Qwen3.5-4B-4bit",
"mlx_repo": "mlx-community/Qwen3.5-4B-OptiQ-4bit",
},
),
ModelSpec(
@@ -490,8 +504,7 @@ BUILTIN_MODELS: List[ModelSpec] = [
metadata={
"architecture": "moe",
"hf_repo": (
"TeichAI/GLM-4.7-Flash-Claude-"
"Opus-4.5-High-Reasoning-Distill-GGUF"
"TeichAI/GLM-4.7-Flash-Claude-Opus-4.5-High-Reasoning-Distill-GGUF"
),
"teacher": "Claude Opus 4.5",
"quantization": "GGUF Q4_K_M / Q8_0",
@@ -637,6 +650,89 @@ BUILTIN_MODELS: List[ModelSpec] = [
},
),
# -----------------------------------------------------------------------
# Cloud models — OpenAI Codex (ChatGPT Plus/Pro subscription)
# -----------------------------------------------------------------------
ModelSpec(
model_id="codex/gpt-4o",
name="GPT-4o (Codex)",
parameter_count_b=0.0,
context_length=128000,
supported_engines=("cloud",),
provider="openai-codex",
requires_api_key=True,
metadata={
"architecture": "proprietary",
"auth": "OPENAI_CODEX_API_KEY",
"pricing_input": 0.0,
"pricing_output": 0.0,
"url": "https://platform.openai.com/docs/models/gpt-4o",
},
),
ModelSpec(
model_id="codex/gpt-4o-mini",
name="GPT-4o Mini (Codex)",
parameter_count_b=0.0,
context_length=128000,
supported_engines=("cloud",),
provider="openai-codex",
requires_api_key=True,
metadata={
"architecture": "proprietary",
"auth": "OPENAI_CODEX_API_KEY",
"pricing_input": 0.0,
"pricing_output": 0.0,
"url": "https://platform.openai.com/docs/models/gpt-4o-mini",
},
),
ModelSpec(
model_id="codex/o3-mini",
name="o3-mini (Codex)",
parameter_count_b=0.0,
context_length=200000,
supported_engines=("cloud",),
provider="openai-codex",
requires_api_key=True,
metadata={
"architecture": "proprietary",
"auth": "OPENAI_CODEX_API_KEY",
"pricing_input": 0.0,
"pricing_output": 0.0,
"url": "https://platform.openai.com/docs/models",
},
),
ModelSpec(
model_id="codex/gpt-5-mini",
name="GPT-5 Mini (Codex)",
parameter_count_b=0.0,
context_length=400000,
supported_engines=("cloud",),
provider="openai-codex",
requires_api_key=True,
metadata={
"architecture": "proprietary",
"auth": "OPENAI_CODEX_API_KEY",
"pricing_input": 0.0,
"pricing_output": 0.0,
"url": "https://platform.openai.com/docs/models",
},
),
ModelSpec(
model_id="codex/gpt-5-mini-2025-08-07",
name="GPT-5 Mini 2025-08-07 (Codex)",
parameter_count_b=0.0,
context_length=400000,
supported_engines=("cloud",),
provider="openai-codex",
requires_api_key=True,
metadata={
"architecture": "proprietary",
"auth": "OPENAI_CODEX_API_KEY",
"pricing_input": 0.0,
"pricing_output": 0.0,
"url": "https://platform.openai.com/docs/models",
},
),
# -----------------------------------------------------------------------
# Cloud models — Anthropic
# -----------------------------------------------------------------------
ModelSpec(
+2
View File
@@ -8,6 +8,7 @@ from openjarvis.mcp.transport import (
MCPTransport,
SSETransport,
StdioTransport,
StreamableHTTPTransport,
)
__all__ = [
@@ -21,4 +22,5 @@ __all__ = [
"InProcessTransport",
"SSETransport",
"StdioTransport",
"StreamableHTTPTransport",
]
+33 -2
View File
@@ -47,13 +47,36 @@ class MCPClient:
def initialize(self) -> Dict[str, Any]:
"""Perform the MCP initialize handshake.
Sends the required client info and protocol version, then
confirms with a ``notifications/initialized`` notification
as required by the MCP specification.
Returns the server capabilities.
"""
response = self._send("initialize")
params = {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "openjarvis", "version": "0.1.0"},
}
response = self._send("initialize", params)
self._initialized = True
self._capabilities = response.result.get("capabilities", {})
# Send the required initialized notification per MCP spec
self.notify("notifications/initialized")
return response.result
def notify(self, method: str, params: Dict[str, Any] | None = None) -> None:
"""Send a JSON-RPC notification (no response expected).
Per JSON-RPC 2.0 spec, notifications omit the ``id`` field entirely.
"""
request = MCPRequest(
method=method,
params=params or {},
id=None, # None → no id field in JSON (notification)
)
self._transport.send_notification(request)
def list_tools(self) -> List[ToolSpec]:
"""Discover available tools from the server.
@@ -71,7 +94,9 @@ class MCPClient:
]
def call_tool(
self, name: str, arguments: Dict[str, Any] | None = None,
self,
name: str,
arguments: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
"""Call a tool on the server.
@@ -87,5 +112,11 @@ class MCPClient:
"""Close the transport connection."""
self._transport.close()
def __enter__(self) -> MCPClient:
return self
def __exit__(self, *exc: Any) -> None:
self.close()
__all__ = ["MCPClient"]
+21 -10
View File
@@ -16,23 +16,34 @@ INTERNAL_ERROR = -32603
@dataclass
class MCPRequest:
"""JSON-RPC 2.0 request message."""
"""JSON-RPC 2.0 request message.
Set *id* to ``None`` to create a JSON-RPC **notification** (no ``id``
field will appear in the serialized output, and no response is expected).
"""
method: str
params: Dict[str, Any] = field(default_factory=dict)
id: int | str = 0
id: Optional[int | str] = 0
jsonrpc: str = "2.0"
def to_dict(self) -> Dict[str, Any]:
"""Return a dict suitable for JSON serialization.
Omits the ``id`` key when it is ``None`` (notification).
"""
obj: Dict[str, Any] = {
"jsonrpc": self.jsonrpc,
"method": self.method,
"params": self.params,
}
if self.id is not None:
obj["id"] = self.id
return obj
def to_json(self) -> str:
"""Serialize to JSON string."""
return json.dumps(
{
"jsonrpc": self.jsonrpc,
"id": self.id,
"method": self.method,
"params": self.params,
}
)
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, data: str) -> MCPRequest:
+26 -7
View File
@@ -70,31 +70,37 @@ class MCPServer:
# Built-in API tools
try:
from openjarvis.tools.calculator import CalculatorTool
_tool_classes.append(CalculatorTool)
except ImportError:
pass
try:
from openjarvis.tools.think import ThinkTool
_tool_classes.append(ThinkTool)
except ImportError:
pass
try:
from openjarvis.tools.file_read import FileReadTool
_tool_classes.append(FileReadTool)
except ImportError:
pass
try:
from openjarvis.tools.web_search import WebSearchTool
_tool_classes.append(WebSearchTool)
except ImportError:
pass
try:
from openjarvis.tools.code_interpreter import CodeInterpreterTool
_tool_classes.append(CodeInterpreterTool)
except ImportError:
pass
try:
from openjarvis.tools.repl import ReplTool
_tool_classes.append(ReplTool)
except ImportError:
pass
@@ -107,10 +113,15 @@ class MCPServer:
MemorySearchTool,
MemoryStoreTool,
)
_tool_classes.extend([
MemoryStoreTool, MemoryRetrieveTool,
MemorySearchTool, MemoryIndexTool,
])
_tool_classes.extend(
[
MemoryStoreTool,
MemoryRetrieveTool,
MemorySearchTool,
MemoryIndexTool,
]
)
except ImportError:
pass
@@ -121,15 +132,21 @@ class MCPServer:
ChannelSendTool,
ChannelStatusTool,
)
_tool_classes.extend([
ChannelSendTool, ChannelListTool, ChannelStatusTool,
])
_tool_classes.extend(
[
ChannelSendTool,
ChannelListTool,
ChannelStatusTool,
]
)
except ImportError:
pass
# LM tool (needs engine/model — instantiate with None)
try:
from openjarvis.tools.llm_tool import LLMTool
_tool_classes.append(LLMTool)
except ImportError:
pass
@@ -137,6 +154,7 @@ class MCPServer:
# Retrieval tool (needs backend — instantiate with None)
try:
from openjarvis.tools.retrieval import RetrievalTool
_tool_classes.append(RetrievalTool)
except ImportError:
pass
@@ -150,6 +168,7 @@ class MCPServer:
# Also check ToolRegistry for any user-registered tools
try:
from openjarvis.core.registry import ToolRegistry
known_names = {t.spec.name for t in tools}
for key in ToolRegistry.keys():
if key not in known_names:
+130 -18
View File
@@ -2,10 +2,9 @@
from __future__ import annotations
import json
import subprocess
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING, Any, List, Optional
from openjarvis.mcp.protocol import MCPRequest, MCPResponse
@@ -20,6 +19,15 @@ class MCPTransport(ABC):
def send(self, request: MCPRequest) -> MCPResponse:
"""Send a request and return the response."""
def send_notification(self, request: MCPRequest) -> None:
"""Send a JSON-RPC notification (no response expected).
The default implementation delegates to :meth:`send` and discards the
response. Transports may override this when the server returns no
body for notifications (e.g. HTTP 202 Accepted).
"""
self.send(request)
@abstractmethod
def close(self) -> None:
"""Release transport resources."""
@@ -88,30 +96,133 @@ class StdioTransport(MCPTransport):
self._process = None
class SSETransport(MCPTransport):
"""JSON-RPC over HTTP with Server-Sent Events.
class StreamableHTTPTransport(MCPTransport):
"""MCP Streamable HTTP transport (JSON-RPC over HTTP).
Sends requests via HTTP POST and reads SSE responses.
Uses a persistent ``httpx.Client`` session, tracks the
``Mcp-Session-Id`` header, and sends the ``Accept`` header
required by the MCP Streamable HTTP specification.
"""
def __init__(self, url: str) -> None:
self._url = url
def send(self, request: MCPRequest) -> MCPResponse:
"""Send request via HTTP POST."""
def __init__(
self,
url: str,
*,
connect_timeout: float = 10.0,
request_timeout: float = 60.0,
) -> None:
import httpx
response = httpx.post(
self._url,
json=json.loads(request.to_json()),
headers={"Content-Type": "application/json"},
timeout=30.0,
self._url = url
self._session_id: Optional[str] = None
self._client = httpx.Client(
timeout=httpx.Timeout(
connect=connect_timeout,
read=request_timeout,
write=request_timeout,
pool=connect_timeout,
),
)
response.raise_for_status()
return MCPResponse.from_json(response.text)
def _safe_url(self) -> str:
"""Return scheme://host:port without path or query (avoids leaking tokens)."""
from urllib.parse import urlparse
parsed = urlparse(self._url)
return f"{parsed.scheme}://{parsed.netloc}"
def _build_headers(self) -> dict:
"""Build common request headers."""
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if self._session_id is not None:
headers["Mcp-Session-Id"] = self._session_id
return headers
def _post(self, request: MCPRequest) -> Any:
"""Post a request and return the raw httpx response."""
import httpx
headers = self._build_headers()
try:
response = self._client.post(
self._url,
json=request.to_dict(),
headers=headers,
)
response.raise_for_status()
except httpx.ConnectError as exc:
raise RuntimeError(
f"Failed to connect to MCP server at {self._safe_url()}: {exc}"
) from exc
except httpx.TimeoutException as exc:
raise RuntimeError(
f"Timeout communicating with MCP server at {self._safe_url()}: {exc}"
) from exc
except httpx.HTTPStatusError as exc:
raise RuntimeError(
f"MCP server at {self._safe_url()} returned HTTP "
f"{exc.response.status_code}"
) from exc
# Track session id from the first response
new_session_id = response.headers.get("mcp-session-id")
if new_session_id is not None:
self._session_id = new_session_id
return response
@staticmethod
def _extract_json_from_sse(text: str) -> str:
"""Extract JSON payload from an SSE response body.
MCP Streamable HTTP servers may respond with ``text/event-stream``
instead of ``application/json``. In that case the body looks like::
event: message
data: {"jsonrpc":"2.0", ...}
This helper finds the last ``data:`` line and returns its content,
which is the actual JSON-RPC response.
"""
last_data = ""
for line in text.splitlines():
if line.startswith("data:"):
last_data = line[len("data:") :].strip()
if not last_data:
raise RuntimeError(
"SSE response contained no 'data:' lines"
" — cannot extract JSON-RPC payload"
)
return last_data
def send(self, request: MCPRequest) -> MCPResponse:
"""Send request via HTTP POST following the MCP Streamable HTTP spec.
Handles both ``application/json`` and ``text/event-stream`` responses
as allowed by the MCP Streamable HTTP specification.
"""
response = self._post(request)
content_type = response.headers.get("content-type", "")
body = response.text
if "text/event-stream" in content_type or body.lstrip().startswith("event:"):
body = self._extract_json_from_sse(body)
return MCPResponse.from_json(body)
def send_notification(self, request: MCPRequest) -> None:
"""Send a notification — accept any 2xx, don't parse the body."""
# Track session id but don't try to parse a JSON-RPC response.
# Servers may return 202 Accepted with an empty body.
self._post(request)
def close(self) -> None:
"""No persistent connection to close."""
"""Close the underlying httpx client."""
self._client.close()
# Backward-compatible alias
SSETransport = StreamableHTTPTransport
__all__ = [
@@ -119,4 +230,5 @@ __all__ = [
"MCPTransport",
"SSETransport",
"StdioTransport",
"StreamableHTTPTransport",
]
+6 -1
View File
@@ -218,8 +218,13 @@ class TaskScheduler:
try:
if self._system is not None:
raw_tools = (
task.tools
if isinstance(task.tools, list)
else task.tools.split(",")
)
tools_list = (
[t.strip() for t in task.tools.split(",") if t.strip()]
[t.strip() for t in raw_tools if t.strip()]
if task.tools
else []
)
+19 -3
View File
@@ -30,11 +30,27 @@ def is_sensitive_file(path: Union[str, Path]) -> bool:
Checks both the filename and the full name against
``DEFAULT_SENSITIVE_PATTERNS`` using :func:`fnmatch.fnmatch`.
Uses the Rust implementation when available, falls back to Python.
"""
from openjarvis._rust_bridge import get_rust_module
try:
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
return _rust.is_sensitive_file(str(path))
_rust = get_rust_module()
return _rust.is_sensitive_file(str(path))
except ImportError:
return _is_sensitive_file_py(str(path))
def _is_sensitive_file_py(path_str: str) -> bool:
"""Pure-Python fallback for sensitive file detection."""
import fnmatch
p = Path(path_str)
name = p.name
for pattern in DEFAULT_SENSITIVE_PATTERNS:
if fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(str(p), pattern):
return True
return False
def filter_sensitive_paths(paths: Iterable[Union[str, Path]]) -> List[Path]:
+67 -10
View File
@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional, Sequence
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.types import Message
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.engine._stubs import InferenceEngine, StreamChunk
from openjarvis.security._stubs import BaseScanner
from openjarvis.security.scanner import PIIScanner, SecretScanner
from openjarvis.security.types import RedactionMode, ScanResult
@@ -50,10 +50,14 @@ class GuardrailsEngine(InferenceEngine):
bus: Optional[EventBus] = None,
) -> None:
self._engine = engine
self._scanners: List[BaseScanner] = scanners if scanners is not None else [
SecretScanner(),
PIIScanner(),
]
self._scanners: List[BaseScanner] = (
scanners
if scanners is not None
else [
SecretScanner(),
PIIScanner(),
]
)
self._mode = mode
self._scan_input = scan_input
self._scan_output = scan_output
@@ -180,7 +184,9 @@ class GuardrailsEngine(InferenceEngine):
processed[i] = Message(
role=msg.role,
content=self._handle_findings(
msg.content, result, "input",
msg.content,
result,
"input",
),
name=msg.name,
tool_calls=msg.tool_calls,
@@ -191,8 +197,11 @@ class GuardrailsEngine(InferenceEngine):
# Call wrapped engine
response = self._engine.generate(
messages, model=model, temperature=temperature,
max_tokens=max_tokens, **kwargs,
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
)
# Scan output
@@ -219,8 +228,11 @@ class GuardrailsEngine(InferenceEngine):
"""Yield tokens in real-time, scan accumulated output post-hoc."""
accumulated = []
async for token in self._engine.stream(
messages, model=model, temperature=temperature,
max_tokens=max_tokens, **kwargs,
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
):
accumulated.append(token)
yield token
@@ -248,6 +260,51 @@ class GuardrailsEngine(InferenceEngine):
},
)
async def stream_full(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
max_tokens: int = 1024,
**kwargs: Any,
) -> AsyncIterator["StreamChunk"]:
"""Delegate to wrapped engine, scan accumulated output post-hoc."""
accumulated: list[str] = []
async for chunk in self._engine.stream_full(
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
):
if chunk.content:
accumulated.append(chunk.content)
yield chunk
# Post-hoc scan of accumulated output
if self._scan_output:
full_output = "".join(accumulated)
if full_output:
result = self._scan_text(full_output)
if not result.clean and self._bus:
finding_dicts = [
{
"pattern": f.pattern_name,
"threat": f.threat_level.value,
"description": f.description,
}
for f in result.findings
]
self._bus.publish(
EventType.SECURITY_ALERT,
{
"direction": "output",
"findings": finding_dicts,
"mode": "stream_full_post_hoc",
},
)
def list_models(self) -> List[str]:
"""Delegate to wrapped engine."""
return self._engine.list_models()
+624 -133
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import re as _re
from typing import Any, Dict, List, Optional, Tuple
from openjarvis.agents.manager import AgentManager
@@ -59,11 +60,91 @@ class FeedbackRequest(BaseModel):
_BROWSER_SUB_TOOLS = {
"browser_navigate", "browser_click", "browser_type",
"browser_screenshot", "browser_extract", "browser_axtree",
"browser_navigate",
"browser_click",
"browser_type",
"browser_screenshot",
"browser_extract",
"browser_axtree",
}
class _LightweightSystem:
"""Minimal system facade for the executor — avoids rebuilding the
full JarvisSystem (which picks a random model from Ollama)."""
def __init__(self, engine: Any, model: str, config: Any = None):
self.engine = engine
self.model = model
self.config = config
self.memory_backend = None
def _make_lightweight_system(
engine: Any,
model: str,
config: Any = None,
) -> _LightweightSystem:
"""Build a minimal system with a plain OllamaEngine.
The server's ``app.state.engine`` is heavily wrapped
(MultiEngine -> InstrumentedEngine -> GuardrailsEngine) and can
return empty content from background threads. Create a fresh
OllamaEngine directly (no health checks or model discovery that
could interfere with in-flight Ollama requests).
"""
try:
from openjarvis.engine.ollama import OllamaEngine
cfg = config
if cfg is None:
from openjarvis.core.config import load_config
cfg = load_config()
host = cfg.engine.ollama.host if cfg else ""
plain_engine = OllamaEngine(host=host) if host else OllamaEngine()
return _LightweightSystem(plain_engine, model, cfg)
except Exception:
pass
return _LightweightSystem(engine, model, config)
def _parse_param_count(model_name: str) -> float:
"""Extract parameter count in billions from model name.
Examples: 'qwen3.5:9b' -> 9.0, 'qwen3.5:0.8b' -> 0.8
"""
m = _re.search(r":(\d+(?:\.\d+)?)b", model_name.lower())
return float(m.group(1)) if m else 0.0
_CLOUD_PREFIXES = ("gpt-", "claude-", "gemini-", "o1-", "o3-", "o4-")
def _pick_recommended_model(
model_ids: list[str],
) -> dict[str, str]:
"""Pick the second-largest local model from a list."""
local = [
m for m in model_ids
if not any(m.startswith(p) for p in _CLOUD_PREFIXES)
]
if not local:
return {
"model": model_ids[0] if model_ids else "",
"reason": "Only model available",
}
sized = sorted(local, key=_parse_param_count, reverse=True)
if len(sized) == 1:
return {"model": sized[0], "reason": "Only local model available"}
pick = sized[1] # second-largest
params = _parse_param_count(pick)
return {
"model": pick,
"reason": f"Second-largest local model ({params}B parameters)",
}
def _ensure_registries_populated() -> None:
"""Ensure ToolRegistry and ChannelRegistry are populated.
@@ -97,9 +178,8 @@ def _ensure_registries_populated() -> None:
# If registries are still empty, reload individual submodules from sys.modules
if not ChannelRegistry.keys():
for mod_name in list(sys.modules):
if (
mod_name.startswith("openjarvis.channels.")
and not mod_name.endswith("_stubs")
if mod_name.startswith("openjarvis.channels.") and not mod_name.endswith(
"_stubs"
):
try:
importlib.reload(sys.modules[mod_name])
@@ -154,53 +234,63 @@ def build_tools_list() -> List[Dict[str, Any]]:
except Exception:
spec = None
cred_keys = TOOL_CREDENTIALS.get(name, [])
items.append({
"name": name,
"description": spec.description if spec else "",
"category": spec.category if spec else "",
"source": "tool",
"requires_credentials": len(cred_keys) > 0,
"credential_keys": cred_keys,
"configured": (
all(bool(os.environ.get(k)) for k in cred_keys)
if cred_keys else True
),
})
items.append(
{
"name": name,
"description": spec.description if spec else "",
"category": spec.category if spec else "",
"source": "tool",
"requires_credentials": len(cred_keys) > 0,
"credential_keys": cred_keys,
"configured": (
all(bool(os.environ.get(k)) for k in cred_keys)
if cred_keys
else True
),
}
)
except Exception:
pass
try:
if any(ToolRegistry.contains(n) for n in _BROWSER_SUB_TOOLS):
items.append({
"name": "browser",
"description": (
"Web browser automation"
" (navigate, click, type, screenshot, extract)"
),
"category": "browser",
"source": "tool",
"requires_credentials": False,
"credential_keys": [],
"configured": True,
})
items.append(
{
"name": "browser",
"description": (
"Web browser automation"
" (navigate, click, type, screenshot, extract)"
),
"category": "browser",
"source": "tool",
"requires_credentials": False,
"credential_keys": [],
"configured": True,
}
)
except Exception:
pass
try:
for name, _cls in ChannelRegistry.items():
cred_keys = TOOL_CREDENTIALS.get(name, [])
items.append({
"name": name,
"description": f"{name.replace('_', ' ').title()} messaging channel",
"category": "communication",
"source": "channel",
"requires_credentials": len(cred_keys) > 0,
"credential_keys": cred_keys,
"configured": (
all(bool(os.environ.get(k)) for k in cred_keys)
if cred_keys else True
),
})
items.append(
{
"name": name,
"description": (
f"{name.replace('_', ' ').title()} messaging channel"
),
"category": "communication",
"source": "channel",
"requires_credentials": len(cred_keys) > 0,
"credential_keys": cred_keys,
"configured": (
all(bool(os.environ.get(k)) for k in cred_keys)
if cred_keys
else True
),
}
)
except Exception:
pass
@@ -242,6 +332,142 @@ def _build_deep_research_tools(
]
def _merge_tool_call_fragments(
accumulated: Dict[int, Dict[str, Any]],
fragments: List[Dict[str, Any]],
) -> None:
"""Merge incremental tool_call delta fragments into accumulated state.
OpenAI-compatible APIs send tool_calls as incremental fragments keyed
by ``index``. Each fragment may contain partial ``function.name`` and/or
``function.arguments`` strings that must be concatenated.
"""
for frag in fragments:
idx = frag.get("index", 0)
if idx not in accumulated:
accumulated[idx] = {
"id": frag.get("id", ""),
"type": "function",
"function": {"name": "", "arguments": ""},
}
entry = accumulated[idx]
if frag.get("id"):
entry["id"] = frag["id"]
fn = frag.get("function", {})
if fn.get("name"):
entry["function"]["name"] += fn["name"]
if fn.get("arguments"):
entry["function"]["arguments"] += fn["arguments"]
def _get_mcp_tools(app_state: Any) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""Return (openai_tools_list, mcp_adapters_by_name).
Lazily discovers MCP tools from config and caches them on ``app_state``
so that subsequent requests reuse the same connections.
"""
cached = getattr(app_state, "_mcp_tools_cache", None)
if cached is not None:
return cached
import json as _json
from openjarvis.core.config import load_config
openai_tools: List[Dict[str, Any]] = []
adapters_by_name: Dict[str, Any] = {}
try:
app_config = load_config()
except Exception as exc:
logger.warning("Failed to load config for MCP discovery: %s", exc)
return openai_tools, adapters_by_name
if not app_config.tools.mcp.enabled or not app_config.tools.mcp.servers:
return openai_tools, adapters_by_name
from openjarvis.mcp.client import MCPClient
from openjarvis.mcp.transport import StdioTransport, StreamableHTTPTransport
from openjarvis.tools.mcp_adapter import MCPToolProvider
# Keep clients alive so transports persist for tool calls at runtime
mcp_clients: list = getattr(app_state, "_mcp_clients", [])
try:
server_list = _json.loads(app_config.tools.mcp.servers)
except (_json.JSONDecodeError, TypeError) as exc:
logger.warning("Failed to parse MCP server config: %s", exc)
return openai_tools, adapters_by_name
if not isinstance(server_list, list):
return openai_tools, adapters_by_name
for server_cfg in server_list:
cfg = _json.loads(server_cfg) if isinstance(server_cfg, str) else server_cfg
name = cfg.get("name", "<unnamed>")
url = cfg.get("url")
command = cfg.get("command", "")
args = cfg.get("args", [])
try:
if url:
transport = StreamableHTTPTransport(url=url)
elif command:
transport = StdioTransport(command=[command] + args)
else:
logger.warning(
"MCP server '%s' has neither 'url' nor 'command' — skipping",
name,
)
continue
client = MCPClient(transport)
client.initialize()
mcp_clients.append(client)
provider = MCPToolProvider(client)
discovered = provider.discover()
# Per-server tool filtering
include_tools = set(cfg.get("include_tools", []))
exclude_tools = set(cfg.get("exclude_tools", []))
if include_tools:
discovered = [t for t in discovered if t.spec.name in include_tools]
if exclude_tools:
discovered = [t for t in discovered if t.spec.name not in exclude_tools]
for adapter in discovered:
spec = adapter.spec
openai_tools.append(
{
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": spec.parameters,
},
}
)
adapters_by_name[spec.name] = adapter
logger.info(
"Discovered %d MCP tools from server '%s'",
len(discovered),
name,
)
except Exception as exc:
logger.warning(
"Failed to discover MCP tools from '%s': %s",
name,
exc,
)
app_state._mcp_clients = mcp_clients
if openai_tools:
app_state._mcp_tools_cache = (openai_tools, adapters_by_name)
return openai_tools, adapters_by_name
async def _stream_managed_agent(
*,
manager: AgentManager,
@@ -250,36 +476,31 @@ async def _stream_managed_agent(
message_id: str,
engine: Any,
bus: Any,
app_state: Any = None,
) -> StreamingResponse:
"""Run a managed agent and stream the response as SSE.
"""Run a managed agent with real LLM token streaming via SSE.
Instantiates the agent from its stored config, builds conversation
context from message history, executes the agent in a background
thread, and yields SSE-formatted chunks. After completion the
full response is persisted via ``manager.store_agent_response()``.
Uses ``engine.stream_full()`` to yield tokens as they arrive from the
LLM. Supports multi-turn tool-calling: when the model emits tool_calls,
they are executed and the results fed back for the next turn.
"""
import asyncio
import json
import uuid
from openjarvis.agents._stubs import AgentContext
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role
agent_id = agent_record["id"]
config = agent_record.get("config", {})
agent_type = agent_record.get("agent_type", "orchestrator")
model = config.get("model", getattr(engine, "_model", ""))
system_prompt = config.get("system_prompt")
temperature = config.get("temperature", 0.7)
max_tokens = config.get("max_tokens", 1024)
max_turns = config.get("max_turns", 10)
# Resolve the agent class from registry
agent_cls = AgentRegistry.get(agent_type)
if agent_cls is None:
# Fallback to orchestrator if the type is not registered
agent_cls = AgentRegistry.get("orchestrator")
if agent_cls is None:
raise HTTPException(
status_code=500, detail=f"Agent type '{agent_type}' not found in registry",
)
# Build conversation messages from history + current input
llm_messages: List[Message] = []
if system_prompt:
llm_messages.append(Message(role=Role.SYSTEM, content=system_prompt))
# Build agent constructor kwargs from config
agent_kwargs: Dict[str, Any] = {
@@ -317,85 +538,234 @@ async def _stream_managed_agent(
messages = manager.list_messages(agent_id, limit=50)
# Messages come in DESC order, reverse for chronological
for m in reversed(messages):
# Skip the message we just stored (it will be the input)
if m["id"] == message_id:
continue
if m["direction"] == "user_to_agent":
ctx.conversation.add(Message(role=Role.USER, content=m["content"]))
llm_messages.append(Message(role=Role.USER, content=m["content"]))
elif m["direction"] == "agent_to_user":
ctx.conversation.add(Message(role=Role.ASSISTANT, content=m["content"]))
llm_messages.append(Message(role=Role.ASSISTANT, content=m["content"]))
# Append the current user message
llm_messages.append(Message(role=Role.USER, content=user_content))
# Mark the user message as delivered
manager.mark_message_delivered(message_id)
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
async def generate():
"""Async generator yielding SSE-formatted chunks."""
collected_content = ""
# Build extra kwargs for stream_full (e.g. tools from config)
stream_kwargs: Dict[str, Any] = {}
if config.get("tools"):
stream_kwargs["tools"] = config["tools"]
# Run agent.run() in a background thread
# Discover MCP tools and merge into stream_kwargs
mcp_adapters: Dict[str, Any] = {}
if app_state is not None:
try:
result = await asyncio.to_thread(agent.run, user_content, context=ctx)
mcp_openai_tools, mcp_adapters = _get_mcp_tools(app_state)
if mcp_openai_tools:
existing_tools = stream_kwargs.get("tools", [])
stream_kwargs["tools"] = existing_tools + mcp_openai_tools
logger.info(
"Added %d MCP tools to streaming request",
len(mcp_openai_tools),
)
except Exception as exc:
logger.error("Managed agent stream error: %s", exc, exc_info=True)
error_data = {
"id": chunk_id,
"object": "chat.completion.chunk",
"model": model,
"choices": [{
"index": 0,
"delta": {"content": f"Error: {exc}"},
"finish_reason": "stop",
}],
}
yield f"data: {json.dumps(error_data)}\n\n"
yield "data: [DONE]\n\n"
return
logger.warning(
"Failed to get MCP tools for streaming: %s", exc, exc_info=True
)
content = result.content or ""
collected_content = content
async def generate():
"""Async generator yielding SSE-formatted chunks with real token streaming."""
# Emit tool results metadata if any
if result.tool_results:
tool_data = []
for tr in result.tool_results:
tool_data.append({
"tool_name": tr.tool_name,
"success": tr.success,
"output": tr.content,
"latency_ms": tr.latency_seconds * 1000,
})
yield f"event: tool_results\ndata: {json.dumps({'results': tool_data})}\n\n"
collected_content = ""
messages_for_llm = list(llm_messages)
turns = 0
# Stream content word-by-word for real-time feel
if content:
words = content.split(" ")
for i, word in enumerate(words):
token = word if i == 0 else " " + word
chunk_data = {
while turns < max_turns:
turns += 1
turn_content = ""
tool_call_fragments: Dict[int, Dict[str, Any]] = {}
current_finish_reason = None
try:
async for chunk in engine.stream_full(
messages_for_llm,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**stream_kwargs,
):
# Stream content tokens immediately to the client
if chunk.content:
turn_content += chunk.content
chunk_data = {
"id": chunk_id,
"object": "chat.completion.chunk",
"model": model,
"choices": [
{
"index": 0,
"delta": {"content": chunk.content},
"finish_reason": None,
}
],
}
yield f"data: {json.dumps(chunk_data)}\n\n"
# Accumulate tool_call fragments
if chunk.tool_calls:
_merge_tool_call_fragments(
tool_call_fragments,
chunk.tool_calls,
)
if chunk.finish_reason:
current_finish_reason = chunk.finish_reason
except Exception as exc:
logger.error("Managed agent stream error: %s", exc, exc_info=True)
error_data = {
"id": chunk_id,
"object": "chat.completion.chunk",
"model": model,
"choices": [{
"index": 0,
"delta": {"content": token},
"finish_reason": None,
}],
"choices": [
{
"index": 0,
"delta": {"content": f"Error: {exc}"},
"finish_reason": "stop",
}
],
}
yield f"data: {json.dumps(chunk_data)}\n\n"
await asyncio.sleep(0.012)
yield f"data: {json.dumps(error_data)}\n\n"
yield "data: [DONE]\n\n"
return
# Handle tool calls: execute tools and loop for next turn
if tool_call_fragments and current_finish_reason == "tool_calls":
# Build the assistant message with tool_calls
sorted_tcs = [
tool_call_fragments[i] for i in sorted(tool_call_fragments.keys())
]
# Emit tool_calls metadata as SSE event
tool_meta = []
for tc in sorted_tcs:
tool_meta.append(
{
"tool_name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
}
)
yield (
f"event: tool_calls\ndata: {json.dumps({'calls': tool_meta})}\n\n"
)
# Add assistant message with tool_calls to conversation
from openjarvis.core.types import ToolCall as MsgToolCall
assistant_msg = Message(
role=Role.ASSISTANT,
content=turn_content or None,
tool_calls=[
MsgToolCall(
id=tc["id"],
name=tc["function"]["name"],
arguments=tc["function"]["arguments"],
)
for tc in sorted_tcs
],
)
messages_for_llm.append(assistant_msg)
# Execute each tool call and append results
for tc in sorted_tcs:
tool_name = tc["function"]["name"]
tool_args = tc["function"]["arguments"]
tool_result_content = f"Tool '{tool_name}' not available"
try:
# Try MCP adapter first (external tools)
mcp_adapter = mcp_adapters.get(tool_name)
if mcp_adapter is not None:
try:
parsed_args = json.loads(tool_args) if tool_args else {}
except (json.JSONDecodeError, TypeError):
parsed_args = {}
result = mcp_adapter.execute(**parsed_args)
tool_result_content = result.content
else:
# Try to use ToolExecutor if tools are configured
from openjarvis.core.registry import ToolRegistry
from openjarvis.tools._stubs import (
ToolCall as StubToolCall,
)
from openjarvis.tools._stubs import (
ToolExecutor,
)
tool_cls = ToolRegistry.get(tool_name)
if tool_cls is not None:
tool_instance = tool_cls()
executor = ToolExecutor(tools=[tool_instance], bus=bus)
result = executor.execute(
StubToolCall(
id=tc["id"],
name=tool_name,
arguments=tool_args,
),
)
tool_result_content = result.content
else:
logger.warning(
"Tool '%s' not found in registry or MCP adapters",
tool_name,
)
except Exception as tool_exc:
logger.error(
"Tool execution error for %s: %s",
tool_name,
tool_exc,
exc_info=True,
)
tool_result_content = f"Error executing {tool_name}: {tool_exc}"
# Emit tool result as SSE event
tool_event_data = json.dumps(
{"tool_name": tool_name, "output": tool_result_content}
)
yield (f"event: tool_result\ndata: {tool_event_data}\n\n")
# Add tool result message to conversation
messages_for_llm.append(
Message(
role=Role.TOOL,
content=tool_result_content,
tool_call_id=tc["id"],
name=tool_name,
)
)
# Continue to next turn (loop back to stream_full)
collected_content += turn_content
continue
# No tool calls — this is the final response
collected_content += turn_content
break
# Final chunk with finish_reason
final_data = {
"id": chunk_id,
"object": "chat.completion.chunk",
"model": model,
"choices": [{
"index": 0,
"delta": {},
"finish_reason": "stop",
}],
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop",
}
],
}
yield f"data: {json.dumps(final_data)}\n\n"
yield "data: [DONE]\n\n"
@@ -406,7 +776,9 @@ async def _stream_managed_agent(
manager.store_agent_response(agent_id, collected_content)
except Exception as store_exc:
logger.error(
"Failed to store agent response: %s", store_exc, exc_info=True,
"Failed to store agent response: %s",
store_exc,
exc_info=True,
)
return StreamingResponse(
@@ -493,7 +865,7 @@ def create_agent_manager_router(
return {"status": "idle"}
@agents_router.post("/{agent_id}/run")
async def run_agent(agent_id: str):
async def run_agent(agent_id: str, request: Request):
import threading
agent = manager.get_agent(agent_id)
@@ -501,30 +873,46 @@ def create_agent_manager_router(
raise HTTPException(status_code=404, detail="Agent not found")
if agent["status"] == "archived":
raise HTTPException(status_code=400, detail="Agent is archived")
if agent["status"] == "running":
# Auto-recover from error/needs_attention state
if agent["status"] in ("error", "needs_attention"):
manager.update_agent(agent_id, status="idle")
# Acquire tick BEFORE spawning thread — prevents race
try:
manager.start_tick(agent_id)
except ValueError:
raise HTTPException(status_code=409, detail="Agent is already running")
# Re-use the server's engine + model so we don't pick a
# random model from Ollama's list.
server_engine = getattr(request.app.state, "engine", None)
server_model = getattr(request.app.state, "model", "")
server_config = getattr(request.app.state, "config", None)
def _run_tick():
try:
from openjarvis.agents.executor import AgentExecutor
from openjarvis.core.events import get_event_bus
from openjarvis.system import SystemBuilder
executor = AgentExecutor(
manager=manager, event_bus=get_event_bus(),
manager=manager,
event_bus=get_event_bus(),
)
try:
system = SystemBuilder().build()
executor.set_system(system)
except Exception as build_err:
manager.update_agent(agent_id, status="error")
manager.update_summary_memory(
agent_id,
f"ERROR: Failed to build system: {build_err}",
)
return
system = _make_lightweight_system(
server_engine,
server_model,
server_config,
)
executor.set_system(system)
executor.execute_tick(agent_id)
except Exception as exc:
logger.error(
"Run-tick failed for agent %s: %s",
agent_id,
exc,
exc_info=True,
)
try:
manager.end_tick(agent_id)
except Exception:
@@ -618,10 +1006,84 @@ def create_agent_manager_router(
if not agent_record:
raise HTTPException(status_code=404, detail="Agent not found")
# Auto-recover error-state agents on immediate messages
if req.mode == "immediate" and agent_record["status"] in (
"error",
"needs_attention",
):
manager.update_agent(agent_id, status="idle")
# Store user message in DB (always, regardless of stream mode)
msg = manager.send_message(agent_id, req.content, mode=req.mode)
if not req.stream:
if not req.stream and req.mode != "immediate":
return msg
if not req.stream and req.mode == "immediate":
# Non-streaming immediate: trigger a background tick so the
# agent processes the message, then return the stored msg.
# Re-use the server's existing system (correct model/engine).
import threading
import time as _time
from openjarvis.agents.executor import AgentExecutor
from openjarvis.core.events import get_event_bus
_srv_engine = getattr(request.app.state, "engine", None)
_srv_model = getattr(request.app.state, "model", "")
_srv_config = getattr(request.app.state, "config", None)
def _immediate_tick():
_start = _time.time()
logger.info(
"Immediate tick starting for agent %s (model=%s)",
agent_id,
_srv_model,
)
try:
executor = AgentExecutor(
manager=manager,
event_bus=get_event_bus(),
)
system = _make_lightweight_system(
_srv_engine,
_srv_model,
_srv_config,
)
executor.set_system(system)
logger.info(
"Immediate tick: system ready in %.1fs, "
"executing tick for agent %s",
_time.time() - _start,
agent_id,
)
executor.execute_tick(agent_id)
logger.info(
"Immediate tick completed for agent %s in %.1fs",
agent_id,
_time.time() - _start,
)
except Exception as exc:
logger.error(
"Immediate tick failed for agent %s: %s",
agent_id,
exc,
exc_info=True,
)
try:
manager.end_tick(agent_id)
except Exception:
pass
manager.update_agent(agent_id, status="error")
manager.update_summary_memory(
agent_id,
f"ERROR: {exc}",
)
threading.Thread(
target=_immediate_tick,
daemon=True,
).start()
return msg
# --- Streaming mode: run agent and return SSE response ---
@@ -640,6 +1102,7 @@ def create_agent_manager_router(
message_id=msg["id"],
engine=engine,
bus=bus,
app_state=request.app.state,
)
# ── State inspection ─────────────────────────────────────
@@ -745,9 +1208,7 @@ def create_agent_manager_router(
@templates_router.post("/{template_id}/instantiate")
async def instantiate_template(template_id: str, req: CreateAgentRequest):
return manager.create_from_template(
template_id, req.name, overrides=req.config
)
return manager.create_from_template(template_id, req.name, overrides=req.config)
# ── Global agent endpoints ───────────────────────────────
@@ -774,13 +1235,42 @@ def create_agent_manager_router(
"by_status": dict(counts),
}
@global_router.get("/v1/recommended-model")
def recommended_model(request: Request):
engine = getattr(request.app.state, "engine", None)
if engine is None:
return {"model": "", "reason": "No engine available"}
try:
models = engine.list_models()
except Exception:
models = []
return _pick_recommended_model(models)
# ── Tools & credentials ──────────────────────────────────
tools_router = APIRouter(prefix="/v1/tools", tags=["tools"])
@tools_router.get("")
def list_tools():
return {"tools": build_tools_list()}
def list_tools(request: Request):
items = build_tools_list()
try:
mcp_tools, _ = _get_mcp_tools(request.app.state)
for tool in mcp_tools:
fn = tool.get("function", {})
items.append(
{
"name": fn.get("name", ""),
"description": fn.get("description", ""),
"category": "mcp",
"source": "mcp",
"requires_credentials": False,
"credential_keys": [],
"configured": True,
}
)
except Exception:
pass
return {"tools": items}
@tools_router.post("/{tool_name}/credentials")
async def save_tool_credentials(tool_name: str, request: Request):
@@ -796,6 +1286,7 @@ def create_agent_manager_router(
@tools_router.get("/{tool_name}/credentials/status")
def credential_status(tool_name: str):
from openjarvis.core.credentials import get_credential_status
return get_credential_status(tool_name)
return agents_router, templates_router, global_router, tools_router
+127 -55
View File
@@ -32,12 +32,14 @@ def _to_messages(chat_messages) -> list[Message]:
messages = []
for m in chat_messages:
role = Role(m.role) if m.role in {r.value for r in Role} else Role.USER
messages.append(Message(
role=role,
content=m.content or "",
name=m.name,
tool_call_id=m.tool_call_id,
))
messages.append(
Message(
role=role,
content=m.content or "",
name=m.name,
tool_call_id=m.tool_call_id,
)
)
return messages
@@ -75,7 +77,10 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
max_context_tokens=config.memory.context_max_tokens,
)
enriched = inject_context(
query_text, messages, memory_backend, config=ctx_cfg,
query_text,
messages,
memory_backend,
config=ctx_cfg,
)
# Rebuild request messages from enriched Message objects
if len(enriched) > len(messages):
@@ -83,16 +88,19 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
new_msgs = []
for msg in enriched:
new_msgs.append(ChatMessage(
role=msg.role.value,
content=msg.content,
name=msg.name,
tool_call_id=getattr(msg, "tool_call_id", None),
))
new_msgs.append(
ChatMessage(
role=msg.role.value,
content=msg.content,
name=msg.name,
tool_call_id=getattr(msg, "tool_call_id", None),
)
)
request_body.messages = new_msgs
except Exception:
logging.getLogger("openjarvis.server").debug(
"Memory context injection failed", exc_info=True,
"Memory context injection failed",
exc_info=True,
)
# Run complexity analysis on the last user message
@@ -111,7 +119,8 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
cr = score_complexity(query_text_for_complexity)
suggested = adjust_tokens_for_model(
cr.suggested_max_tokens, model,
cr.suggested_max_tokens,
model,
)
complexity_info = ComplexityInfo(
score=cr.score,
@@ -124,7 +133,8 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
request_body.max_tokens = suggested
except Exception:
logging.getLogger("openjarvis.server").debug(
"Complexity analysis failed", exc_info=True,
"Complexity analysis failed",
exc_info=True,
)
if request_body.stream:
@@ -143,13 +153,19 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
bus = getattr(request.app.state, "bus", None)
return _handle_direct(
engine, model, request_body,
bus=bus, complexity_info=complexity_info,
engine,
model,
request_body,
bus=bus,
complexity_info=complexity_info,
)
def _handle_direct(
engine, model: str, req: ChatCompletionRequest, bus=None,
engine,
model: str,
req: ChatCompletionRequest,
bus=None,
complexity_info=None,
) -> ChatCompletionResponse:
"""Direct engine call without agent."""
@@ -161,8 +177,12 @@ def _handle_direct(
from openjarvis.telemetry.wrapper import instrumented_generate
result = instrumented_generate(
engine, messages, model=model, bus=bus,
temperature=req.temperature, max_tokens=req.max_tokens,
engine,
messages,
model=model,
bus=bus,
temperature=req.temperature,
max_tokens=req.max_tokens,
**kwargs,
)
else:
@@ -194,10 +214,12 @@ def _handle_direct(
return ChatCompletionResponse(
model=model,
choices=[Choice(
message=choice_msg,
finish_reason=result.get("finish_reason", "stop"),
)],
choices=[
Choice(
message=choice_msg,
finish_reason=result.get("finish_reason", "stop"),
)
],
usage=UsageInfo(
prompt_tokens=usage.get("prompt_tokens", 0),
completion_tokens=usage.get("completion_tokens", 0),
@@ -208,7 +230,9 @@ def _handle_direct(
def _handle_agent(
agent, model: str, req: ChatCompletionRequest,
agent,
model: str,
req: ChatCompletionRequest,
complexity_info=None,
) -> ChatCompletionResponse:
"""Run through agent."""
@@ -241,10 +265,12 @@ def _handle_agent(
return ChatCompletionResponse(
model=model,
choices=[Choice(
message=ChoiceMessage(role="assistant", content=result.content),
finish_reason="stop",
)],
choices=[
Choice(
message=ChoiceMessage(role="assistant", content=result.content),
finish_reason="stop",
)
],
usage=usage,
complexity=complexity_info,
)
@@ -258,7 +284,9 @@ async def _handle_agent_stream(agent, bus, model, req):
async def _handle_stream(
engine, model: str, req: ChatCompletionRequest,
engine,
model: str,
req: ChatCompletionRequest,
complexity_info=None,
):
"""Stream response using SSE format."""
@@ -270,9 +298,11 @@ async def _handle_stream(
first_chunk = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[StreamChoice(
delta=DeltaMessage(role="assistant"),
)],
choices=[
StreamChoice(
delta=DeltaMessage(role="assistant"),
)
],
)
yield f"data: {first_chunk.model_dump_json()}\n\n"
@@ -287,27 +317,34 @@ async def _handle_stream(
chunk = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[StreamChoice(
delta=DeltaMessage(content=token),
)],
choices=[
StreamChoice(
delta=DeltaMessage(content=token),
)
],
)
yield f"data: {chunk.model_dump_json()}\n\n"
except Exception as exc:
# Surface errors as a content chunk so the frontend can
# display them instead of silently failing.
import logging
logging.getLogger("openjarvis.server").error(
"Stream error: %s", exc, exc_info=True,
"Stream error: %s",
exc,
exc_info=True,
)
error_chunk = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[StreamChoice(
delta=DeltaMessage(
content=f"\n\nError during generation: {exc}",
),
finish_reason="stop",
)],
choices=[
StreamChoice(
delta=DeltaMessage(
content=f"\n\nError during generation: {exc}",
),
finish_reason="stop",
)
],
)
yield f"data: {error_chunk.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
@@ -315,13 +352,16 @@ async def _handle_stream(
# Send finish chunk with usage data if available
import json as _json
finish_data = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[StreamChoice(
delta=DeltaMessage(),
finish_reason="stop",
)],
choices=[
StreamChoice(
delta=DeltaMessage(),
finish_reason="stop",
)
],
)
finish_dict = _json.loads(finish_data.model_dump_json())
@@ -456,18 +496,22 @@ async def savings(request: Request):
# Exclude cloud model tokens from savings — only local
# inference counts toward cost savings.
_cloud_prefixes = (
"gpt-", "o1-", "o3-", "o4-",
"claude-", "gemini-", "openrouter/",
"gpt-",
"o1-",
"o3-",
"o4-",
"claude-",
"gemini-",
"openrouter/",
)
local_models = [
m for m in summary.per_model
m
for m in summary.per_model
if not any(m.model_id.startswith(p) for p in _cloud_prefixes)
]
result = compute_savings(
prompt_tokens=sum(m.prompt_tokens for m in local_models),
completion_tokens=sum(
m.completion_tokens for m in local_models
),
completion_tokens=sum(m.completion_tokens for m in local_models),
total_calls=sum(m.call_count for m in local_models),
session_start=session_start if session_start else 0.0,
prompt_tokens_evaluated=sum(
@@ -557,7 +601,8 @@ async def channel_send(request: Request):
if not channel_name or not content:
raise HTTPException(
status_code=400, detail="'channel' and 'content' are required",
status_code=400,
detail="'channel' and 'content' are required",
)
ok = bridge.send(channel_name, content, conversation_id=conversation_id)
@@ -575,4 +620,31 @@ async def channel_status(request: Request):
return {"status": bridge.status().value}
# ---------------------------------------------------------------------------
# Security scan endpoint
# ---------------------------------------------------------------------------
@router.get("/v1/security/scan")
async def security_scan():
"""Run a read-only security environment audit and return findings."""
from openjarvis.cli.scan_cmd import PrivacyScanner
scanner = PrivacyScanner()
results = scanner.run_all()
return {
"has_warnings": any(r.status == "warn" for r in results),
"has_failures": any(r.status == "fail" for r in results),
"findings": [
{
"name": r.name,
"status": r.status,
"message": r.message,
"platform": r.platform,
}
for r in results
],
}
__all__ = ["router"]
+98 -38
View File
@@ -119,17 +119,15 @@ class AgentStreamBridge:
from openjarvis.core.types import Message, Role
for m in self._request.messages[:-1]:
role = (
Role(m.role)
if m.role in {r.value for r in Role}
else Role.USER
role = Role(m.role) if m.role in {r.value for r in Role} else Role.USER
ctx.conversation.add(
Message(
role=role,
content=m.content or "",
name=m.name,
tool_call_id=m.tool_call_id,
)
)
ctx.conversation.add(Message(
role=role,
content=m.content or "",
name=m.name,
tool_call_id=m.tool_call_id,
))
input_text = (
self._request.messages[-1].content if self._request.messages else ""
@@ -166,9 +164,11 @@ class AgentStreamBridge:
first_chunk = ChatCompletionChunk(
id=self._chunk_id,
model=self._model,
choices=[StreamChoice(
delta=DeltaMessage(role="assistant"),
)],
choices=[
StreamChoice(
delta=DeltaMessage(role="assistant"),
)
],
)
yield f"data: {first_chunk.model_dump_json()}\n\n"
@@ -202,18 +202,18 @@ class AgentStreamBridge:
"Please try a shorter message."
)
elif "400" in error_str:
error_content = (
f"The model returned an error: {error_str}"
)
error_content = f"The model returned an error: {error_str}"
else:
error_content = f"Sorry, an error occurred: {error_str}"
error_chunk = ChatCompletionChunk(
id=self._chunk_id,
model=self._model,
choices=[StreamChoice(
delta=DeltaMessage(content=error_content),
finish_reason="stop",
)],
choices=[
StreamChoice(
delta=DeltaMessage(content=error_content),
finish_reason="stop",
)
],
)
yield f"data: {error_chunk.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
@@ -222,31 +222,88 @@ class AgentStreamBridge:
# Emit tool results metadata if any
tool_results_data = []
for tr in agent_result.tool_results:
tool_results_data.append({
"tool_name": tr.tool_name,
"success": tr.success,
"output": tr.content,
"latency_ms": tr.latency_seconds * 1000,
})
tool_results_data.append(
{
"tool_name": tr.tool_name,
"success": tr.success,
"output": tr.content,
"latency_ms": tr.latency_seconds * 1000,
}
)
if tool_results_data:
yield self._format_named_event(
"tool_results", {"results": tool_results_data},
"tool_results",
{"results": tool_results_data},
)
# Stream content progressively (word-by-word) for a
# real-time feel, then send a final chunk with usage.
# Stream content using real LLM token streaming via
# engine.stream_full() when the engine is available.
content = agent_result.content or ""
if content:
engine = getattr(self._agent, "_engine", None)
used_real_streaming = False
if engine is not None and hasattr(engine, "stream_full") and content:
# Re-stream using the engine for real token delivery.
# Build the same messages the agent used for its final turn.
try:
from openjarvis.core.types import Message as MsgType
from openjarvis.core.types import Role as RoleType
replay_messages = []
for m in self._request.messages:
role = (
RoleType(m.role)
if m.role in {r.value for r in RoleType}
else RoleType.USER
)
replay_messages.append(
MsgType(
role=role,
content=m.content or "",
name=m.name,
tool_call_id=m.tool_call_id,
)
)
async for sc in engine.stream_full(
replay_messages,
model=self._model,
):
if sc.content:
chunk = ChatCompletionChunk(
id=self._chunk_id,
model=self._model,
choices=[
StreamChoice(
delta=DeltaMessage(content=sc.content),
)
],
)
yield f"data: {chunk.model_dump_json()}\n\n"
used_real_streaming = True
except Exception as stream_exc:
import logging as _logging
_logger = _logging.getLogger("openjarvis.server")
_logger.warning(
"Real streaming failed, falling back to word replay: %s",
stream_exc,
)
# Fallback: word-by-word replay if real streaming was not used
if not used_real_streaming and content:
words = content.split(" ")
for i, word in enumerate(words):
token = word if i == 0 else " " + word
chunk = ChatCompletionChunk(
id=self._chunk_id,
model=self._model,
choices=[StreamChoice(
delta=DeltaMessage(content=token),
)],
choices=[
StreamChoice(
delta=DeltaMessage(content=token),
)
],
)
yield f"data: {chunk.model_dump_json()}\n\n"
await asyncio.sleep(0.012)
@@ -254,7 +311,8 @@ class AgentStreamBridge:
# Final chunk: finish_reason + usage
prompt_tokens = agent_result.metadata.get("prompt_tokens", 0)
completion_tokens = agent_result.metadata.get(
"completion_tokens", 0,
"completion_tokens",
0,
)
total_tokens = agent_result.metadata.get("total_tokens", 0)
if total_tokens == 0:
@@ -266,10 +324,12 @@ class AgentStreamBridge:
final_chunk = ChatCompletionChunk(
id=self._chunk_id,
model=self._model,
choices=[StreamChoice(
delta=DeltaMessage(),
finish_reason="stop",
)],
choices=[
StreamChoice(
delta=DeltaMessage(),
finish_reason="stop",
)
],
)
final_data = json.loads(final_chunk.model_dump_json())
final_data["usage"] = UsageInfo(
+57 -7
View File
@@ -49,6 +49,7 @@ class JarvisSystem:
agent_executor: Optional[Any] = None # AgentExecutor
speech_backend: Optional[Any] = None # SpeechBackend
_learning_orchestrator: Optional[Any] = None # LearningOrchestrator
_mcp_clients: List = field(default_factory=list)
def ask(
self,
@@ -251,6 +252,7 @@ class JarvisSystem:
"tool_name": tr.tool_name,
"content": tr.content,
"success": tr.success,
"arguments": tr.metadata.get("arguments", {}),
}
for tr in getattr(result, "tool_results", [])
],
@@ -370,6 +372,14 @@ class JarvisSystem:
channel_bridge.on_message(_on_channel_message)
def _close_mcp_clients(self) -> None:
"""Close all persistent MCP client connections."""
for client in self._mcp_clients:
try:
client.close()
except Exception:
logger.debug("Error closing MCP client", exc_info=True)
def close(self) -> None:
"""Release resources."""
if self.scheduler and hasattr(self.scheduler, "stop"):
@@ -392,6 +402,7 @@ class JarvisSystem:
self.agent_manager.close()
if self.agent_scheduler is not None:
self.agent_scheduler.stop()
self._close_mcp_clients()
def __enter__(self) -> JarvisSystem:
return self
@@ -430,6 +441,7 @@ class SystemBuilder:
self._workflow: Optional[bool] = None
self._sessions: Optional[bool] = None
self._speech: Optional[bool] = None
self._mcp_clients: List = []
def engine(self, key: str) -> SystemBuilder:
self._engine_key = key
@@ -670,6 +682,8 @@ class SystemBuilder:
speech_backend=speech_backend,
)
system._learning_orchestrator = learning_orchestrator
# Transfer MCP clients so JarvisSystem.close() can shut them down
system._mcp_clients = list(getattr(self, "_mcp_clients", []))
# Wire system reference — must happen before scheduler.start()
if system.agent_executor is not None:
system.agent_executor.set_system(system)
@@ -1068,24 +1082,60 @@ class SystemBuilder:
logger.warning("Failed to set up learning orchestrator: %s", exc)
return None
@staticmethod
def _discover_external_mcp(server_cfg) -> List[BaseTool]:
"""Discover tools from an external MCP server configuration."""
def _discover_external_mcp(self, server_cfg) -> List[BaseTool]:
"""Discover tools from an external MCP server configuration.
Supports both stdio (command + args) and Streamable HTTP (url)
transports. Persists MCP clients on ``self._mcp_clients`` so
that transports stay alive for runtime tool calls.
"""
import json
from openjarvis.mcp.client import MCPClient
from openjarvis.mcp.transport import StdioTransport
from openjarvis.mcp.transport import StdioTransport, StreamableHTTPTransport
from openjarvis.tools.mcp_adapter import MCPToolProvider
cfg = json.loads(server_cfg) if isinstance(server_cfg, str) else server_cfg
name = cfg.get("name", "<unnamed>")
url = cfg.get("url")
command = cfg.get("command", "")
args = cfg.get("args", [])
if not command:
# Build transport based on config keys
if url:
transport = StreamableHTTPTransport(url=url)
elif command:
transport = StdioTransport(command=[command] + args)
else:
logger.warning(
"MCP server '%s' has neither 'url' nor 'command' — skipping",
name,
)
return []
transport = StdioTransport(command=command, args=args)
client = MCPClient(transport)
client.initialize()
# Persist client so the transport stays alive for tool calls
self._mcp_clients.append(client)
provider = MCPToolProvider(client)
return provider.discover()
discovered = provider.discover()
# Per-server tool filtering
include_tools = set(cfg.get("include_tools", []))
exclude_tools = set(cfg.get("exclude_tools", []))
if include_tools:
discovered = [t for t in discovered if t.spec.name in include_tools]
if exclude_tools:
discovered = [t for t in discovered if t.spec.name not in exclude_tools]
logger.info(
"Discovered %d tools from MCP server '%s'",
len(discovered),
name,
)
return discovered
__all__ = ["JarvisSystem", "SystemBuilder"]
+81 -30
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
import statistics
import time
from collections.abc import AsyncIterator
from typing import Any, Dict, List, Optional, Sequence
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.types import Message, TelemetryRecord
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.engine._stubs import InferenceEngine, StreamChunk
from openjarvis.telemetry.gpu_monitor import GpuSample
# ---------------------------------------------------------------------------
@@ -30,8 +31,14 @@ def _percentile(data: list[float], p: float) -> float:
def _compute_itl_stats(itl_values_ms: list[float]) -> dict:
"""Compute ITL summary statistics from a list of inter-token latencies in ms."""
if not itl_values_ms:
return {"mean": 0.0, "median": 0.0, "p90": 0.0,
"p95": 0.0, "p99": 0.0, "std": 0.0}
return {
"mean": 0.0,
"median": 0.0,
"p90": 0.0,
"p95": 0.0,
"p99": 0.0,
"std": 0.0,
}
return {
"mean": statistics.mean(itl_values_ms),
"median": statistics.median(itl_values_ms),
@@ -78,9 +85,13 @@ class InstrumentedEngine(InferenceEngine):
**kwargs: Any,
) -> Dict[str, Any]:
"""Generate with telemetry recording."""
self._bus.publish(EventType.INFERENCE_START, {
"model": model, "message_count": len(messages),
})
self._bus.publish(
EventType.INFERENCE_START,
{
"model": model,
"message_count": len(messages),
},
)
gpu_sample: Optional[GpuSample] = None
energy_sample: Optional[Any] = None
@@ -90,19 +101,28 @@ class InstrumentedEngine(InferenceEngine):
if self._energy_monitor is not None:
with self._energy_monitor.sample() as energy_sample:
result = self._inner.generate(
messages, model=model, temperature=temperature,
max_tokens=max_tokens, **kwargs,
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
)
elif self._gpu_monitor is not None:
with self._gpu_monitor.sample() as gpu_sample:
result = self._inner.generate(
messages, model=model, temperature=temperature,
max_tokens=max_tokens, **kwargs,
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
)
else:
result = self._inner.generate(
messages, model=model, temperature=temperature,
max_tokens=max_tokens, **kwargs,
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
)
latency = time.time() - t0
@@ -155,9 +175,7 @@ class InstrumentedEngine(InferenceEngine):
energy_per_output_token = (
energy_joules / completion_tokens if completion_tokens > 0 else 0.0
)
throughput_per_watt = (
throughput / power_watts if power_watts > 0 else 0.0
)
throughput_per_watt = throughput / power_watts if power_watts > 0 else 0.0
# --- Tier 2.1: Phase energy split ---
decode_latency = latency - prefill_latency if prefill_latency > 0 else 0.0
@@ -171,13 +189,15 @@ class InstrumentedEngine(InferenceEngine):
# --- Tier 3: Non-streaming mean ITL approximation ---
mean_itl_ms = (
(decode_latency / completion_tokens) * 1000
if completion_tokens > 0 and decode_latency > 0 else 0.0
if completion_tokens > 0 and decode_latency > 0
else 0.0
)
# --- Tier 4: Per-inference efficiency ---
tokens_per_joule = (
completion_tokens / energy_joules
if energy_joules > 0 and completion_tokens > 0 else 0.0
if energy_joules > 0 and completion_tokens > 0
else 0.0
)
engine_id = getattr(self._inner, "engine_id", "unknown")
@@ -275,9 +295,13 @@ class InstrumentedEngine(InferenceEngine):
**kwargs: Any,
) -> Any:
"""Stream with per-token timing and full telemetry recording."""
self._bus.publish(EventType.INFERENCE_START, {
"model": model, "message_count": len(messages),
})
self._bus.publish(
EventType.INFERENCE_START,
{
"model": model,
"message_count": len(messages),
},
)
t0 = time.time()
token_timestamps: list[float] = []
@@ -289,8 +313,11 @@ class InstrumentedEngine(InferenceEngine):
if self._energy_monitor is not None:
with self._energy_monitor.sample() as energy_sample:
async for token in self._inner.stream(
messages, model=model, temperature=temperature,
max_tokens=max_tokens, **kwargs,
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
):
token_timestamps.append(time.time())
token_count += 1
@@ -298,16 +325,22 @@ class InstrumentedEngine(InferenceEngine):
elif self._gpu_monitor is not None:
with self._gpu_monitor.sample() as gpu_sample:
async for token in self._inner.stream(
messages, model=model, temperature=temperature,
max_tokens=max_tokens, **kwargs,
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
):
token_timestamps.append(time.time())
token_count += 1
yield token
else:
async for token in self._inner.stream(
messages, model=model, temperature=temperature,
max_tokens=max_tokens, **kwargs,
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
):
token_timestamps.append(time.time())
token_count += 1
@@ -362,9 +395,7 @@ class InstrumentedEngine(InferenceEngine):
energy_per_output_token = (
energy_joules / token_count if token_count > 0 else 0.0
)
throughput_per_watt = (
throughput / power_watts if power_watts > 0 else 0.0
)
throughput_per_watt = throughput / power_watts if power_watts > 0 else 0.0
# Phase energy split
decode_latency = latency - prefill_latency if prefill_latency > 0 else 0.0
@@ -378,7 +409,8 @@ class InstrumentedEngine(InferenceEngine):
# Per-inference efficiency
tokens_per_joule = (
token_count / energy_joules
if energy_joules > 0 and token_count > 0 else 0.0
if energy_joules > 0 and token_count > 0
else 0.0
)
engine_id = getattr(self._inner, "engine_id", "unknown")
@@ -436,6 +468,25 @@ class InstrumentedEngine(InferenceEngine):
self._bus.publish(EventType.INFERENCE_END, event_data)
self._bus.publish(EventType.TELEMETRY_RECORD, {"record": record})
async def stream_full(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
max_tokens: int = 1024,
**kwargs: Any,
) -> AsyncIterator["StreamChunk"]:
"""Delegate to inner engine's stream_full for tool-call support."""
async for chunk in self._inner.stream_full(
messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
):
yield chunk
def list_models(self) -> List[str]:
return self._inner.list_models()
+1
View File
@@ -241,6 +241,7 @@ class ToolExecutor:
)
latency = time.time() - t0
result.latency_seconds = latency
result.metadata["arguments"] = params
# Auto-detect taints in results
if result.success:
+9 -4
View File
@@ -89,10 +89,15 @@ def _safe_eval_node(node: ast.AST) -> Any:
def safe_eval(expression: str) -> float:
"""Evaluate a math expression safely — always via Rust backend."""
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
return float(_rust.CalculatorTool().execute(expression))
"""Evaluate a math expression safely — Rust backend with Python fallback."""
try:
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
return float(_rust.CalculatorTool().execute(expression))
except ImportError:
import ast as _ast
tree = _ast.parse(expression, mode="eval")
return float(_safe_eval_node(tree.body))
@ToolRegistry.register("calculator")
+7 -2
View File
@@ -114,10 +114,15 @@ class FileReadTool(BaseTool):
content=f"File too large: {size} bytes (max {_MAX_SIZE_BYTES}).",
success=False,
)
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
try:
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
text = _rust.FileReadTool().execute(str(path))
except ImportError:
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
text = path.read_text(encoding="utf-8", errors="replace")
except Exception as exc:
return ToolResult(
tool_name="file_read",
+11 -11
View File
@@ -155,26 +155,26 @@ class FileWriteTool(BaseTool):
success=False,
)
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
if mode == "write":
try:
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
_rust.FileWriteTool().execute(str(path), content)
except ImportError:
try:
path.write_text(content, encoding="utf-8")
except OSError as exc:
return ToolResult(
tool_name="file_write",
content=f"Write error: {exc}",
success=False,
)
except Exception as exc:
return ToolResult(
tool_name="file_write",
content=f"Write error: {exc}",
success=False,
)
elif False: # dead code — all write modes go through Rust
try:
path.write_text(content, encoding="utf-8")
except OSError as exc:
return ToolResult(
tool_name="file_write",
content=f"Write error: {exc}",
success=False,
)
else:
# append mode — always Python
try:
+7 -3
View File
@@ -103,9 +103,13 @@ class HttpRequestTool(BaseTool):
body = params.get("body")
timeout = params.get("timeout", 30)
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
if not headers:
_rust = None
try:
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
except ImportError:
pass
if _rust is not None and not headers:
try:
content = _rust.HttpRequestTool().execute(url, method, body)
return ToolResult(
+27 -26
View File
@@ -125,32 +125,33 @@ class ShellExecTool(BaseTool):
if val is not None:
env[key] = val
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
if True:
try:
output = _rust.ShellExecTool().execute(command, working_dir)
return ToolResult(
tool_name="shell_exec",
content=output or "(no output)",
success=True,
metadata={
"returncode": 0,
"timeout_used": timeout,
"working_dir": working_dir,
},
)
except Exception as exc:
return ToolResult(
tool_name="shell_exec",
content=str(exc),
success=False,
metadata={
"returncode": -1,
"timeout_used": timeout,
"working_dir": working_dir,
},
)
try:
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
output = _rust.ShellExecTool().execute(command, working_dir)
return ToolResult(
tool_name="shell_exec",
content=output or "(no output)",
success=True,
metadata={
"returncode": 0,
"timeout_used": timeout,
"working_dir": working_dir,
},
)
except ImportError:
pass # Fall through to subprocess below
except Exception as exc:
return ToolResult(
tool_name="shell_exec",
content=str(exc),
success=False,
metadata={
"returncode": -1,
"timeout_used": timeout,
"working_dir": working_dir,
},
)
try:
result = subprocess.run(
command,
+47
View File
@@ -0,0 +1,47 @@
"""Shared fixtures for agent tests."""
from __future__ import annotations
import pytest
from openjarvis.agents.executor import AgentExecutor
from openjarvis.agents.manager import AgentManager
from openjarvis.agents.scheduler import AgentScheduler
from openjarvis.core.events import EventBus
from tests.agents.fake_engine import FakeEngine
from tests.agents.scenario_harness import FakeSystem, ScenarioHarness
@pytest.fixture
def scenario_harness(tmp_path):
"""Wire up real components for agent lifecycle testing."""
from openjarvis.agents.monitor_operative import MonitorOperativeAgent
from openjarvis.core.registry import AgentRegistry
# Re-register agent types (conftest auto-clears registries)
if not AgentRegistry.contains("monitor_operative"):
AgentRegistry.register("monitor_operative")(MonitorOperativeAgent)
db_path = str(tmp_path / "agents.db")
bus = EventBus(record_history=True)
manager = AgentManager(db_path=db_path)
engine = FakeEngine([{"content": "Default response."}])
system = FakeSystem(engine=engine)
executor = AgentExecutor(manager=manager, event_bus=bus)
executor.set_system(system)
scheduler = AgentScheduler(
manager=manager, executor=executor, event_bus=bus,
)
return ScenarioHarness(
manager=manager,
executor=executor,
scheduler=scheduler,
bus=bus,
engine=engine,
system=system,
db_path=db_path,
)
+70
View File
@@ -0,0 +1,70 @@
"""Scripted inference engine for deterministic agent testing."""
from __future__ import annotations
from typing import Any, AsyncIterator, Dict, List
from openjarvis.engine._stubs import InferenceEngine
class FakeEngine(InferenceEngine):
"""Returns pre-defined responses in order. Captures prompts for assertions."""
engine_id = "fake"
def __init__(self, responses: list[dict]) -> None:
self._responses = list(responses)
self._call_count = 0
self._last_messages: list | None = None
@property
def call_count(self) -> int:
return self._call_count
@property
def last_messages(self) -> list | None:
return self._last_messages
def generate(
self,
messages: list,
*,
model: str,
temperature: float = 0.7,
max_tokens: int = 1024,
**kw: Any,
) -> Dict[str, Any]:
self._last_messages = messages
idx = min(self._call_count, len(self._responses) - 1)
resp = self._responses[idx]
self._call_count += 1
# Support raising exceptions for error testing
if "raise" in resp:
raise resp["raise"]
result: Dict[str, Any] = {
"content": resp.get("content", ""),
"usage": resp.get("usage", {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
}),
"model": model,
"finish_reason": "tool_calls" if resp.get("tool_calls") else "stop",
}
if resp.get("tool_calls"):
result["tool_calls"] = resp["tool_calls"]
return result
async def stream(
self, messages: list, *, model: str, **kw: Any,
) -> AsyncIterator[str]:
result = self.generate(messages, model=model, **kw)
yield result["content"]
def list_models(self) -> List[str]:
return ["fake-model"]
def health(self) -> bool:
return True
+38
View File
@@ -0,0 +1,38 @@
"""Shared harness dataclasses for agent lifecycle scenario tests."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from openjarvis.agents.executor import AgentExecutor
from openjarvis.agents.manager import AgentManager
from openjarvis.agents.scheduler import AgentScheduler
from openjarvis.core.events import EventBus
from tests.agents.fake_engine import FakeEngine
@dataclass(slots=True)
class FakeSystem:
"""Lightweight stand-in for JarvisSystem — just engine + model."""
engine: FakeEngine
model: str = "fake-model"
memory_backend: Any = None
channel_backend: Any = None
tools: list = field(default_factory=list)
config: Any = None
session_store: Any = None
@dataclass(slots=True)
class ScenarioHarness:
"""All components needed for an agent lifecycle scenario test."""
manager: AgentManager
executor: AgentExecutor
scheduler: AgentScheduler
bus: EventBus
engine: FakeEngine
system: FakeSystem
db_path: str
@@ -0,0 +1,538 @@
"""Agent lifecycle scenario tests.
Twelve+ scenarios exercising the full managed-agent stack (manager, executor,
scheduler) with real SQLite state and a scripted FakeEngine.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from openjarvis.agents.errors import RetryableError
from openjarvis.core.events import EventType
from tests.agents.scenario_harness import ScenarioHarness
# ---------------------------------------------------------------------------
# Scenario 1: Manual agent full lifecycle
# ---------------------------------------------------------------------------
def test_manual_agent_full_lifecycle(scenario_harness: ScenarioHarness) -> None:
"""Create a manual agent, run a tick, verify status/runs/memory."""
h = scenario_harness
h.engine._responses = [{"content": "Tick 1 completed."}]
h.engine._call_count = 0
agent = h.manager.create_agent(
name="manual-agent",
config={"schedule_type": "manual", "instruction": "Do something."},
)
aid = agent["id"]
h.executor.execute_tick(aid)
agent = h.manager.get_agent(aid)
assert agent is not None
assert agent["status"] == "idle"
assert agent["total_runs"] == 1
assert "Tick 1 completed." in agent["summary_memory"]
# ---------------------------------------------------------------------------
# Scenario 2: Interval-scheduled agent
# ---------------------------------------------------------------------------
def test_interval_scheduled_agent(scenario_harness: ScenarioHarness) -> None:
"""Interval agent does not fire until time advances past interval."""
h = scenario_harness
h.engine._responses = [{"content": "Interval response."}]
h.engine._call_count = 0
base_time = 1_000_000.0
agent = h.manager.create_agent(
name="interval-agent",
config={
"schedule_type": "interval",
"schedule_value": 60,
"instruction": "Check status.",
},
)
aid = agent["id"]
# Register at base_time — next_fire = base_time + 60
with patch("openjarvis.agents.scheduler.time") as mock_time:
mock_time.time.return_value = base_time
h.scheduler.register_agent(aid)
info = h.scheduler._agents[aid]
assert info["next_fire"] == pytest.approx(base_time + 60, abs=1)
# At base_time + 30 the agent should NOT fire
with patch("openjarvis.agents.scheduler.time") as mock_time:
mock_time.time.return_value = base_time + 30
h.scheduler._check_due_agents()
agent = h.manager.get_agent(aid)
assert agent is not None
assert agent["total_runs"] == 0
# At base_time + 61 the agent SHOULD fire
with patch("openjarvis.agents.scheduler.time") as mock_time:
mock_time.time.return_value = base_time + 61
h.scheduler._check_due_agents()
agent = h.manager.get_agent(aid)
assert agent is not None
assert agent["total_runs"] == 1
assert "Interval response." in agent["summary_memory"]
# ---------------------------------------------------------------------------
# Scenario 3: Cron-scheduled agent
# ---------------------------------------------------------------------------
def test_cron_scheduled_agent(scenario_harness: ScenarioHarness) -> None:
"""Cron agent gets next_fire set correctly."""
h = scenario_harness
agent = h.manager.create_agent(
name="cron-agent",
config={
"schedule_type": "cron",
"schedule_value": "0 9 * * *",
"instruction": "Daily check.",
},
)
aid = agent["id"]
h.scheduler.register_agent(aid)
info = h.scheduler._agents[aid]
assert info["schedule_type"] == "cron"
# next_fire should be in the future
import time
assert info["next_fire"] > time.time()
# ---------------------------------------------------------------------------
# Scenario 4: Queued message delivery
# ---------------------------------------------------------------------------
def test_queued_message_delivery(scenario_harness: ScenarioHarness) -> None:
"""Queue 3 messages, run tick, verify all delivered and in prompt."""
h = scenario_harness
h.engine._responses = [{"content": "Processed all messages."}]
h.engine._call_count = 0
agent = h.manager.create_agent(
name="msg-agent",
config={"schedule_type": "manual", "instruction": "Handle messages."},
)
aid = agent["id"]
h.manager.send_message(aid, "Message one", mode="queued")
h.manager.send_message(aid, "Message two", mode="queued")
h.manager.send_message(aid, "Message three", mode="queued")
# Verify 3 pending before tick
pending = h.manager.get_pending_messages(aid)
assert len(pending) == 3
h.executor.execute_tick(aid)
# All should be delivered
pending = h.manager.get_pending_messages(aid)
assert len(pending) == 0
# Engine should have been called with messages in the prompt
assert h.engine.last_messages is not None
prompt_text = " ".join(
str(getattr(m, "content", m)) for m in h.engine.last_messages
)
assert "Message one" in prompt_text
assert "Message two" in prompt_text
assert "Message three" in prompt_text
# Response stored
agent = h.manager.get_agent(aid)
assert agent is not None
assert "Processed all messages." in agent["summary_memory"]
# ---------------------------------------------------------------------------
# Scenario 5: Immediate message
# ---------------------------------------------------------------------------
def test_immediate_message(scenario_harness: ScenarioHarness) -> None:
"""Send an immediate message, run tick, assert response stored."""
h = scenario_harness
h.engine._responses = [{"content": "Immediate reply."}]
h.engine._call_count = 0
agent = h.manager.create_agent(
name="imm-agent",
config={"schedule_type": "manual", "instruction": "Reply to user."},
)
aid = agent["id"]
h.manager.send_message(aid, "Urgent question", mode="immediate")
h.executor.execute_tick(aid)
# The response should be stored
messages = h.manager.list_messages(aid)
responses = [m for m in messages if m["direction"] == "agent_to_user"]
assert len(responses) >= 1
assert any("Immediate reply." in r["content"] for r in responses)
# ---------------------------------------------------------------------------
# Scenario 6: Budget enforcement
# ---------------------------------------------------------------------------
def test_budget_enforcement(scenario_harness: ScenarioHarness) -> None:
"""Agent with max_cost runs tick, total_runs incremented, stays idle
because cost is below threshold."""
h = scenario_harness
# The default usage in FakeEngine reports 0 cost (no cost key in metadata),
# so total_cost stays at 0 which is below max_cost=0.01.
h.engine._responses = [{"content": "Budget check."}]
h.engine._call_count = 0
agent = h.manager.create_agent(
name="budget-agent",
config={
"schedule_type": "manual",
"max_cost": 0.01,
"instruction": "Cheap task.",
},
)
aid = agent["id"]
h.executor.execute_tick(aid)
agent = h.manager.get_agent(aid)
assert agent is not None
assert agent["total_runs"] == 1
# Cost is 0 (FakeEngine doesn't report cost), so stays idle
assert agent["status"] == "idle"
# ---------------------------------------------------------------------------
# Scenario 7: Error + retry (success on 3rd attempt)
# ---------------------------------------------------------------------------
def test_error_retry_success(scenario_harness: ScenarioHarness) -> None:
"""FakeEngine raises RetryableError first 2 times, succeeds on 3rd."""
h = scenario_harness
h.engine._responses = [
{"raise": RetryableError("transient-1")},
{"raise": RetryableError("transient-2")},
{"content": "Success after retries."},
]
h.engine._call_count = 0
agent = h.manager.create_agent(
name="retry-agent",
config={"schedule_type": "manual", "instruction": "Retry test."},
)
aid = agent["id"]
# Patch retry_delay to avoid real sleeps
with patch("openjarvis.agents.executor.time.sleep"):
h.executor.execute_tick(aid)
assert h.engine.call_count == 3
agent = h.manager.get_agent(aid)
assert agent is not None
assert agent["status"] == "idle"
assert "Success after retries." in agent["summary_memory"]
# ---------------------------------------------------------------------------
# Scenario 7b: Error exhaustion
# ---------------------------------------------------------------------------
def test_error_exhaustion(scenario_harness: ScenarioHarness) -> None:
"""FakeEngine always raises RetryableError. Agent ends in error state."""
h = scenario_harness
h.engine._responses = [
{"raise": RetryableError("fail-1")},
{"raise": RetryableError("fail-2")},
{"raise": RetryableError("fail-3")},
]
h.engine._call_count = 0
agent = h.manager.create_agent(
name="exhaust-agent",
config={"schedule_type": "manual", "instruction": "Always fail."},
)
aid = agent["id"]
with patch("openjarvis.agents.executor.time.sleep"):
h.executor.execute_tick(aid)
agent = h.manager.get_agent(aid)
assert agent is not None
assert agent["status"] == "error"
# ---------------------------------------------------------------------------
# Scenario 8: Stall detection + recovery
# ---------------------------------------------------------------------------
def test_stall_detection_and_recovery(scenario_harness: ScenarioHarness) -> None:
"""Set agent to running with stale last_activity, reconcile detects stall,
then recover resets to idle."""
h = scenario_harness
import time as real_time
agent = h.manager.create_agent(
name="stall-agent",
config={
"schedule_type": "manual",
"timeout_seconds": 300,
"max_stall_retries": 5,
"instruction": "Long task.",
},
)
aid = agent["id"]
# Simulate: agent is running with stale activity
h.manager.start_tick(aid)
ten_minutes_ago = real_time.time() - 600
h.manager.update_agent(aid, last_activity_at=ten_minutes_ago)
# Reconcile should detect the stall
h.scheduler._reconcile()
agent = h.manager.get_agent(aid)
assert agent is not None
# Stall detection should have released the concurrency guard (end_tick)
# and incremented stall_retries
assert agent["status"] == "idle"
assert agent["stall_retries"] == 1
# Verify stall event was published
stall_events = [
e for e in h.bus.history if e.event_type == EventType.AGENT_STALL_DETECTED
]
assert len(stall_events) >= 1
assert stall_events[0].data["agent_id"] == aid
# Now recover the agent explicitly
h.manager.recover_agent(aid)
agent = h.manager.get_agent(aid)
assert agent is not None
assert agent["status"] == "idle"
# ---------------------------------------------------------------------------
# Scenario 9: Pause / resume
# ---------------------------------------------------------------------------
def test_pause_resume(scenario_harness: ScenarioHarness) -> None:
"""Paused agent does not fire; resumed agent fires normally."""
h = scenario_harness
h.engine._responses = [{"content": "Resumed response."}]
h.engine._call_count = 0
base_time = 2_000_000.0
agent = h.manager.create_agent(
name="pause-agent",
config={
"schedule_type": "interval",
"schedule_value": 60,
"instruction": "Pause test.",
},
)
aid = agent["id"]
# Register at base_time
with patch("openjarvis.agents.scheduler.time") as mock_time:
mock_time.time.return_value = base_time
h.scheduler.register_agent(aid)
# Pause the agent
h.manager.pause_agent(aid)
# Advance past interval — should NOT fire because paused
with patch("openjarvis.agents.scheduler.time") as mock_time:
mock_time.time.return_value = base_time + 61
h.scheduler._check_due_agents()
agent = h.manager.get_agent(aid)
assert agent is not None
assert agent["total_runs"] == 0
# Resume
h.manager.resume_agent(aid)
# Now advance again — should fire
with patch("openjarvis.agents.scheduler.time") as mock_time:
mock_time.time.return_value = base_time + 61
h.scheduler._check_due_agents()
agent = h.manager.get_agent(aid)
assert agent is not None
assert agent["total_runs"] == 1
# ---------------------------------------------------------------------------
# Scenario 10: Multi-agent concurrent scheduling
# ---------------------------------------------------------------------------
def test_multi_agent_scheduling(scenario_harness: ScenarioHarness) -> None:
"""Three agents with different intervals fire at the right times."""
h = scenario_harness
h.engine._responses = [{"content": "Agent response."}]
h.engine._call_count = 0
base_time = 3_000_000.0
agents = []
for i, interval in enumerate([30, 60, 120]):
a = h.manager.create_agent(
name=f"multi-{i}",
config={
"schedule_type": "interval",
"schedule_value": interval,
"instruction": f"Task {i}.",
},
)
agents.append(a)
with patch("openjarvis.agents.scheduler.time") as mock_time:
mock_time.time.return_value = base_time
h.scheduler.register_agent(a["id"])
# At base_time + 35, only agent 0 (30s interval) should fire
h.engine._call_count = 0
with patch("openjarvis.agents.scheduler.time") as mock_time:
mock_time.time.return_value = base_time + 35
h.scheduler._check_due_agents()
a0 = h.manager.get_agent(agents[0]["id"])
a1 = h.manager.get_agent(agents[1]["id"])
a2 = h.manager.get_agent(agents[2]["id"])
assert a0 is not None and a0["total_runs"] == 1
assert a1 is not None and a1["total_runs"] == 0
assert a2 is not None and a2["total_runs"] == 0
# At base_time + 65, agents 0 and 1 should have fired (but agent 0
# next_fire was reset to base_time + 35 + 30 = base_time + 65, so it
# fires again at exactly 65)
h.engine._responses = [{"content": "Agent response again."}]
h.engine._call_count = 0
with patch("openjarvis.agents.scheduler.time") as mock_time:
mock_time.time.return_value = base_time + 65
h.scheduler._check_due_agents()
a0 = h.manager.get_agent(agents[0]["id"])
a1 = h.manager.get_agent(agents[1]["id"])
a2 = h.manager.get_agent(agents[2]["id"])
assert a0 is not None and a0["total_runs"] == 2
assert a1 is not None and a1["total_runs"] == 1
assert a2 is not None and a2["total_runs"] == 0
# At base_time + 125, all three should have fired
h.engine._responses = [{"content": "All fired."}]
h.engine._call_count = 0
with patch("openjarvis.agents.scheduler.time") as mock_time:
mock_time.time.return_value = base_time + 125
h.scheduler._check_due_agents()
a0 = h.manager.get_agent(agents[0]["id"])
a1 = h.manager.get_agent(agents[1]["id"])
a2 = h.manager.get_agent(agents[2]["id"])
assert a0 is not None and a0["total_runs"] >= 3
assert a1 is not None and a1["total_runs"] >= 2
assert a2 is not None and a2["total_runs"] == 1
# ---------------------------------------------------------------------------
# Scenario 11: Template instantiation
# ---------------------------------------------------------------------------
def test_template_instantiation(scenario_harness: ScenarioHarness) -> None:
"""Each built-in template creates an agent with correct config fields."""
h = scenario_harness
templates = h.manager.list_templates()
for tpl in templates:
tpl_id = tpl.get("id")
if not tpl_id:
continue
agent = h.manager.create_from_template(tpl_id, name=f"from-{tpl_id}")
config = agent["config"]
# Every template has a schedule_type and schedule_value
assert "schedule_type" in config
assert "schedule_value" in config
# Every template has a system_prompt
assert "system_prompt" in config
# Agent type should be set
assert agent["agent_type"] == tpl.get("agent_type", "monitor_operative")
# ---------------------------------------------------------------------------
# Scenario 12: Memory persistence across ticks
# ---------------------------------------------------------------------------
def test_memory_persistence_across_ticks(scenario_harness: ScenarioHarness) -> None:
"""Tick 1 summary becomes part of tick 2 engine prompt (Previous context)."""
h = scenario_harness
h.engine._responses = [{"content": "Findings from tick one."}]
h.engine._call_count = 0
agent = h.manager.create_agent(
name="memory-agent",
config={
"schedule_type": "manual",
"instruction": "Investigate topic X.",
},
)
aid = agent["id"]
# --- Tick 1 ---
h.executor.execute_tick(aid)
agent = h.manager.get_agent(aid)
assert agent is not None
assert "Findings from tick one." in agent["summary_memory"]
# --- Tick 2 ---
h.engine._responses = [{"content": "Tick two builds on prior context."}]
h.engine._call_count = 0
h.executor.execute_tick(aid)
# The engine should have received the tick-1 summary in its prompt
assert h.engine.last_messages is not None
prompt_text = " ".join(
str(getattr(m, "content", m)) for m in h.engine.last_messages
)
assert "Findings from tick one." in prompt_text
# Tick 2 summary should now be stored
agent = h.manager.get_agent(aid)
assert agent is not None
assert "Tick two builds on prior context." in agent["summary_memory"]
+104
View File
@@ -0,0 +1,104 @@
"""Tests for tool wiring in AgentExecutor."""
from __future__ import annotations
from openjarvis.agents.executor import AgentExecutor
from openjarvis.agents.manager import AgentManager
from openjarvis.core.events import EventBus
from tests.agents.fake_engine import FakeEngine
from tests.agents.scenario_harness import FakeSystem
def _register_agent():
"""Re-register MonitorOperativeAgent (cleared by autouse fixture)."""
from openjarvis.agents.monitor_operative import MonitorOperativeAgent
from openjarvis.core.registry import AgentRegistry
if not AgentRegistry.contains("monitor_operative"):
AgentRegistry.register("monitor_operative")(MonitorOperativeAgent)
def test_executor_runs_with_tools_from_config(tmp_path):
"""Executor should resolve tool names from config and complete tick."""
_register_agent()
engine = FakeEngine([{"content": "test response"}])
system = FakeSystem(engine=engine)
mgr = AgentManager(db_path=str(tmp_path / "test.db"))
agent = mgr.create_agent(
"test",
agent_type="monitor_operative",
config={
"system_prompt": "You are a test agent.",
"tools": ["think"],
"instruction": "test",
},
)
mgr.send_message(agent["id"], "hello", mode="immediate")
executor = AgentExecutor(manager=mgr, event_bus=EventBus())
executor.set_system(system)
executor.execute_tick(agent["id"])
result_agent = mgr.get_agent(agent["id"])
assert result_agent["status"] == "idle"
assert result_agent["total_runs"] == 1
mgr.close()
def test_executor_handles_missing_tools(tmp_path):
"""Executor should not crash if tool names don't exist in registry."""
_register_agent()
engine = FakeEngine([{"content": "test response"}])
system = FakeSystem(engine=engine)
mgr = AgentManager(db_path=str(tmp_path / "test.db"))
agent = mgr.create_agent(
"test",
agent_type="monitor_operative",
config={
"system_prompt": "You are a test agent.",
"tools": ["nonexistent_tool_xyz"],
"instruction": "test",
},
)
mgr.send_message(agent["id"], "hello", mode="immediate")
executor = AgentExecutor(manager=mgr, event_bus=EventBus())
executor.set_system(system)
executor.execute_tick(agent["id"])
result_agent = mgr.get_agent(agent["id"])
assert result_agent["status"] == "idle"
assert result_agent["total_runs"] == 1
mgr.close()
def test_executor_handles_string_tools(tmp_path):
"""Executor should handle comma-separated tool string as well as list."""
_register_agent()
engine = FakeEngine([{"content": "test response"}])
system = FakeSystem(engine=engine)
mgr = AgentManager(db_path=str(tmp_path / "test.db"))
agent = mgr.create_agent(
"test",
agent_type="monitor_operative",
config={
"system_prompt": "You are a test agent.",
"tools": "think,calculator",
"instruction": "test",
},
)
mgr.send_message(agent["id"], "hello", mode="immediate")
executor = AgentExecutor(manager=mgr, event_bus=EventBus())
executor.set_system(system)
executor.execute_tick(agent["id"])
result_agent = mgr.get_agent(agent["id"])
assert result_agent["status"] == "idle"
mgr.close()
+42
View File
@@ -0,0 +1,42 @@
"""Tests for FakeEngine."""
from __future__ import annotations
import pytest
from tests.agents.fake_engine import FakeEngine
def test_fake_engine_returns_responses_in_order():
engine = FakeEngine([
{"content": "first"},
{"content": "second"},
])
r1 = engine.generate([], model="m")
r2 = engine.generate([], model="m")
assert r1["content"] == "first"
assert r2["content"] == "second"
assert engine.call_count == 2
def test_fake_engine_repeats_last_response():
engine = FakeEngine([{"content": "only"}])
engine.generate([], model="m")
r = engine.generate([], model="m")
assert r["content"] == "only"
def test_fake_engine_raises_on_request():
engine = FakeEngine([{"raise": ValueError("boom")}])
with pytest.raises(ValueError, match="boom"):
engine.generate([], model="m")
def test_fake_engine_tool_calls():
engine = FakeEngine([{
"content": "",
"tool_calls": [{"id": "1", "function": {"name": "think", "arguments": "{}"}}],
}])
r = engine.generate([], model="m")
assert r["finish_reason"] == "tool_calls"
assert len(r["tool_calls"]) == 1
@@ -0,0 +1,19 @@
"""Smoke test for the scenario harness fixture."""
from __future__ import annotations
from tests.agents.scenario_harness import ScenarioHarness
def test_harness_creates_and_runs_agent(scenario_harness: ScenarioHarness):
"""Verify the harness wires up correctly — create agent, run tick."""
h = scenario_harness
agent = h.manager.create_agent("Smoke Test", config={
"schedule_type": "manual",
"instruction": "Say hello.",
})
h.executor.execute_tick(agent["id"])
updated = h.manager.get_agent(agent["id"])
assert updated["status"] == "idle"
assert updated["total_runs"] == 1
assert updated["summary_memory"] != ""
+40
View File
@@ -0,0 +1,40 @@
"""Tests for system_prompt_template expansion in agent creation."""
from __future__ import annotations
from openjarvis.agents.manager import AgentManager
def test_create_from_template_expands_system_prompt(tmp_path):
"""system_prompt_template should be expanded with the instruction."""
mgr = AgentManager(db_path=str(tmp_path / "test.db"))
agent = mgr.create_from_template(
"research_monitor",
"Test Agent",
overrides={"instruction": "Monitor AI safety papers"},
)
config = agent["config"]
# system_prompt should contain the expanded instruction
assert "Monitor AI safety papers" in config.get("system_prompt", "")
# system_prompt_template should NOT be in the stored config
assert "system_prompt_template" not in config
mgr.close()
def test_create_from_template_without_instruction(tmp_path):
"""Template with no instruction should still have a system_prompt."""
mgr = AgentManager(db_path=str(tmp_path / "test.db"))
agent = mgr.create_from_template("research_monitor", "Test Agent")
config = agent["config"]
assert "system_prompt" in config
assert len(config["system_prompt"]) > 100 # non-trivial prompt
mgr.close()
def test_create_from_template_preserves_icon(tmp_path):
"""Template icon field should be preserved in config."""
mgr = AgentManager(db_path=str(tmp_path / "test.db"))
agent = mgr.create_from_template("research_monitor", "Test Agent")
config = agent["config"]
assert config.get("icon") == "🔬"
mgr.close()
+64
View File
@@ -0,0 +1,64 @@
"""Shared fixtures for channel integration tests."""
from __future__ import annotations
import os
from contextlib import contextmanager
import pytest
from openjarvis.core.events import EventBus
from tests.agents.fake_engine import FakeEngine
from tests.agents.scenario_harness import FakeSystem, ScenarioHarness
@pytest.fixture
def scenario_harness(tmp_path):
"""Wire up real components for agent lifecycle testing (channels copy)."""
from openjarvis.agents.executor import AgentExecutor
from openjarvis.agents.manager import AgentManager
from openjarvis.agents.monitor_operative import MonitorOperativeAgent
from openjarvis.agents.scheduler import AgentScheduler
from openjarvis.core.registry import AgentRegistry
if not AgentRegistry.contains("monitor_operative"):
AgentRegistry.register("monitor_operative")(MonitorOperativeAgent)
db_path = str(tmp_path / "agents.db")
bus = EventBus(record_history=True)
manager = AgentManager(db_path=db_path)
engine = FakeEngine([{"content": "Default response."}])
system = FakeSystem(engine=engine)
executor = AgentExecutor(manager=manager, event_bus=bus)
executor.set_system(system)
scheduler = AgentScheduler(manager=manager, executor=executor, event_bus=bus)
return ScenarioHarness(
manager=manager, executor=executor, scheduler=scheduler,
bus=bus, engine=engine, system=system, db_path=db_path,
)
@pytest.fixture
def fake_event_bus() -> EventBus:
"""EventBus with history recording for asserting event emissions."""
return EventBus(record_history=True)
@contextmanager
def credential_env(channel_name: str, **creds: str):
"""Context manager that sets env vars for a channel, cleans up after."""
prefix = channel_name.upper()
old_values = {}
for key, value in creds.items():
env_key = f"{prefix}_{key.upper()}"
old_values[env_key] = os.environ.get(env_key)
os.environ[env_key] = value
try:
yield
finally:
for env_key, old in old_values.items():
if old is None:
os.environ.pop(env_key, None)
else:
os.environ[env_key] = old
+64
View File
@@ -0,0 +1,64 @@
"""Agent-channel E2E tests — using WebChatChannel (in-memory, no external deps)."""
from __future__ import annotations
from tests.agents.scenario_harness import ScenarioHarness
def test_agent_sends_to_webchat(scenario_harness: ScenarioHarness):
"""Agent runs and completes tick with WebChatChannel available."""
from openjarvis.channels.webchat import WebChatChannel
h = scenario_harness
webchat = WebChatChannel()
webchat.connect()
agent = h.manager.create_agent("Channel Agent", config={
"schedule_type": "manual",
"instruction": "Send a report to the general channel.",
})
h.executor.execute_tick(agent["id"])
data = h.manager.get_agent(agent["id"])
assert data["status"] == "idle"
assert data["total_runs"] == 1
webchat.disconnect()
def test_channel_failure_does_not_crash_agent(scenario_harness: ScenarioHarness):
"""Agent continues if channel send fails."""
h = scenario_harness
agent = h.manager.create_agent("Resilient Agent", config={
"schedule_type": "manual",
"instruction": "Try to send a message.",
})
h.executor.execute_tick(agent["id"])
data = h.manager.get_agent(agent["id"])
assert data["status"] == "idle"
assert data["total_runs"] == 1
def test_agent_with_channel_binding(scenario_harness: ScenarioHarness):
"""Agent with a channel binding completes tick without error."""
from openjarvis.channels.webchat import WebChatChannel
h = scenario_harness
webchat = WebChatChannel()
webchat.connect()
agent = h.manager.create_agent("Bound Agent", config={
"schedule_type": "manual",
"instruction": "Monitor and report.",
})
aid = agent["id"]
# Bind a channel
h.manager.bind_channel(aid, "webchat", config={"channel": "general"})
bindings = h.manager.list_channel_bindings(aid)
assert len(bindings) == 1
h.executor.execute_tick(aid)
data = h.manager.get_agent(aid)
assert data["status"] == "idle"
webchat.disconnect()
+85
View File
@@ -0,0 +1,85 @@
"""Parametrized contract tests — verify every channel implements BaseChannel correctly.
These tests run without any credentials and verify that all channel adapters
handle graceful degradation (no crashes on missing auth, idempotent disconnect,
valid enum returns).
"""
from __future__ import annotations
import importlib
import pytest
import openjarvis.channels # noqa: F401 — trigger registration
from openjarvis.channels._stubs import ChannelStatus
from openjarvis.core.registry import ChannelRegistry
# Collect channel classes at import time (before registry gets cleared).
# We store the actual class objects, not registry keys, so they survive
# the autouse _clean_registries fixture.
importlib.reload(openjarvis.channels)
_ALL_CHANNELS = [
(key, ChannelRegistry.get(key)) for key in ChannelRegistry.keys()
]
@pytest.fixture(params=_ALL_CHANNELS, ids=lambda x: x[0])
def channel_entry(request):
"""Parametrized fixture yielding (channel_key, channel_cls) tuples.
Uses the class reference captured at import time does not depend
on the registry being populated at test time.
"""
return request.param
def test_has_channel_id(channel_entry):
key, channel_cls = channel_entry
assert hasattr(channel_cls, "channel_id")
assert isinstance(channel_cls.channel_id, str)
assert len(channel_cls.channel_id) > 0
def test_connect_no_credentials_no_crash(channel_entry):
key, channel_cls = channel_entry
ch = channel_cls()
ch.connect()
# Should not raise — just set status to ERROR or DISCONNECTED
def test_disconnect_idempotent(channel_entry):
key, channel_cls = channel_entry
ch = channel_cls()
ch.disconnect()
ch.disconnect() # Second call should not raise
def test_status_returns_valid_enum(channel_entry):
key, channel_cls = channel_entry
ch = channel_cls()
s = ch.status()
assert isinstance(s, ChannelStatus)
def test_list_channels_returns_list(channel_entry):
key, channel_cls = channel_entry
ch = channel_cls()
result = ch.list_channels()
assert isinstance(result, list)
def test_send_no_credentials_returns_false(channel_entry):
key, channel_cls = channel_entry
if key == "webchat":
pytest.skip("WebChatChannel is in-memory and always succeeds")
ch = channel_cls()
result = ch.send("test", "hello")
assert result is False
def test_on_message_accepts_handler(channel_entry):
key, channel_cls = channel_entry
ch = channel_cls()
ch.on_message(lambda msg: None)
# Should not raise
+209
View File
@@ -0,0 +1,209 @@
"""Tests for the GmailChannel adapter."""
from __future__ import annotations
import base64
import os
from unittest.mock import MagicMock, patch
import pytest
from openjarvis.channels._stubs import ChannelStatus
from openjarvis.channels.gmail import GmailChannel
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
@pytest.fixture(autouse=True)
def _register_gmail():
"""Re-register after any registry clear."""
if not ChannelRegistry.contains("gmail"):
ChannelRegistry.register_value("gmail", GmailChannel)
class TestRegistration:
def test_gmail_channel_registered(self):
assert ChannelRegistry.contains("gmail")
def test_channel_id(self):
ch = GmailChannel()
assert ch.channel_id == "gmail"
class TestNoCredentials:
def test_gmail_no_credentials_status(self):
ch = GmailChannel()
ch.connect()
assert ch.status() in (ChannelStatus.ERROR, ChannelStatus.DISCONNECTED)
def test_gmail_send_no_credentials_returns_false(self):
ch = GmailChannel()
result = ch.send("recipient@example.com", "Hello!")
assert result is False
class TestSend:
def test_gmail_send_constructs_mime(self):
ch = GmailChannel()
mock_service = MagicMock()
ch._service = mock_service
ch._status = ChannelStatus.CONNECTED
result = ch.send("recipient@example.com", "Hello Gmail!")
assert result is True
# Verify send was called
mock_service.users().messages().send.assert_called_once()
call_kwargs = mock_service.users().messages().send.call_args[1]
assert call_kwargs["userId"] == "me"
# Verify the body contains base64-encoded MIME message
body = call_kwargs["body"]
assert "raw" in body
decoded = base64.urlsafe_b64decode(body["raw"]).decode("utf-8")
assert "recipient@example.com" in decoded
assert "Hello Gmail!" in decoded
def test_gmail_send_with_thread_id(self):
ch = GmailChannel()
mock_service = MagicMock()
ch._service = mock_service
ch._status = ChannelStatus.CONNECTED
result = ch.send(
"recipient@example.com",
"Thread reply",
conversation_id="thread-abc123",
)
assert result is True
call_kwargs = mock_service.users().messages().send.call_args[1]
body = call_kwargs["body"]
assert body["threadId"] == "thread-abc123"
def test_gmail_send_with_subject_metadata(self):
ch = GmailChannel()
mock_service = MagicMock()
ch._service = mock_service
ch._status = ChannelStatus.CONNECTED
result = ch.send(
"recipient@example.com",
"Body text",
metadata={"subject": "Custom Subject"},
)
assert result is True
call_kwargs = mock_service.users().messages().send.call_args[1]
decoded = base64.urlsafe_b64decode(
call_kwargs["body"]["raw"],
).decode("utf-8")
assert "Custom Subject" in decoded
def test_gmail_send_exception_returns_false(self):
ch = GmailChannel()
mock_service = MagicMock()
mock_service.users().messages().send().execute.side_effect = (
RuntimeError("API error")
)
ch._service = mock_service
ch._status = ChannelStatus.CONNECTED
result = ch.send("recipient@example.com", "Hello!")
assert result is False
class TestListChannels:
def test_gmail_list_channels(self):
ch = GmailChannel()
assert ch.list_channels() == ["inbox"]
class TestOnMessage:
def test_gmail_on_message_registers_handler(self):
ch = GmailChannel()
handler = MagicMock()
ch.on_message(handler)
assert handler in ch._handlers
class TestEventBus:
def test_gmail_event_bus_integration(self):
bus = EventBus(record_history=True)
ch = GmailChannel(bus=bus)
mock_service = MagicMock()
ch._service = mock_service
ch._status = ChannelStatus.CONNECTED
ch.send("recipient@example.com", "Hello!")
event_types = [e.event_type for e in bus.history]
assert EventType.CHANNEL_MESSAGE_SENT in event_types
class TestStatus:
def test_disconnected_initially(self):
ch = GmailChannel()
assert ch.status() == ChannelStatus.DISCONNECTED
def test_status_error_when_connected_but_no_service(self):
ch = GmailChannel()
ch._status = ChannelStatus.CONNECTED
ch._service = None
assert ch.status() == ChannelStatus.ERROR
class TestDisconnect:
def test_disconnect(self):
ch = GmailChannel()
ch._service = MagicMock()
ch._status = ChannelStatus.CONNECTED
ch.disconnect()
assert ch.status() == ChannelStatus.DISCONNECTED
assert ch._service is None
class TestEnvVarFallback:
def test_credentials_path_env_var(self):
with patch.dict(os.environ, {"GMAIL_CREDENTIALS_PATH": "/tmp/creds.json"}):
ch = GmailChannel()
assert ch._credentials_path == "/tmp/creds.json"
def test_token_path_env_var(self):
with patch.dict(os.environ, {"GMAIL_TOKEN_PATH": "/tmp/token.json"}):
ch = GmailChannel()
assert ch._token_path == "/tmp/token.json"
def test_constructor_overrides_env(self):
with patch.dict(os.environ, {"GMAIL_CREDENTIALS_PATH": "/env/creds.json"}):
ch = GmailChannel(credentials_path="/explicit/creds.json")
assert ch._credentials_path == "/explicit/creds.json"
@pytest.mark.live_channel
class TestLive:
def test_gmail_send_live(self):
creds_path = os.environ.get("GMAIL_CREDENTIALS_PATH", "")
token_path = os.environ.get("GMAIL_TOKEN_PATH", "")
recipient = os.environ.get("GMAIL_TEST_RECIPIENT", "")
if not creds_path and not token_path:
pytest.skip("No Gmail credentials configured")
if not recipient:
pytest.skip("No GMAIL_TEST_RECIPIENT configured")
ch = GmailChannel(
credentials_path=creds_path,
token_path=token_path,
)
ch.connect()
if ch.status() != ChannelStatus.CONNECTED:
pytest.skip("Gmail channel failed to connect")
result = ch.send(
recipient,
"OpenJarvis Gmail channel test message",
metadata={"subject": "OpenJarvis Test"},
)
assert result is True
ch.disconnect()
+136
View File
@@ -0,0 +1,136 @@
"""Tests for the TwitterChannel adapter."""
from __future__ import annotations
import os
from unittest.mock import MagicMock
import pytest
from openjarvis.channels._stubs import ChannelStatus
from openjarvis.channels.twitter import TwitterChannel
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import ChannelRegistry
@pytest.fixture(autouse=True)
def _register_twitter():
"""Re-register after any registry clear."""
if not ChannelRegistry.contains("twitter"):
ChannelRegistry.register_value("twitter", TwitterChannel)
def test_twitter_channel_registered():
"""Twitter channel should be discoverable via the registry."""
assert ChannelRegistry.contains("twitter")
cls = ChannelRegistry.get("twitter")
assert cls is TwitterChannel
def test_twitter_no_credentials_status():
"""Without credentials, connect() sets status to ERROR."""
ch = TwitterChannel()
ch.connect()
assert ch.status() == ChannelStatus.ERROR
def test_twitter_send_no_credentials_returns_false():
"""send() returns False when client is not connected."""
ch = TwitterChannel()
result = ch.send("timeline", "Hello Twitter!")
assert result is False
def test_twitter_send_tweet():
"""send() with a non-numeric channel calls create_tweet."""
ch = TwitterChannel(bearer_token="test-bearer")
mock_client = MagicMock()
ch._client = mock_client
ch._status = ChannelStatus.CONNECTED
result = ch.send("timeline", "Hello from Jarvis!")
assert result is True
mock_client.create_tweet.assert_called_once_with(text="Hello from Jarvis!")
def test_twitter_send_dm_when_channel_is_numeric():
"""send() with a numeric channel calls create_direct_message."""
ch = TwitterChannel(bearer_token="test-bearer")
mock_client = MagicMock()
ch._client = mock_client
ch._status = ChannelStatus.CONNECTED
result = ch.send("12345678", "Hello via DM!")
assert result is True
mock_client.create_direct_message.assert_called_once_with(
participant_id=12345678,
text="Hello via DM!",
)
def test_twitter_send_reply():
"""send() with conversation_id sets in_reply_to_tweet_id."""
ch = TwitterChannel(bearer_token="test-bearer")
mock_client = MagicMock()
ch._client = mock_client
ch._status = ChannelStatus.CONNECTED
result = ch.send(
"timeline", "This is a reply", conversation_id="999888777",
)
assert result is True
mock_client.create_tweet.assert_called_once_with(
text="This is a reply",
in_reply_to_tweet_id=999888777,
)
def test_twitter_list_channels():
"""list_channels() returns the expected identifiers."""
ch = TwitterChannel()
assert ch.list_channels() == ["timeline", "dm"]
def test_twitter_event_bus_integration():
"""Successful send publishes CHANNEL_MESSAGE_SENT on the event bus."""
bus = EventBus(record_history=True)
ch = TwitterChannel(bearer_token="test-bearer", bus=bus)
mock_client = MagicMock()
ch._client = mock_client
ch._status = ChannelStatus.CONNECTED
ch.send("timeline", "Event test!")
event_types = [e.event_type for e in bus.history]
assert EventType.CHANNEL_MESSAGE_SENT in event_types
sent_event = next(
e for e in bus.history
if e.event_type == EventType.CHANNEL_MESSAGE_SENT
)
assert sent_event.data["content"] == "Event test!"
assert sent_event.data["channel"] == "timeline"
@pytest.mark.live_channel
def test_twitter_connect_live():
"""Live test: connect with real credentials from env vars.
Skipped unless all required env vars are set.
"""
required_vars = [
"TWITTER_BEARER_TOKEN",
"TWITTER_API_KEY",
"TWITTER_API_SECRET",
"TWITTER_ACCESS_TOKEN",
"TWITTER_ACCESS_SECRET",
]
for var in required_vars:
if not os.environ.get(var):
pytest.skip(f"Missing env var {var}")
ch = TwitterChannel()
ch.connect()
assert ch.status() == ChannelStatus.CONNECTED
ch.disconnect()
assert ch.status() == ChannelStatus.DISCONNECTED
+154
View File
@@ -0,0 +1,154 @@
"""Tests for ``jarvis config set`` command."""
from __future__ import annotations
import os
from pathlib import Path
from unittest import mock
from click.testing import CliRunner
from openjarvis.cli import cli
class TestConfigSet:
def test_set_creates_config_file(self, tmp_path: Path) -> None:
"""config set creates config.toml if it doesn't exist."""
config_file = tmp_path / "config.toml"
env = {"OPENJARVIS_CONFIG": str(config_file)}
with mock.patch.dict(os.environ, env):
result = CliRunner().invoke(
cli, ["config", "set", "engine.default", "vllm"]
)
assert result.exit_code == 0
assert config_file.exists()
content = config_file.read_text()
assert "vllm" in content
def test_set_engine_ollama_host(self, tmp_path: Path) -> None:
"""config set writes engine.ollama.host correctly."""
config_file = tmp_path / "config.toml"
config_file.write_text('[engine]\ndefault = "ollama"\n')
env = {"OPENJARVIS_CONFIG": str(config_file)}
with (
mock.patch.dict(os.environ, env),
mock.patch("openjarvis.cli.config_cmd.httpx"),
):
result = CliRunner().invoke(
cli,
["config", "set", "engine.ollama.host", "http://192.168.1.50:11434"],
)
assert result.exit_code == 0
content = config_file.read_text()
assert "http://192.168.1.50:11434" in content
def test_set_preserves_existing_keys(self, tmp_path: Path) -> None:
"""config set preserves other keys in the file."""
config_file = tmp_path / "config.toml"
config_file.write_text(
'[engine]\ndefault = "ollama"\n\n'
"[intelligence]\n"
'default_model = "qwen2.5:3b"\n'
)
env = {"OPENJARVIS_CONFIG": str(config_file)}
with mock.patch.dict(os.environ, env):
result = CliRunner().invoke(
cli, ["config", "set", "engine.default", "vllm"]
)
assert result.exit_code == 0
content = config_file.read_text()
assert "vllm" in content
assert "qwen2.5:3b" in content
def test_set_invalid_key_rejected(self, tmp_path: Path) -> None:
"""config set rejects unknown keys."""
config_file = tmp_path / "config.toml"
config_file.write_text("")
env = {"OPENJARVIS_CONFIG": str(config_file)}
with mock.patch.dict(os.environ, env):
result = CliRunner().invoke(
cli, ["config", "set", "engine.olllama.host", "http://x:1234"]
)
assert result.exit_code != 0
assert "Unknown config key" in result.output
def test_set_engine_host_probes_reachable(self, tmp_path: Path) -> None:
"""config set probes engine host and reports reachability."""
config_file = tmp_path / "config.toml"
config_file.write_text("")
env = {"OPENJARVIS_CONFIG": str(config_file)}
with (
mock.patch.dict(os.environ, env),
mock.patch("openjarvis.cli.config_cmd.httpx") as mock_httpx,
):
mock_httpx.get.return_value = mock.Mock(status_code=200)
result = CliRunner().invoke(
cli,
["config", "set", "engine.ollama.host", "http://myserver:11434"],
)
assert result.exit_code == 0
assert "Reachable" in result.output
def test_set_engine_host_probes_unreachable(self, tmp_path: Path) -> None:
"""config set warns when engine host is unreachable, but still writes."""
config_file = tmp_path / "config.toml"
config_file.write_text("")
env = {"OPENJARVIS_CONFIG": str(config_file)}
with (
mock.patch.dict(os.environ, env),
mock.patch("openjarvis.cli.config_cmd.httpx") as mock_httpx,
):
mock_httpx.get.side_effect = Exception("Connection refused")
result = CliRunner().invoke(
cli,
["config", "set", "engine.ollama.host", "http://myserver:11434"],
)
assert result.exit_code == 0
output_lower = result.output.lower()
assert "unreachable" in output_lower or "warning" in output_lower
content = config_file.read_text()
assert "http://myserver:11434" in content
def test_set_integer_value(self, tmp_path: Path) -> None:
"""config set coerces integer values."""
config_file = tmp_path / "config.toml"
config_file.write_text("")
env = {"OPENJARVIS_CONFIG": str(config_file)}
with mock.patch.dict(os.environ, env):
result = CliRunner().invoke(
cli, ["config", "set", "intelligence.max_tokens", "2048"]
)
assert result.exit_code == 0
content = config_file.read_text()
assert "2048" in content
def test_set_float_value(self, tmp_path: Path) -> None:
"""config set coerces float values."""
config_file = tmp_path / "config.toml"
config_file.write_text("")
env = {"OPENJARVIS_CONFIG": str(config_file)}
with mock.patch.dict(os.environ, env):
result = CliRunner().invoke(
cli, ["config", "set", "intelligence.temperature", "0.9"]
)
assert result.exit_code == 0
content = config_file.read_text()
assert "0.9" in content
def test_set_missing_args(self) -> None:
"""config set with missing args shows usage error."""
result = CliRunner().invoke(cli, ["config", "set"])
assert result.exit_code != 0
def test_set_shows_confirmation(self, tmp_path: Path) -> None:
"""config set prints a confirmation message."""
config_file = tmp_path / "config.toml"
config_file.write_text("")
env = {"OPENJARVIS_CONFIG": str(config_file)}
with mock.patch.dict(os.environ, env):
result = CliRunner().invoke(
cli, ["config", "set", "engine.default", "vllm"]
)
assert result.exit_code == 0
assert "Set" in result.output
assert "engine.default" in result.output
+10
View File
@@ -35,3 +35,13 @@ class TestHintFunctions:
def test_hint_no_model_with_name(self):
msg = hint_no_model("qwen3:8b")
assert "qwen3:8b" in msg
def test_hint_no_engine_includes_remote_tip(self):
msg = hint_no_engine()
assert "config set" in msg
assert "OLLAMA_HOST" in msg
def test_hint_no_engine_with_name_includes_remote_tip(self):
msg = hint_no_engine("vllm")
assert "config set" in msg
assert "engine.vllm.host" in msg
+13 -29
View File
@@ -23,9 +23,7 @@ class TestInitShowsNextSteps:
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"),
):
result = CliRunner().invoke(
cli, ["init", "--engine", "llamacpp", _NO_DL]
)
result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp", _NO_DL])
assert result.exit_code == 0
assert "Getting Started" in result.output
assert "jarvis ask" in result.output
@@ -40,9 +38,7 @@ class TestInitShowsNextSteps:
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"),
):
result = CliRunner().invoke(
cli, ["init", "--engine", "llamacpp", _NO_DL]
)
result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp", _NO_DL])
assert result.exit_code == 0
assert "[engine]" in result.output
assert "[intelligence]" in result.output
@@ -57,12 +53,12 @@ class TestNextStepsOllama:
assert "jarvis doctor" in text
def test_next_steps_ollama_with_model(self) -> None:
text = _next_steps_text("ollama", "qwen3.5:14b")
assert "ollama pull qwen3.5:14b" in text
text = _next_steps_text("ollama", "qwen3.5:27b")
assert "ollama pull qwen3.5:27b" in text
def test_next_steps_ollama_default_model(self) -> None:
text = _next_steps_text("ollama")
assert "ollama pull qwen3.5:3b" in text
assert "ollama pull qwen3.5:2b" in text
class TestNextStepsVllm:
@@ -102,9 +98,7 @@ class TestMinimalConfig:
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"),
):
result = CliRunner().invoke(
cli, ["init", "--engine", "ollama", _NO_DL]
)
result = CliRunner().invoke(cli, ["init", "--engine", "ollama", _NO_DL])
assert result.exit_code == 0
content = config_path.read_text()
# Minimal config should be short
@@ -158,9 +152,7 @@ class TestInitDownloadPrompt:
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"),
):
result = CliRunner().invoke(
cli, ["init", "--engine", "ollama", _NO_DL]
)
result = CliRunner().invoke(cli, ["init", "--engine", "ollama", _NO_DL])
assert result.exit_code == 0
assert "Download" not in result.output
@@ -175,13 +167,10 @@ class TestInitEmptyModelFallback:
mock.patch("openjarvis.cli.init_cmd.recommend_model", return_value=""),
mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"),
):
result = CliRunner().invoke(
cli, ["init", "--engine", "llamacpp"]
)
result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"])
assert result.exit_code == 0
assert (
"Not enough memory" in result.output
or "not enough memory" in result.output
"Not enough memory" in result.output or "not enough memory" in result.output
)
@@ -200,9 +189,7 @@ class TestNextStepsExoNexa:
class TestInitDownloadDispatch:
def test_init_ollama_download_calls_ollama_pull(
self, tmp_path: Path
) -> None:
def test_init_ollama_download_calls_ollama_pull(self, tmp_path: Path) -> None:
config_dir = tmp_path / ".openjarvis"
config_path = config_dir / "config.toml"
with (
@@ -228,9 +215,7 @@ class TestInitDownloadDispatch:
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"),
):
result = CliRunner().invoke(
cli, ["init", "--engine", "vllm"], input="y\n"
)
result = CliRunner().invoke(cli, ["init", "--engine", "vllm"], input="y\n")
assert result.exit_code == 0
assert "automatically" in result.output
@@ -245,12 +230,11 @@ class TestInitPrivacyHook:
mock.patch("openjarvis.cli.init_cmd.PrivacyScanner") as MockScanner,
):
from openjarvis.cli.scan_cmd import ScanResult
instance = MockScanner.return_value
instance.run_quick.return_value = [
ScanResult("FileVault", "ok", "FileVault enabled", "darwin"),
]
result = CliRunner().invoke(
cli, ["init", "--engine", "llamacpp", _NO_DL]
)
result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp", _NO_DL])
assert result.exit_code == 0
assert "jarvis scan" in result.output
+117
View File
@@ -0,0 +1,117 @@
"""Tests for ``jarvis init --host`` option."""
from __future__ import annotations
from pathlib import Path
from unittest import mock
from click.testing import CliRunner
from openjarvis.cli import cli
from openjarvis.core.config import generate_default_toml, generate_minimal_toml
_NO_DL = "--no-download"
class TestInitHost:
def test_init_host_writes_to_config(self, tmp_path: Path) -> None:
"""jarvis init --host writes the host into config.toml."""
config_dir = tmp_path / ".openjarvis"
config_path = config_dir / "config.toml"
with (
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"),
):
result = CliRunner().invoke(
cli,
[
"init",
"--engine",
"ollama",
"--host",
"http://192.168.1.50:11434",
_NO_DL,
],
)
assert result.exit_code == 0
content = config_path.read_text()
assert "http://192.168.1.50:11434" in content
def test_init_host_with_vllm(self, tmp_path: Path) -> None:
"""jarvis init --host applies to the selected engine."""
config_dir = tmp_path / ".openjarvis"
config_path = config_dir / "config.toml"
with (
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"),
):
result = CliRunner().invoke(
cli,
["init", "--engine", "vllm", "--host", "http://10.0.0.5:8000", _NO_DL],
)
assert result.exit_code == 0
content = config_path.read_text()
assert "http://10.0.0.5:8000" in content
def test_init_host_probes_and_reports(self, tmp_path: Path) -> None:
"""jarvis init --host shows reachability status."""
config_dir = tmp_path / ".openjarvis"
config_path = config_dir / "config.toml"
with (
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"),
mock.patch("openjarvis.cli.init_cmd.httpx") as mock_httpx,
):
mock_httpx.get.side_effect = Exception("Connection refused")
result = CliRunner().invoke(
cli,
["init", "--engine", "ollama", "--host", "http://bad:11434", _NO_DL],
)
assert result.exit_code == 0
output_lower = result.output.lower()
assert "unreachable" in output_lower or "warning" in output_lower
def test_init_without_host_still_works(self, tmp_path: Path) -> None:
"""jarvis init without --host still produces valid config."""
config_dir = tmp_path / ".openjarvis"
config_path = config_dir / "config.toml"
with (
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
mock.patch("openjarvis.cli.init_cmd.PrivacyScanner"),
):
result = CliRunner().invoke(cli, ["init", "--engine", "ollama", _NO_DL])
assert result.exit_code == 0
content = config_path.read_text()
assert "[engine]" in content
class TestGenerateTomlHost:
def test_minimal_toml_with_host(self) -> None:
from openjarvis.core.config import HardwareInfo
hw = HardwareInfo()
toml_str = generate_minimal_toml(
hw, engine="ollama", host="http://remote:11434"
)
assert "http://remote:11434" in toml_str
assert "[engine.ollama]" in toml_str
def test_minimal_toml_without_host_has_comment(self) -> None:
from openjarvis.core.config import HardwareInfo
hw = HardwareInfo()
toml_str = generate_minimal_toml(hw, engine="ollama")
assert "# host" in toml_str
def test_default_toml_with_host(self) -> None:
from openjarvis.core.config import HardwareInfo
hw = HardwareInfo()
toml_str = generate_default_toml(
hw, engine="ollama", host="http://remote:11434"
)
assert "http://remote:11434" in toml_str
+8 -7
View File
@@ -15,6 +15,7 @@ class TestOllamaPull:
def test_ollama_pull_success(self) -> None:
import io
console = Console(file=io.StringIO())
mock_lines = [
'{"status": "pulling manifest"}',
@@ -28,7 +29,7 @@ class TestOllamaPull:
mock_resp.__exit__ = mock.MagicMock(return_value=False)
with mock.patch("httpx.stream", return_value=mock_resp):
result = ollama_pull("http://localhost:11434", "qwen3.5:3b", console)
result = ollama_pull("http://localhost:11434", "qwen3.5:2b", console)
assert result is True
def test_ollama_pull_connect_error(self) -> None:
@@ -38,7 +39,7 @@ class TestOllamaPull:
console = Console(file=io.StringIO())
with mock.patch("httpx.stream", side_effect=httpx.ConnectError("refused")):
result = ollama_pull("http://localhost:11434", "qwen3.5:3b", console)
result = ollama_pull("http://localhost:11434", "qwen3.5:2b", console)
assert result is False
@@ -58,14 +59,14 @@ class TestPullCliMultiEngine:
mock_run.return_value = mock.MagicMock(returncode=0)
result = runner.invoke(
cli, ["model", "pull", "qwen3.5:8b", "--engine", "llamacpp"]
cli, ["model", "pull", "qwen3.5:9b", "--engine", "llamacpp"]
)
assert result.exit_code == 0
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
assert "huggingface-cli" in call_args
assert "qwen3.5-8b-q4_k_m.gguf" in call_args
assert "qwen3.5-9b-q4_k_m.gguf" in call_args
def test_pull_mlx_uses_huggingface_cli(self) -> None:
from openjarvis.cli import cli
@@ -80,14 +81,14 @@ class TestPullCliMultiEngine:
mock_run.return_value = mock.MagicMock(returncode=0)
result = runner.invoke(
cli, ["model", "pull", "qwen3.5:8b", "--engine", "mlx"]
cli, ["model", "pull", "qwen3.5:9b", "--engine", "mlx"]
)
assert result.exit_code == 0
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
assert "huggingface-cli" in call_args
assert "mlx-community/Qwen3.5-8B-4bit" in call_args
assert "mlx-community/Qwen3.5-9B-MLX-4bit" in call_args
def test_pull_llamacpp_huggingface_cli_not_found(self) -> None:
from openjarvis.cli import cli
@@ -101,7 +102,7 @@ class TestPullCliMultiEngine:
mock_cfg.return_value.engine.ollama_host = None
result = runner.invoke(
cli, ["model", "pull", "qwen3.5:8b", "--engine", "llamacpp"]
cli, ["model", "pull", "qwen3.5:9b", "--engine", "llamacpp"]
)
assert result.exit_code != 0
+134 -21
View File
@@ -31,9 +31,7 @@ def _make_proc(
class TestScanResultDataclass:
def test_fields_exist(self) -> None:
r = ScanResult(
name="test", status="ok", message="all good", platform="all"
)
r = ScanResult(name="test", status="ok", message="all good", platform="all")
assert r.name == "test"
assert r.status == "ok"
assert r.message == "all good"
@@ -246,15 +244,9 @@ class TestPlatformFiltering:
other_plat = "linux" if current_plat == "darwin" else "darwin"
with patch.object(scanner, "_get_all_checks") as mock_get:
darwin_ck = MagicMock(
return_value=ScanResult("d", "ok", "msg", "darwin")
)
linux_ck = MagicMock(
return_value=ScanResult("l", "ok", "msg", "linux")
)
all_ck = MagicMock(
return_value=ScanResult("a", "ok", "msg", "all")
)
darwin_ck = MagicMock(return_value=ScanResult("d", "ok", "msg", "darwin"))
linux_ck = MagicMock(return_value=ScanResult("l", "ok", "msg", "linux"))
all_ck = MagicMock(return_value=ScanResult("a", "ok", "msg", "all"))
mock_get.return_value = [darwin_ck, linux_ck, all_ck]
results = scanner.run_all()
@@ -275,23 +267,19 @@ class TestRemoteAccess:
def test_no_remote_access(self) -> None:
scanner = PrivacyScanner()
with patch.object(scanner, "_run") as mock_run:
mock_run.return_value = CompletedProcess(
[], 1, stdout="", stderr=""
)
mock_run.return_value = CompletedProcess([], 1, stdout="", stderr="")
result = scanner.check_remote_access()
assert result.status == "ok"
def test_xrdp_running(self) -> None:
scanner = PrivacyScanner()
with patch.object(scanner, "_run") as mock_run:
def side_effect(cmd, **kw):
if any("xrdp" in str(c) for c in cmd):
return CompletedProcess(
cmd, 0, stdout="12345", stderr=""
)
return CompletedProcess(
cmd, 1, stdout="", stderr=""
)
return CompletedProcess(cmd, 0, stdout="12345", stderr="")
return CompletedProcess(cmd, 1, stdout="", stderr="")
mock_run.side_effect = side_effect
result = scanner.check_remote_access()
assert result.status == "warn"
@@ -348,3 +336,128 @@ class TestRunQuick:
names = {r.name for r in results}
assert "Network Exposure" not in names
assert "Screen Recording" not in names
# ---------------------------------------------------------------------------
# TestDNS
# ---------------------------------------------------------------------------
class TestDNS:
"""Tests for check_dns (macOS)."""
def test_encrypted_dns_detected(self) -> None:
scanner = PrivacyScanner()
scutil_out = (
"resolver #1\n nameserver[0] : 127.0.0.1\n flags : dns-over-https\n"
)
with (
patch("sys.platform", "darwin"),
patch("subprocess.run", return_value=_make_proc(stdout=scutil_out)),
):
result = scanner.check_dns()
assert result.status == "ok"
assert "Encrypted" in result.message or "DoH" in result.message
def test_plain_dns_detected(self) -> None:
scanner = PrivacyScanner()
scutil_out = (
"resolver #1\n nameserver[0] : 8.8.8.8\n nameserver[1] : 8.8.4.4\n"
)
with (
patch("sys.platform", "darwin"),
patch("subprocess.run", return_value=_make_proc(stdout=scutil_out)),
):
result = scanner.check_dns()
assert result.status == "warn"
assert "8.8.8.8" in result.message
def test_private_dns(self) -> None:
scanner = PrivacyScanner()
scutil_out = "resolver #1\n nameserver[0] : 192.168.1.1\n"
with (
patch("sys.platform", "darwin"),
patch("subprocess.run", return_value=_make_proc(stdout=scutil_out)),
):
result = scanner.check_dns()
assert result.status == "ok"
assert "192.168.1.1" in result.message
def test_skip_on_linux(self) -> None:
scanner = PrivacyScanner()
with patch("sys.platform", "linux"):
result = scanner.check_dns()
assert result.status == "skip"
def test_scutil_not_available(self) -> None:
scanner = PrivacyScanner()
with (
patch("sys.platform", "darwin"),
patch("subprocess.run", side_effect=FileNotFoundError),
):
result = scanner.check_dns()
assert result.status == "skip"
# ---------------------------------------------------------------------------
# TestExpandedRemoteAccess
# ---------------------------------------------------------------------------
class TestExpandedRemoteAccess:
"""Verify expanded remote-access process list."""
def test_ngrok_detected(self) -> None:
scanner = PrivacyScanner()
with patch.object(scanner, "_run") as mock_run:
def side_effect(cmd, **kw):
if any("ngrok" in str(c) for c in cmd):
return CompletedProcess(cmd, 0, stdout="99999", stderr="")
return CompletedProcess(cmd, 1, stdout="", stderr="")
mock_run.side_effect = side_effect
result = scanner.check_remote_access()
assert result.status == "warn"
assert "ngrok" in result.message
def test_tailscaled_detected(self) -> None:
scanner = PrivacyScanner()
with patch.object(scanner, "_run") as mock_run:
def side_effect(cmd, **kw):
if any("tailscaled" in str(c) for c in cmd):
return CompletedProcess(cmd, 0, stdout="88888", stderr="")
return CompletedProcess(cmd, 1, stdout="", stderr="")
mock_run.side_effect = side_effect
result = scanner.check_remote_access()
assert result.status == "warn"
# ---------------------------------------------------------------------------
# TestJsonOutput
# ---------------------------------------------------------------------------
class TestJsonOutput:
"""Test --json output flag."""
def test_json_output_structure(self) -> None:
from click.testing import CliRunner
from openjarvis.cli.scan_cmd import scan
runner = CliRunner()
with patch(
"openjarvis.cli.scan_cmd.PrivacyScanner.run_all",
return_value=[
ScanResult("Test", "ok", "all good", "all"),
],
):
result = runner.invoke(scan, ["--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert isinstance(data, list)
assert data[0]["name"] == "Test"
assert data[0]["status"] == "ok"
+35
View File
@@ -447,6 +447,41 @@ class TestSchedulerConfig:
# ---------------------------------------------------------------------------
class TestApplyTomlSectionListNormalization:
def test_apply_toml_section_list_to_str_field(self) -> None:
"""TOML arrays assigned to str-typed fields should be joined with ','."""
from openjarvis.core.config import ToolsConfig, _apply_toml_section
target = ToolsConfig()
tools = ["code_interpreter", "web_search", "file_read"]
_apply_toml_section(target, {"enabled": tools})
assert isinstance(target.enabled, str)
assert target.enabled == "code_interpreter,web_search,file_read"
def test_apply_toml_section_list_to_property_setter(self) -> None:
"""TOML arrays passed to backward-compat property setters should be
normalized to comma-separated strings, not passed as raw lists."""
from openjarvis.core.config import _apply_toml_section
target = LearningConfig()
_apply_toml_section(target, {
"reward_weights": ["accuracy=0.8", "latency=0.2"],
})
assert target.metrics.accuracy_weight == 0.8
assert target.metrics.latency_weight == 0.2
def test_apply_toml_section_agent_tools_list(self) -> None:
"""Agent tools should work as a TOML array."""
from openjarvis.core.config import _apply_toml_section
target = AgentConfig()
_apply_toml_section(target, {
"tools": ["web_search", "http_request", "file_read"],
})
assert isinstance(target.tools, str)
assert target.tools == "web_search,http_request,file_read"
class TestWhatsAppBaileysChannelConfig:
def test_defaults(self) -> None:
wc = WhatsAppBaileysChannelConfig()
+54
View File
@@ -0,0 +1,54 @@
"""Tests for config key validation."""
from __future__ import annotations
import pytest
from openjarvis.core.config import validate_config_key
class TestValidateConfigKey:
def test_valid_engine_default(self):
field_type = validate_config_key("engine.default")
assert field_type is str
def test_valid_engine_ollama_host(self):
field_type = validate_config_key("engine.ollama.host")
assert field_type is str
def test_valid_intelligence_temperature(self):
field_type = validate_config_key("intelligence.temperature")
assert field_type is float
def test_valid_intelligence_max_tokens(self):
field_type = validate_config_key("intelligence.max_tokens")
assert field_type is int
def test_valid_agent_default_agent(self):
field_type = validate_config_key("agent.default_agent")
assert field_type is str
def test_invalid_top_level_key(self):
with pytest.raises(ValueError, match="Unknown config key"):
validate_config_key("nonexistent.foo")
def test_invalid_nested_key(self):
with pytest.raises(ValueError, match="Unknown config key"):
validate_config_key("engine.olllama.host")
def test_invalid_leaf_key(self):
with pytest.raises(ValueError, match="Unknown config key"):
validate_config_key("engine.ollama.nonexistent")
def test_empty_key(self):
with pytest.raises(ValueError):
validate_config_key("")
def test_single_segment(self):
with pytest.raises(ValueError):
validate_config_key("engine")
def test_hardware_key_rejected(self):
"""Hardware is auto-detected, not user-settable."""
with pytest.raises(ValueError, match="Unknown config key"):
validate_config_key("hardware.cpu_count")
+12 -8
View File
@@ -27,7 +27,7 @@ class TestRecommendModelGpu:
result = recommend_model(hw, "ollama")
# 14B * 0.5 * 1.1 = 7.7 GB; available = 8 * 0.9 = 7.2 → too big
# 8B * 0.5 * 1.1 = 4.4 GB; available = 7.2 → fits
assert result == "qwen3.5:8b"
assert result == "qwen3.5:9b"
def test_4gb_gpu_picks_qwen35_4b(self) -> None:
hw = HardwareInfo(
@@ -47,7 +47,7 @@ class TestRecommendModelGpu:
)
result = recommend_model(hw, "ollama")
# 3B * 0.5 * 1.1 = 1.65 GB; available = 2 * 0.9 = 1.8 → fits
assert result == "qwen3.5:3b"
assert result == "qwen3.5:2b"
def test_multi_gpu_picks_larger_model(self) -> None:
hw = HardwareInfo(
@@ -80,8 +80,9 @@ class TestRecommendModelCpuOnly:
hw = HardwareInfo(platform="linux", ram_gb=16.0, gpu=None)
result = recommend_model(hw, "llamacpp")
# available = (16 - 4) * 0.8 = 9.6 GB
# 14B * 0.5 * 1.1 = 7.7 → fits
assert result == "qwen3.5:14b"
# 27B * 0.5 * 1.1 = 14.85 → too big
# 9B * 0.5 * 1.1 = 4.95 → fits
assert result == "qwen3.5:9b"
def test_cpu_only_8gb_ram(self) -> None:
hw = HardwareInfo(platform="linux", ram_gb=8.0, gpu=None)
@@ -127,7 +128,7 @@ class TestRecommendModelMlx:
gpu=GpuInfo(vendor="apple", name="Apple M1", vram_gb=8.0, count=1),
)
result = recommend_model(hw, "mlx")
assert result == "qwen3.5:8b"
assert result == "qwen3.5:9b"
def test_apple_silicon_16gb_mlx(self) -> None:
hw = HardwareInfo(
@@ -136,7 +137,10 @@ class TestRecommendModelMlx:
gpu=GpuInfo(vendor="apple", name="Apple M2", vram_gb=16.0, count=1),
)
result = recommend_model(hw, "mlx")
assert result == "qwen3.5:14b"
# available = 16 * 0.9 = 14.4 GB
# 27B * 0.5 * 1.1 = 14.85 → too big
# 9B * 0.5 * 1.1 = 4.95 → fits
assert result == "qwen3.5:9b"
def test_apple_silicon_32gb_mlx(self) -> None:
hw = HardwareInfo(
@@ -145,7 +149,7 @@ class TestRecommendModelMlx:
gpu=GpuInfo(vendor="apple", name="Apple M2 Pro", vram_gb=32.0, count=1),
)
result = recommend_model(hw, "mlx")
assert result == "qwen3.5:14b"
assert result == "qwen3.5:27b"
def test_apple_silicon_64gb_mlx(self) -> None:
hw = HardwareInfo(
@@ -154,4 +158,4 @@ class TestRecommendModelMlx:
gpu=GpuInfo(vendor="apple", name="Apple M2 Max", vram_gb=64.0, count=1),
)
result = recommend_model(hw, "mlx")
assert result == "qwen3.5:14b"
assert result == "qwen3.5:27b"
+183 -1
View File
@@ -9,7 +9,11 @@ import pytest
from openjarvis.core.registry import EngineRegistry
from openjarvis.core.types import Message, Role
from openjarvis.engine.cloud import CloudEngine, estimate_cost
from openjarvis.engine.cloud import (
CloudEngine,
_is_codex_model,
estimate_cost,
)
class TestEstimateCost:
@@ -108,3 +112,181 @@ class TestCloudEngineGenerate:
assert result["content"] == "Greetings!"
assert result["usage"]["prompt_tokens"] == 12
assert result["usage"]["completion_tokens"] == 8
# ---------------------------------------------------------------------------
# Codex provider support (OpenAI Responses API)
# ---------------------------------------------------------------------------
class TestCodexModelDetection:
def test_is_codex_model(self) -> None:
assert _is_codex_model("codex/gpt-4o") is True
assert _is_codex_model("codex/gpt-5-mini") is True
assert _is_codex_model("codex/gpt-5-mini-2025-08-07") is True
def test_not_codex_model(self) -> None:
assert _is_codex_model("gpt-4o") is False
assert _is_codex_model("openrouter/openai/gpt-4o") is False
class TestCodexClientInit:
def test_health_with_codex_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_CODEX_API_KEY", "test-token")
engine = CloudEngine()
assert engine.health() is True
assert engine._codex_client is not None
assert engine._codex_client["token"] == "test-token"
assert "responses" in engine._codex_client["url"]
def test_custom_codex_base_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_CODEX_API_KEY", "test-token")
monkeypatch.setenv("OPENAI_CODEX_BASE_URL", "http://localhost:9999")
engine = CloudEngine()
assert engine._codex_client["url"] == "http://localhost:9999/responses"
def test_list_models_includes_codex(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_CODEX_API_KEY", "test-token")
engine = CloudEngine()
models = engine.list_models()
assert "codex/gpt-4o" in models
assert "codex/gpt-5-mini" in models
assert "codex/gpt-5-mini-2025-08-07" in models
def test_no_codex_key_means_no_codex(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_CODEX_API_KEY", raising=False)
engine = CloudEngine()
assert engine._codex_client is None
assert "codex/gpt-4o" not in engine.list_models()
class TestCodexGenerate:
def test_generate_codex_uses_responses_api(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
fake_response = mock.MagicMock()
fake_response.status_code = 200
fake_response.json.return_value = {
"output_text": "Codex response!",
"usage": {"input_tokens": 10, "output_tokens": 5},
}
fake_response.raise_for_status = mock.MagicMock()
engine = CloudEngine()
engine._codex_client = {
"token": "test-token",
"url": "https://api.openai.com/v1/responses",
}
with mock.patch(
"openjarvis.engine.cloud.httpx.post",
return_value=fake_response,
) as mock_post:
result = engine.generate(
[Message(role=Role.USER, content="Hi")],
model="codex/gpt-5-mini-2025-08-07",
)
assert result["content"] == "Codex response!"
assert result["model"] == "gpt-5-mini-2025-08-07"
assert result["usage"]["prompt_tokens"] == 10
assert result["usage"]["completion_tokens"] == 5
# Verify correct Responses API request format
call_kwargs = mock_post.call_args
sent_body = call_kwargs.kwargs["json"]
assert sent_body["model"] == "gpt-5-mini-2025-08-07"
assert sent_body["stream"] is False
assert "input" in sent_body # Responses API format
assert "messages" not in sent_body # NOT chat completions
# Verify correct headers
sent_headers = call_kwargs.kwargs["headers"]
assert sent_headers["Authorization"] == "Bearer test-token"
assert sent_headers["OpenAI-Beta"] == "responses=experimental"
def test_generate_codex_extracts_from_output_blocks(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Fallback extraction from output[].content[] blocks."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
fake_response = mock.MagicMock()
fake_response.json.return_value = {
"output": [{"content": [{"type": "output_text", "text": "From blocks!"}]}],
"usage": {"input_tokens": 5, "output_tokens": 3},
}
fake_response.raise_for_status = mock.MagicMock()
engine = CloudEngine()
engine._codex_client = {
"token": "t",
"url": "https://api.openai.com/v1/responses",
}
with mock.patch(
"openjarvis.engine.cloud.httpx.post",
return_value=fake_response,
):
result = engine.generate(
[Message(role=Role.USER, content="Hi")],
model="codex/gpt-4o",
)
assert result["content"] == "From blocks!"
def test_generate_codex_passes_system_as_instructions(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
fake_response = mock.MagicMock()
fake_response.json.return_value = {
"output_text": "ok",
"usage": {},
}
fake_response.raise_for_status = mock.MagicMock()
engine = CloudEngine()
engine._codex_client = {
"token": "t",
"url": "https://api.openai.com/v1/responses",
}
with mock.patch(
"openjarvis.engine.cloud.httpx.post",
return_value=fake_response,
) as mock_post:
engine.generate(
[
Message(role=Role.SYSTEM, content="Be helpful"),
Message(role=Role.USER, content="Hi"),
],
model="codex/gpt-4o",
)
sent_body = mock_post.call_args.kwargs["json"]
assert sent_body["instructions"] == "Be helpful"
# System message should NOT appear in input messages
roles = [m["role"] for m in sent_body["input"]]
assert "system" not in roles
def test_codex_close(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
engine = CloudEngine()
engine._codex_client = {"token": "t", "url": "http://test"}
engine.close()
assert engine._codex_client is None
+466
View File
@@ -0,0 +1,466 @@
"""Tests for CloudEngine.stream_full, _stream_full_openai, _stream_full_anthropic,
and _prepare_anthropic_messages."""
from __future__ import annotations
from typing import Any, List
from unittest.mock import MagicMock
import pytest
from openjarvis.core.types import Message, Role, ToolCall
from openjarvis.engine._stubs import StreamChunk
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_cloud_engine(**overrides: Any) -> Any:
"""Create a CloudEngine without calling __init__ (no env vars needed)."""
from openjarvis.engine.cloud import CloudEngine
engine = CloudEngine.__new__(CloudEngine)
engine._openai_client = overrides.get("openai_client")
engine._anthropic_client = overrides.get("anthropic_client")
engine._google_client = overrides.get("google_client")
engine._openrouter_client = overrides.get("openrouter_client")
engine._minimax_client = overrides.get("minimax_client")
return engine
def _openai_chunk(
*,
content: str | None = None,
tool_calls: list | None = None,
finish_reason: str | None = None,
) -> MagicMock:
"""Build a mock OpenAI streaming chunk."""
delta = MagicMock()
delta.content = content
delta.tool_calls = tool_calls
choice = MagicMock()
choice.delta = delta
choice.finish_reason = finish_reason
chunk = MagicMock()
chunk.choices = [choice]
return chunk
def _openai_tool_call_delta(
*,
index: int = 0,
tc_id: str = "",
name: str = "",
arguments: str = "",
) -> MagicMock:
tc = MagicMock()
tc.index = index
tc.id = tc_id
tc.function = MagicMock()
tc.function.name = name
tc.function.arguments = arguments
return tc
# ---------------------------------------------------------------------------
# _stream_full_openai tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_full_openai_content():
"""Mock OpenAI streaming response with content chunks."""
mock_client = MagicMock()
chunks = [
_openai_chunk(content="Hello"),
_openai_chunk(content=" world"),
_openai_chunk(finish_reason="stop"),
]
mock_client.chat.completions.create.return_value = iter(chunks)
engine = _make_cloud_engine(openai_client=mock_client)
msgs = [Message(role=Role.USER, content="hi")]
result: List[StreamChunk] = []
async for sc in engine._stream_full_openai(
msgs,
model="gpt-4o",
temperature=0.7,
max_tokens=100,
):
result.append(sc)
assert len(result) == 3
assert result[0].content == "Hello"
assert result[1].content == " world"
assert result[2].finish_reason == "stop"
@pytest.mark.asyncio
async def test_stream_full_openai_tool_calls():
"""Mock response with tool_call deltas, verify StreamChunk.tool_calls format."""
mock_client = MagicMock()
tc1 = _openai_tool_call_delta(index=0, tc_id="call_1", name="calc", arguments="")
tc2 = _openai_tool_call_delta(index=0, tc_id="", name="", arguments='{"x": 1}')
chunks = [
_openai_chunk(tool_calls=[tc1]),
_openai_chunk(tool_calls=[tc2]),
_openai_chunk(finish_reason="tool_calls"),
]
mock_client.chat.completions.create.return_value = iter(chunks)
engine = _make_cloud_engine(openai_client=mock_client)
msgs = [Message(role=Role.USER, content="calc")]
result: List[StreamChunk] = []
async for sc in engine._stream_full_openai(
msgs,
model="gpt-4o",
temperature=0.7,
max_tokens=100,
):
result.append(sc)
assert result[0].tool_calls is not None
assert result[0].tool_calls[0]["function"]["name"] == "calc"
assert result[0].tool_calls[0]["id"] == "call_1"
assert result[1].tool_calls[0]["function"]["arguments"] == '{"x": 1}'
assert result[2].finish_reason == "tool_calls"
@pytest.mark.asyncio
async def test_stream_full_openai_finish_reason():
"""Verify finish_reason='tool_calls' and 'stop' propagated correctly."""
mock_client = MagicMock()
chunks_stop = [
_openai_chunk(content="ok"),
_openai_chunk(finish_reason="stop"),
]
mock_client.chat.completions.create.return_value = iter(chunks_stop)
engine = _make_cloud_engine(openai_client=mock_client)
msgs = [Message(role=Role.USER, content="hi")]
result = []
async for sc in engine._stream_full_openai(
msgs,
model="gpt-4o",
temperature=0.7,
max_tokens=100,
):
result.append(sc)
assert result[-1].finish_reason == "stop"
# Now test tool_calls finish
tc = _openai_tool_call_delta(index=0, tc_id="c1", name="fn", arguments="{}")
chunks_tc = [
_openai_chunk(tool_calls=[tc]),
_openai_chunk(finish_reason="tool_calls"),
]
mock_client.chat.completions.create.return_value = iter(chunks_tc)
result2 = []
async for sc in engine._stream_full_openai(
msgs,
model="gpt-4o",
temperature=0.7,
max_tokens=100,
):
result2.append(sc)
assert result2[-1].finish_reason == "tool_calls"
# ---------------------------------------------------------------------------
# _stream_full_anthropic tests
# ---------------------------------------------------------------------------
def _anthropic_event(event_type: str, **kwargs: Any) -> MagicMock:
"""Build a mock Anthropic stream event."""
event = MagicMock()
event.type = event_type
for k, v in kwargs.items():
setattr(event, k, v)
return event
@pytest.mark.asyncio
async def test_stream_full_anthropic_content():
"""Mock Anthropic stream events with text content."""
# Build content_block_start with text type
text_block = MagicMock()
text_block.type = "text"
# Build text delta
text_delta = MagicMock()
text_delta.type = "text_delta"
text_delta.text = "Hello world"
# Build message_delta with stop
msg_delta = MagicMock()
msg_delta.stop_reason = "end_turn"
events = [
_anthropic_event("content_block_start", content_block=text_block),
_anthropic_event("content_block_delta", delta=text_delta),
_anthropic_event("message_delta", delta=msg_delta),
]
mock_stream = MagicMock()
mock_stream.__enter__ = MagicMock(return_value=iter(events))
mock_stream.__exit__ = MagicMock(return_value=False)
mock_anthropic = MagicMock()
mock_anthropic.messages.stream.return_value = mock_stream
engine = _make_cloud_engine(anthropic_client=mock_anthropic)
msgs = [Message(role=Role.USER, content="hi")]
result: List[StreamChunk] = []
async for sc in engine._stream_full_anthropic(
msgs,
model="claude-sonnet-4-20250514",
temperature=0.7,
max_tokens=100,
):
result.append(sc)
# Should have text content and a finish reason
content_chunks = [r for r in result if r.content is not None]
assert len(content_chunks) >= 1
assert content_chunks[0].content == "Hello world"
finish_chunks = [r for r in result if r.finish_reason is not None]
assert len(finish_chunks) == 1
assert finish_chunks[0].finish_reason == "stop"
@pytest.mark.asyncio
async def test_stream_full_anthropic_tool_calls():
"""Mock Anthropic tool_use events, verify OpenAI-delta-format tool_calls."""
# content_block_start with tool_use
tool_block = MagicMock()
tool_block.type = "tool_use"
tool_block.id = "toolu_123"
tool_block.name = "get_weather"
# input_json_delta
json_delta = MagicMock()
json_delta.type = "input_json_delta"
json_delta.partial_json = '{"city": "Berlin"}'
# message_delta with tool_use stop
msg_delta = MagicMock()
msg_delta.stop_reason = "tool_use"
events = [
_anthropic_event("content_block_start", content_block=tool_block),
_anthropic_event("content_block_delta", delta=json_delta),
_anthropic_event("message_delta", delta=msg_delta),
]
mock_stream = MagicMock()
mock_stream.__enter__ = MagicMock(return_value=iter(events))
mock_stream.__exit__ = MagicMock(return_value=False)
mock_anthropic = MagicMock()
mock_anthropic.messages.stream.return_value = mock_stream
engine = _make_cloud_engine(anthropic_client=mock_anthropic)
msgs = [Message(role=Role.USER, content="weather?")]
result: List[StreamChunk] = []
async for sc in engine._stream_full_anthropic(
msgs,
model="claude-sonnet-4-20250514",
temperature=0.7,
max_tokens=100,
):
result.append(sc)
# First chunk: tool_use start with name
assert result[0].tool_calls is not None
assert result[0].tool_calls[0]["function"]["name"] == "get_weather"
assert result[0].tool_calls[0]["id"] == "toolu_123"
# Second chunk: arguments fragment
assert result[1].tool_calls is not None
assert result[1].tool_calls[0]["function"]["arguments"] == '{"city": "Berlin"}'
# Third chunk: finish with tool_calls
assert result[2].finish_reason == "tool_calls"
@pytest.mark.asyncio
async def test_stream_full_anthropic_finish_reason():
"""message_delta with stop_reason='tool_use' maps to finish_reason='tool_calls'."""
msg_delta_tool = MagicMock()
msg_delta_tool.stop_reason = "tool_use"
msg_delta_stop = MagicMock()
msg_delta_stop.stop_reason = "end_turn"
# Test tool_use -> tool_calls
events_tool = [_anthropic_event("message_delta", delta=msg_delta_tool)]
mock_stream = MagicMock()
mock_stream.__enter__ = MagicMock(return_value=iter(events_tool))
mock_stream.__exit__ = MagicMock(return_value=False)
mock_anthropic = MagicMock()
mock_anthropic.messages.stream.return_value = mock_stream
engine = _make_cloud_engine(anthropic_client=mock_anthropic)
msgs = [Message(role=Role.USER, content="test")]
result = []
async for sc in engine._stream_full_anthropic(
msgs,
model="claude-sonnet-4-20250514",
temperature=0.7,
max_tokens=100,
):
result.append(sc)
assert result[0].finish_reason == "tool_calls"
# Test end_turn -> stop
events_stop = [_anthropic_event("message_delta", delta=msg_delta_stop)]
mock_stream2 = MagicMock()
mock_stream2.__enter__ = MagicMock(return_value=iter(events_stop))
mock_stream2.__exit__ = MagicMock(return_value=False)
mock_anthropic.messages.stream.return_value = mock_stream2
result2 = []
async for sc in engine._stream_full_anthropic(
msgs,
model="claude-sonnet-4-20250514",
temperature=0.7,
max_tokens=100,
):
result2.append(sc)
assert result2[0].finish_reason == "stop"
# ---------------------------------------------------------------------------
# _prepare_anthropic_messages tests
# ---------------------------------------------------------------------------
def test_prepare_anthropic_messages_system():
"""System message extracted separately from chat messages."""
engine = _make_cloud_engine()
msgs = [
Message(role=Role.SYSTEM, content="You are helpful"),
Message(role=Role.USER, content="Hello"),
]
system_text, chat_msgs = engine._prepare_anthropic_messages(msgs)
assert system_text == "You are helpful"
assert len(chat_msgs) == 1
assert chat_msgs[0]["role"] == "user"
assert chat_msgs[0]["content"] == "Hello"
def test_prepare_anthropic_messages_tool_result():
"""Tool role converted to user + tool_result content block."""
engine = _make_cloud_engine()
msgs = [
Message(role=Role.USER, content="What's the weather?"),
Message(
role=Role.TOOL,
content='{"temp": 20}',
tool_call_id="call_abc",
),
]
system_text, chat_msgs = engine._prepare_anthropic_messages(msgs)
assert system_text == ""
assert len(chat_msgs) == 2
# Second message is the tool result wrapped as user
tool_msg = chat_msgs[1]
assert tool_msg["role"] == "user"
assert isinstance(tool_msg["content"], list)
assert tool_msg["content"][0]["type"] == "tool_result"
assert tool_msg["content"][0]["tool_use_id"] == "call_abc"
assert tool_msg["content"][0]["content"] == '{"temp": 20}'
def test_prepare_anthropic_messages_tool_calls():
"""Assistant with tool_calls converted to content blocks with tool_use."""
engine = _make_cloud_engine()
msgs = [
Message(
role=Role.ASSISTANT,
content="Let me check.",
tool_calls=[
ToolCall(
id="call_1", name="get_weather", arguments='{"city": "Berlin"}'
),
],
),
]
system_text, chat_msgs = engine._prepare_anthropic_messages(msgs)
assert len(chat_msgs) == 1
blocks = chat_msgs[0]["content"]
assert blocks[0]["type"] == "text"
assert blocks[0]["text"] == "Let me check."
assert blocks[1]["type"] == "tool_use"
assert blocks[1]["id"] == "call_1"
assert blocks[1]["name"] == "get_weather"
assert blocks[1]["input"] == {"city": "Berlin"}
# ---------------------------------------------------------------------------
# stream_full routing tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_full_routes_to_anthropic():
"""model='claude-xxx' routes to _stream_full_anthropic."""
msg_delta = MagicMock()
msg_delta.stop_reason = "end_turn"
events = [_anthropic_event("message_delta", delta=msg_delta)]
mock_stream = MagicMock()
mock_stream.__enter__ = MagicMock(return_value=iter(events))
mock_stream.__exit__ = MagicMock(return_value=False)
mock_anthropic = MagicMock()
mock_anthropic.messages.stream.return_value = mock_stream
engine = _make_cloud_engine(anthropic_client=mock_anthropic)
msgs = [Message(role=Role.USER, content="test")]
result = []
async for sc in engine.stream_full(msgs, model="claude-sonnet-4-20250514"):
result.append(sc)
# Verify Anthropic client was used
mock_anthropic.messages.stream.assert_called_once()
assert any(r.finish_reason is not None for r in result)
@pytest.mark.asyncio
async def test_stream_full_routes_to_openai():
"""model='gpt-xxx' routes to _stream_full_openai."""
mock_client = MagicMock()
chunks = [
_openai_chunk(content="hi"),
_openai_chunk(finish_reason="stop"),
]
mock_client.chat.completions.create.return_value = iter(chunks)
engine = _make_cloud_engine(openai_client=mock_client)
msgs = [Message(role=Role.USER, content="test")]
result = []
async for sc in engine.stream_full(msgs, model="gpt-4o"):
result.append(sc)
# Verify OpenAI client was used
mock_client.chat.completions.create.assert_called_once()
assert result[0].content == "hi"
assert result[1].finish_reason == "stop"
+142
View File
@@ -0,0 +1,142 @@
"""Tests for InstrumentedEngine, GuardrailsEngine, and MultiEngine stream_full
delegation."""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any, Dict, List
import pytest
from openjarvis.core.events import EventBus
from openjarvis.core.types import Message, Role
from openjarvis.engine._stubs import InferenceEngine, StreamChunk
from openjarvis.engine.multi import MultiEngine
from openjarvis.security.guardrails import GuardrailsEngine
from openjarvis.telemetry.instrumented_engine import InstrumentedEngine
# ---------------------------------------------------------------------------
# Fake engine that yields predetermined StreamChunks via stream_full
# ---------------------------------------------------------------------------
class _FakeStreamFullEngine(InferenceEngine):
engine_id = "fake-sf"
def __init__(self, chunks: list[StreamChunk]) -> None:
self._chunks = chunks
def generate(self, messages, *, model, **kwargs) -> Dict[str, Any]:
return {"content": "ok", "usage": {}}
async def stream(self, messages, *, model, **kwargs) -> AsyncIterator[str]:
yield "ok"
async def stream_full(
self, messages, *, model, **kwargs
) -> AsyncIterator[StreamChunk]:
for c in self._chunks:
yield c
def list_models(self) -> List[str]:
return ["fake-model"]
def health(self) -> bool:
return True
# ---------------------------------------------------------------------------
# InstrumentedEngine.stream_full delegation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_instrumented_delegates_stream_full():
"""InstrumentedEngine.stream_full delegates to inner engine."""
expected = [
StreamChunk(content="Hello"),
StreamChunk(content=" world"),
StreamChunk(finish_reason="stop"),
]
inner = _FakeStreamFullEngine(expected)
bus = EventBus(record_history=True)
engine = InstrumentedEngine(inner, bus)
result = []
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="test")],
model="fake-model",
):
result.append(chunk)
assert len(result) == 3
assert result[0].content == "Hello"
assert result[1].content == " world"
assert result[2].finish_reason == "stop"
# ---------------------------------------------------------------------------
# GuardrailsEngine.stream_full delegation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_guardrails_delegates_stream_full():
"""GuardrailsEngine.stream_full delegates to wrapped engine."""
expected = [
StreamChunk(content="safe output"),
StreamChunk(finish_reason="stop"),
]
inner = _FakeStreamFullEngine(expected)
engine = GuardrailsEngine(inner, scanners=[])
result = []
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="test")],
model="fake-model",
):
result.append(chunk)
assert len(result) == 2
assert result[0].content == "safe output"
assert result[1].finish_reason == "stop"
# ---------------------------------------------------------------------------
# MultiEngine.stream_full routing
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_multi_routes_stream_full_by_model():
"""MultiEngine routes stream_full to the correct engine by model name."""
chunks_a = [StreamChunk(content="from A"), StreamChunk(finish_reason="stop")]
chunks_b = [StreamChunk(content="from B"), StreamChunk(finish_reason="stop")]
engine_a = _FakeStreamFullEngine(chunks_a)
engine_a.list_models = lambda: ["model-a"]
engine_b = _FakeStreamFullEngine(chunks_b)
engine_b.list_models = lambda: ["model-b"]
multi = MultiEngine([("a", engine_a), ("b", engine_b)])
# Route to engine A
result_a = []
async for chunk in multi.stream_full(
[Message(role=Role.USER, content="test")],
model="model-a",
):
result_a.append(chunk)
assert result_a[0].content == "from A"
# Route to engine B
result_b = []
async for chunk in multi.stream_full(
[Message(role=Role.USER, content="test")],
model="model-b",
):
result_b.append(chunk)
assert result_b[0].content == "from B"
+250
View File
@@ -0,0 +1,250 @@
"""Tests for StreamChunk dataclass and stream_full() engine method."""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import Any, Dict, List
from unittest.mock import MagicMock
import pytest
from openjarvis.core.types import Message, Role
from openjarvis.engine._stubs import InferenceEngine, StreamChunk
# ---------------------------------------------------------------------------
# StreamChunk dataclass tests
# ---------------------------------------------------------------------------
class TestStreamChunk:
def test_defaults(self):
chunk = StreamChunk()
assert chunk.content is None
assert chunk.tool_calls is None
assert chunk.finish_reason is None
assert chunk.usage is None
def test_content_only(self):
chunk = StreamChunk(content="hello")
assert chunk.content == "hello"
assert chunk.finish_reason is None
def test_finish_reason(self):
chunk = StreamChunk(finish_reason="stop")
assert chunk.content is None
assert chunk.finish_reason == "stop"
def test_tool_calls(self):
tc = [{"index": 0, "function": {"name": "calc", "arguments": "{}"}}]
chunk = StreamChunk(tool_calls=tc)
assert chunk.tool_calls == tc
def test_usage(self):
usage = {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
chunk = StreamChunk(usage=usage)
assert chunk.usage == usage
def test_all_fields(self):
chunk = StreamChunk(
content="hi",
tool_calls=[{"index": 0}],
finish_reason="tool_calls",
usage={"total_tokens": 1},
)
assert chunk.content == "hi"
assert chunk.tool_calls is not None
assert chunk.finish_reason == "tool_calls"
assert chunk.usage is not None
# ---------------------------------------------------------------------------
# Concrete engine stub for testing default stream_full()
# ---------------------------------------------------------------------------
class _FakeEngine(InferenceEngine):
"""Minimal engine that yields predefined tokens via stream()."""
engine_id = "fake"
def __init__(self, tokens: list[str]) -> None:
self._tokens = tokens
def generate(self, messages, *, model, **kwargs) -> Dict[str, Any]:
return {"content": "".join(self._tokens), "usage": {}}
async def stream(self, messages, *, model, **kwargs) -> AsyncIterator[str]:
for t in self._tokens:
yield t
def list_models(self) -> List[str]:
return ["fake-model"]
def health(self) -> bool:
return True
class TestDefaultStreamFull:
"""Test the default stream_full() implementation that wraps stream()."""
@pytest.mark.asyncio
async def test_wraps_stream_tokens(self):
engine = _FakeEngine(["Hello", " world", "!"])
chunks = []
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="test")],
model="fake-model",
):
chunks.append(chunk)
# Should have 3 content chunks + 1 finish chunk
assert len(chunks) == 4
assert chunks[0].content == "Hello"
assert chunks[1].content == " world"
assert chunks[2].content == "!"
assert chunks[3].finish_reason == "stop"
assert chunks[3].content is None
@pytest.mark.asyncio
async def test_empty_stream(self):
engine = _FakeEngine([])
chunks = []
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="test")],
model="fake-model",
):
chunks.append(chunk)
# Should have just the finish chunk
assert len(chunks) == 1
assert chunks[0].finish_reason == "stop"
@pytest.mark.asyncio
async def test_kwargs_passed_through(self):
"""Verify that temperature/max_tokens reach stream()."""
engine = _FakeEngine(["ok"])
chunks = []
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="test")],
model="fake-model",
temperature=0.1,
max_tokens=50,
):
chunks.append(chunk)
assert len(chunks) == 2
assert chunks[0].content == "ok"
# ---------------------------------------------------------------------------
# OpenAI-compatible stream_full() with mock HTTP response
# ---------------------------------------------------------------------------
class TestOpenAICompatStreamFull:
"""Test _OpenAICompatibleEngine.stream_full() with mocked HTTP."""
@pytest.mark.asyncio
async def test_parses_sse_with_content_and_finish(self):
from openjarvis.engine._openai_compat import _OpenAICompatibleEngine
# Build mock SSE lines
sse_lines = []
for token in ["Hello", " world"]:
chunk = {
"choices": [{"delta": {"content": token}, "finish_reason": None}],
}
sse_lines.append(f"data: {json.dumps(chunk)}")
# Final chunk with finish_reason
final = {
"choices": [{"delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},
}
sse_lines.append(f"data: {json.dumps(final)}")
sse_lines.append("data: [DONE]")
# Mock the httpx client stream context manager
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.iter_lines.return_value = iter(sse_lines)
engine = _OpenAICompatibleEngine.__new__(_OpenAICompatibleEngine)
engine.engine_id = "test"
engine._host = "http://localhost:8000"
engine._api_prefix = "/v1"
mock_client = MagicMock()
mock_stream_ctx = MagicMock()
mock_stream_ctx.__enter__ = MagicMock(return_value=mock_resp)
mock_stream_ctx.__exit__ = MagicMock(return_value=False)
mock_client.stream.return_value = mock_stream_ctx
engine._client = mock_client
chunks = []
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="test")],
model="test-model",
):
chunks.append(chunk)
# Should have: "Hello", " world", finish+usage
assert len(chunks) == 3
assert chunks[0].content == "Hello"
assert chunks[1].content == " world"
assert chunks[2].finish_reason == "stop"
assert chunks[2].usage is not None
assert chunks[2].usage["total_tokens"] == 7
@pytest.mark.asyncio
async def test_parses_tool_call_fragments(self):
from openjarvis.engine._openai_compat import _OpenAICompatibleEngine
# Simulate streamed tool_call fragments
_tc1 = (
'{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_1",'
' "function": {"name": "calc", "arguments": ""}}]},'
' "finish_reason": null}]}'
)
_tc2 = (
'{"choices": [{"delta": {"tool_calls": [{"index": 0,'
' "function": {"name": "", "arguments": "{\\"x\\": 1}"}}]},'
' "finish_reason": null}]}'
)
sse_lines = [
f"data: {_tc1}",
f"data: {_tc2}",
'data: {"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}',
"data: [DONE]",
]
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.iter_lines.return_value = iter(sse_lines)
engine = _OpenAICompatibleEngine.__new__(_OpenAICompatibleEngine)
engine.engine_id = "test"
engine._host = "http://localhost:8000"
engine._api_prefix = "/v1"
mock_client = MagicMock()
mock_stream_ctx = MagicMock()
mock_stream_ctx.__enter__ = MagicMock(return_value=mock_resp)
mock_stream_ctx.__exit__ = MagicMock(return_value=False)
mock_client.stream.return_value = mock_stream_ctx
engine._client = mock_client
chunks = []
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="test")],
model="test-model",
):
chunks.append(chunk)
# First chunk has tool_calls with name
assert chunks[0].tool_calls is not None
assert chunks[0].tool_calls[0]["function"]["name"] == "calc"
# Second chunk has arguments fragment
assert chunks[1].tool_calls is not None
# Third chunk has finish_reason="tool_calls"
assert chunks[2].finish_reason == "tool_calls"
+1 -1
View File
@@ -313,7 +313,7 @@ class TestConfigBenchmarks:
def test_benchmarks_count(self) -> None:
from openjarvis.evals.core.config import KNOWN_BENCHMARKS
assert len(KNOWN_BENCHMARKS) == 25
assert len(KNOWN_BENCHMARKS) == 26
# ---------------------------------------------------------------------------
+2 -2
View File
@@ -74,7 +74,7 @@ def grade(transcript, workspace_path):
{
"type": "toolCall",
"name": "read_file",
"arguments": {"path": "a.txt"},
"params": {"path": "a.txt"},
}
],
},
@@ -124,7 +124,7 @@ class TestSummarizeTranscript:
{
"type": "toolCall",
"name": "read_file",
"arguments": {"path": "a.txt"},
"params": {"path": "a.txt"},
}
],
},
@@ -186,26 +186,26 @@ class TestCloudModelSpecs:
class TestQwen35ModelSpecs:
"""Verify Qwen3.5 MoE model entries."""
def test_qwen35_3b(self) -> None:
spec = _get_spec("qwen3.5:3b")
assert spec.parameter_count_b == 3.0
assert spec.active_parameter_count_b == 0.6
def test_qwen35_2b(self) -> None:
spec = _get_spec("qwen3.5:2b")
assert spec.parameter_count_b == 2.0
assert spec.active_parameter_count_b == 0.4
assert spec.context_length == 131072
assert spec.provider == "alibaba"
assert spec.metadata["architecture"] == "moe"
for e in ("ollama", "vllm", "llamacpp", "sglang"):
assert e in spec.supported_engines
def test_qwen35_8b(self) -> None:
spec = _get_spec("qwen3.5:8b")
assert spec.parameter_count_b == 8.0
assert spec.active_parameter_count_b == 1.0
def test_qwen35_9b(self) -> None:
spec = _get_spec("qwen3.5:9b")
assert spec.parameter_count_b == 9.0
assert spec.active_parameter_count_b == 1.5
assert spec.context_length == 131072
def test_qwen35_14b(self) -> None:
spec = _get_spec("qwen3.5:14b")
assert spec.parameter_count_b == 14.0
assert spec.active_parameter_count_b == 2.0
def test_qwen35_27b(self) -> None:
spec = _get_spec("qwen3.5:27b")
assert spec.parameter_count_b == 27.0
assert spec.active_parameter_count_b == 3.0
def test_qwen35_35b(self) -> None:
spec = _get_spec("qwen3.5:35b")
@@ -300,9 +300,9 @@ class TestModelDiscovery:
"gpt-oss:120b",
"glm-4.7-flash",
"trinity-mini",
"qwen3.5:3b",
"qwen3.5:8b",
"qwen3.5:14b",
"qwen3.5:2b",
"qwen3.5:9b",
"qwen3.5:27b",
"qwen3.5:35b",
"qwen3.5:122b",
"qwen3.5:397b",

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