mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904133cb25 | ||
|
|
299dee1f40 | ||
|
|
44ff286005 | ||
|
|
be51eb8684 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 137874,
|
||||
"last_updated": "2026-06-29T07:46:59Z",
|
||||
"last_updated": "2026-06-30T07:21:14Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
|
||||
@@ -19,6 +19,71 @@ Agents are the agentic logic layer of OpenJarvis. They determine how a query is
|
||||
|
||||
---
|
||||
|
||||
## Persistent Persona: SOUL.md, MEMORY.md, USER.md
|
||||
|
||||
Every agent's system prompt is assembled at conversation start by the `SystemPromptBuilder`, which injects up to three optional Markdown files -- the **persistent persona**. They are plain text you own and edit, loaded at the start of each conversation. There is no vector database or embedding cache behind them.
|
||||
|
||||
| File | What it holds | Example line |
|
||||
|------|---------------|--------------|
|
||||
| `SOUL.md` | How the agent should behave -- tone, length, what to push back on | `Be concise. Challenge weak assumptions.` |
|
||||
| `MEMORY.md` | Facts about you, your projects, your preferences | `I deploy to Postgres, never MySQL.` |
|
||||
| `USER.md` | Who you are -- role, team, context | `Backend engineer at Acme, on the payments team.` |
|
||||
|
||||
This persona is distinct from the retrieval [memory backend](memory.md): the persona is always-on Markdown context loaded into the prompt, while the memory backend is searchable long-term storage the agent queries on demand.
|
||||
|
||||
### Where they live
|
||||
|
||||
By default the files are read from the config directory:
|
||||
|
||||
```
|
||||
~/.openjarvis/SOUL.md
|
||||
~/.openjarvis/MEMORY.md
|
||||
~/.openjarvis/USER.md
|
||||
```
|
||||
|
||||
(The config directory honors `$OPENJARVIS_HOME` / `$XDG_DATA_HOME` when set.) The paths are configurable under `[memory_files]`:
|
||||
|
||||
```toml
|
||||
[memory_files]
|
||||
soul_path = "~/.openjarvis/SOUL.md"
|
||||
memory_path = "~/.openjarvis/MEMORY.md"
|
||||
user_path = "~/.openjarvis/USER.md"
|
||||
persona_name = "" # optional named persona -- see below
|
||||
```
|
||||
|
||||
### How they're loaded
|
||||
|
||||
At the start of each conversation, `SystemPromptBuilder` reads each file as UTF-8 and adds its contents as a section of the system prompt, after the agent template and before the skill catalog:
|
||||
|
||||
- **All three are optional.** A missing or empty file is skipped, so any subset works and an install with no persona files behaves exactly as before.
|
||||
- **Edits apply to the next conversation.** The files are read once when a conversation's prompt is built, so there is no restart or re-indexing -- edit or delete a line and it takes effect the next time you start a conversation.
|
||||
- **Each section is length-capped.** Files are truncated to a per-section character budget so a large `MEMORY.md` cannot crowd out the rest of the prompt.
|
||||
|
||||
### Named personas
|
||||
|
||||
A single install can answer as different personas without changing global config. A named persona lives in its own directory:
|
||||
|
||||
```
|
||||
~/.openjarvis/personas/<name>/SOUL.md
|
||||
~/.openjarvis/personas/<name>/MEMORY.md
|
||||
~/.openjarvis/personas/<name>/USER.md
|
||||
```
|
||||
|
||||
Select one per invocation, or opt out entirely:
|
||||
|
||||
```bash
|
||||
jarvis ask --persona work "summarize my open PRs"
|
||||
jarvis ask --persona none "what is 2 + 2?" # inject no persona
|
||||
```
|
||||
|
||||
Set `persona_name` under `[memory_files]` to make a named persona the default. `persona_name = "none"` (equivalently `--persona none`) disables persona injection for that run.
|
||||
|
||||
### Editing them
|
||||
|
||||
`SOUL.md`, `MEMORY.md`, and `USER.md` are plain Markdown -- open them in any editor. `MEMORY.md` and `USER.md` can also be updated by the agent itself through the `memory_manage` and `user_profile_manage` tools when those are enabled, so the agent can record a new fact mid-conversation. These tools always target the default `MEMORY.md` and `USER.md` (under `~/.openjarvis/`), never a named persona's copies -- edit those by hand.
|
||||
|
||||
---
|
||||
|
||||
## BaseAgent ABC
|
||||
|
||||
All agents extend the abstract `BaseAgent` class.
|
||||
|
||||
+198
-13
@@ -731,13 +731,20 @@ fn format_uv_sync_failure(
|
||||
let code = exit_code
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let tail = uv_sync_stderr_tail(stderr, 800);
|
||||
let rust_hint = if looks_like_rust_extension_build_error(stderr) {
|
||||
format!("\n\n{}", rust_toolchain_install_hint())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\
|
||||
Try opening a terminal in that directory and running \
|
||||
`uv sync --extra desktop` manually for the full output.",
|
||||
`uv sync --extra desktop` manually for the full output.{}",
|
||||
root.display(),
|
||||
code,
|
||||
uv_sync_stderr_tail(stderr, 800),
|
||||
tail,
|
||||
rust_hint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -786,6 +793,121 @@ fn format_uv_sync_spawn_error(root: &std::path::Path, uv_bin: &str, err: &str) -
|
||||
)
|
||||
}
|
||||
|
||||
fn rust_toolchain_install_hint() -> &'static str {
|
||||
"The desktop app needs the Rust toolchain to build `openjarvis_rust`. \
|
||||
Install Rust from https://rustup.rs. On Windows, also install Visual Studio \
|
||||
Build Tools with the C++ workload, then relaunch."
|
||||
}
|
||||
|
||||
fn looks_like_rust_extension_build_error(stderr: &str) -> bool {
|
||||
let lower = stderr.to_ascii_lowercase();
|
||||
[
|
||||
"openjarvis-rust",
|
||||
"openjarvis_rust",
|
||||
"maturin",
|
||||
"cargo",
|
||||
"rustc",
|
||||
"link.exe",
|
||||
"visual studio",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lower.contains(marker))
|
||||
}
|
||||
|
||||
fn format_missing_rust_toolchain() -> String {
|
||||
format!(
|
||||
"Could not find Rust's `cargo` command. {}\n\n\
|
||||
If Rust is already installed, close and relaunch the desktop app so \
|
||||
PATH includes `~/.cargo/bin`.",
|
||||
rust_toolchain_install_hint(),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_extension_import_failure(root: &std::path::Path, stderr: &str) -> String {
|
||||
let tail = uv_sync_stderr_tail(stderr, 4000);
|
||||
format!(
|
||||
"`openjarvis_rust` is still not importable after building. Last output:\n\n{}\n\n\
|
||||
Run these manually for the full build log:\n\n\
|
||||
cd {}\n\
|
||||
uv sync --extra desktop\n\
|
||||
uv run python -c \"import openjarvis_rust\"",
|
||||
if tail.is_empty() {
|
||||
"(no stderr output)"
|
||||
} else {
|
||||
&tail
|
||||
},
|
||||
root.display(),
|
||||
)
|
||||
}
|
||||
|
||||
fn add_cargo_bin_to_path(cmd: &mut tokio::process::Command) {
|
||||
let mut paths: Vec<std::path::PathBuf> = std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).collect())
|
||||
.unwrap_or_default();
|
||||
paths.insert(
|
||||
0,
|
||||
std::path::PathBuf::from(home_dir())
|
||||
.join(".cargo")
|
||||
.join("bin"),
|
||||
);
|
||||
if let Ok(joined) = std::env::join_paths(paths) {
|
||||
cmd.env("PATH", joined);
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_openjarvis_rust_extension(
|
||||
root: &std::path::Path,
|
||||
uv_bin: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut cmd = tokio::process::Command::new(uv_bin);
|
||||
cmd.args(["run", "python", "-c", "import openjarvis_rust"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.current_dir(root);
|
||||
prepare_subprocess_for_appimage(&mut cmd);
|
||||
add_cargo_bin_to_path(&mut cmd);
|
||||
|
||||
match cmd.output().await {
|
||||
Ok(out) if out.status.success() => Ok(()),
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
Err(format_extension_import_failure(root, &stderr))
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"Could not verify `openjarvis_rust`: {}. Verify uv is installed at `{}`.",
|
||||
e, uv_bin
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn port_owner_hint() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
format!("netstat -ano | findstr :{}", JARVIS_PORT)
|
||||
} else {
|
||||
format!("lsof -i :{}", JARVIS_PORT)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_port_unavailable(port: u16, reason: &str) -> String {
|
||||
format!(
|
||||
"Port {} is not available: {}. Stop the process using that port or \
|
||||
change the OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
|
||||
port,
|
||||
reason,
|
||||
port_owner_hint(),
|
||||
)
|
||||
}
|
||||
|
||||
fn check_jarvis_port_available() -> Result<(), String> {
|
||||
match std::net::TcpListener::bind(("127.0.0.1", JARVIS_PORT)) {
|
||||
Ok(listener) => {
|
||||
drop(listener);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(format_port_unavailable(JARVIS_PORT, &err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend boot sequence (runs in background after app launch)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1171,11 +1293,6 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
// Something else (a different web server, a stale process,
|
||||
// a 4xx-returning instance) is on our port. Don't kill it —
|
||||
// give the user actionable info instead.
|
||||
let lsof_hint = if cfg!(target_os = "windows") {
|
||||
format!("netstat -ano | findstr :{}", JARVIS_PORT)
|
||||
} else {
|
||||
format!("lsof -i :{}", JARVIS_PORT)
|
||||
};
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!(
|
||||
"Port {} is already in use by another service (it answered \
|
||||
@@ -1183,7 +1300,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
|
||||
JARVIS_PORT,
|
||||
resp.status(),
|
||||
lsof_hint,
|
||||
port_owner_hint(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
@@ -1193,8 +1310,21 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = check_jarvis_port_available() {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let root = project_root.as_ref().unwrap();
|
||||
|
||||
let cargo_bin = resolve_bin("cargo");
|
||||
if !std::path::Path::new(&cargo_bin).exists() && cargo_bin == "cargo" {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format_missing_rust_toolchain());
|
||||
return;
|
||||
}
|
||||
|
||||
// Install dependencies automatically (handles fresh clones).
|
||||
//
|
||||
// Previously we ran `uv sync` with both stdout AND stderr piped to
|
||||
@@ -1226,6 +1356,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
.current_dir(root);
|
||||
// Avoid LD_LIBRARY_PATH leak when running inside an AppImage (#455).
|
||||
prepare_subprocess_for_appimage(&mut sync_cmd);
|
||||
add_cargo_bin_to_path(&mut sync_cmd);
|
||||
let sync_output = sync_cmd.output().await;
|
||||
match sync_output {
|
||||
Ok(out) if !out.status.success() => {
|
||||
@@ -1242,6 +1373,16 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
Ok(_) => {} // success — fall through
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = "Verifying Rust extension (openjarvis_rust)...".into();
|
||||
}
|
||||
if let Err(err) = verify_openjarvis_rust_extension(root, &uv_bin).await {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(err);
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Starting API server from {}...", root.display());
|
||||
@@ -2720,11 +2861,12 @@ pub fn run() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
boot_plan, default_local_model, format_uv_sync_failure, format_uv_sync_spawn_error,
|
||||
matching_installed_model, model_names_match, normalize_host, parse_inference_config,
|
||||
parse_ollama_model_names, preferred_installed_model, should_persist_resolved_model,
|
||||
startup_installed_model,
|
||||
upsert_engine_host, uv_sync_stderr_tail, InferenceConfig, SourceKind,
|
||||
boot_plan, default_local_model, format_extension_import_failure,
|
||||
format_missing_rust_toolchain, format_port_unavailable, format_uv_sync_failure,
|
||||
format_uv_sync_spawn_error, matching_installed_model, model_names_match, normalize_host,
|
||||
parse_inference_config, parse_ollama_model_names, preferred_installed_model,
|
||||
should_persist_resolved_model, startup_installed_model, upsert_engine_host,
|
||||
uv_sync_stderr_tail, InferenceConfig, SourceKind,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -2791,6 +2933,49 @@ mod tests {
|
||||
assert!(msg.contains("No such file or directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_rust_toolchain_message_names_cargo_and_installer() {
|
||||
let msg = format_missing_rust_toolchain();
|
||||
assert!(msg.contains("cargo"));
|
||||
assert!(msg.contains("https://rustup.rs"));
|
||||
assert!(msg.contains("openjarvis_rust"));
|
||||
assert!(msg.contains("Visual Studio Build Tools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_sync_rust_failure_mentions_toolchain() {
|
||||
let msg = format_uv_sync_failure(
|
||||
Path::new("C:\\Users\\me\\OpenJarvis"),
|
||||
Some(1),
|
||||
"maturin failed: linker `link.exe` not found while building openjarvis-rust",
|
||||
);
|
||||
assert!(msg.contains("exit 1"));
|
||||
assert!(msg.contains("link.exe"));
|
||||
assert!(msg.contains("https://rustup.rs"));
|
||||
assert!(msg.contains("Visual Studio Build Tools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_import_failure_names_verification_command() {
|
||||
let msg = format_extension_import_failure(
|
||||
Path::new("C:\\Users\\me\\OpenJarvis"),
|
||||
"ModuleNotFoundError: No module named 'openjarvis_rust'",
|
||||
);
|
||||
assert!(msg.contains("openjarvis_rust"));
|
||||
assert!(msg.contains("uv sync --extra desktop"));
|
||||
assert!(msg.contains("uv run python -c \"import openjarvis_rust\""));
|
||||
assert!(msg.contains("ModuleNotFoundError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_unavailable_message_names_port_and_owner_hint() {
|
||||
let msg = format_port_unavailable(8000, "address already in use");
|
||||
assert!(msg.contains("Port 8000 is not available"));
|
||||
assert!(msg.contains("address already in use"));
|
||||
assert!(msg.contains("To identify it"));
|
||||
assert!(msg.contains("8000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_local_model_picks_second_largest_that_fits() {
|
||||
// QWEN35_MODELS min_ram ladder: 4,6,8,12,24,32,96 GB
|
||||
|
||||
@@ -243,7 +243,11 @@ export function InputArea() {
|
||||
|
||||
try {
|
||||
if (deepResearch) {
|
||||
for await (const ev of streamResearch(content, controller.signal)) {
|
||||
for await (const ev of streamResearch(
|
||||
content,
|
||||
selectedModel,
|
||||
controller.signal,
|
||||
)) {
|
||||
if (ev.type === 'search_call') {
|
||||
const trace: ResearchSearchTrace = {
|
||||
id: generateId(),
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function* streamChat(
|
||||
|
||||
export async function* streamResearch(
|
||||
query: string,
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<ResearchEvent> {
|
||||
// /api/research is mounted at the server root — strip any trailing /v1
|
||||
@@ -68,7 +69,7 @@ export async function* streamResearch(
|
||||
const response = await fetch(`${base}/api/research`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ query }),
|
||||
body: JSON.stringify({ query, ...(model ? { model } : {}) }),
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -106,4 +107,3 @@ export async function* streamResearch(
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ desktop = [
|
||||
"pydantic>=2.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"faster-whisper>=1.0",
|
||||
"openjarvis-rust",
|
||||
]
|
||||
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
|
||||
gpu-metrics = ["pynvml>=12.0"]
|
||||
@@ -186,6 +187,9 @@ git_describe_command = [
|
||||
# Such builds can inject the real version via SETUPTOOLS_SCM_PRETEND_VERSION.
|
||||
fallback_version = "0.0.0+unknown"
|
||||
|
||||
[tool.uv.sources]
|
||||
openjarvis-rust = { path = "rust/crates/openjarvis-python" }
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openjarvis"]
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
A small, self-contained planner-executor loop:
|
||||
|
||||
* the planner is a local Ollama chat model (default ``gemma4:31b``),
|
||||
* the planner is supplied by the caller (the web endpoint resolves it from
|
||||
config, falling back to ``gemma4:31b`` on Ollama for legacy installs),
|
||||
* the only tool it can call is :meth:`HybridSearch.search`,
|
||||
* it gets up to ``max_iterations`` tool calls,
|
||||
* tool results are trimmed before re-entering the context window, and
|
||||
|
||||
@@ -593,6 +593,14 @@ class IntelligenceConfig:
|
||||
stop_sequences: str = "" # Comma-separated stop strings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeepResearchConfig:
|
||||
"""Planner settings for the web Deep Research endpoint."""
|
||||
|
||||
engine: str = "" # Empty means use the active chat engine.
|
||||
model: str = "" # Empty means use the active chat model.
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RoutingLearningConfig:
|
||||
"""Routing sub-policy config within Learning."""
|
||||
@@ -1578,6 +1586,7 @@ class JarvisConfig:
|
||||
hardware: HardwareInfo = field(default_factory=HardwareInfo)
|
||||
engine: EngineConfig = field(default_factory=EngineConfig)
|
||||
intelligence: IntelligenceConfig = field(default_factory=IntelligenceConfig)
|
||||
deep_research: DeepResearchConfig = field(default_factory=DeepResearchConfig)
|
||||
learning: LearningConfig = field(default_factory=LearningConfig)
|
||||
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
||||
agent: AgentConfig = field(default_factory=AgentConfig)
|
||||
@@ -1839,6 +1848,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
|
||||
top_sections = (
|
||||
"engine",
|
||||
"intelligence",
|
||||
"deep_research",
|
||||
"learning",
|
||||
"agent",
|
||||
"server",
|
||||
@@ -2007,6 +2017,10 @@ max_tokens = 1024
|
||||
# repetition_penalty = 1.0
|
||||
# stop_sequences = ""
|
||||
|
||||
# [deep_research]
|
||||
# engine = "" # empty = use [engine].default
|
||||
# model = "" # empty = use [intelligence].default_model
|
||||
|
||||
[agent]
|
||||
default_agent = "simple"
|
||||
max_turns = 10
|
||||
@@ -2177,6 +2191,7 @@ __all__ = [
|
||||
"DEFAULT_CONFIG_DIR",
|
||||
"DEFAULT_CONFIG_PATH",
|
||||
"DiscordChannelConfig",
|
||||
"DeepResearchConfig",
|
||||
"get_cache_dir",
|
||||
"get_config_dir",
|
||||
"get_config_path",
|
||||
|
||||
@@ -27,7 +27,7 @@ import threading
|
||||
import time
|
||||
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -38,9 +38,10 @@ from openjarvis.agents.research_loop import (
|
||||
from openjarvis.connectors.embeddings import OllamaEmbedder
|
||||
from openjarvis.connectors.hybrid_search import HybridSearch
|
||||
from openjarvis.connectors.store import KnowledgeStore
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR, JarvisConfig, load_config
|
||||
from openjarvis.core.types import TelemetryRecord
|
||||
from openjarvis.engine.ollama import OllamaEngine
|
||||
from openjarvis.engine._base import InferenceEngine
|
||||
from openjarvis.engine._discovery import get_engine
|
||||
from openjarvis.telemetry.store import TelemetryStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,13 +49,99 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["research"])
|
||||
|
||||
_WEB_CLARIFY_RESPONSE = "no clarification available in web session"
|
||||
_LEGACY_PLANNER_ENGINE = "ollama"
|
||||
|
||||
# Sentinel placed on the queue when the agent thread terminates.
|
||||
_DONE = object()
|
||||
|
||||
|
||||
def _first_nonempty(*values: str) -> str:
|
||||
for value in values:
|
||||
stripped = value.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_planner_config(
|
||||
config: JarvisConfig,
|
||||
*,
|
||||
active_engine_key: str = "",
|
||||
active_model: str = "",
|
||||
request_model: str = "",
|
||||
) -> tuple[str, str]:
|
||||
"""Resolve the planner engine/model for web Deep Research.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. explicit ``[deep_research]`` overrides,
|
||||
2. the active chat engine/request model,
|
||||
3. server/config defaults,
|
||||
4. legacy Ollama/gemma4 fallback for unconfigured installs.
|
||||
"""
|
||||
engine_key = _first_nonempty(
|
||||
config.deep_research.engine,
|
||||
active_engine_key,
|
||||
config.engine.default,
|
||||
_LEGACY_PLANNER_ENGINE,
|
||||
)
|
||||
model = _first_nonempty(
|
||||
config.deep_research.model,
|
||||
request_model,
|
||||
active_model,
|
||||
config.server.model,
|
||||
config.intelligence.default_model,
|
||||
DEFAULT_PLANNER_MODEL,
|
||||
)
|
||||
return engine_key, model
|
||||
|
||||
|
||||
def _build_planner_engine(
|
||||
config: JarvisConfig,
|
||||
*,
|
||||
active_engine: InferenceEngine | None = None,
|
||||
active_engine_key: str = "",
|
||||
active_model: str = "",
|
||||
request_model: str = "",
|
||||
) -> tuple[str, InferenceEngine, str]:
|
||||
"""Instantiate the exact configured planner engine.
|
||||
|
||||
``get_engine`` intentionally falls back to any healthy engine for general
|
||||
chat routing. Deep Research must not do that here: if the configured chat
|
||||
engine is LM Studio but unavailable, silently falling back to Ollama would
|
||||
recreate the issue this endpoint is fixing.
|
||||
"""
|
||||
engine_key, model = _resolve_planner_config(
|
||||
config,
|
||||
active_engine_key=active_engine_key,
|
||||
active_model=active_model,
|
||||
request_model=request_model,
|
||||
)
|
||||
if active_engine is not None and not config.deep_research.engine.strip():
|
||||
if model and not active_engine.can_serve(model):
|
||||
raise RuntimeError(
|
||||
"Deep Research planner engine "
|
||||
f"{engine_key!r} cannot serve model {model!r}. "
|
||||
"Choose a compatible model or set [deep_research] engine/model "
|
||||
"in config.toml."
|
||||
)
|
||||
return engine_key, active_engine, model
|
||||
|
||||
resolved = get_engine(config, engine_key=engine_key, model=model)
|
||||
if resolved is None or resolved[0] != engine_key:
|
||||
raise RuntimeError(
|
||||
"Deep Research planner engine "
|
||||
f"{engine_key!r} is unavailable or cannot serve model {model!r}. "
|
||||
"Start the configured engine, load the configured model, or set "
|
||||
"[deep_research] engine/model in config.toml."
|
||||
)
|
||||
resolved_key, engine = resolved
|
||||
return resolved_key, engine, model
|
||||
|
||||
|
||||
def _record_research_telemetry(
|
||||
*,
|
||||
engine_key: str,
|
||||
model: str,
|
||||
usage: Dict[str, int],
|
||||
latency_seconds: float,
|
||||
@@ -86,7 +173,7 @@ def _record_research_telemetry(
|
||||
rec = TelemetryRecord(
|
||||
timestamp=time.time(),
|
||||
model_id=model,
|
||||
engine="ollama",
|
||||
engine=engine_key,
|
||||
agent="research",
|
||||
prompt_tokens=int(usage.get("prompt_tokens", 0)),
|
||||
prompt_tokens_evaluated=int(usage.get("prompt_tokens", 0)),
|
||||
@@ -244,12 +331,11 @@ class _LiveGPUSampler:
|
||||
|
||||
class ResearchRequest(BaseModel):
|
||||
query: str = Field(..., description="Natural-language question to research.")
|
||||
# Deep Research has its own model requirements (function-calling support,
|
||||
# sufficient reasoning capability) that the chat-model selector should not
|
||||
# override. We accept the field for forward-compat with older clients but
|
||||
# ignore it — the planner always runs on DEFAULT_PLANNER_MODEL.
|
||||
# Preferred planner model from the active chat selector. Server-side
|
||||
# [deep_research] config can still override it when a dedicated planner is
|
||||
# desired.
|
||||
model: Optional[str] = Field(
|
||||
default=None, description="Ignored; retained for client compatibility."
|
||||
default=None, description="Preferred planner model for this request."
|
||||
)
|
||||
|
||||
|
||||
@@ -290,7 +376,14 @@ def _chunk_synthesis(text: str, window_chars: int = 40) -> list[str]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
async def _stream_research(
|
||||
query: str,
|
||||
*,
|
||||
active_engine: InferenceEngine | None = None,
|
||||
active_engine_key: str = "",
|
||||
active_model: str = "",
|
||||
request_model: str = "",
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Drive ResearchAgent on a worker thread; yield SSE frames as they land.
|
||||
|
||||
Three error envelopes — setup, worker, consumer — all funnel into the
|
||||
@@ -298,7 +391,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
``{"type": "done", "usage": {...}}``. The client can rely on always
|
||||
seeing a ``done`` frame, even when the agent never started.
|
||||
"""
|
||||
# Phase 1: setup. Failures here (Ollama daemon down, DB locked, etc.)
|
||||
# Phase 1: setup. Failures here (planner engine down, DB locked, etc.)
|
||||
# yield error + done and return — nothing has been emitted yet so the
|
||||
# client gets a clean two-frame stream instead of a dangling connection.
|
||||
try:
|
||||
@@ -309,6 +402,15 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
# Called from the agent's worker thread; bounce onto the event loop.
|
||||
loop.call_soon_threadsafe(queue.put_nowait, event)
|
||||
|
||||
config = load_config()
|
||||
engine_key, engine, model = _build_planner_engine(
|
||||
config,
|
||||
active_engine=active_engine,
|
||||
active_engine_key=active_engine_key,
|
||||
active_model=active_model,
|
||||
request_model=request_model,
|
||||
)
|
||||
|
||||
# Each request gets its own thin set of connectors. Constructing them
|
||||
# is cheap (SQLite open + HTTP keepalive) and avoids state leaks
|
||||
# between concurrent requests.
|
||||
@@ -320,7 +422,6 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
)
|
||||
embedder = None
|
||||
|
||||
engine = OllamaEngine()
|
||||
agent = ResearchAgent(
|
||||
engine=engine,
|
||||
search=HybridSearch(store, embedder),
|
||||
@@ -367,6 +468,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
# rolls research into the same Power/Energy numbers as chat —
|
||||
# this is what the launch-video System panel reads.
|
||||
_record_research_telemetry(
|
||||
engine_key=engine_key,
|
||||
model=model,
|
||||
usage=usage_dict,
|
||||
latency_seconds=time.time() - t0,
|
||||
@@ -472,7 +574,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
|
||||
|
||||
@router.post("/research")
|
||||
async def research(req: ResearchRequest) -> StreamingResponse:
|
||||
async def research(req: ResearchRequest, request: Request) -> StreamingResponse:
|
||||
"""Run a research query and stream the agent's trace + synthesis via SSE.
|
||||
|
||||
Response is ``text/event-stream`` with one JSON event per frame. See the
|
||||
@@ -480,14 +582,19 @@ async def research(req: ResearchRequest) -> StreamingResponse:
|
||||
terminates the stream so clients can detect end-of-response without
|
||||
parsing the underlying ``[DONE]`` sentinel used by OpenAI-style routes.
|
||||
"""
|
||||
if req.model and req.model != DEFAULT_PLANNER_MODEL:
|
||||
logger.info(
|
||||
"research: ignoring client model=%r; using DEFAULT_PLANNER_MODEL=%r",
|
||||
req.model,
|
||||
DEFAULT_PLANNER_MODEL,
|
||||
)
|
||||
active_engine = getattr(request.app.state, "engine", None)
|
||||
active_model = str(getattr(request.app.state, "model", "") or "")
|
||||
active_engine_key = str(getattr(request.app.state, "engine_name", "") or "")
|
||||
if active_engine is not None and not active_engine_key:
|
||||
active_engine_key = str(getattr(active_engine, "engine_id", "") or "")
|
||||
return StreamingResponse(
|
||||
_stream_research(req.query, DEFAULT_PLANNER_MODEL),
|
||||
_stream_research(
|
||||
req.query,
|
||||
active_engine=active_engine,
|
||||
active_engine_key=active_engine_key,
|
||||
active_model=active_model,
|
||||
request_model=req.model or "",
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for Deep Research planner configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.config import (
|
||||
DeepResearchConfig,
|
||||
HardwareInfo,
|
||||
JarvisConfig,
|
||||
generate_default_toml,
|
||||
load_config,
|
||||
validate_config_key,
|
||||
)
|
||||
|
||||
|
||||
def test_deep_research_config_defaults_to_chat_selection() -> None:
|
||||
cfg = JarvisConfig()
|
||||
|
||||
assert isinstance(cfg.deep_research, DeepResearchConfig)
|
||||
assert cfg.deep_research.engine == ""
|
||||
assert cfg.deep_research.model == ""
|
||||
|
||||
|
||||
def test_loads_deep_research_overrides(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"[deep_research]",
|
||||
'engine = "lmstudio"',
|
||||
'model = "qwen/qwen3-14b"',
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
cfg = load_config(config_file)
|
||||
|
||||
assert cfg.deep_research.engine == "lmstudio"
|
||||
assert cfg.deep_research.model == "qwen/qwen3-14b"
|
||||
|
||||
|
||||
def test_deep_research_keys_are_settable() -> None:
|
||||
assert validate_config_key("deep_research.engine") is str
|
||||
assert validate_config_key("deep_research.model") is str
|
||||
|
||||
|
||||
def test_default_toml_documents_deep_research_override() -> None:
|
||||
toml = generate_default_toml(HardwareInfo())
|
||||
|
||||
assert "# [deep_research]" in toml
|
||||
assert '# engine = ""' in toml
|
||||
assert '# model = ""' in toml
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Tests for web Deep Research planner engine selection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.agents.research_loop import DEFAULT_PLANNER_MODEL
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.server import research_router
|
||||
|
||||
|
||||
class _DummyEngine:
|
||||
def __init__(self, servable: bool = True) -> None:
|
||||
self.servable = servable
|
||||
|
||||
def can_serve(self, model: str) -> bool:
|
||||
return self.servable
|
||||
|
||||
|
||||
def test_resolve_planner_config_uses_chat_defaults() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"lmstudio",
|
||||
"local-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_prefers_active_chat_runtime() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "ollama"
|
||||
cfg.intelligence.default_model = ""
|
||||
|
||||
assert research_router._resolve_planner_config(
|
||||
cfg,
|
||||
active_engine_key="lmstudio",
|
||||
active_model="server-model",
|
||||
request_model="selected-model",
|
||||
) == (
|
||||
"lmstudio",
|
||||
"selected-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_uses_server_model_before_legacy_default() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "ollama"
|
||||
cfg.intelligence.default_model = ""
|
||||
cfg.server.model = "serve-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"ollama",
|
||||
"serve-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_allows_deep_research_override() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "chat-model"
|
||||
cfg.deep_research.engine = "vllm"
|
||||
cfg.deep_research.model = "planner-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"vllm",
|
||||
"planner-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_allows_partial_model_override() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "chat-model"
|
||||
cfg.deep_research.model = "planner-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"lmstudio",
|
||||
"planner-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_allows_partial_engine_override() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "chat-model"
|
||||
cfg.deep_research.engine = "vllm"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"vllm",
|
||||
"chat-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_keeps_legacy_fallback_when_unconfigured() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = ""
|
||||
cfg.intelligence.default_model = ""
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"ollama",
|
||||
DEFAULT_PLANNER_MODEL,
|
||||
)
|
||||
|
||||
|
||||
def test_build_planner_engine_uses_configured_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
engine = _DummyEngine()
|
||||
calls: list[tuple[str | None, str | None]] = []
|
||||
|
||||
def fake_get_engine(
|
||||
config: JarvisConfig,
|
||||
engine_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, _DummyEngine]:
|
||||
calls.append((engine_key, model))
|
||||
return "lmstudio", engine
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
|
||||
|
||||
engine_key, resolved_engine, model = research_router._build_planner_engine(cfg)
|
||||
|
||||
assert calls == [("lmstudio", "local-model")]
|
||||
assert engine_key == "lmstudio"
|
||||
assert resolved_engine is engine
|
||||
assert model == "local-model"
|
||||
|
||||
|
||||
def test_build_planner_engine_uses_active_engine_without_config_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "ollama"
|
||||
cfg.intelligence.default_model = ""
|
||||
active_engine = _DummyEngine()
|
||||
|
||||
def fail_get_engine(*args: object, **kwargs: object) -> None:
|
||||
raise AssertionError("should use the live app engine")
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fail_get_engine)
|
||||
|
||||
engine_key, resolved_engine, model = research_router._build_planner_engine(
|
||||
cfg,
|
||||
active_engine=active_engine,
|
||||
active_engine_key="lmstudio",
|
||||
active_model="server-model",
|
||||
request_model="selected-model",
|
||||
)
|
||||
|
||||
assert engine_key == "lmstudio"
|
||||
assert resolved_engine is active_engine
|
||||
assert model == "selected-model"
|
||||
|
||||
|
||||
def test_build_planner_engine_rejects_active_engine_that_cannot_serve_model() -> None:
|
||||
cfg = JarvisConfig()
|
||||
|
||||
with pytest.raises(RuntimeError, match="selected-model"):
|
||||
research_router._build_planner_engine(
|
||||
cfg,
|
||||
active_engine=_DummyEngine(servable=False),
|
||||
active_engine_key="cloud",
|
||||
request_model="selected-model",
|
||||
)
|
||||
|
||||
|
||||
def test_build_planner_engine_honors_explicit_deep_research_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.deep_research.engine = "vllm"
|
||||
cfg.deep_research.model = "planner-model"
|
||||
active_engine = _DummyEngine()
|
||||
planner_engine = _DummyEngine()
|
||||
|
||||
def fake_get_engine(
|
||||
config: JarvisConfig,
|
||||
engine_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, _DummyEngine]:
|
||||
assert engine_key == "vllm"
|
||||
assert model == "planner-model"
|
||||
return "vllm", planner_engine
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
|
||||
|
||||
engine_key, resolved_engine, model = research_router._build_planner_engine(
|
||||
cfg,
|
||||
active_engine=active_engine,
|
||||
active_engine_key="lmstudio",
|
||||
active_model="chat-model",
|
||||
request_model="selected-model",
|
||||
)
|
||||
|
||||
assert engine_key == "vllm"
|
||||
assert resolved_engine is planner_engine
|
||||
assert model == "planner-model"
|
||||
|
||||
|
||||
def test_research_route_passes_live_engine_and_selected_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
active_engine = _DummyEngine()
|
||||
|
||||
def fake_stream(query: str, **kwargs: object):
|
||||
captured["query"] = query
|
||||
captured.update(kwargs)
|
||||
|
||||
async def gen():
|
||||
yield "data: {\"type\":\"done\",\"usage\":{}}\n\n"
|
||||
|
||||
return gen()
|
||||
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
engine=active_engine,
|
||||
engine_name="lmstudio",
|
||||
model="server-model",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(research_router, "_stream_research", fake_stream)
|
||||
|
||||
response = asyncio.run(
|
||||
research_router.research(
|
||||
research_router.ResearchRequest(
|
||||
query="find notes",
|
||||
model="selected-model",
|
||||
),
|
||||
request, # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
|
||||
assert response.media_type == "text/event-stream"
|
||||
assert captured == {
|
||||
"query": "find notes",
|
||||
"active_engine": active_engine,
|
||||
"active_engine_key": "lmstudio",
|
||||
"active_model": "server-model",
|
||||
"request_model": "selected-model",
|
||||
}
|
||||
|
||||
|
||||
def test_build_planner_engine_rejects_fallback_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
|
||||
def fake_get_engine(
|
||||
config: JarvisConfig,
|
||||
engine_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, _DummyEngine]:
|
||||
return "ollama", _DummyEngine()
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
|
||||
|
||||
with pytest.raises(RuntimeError, match="lmstudio"):
|
||||
research_router._build_planner_engine(cfg)
|
||||
|
||||
|
||||
def test_build_planner_engine_rejects_unavailable_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", lambda *args, **kwargs: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="local-model"):
|
||||
research_router._build_planner_engine(cfg)
|
||||
@@ -4206,6 +4206,7 @@ dashboard = [
|
||||
desktop = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "faster-whisper" },
|
||||
{ name = "openjarvis-rust" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn" },
|
||||
@@ -4390,6 +4391,7 @@ requires-dist = [
|
||||
{ name = "openai", marker = "extra == 'inference-cloud'", specifier = ">=1.30" },
|
||||
{ name = "openai", marker = "extra == 'media'", specifier = ">=1.30" },
|
||||
{ name = "openhands-sdk", marker = "python_full_version >= '3.12' and extra == 'openhands'", specifier = ">=1.0" },
|
||||
{ name = "openjarvis-rust", marker = "extra == 'desktop'", directory = "rust/crates/openjarvis-python" },
|
||||
{ name = "pdfplumber", marker = "extra == 'memory-pdf'", specifier = ">=0.10" },
|
||||
{ name = "pdfplumber", marker = "extra == 'pdf'", specifier = ">=0.10" },
|
||||
{ name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" },
|
||||
@@ -4445,6 +4447,11 @@ provides-extras = ["browser", "channel-discord", "channel-gmail", "channel-line"
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "maturin", specifier = ">=1.12.6" }]
|
||||
|
||||
[[package]]
|
||||
name = "openjarvis-rust"
|
||||
version = "0.1.0"
|
||||
source = { directory = "rust/crates/openjarvis-python" }
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.39.1"
|
||||
|
||||
Reference in New Issue
Block a user